feat(training): complete training knowledge v1

This commit is contained in:
fy59 2026-09-09 19:43:11 +02:00
parent 1aad7b48b3
commit fd955315cc
56 changed files with 13745 additions and 28 deletions

View 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.

View file

@ -324,3 +324,13 @@ A task is complete only when:
- synchronization remains idempotent where applicable; - synchronization remains idempotent where applicable;
- documentation describes the resulting state; - documentation describes the resulting state;
- no known regression is intentionally left behind. - 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.

View file

@ -9,6 +9,19 @@ Detailed implementation chronology remains available in Git history and
### Added ### 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 - `BODY_ZONES_V1`: the canonical `catalog/body-zones-v1.json` taxonomy with
stable IDs, French display metadata, hierarchy, deterministic sort order and stable IDs, French display metadata, hierarchy, deterministic sort order and
exact stable-exercise-ID migration evidence; exact stable-exercise-ID migration evidence;

View file

@ -42,10 +42,25 @@ BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
DESKTOP_TESTS=39/39 PASS DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint)
ANDROID_BUILD=PASS 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 ## Architecture
```text ```text
@ -255,6 +270,8 @@ Exercise-zone metadata travels separately in the sole bidirectional
- `docs/exchange_format.md`: frozen Trainlog JSON v1 contract; - `docs/exchange_format.md`: frozen Trainlog JSON v1 contract;
- `docs/tests.md`: validation strategy; - `docs/tests.md`: validation strategy;
- `docs/roadmap.md`: completed gates and future cursor; - `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. - `AGENTS.md`: development contract.
## Development principles ## Development principles

View file

@ -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" } }
}
}

View file

@ -184,6 +184,68 @@ sealed interface FinalizeActiveDraftResult {
) : 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( class TrainlogRepository(
context: Context, context: Context,
databaseName: String = databaseName: String =
@ -191,6 +253,7 @@ class TrainlogRepository(
) { ) {
private val applicationContext = context.applicationContext private val applicationContext = context.applicationContext
private val bodyZones = BodyZoneCatalog.load(applicationContext) private val bodyZones = BodyZoneCatalog.load(applicationContext)
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
private val database = private val database =
TrainlogDatabaseHelper( TrainlogDatabaseHelper(
applicationContext, applicationContext,
@ -201,6 +264,23 @@ class TrainlogRepository(
database.close() 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> { fun listEquipment(): List<EquipmentCatalogEntry> {
val output = mutableListOf<EquipmentCatalogEntry>() val output = mutableListOf<EquipmentCatalogEntry>()
database.readableDatabase.query( 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 * Return one newest explicit measured max per movement. Equipment is
* presentation context only and never participates in max identity. * presentation context only and never participates in max identity.

View file

@ -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
}
}

View file

@ -19,6 +19,9 @@ import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.BodyZone import com.labfytools.trainlog.data.BodyZone
import com.labfytools.trainlog.data.BodyZoneKind import com.labfytools.trainlog.data.BodyZoneKind
import com.labfytools.trainlog.data.EditExerciseResult 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.data.TrainlogRepository
import com.labfytools.trainlog.model.ExerciseDataFields import com.labfytools.trainlog.model.ExerciseDataFields
import com.labfytools.trainlog.model.ExerciseEditInput import com.labfytools.trainlog.model.ExerciseEditInput
@ -73,6 +76,7 @@ fun ExerciseScreen(
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var filterZoneId by remember { mutableStateOf<String?>(null) } var filterZoneId by remember { mutableStateOf<String?>(null) }
var unclassifiedFilter by remember { mutableStateOf(false) } var unclassifiedFilter by remember { mutableStateOf(false) }
var expandedKnowledgeIds by remember { mutableStateOf(emptySet<String>()) }
val zones = repository.listBodyZones() val zones = repository.listBodyZones()
var message by var message by
@ -513,6 +517,22 @@ fun ExerciseScreen(
accent = colors.accent, accent = colors.accent,
onClick = { startEditing(exercise) }, 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 @Composable
private fun BodyZoneChoices( private fun BodyZoneChoices(
zones: List<BodyZone>, zones: List<BodyZone>,

View file

@ -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"
}
}

View file

@ -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()
}
}

View file

@ -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)) }
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View 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 lavant-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 lavant-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."
}
]
}

View 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 à lextension 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

File diff suppressed because it is too large Load diff

View 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"
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -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 currently accepts known supplemental bits on either recording mode. Supplied
profiles do not exercise this difference. Supporting a future set-based profiles do not exercise this difference. Supporting a future set-based
supplemental field requires an explicit shared-model decision. 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 132 and 164 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).

View file

@ -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 distance only with `CONTINUOUS`; defining cross-platform behavior for a future
set-based supplemental field is a model-contract decision, not part of this set-based supplemental field is a model-contract decision, not part of this
reconciliation. 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).

View file

@ -62,7 +62,9 @@ BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
BODY_ZONES_TUI_REAL_VALIDATION=PASS BODY_ZONES_TUI_REAL_VALIDATION=PASS
BODY_ZONES_ANDROID_DEVICE_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 ANDROID_BUILD=PASS
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
``` ```
@ -72,6 +74,8 @@ HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
Implemented: Implemented:
- C17/Notcurses true-color TUI (72x20 minimum, UTF-8 prompts, resize fallback); - 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`, - SQLite schema v11, with stable ordered `session_exercises.entry_id`,
occurrence-level equipment identity, and desktop-local custom-equipment occurrence-level equipment identity, and desktop-local custom-equipment
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration 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 Notcurses accent, muted context line, and flat touch layout reproduce the TUI
plaque without literal terminal box drawing. 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 ## Synchronization
Canonical exchange directory: Canonical exchange directory:

View file

@ -546,3 +546,25 @@ left/right asymmetry percentages
The optional estimation profile is desktop configuration, not database The optional estimation profile is desktop configuration, not database
history. 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`.

View 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.

View 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.

View 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 |

View 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 132 and a set
preview limit of 164 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.

View 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.

View file

@ -317,3 +317,30 @@ The newest successful explicit test is the current measured result. A separate
same-mode historical record may be older. same-mode historical record may be older.
No estimated 1RM is mixed into this contract. 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.

View 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.

View 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.

View 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`.

View 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 00019999, valid calendar days,
hours 0023, minutes 0059 and ordinary seconds 0059. 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 00019999 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`.

View 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.

View file

@ -31,7 +31,7 @@ BODY_ZONES_V1=PASS
BODY_ZONE_SYNC_V1=PASS BODY_ZONE_SYNC_V1=PASS
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
DESKTOP_TESTS=39/39 PASS DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint)
TUI_NOTCURSES_V1=PASS TUI_NOTCURSES_V1=PASS
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
NOTCURSES_TRUECOLOR_THEME=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 companion. It does not implement a session generator, custom zones or proposed
loads. 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 `EXERCISE_EDIT_V1` is a completed capture correction: Android permits
stable-ID renames, protects referenced profiles, and reconciles same-ID display stable-ID renames, protects referenced profiles, and reconciles same-ID display
metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only metadata without duplicates. `ANDROID_BANNER_PARITY_V1` is a presentation-only

View file

@ -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. 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). 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.

View file

@ -465,3 +465,18 @@ Proportions can display:
All estimates are explicitly labeled as estimates. No result is converted into All estimates are explicitly labeled as estimates. No result is converted into
a medical or diagnostic classification. 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).

View 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": []
}
}

View 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": []
}
}

View 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()

View 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()

View 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=&empty;
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()

View 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()

View file

@ -4,12 +4,12 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from dataclasses import dataclass
import json import json
import math import math
import re import re
import sys import sys
import unicodedata import unicodedata
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -120,25 +120,67 @@ def normalize_exercise_name(name: str) -> str:
return collapsed.casefold() return collapsed.casefold()
def parse_timestamp(value: str, field_name: str) -> datetime: @dataclass(frozen=True)
"""Parse a Trainlog timestamp while requiring an explicit UTC offset.""" class TrainlogTimestamp:
candidate = value """Exact comparable instant key; fraction has insignificant zeros removed."""
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try: utc_second: int
parsed = datetime.fromisoformat(candidate) fraction: str
except ValueError as exc:
raise TrainlogSemanticError(
f"{field_name}: invalid date-time: {value!r}"
) from exc
if parsed.utcoffset() is None: def __lt__(self, other: "TrainlogTimestamp") -> bool:
raise TrainlogSemanticError( return (self.utc_second, self.fraction) < (other.utc_second, other.fraction)
f"{field_name}: UTC offset is required: {value!r}"
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")
return parsed 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: def require_non_blank(value: str, field_name: str) -> None:
@ -358,8 +400,23 @@ def structural_errors(
document: Any, document: Any,
) -> list[str]: ) -> list[str]:
"""Return deterministic human-readable JSON Schema errors.""" """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( errors = sorted(
validator.iter_errors(document), temporal_validator.iter_errors(document),
key=lambda error: [str(part) for part in error.absolute_path], 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) validator.check_schema(schema)
if not args.paths: 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 failed = False
for path in args.paths: for path in args.paths:

View 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()

View file

@ -15,6 +15,11 @@
typedef struct TrainlogDatabase TrainlogDatabase; 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 /* WHY: session occurrences retain an equipment ID, while custom definitions
* need durable presentation metadata. A reference alone is never a definition. */ * need durable presentation metadata. A reference alone is never a definition. */
typedef struct TrainlogCustomEquipment { typedef struct TrainlogCustomEquipment {
@ -386,6 +391,115 @@ TrainlogStatus trainlog_database_list_exercise_performance(
size_t *output_count 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 */ /* TRAINLOG_SESSION_EDIT_API */

View 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

View 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

View file

@ -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@'], 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 = [ strict_c_args = [
'-D_POSIX_C_SOURCE=200809L', '-D_POSIX_C_SOURCE=200809L',
'-Wconversion', '-Wconversion',
@ -45,15 +67,18 @@ trainlog_core_sources = files(
'src/duration.c', 'src/duration.c',
'src/id.c', 'src/id.c',
'src/timeutil.c', 'src/timeutil.c',
'src/training_context.c',
'src/usb.c', 'src/usb.c',
'src/mtp.c', 'src/mtp.c',
'src/reps.c', 'src/reps.c',
'src/measured_max.c', 'src/measured_max.c',
'src/sync.c', 'src/sync.c',
'src/sync_history.c', 'src/sync_history.c',
'src/timestamp.c',
) )
trainlog_core_sources += equipment_catalog_generated trainlog_core_sources += equipment_catalog_generated
trainlog_core_sources += body_zone_catalog_generated trainlog_core_sources += body_zone_catalog_generated
trainlog_core_sources += training_knowledge_generated
trainlog_core = static_library( trainlog_core = static_library(
'trainlog_core', 'trainlog_core',
@ -142,6 +167,22 @@ test_body_zones = executable(
) )
test('body_zones', test_body_zones) 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 = executable(
'test_custom_equipment', 'test_custom_equipment',
'tests/test_custom_equipment.c', 'tests/test_custom_equipment.c',
@ -399,6 +440,12 @@ test(
python3_trainlog_tests = find_program('python3') python3_trainlog_tests = find_program('python3')
test(
'timestamp_validation',
python3_trainlog_tests,
args: [meson.project_source_root() / 'tests/test_timestamp_validation.py'],
)
test( test(
'mobile_import_variable_sets', 'mobile_import_variable_sets',
python3_trainlog_tests, python3_trainlog_tests,

View file

@ -8,8 +8,10 @@
#include "trainlog/duration.h" #include "trainlog/duration.h"
#include "trainlog/equipment_catalog.h" #include "trainlog/equipment_catalog.h"
#include "trainlog/id.h" #include "trainlog/id.h"
#include "timestamp.h"
#include <math.h> #include <math.h>
#include <limits.h>
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@ -19,8 +21,38 @@
struct TrainlogDatabase { struct TrainlogDatabase {
sqlite3 *connection; 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( static TrainlogStatus lookup_exercise_row_id(
TrainlogDatabase *database, TrainlogDatabase *database,
const char *exercise_id, const char *exercise_id,
@ -6102,3 +6134,486 @@ TrainlogStatus trainlog_database_update_body_observation(
? TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_OK
: TRAINLOG_STATUS_DATABASE_ERROR; : 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
View 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
View 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
View 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;
}

View file

@ -20,6 +20,7 @@
#include <wchar.h> #include <wchar.h>
#include <unistd.h> #include <unistd.h>
#include <utf8proc.h>
#include <uuid/uuid.h> #include <uuid/uuid.h>
#include "trainlog/terminal.h" #include "trainlog/terminal.h"
@ -39,6 +40,7 @@
#include "trainlog/reps.h" #include "trainlog/reps.h"
#include "trainlog/theme.h" #include "trainlog/theme.h"
#include "trainlog/timeutil.h" #include "trainlog/timeutil.h"
#include "trainlog/training_knowledge.h"
#include "trainlog/usb.h" #include "trainlog/usb.h"
#define MAX_EXERCISES 128U #define MAX_EXERCISES 128U
@ -2380,6 +2382,180 @@ static bool edit_exercise_body_zones(
return status == TRAINLOG_STATUS_OK; 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( static void screen_exercise_detail(
TrainlogDatabase *database, TrainlogDatabase *database,
TrainlogExercise *exercise TrainlogExercise *exercise
@ -2418,7 +2594,7 @@ static void screen_exercise_detail(
int key; int key;
if (total > 0U && selected >= total) selected = total - 1U; if (total > 0U && selected >= total) selected = total - 1U;
draw_shell("TRAINLOG — Fiche exercice", 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, 3, 4, "%s", exercise->name);
trainlog_terminal_printf(tui_terminal, 4, 4, "Identifiant : %s · suivi : %s", trainlog_terminal_printf(tui_terminal, 4, 4, "Identifiant : %s · suivi : %s",
exercise->exercise_id, exercise->exercise_id,
@ -2479,6 +2655,7 @@ static void screen_exercise_detail(
? &explicit_items[selected] : &historic_items[selected - explicit_count]); ? &explicit_items[selected] : &historic_items[selected - explicit_count]);
else if (key == 'p' || key == 'P') screen_exercise_performance(database, exercise); 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 == '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); else if (key == 'e' || key == 'E') (void)edit_exercise_body_zones(database, exercise);
} }
} }

View 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;
}

View 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;
}

View file

@ -16,6 +16,10 @@ struct TrainlogTerminal {
size_t event_index; size_t event_index;
char output[32768]; char output[32768];
size_t output_used; size_t output_used;
int rows;
int columns;
bool coordinate_overflow;
bool text_overflow;
}; };
struct TrainlogPanel { int unused; }; 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)memset(terminal, 0, sizeof(*terminal));
(void)memcpy(terminal->events, events, count * sizeof(events[0])); (void)memcpy(terminal->events, events, count * sizeof(events[0]));
terminal->event_count = count; terminal->event_count = count;
terminal->rows = 24;
terminal->columns = 80;
} }
TrainlogTerminal *trainlog_terminal_create(void) { return NULL; } TrainlogTerminal *trainlog_terminal_create(void) { return NULL; }
void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; } void trainlog_terminal_destroy(TrainlogTerminal *terminal) { (void)terminal; }
int trainlog_terminal_rows(const TrainlogTerminal *terminal) { (void)terminal; return 24; } int trainlog_terminal_rows(const TrainlogTerminal *terminal) { return terminal->rows; }
int trainlog_terminal_columns(const TrainlogTerminal *terminal) { (void)terminal; return 80; } int trainlog_terminal_columns(const TrainlogTerminal *terminal) { return terminal->columns; }
void trainlog_terminal_erase(TrainlogTerminal *terminal) { (void)terminal; } void trainlog_terminal_erase(TrainlogTerminal *terminal) { (void)terminal; }
void trainlog_terminal_render(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; } 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; va_list arguments;
int written; int written;
(void)row; if (row < 0 || row >= terminal->rows || column < 0 || column >= terminal->columns)
(void)column; terminal->coordinate_overflow = true;
if (terminal->output_used >= sizeof(terminal->output)) return; if (terminal->output_used >= sizeof(terminal->output)) return;
va_start(arguments, format); va_start(arguments, format);
written = vsnprintf(terminal->output + terminal->output_used, written = vsnprintf(terminal->output + terminal->output_used,
sizeof(terminal->output) - terminal->output_used, format, arguments); sizeof(terminal->output) - terminal->output_used, format, arguments);
va_end(arguments); va_end(arguments);
if (written > 0 && (size_t)written < sizeof(terminal->output) - terminal->output_used) { 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_used += (size_t)written;
terminal->output[terminal->output_used++] = '\n'; terminal->output[terminal->output_used++] = '\n';
terminal->output[terminal->output_used] = '\0'; terminal->output[terminal->output_used] = '\0';
@ -240,12 +260,39 @@ static bool test_empty_sets_cannot_finish(void)
return true; 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) int main(void)
{ {
if (!test_assistance_creation_labels() || if (!test_assistance_creation_labels() ||
!test_duration_creation_starts_empty() || !test_duration_creation_starts_empty() ||
!test_append_requires_actual_and_rolls_back() || !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"); (void)printf("PASS tui_workflows\n");
return 0; return 0;
} }