feat(trainlog): adopt machine-specific exercise model

This commit is contained in:
fy59 2026-09-12 09:41:36 +02:00
parent 42b12b8e1e
commit bb3790588f
66 changed files with 1581 additions and 214 deletions

View file

@ -1,5 +1,16 @@
# Changelog
## Unreleased — MACHINE_EXERCISE_MODEL_V1
- Add desktop and Android schema v13 for machine-specific exercise metadata.
- Preserve stable IDs for pure renames and introduce the five approved UUIDv4
identities for Plate Loaded Leg Press, Treadmill, Pec Fly, Chin Assist, and
Dip Assist.
- Make exact manifest splits transactional and idempotent while preserving all
occurrence child facts and unresolved provenance.
- Remove Equipment from normal Android and TUI navigation while retaining its
Phase 1 storage and sync compatibility roles.
- Added `PERCENT_MAX_INPUT_V1` as a transient user calculator in TUI planning
and both generator previews: exact exercise/equipment external-load context,
integer 1..100, `MAX × percentage / 100`, no recommendation, and only the

View file

@ -52,7 +52,7 @@ APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2
```
`APP_SHELL_V1` provides the shared seven-section application shell on both
platforms: **Accueil**, **Séances**, **Exercices**, **Équipements**,
platforms: **Accueil**, **Séances**, **Exercices**,
**Statistiques**, **Synchronisation**, and **Paramètres**. Completed history
and the existing generator now live under **Séances**; existing body and MAX
views live under **Statistiques**. It records no new statistics, schema, or
@ -205,7 +205,7 @@ export or desktop synchronization as completed sessions.
Schema migrations are additive and preserve existing capture data. See
[Android behavior](docs/android.md) and [validation](docs/tests.md).
The current Android schema is v12. Its additive v4 -> v12 chain adds the shared
The current Android schema is v13. Its additive v4 -> v13 chain adds the shared
equipment catalogue, per-occurrence equipment links, durable occurrence
identities, custom-equipment definition support, explicit MAX results and
stable-source Test max resumption, then direct primary/secondary body-zone

View file

@ -97,6 +97,12 @@ data class EquipmentKnowledge(
val scientificStatusScope: String,
)
data class EquipmentExerciseRelation(
val equipmentId: String, val exerciseId: String, val relationType: String,
val configurationLabel: String?, val confidence: KnowledgeConfidence,
val sourceRefs: List<String>,
)
data class KnowledgeExerciseFilters(
val movementPatternId: String? = null,
val muscleId: String? = null,
@ -107,7 +113,7 @@ data class KnowledgeExerciseFilters(
)
/**
* Immutable TRAINING KNOWLEDGE V1 view over the six shared repository assets.
* Immutable training-knowledge view over the shared repository assets.
* WHY: strict loading makes malformed scientific data a deployment failure and
* prevents Android from quietly developing a second, hand-maintained taxonomy.
*/
@ -115,6 +121,7 @@ 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>,
val equipmentExerciseRelations: List<EquipmentExerciseRelation>,
private val bodyZones: BodyZoneCatalog,
) {
private val referencesById = references.associateBy { it.refId }
@ -123,6 +130,8 @@ class TrainingKnowledgeCatalog private constructor(
private val patternsById = movementPatterns.associateBy { it.patternId }
private val exercisesById = exercises.associateBy { it.exerciseId }
private val equipmentById = equipment.associateBy { it.equipmentId }
private val relationsByEquipment = equipmentExerciseRelations.groupBy { it.equipmentId }
private val relationsByExercise = equipmentExerciseRelations.groupBy { it.exerciseId }
fun getReference(refId: String) = referencesById[refId]
fun getMuscle(muscleId: String) = musclesById[muscleId]
@ -133,6 +142,28 @@ class TrainingKnowledgeCatalog private constructor(
exercisesById[exerciseId]?.takeIf { it.resolutionStatus == ExerciseKnowledgeStatus.CONDITIONAL }
fun getEquipmentKnowledge(equipmentId: String): EquipmentKnowledge? = equipmentById[equipmentId]
fun listExercisesForEquipment(equipmentId: String): List<ExerciseKnowledge> {
require(equipmentById.containsKey(equipmentId)) { "equipment_id inconnu: $equipmentId" }
return relationsByEquipment[equipmentId].orEmpty().map { exercisesById.getValue(it.exerciseId) }
}
fun listRelationsForEquipment(equipmentId: String): List<EquipmentExerciseRelation> =
relationsByEquipment[equipmentId].orEmpty()
fun listEquipmentForExercise(exerciseId: String): List<EquipmentKnowledge> {
require(exercisesById.containsKey(exerciseId)) { "exercise_id inconnu: $exerciseId" }
return relationsByExercise[exerciseId].orEmpty().map { equipmentById.getValue(it.equipmentId) }
}
fun listEquipmentForBodyZone(zoneId: String): List<EquipmentKnowledge> {
val exerciseIds = queryExercises(KnowledgeExerciseFilters(scientificZoneId = zoneId))
.map { it.exerciseId }.toSet()
// CONTRACT: BODY ZONE membership comes from resolved exercise anatomy,
// never from an equipment name, capability label, or custom record.
return equipment.filter { item -> relationsByEquipment[item.equipmentId].orEmpty()
.any { it.exerciseId in exerciseIds } }
}
fun getScientificBodyZoneMapping(exerciseId: String): ScientificBodyZoneMapping? =
resolvedInterpretation(exerciseId)?.let { ScientificBodyZoneMapping(it.primaryZoneId, it.secondaryZoneIds) }
@ -148,7 +179,7 @@ class TrainingKnowledgeCatalog private constructor(
fun listCompatibleExercises(equipmentId: String): List<ExerciseKnowledge> {
require(equipmentById.containsKey(equipmentId)) { "equipment_id inconnu: $equipmentId" }
return queryExercises(KnowledgeExerciseFilters(equipmentId = equipmentId))
return listExercisesForEquipment(equipmentId)
}
fun queryExercises(filters: KnowledgeExerciseFilters): List<ExerciseKnowledge> {
@ -166,7 +197,8 @@ class TrainingKnowledgeCatalog private constructor(
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) &&
(filters.equipmentId == null || relationsByEquipment[filters.equipmentId].orEmpty()
.any { it.exerciseId == exercise.exerciseId }) &&
(zones == null || interpretation.primaryZoneId in zones ||
interpretation.secondaryZoneIds.any { it in zones })
}
@ -209,6 +241,13 @@ class TrainingKnowledgeCatalog private constructor(
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 relationText = assetText("equipment-exercise-relations-v2.json")
DuplicateJsonKeyValidator.validate(relationText)
val relationRoot = JSONObject(relationText)
requireKeys(relationRoot, setOf("format", "version", "equipment_relations"), "equipment_relations")
check(relationRoot.getString("format") == "trainlog-equipment-exercise-relations-v2" &&
relationRoot.rawInt("version") == 2) { "equipment_relations: version non prise en charge" }
val relationJson = relationRoot.getJSONArray("equipment_relations")
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"))
@ -319,7 +358,34 @@ class TrainingKnowledgeCatalog private constructor(
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)
val relationPairs = mutableSetOf<Pair<String, String>>()
var previousEquipmentId: String? = null
val relations = relationJson.objectsUnordered { group ->
requireKeys(group, setOf("equipment_id", "exercise_options"), "equipment relation")
val equipmentId = group.text("equipment_id")
check(equipmentId in equipmentIds && (previousEquipmentId == null || previousEquipmentId!! < equipmentId)) {
"equipment relation inconnue, dupliquée ou hors ordre"
}
previousEquipmentId = equipmentId
var previousOption: String? = null
group.getJSONArray("exercise_options").objectsUnordered { option ->
requireKeys(option, setOf("exercise_id", "relation_type", "configuration_label", "confidence", "source_refs"), "equipment exercise option")
val exerciseId = option.text("exercise_id")
val configuration = option.nullableString("configuration_label")
val key = "$exerciseId|${configuration ?: ""}"
val refs = option.strings("source_refs", true)
check(exerciseId in exerciseIds && option.text("relation_type") == "supported_exercise" &&
refs.all { it in refIds } && relationPairs.add(equipmentId to exerciseId) &&
(previousOption == null || previousOption!! < key)) { "relation équipement/exercice invalide" }
previousOption = key
EquipmentExerciseRelation(equipmentId, exerciseId, option.text("relation_type"),
configuration, confidence(option), refs)
}
}.flatten()
check(relations.none { it.exerciseId == "ex_617007f9-7420-4408-91b9-8ffb77900f13" }) {
"Seated Leg reste non résolu et distinct de la flexion de genou assise"
}
return TrainingKnowledgeCatalog(references, muscles, actions, patterns, exercises, equipment, relations, bodyZones)
}
private fun confidence(item: JSONObject): KnowledgeConfidence = confidenceValues[item.text("confidence")] ?: error("confidence invalide")

View file

@ -655,6 +655,18 @@ class TrainlogRepository(
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 listEquipmentExerciseOptions(equipmentId: String): List<Pair<EquipmentExerciseRelation, ExerciseKnowledge>> =
trainingKnowledge.listRelationsForEquipment(equipmentId).mapNotNull { relation ->
trainingKnowledge.getExerciseKnowledge(relation.exerciseId)?.let { relation to it }
}
fun listEquipmentForKnownExercise(exerciseId: String): List<EquipmentKnowledge> =
resolveExerciseId(database.readableDatabase, exerciseId).let { canonicalExerciseId ->
// INVARIANT: retired creator IDs canonicalize exactly once before
// immutable equipment relations are queried; aliases are not extra options.
trainingKnowledge.getExerciseKnowledge(canonicalExerciseId)?.let {
trainingKnowledge.listEquipmentForExercise(canonicalExerciseId)
}.orEmpty()
}
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)
@ -691,6 +703,20 @@ class TrainlogRepository(
fun searchEquipment(query: String): List<EquipmentCatalogEntry> =
EquipmentCatalog.search(listEquipment(), query)
/**
* CONTRACT: resolved machine exercises carry their Phase-1 legacy
* equipment context themselves. The UI may write this compatibility ID
* without asking the user for a second identity; NULL remains explicit for
* custom and unresolved exercises and is never inferred from a name.
*/
fun machineExerciseLegacyEquipmentId(exerciseId: String): String? =
database.readableDatabase.rawQuery(
"SELECT legacy_equipment_id FROM exercises WHERE exercise_id=?;",
arrayOf(resolveExerciseId(database.readableDatabase, exerciseId)),
).use { cursor ->
if (!cursor.moveToFirst() || cursor.isNull(0)) null else cursor.getString(0)
}
/** Custom equipment is local user data, not an edit to the bundled manifest. */
fun createCustomEquipment(name: String): CreateEquipmentResult {
val displayName = name.trim().replace(Regex("\\s+"), " ")
@ -4376,7 +4402,11 @@ class TrainlogRepository(
val ownsTransaction = !db.inTransaction()
if (ownsTransaction) db.beginTransactionNonExclusive()
return try {
val exercise = readExerciseProfileExact(db, exerciseId) ?: return null
// INVARIANT: retired creator IDs canonicalize once in this read
// snapshot, so profile, scientific metadata, relations, and actual
// history all describe the same exercise without alias duplicates.
val canonicalExerciseId = resolveExerciseId(db, exerciseId)
val exercise = readExerciseProfileExact(db, canonicalExerciseId) ?: return null
val direct = buildList {
exercise.primaryZoneId?.let(::add)
addAll(exercise.secondaryZoneIds)
@ -4386,16 +4416,16 @@ class TrainlogRepository(
expanded += zoneId
expanded += bodyZones.ancestors(zoneId).map { it.zoneId }
}
val knowledge = trainingKnowledge.getExerciseKnowledge(exerciseId)
val compatible = knowledge?.equipmentIds.orEmpty().mapNotNull(trainingKnowledge::getEquipmentKnowledge)
val knowledge = trainingKnowledge.getExerciseKnowledge(canonicalExerciseId)
val compatible = knowledge?.let { trainingKnowledge.listEquipmentForExercise(canonicalExerciseId) }.orEmpty()
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),
latestExplicitMax = readLatestExplicitMax(db, canonicalExerciseId),
recentPerformance = readExerciseOccurrencePage(db, canonicalExerciseId, occurrenceLimit, null, setPreviewLimit),
)
if (ownsTransaction) db.setTransactionSuccessful()
result
@ -5783,7 +5813,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
12,
13,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -5911,6 +5941,11 @@ private class TrainlogDatabaseHelper(
version = 12
}
if (version < 13 && newVersion >= 13) {
migrateMachineExercisesToVersionThirteen(db)
version = 13
}
if (version != newVersion) {
error(
"Unsupported Android DB upgrade " +
@ -6198,6 +6233,14 @@ private class TrainlogDatabaseHelper(
data_fields >= 0 AND
(data_fields & ~3) = 0
),
load_semantics TEXT
CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio')),
machine_variant TEXT,
machine_provenance TEXT,
scientific_profile_id TEXT,
science_state TEXT NOT NULL DEFAULT 'unresolved'
CHECK(science_state IN ('resolved','unresolved')),
legacy_equipment_id TEXT,
CHECK(
recording_mode !=
'continuous' OR
@ -6214,6 +6257,121 @@ private class TrainlogDatabaseHelper(
)
}
/**
* WHY: a fixed gym machine/movement is now the selectable performance
* identity, while equipment rows remain only compatibility/provenance.
* CONTRACT: v13 adds exercise metadata and the five frozen creator IDs.
* INVARIANT: only approved entry/exercise/equipment triples are repointed;
* sessions, entry IDs, child facts, targets and the durable draft survive.
*/
private fun migrateMachineExercisesToVersionThirteen(db: SQLiteDatabase) {
val columns = mutableSetOf<String>()
db.rawQuery("PRAGMA table_info(exercises);", null).use { cursor ->
val nameIndex = cursor.getColumnIndexOrThrow("name")
while (cursor.moveToNext()) columns += cursor.getString(nameIndex)
}
fun add(name: String, declaration: String) {
if (name !in columns) db.execSQL("ALTER TABLE exercises ADD COLUMN $declaration;")
}
add("load_semantics", "load_semantics TEXT CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio'))")
add("machine_variant", "machine_variant TEXT")
add("machine_provenance", "machine_provenance TEXT")
add("scientific_profile_id", "scientific_profile_id TEXT")
add("science_state", "science_state TEXT NOT NULL DEFAULT 'unresolved' CHECK(science_state IN ('resolved','unresolved'))")
add("legacy_equipment_id", "legacy_equipment_id TEXT")
seedMachineExercisesV13(db)
}
private data class MachineSeed(
val exerciseId: String,
val oldName: String?,
val name: String,
val recording: String,
val tracking: String,
val fields: Int,
val load: String?,
val variant: String?,
val profile: String?,
val legacyEquipment: String?,
)
private fun seedMachineExercisesV13(db: SQLiteDatabase) {
fun normalized(value: String): String = Normalizer.normalize(
value.trim().lowercase(Locale.ROOT), Normalizer.Form.NFD,
).replace(Regex("\\p{Mn}+"), "")
val rows = listOf(
MachineSeed("ex_7e7cf906-2214-4066-bcb7-c16382d83b3b", "Abduction de hanche assise", "Hip Abduction", "sets", "reps", 0, "external", "selectorized", "sp_hip_abduction_v1", "hip_abduction"),
MachineSeed("ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", "Extension de genou assise", "Leg Extension", "sets", "reps", 0, "external", "selectorized", "sp_leg_extension_v1", "leg_extension"),
MachineSeed("ex_474ec393-3efa-4aaa-8e08-1a0245ed7835", "Extension du tronc", "Back Extension", "sets", "reps", 0, "external", "selectorized", "sp_back_extension_v1", "back_extension"),
MachineSeed("ex_ec619fc2-4685-4044-873c-86764bd4a0fe", "Flexion de coude à la machine", "Arm Curl", "sets", "reps", 0, "external", "selectorized", "sp_arm_curl_v1", "arm_curl"),
MachineSeed("ex_d7398d9f-d928-4d2e-94e9-74e201da55c5", "Flexion de genou couchée", "Prone Leg Curl", "sets", "reps", 0, "external", "selectorized", "sp_prone_leg_curl_v1", "prone_leg_curl"),
MachineSeed("ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0", "Rotation du tronc à la machine", "Rotary Torso", "sets", "reps", 0, "external", "selectorized", "sp_rotary_torso_v1", "rotary_torso"),
MachineSeed("ex_33f79331-871c-4eed-babe-346e53a99070", "Tirage horizontal assis", "Seated Row", "sets", "reps", 0, "external", "selectorized", "sp_seated_row_v1", "seated_row"),
MachineSeed("ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", "Tirage horizontal divergent assis", "Diverging Seated Row", "sets", "reps", 0, "external", "diverging", "sp_seated_row_v1", "diverging_seated_row"),
MachineSeed("ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6", "Tirage vertical divergent", "Diverging Lat Pulldown", "sets", "reps", 0, "external", "diverging", "sp_vertical_pull_v1", "diverging_lat_pulldown"),
MachineSeed("ex_a72fa713-4b0e-431d-95e2-42d95beb77b1", "Tirage vertical à la poulie", "Lat Pull", "sets", "reps", 0, "external", "selectorized", "sp_vertical_pull_v1", "lat_pull"),
MachineSeed("ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", "Développé épaules convergent", "Converging Shoulder Press", "sets", "reps", 0, "external", "converging", "sp_shoulder_press_v1", "converging_shoulder_press"),
MachineSeed("ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", "Flexion de genou assise", "Seated Leg Curl", "sets", "reps", 0, "external", "selectorized", "sp_seated_leg_curl_v1", "seated_leg_curl"),
MachineSeed("ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "Presse à cuisses", "Leg Press", "sets", "reps", 0, "external", "selectorized", "sp_leg_press_v1", "leg_press"),
MachineSeed("ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", "Écarté inversé à la machine", "Rear Delt", "sets", "reps", 0, "external", "rear_delt", "sp_rear_delt_v1", "rear_delt_pec_fly"),
MachineSeed("ex_0e26c06f-a458-40a4-be20-4ed219ede30d", null, "Plate Loaded Leg Press", "sets", "reps", 0, "external", "plate_loaded", "sp_leg_press_v1", "plate_loaded_leg_press"),
MachineSeed("ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2", null, "Treadmill", "continuous", "duration", 3, "cardio", "treadmill", null, "treadmill"),
MachineSeed("ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", null, "Pec Fly", "sets", "reps", 0, "external", "pec_fly", "sp_pec_fly_v1", "rear_delt_pec_fly"),
MachineSeed("ex_58b8dfbc-92b2-4783-a449-9947a42480b8", null, "Chin Assist", "sets", "reps", 0, "assistance", "assisted", "sp_assisted_chin_v1", "assisted_dip_chin_machine"),
MachineSeed("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", null, "Dip Assist", "sets", "reps", 0, "assistance", "assisted", "sp_assisted_dip_v1", "assisted_dip_chin_machine"),
)
rows.forEach { item ->
val existing = exerciseIdentityRow(db, item.exerciseId)
if (existing == null) {
db.execSQL(
"INSERT INTO exercises(exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields,load_semantics,machine_variant,scientific_profile_id,science_state,legacy_equipment_id) VALUES(?,?,?,?,?,?,?,?,?,?,?);",
arrayOf<Any?>(item.exerciseId, item.name, normalized(item.name), item.recording, item.tracking, item.fields, item.load, item.variant, item.profile, if (item.profile == null) "unresolved" else "resolved", item.legacyEquipment),
)
} else if (item.oldName != null) {
db.execSQL(
"UPDATE exercises SET name=?,normalized_name=?,load_semantics=?,machine_variant=?,scientific_profile_id=?,science_state='resolved',legacy_equipment_id=? WHERE exercise_id=? AND name=?;",
arrayOf<Any?>(item.name, normalized(item.name), item.load, item.variant, item.profile, item.legacyEquipment, item.exerciseId, item.oldName),
)
}
}
val hasBodyZoneTables = db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('exercise_body_zones','exercise_body_zone_sync');",
null,
).use { cursor -> cursor.moveToFirst() && cursor.getInt(0) == 2 }
/* CONTRACT: BODY FOCUS consumes the existing exercise-owned relation.
* New performance identities reuse reviewed profile mappings without
* grouping their load histories. */
if (hasBodyZoneTables) {
db.execSQL("INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT n.id,z.zone_id,z.role FROM exercises n JOIN exercises o ON o.exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' JOIN exercise_body_zones z ON z.exercise_row_id=o.id WHERE n.exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';")
listOf(
Triple("ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "chest", "primary"),
Triple("ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "shoulders", "secondary"),
Triple("ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "back", "primary"),
Triple("ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "arms", "secondary"),
Triple("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "arms", "primary"),
Triple("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "chest", "secondary"),
Triple("ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "shoulders", "secondary"),
).forEach { (exerciseId, zoneId, role) -> db.execSQL(
"INSERT OR IGNORE INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,?,? FROM exercises WHERE exercise_id=?;",
arrayOf(zoneId, role, exerciseId),
) }
listOf(
"ex_0e26c06f-a458-40a4-be20-4ed219ede30d" to "thighs|glutes",
"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee" to "chest|shoulders",
"ex_58b8dfbc-92b2-4783-a449-9947a42480b8" to "back|arms",
"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf" to "arms|chest,shoulders",
).forEach { (exerciseId, state) -> db.execSQL(
"INSERT OR REPLACE INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,? FROM exercises WHERE exercise_id=?;",
arrayOf(state, exerciseId),
) }
}
db.execSQL("UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id='ex_617007f9-7420-4408-91b9-8ffb77900f13';")
fun equipmentRow(id: String) = "(SELECT id FROM equipment WHERE equipment_id='$id')"
db.execSQL("UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d') WHERE entry_id='sxe_draft_legacy_7' AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde') AND equipment_row_id=${equipmentRow("plate_loaded_leg_press")};")
val treadmillEntries = listOf("sxe_093c1331-beaa-4b69-91b3-240292709be6", "sxe_f25142c8-455e-4346-9bfc-31d0989e275d", "sxe_f2242691-ba6c-48a0-b938-a1f744375d75", "sxe_9f8882f4-069e-49d5-8216-19d16b467e4a")
treadmillEntries.forEach { entry -> db.execSQL("UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2') WHERE entry_id=? AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d') AND equipment_row_id=${equipmentRow("treadmill")};", arrayOf(entry)) }
}
private fun createSessionTables(
db: SQLiteDatabase,
) {

View file

@ -71,7 +71,9 @@ fun AndroidAppShell(
drawerContainerColor = colors.mantle,
) {
Text("TRAINLOG", modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp))
AppSection.entries.forEach { section ->
/* Equipment remains a compatibility store in schema v13, but
* it is no longer a normal product destination. */
AppSection.entries.filterNot { it == AppSection.EQUIPMENT }.forEach { section ->
NavigationDrawerItem(
icon = { Icon(painterResource(drawerIcons.getValue(section)), contentDescription = null) },
label = { Text(section.label) },

View file

@ -608,13 +608,17 @@ private fun KnowledgePanel(repository: TrainlogRepository, knowledge: ExerciseKn
val patterns = resolved.patternIds.mapNotNull(repository::getMovementPatternKnowledge)
val primary = resolved.primaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val secondary = resolved.secondaryMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val stabilizers = resolved.stabilizerMuscleIds.mapNotNull(repository::getMuscleKnowledge)
val actions = resolved.actionIds.mapNotNull(repository::getJointActionKnowledge)
val primaryZone = repository.bodyZone(resolved.primaryZoneId)?.displayName ?: resolved.primaryZoneId
val secondaryZones = resolved.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é" }}")
val equipment = repository.listEquipmentForKnownExercise(knowledge.exerciseId)
.map { runtimeEquipment[it.equipmentId]?.displayName ?: it.equipmentId }
TrainlogInfo("Mouvements : ${actions.joinToString { it.displayNameFr }.ifEmpty { patterns.joinToString { it.displayNameFr }.ifEmpty { "Non classés" } }}")
TrainlogInfo("Muscles principaux : ${primary.joinToString { it.displayNameFr }.ifEmpty { "Non classés" }}")
TrainlogInfo("Muscles secondaires : ${secondary.joinToString { it.displayNameFr }.ifEmpty { "Aucun établi" }}")
TrainlogInfo("Stabilisateurs : ${stabilizers.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(resolved.confidence)}", colors.muted)
@ -695,7 +699,8 @@ fun ExerciseDetailScreen(
TrainlogFrame("Équipements compatibles", active = context.compatibleEquipment.isNotEmpty()) {
if (context.compatibleEquipment.isEmpty()) TrainlogInfo("Compatibilité non établie.")
context.compatibleEquipment.forEach { equipment ->
val name = listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
val name = repository.listEquipment().firstOrNull { it.equipmentId == equipment.equipmentId }?.displayName
?: listOfNotNull(equipment.manufacturer, equipment.model).joinToString(" ").ifBlank { equipment.equipmentId }
TrainlogInfo(name)
}
}

View file

@ -152,8 +152,8 @@ internal fun BodyZoneHomeSection(
style = TrainlogTypography.small.copy(color = colors.text),
)
BasicText(
exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j · " +
exposureCountLabel(zone.secondaryWork7Days, "secondaire") + " sur 7 j",
recentWorkLabel(zone.primaryWork7Days, "principal") + " · " +
recentWorkLabel(zone.secondaryWork7Days, "secondaire"),
style = TrainlogTypography.small.copy(color = colors.text),
)
BasicText(
@ -482,7 +482,7 @@ private fun stateColor(state: BodyZoneHomeState, colors: TrainlogColors): Color
internal fun recommendationReason(zone: BodyZoneHomeStatus): String = when {
zone.lastPrimaryExposure == null -> "Aucune exposition principale enregistrée"
zone.primaryWork7Days == 0 -> "Dernier travail principal : ${ageLabel(zone.primaryExposureAgeSeconds)}"
else -> exposureCountLabel(zone.primaryWork7Days, "principale") + " sur 7 j"
else -> recentWorkLabel(zone.primaryWork7Days, "principal")
}
internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
@ -492,9 +492,8 @@ internal fun homeStateLabel(zone: BodyZoneHomeStatus): String =
zone.state.label
}
internal fun exposureCountLabel(count: Int, adjective: String): String =
"$count ${if (count == 1) "exposition" else "expositions"} " +
if (count == 1) adjective else "${adjective}s"
internal fun recentWorkLabel(count: Int, kind: String): String =
"Travail $kind sur 7 j : $count"
private fun availableExerciseLabel(count: Int): String =
"$count ${if (count == 1) "exercice disponible" else "exercices disponibles"}"

View file

@ -11,6 +11,7 @@ import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.CatalogInboxResult
import com.labfytools.trainlog.data.EquipmentCatalogEntry
import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.data.KnowledgeConfidence
import com.labfytools.trainlog.data.SyncCatalogInbox
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft
@ -88,9 +89,10 @@ fun EquipmentScreen(
}
@Composable
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String) {
fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, onOpenExercise: (String) -> Unit) {
val colors = LocalTrainlogColors.current
val entry = remember(equipmentId) { repository.listEquipment().firstOrNull { it.equipmentId == equipmentId } }
val options = remember(equipmentId) { repository.listEquipmentExerciseOptions(equipmentId) }
TrainlogScreen("Fiche équipement", scrollKey = "equipment-detail:$equipmentId") {
if (entry == null) {
TrainlogInfo("Référence inconnue · $equipmentId", colors.warning)
@ -104,6 +106,19 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String) {
TrainlogInfo("Charge : ${equipmentSemanticsLabel(entry.loadSemantics)}")
if (entry.aliases.isNotEmpty()) TrainlogInfo("Alias : ${entry.aliases.joinToString()}", colors.muted)
}
TrainlogFrame("Exercices possibles", active = options.isNotEmpty()) {
if (options.isEmpty()) TrainlogInfo("Aucune association vérifiée. L’anatomie n’est pas déduite du nom de la machine.", colors.muted)
options.forEach { (relation, exercise) ->
val confidence = when (relation.confidence) {
KnowledgeConfidence.HIGH -> "élevée"
KnowledgeConfidence.MODERATE -> "modérée"
KnowledgeConfidence.UNCERTAIN -> "incertaine"
}
TrainlogAction(exercise.exerciseName,
listOfNotNull(relation.configurationLabel, "Confiance $confidence", "Sources vérifiées : ${relation.sourceRefs.size}").joinToString(" · "),
{ onOpenExercise(exercise.exerciseId) })
}
}
}
}

View file

@ -841,10 +841,13 @@ private fun SessionExerciseForm(
var equipmentRevision by remember(key) { mutableStateOf(0) }
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
val fixedEquipmentId = remember(exercise.exerciseId) {
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
}
var equipmentSearch by remember(key) { mutableStateOf("") }
var customEquipmentName by remember(key) { mutableStateOf("") }
var selectedEquipmentId by remember(key) {
mutableStateOf(initialForm.selectedEquipmentId)
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
}
var targetChoice by remember(key) {
mutableStateOf(if (initialPlan?.weightKg != null) ManualTargetChoice.KG
@ -871,34 +874,38 @@ private fun SessionExerciseForm(
color = colors.accent,
)
TrainlogInputField(
label = "Machine / équipement (optionnel)",
value = equipmentSearch,
onValueChange = { equipmentSearch = it },
)
TrainlogInputField(
label = "Nouvelle machine",
value = customEquipmentName,
onValueChange = { customEquipmentName = it },
)
TrainlogAction(
label = "Créer la machine",
description = "L'ajouter à votre catalogue puis la sélectionner pour cette entrée.",
accent = colors.success,
onClick = {
when (val result = repository.createCustomEquipment(customEquipmentName)) {
is CreateEquipmentResult.Created -> {
customEquipmentName = ""
equipmentRevision += 1
selectedEquipmentId = result.equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
if (fixedEquipmentId == null) {
/* Legacy/custom/unresolved rows retain the compatibility selector;
* resolved fixed machines never require a second user choice. */
TrainlogInputField(
label = "Contexte historique (optionnel)",
value = equipmentSearch,
onValueChange = { equipmentSearch = it },
)
TrainlogInputField(
label = "Nouveau contexte historique",
value = customEquipmentName,
onValueChange = { customEquipmentName = it },
)
TrainlogAction(
label = "Créer le contexte",
description = "Compatibilité temporaire pour un exercice custom ou non résolu.",
accent = colors.success,
onClick = {
when (val result = repository.createCustomEquipment(customEquipmentName)) {
is CreateEquipmentResult.Created -> {
customEquipmentName = ""
equipmentRevision += 1
selectedEquipmentId = result.equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
}
CreateEquipmentResult.Invalid -> error = "Donnez un nom de contexte valide."
CreateEquipmentResult.Conflict -> error = "Ce contexte existe déjà."
is CreateEquipmentResult.DatabaseError -> error = "Contexte non créé : ${result.message}"
}
CreateEquipmentResult.Invalid -> error = "Donnez un nom de machine valide."
CreateEquipmentResult.Conflict -> error = "Cette machine existe déjà."
is CreateEquipmentResult.DatabaseError -> error = "Machine non créée : ${result.message}"
}
},
)
},
)
}
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
val percentResult = remember(
exercise.exerciseId, selectedEquipmentId, targetPercentText,
@ -911,7 +918,7 @@ private fun SessionExerciseForm(
)
else null
}
if (selectedEquipment != null) {
if (selectedEquipment != null && fixedEquipmentId == null) {
TrainlogAction(
label = "✓ ${selectedEquipment.displayName}",
description = selectedEquipment.labelName.ifBlank { "Équipement sélectionné." },
@ -922,7 +929,7 @@ private fun SessionExerciseForm(
},
)
}
repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
TrainlogAction(
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
description = equipment.labelName.ifBlank { equipment.type },

View file

@ -105,7 +105,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
is AppRoute.ExerciseCreate -> ExerciseEditorRoute(repository, exerciseState, null, route.caller == AppRoute.SessionEditor) { exporter.exportMobileBundle(); catalogRevision++; back() }
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exporter.exportMobileBundle(); catalogRevision++; back() }
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId)
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exporter.exportMobileBundle(); catalogRevision++; back() }
AppRoute.Statistics -> StatisticsDashboard(repository)
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exporter.exportMobileBundle() }, { back() })

View file

@ -61,7 +61,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(12, scalar(db, "PRAGMA user_version"))
assertEquals(13, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -64,8 +64,12 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(12, scalar(db, "PRAGMA user_version"))
assertEquals(before, snapshot(db).filterKeys { it != "exercise_aliases" })
assertEquals(13, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it != "exercise_aliases" }
before.filterKeys { it !in setOf("exercises", "exercise_body_zones", "exercise_body_zone_sync") }
.forEach { (table, rows) -> assertEquals(rows, after[table]) }
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM exercise_aliases"))
db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) }
}

View file

@ -255,7 +255,7 @@ class BodyZonesTest {
db.execSQL("DROP TABLE exercise_body_zone_sync;")
db.execSQL("DROP TABLE exercise_body_zones;")
db.execSQL(
"INSERT INTO exercises VALUES(42,'ex_9adf7566-f10c-443c-b63b-681d37665693'," +
"INSERT INTO exercises(id,exercise_id,name,normalized_name,recording_mode,tracking_mode,data_fields) VALUES(42,'ex_9adf7566-f10c-443c-b63b-681d37665693'," +
"'Abdominal crunch','abdominal crunch','sets','reps',0);",
)
val equipmentRow = db.rawQuery(
@ -307,7 +307,9 @@ class BodyZonesTest {
db.execSQL("PRAGMA user_version=9;")
}
val repository = TrainlogRepository(context, name)
val exercise = repository.listExercises().single()
val exercise = repository.listExercises().single {
it.exerciseId == "ex_9adf7566-f10c-443c-b63b-681d37665693"
}
assertEquals("core", exercise.primaryZoneId)
val training = repository.getSessionDetail("se_zone_training")!!.exercises.single()
assertEquals("sxe_zone_training", training.entryId)
@ -321,7 +323,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(12, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(13, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -0,0 +1,129 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
/**
* Optional real-copy v12 -> v13 production migration harness.
*
* WHY: synthetic fixtures cannot demonstrate that the production SQLiteOpenHelper
* accepts the user's complete database graph and preserves its durable draft.
* CONTRACT: the immutable source is copied before TrainlogRepository sees it;
* only the test-owned database is opened read/write, and the migrated artifact is
* exported to the explicit TRAINLOG_ANDROID_V13_OUTPUT path for host-side audit.
* INVARIANT: a second production open is a logical no-op at schema v13.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class RealAndroidV12MachineExerciseMigrationTest {
private lateinit var context: Context
private val databaseName = "machine-exercise-real-v12.db"
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
}
@After
fun tearDown() {
context.deleteDatabase(databaseName)
}
@Test
fun realVersionTwelveCopyUsesProductionMigrationAndIsIdempotent() {
val source = File(System.getenv("TRAINLOG_ANDROID_V12_FIXTURE") ?: "")
val output = File(System.getenv("TRAINLOG_ANDROID_V13_OUTPUT") ?: "")
assumeTrue("TRAINLOG_ANDROID_V12_FIXTURE is not available", source.isFile)
assumeTrue("TRAINLOG_ANDROID_V13_OUTPUT is not available", output.parentFile?.isDirectory == true)
val databasePath = context.getDatabasePath(databaseName)
databasePath.parentFile?.mkdirs()
Files.copy(source.toPath(), databasePath.toPath(), StandardCopyOption.REPLACE_EXISTING)
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { before ->
assertEquals(12, scalarInt(before, "PRAGMA user_version;"))
assertEquals("ok", scalarString(before, "PRAGMA integrity_check;"))
before.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
}
TrainlogRepository(context, databaseName).let { repository ->
try {
assertTrue(repository.listExercises().isNotEmpty())
} finally {
repository.close()
}
}
assertValidVersionThirteen(databasePath)
val firstLogicalState = logicalDump(databasePath)
TrainlogRepository(context, databaseName).let { repository ->
try {
assertTrue(repository.listExercises().isNotEmpty())
} finally {
repository.close()
}
}
assertValidVersionThirteen(databasePath)
assertEquals(firstLogicalState, logicalDump(databasePath))
Files.copy(databasePath.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
private fun assertValidVersionThirteen(databasePath: File) {
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
assertEquals(13, scalarInt(database, "PRAGMA user_version;"))
assertEquals("ok", scalarString(database, "PRAGMA integrity_check;"))
database.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) }
}
}
private fun logicalDump(databasePath: File): List<String> =
SQLiteDatabase.openDatabase(databasePath.path, null, SQLiteDatabase.OPEN_READONLY).use { database ->
val tables = database.rawQuery(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;",
null,
).use { cursor -> buildList { while (cursor.moveToNext()) add(cursor.getString(0)) } }
buildList {
tables.forEach { table ->
/* Table names originate from sqlite_master, not external input. */
database.rawQuery("SELECT * FROM `$table` ORDER BY rowid;", null).use { cursor ->
while (cursor.moveToNext()) {
add(buildString {
append(table)
repeat(cursor.columnCount) { column ->
append('|')
append(if (cursor.isNull(column)) "<NULL>" else cursor.getString(column))
}
})
}
}
}
}
}
private fun scalarInt(database: SQLiteDatabase, sql: String): Int =
database.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getInt(0)
}
private fun scalarString(database: SQLiteDatabase, sql: String): String =
database.rawQuery(sql, null).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
}
}

View file

@ -58,6 +58,23 @@ class TrainingExerciseContextTest {
assertNull(repository.getTrainingExerciseContext("ex_00000000-0000-4000-8000-000000000000"))
}
@Test
fun retiredAliasResolvesToCanonicalV2EquipmentContextExactlyOnce() {
seedFixture()
SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { db ->
db.execSQL(
"INSERT INTO exercise_aliases(source_exercise_id,canonical_exercise_id) VALUES(?,?);",
arrayOf(RETIRED_LEG_PRESS_ID, LEG_PRESS_ID),
)
}
val context = repository.getTrainingExerciseContext(RETIRED_LEG_PRESS_ID)!!
assertEquals(LEG_PRESS_ID, context.exercise.exerciseId)
assertEquals(LEG_PRESS_ID, context.knowledge!!.exerciseId)
assertEquals(1, context.compatibleEquipment.count { it.equipmentId == "leg_press" })
}
@Test
fun continuousOccurrenceHasNoArtificialSetsAndInvalidPagingFails() {
seedFixture()
@ -202,5 +219,6 @@ class TrainingExerciseContextTest {
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"
private const val RETIRED_LEG_PRESS_ID = "ex_00000000-0000-4000-8000-000000000099"
}
}

View file

@ -20,6 +20,7 @@ class TrainingKnowledgeCatalogTest {
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",
"equipment-exercise-relations-v2.json",
)
@Test
@ -38,6 +39,15 @@ class TrainingKnowledgeCatalogTest {
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" })
assertEquals(listOf("ex_4cd2433e-80b1-478a-b8df-73fc6ef80962"),
catalog.listExercisesForEquipment("rear_delt_pec_fly").map { it.exerciseId })
assertTrue(catalog.listExercisesForEquipment("functional_trainer").isEmpty())
assertTrue(catalog.listExercisesForEquipment("assisted_dip_chin_machine").isEmpty())
assertFalse(catalog.listExercisesForEquipment("seated_leg_curl")
.any { it.exerciseId == "ex_617007f9-7420-4408-91b9-8ffb77900f13" })
assertEquals(listOf("leg_press", "plate_loaded_leg_press"),
catalog.listEquipmentForExercise(legPress.exerciseId).map { it.equipmentId })
assertFalse(catalog.listEquipmentForBodyZone("chest").any { it.equipmentId == "rear_delt_pec_fly" })
assertNotNull(catalog.getMuscle("deltoid_posterior"))
assertNotNull(catalog.getJointAction("shoulder_horizontal_abduction"))
assertNotNull(catalog.getReference(legPress.sourceRefs.first()))

View file

@ -618,7 +618,7 @@ class TrainlogRepositoryDraftTest {
repo.applyPcCatalogJson(stalePcCatalog.toString()),
)
val byId = repo.listExercises().associateBy { it.exerciseId }
assertEquals("Flexion de genou assise", byId.getValue(mappedId).name)
assertEquals("Seated Leg Curl", byId.getValue(mappedId).name)
assertEquals("Seated Leg", byId.getValue(distinctId).name)
assertEquals("My custom curl", byId.getValue(customId).name)
@ -628,7 +628,7 @@ class TrainlogRepositoryDraftTest {
repo.applyPcCatalogJson(stalePcCatalog.toString()),
)
assertEquals(
"Flexion de genou assise",
"Seated Leg Curl",
repo.listExercises().single { it.exerciseId == mappedId }.name,
)
}
@ -1651,7 +1651,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(12, cursor.getInt(0))
assertEquals(13, cursor.getInt(0))
}
db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -1765,7 +1765,7 @@ class TrainlogRepositoryDraftTest {
createVersionFourFixture(context.getDatabasePath(databaseName).path)
val repo = openRepository()
assertEquals(1, repo.listExercises().size)
assertEquals(20, repo.listExercises().size)
assertEquals(1, repo.listSessions().size)
assertEquals(1, repo.listBodyObservations().size)
val loadedDraft = loadDraft(repo)
@ -1778,7 +1778,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(12, cursor.getInt(0))
assertEquals(13, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -35,11 +35,11 @@ class BodyZoneHomePresentationTest {
}
}
@Test fun exposureLabelsUseNaturalFrenchSingularAndPlural() {
assertEquals("1 exposition principale", exposureCountLabel(1, "principale"))
assertEquals("2 expositions principales", exposureCountLabel(2, "principale"))
assertEquals("1 exposition secondaire", exposureCountLabel(1, "secondaire"))
assertEquals("3 expositions secondaires", exposureCountLabel(3, "secondaire"))
@Test fun recentWorkLabelsAreNeutralFactualCounts() {
assertEquals("Travail principal sur 7 j : 1", recentWorkLabel(1, "principal"))
assertEquals("Travail principal sur 7 j : 2", recentWorkLabel(2, "principal"))
assertEquals("Travail secondaire sur 7 j : 1", recentWorkLabel(1, "secondaire"))
assertEquals("Travail secondaire sur 7 j : 3", recentWorkLabel(3, "secondaire"))
}
@Test fun recentPrioritizedExposureIsPresentedAsRelativePriority() {
@ -61,7 +61,8 @@ class BodyZoneHomePresentationTest {
)
assertEquals("Priorité relative", homeStateLabel(status))
assertEquals("2 expositions principales sur 7 j", recommendationReason(status))
assertEquals("Travail principal sur 7 j : 2", recommendationReason(status))
assertFalse(recommendationReason(status).contains("exposition"))
assertFalse(recommendationReason(status).contains("fait(s)"))
assertFalse(recommendationReason(status).contains("pondéré"))
}

View file

@ -73,8 +73,8 @@ class BodyZoneHomeSemanticsTest {
compose.onNodeWithTag("body-map-front", useUnmergedTree = true).onChildren().assertCountEquals(0)
compose.onNodeWithTag("body-map-back", useUnmergedTree = true).onChildren().assertCountEquals(0)
compose.onNodeWithText("1. Pectoraux · Priorité relative").assertExists()
compose.onNodeWithText("1 exposition principale sur 7 j").assertExists()
listOf("fait(s) primaire(s)", "fait(s) secondaire(s)", "secondaire pondéré plus faiblement").forEach {
compose.onNodeWithText("Travail principal sur 7 j : 1").assertExists()
listOf("exposition principale sur 7 j", "exposition secondaire sur 7 j", "fait(s) primaire(s)", "fait(s) secondaire(s)", "secondaire pondéré plus faiblement").forEach {
compose.onAllNodesWithText(it, substring = true).assertCountEquals(0)
}
}

View file

@ -0,0 +1,23 @@
{
"format": "trainlog-equipment-exercise-relations-v2",
"version": 2,
"equipment_relations": [
{"equipment_id":"abdominal","exercise_options":[{"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_abdominal_wall","openstax_trunk"]}]},
{"equipment_id":"arm_curl","exercise_options":[{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["openstax_upper"]}]},
{"equipment_id":"back_extension","exercise_options":[{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["lee_2015","openstax_back","openstax_lower"]}]},
{"equipment_id":"converging_shoulder_press","exercise_options":[{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_deltoid","openstax_upper"]}]},
{"equipment_id":"diverging_lat_pulldown","exercise_options":[{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["lehman_2004","openstax_upper","vigotsky_2018"]}]},
{"equipment_id":"diverging_seated_row","exercise_options":[{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["franke_2015","lehman_2004","openstax_upper"]}]},
{"equipment_id":"hip_abduction","exercise_options":[{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_gluteus_minimus","openstax_lower"]}]},
{"equipment_id":"hip_adduction","exercise_options":[{"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["openstax_lower"]}]},
{"equipment_id":"lat_pull","exercise_options":[{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["lehman_2004","openstax_upper","vigotsky_2018"]}]},
{"equipment_id":"leg_extension","exercise_options":[{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["openstax_lower"]}]},
{"equipment_id":"leg_press","exercise_options":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}]},
{"equipment_id":"plate_loaded_leg_press","exercise_options":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}]},
{"equipment_id":"prone_leg_curl","exercise_options":[{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["maeo_2021","openstax_lower"]}]},
{"equipment_id":"rear_delt_pec_fly","exercise_options":[{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","relation_type":"supported_exercise","configuration_label":"Oiseaux / Rear Delt","confidence":"moderate","source_refs":["franke_2015","life_fitness_catalog_2024","nih_deltoid","openstax_upper","vigotsky_2018"]}]},
{"equipment_id":"rotary_torso","exercise_options":[{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["nih_abdominal_wall","openstax_back","openstax_trunk"]}]},
{"equipment_id":"seated_leg_curl","exercise_options":[{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","relation_type":"supported_exercise","configuration_label":null,"confidence":"high","source_refs":["maeo_2021","openstax_lower"]}]},
{"equipment_id":"seated_row","exercise_options":[{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","relation_type":"supported_exercise","configuration_label":null,"confidence":"moderate","source_refs":["franke_2015","lehman_2004","openstax_upper"]}]}
]
}

View file

@ -3,20 +3,20 @@
"version": 1,
"names": [
{"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","display_name":"Adduction de hanche assise","normalized_name":"adduction de hanche assise","previous_display_names":["Hip adduction"]},
{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Extension de genou assise","normalized_name":"extension de genou assise","previous_display_names":["Leg extension"]},
{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotation du tronc à la machine","normalized_name":"rotation du tronc à la machine","previous_display_names":["Rotary torso"]},
{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Tirage horizontal divergent assis","normalized_name":"tirage horizontal divergent assis","previous_display_names":["Diverged seated row"]},
{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","display_name":"Tirage horizontal assis","normalized_name":"tirage horizontal assis","previous_display_names":["Seated row"]},
{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Extension du tronc","normalized_name":"extension du tronc","previous_display_names":["Back extension"]},
{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Écarté inversé à la machine","normalized_name":"écarté inversé à la machine","previous_display_names":["Rear Delt"]},
{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Développé épaules convergent","normalized_name":"développé épaules convergent","previous_display_names":["Converging Shoulder Press"]},
{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Abduction de hanche assise","normalized_name":"abduction de hanche assise","previous_display_names":["Hip abduction"]},
{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Leg Extension","normalized_name":"leg extension","previous_display_names":["Extension de genou assise"]},
{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotary Torso","normalized_name":"rotary torso","previous_display_names":["Rotation du tronc à la machine"]},
{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Diverging Seated Row","normalized_name":"diverging seated row","previous_display_names":["Diverged seated row","Tirage horizontal divergent assis"]},
{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","display_name":"Seated Row","normalized_name":"seated row","previous_display_names":["Tirage horizontal assis"]},
{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Back Extension","normalized_name":"back extension","previous_display_names":["Extension du tronc"]},
{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Rear Delt","normalized_name":"rear delt","previous_display_names":["Écarté inversé à la machine"]},
{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Converging Shoulder Press","normalized_name":"converging shoulder press","previous_display_names":["Développé épaules convergent"]},
{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Hip Abduction","normalized_name":"hip abduction","previous_display_names":["Abduction de hanche assise"]},
{"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","display_name":"Crunch à la machine","normalized_name":"crunch à la machine","previous_display_names":["Abdominal crunch"]},
{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Flexion de genou assise","normalized_name":"flexion de genou assise","previous_display_names":["Seated leg curl"]},
{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Tirage vertical à la poulie","normalized_name":"tirage vertical à la poulie","previous_display_names":["Lat pull"]},
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Presse à cuisses","normalized_name":"presse à cuisses","previous_display_names":["Leg press"]},
{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Tirage vertical divergent","normalized_name":"tirage vertical divergent","previous_display_names":["Diverging lat pulldown"]},
{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Flexion de genou couchée","normalized_name":"flexion de genou couchée","previous_display_names":["Prone leg curl"]},
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Flexion de coude à la machine","normalized_name":"flexion de coude à la machine","previous_display_names":["Arm curl"]}
{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Seated Leg Curl","normalized_name":"seated leg curl","previous_display_names":["Flexion de genou assise"]},
{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Lat Pull","normalized_name":"lat pull","previous_display_names":["Tirage vertical à la poulie"]},
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Leg Press","normalized_name":"leg press","previous_display_names":["Presse à cuisses"]},
{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Diverging Lat Pulldown","normalized_name":"diverging lat pulldown","previous_display_names":["Tirage vertical divergent"]},
{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Prone Leg Curl","normalized_name":"prone leg curl","previous_display_names":["Flexion de genou couchée"]},
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Arm Curl","normalized_name":"arm curl","previous_display_names":["Flexion de coude à la machine"]}
]
}

View file

@ -0,0 +1,26 @@
{
"format": "trainlog-machine-exercises",
"version": 1,
"exercises": [
{"exercise_id":"ex_0e26c06f-a458-40a4-be20-4ed219ede30d","display_name":"Plate Loaded Leg Press","load_semantics":"external","machine_variant":"plate_loaded","legacy_equipment_id":"plate_loaded_leg_press","scientific_profile_id":"sp_leg_press_v1","science_state":"resolved"},
{"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","display_name":"Leg Extension","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"leg_extension","scientific_profile_id":"sp_leg_extension_v1","science_state":"resolved"},
{"exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0","display_name":"Rotary Torso","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"rotary_torso","scientific_profile_id":"sp_rotary_torso_v1","science_state":"resolved"},
{"exercise_id":"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de","display_name":"Diverging Seated Row","load_semantics":"external","machine_variant":"diverging","legacy_equipment_id":"diverging_seated_row","scientific_profile_id":"sp_seated_row_v1","science_state":"resolved"},
{"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","display_name":"Seated Row","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"seated_row","scientific_profile_id":"sp_seated_row_v1","science_state":"resolved"},
{"exercise_id":"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf","display_name":"Dip Assist","load_semantics":"assistance","machine_variant":"assisted","legacy_equipment_id":"assisted_dip_chin_machine","scientific_profile_id":"sp_assisted_dip_v1","science_state":"resolved"},
{"exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835","display_name":"Back Extension","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"back_extension","scientific_profile_id":"sp_back_extension_v1","science_state":"resolved"},
{"exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962","display_name":"Rear Delt","load_semantics":"external","machine_variant":"rear_delt","legacy_equipment_id":"rear_delt_pec_fly","scientific_profile_id":"sp_rear_delt_v1","science_state":"resolved"},
{"exercise_id":"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee","display_name":"Pec Fly","load_semantics":"external","machine_variant":"pec_fly","legacy_equipment_id":"rear_delt_pec_fly","scientific_profile_id":"sp_pec_fly_v1","science_state":"resolved"},
{"exercise_id":"ex_58b8dfbc-92b2-4783-a449-9947a42480b8","display_name":"Chin Assist","load_semantics":"assistance","machine_variant":"assisted","legacy_equipment_id":"assisted_dip_chin_machine","scientific_profile_id":"sp_assisted_chin_v1","science_state":"resolved"},
{"exercise_id":"ex_617007f9-7420-4408-91b9-8ffb77900f13","display_name":"Seated Leg","load_semantics":null,"machine_variant":null,"legacy_equipment_id":null,"scientific_profile_id":null,"science_state":"unresolved"},
{"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","display_name":"Converging Shoulder Press","load_semantics":"external","machine_variant":"converging","legacy_equipment_id":"converging_shoulder_press","scientific_profile_id":"sp_shoulder_press_v1","science_state":"resolved"},
{"exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b","display_name":"Hip Abduction","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"hip_abduction","scientific_profile_id":"sp_hip_abduction_v1","science_state":"resolved"},
{"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","display_name":"Seated Leg Curl","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"seated_leg_curl","scientific_profile_id":"sp_seated_leg_curl_v1","science_state":"resolved"},
{"exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1","display_name":"Lat Pull","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"lat_pull","scientific_profile_id":"sp_vertical_pull_v1","science_state":"resolved"},
{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","display_name":"Leg Press","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"leg_press","scientific_profile_id":"sp_leg_press_v1","science_state":"resolved"},
{"exercise_id":"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6","display_name":"Diverging Lat Pulldown","load_semantics":"external","machine_variant":"diverging","legacy_equipment_id":"diverging_lat_pulldown","scientific_profile_id":"sp_vertical_pull_v1","science_state":"resolved"},
{"exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5","display_name":"Prone Leg Curl","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"prone_leg_curl","scientific_profile_id":"sp_prone_leg_curl_v1","science_state":"resolved"},
{"exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe","display_name":"Arm Curl","load_semantics":"external","machine_variant":"selectorized","legacy_equipment_id":"arm_curl","scientific_profile_id":"sp_arm_curl_v1","science_state":"resolved"},
{"exercise_id":"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2","display_name":"Treadmill","load_semantics":"cardio","machine_variant":"treadmill","legacy_equipment_id":"treadmill","scientific_profile_id":null,"science_state":"unresolved"}
]
}

View file

@ -0,0 +1,21 @@
{
"format": "trainlog-scientific-profiles",
"version": 1,
"profiles": [
{"scientific_profile_id":"sp_arm_curl_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_ec619fc2-4685-4044-873c-86764bd4a0fe"}},
{"scientific_profile_id":"sp_assisted_chin_v1","knowledge_source":{"kind":"equipment_capability","equipment_id":"assisted_dip_chin_machine","capability":"assisted chin pullup"}},
{"scientific_profile_id":"sp_assisted_dip_v1","knowledge_source":{"kind":"equipment_capability","equipment_id":"assisted_dip_chin_machine","capability":"assisted dip"}},
{"scientific_profile_id":"sp_back_extension_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835"}},
{"scientific_profile_id":"sp_hip_abduction_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b"}},
{"scientific_profile_id":"sp_leg_extension_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab"}},
{"scientific_profile_id":"sp_leg_press_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"}},
{"scientific_profile_id":"sp_pec_fly_v1","knowledge_source":{"kind":"equipment_capability","equipment_id":"rear_delt_pec_fly","capability":"pec fly"}},
{"scientific_profile_id":"sp_prone_leg_curl_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5"}},
{"scientific_profile_id":"sp_rear_delt_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962"}},
{"scientific_profile_id":"sp_rotary_torso_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0"}},
{"scientific_profile_id":"sp_seated_leg_curl_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163"}},
{"scientific_profile_id":"sp_seated_row_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070"}},
{"scientific_profile_id":"sp_shoulder_press_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b"}},
{"scientific_profile_id":"sp_vertical_pull_v1","knowledge_source":{"kind":"exercise","exercise_id":"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1"}}
]
}

View file

@ -26,7 +26,6 @@ never silently rewritten as V3.
Accueil
Séances
Exercices
Équipements
Statistiques
Synchronisation
Paramètres
@ -78,7 +77,8 @@ The section roots are:
Accueil durable-draft resume, manual capture, recent BODY ZONES exposure, concise local links
Séances current session, programme a session, manual entry, completed sessions
Exercices catalogue, detail, create, and contextual edit
Équipements catalogue, detail, and custom-equipment creation
Legacy equipment catalogue/detail routes remain compatibility-only and are
not exposed in the normal drawer.
Statistiques STATS_V1 dashboard, conservative graphs and detailed local drill-downs
Synchronisation existing request, result, and diagnostic workflow
Paramètres exchange-folder authorization and explicit PC-catalog refresh
@ -512,7 +512,7 @@ result.
Before applying the PC catalog or its V2 artifacts, Android applies
`trainlog-pc-equipment-definitions-v1.json`. Thus custom definitions are known
before a received V2 association references them. Android schema v12 retains
before a received V2 association references them. Android schema v13 retains
the non-destructive v7 -> v8 migration required for `load_semantics = none`.
It also applies `trainlog-exercise-aliases-v1.json` before catalog and session
reconciliation. The additive v11 → v12 alias table lets legacy exercise IDs
@ -668,7 +668,8 @@ frozen desktop/Python normalization contract uses NFC, Unicode whitespace
collapse and case folding without accent removal. Existing Marche/Leg press
data is unaffected, but changing this safely requires an explicit Android
schema migration that recomputes every normalized key and handles newly exposed
collisions. It is not silently changed inside schema v12.
collisions. Schema v13 changes only the additive machine-exercise metadata and
the approved exact-ID historical contexts.
The bundled exercise/equipment relationship metadata is seeded and preserved,
including during exercise-identity reconciliation, but the current equipment
@ -720,3 +721,7 @@ full source and uncertainty contract is in [Training knowledge system V1](domain
The normal shell exposes manual sessions but not SESSION_GENERATOR_V1 pending
V2. When importing the PC catalog, Android applies the versioned canonical
exercise-name mapping for mapped IDs; unmapped user exercises remain editable.
Equipment Detail reads the shared V2 relation asset, shows verified possible
exercises with French confidence/evidence status, and opens Exercise Detail.
Exercise Detail shows exercise-owned muscles, movements, BODY ZONES, and
compatible physical equipment. Custom equipment receives no inferred anatomy.

View file

@ -83,7 +83,7 @@ It consumes core services for:
- synchronization log/detail display.
APP_SHELL_V1 places these existing capabilities below seven platform-neutral
sections: Accueil, Séances, Exercices, Équipements, Statistiques,
sections: Accueil, Séances, Exercices, Statistiques,
Synchronisation and Paramètres. The controller owns route history, focus,
overlays and transient leave guards; rendering owns no persistence, SQLite,
transport, or synchronization decisions. Navigation and redraw alone never
@ -168,7 +168,7 @@ silently rendered as unclassified.
### Desktop
Desktop SQLite schema v12 is canonical long-term history. Its v9 -> v10
Desktop SQLite schema v13 is canonical long-term history. Its v9 -> v10
migration losslessly rebuilds only `performed_sets` so actual `weight_kg` may
be finite `>= 0`; the column already existed and targets/max results retain
their strictly-positive contracts. `session_exercises`
@ -203,7 +203,7 @@ exercise_body_zone_sync
### Android
Android has an independent local SQLite schema, currently v12. Completed and
Android has an independent local SQLite schema, currently v13. Completed and
draft MAX values use one-to-one `max_results` and `draft_max_results` rows;
resuming a completed Test max records its stable source session in the one
durable draft.
@ -537,14 +537,17 @@ reconciliation.
## 14. Training knowledge V1 boundary
`TRAINING_KNOWLEDGE_V1` is a read-only composition layer. Six versioned JSON
`TRAINING_KNOWLEDGE_V1` is a read-only composition layer. 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
stable-ID queries. `EQUIPMENT_KNOWLEDGE_V2` adds a separate immutable relation artifact and bounded
equipment/exercise/zone queries. Zone queries are strictly zone → resolved
exercise → equipment relation; equipment labels never infer anatomy.
`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.

View file

@ -81,7 +81,7 @@ Implemented:
F7 actions, and restored focus after overlays/routes);
- UTF-8 cell-aware scrolling training-knowledge screen, tested at the 72x20
minimum terminal;
- SQLite schema v12, with stable ordered `session_exercises.entry_id`,
- SQLite schema v13, with stable ordered `session_exercises.entry_id`,
occurrence-level equipment identity, and desktop-local custom-equipment
definitions, plus occurrence-owned `max_results`; its v9 -> v10 migration
rebuilds only `performed_sets` to permit explicit zero actual loads while
@ -111,7 +111,7 @@ Implemented:
Primary navigation is now shared with Android:
```text
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
Accueil | Séances | Exercices | Statistiques | Synchronisation | Paramètres
```
`Séances` contains the current draft, the existing generator, manual entry,
@ -181,14 +181,21 @@ top bar and content host keep navigation separate from page ownership.
## Training knowledge V1
The implemented read-only training-knowledge layer loads six versioned JSON
The implemented read-only training-knowledge layer loads shared 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 v12 and Android remains schema v12.
desktop and Android databases are schema v13. Schema v13 is additive: exercise
rows own machine-facing load semantics, optional legacy equipment provenance,
an optional scientific profile, and an explicit resolved/unresolved science
state. Legacy equipment tables and occurrence columns remain intact.
Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose
source-linked science lookups and resolved-candidate filters. Desktop
source-linked science lookups and resolved-candidate filters. The additive
`equipment-exercise-relations-v2.json` catalog is the canonical
equipment-to-exercise compatibility source. Equipment owns physical identity,
load semantics, configuration labels, confidence, and source references only;
exercise knowledge exclusively owns muscles, movements, and BODY ZONES. 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

View file

@ -1,5 +1,18 @@
# Desktop database
## Schema v13: machine-specific exercises
Migration v12→v13 is additive and transactional. It adds exercise-owned load
and provenance metadata, installs the five frozen UUIDv4 identities, applies
the approved same-ID display renames, and repoints only manifest-listed
`entry_id` + source exercise + source equipment triples. Session IDs, entry
IDs, positions, child set/MAX/continuous rows, notes, targets, timestamps,
aliases, and body observations are unchanged. Reopening v13 is idempotent.
Equipment tables and occurrence equipment columns remain in Phase 1 for sync,
durable drafts, old imports, and explicit historical provenance. Schema cleanup
is a later, separately versioned migration.
## 1. Status
```text
@ -569,7 +582,7 @@ Desktop schema v11 and Android schema v10 then add only body-zone relation and
sync-baseline tables. Both seed exact manifest mappings by stable exercise ID;
neither migration changes the occurrence/equipment/MAX graph described above.
Desktop and Android schema v12 add only the durable flattened
Desktop and Android schema v12 added only the durable flattened
`exercise_aliases` mapping. It resolves retired creator IDs to a live canonical
exercise during catalog and companion reconciliation without changing any
session, set, continuous, MAX, equipment, body-zone or planning wire shape.
@ -600,7 +613,7 @@ history.
## 14. Training knowledge read boundary
Training Knowledge V1 adds no table, migration, seed data or synchronization
artifact. The desktop database remains schema v12. Read-only context assembly
artifact. Desktop schema v13 retains it unchanged. 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

View file

@ -1,5 +1,24 @@
# Exercise data model
## Machine-specific performance identity (schema v13)
Normal selection is exercise-centric: a fixed machine/movement is represented
by one stable `exercise_id`. `Leg Press` and `Plate Loaded Leg Press`, or
`Rear Delt` and `Pec Fly`, therefore keep independent occurrences, MAX results,
and working-load series even when they share a frame or scientific profile.
The additive exercise metadata is `load_semantics` (`none`, `external`,
`assistance`, `bodyweight`, or `cardio`), nullable `machine_variant` and
`machine_provenance`, nullable `scientific_profile_id`, explicit
`science_state`, and nullable `legacy_equipment_id`. Null metadata is valid for
custom or unresolved exercises and must never be inferred from the name.
Occurrence `load_mode` remains a separate recorded fact.
Scientific profiles are versioned knowledge identities. They may be shared by
several exercises to reuse movement, joint-action, muscle, evidence, and BODY
ZONE knowledge. They are never a performance grouping key. Seated Leg and
Treadmill deliberately have no profile in V1.
## Status
```text
@ -356,3 +375,7 @@ independently verified; final validation passed.
Exercise names are mutable display metadata. The versioned canonical-name map
applies only to listed stable IDs and never makes two distinct exercises,
equipment contexts, MAX observations, or historical occurrences equivalent.
Equipment compatibility is an additive relation between a physical equipment
ID and an already-known canonical exercise UUID. It is not exercise identity,
anatomy, occurrence history, or MAX context. Generic and custom equipment stay
unassociated unless an explicit verified relation is representable.

View file

@ -0,0 +1,59 @@
# EQUIPMENT_KNOWLEDGE_V2 audit
Date: 2026-09-11. Scope: all 41 records in `equipment-knowledge-v1.json`, checked
against the supplied manifest, current UUID exercise knowledge, and the canonical
V2 relations. `S` means supplied; `C` means custom. Sources are existing catalog
references only. An empty option is intentional and never implies anatomy.
| Equipment (ID — name) | Origin / load | Classification | Canonical exercise options; confidence; evidence | Missing question |
|---|---|---|---|---|
| abdominal — Abdominal / Crunch | S / external | RESOLVED_SINGLE_EXERCISE | Abdominal crunch; moderate; `nih_abdominal_wall`, `openstax_trunk` | Local linkage/trajectory |
| abdominal_bench — Banc à abdominaux | S / bodyweight | GENERIC_MULTI_PURPOSE | none | Crunch or sit-up execution? |
| arm_curl — Curl biceps | S / external | RESOLVED_SINGLE_EXERCISE | Arm curl; high; `openstax_upper` | Grip/cam/setup |
| assisted_dip_chin_machine — Chin / Dip Assist | S / assistance | PARTIALLY_RESOLVED | none: assisted dip and assisted chin are distinct capabilities, but neither has a resolved UUID exercise record | Create/identify each canonical exercise separately |
| back_extension — Extension lombaires | S / external | RESOLVED_SINGLE_EXERCISE | Back extension; moderate; `lee_2015`, `openstax_back`, `openstax_lower` | Local axis/ROM |
| battle_ropes — Cordes ondulatoires | S / bodyweight | GENERIC_MULTI_PURPOSE | none | Actual exercise protocol? |
| converging_shoulder_press — Presse épaules | S / external | RESOLVED_SINGLE_EXERCISE | Converging Shoulder Press; moderate; `nih_deltoid`, `openstax_upper` | Local handle/path |
| diverging_lat_pulldown — Tirage vertical divergent | S / external | RESOLVED_SINGLE_EXERCISE | Diverging lat pulldown; moderate; `lehman_2004`, `openstax_upper`, `vigotsky_2018` | Grip/path |
| diverging_seated_row — Tirage horizontal divergent | S / external | RESOLVED_SINGLE_EXERCISE | Diverged seated row; moderate; `franke_2015`, `lehman_2004`, `openstax_upper` | Grip/path |
| dual_adjustable_pulley — Double poulie | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise and configuration? |
| dumbbells — Haltères | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
| eq_0a462c1e-9fb6-4fd7-b0a2-53a0e86f33c6 — custom chest press | C / runtime-owned | UNRESOLVED | none | Explicit verified canonical link unavailable in current schema |
| eq_3f987a36-bbb4-4e29-9c9f-1f201f1596e0 — custom abdominal | C / runtime-owned | UNRESOLVED | none | Explicit verified canonical link unavailable in current schema |
| eq_c660cd61-5b17-498e-8d51-9eafcf7e2731 — custom converting press | C / runtime-owned | UNRESOLVED | none | Explicit verified canonical link unavailable in current schema |
| functional_trainer — Functional Trainer | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise/configuration? |
| hip_abduction — Abducteurs | S / external | RESOLVED_SINGLE_EXERCISE | Hip abduction; moderate; `nih_gluteus_minimus`, `openstax_lower` | Local setup |
| hip_adduction — Adducteurs | S / external | RESOLVED_SINGLE_EXERCISE | Hip adduction; high; `openstax_lower` | Local setup |
| indoor_cycle — Vélo indoor | S / cardio | PARTIALLY_RESOLVED | none | Which canonical continuous exercise? |
| kettlebells — Kettlebells | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
| lat_pull — Tirage vertical | S / external | RESOLVED_SINGLE_EXERCISE | Lat pull; moderate; `lehman_2004`, `openstax_upper`, `vigotsky_2018` | Grip/path |
| lat_pulldown — Lat Pulldown | S / external | PARTIALLY_RESOLVED | none | Distinguish from current Lat pull identity |
| leg_extension — Extension des jambes | S / external | RESOLVED_SINGLE_EXERCISE | Leg extension; high; `openstax_lower` | Local axis/setup |
| leg_press — Presse à cuisses | S / external | RESOLVED_SINGLE_EXERCISE | Leg press / Presse à cuisses; moderate; `martin_fuentes_2020`, `openstax_lower`, `vigotsky_2018` | Local geometry |
| low_row — Tirage horizontal bas | S / external | PARTIALLY_RESOLVED | none | Does an existing row exercise identity match exactly? |
| medicine_ball — Medicine Ball | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
| perfect_squat — Squat guidé | S / external | PARTIALLY_RESOLVED | none | Canonical exercise identity and local mechanics? |
| plate_loaded_leg_press — Presse à cuisses à disques | S / external | RESOLVED_SINGLE_EXERCISE | Leg press / Presse à cuisses; moderate; same existing leg-press sources | Local geometry/load equivalence |
| prone_leg_curl — Leg curl allongé | S / external | RESOLVED_SINGLE_EXERCISE | Prone leg curl; high; `maeo_2021`, `openstax_lower` | Local setup |
| rear_delt_pec_fly — Oiseaux / Pec Fly | S / external | PARTIALLY_RESOLVED | Rear Delt only, configuration “Oiseaux / Rear Delt”; moderate; existing rear-delt sources | No canonical chest-fly UUID exists; do not fabricate one |
| recumbent_bike — Vélo semi-allongé | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous exercise? |
| rotary_torso — Rotation du buste | S / external | RESOLVED_SINGLE_EXERCISE | Rotary torso; moderate; `nih_abdominal_wall`, `openstax_back`, `openstax_trunk` | Local axis/ROM |
| rowing_machine — Rameur | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous rowing identity? |
| seated_dip — Dips assis | S / external | PARTIALLY_RESOLVED | none | Canonical seated-dip identity? |
| seated_leg_curl — Leg curl assis | S / external | RESOLVED_SINGLE_EXERCISE | Seated leg curl / Flexion de genou assise; high; `maeo_2021`, `openstax_lower` | Local setup |
| seated_row — Tirage horizontal assis | S / external | RESOLVED_SINGLE_EXERCISE | Seated row; moderate; `franke_2015`, `lehman_2004`, `openstax_upper` | Grip/path |
| stair_climber — Escalier | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous exercise? |
| suspension_trainer — Sangles | S / bodyweight | GENERIC_MULTI_PURPOSE | none | Actual exercise/setup? |
| treadmill — Tapis de course | S / cardio | PARTIALLY_RESOLVED | none | Walk, run, incline, and canonical identity? |
| upright_bike — Vélo droit | S / cardio | PARTIALLY_RESOLVED | none | Canonical continuous exercise? |
| vertical_chest_press — Presse pectoraux | S / external | PARTIALLY_RESOLVED | none | Existing chest-press records are conditional/custom, not this supplied identity |
| weighted_bag — Sac lesté | S / external | GENERIC_MULTI_PURPOSE | none | Actual exercise? |
`Seated Leg` (`ex_617007f9-7420-4408-91b9-8ffb77900f13`) remains explicitly
unresolved in V2 and receives no relation. It is not “Flexion de genou assise”
and receives no MAX, history, equipment, or anatomy transfer.
Future identification is documentation only: manufacturer/model/name plus an
optional photo, pictogram, and notes may create a proposal; the user verifies
the proposed identity before it is persisted. V2 performs no OCR or image
recognition and the current schema has no custom-equipment link field.

View file

@ -1,5 +1,16 @@
# Roadmap
## MACHINE_EXERCISE_MODEL_V1 — implementation awaiting human review
Phase 1 introduces additive schema v13, machine-facing exercise identities,
exercise-owned load semantics, reusable scientific profile references, exact
manifest splits, and exercise-centric Android/TUI navigation. Real database
migration remains gated on copy validation and human review.
`EQUIPMENT_SCHEMA_CLEANUP_V1` remains a later phase after real-world
validation; it may remove obsolete legacy tables, fields, companions, and UI
only with a new explicit migration and compatibility decision.
This file is the canonical product roadmap for Trainlog.
Historical implementation detail belongs in Git history and `docs/reviews`.
@ -479,3 +490,7 @@ Exact parsed instants govern boundaries and ordering. Assistance, missing values
and incomparable equipment contexts are never coerced into progress values.
Malformed legacy timestamps are skipped by the projection with a visible warning
while valid statistics remain available.
Future equipment identification may collect manufacturer, model, name, photo,
pictogram, and notes to form a proposal which the user must verify before
persistence. OCR/image recognition and a custom-equipment relation schema are
not part of EQUIPMENT_KNOWLEDGE_V2.

View file

@ -1,5 +1,15 @@
# Synchronization exchange
## Machine-exercise Phase 1 compatibility
Desktop and Android schema v13 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names
travel through the existing catalog, while v13 metadata stays local until a
dedicated versioned companion is required. Legacy equipment IDs may still be
written for fixed supplied machines as compatibility provenance.
## 1. Status
```text
@ -568,6 +578,16 @@ previously known choice. Unknown canonical IDs, unknown entries, ambiguous
identities and divergent values reject processing explicitly; an unknown
equipment reference is never silently changed to null.
After `MACHINE_EXERCISE_MODEL_V1`, a pre-v13 association companion can outlive
the five approved completed-occurrence splits even though the active mobile V3
snapshot already carries the new identities. Desktop accepts that stale
corroborating `exercise_id` only for the frozen `(session_id, entry_id, old ID,
new ID)` split table and only when the mobile V3 snapshot imported in the same
run proves the exact new local identity. Equipment state must still be equal.
Every other exercise mismatch remains a conflict. The old Leg Press and Marche
IDs are not aliases for their machine-specific siblings, and draft occurrences
are outside this completed-history compatibility rule.
V2 association semantics are unchanged. A custom equipment ID is accepted only
after its definition V1 artifact has reconciled it on the receiving side.
Unknown IDs, unknown entries, ambiguous identities, and definition conflicts

View file

@ -496,7 +496,7 @@ Historical validation checkpoint (the current desktop suite is 48/48):
## 13. Android session draft v1
Android schema v4 introduced one durable active draft; the current additive
chain reaches schema v12 without clearing completed history or the draft. The
chain reaches schema v13 without clearing completed history or the draft. The
explicit v10 -> v11 migration adds optional planning metadata while preserving
existing rows with `load_mode=none`, zero rest and NULL targets. The
v11 -> v12 migration adds the durable flattened exercise-alias table. Its exact
@ -630,6 +630,13 @@ This exercises real artifacts and production import/export code on copies. It
does not substitute for a final direct-MTP run when USB ownership or sandbox
permissions prevent libmtp access.
`tests/test_equipment_associations_exchange.py` additionally covers the
bounded v13 stale-companion bridge for the five frozen machine-exercise splits.
Plate Loaded Leg Press and Treadmill pass only with matching current mobile V3
proof; generic Marche, the durable Marche+treadmill draft, Seated Leg, and Rear
Delt NULL provenance remain unchanged; an unrelated exercise mismatch still
fails; and replay is idempotent.
For this checkpoint, the USB probe discovered the connected Samsung interface
but libmtp failed at `libusb_open()`. The canonical desktop DB was also
read-only to the sandbox; the first attempted definitions import failed before
@ -688,3 +695,7 @@ for Training Knowledge V1.
Shell/naming closeout coverage includes real-PTY shell behavior, canonical
name export/import replay on temporary databases, and preservation of sets,
MAX, equipment, BODY ZONES, aliases, and distinct unmapped exercises.
EQUIPMENT_KNOWLEDGE_V2 validation covers relation envelopes, canonical UUID and
equipment references, enum/source integrity, uniqueness and stable ordering,
the unresolved Seated Leg invariant, generic/custom non-inference, and generated
C/Android query parity for leg press, combo equipment, and chest-zone derivation.

View file

@ -13,7 +13,7 @@ Desktop SQLite is the canonical long-term history.
The persistent shell has seven root sections:
```text
Accueil | Séances | Exercices | Équipements | Statistiques | Synchronisation | Paramètres
Accueil | Séances | Exercices | Statistiques | Synchronisation | Paramètres
```
`Séances` contains **Séance en cours**, **Programmer une séance**, **Nouvelle
@ -281,7 +281,9 @@ catalog.
## 7. Equipment
`Équipements` (direct alias `4/F4`) provides supplied-equipment browsing,
The legacy Equipment catalogue is no longer a normal top-level workflow.
Its storage and compatibility APIs remain available for old history and peers.
Historically, `Équipements` (direct alias `4/F4`) provided supplied-equipment browsing,
search, detail, and custom-equipment creation and selection. Supplied definitions are generated
from `catalog/equipment-v1.json`; user-created definitions persist in desktop
SQLite and synchronize separately through definitions V1.
@ -635,3 +637,6 @@ passed. See [Training knowledge system V1](domain/knowledge_system.md).
The normal Sessions hub has current/manual/completed paths only; the retained
SESSION_GENERATOR_V1 route is not advertised. Physical `/` and F7 Rechercher
dispatch the one registered list-search action on supported lists.
Equipment Detail lists V2 verified exercise options and confidence within the
72x20 layout. The generated core also exposes bounded reverse and BODY ZONE
queries without changing Body Focus geometry or session generation.

View file

@ -95,6 +95,106 @@ def main():
assert canonical_conflict.returncode != 0
assert 'conflit exercice association' in canonical_conflict.stdout
# MACHINE_EXERCISE_MODEL_V1: a stale pre-split companion is accepted
# only for the closed set of completed stable entries and only when the
# current V3 snapshot proves the exact machine-specific target.
split_db = directory / 'machine-splits.db'
db = sqlite3.connect(split_db)
db.executescript("""
CREATE TABLE exercises(id INTEGER PRIMARY KEY, exercise_id TEXT UNIQUE);
CREATE TABLE sessions(id INTEGER PRIMARY KEY, session_id TEXT UNIQUE, started_at TEXT);
CREATE TABLE session_exercises(id INTEGER PRIMARY KEY, entry_id TEXT UNIQUE,
session_row_id INTEGER, exercise_row_id INTEGER, position INTEGER, equipment_id TEXT);
CREATE TABLE custom_equipment(equipment_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
label_name TEXT NOT NULL, equipment_type TEXT NOT NULL, load_semantics TEXT NOT NULL);
CREATE TABLE draft_session_exercises(entry_id TEXT PRIMARY KEY, exercise_id TEXT, equipment_id TEXT);
PRAGMA user_version=13;
""")
plate_old = 'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde'
plate_new = 'ex_0e26c06f-a458-40a4-be20-4ed219ede30d'
walk_old = 'ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d'
treadmill_new = 'ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2'
seated_leg = 'ex_617007f9-7420-4408-91b9-8ffb77900f13'
rear_delt = 'ex_4cd2433e-80b1-478a-b8df-487bf6014ce6'
ids = [plate_new, walk_old, treadmill_new, seated_leg, rear_delt, 'ex_unrelated']
db.executemany('INSERT INTO exercises VALUES(?,?)', enumerate(ids, 1))
sessions = [
('se_c0d07454-b58b-403e-8b79-744619815fc5', '2026-09-07T19:27:35+02:00'),
('se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a', '2026-09-08T11:25:30+02:00'),
]
db.executemany('INSERT INTO sessions(id,session_id,started_at) VALUES(?,?,?)',
[(i, *row) for i, row in enumerate(sessions, 1)])
row_by_id = {value: index for index, value in enumerate(ids, 1)}
occurrences = [
('sxe_draft_legacy_7', 1, plate_new, 0, 'plate_loaded_leg_press'),
('sxe_093c1331-beaa-4b69-91b3-240292709be6', 1, treadmill_new, 1, 'treadmill'),
('sxe_draft_legacy_1', 1, walk_old, 2, None),
('sxe_draft_legacy_3', 1, seated_leg, 3, 'seated_leg_curl'),
('sxe_8b0b1722-7c32-4364-aefa-b4825fe681d7', 2, rear_delt, 0, None),
]
db.executemany(
'INSERT INTO session_exercises(entry_id,session_row_id,exercise_row_id,position,equipment_id) VALUES(?,?,?,?,?)',
[(entry, session, row_by_id[exercise], position, equipment)
for entry, session, exercise, position, equipment in occurrences])
db.execute("INSERT INTO draft_session_exercises VALUES('sxe_draft_current',?,?)",
(walk_old, 'treadmill'))
db.commit(); db.close()
split_artifact = directory / 'stale-machine-associations.json'
split_payload = {
'format': 'trainlog-equipment-associations', 'version': 2,
'generated_at': '2026-09-12T08:05:57+02:00',
'associations': [
{'session_id': sessions[0][0], 'entry_id': 'sxe_draft_legacy_7',
'exercise_id': plate_old, 'state': 'set', 'equipment_id': 'plate_loaded_leg_press'},
{'session_id': sessions[0][0], 'entry_id': 'sxe_093c1331-beaa-4b69-91b3-240292709be6',
'exercise_id': walk_old, 'state': 'set', 'equipment_id': 'treadmill'},
{'session_id': sessions[0][0], 'entry_id': 'sxe_draft_legacy_1',
'exercise_id': walk_old, 'state': 'cleared'},
{'session_id': sessions[0][0], 'entry_id': 'sxe_draft_legacy_3',
'exercise_id': seated_leg, 'state': 'set', 'equipment_id': 'seated_leg_curl'},
{'session_id': sessions[1][0], 'entry_id': 'sxe_8b0b1722-7c32-4364-aefa-b4825fe681d7',
'exercise_id': rear_delt, 'state': 'cleared'},
],
}
split_artifact.write_text(json.dumps(split_payload))
proof = directory / 'current-machine-v3.json'
proof.write_text(json.dumps({
'format': 'trainlog-mobile-export', 'version': 3,
'generated_at': '2026-09-12T09:26:00+02:00', 'exercises': [],
'body_observations': [], 'sessions': [
{'session_id': session_id, 'exercises': [
{'entry_id': entry, 'exercise_id': exercise}
for entry, session, exercise, _position, _equipment in occurrences
if session == session_index
]}
for session_index, (session_id, _started) in enumerate(sessions, 1)
],
}))
for _ in range(2):
accepted = subprocess.run(
[sys.executable, ROOT / 'tools/import_equipment_associations.py', split_artifact,
'--database', split_db, '--mobile-export', proof],
text=True, capture_output=True,
)
assert accepted.returncode == 0, accepted.stdout + accepted.stderr
with sqlite3.connect(split_db) as db:
assert db.execute('SELECT exercise_id,equipment_id FROM draft_session_exercises').fetchone() == (walk_old, 'treadmill')
assert db.execute('SELECT exercise_id FROM exercises WHERE exercise_id=?', (seated_leg,)).fetchone() == (seated_leg,)
assert db.execute('SELECT equipment_id FROM session_exercises WHERE entry_id=?',
('sxe_8b0b1722-7c32-4364-aefa-b4825fe681d7',)).fetchone() == (None,)
conflict_payload = json.loads(split_artifact.read_text())
conflict_payload['associations'][0]['exercise_id'] = 'ex_unrelated'
split_artifact.write_text(json.dumps(conflict_payload))
true_conflict = subprocess.run(
[sys.executable, ROOT / 'tools/import_equipment_associations.py', split_artifact,
'--database', split_db, '--mobile-export', proof],
text=True, capture_output=True,
)
assert true_conflict.returncode != 0
assert 'conflit exercice association' in true_conflict.stdout
# The importer must validate the entire artifact before applying its
# first otherwise-valid association. v2 cannot transport custom IDs.
prevalidation_artifact = directory / 'prevalidation-rejected.json'

View file

@ -559,7 +559,7 @@ def main():
with sqlite3.connect(names_db) as connection:
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_CURL_ID,)
).fetchone()[0] == "Flexion de genou assise"
).fetchone()[0] == "Seated Leg Curl"
assert connection.execute(
"SELECT name FROM exercises WHERE exercise_id=?", (SEATED_LEG_DISTINCT_ID,)
).fetchone()[0] == "Seated Leg"
@ -573,7 +573,7 @@ def main():
for artifact in (names_catalog, names_mobile):
exercises = json.loads(artifact.read_text(encoding="utf-8"))["exercises"]
by_id = {item["exercise_id"]: item["name"] for item in exercises}
assert by_id[SEATED_CURL_ID] == "Flexion de genou assise"
assert by_id[SEATED_CURL_ID] == "Seated Leg Curl"
assert by_id[SEATED_LEG_DISTINCT_ID] == "Seated Leg"
assert by_id["ex_fcc75fa7-671e-4868-bab2-47033128dfb7"] == "Custom curl"

View file

@ -22,6 +22,7 @@ class TrainingKnowledgeValidationTest(unittest.TestCase):
"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",
"equipment-exercise-relations-v2.json",
"training-knowledge-audit-v1.json",
)
@ -128,6 +129,20 @@ class TrainingKnowledgeValidationTest(unittest.TestCase):
self.mutate("equipment-knowledge-v1.json", mutation)
self.assert_invalid()
def test_equipment_v2_rejects_phantoms_duplicates_and_unresolved_seated_leg(self):
original = (self.catalog / "equipment-exercise-relations-v2.json").read_bytes()
mutations = [
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("exercise_id", "ex_00000000-0000-4000-8000-000000000000"),
lambda root: root["equipment_relations"][0]["exercise_options"].append(copy.deepcopy(root["equipment_relations"][0]["exercise_options"][0])),
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("confidence", "likely"),
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("source_refs", ["missing_ref"]),
lambda root: root["equipment_relations"][0]["exercise_options"][0].__setitem__("exercise_id", "ex_617007f9-7420-4408-91b9-8ffb77900f13"),
]
for mutation in mutations:
(self.catalog / "equipment-exercise-relations-v2.json").write_bytes(original)
self.mutate("equipment-exercise-relations-v2.json", mutation)
self.assert_invalid()
if __name__ == "__main__":
unittest.main()

View file

@ -27,8 +27,8 @@ def main():
args = parser.parse_args()
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
raise ValueError("schema desktop v8 à v12 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
raise ValueError("schema desktop v8 à v13 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -23,8 +23,8 @@ def main():
connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
raise ValueError("schema desktop v8 à v12 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
raise ValueError("schema desktop v8 à v13 requis")
equipment = [dict(row) for row in connection.execute(
"SELECT equipment_id,display_name,label_name,equipment_type,load_semantics "
"FROM custom_equipment ORDER BY equipment_id")]

View file

@ -15,8 +15,8 @@ def main():
args = parser.parse_args()
con = sqlite3.connect(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
raise ValueError("schema desktop v12 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13):
raise ValueError("schema desktop v12/v13 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -40,8 +40,8 @@ def main():
connection = sqlite3.connect(args.database)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
raise ValueError("schema desktop v11/v12 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13):
raise ValueError("schema desktop v11/v12/v13 requis")
exercises = []
for exercise in connection.execute("SELECT id,exercise_id FROM exercises ORDER BY exercise_id"):
if EXERCISE_ID_PATTERN.fullmatch(exercise["exercise_id"]) is None:

View file

@ -67,7 +67,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in (8, 9, 10, 11, 12):
if version not in (8, 9, 10, 11, 12, 13):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -68,8 +68,8 @@ def main():
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in (11, 12) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11/v12 requis (v10 accepté pour export V2 explicite)")
if schema_version not in (11, 12, 13) and not (args.version == 2 and schema_version == 10):
raise ValueError("schema desktop v11/v12/v13 requis (v10 accepté pour export V2 explicite)")
known_equipment = supplied_equipment_ids()
canonical_names = load_exercise_names()
known_equipment.update(row[0] for row in con.execute(

View file

@ -46,6 +46,7 @@ def main() -> None:
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")
relation_groups = load(root / "equipment-exercise-relations-v2.json", "equipment_relations")
lines = ['#include "trainlog/training_knowledge.h"',
'#include "trainlog/body_zone_catalog.h"', "#include <string.h>", ""]
lines.append("static const TrainlogKnowledgeReference references[] = {")
@ -114,6 +115,14 @@ def main() -> None:
fields.append("true" if row["requires_actual_exercise"] else "false")
lines.append(" {%s}," % ",".join(fields))
lines.append("};")
relations = [(row["equipment_id"], option) for row in relation_groups
for option in row["exercise_options"]]
lines.append("static const TrainlogEquipmentExerciseRelation equipment_exercise_relations[] = {")
for equipment_id, option in relations:
lines.append(" {%s}," % ",".join(c(value) for value in (
equipment_id, option["exercise_id"], option["relation_type"],
option["configuration_label"], option["confidence"], option["source_refs"])))
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:
@ -158,6 +167,40 @@ size_t trainlog_equipment_capability_count(void) { return sizeof(capabilities)/s
const TrainlogEquipmentCapability *trainlog_equipment_capability_at(size_t index) {
return index < trainlog_equipment_capability_count() ? &capabilities[index] : NULL;
}
size_t trainlog_equipment_exercise_relation_count(void) {
return sizeof(equipment_exercise_relations)/sizeof(equipment_exercise_relations[0]);
}
const TrainlogEquipmentExerciseRelation *trainlog_equipment_exercise_relation_at(size_t index) {
return index < trainlog_equipment_exercise_relation_count() ? &equipment_exercise_relations[index] : NULL;
}
TrainlogStatus trainlog_knowledge_list_exercises_for_equipment(const char *equipment_id,
const TrainlogExerciseKnowledge **output, size_t capacity, size_t *output_count) {
size_t i, count=0;
if (output_count == NULL || (capacity > 0 && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count=0;
if (trainlog_equipment_knowledge_lookup(equipment_id) == NULL) return TRAINLOG_STATUS_NOT_FOUND;
for (i=0; i<trainlog_equipment_exercise_relation_count(); ++i) {
const TrainlogEquipmentExerciseRelation *r=&equipment_exercise_relations[i];
if (strcmp(r->equipment_id,equipment_id)!=0) continue;
if (count<capacity) output[count]=trainlog_exercise_knowledge_lookup(r->exercise_id);
++count;
}
*output_count=count; return count>capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
}
TrainlogStatus trainlog_knowledge_list_equipment_for_exercise(const char *exercise_id,
const TrainlogEquipmentKnowledge **output, size_t capacity, size_t *output_count) {
size_t i, count=0;
if (output_count == NULL || (capacity > 0 && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count=0;
if (trainlog_exercise_knowledge_lookup(exercise_id) == NULL) return TRAINLOG_STATUS_NOT_FOUND;
for (i=0; i<trainlog_equipment_exercise_relation_count(); ++i) {
const TrainlogEquipmentExerciseRelation *r=&equipment_exercise_relations[i];
if (strcmp(r->exercise_id,exercise_id)!=0) continue;
if (count<capacity) output[count]=trainlog_equipment_knowledge_lookup(r->equipment_id);
++count;
}
*output_count=count; return count>capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
}
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;
@ -175,6 +218,29 @@ static bool zone_matches(const TrainlogKnowledgeInterpretation *value, const cha
}
return false;
}
TrainlogStatus trainlog_knowledge_list_equipment_for_body_zone(const char *zone_id, bool descendants,
const TrainlogEquipmentKnowledge **output, size_t capacity, size_t *output_count) {
size_t i, count=0;
if (output_count == NULL || (capacity > 0 && output == NULL)) return TRAINLOG_STATUS_INVALID_ARGUMENT;
*output_count=0;
if (trainlog_body_zone_catalog_lookup(zone_id) == NULL) return TRAINLOG_STATUS_NOT_FOUND;
/* CONTRACT: zone -> resolved exercise anatomy -> physical relation. Machine
* labels never participate in this derivation. */
for (i=0; i<trainlog_equipment_knowledge_count(); ++i) {
const TrainlogEquipmentKnowledge *eq=&equipment[i]; size_t j; bool matched=false;
for (j=0; j<trainlog_equipment_exercise_relation_count(); ++j) {
const TrainlogEquipmentExerciseRelation *r=&equipment_exercise_relations[j];
const TrainlogExerciseKnowledge *e;
if (strcmp(r->equipment_id,eq->equipment_id)!=0) continue;
e=trainlog_exercise_knowledge_lookup(r->exercise_id);
if (e!=NULL && e->interpretation!=NULL && zone_matches(e->interpretation,zone_id,descendants)) { matched=true; break; }
}
if (!matched) continue;
if (count<capacity) output[count]=eq;
++count;
}
*output_count=count; return count>capacity ? TRAINLOG_STATUS_INVALID_ARGUMENT : TRAINLOG_STATUS_OK;
}
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};

View file

@ -8,6 +8,31 @@ from pathlib import Path
FORMAT = "trainlog-equipment-associations"
VERSION = 2
# CONTRACT: MACHINE_EXERCISE_MODEL_V1 split only these completed occurrences.
# The legacy exercise IDs are deliberately not aliases: other occurrences of
# the same source exercise remain valid unsplit history.
MACHINE_EXERCISE_SPLITS = {
("se_c0d07454-b58b-403e-8b79-744619815fc5", "sxe_draft_legacy_7"):
("ex_b432623f-bfe9-4daf-a653-60ec7fdffbde",
"ex_0e26c06f-a458-40a4-be20-4ed219ede30d"),
("se_c0d07454-b58b-403e-8b79-744619815fc5",
"sxe_093c1331-beaa-4b69-91b3-240292709be6"):
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
("se_ac3908d6-8e3e-4ac6-81a6-62dc2c39075a",
"sxe_f25142c8-455e-4346-9bfc-31d0989e275d"):
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
("se_8a7ac0cb-9e67-42b6-bac9-179bbedf0024",
"sxe_f2242691-ba6c-48a0-b938-a1f744375d75"):
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
("se_8e6baa18-53ff-486b-b011-02a15291789e",
"sxe_9f8882f4-069e-49d5-8216-19d16b467e4a"):
("ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d",
"ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2"),
}
def fail(message):
raise ValueError(message)
@ -55,11 +80,11 @@ def validate_associations(associations, known):
def load_mobile_occurrences(path):
"""Return the source V2 identity claimed for every stable occurrence."""
"""Return the current V2/V3 identity claimed for every stable occurrence."""
payload = json.loads(path.read_text(encoding="utf-8"))
if (
payload.get("format") != "trainlog-mobile-export"
or payload.get("version") != 2
or payload.get("version") not in (2, 3)
or not isinstance(payload.get("sessions"), list)
):
fail("snapshot mobile V2 de preuve invalide")
@ -91,6 +116,18 @@ def load_mobile_occurrences(path):
return occurrences
def approved_machine_split(session_id, entry_id, incoming_id, local_id,
mobile_occurrences):
"""Corroborate one frozen completed-history split with the current snapshot."""
expected = MACHINE_EXERCISE_SPLITS.get((session_id, entry_id))
if expected != (incoming_id, local_id):
return False
# INVARIANT: the stale companion alone is never authority. The mobile
# snapshot selected and imported in this same sync must already carry the
# exact v13 target identity for this stable completed occurrence.
return mobile_occurrences.get((session_id, entry_id)) == local_id
def canonical_exercise_id(connection, exercise_id):
"""Resolve one flattened durable alias without inventing identity."""
aliases_available = connection.execute(
@ -123,7 +160,7 @@ def main():
"--mobile-export",
type=Path,
help=(
"snapshot mobile V2 importé juste avant ce companion; requis "
"snapshot mobile V2/V3 importé juste avant ce companion; requis "
"pour prouver un exercise_id réconcilié"
),
)
@ -135,8 +172,8 @@ def main():
fail("clés extension équipement invalides")
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12):
fail("schema desktop v8 à v12 requis")
if connection.execute("PRAGMA user_version;").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
fail("schema desktop v8 à v13 requis")
known = load_catalog(args.catalog)
known.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))
@ -170,12 +207,15 @@ def main():
"SELECT 1 FROM exercises WHERE exercise_id=?;",
(exercise_id,),
).fetchone() is not None
if proof != exercise_id or incoming_still_exists:
alias_reconciliation = proof == exercise_id and not incoming_still_exists
split_reconciliation = approved_machine_split(
session_id, entry_id, incoming_canonical,
stored_canonical, mobile_occurrences)
if not alias_reconciliation and not split_reconciliation:
fail(f"conflit exercice association: {session_id}/{entry_id}")
# CONTRACT: this fallback is only for schemas/runs without a
# persistent alias. import_mobile_export may have removed a
# profile-compatible duplicate; the selected V2 snapshot then
# proves the stale source identity for this stable occurrence.
# CONTRACT: the first fallback is only for schemas/runs without
# a persistent alias. The second is the closed v13 split table
# above; no source ID becomes a global one-to-many alias.
# INVARIANT: identity reconciliation never changes session_id,
# entry_id, equipment_id, or any persisted occurrence. A distinct
# live canonical exercise therefore remains a hard conflict.

View file

@ -60,8 +60,8 @@ def main():
definitions = validate(json.loads(args.artifact.read_text(encoding="utf-8")), supplied_ids(args.catalog))
connection = sqlite3.connect(args.database)
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12):
fail("schema desktop v8 à v12 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (8, 9, 10, 11, 12, 13):
fail("schema desktop v8 à v13 requis")
imported = skipped = 0
# Validate every same-ID row before inserting any definition.
for definition in definitions:

View file

@ -62,8 +62,8 @@ def main():
con = sqlite3.connect(args.database)
try:
con.execute("PRAGMA foreign_keys=ON")
if con.execute("PRAGMA user_version").fetchone()[0] != 12:
fail("schema desktop v12 requis")
if con.execute("PRAGMA user_version").fetchone()[0] not in (12, 13):
fail("schema desktop v12/v13 requis")
con.execute("BEGIN IMMEDIATE")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()

View file

@ -204,8 +204,8 @@ def main():
connection = sqlite3.connect(args.database)
updated = skipped = kept_local = 0
try:
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12):
raise ImportFailure("schema desktop v11/v12 requis")
if connection.execute("PRAGMA user_version").fetchone()[0] not in (11, 12, 13):
raise ImportFailure("schema desktop v11/v12/v13 requis")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("BEGIN IMMEDIATE")
grouped = {}

View file

@ -820,9 +820,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in (5, 6, 7, 8, 9, 10, 11, 12):
if version not in (5, 6, 7, 8, 9, 10, 11, 12, 13):
raise ImportFailure(
f"base desktop schema v5 à v12 attendue, version trouvée: {version}"
f"base desktop schema v5 à v13 attendue, version trouvée: {version}"
)

View file

@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Apply MACHINE_EXERCISE_MODEL_V1 to an explicit non-live SQLite database.
The executor is intentionally manifest-driven and refuses the canonical live
desktop path. Context splits match entry ID, canonical source exercise ID and
the exact legacy equipment ID before any write begins.
"""
import argparse
import json
import os
import sqlite3
from pathlib import Path
from exercise_names import normalize_catalog_name
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = Path("/tmp/trainlog-machine-exercise-manifest/migration-manifest-v1.json")
LIVE_DB = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "trainlog/trainlog.db"
COLUMNS = (
("load_semantics", "TEXT CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio'))"),
("machine_variant", "TEXT"),
("machine_provenance", "TEXT"),
("scientific_profile_id", "TEXT"),
("science_state", "TEXT NOT NULL DEFAULT 'unresolved' CHECK(science_state IN ('resolved','unresolved'))"),
("legacy_equipment_id", "TEXT"),
)
def fail(message):
raise RuntimeError(message)
def row_id(connection, exercise_id):
row = connection.execute("SELECT id FROM exercises WHERE exercise_id=?", (exercise_id,)).fetchone()
return None if row is None else row[0]
def split_facts(connection, entry_ids):
placeholders = ",".join("?" for _ in entry_ids)
return connection.execute(f"""
SELECT se.entry_id,e.exercise_id,se.equipment_id,
(SELECT count(*) FROM performed_sets p WHERE p.session_exercise_row_id=se.id),
(SELECT count(*) FROM max_results m WHERE m.session_exercise_row_id=se.id),
(SELECT count(*) FROM continuous_activity c WHERE c.session_exercise_row_id=se.id)
FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id
WHERE se.entry_id IN ({placeholders}) ORDER BY se.entry_id
""", entry_ids).fetchall()
def validate_preconditions(connection, manifest):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (12, 13):
fail(f"schema v12 or v13 required, found v{version}")
split_entries = []
for rename in manifest["renames"]:
row = connection.execute("SELECT name FROM exercises WHERE exercise_id=?", (rename["exercise_id"],)).fetchone()
if row is None:
fail(f"rename source missing: {rename['exercise_id']}")
if version == 12 and row[0] != rename["old_display_name"]:
fail(f"rename source name mismatch: {rename['exercise_id']}")
if version == 13 and row[0] != rename["new_display_name"]:
fail(f"migrated rename mismatch: {rename['exercise_id']}")
for split in manifest["context_splits"]:
split_entries.extend(split["entry_ids"])
facts = split_facts(connection, split["entry_ids"])
expected_exercise = split["target_exercise_id"] if version == 13 else split["source_exercise_id"]
if len(facts) != split["expected_occurrence_count"] or {row[0] for row in facts} != set(split["entry_ids"]):
fail(f"split occurrence mismatch: {split['target_exercise_id']}")
if any(row[1] != expected_exercise or row[2] != split["source_equipment_id"] for row in facts):
fail(f"split identity/context mismatch: {split['target_exercise_id']}")
if sum(row[3] for row in facts) != split["expected_performed_set_count"] or sum(row[4] for row in facts) != split["expected_max_count"] or sum(row[5] for row in facts) != split["expected_continuous_count"]:
fail(f"split child count mismatch: {split['target_exercise_id']}")
if len(split_entries) != len(set(split_entries)):
fail("one entry appears in multiple split targets")
unresolved = set()
for item in manifest["unresolved_contexts"]:
unresolved.update([item["entry_id"]] if "entry_id" in item else item.get("historical_entry_ids", []))
if unresolved & set(split_entries):
fail("unresolved entry included in automatic split")
def profile_interpretations():
profiles = json.loads((ROOT / "catalog/scientific-profiles-v1.json").read_text(encoding="utf-8"))["profiles"]
exercises = {row["exercise_id"]: row for row in json.loads((ROOT / "catalog/exercise-knowledge-v1.json").read_text(encoding="utf-8"))["exercises"]}
equipment = {row["equipment_id"]: row for row in json.loads((ROOT / "catalog/equipment-knowledge-v1.json").read_text(encoding="utf-8"))["equipment"]}
result = {}
for profile in profiles:
source = profile["knowledge_source"]
if source["kind"] == "exercise":
interpretation = exercises[source["exercise_id"]].get("interpretation")
else:
capability = next(cap for cap in equipment[source["equipment_id"]]["capabilities"] if cap["display_name"] == source["capability"])
interpretation = capability.get("interpretation")
if interpretation is None:
fail(f"resolved profile has no interpretation: {profile['scientific_profile_id']}")
result[profile["scientific_profile_id"]] = interpretation
return result
def seed_zones(connection, exercise_id, interpretation):
target = row_id(connection, exercise_id)
if target is None:
fail(f"cannot seed zones for missing exercise: {exercise_id}")
existing = connection.execute("SELECT zone_id,role FROM exercise_body_zones WHERE exercise_row_id=? ORDER BY role,zone_id", (target,)).fetchall()
expected = [(interpretation["primary_zone_id"], "primary")] + [(zone, "secondary") for zone in sorted(interpretation["secondary_zone_ids"])]
if existing:
if sorted(existing) != sorted(expected):
fail(f"BODY ZONE conflict for {exercise_id}")
else:
connection.executemany("INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) VALUES(?,?,?)", [(target, zone, role) for zone, role in expected])
state = interpretation["primary_zone_id"] + "|" + ",".join(sorted(interpretation["secondary_zone_ids"]))
connection.execute("INSERT OR IGNORE INTO exercise_body_zone_sync(exercise_row_id,synced_state) VALUES(?,?)", (target, state))
def apply(connection, manifest):
validate_preconditions(connection, manifest)
if connection.execute("PRAGMA user_version").fetchone()[0] == 13:
return False
interpretations = profile_interpretations()
connection.execute("BEGIN IMMEDIATE")
try:
for name, definition in COLUMNS:
connection.execute(f"ALTER TABLE exercises ADD COLUMN {name} {definition}")
for rename in manifest["renames"]:
changed = connection.execute("UPDATE exercises SET name=?,normalized_name=?,load_semantics=?,machine_variant=NULL,scientific_profile_id=?,science_state='resolved',legacy_equipment_id=? WHERE exercise_id=? AND name=?",
(rename["new_display_name"], normalize_catalog_name(rename["new_display_name"]), rename["load_semantics"], rename["scientific_profile_id"], rename["legacy_equipment_id"], rename["exercise_id"], rename["old_display_name"])).rowcount
if changed != 1:
fail(f"rename update failed: {rename['exercise_id']}")
machine_rows = {row["exercise_id"]: row for row in json.loads((ROOT / "catalog/machine-exercises-v1.json").read_text(encoding="utf-8"))["exercises"]}
for rename in manifest["renames"]:
machine = machine_rows[rename["exercise_id"]]
connection.execute("UPDATE exercises SET machine_variant=? WHERE exercise_id=?", (machine["machine_variant"], rename["exercise_id"]))
for item in manifest["new_exercises"]:
machine = machine_rows[item["exercise_id"]]
connection.execute("""INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,load_semantics,machine_variant,machine_provenance,scientific_profile_id,science_state,legacy_equipment_id)
VALUES(?,?,?,?,?,?,?,?,NULL,?,?,?)""", (item["exercise_id"], item["display_name"], normalize_catalog_name(item["display_name"]), item["tracking_mode"], item["recording_mode"], item["data_fields"], item["load_semantics"], machine["machine_variant"], item["scientific_profile_id"], machine["science_state"], item["legacy_equipment_id"]))
if item["scientific_profile_id"] is not None:
seed_zones(connection, item["exercise_id"], interpretations[item["scientific_profile_id"]])
for split in manifest["context_splits"]:
target = row_id(connection, split["target_exercise_id"])
source = row_id(connection, split["source_exercise_id"])
for entry in split["entry_ids"]:
changed = connection.execute("UPDATE session_exercises SET exercise_row_id=? WHERE entry_id=? AND exercise_row_id=? AND equipment_id=?", (target, entry, source, split["source_equipment_id"])).rowcount
if changed != 1:
fail(f"exact split update failed: {entry}")
seated = machine_rows["ex_617007f9-7420-4408-91b9-8ffb77900f13"]
connection.execute("UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id=?", (seated["exercise_id"],))
connection.execute("PRAGMA user_version=13")
validate_preconditions(connection, manifest)
if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
fail("foreign key check failed")
connection.commit()
except Exception:
connection.rollback()
raise
return True
def main():
parser = argparse.ArgumentParser()
parser.add_argument("database", type=Path)
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--audit", type=Path)
args = parser.parse_args()
database = args.database.resolve()
if database == LIVE_DB.resolve():
fail("refusing to migrate the canonical live desktop database")
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
connection = sqlite3.connect(database)
connection.execute("PRAGMA foreign_keys=ON")
changed = apply(connection, manifest)
integrity = connection.execute("PRAGMA integrity_check").fetchone()[0]
version = connection.execute("PRAGMA user_version").fetchone()[0]
connection.close()
audit = {"format":"trainlog-machine-exercise-migration-audit","version":1,"database":str(database),"changed":changed,"schema_version":version,"integrity_check":integrity}
if args.audit:
args.audit.write_text(json.dumps(audit, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(audit, ensure_ascii=False, sort_keys=True))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Validate MACHINE_EXERCISE_MODEL_V1 catalogs without inferring science."""
import json
import re
from pathlib import Path
from exercise_names import normalize_catalog_name
ROOT = Path(__file__).resolve().parents[1]
EXERCISE_ID = re.compile(r"^ex_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
PROFILE_ID = re.compile(r"^sp_[a-z0-9_]+_v1$")
LOADS = {"none", "external", "assistance", "bodyweight", "cardio"}
def load(name):
return json.loads((ROOT / "catalog" / name).read_text(encoding="utf-8"))
def main():
machine = load("machine-exercises-v1.json")
profiles = load("scientific-profiles-v1.json")
equipment = load("equipment-v1.json")
knowledge = load("exercise-knowledge-v1.json")
equipment_knowledge = load("equipment-knowledge-v1.json")
if set(machine) != {"format", "version", "exercises"} or machine["format"] != "trainlog-machine-exercises" or machine["version"] != 1:
raise ValueError("invalid machine exercise envelope")
if set(profiles) != {"format", "version", "profiles"} or profiles["format"] != "trainlog-scientific-profiles" or profiles["version"] != 1:
raise ValueError("invalid scientific profile envelope")
equipment_ids = {row["id"] for row in equipment["equipment"]}
exercise_knowledge = {row["exercise_id"] for row in knowledge["exercises"]}
capabilities = {(row["equipment_id"], cap["display_name"])
for row in equipment_knowledge["equipment"] for cap in row["capabilities"]}
profile_ids = set()
previous = ""
for row in profiles["profiles"]:
if set(row) != {"scientific_profile_id", "knowledge_source"}:
raise ValueError("invalid scientific profile record")
profile_id = row["scientific_profile_id"]
if not PROFILE_ID.fullmatch(profile_id) or profile_id <= previous or profile_id in profile_ids:
raise ValueError("scientific profiles must be unique and sorted")
previous = profile_id
profile_ids.add(profile_id)
source = row["knowledge_source"]
if source.get("kind") == "exercise":
if set(source) != {"kind", "exercise_id"} or source["exercise_id"] not in exercise_knowledge:
raise ValueError("unknown exercise knowledge source")
elif source.get("kind") == "equipment_capability":
if set(source) != {"kind", "equipment_id", "capability"} or (source["equipment_id"], source["capability"]) not in capabilities:
raise ValueError("unknown equipment capability source")
else:
raise ValueError("invalid profile source kind")
seen_ids = set()
seen_names = set()
previous = ""
for row in machine["exercises"]:
required = {"exercise_id", "display_name", "load_semantics", "machine_variant", "legacy_equipment_id", "scientific_profile_id", "science_state"}
if set(row) != required or not EXERCISE_ID.fullmatch(row["exercise_id"]):
raise ValueError("invalid machine exercise record")
if row["exercise_id"] <= previous or row["exercise_id"] in seen_ids:
raise ValueError("machine exercises must be unique and sorted")
previous = row["exercise_id"]
seen_ids.add(row["exercise_id"])
name = normalize_catalog_name(row["display_name"])
if name in seen_names:
raise ValueError("duplicate machine exercise name")
seen_names.add(name)
if row["load_semantics"] is not None and row["load_semantics"] not in LOADS:
raise ValueError("invalid load semantics")
if row["legacy_equipment_id"] is not None and row["legacy_equipment_id"] not in equipment_ids:
raise ValueError("unknown legacy equipment")
if row["scientific_profile_id"] is not None and row["scientific_profile_id"] not in profile_ids:
raise ValueError("unknown scientific profile")
resolved = row["science_state"] == "resolved"
if row["science_state"] not in {"resolved", "unresolved"} or resolved != (row["scientific_profile_id"] is not None):
raise ValueError("science state/profile mismatch")
seated_leg = next(row for row in machine["exercises"] if row["exercise_id"] == "ex_617007f9-7420-4408-91b9-8ffb77900f13")
if any(seated_leg[key] is not None for key in ("load_semantics", "legacy_equipment_id", "scientific_profile_id")):
raise ValueError("Seated Leg must remain unresolved")
print("MACHINE_EXERCISE_MODEL_V1 catalogs: OK")
if __name__ == "__main__":
main()

View file

@ -125,6 +125,11 @@ def validate(root: Path) -> None:
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")
relation_root = load(root / "equipment-exercise-relations-v2.json")
require(set(relation_root) == {"format", "version", "equipment_relations"} and
relation_root["format"] == "trainlog-equipment-exercise-relations-v2" and
relation_root["version"] == 2 and isinstance(relation_root["equipment_relations"], list),
"invalid equipment relation V2 envelope")
refs = ids(references, "ref_id", "references")
muscles = ids(muscles_rows, "muscle_id", "muscles")
actions = ids(actions_rows, "action_id", "joint_actions")
@ -160,6 +165,34 @@ def validate(root: Path) -> None:
require(required_equipment <= equipment, "knowledge catalog misses supplied equipment")
require(required_exercises <= exercises, "knowledge catalog misses observed exercises")
# CONTRACT: equipment relations contain identity/provenance only. Anatomy is
# exercise-owned and therefore cannot be duplicated or inferred here.
relation_rows = relation_root["equipment_relations"]
relation_equipment = ids(relation_rows, "equipment_id", "equipment relations")
require(relation_equipment <= equipment, "equipment relation references unknown equipment")
seen_relations = set()
for relation_row in relation_rows:
exact_object(relation_row, {"equipment_id", "exercise_options"}, "equipment relation")
options = relation_row["exercise_options"]
require(isinstance(options, list) and bool(options), "equipment relation options must be non-empty")
order = []
for option in options:
exact_object(option, {"exercise_id", "relation_type", "configuration_label",
"confidence", "source_refs"}, "equipment exercise option")
require(option["exercise_id"] in exercises, "equipment option references unknown exercise")
require(option["relation_type"] == "supported_exercise", "invalid equipment relation type")
nullable_text(option["configuration_label"], "equipment option.configuration_label")
confidence(option["confidence"], "equipment option.confidence")
refs_list(option["source_refs"], "equipment option.source_refs", refs)
pair = (relation_row["equipment_id"], option["exercise_id"])
require(pair not in seen_relations, "duplicate equipment/exercise relation")
seen_relations.add(pair)
order.append((option["exercise_id"], option["configuration_label"] or ""))
require(order == sorted(order), "equipment exercise options must be deterministically ordered")
seated_leg = "ex_617007f9-7420-4408-91b9-8ffb77900f13"
require(all(exercise_id != seated_leg for _, exercise_id in seen_relations),
"Seated Leg is unresolved and must not inherit seated-leg-curl knowledge")
ref_required = {"ref_id", "title", "authors_or_organization", "year", "type", "url", "topics",
"notes", "limitations", "doi", "pmid", "accessed_on"}
for row in references:

View file

@ -11,7 +11,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 12
#define TRAINLOG_DATABASE_SCHEMA_VERSION 13
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -164,6 +164,17 @@ typedef struct TrainlogEquipmentCapability {
const TrainlogKnowledgeInterpretation *interpretation;
} TrainlogEquipmentCapability;
/** Physical-equipment compatibility with one already-known canonical exercise.
* INVARIANT: this record never owns muscles, movements, or BODY ZONES. */
typedef struct TrainlogEquipmentExerciseRelation {
const char *equipment_id;
const char *exercise_id;
const char *relation_type;
const char *configuration_label;
const char *confidence;
const char *source_refs;
} TrainlogEquipmentExerciseRelation;
typedef struct TrainlogKnowledgeQuery {
const char *scientific_zone_id;
bool include_zone_descendants;
@ -200,6 +211,18 @@ 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);
size_t trainlog_equipment_exercise_relation_count(void);
const TrainlogEquipmentExerciseRelation *trainlog_equipment_exercise_relation_at(size_t index);
TrainlogStatus trainlog_knowledge_list_exercises_for_equipment(
const char *equipment_id, const TrainlogExerciseKnowledge **output,
size_t capacity, size_t *output_count);
TrainlogStatus trainlog_knowledge_list_equipment_for_exercise(
const char *exercise_id, const TrainlogEquipmentKnowledge **output,
size_t capacity, size_t *output_count);
TrainlogStatus trainlog_knowledge_list_equipment_for_body_zone(
const char *zone_id, bool include_descendants,
const TrainlogEquipmentKnowledge **output, size_t capacity,
size_t *output_count);
/**
* AND-combine optional resolved-knowledge filters in stable exercise-ID order.

View file

@ -48,6 +48,7 @@ training_knowledge_generated = custom_target(
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/equipment-exercise-relations-v2.json',
meson.project_source_root() / 'catalog/training-knowledge-audit-v1.json',
],
output: 'training_knowledge_generated.c',

View file

@ -512,6 +512,60 @@ static const char *const MIGRATE_V11_TO_V12_SQL =
"ON exercise_aliases(canonical_exercise_id);"
"PRAGMA user_version = 12;COMMIT;";
/* WHY: the selectable identity is now the concrete machine exercise; legacy
* equipment remains occurrence provenance for V3/old-peer compatibility.
* CONTRACT: v13 is additive, creates only the five frozen UUID identities and
* repoints only the approved entry/exercise/equipment triples. INVARIANT:
* session/entry IDs and every child fact remain untouched. */
static const char *const MIGRATE_V12_TO_V13_SQL_A =
"BEGIN IMMEDIATE;"
"ALTER TABLE exercises ADD COLUMN load_semantics TEXT CHECK(load_semantics IN ('none','external','assistance','bodyweight','cardio'));"
"ALTER TABLE exercises ADD COLUMN machine_variant TEXT;"
"ALTER TABLE exercises ADD COLUMN machine_provenance TEXT;"
"ALTER TABLE exercises ADD COLUMN scientific_profile_id TEXT;"
"ALTER TABLE exercises ADD COLUMN science_state TEXT NOT NULL DEFAULT 'unresolved' CHECK(science_state IN ('resolved','unresolved'));"
"ALTER TABLE exercises ADD COLUMN legacy_equipment_id TEXT;"
"UPDATE exercises SET name='Hip Abduction',normalized_name='hip abduction',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_hip_abduction_v1',science_state='resolved',legacy_equipment_id='hip_abduction' WHERE exercise_id='ex_7e7cf906-2214-4066-bcb7-c16382d83b3b' AND name='Abduction de hanche assise';"
"UPDATE exercises SET name='Leg Extension',normalized_name='leg extension',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_leg_extension_v1',science_state='resolved',legacy_equipment_id='leg_extension' WHERE exercise_id='ex_1872246a-39ae-44dc-b58d-f87e90ca49ab' AND name='Extension de genou assise';"
"UPDATE exercises SET name='Back Extension',normalized_name='back extension',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_back_extension_v1',science_state='resolved',legacy_equipment_id='back_extension' WHERE exercise_id='ex_474ec393-3efa-4aaa-8e08-1a0245ed7835' AND name='Extension du tronc';"
"UPDATE exercises SET name='Arm Curl',normalized_name='arm curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_arm_curl_v1',science_state='resolved',legacy_equipment_id='arm_curl' WHERE exercise_id='ex_ec619fc2-4685-4044-873c-86764bd4a0fe' AND name='Flexion de coude à la machine';"
"UPDATE exercises SET name='Prone Leg Curl',normalized_name='prone leg curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_prone_leg_curl_v1',science_state='resolved',legacy_equipment_id='prone_leg_curl' WHERE exercise_id='ex_d7398d9f-d928-4d2e-94e9-74e201da55c5' AND name='Flexion de genou couchée';"
"UPDATE exercises SET name='Rotary Torso',normalized_name='rotary torso',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_rotary_torso_v1',science_state='resolved',legacy_equipment_id='rotary_torso' WHERE exercise_id='ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0' AND name='Rotation du tronc à la machine';";
static const char *const MIGRATE_V12_TO_V13_SQL_B =
"UPDATE exercises SET name='Seated Row',normalized_name='seated row',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_seated_row_v1',science_state='resolved',legacy_equipment_id='seated_row' WHERE exercise_id='ex_33f79331-871c-4eed-babe-346e53a99070' AND name='Tirage horizontal assis';"
"UPDATE exercises SET name='Diverging Seated Row',normalized_name='diverging seated row',load_semantics='external',machine_variant='diverging',scientific_profile_id='sp_seated_row_v1',science_state='resolved',legacy_equipment_id='diverging_seated_row' WHERE exercise_id='ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de' AND name='Tirage horizontal divergent assis';"
"UPDATE exercises SET name='Diverging Lat Pulldown',normalized_name='diverging lat pulldown',load_semantics='external',machine_variant='diverging',scientific_profile_id='sp_vertical_pull_v1',science_state='resolved',legacy_equipment_id='diverging_lat_pulldown' WHERE exercise_id='ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6' AND name='Tirage vertical divergent';"
"UPDATE exercises SET name='Lat Pull',normalized_name='lat pull',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_vertical_pull_v1',science_state='resolved',legacy_equipment_id='lat_pull' WHERE exercise_id='ex_a72fa713-4b0e-431d-95e2-42d95beb77b1' AND name='Tirage vertical à la poulie';"
"UPDATE exercises SET name='Converging Shoulder Press',normalized_name='converging shoulder press',load_semantics='external',machine_variant='converging',scientific_profile_id='sp_shoulder_press_v1',science_state='resolved',legacy_equipment_id='converging_shoulder_press' WHERE exercise_id='ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b' AND name='Développé épaules convergent';"
"UPDATE exercises SET name='Seated Leg Curl',normalized_name='seated leg curl',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_seated_leg_curl_v1',science_state='resolved',legacy_equipment_id='seated_leg_curl' WHERE exercise_id='ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163' AND name='Flexion de genou assise';"
"UPDATE exercises SET name='Leg Press',normalized_name='leg press',load_semantics='external',machine_variant='selectorized',scientific_profile_id='sp_leg_press_v1',science_state='resolved',legacy_equipment_id='leg_press' WHERE exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' AND name IN ('Presse à cuisses','Leg press');"
"UPDATE exercises SET name='Rear Delt',normalized_name='rear delt',load_semantics='external',machine_variant='rear_delt',scientific_profile_id='sp_rear_delt_v1',science_state='resolved',legacy_equipment_id='rear_delt_pec_fly' WHERE exercise_id='ex_4cd2433e-80b1-478a-b8df-73fc6ef80962' AND name='Écarté inversé à la machine';"
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,recording_mode,data_fields,load_semantics,machine_variant,scientific_profile_id,science_state,legacy_equipment_id) VALUES"
"('ex_0e26c06f-a458-40a4-be20-4ed219ede30d','Plate Loaded Leg Press','plate loaded leg press','reps','sets',0,'external','plate_loaded','sp_leg_press_v1','resolved','plate_loaded_leg_press'),"
"('ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2','Treadmill','treadmill','duration','continuous',3,'cardio','treadmill',NULL,'unresolved','treadmill'),"
"('ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee','Pec Fly','pec fly','reps','sets',0,'external','pec_fly','sp_pec_fly_v1','resolved','rear_delt_pec_fly'),"
"('ex_58b8dfbc-92b2-4783-a449-9947a42480b8','Chin Assist','chin assist','reps','sets',0,'assistance','assisted','sp_assisted_chin_v1','resolved','assisted_dip_chin_machine'),"
"('ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf','Dip Assist','dip assist','reps','sets',0,'assistance','assisted','sp_assisted_dip_v1','resolved','assisted_dip_chin_machine');";
static const char *const MIGRATE_V12_TO_V13_SQL_C =
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT n.id,z.zone_id,z.role FROM exercises n JOIN exercises o ON o.exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde' JOIN exercise_body_zones z ON z.exercise_row_id=o.id WHERE n.exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','primary' FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'shoulders','secondary' FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'back','primary' FROM exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','secondary' FROM exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'arms','primary' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'chest','secondary' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
"INSERT INTO exercise_body_zones(exercise_row_id,zone_id,role) SELECT id,'shoulders','secondary' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'thighs|glutes' FROM exercises WHERE exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d';"
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'chest|shoulders' FROM exercises WHERE exercise_id='ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee';"
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'back|arms' FROM exercises WHERE exercise_id='ex_58b8dfbc-92b2-4783-a449-9947a42480b8';"
"INSERT INTO exercise_body_zone_sync(exercise_row_id,synced_state) SELECT id,'arms|chest,shoulders' FROM exercises WHERE exercise_id='ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf';"
"UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_0e26c06f-a458-40a4-be20-4ed219ede30d') WHERE entry_id='sxe_draft_legacy_7' AND equipment_id='plate_loaded_leg_press' AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b432623f-bfe9-4daf-a653-60ec7fdffbde');"
"UPDATE session_exercises SET exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2') WHERE entry_id IN ('sxe_093c1331-beaa-4b69-91b3-240292709be6','sxe_f25142c8-455e-4346-9bfc-31d0989e275d','sxe_f2242691-ba6c-48a0-b938-a1f744375d75','sxe_9f8882f4-069e-49d5-8216-19d16b467e4a') AND equipment_id='treadmill' AND exercise_row_id=(SELECT id FROM exercises WHERE exercise_id='ex_b1e6ffc6-75b5-45ff-a3c0-e7433c58013d');"
"UPDATE exercises SET load_semantics=NULL,machine_variant=NULL,machine_provenance=NULL,scientific_profile_id=NULL,science_state='unresolved',legacy_equipment_id=NULL WHERE exercise_id='ex_617007f9-7420-4408-91b9-8ffb77900f13';"
"PRAGMA user_version=13;COMMIT;";
static const char *const MIGRATE_V1_TO_V3_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE sessions "
@ -1028,7 +1082,7 @@ static TrainlogStatus initialize_or_validate_schema(
status = execute_sql(database, MIGRATE_V9_TO_V10_SQL);
} else if (version == 10) {
status = TRAINLOG_STATUS_OK;
} else if (version == 11) {
} else if (version == 11 || version == 12) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -1165,9 +1219,18 @@ static TrainlogStatus initialize_or_validate_schema(
if (status == TRAINLOG_STATUS_OK) {
if (version < 11) status = migrate_v10_to_v11(database);
}
if (status == TRAINLOG_STATUS_OK) {
if (status == TRAINLOG_STATUS_OK && version < 12) {
status = execute_sql(database, MIGRATE_V11_TO_V12_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 13) {
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_A);
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_B);
}
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, MIGRATE_V12_TO_V13_SQL_C);
}
}
if (
status !=
@ -1176,7 +1239,7 @@ static TrainlogStatus initialize_or_validate_schema(
set_open_diagnostic(
output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v12" : "migrate database to schema v12",
version == 0 ? "create schema v13" : "migrate database to schema v13",
database->connection,
SQLITE_ERROR
);

View file

@ -1315,9 +1315,9 @@ static TrainlogStatus sync_run_python_tool(
);
if (database_path != NULL && mobile_export_path != NULL) {
/* CONTRACT: the body-zone importer may consume the exact retained
* V2 snapshot as identity-reconciliation proof. argv stays
* bounded and shell-free; all other helpers omit this pair. */
/* CONTRACT: reconciliation-aware companion importers may consume
* the exact retained V2/V3 snapshot as stable-entry identity
* proof. argv stays bounded and shell-free. */
execlp(
"python3",
"python3",
@ -3070,7 +3070,7 @@ TrainlogStatus trainlog_sync_run(
status = sync_run_python_tool("import_equipment_associations.py",
EQUIPMENT_ASSOCIATIONS_LOCAL,
database_path,
NULL,
MOBILE_EXPORT_LOCAL,
EQUIPMENT_ASSOCIATIONS_RESULT,
tool_output, sizeof(tool_output));
if (status != TRAINLOG_STATUS_OK ||

View file

@ -4,30 +4,6 @@
#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;
@ -44,7 +20,7 @@ TrainlogStatus trainlog_training_exercise_context_load(
{
TrainlogTrainingExerciseContext result = {0};
TrainlogStatus status;
const char *equipment_at;
char canonical_exercise_id[TRAINLOG_ID_MAX + 1U];
size_t index;
size_t zone_count = 0U;
bool snapshot = false;
@ -56,7 +32,12 @@ TrainlogStatus trainlog_training_exercise_context_load(
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);
/* INVARIANT: aliases canonicalize exactly once before immutable knowledge
* joins. Persisted history/MAX APIs keep their existing canonical behavior. */
status = trainlog_database_resolve_exercise_id(database, exercise_id,
canonical_exercise_id, sizeof(canonical_exercise_id));
if (status != TRAINLOG_STATUS_OK) goto done;
status = trainlog_database_get_exercise_profile(database, canonical_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;
@ -67,18 +48,20 @@ TrainlogStatus trainlog_training_exercise_context_load(
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);
result.knowledge=trainlog_exercise_knowledge_lookup(canonical_exercise_id);
if(result.knowledge!=NULL){
result.compatible_equipment_count=id_list_count(result.knowledge->equipment_ids);
status=trainlog_knowledge_list_equipment_for_exercise(canonical_exercise_id,NULL,0U,
&result.compatible_equipment_count);
if(status!=TRAINLOG_STATUS_OK && !(status==TRAINLOG_STATUS_INVALID_ARGUMENT &&
result.compatible_equipment_count>0U))goto done;
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_knowledge_list_equipment_for_exercise(canonical_exercise_id,
result.compatible_equipment,result.compatible_equipment_count,
&result.compatible_equipment_count);
if(status!=TRAINLOG_STATUS_OK)goto done;
}
}
status=trainlog_database_latest_explicit_max_context(database,exercise_id,&result.latest_max);

View file

@ -3090,6 +3090,46 @@ static void session_controller_load_equipment(TrainlogAppContext *app)
session->phase = TRAINLOG_SESSION_EQUIPMENT_PICKER;
}
static bool machine_exercise_compatibility(const char *exercise_id,
const char **equipment_id, TrainlogLoadMode *load_mode)
{
static const struct { const char *exercise; const char *equipment; bool assistance; } rows[] = {
{"ex_0e26c06f-a458-40a4-be20-4ed219ede30d", "plate_loaded_leg_press", false},
{"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", "leg_extension", false},
{"ex_1a34814c-2e46-40fc-b1f4-6d60b8e5a3e0", "rotary_torso", false},
{"ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", "diverging_seated_row", false},
{"ex_33f79331-871c-4eed-babe-346e53a99070", "seated_row", false},
{"ex_44358a7b-09c8-4992-8f70-7eee4e99bbdf", "assisted_dip_chin_machine", true},
{"ex_474ec393-3efa-4aaa-8e08-1a0245ed7835", "back_extension", false},
{"ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", "rear_delt_pec_fly", false},
{"ex_54dcdfd2-280d-4c2b-ae6b-c6089a985eee", "rear_delt_pec_fly", false},
{"ex_58b8dfbc-92b2-4783-a449-9947a42480b8", "assisted_dip_chin_machine", true},
{"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", "converging_shoulder_press", false},
{"ex_7e7cf906-2214-4066-bcb7-c16382d83b3b", "hip_abduction", false},
{"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", "seated_leg_curl", false},
{"ex_a72fa713-4b0e-431d-95e2-42d95beb77b1", "lat_pull", false},
{"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "leg_press", false},
{"ex_b4d1daf1-de4a-4016-abdf-487bf6014ce6", "diverging_lat_pulldown", false},
{"ex_d7398d9f-d928-4d2e-94e9-74e201da55c5", "prone_leg_curl", false},
{"ex_ec619fc2-4685-4044-873c-86764bd4a0fe", "arm_curl", false}
};
size_t index;
if (strcmp(exercise_id, "ex_f01d2a46-6984-4dec-8934-4d82fca6dfc2") == 0) {
*equipment_id = "treadmill";
*load_mode = TRAINLOG_LOAD_NONE;
return true;
}
for (index = 0U; index < sizeof(rows) / sizeof(rows[0]); ++index) {
if (strcmp(rows[index].exercise, exercise_id) == 0) {
*equipment_id = rows[index].equipment;
*load_mode = rows[index].assistance ? TRAINLOG_LOAD_ASSISTANCE :
TRAINLOG_LOAD_EXTERNAL;
return true;
}
}
return false;
}
static bool session_controller_choose_exercise(TrainlogSessionController *session)
{
TrainlogSessionDraftExercise replacement;
@ -3119,6 +3159,19 @@ static bool session_controller_choose_exercise(TrainlogSessionController *sessio
replacement.input.recording_mode = exercise->recording_mode;
replacement.input.data_fields = exercise->data_fields;
replacement.input.load_mode = TRAINLOG_LOAD_NONE;
{
const char *compatibility_equipment = NULL;
TrainlogLoadMode compatibility_load = TRAINLOG_LOAD_NONE;
/* WHY: fixed-machine identity already determines the Phase-1 legacy
* provenance. CONTRACT: custom/unresolved IDs never use name
* inference and retain the explicit compatibility editor. */
if (machine_exercise_compatibility(exercise->exercise_id,
&compatibility_equipment, &compatibility_load)) {
(void)snprintf(replacement.input.equipment_id,
sizeof(replacement.input.equipment_id), "%s", compatibility_equipment);
replacement.input.load_mode = compatibility_load;
}
}
draft_bind_input(&replacement);
if (session->replacing_occurrence) session->drafts[session->selected] = replacement;
else {
@ -3312,12 +3365,11 @@ static bool session_controller_accept_generator(TrainlogAppContext *app)
static const TrainlogAppRoute shell_sections[] = {
TRAINLOG_ROUTE_HOME, TRAINLOG_ROUTE_SESSIONS, TRAINLOG_ROUTE_EXERCISES,
TRAINLOG_ROUTE_EQUIPMENT, TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC,
TRAINLOG_ROUTE_SETTINGS
TRAINLOG_ROUTE_STATS, TRAINLOG_ROUTE_SYNC, TRAINLOG_ROUTE_SETTINGS
};
static const char *const shell_section_labels[] = {
"Accueil", "Séances", "Exercices", "Équipements", "Statistiques",
"Synchronisation", "Paramètres"
"Accueil", "Séances", "Exercices", "Statistiques", "Synchronisation",
"Paramètres"
};
static void app_shell_destroy_surfaces(TrainlogAppContext *app)
@ -5203,8 +5255,6 @@ static void app_shell_actions(TrainlogAppContext *app)
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_SESSIONS_COMPLETED);
app_shell_add_action(app, "exercises.alias", '3', "3 Exercices", 103U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EXERCISES);
app_shell_add_action(app, "equipment.alias", '4', "4 Équipements", 104U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_EQUIPMENT);
app_shell_add_action(app, "body.alias", '5', "5 Mensurations", 105U,
TRAINLOG_INTENT_OPEN_ROUTE, TRAINLOG_ROUTE_BODY);
app_shell_add_action(app, "sync.alias", '6', "6 Synchronisation", 106U,
@ -6923,6 +6973,8 @@ static void app_shell_render_content(TrainlogAppContext *app)
}
} else if (route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL) {
const TrainlogResolvedEquipment *item = &app->equipment_detail;
size_t relation_index;
int relation_row = 12;
trainlog_surface_printf(app->content, 4, 2, "%s", item->display_name);
trainlog_surface_printf(app->content, 6, 2, "Identifiant : %s", item->equipment_id);
trainlog_surface_printf(app->content, 7, 2, "Étiquette : %s",
@ -6933,6 +6985,27 @@ static void app_shell_render_content(TrainlogAppContext *app)
item->load_semantics[0] != '\0' ? item->load_semantics : "—");
trainlog_surface_printf(app->content, 10, 2, "Origine : %s",
equipment_origin_label(item->origin));
trainlog_surface_printf(app->content, relation_row++, 2, "Exercices possibles");
for (relation_index = 0U;
relation_index < trainlog_equipment_exercise_relation_count() &&
relation_row < app->layout.content.height; ++relation_index) {
const TrainlogEquipmentExerciseRelation *relation =
trainlog_equipment_exercise_relation_at(relation_index);
const TrainlogExerciseKnowledge *exercise;
const char *confidence;
if (relation == NULL || strcmp(relation->equipment_id,
item->equipment_id) != 0) continue;
exercise = trainlog_exercise_knowledge_lookup(relation->exercise_id);
if (exercise == NULL) continue;
confidence = strcmp(relation->confidence, "high") == 0 ? "élevée" :
strcmp(relation->confidence, "moderate") == 0 ? "modérée" : "incertaine";
trainlog_surface_printf(app->content, relation_row++, 4,
"> %.38s · confiance %s · sources vérifiées",
exercise->exercise_name, confidence);
}
if (relation_row == 13)
trainlog_surface_printf(app->content, relation_row, 4,
"Aucune association vérifiée; aucune anatomie déduite du nom.");
} else if (route == TRAINLOG_ROUTE_EXERCISE_DETAIL) {
const TrainlogExercise *item = &app->exercise_detail;
const TrainlogBodyZone *primary = NULL;
@ -8963,6 +9036,25 @@ body_global_unhandled:;
return;
}
shell_nonknowledge_input:
if (app->navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT_DETAIL &&
(key == TRAINLOG_KEY_ENTER || key == '\n')) {
size_t relation_index;
for (relation_index = 0U;
relation_index < trainlog_equipment_exercise_relation_count();
++relation_index) {
const TrainlogEquipmentExerciseRelation *relation =
trainlog_equipment_exercise_relation_at(relation_index);
if (relation == NULL || strcmp(relation->equipment_id,
app->equipment_detail.equipment_id) != 0) continue;
if (trainlog_database_get_exercise_profile(app->database,
relation->exercise_id, &app->exercise_detail) == TRAINLOG_STATUS_OK) {
app_shell_load_exercise_detail_metadata(app);
(void)trainlog_navigation_open(&app->navigation,
TRAINLOG_ROUTE_EXERCISE_DETAIL, app->exercise_detail.exercise_id);
}
return;
}
}
if (app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_PERFORMANCE ||
app->navigation.current.route == TRAINLOG_ROUTE_EXERCISE_MAX) {
size_t page = app->layout.content.height > 10

View file

@ -90,11 +90,11 @@ static int relation_and_filter_contract(void)
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U && strcmp(results[0].exercise_id, created.exercise_id) == 0);
CHECK(trainlog_database_list_exercises_filtered(database, "", "chest",
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 3U);
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
false, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 3U);
CHECK(trainlog_database_list_exercises_filtered(database, "", "arms",
false, true, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U);
false, true, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
CHECK(trainlog_database_list_exercises_filtered(database, "chest", "back",
true, false, false, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 0U);
CHECK(trainlog_database_list_exercises_filtered(database, "", "back",
@ -157,7 +157,8 @@ static int relation_and_filter_contract(void)
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_list_exercises_filtered(database, "", NULL,
true, false, true, results, 8U, &count) == TRAINLOG_STATUS_OK && count == 1U);
true, false, true, results, 8U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 3U);
trainlog_database_close(database);
database = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
@ -186,6 +187,7 @@ static int migration_preserves_identity_and_history(void)
"name TEXT NOT NULL,normalized_name TEXT NOT NULL UNIQUE,tracking_mode TEXT NOT NULL,"
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT NOT NULL UNIQUE);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,exercise_row_id INTEGER,entry_id TEXT,equipment_id TEXT);"
"INSERT INTO exercises VALUES(7,'ex_b432623f-bfe9-4daf-a653-60ec7fdffbde',"
"'Leg press','leg press','reps','sets',0);"
"INSERT INTO sessions VALUES(3,'se_preserved');"
@ -193,7 +195,7 @@ static int migration_preserves_identity_and_history(void)
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 12);
CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK && version == 13);
CHECK(trainlog_database_list_exercise_body_zones(database,
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", relations, 4U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U);

View file

@ -332,7 +332,9 @@ static bool test_transaction_rollback(void)
trainlog_database_exercise_count(database, &count) ==
TRAINLOG_STATUS_OK
);
CHECK(count == 0U);
/* Schema v13 seeds the five frozen supplied machine identities. The
* rolled-back custom row must not change that baseline. */
CHECK(count == 5U);
trainlog_database_close(database);
return true;
@ -394,7 +396,7 @@ static bool test_exercise_merge_aliases_and_conflicts(void)
sizeof(canonical)) == TRAINLOG_STATUS_OK);
CHECK(strcmp(canonical, "ex_final") == 0);
CHECK(trainlog_database_exercise_count(database, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U);
CHECK(count == 7U);
trainlog_database_close(database);
return true;
}

View file

@ -164,14 +164,15 @@ static bool test_v8_migration_refuses_to_guess_multiple_attempts(void)
"recording_mode TEXT NOT NULL,data_fields INTEGER NOT NULL);"
"CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_type TEXT);"
"CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,"
"session_row_id INTEGER,recording_mode TEXT);"
"session_row_id INTEGER,exercise_row_id INTEGER,entry_id TEXT,"
"equipment_id TEXT,recording_mode TEXT);"
"CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,"
"session_exercise_row_id INTEGER,position INTEGER,reps INTEGER,"
"duration_seconds INTEGER,weight_kg REAL);"
"INSERT INTO sessions VALUES(1,'max_test');"
"INSERT INTO session_exercises VALUES(10,1,'sets');"
"INSERT INTO session_exercises VALUES(11,1,'sets');"
"INSERT INTO session_exercises VALUES(12,1,'sets');"
"INSERT INTO session_exercises VALUES(10,1,NULL,NULL,NULL,'sets');"
"INSERT INTO session_exercises VALUES(11,1,NULL,NULL,NULL,'sets');"
"INSERT INTO session_exercises VALUES(12,1,NULL,NULL,NULL,'sets');"
"INSERT INTO performed_sets VALUES(20,10,0,1,NULL,100.0);"
"INSERT INTO performed_sets VALUES(21,11,0,1,NULL,80.0);"
"INSERT INTO performed_sets VALUES(22,11,1,1,NULL,86.0);"

View file

@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic, "migrate database to schema v12: SQLite rc=",
strlen("migrate database to schema v12: SQLite rc=")) == 0);
CHECK(strncmp(diagnostic, "migrate database to schema v13: SQLite rc=",
strlen("migrate database to schema v13: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -145,7 +145,7 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void)
static bool test_newer_schema_has_application_diagnostic(void)
{
char path[] = "/tmp/trainlog-schema-v13-XXXXXX";
char path[] = "/tmp/trainlog-schema-v14-XXXXXX";
char diagnostic[256];
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
@ -154,12 +154,12 @@ static bool test_newer_schema_has_application_diagnostic(void)
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=13;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=14;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 13 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 14 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void)
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic,
sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v12") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v13") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -403,7 +403,7 @@ static bool database_state(
CHECK(sqlite3_prepare_v2(raw, "SELECT COUNT(*) FROM exercises", -1,
&statement, NULL) == SQLITE_OK);
CHECK(sqlite3_step(statement) == SQLITE_ROW);
CHECK(sqlite3_column_int(statement, 0) == 1);
CHECK(sqlite3_column_int(statement, 0) == 6);
CHECK(sqlite3_finalize(statement) == SQLITE_OK);
statement = NULL;
if (source_should_resolve) {
@ -493,9 +493,15 @@ static bool run_success_case(
CHECK(prepare_database(case_root, persistent_alias, false,
database_path, sizeof(database_path)));
CHECK(write_artifacts(SOURCE_ID, SOURCE_ID, persistent_alias));
CHECK(trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report) == TRAINLOG_STATUS_OK);
{
TrainlogStatus status = trainlog_sync_run(TRAINLOG_SYNC_TRIGGER_TUI, false,
persistent_alias ? TRAINLOG_SYNC_BIDIRECTIONAL :
TRAINLOG_SYNC_ANDROID_TO_PC, &report);
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(stderr, "sync failure: %s\n", report.error);
}
CHECK(status == TRAINLOG_STATUS_OK);
}
CHECK(report.success);
CHECK(database_state(database_path, persistent_alias, "back", "arms", 2U));
CHECK(successful_journal(case_root, report.sync_id));

View file

@ -29,6 +29,7 @@ int main(void)
size_t index;
int rotary_capability_found = 0;
const TrainlogKnowledgeBodyZoneAudit *audit;
const TrainlogEquipmentKnowledge *equipment_rows[32];
CHECK(trainlog_knowledge_reference_count() == 23U);
CHECK(trainlog_knowledge_muscle_count() == 53U);
@ -52,6 +53,20 @@ int main(void)
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);
CHECK(trainlog_equipment_exercise_relation_count() == 17U);
CHECK(trainlog_knowledge_list_exercises_for_equipment("leg_press", rows, 32U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U && strcmp(rows[0]->exercise_id, leg_press) == 0);
CHECK(trainlog_knowledge_list_exercises_for_equipment("functional_trainer", rows, 32U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 0U);
CHECK(trainlog_knowledge_list_exercises_for_equipment("assisted_dip_chin_machine", rows, 32U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 0U);
CHECK(trainlog_knowledge_list_exercises_for_equipment("seated_leg_curl", rows, 32U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U && strcmp(rows[0]->exercise_id, "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163") == 0);
CHECK(trainlog_knowledge_list_equipment_for_exercise(leg_press, equipment_rows, 32U, &count) == TRAINLOG_STATUS_OK);
CHECK(count == 2U && strcmp(equipment_rows[0]->equipment_id, "leg_press") == 0);
CHECK(trainlog_knowledge_list_equipment_for_body_zone("chest", true, equipment_rows, 32U, &count) == TRAINLOG_STATUS_OK);
for (index = 0U; index < count; ++index)
CHECK(strcmp(equipment_rows[index]->equipment_id, "rear_delt_pec_fly") != 0);
record = trainlog_exercise_knowledge_lookup(rear_delt);
CHECK(record != NULL && record->interpretation != NULL);

View file

@ -548,7 +548,7 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
TrainlogDatabase *database = NULL;
TrainlogAppContext app;
TrainlogExercise created;
TrainlogExercise listed[4];
TrainlogExercise listed[8];
size_t count = 0U;
const char *name = "Course inclinée";
size_t index;
@ -574,7 +574,7 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
CHECK(app.exercise_controller.phase == TRAINLOG_EXERCISE_IDLE);
CHECK(app.session.phase == TRAINLOG_SESSION_EXERCISE_PICKER);
CHECK(app.session.picker_count == 1U);
CHECK(app.session.picker_count == 6U);
created = app.session.picker[app.session.picker_selected];
CHECK(created.recording_mode == TRAINLOG_RECORDING_CONTINUOUS);
CHECK(created.tracking_mode == TRAINLOG_TRACKING_DURATION);
@ -588,9 +588,9 @@ static bool test_shell_inline_exercise_creation_and_stable_edit(void)
CHECK(app_shell_dispatch_exercise_controller(&app, TRAINLOG_KEY_ENTER));
CHECK(strcmp(app.exercise_detail.exercise_id, created.exercise_id) == 0);
CHECK(strcmp(app.exercise_detail.name, "Course renommée") == 0);
CHECK(trainlog_database_list_exercises(database, listed, 4U,
CHECK(trainlog_database_list_exercises(database, listed, 8U,
&count) == TRAINLOG_STATUS_OK);
CHECK(count == 1U && strcmp(listed[0].exercise_id, created.exercise_id) == 0);
CHECK(count == 6U);
trainlog_database_close(database);
return true;
}
@ -618,7 +618,7 @@ static bool test_shell_navigation_focus_consumes_input(void)
CHECK(app.focus == TRAINLOG_FOCUS_NAVIGATION);
app.navigation_selected = 3U;
app_shell_dispatch(&app, TRAINLOG_KEY_ENTER);
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_EQUIPMENT);
CHECK(app.navigation.current.route == TRAINLOG_ROUTE_STATS);
CHECK(app.focus == TRAINLOG_FOCUS_CONTENT);
trainlog_database_close(database);
return true;
@ -1952,15 +1952,15 @@ static bool test_stats_detail_charts_and_global_zone_projection(void)
(void)memset(&app, 0, sizeof(app)); app.database = database;
app.dashboard.period = TRAINLOG_STATS_ALL;
app_shell_load_dashboard(&app);
CHECK(!app.dashboard.error && app.dashboard.zone_count == 3U);
CHECK(!app.dashboard.error && app.dashboard.zone_count == 4U);
for (index = 0U; index < app.dashboard.zone_count; ++index) {
chest = chest || (strcmp(app.dashboard.zones[index].label, "Pectoraux") == 0 &&
app.dashboard.zones[index].count == 1U);
app.dashboard.zones[index].count == 2U);
back = back || (strcmp(app.dashboard.zones[index].label, "Dos") == 0 &&
app.dashboard.zones[index].count == 1U);
app.dashboard.zones[index].count == 2U);
unclassified = unclassified ||
(strcmp(app.dashboard.zones[index].label, "Non classés") == 0 &&
app.dashboard.zones[index].count == 1U);
app.dashboard.zones[index].count == 3U);
}
CHECK(chest && back && unclassified);