feat(training): complete training knowledge v1
This commit is contained in:
parent
1aad7b48b3
commit
fd955315cc
56 changed files with 13745 additions and 28 deletions
44
.agents/skills/trainlog-anatomy/SKILL.md
Normal file
44
.agents/skills/trainlog-anatomy/SKILL.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
name: trainlog-anatomy
|
||||
description: Use Trainlog's cited anatomy and biomechanics knowledge for exercise targeting, BODY ZONES, equipment interpretation, substitution, MAX context, and future workout or program design.
|
||||
---
|
||||
|
||||
# Trainlog anatomy
|
||||
|
||||
Resolve domain decisions from scientific evidence before implementation.
|
||||
Start at `docs/domain/` from the repository root and consult the relevant
|
||||
canonical assets in `catalog/`:
|
||||
|
||||
- `science-references-v1.json`: evidence, bibliographic metadata and limitations;
|
||||
- `muscles-v1.json` and `joint-actions-v1.json`: functional anatomy;
|
||||
- `movement-patterns-v1.json`: explicitly defined programming abstractions;
|
||||
- `exercise-knowledge-v1.json`: variant-specific roles and BODY ZONE projection;
|
||||
- `equipment-knowledge-v1.json`: physical equipment and compatible exercises.
|
||||
|
||||
Follow `source_refs` to the cited references. Check that evidence supports the
|
||||
actual variant and claim; structural validation is not scientific validation.
|
||||
Use `anatomie` for substantive new research and `anatomie-xhigh` only when one
|
||||
substantive investigation leaves material scientific ambiguity unresolved.
|
||||
Scientific conclusions must be settled before implementation agents encode them.
|
||||
|
||||
Preserve these distinctions:
|
||||
|
||||
- Physical machine != exercise != movement pattern != joint action != muscle.
|
||||
- BODY ZONE is a business/UX projection, not a complete anatomical taxonomy.
|
||||
- Higher EMG amplitude does not establish better hypertrophy, strength outcomes,
|
||||
or universal primary-muscle status.
|
||||
- Manufacturer documentation identifies mechanics; it does not establish
|
||||
anatomical outcomes. Do not invent a manufacturer, model or trajectory.
|
||||
|
||||
Separate established anatomy, biomechanical interpretation, EMG, intervention
|
||||
evidence, manufacturer statements and practical inference. Retain `high`,
|
||||
`moderate` or `uncertain` confidence; downgrade unsupported claims. Unknown
|
||||
custom exercises stay unclassified until their actual execution is established.
|
||||
|
||||
Use existing exercise/equipment identities. Never infer runtime knowledge from
|
||||
names, merge multifunction-machine exercises, overwrite persisted BODY ZONES,
|
||||
or copy user history into scientific catalogs. MAX belongs to an exercise and
|
||||
its equipment/resistance/execution context; assistance is not external load.
|
||||
|
||||
TRAINING KNOWLEDGE V1 provides read-only knowledge and history composition.
|
||||
Workout/program prescription and clinical rehabilitation are outside its scope.
|
||||
10
AGENTS.md
10
AGENTS.md
|
|
@ -324,3 +324,13 @@ A task is complete only when:
|
|||
- synchronization remains idempotent where applicable;
|
||||
- documentation describes the resulting state;
|
||||
- no known regression is intentionally left behind.
|
||||
|
||||
## 14. Training knowledge
|
||||
|
||||
Before domain decisions involving anatomy, biomechanics, exercise targeting,
|
||||
BODY ZONES, machine/exercise interpretation, substitution, MAX interpretation,
|
||||
workout generation or program generation, consult the project
|
||||
[`trainlog-anatomy` skill](.agents/skills/trainlog-anatomy/SKILL.md),
|
||||
`docs/domain/`, the scientific catalogs in `catalog/`, and their cited references.
|
||||
Scientific knowledge is separate from runtime user data; preserve explicit
|
||||
uncertainty and settle scientific conclusions before encoding behavior.
|
||||
|
|
|
|||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -9,6 +9,19 @@ Detailed implementation chronology remains available in Git history and
|
|||
|
||||
### Added
|
||||
|
||||
- `TRAINING_KNOWLEDGE_V1` read-only scientific knowledge infrastructure:
|
||||
six authored, versioned JSON catalogs with cited references; deterministic C
|
||||
generation; Android immutable asset loading; stable-ID catalog queries; and
|
||||
desktop/Android composition of persisted exercise zones, compatible
|
||||
equipment, explicit MAX and bounded occurrence/set history. The feature
|
||||
introduces no schema migration, database seeding, synchronization artifact,
|
||||
runtime prescription or generated UUID association. Scientific review,
|
||||
independent temporal review, final engineering review, repair verification,
|
||||
and final executable validation passed. The initial audit's four findings
|
||||
were closed by one bounded repair chain. See
|
||||
`docs/reviews/training_knowledge_v1_temporal_contract.md` and
|
||||
`docs/domain/knowledge_system.md`.
|
||||
|
||||
- `BODY_ZONES_V1`: the canonical `catalog/body-zones-v1.json` taxonomy with
|
||||
stable IDs, French display metadata, hierarchy, deterministic sort order and
|
||||
exact stable-exercise-ID migration evidence;
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -42,10 +42,25 @@ BODY_ZONE_SYNC_V1=PASS
|
|||
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
|
||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
||||
|
||||
DESKTOP_TESTS=39/39 PASS
|
||||
DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint)
|
||||
ANDROID_BUILD=PASS
|
||||
|
||||
TRAINING_KNOWLEDGE_V1=PASS
|
||||
```
|
||||
|
||||
`TRAINING_KNOWLEDGE_V1` has passed its bounded scientific review, independent
|
||||
temporal delta review, final engineering audit, repair verification, and final
|
||||
executable validation. Its C and
|
||||
Android occurrence pages and latest-MAX context share the settled exact-instant
|
||||
ordering and source-text cursor contract. The independent temporal review
|
||||
returned PASS with no findings. The initial full-tranche audit's four findings
|
||||
were resolved by one bounded repair chain and independently verified. Fresh
|
||||
validation passed: strict build, 42 Meson tests, Python knowledge/temporal
|
||||
tests, validators, headers, sanitizers, 12-form temporal probes, Android 56
|
||||
tests with one known fixture skip, and Java 17 debug assembly.
|
||||
The [temporal correction record](docs/reviews/training_knowledge_v1_temporal_contract.md)
|
||||
defines the accepted grammar, regression evidence and remaining review boundary.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
|
|
@ -255,6 +270,8 @@ Exercise-zone metadata travels separately in the sole bidirectional
|
|||
- `docs/exchange_format.md`: frozen Trainlog JSON v1 contract;
|
||||
- `docs/tests.md`: validation strategy;
|
||||
- `docs/roadmap.md`: completed gates and future cursor;
|
||||
- `docs/domain/knowledge_system.md`: read-only scientific knowledge system,
|
||||
runtime-context boundary and documented future planning pipeline;
|
||||
- `AGENTS.md`: development contract.
|
||||
|
||||
## Development principles
|
||||
|
|
|
|||
|
|
@ -0,0 +1,383 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
enum class KnowledgeConfidence { HIGH, MODERATE, UNCERTAIN }
|
||||
enum class MuscleEntityType { MUSCLE, MUSCLE_REGION, MUSCLE_GROUP }
|
||||
enum class ExerciseKnowledgeStatus { RESOLVED_FAMILY_VARIANT_LIMITED, CONDITIONAL, UNRESOLVED }
|
||||
enum class BodyZoneAuditStatus { CONFIRMED, QUESTIONABLE, UNRESOLVED }
|
||||
enum class BodyZoneAuditSeverity { NONE, INFORMATION }
|
||||
enum class EquipmentScienceStatus {
|
||||
SCIENTIFICALLY_DOCUMENTED,
|
||||
MECHANICALLY_IDENTIFIED_ANATOMY_INCOMPLETE,
|
||||
EQUIPMENT_IDENTITY_UNCERTAIN,
|
||||
}
|
||||
enum class MuscleRole { PRIMARY, SECONDARY, STABILIZERS }
|
||||
|
||||
data class ScienceReference(
|
||||
val refId: String, val title: String, val authorsOrOrganization: String, val year: Int?,
|
||||
val type: String, val url: String, val topics: List<String>, val notes: String,
|
||||
val limitations: String, val doi: String?, val pmid: String?, val accessedOn: String,
|
||||
val publicationNote: String?,
|
||||
)
|
||||
|
||||
data class MuscleKnowledge(
|
||||
val muscleId: String, val displayName: String, val displayNameFr: String,
|
||||
val entityType: MuscleEntityType, val anatomicalGroup: String, val aggregateGroupId: String?,
|
||||
val bodyZoneId: String, val bodyZoneIds: List<String>, val jointActionIds: List<String>,
|
||||
val primaryActions: List<String>, val primaryActionsSemantics: String,
|
||||
val overlapWarning: String, val functionalNotes: String, val confidence: KnowledgeConfidence,
|
||||
val evidenceType: String, val sourceRefs: List<String>, val memberMuscleIds: List<String>,
|
||||
)
|
||||
|
||||
data class JointActionKnowledge(
|
||||
val actionId: String, val displayNameFr: String, val definition: String,
|
||||
val anatomicalRegion: String, val jointComplex: String, val jointOrComplex: String,
|
||||
val principalPlane: String, val planeNotes: String, val contributingMuscleIds: List<String>,
|
||||
val contributorSemantics: String, val evidenceType: String, val confidence: KnowledgeConfidence,
|
||||
val sourceRefs: List<String>, val notes: String,
|
||||
)
|
||||
|
||||
data class MovementPatternKnowledge(
|
||||
val patternId: String, val displayNameFr: String, val definition: String,
|
||||
val typicalActionIds: List<String>, val typicalBodyZoneIds: List<String>,
|
||||
val evidenceType: String, val confidence: KnowledgeConfidence, val sourceRefs: List<String>,
|
||||
val notes: String, val bodyZoneSemantics: String?, val parentPatternId: String?,
|
||||
)
|
||||
|
||||
data class ExerciseInterpretation(
|
||||
val familyDescription: String, val actionIds: List<String>, val patternIds: List<String>,
|
||||
val primaryMuscleIds: List<String>, val secondaryMuscleIds: List<String>,
|
||||
val stabilizerMuscleIds: List<String>, val primaryZoneId: String,
|
||||
val secondaryZoneIds: List<String>, val confidence: KnowledgeConfidence,
|
||||
val evidenceType: String, val sourceRefs: List<String>, val variantNotes: String,
|
||||
val roleNotes: String, val requiredConfirmation: String? = null,
|
||||
) {
|
||||
fun muscles(role: MuscleRole): List<String> = when (role) {
|
||||
MuscleRole.PRIMARY -> primaryMuscleIds
|
||||
MuscleRole.SECONDARY -> secondaryMuscleIds
|
||||
MuscleRole.STABILIZERS -> stabilizerMuscleIds
|
||||
}
|
||||
}
|
||||
|
||||
data class ScientificBodyZoneMapping(val primaryZoneId: String, val secondaryZoneIds: List<String>)
|
||||
data class ExistingBodyZoneMapping(val primaryZoneId: String?, val secondaryZoneIds: List<String>)
|
||||
|
||||
data class BodyZoneAudit(
|
||||
val status: BodyZoneAuditStatus, val severity: BodyZoneAuditSeverity,
|
||||
val confidence: KnowledgeConfidence, val rationale: String,
|
||||
val existingPrimaryZoneId: String?, val existingSecondaryZoneIds: List<String>,
|
||||
val proposedMutation: String?, val sourceRefs: List<String>,
|
||||
)
|
||||
|
||||
data class ExerciseKnowledge(
|
||||
val exerciseId: String, val exerciseName: String, val equipmentIds: List<String>,
|
||||
val identityEvidence: String, val identityEvidenceType: String,
|
||||
val resolutionStatus: ExerciseKnowledgeStatus, val confidence: KnowledgeConfidence,
|
||||
val interpretation: ExerciseInterpretation?, val conditionalInterpretation: ExerciseInterpretation?,
|
||||
val existingBodyZones: ExistingBodyZoneMapping, val sourceRefs: List<String>,
|
||||
val limitations: List<String>, val equipmentLinkStatus: String?, val bodyZoneAudit: BodyZoneAudit,
|
||||
)
|
||||
|
||||
data class EquipmentCapabilityKnowledge(
|
||||
val displayName: String, val exerciseIds: List<String>, val linkStatus: String,
|
||||
val interpretation: ExerciseInterpretation?, val requirements: String,
|
||||
)
|
||||
|
||||
data class EquipmentKnowledge(
|
||||
val equipmentId: String, val manufacturer: String?, val model: String?,
|
||||
val identificationStatus: String, val scienceStatus: EquipmentScienceStatus,
|
||||
val catalogType: String, val catalogLoadSemantics: EquipmentLoadSemantics?,
|
||||
val mechanics: String, val evidenceType: String, val confidence: KnowledgeConfidence,
|
||||
val sourceRefs: List<String>, val capabilities: List<EquipmentCapabilityKnowledge>,
|
||||
val requiresActualExercise: Boolean, val limitations: List<String>, val auditNote: String?,
|
||||
val scientificStatusScope: String,
|
||||
)
|
||||
|
||||
data class KnowledgeExerciseFilters(
|
||||
val movementPatternId: String? = null,
|
||||
val muscleId: String? = null,
|
||||
val muscleRole: MuscleRole? = null,
|
||||
val scientificZoneId: String? = null,
|
||||
val includeZoneDescendants: Boolean = true,
|
||||
val equipmentId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Immutable TRAINING KNOWLEDGE V1 view over the six shared repository assets.
|
||||
* WHY: strict loading makes malformed scientific data a deployment failure and
|
||||
* prevents Android from quietly developing a second, hand-maintained taxonomy.
|
||||
*/
|
||||
class TrainingKnowledgeCatalog private constructor(
|
||||
val references: List<ScienceReference>, val muscles: List<MuscleKnowledge>,
|
||||
val jointActions: List<JointActionKnowledge>, val movementPatterns: List<MovementPatternKnowledge>,
|
||||
val exercises: List<ExerciseKnowledge>, val equipment: List<EquipmentKnowledge>,
|
||||
private val bodyZones: BodyZoneCatalog,
|
||||
) {
|
||||
private val referencesById = references.associateBy { it.refId }
|
||||
private val musclesById = muscles.associateBy { it.muscleId }
|
||||
private val actionsById = jointActions.associateBy { it.actionId }
|
||||
private val patternsById = movementPatterns.associateBy { it.patternId }
|
||||
private val exercisesById = exercises.associateBy { it.exerciseId }
|
||||
private val equipmentById = equipment.associateBy { it.equipmentId }
|
||||
|
||||
fun getReference(refId: String) = referencesById[refId]
|
||||
fun getMuscle(muscleId: String) = musclesById[muscleId]
|
||||
fun getJointAction(actionId: String) = actionsById[actionId]
|
||||
fun getMovementPattern(patternId: String) = patternsById[patternId]
|
||||
fun getExerciseKnowledge(exerciseId: String): ExerciseKnowledge? = exercisesById[exerciseId]
|
||||
fun getConditionalExerciseKnowledge(exerciseId: String): ExerciseKnowledge? =
|
||||
exercisesById[exerciseId]?.takeIf { it.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL }
|
||||
fun getEquipmentKnowledge(equipmentId: String): EquipmentKnowledge? = equipmentById[equipmentId]
|
||||
|
||||
fun getScientificBodyZoneMapping(exerciseId: String): ScientificBodyZoneMapping? =
|
||||
resolvedInterpretation(exerciseId)?.let { ScientificBodyZoneMapping(it.primaryZoneId, it.secondaryZoneIds) }
|
||||
|
||||
fun listExercisesByMovementPattern(patternId: String): List<ExerciseKnowledge> {
|
||||
require(patternsById.containsKey(patternId)) { "pattern_id inconnu: $patternId" }
|
||||
return queryExercises(KnowledgeExerciseFilters(movementPatternId = patternId))
|
||||
}
|
||||
|
||||
fun listExercisesByMuscle(muscleId: String, role: MuscleRole): List<ExerciseKnowledge> {
|
||||
require(musclesById.containsKey(muscleId)) { "muscle_id inconnu: $muscleId" }
|
||||
return queryExercises(KnowledgeExerciseFilters(muscleId = muscleId, muscleRole = role))
|
||||
}
|
||||
|
||||
fun listCompatibleExercises(equipmentId: String): List<ExerciseKnowledge> {
|
||||
require(equipmentById.containsKey(equipmentId)) { "equipment_id inconnu: $equipmentId" }
|
||||
return queryExercises(KnowledgeExerciseFilters(equipmentId = equipmentId))
|
||||
}
|
||||
|
||||
fun queryExercises(filters: KnowledgeExerciseFilters): List<ExerciseKnowledge> {
|
||||
filters.movementPatternId?.let { require(patternsById.containsKey(it)) { "pattern_id inconnu: $it" } }
|
||||
filters.muscleId?.let { require(musclesById.containsKey(it)) { "muscle_id inconnu: $it" } }
|
||||
require((filters.muscleId == null) == (filters.muscleRole == null)) {
|
||||
"muscleId et muscleRole doivent être fournis ensemble"
|
||||
}
|
||||
filters.equipmentId?.let { require(equipmentById.containsKey(it)) { "equipment_id inconnu: $it" } }
|
||||
val zones = filters.scientificZoneId?.let {
|
||||
require(bodyZones.lookup(it) != null) { "zone_id inconnu: $it" }
|
||||
if (filters.includeZoneDescendants) bodyZones.descendantsAndSelf(it) else setOf(it)
|
||||
}
|
||||
return exercises.filter { exercise ->
|
||||
val interpretation = resolvedInterpretation(exercise.exerciseId) ?: return@filter false
|
||||
(filters.movementPatternId == null || filters.movementPatternId in interpretation.patternIds) &&
|
||||
(filters.muscleId == null || filters.muscleId in interpretation.muscles(filters.muscleRole!!)) &&
|
||||
(filters.equipmentId == null || filters.equipmentId in exercise.equipmentIds) &&
|
||||
(zones == null || interpretation.primaryZoneId in zones ||
|
||||
interpretation.secondaryZoneIds.any { it in zones })
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvedInterpretation(exerciseId: String): ExerciseInterpretation? =
|
||||
exercisesById[exerciseId]?.takeIf {
|
||||
it.resolutionStatus == ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED
|
||||
}?.interpretation
|
||||
|
||||
companion object {
|
||||
private val confidenceValues = KnowledgeConfidence.entries.associateBy { it.name.lowercase(Locale.ROOT) }
|
||||
private val idPattern = Regex("[a-z][a-z0-9_]*")
|
||||
private val exerciseIdPattern = Regex("ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|
||||
private val equipmentIdPattern = Regex("(?:[a-z][a-z0-9_]*|eq_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})")
|
||||
private val scientificReferenceTypes = setOf("established_anatomy", "emg_evidence", "intervention_evidence")
|
||||
|
||||
fun load(context: Context): TrainingKnowledgeCatalog = load(
|
||||
context = context,
|
||||
bodyZones = BodyZoneCatalog.load(context),
|
||||
)
|
||||
|
||||
internal fun load(context: Context, bodyZones: BodyZoneCatalog): TrainingKnowledgeCatalog {
|
||||
return load({ name -> context.assets.open(name).bufferedReader().use { it.readText() } }, bodyZones)
|
||||
}
|
||||
|
||||
internal fun load(assetText: (String) -> String, bodyZones: BodyZoneCatalog): TrainingKnowledgeCatalog {
|
||||
fun root(name: String, format: String, collection: String): JSONArray {
|
||||
val text = assetText(name)
|
||||
DuplicateJsonKeyValidator.validate(text)
|
||||
val value = JSONObject(text)
|
||||
requireKeys(value, setOf("format", "version", collection), collection)
|
||||
check(value.getString("format") == format) { "$collection: format non pris en charge" }
|
||||
check(value.rawInt("version") == 1) { "$collection: version non prise en charge" }
|
||||
return value.getJSONArray(collection)
|
||||
}
|
||||
val referenceJson = root("science-references-v1.json", "trainlog-science-references-v1", "references")
|
||||
val muscleJson = root("muscles-v1.json", "trainlog-muscles-v1", "muscles")
|
||||
val actionJson = root("joint-actions-v1.json", "trainlog-joint-actions-v1", "joint_actions")
|
||||
val patternJson = root("movement-patterns-v1.json", "trainlog-movement-patterns-v1", "movement_patterns")
|
||||
val exerciseJson = root("exercise-knowledge-v1.json", "trainlog-exercise-knowledge-v1", "exercises")
|
||||
val equipmentJson = root("equipment-knowledge-v1.json", "trainlog-equipment-knowledge-v1", "equipment")
|
||||
|
||||
val references = referenceJson.objects("ref_id") { item ->
|
||||
requireKeys(item, setOf("ref_id", "title", "authors_or_organization", "year", "type", "url", "topics", "notes", "limitations", "doi", "pmid", "accessed_on"), "reference", setOf("publication_note"))
|
||||
ScienceReference(item.text("ref_id"), item.text("title"), item.text("authors_or_organization"), item.nullableInt("year"), item.text("type"), item.text("url"), item.strings("topics"), item.text("notes"), item.text("limitations"), item.nullableString("doi"), item.nullableString("pmid"), item.text("accessed_on"), item.optionalString("publication_note"))
|
||||
}
|
||||
val refIds = references.map { it.refId }.toSet()
|
||||
val referenceTypes = references.associate { it.refId to it.type }
|
||||
|
||||
data class RawMuscle(val item: JSONObject)
|
||||
val rawMuscles = muscleJson.objects("muscle_id") { RawMuscle(it) }
|
||||
val muscleIds = rawMuscles.map { it.item.text("muscle_id") }.toSet()
|
||||
val actionIds = actionJson.ids("action_id")
|
||||
val patternIds = patternJson.ids("pattern_id")
|
||||
val zoneIds = bodyZones.zones.map { it.zoneId }.toSet()
|
||||
val equipmentIds = equipmentJson.ids("equipment_id")
|
||||
val exerciseIds = exerciseJson.ids("exercise_id")
|
||||
// CONTRACT: V1 catalogs are additive; identity, ordering, and cross-reference
|
||||
// validation below define integrity without freezing today's collection sizes.
|
||||
|
||||
val muscles = rawMuscles.map { raw ->
|
||||
val item = raw.item
|
||||
val required = setOf("muscle_id", "display_name", "display_name_fr", "entity_type", "anatomical_group", "aggregate_group_id", "body_zone_id", "body_zone_ids", "joint_action_ids", "primary_actions", "primary_actions_semantics", "overlap_warning", "functional_notes", "confidence", "evidence_type", "source_refs")
|
||||
requireKeys(item, required, "muscle", setOf("member_muscle_ids"))
|
||||
val aggregate = item.nullableString("aggregate_group_id")
|
||||
check(aggregate == null || aggregate in muscleIds) { "aggregate_group_id pendant" }
|
||||
val members = item.optionalStrings("member_muscle_ids")
|
||||
check(members.all { it in muscleIds }) { "membre musculaire pendant" }
|
||||
val zones = item.strings("body_zone_ids"); val actions = item.strings("joint_action_ids"); val primaryActions = item.strings("primary_actions"); val refs = item.strings("source_refs", true)
|
||||
check(item.text("body_zone_id") in zoneIds && zones.all { it in zoneIds } && actions.all { it in actionIds } && primaryActions.all { it in actionIds } && refs.all { it in refIds }) { "référence croisée muscle invalide" }
|
||||
MuscleKnowledge(item.text("muscle_id"), item.text("display_name"), item.text("display_name_fr"), enumValue(item.text("entity_type"), MuscleEntityType.entries), item.text("anatomical_group"), aggregate, item.text("body_zone_id"), zones, actions, primaryActions, item.text("primary_actions_semantics"), item.text("overlap_warning"), item.string("functional_notes"), confidence(item), item.text("evidence_type"), refs, members)
|
||||
}
|
||||
|
||||
val actions = actionJson.objects("action_id") { item ->
|
||||
requireKeys(item, setOf("action_id", "display_name_fr", "definition", "anatomical_region", "joint_complex", "joint_or_complex", "principal_plane", "plane_notes", "contributing_muscle_ids", "contributor_semantics", "evidence_type", "confidence", "source_refs", "notes"), "joint_action")
|
||||
val contributors = item.strings("contributing_muscle_ids"); val refs = item.strings("source_refs", true)
|
||||
check(contributors.all { it in muscleIds } && refs.all { it in refIds }) { "référence croisée action invalide" }
|
||||
JointActionKnowledge(item.text("action_id"), item.text("display_name_fr"), item.text("definition"), item.text("anatomical_region"), item.text("joint_complex"), item.text("joint_or_complex"), item.text("principal_plane"), item.text("plane_notes"), contributors, item.text("contributor_semantics"), item.text("evidence_type"), confidence(item), refs, item.text("notes"))
|
||||
}
|
||||
val patterns = patternJson.objects("pattern_id") { item ->
|
||||
val required = setOf("pattern_id", "display_name_fr", "definition", "typical_action_ids", "typical_body_zone_ids", "evidence_type", "confidence", "source_refs", "notes")
|
||||
requireKeys(item, required, "movement_pattern", setOf("body_zone_semantics", "parent_pattern_id"))
|
||||
val typicalActions = item.strings("typical_action_ids"); val typicalZones = item.strings("typical_body_zone_ids"); val refs = item.strings("source_refs", true)
|
||||
check(typicalActions.all { it in actionIds } && typicalZones.all { it in zoneIds } && refs.all { it in refIds }) { "référence croisée pattern invalide" }
|
||||
val parent = item.optionalString("parent_pattern_id"); check(parent == null || parent in patternIds) { "parent_pattern_id pendant" }
|
||||
MovementPatternKnowledge(item.text("pattern_id"), item.text("display_name_fr"), item.text("definition"), typicalActions, typicalZones, item.text("evidence_type"), confidence(item), refs, item.text("notes"), item.optionalString("body_zone_semantics"), parent)
|
||||
}
|
||||
fun interpretation(item: JSONObject, conditional: Boolean): ExerciseInterpretation {
|
||||
val required = setOf("family_description", "action_ids", "pattern_ids", "primary_muscle_ids", "secondary_muscle_ids", "stabilizer_muscle_ids", "primary_zone_id", "secondary_zone_ids", "confidence", "evidence_type", "source_refs", "variant_notes", "role_notes")
|
||||
requireKeys(item, required, "interpretation", if (conditional) setOf("required_confirmation") else emptySet())
|
||||
val actionRefs = item.strings("action_ids"); val patternRefs = item.strings("pattern_ids")
|
||||
val primary = item.strings("primary_muscle_ids"); val secondary = item.strings("secondary_muscle_ids"); val stabilizers = item.strings("stabilizer_muscle_ids")
|
||||
check((primary + secondary + stabilizers).size == (primary + secondary + stabilizers).toSet().size) { "muscle présent dans plusieurs rôles" }
|
||||
val secondaryZones = item.strings("secondary_zone_ids"); val refs = item.strings("source_refs", true); val primaryZone = item.text("primary_zone_id")
|
||||
check(actionRefs.all { it in actionIds } && patternRefs.all { it in patternIds } && (primary + secondary + stabilizers).all { it in muscleIds } && primaryZone in zoneIds && secondaryZones.all { it in zoneIds } && primaryZone !in secondaryZones && refs.all { it in refIds }) { "référence croisée interprétation invalide" }
|
||||
return ExerciseInterpretation(item.text("family_description"), actionRefs, patternRefs, primary, secondary, stabilizers, primaryZone, secondaryZones, confidence(item), item.text("evidence_type"), refs, item.text("variant_notes"), item.text("role_notes"), item.optionalString("required_confirmation"))
|
||||
}
|
||||
val exercises = exerciseJson.objects("exercise_id") { item ->
|
||||
val required = setOf("exercise_id", "exercise_name", "equipment_ids", "identity_evidence", "identity_evidence_type", "resolution_status", "confidence", "interpretation", "conditional_interpretation", "existing_body_zones", "source_refs", "limitations", "body_zone_audit")
|
||||
requireKeys(item, required, "exercise", setOf("equipment_link_status"))
|
||||
val status = enumValue(item.text("resolution_status"), ExerciseKnowledgeStatus.entries)
|
||||
val regular = item.nullableObject("interpretation")?.let { interpretation(it, false) }
|
||||
val conditional = item.nullableObject("conditional_interpretation")?.let { interpretation(it, true) }
|
||||
check((status == ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED && regular != null && conditional == null) || (status == ExerciseKnowledgeStatus.CONDITIONAL && regular == null && conditional != null) || (status == ExerciseKnowledgeStatus.UNRESOLVED && regular == null && conditional == null)) { "interprétation incompatible avec resolution_status" }
|
||||
val equipmentRefs = item.strings("equipment_ids"); val refs = item.strings("source_refs")
|
||||
check(status == ExerciseKnowledgeStatus.UNRESOLVED || refs.isNotEmpty()) { "exercice résolu/conditionnel sans source" }
|
||||
check(equipmentRefs.all { it in equipmentIds } && refs.all { it in refIds }) { "référence croisée exercice invalide" }
|
||||
val existing = item.getJSONObject("existing_body_zones"); requireKeys(existing, setOf("primary_zone_id", "secondary_zone_ids"), "existing_body_zones")
|
||||
val existingZones = ExistingBodyZoneMapping(existing.nullableString("primary_zone_id"), existing.strings("secondary_zone_ids")); check((existingZones.primaryZoneId == null || existingZones.primaryZoneId in zoneIds) && existingZones.secondaryZoneIds.all { it in zoneIds }) { "zone existante pendante" }
|
||||
val audit = item.getJSONObject("body_zone_audit")
|
||||
requireKeys(audit, setOf("status", "severity", "confidence", "rationale", "existing_primary_zone_id", "existing_secondary_zone_ids", "proposed_mutation", "source_refs"), "body_zone_audit")
|
||||
val auditPrimary = audit.nullableString("existing_primary_zone_id")
|
||||
val auditSecondary = audit.strings("existing_secondary_zone_ids")
|
||||
val auditRefs = audit.strings("source_refs")
|
||||
check((auditPrimary == null || auditPrimary in zoneIds) && auditSecondary.all { it in zoneIds }) { "zone d'audit pendante" }
|
||||
check(auditRefs.all { it in refIds }) { "référence d'audit pendante" }
|
||||
// INVARIANT: every non-unresolved BODY ZONE conclusion remains traceable
|
||||
// to evidence, matching the canonical cross-catalog validator.
|
||||
check(audit.text("status") == "unresolved" || auditRefs.isNotEmpty()) { "audit BODY ZONE résolu sans source" }
|
||||
val bodyZoneAudit = BodyZoneAudit(
|
||||
enumValue(audit.text("status"), BodyZoneAuditStatus.entries),
|
||||
enumValue(audit.text("severity"), BodyZoneAuditSeverity.entries),
|
||||
confidence(audit), audit.text("rationale"), auditPrimary, auditSecondary,
|
||||
audit.nullableString("proposed_mutation"), auditRefs,
|
||||
)
|
||||
ExerciseKnowledge(item.text("exercise_id"), item.text("exercise_name"), equipmentRefs, item.text("identity_evidence"), item.text("identity_evidence_type"), status, confidence(item), regular, conditional, existingZones, refs, item.strings("limitations"), item.optionalString("equipment_link_status"), bodyZoneAudit)
|
||||
}
|
||||
val linked = mutableSetOf<Pair<String, String>>()
|
||||
val equipment = equipmentJson.objects("equipment_id") { item ->
|
||||
val required = setOf("equipment_id", "manufacturer", "model", "identification_status", "scientific_status", "catalog_type", "catalog_load_semantics", "mechanics", "evidence_type", "confidence", "source_refs", "capabilities", "requires_actual_exercise", "limitations", "scientific_status_scope")
|
||||
requireKeys(item, required, "equipment", setOf("audit_note"))
|
||||
val refs = item.strings("source_refs", true); check(refs.all { it in refIds }) { "référence équipement pendante" }
|
||||
val conf = confidence(item); check(!(conf == KnowledgeConfidence.HIGH && item.text("evidence_type") == "manufacturer_statement")) { "cartographie anatomique élevée fondée seulement sur fabricant" }
|
||||
// CONTRACT: HIGH equipment mappings require at least one admissible
|
||||
// scientific source type; Android must accept exactly the validator policy.
|
||||
check(conf != KnowledgeConfidence.HIGH || refs.any { referenceTypes[it] in scientificReferenceTypes }) { "cartographie équipement élevée sans source scientifique" }
|
||||
val capabilities = item.getJSONArray("capabilities").objectsUnordered { capability ->
|
||||
requireKeys(capability, setOf("display_name", "exercise_ids", "link_status", "interpretation", "requirements"), "capability")
|
||||
val capabilityExercises = capability.strings("exercise_ids"); check(capabilityExercises.all { it in exerciseIds }) { "exercice fantôme dans capability" }
|
||||
check(capability.text("link_status") in setOf("linked_existing", "linked_identity_conditional_interpretation", "unlinked_capability", "catalog_compatible_not_observed_occurrence")) { "link_status invalide" }
|
||||
capabilityExercises.forEach { linked += it to item.text("equipment_id") }
|
||||
// WHY: a one-sided capability would make desktop and Android
|
||||
// compatibility queries disagree over the same immutable assets.
|
||||
capabilityExercises.forEach { exerciseId ->
|
||||
check(item.text("equipment_id") in exercises.single { it.exerciseId == exerciseId }.equipmentIds) { "compatibilité équipement/exercice asymétrique" }
|
||||
}
|
||||
EquipmentCapabilityKnowledge(capability.text("display_name"), capabilityExercises, capability.text("link_status"), capability.nullableObject("interpretation")?.let { interpretation(it, false) }, capability.text("requirements"))
|
||||
}
|
||||
EquipmentKnowledge(item.text("equipment_id"), item.nullableString("manufacturer"), item.nullableString("model"), item.text("identification_status"), enumValue(item.text("scientific_status"), EquipmentScienceStatus.entries), item.text("catalog_type"), item.nullableString("catalog_load_semantics")?.let { enumValue(it, EquipmentLoadSemantics.entries) }, item.text("mechanics"), item.text("evidence_type"), conf, refs, capabilities, item.rawBoolean("requires_actual_exercise"), item.strings("limitations"), item.optionalString("audit_note"), item.text("scientific_status_scope"))
|
||||
}
|
||||
exercises.forEach { exercise -> exercise.equipmentIds.forEach { check(exercise.exerciseId to it in linked) { "compatibilité exercice/équipement asymétrique" } } }
|
||||
return TrainingKnowledgeCatalog(references, muscles, actions, patterns, exercises, equipment, bodyZones)
|
||||
}
|
||||
|
||||
private fun confidence(item: JSONObject): KnowledgeConfidence = confidenceValues[item.text("confidence")] ?: error("confidence invalide")
|
||||
private fun <T : Enum<T>> enumValue(value: String, entries: List<T>): T = entries.firstOrNull { it.name.lowercase(Locale.ROOT) == value } ?: error("valeur enum invalide: $value")
|
||||
private fun requireKeys(value: JSONObject, required: Set<String>, where: String, optional: Set<String> = emptySet()) {
|
||||
val keys = value.keys().asSequence().toSet(); check(required.all { it in keys } && keys.all { it in required || it in optional }) { "$where: clés invalides" }
|
||||
}
|
||||
private fun JSONObject.text(key: String): String = get(key).let { check(it is String && it.isNotBlank()) { "$key: texte requis" }; it }
|
||||
private fun JSONObject.string(key: String): String = get(key).let { check(it is String) { "$key: chaîne attendue" }; it }
|
||||
private fun JSONObject.nullableString(key: String): String? = if (isNull(key)) null else get(key).let { check(it is String) { "$key: chaîne attendue" }; it }
|
||||
private fun JSONObject.optionalString(key: String): String? = if (!has(key) || isNull(key)) null else nullableString(key)
|
||||
private fun JSONObject.rawInt(key: String): Int = get(key).let { check(it is Int) { "$key: entier attendu" }; it }
|
||||
private fun JSONObject.nullableInt(key: String): Int? = if (isNull(key)) null else rawInt(key)
|
||||
private fun JSONObject.rawBoolean(key: String): Boolean = get(key).let { check(it is Boolean) { "$key: booléen attendu" }; it }
|
||||
private fun JSONObject.nullableObject(key: String): JSONObject? = if (isNull(key)) null else get(key).let { check(it is JSONObject) { "$key: objet attendu" }; it }
|
||||
private fun JSONObject.strings(key: String, nonempty: Boolean = false): List<String> = get(key).let { value ->
|
||||
check(value is JSONArray) { "$key: tableau attendu" }; List(value.length()) { index -> value.get(index).let { check(it is String && it.isNotBlank()) { "$key: chaîne vide/invalide" }; it } }.also {
|
||||
check(it.size == it.toSet().size && (!nonempty || it.isNotEmpty())) { "$key: doublon ou tableau vide" }
|
||||
if (key.endsWith("_ids") || key == "source_refs" || key == "joint_action_ids" || key == "primary_actions") {
|
||||
check(it == it.sorted()) { "$key: ordre stable requis" }
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun JSONObject.optionalStrings(key: String): List<String> = if (has(key)) strings(key) else emptyList()
|
||||
private fun JSONArray.ids(key: String): Set<String> = objects(key) { it.text(key) }.toSet()
|
||||
private fun <T> JSONArray.objects(key: String, transform: (JSONObject) -> T): List<T> {
|
||||
val previous = mutableSetOf<String>(); var last: String? = null
|
||||
return objectsUnordered { item ->
|
||||
val id = item.text(key)
|
||||
val validId = when (key) {
|
||||
"exercise_id" -> exerciseIdPattern.matches(id)
|
||||
"equipment_id" -> equipmentIdPattern.matches(id)
|
||||
else -> idPattern.matches(id)
|
||||
}
|
||||
check(validId) { "$key invalide" }
|
||||
check(previous.add(id) && (last == null || last!! < id)) { "$key dupliqué ou hors ordre" }
|
||||
last = id
|
||||
transform(item)
|
||||
}
|
||||
}
|
||||
private fun <T> JSONArray.objectsUnordered(transform: (JSONObject) -> T): List<T> = List(length()) { index -> get(index).let { check(it is JSONObject) { "objet attendu" }; transform(it) } }
|
||||
}
|
||||
}
|
||||
|
||||
/** Small lexical pass because org.json otherwise silently accepts duplicate object keys. */
|
||||
private object DuplicateJsonKeyValidator {
|
||||
fun validate(text: String) { Parser(text).parse() }
|
||||
private class Parser(private val source: String) {
|
||||
private var index = 0
|
||||
fun parse() { value(); whitespace(); check(index == source.length) { "JSON suffixe invalide" } }
|
||||
private fun value() { whitespace(); check(index < source.length) { "JSON tronqué" }; when (source[index]) { '{' -> objectValue(); '[' -> arrayValue(); '"' -> string(); 't' -> literal("true"); 'f' -> literal("false"); 'n' -> literal("null"); else -> number() } }
|
||||
private fun objectValue() { index++; whitespace(); val keys = mutableSetOf<String>(); if (take('}')) return; while (true) { whitespace(); val key = string(); check(keys.add(key)) { "clé JSON dupliquée: $key" }; whitespace(); expect(':'); value(); whitespace(); if (take('}')) return; expect(',') } }
|
||||
private fun arrayValue() { index++; whitespace(); if (take(']')) return; while (true) { value(); whitespace(); if (take(']')) return; expect(',') } }
|
||||
private fun string(): String { expect('"'); val out = StringBuilder(); while (index < source.length) { val c = source[index++]; when (c) { '"' -> return out.toString(); '\\' -> { check(index < source.length); val escaped = source[index++]; if (escaped == 'u') { check(index + 4 <= source.length); val hex = source.substring(index, index + 4); check(hex.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }); out.append(hex.toInt(16).toChar()); index += 4 } else { check(escaped in "\"\\/bfnrt"); out.append(escaped) } }; else -> { check(c.code >= 0x20); out.append(c) } } }; error("chaîne JSON tronquée") }
|
||||
private fun literal(value: String) { check(source.startsWith(value, index)); index += value.length }
|
||||
private fun number() { val start = index; if (take('-')) Unit; check(index < source.length && source[index].isDigit()); if (source[index] == '0') index++ else while (index < source.length && source[index].isDigit()) index++; if (take('.')) { check(index < source.length && source[index].isDigit()); while (index < source.length && source[index].isDigit()) index++ }; if (index < source.length && source[index] in "eE") { index++; if (index < source.length && source[index] in "+-") index++; check(index < source.length && source[index].isDigit()); while (index < source.length && source[index].isDigit()) index++ }; check(index > start) }
|
||||
private fun whitespace() { while (index < source.length && source[index].isWhitespace()) index++ }
|
||||
private fun take(c: Char): Boolean { if (index < source.length && source[index] == c) { index++; return true }; return false }
|
||||
private fun expect(c: Char) { check(take(c)) { "JSON: '$c' attendu à $index" } }
|
||||
}
|
||||
}
|
||||
|
|
@ -184,6 +184,68 @@ sealed interface FinalizeActiveDraftResult {
|
|||
) : FinalizeActiveDraftResult
|
||||
}
|
||||
|
||||
data class ExerciseOccurrenceCursor(
|
||||
val startedAt: String,
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
)
|
||||
|
||||
data class ExerciseSetContext(
|
||||
val position: Int,
|
||||
val reps: Int?,
|
||||
val durationSeconds: Int?,
|
||||
/** Null and 0.0 are intentionally distinct actual observations. */
|
||||
val weightKg: Double?,
|
||||
)
|
||||
|
||||
data class ExerciseSetPage(
|
||||
val sets: List<ExerciseSetContext>,
|
||||
val nextPosition: Int?,
|
||||
)
|
||||
|
||||
data class ExerciseOccurrenceContext(
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
val startedAt: String,
|
||||
val sessionType: SessionType,
|
||||
val equipmentId: String?,
|
||||
val equipmentDisplayName: String?,
|
||||
val loadSemantics: EquipmentLoadSemantics?,
|
||||
val recordingMode: RecordingMode,
|
||||
val trackingMode: TrackingMode,
|
||||
val dataFields: Int,
|
||||
val setPreview: ExerciseSetPage?,
|
||||
val continuousDurationSeconds: Int?,
|
||||
val speedKmh: Double?,
|
||||
val distanceKm: Double?,
|
||||
)
|
||||
|
||||
data class ExerciseOccurrencePage(
|
||||
val occurrences: List<ExerciseOccurrenceContext>,
|
||||
val nextCursor: ExerciseOccurrenceCursor?,
|
||||
)
|
||||
|
||||
data class ExplicitMaxContext(
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
val startedAt: String,
|
||||
val maxWeightKg: Double,
|
||||
val equipmentId: String?,
|
||||
val equipmentDisplayName: String?,
|
||||
val loadSemantics: EquipmentLoadSemantics?,
|
||||
)
|
||||
|
||||
data class TrainingExerciseContext(
|
||||
val exercise: ExerciseProfile,
|
||||
/** Persisted user classification; never replaced by scientific projection. */
|
||||
val persistedDirectZoneIds: List<String>,
|
||||
val persistedZoneIdsWithAncestors: List<String>,
|
||||
val knowledge: ExerciseKnowledge?,
|
||||
val compatibleEquipment: List<EquipmentKnowledge>,
|
||||
val latestExplicitMax: ExplicitMaxContext?,
|
||||
val recentPerformance: ExerciseOccurrencePage,
|
||||
)
|
||||
|
||||
class TrainlogRepository(
|
||||
context: Context,
|
||||
databaseName: String =
|
||||
|
|
@ -191,6 +253,7 @@ class TrainlogRepository(
|
|||
) {
|
||||
private val applicationContext = context.applicationContext
|
||||
private val bodyZones = BodyZoneCatalog.load(applicationContext)
|
||||
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
|
||||
private val database =
|
||||
TrainlogDatabaseHelper(
|
||||
applicationContext,
|
||||
|
|
@ -201,6 +264,23 @@ class TrainlogRepository(
|
|||
database.close()
|
||||
}
|
||||
|
||||
fun getExerciseKnowledge(exerciseId: String): ExerciseKnowledge? =
|
||||
trainingKnowledge.getExerciseKnowledge(exerciseId)
|
||||
|
||||
fun getConditionalExerciseKnowledge(exerciseId: String): ExerciseKnowledge? =
|
||||
trainingKnowledge.getConditionalExerciseKnowledge(exerciseId)
|
||||
|
||||
fun getMuscleKnowledge(muscleId: String): MuscleKnowledge? = trainingKnowledge.getMuscle(muscleId)
|
||||
fun getJointActionKnowledge(actionId: String): JointActionKnowledge? = trainingKnowledge.getJointAction(actionId)
|
||||
fun getMovementPatternKnowledge(patternId: String): MovementPatternKnowledge? = trainingKnowledge.getMovementPattern(patternId)
|
||||
fun getScienceReference(refId: String): ScienceReference? = trainingKnowledge.getReference(refId)
|
||||
fun getEquipmentKnowledge(equipmentId: String): EquipmentKnowledge? = trainingKnowledge.getEquipmentKnowledge(equipmentId)
|
||||
fun getScientificBodyZoneMapping(exerciseId: String): ScientificBodyZoneMapping? = trainingKnowledge.getScientificBodyZoneMapping(exerciseId)
|
||||
fun listExercisesByMovementPattern(patternId: String): List<ExerciseKnowledge> = trainingKnowledge.listExercisesByMovementPattern(patternId)
|
||||
fun listExercisesByMuscle(muscleId: String, role: MuscleRole): List<ExerciseKnowledge> = trainingKnowledge.listExercisesByMuscle(muscleId, role)
|
||||
fun listCompatibleKnowledgeExercises(equipmentId: String): List<ExerciseKnowledge> = trainingKnowledge.listCompatibleExercises(equipmentId)
|
||||
fun queryExerciseKnowledge(filters: KnowledgeExerciseFilters): List<ExerciseKnowledge> = trainingKnowledge.queryExercises(filters)
|
||||
|
||||
fun listEquipment(): List<EquipmentCatalogEntry> {
|
||||
val output = mutableListOf<EquipmentCatalogEntry>()
|
||||
database.readableDatabase.query(
|
||||
|
|
@ -3172,6 +3252,286 @@ class TrainlogRepository(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose immutable scientific metadata with exact persisted runtime state.
|
||||
* WHY: names are editable labels, so every join and lookup stays on stable
|
||||
* exercise_id; the transaction gives all components one SQLite read view.
|
||||
*/
|
||||
fun getTrainingExerciseContext(
|
||||
exerciseId: String,
|
||||
occurrenceLimit: Int = 8,
|
||||
setPreviewLimit: Int = 8,
|
||||
): TrainingExerciseContext? {
|
||||
require(occurrenceLimit in 1..MAX_OCCURRENCE_PAGE_SIZE) { "occurrenceLimit doit être compris entre 1 et $MAX_OCCURRENCE_PAGE_SIZE" }
|
||||
require(setPreviewLimit in 1..MAX_SET_PAGE_SIZE) { "setPreviewLimit doit être compris entre 1 et $MAX_SET_PAGE_SIZE" }
|
||||
val db = database.readableDatabase
|
||||
val ownsTransaction = !db.inTransaction()
|
||||
if (ownsTransaction) db.beginTransactionNonExclusive()
|
||||
return try {
|
||||
val exercise = readExerciseProfileExact(db, exerciseId) ?: return null
|
||||
val direct = buildList {
|
||||
exercise.primaryZoneId?.let(::add)
|
||||
addAll(exercise.secondaryZoneIds)
|
||||
}
|
||||
val expanded = linkedSetOf<String>()
|
||||
direct.forEach { zoneId ->
|
||||
expanded += zoneId
|
||||
expanded += bodyZones.ancestors(zoneId).map { it.zoneId }
|
||||
}
|
||||
val knowledge = trainingKnowledge.getExerciseKnowledge(exerciseId)
|
||||
val compatible = knowledge?.equipmentIds.orEmpty().mapNotNull(trainingKnowledge::getEquipmentKnowledge)
|
||||
val result = TrainingExerciseContext(
|
||||
exercise = exercise,
|
||||
persistedDirectZoneIds = direct,
|
||||
persistedZoneIdsWithAncestors = bodyZones.zones.map { it.zoneId }.filter { it in expanded },
|
||||
knowledge = knowledge,
|
||||
compatibleEquipment = compatible,
|
||||
latestExplicitMax = readLatestExplicitMax(db, exerciseId),
|
||||
recentPerformance = readExerciseOccurrencePage(db, exerciseId, occurrenceLimit, null, setPreviewLimit),
|
||||
)
|
||||
if (ownsTransaction) db.setTransactionSuccessful()
|
||||
result
|
||||
} finally {
|
||||
if (ownsTransaction) db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
/** Deterministic keyset page over current data; pages do not hold a cross-call snapshot. */
|
||||
fun listExerciseOccurrences(
|
||||
exerciseId: String,
|
||||
limit: Int,
|
||||
after: ExerciseOccurrenceCursor? = null,
|
||||
setPreviewLimit: Int = 8,
|
||||
): ExerciseOccurrencePage {
|
||||
require(limit in 1..MAX_OCCURRENCE_PAGE_SIZE) { "limit doit être compris entre 1 et $MAX_OCCURRENCE_PAGE_SIZE" }
|
||||
require(setPreviewLimit in 1..MAX_SET_PAGE_SIZE) { "setPreviewLimit doit être compris entre 1 et $MAX_SET_PAGE_SIZE" }
|
||||
validateOccurrenceCursor(after)
|
||||
val db = database.readableDatabase
|
||||
val ownsTransaction = !db.inTransaction()
|
||||
if (ownsTransaction) db.beginTransactionNonExclusive()
|
||||
return try {
|
||||
require(readExerciseProfileExact(db, exerciseId) != null) { "exercise_id inconnu: $exerciseId" }
|
||||
val result = readExerciseOccurrencePage(db, exerciseId, limit, after, setPreviewLimit)
|
||||
if (ownsTransaction) db.setTransactionSuccessful()
|
||||
result
|
||||
} finally {
|
||||
if (ownsTransaction) db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
/** Follow-up bounded set page for one exact occurrence; no global set load exists. */
|
||||
fun listExerciseOccurrenceSets(
|
||||
exerciseId: String,
|
||||
entryId: String,
|
||||
limit: Int,
|
||||
afterPosition: Int? = null,
|
||||
): ExerciseSetPage {
|
||||
require(exerciseId.isNotBlank() && entryId.isNotBlank()) { "identités vides" }
|
||||
require(limit in 1..MAX_SET_PAGE_SIZE) { "limit doit être compris entre 1 et $MAX_SET_PAGE_SIZE" }
|
||||
require(afterPosition == null || afterPosition >= 0) { "position de curseur invalide" }
|
||||
val db = database.readableDatabase
|
||||
val ownsTransaction = !db.inTransaction()
|
||||
if (ownsTransaction) db.beginTransactionNonExclusive()
|
||||
return try {
|
||||
val rowId = db.rawQuery(
|
||||
"SELECT se.id,se.recording_mode FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id WHERE e.exercise_id=? AND se.entry_id=?;",
|
||||
arrayOf(exerciseId, entryId),
|
||||
).use { cursor ->
|
||||
require(cursor.moveToFirst()) { "occurrence inconnue pour cet exercice" }
|
||||
require(cursor.getString(1) == "sets") { "une activité continue ne possède pas de séries" }
|
||||
cursor.getLong(0)
|
||||
}
|
||||
val result = readSetPage(db, rowId, limit, afterPosition)
|
||||
if (ownsTransaction) db.setTransactionSuccessful()
|
||||
result
|
||||
} finally {
|
||||
if (ownsTransaction) db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readExerciseProfileExact(db: SQLiteDatabase, exerciseId: String): ExerciseProfile? =
|
||||
db.rawQuery(
|
||||
"SELECT exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields FROM exercises WHERE exercise_id=?;",
|
||||
arrayOf(exerciseId),
|
||||
).use { cursor ->
|
||||
if (!cursor.moveToFirst()) null else {
|
||||
val zones = readExerciseBodyZones(db, exerciseId)
|
||||
ExerciseProfile(
|
||||
exerciseId = cursor.getString(0), name = cursor.getString(1), normalizedName = cursor.getString(2),
|
||||
recordingMode = parseRecordingMode(cursor.getString(3)), trackingMode = parseTrackingMode(cursor.getString(4)),
|
||||
dataFields = checkedNonNegativeInt(cursor.getLong(5), "data_fields"), primaryZoneId = zones.first,
|
||||
secondaryZoneIds = zones.second,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class TemporalCandidate(
|
||||
val rowId: Long,
|
||||
val sessionId: String,
|
||||
val entryId: String,
|
||||
val startedAt: String,
|
||||
val timestamp: TrainlogTimestampKey,
|
||||
)
|
||||
|
||||
private fun compareTemporal(left: TemporalCandidate, right: TemporalCandidate): Int =
|
||||
left.timestamp.compareTo(right.timestamp).takeIf { it != 0 }
|
||||
?: TrainlogTimestamp.compareIds(left.sessionId, right.sessionId).takeIf { it != 0 }
|
||||
?: TrainlogTimestamp.compareIds(left.entryId, right.entryId)
|
||||
|
||||
private fun candidate(cursor: android.database.Cursor): TemporalCandidate {
|
||||
val startedAt = cursor.requiredText(3, "started_at")
|
||||
return TemporalCandidate(
|
||||
rowId = cursor.getLong(0),
|
||||
sessionId = cursor.requiredText(1, "session_id"),
|
||||
entryId = cursor.requiredText(2, "entry_id"),
|
||||
startedAt = startedAt,
|
||||
timestamp = checkNotNull(TrainlogTimestamp.parse(startedAt)) {
|
||||
"started_at persistant invalide pour l'occurrence"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun retainCandidate(rows: MutableList<TemporalCandidate>, value: TemporalCandidate, capacity: Int) {
|
||||
val position = rows.indexOfFirst { compareTemporal(value, it) > 0 }.let { if (it < 0) rows.size else it }
|
||||
if (position < capacity) {
|
||||
rows.add(position, value)
|
||||
if (rows.size > capacity) rows.removeAt(rows.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLatestExplicitMax(db: SQLiteDatabase, exerciseId: String): ExplicitMaxContext? {
|
||||
val selected = mutableListOf<TemporalCandidate>()
|
||||
db.rawQuery(
|
||||
"""SELECT se.id,s.session_id,se.entry_id,s.started_at FROM max_results mr
|
||||
JOIN session_exercises se ON se.id=mr.session_exercise_row_id
|
||||
JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id
|
||||
WHERE e.exercise_id=? AND s.session_type='max_test';""",
|
||||
arrayOf(exerciseId),
|
||||
).use { cursor -> while (cursor.moveToNext()) retainCandidate(selected, candidate(cursor), 1) }
|
||||
val winner = selected.singleOrNull() ?: return null
|
||||
return db.rawQuery(
|
||||
"""SELECT s.session_id,se.entry_id,s.started_at,mr.max_weight_kg,
|
||||
eq.equipment_id,eq.display_name,eq.load_semantics
|
||||
FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id
|
||||
JOIN sessions s ON s.id=se.session_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id
|
||||
WHERE se.id=?;""",
|
||||
arrayOf(winner.rowId.toString()),
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst()) { "résultat MAX sélectionné absent" }
|
||||
ExplicitMaxContext(
|
||||
sessionId = cursor.requiredText(0, "session_id"), entryId = cursor.requiredText(1, "entry_id"),
|
||||
startedAt = cursor.requiredText(2, "started_at"), maxWeightKg = cursor.finiteNonNegativeDouble(3, "max_weight_kg", strictlyPositive = true),
|
||||
equipmentId = cursor.optionalText(4), equipmentDisplayName = cursor.optionalText(5),
|
||||
loadSemantics = cursor.optionalText(6)?.let(::parseLoadSemantics),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readExerciseOccurrencePage(
|
||||
db: SQLiteDatabase, exerciseId: String, limit: Int, after: ExerciseOccurrenceCursor?, setPreviewLimit: Int,
|
||||
): ExerciseOccurrencePage {
|
||||
val afterCandidate = after?.let {
|
||||
TemporalCandidate(-1L, it.sessionId, it.entryId, it.startedAt,
|
||||
requireNotNull(TrainlogTimestamp.parse(it.startedAt)))
|
||||
}
|
||||
val selected = mutableListOf<TemporalCandidate>()
|
||||
db.rawQuery(
|
||||
"""
|
||||
SELECT se.id,s.session_id,se.entry_id,s.started_at
|
||||
FROM session_exercises se
|
||||
JOIN sessions s ON s.id=se.session_row_id
|
||||
JOIN exercises e ON e.id=se.exercise_row_id
|
||||
WHERE e.exercise_id=?;
|
||||
""".trimIndent(), arrayOf(exerciseId),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val value = candidate(cursor)
|
||||
// INVARIANT: selection and the exclusive cursor share compareTemporal.
|
||||
if (afterCandidate == null || compareTemporal(value, afterCandidate) < 0)
|
||||
retainCandidate(selected, value, limit + 1)
|
||||
}
|
||||
}
|
||||
val hasMore = selected.size > limit
|
||||
val kept = if (hasMore) selected.take(limit) else selected
|
||||
val rows = kept.map { selectedRow ->
|
||||
db.rawQuery(
|
||||
"""SELECT se.id,s.session_id,se.entry_id,s.started_at,s.session_type,
|
||||
eq.equipment_id,eq.display_name,eq.load_semantics,
|
||||
se.recording_mode,se.tracking_mode,se.data_fields,
|
||||
ca.duration_seconds,ca.speed_kmh,ca.distance_km
|
||||
FROM session_exercises se JOIN sessions s ON s.id=se.session_row_id
|
||||
LEFT JOIN equipment eq ON eq.id=se.equipment_row_id
|
||||
LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id
|
||||
WHERE se.id=?;""",
|
||||
arrayOf(selectedRow.rowId.toString()),
|
||||
).use { cursor ->
|
||||
check(cursor.moveToFirst()) { "occurrence sélectionnée absente" }
|
||||
val recording = parseRecordingMode(cursor.requiredText(8, "recording_mode"))
|
||||
val rowId = cursor.getLong(0)
|
||||
val context = ExerciseOccurrenceContext(
|
||||
sessionId = cursor.requiredText(1, "session_id"), entryId = cursor.requiredText(2, "entry_id"),
|
||||
startedAt = cursor.requiredText(3, "started_at"), sessionType = SessionType.fromWire(cursor.requiredText(4, "session_type")),
|
||||
equipmentId = cursor.optionalText(5), equipmentDisplayName = cursor.optionalText(6),
|
||||
loadSemantics = cursor.optionalText(7)?.let(::parseLoadSemantics), recordingMode = recording,
|
||||
trackingMode = parseTrackingMode(cursor.requiredText(9, "tracking_mode")), dataFields = checkedNonNegativeInt(cursor.getLong(10), "data_fields"),
|
||||
setPreview = if (recording == RecordingMode.SETS) readSetPage(db, rowId, setPreviewLimit, null) else null,
|
||||
continuousDurationSeconds = if (recording == RecordingMode.CONTINUOUS) cursor.requiredPositiveInt(11, "duration_seconds") else null,
|
||||
speedKmh = cursor.optionalFinitePositiveDouble(12, "speed_kmh"), distanceKm = cursor.optionalFinitePositiveDouble(13, "distance_km"),
|
||||
)
|
||||
if (recording == RecordingMode.SETS) check(cursor.isNull(11) && cursor.isNull(12) && cursor.isNull(13)) { "activité continue attachée à une occurrence SETS" }
|
||||
context
|
||||
}
|
||||
}
|
||||
val last = rows.lastOrNull()
|
||||
return ExerciseOccurrencePage(
|
||||
occurrences = rows,
|
||||
nextCursor = if (hasMore && last != null) ExerciseOccurrenceCursor(last.startedAt, last.sessionId, last.entryId) else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readSetPage(db: SQLiteDatabase, occurrenceRowId: Long, limit: Int, afterPosition: Int?): ExerciseSetPage {
|
||||
val selection = if (afterPosition == null) "session_exercise_row_id=?" else "session_exercise_row_id=? AND position>?"
|
||||
val args = if (afterPosition == null) arrayOf(occurrenceRowId.toString(), (limit + 1).toString()) else arrayOf(occurrenceRowId.toString(), afterPosition.toString(), (limit + 1).toString())
|
||||
val rows = mutableListOf<ExerciseSetContext>()
|
||||
db.rawQuery("SELECT position,reps,duration_seconds,weight_kg FROM performed_sets WHERE $selection ORDER BY position ASC LIMIT ?;", args).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val reps = if (cursor.isNull(1)) null else checkedNonNegativeInt(cursor.getLong(1), "reps")
|
||||
val duration = if (cursor.isNull(2)) null else cursor.requiredPositiveInt(2, "duration_seconds")
|
||||
check((reps == null) != (duration == null)) { "forme de série corrompue" }
|
||||
rows += ExerciseSetContext(checkedNonNegativeInt(cursor.getLong(0), "position"), reps, duration, if (cursor.isNull(3)) null else cursor.finiteNonNegativeDouble(3, "weight_kg"))
|
||||
}
|
||||
}
|
||||
val hasMore = rows.size > limit
|
||||
val kept = if (hasMore) rows.take(limit) else rows
|
||||
return ExerciseSetPage(kept, if (hasMore) kept.last().position else null)
|
||||
}
|
||||
|
||||
private fun validateOccurrenceCursor(cursor: ExerciseOccurrenceCursor?) {
|
||||
if (cursor == null) return
|
||||
require(cursor.startedAt.isNotBlank() && cursor.sessionId.isNotBlank() && cursor.entryId.isNotBlank()) { "curseur d'occurrence invalide" }
|
||||
require(TrainlogTimestamp.parse(cursor.startedAt) != null) { "startedAt du curseur invalide" }
|
||||
}
|
||||
|
||||
private fun parseRecordingMode(value: String): RecordingMode = when (value) {
|
||||
"sets" -> RecordingMode.SETS; "continuous" -> RecordingMode.CONTINUOUS; else -> error("recording_mode corrompu: $value")
|
||||
}
|
||||
private fun parseTrackingMode(value: String): TrackingMode = when (value) {
|
||||
"reps" -> TrackingMode.REPS; "duration" -> TrackingMode.DURATION; else -> error("tracking_mode corrompu: $value")
|
||||
}
|
||||
private fun parseLoadSemantics(value: String): EquipmentLoadSemantics = runCatching { EquipmentLoadSemantics.valueOf(value.uppercase(Locale.ROOT)) }.getOrElse { error("load_semantics corrompu: $value") }
|
||||
|
||||
private fun android.database.Cursor.requiredText(index: Int, name: String): String = getString(index)?.takeIf { it.isNotBlank() } ?: error("$name absent")
|
||||
private fun android.database.Cursor.optionalText(index: Int): String? = if (isNull(index)) null else getString(index)
|
||||
private fun android.database.Cursor.requiredPositiveInt(index: Int, name: String): Int = checkedNonNegativeInt(getLong(index), name).also { check(it > 0) { "$name doit être positif" } }
|
||||
private fun android.database.Cursor.finiteNonNegativeDouble(index: Int, name: String, strictlyPositive: Boolean = false): Double = getDouble(index).also { check(it.isFinite() && if (strictlyPositive) it > 0.0 else it >= 0.0) { "$name invalide" } }
|
||||
private fun android.database.Cursor.optionalFinitePositiveDouble(index: Int, name: String): Double? = if (isNull(index)) null else finiteNonNegativeDouble(index, name, true)
|
||||
private fun checkedNonNegativeInt(value: Long, name: String): Int { check(value in 0..Int.MAX_VALUE.toLong()) { "$name hors plage" }; return value.toInt() }
|
||||
|
||||
private companion object {
|
||||
const val MAX_OCCURRENCE_PAGE_SIZE = 32
|
||||
const val MAX_SET_PAGE_SIZE = 64
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one newest explicit measured max per movement. Equipment is
|
||||
* presentation context only and never participates in max identity.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
internal data class TrainlogTimestampKey(
|
||||
val utcSecond: Long,
|
||||
val fraction: String,
|
||||
) : Comparable<TrainlogTimestampKey> {
|
||||
override fun compareTo(other: TrainlogTimestampKey): Int {
|
||||
utcSecond.compareTo(other.utcSecond).takeIf { it != 0 }?.let { return it }
|
||||
val count = maxOf(fraction.length, other.fraction.length)
|
||||
for (index in 0 until count) {
|
||||
val left = fraction.getOrElse(index) { '0' }
|
||||
val right = other.fraction.getOrElse(index) { '0' }
|
||||
if (left != right) return left.compareTo(right)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
internal object TrainlogTimestamp {
|
||||
/**
|
||||
* CONTRACT: parse the frozen ASCII timestamp grammar without inheriting a
|
||||
* platform ISO parser's syntax, offset bounds, or fractional precision.
|
||||
*/
|
||||
fun parse(value: String): TrainlogTimestampKey? {
|
||||
if (value.length < 17 || value.any { it.code > 0x7f }) return null
|
||||
val year = digits(value, 0, 4) ?: return null
|
||||
val month = digits(value, 5, 2) ?: return null
|
||||
val day = digits(value, 8, 2) ?: return null
|
||||
val hour = digits(value, 11, 2) ?: return null
|
||||
val minute = digits(value, 14, 2) ?: return null
|
||||
if (value.getOrNull(4) != '-' || value.getOrNull(7) != '-' ||
|
||||
value.getOrNull(10) !in listOf('T', 't') || value.getOrNull(13) != ':' ||
|
||||
year !in 1..9999 || month !in 1..12 || day !in 1..daysInMonth(year, month) ||
|
||||
hour !in 0..23 || minute !in 0..59
|
||||
) return null
|
||||
|
||||
var at = 16
|
||||
var second = 0
|
||||
var fraction = ""
|
||||
if (value.getOrNull(at) == ':') {
|
||||
second = digits(value, at + 1, 2) ?: return null
|
||||
if (second !in 0..59) return null
|
||||
at += 3
|
||||
if (value.getOrNull(at) == '.') {
|
||||
val start = ++at
|
||||
while (value.getOrNull(at)?.isAsciiDigit() == true) at++
|
||||
if (at == start) return null
|
||||
fraction = value.substring(start, at)
|
||||
}
|
||||
}
|
||||
|
||||
var offsetSeconds = 0L
|
||||
if (at + 1 == value.length && value[at] in listOf('Z', 'z')) {
|
||||
// UTC designator.
|
||||
} else {
|
||||
if (at + 6 != value.length || value.getOrNull(at) !in listOf('+', '-') ||
|
||||
value.getOrNull(at + 3) != ':'
|
||||
) return null
|
||||
val offsetHour = digits(value, at + 1, 2) ?: return null
|
||||
val offsetMinute = digits(value, at + 4, 2) ?: return null
|
||||
if (offsetHour !in 0..23 || offsetMinute !in 0..59) return null
|
||||
offsetSeconds = (offsetHour * 3600L + offsetMinute * 60L) *
|
||||
if (value[at] == '+') 1L else -1L
|
||||
}
|
||||
val localSecond = dayNumber(year, month, day) * 86400L +
|
||||
hour * 3600L + minute * 60L + second
|
||||
return TrainlogTimestampKey(localSecond - offsetSeconds, fraction)
|
||||
}
|
||||
|
||||
/** Bytewise identity order shared with C's unsigned memcmp semantics. */
|
||||
fun compareIds(left: String, right: String): Int {
|
||||
val leftBytes = left.toByteArray(Charsets.UTF_8)
|
||||
val rightBytes = right.toByteArray(Charsets.UTF_8)
|
||||
for (index in 0 until minOf(leftBytes.size, rightBytes.size)) {
|
||||
val result = (leftBytes[index].toInt() and 0xff).compareTo(rightBytes[index].toInt() and 0xff)
|
||||
if (result != 0) return result
|
||||
}
|
||||
return leftBytes.size.compareTo(rightBytes.size)
|
||||
}
|
||||
|
||||
private fun digits(value: String, start: Int, count: Int): Int? {
|
||||
if (start < 0 || start + count > value.length) return null
|
||||
var result = 0
|
||||
repeat(count) { offset ->
|
||||
val character = value[start + offset]
|
||||
if (!character.isAsciiDigit()) return null
|
||||
result = result * 10 + (character - '0')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Char.isAsciiDigit(): Boolean = this in '0'..'9'
|
||||
private fun leap(year: Int): Boolean = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
|
||||
private fun daysInMonth(year: Int, month: Int): Int = when (month) {
|
||||
2 -> if (leap(year)) 29 else 28
|
||||
4, 6, 9, 11 -> 30
|
||||
else -> 31
|
||||
}
|
||||
|
||||
private fun dayNumber(year: Int, month: Int, day: Int): Long {
|
||||
val prior = year - 1
|
||||
var days = prior * 365L + prior / 4 - prior / 100 + prior / 400
|
||||
for (current in 1 until month) days += daysInMonth(year, current)
|
||||
return days + day - 1
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,9 @@ import com.labfytools.trainlog.data.CreateExerciseResult
|
|||
import com.labfytools.trainlog.data.BodyZone
|
||||
import com.labfytools.trainlog.data.BodyZoneKind
|
||||
import com.labfytools.trainlog.data.EditExerciseResult
|
||||
import com.labfytools.trainlog.data.ExerciseKnowledge
|
||||
import com.labfytools.trainlog.data.ExerciseKnowledgeStatus
|
||||
import com.labfytools.trainlog.data.KnowledgeConfidence
|
||||
import com.labfytools.trainlog.data.TrainlogRepository
|
||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
||||
import com.labfytools.trainlog.model.ExerciseEditInput
|
||||
|
|
@ -73,6 +76,7 @@ fun ExerciseScreen(
|
|||
var searchQuery by remember { mutableStateOf("") }
|
||||
var filterZoneId by remember { mutableStateOf<String?>(null) }
|
||||
var unclassifiedFilter by remember { mutableStateOf(false) }
|
||||
var expandedKnowledgeIds by remember { mutableStateOf(emptySet<String>()) }
|
||||
val zones = repository.listBodyZones()
|
||||
|
||||
var message by
|
||||
|
|
@ -513,6 +517,22 @@ fun ExerciseScreen(
|
|||
accent = colors.accent,
|
||||
onClick = { startEditing(exercise) },
|
||||
)
|
||||
repository.getExerciseKnowledge(exercise.exerciseId)?.let { knowledge ->
|
||||
val expanded = exercise.exerciseId in expandedKnowledgeIds
|
||||
TrainlogAction(
|
||||
label = if (expanded) "− Connaissances" else "+ Connaissances",
|
||||
description = knowledgeSummary(knowledge),
|
||||
accent = colors.muted,
|
||||
onClick = {
|
||||
expandedKnowledgeIds = if (expanded) {
|
||||
expandedKnowledgeIds - exercise.exerciseId
|
||||
} else {
|
||||
expandedKnowledgeIds + exercise.exerciseId
|
||||
}
|
||||
},
|
||||
)
|
||||
if (expanded) KnowledgePanel(repository, knowledge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -532,6 +552,59 @@ fun ExerciseScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKnowledge) {
|
||||
val colors = LocalTrainlogColors.current
|
||||
/* WHY: conditional content is opened only under an explicit uncertainty
|
||||
* label; it cannot be mistaken for an ordinary resolved classification. */
|
||||
val interpretation = when (knowledge.resolutionStatus) {
|
||||
ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED -> knowledge.interpretation
|
||||
ExerciseKnowledgeStatus.CONDITIONAL -> knowledge.conditionalInterpretation
|
||||
ExerciseKnowledgeStatus.UNRESOLVED -> null
|
||||
}
|
||||
if (interpretation == null) {
|
||||
TrainlogInfo("Classification scientifique non résolue.", colors.warning)
|
||||
return
|
||||
}
|
||||
if (knowledge.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL) {
|
||||
TrainlogInfo(
|
||||
"Interprétation conditionnelle · à confirmer : ${interpretation.requiredConfirmation.orEmpty()}",
|
||||
colors.warning,
|
||||
)
|
||||
}
|
||||
val patterns = interpretation.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
|
||||
val primary = interpretation.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val secondary = interpretation.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
|
||||
val primaryZone = repository.bodyZone(interpretation.primaryZoneId)?.displayName
|
||||
?: interpretation.primaryZoneId
|
||||
val secondaryZones = interpretation.secondaryZoneIds.map { repository.bodyZone(it)?.displayName ?: it }
|
||||
val runtimeEquipment = repository.listEquipment().associateBy { it.equipmentId }
|
||||
val equipment = knowledge.equipmentIds.map { runtimeEquipment[it]?.displayName ?: it }
|
||||
TrainlogInfo("Mouvement : ${patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classé" }}")
|
||||
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
|
||||
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
|
||||
TrainlogInfo(
|
||||
"Zones scientifiques : $primaryZone" +
|
||||
if (secondaryZones.isEmpty()) "" else " · secondaires : ${secondaryZones.joinToString()}",
|
||||
)
|
||||
TrainlogInfo("Équipement compatible : ${equipment.joinToString().ifEmpty { "Non établi" }}")
|
||||
TrainlogInfo("Confiance : ${confidenceLabel(interpretation.confidence)}", colors.muted)
|
||||
}
|
||||
|
||||
private fun knowledgeSummary(knowledge: ExerciseKnowledge): String = when (knowledge.resolutionStatus) {
|
||||
ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED ->
|
||||
"Classification scientifique · confiance ${confidenceLabel(knowledge.confidence)}"
|
||||
ExerciseKnowledgeStatus.CONDITIONAL ->
|
||||
"Classification conditionnelle · incertitude explicite"
|
||||
ExerciseKnowledgeStatus.UNRESOLVED -> "Classification scientifique non résolue"
|
||||
}
|
||||
|
||||
private fun confidenceLabel(confidence: KnowledgeConfidence): String = when (confidence) {
|
||||
KnowledgeConfidence.HIGH -> "élevée"
|
||||
KnowledgeConfidence.MODERATE -> "modérée"
|
||||
KnowledgeConfidence.UNCERTAIN -> "incertaine"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BodyZoneChoices(
|
||||
zones: List<BodyZone>,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.labfytools.trainlog.model.ExerciseEditInput
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class TrainingExerciseContextTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private val databaseName = "knowledge-context-${UUID.randomUUID()}.db"
|
||||
private val repository = TrainlogRepository(context, databaseName)
|
||||
|
||||
@After fun close() { repository.close(); context.deleteDatabase(databaseName) }
|
||||
|
||||
@Test
|
||||
fun composesExactIdentityZonesEquipmentMaxAndBoundedOccurrencePages() {
|
||||
seedFixture()
|
||||
val exerciseId = LEG_PRESS_ID
|
||||
val first = repository.getTrainingExerciseContext(exerciseId, occurrenceLimit = 2, setPreviewLimit = 2)!!
|
||||
assertEquals("Nom modifiable", first.exercise.name)
|
||||
assertEquals(listOf("thighs", "glutes"), first.persistedDirectZoneIds)
|
||||
assertTrue("lower_body" in first.persistedZoneIdsWithAncestors)
|
||||
assertEquals("thighs", repository.getScientificBodyZoneMapping(exerciseId)?.primaryZoneId)
|
||||
assertTrue(first.compatibleEquipment.any { it.equipmentId == "leg_press" })
|
||||
|
||||
val maximum = first.latestExplicitMax!!
|
||||
assertEquals(140.0, maximum.maxWeightKg, 0.0)
|
||||
assertEquals(EquipmentLoadSemantics.ASSISTANCE, maximum.loadSemantics)
|
||||
assertFalse(first.recentPerformance.occurrences.any { occurrence ->
|
||||
occurrence.setPreview?.sets.orEmpty().any { it.weightKg == 999.0 } && occurrence.sessionType == com.labfytools.trainlog.model.SessionType.MAX_TEST
|
||||
})
|
||||
assertEquals(2, first.recentPerformance.occurrences.size)
|
||||
val cursor = first.recentPerformance.nextCursor!!
|
||||
val second = repository.listExerciseOccurrences(exerciseId, 2, cursor, 2)
|
||||
assertEquals(2, second.occurrences.size)
|
||||
assertTrue(first.recentPerformance.occurrences.map { it.entryId }.toSet().intersect(second.occurrences.map { it.entryId }.toSet()).isEmpty())
|
||||
|
||||
val assistanceOccurrence = (first.recentPerformance.occurrences + second.occurrences).first { it.entryId == "entry_a" }
|
||||
assertEquals(EquipmentLoadSemantics.ASSISTANCE, assistanceOccurrence.loadSemantics)
|
||||
assertEquals(listOf(null, 0.0), assistanceOccurrence.setPreview!!.sets.map { it.weightKg })
|
||||
val setPage2 = repository.listExerciseOccurrenceSets(exerciseId, "entry_a", 2, assistanceOccurrence.setPreview.nextPosition)
|
||||
assertEquals(listOf(22.5), setPage2.sets.map { it.weightKg })
|
||||
assertNull(setPage2.nextPosition)
|
||||
|
||||
assertTrue(repository.getExerciseKnowledge(exerciseId) != null)
|
||||
assertNull(repository.getTrainingExerciseContext("ex_00000000-0000-4000-8000-000000000000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun continuousOccurrenceHasNoArtificialSetsAndInvalidPagingFails() {
|
||||
seedFixture()
|
||||
val continuous = repository.getTrainingExerciseContext(CONTINUOUS_ID)!!.recentPerformance.occurrences.single()
|
||||
assertNull(continuous.setPreview)
|
||||
assertEquals(1800, continuous.continuousDurationSeconds)
|
||||
assertEquals(8.0, continuous.speedKmh!!, 0.0)
|
||||
assertTrue(runCatching { repository.listExerciseOccurrences(LEG_PRESS_ID, 0) }.isFailure)
|
||||
assertTrue(runCatching { repository.listExerciseOccurrences(LEG_PRESS_ID, 2, ExerciseOccurrenceCursor("bad", "s", "e")) }.isFailure)
|
||||
assertTrue(runCatching { repository.listExerciseOccurrenceSets(CONTINUOUS_ID, "entry_cont", 2) }.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun occurrencesAndExplicitMaxOrderByInstantAcrossOffsetsWithStablePagingTies() {
|
||||
seedFixture()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
listOf(
|
||||
arrayOf<Any?>("offset_local_older", "2026-02-01T10:00:00+02:00", "training", "entry_offset_old"),
|
||||
arrayOf<Any?>("offset_utc_newer", "2026-02-01T09:00:00Z", "training", "entry_offset_new"),
|
||||
arrayOf<Any?>("tie_a", "2026-02-02T10:00:00+02:00", "training", "entry_tie_a"),
|
||||
arrayOf<Any?>("tie_z", "2026-02-02T08:00:00Z", "training", "entry_tie_z"),
|
||||
arrayOf<Any?>("max_local_older", "2025-03-01T10:00:00+02:00", "max_test", "entry_max_old"),
|
||||
arrayOf<Any?>("max_utc_newer", "2025-03-01T09:00:00Z", "max_test", "entry_max_new"),
|
||||
).forEach { row ->
|
||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);", row.copyOfRange(0, 3))
|
||||
db.execSQL("INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id) SELECT s.id,e.id,0,'sets','reps',0,? FROM sessions s JOIN exercises e ON e.exercise_id=? WHERE s.session_id=?;", arrayOf(row[3], LEG_PRESS_ID, row[0]))
|
||||
}
|
||||
db.execSQL("INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,150.0 FROM session_exercises WHERE entry_id='entry_max_old';")
|
||||
db.execSQL("INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,160.0 FROM session_exercises WHERE entry_id='entry_max_new';")
|
||||
}
|
||||
|
||||
val tieFirst = repository.listExerciseOccurrences(LEG_PRESS_ID, 1)
|
||||
assertEquals("tie_z", tieFirst.occurrences.single().sessionId)
|
||||
val tieSecond = repository.listExerciseOccurrences(LEG_PRESS_ID, 1, tieFirst.nextCursor)
|
||||
assertEquals("tie_a", tieSecond.occurrences.single().sessionId)
|
||||
val following = repository.listExerciseOccurrences(LEG_PRESS_ID, 2, tieSecond.nextCursor)
|
||||
assertEquals(listOf("offset_utc_newer", "offset_local_older"), following.occurrences.map { it.sessionId })
|
||||
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("UPDATE sessions SET started_at='2026-03-01T10:00:00+02:00' WHERE session_id='max_local_older';")
|
||||
db.execSQL("UPDATE sessions SET started_at='2026-03-01T09:00:00Z' WHERE session_id='max_utc_newer';")
|
||||
}
|
||||
val maximum = repository.getTrainingExerciseContext(LEG_PRESS_ID)!!.latestExplicitMax!!
|
||||
assertEquals("max_utc_newer", maximum.sessionId)
|
||||
assertEquals(160.0, maximum.maxWeightKg, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exceptionalTimestampSpellingsRoundTripWithExactFractionsAndCorruptionFails() {
|
||||
seedFixture()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
listOf(
|
||||
arrayOf("omit", "2030-01-05T10:00+15:00", "entry_omit"),
|
||||
arrayOf("frac_low", "2030-01-04T10:00:00.12345678901234567890Z", "entry_frac_low"),
|
||||
arrayOf("frac_high", "2030-01-04T10:00:00.12345678901234567891Z", "entry_frac_high"),
|
||||
arrayOf("tie_a", "2030-01-04T12:00:00+02:00", "entry_tie_a2"),
|
||||
arrayOf("tie_z", "2030-01-04T10:00:00-00:00", "entry_tie_z2"),
|
||||
arrayOf("lower", "2030-01-03t10:00:00z", "entry_lower"),
|
||||
arrayOf("high_offset", "2030-01-04T09:00:00+23:59", "entry_high_offset"),
|
||||
).forEach { row ->
|
||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,'training');", arrayOf(row[0], row[1]))
|
||||
db.execSQL("INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id) SELECT s.id,e.id,0,'sets','reps',0,? FROM sessions s JOIN exercises e ON e.exercise_id=? WHERE s.session_id=?;", arrayOf(row[2], LEG_PRESS_ID, row[0]))
|
||||
}
|
||||
listOf(
|
||||
arrayOf<Any?>("max_offset", "2031-01-02T10:00:00+15:00", "entry_max_offset", 151.0),
|
||||
arrayOf<Any?>("max_true", "2031-01-01T20:00:00Z", "entry_max_true", 152.0),
|
||||
).forEach { row ->
|
||||
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,'max_test');", arrayOf(row[0], row[1]))
|
||||
db.execSQL("INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,entry_id) SELECT s.id,e.id,0,'sets','reps',0,? FROM sessions s JOIN exercises e ON e.exercise_id=? WHERE s.session_id=?;", arrayOf(row[2], LEG_PRESS_ID, row[0]))
|
||||
db.execSQL("INSERT INTO max_results(session_exercise_row_id,max_weight_kg) SELECT id,? FROM session_exercises WHERE entry_id=?;", arrayOf(row[3], row[2]))
|
||||
}
|
||||
}
|
||||
|
||||
val expected = listOf("max_true", "max_offset", "omit", "frac_high", "frac_low", "tie_z", "tie_a", "lower", "high_offset")
|
||||
var cursor: ExerciseOccurrenceCursor? = null
|
||||
val seen = mutableSetOf<String>()
|
||||
expected.forEach { sessionId ->
|
||||
val page = repository.listExerciseOccurrences(LEG_PRESS_ID, 1, cursor)
|
||||
assertEquals(sessionId, page.occurrences.single().sessionId)
|
||||
assertTrue(seen.add(page.occurrences.single().entryId))
|
||||
assertEquals(page.occurrences.single().startedAt, page.nextCursor!!.startedAt)
|
||||
cursor = page.nextCursor
|
||||
}
|
||||
while (cursor != null) {
|
||||
val page = repository.listExerciseOccurrences(LEG_PRESS_ID, 1, cursor)
|
||||
page.occurrences.forEach { assertTrue(seen.add(it.entryId)) }
|
||||
cursor = page.nextCursor
|
||||
}
|
||||
val maximum = repository.getTrainingExerciseContext(LEG_PRESS_ID)!!.latestExplicitMax!!
|
||||
assertEquals("max_true", maximum.sessionId)
|
||||
assertEquals(152.0, maximum.maxWeightKg, 0.0)
|
||||
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("UPDATE sessions SET started_at='2030-01-03 10:00:00Z' WHERE session_id='lower';")
|
||||
}
|
||||
assertTrue(runCatching { repository.listExerciseOccurrences(LEG_PRESS_ID, 1) }.isFailure)
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("UPDATE sessions SET started_at='2030-01-03T10:00:00Z' WHERE session_id='lower';")
|
||||
db.execSQL("UPDATE sessions SET started_at='bad-max' WHERE session_id='max_offset';")
|
||||
}
|
||||
assertTrue(runCatching { repository.getTrainingExerciseContext(LEG_PRESS_ID) }.isFailure)
|
||||
}
|
||||
|
||||
private fun seedFixture() {
|
||||
repository.listExercises()
|
||||
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
|
||||
db.execSQL("PRAGMA foreign_keys=ON;")
|
||||
db.execSQL("INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?,?,?,?,?,?);", arrayOf<Any?>(LEG_PRESS_ID, "Nom original", "nom original", "sets", "reps", 0))
|
||||
db.execSQL("INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(?,?,?,?,?,?);", arrayOf<Any?>(CONTINUOUS_ID, "Marche test", "marche test", "continuous", "duration", 3))
|
||||
db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'thighs','primary' FROM exercises WHERE exercise_id=?;", arrayOf(LEG_PRESS_ID))
|
||||
db.execSQL("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'glutes','secondary' FROM exercises WHERE exercise_id=?;", arrayOf(LEG_PRESS_ID))
|
||||
listOf(
|
||||
arrayOf<Any?>("session_latest", "2026-01-04T10:00:00+00:00", "training"),
|
||||
arrayOf<Any?>("session_tied", "2026-01-03T10:00:00+00:00", "training"),
|
||||
arrayOf<Any?>("session_max", "2026-01-02T10:00:00+00:00", "max_test"),
|
||||
arrayOf<Any?>("session_old", "2026-01-01T10:00:00+00:00", "training"),
|
||||
arrayOf<Any?>("session_cont", "2026-01-05T10:00:00+00:00", "training"),
|
||||
).forEach { db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?);", it) }
|
||||
fun occurrence(session: String, entry: String, position: Int, equipment: String? = null): Long {
|
||||
db.execSQL(
|
||||
"INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id) SELECT s.id,e.id,?,'sets','reps',0,eq.id,? FROM sessions s JOIN exercises e ON e.exercise_id=? LEFT JOIN equipment eq ON eq.equipment_id=? WHERE s.session_id=?;",
|
||||
arrayOf<Any?>(position, entry, LEG_PRESS_ID, equipment, session),
|
||||
)
|
||||
return db.rawQuery("SELECT id FROM session_exercises WHERE entry_id=?;", arrayOf(entry)).use { it.moveToFirst(); it.getLong(0) }
|
||||
}
|
||||
val latest = occurrence("session_latest", "entry_latest", 0, "leg_press")
|
||||
db.execSQL("INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,0,3,999.0);", arrayOf(latest))
|
||||
val a = occurrence("session_tied", "entry_a", 0, "assisted_dip_chin_machine")
|
||||
db.execSQL("INSERT INTO performed_sets(session_exercise_row_id,position,reps,weight_kg) VALUES(?,0,8,NULL),(?,1,7,0.0),(?,2,6,22.5);", arrayOf(a, a, a))
|
||||
occurrence("session_tied", "entry_b", 1, "plate_loaded_leg_press")
|
||||
val max = occurrence("session_max", "entry_max", 0, "assisted_dip_chin_machine")
|
||||
db.execSQL("INSERT INTO max_results(session_exercise_row_id,max_weight_kg) VALUES(?,140.0);", arrayOf(max))
|
||||
occurrence("session_old", "entry_old", 0)
|
||||
db.execSQL("INSERT INTO session_exercises(session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id) SELECT s.id,e.id,0,'continuous','duration',3,eq.id,'entry_cont' FROM sessions s JOIN exercises e ON e.exercise_id=? LEFT JOIN equipment eq ON eq.equipment_id='treadmill' WHERE s.session_id='session_cont';", arrayOf(CONTINUOUS_ID))
|
||||
db.execSQL("INSERT INTO continuous_activity(session_exercise_row_id,duration_seconds,speed_kmh,distance_km) SELECT id,1800,8.0,4.0 FROM session_exercises WHERE entry_id='entry_cont';")
|
||||
}
|
||||
val profile = repository.listExercises().first { it.exerciseId == LEG_PRESS_ID }
|
||||
val result = repository.editExercise(ExerciseEditInput(profile.exerciseId, "Nom modifiable", profile.recordingMode, profile.trackingMode, profile.dataFields, profile.primaryZoneId, profile.secondaryZoneIds))
|
||||
assertTrue(result is EditExerciseResult.Saved)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LEG_PRESS_ID = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"
|
||||
private const val CONTINUOUS_ID = "ex_00000000-0000-4000-8000-000000000001"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class TrainingKnowledgeCatalogTest {
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private val names = listOf(
|
||||
"science-references-v1.json", "muscles-v1.json", "joint-actions-v1.json",
|
||||
"movement-patterns-v1.json", "exercise-knowledge-v1.json", "equipment-knowledge-v1.json",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun loadsAllStructuredCatalogsAndRepresentativeMappings() {
|
||||
val catalog = TrainingKnowledgeCatalog.load(context)
|
||||
assertEquals(listOf(23, 53, 35, 26, 23, 41), listOf(catalog.references.size, catalog.muscles.size, catalog.jointActions.size, catalog.movementPatterns.size, catalog.exercises.size, catalog.equipment.size))
|
||||
val legPress = catalog.getExerciseKnowledge("ex_b432623f-bfe9-4daf-a653-60ec7fdffbde")
|
||||
assertEquals("knee_dominant", legPress?.interpretation?.patternIds?.single())
|
||||
assertTrue(catalog.listExercisesByMovementPattern("knee_dominant").any { it.exerciseId == legPress?.exerciseId })
|
||||
assertTrue(catalog.listExercisesByMuscle("quadriceps", MuscleRole.PRIMARY).any { it.exerciseId == legPress?.exerciseId })
|
||||
assertEquals("thighs", catalog.getScientificBodyZoneMapping(legPress!!.exerciseId)?.primaryZoneId)
|
||||
assertEquals(BodyZoneAuditStatus.CONFIRMED, legPress.bodyZoneAudit.status)
|
||||
assertEquals("thighs", legPress.bodyZoneAudit.existingPrimaryZoneId)
|
||||
assertTrue(legPress.bodyZoneAudit.sourceRefs.isNotEmpty())
|
||||
|
||||
val multifunction = catalog.getEquipmentKnowledge("rear_delt_pec_fly")
|
||||
assertEquals(2, multifunction?.capabilities?.size)
|
||||
assertTrue(catalog.listCompatibleExercises("rear_delt_pec_fly").any { it.exerciseId == "ex_4cd2433e-80b1-478a-b8df-73fc6ef80962" })
|
||||
assertNotNull(catalog.getMuscle("deltoid_posterior"))
|
||||
assertNotNull(catalog.getJointAction("shoulder_horizontal_abduction"))
|
||||
assertNotNull(catalog.getReference(legPress.sourceRefs.first()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun conditionalAndUnknownRecordsCannotLeakIntoOrdinaryQueries() {
|
||||
val catalog = TrainingKnowledgeCatalog.load(context)
|
||||
val chestPress = "ex_8552dd77-fcd7-4f06-a1cc-d956eb1009af"
|
||||
assertEquals(ExerciseKnowledgeStatus.CONDITIONAL, catalog.getConditionalExerciseKnowledge(chestPress)?.resolutionStatus)
|
||||
assertNull(catalog.getScientificBodyZoneMapping(chestPress))
|
||||
assertFalse(catalog.queryExercises(KnowledgeExerciseFilters()).any { it.exerciseId == chestPress })
|
||||
assertNull(catalog.getExerciseKnowledge("ex_00000000-0000-4000-8000-000000000000"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsAdditiveNonRuntimeKnowledgeRecord() {
|
||||
val files = assetFiles()
|
||||
val root = JSONObject(files.getValue("science-references-v1.json"))
|
||||
root.getJSONArray("references").put(
|
||||
JSONObject()
|
||||
.put("ref_id", "zz_additive_reference")
|
||||
.put("title", "Additive reference fixture")
|
||||
.put("authors_or_organization", "Trainlog test")
|
||||
.put("year", 2026)
|
||||
.put("type", "test_fixture")
|
||||
.put("url", "https://example.invalid/additive-reference")
|
||||
.put("topics", org.json.JSONArray().put("validation"))
|
||||
.put("notes", "Valid additive metadata record.")
|
||||
.put("limitations", "Test fixture only.")
|
||||
.put("doi", JSONObject.NULL)
|
||||
.put("pmid", JSONObject.NULL)
|
||||
.put("accessed_on", "2026-09-09"),
|
||||
)
|
||||
files["science-references-v1.json"] = root.toString()
|
||||
|
||||
val catalog = TrainingKnowledgeCatalog.load(files::getValue, BodyZoneCatalog.load(context))
|
||||
assertNotNull(catalog.getReference("zz_additive_reference"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsVersionEnumsDanglingReferencesAndDuplicateKeys() {
|
||||
assertInvalid { files -> files["muscles-v1.json"] = files.getValue("muscles-v1.json").replaceFirst("\"version\": 1", "\"version\": 2") }
|
||||
assertInvalid { files -> files["muscles-v1.json"] = files.getValue("muscles-v1.json").replaceFirst("\"entity_type\": \"muscle\"", "\"entity_type\": \"organ\"") }
|
||||
assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("exercise-knowledge-v1.json")); root.getJSONArray("exercises").getJSONObject(0).getJSONArray("source_refs").put("missing_ref")
|
||||
files["exercise-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
assertInvalid { files -> files["science-references-v1.json"] = files.getValue("science-references-v1.json").replaceFirst("{", "{\"version\":1,") }
|
||||
assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("exercise-knowledge-v1.json"))
|
||||
val legPress = root.getJSONArray("exercises").let { rows -> (0 until rows.length()).map(rows::getJSONObject).first { it.getString("exercise_id") == "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde" } }
|
||||
val equipment = legPress.getJSONArray("equipment_ids"); val first = equipment.getString(0)
|
||||
equipment.put(0, equipment.getString(1)); equipment.put(1, first)
|
||||
files["exercise-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("exercise-knowledge-v1.json")); val interpretation = root.getJSONArray("exercises").getJSONObject(0).getJSONObject("interpretation")
|
||||
interpretation.getJSONArray("secondary_muscle_ids").put(interpretation.getJSONArray("primary_muscle_ids").getString(0))
|
||||
files["exercise-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("equipment-knowledge-v1.json")); val equipment = root.getJSONArray("equipment").getJSONObject(0)
|
||||
equipment.put("confidence", "high"); equipment.put("evidence_type", "manufacturer_statement")
|
||||
files["equipment-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
assertInvalidAudit { it.getJSONArray("source_refs").put("missing_ref") }
|
||||
assertInvalidAudit { it.put("existing_primary_zone_id", "missing_zone") }
|
||||
assertInvalidAudit { it.put("status", "compatible") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsInvalidRuntimeIdentitySyntax() {
|
||||
assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("exercise-knowledge-v1.json"))
|
||||
val row = root.getJSONArray("exercises").getJSONObject(0)
|
||||
row.put("exercise_id", row.getString("exercise_id").dropLast(1) + "g")
|
||||
files["exercise-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("equipment-knowledge-v1.json"))
|
||||
val rows = root.getJSONArray("equipment")
|
||||
val row = rows.getJSONObject(0)
|
||||
val oldId = row.getString("equipment_id")
|
||||
val invalidId = "$oldId-"
|
||||
row.put("equipment_id", invalidId)
|
||||
val exercises = JSONObject(files.getValue("exercise-knowledge-v1.json"))
|
||||
val exerciseRows = exercises.getJSONArray("exercises")
|
||||
for (index in 0 until exerciseRows.length()) {
|
||||
val ids = exerciseRows.getJSONObject(index).getJSONArray("equipment_ids")
|
||||
for (idIndex in 0 until ids.length()) if (ids.getString(idIndex) == oldId) ids.put(idIndex, invalidId)
|
||||
}
|
||||
files["equipment-knowledge-v1.json"] = root.toString()
|
||||
files["exercise-knowledge-v1.json"] = exercises.toString()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsReverseCompatibilityHighEvidenceAndResolvedAuditGaps() {
|
||||
assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("exercise-knowledge-v1.json"))
|
||||
val rows = root.getJSONArray("exercises")
|
||||
val row = (0 until rows.length()).map(rows::getJSONObject).first { it.getJSONArray("equipment_ids").length() > 0 }
|
||||
row.put("equipment_ids", org.json.JSONArray())
|
||||
files["exercise-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
assertInvalid { files ->
|
||||
val references = JSONObject(files.getValue("science-references-v1.json")).getJSONArray("references")
|
||||
val nonScientificRef = (0 until references.length()).map(references::getJSONObject)
|
||||
.first { it.getString("type") !in setOf("established_anatomy", "emg_evidence", "intervention_evidence") }
|
||||
.getString("ref_id")
|
||||
val root = JSONObject(files.getValue("equipment-knowledge-v1.json"))
|
||||
val rows = root.getJSONArray("equipment")
|
||||
val row = rows.getJSONObject(0)
|
||||
row.put("confidence", "high")
|
||||
row.put("source_refs", org.json.JSONArray().put(nonScientificRef))
|
||||
row.put("evidence_type", "mixed_evidence")
|
||||
files["equipment-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
assertInvalidAudit { audit ->
|
||||
audit.put("source_refs", org.json.JSONArray())
|
||||
audit.put("status", "confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertInvalid(mutate: (MutableMap<String, String>) -> Unit) {
|
||||
val files = assetFiles()
|
||||
mutate(files)
|
||||
val failed = runCatching { TrainingKnowledgeCatalog.load(files::getValue, BodyZoneCatalog.load(context)) }.isFailure
|
||||
assertTrue("catalogue corrompu accepté", failed)
|
||||
}
|
||||
|
||||
private fun assetFiles(): MutableMap<String, String> = names.associateWith { name ->
|
||||
context.assets.open(name).bufferedReader().use { it.readText() }
|
||||
}.toMutableMap()
|
||||
|
||||
private fun assertInvalidAudit(mutate: (JSONObject) -> Unit) = assertInvalid { files ->
|
||||
val root = JSONObject(files.getValue("exercise-knowledge-v1.json"))
|
||||
mutate(root.getJSONArray("exercises").getJSONObject(0).getJSONObject("body_zone_audit"))
|
||||
files["exercise-knowledge-v1.json"] = root.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.labfytools.trainlog.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TrainlogTimestampTest {
|
||||
@Test fun acceptsSettledGrammarAndComparesEveryFractionDigit() {
|
||||
listOf(
|
||||
"0001-01-01T00:00Z", "2000-02-29t23:59:59.1z",
|
||||
"2026-09-05T18:34+23:59", "2026-09-05T18:34:12-00:00",
|
||||
"9999-12-31T23:59:59.9-23:59",
|
||||
).forEach { assertNotNull(it, TrainlogTimestamp.parse(it)) }
|
||||
val low = TrainlogTimestamp.parse("2026-01-01T00:00:00.12345678901234567890Z")!!
|
||||
val high = TrainlogTimestamp.parse("2026-01-01T00:00:00.12345678901234567891Z")!!
|
||||
val equal = TrainlogTimestamp.parse("2026-01-01T00:00:00.123456789012345678900Z")!!
|
||||
assertTrue(low < high)
|
||||
assertEquals(0, low.compareTo(equal))
|
||||
}
|
||||
|
||||
@Test fun rejectsPlatformOnlyAndOutOfRangeForms() {
|
||||
listOf(
|
||||
"0000-01-01T00:00:00Z", "2026-02-29T00:00:00Z",
|
||||
"2026-09-05 18:34:12+02:00", "20260905T183412+0200",
|
||||
"2026-W36-5T18:34:12+02:00", "2026-09-05T18:34:12,5+02:00",
|
||||
"2026-09-05T18:34:12+0200", "2026-09-05T18:34:12+02",
|
||||
"2026-09-05T18:34.5Z", "2026-09-05T18:34:60Z",
|
||||
"2026-09-05T18:34:12+24:00",
|
||||
).forEach { assertNull(it, TrainlogTimestamp.parse(it)) }
|
||||
}
|
||||
}
|
||||
2220
catalog/equipment-knowledge-v1.json
Normal file
2220
catalog/equipment-knowledge-v1.json
Normal file
File diff suppressed because it is too large
Load diff
1596
catalog/exercise-knowledge-v1.json
Normal file
1596
catalog/exercise-knowledge-v1.json
Normal file
File diff suppressed because it is too large
Load diff
808
catalog/joint-actions-v1.json
Normal file
808
catalog/joint-actions-v1.json
Normal file
|
|
@ -0,0 +1,808 @@
|
|||
{
|
||||
"format": "trainlog-joint-actions-v1",
|
||||
"version": 1,
|
||||
"joint_actions": [
|
||||
{
|
||||
"action_id": "ankle_dorsiflexion",
|
||||
"definition": "Bring dorsum of foot toward shin",
|
||||
"anatomical_region": "ankle",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Dorsiflexion de la cheville",
|
||||
"joint_complex": "talocrural_joint",
|
||||
"joint_or_complex": "talocrural_joint",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"tibialis_anterior"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "ankle_plantarflexion",
|
||||
"definition": "Point foot away from shin",
|
||||
"anatomical_region": "ankle",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion plantaire de la cheville",
|
||||
"joint_complex": "talocrural_joint",
|
||||
"joint_or_complex": "talocrural_joint",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"gastrocnemius",
|
||||
"soleus"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "elbow_extension",
|
||||
"definition": "Increase elbow angle",
|
||||
"anatomical_region": "elbow",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Extension du coude",
|
||||
"joint_complex": "humeroulnar_humeroradial_complex",
|
||||
"joint_or_complex": "humeroulnar_humeroradial_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"triceps_brachii"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "elbow_flexion",
|
||||
"definition": "Reduce elbow angle",
|
||||
"anatomical_region": "elbow",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion du coude",
|
||||
"joint_complex": "humeroulnar_humeroradial_complex",
|
||||
"joint_or_complex": "humeroulnar_humeroradial_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"biceps_brachii",
|
||||
"brachialis",
|
||||
"brachioradialis"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "finger_flexion",
|
||||
"definition": "Close fingers toward palm",
|
||||
"anatomical_region": "finger",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion des doigts",
|
||||
"joint_complex": "metacarpophalangeal_and_interphalangeal_joints",
|
||||
"joint_or_complex": "metacarpophalangeal_and_interphalangeal_joints",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"forearm_flexors"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "forearm_pronation",
|
||||
"definition": "Rotate forearm toward palm-back orientation",
|
||||
"anatomical_region": "forearm",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Pronation de l’avant-bras",
|
||||
"joint_complex": "proximal_and_distal_radioulnar_joints",
|
||||
"joint_or_complex": "proximal_and_distal_radioulnar_joints",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Axial rotation of forearm; spatial plane depends on forearm position.",
|
||||
"contributing_muscle_ids": [
|
||||
"forearm_pronators"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "forearm_supination",
|
||||
"definition": "Rotate forearm toward palm-forward orientation",
|
||||
"anatomical_region": "forearm",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Supination de l’avant-bras",
|
||||
"joint_complex": "proximal_and_distal_radioulnar_joints",
|
||||
"joint_or_complex": "proximal_and_distal_radioulnar_joints",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Axial rotation of forearm; spatial plane depends on forearm position.",
|
||||
"contributing_muscle_ids": [
|
||||
"biceps_brachii",
|
||||
"supinator"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "hip_abduction",
|
||||
"definition": "Move femur away from midline",
|
||||
"anatomical_region": "hip",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_gluteus_minimus",
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Abduction de la hanche",
|
||||
"joint_complex": "acetabulofemoral_joint",
|
||||
"joint_or_complex": "acetabulofemoral_joint",
|
||||
"principal_plane": "frontal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"gluteus_medius",
|
||||
"gluteus_minimus",
|
||||
"tensor_fasciae_latae"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "hip_adduction",
|
||||
"definition": "Move femur toward midline",
|
||||
"anatomical_region": "hip",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Adduction de la hanche",
|
||||
"joint_complex": "acetabulofemoral_joint",
|
||||
"joint_or_complex": "acetabulofemoral_joint",
|
||||
"principal_plane": "frontal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"adductor_magnus",
|
||||
"hip_adductors"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "hip_extension",
|
||||
"definition": "Move femur posteriorly or rise from flexion",
|
||||
"anatomical_region": "hip",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"maeo_2021",
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Extension de la hanche",
|
||||
"joint_complex": "acetabulofemoral_joint",
|
||||
"joint_or_complex": "acetabulofemoral_joint",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"adductor_magnus",
|
||||
"gluteus_maximus",
|
||||
"hamstrings_biarticular"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "hip_external_rotation",
|
||||
"definition": "Rotate femur outward",
|
||||
"anatomical_region": "hip",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Rotation externe de la hanche",
|
||||
"joint_complex": "acetabulofemoral_joint",
|
||||
"joint_or_complex": "acetabulofemoral_joint",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"gluteus_maximus"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "hip_flexion",
|
||||
"definition": "Bring femur toward anterior trunk",
|
||||
"anatomical_region": "hip",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_gluteus_minimus",
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion de la hanche",
|
||||
"joint_complex": "acetabulofemoral_joint",
|
||||
"joint_or_complex": "acetabulofemoral_joint",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"iliopsoas",
|
||||
"rectus_femoris",
|
||||
"tensor_fasciae_latae"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "hip_internal_rotation",
|
||||
"definition": "Rotate femur inward",
|
||||
"anatomical_region": "hip",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_gluteus_minimus",
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Rotation interne de la hanche",
|
||||
"joint_complex": "acetabulofemoral_joint",
|
||||
"joint_or_complex": "acetabulofemoral_joint",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"gluteus_minimus",
|
||||
"tensor_fasciae_latae"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "knee_extension",
|
||||
"definition": "Increase knee angle",
|
||||
"anatomical_region": "knee",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Extension du genou",
|
||||
"joint_complex": "tibiofemoral_complex",
|
||||
"joint_or_complex": "tibiofemoral_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"quadriceps",
|
||||
"rectus_femoris",
|
||||
"vasti"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "knee_flexion",
|
||||
"definition": "Reduce knee angle",
|
||||
"anatomical_region": "knee",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"maeo_2021",
|
||||
"openstax_actions",
|
||||
"openstax_lower"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion du genou",
|
||||
"joint_complex": "tibiofemoral_complex",
|
||||
"joint_or_complex": "tibiofemoral_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"biceps_femoris_short_head",
|
||||
"gastrocnemius",
|
||||
"hamstrings",
|
||||
"hamstrings_biarticular"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "scapular_depression",
|
||||
"definition": "Move scapula inferiorly",
|
||||
"anatomical_region": "scapular",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Scapular actions are scapulothoracic descriptions produced through coordinated shoulder-girdle articulations.",
|
||||
"display_name_fr": "Abaissement scapulaire",
|
||||
"joint_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"joint_or_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"principal_plane": "three_dimensional_scapular_motion",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"pectoralis_minor",
|
||||
"trapezius_lower"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "scapular_downward_rotation",
|
||||
"definition": "Rotate glenoid downward on return",
|
||||
"anatomical_region": "scapular",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Scapular actions are scapulothoracic descriptions produced through coordinated shoulder-girdle articulations.",
|
||||
"display_name_fr": "Rotation scapulaire vers le bas",
|
||||
"joint_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"joint_or_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"principal_plane": "three_dimensional_scapular_motion",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"rhomboids"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "scapular_elevation",
|
||||
"definition": "Move scapula superiorly",
|
||||
"anatomical_region": "scapular",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Scapular actions are scapulothoracic descriptions produced through coordinated shoulder-girdle articulations.",
|
||||
"display_name_fr": "Élévation scapulaire",
|
||||
"joint_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"joint_or_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"principal_plane": "three_dimensional_scapular_motion",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"trapezius_upper"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "scapular_protraction",
|
||||
"definition": "Move scapula anterolaterally around thorax",
|
||||
"anatomical_region": "scapular",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Scapular actions are scapulothoracic descriptions produced through coordinated shoulder-girdle articulations.",
|
||||
"display_name_fr": "Protraction scapulaire",
|
||||
"joint_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"joint_or_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"principal_plane": "three_dimensional_scapular_motion",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"pectoralis_minor",
|
||||
"serratus_anterior"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "scapular_retraction",
|
||||
"definition": "Move scapula toward vertebral column",
|
||||
"anatomical_region": "scapular",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Scapular actions are scapulothoracic descriptions produced through coordinated shoulder-girdle articulations.",
|
||||
"display_name_fr": "Rétraction scapulaire",
|
||||
"joint_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"joint_or_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"principal_plane": "three_dimensional_scapular_motion",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"rhomboids",
|
||||
"trapezius_middle"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "scapular_upward_rotation",
|
||||
"definition": "Rotate glenoid upward during arm elevation",
|
||||
"anatomical_region": "scapular",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Scapular actions are scapulothoracic descriptions produced through coordinated shoulder-girdle articulations.",
|
||||
"display_name_fr": "Rotation scapulaire vers le haut",
|
||||
"joint_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"joint_or_complex": "scapulothoracic_function_via_sternoclavicular_and_acromioclavicular_joints",
|
||||
"principal_plane": "three_dimensional_scapular_motion",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"serratus_anterior",
|
||||
"trapezius_lower",
|
||||
"trapezius_upper"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_abduction",
|
||||
"definition": "Move humerus away from trunk in frontal/scapular plane",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Abduction de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "frontal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"deltoid_middle",
|
||||
"supraspinatus"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_adduction",
|
||||
"definition": "Move humerus toward trunk",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_pectoralis",
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Adduction de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "frontal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"latissimus_dorsi",
|
||||
"pectoralis_major",
|
||||
"pectoralis_major_sternocostal",
|
||||
"teres_major"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_extension",
|
||||
"definition": "Move humerus posteriorly or return from flexion",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"nih_pectoralis",
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Extension de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"deltoid_posterior",
|
||||
"latissimus_dorsi",
|
||||
"pectoralis_major_sternocostal",
|
||||
"teres_major"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_external_rotation",
|
||||
"definition": "Rotate humerus outward about long axis",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Rotation externe de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"deltoid_posterior",
|
||||
"infraspinatus",
|
||||
"teres_minor"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_flexion",
|
||||
"definition": "Move humerus anteriorly/elevate forward relative to trunk",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"nih_pectoralis",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"deltoid_anterior",
|
||||
"pectoralis_major_clavicular"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_horizontal_abduction",
|
||||
"definition": "Move elevated humerus backward in transverse plane",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Abduction horizontale de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"deltoid_posterior"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_horizontal_adduction",
|
||||
"definition": "Bring elevated humerus across anterior trunk",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"nih_pectoralis",
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Adduction horizontale de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"deltoid_anterior",
|
||||
"pectoralis_major",
|
||||
"pectoralis_major_clavicular",
|
||||
"pectoralis_major_sternocostal"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "shoulder_internal_rotation",
|
||||
"definition": "Rotate humerus inward about long axis",
|
||||
"anatomical_region": "shoulder",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"nih_pectoralis",
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Rotation interne de l’épaule",
|
||||
"joint_complex": "glenohumeral_joint",
|
||||
"joint_or_complex": "glenohumeral_joint",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"deltoid_anterior",
|
||||
"latissimus_dorsi",
|
||||
"pectoralis_major",
|
||||
"subscapularis",
|
||||
"teres_major"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "trunk_extension",
|
||||
"definition": "Extend vertebral column; not synonymous with hip extension",
|
||||
"anatomical_region": "trunk",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_back"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Extension du tronc",
|
||||
"joint_complex": "intervertebral_column_complex",
|
||||
"joint_or_complex": "intervertebral_column_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"erector_spinae",
|
||||
"multifidus"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "trunk_flexion",
|
||||
"definition": "Flex vertebral column; not synonymous with hip flexion",
|
||||
"anatomical_region": "trunk",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_abdominal_wall",
|
||||
"openstax_actions",
|
||||
"openstax_trunk"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion du tronc",
|
||||
"joint_complex": "intervertebral_column_complex",
|
||||
"joint_or_complex": "intervertebral_column_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"external_oblique",
|
||||
"internal_oblique",
|
||||
"rectus_abdominis"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "trunk_lateral_flexion",
|
||||
"definition": "Bend vertebral column sideways",
|
||||
"anatomical_region": "trunk",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_abdominal_wall",
|
||||
"openstax_actions",
|
||||
"openstax_back",
|
||||
"openstax_trunk"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Inclinaison latérale du tronc",
|
||||
"joint_complex": "intervertebral_column_complex",
|
||||
"joint_or_complex": "intervertebral_column_complex",
|
||||
"principal_plane": "frontal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"erector_spinae",
|
||||
"external_oblique",
|
||||
"internal_oblique",
|
||||
"quadratus_lumborum"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "trunk_rotation",
|
||||
"definition": "Rotate thorax and pelvis relative to each other",
|
||||
"anatomical_region": "trunk",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"nih_abdominal_wall",
|
||||
"openstax_actions",
|
||||
"openstax_back",
|
||||
"openstax_trunk"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Rotation du tronc",
|
||||
"joint_complex": "intervertebral_column_complex",
|
||||
"joint_or_complex": "intervertebral_column_complex",
|
||||
"principal_plane": "transverse",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"external_oblique",
|
||||
"internal_oblique",
|
||||
"multifidus"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "wrist_extension",
|
||||
"definition": "Bend dorsum of hand toward posterior forearm",
|
||||
"anatomical_region": "wrist",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Extension du poignet",
|
||||
"joint_complex": "radiocarpal_midcarpal_complex",
|
||||
"joint_or_complex": "radiocarpal_midcarpal_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"forearm_extensors"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
},
|
||||
{
|
||||
"action_id": "wrist_flexion",
|
||||
"definition": "Bend palm toward anterior forearm",
|
||||
"anatomical_region": "wrist",
|
||||
"evidence_type": "established_anatomy",
|
||||
"confidence": "high",
|
||||
"source_refs": [
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Opposite motion in a loaded return may be controlled eccentrically by the same agonists.",
|
||||
"display_name_fr": "Flexion du poignet",
|
||||
"joint_complex": "radiocarpal_midcarpal_complex",
|
||||
"joint_or_complex": "radiocarpal_midcarpal_complex",
|
||||
"principal_plane": "sagittal",
|
||||
"plane_notes": "Nominal anatomical plane; actual exercise can combine planes. Scapular kinematics are three-dimensional.",
|
||||
"contributing_muscle_ids": [
|
||||
"forearm_flexors"
|
||||
],
|
||||
"contributor_semantics": "Non-exhaustive anatomical capability list; not a primary-role list or inverse EMG ranking. Aggregate/member overlap retained explicitly."
|
||||
}
|
||||
]
|
||||
}
|
||||
538
catalog/movement-patterns-v1.json
Normal file
538
catalog/movement-patterns-v1.json
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
{
|
||||
"format": "trainlog-movement-patterns-v1",
|
||||
"version": 1,
|
||||
"movement_patterns": [
|
||||
{
|
||||
"pattern_id": "cyclic_lower_limb",
|
||||
"definition": "Repeated lower-limb propulsion such as cycling; not identical to gait.",
|
||||
"typical_action_ids": [
|
||||
"hip_extension",
|
||||
"hip_flexion",
|
||||
"knee_extension",
|
||||
"knee_flexion"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Propulsion cyclique des membres inférieurs",
|
||||
"typical_body_zone_ids": [
|
||||
"calves",
|
||||
"glutes",
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "cyclic_rowing",
|
||||
"definition": "Repeated leg drive, trunk control and arm pull; cardio rower differs from seated resistance row.",
|
||||
"typical_action_ids": [
|
||||
"elbow_flexion",
|
||||
"hip_extension",
|
||||
"knee_extension",
|
||||
"shoulder_extension"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Rame cyclique",
|
||||
"typical_body_zone_ids": [
|
||||
"arms",
|
||||
"back",
|
||||
"core",
|
||||
"glutes",
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "hip_dominant",
|
||||
"definition": "Multi-joint task organized around hip extension with comparatively restrained knee excursion.",
|
||||
"typical_action_ids": [
|
||||
"hip_extension"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Mouvement à dominante hanche",
|
||||
"typical_body_zone_ids": [
|
||||
"glutes",
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "horizontal_pull",
|
||||
"definition": "Pull toward torso with shoulder extension/horizontal abduction and elbow flexion.",
|
||||
"typical_action_ids": [
|
||||
"elbow_flexion",
|
||||
"scapular_retraction",
|
||||
"shoulder_extension",
|
||||
"shoulder_horizontal_abduction"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Tirage horizontal",
|
||||
"typical_body_zone_ids": [
|
||||
"arms",
|
||||
"back"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "horizontal_push",
|
||||
"definition": "Press resistance away anterior to torso with coordinated shoulder and elbow movement.",
|
||||
"typical_action_ids": [
|
||||
"elbow_extension",
|
||||
"shoulder_horizontal_adduction"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Poussée horizontale",
|
||||
"typical_body_zone_ids": [
|
||||
"arms",
|
||||
"chest",
|
||||
"shoulders"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "knee_dominant",
|
||||
"definition": "Multi-joint lower-limb task with substantial knee-extension demand; hip extension remains involved.",
|
||||
"typical_action_ids": [
|
||||
"hip_extension",
|
||||
"knee_extension"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Mouvement à dominante genou",
|
||||
"typical_body_zone_ids": [
|
||||
"glutes",
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "locomotion",
|
||||
"definition": "Repeated gait cycles with support and progression; treadmill belt can replace overground translation.",
|
||||
"typical_action_ids": [
|
||||
"ankle_dorsiflexion",
|
||||
"ankle_plantarflexion",
|
||||
"hip_extension",
|
||||
"hip_flexion",
|
||||
"knee_extension",
|
||||
"knee_flexion"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Locomotion",
|
||||
"typical_body_zone_ids": [
|
||||
"calves",
|
||||
"glutes",
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "shoulder_abduction",
|
||||
"display_name_fr": "Abduction de l’épaule",
|
||||
"definition": "Raise humerus laterally or in scapular plane against resistance; lateral-raise family. Scapular upward rotation accompanies larger elevation.",
|
||||
"typical_action_ids": [
|
||||
"scapular_upward_rotation",
|
||||
"shoulder_abduction"
|
||||
],
|
||||
"typical_body_zone_ids": [
|
||||
"shoulders"
|
||||
],
|
||||
"parent_pattern_id": null,
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"nih_deltoid",
|
||||
"openstax_actions",
|
||||
"openstax_upper"
|
||||
],
|
||||
"notes": "Explicit authored task convention; anti-motion is a demand, not a new joint action or claim of muscle isolation."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_ankle_plantarflexion",
|
||||
"definition": "Predominant resisted ankle plantarflexion.",
|
||||
"typical_action_ids": [
|
||||
"ankle_plantarflexion"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Flexion plantaire",
|
||||
"typical_body_zone_ids": [
|
||||
"calves"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_elbow_extension",
|
||||
"definition": "Predominant resisted elbow extension.",
|
||||
"typical_action_ids": [
|
||||
"elbow_extension"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Extension isolée du coude",
|
||||
"typical_body_zone_ids": [
|
||||
"arms"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_elbow_flexion",
|
||||
"definition": "Predominant resisted elbow flexion.",
|
||||
"typical_action_ids": [
|
||||
"elbow_flexion"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Flexion isolée du coude",
|
||||
"typical_body_zone_ids": [
|
||||
"arms"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_hip_abduction",
|
||||
"definition": "Predominant resisted hip abduction.",
|
||||
"typical_action_ids": [
|
||||
"hip_abduction"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Abduction isolée de hanche",
|
||||
"typical_body_zone_ids": [
|
||||
"glutes"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_hip_adduction",
|
||||
"definition": "Predominant resisted hip adduction.",
|
||||
"typical_action_ids": [
|
||||
"hip_adduction"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Adduction isolée de hanche",
|
||||
"typical_body_zone_ids": [
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_knee_extension",
|
||||
"definition": "Predominant resisted knee extension.",
|
||||
"typical_action_ids": [
|
||||
"knee_extension"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Extension isolée du genou",
|
||||
"typical_body_zone_ids": [
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_knee_flexion",
|
||||
"definition": "Predominant resisted knee flexion.",
|
||||
"typical_action_ids": [
|
||||
"knee_flexion"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Flexion isolée du genou",
|
||||
"typical_body_zone_ids": [
|
||||
"thighs"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_shoulder_horizontal_abduction",
|
||||
"definition": "Reverse-fly humeral horizontal abduction with approximately fixed elbow.",
|
||||
"typical_action_ids": [
|
||||
"shoulder_horizontal_abduction"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Écarté inversé",
|
||||
"typical_body_zone_ids": [
|
||||
"back",
|
||||
"shoulders"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "single_joint_shoulder_horizontal_adduction",
|
||||
"definition": "Fly-like humeral adduction across torso with approximately fixed elbow.",
|
||||
"typical_action_ids": [
|
||||
"shoulder_horizontal_adduction"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Écarté pectoral",
|
||||
"typical_body_zone_ids": [
|
||||
"chest",
|
||||
"shoulders"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "trunk_anti_extension",
|
||||
"display_name_fr": "Résistance à l’extension du tronc",
|
||||
"definition": "Resist an external trunk-extension moment while maintaining intended spinal orientation. No dynamic joint action is required.",
|
||||
"typical_action_ids": [],
|
||||
"typical_body_zone_ids": [
|
||||
"core"
|
||||
],
|
||||
"parent_pattern_id": "trunk_stabilization",
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"nih_abdominal_wall",
|
||||
"openstax_actions",
|
||||
"openstax_back"
|
||||
],
|
||||
"notes": "Explicit authored task convention; anti-motion is a demand, not a new joint action or claim of muscle isolation."
|
||||
},
|
||||
{
|
||||
"pattern_id": "trunk_anti_rotation",
|
||||
"display_name_fr": "Résistance à la rotation du tronc",
|
||||
"definition": "Resist an external rotational moment between thorax and pelvis while maintaining intended orientation.",
|
||||
"typical_action_ids": [],
|
||||
"typical_body_zone_ids": [
|
||||
"core"
|
||||
],
|
||||
"parent_pattern_id": "trunk_stabilization",
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"nih_abdominal_wall",
|
||||
"openstax_actions",
|
||||
"openstax_back"
|
||||
],
|
||||
"notes": "Explicit authored task convention; anti-motion is a demand, not a new joint action or claim of muscle isolation."
|
||||
},
|
||||
{
|
||||
"pattern_id": "trunk_extension",
|
||||
"definition": "Dynamic resisted spinal extension.",
|
||||
"typical_action_ids": [
|
||||
"trunk_extension"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Extension du tronc",
|
||||
"typical_body_zone_ids": [
|
||||
"back",
|
||||
"core"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "trunk_flexion",
|
||||
"definition": "Dynamic resisted spinal flexion.",
|
||||
"typical_action_ids": [
|
||||
"trunk_flexion"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Flexion du tronc",
|
||||
"typical_body_zone_ids": [
|
||||
"core"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "trunk_lateral_stability",
|
||||
"display_name_fr": "Stabilité latérale du tronc",
|
||||
"definition": "Resist an external lateral-flexion moment while maintaining trunk orientation; not dynamic side bending.",
|
||||
"typical_action_ids": [],
|
||||
"typical_body_zone_ids": [
|
||||
"back",
|
||||
"core"
|
||||
],
|
||||
"parent_pattern_id": "trunk_stabilization",
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"nih_abdominal_wall",
|
||||
"openstax_actions",
|
||||
"openstax_back"
|
||||
],
|
||||
"notes": "Explicit authored task convention; anti-motion is a demand, not a new joint action or claim of muscle isolation."
|
||||
},
|
||||
{
|
||||
"pattern_id": "trunk_rotation",
|
||||
"definition": "Dynamic resisted thorax-pelvis rotation.",
|
||||
"typical_action_ids": [
|
||||
"trunk_rotation"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Rotation du tronc",
|
||||
"typical_body_zone_ids": [
|
||||
"core"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "trunk_stabilization",
|
||||
"definition": "Maintain trunk orientation against external moments; anti-extension, anti-rotation and anti-lateral-flexion are demands, not anatomical joint motions.",
|
||||
"typical_action_ids": [],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Stabilisation du tronc",
|
||||
"typical_body_zone_ids": [
|
||||
"back",
|
||||
"core"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "vertical_pull",
|
||||
"definition": "Pull from overhead toward torso.",
|
||||
"typical_action_ids": [
|
||||
"elbow_flexion",
|
||||
"shoulder_adduction",
|
||||
"shoulder_extension"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Tirage vertical",
|
||||
"typical_body_zone_ids": [
|
||||
"arms",
|
||||
"back"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
},
|
||||
{
|
||||
"pattern_id": "vertical_push",
|
||||
"definition": "Press overhead relative to torso with humeral elevation and elbow extension.",
|
||||
"typical_action_ids": [
|
||||
"elbow_extension",
|
||||
"scapular_upward_rotation",
|
||||
"shoulder_abduction",
|
||||
"shoulder_flexion"
|
||||
],
|
||||
"evidence_type": "practical_inference",
|
||||
"confidence": "moderate",
|
||||
"source_refs": [
|
||||
"acsm_2009",
|
||||
"openstax_actions"
|
||||
],
|
||||
"notes": "Authored classification convention; typical actions are not mandatory in every variant and single-joint does not mean one muscle or zero stabilization.",
|
||||
"display_name_fr": "Poussée verticale",
|
||||
"typical_body_zone_ids": [
|
||||
"arms",
|
||||
"shoulders"
|
||||
],
|
||||
"body_zone_semantics": "Descriptive common participation, not a persisted exercise mapping or requirement that every listed zone be assigned."
|
||||
}
|
||||
]
|
||||
}
|
||||
1509
catalog/muscles-v1.json
Normal file
1509
catalog/muscles-v1.json
Normal file
File diff suppressed because it is too large
Load diff
407
catalog/science-references-v1.json
Normal file
407
catalog/science-references-v1.json
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
{
|
||||
"format": "trainlog-science-references-v1",
|
||||
"version": 1,
|
||||
"references": [
|
||||
{
|
||||
"ref_id": "acsm_2009",
|
||||
"title": "American College of Sports Medicine position stand. Progression models in resistance training for healthy adults.",
|
||||
"year": 2009,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/19204579/",
|
||||
"doi": "10.1249/mss.0b013e3181915670",
|
||||
"pmid": "19204579",
|
||||
"topics": [
|
||||
"progression",
|
||||
"specificity",
|
||||
"programming"
|
||||
],
|
||||
"notes": "Progressive overload and specificity organize resistance-training progression; adjustment should follow achieved performance and goals.",
|
||||
"limitations": "Historical professional position stand; contemporary synthesis above takes precedence for comparative outcomes. No numeric recommendations adopted.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "American College of Sports Medicine.",
|
||||
"type": "practical_inference"
|
||||
},
|
||||
{
|
||||
"ref_id": "currier_2023",
|
||||
"title": "Resistance training prescription for muscle strength and hypertrophy in healthy adults: a systematic review and Bayesian network meta-analysis.",
|
||||
"year": 2023,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/37414459/",
|
||||
"doi": "10.1136/bjsports-2023-106807",
|
||||
"pmid": "37414459",
|
||||
"topics": [
|
||||
"programming",
|
||||
"load",
|
||||
"sets",
|
||||
"frequency"
|
||||
],
|
||||
"notes": "Across reviewed adult trials, resistance training improved strength and hypertrophy; heavier loads ranked better for strength, and multiple sets characterized higher-ranked hypertrophy programs.",
|
||||
"limitations": "Network rankings are not individual prescriptions or proof of one universally optimal combination.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Currier BS, Mcleod JC, Banfield L, Beyene J, Welton NJ, D'Souza AC, Keogh JAJ, Lin L, Coletta G, Yang A, Colenso-Semple L, Lau KJ, Verboom A, Phillips SM.",
|
||||
"type": "intervention_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "franke_2015",
|
||||
"title": "Analysis of anterior, middle and posterior deltoid activation during single and multijoint exercises.",
|
||||
"year": 2015,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/24947920/",
|
||||
"doi": null,
|
||||
"pmid": "24947920",
|
||||
"topics": [
|
||||
"rear_delt",
|
||||
"rows"
|
||||
],
|
||||
"notes": "Reverse pec-deck elicited greater posterior-deltoid surface EMG than the studied row/pulldown tasks.",
|
||||
"limitations": "Twelve trained men; selected exercises/techniques only; no longitudinal hypertrophy comparison; no DOI reported in indexed record.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"publication_note": "Print 2015; online indexed June 2014.",
|
||||
"authors_or_organization": "Franke Rde A, Botton CE, Rodrigues R, Pinto RS, Lima CS.",
|
||||
"type": "emg_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "grgic_2022",
|
||||
"title": "Effects of resistance training performed to repetition failure or non-failure on muscular strength and hypertrophy: A systematic review and meta-analysis.",
|
||||
"year": 2022,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/33497853/",
|
||||
"doi": "10.1016/j.jshs.2021.01.007",
|
||||
"pmid": "33497853",
|
||||
"topics": [
|
||||
"failure",
|
||||
"strength",
|
||||
"hypertrophy"
|
||||
],
|
||||
"notes": "Failure was not generally necessary for strength or hypertrophy gains in included comparisons.",
|
||||
"limitations": "Nonfailure effort and volume differ between studies; this does not imply arbitrary easy sets equal hard sets.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Grgic J, Schoenfeld BJ, Orazem J, Sabol F.",
|
||||
"type": "intervention_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "kassiano_2022",
|
||||
"title": "Does Varying Resistance Exercises Promote Superior Muscle Hypertrophy and Strength Gains? A Systematic Review.",
|
||||
"year": 2022,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/35438660/",
|
||||
"doi": "10.1519/jsc.0000000000004258",
|
||||
"pmid": "35438660",
|
||||
"topics": [
|
||||
"selection",
|
||||
"variation"
|
||||
],
|
||||
"notes": "Systematic exercise variation may distribute regional stimulus and support task-specific strength; excessive random variation lacks support.",
|
||||
"limitations": "Eight studies, all young men; limited generalizability and no universal rotation schedule.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Kassiano W, Nunes JP, Costa B, Ribeiro AS, Schoenfeld BJ, Cyrino ES.",
|
||||
"type": "intervention_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "lee_2015",
|
||||
"title": "Enhanced muscle activity during lumbar extension exercise with pelvic stabilization.",
|
||||
"year": 2015,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/26730390/",
|
||||
"doi": "10.12965/jer.150249",
|
||||
"pmid": "26730390",
|
||||
"topics": [
|
||||
"back_extension",
|
||||
"pelvic_stabilization"
|
||||
],
|
||||
"notes": "Pelvic stabilization altered lumbar-extensor excitation during extension testing.",
|
||||
"limitations": "Acute small study; equipment restraint and execution matter; no claim of clinical benefit or universal isolation.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Lee HS.",
|
||||
"type": "emg_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "lehman_2004",
|
||||
"title": "Variations in muscle activation levels during traditional latissimus dorsi weight training exercises: An experimental study.",
|
||||
"year": 2004,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/15228624/",
|
||||
"doi": "10.1186/1476-5918-3-4",
|
||||
"pmid": "15228624",
|
||||
"topics": [
|
||||
"lat_pulldown",
|
||||
"seated_row"
|
||||
],
|
||||
"notes": "Measured latissimus, elbow-flexor and scapular-muscle activity differed across studied pulldown/row tasks.",
|
||||
"limitations": "Isometric portions and surface recordings; no mapping of all divergent machine trajectories or outcome superiority.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Lehman GJ, Buchan DD, Lundy A, Myers N, Nalborczyk A.",
|
||||
"type": "emg_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "life_fitness_catalog_2024",
|
||||
"title": "Life Fitness commercial product catalogue 2024",
|
||||
"year": 2024,
|
||||
"url": "https://www.lifefitness.com.au/wp-content/uploads/2024/02/Life-Fitness-Catalogue_2024_web.pdf",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"multifunction",
|
||||
"equipment_identity"
|
||||
],
|
||||
"notes": "Catalogue lists distinct Pectoral Fly/Rear Deltoid and Assist Dip Chin combination products.",
|
||||
"limitations": "EXAMPLE ONLY: establishes available equipment concepts, not manufacturer/model identification in the observed gym or anatomical efficacy.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Life Fitness",
|
||||
"type": "manufacturer_statement"
|
||||
},
|
||||
{
|
||||
"ref_id": "maeo_2021",
|
||||
"title": "Greater Hamstrings Muscle Hypertrophy but Similar Damage Protection after Training at Long versus Short Muscle Lengths.",
|
||||
"year": 2021,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/33009197/",
|
||||
"doi": "10.1249/mss.0000000000002523",
|
||||
"pmid": "33009197",
|
||||
"topics": [
|
||||
"hamstrings",
|
||||
"seated_leg_curl",
|
||||
"prone_leg_curl"
|
||||
],
|
||||
"notes": "Within-person training found greater biarticular hamstring growth with seated than prone curls; both variants trained knee flexion.",
|
||||
"limitations": "One protocol/population; not a guarantee for every machine, person, muscle region or strength task.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Maeo S, Huang M, Wu Y, Sakurai H, Kusagawa Y, Sugiyama T, Kanehisa H, Isaka T.",
|
||||
"type": "intervention_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "martin_fuentes_2020",
|
||||
"title": "Evaluation of the Lower Limb Muscles' Electromyographic Activity during the Leg Press Exercise and Its Variants: A Systematic Review.",
|
||||
"year": 2020,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/32605065/",
|
||||
"doi": "10.3390/ijerph17134626",
|
||||
"pmid": "32605065",
|
||||
"topics": [
|
||||
"leg_press"
|
||||
],
|
||||
"notes": "Leg-press studies report substantial quadriceps excitation; variant findings are inconsistent.",
|
||||
"limitations": "EMG review does not establish universal foot-position targeting, force shares or hypertrophy ranking.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Martín-Fuentes I, Oliva-Lozano JM, Muyor JM.",
|
||||
"type": "emg_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "nih_abdominal_wall",
|
||||
"title": "Anatomy, Abdomen and Pelvis: Anterolateral Abdominal Wall",
|
||||
"year": 2023,
|
||||
"url": "https://www.ncbi.nlm.nih.gov/books/NBK525975/",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"core",
|
||||
"trunk"
|
||||
],
|
||||
"notes": "Rectus abdominis, internal/external obliques and transversus have movement, abdominal-wall tension and stabilization roles.",
|
||||
"limitations": "Functional anatomy only; clinical sections are outside this handoff scope.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Kevin Seeras; Ryan N. Qasawa; Ricky Ju; Shivana Prakash",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "nih_deltoid",
|
||||
"title": "Anatomy, Shoulder and Upper Limb, Deltoid Muscle",
|
||||
"year": 2024,
|
||||
"url": "https://www.ncbi.nlm.nih.gov/books/NBK537056/",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"deltoids",
|
||||
"shoulder"
|
||||
],
|
||||
"notes": "Anterior, middle and posterior portions have distinct lines of action; relative contribution depends on arm position.",
|
||||
"limitations": "Functional anatomy only; clinical sections are outside this handoff scope.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Adel Elzanie; Matthew A. Varacallo",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "nih_gluteus_minimus",
|
||||
"title": "Anatomy, Bony Pelvis and Lower Limb, Gluteus Minimus Muscle",
|
||||
"year": 2023,
|
||||
"url": "https://www.ncbi.nlm.nih.gov/books/NBK556144/",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"hip_abduction",
|
||||
"glutes",
|
||||
"gait"
|
||||
],
|
||||
"notes": "Minimus abducts and stabilizes the hip; anterior fibers support internal rotation. Prevents erroneous adduction classification from textbook alternative text.",
|
||||
"limitations": "Functional anatomy only; clinical sections are outside this handoff scope.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Anthony J. Greco; Renato C. Vilella",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "nih_pectoralis",
|
||||
"title": "Anatomy, Thorax, Pectoralis Major Major",
|
||||
"year": 2023,
|
||||
"url": "https://www.ncbi.nlm.nih.gov/books/NBK525991/",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"chest",
|
||||
"shoulder"
|
||||
],
|
||||
"notes": "Clavicular and sternocostal parts share shoulder adduction/internal rotation; clavicular fibers support flexion, sternocostal fibers extension from flexion. Regional anatomy does not prove isolated regional training.",
|
||||
"limitations": "Functional anatomy only; clinical sections are outside this handoff scope.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Francesca Solari; Bracken Burns",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "openstax_actions",
|
||||
"title": "Anatomy and Physiology 2e: Types of Body Movements",
|
||||
"year": 2022,
|
||||
"url": "https://openstax.org/books/anatomy-and-physiology-2e/pages/9-5-types-of-body-movements",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"joint_actions"
|
||||
],
|
||||
"notes": "Terminology of joint movements. Pattern labels in Trainlog remain authored programming abstractions.",
|
||||
"limitations": "Textbook synthesis; not a machine-specific force or adaptation study.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "J. Gordon Betts; Kelly A. Young; James A. Wise; Eddie Johnson; Brandon Poe; Dean H. Kruse; Oksana Korol; Jody E. Johnson; Mark Womble; Peter DeSaix",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "openstax_back",
|
||||
"title": "Anatomy and Physiology 2e: Axial Muscles of the Head, Neck, and Back",
|
||||
"year": 2022,
|
||||
"url": "https://openstax.org/books/anatomy-and-physiology-2e/pages/11-3-axial-muscles-of-the-head-neck-and-back",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"spinal_extensors",
|
||||
"multifidus"
|
||||
],
|
||||
"notes": "Vertebral-column muscle functions including erector spinae and deep posterior muscles.",
|
||||
"limitations": "Textbook synthesis; not a machine-specific force or adaptation study.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "J. Gordon Betts; Kelly A. Young; James A. Wise; Eddie Johnson; Brandon Poe; Dean H. Kruse; Oksana Korol; Jody E. Johnson; Mark Womble; Peter DeSaix",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "openstax_lower",
|
||||
"title": "Anatomy and Physiology 2e: Appendicular Muscles of the Pelvic Girdle and Lower Limbs",
|
||||
"year": 2022,
|
||||
"url": "https://openstax.org/books/anatomy-and-physiology-2e/pages/11-6-appendicular-muscles-of-the-pelvic-girdle-and-lower-limbs",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"hip",
|
||||
"knee",
|
||||
"ankle"
|
||||
],
|
||||
"notes": "Functional lower-limb anatomy. Figure alternative text contains direction inconsistencies for minimus/pectineus/gracilis; use action definitions and NIH corroboration, not those phrases.",
|
||||
"limitations": "Textbook synthesis; not a machine-specific force or adaptation study.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "J. Gordon Betts; Kelly A. Young; James A. Wise; Eddie Johnson; Brandon Poe; Dean H. Kruse; Oksana Korol; Jody E. Johnson; Mark Womble; Peter DeSaix",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "openstax_trunk",
|
||||
"title": "Anatomy and Physiology 2e: Axial Muscles of the Abdominal Wall and Thorax",
|
||||
"year": 2022,
|
||||
"url": "https://openstax.org/books/anatomy-and-physiology-2e/pages/11-4-axial-muscles-of-the-abdominal-wall-and-thorax",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"trunk",
|
||||
"abdominals",
|
||||
"spinal_extensors"
|
||||
],
|
||||
"notes": "Trunk movement, abdominal compression and postural roles.",
|
||||
"limitations": "Textbook synthesis; not a machine-specific force or adaptation study.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "J. Gordon Betts; Kelly A. Young; James A. Wise; Eddie Johnson; Brandon Poe; Dean H. Kruse; Oksana Korol; Jody E. Johnson; Mark Womble; Peter DeSaix",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "openstax_upper",
|
||||
"title": "Anatomy and Physiology 2e: Muscles of the Pectoral Girdle and Upper Limbs",
|
||||
"year": 2022,
|
||||
"url": "https://openstax.org/books/anatomy-and-physiology-2e/pages/11-5-muscles-of-the-pectoral-girdle-and-upper-limbs",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"shoulder",
|
||||
"scapula",
|
||||
"elbow",
|
||||
"forearm"
|
||||
],
|
||||
"notes": "Functional anatomy of upper-limb and scapular muscles; no exercise outcome ranking.",
|
||||
"limitations": "Textbook synthesis; not a machine-specific force or adaptation study.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "J. Gordon Betts; Kelly A. Young; James A. Wise; Eddie Johnson; Brandon Poe; Dean H. Kruse; Oksana Korol; Jody E. Johnson; Mark Womble; Peter DeSaix",
|
||||
"type": "established_anatomy"
|
||||
},
|
||||
{
|
||||
"ref_id": "precor_pulley_example",
|
||||
"title": "Resolute Dual Adjustable Pulley RUD0915",
|
||||
"year": null,
|
||||
"url": "https://www.precor.com/en-US/products/RUD0915",
|
||||
"doi": null,
|
||||
"pmid": null,
|
||||
"topics": [
|
||||
"cable",
|
||||
"load_context"
|
||||
],
|
||||
"notes": "Manufacturer documents a 4:1 cable ratio on this particular example, demonstrating why stack labels and handle resistance differ.",
|
||||
"limitations": "EXAMPLE ONLY: not evidence that the user owns Precor or this model; local pulley ratios remain unresolved.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Precor",
|
||||
"type": "manufacturer_statement"
|
||||
},
|
||||
{
|
||||
"ref_id": "refalo_2023",
|
||||
"title": "Influence of Resistance Training Proximity-to-Failure on Skeletal Muscle Hypertrophy: A Systematic Review with Meta-analysis.",
|
||||
"year": 2023,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/36334240/",
|
||||
"doi": "10.1007/s40279-022-01784-y",
|
||||
"pmid": "36334240",
|
||||
"topics": [
|
||||
"proximity_to_failure"
|
||||
],
|
||||
"notes": "Failure definitions matter; available categorical comparisons do not establish a simple more-failure-is-better hypertrophy rule.",
|
||||
"limitations": "Literature search predates newer trials; uncertainty in actual repetitions in reserve prevents exact universal thresholds.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Refalo MC, Helms ER, Trexler ET, Hamilton DL, Fyfe JJ.",
|
||||
"type": "intervention_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "vieira_2022",
|
||||
"title": "Effects of Resistance Training to Muscle Failure on Acute Fatigue: A Systematic Review and Meta-Analysis.",
|
||||
"year": 2022,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/34881412/",
|
||||
"doi": "10.1007/s40279-021-01602-x",
|
||||
"pmid": "34881412",
|
||||
"topics": [
|
||||
"fatigue",
|
||||
"recovery",
|
||||
"failure"
|
||||
],
|
||||
"notes": "Failure training generated greater acute fatigue than nonfailure conditions in the reviewed experiments.",
|
||||
"limitations": "Acute markers do not establish a universal recovery duration or predict individual long-term adaptation.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"authors_or_organization": "Vieira JG, Sardeli AV, Dias MR, Filho JE, Campos Y, Sant'Ana L, Leitão L, Reis V, Wilk M, Novaes J, Vianna J.",
|
||||
"type": "intervention_evidence"
|
||||
},
|
||||
{
|
||||
"ref_id": "vigotsky_2018",
|
||||
"title": "Interpreting Signal Amplitudes in Surface Electromyography Studies in Sport and Rehabilitation Sciences.",
|
||||
"year": 2017,
|
||||
"url": "https://pubmed.ncbi.nlm.nih.gov/29354060/",
|
||||
"doi": "10.3389/fphys.2017.00985",
|
||||
"pmid": "29354060",
|
||||
"topics": [
|
||||
"measurement",
|
||||
"emg"
|
||||
],
|
||||
"notes": "Surface EMG amplitude reflects a recording-dependent excitation signal and cannot directly rank hypertrophy, force or training effectiveness.",
|
||||
"limitations": "Methodological review; anatomical inference and longitudinal outcomes require separate evidence.",
|
||||
"accessed_on": "2026-09-09",
|
||||
"publication_note": "Volume/index year 2017; online publication January 4, 2018.",
|
||||
"authors_or_organization": "Vigotsky AD, Halperin I, Lehman GJ, Trajano GS, Vieira TM.",
|
||||
"type": "emg_evidence"
|
||||
}
|
||||
]
|
||||
}
|
||||
1070
catalog/training-knowledge-audit-v1.json
Normal file
1070
catalog/training-knowledge-audit-v1.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -510,3 +510,28 @@ Android requires `data_fields = 0` for `SETS`, while the desktop model/API
|
|||
currently accepts known supplemental bits on either recording mode. Supplied
|
||||
profiles do not exercise this difference. Supporting a future set-based
|
||||
supplemental field requires an explicit shared-model decision.
|
||||
|
||||
## 18. Training knowledge V1 read APIs
|
||||
|
||||
Android bundles the six authored training-knowledge catalogs as immutable
|
||||
assets. `TrainingKnowledgeCatalog` validates and exposes their stable-ID
|
||||
records; it is not a second manually authored scientific table.
|
||||
`TrainlogRepository.getTrainingExerciseContext()` composes an exact persisted
|
||||
exercise with its direct/ancestor persisted zones, optional scientific mapping,
|
||||
compatible equipment, latest explicit MAX and recent occurrence/set preview.
|
||||
`listExerciseOccurrences()` and `listExerciseOccurrenceSets()` provide bounded
|
||||
follow-up pages. Occurrence and set limits are 1–32 and 1–64 respectively.
|
||||
Cursors order current data chronologically by original timestamp, session ID
|
||||
and occurrence ID, and do not preserve a snapshot across calls.
|
||||
|
||||
This read-only feature makes no Android schema change (the runtime schema
|
||||
remains v10), does not seed rows, and does not export/synchronize new data. It
|
||||
does not implement recommendations, planned weights, set counts or fatigue
|
||||
scores. Its occurrence and latest-MAX readers use the same explicit temporal
|
||||
grammar, exact fractional comparison and bytewise ID tie breakers as C.
|
||||
The Android writer's omitted-seconds form is admitted, and emitted cursors
|
||||
retain the original source text. Production pagination/MAX parity tests pass.
|
||||
The tranche is `TRAINING_KNOWLEDGE_V1=PASS`. The Android loader enforces canonical exercise
|
||||
and equipment identity syntax, bidirectional exercise/capability compatibility,
|
||||
HIGH evidence source type, and non-unresolved BODY ZONE audit evidence. Its
|
||||
full source and uncertainty contract is in [Training knowledge system V1](domain/knowledge_system.md).
|
||||
|
|
|
|||
|
|
@ -477,3 +477,36 @@ masks to be zero for `SETS`. The shipped catalog uses supplemental speed and
|
|||
distance only with `CONTINUOUS`; defining cross-platform behavior for a future
|
||||
set-based supplemental field is a model-contract decision, not part of this
|
||||
reconciliation.
|
||||
|
||||
## 14. Training knowledge V1 boundary
|
||||
|
||||
`TRAINING_KNOWLEDGE_V1` is a read-only composition layer. Six versioned JSON
|
||||
catalogs under `catalog/` are the only authored scientific source; generated C
|
||||
data and Android asset loading derive from them. They contain cited anatomy,
|
||||
movement, exercise and equipment knowledge, not user history. The generated
|
||||
knowledge audit is evidence output, not an editable source.
|
||||
|
||||
The desktop `training_knowledge.h` API exposes immutable catalog records and
|
||||
stable-ID queries. `training_context.h` combines one exact persisted exercise
|
||||
with its stored BODY ZONE relations, optional science, compatible equipment,
|
||||
latest explicit maximum, and bounded occurrence/set history under one read
|
||||
snapshot. Android provides the corresponding catalog and repository context.
|
||||
This composition neither writes SQLite nor seeds catalog mappings. An unknown
|
||||
runtime ID and a missing scientific record remain valid states.
|
||||
|
||||
Scientific BODY ZONE projections and persisted BODY ZONE relations have
|
||||
different ownership and are never substituted for one another. Labels and
|
||||
generic equipment descriptions are not runtime identities; an equipment
|
||||
capability does not create an `ex_<uuid-v4>` exercise or historical association.
|
||||
The feature is `TRAINING_KNOWLEDGE_V1=PASS`. The temporal contract has
|
||||
independent PASS evidence:
|
||||
the readers parse the admitted source forms into exact instants, compare exact
|
||||
fractions, then use bytewise session and entry ID ties; emitted exclusive
|
||||
cursors preserve the original timestamp text and IDs. Selection and hydration
|
||||
share a read snapshot, while separate page calls retain current-data semantics.
|
||||
Malformed caller cursors and malformed matching stored timestamps fail
|
||||
explicitly; a selected timestamp beyond C's 40-character output field also
|
||||
fails explicitly. The initial full-tranche audit's stale temporal-documentation,
|
||||
Android loader, Meson input, and C role-only query findings were resolved by one
|
||||
bounded repair chain and independently verified. Its full contract, uncertainty boundary and
|
||||
future-only planning architecture are in [Training knowledge system V1](domain/knowledge_system.md).
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
|
|||
BODY_ZONES_TUI_REAL_VALIDATION=PASS
|
||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
||||
|
||||
DESKTOP_TESTS=39/39 PASS
|
||||
TRAINING_KNOWLEDGE_V1=PASS
|
||||
|
||||
DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint)
|
||||
ANDROID_BUILD=PASS
|
||||
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
||||
```
|
||||
|
|
@ -72,6 +74,8 @@ HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
|||
Implemented:
|
||||
|
||||
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback);
|
||||
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
|
||||
minimum terminal;
|
||||
- SQLite schema v11, with stable ordered `session_exercises.entry_id`,
|
||||
occurrence-level equipment identity, and desktop-local custom-equipment
|
||||
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
|
||||
|
|
@ -152,6 +156,48 @@ All Android screens use the shared compact `◆ TRAINLOG ◆` header: the
|
|||
Notcurses accent, muted context line, and flat touch layout reproduce the TUI
|
||||
plaque without literal terminal box drawing.
|
||||
|
||||
## Training knowledge V1
|
||||
|
||||
The implemented read-only training-knowledge layer loads six versioned JSON
|
||||
catalogs as the sole authored scientific source, generates the immutable C
|
||||
catalog representation, and loads the same assets on Android. It has no
|
||||
database migration, no auto-seeding, and no synchronization artifact. The
|
||||
desktop database remains schema v11 and Android remains schema v10.
|
||||
|
||||
Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose
|
||||
source-linked science lookups and resolved-candidate filters. Desktop
|
||||
`training_context.h` and Android `TrainlogRepository` compose one real runtime
|
||||
exercise with its persisted zones, compatible equipment, latest explicit MAX,
|
||||
and bounded chronological occurrence/set history. Persisted zones remain
|
||||
separate from scientific mappings; missing scientific knowledge is valid.
|
||||
|
||||
The scientific review passed and the reviewed catalog bytes remain unchanged.
|
||||
The independent temporal review returned `TEMPORAL_DELTA_REVIEW=PASS`, with no
|
||||
temporal defects or repairs. It reviewed the grammar, calendar and offset
|
||||
bounds, fraction precision, bytewise ties, cursor aliasing and exclusivity,
|
||||
source capacity, snapshots, and Android/Python parity; it ran the targeted
|
||||
Meson and four Python temporal tests. The initial full-tranche engineering
|
||||
audit initially failed with stale temporal-defect documentation (BLOCKER),
|
||||
Android loader parity gaps (BLOCKER), omitted Meson generator inputs (BLOCKER),
|
||||
and a C role-only query mismatch (HIGH). One bounded repair chain resolved all
|
||||
four findings; independent repair verification returned
|
||||
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and
|
||||
`TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`. Fresh final validation passed:
|
||||
strict build, 42 Meson tests, eight knowledge and four temporal Python tests,
|
||||
knowledge/JSON/import validators, three C17 headers, affected C knowledge and
|
||||
context tests under ASan/UBSan plus Python timestamp validation, normal and
|
||||
sanitized 12-form temporal probes, and Android 56 tests with zero failures or
|
||||
errors and one known missing-real-v9-fixture skip; Java 17 `assembleDebug` also
|
||||
passed. Generated C is byte-identical with SHA-256
|
||||
`e8c099f67eb111d61621b5d76592c049823af5508d43e73ec646f22e4c377fca`; all six
|
||||
Android assets are byte-identical. Preservation before and after repair confirms
|
||||
schema v11/v10, unchanged catalog/science/temporal bytes, and unchanged real
|
||||
database logical SHA-256 `26139cafeffbde3ec08f6ef23c5069e75afb9cd69ffffb40be5c099006fedc4d`,
|
||||
counts, integrity, and foreign keys. `TRAINING_KNOWLEDGE_V1=PASS`. The
|
||||
[temporal contract](reviews/training_knowledge_v1_temporal_contract.md)
|
||||
defines the settled reader behavior. No manual Android install, manual TUI
|
||||
visual validation or manual MTP validation is claimed for this tranche.
|
||||
|
||||
## Synchronization
|
||||
|
||||
Canonical exchange directory:
|
||||
|
|
|
|||
|
|
@ -546,3 +546,25 @@ left/right asymmetry percentages
|
|||
|
||||
The optional estimation profile is desktop configuration, not database
|
||||
history.
|
||||
|
||||
## 14. Training knowledge read boundary
|
||||
|
||||
Training Knowledge V1 adds no table, migration, seed data or synchronization
|
||||
artifact. The desktop database remains schema v11. Read-only context assembly
|
||||
joins an exact existing exercise with its persisted BODY ZONE relations,
|
||||
occurrence history, raw sets, actual equipment and latest explicit MAX, then
|
||||
optionally attaches immutable catalog knowledge. Missing catalog knowledge is
|
||||
valid and never causes a database mutation. The scientific catalog's BODY ZONE
|
||||
projection is not stored in `exercise_body_zones` and does not replace user
|
||||
classification. See [Training knowledge system V1](domain/knowledge_system.md).
|
||||
Occurrence pagination and latest explicit MAX use an exact derived instant
|
||||
comparison, then bytewise session/entry ID ties. They scan all matching
|
||||
metadata and retain only bounded candidates before hydration, inside a read
|
||||
snapshot. Original timestamp text stays unchanged. Malformed storage and a
|
||||
selected timestamp beyond the existing 40-character C output capacity produce
|
||||
explicit errors. The [temporal contract](reviews/training_knowledge_v1_temporal_contract.md)
|
||||
distinguishes the accepted profile from accidental Python ISO extensions.
|
||||
The temporal contract has independent PASS evidence. The initial audit's stale
|
||||
temporal documentation, Android loader, Meson input, and C role-only query
|
||||
findings were repaired, independently verified, and final-validated;
|
||||
`TRAINING_KNOWLEDGE_V1=PASS`.
|
||||
|
|
|
|||
113
docs/domain/anatomy_and_movement.md
Normal file
113
docs/domain/anatomy_and_movement.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# Functional anatomy and movement knowledge
|
||||
|
||||
TRAINING KNOWLEDGE V1 separates anatomy from the exercise, its execution and
|
||||
its physical equipment. A muscle can move a joint, assist another mover or
|
||||
stabilize a segment; its role changes with posture, resistance direction and
|
||||
movement phase. The catalogs record qualitative roles, not force percentages,
|
||||
effective-set fractions or physiological measurements.
|
||||
|
||||
The scientific references are in `catalog/science-references-v1.json`.
|
||||
Functional entities, joint actions and authored movement conventions belong in
|
||||
`catalog/muscles-v1.json`, `catalog/joint-actions-v1.json` and
|
||||
`catalog/movement-patterns-v1.json`. A muscle region or muscle group is not a
|
||||
new anatomical muscle. Parent groups and their component muscles must not be
|
||||
summed as independent exposure.
|
||||
|
||||
## Evidence and confidence
|
||||
|
||||
Every interpretation distinguishes established anatomy, biomechanical
|
||||
interpretation, EMG evidence, intervention evidence, manufacturer statements
|
||||
and practical inference. Anatomy explains plausible function; longitudinal
|
||||
training studies address adaptation. Surface EMG measures a signal affected
|
||||
by recording and physiological conditions. Greater amplitude does not establish
|
||||
greater muscle force, hypertrophy, strength improvement or universal primary
|
||||
muscle status. [Vigotsky and colleagues](https://pubmed.ncbi.nlm.nih.gov/29354060/)
|
||||
|
||||
Confidence uses exactly `high`, `moderate` and `uncertain`. Established anatomy
|
||||
can have high confidence while its application to an unobserved machine variant
|
||||
remains uncertain. Moderate confidence is appropriate when the exercise family
|
||||
is clear but geometry changes secondary or stabilizing roles. Missing evidence
|
||||
is retained explicitly, not replaced with a commercial-name rule.
|
||||
|
||||
## Functional muscle coverage
|
||||
|
||||
The catalog covers these functional distinctions:
|
||||
|
||||
| Area | Functional distinctions |
|
||||
|---|---|
|
||||
| Chest | Pectoralis major, clavicular and sternocostal regions; pectoralis minor as a scapular muscle |
|
||||
| Back and scapula | Latissimus dorsi, teres major, trapezius regions, rhomboids and serratus anterior |
|
||||
| Shoulder | Anterior, middle and posterior deltoid; supraspinatus, infraspinatus, teres minor and subscapularis |
|
||||
| Arms and forearms | Elbow flexors, triceps, grip/wrist flexors and extensors, pronation and supination |
|
||||
| Trunk | Rectus abdominis, obliques, transversus abdominis, erector spinae, multifidus and quadratus lumborum |
|
||||
| Hip | Gluteal muscles, tensor fasciae latae, iliopsoas and adductors |
|
||||
| Thigh | Quadriceps with biarticular rectus femoris distinguished from vasti; biarticular hamstrings distinguished from biceps femoris short head |
|
||||
| Lower leg | Gastrocnemius, soleus and tibialis anterior |
|
||||
|
||||
Upper-limb anatomy supports shoulder, scapular, elbow and forearm distinctions.
|
||||
Scapular movement is not interchangeable with glenohumeral movement. Stabilizing
|
||||
the humeral head is not the same task as dynamically rotating the shoulder.
|
||||
[OpenStax upper-limb anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-5-muscles-of-the-pectoral-girdle-and-upper-limbs)
|
||||
|
||||
Pectoral regions share actions but differ in orientation and contribution across
|
||||
shoulder positions. Their existence does not establish separate isolatable
|
||||
“upper” and “lower” chest muscles. Deltoid regions likewise have different
|
||||
lines of action; the movement identifies the likely emphasis.
|
||||
[NIH pectoralis anatomy](https://www.ncbi.nlm.nih.gov/books/NBK525991/),
|
||||
[NIH deltoid anatomy](https://www.ncbi.nlm.nih.gov/books/NBK537056/)
|
||||
|
||||
The hip and knee distinctions are essential: knee extension and knee flexion
|
||||
are different functions despite both mapping to `thighs`. Gastrocnemius crosses
|
||||
the knee and ankle; soleus does not cross the knee. Hip flexion changes the
|
||||
length of biarticular hamstrings but not biceps femoris short head.
|
||||
[OpenStax lower-limb anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-6-appendicular-muscles-of-the-pelvic-girdle-and-lower-limbs),
|
||||
[Maeo and colleagues](https://pubmed.ncbi.nlm.nih.gov/33009197/)
|
||||
|
||||
Gluteus minimus abducts and stabilizes the hip. Some lower-limb textbook figure
|
||||
alternative text describes direction inconsistently, including minimus and
|
||||
adductor examples; those descriptions are not copied as anatomical truth.
|
||||
The NIH account corroborates the minimus classification.
|
||||
[NIH gluteus minimus anatomy](https://www.ncbi.nlm.nih.gov/books/NBK556144/)
|
||||
|
||||
Spinal flexion/extension and hip flexion/extension must remain separate.
|
||||
Abdominal-wall muscles combine movement and tension/control functions, while
|
||||
posterior spinal muscles contribute extension and segmental control.
|
||||
[OpenStax spinal anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-3-axial-muscles-of-the-head-neck-and-back),
|
||||
[OpenStax trunk anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-4-axial-muscles-of-the-abdominal-wall-and-thorax),
|
||||
[NIH abdominal-wall anatomy](https://www.ncbi.nlm.nih.gov/books/NBK525975/)
|
||||
|
||||
## Actions, patterns and stabilization
|
||||
|
||||
Joint actions describe motion. Their definitions include shoulder and scapular
|
||||
actions, elbow flexion/extension, forearm rotation, hip actions, knee actions,
|
||||
ankle actions and trunk flexion/extension/rotation/lateral flexion.
|
||||
A loaded return may reverse the visible joint motion while the same agonists
|
||||
control it eccentrically.
|
||||
[OpenStax movement terminology](https://openstax.org/books/anatomy-and-physiology-2e/pages/9-5-types-of-body-movements)
|
||||
|
||||
Horizontal/vertical push and pull, knee dominant, hip dominant and single-joint
|
||||
patterns are Trainlog programming conventions grounded in those actions.
|
||||
They are not universally standardized anatomical categories or exact torque
|
||||
ratios. A single-joint exercise can involve many muscles and stabilizers.
|
||||
A dip grouped as a vertical push is still mechanically different from an
|
||||
overhead press.
|
||||
|
||||
Trunk stabilization describes resisting an external moment while limiting
|
||||
motion. Anti-extension, anti-rotation and anti-lateral-flexion are task demands,
|
||||
not invented joint movements. Locomotion, cyclic pedaling and cyclic rowing
|
||||
remain distinct; a cardio profile alone does not identify the action sequence.
|
||||
|
||||
## BODY ZONES projection
|
||||
|
||||
`catalog/body-zones-v1.json` remains the authoritative UX taxonomy. Its zones
|
||||
are coarser than functional anatomy. Forearm muscles fall under `arms`,
|
||||
posterior trunk muscles can relate to both `back` and `core`, and the lateral
|
||||
thorax/scapular function of serratus anterior does not fit a simple surface
|
||||
location rule. Scientific muscle-level projections explain these conventions;
|
||||
they do not alter persisted exercise relations.
|
||||
|
||||
A secondary BODY ZONE need not list every accessory or stabilizing muscle.
|
||||
Absence of `shoulders` on a row does not deny posterior-deltoid participation.
|
||||
Absence of `thighs` on hip abduction does not deny tensor fasciae latae
|
||||
participation. `full_body` is not an automatic synonym for cardio or a command
|
||||
to mark every zone.
|
||||
117
docs/domain/exercise_equipment_interpretation.md
Normal file
117
docs/domain/exercise_equipment_interpretation.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Exercise and equipment interpretation
|
||||
|
||||
Scientific knowledge is keyed to existing stable exercise and equipment
|
||||
identities. Display labels are identification clues. They are not anatomical
|
||||
proof, runtime classification rules or grounds for merging identities.
|
||||
`catalog/exercise-knowledge-v1.json` separates reviewed interpretations from
|
||||
conditional candidates; `catalog/equipment-knowledge-v1.json` describes
|
||||
physical apparatus and its exercise capabilities.
|
||||
|
||||
The reviewed inventory contains 23 exercise identities and 41 equipment
|
||||
identities: 38 supplied definitions and three durable custom entries. No
|
||||
manufacturer or model has been confirmed for the local apparatus. Available
|
||||
manufacturer examples remain examples; their ratios, trajectories and outcome
|
||||
claims are not transferred to the local inventory.
|
||||
|
||||
## Reviewed exercise families
|
||||
|
||||
The following are biomechanical interpretations using the stated execution.
|
||||
They are qualitative classifications, not measurements of individual force
|
||||
contributions. Anatomy and the references linked below support the rationale.
|
||||
|
||||
| Actual catalog exercise | Interpretation | Existing primary / secondary BODY ZONES | Main qualification |
|
||||
|---|---|---|---|
|
||||
| Abdominal crunch | Resisted spinal flexion; rectus and obliques | core | Confirm that movement is not predominantly hip flexion |
|
||||
| Arm curl | Elbow flexion; biceps, brachialis and brachioradialis | arms | Grip and shoulder support change participation |
|
||||
| Back extension | Spinal extension; erector spinae and multifidus | back / core | Pelvic restraint determines hip involvement |
|
||||
| Converging Shoulder Press | Overhead push; deltoids with elbow extension | shoulders / arms | Plane, seat support and scapular freedom unresolved |
|
||||
| Diverged seated row; Seated row | Horizontal pull; humeral extension and scapular retraction | back / arms | Elbow path determines lat/scapular/posterior-deltoid emphasis |
|
||||
| Diverging lat pulldown; Lat pull | Vertical pull; shoulder adduction/extension and elbow flexion | back / arms | Grip and linkage do not establish regional isolation |
|
||||
| Hip abduction | Abduction involving medius/minimus and other abductors | glutes | Tensor fasciae latae and superior maximus contributions vary |
|
||||
| Hip adduction | Resisted thigh approximation by hip adductors | thighs | Hip and knee angles affect individual muscles |
|
||||
| Leg extension | Resisted knee extension by quadriceps | thighs | Rectus femoris also crosses hip |
|
||||
| Leg press | Combined knee/hip extension | thighs / glutes | Machine, depth and foot placement alter moments |
|
||||
| Prone leg curl | Knee flexion with prone support | thighs | Distinct length context from seated curl |
|
||||
| Seated Leg; Seated leg curl | Knee flexion with seated support | thighs | Two existing identities retained; common family does not merge IDs |
|
||||
| Rear Delt | Reverse fly: humeral horizontal abduction with scapular contribution | shoulders / back | Distinct from Pec Fly on the same device |
|
||||
| Rotary torso | Relative thorax-pelvis rotation; paired oblique action | core | Compatible supplied equipment is not proven occurrence linkage |
|
||||
|
||||
Chest Press priority is addressed through a complete **conditional** anterior
|
||||
press interpretation: pectoralis major as a primary mover, anterior deltoid and
|
||||
triceps as synergists, shoulder horizontal adduction with elbow extension,
|
||||
and `horizontal_push`. The custom `Chest press` and `Converting chest press`
|
||||
identities have not independently established that execution. Their retained
|
||||
chest/shoulders/arms relations are plausible historical mappings; the scientific
|
||||
candidate stays uncertain until apparatus and motion are confirmed.
|
||||
[NIH pectoralis anatomy](https://www.ncbi.nlm.nih.gov/books/NBK525991/),
|
||||
[OpenStax upper-limb anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-5-muscles-of-the-pectoral-girdle-and-upper-limbs)
|
||||
|
||||
The custom `Abdominal` entry similarly retains a conditional crunch candidate,
|
||||
not an asserted execution. Both generic warm-up identities remain unresolved.
|
||||
`Marche` has a conditional walking interpretation; a continuous/duration profile
|
||||
and an aggregate treadmill inventory do not establish all execution details or
|
||||
a specific occurrence association. Unknown new custom exercises remain
|
||||
unclassified until evidence is added explicitly.
|
||||
|
||||
Seated versus prone curl has direct longitudinal evidence: the studied seated
|
||||
condition produced greater growth of biarticular hamstrings. This supports
|
||||
retaining length context while avoiding a universal machine or outcome ranking.
|
||||
[Maeo and colleagues](https://pubmed.ncbi.nlm.nih.gov/33009197/)
|
||||
|
||||
The leg-press review concerns EMG. It supports quadriceps involvement but does
|
||||
not establish a robust universal foot-placement recipe or prove that hamstring
|
||||
coactivation replaces knee-flexion training.
|
||||
[Martín-Fuentes and colleagues](https://pubmed.ncbi.nlm.nih.gov/32605065/)
|
||||
|
||||
Reverse pec-deck and row/pulldown investigations concern selected EMG tasks.
|
||||
They corroborate plausible posterior-deltoid and scapular contributions without
|
||||
ranking long-term growth. Divergent/convergent machine labels add no independent
|
||||
outcome evidence.
|
||||
[Franke and colleagues](https://pubmed.ncbi.nlm.nih.gov/24947920/),
|
||||
[Lehman and colleagues](https://pubmed.ncbi.nlm.nih.gov/15228624/)
|
||||
|
||||
Pelvic stabilization changes lumbar-extensor excitation. A Back Extension
|
||||
execution dominated by hip motion may deserve different muscle roles from the
|
||||
spinal-extension interpretation; that decision requires actual geometry and
|
||||
execution evidence.
|
||||
[Lee](https://pubmed.ncbi.nlm.nih.gov/26730390/)
|
||||
|
||||
## Multifunction and unrestricted equipment
|
||||
|
||||
A Rear Delt / Pec Fly device supports at least two distinct tasks. Rear Delt
|
||||
uses shoulder horizontal abduction and a shoulder/back projection. Pec Fly
|
||||
uses horizontal adduction, with a chest emphasis and possible anterior-deltoid
|
||||
assistance. Pec Fly has no verified local exercise ID. It remains an unlinked
|
||||
capability, not a fabricated catalog exercise.
|
||||
|
||||
The assistance device likewise supports separate assisted dip and assisted
|
||||
chin/pull-up capabilities. Neither has a verified local exercise ID. The
|
||||
legacy `assisted_dip` and `assisted_chin` strings in the equipment manifest are
|
||||
not creator exercise identities. Chin-up and pronated pull-up also require
|
||||
specific grip/execution context. Manufacturer catalogs demonstrate that such
|
||||
combination products exist, not that these examples identify the local model.
|
||||
[Life Fitness equipment catalogue](https://www.lifefitness.com.au/wp-content/uploads/2024/02/Life-Fitness-Catalogue_2024_web.pdf)
|
||||
|
||||
Functional trainers, adjustable pulleys, dumbbells, kettlebells, bags,
|
||||
medicine balls and suspension straps require the actual exercise. Their
|
||||
presence cannot establish a primary BODY ZONE. Cable height and routing,
|
||||
attachment, stance and line of pull determine the task. A documented example
|
||||
with a 4:1 cable ratio illustrates why local ratios must be verified separately.
|
||||
[Precor RUD0915](https://www.precor.com/en-US/products/RUD0915)
|
||||
|
||||
Benches and guided squat machines require support and movement details. Cardio
|
||||
apparatus requires mode, speed/cadence, resistance and support context. A rowing
|
||||
ergometer includes a leg/trunk/arm cycle and is not the same exercise as a
|
||||
seated resistance row. Battle ropes have mass and inertia: their existing
|
||||
`bodyweight` catalog category is retained as legacy metadata, not a calibrated
|
||||
physical load model. Any future vocabulary change needs a separate explicit
|
||||
compatibility decision.
|
||||
|
||||
## BODY ZONE audit result
|
||||
|
||||
No reviewed finding justifies an automatic persisted-zone change. Existing
|
||||
assignments are compatible with the identified families or remain conditional
|
||||
where observation is missing. Additional potential synergists are documented
|
||||
at muscle level. A future correction must state its evidence, affected stable
|
||||
identities and explicit migration separately; enrichment never silently
|
||||
rewrites historical mappings.
|
||||
29
docs/domain/knowledge_audit.md
Normal file
29
docs/domain/knowledge_audit.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Training knowledge science audit
|
||||
|
||||
Generated deterministically from the six canonical knowledge catalogs. Conditional rows are candidates that require explicit confirmation and do not participate in ordinary resolved queries.
|
||||
|
||||
| ID | Name | Actions | Patterns | Primary | Secondary | Stabilizers | Science primary zone | Science secondary zones | Existing primary zone | Existing secondary zones | Equipment | Confidence | Source refs | Audit status |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef | Hip adduction | hip_adduction | single_joint_hip_adduction | hip_adductors | — | — | thighs | — | thighs | — | hip_adduction | high | openstax_lower | confirmed |
|
||||
| ex_1872246a-39ae-44dc-b58d-f87e90ca49ab | Leg extension | knee_extension | single_joint_knee_extension | quadriceps | — | — | thighs | — | thighs | — | leg_extension | high | openstax_lower | confirmed |
|
||||
| ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0 | Rotary torso | trunk_rotation | trunk_rotation | external_oblique, internal_oblique | multifidus | erector_spinae, transversus_abdominis | core | — | core | — | rotary_torso | moderate | nih_abdominal_wall, openstax_back, openstax_trunk | confirmed |
|
||||
| ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de | Diverged seated row | elbow_flexion, scapular_retraction, shoulder_extension, shoulder_horizontal_abduction | horizontal_pull | latissimus_dorsi, rhomboids, trapezius_middle | biceps_brachii, brachialis, brachioradialis, deltoid_posterior, teres_major | erector_spinae, forearm_extensors, forearm_flexors | back | arms | back | arms | diverging_seated_row | moderate | franke_2015, lehman_2004, openstax_upper | confirmed |
|
||||
| ex_2d488c08-194c-4051-a3c9-34471646c1d3 | Gym échauffement | — | — | — | — | — | — | — | — | — | — | uncertain | — | unresolved |
|
||||
| ex_33f79331-871c-4eed-babe-346e53a99070 | Seated row | elbow_flexion, scapular_retraction, shoulder_extension, shoulder_horizontal_abduction | horizontal_pull | latissimus_dorsi, rhomboids, trapezius_middle | biceps_brachii, brachialis, brachioradialis, deltoid_posterior, teres_major | erector_spinae, forearm_extensors, forearm_flexors | back | arms | back | arms | seated_row | moderate | franke_2015, lehman_2004, openstax_upper | confirmed |
|
||||
| ex_34a5c9c3-c032-4dfb-be32-8bf09832f72b | Converting chest press | elbow_extension, shoulder_horizontal_adduction | horizontal_push | pectoralis_major | deltoid_anterior, triceps_brachii | infraspinatus, subscapularis, supraspinatus, teres_minor | chest | arms, shoulders | chest | arms, shoulders | eq_c660cd61-5b17-498e-8d51-9eafcf7e2731 | uncertain | nih_deltoid, nih_pectoralis, openstax_upper | questionable |
|
||||
| ex_43c7375f-934c-4650-930c-45807d2f2929 | Gym/Échauffement | — | — | — | — | — | — | — | — | — | — | uncertain | — | unresolved |
|
||||
| ex_474ec393-3efa-4aaa-8e08-1a0245ed7835 | Back extension | trunk_extension | trunk_extension | erector_spinae | multifidus | external_oblique, internal_oblique, rectus_abdominis | back | core | back | core | back_extension | moderate | lee_2015, openstax_back, openstax_lower | questionable |
|
||||
| ex_4bd03d55-e644-436e-876e-07837824bde5 | Abdominal | trunk_flexion | trunk_flexion | rectus_abdominis | external_oblique, internal_oblique | transversus_abdominis | core | — | core | — | eq_3f987a36-bbb4-4e29-9c9f-1f201f1596e0 | uncertain | nih_abdominal_wall, openstax_trunk | questionable |
|
||||
| ex_4cd2433e-80b1-478a-b8df-73fc6ef80962 | Rear Delt | scapular_retraction, shoulder_horizontal_abduction | single_joint_shoulder_horizontal_abduction | deltoid_posterior | rhomboids, trapezius_middle | infraspinatus, subscapularis, supraspinatus, teres_minor | shoulders | back | shoulders | back | rear_delt_pec_fly | moderate | franke_2015, nih_deltoid, openstax_upper, vigotsky_2018 | confirmed |
|
||||
| ex_617007f9-7420-4408-91b9-8ffb77900f13 | Seated Leg | knee_flexion | single_joint_knee_flexion | hamstrings | gastrocnemius | — | thighs | — | thighs | — | seated_leg_curl | high | maeo_2021, openstax_lower | confirmed |
|
||||
| ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b | Converging Shoulder Press | elbow_extension, scapular_upward_rotation, shoulder_abduction, shoulder_flexion | vertical_push | deltoid_anterior, deltoid_middle | serratus_anterior, trapezius_lower, trapezius_upper, triceps_brachii | infraspinatus, subscapularis, supraspinatus, teres_minor | shoulders | arms | shoulders | arms | converging_shoulder_press | moderate | nih_deltoid, openstax_upper | confirmed |
|
||||
| ex_7e7cf906-2214-4066-bcb7-c16382d83b3b | Hip abduction | hip_abduction | single_joint_hip_abduction | gluteus_medius, gluteus_minimus | gluteus_maximus, tensor_fasciae_latae | — | glutes | — | glutes | — | hip_abduction | moderate | nih_gluteus_minimus, openstax_lower | confirmed |
|
||||
| ex_8552dd77-fcd7-4f06-a1cc-d956eb1009af | Chest press | elbow_extension, shoulder_horizontal_adduction | horizontal_push | pectoralis_major | deltoid_anterior, triceps_brachii | infraspinatus, subscapularis, supraspinatus, teres_minor | chest | arms, shoulders | chest | arms, shoulders | eq_0a462c1e-9fb6-4fd7-b0a2-53a0e86f33c6 | uncertain | nih_deltoid, nih_pectoralis, openstax_upper | questionable |
|
||||
| ex_9adf7566-f10c-443c-b63b-681d37665693 | Abdominal crunch | trunk_flexion | trunk_flexion | rectus_abdominis | external_oblique, internal_oblique | transversus_abdominis | core | — | core | — | abdominal | moderate | nih_abdominal_wall, openstax_trunk | confirmed |
|
||||
| ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163 | Seated leg curl | knee_flexion | single_joint_knee_flexion | hamstrings | gastrocnemius | — | thighs | — | thighs | — | seated_leg_curl | high | maeo_2021, openstax_lower | confirmed |
|
||||
| ex_a72fa713-4b0e-431d-95e2-42d95beb77b1 | Lat pull | elbow_flexion, scapular_downward_rotation, shoulder_adduction, shoulder_extension | vertical_pull | latissimus_dorsi | biceps_brachii, brachialis, brachioradialis, teres_major | forearm_extensors, forearm_flexors, infraspinatus, subscapularis, supraspinatus, teres_minor | back | arms | back | arms | lat_pull | moderate | lehman_2004, openstax_upper, vigotsky_2018 | confirmed |
|
||||
| ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d | Marche | ankle_dorsiflexion, ankle_plantarflexion, hip_extension, hip_flexion, knee_extension, knee_flexion | locomotion | gastrocnemius, gluteus_maximus, quadriceps, soleus | hamstrings, iliopsoas, tibialis_anterior | erector_spinae, gluteus_medius, gluteus_minimus | full_body | — | — | — | — | uncertain | nih_gluteus_minimus, openstax_lower | unresolved |
|
||||
| ex_b432623f-bfe9-4daf-a653-60ec7fdffbde | Leg press | hip_extension, knee_extension | knee_dominant | quadriceps | adductor_magnus, gluteus_maximus | gastrocnemius, soleus | thighs | glutes | thighs | glutes | leg_press, plate_loaded_leg_press | moderate | martin_fuentes_2020, openstax_lower, vigotsky_2018 | confirmed |
|
||||
| ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6 | Diverging lat pulldown | elbow_flexion, scapular_downward_rotation, shoulder_adduction, shoulder_extension | vertical_pull | latissimus_dorsi | biceps_brachii, brachialis, brachioradialis, teres_major | forearm_extensors, forearm_flexors, infraspinatus, subscapularis, supraspinatus, teres_minor | back | arms | back | arms | diverging_lat_pulldown | moderate | lehman_2004, openstax_upper, vigotsky_2018 | confirmed |
|
||||
| ex_d7398d9f-d928-4d2e-94e9-74e201da55c5 | Prone leg curl | knee_flexion | single_joint_knee_flexion | hamstrings | gastrocnemius | — | thighs | — | thighs | — | prone_leg_curl | high | maeo_2021, openstax_lower | confirmed |
|
||||
| ex_ec619fc2-4685-4044-873c-86764bd4a0fe | Arm curl | elbow_flexion | single_joint_elbow_flexion | biceps_brachii, brachialis | brachioradialis | forearm_extensors, forearm_flexors | arms | — | arms | — | arm_curl | high | openstax_upper | confirmed |
|
||||
129
docs/domain/knowledge_system.md
Normal file
129
docs/domain/knowledge_system.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Training knowledge system V1
|
||||
|
||||
`TRAINING_KNOWLEDGE_V1` is an implemented read-only, evidence-linked knowledge
|
||||
layer with lifecycle status `TRAINING_KNOWLEDGE_V1=PASS`. Its bounded scientific
|
||||
review, independent temporal review, final engineering audit, repair
|
||||
verification, and final executable validation passed. It does not change the frozen
|
||||
exchange formats, either SQLite schema, synchronization,
|
||||
or the meaning of an exercise, BODY ZONE, occurrence, set, or measured maximum.
|
||||
|
||||
The authored scientific source is the six versioned JSON catalogs in
|
||||
[`catalog/`](../../catalog/):
|
||||
|
||||
- `science-references-v1.json` (23 references);
|
||||
- `muscles-v1.json` (53 muscles);
|
||||
- `joint-actions-v1.json` (35 joint actions);
|
||||
- `movement-patterns-v1.json` (26 movement patterns);
|
||||
- `exercise-knowledge-v1.json` (23 exercise records);
|
||||
- `equipment-knowledge-v1.json` (41 equipment records).
|
||||
|
||||
The catalog loader and validator enforce IDs, ordering and cross-references.
|
||||
The C representation is generated from those assets; Android loads the same
|
||||
assets through `TrainingKnowledgeCatalog`. Neither C nor Kotlin contains a
|
||||
manually maintained duplicate scientific table. The generated knowledge audit
|
||||
is an audit artifact and is not an authored source or a file to edit directly;
|
||||
the retained review-oriented [knowledge audit](knowledge_audit.md) and
|
||||
[`training-knowledge-audit-v1.json`](../../catalog/training-knowledge-audit-v1.json)
|
||||
provide the current navigable audit records.
|
||||
|
||||
The catalog contains no runtime history and does not seed either database.
|
||||
Unknown future exercise and equipment IDs remain valid runtime data. A display
|
||||
name, legacy slug, equipment family, or catalog label never substitutes for an
|
||||
actual `ex_<uuid-v4>` exercise ID. Consequently the documented capabilities
|
||||
for Pec Fly, Assisted Dip and Assisted Chin do not create associations until a
|
||||
real runtime exercise UUID exists. The two Leg press equipment variants remain
|
||||
separate contexts; the unobserved Rotary compatibility never enters history.
|
||||
|
||||
## Scientific scope and uncertainty
|
||||
|
||||
Scientific mappings describe anatomy, mechanics, movement patterns and the
|
||||
BODY ZONE projection used by the knowledge catalog. They do not overwrite the
|
||||
persisted primary/secondary BODY ZONE relations selected for a runtime
|
||||
exercise. Source type, notes, limitations, confidence and conditional
|
||||
interpretation remain available through the APIs.
|
||||
|
||||
The current inventory has six high-confidence, eleven moderate-confidence and
|
||||
six uncertain exercise records. It represents 20 initial zone-catalog exercise
|
||||
IDs plus three further reviewed IDs, 38 supplied equipment IDs and three
|
||||
observed custom IDs. The BODY ZONE audit has 16 confirmed, four questionable
|
||||
and three unresolved records. It records no database mutation. `Marche` is a
|
||||
conditional candidate and remains excluded by normal resolved-knowledge
|
||||
filters; two warmups and the custom Chest press, Converting and Abdominal
|
||||
entries remain unresolved until their execution is known. Across equipment,
|
||||
21 records are scientifically documented, 17 are mechanically identified with
|
||||
incomplete anatomy, and three have uncertain equipment identity. All 41 have
|
||||
unknown manufacturer and model: a generic-family source does not prove a local
|
||||
machine model.
|
||||
|
||||
The detailed evidence and limitations are in
|
||||
[anatomy and movement](anatomy_and_movement.md),
|
||||
[exercise and equipment interpretation](exercise_equipment_interpretation.md),
|
||||
[programming foundations](programming_foundations.md), and the central
|
||||
[`science-references-v1.json`](../../catalog/science-references-v1.json).
|
||||
Project domain decisions follow the
|
||||
[`trainlog-anatomy` guidance](../../.agents/skills/trainlog-anatomy/SKILL.md).
|
||||
|
||||
## Read-only application contracts
|
||||
|
||||
On desktop, [`training_knowledge.h`](../../tui/include/trainlog/training_knowledge.h)
|
||||
provides immutable lookup, enumeration and resolved-knowledge query APIs. All
|
||||
strings and records are borrowed generated storage with process-lifetime
|
||||
validity; stable IDs, rather than labels, are keys. The query AND-combines its
|
||||
optional scientific-zone, movement-pattern, muscle-role and available-equipment
|
||||
filters. Conditional and unresolved entries cannot match it.
|
||||
|
||||
[`training_context.h`](../../tui/include/trainlog/training_context.h) composes
|
||||
one exact runtime `exercise_id` with its persisted zones, optional scientific
|
||||
record, compatible equipment, latest explicit MAX, and actual occurrence/set
|
||||
history. Missing science is valid; it never fabricates history. One load uses a
|
||||
nested-safe read snapshot. It accepts an occurrence limit of 1–32 and a set
|
||||
preview limit of 1–64 per occurrence; empty history remains empty. Follow-up cursors order current data by the
|
||||
exact represented instant, then bytewise session ID and occurrence ID; they are not a
|
||||
snapshot across calls, including when equivalent instants use different
|
||||
offset text.
|
||||
|
||||
The corrected C and Android readers use one explicit parser/comparator policy
|
||||
for stored timestamps and exclusive cursors. They admit extended dates,
|
||||
`T/t`, `Z/z`, offsets through `23:59`, arbitrary exact dot fractions and the
|
||||
Android writer's omitted-seconds form. They scan all matching metadata and
|
||||
retain bounded winners before hydration; no SQLite date parser selects the
|
||||
page. Source text and identities remain unchanged. Malformed timestamps fail
|
||||
explicitly, as does a selected value beyond C's existing output capacity.
|
||||
The [temporal correction record](../reviews/training_knowledge_v1_temporal_contract.md)
|
||||
documents parser-only extensions, limits and passing production regressions.
|
||||
|
||||
Android exposes the same scientific lookups through `TrainingKnowledgeCatalog`
|
||||
and composes the same boundary through
|
||||
`TrainlogRepository.getTrainingExerciseContext()`,
|
||||
`listExerciseOccurrences()` and `listExerciseOccurrenceSets()`. Its cursors
|
||||
have the same current-data, chronological contract. Returned runtime context
|
||||
keeps persisted zone IDs separate from the scientific mapping, includes actual
|
||||
equipment and the latest explicit MAX, and preserves raw per-set values.
|
||||
|
||||
No API infers range of motion, setup, actual force, or comparability of raw
|
||||
kilogram labels. Equipment, resistance semantics and execution context remain
|
||||
explicit. There are no prescriptions in V1.
|
||||
|
||||
## Documented future pipeline only
|
||||
|
||||
The following is an architectural boundary for later work, not a V1 generator,
|
||||
scoring algorithm, proposal, database change, or user-interface behavior.
|
||||
|
||||
```text
|
||||
Session inputs
|
||||
BODY ZONE, duration, goal, available equipment, recent history
|
||||
-> movement functions
|
||||
-> real resolved candidates
|
||||
-> explicit availability
|
||||
-> recent history and explicit-MAX context
|
||||
-> future fatigue/recent-coverage interpretation
|
||||
-> a future proposal
|
||||
```
|
||||
|
||||
Candidate selection must seek diverse movement patterns rather than repeatedly
|
||||
selecting the same muscle. A future multi-session program would additionally
|
||||
take the goal, available days, functions/zones, recent frequency, recovery,
|
||||
progression, equipment and history, then reason across sessions. Medical
|
||||
constraints would be explicit inputs; no diagnosis or rehabilitation status is
|
||||
to be inferred. V1 prescribes no exercises, weights, sets, fatigue scores,
|
||||
progression or schedule.
|
||||
93
docs/domain/programming_foundations.md
Normal file
93
docs/domain/programming_foundations.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Programming foundations and MAX context
|
||||
|
||||
TRAINING KNOWLEDGE V1 supplies knowledge for future reasoning about training.
|
||||
It does not generate workouts, prescribe loads or provide clinical
|
||||
rehabilitation advice. Principles below distinguish intervention findings from
|
||||
Trainlog's practical interpretation of them.
|
||||
|
||||
## Adaptation and exposure
|
||||
|
||||
Resistance-training interventions improve strength and muscle size across many
|
||||
studied configurations. Higher loads tend to favor high-load strength outcomes;
|
||||
multiple sets characterize better-ranked hypertrophy configurations in a large
|
||||
network meta-analysis. These are population-level comparisons, not an optimal
|
||||
program for every person. Load, sets and frequency interact.
|
||||
[Currier and colleagues](https://pubmed.ncbi.nlm.nih.gov/37414459/)
|
||||
|
||||
Volume can mean sets, repetitions or accumulated load. Those are different
|
||||
quantities. Machine kilograms multiplied by repetitions do not establish
|
||||
equal muscle exposure across different devices. Direct and indirect muscle
|
||||
participation cannot be assigned universal equal set credit. Frequency helps
|
||||
distribute exposure and fatigue; no weekly frequency or unbounded more-volume
|
||||
rule is encoded here.
|
||||
|
||||
Relative external load and effort are also different. A heavy task need not
|
||||
end in failure; a lighter task can. Evidence does not establish that momentary
|
||||
failure is generally required for strength or hypertrophy, but it does not
|
||||
imply that arbitrarily easy sets provide the same stimulus. Failure definitions,
|
||||
actual effort and compared volume constrain interpretation.
|
||||
[Grgic and colleagues](https://pubmed.ncbi.nlm.nih.gov/33497853/),
|
||||
[Refalo and colleagues](https://pubmed.ncbi.nlm.nih.gov/36334240/)
|
||||
|
||||
Failure conditions generally produce greater acute fatigue in the reviewed
|
||||
experiments. Acute markers cannot supply a universal recovery clock or predict
|
||||
an individual's long-term result. Repeated performance, actual execution and
|
||||
context matter when a future system interprets recovery.
|
||||
[Vieira and colleagues](https://pubmed.ncbi.nlm.nih.gov/34881412/)
|
||||
|
||||
## Progression, selection and coverage
|
||||
|
||||
Progression and specificity require consistent definitions of the task and
|
||||
its successful performance. The historical ACSM position stand supplies these
|
||||
organizing principles; its numeric schedules and load recommendations are not
|
||||
adopted as V1 rules. Comparative outcomes are interpreted using the subsequent
|
||||
reviews above.
|
||||
[ACSM progression position stand](https://pubmed.ncbi.nlm.nih.gov/19204579/)
|
||||
|
||||
Exercise variation can change regional exposure and task practice. The limited
|
||||
intervention literature supports considering purposeful variation, not a
|
||||
requirement to randomize exercises or change them as often as possible. Stable
|
||||
exercise context also makes progress interpretable.
|
||||
[Kassiano and colleagues](https://pubmed.ncbi.nlm.nih.gov/35438660/)
|
||||
|
||||
Practical selection should compare the intended joint action, muscle role,
|
||||
range of motion, stability demand and available equipment. BODY ZONES alone
|
||||
cannot establish a substitute. Leg extension and leg curl share `thighs` while
|
||||
training opposing knee actions. Seated and prone curls share knee flexion but
|
||||
differ in the length context of biarticular muscles.
|
||||
[OpenStax lower-limb anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-6-appendicular-muscles-of-the-pelvic-girdle-and-lower-limbs),
|
||||
[Maeo and colleagues](https://pubmed.ncbi.nlm.nih.gov/33009197/)
|
||||
|
||||
Coverage therefore considers movement functions as well as zones: horizontal
|
||||
and vertical pushing/pulling, knee and hip extension, knee flexion, hip
|
||||
abduction/adduction, plantarflexion, trunk motion and resisting trunk motion.
|
||||
This is an authored reasoning aid. It is not a universal checklist that proves
|
||||
a program balanced, effective or suitable for a particular individual.
|
||||
|
||||
## What a recorded MAX can establish
|
||||
|
||||
`max_weight_kg` records an observed result owned by an exercise occurrence.
|
||||
Interpreting it requires its resistance and execution context. At minimum,
|
||||
consider exercise identity, physical equipment, external-versus-assistance
|
||||
mode, machine settings, range of motion, support, technique and success
|
||||
criterion. Unknown context stays unknown.
|
||||
|
||||
An explicit MAX result without repetition count is not proven to be a one-rep
|
||||
maximum. Ordinary sets do not become measured maxima by being the heaviest
|
||||
available observation. No estimated 1RM or percentage prescription follows
|
||||
from the value alone. A shared BODY ZONE, movement family or machine does not
|
||||
make distinct exercises' results interchangeable.
|
||||
|
||||
Stack labels need not equal handle resistance. Pulley routing, lever arms,
|
||||
cams, tare and friction affect external moments; human moment arms add further
|
||||
variation. Manufacturer documentation for an identified example demonstrates
|
||||
a non-unit cable ratio, but supplies no conversion for the unconfirmed local
|
||||
equipment.
|
||||
[Precor pulley documentation](https://www.precor.com/en-US/products/RUD0915)
|
||||
|
||||
For otherwise matched assisted performance, less help represents greater
|
||||
unsupported demand. Body mass, assistance mechanism and motion remain part of
|
||||
the comparison. Do not treat assistance as added external weight or assume
|
||||
that body mass minus displayed assistance gives a calibrated effective load.
|
||||
The source exercise and resistance context must remain visible when knowledge
|
||||
is composed with history.
|
||||
|
|
@ -317,3 +317,30 @@ The newest successful explicit test is the current measured result. A separate
|
|||
same-mode historical record may be older.
|
||||
|
||||
No estimated 1RM is mixed into this contract.
|
||||
|
||||
## 12. Read-only training knowledge context
|
||||
|
||||
Training Knowledge V1 adds no exercise profile field and no persisted training
|
||||
prescription. It can associate a real stable `exercise_id` with source-linked
|
||||
scientific knowledge, compatible equipment and a separate scientific BODY ZONE
|
||||
projection. That projection is not the persisted primary/secondary BODY ZONE
|
||||
classification and cannot rewrite it.
|
||||
|
||||
The composed runtime view contains the actual exercise profile, persisted
|
||||
zones, compatible equipment, latest explicit MAX and bounded chronological
|
||||
occurrences with raw set values. Unknown custom and future runtime IDs remain
|
||||
valid and may have no knowledge record. A name, legacy slug or generic machine
|
||||
capability is never made into a phantom UUID association. Raw kilogram labels,
|
||||
ROM, setup and actual force retain their recorded/unknown context; they do not
|
||||
become interchangeable performance measurements.
|
||||
|
||||
See [Training knowledge system V1](domain/knowledge_system.md) for catalog
|
||||
ownership, confidence and the documented-only future planning boundary.
|
||||
The current implementation lifecycle is `TRAINING_KNOWLEDGE_V1=PASS`. Occurrence pagination and latest
|
||||
explicit MAX use the settled shared temporal policy: the admitted source text
|
||||
is preserved, instants and arbitrary dot fractions compare exactly, and equal
|
||||
instants use bytewise session and entry ID ties. Cursors are exclusive and use
|
||||
the same comparison; malformed cursors or matching stored timestamps report
|
||||
explicit errors. The initial audit's stale temporal documentation, Android
|
||||
loader, Meson input, and C role-only query findings were repaired and
|
||||
independently verified; final validation passed.
|
||||
|
|
|
|||
122
docs/reviews/training_knowledge_v1_engineering_review.md
Normal file
122
docs/reviews/training_knowledge_v1_engineering_review.md
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
# Training Knowledge V1 — engineering checkpoint
|
||||
|
||||
Date: 2026-09-09. Status: `TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`.
|
||||
|
||||
Scientific review separately passed within its documented scope and uncertainty.
|
||||
The independent temporal delta review returned `TEMPORAL_DELTA_REVIEW=PASS`
|
||||
with no findings. One configured final-reviewer completed an initial full-tranche
|
||||
audit, reported the findings recorded below, and its authorized bounded repair
|
||||
chain completed. Independent bounded verification returned
|
||||
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS`; fresh final executable validation also
|
||||
passed. `TRAINING_KNOWLEDGE_V1=PASS`.
|
||||
|
||||
## Current implementation and contract
|
||||
|
||||
Six canonical scientific catalogs feed generated C data and the Android asset
|
||||
loader. Both clients expose scientific lookup/filter APIs and runtime context
|
||||
composition. Android has a collapsible knowledge section; the TUI has a UTF-8
|
||||
cell-aware scrolling knowledge screen tested at 72×20. No schema, scientific
|
||||
catalog, frozen exchange artifact or stored user history changed in this resume.
|
||||
|
||||
The temporal repair covers the two new C readers and their Android counterparts:
|
||||
occurrence pagination and latest explicit MAX. The established operational
|
||||
profile and parser-only extension classification are recorded in the
|
||||
[temporal contract](training_knowledge_v1_temporal_contract.md). All use exact
|
||||
integer-second/fraction comparisons and bytewise stable-ID ties. Pagination
|
||||
scans all matching metadata, retains at most `limit + 1`, then hydrates only
|
||||
selected rows under one read snapshot. Emitted cursors preserve source text.
|
||||
|
||||
C has the existing 40-character timestamp output limit: long valid candidates
|
||||
are compared exactly, and a selected unrepresentable result fails explicitly.
|
||||
Invalid caller cursors and malformed stored timestamps have distinct error
|
||||
paths. No malformed date can disappear through SQLite NULL comparison.
|
||||
|
||||
Python validation uses the same explicit grammar and exact chronology. The
|
||||
parent reproduced and repaired an optional-checker dependency issue after the
|
||||
worker implementation: a strict JSON Schema checker could reject omitted
|
||||
seconds before Trainlog validation. A private per-call override now aligns the
|
||||
schema-format check while preserving all other checker configuration. Actual
|
||||
validation-path tests cover absent, strict and permissive optional checkers.
|
||||
|
||||
## Original defect retained as historical evidence
|
||||
|
||||
The original implementation combined an offset-limited cursor guard with
|
||||
SQLite `julianday()` and a different Android parser. Its production probe was:
|
||||
|
||||
| Newer source timestamp | Original outcome | Current independent probe |
|
||||
|---|---|---|
|
||||
| `2026-09-10T10:00:00+02:00` | control passed | PASS |
|
||||
| `2026-09-10T10:00:00+14:30` | emitted cursor rejected | PASS |
|
||||
| `2026-09-10T10:00:00+15:00` | newer occurrence omitted | PASS |
|
||||
| `2026-09-10t10:00:00Z` | newer occurrence omitted | PASS |
|
||||
|
||||
The independent current probe extends this to twelve spellings. Every case
|
||||
uses two sessions in a temporary database, page size one, exact source/cursor
|
||||
assertions, continuation and exhaustion. Both normal and sanitized builds pass.
|
||||
Original evidence remains `/tmp/trainlog-knowledge-validation/cursor-probe.c`
|
||||
and `cursor-probe.log`; fresh final probe evidence is under
|
||||
`/tmp/trainlog-knowledge-review-resume/`.
|
||||
|
||||
## Final validation evidence
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `meson compile -C build` | PASS, fresh strict warning-as-error build |
|
||||
| `meson test -C build --print-errorlogs` | 42 passed |
|
||||
| Knowledge validator/generation tests | PASS; eight Python tests |
|
||||
| Temporal Python tests | PASS; four tests including optional-checker independence |
|
||||
| JSON/import-contract validators | PASS; six import cases |
|
||||
| Three standalone public C17 headers | PASS, including `-pedantic-errors` |
|
||||
| Two targeted C tests under ASan/UBSan | PASS; timestamp Python test also passes in that test invocation |
|
||||
| Independent twelve-form C production probe | PASS normally and under ASan/UBSan |
|
||||
| Android unit tests and debug assembly, Java 17 | PASS; 56 tests, zero failures/errors, one known fixture skip |
|
||||
| Project skill validator and `git diff --check` | PASS |
|
||||
|
||||
The known Android skip is `RealAndroidV9BodyZonesMigrationTest`, because its
|
||||
external `TRAINLOG_ANDROID_V9_FIXTURE` is unavailable. No knowledge/temporal
|
||||
test is skipped. Manual MTP, installed-device UI and real-terminal visual
|
||||
checks were not performed or claimed. Sanitizer evidence is scoped to the
|
||||
new affected C paths, not the complete GUI executable.
|
||||
|
||||
## Independent review and final-review repair record
|
||||
|
||||
The isolated temporal review was independent of the original implementation
|
||||
and reviewed grammar/calendar constraints, offset bounds, fractions, byte ties,
|
||||
cursor aliasing/exclusivity, source capacity, snapshots, and Android/Python
|
||||
parity. It ran Meson `training_context` and `timestamp_validation` plus four
|
||||
Python temporal tests, returned `TEMPORAL_DELTA_REVIEW=PASS`, and required no
|
||||
repair.
|
||||
|
||||
The configured final-reviewer then audited the full tranche. Its initial result
|
||||
was FAIL and identified these concrete findings:
|
||||
|
||||
| Severity | Finding | Authorized repair recorded in `/tmp/trainlog-knowledge-review-resume/repair.md` |
|
||||
|---|---|---|
|
||||
| BLOCKER | Stale temporal-defect documentation in `architecture.md`, `exercise_data_model.md`, and `tui.md` | Replaced claims of an unresolved reader defect with the independently passing temporal contract and correct repair/validation state. |
|
||||
| BLOCKER | Android loader parity holes | Enforced canonical exercise/equipment identity syntax, reverse capability compatibility, HIGH evidence source type, and non-unresolved BODY ZONE audit evidence; added loader regressions. |
|
||||
| BLOCKER | Meson generator inputs omitted `body-zones-v1.json` and `equipment-v1.json` although validation reads them | Declared both files as `training_knowledge_generated` inputs and proved regeneration without output-byte change. |
|
||||
| HIGH | C query accepted a muscle role without a muscle ID, unlike Android's paired filter | Rejected role-only queries, documented the paired contract in the public header, and added a C regression. |
|
||||
|
||||
The repair report records strict C17 syntax, knowledge validation, generated
|
||||
output equality, targeted Meson test, Android loader tests, dependency proof,
|
||||
and `git diff --check` as PASS. Independent bounded verification closed all
|
||||
four findings and explicitly returned
|
||||
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and
|
||||
`TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`. The fresh tester matrix then
|
||||
passed: strict build; 42 Meson tests; eight knowledge and four temporal Python
|
||||
tests; knowledge/JSON/import validators; three strict C17 headers; affected C
|
||||
knowledge/context tests under ASan/UBSan plus timestamp validation; normal and
|
||||
sanitized 12-form temporal probes; Android 56 tests with zero failures/errors
|
||||
and one known missing-fixture skip; Java 17 debug assembly; skill validation;
|
||||
and diff check. Fresh generated C is byte-identical with SHA-256
|
||||
`e8c099f67eb111d61621b5d76592c049823af5508d43e73ec646f22e4c377fca`; all six
|
||||
Android assets are byte-identical.
|
||||
|
||||
## Remaining observations
|
||||
|
||||
The prior bounded review's secondary scientific BODY ZONE IDs on the TUI
|
||||
knowledge page remain a nonblocking presentation inconsistency. Local machine
|
||||
model/execution uncertainty and missing real exercise UUIDs remain as recorded
|
||||
in the scientific review; no phantom identity or scientific reassessment was
|
||||
introduced. Older lexical timestamp readers, arbitrary-length C output and
|
||||
verified leap-event support are outside this bounded repair.
|
||||
120
docs/reviews/training_knowledge_v1_resume.md
Normal file
120
docs/reviews/training_knowledge_v1_resume.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Training Knowledge V1 — continuation record
|
||||
|
||||
Checkpoint: 2026-09-09. Status: `TRAINING_KNOWLEDGE_V1=PASS`.
|
||||
|
||||
This is a completed closeout record. Do not restart science, temporal
|
||||
implementation, broad audit, or the bounded repair chain. The independent
|
||||
temporal review returned `TEMPORAL_DELTA_REVIEW=PASS` with no findings. One
|
||||
deep final audit initially failed, one bounded repair chain closed its four
|
||||
findings, independent verification returned
|
||||
`FINAL_REVIEW_REPAIR_VERIFICATION=PASS` and
|
||||
`TRAINING_KNOWLEDGE_V1_ENGINEERING_REVIEW=PASS`, and fresh final executable
|
||||
validation passed.
|
||||
|
||||
## Baseline and protections
|
||||
|
||||
- Repository: `/home/fy59/Documents/trainlog`.
|
||||
- Baseline HEAD: `1aad7b48b3143783ccfd326c0145f495fd568442`.
|
||||
- The initial worktree was clean; the current delta belongs to this tranche.
|
||||
- This resume initially matched every file hash in the preserved final manifest.
|
||||
- No stage, commit, push, reset, restore, stash or clean was performed.
|
||||
- Desktop schema remains v11 and Android schema remains v10.
|
||||
- Real database logical contents/counts and all scientific/frozen catalogs are
|
||||
preserved. No device install, uninstall, keystore or user-history change.
|
||||
|
||||
## Completed during the temporal correction
|
||||
|
||||
1. `advisor` / Terra-high completed the isolated contract analysis. Its retained
|
||||
handoff is `/tmp/trainlog-temporal-contract-settled.md`; the durable repository
|
||||
record is [temporal contract](training_knowledge_v1_temporal_contract.md).
|
||||
2. `worker` / Sol-medium implemented exact C/Kotlin timestamp parsers,
|
||||
scan/select/hydrate occurrence and latest-MAX reads, coherent exclusive
|
||||
cursors, nested read snapshots and production regressions. Python validation
|
||||
uses the same grammar and exact fractional comparison.
|
||||
3. Parent's independent production C probe passed all twelve temporal spellings
|
||||
through two sessions, page size one, source/cursor equality and exhaustion.
|
||||
4. A bounded additional validator defect was reproduced: a strict optional
|
||||
JSON Schema format checker could still reject the Android no-seconds form
|
||||
before semantic validation. A per-validation `date-time` override now uses
|
||||
the Trainlog parser without mutating global/caller checkers. Document-path
|
||||
regressions cover missing, strict and permissive checkers.
|
||||
5. Full executable validation passed, including the independent C probe under
|
||||
ASan/UBSan. Canonical documentation describes the settled behavior.
|
||||
|
||||
Scientific review remains PASS within its recorded scope; no scientific delta
|
||||
was introduced. Its reviewed catalog hashes remain applicable.
|
||||
|
||||
## Completed independent review and repair chain
|
||||
|
||||
The independent temporal reviewer covered grammar, calendar and offset ranges,
|
||||
fractions, byte ties, cursor aliasing/exclusivity, source capacity, snapshots,
|
||||
and Android/Python parity. It ran the targeted Meson and four Python temporal
|
||||
tests and returned `TEMPORAL_DELTA_REVIEW=PASS`, with no temporal repair.
|
||||
|
||||
The one final-reviewer audited the complete tranche. Its initial FAIL found
|
||||
stale temporal-defect documentation (BLOCKER), Android loader parity holes
|
||||
(BLOCKER), missing Meson generator dependencies (BLOCKER), and C acceptance of
|
||||
a role-only muscle query (HIGH). The authorized repair report is
|
||||
`/tmp/trainlog-knowledge-review-resume/repair.md`: it records the exact loader
|
||||
constraints, Meson input declarations, C paired-filter rejection, regressions,
|
||||
and repair-scope validation. Independent review of that delta closed all four
|
||||
findings in `/tmp/trainlog-knowledge-review-resume/repair-verification.md`.
|
||||
No scientific catalog, schema, frozen format, temporal implementation, or
|
||||
user-history content changed.
|
||||
|
||||
## Closeout evidence
|
||||
|
||||
Fresh final validation recorded in
|
||||
`/tmp/trainlog-knowledge-review-resume/full-results.json`,
|
||||
`android-results.json`, `sanitizer-results.json`,
|
||||
`probe-normal-results.json`, and `android-counts.json` passed: strict build;
|
||||
42 Meson tests; eight knowledge and four temporal Python tests;
|
||||
knowledge/JSON/import validators; three strict C17 headers; affected C
|
||||
knowledge/context tests under ASan/UBSan plus timestamp validation; normal and
|
||||
sanitized independent 12-form temporal probes; Android 56 tests with zero
|
||||
failures/errors and one known missing-real-v9-fixture skip; Java 17 debug
|
||||
assembly; skill validation; and diff check. Fresh generated C is byte-identical
|
||||
with SHA-256 `e8c099f67eb111d61621b5d76592c049823af5508d43e73ec646f22e4c377fca`;
|
||||
all six Android assets are byte-identical.
|
||||
|
||||
Preservation before and after repair confirms desktop schema v11 and Android
|
||||
schema v10; unchanged frozen/scientific catalogs and temporal implementation;
|
||||
real database logical SHA-256
|
||||
`26139cafeffbde3ec08f6ef23c5069e75afb9cd69ffffb40be5c099006fedc4d`; counts
|
||||
of 1 body observation, 6 continuous activities, 3 custom equipment, 23 zone
|
||||
sync rows, 32 exercise-zone rows, 23 exercises, 19 max results, 28 performed
|
||||
sets, 31 occurrences, and 3 sessions; `PRAGMA integrity_check = ok`; clean
|
||||
foreign-key check; and an empty index. Current closeout capture locations are
|
||||
`/tmp/trainlog-knowledge-review-resume/final.patch` and
|
||||
`/tmp/trainlog-knowledge-review-resume/final-manifest.json`. The completed capture
|
||||
includes all 19 modified tracked files and 37 untracked files, validates their
|
||||
hashes and whitespace, and confirms the real index remained untouched and empty.
|
||||
|
||||
## Validation and retained evidence
|
||||
|
||||
- 42 desktop tests; eight Python knowledge tests; four Python temporal tests.
|
||||
- Android tests and debug assembly: 56 tests, zero failures/errors, one skipped
|
||||
`RealAndroidV9BodyZonesMigrationTest` because `TRAINLOG_ANDROID_V9_FIXTURE`
|
||||
is unavailable. No knowledge/temporal test is skipped.
|
||||
- Catalog, JSON and import-contract validators; project skill validator.
|
||||
- Three public C17 headers with `-pedantic-errors` and strict warning flags.
|
||||
- Two affected C tests under ASan/UBSan, plus the temporal Python test; independent
|
||||
twelve-form production-API probe also passes under ASan/UBSan.
|
||||
- `git diff --check` passes. Manual MTP/device/visual checks remain unclaimed.
|
||||
|
||||
Current logs/results and reusable runner include
|
||||
`/tmp/trainlog-knowledge-review-resume/full-results.json`,
|
||||
`android-results.json`, `android-counts.json`, `sanitizer-results.json`, and
|
||||
`probe-normal-results.json`.
|
||||
Reusable sanitizer build: `/tmp/trainlog-knowledge-validation/build`.
|
||||
Implementation record: `/tmp/trainlog-temporal-implementation.md`.
|
||||
|
||||
The older `/tmp/trainlog-knowledge-final.*` files are a previous checkpoint,
|
||||
not current complete evidence. Durable contract/evidence is in this review
|
||||
directory when `/tmp` is absent.
|
||||
|
||||
Known explicit limits: C output timestamp capacity 40 characters with explicit
|
||||
failure for a selected longer value; no leap-event table (`:60` rejected);
|
||||
O(N) metadata scan per page; older unrelated lexical history readers remain
|
||||
future scope. No source-text normalization, migration or history rewrite is
|
||||
part of this correction.
|
||||
154
docs/reviews/training_knowledge_v1_scientific_review.md
Normal file
154
docs/reviews/training_knowledge_v1_scientific_review.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# TRAINING KNOWLEDGE V1 — final scientific review
|
||||
|
||||
Date: 2026-09-09. Result: **PASS within the documented scope and uncertainty**.
|
||||
No blocking scientific correction or confidence downgrade is required.
|
||||
|
||||
This bounded review assessed the six canonical scientific catalogs against the
|
||||
previously researched source corpus, reviewed staging interpretations and
|
||||
`schema_supplement.json`. It also inspected the generated BODY ZONE audit,
|
||||
`docs/domain/` synthesis and Android's resolved-versus-conditional query guard.
|
||||
It is a scientific review, not a build, persistence or UI validation report.
|
||||
No production code, canonical catalog, database or persisted relation was edited
|
||||
by this review.
|
||||
|
||||
## Coverage and fidelity
|
||||
|
||||
| Canonical collection | Count | Finding |
|
||||
|---|---:|---|
|
||||
| Scientific references | 23 | Bibliography, identifiers, source types, limitations and claims preserved |
|
||||
| Functional muscle entities | 53 | Muscles, regions and groups remain distinct; membership overlap documented |
|
||||
| Joint actions | 35 | Actions, joint complexes, nominal planes and non-exhaustive contributors preserved |
|
||||
| Movement patterns | 26 | Authored conventions, French names, zones and anti-motion distinctions preserved |
|
||||
| Exercise identities | 23 | 17 resolved families, 4 conditional candidates, 2 unresolved warm-ups |
|
||||
| Equipment identities | 41 | 38 supplied and 3 custom; manufacturer and model remain null throughout |
|
||||
| BODY ZONE audit rows | 23 | 16 confirmed, 4 questionable, 3 unresolved; no proposed mutation |
|
||||
|
||||
Scientific fields compare equal to the settled staging data plus the reviewed
|
||||
supplement after ignoring array order. References compare equal under the
|
||||
canonical field renaming. The rotary-torso compatibility representation follows
|
||||
`representation-decisions.md`: a symmetric UUID association marked
|
||||
`catalog_compatible_not_observed_occurrence` does not assert observed history.
|
||||
|
||||
The exercise confidence distribution is 6 `high`, 11 `moderate` and 6
|
||||
`uncertain`. The equipment scientific-status distribution is 21
|
||||
`scientifically_documented`, 17 `mechanically_identified_anatomy_incomplete`
|
||||
and 3 `equipment_identity_uncertain`. Scientific documentation of a generic
|
||||
machine family does not certify its local model, trajectory or calibration.
|
||||
|
||||
## All six high-confidence exercise mappings
|
||||
|
||||
Here `high` applies to the explicitly limited movement-family interpretation:
|
||||
the relevant action, plausible muscle roles and coarse BODY ZONE projection.
|
||||
It does not assert measured local force shares, a known manufacturer, exact
|
||||
resistance curves, equal adaptation or transferable MAX. The source hierarchy
|
||||
supports these bounded claims without requiring an EMG study for every simple
|
||||
anatomical action.
|
||||
|
||||
| Stable exercise ID | Identified variant and equipment | Actions, roles and zones | Evidence assessment |
|
||||
|---|---|---|---|
|
||||
| `ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef` | Hip adduction; recorded `hip_adduction` context | Resisted hip adduction; adductor group; `thighs` | High retained. Established adductor function supports the family mapping; individual contributions depend on hip/knee position. |
|
||||
| `ex_1872246a-39ae-44dc-b58d-f87e90ca49ab` | Leg extension; recorded `leg_extension` context | Knee extension; quadriceps; `thighs` | High retained. Rectus femoris/vasti distinction and alignment/hip-angle limitations are explicit. |
|
||||
| `ex_617007f9-7420-4408-91b9-8ffb77900f13` | Seated Leg; recorded `seated_leg_curl` establishes curl interpretation beyond the ambiguous label | Knee flexion; hamstrings with possible gastrocnemius assistance; `thighs` | High retained. Equipment provenance identifies the variant; no name heuristic or identity merge. |
|
||||
| `ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163` | Seated leg curl; recorded `seated_leg_curl` | Knee flexion; hamstrings with gastrocnemius assistance; `thighs` | High retained. Hip-flexion length effect is confined to biarticular hamstrings; no outcome equivalence claimed. |
|
||||
| `ex_d7398d9f-d928-4d2e-94e9-74e201da55c5` | Prone leg curl; recorded `prone_leg_curl` | Knee flexion; hamstrings with position-dependent gastrocnemius assistance; `thighs` | High retained. Prone/seated context remains distinct, without an unverified exact hip angle. |
|
||||
| `ex_ec619fc2-4685-4044-873c-86764bd4a0fe` | Arm curl; recorded `arm_curl` | Elbow flexion; biceps/brachialis primary, brachioradialis secondary, plausible forearm stabilization; `arms` | High retained for qualitative family roles. Grip/shoulder support limitations prevent a universal quantitative ranking. |
|
||||
|
||||
The first five rows use established lower-limb anatomy. OpenStax's main text
|
||||
identifies the adductors, knee extensors and knee flexors and distinguishes
|
||||
lower-leg plantarflexors. Inconsistent image alternative text is not used to
|
||||
reverse an anatomical action.
|
||||
[OpenStax lower-limb anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-6-appendicular-muscles-of-the-pelvic-girdle-and-lower-limbs)
|
||||
|
||||
The seated/prone curl distinction also has an intervention source which
|
||||
explicitly considers biarticular hamstrings and biceps femoris short head.
|
||||
Its longitudinal finding is not generalized to all people, machines or
|
||||
strength tasks. [Maeo and colleagues](https://pubmed.ncbi.nlm.nih.gov/33009197/)
|
||||
|
||||
The curl mapping is supported by the anatomical elbow-flexor and forearm
|
||||
functions. Its primary/secondary labels are qualitative exercise interpretation,
|
||||
not ratios established by the textbook.
|
||||
[OpenStax upper-limb anatomy](https://openstax.org/books/anatomy-and-physiology-2e/pages/11-5-muscles-of-the-pectoral-girdle-and-upper-limbs)
|
||||
|
||||
## Representation checks
|
||||
|
||||
- Custom Chest press, Converting chest press and Abdominal retain
|
||||
`interpretation: null`. Their useful candidate interpretations remain
|
||||
explicitly conditional and uncertain. Chest candidates include the reviewed
|
||||
`pectoralis_major` / `horizontal_push` interpretation only under stated
|
||||
execution assumptions.
|
||||
- Marche retains a conditional gait interpretation, without creating an
|
||||
occurrence-to-treadmill association or assigning a persisted full-body zone.
|
||||
Both generic warm-up identities have neither ordinary nor candidate mapping.
|
||||
- Rear Delt is linked to its actual UUID on the combined apparatus. Pec Fly
|
||||
is a separate unlinked capability. Assisted dip and assisted chin/pull-up
|
||||
are separate unlinked capabilities with empty actual exercise-ID lists.
|
||||
No capability or legacy manifest slug becomes a phantom exercise identity.
|
||||
- The four added aggregates preserve member and overlap semantics. The
|
||||
functional hip-flexor and spinal-stabilizer groups are non-exhaustive.
|
||||
Their action lists describe member capabilities, not actions shared by every
|
||||
member. Anatomical regions remain `muscle_region`.
|
||||
- Action contributors are non-exhaustive; missing a possible contributor does
|
||||
not claim absence of participation. Nominal planes are not trajectory
|
||||
constraints. Anti-extension, anti-rotation and lateral stabilization have
|
||||
no invented dynamic joint action and remain authored task conventions.
|
||||
- Manufacturer/model fields are null for every physical identity. Example
|
||||
manufacturer references remain mechanics/identification examples rather
|
||||
than anatomical outcome evidence or local equipment identification.
|
||||
- EMG findings remain distinct from intervention evidence and do not establish
|
||||
force shares, hypertrophy rankings or universal primary-muscle status.
|
||||
[Vigotsky and colleagues](https://pubmed.ncbi.nlm.nih.gov/29354060/)
|
||||
- BODY ZONE audit `confirmed` means the existing coarse projection is
|
||||
scientifically compatible under the stated interpretation. It does not
|
||||
certify observed technique. `questionable` preserves conditional geometry
|
||||
or identity; it is not a direction to mutate persistence.
|
||||
- Android's inspected ordinary query path obtains only
|
||||
`resolved_family_variant_limited` interpretations. Conditional/null records
|
||||
cannot enter ordinary muscle, pattern or scientific-zone matches through
|
||||
that guard. Broader runtime behavior remains the engineering review's scope.
|
||||
|
||||
## Remaining uncertainty and handoff
|
||||
|
||||
Exact local manufacturer/model, resistance curves, range settings and detailed
|
||||
trajectories remain unresolved. Custom apparatus require actual execution
|
||||
confirmation. Back Extension requires pelvic-restraint and spinal-versus-hip
|
||||
motion evidence. Row/pulldown contributions depend on arm path and support;
|
||||
hip-abduction contributions depend on hip angle. Rotary torso compatibility
|
||||
must not be presented as a historical occurrence. Missing Pec Fly and assisted
|
||||
exercise identities require real catalog creation or identity evidence before
|
||||
linkage. Generic warm-ups require their actual activity sequences.
|
||||
|
||||
The scientific foundation supports contextual MAX interpretation only.
|
||||
Assistance is not external resistance; no conversion, estimated 1RM, percentage
|
||||
prescription, universal recovery duration or exercise-equivalence guarantee is
|
||||
justified by these catalogs.
|
||||
|
||||
These uncertainties require observation or future scoped research. They do
|
||||
not require xhigh escalation solely because the catalog is large. Scientific
|
||||
implementation handoff: preserve the reviewed distinctions and current
|
||||
confidence levels, and complete engineering validation separately. No BODY
|
||||
ZONE migration is recommended.
|
||||
|
||||
## Reviewed catalog content hashes
|
||||
|
||||
SHA-256 values identify the exact reviewed catalog bytes. A subsequent change
|
||||
to scientific fields requires a bounded delta review.
|
||||
|
||||
| File | SHA-256 |
|
||||
|---|---|
|
||||
| `science-references-v1.json` | `ceef31455e0c1da1bc383d4b3a7dc63198cfb62ba0f80e26bc79bae77abb7836` |
|
||||
| `muscles-v1.json` | `21cb08c9c2ca393d5c87cf73a607175b7c267061687767912a0c92769be5ff4b` |
|
||||
| `joint-actions-v1.json` | `a66737f4419bcc4040d0abe5ca525b2a185e80f0bcf679091222eb540b3fd8a1` |
|
||||
| `movement-patterns-v1.json` | `ddd4e3522ebcb4df4618ed2a952fdb5834c42e21e7220a0afd69ab8f3437f072` |
|
||||
| `exercise-knowledge-v1.json` | `30ee4f400626af9a39c0547ae28360db94e76736462501e113f57721429ef43f` |
|
||||
| `equipment-knowledge-v1.json` | `b96ca23026fa1f0eb94bc8c0b2a586951130919db5342ee5d60ff1353725acb2` |
|
||||
|
||||
### Subsequent representation verification
|
||||
|
||||
The authored BODY ZONE audit was subsequently embedded in each exercise record
|
||||
so both platforms and the audit generator can read it from the canonical source.
|
||||
An automated comparison verified that removing only `body_zone_audit` reproduces
|
||||
the exact reviewed exercise-catalog SHA-256 above. All 23 embedded annotations
|
||||
also equal the reviewed audit data after the documented status and array-order
|
||||
normalization. No scientific assertion or confidence changed. The complete
|
||||
exercise catalog now has SHA-256
|
||||
`a2c72e4743dc5dc24d1a7bbf9e8234442adc2d5ef6882f15cadea1cb9629d647`.
|
||||
139
docs/reviews/training_knowledge_v1_temporal_contract.md
Normal file
139
docs/reviews/training_knowledge_v1_temporal_contract.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# Training Knowledge V1 — temporal contract and correction
|
||||
|
||||
Date: 2026-09-09. Contract analysis: advisor / Terra-high, completed.
|
||||
Temporal implementation and executable validation: PASS. Independent temporal
|
||||
delta review: `TEMPORAL_DELTA_REVIEW=PASS`, with no findings or repairs. The
|
||||
initial full-tranche audit found non-temporal defects and its authorized repair
|
||||
chain completed. Independent bounded verification and fresh final validation
|
||||
subsequently passed; `TRAINING_KNOWLEDGE_V1=PASS`. This record preserves the
|
||||
temporal contract and does not expand scientific scope or declare a frozen
|
||||
format decision.
|
||||
|
||||
## Authority and compatibility
|
||||
|
||||
The frozen [exchange timestamp rule](../exchange_format.md#7-session-timestamps)
|
||||
requires RFC 3339 / ISO 8601 date-time text with an explicit UTC offset. The V1
|
||||
schema declares `format: date-time`. The operational reader profile is:
|
||||
|
||||
```text
|
||||
YYYY-MM-DD[Tt]HH:MM[:SS[.digits]](Z|z|±HH:MM)
|
||||
```
|
||||
|
||||
It uses extended Gregorian dates in years 0001–9999, valid calendar days,
|
||||
hours 00–23, minutes 00–59 and ordinary seconds 00–59. Fractions require
|
||||
seconds and at least one ASCII digit after a dot. Every fractional digit
|
||||
participates in comparison; trailing zeros do not change an instant.
|
||||
|
||||
Numeric offsets range through ±23:59. Lowercase `t`/`z` and the offset grammar
|
||||
follow [RFC 3339 §5.6](https://www.rfc-editor.org/rfc/rfc3339#section-5.6).
|
||||
`-00:00` compares as a zero UTC offset while its distinct source spelling is
|
||||
preserved. Omitted seconds are an application compatibility requirement:
|
||||
Android persists `OffsetDateTime.now().toString()`, which can omit seconds
|
||||
when seconds and fractional seconds are both zero.
|
||||
|
||||
Basic dates, ISO week dates, spaces instead of `T/t`, comma fractions, compact
|
||||
offsets and hour-only offsets are **NOT CONTRACTUALLY REQUIRED**. They are
|
||||
rejected by the selected profile. The observed acceptance of these forms by
|
||||
Python `datetime.fromisoformat()` is not normative evidence: this environment's
|
||||
jsonschema 4.26.0 has no registered default `date-time` checker. No Trainlog
|
||||
writer, pre-existing fixture or inspected real session establishes a need for
|
||||
those extensions. All three real session timestamps match the selected
|
||||
profile and fit the existing C result fields.
|
||||
|
||||
Years outside 0001–9999 and `:60` remain outside the established operational
|
||||
admission. Supporting real leap seconds would require a verified shared
|
||||
leap-event schedule and matching validator behavior; the repair does not
|
||||
guess a conversion or collapse a leap second into the following minute.
|
||||
[RFC 3339 §5.7](https://www.rfc-editor.org/rfc/rfc3339#section-5.7)
|
||||
|
||||
## Root cause and implementation
|
||||
|
||||
The old cursor guard, SQLite `julianday()`, Java `OffsetDateTime.parse()` and
|
||||
Python's ISO parser had different languages and precision. A `+14:30` source
|
||||
produced a rejected cursor. SQLite returned NULL for valid `+15:00` and
|
||||
lowercase-`t` values, allowing records to disappear. Floating-point Julian
|
||||
days also did not preserve arbitrary fractional precision.
|
||||
|
||||
The C and Kotlin readers now parse source text into an integer UTC second
|
||||
and an exact fractional digit sequence. Both occurrence pagination and the
|
||||
new latest-explicit-MAX reader order descending by:
|
||||
|
||||
```text
|
||||
(represented UTC instant, session_id bytes, entry_id bytes)
|
||||
```
|
||||
|
||||
The cursor applies the same comparison exclusively. Equal instants with
|
||||
different offsets or fractional trailing zeros use identity tie breakers,
|
||||
never timestamp-text tie breakers. Cursor emission preserves the source
|
||||
timestamp and both IDs. C supports aliasing the input and output cursor.
|
||||
|
||||
Each read scans all matching metadata candidates and retains at most
|
||||
`limit + 1` occurrence candidates, or one MAX candidate. Only selected rows
|
||||
are hydrated. It does not use a timestamp SQL predicate, SQL temporal order
|
||||
or SQL limit before comparison. Scan work is O(N) for the exercise history;
|
||||
retained candidate count is O(limit), with no global dataset-size cap. Each
|
||||
read has a snapshot covering selection and hydration. Separate page calls
|
||||
retain the existing current-data semantics, without a cross-call snapshot.
|
||||
|
||||
Malformed caller cursors fail before the scan. Malformed matching stored
|
||||
timestamps cause an explicit database/consistency error; they are not
|
||||
silently skipped. Source timestamps are never rewritten or normalized in
|
||||
persistent storage.
|
||||
|
||||
The C public timestamp fields retain their existing 40-character capacity.
|
||||
Longer valid SQLite text is compared with full precision; if selected for
|
||||
output, it produces `DATABASE_ERROR`. It is never truncated or silently
|
||||
omitted. Android can return longer strings. This is an existing C API
|
||||
representation limit, not a new wire precision bound. Arbitrary-length C
|
||||
output would require a separate ownership/length API change.
|
||||
|
||||
The Python validator uses the same grammar and exact chronology. It overrides
|
||||
only `date-time` on a fresh per-validation format-checker instance, preserving
|
||||
other checks and caller/global state. The document path therefore admits
|
||||
Android's no-seconds form even when an optional strict RFC checker is present.
|
||||
|
||||
## Regression and validation evidence
|
||||
|
||||
Production C and Android tests cover mixed representations, page size one,
|
||||
emitted cursors, continuation, exhaustion, no duplicates, chronological order,
|
||||
equal-instant session/entry ties, near-equal fractions, malformed cursors and
|
||||
stored values, and latest-MAX selection. C also checks selected and unselected
|
||||
timestamps beyond its fixed output capacity. Python tests cover grammar,
|
||||
exact chronology, date/offset bounds and missing/strict/permissive optional
|
||||
format checkers through actual document validation.
|
||||
|
||||
An independent C production-API probe exercises twelve admitted spellings:
|
||||
`+02:00`, `+14:30`, `+15:00`, lowercase `t`, `Z`, lowercase `t/z`, omitted
|
||||
seconds, `+23:59`, `-23:59`, `-00:00`, and two long fractional forms. Each case
|
||||
uses two sessions in a temporary database, page size one, exact source/cursor
|
||||
equality checks and traversal through exhaustion. It passes normally and
|
||||
under ASan/UBSan.
|
||||
|
||||
Full validation passes: 42 desktop tests; eight knowledge Python tests; four
|
||||
timestamp Python tests; JSON/import/catalog validators; three standalone C17
|
||||
headers; Android unit tests and debug assembly (56 tests, zero failures or
|
||||
errors, one pre-existing missing-fixture skip); two targeted C sanitizer tests
|
||||
plus the timestamp Python test; project skill validation; `git diff --check`.
|
||||
Logs and reusable runner: `/tmp/trainlog-temporal-validation/`.
|
||||
|
||||
The independent temporal reviewer covered grammar, calendar validity, offset
|
||||
ranges, exact fractions, bytewise ties, cursor aliasing and exclusivity, source
|
||||
capacity, snapshot behavior, and Android/Python parity. It ran the targeted
|
||||
Meson `training_context` and `timestamp_validation` tests and all four Python
|
||||
temporal tests. It reported no temporal defect, repair, or unresolved temporal
|
||||
boundary.
|
||||
|
||||
## Preserved boundaries and remaining scope
|
||||
|
||||
Desktop schema v11, Android schema v10, stable IDs, real database logical
|
||||
contents and counts, BODY ZONES, equipment and all scientific catalog bytes
|
||||
remain unchanged. Scientific review stays valid without a scientific delta.
|
||||
No staging, commit, push, database deletion, migration, device installation,
|
||||
uninstall, keystore change or history rewrite occurred.
|
||||
|
||||
Older general history/export/MAX-list readers with lexical timestamp ordering
|
||||
are outside this bounded new-reader repair. Leap-event support, arbitrary-
|
||||
length C output and manual device/MTP/visual checks remain explicit future or
|
||||
manual work. The previously recorded secondary-zone-ID TUI presentation
|
||||
inconsistency remains nonblocking. Final-review repair verification and fresh
|
||||
final validation completed with `TRAINING_KNOWLEDGE_V1=PASS`.
|
||||
27
docs/reviews/training_knowledge_v1_temporal_review.md
Normal file
27
docs/reviews/training_knowledge_v1_temporal_review.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Training Knowledge V1 — independent temporal delta review
|
||||
|
||||
Date: 2026-09-09. Result: `TEMPORAL_DELTA_REVIEW=PASS`.
|
||||
|
||||
This isolated review found no temporal defect and required no repair. It does
|
||||
not declare the wider `TRAINING_KNOWLEDGE_V1` tranche PASS or FROZEN.
|
||||
|
||||
## Scope and evidence
|
||||
|
||||
The reviewer independently examined the settled temporal reader contract for:
|
||||
|
||||
- accepted grammar, calendar validation, and numeric offset range;
|
||||
- exact fractional-second comparison and bytewise stable-ID ties;
|
||||
- exclusive cursor behavior and C input/output cursor aliasing;
|
||||
- source-text output capacity and explicit failure behavior;
|
||||
- selection/hydration snapshot boundaries; and
|
||||
- parity between C, Android, and Python document validation.
|
||||
|
||||
It ran Meson `training_context` and `timestamp_validation` tests and all four
|
||||
Python temporal tests. No temporal semantic discrepancy, repair, or remaining
|
||||
temporal finding was reported.
|
||||
|
||||
The settled admitted source profile, ordering, cursor, snapshot, and explicit
|
||||
error semantics remain verbatim in the [temporal contract](training_knowledge_v1_temporal_contract.md).
|
||||
The separate final-review repair verification and final validation subsequently
|
||||
passed. `TRAINING_KNOWLEDGE_V1=PASS`; this record remains limited to the
|
||||
independent temporal review and does not expand scientific scope.
|
||||
|
|
@ -31,7 +31,7 @@ BODY_ZONES_V1=PASS
|
|||
BODY_ZONE_SYNC_V1=PASS
|
||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
||||
|
||||
DESKTOP_TESTS=39/39 PASS
|
||||
DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint)
|
||||
TUI_NOTCURSES_V1=PASS
|
||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||
NOTCURSES_TRUECOLOR_THEME=PASS
|
||||
|
|
@ -63,6 +63,18 @@ descendant-aware filters, unclassified history and one explicit-conflict sync
|
|||
companion. It does not implement a session generator, custom zones or proposed
|
||||
loads.
|
||||
|
||||
`TRAINING_KNOWLEDGE_V1=PASS` is read-only infrastructure. Scientific review,
|
||||
independent temporal review, final engineering review, repair verification, and
|
||||
final executable validation passed. The temporal contract preserves source text
|
||||
and exact C/Android chronological pagination; one bounded repair chain closed
|
||||
the audit's stale-documentation, Android-loader, Meson-input, and role-only C
|
||||
query findings.
|
||||
It provides catalog-backed scientific lookup and
|
||||
runtime context composition without prescriptions, schema changes or catalog
|
||||
seeding. The documented future session/program input pipeline is deliberately
|
||||
not a roadmap gate or an implemented generator; see
|
||||
[Training knowledge system V1](domain/knowledge_system.md).
|
||||
|
||||
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits
|
||||
stable-ID renames, protects referenced profiles, and reconciles same-ID display
|
||||
metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only
|
||||
|
|
|
|||
|
|
@ -528,3 +528,48 @@ inspection/pull. Applying the validated state to both real stores and running
|
|||
the two transport directions remains an out-of-sandbox hardware validation.
|
||||
|
||||
Detailed retained evidence: [Android draft execution record](reviews/android_session_draft_v1_resume.md).
|
||||
|
||||
## 15. Training knowledge V1 validation checkpoint
|
||||
|
||||
The current implementation passes the training-knowledge catalog
|
||||
validator, eight Python generation/catalog regressions, 42 desktop Meson tests,
|
||||
and standalone C17 public-header checks for `training_knowledge.h`,
|
||||
`training_context.h` and `database.h`. A targeted AddressSanitizer/Undefined-
|
||||
Behavior-Sanitizer build passed the two new desktop API tests. Android
|
||||
`testDebugUnitTest assembleDebug` passed with 56 tests, zero failures/errors,
|
||||
and one skipped real-v9 fixture test because `TRAINLOG_ANDROID_V9_FIXTURE` was
|
||||
not available. The TUI knowledge screen's UTF-8 cell-aware scrolling was tested
|
||||
at the 72x20 minimum terminal.
|
||||
|
||||
The catalog validator, JSON validator and import-contract validator also pass.
|
||||
`git diff --check` passes. Scientific-review metadata and
|
||||
catalog hashes were verified separately.
|
||||
|
||||
Temporal regressions now cover the original `+14:30`, `+15:00` and lowercase-`t`
|
||||
failure, lowercase `z`, omitted seconds, high/negative offsets, exact fractions,
|
||||
stable identity ties, cursor reuse, traversal through exhaustion and malformed
|
||||
stored values. C tests also cover selected/unselected values beyond its fixed
|
||||
output capacity. Four Python tests cover the explicit grammar and exact
|
||||
chronology, including actual document admission with missing, strict and
|
||||
permissive optional JSON Schema format checkers. An independent twelve-form
|
||||
production C probe passes normally and under ASan/UBSan.
|
||||
|
||||
Fresh final validation passed: strict build; 42 Meson tests; eight Python
|
||||
knowledge tests; four Python temporal tests; knowledge, JSON, and import
|
||||
validators; three strict C17 headers; affected C knowledge/context tests under
|
||||
ASan/UBSan plus Python timestamp validation; normal and sanitized independent
|
||||
12-form temporal probes; Android 56 tests with zero failures/errors and one
|
||||
known missing real-v9 fixture skip; Java 17 debug assembly; skill validation;
|
||||
and `git diff --check`. Generated C is byte-identical with SHA-256
|
||||
`e8c099f67eb111d61621b5d76592c049823af5508d43e73ec646f22e4c377fca`; all six
|
||||
Android assets are byte-identical.
|
||||
|
||||
`TRAINING_KNOWLEDGE_V1=PASS`. The independent temporal review passed with no
|
||||
findings; its coverage included parser grammar and limits, exact chronology,
|
||||
ties, cursor semantics, source capacity, snapshots, and Android/Python parity.
|
||||
The initial full audit's stale temporal documentation, Android loader, Meson
|
||||
dependency, and C role-only query findings were repaired and independently
|
||||
verified. See the [temporal contract](reviews/training_knowledge_v1_temporal_contract.md)
|
||||
for the established contract. No real Android install,
|
||||
manual TUI visual exercise, or manual MTP hardware validation was performed
|
||||
for Training Knowledge V1.
|
||||
|
|
|
|||
15
docs/tui.md
15
docs/tui.md
|
|
@ -465,3 +465,18 @@ Proportions can display:
|
|||
|
||||
All estimates are explicitly labeled as estimates. No result is converted into
|
||||
a medical or diagnostic classification.
|
||||
|
||||
## 18. Training knowledge infrastructure
|
||||
|
||||
The desktop core includes immutable `training_knowledge.h` catalog access and
|
||||
read-only `training_context.h` composition. The context uses real persisted IDs
|
||||
and returns persisted zones, optional science, compatible equipment, latest
|
||||
explicit MAX and bounded chronological history; it does not write data. The
|
||||
TUI provides a UTF-8 cell-aware scrolling knowledge screen, tested at the 72x20
|
||||
minimum terminal. V1 provides no prescription, generator or scoring flow. Its
|
||||
lifecycle is `TRAINING_KNOWLEDGE_V1=PASS`. Its occurrence and latest-MAX readers use the settled
|
||||
temporal contract: exact instant/fraction ordering, bytewise stable-ID ties,
|
||||
exclusive source-text cursors, bounded selection before hydration, and one
|
||||
read snapshot per call. Malformed cursors and matching stored timestamps fail
|
||||
explicitly. The full-tranche audit repair chain and independent verification
|
||||
passed. See [Training knowledge system V1](domain/knowledge_system.md).
|
||||
|
|
|
|||
10
tests/fixtures/invalid/timestamp-space-separator.json
vendored
Normal file
10
tests/fixtures/invalid/timestamp-space-separator.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"format": "trainlog",
|
||||
"version": 1,
|
||||
"exercises": [],
|
||||
"session": {
|
||||
"session_id": "invalid-temporal-space",
|
||||
"started_at": "2026-09-05 18:34:12+02:00",
|
||||
"exercises": []
|
||||
}
|
||||
}
|
||||
11
tests/fixtures/valid/temporal-extended-forms.json
vendored
Normal file
11
tests/fixtures/valid/temporal-extended-forms.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"format": "trainlog",
|
||||
"version": 1,
|
||||
"exercises": [],
|
||||
"session": {
|
||||
"session_id": "temporal-extended-forms",
|
||||
"started_at": "2026-09-05t18:34:12.12345678901234567890z",
|
||||
"ended_at": "2026-09-06T18:34+23:59",
|
||||
"exercises": []
|
||||
}
|
||||
}
|
||||
57
tests/test_timestamp_validation.py
Normal file
57
tests/test_timestamp_validation.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Focused frozen timestamp grammar and exact chronology regressions."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
from validate_json import TrainlogSemanticError, parse_timestamp # noqa: E402
|
||||
import validate_json as validation # noqa: E402
|
||||
|
||||
class TimestampValidationTest(unittest.TestCase):
|
||||
def test_document_admission_does_not_depend_on_optional_format_checker(self):
|
||||
schema = validation.load_json(validation.SCHEMA_PATH)
|
||||
# Simulate missing, stricter RFC-only, and permissive optional checkers.
|
||||
# Exercise the actual document path, including schema checks before semantics.
|
||||
for mode in ("missing", "strict", "permissive"):
|
||||
checker = validation.jsonschema.FormatChecker()
|
||||
checker.checkers.pop("date-time", None)
|
||||
if mode != "missing":
|
||||
checker.checks("date-time")(
|
||||
lambda value, strict=mode == "strict": not strict
|
||||
or not isinstance(value, str)
|
||||
or len(value) > 19 and value[16] == ":"
|
||||
)
|
||||
original_checks = checker.checkers.copy()
|
||||
validator = validation.jsonschema.Draft202012Validator(schema, format_checker=checker)
|
||||
with self.subTest(mode=mode):
|
||||
self.assertEqual([], validation.validate_document(
|
||||
validator, validation.VALID_FIXTURE_DIR / "temporal-extended-forms.json"))
|
||||
self.assertTrue(validation.validate_document(
|
||||
validator, validation.INVALID_FIXTURE_DIR / "timestamp-space-separator.json"))
|
||||
self.assertEqual(original_checks, checker.checkers)
|
||||
|
||||
def test_selected_grammar(self):
|
||||
for value in ("0001-01-01T00:00Z", "2000-02-29t23:59:59.00000000000000000001z",
|
||||
"2026-09-05T18:34+23:59", "2026-09-05T18:34:12-00:00",
|
||||
"9999-12-31T23:59:59.9-23:59"):
|
||||
with self.subTest(value=value): self.assertIsNotNone(parse_timestamp(value, "probe"))
|
||||
|
||||
def test_platform_only_forms_rejected(self):
|
||||
for value in ("0000-01-01T00:00:00Z", "2026-02-29T00:00:00Z",
|
||||
"2026-09-05 18:34:12+02:00", "20260905T183412+0200",
|
||||
"2026-W36-5T18:34:12+02:00", "2026-09-05T18:34:12,5+02:00",
|
||||
"2026-09-05T18:34:12+0200", "2026-09-05T18:34:12+02",
|
||||
"2026-09-05T18:34:12.5", "2026-09-05T18:34.5Z",
|
||||
"2026-09-05T18:34:60Z", "2026-09-05T18:34:12+24:00"):
|
||||
with self.subTest(value=value), self.assertRaises(TrainlogSemanticError):
|
||||
parse_timestamp(value, "probe")
|
||||
|
||||
def test_exact_fraction_and_rollover(self):
|
||||
earlier = parse_timestamp("2026-01-01T00:00:00.12345678901234567890Z", "probe")
|
||||
later = parse_timestamp("2026-01-01T00:00:00.12345678901234567891Z", "probe")
|
||||
equal = parse_timestamp("2026-01-01T00:00:00.1234567890123456789000Z", "probe")
|
||||
self.assertLess(earlier, later); self.assertEqual(earlier, equal)
|
||||
self.assertLess(parse_timestamp("2025-12-31T09:14:59Z", "probe"),
|
||||
parse_timestamp("2026-01-01T00:15:00+15:00", "probe"))
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
133
tests/test_training_knowledge.py
Normal file
133
tests/test_training_knowledge.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"validate_training_knowledge", ROOT / "tools" / "validate_training_knowledge.py"
|
||||
)
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class TrainingKnowledgeValidationTest(unittest.TestCase):
|
||||
files = (
|
||||
"body-zones-v1.json", "equipment-v1.json", "science-references-v1.json", "muscles-v1.json",
|
||||
"joint-actions-v1.json", "movement-patterns-v1.json",
|
||||
"exercise-knowledge-v1.json", "equipment-knowledge-v1.json",
|
||||
"training-knowledge-audit-v1.json",
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.catalog = Path(self.temp.name)
|
||||
for filename in self.files:
|
||||
(self.catalog / filename).write_bytes((ROOT / "catalog" / filename).read_bytes())
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def mutate(self, filename, operation):
|
||||
path = self.catalog / filename
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
operation(value)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def assert_invalid(self):
|
||||
with self.assertRaises(MODULE.ValidationError):
|
||||
MODULE.validate(self.catalog)
|
||||
|
||||
def test_valid_and_deterministic_generation(self):
|
||||
MODULE.validate(self.catalog)
|
||||
first = self.catalog / "first.c"
|
||||
second = self.catalog / "second.c"
|
||||
command = [sys.executable, str(ROOT / "tools" / "generate_training_knowledge.py"),
|
||||
str(self.catalog)]
|
||||
subprocess.run(command + [str(first)], check=True)
|
||||
subprocess.run(command + [str(second)], check=True)
|
||||
self.assertEqual(first.read_bytes(), second.read_bytes())
|
||||
audit = self.catalog / "regenerated-audit.json"
|
||||
subprocess.run([sys.executable, str(ROOT / "tools" / "generate_training_knowledge_audit.py"),
|
||||
str(self.catalog), str(audit)], check=True)
|
||||
self.assertEqual(audit.read_bytes(), (self.catalog / "training-knowledge-audit-v1.json").read_bytes())
|
||||
markdown = self.catalog / "audit.md"
|
||||
subprocess.run([sys.executable, str(ROOT / "tools" / "generate_training_knowledge_audit.py"),
|
||||
str(self.catalog), str(markdown), "--markdown"], check=True)
|
||||
self.assertEqual(markdown.read_bytes(), (ROOT / "docs/domain/knowledge_audit.md").read_bytes())
|
||||
|
||||
def test_unknown_nested_keys_and_types_are_validation_errors(self):
|
||||
mutations = [
|
||||
lambda root: root["equipment"][0].__setitem__("unexpected_review_key", True),
|
||||
lambda root: root["equipment"][0]["capabilities"][0].__setitem__("requirements", None),
|
||||
lambda root: root["equipment"][0].__setitem__("requires_actual_exercise", 1),
|
||||
]
|
||||
original = (self.catalog / "equipment-knowledge-v1.json").read_bytes()
|
||||
for mutation in mutations:
|
||||
(self.catalog / "equipment-knowledge-v1.json").write_bytes(original)
|
||||
self.mutate("equipment-knowledge-v1.json", mutation)
|
||||
self.assert_invalid()
|
||||
|
||||
def test_additive_non_runtime_reference_is_valid(self):
|
||||
def add(root):
|
||||
row = copy.deepcopy(root["references"][-1])
|
||||
row["ref_id"] = "zz_additive_provenance"
|
||||
row["title"] = "Additional provenance record"
|
||||
root["references"].append(row)
|
||||
self.mutate("science-references-v1.json", add)
|
||||
MODULE.validate(self.catalog)
|
||||
|
||||
def test_stale_generated_audit_is_rejected(self):
|
||||
self.mutate("training-knowledge-audit-v1.json",
|
||||
lambda root: root["rows"][0].__setitem__("rationale", "stale generated value"))
|
||||
self.assert_invalid()
|
||||
|
||||
def test_duplicate_object_key_rejected(self):
|
||||
path = self.catalog / "science-references-v1.json"
|
||||
text = path.read_text(encoding="utf-8")
|
||||
path.write_text(text.replace('"version": 1', '"version": 1, "version": 1', 1), encoding="utf-8")
|
||||
self.assert_invalid()
|
||||
|
||||
def test_version_order_enum_role_and_cross_reference_mutations(self):
|
||||
cases = [
|
||||
("muscles-v1.json", lambda root: root.__setitem__("version", 2)),
|
||||
("muscles-v1.json", lambda root: root["muscles"].reverse()),
|
||||
("muscles-v1.json", lambda root: root["muscles"][0]["joint_action_ids"].reverse()),
|
||||
("muscles-v1.json", lambda root: root["muscles"][0].__setitem__("confidence", "limited")),
|
||||
("exercise-knowledge-v1.json", lambda root: root["exercises"][0]["interpretation"].
|
||||
__setitem__("primary_muscle_ids", ["missing_muscle"])),
|
||||
("exercise-knowledge-v1.json", lambda root: root["exercises"][0]["interpretation"].
|
||||
__setitem__("secondary_muscle_ids", root["exercises"][0]["interpretation"]["primary_muscle_ids"])),
|
||||
("equipment-knowledge-v1.json", lambda root: root["equipment"][0]["capabilities"][0].
|
||||
__setitem__("exercise_ids", ["ex_00000000-0000-4000-8000-000000000000"])),
|
||||
]
|
||||
originals = {filename: (self.catalog / filename).read_bytes() for filename in self.files}
|
||||
for filename, mutation in cases:
|
||||
for restore, contents in originals.items():
|
||||
(self.catalog / restore).write_bytes(contents)
|
||||
self.mutate(filename, mutation)
|
||||
self.assert_invalid()
|
||||
|
||||
def test_conditional_candidate_cannot_become_resolved_data(self):
|
||||
def leak(root):
|
||||
row = next(item for item in root["exercises"] if item["resolution_status"] == "conditional")
|
||||
row["interpretation"] = copy.deepcopy(row["conditional_interpretation"])
|
||||
self.mutate("exercise-knowledge-v1.json", leak)
|
||||
self.assert_invalid()
|
||||
|
||||
def test_manufacturer_only_high_mapping_rejected(self):
|
||||
def mutation(root):
|
||||
root["equipment"][0]["confidence"] = "high"
|
||||
root["equipment"][0]["evidence_type"] = "manufacturer_statement"
|
||||
self.mutate("equipment-knowledge-v1.json", mutation)
|
||||
self.assert_invalid()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
223
tools/generate_training_knowledge.py
Normal file
223
tools/generate_training_knowledge.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate deterministic C tables and read/query functions from knowledge V1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from validate_training_knowledge import ValidationError, validate
|
||||
|
||||
|
||||
def c(value) -> str:
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, list):
|
||||
value = "\n".join(value)
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def interp(value) -> str:
|
||||
if value is None:
|
||||
return "{NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL}"
|
||||
keys = ("family_description", "action_ids", "pattern_ids", "primary_muscle_ids",
|
||||
"secondary_muscle_ids", "stabilizer_muscle_ids", "primary_zone_id",
|
||||
"secondary_zone_ids", "confidence", "evidence_type", "source_refs",
|
||||
"variant_notes", "role_notes", "required_confirmation")
|
||||
return "{" + ",".join(c(value.get(key)) for key in keys) + "}"
|
||||
|
||||
|
||||
def load(path: Path, key: str):
|
||||
return json.loads(path.read_text(encoding="utf-8"))[key]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 3:
|
||||
raise SystemExit("usage: generate_training_knowledge.py CATALOG_DIR OUTPUT_C")
|
||||
root, output = Path(sys.argv[1]), Path(sys.argv[2])
|
||||
try:
|
||||
validate(root)
|
||||
except ValidationError as error:
|
||||
raise SystemExit(str(error)) from error
|
||||
refs = load(root / "science-references-v1.json", "references")
|
||||
muscles = load(root / "muscles-v1.json", "muscles")
|
||||
actions = load(root / "joint-actions-v1.json", "joint_actions")
|
||||
patterns = load(root / "movement-patterns-v1.json", "movement_patterns")
|
||||
exercises = load(root / "exercise-knowledge-v1.json", "exercises")
|
||||
equipment = load(root / "equipment-knowledge-v1.json", "equipment")
|
||||
lines = ['#include "trainlog/training_knowledge.h"',
|
||||
'#include "trainlog/body_zone_catalog.h"', "#include <string.h>", ""]
|
||||
lines.append("static const TrainlogKnowledgeReference references[] = {")
|
||||
for row in refs:
|
||||
lines.append(" {%s}," % ",".join((c(row["ref_id"]), c(row["title"]),
|
||||
c(row["authors_or_organization"]), str(row["year"] or 0), c(row["type"]),
|
||||
c(row["url"]), c(row["doi"]), c(row["pmid"]), c(row["topics"]),
|
||||
c(row["notes"]), c(row["limitations"]), c(row["accessed_on"]),
|
||||
c(row.get("publication_note")))))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogKnowledgeBodyZoneAudit audits[] = {")
|
||||
for row in exercises:
|
||||
audit = row["body_zone_audit"]
|
||||
interpretation = row["interpretation"] or row["conditional_interpretation"]
|
||||
fields = (row["exercise_id"], audit["status"], audit["severity"], audit["rationale"],
|
||||
audit["confidence"], audit["source_refs"], audit["existing_primary_zone_id"],
|
||||
audit["existing_secondary_zone_ids"],
|
||||
None if interpretation is None else interpretation["primary_zone_id"],
|
||||
[] if interpretation is None else interpretation["secondary_zone_ids"],
|
||||
audit["proposed_mutation"])
|
||||
lines.append(" {%s}," % ",".join(c(value) for value in fields))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogKnowledgeMuscle muscles[] = {")
|
||||
for row in muscles:
|
||||
lines.append(" {%s}," % ",".join(c(row.get(key)) for key in (
|
||||
"muscle_id", "display_name", "display_name_fr", "entity_type", "anatomical_group",
|
||||
"aggregate_group_id", "member_muscle_ids", "body_zone_ids", "joint_action_ids",
|
||||
"primary_actions", "primary_actions_semantics", "functional_notes", "confidence", "evidence_type", "source_refs",
|
||||
"overlap_warning")))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogKnowledgeJointAction actions[] = {")
|
||||
for row in actions:
|
||||
lines.append(" {%s}," % ",".join(c(row.get(key)) for key in (
|
||||
"action_id", "display_name_fr", "definition", "anatomical_region", "joint_complex",
|
||||
"principal_plane", "plane_notes", "contributing_muscle_ids", "contributor_semantics", "confidence",
|
||||
"evidence_type", "source_refs", "notes")))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogKnowledgeMovementPattern patterns[] = {")
|
||||
for row in patterns:
|
||||
lines.append(" {%s}," % ",".join(c(row.get(key)) for key in (
|
||||
"pattern_id", "display_name_fr", "definition", "parent_pattern_id", "typical_action_ids",
|
||||
"typical_body_zone_ids", "body_zone_semantics", "confidence", "evidence_type", "source_refs", "notes")))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogKnowledgeInterpretation exercise_interpretations[] = {")
|
||||
for row in exercises:
|
||||
lines.append(" %s," % interp(row["interpretation"]))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogKnowledgeInterpretation exercise_conditionals[] = {")
|
||||
for row in exercises:
|
||||
lines.append(" %s," % interp(row["conditional_interpretation"]))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogExerciseKnowledge exercises[] = {")
|
||||
for index, row in enumerate(exercises):
|
||||
resolved = "&exercise_interpretations[%d]" % index if row["interpretation"] else "NULL"
|
||||
conditional = "&exercise_conditionals[%d]" % index if row["conditional_interpretation"] else "NULL"
|
||||
fields = [c(row.get(key)) for key in ("exercise_id", "exercise_name", "resolution_status",
|
||||
"confidence", "equipment_ids", "identity_evidence", "identity_evidence_type", "source_refs",
|
||||
"limitations", "equipment_link_status")]
|
||||
lines.append(" {%s,%s,%s}," % (",".join(fields), resolved, conditional))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogEquipmentKnowledge equipment[] = {")
|
||||
for row in equipment:
|
||||
fields = [c(row.get(key)) for key in ("equipment_id", "manufacturer", "model", "identification_status",
|
||||
"scientific_status", "scientific_status_scope", "catalog_type", "catalog_load_semantics", "mechanics", "confidence",
|
||||
"evidence_type", "source_refs", "limitations", "audit_note")]
|
||||
fields.append("true" if row["requires_actual_exercise"] else "false")
|
||||
lines.append(" {%s}," % ",".join(fields))
|
||||
lines.append("};")
|
||||
capabilities = [(row["equipment_id"], cap) for row in equipment for cap in row["capabilities"]]
|
||||
lines.append("static const TrainlogKnowledgeInterpretation capability_interpretations[] = {")
|
||||
for _, cap in capabilities:
|
||||
lines.append(" %s," % interp(cap["interpretation"]))
|
||||
lines.append("};")
|
||||
lines.append("static const TrainlogEquipmentCapability capabilities[] = {")
|
||||
for index, (equipment_id, cap) in enumerate(capabilities):
|
||||
pointer = "&capability_interpretations[%d]" % index if cap["interpretation"] else "NULL"
|
||||
lines.append(" {%s,%s,%s,%s,%s,%s}," % (c(equipment_id), c(cap["display_name"]),
|
||||
c(cap["exercise_ids"]), c(cap["link_status"]), c(cap["requirements"]), pointer))
|
||||
lines.append("};")
|
||||
lines.append(r'''
|
||||
static bool list_has(const char *list, const char *id) {
|
||||
size_t length;
|
||||
const char *at;
|
||||
if (list == NULL || id == NULL || id[0] == '\0') return false;
|
||||
length = strlen(id);
|
||||
at = list;
|
||||
while (*at != '\0') {
|
||||
const char *end = strchr(at, '\n');
|
||||
size_t item_length = end == NULL ? strlen(at) : (size_t)(end - at);
|
||||
if (item_length == length && memcmp(at, id, length) == 0) return true;
|
||||
if (end == NULL) break;
|
||||
at = end + 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#define DEFINE_ACCESSORS(prefix,type,array,key) \
|
||||
size_t prefix##_count(void) { return sizeof(array) / sizeof(array[0]); } \
|
||||
const type *prefix##_at(size_t index) { return index < prefix##_count() ? &array[index] : NULL; } \
|
||||
const type *prefix##_lookup(const char *id) { size_t i; if (id == NULL || id[0] == '\0') return NULL; \
|
||||
for (i=0; i<prefix##_count(); ++i) { if (strcmp(array[i].key,id)==0) return &array[i]; } \
|
||||
return NULL; }
|
||||
DEFINE_ACCESSORS(trainlog_knowledge_reference, TrainlogKnowledgeReference, references, ref_id)
|
||||
DEFINE_ACCESSORS(trainlog_knowledge_muscle, TrainlogKnowledgeMuscle, muscles, muscle_id)
|
||||
DEFINE_ACCESSORS(trainlog_knowledge_joint_action, TrainlogKnowledgeJointAction, actions, action_id)
|
||||
DEFINE_ACCESSORS(trainlog_knowledge_movement_pattern, TrainlogKnowledgeMovementPattern, patterns, pattern_id)
|
||||
DEFINE_ACCESSORS(trainlog_exercise_knowledge, TrainlogExerciseKnowledge, exercises, exercise_id)
|
||||
DEFINE_ACCESSORS(trainlog_knowledge_body_zone_audit, TrainlogKnowledgeBodyZoneAudit, audits, exercise_id)
|
||||
DEFINE_ACCESSORS(trainlog_equipment_knowledge, TrainlogEquipmentKnowledge, equipment, equipment_id)
|
||||
size_t trainlog_equipment_capability_count(void) { return sizeof(capabilities)/sizeof(capabilities[0]); }
|
||||
const TrainlogEquipmentCapability *trainlog_equipment_capability_at(size_t index) {
|
||||
return index < trainlog_equipment_capability_count() ? &capabilities[index] : NULL;
|
||||
}
|
||||
const TrainlogKnowledgeInterpretation *trainlog_exercise_knowledge_conditional(const char *exercise_id) {
|
||||
const TrainlogExerciseKnowledge *record = trainlog_exercise_knowledge_lookup(exercise_id);
|
||||
return record == NULL ? NULL : record->conditional_interpretation;
|
||||
}
|
||||
static bool zone_matches(const TrainlogKnowledgeInterpretation *value, const char *zone, bool descendants) {
|
||||
if (strcmp(value->primary_zone_id, zone) == 0 || list_has(value->secondary_zone_ids, zone)) return true;
|
||||
if (!descendants) return false;
|
||||
if (trainlog_body_zone_catalog_is_descendant(value->primary_zone_id, zone)) return true;
|
||||
{ const char *at = value->secondary_zone_ids;
|
||||
while (at != NULL && *at != '\0') { const char *end = strchr(at, '\n'); char id[64];
|
||||
size_t n = end == NULL ? strlen(at) : (size_t)(end-at); if (n >= sizeof(id)) return false;
|
||||
memcpy(id,at,n); id[n]='\0'; if (trainlog_body_zone_catalog_is_descendant(id,zone)) return true;
|
||||
if (end == NULL) break;
|
||||
at=end+1; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
TrainlogStatus trainlog_exercise_knowledge_query(const TrainlogKnowledgeQuery *query,
|
||||
const TrainlogExerciseKnowledge **output, size_t capacity, size_t *output_count) {
|
||||
size_t i, count=0; const TrainlogKnowledgeQuery empty={0};
|
||||
if (output_count == NULL || (capacity > 0 && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
*output_count=0; if (query == NULL) query=∅
|
||||
if (query->muscle_role < TRAINLOG_KNOWLEDGE_ROLE_ANY || query->muscle_role > TRAINLOG_KNOWLEDGE_ROLE_STABILIZER)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
/* INVARIANT: a specific muscle role is meaningful only with a muscle ID.
|
||||
* Keep the C query contract aligned with Android's paired filters. */
|
||||
if (query->muscle_id == NULL && query->muscle_role != TRAINLOG_KNOWLEDGE_ROLE_ANY)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
if (query->scientific_zone_id != NULL && trainlog_body_zone_catalog_lookup(query->scientific_zone_id) == NULL)
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
if (query->movement_pattern_id != NULL && trainlog_knowledge_movement_pattern_lookup(query->movement_pattern_id) == NULL)
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
if (query->muscle_id != NULL && trainlog_knowledge_muscle_lookup(query->muscle_id) == NULL)
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
if (query->available_equipment_id != NULL && trainlog_equipment_knowledge_lookup(query->available_equipment_id) == NULL)
|
||||
return TRAINLOG_STATUS_NOT_FOUND;
|
||||
for (i=0; i<trainlog_exercise_knowledge_count(); ++i) { const TrainlogExerciseKnowledge *e=&exercises[i];
|
||||
const TrainlogKnowledgeInterpretation *v=e->interpretation; bool muscle_ok=true;
|
||||
if (v == NULL) continue;
|
||||
if (query->scientific_zone_id != NULL && !zone_matches(v,query->scientific_zone_id,query->include_zone_descendants)) continue;
|
||||
if (query->movement_pattern_id != NULL && !list_has(v->pattern_ids,query->movement_pattern_id)) continue;
|
||||
if (query->available_equipment_id != NULL && !list_has(e->equipment_ids,query->available_equipment_id)) continue;
|
||||
if (query->muscle_id != NULL) {
|
||||
switch (query->muscle_role) {
|
||||
case TRAINLOG_KNOWLEDGE_ROLE_PRIMARY: muscle_ok=list_has(v->primary_muscle_ids,query->muscle_id); break;
|
||||
case TRAINLOG_KNOWLEDGE_ROLE_SECONDARY: muscle_ok=list_has(v->secondary_muscle_ids,query->muscle_id); break;
|
||||
case TRAINLOG_KNOWLEDGE_ROLE_STABILIZER: muscle_ok=list_has(v->stabilizer_muscle_ids,query->muscle_id); break;
|
||||
case TRAINLOG_KNOWLEDGE_ROLE_ANY: muscle_ok=list_has(v->primary_muscle_ids,query->muscle_id) ||
|
||||
list_has(v->secondary_muscle_ids,query->muscle_id) || list_has(v->stabilizer_muscle_ids,query->muscle_id); break;
|
||||
}
|
||||
}
|
||||
if (!muscle_ok) continue;
|
||||
if (count < capacity) output[count]=e;
|
||||
++count;
|
||||
}
|
||||
*output_count=count; return count > capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
|
||||
}
|
||||
''')
|
||||
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
83
tools/generate_training_knowledge_audit.py
Normal file
83
tools/generate_training_knowledge_audit.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regenerate the review audit solely from canonical TRAINING KNOWLEDGE V1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def rows_from_catalog(catalog: Path) -> list[dict]:
|
||||
exercises = json.loads((catalog / "exercise-knowledge-v1.json").read_text(encoding="utf-8"))["exercises"]
|
||||
rows = []
|
||||
for exercise in exercises:
|
||||
authored = dict(exercise["body_zone_audit"])
|
||||
interpretation = exercise["interpretation"] or exercise["conditional_interpretation"]
|
||||
authored.update({
|
||||
"exercise_id": exercise["exercise_id"],
|
||||
"exercise_name": exercise["exercise_name"],
|
||||
"resolution_status": exercise["resolution_status"],
|
||||
"equipment_ids": exercise["equipment_ids"],
|
||||
"knowledge_confidence": exercise["confidence"],
|
||||
"knowledge_source_refs": exercise["source_refs"],
|
||||
"family_description": None if interpretation is None else interpretation["family_description"],
|
||||
"action_ids": [] if interpretation is None else interpretation["action_ids"],
|
||||
"pattern_ids": [] if interpretation is None else interpretation["pattern_ids"],
|
||||
"primary_muscle_ids": [] if interpretation is None else interpretation["primary_muscle_ids"],
|
||||
"secondary_muscle_ids": [] if interpretation is None else interpretation["secondary_muscle_ids"],
|
||||
"stabilizer_muscle_ids": [] if interpretation is None else interpretation["stabilizer_muscle_ids"],
|
||||
"scientific_primary_zone_id": None if interpretation is None else interpretation["primary_zone_id"],
|
||||
"scientific_secondary_zone_ids": [] if interpretation is None else interpretation["secondary_zone_ids"],
|
||||
})
|
||||
rows.append(authored)
|
||||
return rows
|
||||
|
||||
|
||||
def generate(catalog: Path, output: Path) -> None:
|
||||
rows = rows_from_catalog(catalog)
|
||||
output.write_text(json.dumps({"format": "trainlog-training-knowledge-audit-v1",
|
||||
"version": 1, "rows": rows}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def markdown_cell(value) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, list):
|
||||
value = ", ".join(value) if value else "—"
|
||||
return str(value).replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def generate_markdown(catalog: Path, output: Path) -> None:
|
||||
columns = (("exercise_id", "ID"), ("exercise_name", "Name"), ("action_ids", "Actions"),
|
||||
("pattern_ids", "Patterns"), ("primary_muscle_ids", "Primary"),
|
||||
("secondary_muscle_ids", "Secondary"), ("stabilizer_muscle_ids", "Stabilizers"),
|
||||
("scientific_primary_zone_id", "Science primary zone"),
|
||||
("scientific_secondary_zone_ids", "Science secondary zones"),
|
||||
("existing_primary_zone_id", "Existing primary zone"),
|
||||
("existing_secondary_zone_ids", "Existing secondary zones"), ("equipment_ids", "Equipment"),
|
||||
("knowledge_confidence", "Confidence"), ("knowledge_source_refs", "Source refs"),
|
||||
("status", "Audit status"))
|
||||
lines = ["# Training knowledge science audit", "",
|
||||
"Generated deterministically from the six canonical knowledge catalogs. Conditional rows are candidates that require explicit confirmation and do not participate in ordinary resolved queries.", "",
|
||||
"| " + " | ".join(label for _, label in columns) + " |",
|
||||
"| " + " | ".join("---" for _ in columns) + " |"]
|
||||
for row in rows_from_catalog(catalog):
|
||||
lines.append("| " + " | ".join(markdown_cell(row[key]) for key, _ in columns) + " |")
|
||||
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("catalog", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--markdown", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.markdown:
|
||||
generate_markdown(args.catalog, args.output)
|
||||
else:
|
||||
generate(args.catalog, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -4,12 +4,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -120,25 +120,67 @@ def normalize_exercise_name(name: str) -> str:
|
|||
return collapsed.casefold()
|
||||
|
||||
|
||||
def parse_timestamp(value: str, field_name: str) -> datetime:
|
||||
"""Parse a Trainlog timestamp while requiring an explicit UTC offset."""
|
||||
candidate = value
|
||||
if candidate.endswith("Z"):
|
||||
candidate = candidate[:-1] + "+00:00"
|
||||
@dataclass(frozen=True)
|
||||
class TrainlogTimestamp:
|
||||
"""Exact comparable instant key; fraction has insignificant zeros removed."""
|
||||
|
||||
try:
|
||||
parsed = datetime.fromisoformat(candidate)
|
||||
except ValueError as exc:
|
||||
raise TrainlogSemanticError(
|
||||
f"{field_name}: invalid date-time: {value!r}"
|
||||
) from exc
|
||||
utc_second: int
|
||||
fraction: str
|
||||
|
||||
if parsed.utcoffset() is None:
|
||||
raise TrainlogSemanticError(
|
||||
f"{field_name}: UTC offset is required: {value!r}"
|
||||
)
|
||||
def __lt__(self, other: "TrainlogTimestamp") -> bool:
|
||||
return (self.utc_second, self.fraction) < (other.utc_second, other.fraction)
|
||||
|
||||
return parsed
|
||||
def __le__(self, other: "TrainlogTimestamp") -> bool:
|
||||
return self == other or self < other
|
||||
|
||||
|
||||
TIMESTAMP_PATTERN = re.compile(
|
||||
r"(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})"
|
||||
r"[Tt](?P<hour>[0-9]{2}):(?P<minute>[0-9]{2})"
|
||||
r"(?::(?P<second>[0-9]{2})(?:\.(?P<fraction>[0-9]+))?)?"
|
||||
r"(?P<zone>[Zz]|[+-][0-9]{2}:[0-9]{2})",
|
||||
re.ASCII,
|
||||
)
|
||||
|
||||
|
||||
def _leap_year(year: int) -> bool:
|
||||
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
||||
|
||||
|
||||
def _days_in_month(year: int, month: int) -> int:
|
||||
if month == 2:
|
||||
return 29 if _leap_year(year) else 28
|
||||
return 30 if month in {4, 6, 9, 11} else 31
|
||||
|
||||
|
||||
def _day_number(year: int, month: int, day: int) -> int:
|
||||
prior = year - 1
|
||||
result = prior * 365 + prior // 4 - prior // 100 + prior // 400
|
||||
result += sum(_days_in_month(year, item) for item in range(1, month))
|
||||
return result + day - 1
|
||||
|
||||
|
||||
def parse_timestamp(value: str, field_name: str) -> TrainlogTimestamp:
|
||||
"""Parse the frozen Trainlog grammar without platform ISO extensions."""
|
||||
match = TIMESTAMP_PATTERN.fullmatch(value) if isinstance(value, str) else None
|
||||
if match is None:
|
||||
raise TrainlogSemanticError(f"{field_name}: invalid date-time: {value!r}")
|
||||
year, month, day, hour, minute = (
|
||||
int(match[name]) for name in ("year", "month", "day", "hour", "minute")
|
||||
)
|
||||
second = int(match["second"] or "0")
|
||||
if year < 1 or month not in range(1, 13) or day not in range(
|
||||
1, _days_in_month(year, month) + 1) or hour > 23 or minute > 59 or second > 59:
|
||||
raise TrainlogSemanticError(f"{field_name}: invalid date-time: {value!r}")
|
||||
zone = match["zone"]
|
||||
offset = 0
|
||||
if zone not in {"Z", "z"}:
|
||||
offset_hour, offset_minute = int(zone[1:3]), int(zone[4:6])
|
||||
if offset_hour > 23 or offset_minute > 59:
|
||||
raise TrainlogSemanticError(f"{field_name}: invalid date-time: {value!r}")
|
||||
offset = (offset_hour * 3600 + offset_minute * 60) * (1 if zone[0] == "+" else -1)
|
||||
local = _day_number(year, month, day) * 86400 + hour * 3600 + minute * 60 + second
|
||||
return TrainlogTimestamp(local - offset, (match["fraction"] or "").rstrip("0"))
|
||||
|
||||
|
||||
def require_non_blank(value: str, field_name: str) -> None:
|
||||
|
|
@ -358,8 +400,23 @@ def structural_errors(
|
|||
document: Any,
|
||||
) -> list[str]:
|
||||
"""Return deterministic human-readable JSON Schema errors."""
|
||||
# WHY: jsonschema's optional RFC checker requires seconds, while Trainlog's
|
||||
# existing Android writer may omit them. Admission must not depend on which
|
||||
# optional dependencies are installed. Keep other supplied format checks,
|
||||
# and override only date-time on a fresh instance; never mutate global state.
|
||||
checker = jsonschema.FormatChecker()
|
||||
if validator.format_checker is not None:
|
||||
checker.checkers = validator.format_checker.checkers.copy()
|
||||
|
||||
@checker.checks("date-time", raises=TrainlogSemanticError)
|
||||
def trainlog_date_time(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
parse_timestamp(value, "date-time")
|
||||
return True # JSON Schema's type keyword handles non-string values.
|
||||
|
||||
temporal_validator = validator.evolve(format_checker=checker)
|
||||
errors = sorted(
|
||||
validator.iter_errors(document),
|
||||
temporal_validator.iter_errors(document),
|
||||
key=lambda error: [str(part) for part in error.absolute_path],
|
||||
)
|
||||
|
||||
|
|
@ -485,7 +542,15 @@ def main(argv: list[str]) -> int:
|
|||
validator.check_schema(schema)
|
||||
|
||||
if not args.paths:
|
||||
return run_suite(validator)
|
||||
result = run_suite(validator)
|
||||
try:
|
||||
from validate_training_knowledge import ValidationError, validate as validate_knowledge
|
||||
validate_knowledge(ROOT / "catalog")
|
||||
print(f"PASS training-knowledge catalogs: {ROOT / 'catalog'}")
|
||||
except ValidationError as error:
|
||||
print(f"FAIL training-knowledge catalogs: {error}")
|
||||
result = 1
|
||||
return result
|
||||
|
||||
failed = False
|
||||
for path in args.paths:
|
||||
|
|
|
|||
399
tools/validate_training_knowledge.py
Normal file
399
tools/validate_training_knowledge.py
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Strict cross-catalog validation for TRAINING KNOWLEDGE V1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValidationError(f"duplicate JSON key: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def load(path: Path):
|
||||
try:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
return json.load(handle, object_pairs_hook=unique_object)
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValidationError(f"{path}: {error}") from error
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValidationError(message)
|
||||
|
||||
|
||||
def strings(value, where: str, *, nonempty: bool = True, ordered: bool = True) -> None:
|
||||
require(isinstance(value, list), f"{where}: expected array")
|
||||
require(all(isinstance(item, str) and (item.strip() or not nonempty) for item in value),
|
||||
f"{where}: expected strings")
|
||||
require(len(value) == len(set(value)), f"{where}: duplicate values")
|
||||
if ordered:
|
||||
require(value == sorted(value), f"{where}: values must be sorted")
|
||||
|
||||
|
||||
def text(value, where: str) -> None:
|
||||
require(isinstance(value, str) and bool(value.strip()), f"{where}: expected non-empty string")
|
||||
|
||||
|
||||
def exact_object(value, required: set[str], where: str, optional: set[str] | None = None) -> None:
|
||||
optional = optional or set()
|
||||
require(isinstance(value, dict), f"{where}: expected object")
|
||||
require(required <= set(value) <= required | optional, f"{where}: invalid keys")
|
||||
|
||||
|
||||
def nullable_text(value, where: str) -> None:
|
||||
require(value is None or isinstance(value, str), f"{where}: expected string or null")
|
||||
|
||||
|
||||
def confidence(value, where: str) -> None:
|
||||
require(isinstance(value, str) and value in {"high", "moderate", "uncertain"},
|
||||
f"{where}: invalid confidence")
|
||||
|
||||
|
||||
def refs_list(value, where: str, refs: set[str], *, nonempty: bool = True) -> None:
|
||||
strings(value, where)
|
||||
require(set(value) <= refs and (bool(value) or not nonempty), f"{where}: invalid references")
|
||||
|
||||
|
||||
def envelope(root, fmt: str, key: str):
|
||||
require(set(root) == {"format", "version", key}, f"{key}: invalid envelope")
|
||||
require(root["format"] == fmt and root["version"] == 1 and not isinstance(root["version"], bool),
|
||||
f"{key}: unsupported version")
|
||||
require(isinstance(root[key], list), f"{key}: expected array")
|
||||
return root[key]
|
||||
|
||||
|
||||
def ids(rows, key: str, where: str) -> set[str]:
|
||||
values = []
|
||||
for row in rows:
|
||||
require(isinstance(row, dict), f"{where}: record must be object")
|
||||
text(row.get(key), f"{where}.{key}")
|
||||
values.append(row[key])
|
||||
require(values == sorted(values), f"{where}: records must be ordered by {key}")
|
||||
require(len(values) == len(set(values)), f"{where}: duplicate {key}")
|
||||
return set(values)
|
||||
|
||||
|
||||
def validate_interpretation(value, where, refs, muscles, actions, patterns, zones):
|
||||
required = {"family_description", "action_ids", "pattern_ids", "primary_muscle_ids",
|
||||
"secondary_muscle_ids", "stabilizer_muscle_ids", "primary_zone_id",
|
||||
"secondary_zone_ids", "confidence", "evidence_type", "source_refs",
|
||||
"variant_notes", "role_notes"}
|
||||
exact_object(value, required, where, {"required_confirmation"})
|
||||
for key in ("action_ids", "pattern_ids", "primary_muscle_ids", "secondary_muscle_ids",
|
||||
"stabilizer_muscle_ids", "secondary_zone_ids", "source_refs"):
|
||||
strings(value[key], f"{where}.{key}")
|
||||
require(set(value["action_ids"]) <= actions, f"{where}: dangling action")
|
||||
require(set(value["pattern_ids"]) <= patterns, f"{where}: dangling pattern")
|
||||
role_lists = [set(value[key]) for key in ("primary_muscle_ids", "secondary_muscle_ids", "stabilizer_muscle_ids")]
|
||||
require(set().union(*role_lists) <= muscles, f"{where}: dangling muscle")
|
||||
require(sum(map(len, role_lists)) == len(set().union(*role_lists)), f"{where}: muscle appears in multiple roles")
|
||||
require(value["primary_zone_id"] in zones and set(value["secondary_zone_ids"]) <= zones,
|
||||
f"{where}: dangling zone")
|
||||
require(value["primary_zone_id"] not in value["secondary_zone_ids"], f"{where}: duplicate zone role")
|
||||
confidence(value["confidence"], f"{where}.confidence")
|
||||
require(bool(value["source_refs"]) and set(value["source_refs"]) <= refs, f"{where}: dangling evidence")
|
||||
for key in ("family_description", "evidence_type", "variant_notes", "role_notes"):
|
||||
text(value[key], f"{where}.{key}")
|
||||
if "required_confirmation" in value:
|
||||
text(value["required_confirmation"], f"{where}.required_confirmation")
|
||||
|
||||
|
||||
def validate(root: Path) -> None:
|
||||
body = load(root / "body-zones-v1.json")
|
||||
require(body.get("format") == "trainlog-body-zone-catalog" and body.get("version") == 1,
|
||||
"unsupported body-zone catalog")
|
||||
require(all(row.get("kind") in {"group", "leaf", "standalone"} for row in body.get("zones", [])),
|
||||
"invalid body-zone kind")
|
||||
zones = {row["zone_id"] for row in body["zones"]}
|
||||
references = envelope(load(root / "science-references-v1.json"), "trainlog-science-references-v1", "references")
|
||||
muscles_rows = envelope(load(root / "muscles-v1.json"), "trainlog-muscles-v1", "muscles")
|
||||
actions_rows = envelope(load(root / "joint-actions-v1.json"), "trainlog-joint-actions-v1", "joint_actions")
|
||||
patterns_rows = envelope(load(root / "movement-patterns-v1.json"), "trainlog-movement-patterns-v1", "movement_patterns")
|
||||
exercise_rows = envelope(load(root / "exercise-knowledge-v1.json"), "trainlog-exercise-knowledge-v1", "exercises")
|
||||
equipment_rows = envelope(load(root / "equipment-knowledge-v1.json"), "trainlog-equipment-knowledge-v1", "equipment")
|
||||
refs = ids(references, "ref_id", "references")
|
||||
muscles = ids(muscles_rows, "muscle_id", "muscles")
|
||||
actions = ids(actions_rows, "action_id", "joint_actions")
|
||||
patterns = ids(patterns_rows, "pattern_id", "movement_patterns")
|
||||
exercises = ids(exercise_rows, "exercise_id", "exercises")
|
||||
equipment = ids(equipment_rows, "equipment_id", "equipment")
|
||||
snake_id = re.compile(r"[a-z][a-z0-9_]*")
|
||||
equipment_id = re.compile(r"(?:[a-z][a-z0-9_]*|eq_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})")
|
||||
require(all(snake_id.fullmatch(value) for value in refs | muscles | actions | patterns),
|
||||
"invalid stable knowledge ID syntax")
|
||||
require(all(equipment_id.fullmatch(value) for value in equipment), "invalid stable equipment ID syntax")
|
||||
equipment_manifest = load(root / "equipment-v1.json")
|
||||
require(isinstance(equipment_manifest, dict) and
|
||||
set(equipment_manifest) == {"format", "version", "equipment", "exercise_equipment"} and
|
||||
equipment_manifest["format"] == "trainlog-equipment-catalog" and
|
||||
equipment_manifest["version"] == 1, "invalid equipment manifest")
|
||||
manifest_equipment = equipment_manifest["equipment"]
|
||||
require(isinstance(manifest_equipment, list), "equipment manifest rows invalid")
|
||||
required_equipment = set()
|
||||
for manifest_row in manifest_equipment:
|
||||
require(isinstance(manifest_row, dict), "equipment manifest row invalid")
|
||||
text(manifest_row.get("id"), "equipment manifest id")
|
||||
require(manifest_row["id"] not in required_equipment, "duplicate equipment manifest id")
|
||||
required_equipment.add(manifest_row["id"])
|
||||
mappings = body.get("exercise_mappings")
|
||||
require(isinstance(mappings, list), "body-zone exercise mappings invalid")
|
||||
required_exercises = set()
|
||||
for mapping in mappings:
|
||||
require(isinstance(mapping, dict), "body-zone exercise mapping invalid")
|
||||
text(mapping.get("exercise_id"), "body-zone exercise mapping id")
|
||||
require(mapping["exercise_id"] not in required_exercises, "duplicate body-zone exercise mapping")
|
||||
required_exercises.add(mapping["exercise_id"])
|
||||
require(required_equipment <= equipment, "knowledge catalog misses supplied equipment")
|
||||
require(required_exercises <= exercises, "knowledge catalog misses observed exercises")
|
||||
|
||||
ref_required = {"ref_id", "title", "authors_or_organization", "year", "type", "url", "topics",
|
||||
"notes", "limitations", "doi", "pmid", "accessed_on"}
|
||||
for row in references:
|
||||
exact_object(row, ref_required, "reference", {"publication_note"})
|
||||
for key in ("title", "authors_or_organization", "type", "url", "notes", "limitations", "accessed_on"):
|
||||
text(row[key], f"reference.{key}")
|
||||
require(row["year"] is None or isinstance(row["year"], int) and not isinstance(row["year"], bool),
|
||||
"reference.year invalid")
|
||||
strings(row["topics"], "reference.topics", ordered=False)
|
||||
require(row["doi"] is None or isinstance(row["doi"], str), "reference.doi invalid")
|
||||
require(row["pmid"] is None or isinstance(row["pmid"], str), "reference.pmid invalid")
|
||||
if "publication_note" in row:
|
||||
nullable_text(row["publication_note"], "reference.publication_note")
|
||||
ref_types = {row["ref_id"]: row["type"] for row in references}
|
||||
scientific_types = {"established_anatomy", "emg_evidence", "intervention_evidence"}
|
||||
|
||||
for row in muscles_rows:
|
||||
required = {"muscle_id", "display_name", "display_name_fr", "entity_type", "anatomical_group",
|
||||
"aggregate_group_id", "body_zone_id", "body_zone_ids", "joint_action_ids",
|
||||
"primary_actions", "primary_actions_semantics", "overlap_warning", "functional_notes",
|
||||
"confidence", "evidence_type", "source_refs"}
|
||||
exact_object(row, required, f"muscle {row['muscle_id']}", {"member_muscle_ids"})
|
||||
for key in ("display_name", "display_name_fr", "anatomical_group", "primary_actions_semantics",
|
||||
"overlap_warning", "evidence_type"):
|
||||
text(row[key], f"muscle.{key}")
|
||||
require(isinstance(row["functional_notes"], str), "muscle.functional_notes invalid")
|
||||
require(row["entity_type"] in {"muscle", "muscle_region", "muscle_group"}, "invalid muscle entity_type")
|
||||
require(row["aggregate_group_id"] is None or row["aggregate_group_id"] in muscles, "dangling aggregate group")
|
||||
if "member_muscle_ids" in row:
|
||||
strings(row["member_muscle_ids"], "muscle.member_muscle_ids")
|
||||
require(set(row["member_muscle_ids"]) <= muscles, "dangling group member")
|
||||
require(row["body_zone_id"] in zones, "dangling muscle zone")
|
||||
for key in ("body_zone_ids", "joint_action_ids", "primary_actions", "source_refs"):
|
||||
strings(row[key], f"muscle.{key}")
|
||||
require(set(row["body_zone_ids"]) <= zones and set(row["joint_action_ids"]) <= actions and
|
||||
set(row["primary_actions"]) <= actions and set(row["source_refs"]) <= refs and row["source_refs"],
|
||||
"muscle cross-reference failure")
|
||||
confidence(row["confidence"], "muscle.confidence")
|
||||
|
||||
for row in actions_rows:
|
||||
required = {"action_id", "display_name_fr", "definition", "anatomical_region", "joint_complex",
|
||||
"joint_or_complex", "principal_plane", "plane_notes", "contributing_muscle_ids",
|
||||
"contributor_semantics", "evidence_type", "confidence", "source_refs", "notes"}
|
||||
require(set(row) == required, f"action {row['action_id']}: invalid keys")
|
||||
for key in ("display_name_fr", "definition", "anatomical_region", "joint_complex", "joint_or_complex",
|
||||
"principal_plane", "plane_notes", "contributor_semantics", "evidence_type", "notes"):
|
||||
text(row[key], f"action.{key}")
|
||||
strings(row["contributing_muscle_ids"], "action.contributing_muscle_ids")
|
||||
strings(row["source_refs"], "action.source_refs")
|
||||
require(set(row["contributing_muscle_ids"]) <= muscles and set(row["source_refs"]) <= refs and row["source_refs"],
|
||||
"action cross-reference failure")
|
||||
confidence(row["confidence"], "action.confidence")
|
||||
|
||||
for row in patterns_rows:
|
||||
required = {"pattern_id", "display_name_fr", "definition", "typical_action_ids", "typical_body_zone_ids",
|
||||
"evidence_type", "confidence", "source_refs", "notes"}
|
||||
require(required <= set(row) <= required | {"body_zone_semantics", "parent_pattern_id"},
|
||||
f"pattern {row['pattern_id']}: invalid keys")
|
||||
for key in ("display_name_fr", "definition", "evidence_type", "notes"):
|
||||
text(row[key], f"pattern.{key}")
|
||||
for key in ("body_zone_semantics", "parent_pattern_id"):
|
||||
if key in row:
|
||||
nullable_text(row[key], f"pattern.{key}")
|
||||
for key in ("typical_action_ids", "typical_body_zone_ids", "source_refs"):
|
||||
strings(row[key], f"pattern.{key}")
|
||||
require(set(row["typical_action_ids"]) <= actions and set(row["typical_body_zone_ids"]) <= zones and
|
||||
set(row["source_refs"]) <= refs and row["source_refs"], "pattern cross-reference failure")
|
||||
if row.get("parent_pattern_id") is not None:
|
||||
require(row["parent_pattern_id"] in patterns, "dangling parent pattern")
|
||||
confidence(row["confidence"], "pattern.confidence")
|
||||
|
||||
linked = set()
|
||||
exercise_equipment = {}
|
||||
for row in exercise_rows:
|
||||
required = {"exercise_id", "exercise_name", "equipment_ids", "identity_evidence",
|
||||
"identity_evidence_type", "resolution_status", "confidence", "interpretation",
|
||||
"conditional_interpretation", "existing_body_zones", "source_refs", "limitations",
|
||||
"body_zone_audit"}
|
||||
require(required <= set(row) <= required | {"equipment_link_status"},
|
||||
f"exercise {row['exercise_id']}: invalid keys")
|
||||
require(re.fullmatch(r"ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
|
||||
row["exercise_id"]) is not None, "invalid exercise UUID")
|
||||
for key in ("exercise_name", "identity_evidence", "identity_evidence_type"):
|
||||
text(row[key], f"exercise.{key}")
|
||||
strings(row["limitations"], "exercise.limitations", ordered=False)
|
||||
if "equipment_link_status" in row:
|
||||
text(row["equipment_link_status"], "exercise.equipment_link_status")
|
||||
exact_object(row["existing_body_zones"], {"primary_zone_id", "secondary_zone_ids"},
|
||||
"exercise.existing_body_zones")
|
||||
require(row["existing_body_zones"]["primary_zone_id"] is None or
|
||||
row["existing_body_zones"]["primary_zone_id"] in zones,
|
||||
"exercise existing primary zone invalid")
|
||||
strings(row["existing_body_zones"]["secondary_zone_ids"], "exercise.existing_body_zones.secondary_zone_ids")
|
||||
require(set(row["existing_body_zones"]["secondary_zone_ids"]) <= zones,
|
||||
"exercise existing secondary zones invalid")
|
||||
body_audit = row["body_zone_audit"]
|
||||
exact_object(body_audit, {"status", "severity", "confidence", "rationale", "existing_primary_zone_id",
|
||||
"existing_secondary_zone_ids", "proposed_mutation", "source_refs"}, "exercise.body_zone_audit")
|
||||
require(body_audit["status"] in {"confirmed", "questionable", "unresolved"}, "invalid embedded body-zone audit")
|
||||
for key in ("severity", "rationale"):
|
||||
text(body_audit[key], f"exercise.body_zone_audit.{key}")
|
||||
confidence(body_audit["confidence"], "exercise.body_zone_audit.confidence")
|
||||
require(body_audit["existing_primary_zone_id"] is None or body_audit["existing_primary_zone_id"] in zones,
|
||||
"audit primary zone invalid")
|
||||
strings(body_audit["existing_secondary_zone_ids"], "audit existing secondary zones")
|
||||
require(set(body_audit["existing_secondary_zone_ids"]) <= zones, "audit secondary zone invalid")
|
||||
nullable_text(body_audit["proposed_mutation"], "audit.proposed_mutation")
|
||||
refs_list(body_audit["source_refs"], "exercise.body_zone_audit.source_refs", refs,
|
||||
nonempty=body_audit["status"] != "unresolved")
|
||||
status = row["resolution_status"]
|
||||
require(status in {"resolved_family_variant_limited", "conditional", "unresolved"}, "invalid resolution status")
|
||||
confidence(row["confidence"], "exercise.confidence")
|
||||
strings(row["equipment_ids"], "exercise.equipment_ids")
|
||||
exercise_equipment[row["exercise_id"]] = set(row["equipment_ids"])
|
||||
strings(row["source_refs"], "exercise.source_refs")
|
||||
require(set(row["equipment_ids"]) <= equipment and set(row["source_refs"]) <= refs,
|
||||
"exercise cross-reference failure")
|
||||
if status == "resolved_family_variant_limited":
|
||||
require(bool(row["source_refs"]), "resolved exercise lacks evidence")
|
||||
require(row["conditional_interpretation"] is None, "resolved record has conditional interpretation")
|
||||
validate_interpretation(row["interpretation"], f"exercise {row['exercise_id']}", refs, muscles, actions, patterns, zones)
|
||||
if row["confidence"] == "high":
|
||||
require(any(ref_types[ref] in scientific_types for ref in row["interpretation"]["source_refs"]),
|
||||
"high exercise mapping lacks scientific source")
|
||||
elif status == "conditional":
|
||||
require(bool(row["source_refs"]), "conditional exercise lacks evidence")
|
||||
require(row["interpretation"] is None, "conditional record leaks into resolved interpretation")
|
||||
validate_interpretation(row["conditional_interpretation"], f"conditional {row['exercise_id']}", refs, muscles, actions, patterns, zones)
|
||||
else:
|
||||
require(row["interpretation"] is None and row["conditional_interpretation"] is None,
|
||||
"unresolved record has interpretation")
|
||||
|
||||
manufacturer_only = {"manufacturer_statement"}
|
||||
equipment_required = {"equipment_id", "manufacturer", "model", "identification_status", "scientific_status",
|
||||
"scientific_status_scope", "catalog_type", "catalog_load_semantics", "mechanics", "evidence_type",
|
||||
"confidence", "source_refs", "capabilities", "requires_actual_exercise", "limitations"}
|
||||
capability_required = {"display_name", "exercise_ids", "link_status", "interpretation", "requirements"}
|
||||
for row in equipment_rows:
|
||||
exact_object(row, equipment_required, f"equipment {row['equipment_id']}", {"audit_note"})
|
||||
for key in ("identification_status", "scientific_status_scope", "catalog_type",
|
||||
"mechanics", "evidence_type"):
|
||||
text(row[key], f"equipment.{key}")
|
||||
for key in ("manufacturer", "model", "catalog_load_semantics"):
|
||||
nullable_text(row[key], f"equipment.{key}")
|
||||
if "audit_note" in row:
|
||||
nullable_text(row["audit_note"], "equipment.audit_note")
|
||||
require(isinstance(row["requires_actual_exercise"], bool), "equipment requires_actual_exercise invalid")
|
||||
strings(row["limitations"], "equipment.limitations", ordered=False)
|
||||
require(row["scientific_status"] in {"scientifically_documented", "mechanically_identified_anatomy_incomplete",
|
||||
"equipment_identity_uncertain"}, "invalid equipment science_status")
|
||||
require(row["confidence"] in {"high", "moderate", "uncertain"}, "invalid equipment confidence")
|
||||
strings(row["source_refs"], "equipment.source_refs")
|
||||
require(set(row["source_refs"]) <= refs and row["source_refs"], "equipment evidence failure")
|
||||
if row["confidence"] == "high":
|
||||
require(row["evidence_type"] not in manufacturer_only, "manufacturer-only high anatomical mapping")
|
||||
require(any(ref_types[ref] in scientific_types for ref in row["source_refs"]),
|
||||
"high equipment mapping lacks scientific source")
|
||||
require(isinstance(row["capabilities"], list), "equipment capabilities invalid")
|
||||
for capability in row["capabilities"]:
|
||||
exact_object(capability, capability_required, "equipment capability")
|
||||
for key in ("display_name", "link_status", "requirements"):
|
||||
text(capability[key], f"capability.{key}")
|
||||
strings(capability["exercise_ids"], "capability.exercise_ids")
|
||||
require(set(capability["exercise_ids"]) <= exercises, "capability phantom exercise")
|
||||
linked.update((exercise_id, row["equipment_id"]) for exercise_id in capability["exercise_ids"])
|
||||
for exercise_id in capability["exercise_ids"]:
|
||||
require(row["equipment_id"] in exercise_equipment[exercise_id],
|
||||
"equipment/exercise compatibility is not symmetric")
|
||||
if capability["interpretation"] is not None:
|
||||
validate_interpretation(capability["interpretation"], "equipment capability", refs, muscles, actions, patterns, zones)
|
||||
for row in exercise_rows:
|
||||
for equipment_id in row["equipment_ids"]:
|
||||
require((row["exercise_id"], equipment_id) in linked, "exercise/equipment compatibility is not symmetric")
|
||||
|
||||
audit = envelope(load(root / "training-knowledge-audit-v1.json"),
|
||||
"trainlog-training-knowledge-audit-v1", "rows")
|
||||
require(ids(audit, "exercise_id", "audit") == exercises, "audit does not cover exercise inventory")
|
||||
for row in audit:
|
||||
audit_required = {"status", "severity", "confidence", "rationale", "existing_primary_zone_id",
|
||||
"existing_secondary_zone_ids", "proposed_mutation", "source_refs", "exercise_id", "exercise_name",
|
||||
"resolution_status", "equipment_ids", "knowledge_confidence", "knowledge_source_refs",
|
||||
"family_description", "action_ids", "pattern_ids", "primary_muscle_ids", "secondary_muscle_ids",
|
||||
"stabilizer_muscle_ids", "scientific_primary_zone_id", "scientific_secondary_zone_ids"}
|
||||
exact_object(row, audit_required, "audit row")
|
||||
require(row["status"] in {"confirmed", "questionable", "unresolved"}, "invalid audit status")
|
||||
confidence(row["confidence"], "audit.confidence")
|
||||
confidence(row["knowledge_confidence"], "audit.knowledge_confidence")
|
||||
for key in ("exercise_name", "resolution_status", "severity", "rationale"):
|
||||
text(row[key], f"audit.{key}")
|
||||
for key in ("family_description", "scientific_primary_zone_id", "proposed_mutation"):
|
||||
nullable_text(row[key], f"audit.{key}")
|
||||
for key in ("source_refs", "knowledge_source_refs", "equipment_ids", "action_ids", "pattern_ids",
|
||||
"primary_muscle_ids", "secondary_muscle_ids", "stabilizer_muscle_ids",
|
||||
"scientific_secondary_zone_ids"):
|
||||
strings(row[key], f"audit.{key}")
|
||||
require(set(row["source_refs"]) <= refs and set(row["knowledge_source_refs"]) <= refs,
|
||||
"audit evidence failure")
|
||||
require(set(row["equipment_ids"]) <= equipment and set(row["action_ids"]) <= actions and
|
||||
set(row["pattern_ids"]) <= patterns and
|
||||
set(row["primary_muscle_ids"] + row["secondary_muscle_ids"] +
|
||||
row["stabilizer_muscle_ids"]) <= muscles and
|
||||
set(row["scientific_secondary_zone_ids"]) <= zones,
|
||||
"audit cross-reference failure")
|
||||
require(row["status"] == "unresolved" or bool(row["source_refs"]), "resolved audit lacks evidence")
|
||||
expected_audit = []
|
||||
for exercise in exercise_rows:
|
||||
expected = dict(exercise["body_zone_audit"])
|
||||
interpretation = exercise["interpretation"] or exercise["conditional_interpretation"]
|
||||
expected.update({
|
||||
"exercise_id": exercise["exercise_id"], "exercise_name": exercise["exercise_name"],
|
||||
"resolution_status": exercise["resolution_status"], "equipment_ids": exercise["equipment_ids"],
|
||||
"knowledge_confidence": exercise["confidence"], "knowledge_source_refs": exercise["source_refs"],
|
||||
"family_description": None if interpretation is None else interpretation["family_description"],
|
||||
"action_ids": [] if interpretation is None else interpretation["action_ids"],
|
||||
"pattern_ids": [] if interpretation is None else interpretation["pattern_ids"],
|
||||
"primary_muscle_ids": [] if interpretation is None else interpretation["primary_muscle_ids"],
|
||||
"secondary_muscle_ids": [] if interpretation is None else interpretation["secondary_muscle_ids"],
|
||||
"stabilizer_muscle_ids": [] if interpretation is None else interpretation["stabilizer_muscle_ids"],
|
||||
"scientific_primary_zone_id": None if interpretation is None else interpretation["primary_zone_id"],
|
||||
"scientific_secondary_zone_ids": [] if interpretation is None else interpretation["secondary_zone_ids"],
|
||||
})
|
||||
expected_audit.append(expected)
|
||||
require(audit == expected_audit, "generated audit is stale or inconsistent with canonical exercises")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("catalog", nargs="?", type=Path, default=Path("catalog"))
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
validate(args.catalog)
|
||||
except ValidationError as error:
|
||||
raise SystemExit(str(error)) from error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -15,6 +15,11 @@
|
|||
|
||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||
|
||||
/* Nestable read savepoints let composition services observe one database state
|
||||
* without exposing SQLite or issuing writes to application data. */
|
||||
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database);
|
||||
TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database, bool commit_snapshot);
|
||||
|
||||
/* WHY: session occurrences retain an equipment ID, while custom definitions
|
||||
* need durable presentation metadata. A reference alone is never a definition. */
|
||||
typedef struct TrainlogCustomEquipment {
|
||||
|
|
@ -386,6 +391,115 @@ TrainlogStatus trainlog_database_list_exercise_performance(
|
|||
size_t *output_count
|
||||
);
|
||||
|
||||
/* TRAINING_KNOWLEDGE_RUNTIME_READ_V1 */
|
||||
#define TRAINLOG_OCCURRENCE_PAGE_MAX 32U
|
||||
#define TRAINLOG_OCCURRENCE_SET_PAGE_MAX 64U
|
||||
|
||||
typedef struct TrainlogExerciseOccurrenceCursor {
|
||||
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||
char session_id[TRAINLOG_ID_MAX + 1U];
|
||||
char entry_id[TRAINLOG_ID_MAX + 1U];
|
||||
} TrainlogExerciseOccurrenceCursor;
|
||||
|
||||
typedef struct TrainlogExerciseOccurrence {
|
||||
char session_id[TRAINLOG_ID_MAX + 1U];
|
||||
char entry_id[TRAINLOG_ID_MAX + 1U];
|
||||
char exercise_id[TRAINLOG_ID_MAX + 1U];
|
||||
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||
TrainlogSessionType session_type;
|
||||
TrainlogTrackingMode tracking_mode;
|
||||
TrainlogRecordingMode recording_mode;
|
||||
TrainlogExerciseDataFields data_fields;
|
||||
TrainlogLoadMode load_mode;
|
||||
size_t set_count;
|
||||
int continuous_duration_seconds;
|
||||
bool continuous_has_speed;
|
||||
double continuous_speed_kmh;
|
||||
bool continuous_has_distance;
|
||||
double continuous_distance_km;
|
||||
} TrainlogExerciseOccurrence;
|
||||
|
||||
typedef struct TrainlogOccurrenceSet {
|
||||
size_t position;
|
||||
bool has_reps;
|
||||
int reps;
|
||||
bool has_duration;
|
||||
int duration_seconds;
|
||||
bool has_weight;
|
||||
double weight_kg;
|
||||
} TrainlogOccurrenceSet;
|
||||
|
||||
typedef struct TrainlogLatestExplicitMax {
|
||||
bool found;
|
||||
char session_id[TRAINLOG_ID_MAX + 1U];
|
||||
char entry_id[TRAINLOG_ID_MAX + 1U];
|
||||
char started_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||
char equipment_id[TRAINLOG_ID_MAX + 1U];
|
||||
TrainlogLoadMode load_mode;
|
||||
double max_weight_kg;
|
||||
} TrainlogLatestExplicitMax;
|
||||
|
||||
/* Exact-ID profile read; unknown IDs return NOT_FOUND. */
|
||||
TrainlogStatus trainlog_database_get_exercise_profile(
|
||||
TrainlogDatabase *database,
|
||||
const char *exercise_id,
|
||||
TrainlogExercise *output
|
||||
);
|
||||
|
||||
/**
|
||||
* Current-data keyset page ordered by the exact started_at instant, then
|
||||
* session_id and entry_id bytewise DESC. Accepted timestamps use the frozen
|
||||
* extended RFC3339 grammar, including T/t, Z/z, numeric offsets through
|
||||
* 23:59, arbitrary fractional precision, and the Android writer's omitted
|
||||
* seconds form. Equivalent trailing-zero fractions represent one instant.
|
||||
* A non-NULL cursor is exclusive. Pages are not a cross-call snapshot under
|
||||
* concurrent edits. Cursor arrays are borrowed for this call, must be NUL
|
||||
* terminated within their declared capacities, and started_at must be a real
|
||||
* accepted instant with an explicit numeric offset or Z/z. Malformed cursors
|
||||
* are INVALID_ARGUMENT before any history query. Malformed matching persisted
|
||||
* timestamps, or a selected timestamp too long for the fixed public output,
|
||||
* are DATABASE_ERROR rather than omitted or truncated. limit must be
|
||||
* 1..TRAINLOG_OCCURRENCE_PAGE_MAX.
|
||||
*/
|
||||
TrainlogStatus trainlog_database_list_exercise_occurrences_page(
|
||||
TrainlogDatabase *database,
|
||||
const char *exercise_id,
|
||||
const TrainlogExerciseOccurrenceCursor *after,
|
||||
size_t limit,
|
||||
TrainlogExerciseOccurrence *output,
|
||||
size_t *output_count,
|
||||
bool *output_has_more,
|
||||
TrainlogExerciseOccurrenceCursor *output_next
|
||||
);
|
||||
|
||||
/* Original set positions are preserved. after_position is exclusive; use -1
|
||||
* for the first page. Reps are non-negative, so zero preserves a failed
|
||||
* attempt. Nullable values remain distinct from zero. Corrupt storage types or
|
||||
* values outside the public int/size_t ranges return DATABASE_ERROR. No rows
|
||||
* exist for continuous occurrences. Output remains caller-owned. */
|
||||
TrainlogStatus trainlog_database_list_occurrence_sets_page(
|
||||
TrainlogDatabase *database,
|
||||
const char *entry_id,
|
||||
int after_position,
|
||||
size_t limit,
|
||||
TrainlogOccurrenceSet *output,
|
||||
size_t *output_count,
|
||||
bool *output_has_more,
|
||||
int *output_next_position
|
||||
);
|
||||
|
||||
/* Reads max_results joined only through max_test sessions; ordinary large sets
|
||||
* never qualify. It uses the same exact instant and bytewise-ID ordering as
|
||||
* occurrence pagination. Output keeps the exact source timestamp and
|
||||
* occurrence load/equipment context; corrupt or over-capacity selected storage
|
||||
* returns DATABASE_ERROR. */
|
||||
TrainlogStatus trainlog_database_latest_explicit_max_context(
|
||||
TrainlogDatabase *database,
|
||||
const char *exercise_id,
|
||||
TrainlogLatestExplicitMax *output
|
||||
);
|
||||
|
||||
|
||||
/* TRAINLOG_SESSION_EDIT_API */
|
||||
|
||||
|
|
|
|||
50
tui/include/trainlog/training_context.h
Normal file
50
tui/include/trainlog/training_context.h
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#ifndef TRAINLOG_TRAINING_CONTEXT_H
|
||||
#define TRAINLOG_TRAINING_CONTEXT_H
|
||||
|
||||
/** @file training_context.h Read-only runtime/scientific exercise composition. */
|
||||
|
||||
#include "trainlog/database.h"
|
||||
#include "trainlog/training_knowledge.h"
|
||||
|
||||
typedef struct TrainlogTrainingOccurrenceView {
|
||||
TrainlogExerciseOccurrence occurrence;
|
||||
size_t set_offset;
|
||||
size_t set_count;
|
||||
bool sets_have_more;
|
||||
int next_set_position;
|
||||
} TrainlogTrainingOccurrenceView;
|
||||
|
||||
typedef struct TrainlogTrainingExerciseContext {
|
||||
TrainlogExercise exercise;
|
||||
TrainlogExerciseBodyZone *persisted_zones;
|
||||
size_t persisted_zone_count;
|
||||
const TrainlogExerciseKnowledge *knowledge; /* borrowed process-lifetime data */
|
||||
const TrainlogEquipmentKnowledge **compatible_equipment; /* owned pointer array */
|
||||
size_t compatible_equipment_count;
|
||||
TrainlogLatestExplicitMax latest_max;
|
||||
TrainlogTrainingOccurrenceView *occurrences;
|
||||
size_t occurrence_count;
|
||||
bool occurrences_have_more;
|
||||
TrainlogExerciseOccurrenceCursor next_occurrence;
|
||||
TrainlogOccurrenceSet *sets;
|
||||
size_t set_count;
|
||||
} TrainlogTrainingExerciseContext;
|
||||
|
||||
/**
|
||||
* Compose one exact runtime ID. occurrence_limit is 1..32 and set_preview_limit
|
||||
* is 1..64 per occurrence. The caller owns output allocations and must call
|
||||
* release after success. Unknown runtime IDs return NOT_FOUND; missing knowledge
|
||||
* is valid and represented by NULL. One nested-safe database read snapshot
|
||||
* covers the call. No catalog relationship fabricates occurrence history.
|
||||
*/
|
||||
TrainlogStatus trainlog_training_exercise_context_load(
|
||||
TrainlogDatabase *database,
|
||||
const char *exercise_id,
|
||||
size_t occurrence_limit,
|
||||
size_t set_preview_limit,
|
||||
TrainlogTrainingExerciseContext *output
|
||||
);
|
||||
|
||||
void trainlog_training_exercise_context_release(TrainlogTrainingExerciseContext *context);
|
||||
|
||||
#endif
|
||||
219
tui/include/trainlog/training_knowledge.h
Normal file
219
tui/include/trainlog/training_knowledge.h
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#ifndef TRAINLOG_TRAINING_KNOWLEDGE_H
|
||||
#define TRAINLOG_TRAINING_KNOWLEDGE_H
|
||||
|
||||
/**
|
||||
* @file training_knowledge.h
|
||||
* @brief Immutable, evidence-linked TRAINING KNOWLEDGE V1 catalog API.
|
||||
*
|
||||
* All returned records and strings are borrowed from generated const storage
|
||||
* and remain valid for process lifetime. Newline-separated ID fields contain
|
||||
* exact stable IDs; labels are never used as identities.
|
||||
*/
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#include "trainlog/status.h"
|
||||
|
||||
typedef enum TrainlogKnowledgeMuscleRole {
|
||||
TRAINLOG_KNOWLEDGE_ROLE_ANY = 0,
|
||||
TRAINLOG_KNOWLEDGE_ROLE_PRIMARY,
|
||||
TRAINLOG_KNOWLEDGE_ROLE_SECONDARY,
|
||||
TRAINLOG_KNOWLEDGE_ROLE_STABILIZER
|
||||
} TrainlogKnowledgeMuscleRole;
|
||||
|
||||
typedef struct TrainlogKnowledgeReference {
|
||||
const char *ref_id;
|
||||
const char *title;
|
||||
const char *authors_or_organization;
|
||||
int year; /* zero means the catalog explicitly records an unknown year */
|
||||
const char *type;
|
||||
const char *url;
|
||||
const char *doi;
|
||||
const char *pmid;
|
||||
const char *topics;
|
||||
const char *notes;
|
||||
const char *limitations;
|
||||
const char *accessed_on;
|
||||
const char *publication_note;
|
||||
} TrainlogKnowledgeReference;
|
||||
|
||||
typedef struct TrainlogKnowledgeMuscle {
|
||||
const char *muscle_id;
|
||||
const char *display_name;
|
||||
const char *display_name_fr;
|
||||
const char *entity_type;
|
||||
const char *anatomical_group;
|
||||
const char *aggregate_group_id;
|
||||
const char *member_muscle_ids;
|
||||
const char *body_zone_ids;
|
||||
const char *joint_action_ids;
|
||||
const char *primary_actions;
|
||||
const char *primary_actions_semantics;
|
||||
const char *functional_notes;
|
||||
const char *confidence;
|
||||
const char *evidence_type;
|
||||
const char *source_refs;
|
||||
const char *overlap_warning;
|
||||
} TrainlogKnowledgeMuscle;
|
||||
|
||||
typedef struct TrainlogKnowledgeJointAction {
|
||||
const char *action_id;
|
||||
const char *display_name_fr;
|
||||
const char *definition;
|
||||
const char *anatomical_region;
|
||||
const char *joint_complex;
|
||||
const char *principal_plane;
|
||||
const char *plane_notes;
|
||||
const char *contributing_muscle_ids;
|
||||
const char *contributor_semantics;
|
||||
const char *confidence;
|
||||
const char *evidence_type;
|
||||
const char *source_refs;
|
||||
const char *notes;
|
||||
} TrainlogKnowledgeJointAction;
|
||||
|
||||
typedef struct TrainlogKnowledgeMovementPattern {
|
||||
const char *pattern_id;
|
||||
const char *display_name_fr;
|
||||
const char *definition;
|
||||
const char *parent_pattern_id;
|
||||
const char *typical_action_ids;
|
||||
const char *typical_body_zone_ids;
|
||||
const char *body_zone_semantics;
|
||||
const char *confidence;
|
||||
const char *evidence_type;
|
||||
const char *source_refs;
|
||||
const char *notes;
|
||||
} TrainlogKnowledgeMovementPattern;
|
||||
|
||||
typedef struct TrainlogKnowledgeInterpretation {
|
||||
const char *family_description;
|
||||
const char *action_ids;
|
||||
const char *pattern_ids;
|
||||
const char *primary_muscle_ids;
|
||||
const char *secondary_muscle_ids;
|
||||
const char *stabilizer_muscle_ids;
|
||||
const char *primary_zone_id;
|
||||
const char *secondary_zone_ids;
|
||||
const char *confidence;
|
||||
const char *evidence_type;
|
||||
const char *source_refs;
|
||||
const char *variant_notes;
|
||||
const char *role_notes;
|
||||
const char *required_confirmation;
|
||||
} TrainlogKnowledgeInterpretation;
|
||||
|
||||
typedef struct TrainlogExerciseKnowledge {
|
||||
const char *exercise_id;
|
||||
const char *exercise_name;
|
||||
const char *resolution_status;
|
||||
const char *confidence;
|
||||
const char *equipment_ids;
|
||||
const char *identity_evidence;
|
||||
const char *identity_evidence_type;
|
||||
const char *source_refs;
|
||||
const char *limitations;
|
||||
const char *equipment_link_status;
|
||||
const TrainlogKnowledgeInterpretation *interpretation;
|
||||
const TrainlogKnowledgeInterpretation *conditional_interpretation;
|
||||
} TrainlogExerciseKnowledge;
|
||||
|
||||
/* WHY: the authored BODY ZONES review is part of the immutable scientific
|
||||
* catalog contract. Exposing every field prevents clients from substituting
|
||||
* the resolved interpretation for the review decision. All pointers are
|
||||
* borrowed from generated storage and remain valid for process lifetime. */
|
||||
typedef struct TrainlogKnowledgeBodyZoneAudit {
|
||||
const char *exercise_id;
|
||||
const char *status;
|
||||
const char *severity;
|
||||
const char *rationale;
|
||||
const char *confidence;
|
||||
const char *source_refs;
|
||||
const char *existing_primary_zone_id;
|
||||
const char *existing_secondary_zone_ids;
|
||||
const char *scientific_primary_zone_id;
|
||||
const char *scientific_secondary_zone_ids;
|
||||
const char *proposed_mutation;
|
||||
} TrainlogKnowledgeBodyZoneAudit;
|
||||
|
||||
typedef struct TrainlogEquipmentKnowledge {
|
||||
const char *equipment_id;
|
||||
const char *manufacturer;
|
||||
const char *model;
|
||||
const char *identification_status;
|
||||
const char *scientific_status;
|
||||
const char *scientific_status_scope;
|
||||
const char *catalog_type;
|
||||
const char *catalog_load_semantics;
|
||||
const char *mechanics;
|
||||
const char *confidence;
|
||||
const char *evidence_type;
|
||||
const char *source_refs;
|
||||
const char *limitations;
|
||||
const char *audit_note;
|
||||
bool requires_actual_exercise;
|
||||
} TrainlogEquipmentKnowledge;
|
||||
|
||||
typedef struct TrainlogEquipmentCapability {
|
||||
const char *equipment_id;
|
||||
const char *display_name;
|
||||
const char *exercise_ids;
|
||||
const char *link_status;
|
||||
const char *requirements;
|
||||
const TrainlogKnowledgeInterpretation *interpretation;
|
||||
} TrainlogEquipmentCapability;
|
||||
|
||||
typedef struct TrainlogKnowledgeQuery {
|
||||
const char *scientific_zone_id;
|
||||
bool include_zone_descendants;
|
||||
const char *movement_pattern_id;
|
||||
const char *muscle_id;
|
||||
TrainlogKnowledgeMuscleRole muscle_role;
|
||||
const char *available_equipment_id;
|
||||
} TrainlogKnowledgeQuery;
|
||||
|
||||
size_t trainlog_knowledge_reference_count(void);
|
||||
const TrainlogKnowledgeReference *trainlog_knowledge_reference_at(size_t index);
|
||||
const TrainlogKnowledgeReference *trainlog_knowledge_reference_lookup(const char *ref_id);
|
||||
size_t trainlog_knowledge_muscle_count(void);
|
||||
const TrainlogKnowledgeMuscle *trainlog_knowledge_muscle_at(size_t index);
|
||||
const TrainlogKnowledgeMuscle *trainlog_knowledge_muscle_lookup(const char *muscle_id);
|
||||
size_t trainlog_knowledge_joint_action_count(void);
|
||||
const TrainlogKnowledgeJointAction *trainlog_knowledge_joint_action_at(size_t index);
|
||||
const TrainlogKnowledgeJointAction *trainlog_knowledge_joint_action_lookup(const char *action_id);
|
||||
size_t trainlog_knowledge_movement_pattern_count(void);
|
||||
const TrainlogKnowledgeMovementPattern *trainlog_knowledge_movement_pattern_at(size_t index);
|
||||
const TrainlogKnowledgeMovementPattern *trainlog_knowledge_movement_pattern_lookup(const char *pattern_id);
|
||||
size_t trainlog_exercise_knowledge_count(void);
|
||||
const TrainlogExerciseKnowledge *trainlog_exercise_knowledge_at(size_t index);
|
||||
const TrainlogExerciseKnowledge *trainlog_exercise_knowledge_lookup(const char *exercise_id);
|
||||
/* at() returns NULL outside the immutable snapshot; lookup() returns NULL for
|
||||
* NULL, empty, or unknown IDs. Neither function transfers ownership. */
|
||||
size_t trainlog_knowledge_body_zone_audit_count(void);
|
||||
const TrainlogKnowledgeBodyZoneAudit *trainlog_knowledge_body_zone_audit_at(size_t index);
|
||||
const TrainlogKnowledgeBodyZoneAudit *trainlog_knowledge_body_zone_audit_lookup(const char *exercise_id);
|
||||
/* Only this explicitly named accessor exposes conditional candidate data. */
|
||||
const TrainlogKnowledgeInterpretation *trainlog_exercise_knowledge_conditional(const char *exercise_id);
|
||||
size_t trainlog_equipment_knowledge_count(void);
|
||||
const TrainlogEquipmentKnowledge *trainlog_equipment_knowledge_at(size_t index);
|
||||
const TrainlogEquipmentKnowledge *trainlog_equipment_knowledge_lookup(const char *equipment_id);
|
||||
size_t trainlog_equipment_capability_count(void);
|
||||
const TrainlogEquipmentCapability *trainlog_equipment_capability_at(size_t index);
|
||||
|
||||
/**
|
||||
* AND-combine optional resolved-knowledge filters in stable exercise-ID order.
|
||||
* muscle_id may be NULL only when muscle_role is ROLE_ANY; a specific role
|
||||
* and muscle_id must be supplied together. Unknown filter IDs return NOT_FOUND. Malformed arguments or insufficient
|
||||
* capacity return INVALID_ARGUMENT. output_count always receives the required
|
||||
* count after valid filters are resolved, so truncation is never reported as
|
||||
* success. Conditional and unresolved records can never match this function.
|
||||
*/
|
||||
TrainlogStatus trainlog_exercise_knowledge_query(
|
||||
const TrainlogKnowledgeQuery *query,
|
||||
const TrainlogExerciseKnowledge **output,
|
||||
size_t capacity,
|
||||
size_t *output_count
|
||||
);
|
||||
|
||||
#endif
|
||||
|
|
@ -30,6 +30,28 @@ body_zone_catalog_generated = custom_target(
|
|||
command: [find_program('python3'), meson.project_source_root() / 'tools/generate_body_zone_catalog.py', '@INPUT@', '@OUTPUT@'],
|
||||
)
|
||||
|
||||
training_knowledge_generated = custom_target(
|
||||
'training_knowledge_generated',
|
||||
input: [
|
||||
meson.project_source_root() / 'catalog/body-zones-v1.json',
|
||||
meson.project_source_root() / 'catalog/equipment-v1.json',
|
||||
meson.project_source_root() / 'catalog/science-references-v1.json',
|
||||
meson.project_source_root() / 'catalog/muscles-v1.json',
|
||||
meson.project_source_root() / 'catalog/joint-actions-v1.json',
|
||||
meson.project_source_root() / 'catalog/movement-patterns-v1.json',
|
||||
meson.project_source_root() / 'catalog/exercise-knowledge-v1.json',
|
||||
meson.project_source_root() / 'catalog/equipment-knowledge-v1.json',
|
||||
meson.project_source_root() / 'catalog/training-knowledge-audit-v1.json',
|
||||
],
|
||||
output: 'training_knowledge_generated.c',
|
||||
depend_files: [
|
||||
meson.project_source_root() / 'tools/generate_training_knowledge.py',
|
||||
meson.project_source_root() / 'tools/validate_training_knowledge.py',
|
||||
],
|
||||
command: [find_program('python3'), meson.project_source_root() / 'tools/generate_training_knowledge.py',
|
||||
meson.project_source_root() / 'catalog', '@OUTPUT@'],
|
||||
)
|
||||
|
||||
strict_c_args = [
|
||||
'-D_POSIX_C_SOURCE=200809L',
|
||||
'-Wconversion',
|
||||
|
|
@ -45,15 +67,18 @@ trainlog_core_sources = files(
|
|||
'src/duration.c',
|
||||
'src/id.c',
|
||||
'src/timeutil.c',
|
||||
'src/training_context.c',
|
||||
'src/usb.c',
|
||||
'src/mtp.c',
|
||||
'src/reps.c',
|
||||
'src/measured_max.c',
|
||||
'src/sync.c',
|
||||
'src/sync_history.c',
|
||||
'src/timestamp.c',
|
||||
)
|
||||
trainlog_core_sources += equipment_catalog_generated
|
||||
trainlog_core_sources += body_zone_catalog_generated
|
||||
trainlog_core_sources += training_knowledge_generated
|
||||
|
||||
trainlog_core = static_library(
|
||||
'trainlog_core',
|
||||
|
|
@ -142,6 +167,22 @@ test_body_zones = executable(
|
|||
)
|
||||
test('body_zones', test_body_zones)
|
||||
|
||||
test_training_knowledge = executable(
|
||||
'test_training_knowledge',
|
||||
'tests/test_training_knowledge.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('training_knowledge', test_training_knowledge)
|
||||
|
||||
test_training_context = executable(
|
||||
'test_training_context',
|
||||
'tests/test_training_context.c',
|
||||
dependencies: trainlog_core_dep,
|
||||
c_args: strict_c_args,
|
||||
)
|
||||
test('training_context', test_training_context)
|
||||
|
||||
test_custom_equipment = executable(
|
||||
'test_custom_equipment',
|
||||
'tests/test_custom_equipment.c',
|
||||
|
|
@ -399,6 +440,12 @@ test(
|
|||
|
||||
python3_trainlog_tests = find_program('python3')
|
||||
|
||||
test(
|
||||
'timestamp_validation',
|
||||
python3_trainlog_tests,
|
||||
args: [meson.project_source_root() / 'tests/test_timestamp_validation.py'],
|
||||
)
|
||||
|
||||
test(
|
||||
'mobile_import_variable_sets',
|
||||
python3_trainlog_tests,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
#include "trainlog/duration.h"
|
||||
#include "trainlog/equipment_catalog.h"
|
||||
#include "trainlog/id.h"
|
||||
#include "timestamp.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
|
@ -19,8 +21,38 @@
|
|||
|
||||
struct TrainlogDatabase {
|
||||
sqlite3 *connection;
|
||||
unsigned int read_snapshot_depth;
|
||||
};
|
||||
|
||||
TrainlogStatus trainlog_database_read_snapshot_begin(TrainlogDatabase *database)
|
||||
{
|
||||
char sql[64];
|
||||
if (database == NULL || database->connection == NULL || database->read_snapshot_depth == UINT_MAX)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
(void)snprintf(sql, sizeof(sql), "SAVEPOINT trainlog_read_%u;", database->read_snapshot_depth);
|
||||
if (sqlite3_exec(database->connection, sql, NULL, NULL, NULL) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
++database->read_snapshot_depth;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database, bool commit_snapshot)
|
||||
{
|
||||
char sql[160];
|
||||
unsigned int depth;
|
||||
if (database == NULL || database->connection == NULL || database->read_snapshot_depth == 0U)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
depth = database->read_snapshot_depth - 1U;
|
||||
if (commit_snapshot)
|
||||
(void)snprintf(sql, sizeof(sql), "RELEASE trainlog_read_%u;", depth);
|
||||
else
|
||||
(void)snprintf(sql, sizeof(sql), "ROLLBACK TO trainlog_read_%u; RELEASE trainlog_read_%u;", depth, depth);
|
||||
if (sqlite3_exec(database->connection, sql, NULL, NULL, NULL) != SQLITE_OK)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
database->read_snapshot_depth = depth;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static TrainlogStatus lookup_exercise_row_id(
|
||||
TrainlogDatabase *database,
|
||||
const char *exercise_id,
|
||||
|
|
@ -6102,3 +6134,486 @@ TrainlogStatus trainlog_database_update_body_observation(
|
|||
? TRAINLOG_STATUS_OK
|
||||
: TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
/* TRAINING_KNOWLEDGE_RUNTIME_READ_V1 */
|
||||
static bool knowledge_copy_column(sqlite3_stmt *statement, int column, char *output, size_t capacity)
|
||||
{
|
||||
const unsigned char *value;
|
||||
size_t length;
|
||||
if (statement == NULL || sqlite3_column_type(statement, column) != SQLITE_TEXT ||
|
||||
output == NULL || capacity == 0U) return false;
|
||||
value = sqlite3_column_text(statement, column);
|
||||
if (value == NULL) return false;
|
||||
length = (size_t)sqlite3_column_bytes(statement, column);
|
||||
if (length >= capacity) return false;
|
||||
(void)memcpy(output, value, length + 1U);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool knowledge_numeric_column(sqlite3_stmt *statement, int column)
|
||||
{
|
||||
int type = sqlite3_column_type(statement, column);
|
||||
return type == SQLITE_INTEGER || type == SQLITE_FLOAT;
|
||||
}
|
||||
|
||||
static bool knowledge_bounded_string(const char *value, size_t capacity, size_t *length)
|
||||
{
|
||||
const char *end;
|
||||
if (value == NULL || capacity == 0U) return false;
|
||||
end = memchr(value, '\0', capacity);
|
||||
if (end == NULL || end == value) return false;
|
||||
if (length != NULL) *length = (size_t)(end - value);
|
||||
return true;
|
||||
}
|
||||
|
||||
typedef struct KnowledgeTemporalCandidate {
|
||||
sqlite3_int64 row_id;
|
||||
char *started_at;
|
||||
size_t started_at_length;
|
||||
char *session_id;
|
||||
size_t session_id_length;
|
||||
char *entry_id;
|
||||
size_t entry_id_length;
|
||||
TrainlogTimestampKey timestamp;
|
||||
} KnowledgeTemporalCandidate;
|
||||
|
||||
static void knowledge_temporal_candidate_release(KnowledgeTemporalCandidate *candidate)
|
||||
{
|
||||
if (candidate == NULL) return;
|
||||
free(candidate->started_at);
|
||||
free(candidate->session_id);
|
||||
free(candidate->entry_id);
|
||||
(void)memset(candidate, 0, sizeof(*candidate));
|
||||
}
|
||||
|
||||
static TrainlogStatus knowledge_copy_dynamic_column(
|
||||
sqlite3_stmt *statement, int column, char **output, size_t *output_length)
|
||||
{
|
||||
const unsigned char *source;
|
||||
size_t length;
|
||||
char *copy;
|
||||
if (sqlite3_column_type(statement, column) != SQLITE_TEXT) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
source = sqlite3_column_text(statement, column);
|
||||
if (source == NULL) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
length = (size_t)sqlite3_column_bytes(statement, column);
|
||||
if (length == 0U || memchr(source, '\0', length) != NULL || length == SIZE_MAX)
|
||||
return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
copy = malloc(length + 1U);
|
||||
if (copy == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||
(void)memcpy(copy, source, length);
|
||||
copy[length] = '\0';
|
||||
*output = copy;
|
||||
*output_length = length;
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
static int knowledge_bytes_compare(
|
||||
const char *left, size_t left_length, const char *right, size_t right_length)
|
||||
{
|
||||
size_t common = left_length < right_length ? left_length : right_length;
|
||||
int result = memcmp(left, right, common);
|
||||
if (result != 0) return result;
|
||||
if (left_length == right_length) return 0;
|
||||
return left_length < right_length ? -1 : 1;
|
||||
}
|
||||
|
||||
/* INVARIANT: This is the single ordering relation used for selection and the
|
||||
* exclusive cursor. Source timestamp spelling never participates in a tie. */
|
||||
static int knowledge_temporal_candidate_compare(
|
||||
const KnowledgeTemporalCandidate *left, const KnowledgeTemporalCandidate *right)
|
||||
{
|
||||
int result = trainlog_timestamp_compare(&left->timestamp, &right->timestamp);
|
||||
if (result != 0) return result;
|
||||
result = knowledge_bytes_compare(left->session_id, left->session_id_length,
|
||||
right->session_id, right->session_id_length);
|
||||
if (result != 0) return result;
|
||||
return knowledge_bytes_compare(left->entry_id, left->entry_id_length,
|
||||
right->entry_id, right->entry_id_length);
|
||||
}
|
||||
|
||||
static TrainlogStatus knowledge_temporal_candidate_read(
|
||||
sqlite3_stmt *statement, KnowledgeTemporalCandidate *output)
|
||||
{
|
||||
TrainlogStatus status;
|
||||
(void)memset(output, 0, sizeof(*output));
|
||||
if (sqlite3_column_type(statement, 0) != SQLITE_INTEGER) return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
status = knowledge_copy_dynamic_column(statement, 1, &output->session_id, &output->session_id_length);
|
||||
if (status == TRAINLOG_STATUS_OK)
|
||||
status = knowledge_copy_dynamic_column(statement, 2, &output->entry_id, &output->entry_id_length);
|
||||
if (status == TRAINLOG_STATUS_OK)
|
||||
status = knowledge_copy_dynamic_column(statement, 3, &output->started_at, &output->started_at_length);
|
||||
if (status != TRAINLOG_STATUS_OK ||
|
||||
!trainlog_timestamp_parse(output->started_at, output->started_at_length, &output->timestamp)) {
|
||||
knowledge_temporal_candidate_release(output);
|
||||
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
|
||||
}
|
||||
output->row_id = sqlite3_column_int64(statement, 0);
|
||||
return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
/* Retain a descending prefix. Capacity is at most page limit + one, so a
|
||||
* sorted bounded array keeps memory independent of history cardinality. */
|
||||
static void knowledge_temporal_candidate_insert(
|
||||
KnowledgeTemporalCandidate *items, size_t *count, size_t capacity,
|
||||
KnowledgeTemporalCandidate *candidate)
|
||||
{
|
||||
size_t position = 0U;
|
||||
size_t index;
|
||||
while (position < *count &&
|
||||
knowledge_temporal_candidate_compare(candidate, &items[position]) <= 0) ++position;
|
||||
if (position >= capacity) {
|
||||
knowledge_temporal_candidate_release(candidate);
|
||||
return;
|
||||
}
|
||||
if (*count == capacity) knowledge_temporal_candidate_release(&items[capacity - 1U]);
|
||||
else ++*count;
|
||||
for (index = *count - 1U; index > position; --index) items[index] = items[index - 1U];
|
||||
items[position] = *candidate;
|
||||
(void)memset(candidate, 0, sizeof(*candidate));
|
||||
}
|
||||
|
||||
static bool knowledge_tracking_mode(const char *value, TrainlogTrackingMode *output)
|
||||
{
|
||||
if (value == NULL || output == NULL) return false;
|
||||
if (strcmp(value,"reps")==0) {*output=TRAINLOG_TRACKING_REPS;return true;}
|
||||
if (strcmp(value,"duration")==0) {*output=TRAINLOG_TRACKING_DURATION;return true;}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool knowledge_recording_mode(const char *value, TrainlogRecordingMode *output)
|
||||
{
|
||||
if (value == NULL || output == NULL) return false;
|
||||
if (strcmp(value,"sets")==0) {*output=TRAINLOG_RECORDING_SETS;return true;}
|
||||
if (strcmp(value,"continuous")==0) {*output=TRAINLOG_RECORDING_CONTINUOUS;return true;}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool knowledge_load_mode(const char *value, TrainlogLoadMode *output)
|
||||
{
|
||||
if (value == NULL || output == NULL) return false;
|
||||
if (strcmp(value,"none")==0) {*output=TRAINLOG_LOAD_NONE;return true;}
|
||||
if (strcmp(value,"external")==0) {*output=TRAINLOG_LOAD_EXTERNAL;return true;}
|
||||
if (strcmp(value,"assistance")==0) {*output=TRAINLOG_LOAD_ASSISTANCE;return true;}
|
||||
return false;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_database_get_exercise_profile(
|
||||
TrainlogDatabase *database, const char *exercise_id, TrainlogExercise *output)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int rc;
|
||||
if (database == NULL || database->connection == NULL || exercise_id == NULL || exercise_id[0] == '\0' ||
|
||||
output == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
(void)memset(output, 0, sizeof(*output));
|
||||
rc = sqlite3_prepare_v2(database->connection,
|
||||
"SELECT exercise_id,name,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?1;",
|
||||
-1, &statement, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
||||
if (rc != SQLITE_OK) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; }
|
||||
rc = sqlite3_step(statement);
|
||||
if (rc == SQLITE_DONE) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_NOT_FOUND; }
|
||||
if (rc != SQLITE_ROW || !knowledge_copy_column(statement, 0, output->exercise_id, sizeof(output->exercise_id)) ||
|
||||
!knowledge_copy_column(statement, 1, output->name, sizeof(output->name)) ||
|
||||
sqlite3_column_type(statement, 4) != SQLITE_INTEGER) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if (sqlite3_column_type(statement,2)!=SQLITE_TEXT || sqlite3_column_type(statement,3)!=SQLITE_TEXT ||
|
||||
!knowledge_tracking_mode((const char *)sqlite3_column_text(statement,2),&output->tracking_mode) ||
|
||||
!knowledge_recording_mode((const char *)sqlite3_column_text(statement,3),&output->recording_mode)) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
if ((sqlite3_column_int64(statement, 4) < 0) ||
|
||||
((sqlite3_uint64)sqlite3_column_int64(statement, 4) > UINT32_MAX)) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
output->data_fields = (TrainlogExerciseDataFields)sqlite3_column_int64(statement, 4);
|
||||
return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_database_list_exercise_occurrences_page(
|
||||
TrainlogDatabase *database, const char *exercise_id, const TrainlogExerciseOccurrenceCursor *after,
|
||||
size_t limit, TrainlogExerciseOccurrence *output, size_t *output_count, bool *output_has_more,
|
||||
TrainlogExerciseOccurrenceCursor *output_next)
|
||||
{
|
||||
static const char *const SCAN_SQL =
|
||||
"SELECT se.id,s.session_id,se.entry_id,s.started_at FROM session_exercises se "
|
||||
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"WHERE e.exercise_id=?1;";
|
||||
static const char *const HYDRATE_SQL =
|
||||
"SELECT s.session_id,se.entry_id,e.exercise_id,s.started_at,COALESCE(se.equipment_id,''),"
|
||||
"s.session_type,e.tracking_mode,se.recording_mode,se.data_fields,se.load_mode,"
|
||||
"(SELECT COUNT(*) FROM performed_sets ps WHERE ps.session_exercise_row_id=se.id),"
|
||||
"ca.duration_seconds,ca.speed_kmh,ca.distance_km FROM session_exercises se "
|
||||
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"LEFT JOIN continuous_activity ca ON ca.session_exercise_row_id=se.id WHERE se.id=?1;";
|
||||
sqlite3_stmt *statement = NULL;
|
||||
KnowledgeTemporalCandidate selected[TRAINLOG_OCCURRENCE_PAGE_MAX + 1U] = {{0}};
|
||||
KnowledgeTemporalCandidate cursor = {0};
|
||||
TrainlogExerciseOccurrenceCursor after_value;
|
||||
TrainlogExercise profile;
|
||||
TrainlogStatus status;
|
||||
size_t selected_count = 0U, count = 0U, index;
|
||||
size_t cursor_started_length = 0U, cursor_session_length = 0U, cursor_entry_length = 0U;
|
||||
int rc;
|
||||
bool snapshot = false;
|
||||
if (output_count != NULL) *output_count = 0U;
|
||||
if (output_has_more != NULL) *output_has_more = false;
|
||||
if (database == NULL || database->connection == NULL || exercise_id == NULL || exercise_id[0] == '\0' ||
|
||||
limit == 0U || limit > TRAINLOG_OCCURRENCE_PAGE_MAX || output == NULL || output_count == NULL ||
|
||||
output_has_more == NULL || output_next == NULL ||
|
||||
(after != NULL && (!knowledge_bounded_string(after->started_at, sizeof(after->started_at),
|
||||
&cursor_started_length) ||
|
||||
!knowledge_bounded_string(after->session_id, sizeof(after->session_id), &cursor_session_length) ||
|
||||
!knowledge_bounded_string(after->entry_id, sizeof(after->entry_id), &cursor_entry_length) ||
|
||||
!trainlog_timestamp_parse(after->started_at, cursor_started_length, &cursor.timestamp))))
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
if (after != NULL) {
|
||||
after_value = *after;
|
||||
cursor.started_at = after_value.started_at; cursor.started_at_length = cursor_started_length;
|
||||
cursor.session_id = after_value.session_id; cursor.session_id_length = cursor_session_length;
|
||||
cursor.entry_id = after_value.entry_id; cursor.entry_id_length = cursor_entry_length;
|
||||
if (!trainlog_timestamp_parse(cursor.started_at, cursor_started_length, &cursor.timestamp))
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
}
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status != TRAINLOG_STATUS_OK) return status;
|
||||
snapshot = true;
|
||||
status = trainlog_database_get_exercise_profile(database, exercise_id, &profile);
|
||||
if (status != TRAINLOG_STATUS_OK) goto done;
|
||||
(void)memset(output_next, 0, sizeof(*output_next));
|
||||
rc = sqlite3_prepare_v2(database->connection, SCAN_SQL, -1, &statement, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_text(statement, 1, exercise_id, -1, SQLITE_TRANSIENT);
|
||||
if (rc != SQLITE_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; }
|
||||
while ((rc = sqlite3_step(statement)) == SQLITE_ROW) {
|
||||
KnowledgeTemporalCandidate candidate;
|
||||
status = knowledge_temporal_candidate_read(statement, &candidate);
|
||||
if (status != TRAINLOG_STATUS_OK) goto done;
|
||||
if (after == NULL || knowledge_temporal_candidate_compare(&candidate, &cursor) < 0)
|
||||
knowledge_temporal_candidate_insert(selected, &selected_count, limit + 1U, &candidate);
|
||||
knowledge_temporal_candidate_release(&candidate);
|
||||
}
|
||||
if (rc != SQLITE_DONE) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; }
|
||||
rc = sqlite3_finalize(statement); statement = NULL;
|
||||
if (rc != SQLITE_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; }
|
||||
statement = NULL;
|
||||
*output_has_more = selected_count > limit;
|
||||
count = *output_has_more ? limit : selected_count;
|
||||
for (index = 0U; index < count; ++index) {
|
||||
TrainlogExerciseOccurrence *item;
|
||||
sqlite3_int64 fields;
|
||||
sqlite3_int64 sets;
|
||||
rc = sqlite3_prepare_v2(database->connection, HYDRATE_SQL, -1, &statement, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(statement, 1, selected[index].row_id);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(statement);
|
||||
if (rc != SQLITE_ROW) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; }
|
||||
item = &output[index]; (void)memset(item, 0, sizeof(*item));
|
||||
if (!knowledge_copy_column(statement,0,item->session_id,sizeof(item->session_id)) ||
|
||||
!knowledge_copy_column(statement,1,item->entry_id,sizeof(item->entry_id)) ||
|
||||
!knowledge_copy_column(statement,2,item->exercise_id,sizeof(item->exercise_id)) ||
|
||||
!knowledge_copy_column(statement,3,item->started_at,sizeof(item->started_at)) ||
|
||||
!knowledge_copy_column(statement,4,item->equipment_id,sizeof(item->equipment_id)) ||
|
||||
sqlite3_column_type(statement,5)!=SQLITE_TEXT ||
|
||||
!session_type_from_sql((const char *)sqlite3_column_text(statement,5),&item->session_type)) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
if(sqlite3_column_type(statement,6)!=SQLITE_TEXT || sqlite3_column_type(statement,7)!=SQLITE_TEXT ||
|
||||
sqlite3_column_type(statement,9)!=SQLITE_TEXT ||
|
||||
!knowledge_tracking_mode((const char *)sqlite3_column_text(statement,6),&item->tracking_mode) ||
|
||||
!knowledge_recording_mode((const char *)sqlite3_column_text(statement,7),&item->recording_mode) ||
|
||||
!knowledge_load_mode((const char *)sqlite3_column_text(statement,9),&item->load_mode)) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
if (sqlite3_column_type(statement,8)!=SQLITE_INTEGER || sqlite3_column_type(statement,10)!=SQLITE_INTEGER) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
fields=sqlite3_column_int64(statement,8); sets=sqlite3_column_int64(statement,10);
|
||||
if (fields<0 || (sqlite3_uint64)fields>UINT32_MAX || sets<0 || (sqlite3_uint64)sets>SIZE_MAX) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
item->data_fields=(TrainlogExerciseDataFields)fields;
|
||||
item->set_count=(size_t)sets;
|
||||
if (sqlite3_column_type(statement,11)!=SQLITE_NULL) {
|
||||
sqlite3_int64 duration;
|
||||
if (sqlite3_column_type(statement,11)!=SQLITE_INTEGER) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
duration=sqlite3_column_int64(statement,11);
|
||||
if (duration<=0 || duration>INT_MAX) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
item->continuous_duration_seconds=(int)duration;
|
||||
}
|
||||
item->continuous_has_speed=sqlite3_column_type(statement,12)!=SQLITE_NULL;
|
||||
if (item->continuous_has_speed && !knowledge_numeric_column(statement,12)) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
item->continuous_speed_kmh=sqlite3_column_double(statement,12);
|
||||
item->continuous_has_distance=sqlite3_column_type(statement,13)!=SQLITE_NULL;
|
||||
if (item->continuous_has_distance && !knowledge_numeric_column(statement,13)) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
item->continuous_distance_km=sqlite3_column_double(statement,13);
|
||||
if ((item->continuous_has_speed && !isfinite(item->continuous_speed_kmh)) ||
|
||||
(item->continuous_has_distance && !isfinite(item->continuous_distance_km))) {
|
||||
status = TRAINLOG_STATUS_DATABASE_ERROR; goto done;
|
||||
}
|
||||
rc = sqlite3_step(statement);
|
||||
if (rc != SQLITE_DONE) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; }
|
||||
rc = sqlite3_finalize(statement); statement = NULL;
|
||||
if (rc != SQLITE_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; }
|
||||
}
|
||||
*output_count=count;
|
||||
if (count > 0U) {
|
||||
(void)snprintf(output_next->started_at,sizeof(output_next->started_at),"%s",output[count-1U].started_at);
|
||||
(void)snprintf(output_next->session_id,sizeof(output_next->session_id),"%s",output[count-1U].session_id);
|
||||
(void)snprintf(output_next->entry_id,sizeof(output_next->entry_id),"%s",output[count-1U].entry_id);
|
||||
}
|
||||
status = TRAINLOG_STATUS_OK;
|
||||
done:
|
||||
(void)sqlite3_finalize(statement);
|
||||
for (index = 0U; index < selected_count; ++index)
|
||||
knowledge_temporal_candidate_release(&selected[index]);
|
||||
if (snapshot) {
|
||||
TrainlogStatus end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK);
|
||||
if (status == TRAINLOG_STATUS_OK) status = end_status;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_database_list_occurrence_sets_page(
|
||||
TrainlogDatabase *database, const char *entry_id, int after_position, size_t limit,
|
||||
TrainlogOccurrenceSet *output, size_t *output_count, bool *output_has_more, int *output_next_position)
|
||||
{
|
||||
sqlite3_stmt *statement = NULL;
|
||||
sqlite3_stmt *identity = NULL;
|
||||
size_t count=0U;
|
||||
int rc;
|
||||
if (output_count != NULL) *output_count=0U;
|
||||
if (output_has_more != NULL) *output_has_more=false;
|
||||
if (database==NULL || database->connection==NULL || entry_id==NULL || entry_id[0]=='\0' ||
|
||||
after_position < -1 || limit==0U || limit>TRAINLOG_OCCURRENCE_SET_PAGE_MAX || output==NULL ||
|
||||
output_count==NULL || output_has_more==NULL || output_next_position==NULL)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
*output_next_position=after_position;
|
||||
rc=sqlite3_prepare_v2(database->connection,"SELECT 1 FROM session_exercises WHERE entry_id=?1;",-1,&identity,NULL);
|
||||
if(rc==SQLITE_OK)rc=sqlite3_bind_text(identity,1,entry_id,-1,SQLITE_TRANSIENT);
|
||||
if(rc!=SQLITE_OK){(void)sqlite3_finalize(identity);return TRAINLOG_STATUS_DATABASE_ERROR;}
|
||||
rc=sqlite3_step(identity);
|
||||
if(rc==SQLITE_DONE){(void)sqlite3_finalize(identity);return TRAINLOG_STATUS_NOT_FOUND;}
|
||||
if(rc!=SQLITE_ROW || sqlite3_finalize(identity)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
rc=sqlite3_prepare_v2(database->connection,
|
||||
"SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps "
|
||||
"JOIN session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id=?1 AND ps.position>?2 "
|
||||
"ORDER BY ps.position ASC LIMIT ?3;",-1,&statement,NULL);
|
||||
if(rc==SQLITE_OK) rc=sqlite3_bind_text(statement,1,entry_id,-1,SQLITE_TRANSIENT);
|
||||
if(rc==SQLITE_OK) rc=sqlite3_bind_int(statement,2,after_position);
|
||||
if(rc==SQLITE_OK) rc=sqlite3_bind_int64(statement,3,(sqlite3_int64)(limit+1U));
|
||||
if(rc!=SQLITE_OK){(void)sqlite3_finalize(statement);return TRAINLOG_STATUS_DATABASE_ERROR;}
|
||||
while((rc=sqlite3_step(statement))==SQLITE_ROW){ TrainlogOccurrenceSet *item; sqlite3_int64 position;
|
||||
if(count==limit){*output_has_more=true;break;} item=&output[count];(void)memset(item,0,sizeof(*item));
|
||||
if(sqlite3_column_type(statement,0)!=SQLITE_INTEGER){(void)sqlite3_finalize(statement);return TRAINLOG_STATUS_DATABASE_ERROR;}
|
||||
position=sqlite3_column_int64(statement,0); if(position<0 || position>INT_MAX){(void)sqlite3_finalize(statement);return TRAINLOG_STATUS_DATABASE_ERROR;}
|
||||
item->position=(size_t)position; item->has_reps=sqlite3_column_type(statement,1)!=SQLITE_NULL;
|
||||
item->has_duration=sqlite3_column_type(statement,2)!=SQLITE_NULL;
|
||||
if (item->has_reps) {
|
||||
sqlite3_int64 reps;
|
||||
if (sqlite3_column_type(statement,1)!=SQLITE_INTEGER) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
reps=sqlite3_column_int64(statement,1);
|
||||
if (reps<0 || reps>INT_MAX) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
item->reps=(int)reps;
|
||||
}
|
||||
if (item->has_duration) {
|
||||
sqlite3_int64 duration;
|
||||
if (sqlite3_column_type(statement,2)!=SQLITE_INTEGER) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
duration=sqlite3_column_int64(statement,2);
|
||||
if (duration<=0 || duration>INT_MAX) {
|
||||
(void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
}
|
||||
item->duration_seconds=(int)duration;
|
||||
}
|
||||
item->has_weight=sqlite3_column_type(statement,3)!=SQLITE_NULL;
|
||||
if(item->has_weight && !knowledge_numeric_column(statement,3)){(void)sqlite3_finalize(statement);return TRAINLOG_STATUS_DATABASE_ERROR;}
|
||||
item->weight_kg=sqlite3_column_double(statement,3);
|
||||
if(item->has_weight && !isfinite(item->weight_kg)){(void)sqlite3_finalize(statement);return TRAINLOG_STATUS_DATABASE_ERROR;}
|
||||
*output_next_position=(int)position; ++count;
|
||||
}
|
||||
if(rc!=SQLITE_DONE && rc!=SQLITE_ROW){(void)sqlite3_finalize(statement);return TRAINLOG_STATUS_DATABASE_ERROR;}
|
||||
if(sqlite3_finalize(statement)!=SQLITE_OK)return TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
*output_count=count; return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_database_latest_explicit_max_context(
|
||||
TrainlogDatabase *database, const char *exercise_id, TrainlogLatestExplicitMax *output)
|
||||
{
|
||||
static const char *const SCAN_SQL =
|
||||
"SELECT se.id,s.session_id,se.entry_id,s.started_at FROM max_results mr "
|
||||
"JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
||||
"JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id "
|
||||
"WHERE e.exercise_id=?1 AND s.session_type='max_test';";
|
||||
static const char *const HYDRATE_SQL =
|
||||
"SELECT s.session_id,se.entry_id,s.started_at,COALESCE(se.equipment_id,''),se.load_mode,mr.max_weight_kg "
|
||||
"FROM max_results mr JOIN session_exercises se ON se.id=mr.session_exercise_row_id "
|
||||
"JOIN sessions s ON s.id=se.session_row_id WHERE se.id=?1;";
|
||||
sqlite3_stmt *statement=NULL;
|
||||
KnowledgeTemporalCandidate selected[1] = {{0}};
|
||||
size_t selected_count = 0U;
|
||||
int rc;
|
||||
TrainlogExercise profile;
|
||||
TrainlogStatus status;
|
||||
bool snapshot = false;
|
||||
if(database==NULL || database->connection==NULL || exercise_id==NULL || exercise_id[0]=='\0' || output==NULL)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
(void)memset(output,0,sizeof(*output));
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status != TRAINLOG_STATUS_OK) return status;
|
||||
snapshot = true;
|
||||
status=trainlog_database_get_exercise_profile(database,exercise_id,&profile);
|
||||
if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||
rc=sqlite3_prepare_v2(database->connection,SCAN_SQL,-1,&statement,NULL);
|
||||
if(rc==SQLITE_OK)rc=sqlite3_bind_text(statement,1,exercise_id,-1,SQLITE_TRANSIENT);
|
||||
if(rc!=SQLITE_OK){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
while((rc=sqlite3_step(statement))==SQLITE_ROW){
|
||||
KnowledgeTemporalCandidate candidate;
|
||||
status=knowledge_temporal_candidate_read(statement,&candidate);
|
||||
if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||
knowledge_temporal_candidate_insert(selected,&selected_count,1U,&candidate);
|
||||
knowledge_temporal_candidate_release(&candidate);
|
||||
}
|
||||
if(rc!=SQLITE_DONE){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
rc=sqlite3_finalize(statement); statement=NULL;
|
||||
if(rc!=SQLITE_OK){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
if(selected_count==0U){status=TRAINLOG_STATUS_OK;goto done;}
|
||||
rc=sqlite3_prepare_v2(database->connection,HYDRATE_SQL,-1,&statement,NULL);
|
||||
if(rc==SQLITE_OK)rc=sqlite3_bind_int64(statement,1,selected[0].row_id);
|
||||
if(rc==SQLITE_OK)rc=sqlite3_step(statement);
|
||||
if(rc!=SQLITE_ROW || !knowledge_copy_column(statement,0,output->session_id,sizeof(output->session_id)) ||
|
||||
!knowledge_copy_column(statement,1,output->entry_id,sizeof(output->entry_id)) ||
|
||||
!knowledge_copy_column(statement,2,output->started_at,sizeof(output->started_at)) ||
|
||||
!knowledge_copy_column(statement,3,output->equipment_id,sizeof(output->equipment_id))){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
if(sqlite3_column_type(statement,4)!=SQLITE_TEXT ||
|
||||
!knowledge_load_mode((const char *)sqlite3_column_text(statement,4),&output->load_mode)){
|
||||
status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;
|
||||
}
|
||||
if(!knowledge_numeric_column(statement,5)){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
output->max_weight_kg=sqlite3_column_double(statement,5);
|
||||
if(!isfinite(output->max_weight_kg) || output->max_weight_kg<=0.0){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
output->found=true;
|
||||
rc=sqlite3_step(statement);
|
||||
if(rc!=SQLITE_DONE){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
rc=sqlite3_finalize(statement); statement=NULL;
|
||||
status=rc==SQLITE_OK?TRAINLOG_STATUS_OK:TRAINLOG_STATUS_DATABASE_ERROR;
|
||||
done:
|
||||
(void)sqlite3_finalize(statement);
|
||||
knowledge_temporal_candidate_release(&selected[0]);
|
||||
if(snapshot){
|
||||
TrainlogStatus end_status=trainlog_database_read_snapshot_end(database,status==TRAINLOG_STATUS_OK);
|
||||
if(status==TRAINLOG_STATUS_OK)status=end_status;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
|
|
|||
108
tui/src/timestamp.c
Normal file
108
tui/src/timestamp.c
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#include "timestamp.h"
|
||||
|
||||
#include <limits.h>
|
||||
|
||||
static bool parse_digits(const char *value, size_t count, int *output)
|
||||
{
|
||||
size_t index;
|
||||
int result = 0;
|
||||
for (index = 0U; index < count; ++index) {
|
||||
if (value[index] < '0' || value[index] > '9') return false;
|
||||
result = result * 10 + (value[index] - '0');
|
||||
}
|
||||
*output = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool leap_year(int year)
|
||||
{
|
||||
return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
}
|
||||
|
||||
static int days_in_month(int year, int month)
|
||||
{
|
||||
static const int DAYS[] = {31,28,31,30,31,30,31,31,30,31,30,31};
|
||||
return month == 2 && leap_year(year) ? 29 : DAYS[month - 1];
|
||||
}
|
||||
|
||||
/* Proleptic Gregorian day number relative to 0001-01-01. */
|
||||
static int64_t day_number(int year, int month, int day)
|
||||
{
|
||||
int prior_year = year - 1;
|
||||
int64_t days = (int64_t)prior_year * 365 + prior_year / 4 -
|
||||
prior_year / 100 + prior_year / 400;
|
||||
int current_month;
|
||||
for (current_month = 1; current_month < month; ++current_month)
|
||||
days += days_in_month(year, current_month);
|
||||
return days + day - 1;
|
||||
}
|
||||
|
||||
bool trainlog_timestamp_parse(
|
||||
const char *value, size_t length, TrainlogTimestampKey *output)
|
||||
{
|
||||
size_t zone;
|
||||
int year, month, day, hour, minute, second = 0;
|
||||
int offset_hour = 0, offset_minute = 0, offset_sign = 0;
|
||||
int64_t local_second;
|
||||
|
||||
if (value == NULL || output == NULL || length < 17U ||
|
||||
!parse_digits(value, 4U, &year) || value[4] != '-' ||
|
||||
!parse_digits(value + 5, 2U, &month) || value[7] != '-' ||
|
||||
!parse_digits(value + 8, 2U, &day) ||
|
||||
(value[10] != 'T' && value[10] != 't') ||
|
||||
!parse_digits(value + 11, 2U, &hour) || value[13] != ':' ||
|
||||
!parse_digits(value + 14, 2U, &minute)) return false;
|
||||
if (year < 1 || month < 1 || month > 12 || day < 1 ||
|
||||
day > days_in_month(year, month) || hour > 23 || minute > 59)
|
||||
return false;
|
||||
|
||||
zone = 16U;
|
||||
output->fraction = NULL;
|
||||
output->fraction_length = 0U;
|
||||
if (zone < length && value[zone] == ':') {
|
||||
if (zone + 3U > length || !parse_digits(value + zone + 1U, 2U, &second) || second > 59)
|
||||
return false;
|
||||
zone += 3U;
|
||||
if (zone < length && value[zone] == '.') {
|
||||
size_t fraction_start = ++zone;
|
||||
while (zone < length && value[zone] >= '0' && value[zone] <= '9') ++zone;
|
||||
if (zone == fraction_start) return false;
|
||||
output->fraction = value + fraction_start;
|
||||
output->fraction_length = zone - fraction_start;
|
||||
}
|
||||
}
|
||||
|
||||
if (zone + 1U == length && (value[zone] == 'Z' || value[zone] == 'z')) {
|
||||
offset_sign = 0;
|
||||
} else {
|
||||
if (zone + 6U != length || (value[zone] != '+' && value[zone] != '-') ||
|
||||
value[zone + 3U] != ':' ||
|
||||
!parse_digits(value + zone + 1U, 2U, &offset_hour) ||
|
||||
!parse_digits(value + zone + 4U, 2U, &offset_minute) ||
|
||||
offset_hour > 23 || offset_minute > 59) return false;
|
||||
offset_sign = value[zone] == '+' ? 1 : -1;
|
||||
}
|
||||
|
||||
local_second = day_number(year, month, day) * INT64_C(86400) +
|
||||
(int64_t)hour * INT64_C(3600) + (int64_t)minute * INT64_C(60) + second;
|
||||
output->utc_second = local_second - offset_sign *
|
||||
((int64_t)offset_hour * INT64_C(3600) + (int64_t)offset_minute * INT64_C(60));
|
||||
return true;
|
||||
}
|
||||
|
||||
int trainlog_timestamp_compare(
|
||||
const TrainlogTimestampKey *left, const TrainlogTimestampKey *right)
|
||||
{
|
||||
size_t index;
|
||||
size_t count;
|
||||
if (left->utc_second != right->utc_second)
|
||||
return left->utc_second < right->utc_second ? -1 : 1;
|
||||
count = left->fraction_length > right->fraction_length
|
||||
? left->fraction_length : right->fraction_length;
|
||||
for (index = 0U; index < count; ++index) {
|
||||
char left_digit = index < left->fraction_length ? left->fraction[index] : '0';
|
||||
char right_digit = index < right->fraction_length ? right->fraction[index] : '0';
|
||||
if (left_digit != right_digit) return left_digit < right_digit ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
29
tui/src/timestamp.h
Normal file
29
tui/src/timestamp.h
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#ifndef TRAINLOG_INTERNAL_TIMESTAMP_H
|
||||
#define TRAINLOG_INTERNAL_TIMESTAMP_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct TrainlogTimestampKey {
|
||||
int64_t utc_second;
|
||||
const char *fraction;
|
||||
size_t fraction_length;
|
||||
} TrainlogTimestampKey;
|
||||
|
||||
/* CONTRACT: Parses the frozen Trainlog timestamp grammar from exactly length
|
||||
* bytes. The key borrows fraction storage from value and remains valid only
|
||||
* while value does. */
|
||||
bool trainlog_timestamp_parse(
|
||||
const char *value,
|
||||
size_t length,
|
||||
TrainlogTimestampKey *output
|
||||
);
|
||||
|
||||
/* Returns less than, zero, or greater than zero by exact represented instant. */
|
||||
int trainlog_timestamp_compare(
|
||||
const TrainlogTimestampKey *left,
|
||||
const TrainlogTimestampKey *right
|
||||
);
|
||||
|
||||
#endif
|
||||
118
tui/src/training_context.c
Normal file
118
tui/src/training_context.c
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#include "trainlog/training_context.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static size_t id_list_count(const char *list)
|
||||
{
|
||||
size_t count = 0U;
|
||||
const char *at = list;
|
||||
if (at == NULL || *at == '\0') return 0U;
|
||||
count = 1U;
|
||||
while (*at != '\0') { if (*at == '\n') ++count; ++at; }
|
||||
return count;
|
||||
}
|
||||
|
||||
static const TrainlogEquipmentKnowledge *equipment_from_list(const char **at)
|
||||
{
|
||||
const char *end;
|
||||
char id[TRAINLOG_ID_MAX + 1U];
|
||||
size_t length;
|
||||
if (at == NULL || *at == NULL || **at == '\0') return NULL;
|
||||
end = strchr(*at, '\n');
|
||||
length = end == NULL ? strlen(*at) : (size_t)(end - *at);
|
||||
if (length == 0U || length >= sizeof(id)) return NULL;
|
||||
(void)memcpy(id, *at, length); id[length] = '\0';
|
||||
*at = end == NULL ? *at + length : end + 1;
|
||||
return trainlog_equipment_knowledge_lookup(id);
|
||||
}
|
||||
|
||||
void trainlog_training_exercise_context_release(TrainlogTrainingExerciseContext *context)
|
||||
{
|
||||
if (context == NULL) return;
|
||||
free(context->persisted_zones);
|
||||
free(context->compatible_equipment);
|
||||
free(context->occurrences);
|
||||
free(context->sets);
|
||||
(void)memset(context, 0, sizeof(*context));
|
||||
}
|
||||
|
||||
TrainlogStatus trainlog_training_exercise_context_load(
|
||||
TrainlogDatabase *database, const char *exercise_id, size_t occurrence_limit,
|
||||
size_t set_preview_limit, TrainlogTrainingExerciseContext *output)
|
||||
{
|
||||
TrainlogTrainingExerciseContext result = {0};
|
||||
TrainlogStatus status;
|
||||
const char *equipment_at;
|
||||
size_t index;
|
||||
size_t zone_count = 0U;
|
||||
bool snapshot = false;
|
||||
if (database == NULL || exercise_id == NULL || exercise_id[0] == '\0' || output == NULL ||
|
||||
occurrence_limit == 0U || occurrence_limit > TRAINLOG_OCCURRENCE_PAGE_MAX ||
|
||||
set_preview_limit == 0U || set_preview_limit > TRAINLOG_OCCURRENCE_SET_PAGE_MAX)
|
||||
return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||
(void)memset(output, 0, sizeof(*output));
|
||||
status = trainlog_database_read_snapshot_begin(database);
|
||||
if (status != TRAINLOG_STATUS_OK) return status;
|
||||
snapshot = true;
|
||||
status = trainlog_database_get_exercise_profile(database, exercise_id, &result.exercise);
|
||||
if (status != TRAINLOG_STATUS_OK) goto done;
|
||||
status = trainlog_database_list_exercise_body_zones(database, exercise_id, NULL, 0U, &zone_count);
|
||||
if (status != TRAINLOG_STATUS_OK && !(status == TRAINLOG_STATUS_INVALID_ARGUMENT && zone_count > 0U)) goto done;
|
||||
if (zone_count > SIZE_MAX / sizeof(*result.persisted_zones)) { status=TRAINLOG_STATUS_INVALID_ARGUMENT; goto done; }
|
||||
if (zone_count > 0U) {
|
||||
result.persisted_zones = calloc(zone_count, sizeof(*result.persisted_zones));
|
||||
if (result.persisted_zones == NULL) { status=TRAINLOG_STATUS_SYSTEM_ERROR; goto done; }
|
||||
status=trainlog_database_list_exercise_body_zones(database,exercise_id,result.persisted_zones,zone_count,&result.persisted_zone_count);
|
||||
if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||
}
|
||||
result.knowledge=trainlog_exercise_knowledge_lookup(exercise_id);
|
||||
if(result.knowledge!=NULL){
|
||||
result.compatible_equipment_count=id_list_count(result.knowledge->equipment_ids);
|
||||
if(result.compatible_equipment_count>SIZE_MAX/sizeof(*result.compatible_equipment)){status=TRAINLOG_STATUS_INVALID_ARGUMENT;goto done;}
|
||||
if(result.compatible_equipment_count>0U){
|
||||
result.compatible_equipment=calloc(result.compatible_equipment_count,sizeof(*result.compatible_equipment));
|
||||
if(result.compatible_equipment==NULL){status=TRAINLOG_STATUS_SYSTEM_ERROR;goto done;}
|
||||
equipment_at=result.knowledge->equipment_ids;
|
||||
for(index=0U;index<result.compatible_equipment_count;++index){
|
||||
result.compatible_equipment[index]=equipment_from_list(&equipment_at);
|
||||
if(result.compatible_equipment[index]==NULL){status=TRAINLOG_STATUS_DATABASE_ERROR;goto done;}
|
||||
}
|
||||
}
|
||||
}
|
||||
status=trainlog_database_latest_explicit_max_context(database,exercise_id,&result.latest_max);
|
||||
if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||
result.occurrences=calloc(occurrence_limit,sizeof(*result.occurrences));
|
||||
if(result.occurrences==NULL){status=TRAINLOG_STATUS_SYSTEM_ERROR;goto done;}
|
||||
{
|
||||
TrainlogExerciseOccurrence *raw=calloc(occurrence_limit,sizeof(*raw));
|
||||
if(raw==NULL){status=TRAINLOG_STATUS_SYSTEM_ERROR;goto done;}
|
||||
status=trainlog_database_list_exercise_occurrences_page(database,exercise_id,NULL,occurrence_limit,raw,
|
||||
&result.occurrence_count,&result.occurrences_have_more,&result.next_occurrence);
|
||||
if(status==TRAINLOG_STATUS_OK){for(index=0U;index<result.occurrence_count;++index)result.occurrences[index].occurrence=raw[index];}
|
||||
free(raw); if(status!=TRAINLOG_STATUS_OK)goto done;
|
||||
}
|
||||
if(result.occurrence_count>0U && set_preview_limit>SIZE_MAX/result.occurrence_count){status=TRAINLOG_STATUS_INVALID_ARGUMENT;goto done;}
|
||||
result.sets=calloc(result.occurrence_count*set_preview_limit,sizeof(*result.sets));
|
||||
if(result.occurrence_count>0U && result.sets==NULL){status=TRAINLOG_STATUS_SYSTEM_ERROR;goto done;}
|
||||
for(index=0U;index<result.occurrence_count;++index){
|
||||
TrainlogTrainingOccurrenceView *view=&result.occurrences[index]; size_t count=0U;
|
||||
view->set_offset=result.set_count; view->next_set_position=-1;
|
||||
if(view->occurrence.recording_mode==TRAINLOG_RECORDING_CONTINUOUS)continue;
|
||||
status=trainlog_database_list_occurrence_sets_page(database,view->occurrence.entry_id,-1,set_preview_limit,
|
||||
result.sets+result.set_count,&count,&view->sets_have_more,&view->next_set_position);
|
||||
if (status != TRAINLOG_STATUS_OK) {
|
||||
goto done;
|
||||
}
|
||||
view->set_count = count;
|
||||
result.set_count += count;
|
||||
}
|
||||
done:
|
||||
if (snapshot) {
|
||||
TrainlogStatus end_status=trainlog_database_read_snapshot_end(database,status==TRAINLOG_STATUS_OK);
|
||||
if(status==TRAINLOG_STATUS_OK)status=end_status;
|
||||
}
|
||||
if(status!=TRAINLOG_STATUS_OK){trainlog_training_exercise_context_release(&result);return status;}
|
||||
*output=result; return TRAINLOG_STATUS_OK;
|
||||
}
|
||||
179
tui/src/tui.c
179
tui/src/tui.c
|
|
@ -20,6 +20,7 @@
|
|||
#include <wchar.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <utf8proc.h>
|
||||
#include <uuid/uuid.h>
|
||||
|
||||
#include "trainlog/terminal.h"
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
#include "trainlog/reps.h"
|
||||
#include "trainlog/theme.h"
|
||||
#include "trainlog/timeutil.h"
|
||||
#include "trainlog/training_knowledge.h"
|
||||
#include "trainlog/usb.h"
|
||||
|
||||
#define MAX_EXERCISES 128U
|
||||
|
|
@ -2380,6 +2382,180 @@ static bool edit_exercise_body_zones(
|
|||
return status == TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
/* CONTRACT: knowledge text is read-only catalogue data. The screen stores
|
||||
* display lines locally so navigation never changes the scientific record. */
|
||||
#define KNOWLEDGE_LINES_MAX 128U
|
||||
#define KNOWLEDGE_LINE_MAX 256U
|
||||
|
||||
static void knowledge_add_wrapped(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||||
const char *text, int width)
|
||||
{
|
||||
const char *at = text == NULL ? "" : text;
|
||||
size_t used = 0U;
|
||||
int cells = 0;
|
||||
|
||||
/* WHY: terminal columns count display cells, while French labels are UTF-8;
|
||||
* utf8proc prevents a wrap from splitting an accented character or from
|
||||
* writing past the right border for a wide code point. */
|
||||
while (*at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
|
||||
utf8proc_int32_t codepoint;
|
||||
utf8proc_ssize_t bytes = utf8proc_iterate((const utf8proc_uint8_t *)at,
|
||||
-1, &codepoint);
|
||||
int codepoint_cells;
|
||||
if (bytes <= 0) { codepoint = (unsigned char)*at; bytes = 1; }
|
||||
codepoint_cells = utf8proc_charwidth(codepoint);
|
||||
if (codepoint_cells < 0) codepoint_cells = 1;
|
||||
if (cells > 0 && cells + codepoint_cells > width) {
|
||||
lines[*count][used] = '\0';
|
||||
++*count;
|
||||
used = 0U;
|
||||
cells = 0;
|
||||
continue;
|
||||
}
|
||||
if (used + (size_t)bytes >= KNOWLEDGE_LINE_MAX - 1U) break;
|
||||
(void)memcpy(lines[*count] + used, at, (size_t)bytes);
|
||||
used += (size_t)bytes;
|
||||
cells += codepoint_cells;
|
||||
at += bytes;
|
||||
}
|
||||
/* INVARIANT: every entry is NUL-terminated before rendering, and the fixed
|
||||
* line capacity bounds catalogue display independently of terminal size. */
|
||||
if (*count < KNOWLEDGE_LINES_MAX) {
|
||||
lines[*count][used] = '\0';
|
||||
++*count;
|
||||
}
|
||||
}
|
||||
|
||||
static void knowledge_add_ids(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||||
const char *heading, const char *ids, bool muscles,
|
||||
int width)
|
||||
{
|
||||
const char *at = ids;
|
||||
knowledge_add_wrapped(lines, count, heading, width);
|
||||
while (at != NULL && *at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
|
||||
const char *end = strchr(at, '\n');
|
||||
size_t length = end == NULL ? strlen(at) : (size_t)(end - at);
|
||||
char id[96];
|
||||
const char *label = NULL;
|
||||
if (length >= sizeof(id)) break;
|
||||
(void)memcpy(id, at, length);
|
||||
id[length] = '\0';
|
||||
if (muscles) {
|
||||
const TrainlogKnowledgeMuscle *item = trainlog_knowledge_muscle_lookup(id);
|
||||
if (item != NULL) label = item->display_name_fr;
|
||||
} else {
|
||||
const TrainlogKnowledgeMovementPattern *item =
|
||||
trainlog_knowledge_movement_pattern_lookup(id);
|
||||
if (item != NULL) label = item->display_name_fr;
|
||||
}
|
||||
knowledge_add_wrapped(lines, count, label == NULL ? id : label, width);
|
||||
if (end == NULL) break;
|
||||
at = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
static void knowledge_add_plain_ids(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||||
const char *heading, const char *ids, int width)
|
||||
{
|
||||
const char *at = ids;
|
||||
knowledge_add_wrapped(lines, count, heading, width);
|
||||
while (at != NULL && *at != '\0' && *count < KNOWLEDGE_LINES_MAX) {
|
||||
const char *end = strchr(at, '\n');
|
||||
size_t length = end == NULL ? strlen(at) : (size_t)(end - at);
|
||||
char id[96];
|
||||
if (length >= sizeof(id)) break;
|
||||
(void)memcpy(id, at, length);
|
||||
id[length] = '\0';
|
||||
knowledge_add_wrapped(lines, count, id, width);
|
||||
if (end == NULL) break;
|
||||
at = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
static void knowledge_add_zones(char lines[][KNOWLEDGE_LINE_MAX], size_t *count,
|
||||
const TrainlogKnowledgeInterpretation *value, int width)
|
||||
{
|
||||
const TrainlogBodyZone *zone;
|
||||
knowledge_add_wrapped(lines, count, "Zones scientifiques :", width);
|
||||
zone = trainlog_body_zone_catalog_lookup(value->primary_zone_id);
|
||||
knowledge_add_wrapped(lines, count,
|
||||
zone == NULL ? value->primary_zone_id : zone->display_name, width);
|
||||
knowledge_add_plain_ids(lines, count, "Zones secondaires :",
|
||||
value->secondary_zone_ids, width);
|
||||
}
|
||||
|
||||
static void screen_exercise_knowledge(const TrainlogExercise *exercise)
|
||||
{
|
||||
const TrainlogExerciseKnowledge *record;
|
||||
const TrainlogKnowledgeInterpretation *value;
|
||||
const char *label;
|
||||
char lines[KNOWLEDGE_LINES_MAX][KNOWLEDGE_LINE_MAX];
|
||||
size_t line_count;
|
||||
size_t scroll = 0U;
|
||||
int key;
|
||||
if (exercise == NULL) return;
|
||||
record = trainlog_exercise_knowledge_lookup(exercise->exercise_id);
|
||||
value = record == NULL ? NULL : record->interpretation;
|
||||
label = "Connaissances validées";
|
||||
if (value == NULL && record != NULL && record->conditional_interpretation != NULL) {
|
||||
value = record->conditional_interpretation;
|
||||
label = "Interprétation conditionnelle — à confirmer";
|
||||
}
|
||||
for (;;) {
|
||||
int rows = trainlog_terminal_rows(tui_terminal);
|
||||
int columns = trainlog_terminal_columns(tui_terminal);
|
||||
int viewport_rows;
|
||||
int width;
|
||||
size_t index;
|
||||
if (rows < 20 || columns < 72) {
|
||||
draw_shell("TRAINLOG — Connaissances exercice", "b/Échap retour");
|
||||
trainlog_terminal_printf(tui_terminal, 3, 4, "Terminal trop petit — minimum 72x20.");
|
||||
trainlog_terminal_render(tui_terminal);
|
||||
key = trainlog_terminal_get_key(tui_terminal);
|
||||
if (key == 27 || key == 'b' || key == 'B' || key == 'k' || key == 'K') return;
|
||||
continue;
|
||||
}
|
||||
width = columns - 8;
|
||||
viewport_rows = rows - 6;
|
||||
line_count = 0U;
|
||||
knowledge_add_wrapped(lines, &line_count, exercise->name, width);
|
||||
if (record == NULL || value == NULL) {
|
||||
knowledge_add_wrapped(lines, &line_count,
|
||||
record == NULL ? "Aucune fiche scientifique pour cet identifiant."
|
||||
: "Interprétation scientifique non résolue.", width);
|
||||
} else {
|
||||
knowledge_add_wrapped(lines, &line_count, label, width);
|
||||
knowledge_add_ids(lines, &line_count, "Mouvement :", value->pattern_ids, false, width);
|
||||
knowledge_add_wrapped(lines, &line_count, "Confiance :", width);
|
||||
knowledge_add_wrapped(lines, &line_count, value->confidence, width);
|
||||
knowledge_add_ids(lines, &line_count, "Muscles principaux :",
|
||||
value->primary_muscle_ids, true, width);
|
||||
knowledge_add_ids(lines, &line_count, "Secondaires :",
|
||||
value->secondary_muscle_ids, true, width);
|
||||
knowledge_add_ids(lines, &line_count, "Stabilisateurs :",
|
||||
value->stabilizer_muscle_ids, true, width);
|
||||
knowledge_add_zones(lines, &line_count, value, width);
|
||||
knowledge_add_plain_ids(lines, &line_count, "Sources :", value->source_refs, width);
|
||||
}
|
||||
if (scroll >= line_count) scroll = line_count == 0U ? 0U : line_count - 1U;
|
||||
draw_shell("TRAINLOG — Connaissances exercice",
|
||||
"↑↓ défiler Pg↑/Pg↓ page b/Échap/k retour");
|
||||
for (index = 0U; index < (size_t)viewport_rows && scroll + index < line_count; ++index)
|
||||
trainlog_terminal_printf(tui_terminal, 3 + (int)index, 4, "%s", lines[scroll + index]);
|
||||
trainlog_terminal_render(tui_terminal);
|
||||
key = trainlog_terminal_get_key(tui_terminal);
|
||||
if (key == 27 || key == 'b' || key == 'B' || key == 'k' || key == 'K') return;
|
||||
if (key == TRAINLOG_KEY_UP && scroll > 0U) --scroll;
|
||||
else if (key == TRAINLOG_KEY_DOWN && scroll + (size_t)viewport_rows < line_count) ++scroll;
|
||||
else if (key == TRAINLOG_KEY_PAGE_UP) {
|
||||
scroll = scroll > (size_t)viewport_rows ? scroll - (size_t)viewport_rows : 0U;
|
||||
} else if (key == TRAINLOG_KEY_PAGE_DOWN && scroll + (size_t)viewport_rows < line_count) {
|
||||
size_t maximum = line_count - (size_t)viewport_rows;
|
||||
scroll = scroll + (size_t)viewport_rows < maximum ? scroll + (size_t)viewport_rows : maximum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void screen_exercise_detail(
|
||||
TrainlogDatabase *database,
|
||||
TrainlogExercise *exercise
|
||||
|
|
@ -2418,7 +2594,7 @@ static void screen_exercise_detail(
|
|||
int key;
|
||||
if (total > 0U && selected >= total) selected = total - 1U;
|
||||
draw_shell("TRAINLOG — Fiche exercice",
|
||||
"↑↓ équipement Entrée fiche e modifier p performance m max mesuré b/Échap retour");
|
||||
"↑↓ équipement Entrée fiche k connaissances p performance m max b/Échap retour");
|
||||
trainlog_terminal_printf(tui_terminal, 3, 4, "%s", exercise->name);
|
||||
trainlog_terminal_printf(tui_terminal, 4, 4, "Identifiant : %s · suivi : %s",
|
||||
exercise->exercise_id,
|
||||
|
|
@ -2479,6 +2655,7 @@ static void screen_exercise_detail(
|
|||
? &explicit_items[selected] : &historic_items[selected - explicit_count]);
|
||||
else if (key == 'p' || key == 'P') screen_exercise_performance(database, exercise);
|
||||
else if (key == 'm' || key == 'M') screen_exercise_measured_max(database, exercise);
|
||||
else if (key == 'k' || key == 'K') screen_exercise_knowledge(exercise);
|
||||
else if (key == 'e' || key == 'E') (void)edit_exercise_body_zones(database, exercise);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
362
tui/tests/test_training_context.c
Normal file
362
tui/tests/test_training_context.c
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
#include "trainlog/training_context.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#define CHECK(test) do { if (!(test)) { fprintf(stderr, "CHECK failed: %s:%d: %s\n", \
|
||||
__FILE__, __LINE__, #test); return 1; } } while (0)
|
||||
|
||||
static void base_occurrence(TrainlogSessionExerciseInput *value, const char *entry_id,
|
||||
const char *exercise_id, const char *equipment_id, TrainlogLoadMode mode)
|
||||
{
|
||||
(void)memset(value, 0, sizeof(*value));
|
||||
(void)snprintf(value->entry_id, sizeof(value->entry_id), "%s", entry_id);
|
||||
(void)snprintf(value->exercise_id, sizeof(value->exercise_id), "%s", exercise_id);
|
||||
(void)snprintf(value->equipment_id, sizeof(value->equipment_id), "%s", equipment_id);
|
||||
value->recording_mode = TRAINLOG_RECORDING_SETS;
|
||||
value->load_mode = mode;
|
||||
value->rest_seconds = 60;
|
||||
value->target_sets = 2;
|
||||
value->target_reps = 10;
|
||||
value->target_has_weight = mode != TRAINLOG_LOAD_NONE;
|
||||
value->target_weight_kg = 20.0;
|
||||
}
|
||||
|
||||
static int insert_session(TrainlogDatabase *database, const char *session_id, const char *started_at,
|
||||
TrainlogSessionType type, TrainlogSessionExerciseInput *items, size_t count)
|
||||
{
|
||||
TrainlogSessionInput session = {0};
|
||||
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
|
||||
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
|
||||
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", started_at);
|
||||
session.session_type = type;
|
||||
session.exercises = items;
|
||||
session.exercise_count = count;
|
||||
return trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const char *exercise_id = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde";
|
||||
const char *zones[] = {"glutes"};
|
||||
TrainlogDatabase *database = NULL;
|
||||
TrainlogSessionExerciseInput occurrences[2];
|
||||
TrainlogSessionExerciseInput max_occurrence;
|
||||
TrainlogSessionExerciseInput assistance_occurrence;
|
||||
TrainlogSessionExerciseInput continuous_occurrence;
|
||||
TrainlogSessionExerciseInput large_occurrence;
|
||||
TrainlogSessionExerciseInput temporal_occurrence;
|
||||
TrainlogSessionExerciseInput temporal_max;
|
||||
TrainlogSetInput first_sets[2] = {{8,0,false,0.0},{6,0,true,0.0}};
|
||||
TrainlogSetInput second_sets[1] = {{11,0,true,30.0}};
|
||||
TrainlogSetInput assistance_set[1] = {{9,0,true,15.0}};
|
||||
TrainlogSetInput large_sets[65];
|
||||
TrainlogTrainingExerciseContext context;
|
||||
TrainlogExerciseOccurrence page[1];
|
||||
TrainlogExerciseOccurrenceCursor next;
|
||||
TrainlogExerciseOccurrenceCursor cursor;
|
||||
bool has_more = false;
|
||||
size_t count = 0U;
|
||||
size_t index;
|
||||
TrainlogOccurrenceSet set_page[64];
|
||||
int next_position = -1;
|
||||
char database_path[] = "/tmp/trainlog-context-XXXXXX";
|
||||
int database_fd = mkstemp(database_path);
|
||||
sqlite3 *raw = NULL;
|
||||
|
||||
CHECK(database_fd >= 0);
|
||||
CHECK(close(database_fd) == 0);
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_insert_exercise_profiled(database, exercise_id, "Leg press renamed",
|
||||
"leg press renamed", TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_replace_exercise_body_zones(database, exercise_id, "thighs", zones, 1U)
|
||||
== TRAINLOG_STATUS_OK);
|
||||
base_occurrence(&occurrences[0], "entry_a", exercise_id, "leg_press", TRAINLOG_LOAD_EXTERNAL);
|
||||
occurrences[0].sets=first_sets; occurrences[0].set_count=2U;
|
||||
base_occurrence(&occurrences[1], "entry_b", exercise_id, "plate_loaded_leg_press", TRAINLOG_LOAD_EXTERNAL);
|
||||
occurrences[1].sets=second_sets; occurrences[1].set_count=1U;
|
||||
CHECK(insert_session(database,"session_b","2026-09-08T12:00:00+02:00",TRAINLOG_SESSION_TRAINING,occurrences,2U));
|
||||
base_occurrence(&assistance_occurrence,"entry_assist",exercise_id,"leg_press",TRAINLOG_LOAD_ASSISTANCE);
|
||||
assistance_occurrence.sets=assistance_set; assistance_occurrence.set_count=1U;
|
||||
CHECK(insert_session(database,"session_a","2026-09-08T11:00:00+00:00",TRAINLOG_SESSION_TRAINING,&assistance_occurrence,1U));
|
||||
base_occurrence(&max_occurrence,"entry_max",exercise_id,"leg_press",TRAINLOG_LOAD_EXTERNAL);
|
||||
max_occurrence.has_max_weight=true; max_occurrence.max_weight_kg=120.0;
|
||||
max_occurrence.target_sets=0; max_occurrence.target_reps=0;
|
||||
max_occurrence.target_has_weight=false; max_occurrence.target_weight_kg=0.0;
|
||||
max_occurrence.rest_seconds=0;
|
||||
max_occurrence.load_mode=TRAINLOG_LOAD_NONE;
|
||||
CHECK(insert_session(database,"session_max","2026-09-09T10:00:00+02:00",TRAINLOG_SESSION_MAX_TEST,&max_occurrence,1U));
|
||||
|
||||
CHECK(trainlog_training_exercise_context_load(database,exercise_id,4U,1U,&context)==TRAINLOG_STATUS_OK);
|
||||
CHECK(strcmp(context.exercise.name,"Leg press renamed")==0);
|
||||
CHECK(context.knowledge!=NULL && context.knowledge->interpretation!=NULL);
|
||||
CHECK(context.persisted_zone_count==2U);
|
||||
CHECK(context.compatible_equipment_count==2U);
|
||||
CHECK(context.latest_max.found && context.latest_max.max_weight_kg==120.0);
|
||||
CHECK(context.occurrence_count==4U);
|
||||
CHECK(strcmp(context.occurrences[0].occurrence.entry_id,"entry_max")==0);
|
||||
CHECK(context.occurrences[0].set_count==0U); /* explicit MAX is not a fake set */
|
||||
CHECK(context.occurrences[1].occurrence.load_mode==TRAINLOG_LOAD_ASSISTANCE);
|
||||
CHECK(strcmp(context.occurrences[2].occurrence.entry_id,"entry_b")==0);
|
||||
CHECK(context.occurrences[2].set_count==1U && !context.occurrences[2].sets_have_more);
|
||||
CHECK(strcmp(context.occurrences[3].occurrence.entry_id,"entry_a")==0);
|
||||
CHECK(context.occurrences[3].set_count==1U && context.occurrences[3].sets_have_more);
|
||||
CHECK(context.sets[context.occurrences[3].set_offset].has_weight==false);
|
||||
trainlog_training_exercise_context_release(&context);
|
||||
CHECK(trainlog_database_list_occurrence_sets_page(database,"entry_a",0,64U,set_page,&count,&has_more,
|
||||
&next_position)==TRAINLOG_STATUS_OK);
|
||||
CHECK(count==1U && !has_more && set_page[0].has_weight && set_page[0].weight_kg==0.0);
|
||||
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,NULL,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_OK);
|
||||
CHECK(count==1U && has_more);
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&next,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_OK);
|
||||
CHECK(count==1U);
|
||||
cursor=next; cursor.entry_id[0]='\0';
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
cursor=next; (void)memset(cursor.started_at,'x',sizeof(cursor.started_at));
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
cursor=next; (void)memset(cursor.session_id,'x',sizeof(cursor.session_id));
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
cursor=next; (void)memset(cursor.entry_id,'x',sizeof(cursor.entry_id));
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
cursor=next; (void)snprintf(cursor.started_at,sizeof(cursor.started_at),"not-a-date");
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
cursor=next; (void)snprintf(cursor.started_at,sizeof(cursor.started_at),"2026-13-01T00:00:00+00:00");
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
cursor=next; (void)snprintf(cursor.started_at,sizeof(cursor.started_at),"2026-02-30T00:00:00+00:00");
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
{
|
||||
const char *invalid_timestamp[] = {
|
||||
"0000-01-01T00:00:00Z", "2026-09-05 18:34:12Z", "20260905T183412Z",
|
||||
"2026-W36-5T18:34:12Z", "2026-09-05T18:34:12,5Z",
|
||||
"2026-09-05T18:34:12+0200", "2026-09-05T18:34:12+02",
|
||||
"2026-09-05T18:34.5Z", "2026-09-05T18:34:60Z", "2026-09-05T18:34:12+24:00"
|
||||
};
|
||||
for(index=0U;index<sizeof(invalid_timestamp)/sizeof(invalid_timestamp[0]);++index){
|
||||
(void)memset(&cursor,0,sizeof(cursor));
|
||||
(void)snprintf(cursor.started_at,sizeof(cursor.started_at),"%s",invalid_timestamp[index]);
|
||||
(void)snprintf(cursor.session_id,sizeof(cursor.session_id),"session_b");
|
||||
(void)snprintf(cursor.entry_id,sizeof(cursor.entry_id),"entry_b");
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
}
|
||||
}
|
||||
cursor=next; (void)snprintf(cursor.started_at,sizeof(cursor.started_at),"2026-09-08T10:00:00+15:00");
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_OK);
|
||||
(void)memset(&cursor,0,sizeof(cursor));
|
||||
(void)snprintf(cursor.started_at,sizeof(cursor.started_at),"2026-09-08T10:00:00Z");
|
||||
(void)snprintf(cursor.session_id,sizeof(cursor.session_id),"session_b");
|
||||
(void)snprintf(cursor.entry_id,sizeof(cursor.entry_id),"entry_b");
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_OK);
|
||||
|
||||
/* TEMPORAL_READER_V1: production paging must preserve exceptional source
|
||||
* spellings and use the same exact comparator for every exclusive cursor. */
|
||||
base_occurrence(&temporal_occurrence,"entry_frac_low",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_occurrence.target_has_weight=false; temporal_occurrence.target_weight_kg=0.0;
|
||||
CHECK(insert_session(database,"session_frac_low","2026-09-21T10:00:00.1234567890123456Z",
|
||||
TRAINLOG_SESSION_TRAINING,&temporal_occurrence,1U));
|
||||
base_occurrence(&temporal_occurrence,"entry_frac_high",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_occurrence.target_has_weight=false; temporal_occurrence.target_weight_kg=0.0;
|
||||
CHECK(insert_session(database,"session_frac_high","2026-09-21T10:00:00.1234567890123457Z",
|
||||
TRAINLOG_SESSION_TRAINING,&temporal_occurrence,1U));
|
||||
base_occurrence(&temporal_occurrence,"entry_lower",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_occurrence.target_has_weight=false; temporal_occurrence.target_weight_kg=0.0;
|
||||
CHECK(insert_session(database,"session_lower","2026-09-20t10:00:00z",TRAINLOG_SESSION_TRAINING,
|
||||
&temporal_occurrence,1U));
|
||||
base_occurrence(&temporal_occurrence,"entry_omit",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_occurrence.target_has_weight=false; temporal_occurrence.target_weight_kg=0.0;
|
||||
CHECK(insert_session(database,"session_omit","2026-09-22T10:00+15:00",TRAINLOG_SESSION_TRAINING,
|
||||
&temporal_occurrence,1U));
|
||||
base_occurrence(&temporal_occurrence,"entry_high_offset",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_occurrence.target_has_weight=false; temporal_occurrence.target_weight_kg=0.0;
|
||||
CHECK(insert_session(database,"session_high_offset","2026-09-22T10:00:00+23:59",TRAINLOG_SESSION_TRAINING,
|
||||
&temporal_occurrence,1U));
|
||||
base_occurrence(&temporal_occurrence,"entry_tie_a",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_occurrence.target_has_weight=false; temporal_occurrence.target_weight_kg=0.0;
|
||||
CHECK(insert_session(database,"session_tie_a","2026-09-20T12:00:00+02:00",TRAINLOG_SESSION_TRAINING,
|
||||
&temporal_occurrence,1U));
|
||||
base_occurrence(&temporal_occurrence,"entry_tie_z",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_occurrence.target_has_weight=false; temporal_occurrence.target_weight_kg=0.0;
|
||||
CHECK(insert_session(database,"session_tie_z","2026-09-20T10:00:00-00:00",TRAINLOG_SESSION_TRAINING,
|
||||
&temporal_occurrence,1U));
|
||||
{
|
||||
const char *expected[] = {"entry_omit","entry_high_offset","entry_frac_high","entry_frac_low",
|
||||
"entry_tie_z","entry_tie_a","entry_lower"};
|
||||
TrainlogExerciseOccurrenceCursor temporal_cursor;
|
||||
const TrainlogExerciseOccurrenceCursor *after_temporal = NULL;
|
||||
for(index=0U;index<7U;++index){
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,after_temporal,1U,
|
||||
page,&count,&has_more,&temporal_cursor)==TRAINLOG_STATUS_OK);
|
||||
CHECK(count==1U && strcmp(page[0].entry_id,expected[index])==0);
|
||||
CHECK(strcmp(page[0].started_at,temporal_cursor.started_at)==0);
|
||||
after_temporal=&temporal_cursor;
|
||||
}
|
||||
}
|
||||
base_occurrence(&temporal_max,"entry_max_offset",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_max.target_sets=0; temporal_max.target_reps=0; temporal_max.target_has_weight=false;
|
||||
temporal_max.rest_seconds=0; temporal_max.has_max_weight=true; temporal_max.max_weight_kg=151.0;
|
||||
CHECK(insert_session(database,"session_max_offset","2026-09-23T10:00:00+15:00",
|
||||
TRAINLOG_SESSION_MAX_TEST,&temporal_max,1U));
|
||||
base_occurrence(&temporal_max,"entry_max_true",exercise_id,"",TRAINLOG_LOAD_NONE);
|
||||
temporal_max.target_sets=0; temporal_max.target_reps=0; temporal_max.target_has_weight=false;
|
||||
temporal_max.rest_seconds=0; temporal_max.has_max_weight=true; temporal_max.max_weight_kg=152.0;
|
||||
CHECK(insert_session(database,"session_max_true","2026-09-22T20:00:00Z",
|
||||
TRAINLOG_SESSION_MAX_TEST,&temporal_max,1U));
|
||||
CHECK(trainlog_database_latest_explicit_max_context(database,exercise_id,&context.latest_max)==TRAINLOG_STATUS_OK);
|
||||
CHECK(context.latest_max.found && strcmp(context.latest_max.entry_id,"entry_max_true")==0 &&
|
||||
context.latest_max.max_weight_kg==152.0);
|
||||
(void)memset(&cursor,0,sizeof(cursor));
|
||||
(void)snprintf(cursor.started_at,sizeof(cursor.started_at),"2026-09-08T12:00:00+02:00");
|
||||
(void)snprintf(cursor.session_id,sizeof(cursor.session_id),"session_b");
|
||||
(void)snprintf(cursor.entry_id,sizeof(cursor.entry_id),"entry_b");
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,&cursor,1U,page,&count,
|
||||
&has_more,&next)==TRAINLOG_STATUS_OK);
|
||||
|
||||
CHECK(trainlog_database_insert_exercise(database,"ex_unknown_runtime","Custom renamed","custom renamed",
|
||||
TRAINLOG_TRACKING_REPS)==TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_training_exercise_context_load(database,"ex_unknown_runtime",1U,1U,&context)==TRAINLOG_STATUS_OK);
|
||||
CHECK(context.knowledge==NULL && context.occurrence_count==0U);
|
||||
trainlog_training_exercise_context_release(&context);
|
||||
|
||||
CHECK(trainlog_database_insert_exercise_profiled(database,"ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
|
||||
"Marche renommée","marche renommee",TRAINLOG_TRACKING_DURATION,TRAINLOG_RECORDING_CONTINUOUS,
|
||||
TRAINLOG_EXERCISE_DATA_SPEED_KMH)==TRAINLOG_STATUS_OK);
|
||||
(void)memset(&continuous_occurrence,0,sizeof(continuous_occurrence));
|
||||
(void)snprintf(continuous_occurrence.entry_id,sizeof(continuous_occurrence.entry_id),"entry_walk");
|
||||
(void)snprintf(continuous_occurrence.exercise_id,sizeof(continuous_occurrence.exercise_id),"%s",
|
||||
"ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d");
|
||||
(void)snprintf(continuous_occurrence.equipment_id,sizeof(continuous_occurrence.equipment_id),"treadmill");
|
||||
continuous_occurrence.recording_mode=TRAINLOG_RECORDING_CONTINUOUS;
|
||||
continuous_occurrence.data_fields=TRAINLOG_EXERCISE_DATA_SPEED_KMH;
|
||||
continuous_occurrence.load_mode=TRAINLOG_LOAD_NONE;
|
||||
continuous_occurrence.continuous_duration_seconds=900;
|
||||
continuous_occurrence.continuous_has_speed=true;
|
||||
continuous_occurrence.continuous_speed_kmh=5.0;
|
||||
CHECK(insert_session(database,"session_walk","2026-09-06T10:00:00+02:00",TRAINLOG_SESSION_TRAINING,&continuous_occurrence,1U));
|
||||
CHECK(trainlog_training_exercise_context_load(database,continuous_occurrence.exercise_id,1U,1U,&context)==TRAINLOG_STATUS_OK);
|
||||
CHECK(context.occurrence_count==1U && context.occurrences[0].occurrence.continuous_duration_seconds==900);
|
||||
CHECK(context.occurrences[0].set_count==0U);
|
||||
trainlog_training_exercise_context_release(&context);
|
||||
|
||||
for(index=0U;index<65U;++index){large_sets[index].reps=(int)(index+1U);large_sets[index].duration_seconds=0;
|
||||
large_sets[index].has_weight=false;large_sets[index].weight_kg=0.0;}
|
||||
base_occurrence(&large_occurrence,"entry_large","ex_unknown_runtime","",TRAINLOG_LOAD_NONE);
|
||||
large_occurrence.target_sets=65; large_occurrence.sets=large_sets; large_occurrence.set_count=65U;
|
||||
CHECK(insert_session(database,"session_large","2026-09-05T10:00:00+02:00",TRAINLOG_SESSION_TRAINING,&large_occurrence,1U));
|
||||
CHECK(trainlog_database_list_occurrence_sets_page(database,"entry_large",-1,64U,set_page,&count,&has_more,
|
||||
&next_position)==TRAINLOG_STATUS_OK);
|
||||
CHECK(count==64U && has_more && next_position==63);
|
||||
CHECK(trainlog_database_list_occurrence_sets_page(database,"entry_large",next_position,64U,set_page,&count,
|
||||
&has_more,&next_position)==TRAINLOG_STATUS_OK);
|
||||
CHECK(count==1U && !has_more && set_page[0].position==64U);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE sessions SET started_at='2026-09-20 10:00:00Z' WHERE session_id='session_lower';",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,NULL,1U,page,&count,&has_more,
|
||||
&next)==TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE sessions SET started_at='2020-01-01T00:00:00.123456789012345678901234567890Z' WHERE session_id='session_lower';",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,NULL,1U,page,&count,&has_more,
|
||||
&next)==TRAINLOG_STATUS_OK);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE sessions SET started_at='9999-12-31T23:59:59.123456789012345678901234567890Z' WHERE session_id='session_lower';",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,NULL,1U,page,&count,&has_more,
|
||||
&next)==TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE sessions SET started_at='2026-09-20t10:00:00z' WHERE session_id='session_lower';",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE sessions SET started_at='9999-12-31T23:59:59.123456789012345678901234567890Z' WHERE session_id='session_max_true';",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_latest_explicit_max_context(database,exercise_id,&context.latest_max)
|
||||
== TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE sessions SET started_at='2026-09-22T20:00:00Z' WHERE session_id='session_max_true';",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE continuous_activity SET duration_seconds=2147483648 WHERE "
|
||||
"session_exercise_row_id=(SELECT id FROM session_exercises WHERE entry_id='entry_walk');",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,continuous_occurrence.exercise_id,NULL,1U,
|
||||
page,&count,&has_more,&next)==TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE performed_sets SET reps=2147483648 WHERE position=0 AND "
|
||||
"session_exercise_row_id=(SELECT id FROM session_exercises WHERE entry_id='entry_large');",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_occurrence_sets_page(database,"entry_large",-1,64U,set_page,&count,&has_more,
|
||||
&next_position)==TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE performed_sets SET reps=0,duration_seconds=NULL,weight_kg=NULL WHERE position=0 AND "
|
||||
"session_exercise_row_id=(SELECT id FROM session_exercises WHERE entry_id='entry_large');",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_occurrence_sets_page(database,"entry_large",-1,1U,set_page,&count,&has_more,
|
||||
&next_position)==TRAINLOG_STATUS_OK);
|
||||
CHECK(count==1U && set_page[0].has_reps && set_page[0].reps==0 && !set_page[0].has_duration &&
|
||||
!set_page[0].has_weight);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "UPDATE performed_sets SET reps=NULL,duration_seconds='bad' WHERE position=0 AND "
|
||||
"session_exercise_row_id=(SELECT id FROM session_exercises WHERE entry_id='entry_large');",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_occurrence_sets_page(database,"entry_large",-1,64U,set_page,&count,&has_more,
|
||||
&next_position)==TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA ignore_check_constraints=ON; UPDATE performed_sets SET duration_seconds=NULL,position='bad'||position WHERE "
|
||||
"session_exercise_row_id=(SELECT id FROM session_exercises WHERE entry_id='entry_large');",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_occurrence_sets_page(database,"entry_large",-1,64U,set_page,&count,&has_more,
|
||||
&next_position)==TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(sqlite3_open(database_path, &raw) == SQLITE_OK);
|
||||
CHECK(sqlite3_exec(raw, "PRAGMA ignore_check_constraints=ON; UPDATE session_exercises SET data_fields='bad' WHERE "
|
||||
"exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde');",
|
||||
NULL, NULL, NULL) == SQLITE_OK);
|
||||
CHECK(sqlite3_close(raw) == SQLITE_OK); raw = NULL;
|
||||
CHECK(trainlog_database_open(database_path, &database) == TRAINLOG_STATUS_OK);
|
||||
CHECK(trainlog_database_list_exercise_occurrences_page(database,exercise_id,NULL,1U,page,&count,&has_more,
|
||||
&next)==TRAINLOG_STATUS_DATABASE_ERROR);
|
||||
trainlog_database_close(database);
|
||||
CHECK(unlink(database_path) == 0);
|
||||
return 0;
|
||||
}
|
||||
107
tui/tests/test_training_knowledge.c
Normal file
107
tui/tests/test_training_knowledge.c
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#include "trainlog/training_knowledge.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#define CHECK(test) do { if (!(test)) { fprintf(stderr, "CHECK failed: %s:%d: %s\n", \
|
||||
__FILE__, __LINE__, #test); return 1; } } while (0)
|
||||
|
||||
static int contains(const TrainlogExerciseKnowledge *const *rows, size_t count, const char *id)
|
||||
{
|
||||
size_t index;
|
||||
for (index = 0; index < count; ++index) {
|
||||
if (strcmp(rows[index]->exercise_id, id) == 0) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
const char *const rear_delt = "ex_4cd2433e-80b1-478a-b8df-73fc6ef80962";
|
||||
const char *const chest_press = "ex_8552dd77-fcd7-4f06-a1cc-d956eb1009af";
|
||||
const char *const leg_press = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde";
|
||||
const char *const rotary = "ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0";
|
||||
const TrainlogExerciseKnowledge *rows[32];
|
||||
const TrainlogExerciseKnowledge *record;
|
||||
const TrainlogKnowledgeInterpretation *conditional;
|
||||
TrainlogKnowledgeQuery query = {0};
|
||||
size_t count = 0U;
|
||||
size_t index;
|
||||
int rotary_capability_found = 0;
|
||||
const TrainlogKnowledgeBodyZoneAudit *audit;
|
||||
|
||||
CHECK(trainlog_knowledge_reference_count() == 23U);
|
||||
CHECK(trainlog_knowledge_muscle_count() == 53U);
|
||||
CHECK(trainlog_knowledge_joint_action_count() == 35U);
|
||||
CHECK(trainlog_knowledge_movement_pattern_count() == 26U);
|
||||
CHECK(trainlog_exercise_knowledge_count() == 23U);
|
||||
CHECK(trainlog_equipment_knowledge_count() == 41U);
|
||||
CHECK(trainlog_knowledge_body_zone_audit_count() == trainlog_exercise_knowledge_count());
|
||||
audit = trainlog_knowledge_body_zone_audit_lookup(leg_press);
|
||||
CHECK(audit != NULL && strcmp(audit->status, "confirmed") == 0);
|
||||
CHECK(audit->rationale[0] != '\0' && audit->source_refs[0] != '\0');
|
||||
audit = trainlog_knowledge_body_zone_audit_lookup(chest_press);
|
||||
CHECK(audit != NULL && strcmp(audit->status, "questionable") == 0);
|
||||
audit = trainlog_knowledge_body_zone_audit_lookup("ex_2d488c08-194c-4051-a3c9-34471646c1d3");
|
||||
CHECK(audit != NULL && strcmp(audit->status, "unresolved") == 0);
|
||||
CHECK(trainlog_knowledge_body_zone_audit_at(trainlog_knowledge_body_zone_audit_count()) == NULL);
|
||||
CHECK(trainlog_knowledge_body_zone_audit_lookup(NULL) == NULL);
|
||||
CHECK(trainlog_knowledge_muscle_lookup("pectoralis_major") != NULL);
|
||||
CHECK(trainlog_knowledge_joint_action_lookup("shoulder_horizontal_abduction") != NULL);
|
||||
CHECK(trainlog_knowledge_movement_pattern_lookup("horizontal_pull") != NULL);
|
||||
CHECK(trainlog_knowledge_reference_lookup("openstax_upper") != NULL);
|
||||
CHECK(trainlog_equipment_knowledge_lookup("rear_delt_pec_fly") != NULL);
|
||||
CHECK(trainlog_exercise_knowledge_lookup("ex_unknown") == NULL);
|
||||
|
||||
record = trainlog_exercise_knowledge_lookup(rear_delt);
|
||||
CHECK(record != NULL && record->interpretation != NULL);
|
||||
CHECK(record->conditional_interpretation == NULL);
|
||||
CHECK(strcmp(record->interpretation->primary_zone_id, "shoulders") == 0);
|
||||
|
||||
record = trainlog_exercise_knowledge_lookup(chest_press);
|
||||
CHECK(record != NULL && record->interpretation == NULL);
|
||||
conditional = trainlog_exercise_knowledge_conditional(chest_press);
|
||||
CHECK(conditional != NULL);
|
||||
CHECK(strcmp(conditional->family_description, "machine_chest_press") == 0);
|
||||
|
||||
query.muscle_id = "quadriceps";
|
||||
query.muscle_role = TRAINLOG_KNOWLEDGE_ROLE_PRIMARY;
|
||||
query.available_equipment_id = "leg_press";
|
||||
CHECK(trainlog_exercise_knowledge_query(&query, rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count == 1U && strcmp(rows[0]->exercise_id, leg_press) == 0);
|
||||
|
||||
query = (TrainlogKnowledgeQuery){0};
|
||||
query.muscle_role = TRAINLOG_KNOWLEDGE_ROLE_PRIMARY;
|
||||
CHECK(trainlog_exercise_knowledge_query(&query, rows, 32U, &count) ==
|
||||
TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
|
||||
query = (TrainlogKnowledgeQuery){0};
|
||||
query.scientific_zone_id = "upper_body";
|
||||
query.include_zone_descendants = true;
|
||||
CHECK(trainlog_exercise_knowledge_query(&query, rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(contains(rows, count, rear_delt));
|
||||
CHECK(!contains(rows, count, chest_press));
|
||||
|
||||
query = (TrainlogKnowledgeQuery){0};
|
||||
query.movement_pattern_id = "horizontal_pull";
|
||||
CHECK(trainlog_exercise_knowledge_query(&query, rows, 0U, &count) == TRAINLOG_STATUS_INVALID_ARGUMENT);
|
||||
CHECK(count > 0U);
|
||||
CHECK(trainlog_exercise_knowledge_query(&query, rows, 32U, &count) == TRAINLOG_STATUS_OK);
|
||||
CHECK(count >= 2U);
|
||||
query.movement_pattern_id = "not_a_pattern";
|
||||
CHECK(trainlog_exercise_knowledge_query(&query, rows, 32U, &count) == TRAINLOG_STATUS_NOT_FOUND);
|
||||
|
||||
for (index = 0; index < trainlog_equipment_capability_count(); ++index) {
|
||||
const TrainlogEquipmentCapability *capability = trainlog_equipment_capability_at(index);
|
||||
if (strcmp(capability->equipment_id, "rotary_torso") == 0 &&
|
||||
strstr(capability->exercise_ids, rotary) != NULL) {
|
||||
rotary_capability_found = 1;
|
||||
CHECK(strcmp(capability->link_status,
|
||||
"catalog_compatible_not_observed_occurrence") == 0);
|
||||
}
|
||||
}
|
||||
/* Catalog compatibility is static metadata; the API contains no occurrence
|
||||
* row and therefore cannot fabricate performance history. */
|
||||
CHECK(rotary_capability_found);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@ struct TrainlogTerminal {
|
|||
size_t event_index;
|
||||
char output[32768];
|
||||
size_t output_used;
|
||||
int rows;
|
||||
int columns;
|
||||
bool coordinate_overflow;
|
||||
bool text_overflow;
|
||||
};
|
||||
|
||||
struct TrainlogPanel { int unused; };
|
||||
|
|
@ -29,12 +33,14 @@ static void script(TrainlogTerminal *terminal, const int *events, size_t count)
|
|||
(void)memset(terminal, 0, sizeof(*terminal));
|
||||
(void)memcpy(terminal->events, events, count * sizeof(events[0]));
|
||||
terminal->event_count = count;
|
||||
terminal->rows = 24;
|
||||
terminal->columns = 80;
|
||||
}
|
||||
|
||||
TrainlogTerminal *trainlog_terminal_create(void) { return NULL; }
|
||||
void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; }
|
||||
int trainlog_terminal_rows(const TrainlogTerminal *terminal) { (void)terminal; return 24; }
|
||||
int trainlog_terminal_columns(const TrainlogTerminal *terminal) { (void)terminal; return 80; }
|
||||
int trainlog_terminal_rows(const TrainlogTerminal *terminal) { return terminal->rows; }
|
||||
int trainlog_terminal_columns(const TrainlogTerminal *terminal) { return terminal->columns; }
|
||||
void trainlog_terminal_erase(TrainlogTerminal *terminal) { (void)terminal; }
|
||||
void trainlog_terminal_render(TrainlogTerminal *terminal) { (void)terminal; }
|
||||
void trainlog_terminal_style_on(TrainlogTerminal *terminal, TrainlogTextStyle style) { (void)terminal; (void)style; }
|
||||
|
|
@ -44,14 +50,28 @@ void trainlog_terminal_printf(TrainlogTerminal *terminal, int row, int column,
|
|||
{
|
||||
va_list arguments;
|
||||
int written;
|
||||
(void)row;
|
||||
(void)column;
|
||||
if (row < 0 || row >= terminal->rows || column < 0 || column >= terminal->columns)
|
||||
terminal->coordinate_overflow = true;
|
||||
if (terminal->output_used >= sizeof(terminal->output)) return;
|
||||
va_start(arguments, format);
|
||||
written = vsnprintf(terminal->output + terminal->output_used,
|
||||
sizeof(terminal->output) - terminal->output_used, format, arguments);
|
||||
va_end(arguments);
|
||||
if (written > 0 && (size_t)written < sizeof(terminal->output) - terminal->output_used) {
|
||||
const char *text = terminal->output + terminal->output_used;
|
||||
const char *at = text;
|
||||
int cells = 0;
|
||||
while (*at != '\0') {
|
||||
utf8proc_int32_t codepoint;
|
||||
utf8proc_ssize_t bytes = utf8proc_iterate((const utf8proc_uint8_t *)at,
|
||||
-1, &codepoint);
|
||||
int codepoint_cells;
|
||||
if (bytes <= 0) { bytes = 1; codepoint = (unsigned char)*at; }
|
||||
codepoint_cells = utf8proc_charwidth(codepoint);
|
||||
cells += codepoint_cells < 0 ? 1 : codepoint_cells;
|
||||
at += bytes;
|
||||
}
|
||||
if (column + cells >= terminal->columns) terminal->text_overflow = true;
|
||||
terminal->output_used += (size_t)written;
|
||||
terminal->output[terminal->output_used++] = '\n';
|
||||
terminal->output[terminal->output_used] = '\0';
|
||||
|
|
@ -240,12 +260,39 @@ static bool test_empty_sets_cannot_finish(void)
|
|||
return true;
|
||||
}
|
||||
|
||||
static bool test_knowledge_scrolls_long_lists_at_minimum_terminal(void)
|
||||
{
|
||||
TrainlogExercise exercise;
|
||||
TrainlogTerminal terminal;
|
||||
const int events[] = {
|
||||
TRAINLOG_KEY_PAGE_DOWN, TRAINLOG_KEY_PAGE_DOWN, TRAINLOG_KEY_PAGE_DOWN,
|
||||
TRAINLOG_KEY_PAGE_DOWN, 'b'
|
||||
};
|
||||
|
||||
(void)memset(&exercise, 0, sizeof(exercise));
|
||||
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s",
|
||||
"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1");
|
||||
(void)snprintf(exercise.name, sizeof(exercise.name), "%s", "Lat pull");
|
||||
script(&terminal, events, sizeof(events) / sizeof(events[0]));
|
||||
terminal.rows = 20;
|
||||
terminal.columns = 72;
|
||||
tui_terminal = &terminal;
|
||||
screen_exercise_knowledge(&exercise);
|
||||
CHECK(!terminal.coordinate_overflow);
|
||||
CHECK(!terminal.text_overflow);
|
||||
CHECK(strstr(terminal.output, "Supra-épineux") != NULL);
|
||||
CHECK(strstr(terminal.output, "Sources :") != NULL);
|
||||
tui_terminal = NULL;
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
if (!test_assistance_creation_labels() ||
|
||||
!test_duration_creation_starts_empty() ||
|
||||
!test_append_requires_actual_and_rolls_back() ||
|
||||
!test_empty_sets_cannot_finish()) return 1;
|
||||
!test_empty_sets_cannot_finish() ||
|
||||
!test_knowledge_scrolls_long_lists_at_minimum_terminal()) return 1;
|
||||
(void)printf("PASS tui_workflows\n");
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue