From 7cb49964e35ffa5f99cd7a287cad6bf8ddde2df0 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 10 Sep 2026 07:08:20 +0200 Subject: [PATCH] feat(generator): add body-zone session generation --- AGENTS.md | 7 + CHANGELOG.md | 13 +- README.md | 8 +- android/app/build.gradle.kts | 3 + .../trainlog/ui/DraftUiTestActivity.kt | 1 + .../trainlog/data/SessionGenerationEngine.kt | 430 +++++ .../trainlog/data/SessionGenerationPolicy.kt | 185 ++ .../data/SessionGeneratorRepositoryModels.kt | 62 + .../trainlog/data/SyncCatalogInbox.kt | 19 +- .../labfytools/trainlog/data/SyncExporter.kt | 8 +- .../trainlog/data/TrainlogRepository.kt | 650 ++++++- .../trainlog/model/SessionModels.kt | 28 + .../com/labfytools/trainlog/ui/HomeScreen.kt | 7 + .../trainlog/ui/SessionDetailScreen.kt | 9 + .../trainlog/ui/SessionGeneratorScreen.kt | 285 +++ .../labfytools/trainlog/ui/SessionScreen.kt | 19 +- .../com/labfytools/trainlog/ui/TrainlogApp.kt | 20 + .../data/AndroidV10PlanningMigrationTest.kt | 83 + .../labfytools/trainlog/data/BodyZonesTest.kt | 2 +- .../RealAndroidV9BodyZonesMigrationTest.kt | 17 +- .../data/SessionGenerationEngineTest.kt | 142 ++ .../data/SessionGeneratorRepositoryTest.kt | 270 +++ .../data/TrainlogRepositoryDraftTest.kt | 264 ++- .../SessionGeneratorPreviewControllerTest.kt | 98 + catalog/session-generation-policy-v1.json | 187 ++ docs/android.md | 37 +- docs/architecture.md | 32 +- docs/current_state.md | 47 +- docs/database.md | 18 +- docs/domain/knowledge_system.md | 19 +- docs/domain/session_generation.md | 387 ++++ docs/exchange_format.md | 4 + docs/exercise_data_model.md | 12 +- ...ession_generator_v1_architecture_review.md | 177 ++ ...session_generator_v1_engineering_review.md | 70 + .../session_generator_v1_final_review.md | 81 + docs/reviews/session_generator_v1_resume.md | 112 ++ ...on_generator_v1_scientific_delta_review.md | 187 ++ .../session_generator_v1_scientific_review.md | 142 ++ docs/roadmap.md | 79 +- docs/sync_exchange.md | 51 +- docs/tests.md | 22 +- docs/tui.md | 16 +- .../create_pc_android_idempotence_fixture.py | 8 +- tests/fixtures/session-generation-v1.json | 1672 +++++++++++++++++ tests/fixtures/session-mobile-export-v3.json | 42 + tests/test_exercise_reconciliation.py | 3 +- tests/test_max_sync.py | 2 +- tests/test_mobile_import_multi_occurrence.py | 5 +- tests/test_mobile_import_variable_sets.py | 2 +- tests/test_session_exchange_v3.py | 211 +++ tools/export_pc_mobile.py | 87 +- tools/generate_session_generation_fixtures.py | 135 ++ tools/generate_session_generation_policy.py | 58 + tools/import_mobile_export.py | 137 +- tools/validate_session_generation_policy.py | 180 ++ tui/include/trainlog/session_generation.h | 214 +++ .../session_generation_policy_internal.h | 43 + tui/meson.build | 46 + tui/src/database.c | 116 ++ tui/src/session_generation.c | 1056 +++++++++++ tui/src/sync.c | 43 +- tui/src/tui.c | 426 ++++- tui/tests/test_session_generation.c | 228 +++ tui/tests/test_sync_direction.c | 8 +- tui/tests/test_tui_workflows.c | 214 ++- 66 files changed, 9012 insertions(+), 234 deletions(-) create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationEngine.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationPolicy.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryModels.kt create mode 100644 android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/SessionGenerationEngineTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt create mode 100644 android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt create mode 100644 catalog/session-generation-policy-v1.json create mode 100644 docs/domain/session_generation.md create mode 100644 docs/reviews/session_generator_v1_architecture_review.md create mode 100644 docs/reviews/session_generator_v1_engineering_review.md create mode 100644 docs/reviews/session_generator_v1_final_review.md create mode 100644 docs/reviews/session_generator_v1_resume.md create mode 100644 docs/reviews/session_generator_v1_scientific_delta_review.md create mode 100644 docs/reviews/session_generator_v1_scientific_review.md create mode 100644 tests/fixtures/session-generation-v1.json create mode 100644 tests/fixtures/session-mobile-export-v3.json create mode 100644 tests/test_session_exchange_v3.py create mode 100644 tools/generate_session_generation_fixtures.py create mode 100644 tools/generate_session_generation_policy.py create mode 100644 tools/validate_session_generation_policy.py create mode 100644 tui/include/trainlog/session_generation.h create mode 100644 tui/include/trainlog/session_generation_policy_internal.h create mode 100644 tui/src/session_generation.c create mode 100644 tui/tests/test_session_generation.c diff --git a/AGENTS.md b/AGENTS.md index 12a54e9..57ac995 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,6 +172,13 @@ insertion and draft deletion are one transaction. Drafts are excluded from completed history and mobile export. Preserve raw partial form input and use an explicit, non-destructive migration for future Android schema changes. +Android schema v11 additionally stores optional planning metadata separately +from actual occurrence data after the additive v10 -> v11 migration. Existing +rows retain `load_mode=none`, zero rest and NULL targets. Desktop remains schema +v11. The active completed-session exchange is the separate strict +`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` +remains unchanged. + ## 7. Synchronization architecture Desktop access to Android uses physical-device discovery with `libudev` and diff --git a/CHANGELOG.md b/CHANGELOG.md index 452c531..f241440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ Detailed implementation chronology remains available in Git history and ### Added +- `SESSION_GENERATOR_V1=PASS`: one frozen shared policy; deterministic bounded previews for + 11 BODY ZONES and four goals; explicit incomplete coverage and recorded-dose + recency; observed exact-equipment 28-day load anchors without MAX-derived + numeric fallback; Android normal-draft and TUI normal-editor acceptance; an + additive Android v10 -> v11 plan migration; and separate strict V3 mobile + exchange preserving plans with actual occurrence data. V1/V2 remain readable. + - `TRAINING_KNOWLEDGE_V1` read-only scientific knowledge infrastructure: six authored, versioned JSON catalogs with cited references; deterministic C generation; Android immutable asset loading; stable-ID catalog queries; and @@ -146,9 +153,9 @@ Detailed implementation chronology remains available in Git history and - desktop SQLite schema v7 and Android SQLite schema v7 preserve historic rows while adding durable occurrence identities and occurrence-level equipment; -- the active Android↔PC completed-session exchange is V2; frozen V1 artifacts - remain readable as historical formats and are not redefined for repeated - occurrences; +- the then-active Android↔PC completed-session exchange was V2; frozen V1 + artifacts remain readable as historical formats and are not redefined for + repeated occurrences; the current active artifact is separately versioned V3; - synchronization invokes each local helper with the explicit XDG-resolved desktop database path and records the concrete equipment-import failure; diff --git a/README.md b/README.md index ccc81ca..c13ea7e 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ TRAINLOG_FORMAT_V1=FROZEN DESKTOP_SCHEMA_V11=PASS ANDROID_LOCAL_WORKFLOWS=PASS ANDROID_LOCAL_DATABASE_V10=PASS +ANDROID_LOCAL_DATABASE_V11=PASS ANDROID_SESSION_DRAFT_V1=PASS EXERCISE_EDIT_V1=PASS ANDROID_BANNER_PARITY_V1=PASS @@ -42,10 +43,11 @@ BODY_ZONE_SYNC_V1=PASS BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS -DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint) +DESKTOP_TESTS=45/45 PASS (latest validated checkpoint) ANDROID_BUILD=PASS TRAINING_KNOWLEDGE_V1=PASS +SESSION_GENERATOR_V1=PASS ``` `TRAINING_KNOWLEDGE_V1` has passed its bounded scientific review, independent @@ -193,7 +195,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 v10. Its additive v4 -> v10 chain adds the shared +The current Android schema is v11. Its additive v4 -> v10 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 @@ -252,7 +254,7 @@ Sync The request is consumed by `trainlog-syncd`, the shared bidirectional engine runs, a receipt is returned to Android, and the PC catalog is applied locally. -The active completed-session exchange is V2 and preserves occurrence +The active completed-session exchange is V3 and preserves occurrence `entry_id`, per-set weights and equipment associations. Frozen V1 artifacts remain readable as legacy artifacts; they are not silently redefined as V2. Exercise-zone metadata travels separately in the sole bidirectional diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9cea674..d741c76 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -33,6 +33,9 @@ android { * are the canonical shared sources. Android must not fork either * taxonomy into Kotlin constants. */ assets.directories.add("../../catalog") + /* Shared production golden inputs are read by Kotlin directly and + * generated into the C runner; neither platform authors copies. */ + assets.directories.add("../../tests/fixtures") } } diff --git a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt index 63a0142..6572c51 100644 --- a/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt +++ b/android/app/src/debug/java/com/labfytools/trainlog/ui/DraftUiTestActivity.kt @@ -113,6 +113,7 @@ class DraftUiTestActivity : ComponentActivity() { onBody = {}, onHistory = {}, onSync = {}, + onGenerateSession = {}, ) } } diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationEngine.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationEngine.kt new file mode 100644 index 0000000..9605b97 --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationEngine.kt @@ -0,0 +1,430 @@ +package com.labfytools.trainlog.data + +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionLoadMode +import com.labfytools.trainlog.model.TrackingMode + +enum class GenerationWarningLevel { NONE, NOTICE, WARNING } + +data class GenerationHistoryRow( + val sessionId: String, val occurrenceId: String, val exerciseId: String, val startedAt: String, + val equipmentId: String?, val recordingMode: RecordingMode, val trackingMode: TrackingMode, + val loadMode: SessionLoadMode, val restSeconds: Int, val hasAnyTarget: Boolean, + val setPosition: Int?, val repetitions: Int?, val weightKg: Double?, val hasExplicitMax: Boolean = false, +) + +fun interface GenerationHistoryReader { + /** The repository calls this once inside one read transaction and streams all occurrences. */ + fun read(visitor: (GenerationHistoryRow) -> Unit) +} + +data class GenerationCandidate( + val exerciseId: String, val equipmentId: String, val primaryZoneId: String, + val secondaryZoneIds: List, val patternIds: List, val sourceRefIds: List, + val confidence: KnowledgeConfidence, val equipmentLoadSemantics: EquipmentLoadSemantics?, +) + +data class GenerationRequest( + val zoneId: String, val goalId: String, val durationMinutes: Int, val referenceTime: String, + val candidates: List, val preferredExerciseIds: Set = emptySet(), + val excludedExerciseIds: Set = emptySet(), val excludedPatternIds: Set = emptySet(), +) + +data class ExposureWindowSummary( + val primarySetCount: Int, val secondarySetCount: Int, val sessionCount: Int, + val patternIds: List, +) + +data class BodyZoneRecentExposure( + val within24h: ExposureWindowSummary, val within72h: ExposureWindowSummary, + val recentExposure: Boolean, val repeatedExposure: Boolean, val warningLevel: GenerationWarningLevel, + val latestStartedAt: String?, val latestSessionId: String?, val latestOccurrenceId: String?, + val latestPatternIds: List, val unclassifiedActualSetCount: Int, +) + +data class TrainingRecencyWarning(val recentSameExercise: Boolean, val recentSamePattern: Boolean) + +data class GeneratedSessionExercise( + val exerciseId: String, val equipmentId: String, val equipmentLoadSemantics: EquipmentLoadSemantics?, + val primaryZoneId: String, val secondaryZoneIds: List, val patternIds: List, + val targetSets: Int, val targetRepetitions: Int, val restSeconds: Int, + val targetWeightKg: Double?, val plannedLoadMode: SessionLoadMode, val estimatedSeconds: Int, + val confidence: KnowledgeConfidence, val recency: TrainingRecencyWarning, + val exposureWarningLevel: GenerationWarningLevel, val rationaleCodes: List, + val sourceRefIds: List, val loadSourceSessionId: String? = null, + val loadSourceOccurrenceId: String? = null, val loadSourceStartedAt: String? = null, +) + +data class GeneratedSessionSuggestion( + val exercises: List, val estimatedDurationSeconds: Int, + val insufficientResolvedCandidates: Boolean, val exposure: BodyZoneRecentExposure, + val shortageCodes: List = emptyList(), +) + +data class DoseQualification( + val targetSets: Int, val targetRepetitions: Int, val restSeconds: Int, + val targetWeightKg: Double?, val plannedLoadMode: SessionLoadMode, val estimatedSeconds: Int, + val rationaleCode: String, val sourceSessionId: String?, val sourceOccurrenceId: String?, + val sourceStartedAt: String?, +) + +/** Pure deterministic engine. It owns bounded accumulator state and retains no history rows. */ +class SessionGenerationEngine( + private val policy: SessionGenerationPolicy, + private val knowledge: TrainingKnowledgeCatalog, + private val bodyZones: BodyZoneCatalog, +) { + private data class Anchor(val weight: Double, val time: ExactInstant, val text: String, val session: String, val occurrence: String) + private data class CandidateState( + val candidate: GenerationCandidate, var recentExercise: Boolean = false, var recentPattern: Boolean = false, + var primary24: Int = 0, var secondary24: Int = 0, var primary72: Int = 0, var secondary72: Int = 0, + var secondaryZonePrimary24: Int = 0, var secondaryZoneSecondary24: Int = 0, + var secondaryZonePrimary72: Int = 0, var secondaryZoneSecondary72: Int = 0, + var anchor: Anchor? = null, var hasCompatibleExplicitMax: Boolean = false, + ) + private data class OccurrenceDose( + val session: String, val occurrence: String, val exercise: String, val equipment: String?, + val time: ExactInstant, val timeText: String, val loadMode: SessionLoadMode, val rest: Int, + val hasTargets: Boolean, val qualifying: IntArray, val minimum: DoubleArray, + ) + + fun generate(request: GenerationRequest, history: GenerationHistoryReader): GeneratedSessionSuggestion { + require(request.durationMinutes in policy.customMinutes) + val goal = requireNotNull(policy.goals[request.goalId]) { "unknown goal" } + require(policy.zoneExpansion.containsKey(request.zoneId)) { "unknown generation zone" } + val reference = ExactInstant.parse(request.referenceTime) + require(request.candidates.size <= 64) + require(request.excludedPatternIds.all { knowledge.getMovementPattern(it) != null }) + val duplicateContexts = request.candidates.groupBy { it.exerciseId to it.equipmentId }.values.any { it.size > 1 } + require(!duplicateContexts) + val states = request.candidates.map { candidate -> + validateCandidate(candidate) + CandidateState(candidate) + } + var occurrence: OccurrenceDose? = null + var previousSetPosition: Int? = null + var currentSession: String? = null + var session24 = false + var session72 = false + var sessions24 = 0 + var sessions72 = 0 + var primary24 = 0 + var secondary24 = 0 + var primary72 = 0 + var secondary72 = 0 + var unclassified = 0 + val patterns24 = sortedSetOf() + val patterns72 = sortedSetOf() + var latest: Triple>? = null + + fun finishOccurrence() { + val dose = occurrence ?: return + states.forEachIndexed { index, state -> + val actualOnly = dose.loadMode == SessionLoadMode.NONE && dose.rest == 0 && !dose.hasTargets + if (dose.exercise == state.candidate.exerciseId && dose.equipment == state.candidate.equipmentId && + state.candidate.equipmentLoadSemantics == EquipmentLoadSemantics.EXTERNAL && + (dose.loadMode == SessionLoadMode.EXTERNAL || actualOnly) && dose.qualifying[index] >= goal.sets && + dose.time.within(reference, policy.loadLookbackSeconds, inclusive = true)) { + val candidate = Anchor(dose.minimum[index], dose.time, dose.timeText, dose.session, dose.occurrence) + if (state.anchor == null || candidate.time > state.anchor!!.time) state.anchor = candidate + } + } + occurrence = null + previousSetPosition = null + } + fun finishSession() { if (session24) sessions24++; if (session72) sessions72++; session24 = false; session72 = false } + + history.read { row -> + val instant = try { ExactInstant.parse(row.startedAt) } catch (error: IllegalArgumentException) { + throw IllegalStateException("invalid stored session timestamp", error) + } + if (currentSession != row.sessionId) { finishOccurrence(); finishSession(); currentSession = row.sessionId } + if (occurrence?.occurrence != row.occurrenceId) { + finishOccurrence() + occurrence = OccurrenceDose(row.sessionId, row.occurrenceId, row.exerciseId, row.equipmentId, + instant, row.startedAt, row.loadMode, row.restSeconds, row.hasAnyTarget, + IntArray(states.size), DoubleArray(states.size) { Double.POSITIVE_INFINITY }) + } else require(occurrence?.session == row.sessionId && occurrence?.exercise == row.exerciseId && occurrence?.time == instant) { + "inconsistent occurrence rows" + } + if (row.hasExplicitMax && row.equipmentId != null && instant <= reference) states.forEach { state -> + if (row.exerciseId == state.candidate.exerciseId && row.equipmentId == state.candidate.equipmentId) + state.hasCompatibleExplicitMax = true + } + val position = row.setPosition ?: return@read + require(position >= 0 && previousSetPosition != position) { "duplicate/invalid performed-set identity" } + previousSetPosition = position + val repetitions = row.repetitions ?: return@read + if (row.recordingMode != RecordingMode.SETS || row.trackingMode != TrackingMode.REPS || repetitions <= 0 || instant > reference) return@read + val interpretation = knowledge.getExerciseKnowledge(row.exerciseId)?.interpretation + val isPrimary = interpretation?.let { zoneMatches(it.primaryZoneId, request.zoneId) } == true + val isSecondary = !isPrimary && interpretation?.secondaryZoneIds?.any { zoneMatches(it, request.zoneId) } == true + val in24 = instant.within(reference, policy.shortWindowSeconds, inclusive = false) + val in72 = instant.within(reference, policy.longWindowSeconds, inclusive = false) + if (interpretation == null) unclassified++ + if (isPrimary || isSecondary) { + if (in24) { if (isPrimary) primary24++ else secondary24++; session24 = true; patterns24 += interpretation.patternIds } + if (in72) { if (isPrimary) primary72++ else secondary72++; session72 = true; patterns72 += interpretation.patternIds } + val prior = latest + if (prior == null || instant > prior.first || (instant == prior.first && + (row.sessionId > prior.second.sessionId || row.sessionId == prior.second.sessionId && row.occurrenceId > prior.second.occurrenceId))) + latest = Triple(instant, row, interpretation.patternIds.sorted()) + } + states.forEachIndexed { index, state -> + val candidateInterpretation = interpretation + val cp = candidateInterpretation?.primaryZoneId == state.candidate.primaryZoneId + val cs = !cp && candidateInterpretation?.secondaryZoneIds?.any { zoneMatches(it, state.candidate.primaryZoneId) } == true + if (in24) { if (cp) state.primary24++ else if (cs) state.secondary24++ } + if (in72) { if (cp) state.primary72++ else if (cs) state.secondary72++ } + val secondaryPrimary = candidateInterpretation?.let { interpretation -> + state.candidate.secondaryZoneIds.any { zoneMatches(interpretation.primaryZoneId, it) } + } == true + val secondaryMatch = secondaryPrimary || candidateInterpretation?.let { interpretation -> + state.candidate.secondaryZoneIds.any { zone -> interpretation.secondaryZoneIds.any { zoneMatches(it, zone) } } + } == true + if (secondaryMatch && in24) { if (secondaryPrimary) state.secondaryZonePrimary24++ else state.secondaryZoneSecondary24++ } + if (secondaryMatch && in72) { if (secondaryPrimary) state.secondaryZonePrimary72++ else state.secondaryZoneSecondary72++ } + if (row.exerciseId == state.candidate.exerciseId && instant.within(reference, policy.sameExerciseWindowSeconds, false)) state.recentExercise = true + if (candidateInterpretation != null && candidateInterpretation.patternIds.any(state.candidate.patternIds::contains) && + instant.within(reference, policy.samePatternWindowSeconds, false)) state.recentPattern = true + val weight = row.weightKg + if (row.exerciseId == state.candidate.exerciseId && row.equipmentId == state.candidate.equipmentId && + repetitions >= goal.repetitions && weight != null && weight.isFinite() && weight > 0.0) { + occurrence!!.qualifying[index]++ + occurrence!!.minimum[index] = minOf(occurrence!!.minimum[index], weight) + } + } + } + finishOccurrence(); finishSession() + val recent = primary24 >= policy.recentPrimaryThreshold || secondary24 >= policy.recentSecondaryThreshold + val repeated = primary72 >= policy.repeatedPrimaryThreshold || secondary72 >= policy.repeatedSecondaryThreshold + val warning = when { + primary24 >= policy.recentPrimaryThreshold || primary72 >= policy.repeatedPrimaryThreshold -> GenerationWarningLevel.WARNING + secondary24 >= policy.recentSecondaryThreshold || secondary72 >= policy.repeatedSecondaryThreshold -> GenerationWarningLevel.NOTICE + else -> GenerationWarningLevel.NONE + } + val exposure = BodyZoneRecentExposure(ExposureWindowSummary(primary24, secondary24, sessions24, patterns24.toList()), + ExposureWindowSummary(primary72, secondary72, sessions72, patterns72.toList()), recent, repeated, warning, + latest?.second?.startedAt, latest?.second?.sessionId, latest?.second?.occurrenceId, latest?.third.orEmpty(), unclassified) + return select(request, goal, states, exposure) + } + + private fun select(request: GenerationRequest, goal: GenerationGoalPolicy, states: List, + exposure: BodyZoneRecentExposure): GeneratedSessionSuggestion { + val selected = mutableListOf() + val used = mutableSetOf() + val usedPatterns = mutableSetOf() + val exerciseSeconds = Math.addExact(policy.setupSeconds, Math.addExact( + Math.multiplyExact(Math.multiplyExact(goal.sets, goal.repetitions), policy.repetitionSeconds), + Math.multiplyExact(goal.sets - 1, goal.restSeconds))) + var total = policy.preparationSeconds + while (selected.size < policy.maxExercises) { + val eligible = states.indices.filter { index -> + val state = states[index] + index !in used && state.candidate.exerciseId !in request.excludedExerciseIds && + state.candidate.patternIds.none(request.excludedPatternIds::contains) && + state.candidate.patternIds.none(usedPatterns::contains) && + candidateMatchesZone(state.candidate, request.zoneId) && total <= request.durationMinutes * 60 - exerciseSeconds + } + val best = eligible.maxWithOrNull(Comparator { left, right -> + val priorityOrder = coveragePriority(states[left].candidate, selected, request.zoneId).compareTo( + coveragePriority(states[right].candidate, selected, request.zoneId)) + val scoreOrder = score(states[left], request, selected).compareTo(score(states[right], request, selected)) + if (priorityOrder != 0) priorityOrder else if (scoreOrder != 0) scoreOrder else -tieCompare(states[left], states[right]) + }) ?: break + val state = states[best] + val anchor = state.anchor + val rationale = mutableListOf(if (anchor != null) "observed_repeated_dose_anchor" else if (state.hasCompatibleExplicitMax) + "explicit_max_present_no_numeric_prescription" else when (state.candidate.equipmentLoadSemantics) { + EquipmentLoadSemantics.ASSISTANCE -> "assistance_numeric_load_omitted" + else -> "numeric_load_absent" + }) + if (state.candidate.exerciseId in request.preferredExerciseIds) rationale += "preferred_exercise" + rationale += if (zoneMatches(state.candidate.primaryZoneId, request.zoneId)) "requested_primary_zone" else "requested_secondary_zone" + rationale += if (state.candidate.equipmentLoadSemantics == EquipmentLoadSemantics.EXTERNAL) + "external_equipment_context" else "non_external_equipment_context" + rationale += "new_exact_pattern" + if (state.recentExercise) rationale += "recent_same_exercise_penalty" + if (state.recentPattern) rationale += "recent_same_pattern_penalty" + selected += GeneratedSessionExercise(state.candidate.exerciseId, state.candidate.equipmentId, + state.candidate.equipmentLoadSemantics, state.candidate.primaryZoneId, state.candidate.secondaryZoneIds.sorted(), + state.candidate.patternIds.sorted(), goal.sets, goal.repetitions, goal.restSeconds, anchor?.weight, + if (anchor == null) SessionLoadMode.NONE else SessionLoadMode.EXTERNAL, exerciseSeconds, state.candidate.confidence, + TrainingRecencyWarning(state.recentExercise, state.recentPattern), exposure.warningLevel, rationale, + state.candidate.sourceRefIds.sorted(), anchor?.session, anchor?.occurrence, anchor?.text) + used += best; usedPatterns += state.candidate.patternIds; total = Math.addExact(total, exerciseSeconds) + } + val insufficient = selected.size < policy.maxExercises + val shortages = if (!insufficient) emptyList() else buildList { + add("insufficient_resolved_candidates") + if (request.zoneId == "full_body") { + fun region(primary: String) = when (primary) { "core" -> "core"; "chest", "back", "shoulders", "arms" -> "upper"; else -> "lower" } + val regions = selected.map { region(it.primaryZoneId) }.toSet() + if ("upper" !in regions) add("missing_upper_region") + if ("lower" !in regions) add("missing_lower_region") + if ("core" !in regions) add("missing_core_region") + } else if (request.zoneId == "upper_body") { + if (selected.none { it.patternIds.any(policy.upperPushPatterns::contains) }) add("missing_upper_push") + if (selected.none { it.patternIds.any(policy.upperPullPatterns::contains) }) add("missing_upper_pull") + } else if (request.zoneId == "lower_body") { + if (selected.none { it.patternIds.any(policy.lowerExtensionPatterns::contains) }) add("missing_lower_extension") + if (selected.none { it.patternIds.any(policy.lowerFlexionPatterns::contains) }) add("missing_lower_flexion") + } + } + return GeneratedSessionSuggestion(selected, total, insufficient, exposure, shortages) + } + + private fun score(state: CandidateState, request: GenerationRequest, + selected: List): Int { + val s = policy.scores + var value = if (zoneMatches(state.candidate.primaryZoneId, request.zoneId)) s.requestedPrimaryZone else s.requestedSecondaryZoneOnly + if (selected.none { it.primaryZoneId == state.candidate.primaryZoneId }) value += s.newPrimaryZone + if (state.candidate.patternIds.none { pattern -> selected.any { pattern in it.patternIds } }) value += s.newPattern + if (state.anchor != null) value += s.qualifyingWorkingLoadHistory + if (state.candidate.exerciseId in request.preferredExerciseIds) value += s.preferredExercise + if (state.recentExercise) value += s.recentSameExercise + if (state.recentPattern) value += s.recentSamePattern + if (state.primary24 >= policy.recentPrimaryThreshold) value += s.recentPrimaryThreshold + if (state.secondary24 >= policy.recentSecondaryThreshold) value += s.recentSecondaryThreshold + if (state.primary72 >= policy.repeatedPrimaryThreshold) value += s.repeatedPrimaryThreshold + if (state.secondary72 >= policy.repeatedSecondaryThreshold) value += s.repeatedSecondaryThreshold + if (state.secondaryZonePrimary24 >= policy.recentPrimaryThreshold || + state.secondaryZoneSecondary24 >= policy.recentSecondaryThreshold || + state.secondaryZonePrimary72 >= policy.repeatedPrimaryThreshold || + state.secondaryZoneSecondary72 >= policy.repeatedSecondaryThreshold) value += s.anySecondaryZoneExposure + return value + } + + private fun coveragePriority(candidate: GenerationCandidate, selected: List, zone: String): Int { + if (zone == "full_body") { + fun region(primary: String) = when (primary) { + "core" -> "core" + "chest", "back", "shoulders", "arms" -> "upper" + else -> "lower" + } + return if (selected.none { region(it.primaryZoneId) == region(candidate.primaryZoneId) }) 1 else 0 + } + if (zone == "upper_body") { + val pushSeen = selected.any { it.patternIds.any(policy.upperPushPatterns::contains) } + val pullSeen = selected.any { it.patternIds.any(policy.upperPullPatterns::contains) } + return if ((!pushSeen && candidate.patternIds.any(policy.upperPushPatterns::contains)) || + (!pullSeen && candidate.patternIds.any(policy.upperPullPatterns::contains))) 1 else 0 + } + if (zone == "lower_body") { + val extensionSeen = selected.any { it.patternIds.any(policy.lowerExtensionPatterns::contains) } + val flexionSeen = selected.any { it.patternIds.any(policy.lowerFlexionPatterns::contains) } + return if ((!extensionSeen && candidate.patternIds.any(policy.lowerExtensionPatterns::contains)) || + (!flexionSeen && candidate.patternIds.any(policy.lowerFlexionPatterns::contains))) 1 else 0 + } + return 0 + } + + private fun tieCompare(left: CandidateState, right: CandidateState): Int { + val exercise = left.candidate.exerciseId.compareTo(right.candidate.exerciseId) + if (exercise != 0) return exercise + if ((left.anchor != null) != (right.anchor != null)) return if (left.anchor != null) -1 else 1 + if (left.anchor != null && right.anchor != null && left.anchor!!.time != right.anchor!!.time) + return -left.anchor!!.time.compareTo(right.anchor!!.time) + return left.candidate.equipmentId.compareTo(right.candidate.equipmentId) + } + + private fun validateCandidate(candidate: GenerationCandidate) { + val record = requireNotNull(knowledge.getExerciseKnowledge(candidate.exerciseId)) + require(record.resolutionStatus == ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED && + record.confidence in setOf(KnowledgeConfidence.HIGH, KnowledgeConfidence.MODERATE) && + candidate.equipmentId in record.equipmentIds && + knowledge.getEquipmentKnowledge(candidate.equipmentId)?.catalogLoadSemantics == candidate.equipmentLoadSemantics) + val interpretation = requireNotNull(record.interpretation) + require(candidate.confidence == record.confidence && candidate.primaryZoneId == interpretation.primaryZoneId && + candidate.patternIds.isNotEmpty() && candidate.patternIds.size <= 16 && + candidate.patternIds.all { it in interpretation.patternIds }) + } + private fun candidateMatchesZone(candidate: GenerationCandidate, zone: String) = + zoneMatches(candidate.primaryZoneId, zone) || candidate.secondaryZoneIds.any { zoneMatches(it, zone) } + private fun zoneMatches(actual: String, requested: String) = actual in requireNotNull(policy.zoneExpansion[requested]) + + fun estimateExerciseSeconds(targetSets: Int, targetRepetitions: Int, restSeconds: Int): Int { + require(targetSets in 1..64 && targetRepetitions in 1..10_000 && restSeconds in 0..86_400) + return Math.addExact(policy.setupSeconds, Math.addExact( + Math.multiplyExact(Math.multiplyExact(targetSets, targetRepetitions), policy.repetitionSeconds), + Math.multiplyExact(targetSets - 1, restSeconds))) + } + + /** Requalifies one edited exercise only; selection order and other preview rows are untouched. */ + fun requalifyDose(candidate: GenerationCandidate, referenceTime: String, targetSets: Int, + targetRepetitions: Int, restSeconds: Int, history: GenerationHistoryReader): DoseQualification { + validateCandidate(candidate) + val reference = ExactInstant.parse(referenceTime) + var currentOccurrence: String? = null + var current: MutableList = mutableListOf() + var best: Anchor? = null + var hasMax = false + fun finish() { + if (current.isEmpty()) return + val first = current.first() + val instant = ExactInstant.parse(first.startedAt) + val positions = current.mapNotNull { it.setPosition } + require(positions.size == positions.toSet().size) { "duplicate performed-set identity" } + val actualOnly = first.loadMode == SessionLoadMode.NONE && first.restSeconds == 0 && !first.hasAnyTarget + val qualifying = current.filter { it.repetitions != null && it.repetitions >= targetRepetitions && + it.weightKg != null && it.weightKg.isFinite() && it.weightKg > 0.0 } + if (first.exerciseId == candidate.exerciseId && first.equipmentId == candidate.equipmentId && + candidate.equipmentLoadSemantics == EquipmentLoadSemantics.EXTERNAL && + (first.loadMode == SessionLoadMode.EXTERNAL || actualOnly) && qualifying.size >= targetSets && + instant.within(reference, policy.loadLookbackSeconds, true)) { + val anchor = Anchor(qualifying.minOf { it.weightKg!! }, instant, first.startedAt, first.sessionId, first.occurrenceId) + if (best == null || anchor.time > best!!.time) best = anchor + } + current = mutableListOf() + } + history.read { row -> + try { ExactInstant.parse(row.startedAt) } catch (error: IllegalArgumentException) { + throw IllegalStateException("invalid stored session timestamp", error) + } + val instant = ExactInstant.parse(row.startedAt) + if (row.hasExplicitMax && row.exerciseId == candidate.exerciseId && row.equipmentId == candidate.equipmentId && + instant <= reference) hasMax = true + if (currentOccurrence != row.occurrenceId) { finish(); currentOccurrence = row.occurrenceId } + current += row + } + finish() + val anchor = best + return DoseQualification(targetSets, targetRepetitions, restSeconds, anchor?.weight, + if (anchor == null) SessionLoadMode.NONE else SessionLoadMode.EXTERNAL, + estimateExerciseSeconds(targetSets, targetRepetitions, restSeconds), + if (anchor != null) "observed_repeated_dose_anchor" else if (hasMax) + "explicit_max_present_no_numeric_prescription" else if (candidate.equipmentLoadSemantics == EquipmentLoadSemantics.ASSISTANCE) + "assistance_numeric_load_omitted" else "numeric_load_absent", + anchor?.session, anchor?.occurrence, anchor?.text) + } +} + +/** Exact frozen timestamp key, including arbitrary fractions and offsets through 23:59. */ +private data class ExactInstant(val second: Long, val fraction: String) : Comparable { + override fun compareTo(other: ExactInstant): Int { + val seconds = second.compareTo(other.second); if (seconds != 0) return seconds + val count = maxOf(fraction.length, other.fraction.length) + return fraction.padEnd(count, '0').compareTo(other.fraction.padEnd(count, '0')) + } + fun within(reference: ExactInstant, seconds: Long, inclusive: Boolean): Boolean { + if (this > reference) return false + val delta = Math.subtractExact(reference.second, second) + return delta < seconds || delta == seconds && if (inclusive) reference.fraction <= fraction else reference.fraction < fraction + } + companion object { + private val regex = Regex("""^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?([Zz]|[+-]\d{2}:\d{2})$""") + fun parse(text: String): ExactInstant { + val match = requireNotNull(regex.matchEntire(text)) { "invalid timestamp" } + val (year, month, day, hour, minute, secondsText, fraction, zone) = match.destructured + val y = year.toInt(); val m = month.toInt(); val d = day.toInt(); val h = hour.toInt(); val min = minute.toInt(); val sec = secondsText.ifEmpty { "0" }.toInt() + require(y >= 1 && m in 1..12 && d in 1..daysInMonth(y, m) && h in 0..23 && min in 0..59 && sec in 0..59) + val sign = when (zone.first()) { '+' -> 1; '-' -> -1; else -> 0 } + val offset = if (sign == 0) 0 else { val oh = zone.substring(1, 3).toInt(); val om = zone.substring(4, 6).toInt(); require(oh <= 23 && om <= 59); sign * (oh * 3600 + om * 60) } + var days = (y - 1L) * 365 + (y - 1) / 4 - (y - 1) / 100 + (y - 1) / 400 + for (prior in 1 until m) days += daysInMonth(y, prior) + days += d - 1 + return ExactInstant(days * 86_400 + h * 3600 + min * 60 + sec - offset, fraction) + } + private fun daysInMonth(year: Int, month: Int): Int = intArrayOf(31, if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)[month - 1] + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationPolicy.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationPolicy.kt new file mode 100644 index 0000000..cec110f --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SessionGenerationPolicy.kt @@ -0,0 +1,185 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject + +data class GenerationGoalPolicy( + val sets: Int, val repetitions: Int, val restSeconds: Int, + val setsRange: IntRange, val repetitionsRange: IntRange, val restSecondsRange: IntRange, +) + +data class GenerationScorePolicy( + val requestedPrimaryZone: Int, val requestedSecondaryZoneOnly: Int, + val newPrimaryZone: Int, val newPattern: Int, val qualifyingWorkingLoadHistory: Int, + val preferredExercise: Int, val recentSameExercise: Int, val recentSamePattern: Int, + val recentPrimaryThreshold: Int, val recentSecondaryThreshold: Int, + val repeatedPrimaryThreshold: Int, val repeatedSecondaryThreshold: Int, + val anySecondaryZoneExposure: Int, +) + +data class SessionGenerationPolicy( + val goals: Map, val zoneExpansion: Map>, + val loadLookbackSeconds: Long, val shortWindowSeconds: Long, val longWindowSeconds: Long, + val sameExerciseWindowSeconds: Long, val samePatternWindowSeconds: Long, + val recentPrimaryThreshold: Int, val recentSecondaryThreshold: Int, + val repeatedPrimaryThreshold: Int, val repeatedSecondaryThreshold: Int, + val maxExercises: Int, val scores: GenerationScorePolicy, + val preparationSeconds: Int, val setupSeconds: Int, val repetitionSeconds: Int, + val customMinutes: IntRange, val durationPresets: List, + val upperPushPatterns: Set, val upperPullPatterns: Set, + val lowerExtensionPatterns: Set, val lowerFlexionPatterns: Set, +) + +/** Strict loader for the one authored policy asset. No policy number is repeated in Kotlin. */ +object SessionGenerationPolicyLoader { + private const val ASSET = "session-generation-policy-v1.json" + + fun load(context: Context, knowledge: TrainingKnowledgeCatalog, bodyZones: BodyZoneCatalog): SessionGenerationPolicy = + context.assets.open(ASSET).bufferedReader().use { load(it.readText(), knowledge, bodyZones) } + + internal fun load(text: String, knowledge: TrainingKnowledgeCatalog, bodyZones: BodyZoneCatalog): SessionGenerationPolicy { + SessionPolicyDuplicateKeys.validate(text) + val root = JSONObject(text) + root.keysExact(setOf("format", "version", "policy_id", "status", "scientific_review_date", "confidence", + "scope", "numeric_rule_status", "source_refs", "additional_references", "goals", + "goal_range_interpretation", "zone_expansion", "eligibility", "load", "exposure", + "selection", "duration", "guidance"), "policy") + check(root.text("format") == "trainlog-session-generation-policy-v1" && root.int("version", 1, 1) == 1 && + root.text("policy_id") == "session_generator_v1") { "unsupported session-generation policy" } + val knownRefs = knowledge.references.mapTo(mutableSetOf()) { it.refId } + root.array("additional_references").objects().forEach { row -> + row.keysExact(setOf("ref_id", "title", "authors_or_organization", "year", "pmid", "doi", "url", + "type", "notes", "limitations", "accessed_on"), "additional reference") + check(knownRefs.add(row.text("ref_id"))) { "duplicate policy reference" } + row.int("year", 1, 9999) + } + root.array("source_refs").strings().also { check(it.isNotEmpty() && it.all(knownRefs::contains)) } + + val goalsObject = root.obj("goals") + goalsObject.keysExact(setOf("general", "strength", "hypertrophy", "endurance"), "goals") + val goals = goalsObject.keys().asSequence().associateWith { id -> + val row = goalsObject.obj(id) + row.keysExact(setOf("sets", "repetitions", "rest_seconds", "sets_range", "repetitions_range", + "rest_seconds_range"), "goal $id") + fun range(key: String, max: Int): IntRange { + val values = row.array(key).ints(0, max) + check(values.size == 2 && values[0] <= values[1]) { "$key invalid" } + return values[0]..values[1] + } + GenerationGoalPolicy(row.int("sets", 1, 64), row.int("repetitions", 1, 10_000), + row.int("rest_seconds", 0, 86_400), range("sets_range", 64), + range("repetitions_range", 10_000), range("rest_seconds_range", 86_400)).also { + check(it.sets in it.setsRange && it.repetitions in it.repetitionsRange && it.restSeconds in it.restSecondsRange) + } + } + val knownZones = bodyZones.zones.mapTo(mutableSetOf()) { it.zoneId } + val expansions = root.obj("zone_expansion").let { value -> + value.keysExact(setOf("full_body", "upper_body", "chest", "back", "shoulders", "arms", "core", + "lower_body", "glutes", "thighs", "calves"), "zone expansion") + value.keys().asSequence().associateWith { key -> value.array(key).strings().also { + check(it.isNotEmpty() && it.size == it.toSet().size && it.all(knownZones::contains)) + } } + } + val knownPatterns = knowledge.movementPatterns.mapTo(mutableSetOf()) { it.patternId } + val load = root.obj("load") + val exposure = root.obj("exposure") + val selection = root.obj("selection") + root.obj("eligibility").keysExact(setOf("recording_mode", "tracking_mode", "knowledge_resolution", + "allowed_confidence", "require_runtime_exercise_id", "require_explicit_equipment_compatibility", + "unknown_conditional_and_unlinked_capabilities", "scientific_zone_role", "persisted_zone_disagreement", + "equipment_choice"), "eligibility") + load.keysExact(setOf("lookback_seconds", "window", "required_context", "priority", "working_rule", + "actual_load_mode_compatibility", "planned_load_mode", "working_confidence", "working_meaning", + "max_rule", "max_confidence", "max_only_does_not_create_performed_sets", "assistance", + "bodyweight_or_unknown_semantics", "machine_increment", "progression"), "load") + exposure.keysExact(setOf("short_window_seconds", "long_window_seconds", "window", "timestamp_policy", + "invalid_timestamp", "counted_rows", "excluded", "zone_source", "primary_secondary", + "requested_zone_aggregation", "summary_consistency", "recent_primary_sets_24h_threshold", + "recent_secondary_sets_24h_threshold", "repeated_primary_sets_72h_threshold", + "repeated_secondary_sets_72h_threshold", "status", "warning_levels", "unknown_history", + "user_continuation"), "exposure") + selection.keysExact(setOf("max_exercises", "max_per_exact_pattern", "duplicate_exercise", "pattern_overlap", + "optional_preferences", "recency", "score", "algorithm", "group_coverage", "upper_push_patterns", + "upper_pull_patterns", "lower_extension_patterns", "lower_flexion_patterns", "coverage_shortage"), "selection") + val recency = selection.obj("recency") + val score = selection.obj("score") + score.keysExact(setOf("requested_primary_zone", "requested_secondary_zone_only", "new_primary_zone", "new_pattern", + "qualifying_working_load_history", "preferred_exercise", "recent_same_exercise", "recent_same_pattern", + "recent_primary_threshold_on_candidate_primary", "recent_secondary_threshold_on_candidate_primary", + "repeated_primary_threshold_on_candidate_primary", "repeated_secondary_threshold_on_candidate_primary", + "any_exposure_flag_on_candidate_secondary_zones"), "score") + fun score(key: String) = score.int(key, -10_000, 10_000) + fun patterns(key: String) = selection.array(key).strings().also { + check(it.isNotEmpty() && it.size == it.toSet().size && it.all(knownPatterns::contains)) + }.toSet() + val duration = root.obj("duration") + duration.keysExact(setOf("presets_minutes", "custom_min_minutes", "custom_max_minutes", "preparation_seconds", + "setup_and_transition_seconds_per_exercise", "estimated_seconds_per_repetition", "exercise_seconds_formula", + "session_seconds_formula", "budget_rule", "precision"), "duration") + root.obj("guidance").keysExact(setOf("effort", "load", "rest", "recent_exposure"), "guidance") + return SessionGenerationPolicy(goals, expansions, load.long("lookback_seconds", 1, Int.MAX_VALUE.toLong()), + exposure.long("short_window_seconds", 1, Int.MAX_VALUE.toLong()), + exposure.long("long_window_seconds", 1, Int.MAX_VALUE.toLong()), + recency.long("same_exercise_window_seconds", 1, Int.MAX_VALUE.toLong()), + recency.long("same_pattern_window_seconds", 1, Int.MAX_VALUE.toLong()), + exposure.int("recent_primary_sets_24h_threshold", 1, Int.MAX_VALUE), + exposure.int("recent_secondary_sets_24h_threshold", 1, Int.MAX_VALUE), + exposure.int("repeated_primary_sets_72h_threshold", 1, Int.MAX_VALUE), + exposure.int("repeated_secondary_sets_72h_threshold", 1, Int.MAX_VALUE), + selection.int("max_exercises", 1, 64), + GenerationScorePolicy(score("requested_primary_zone"), score("requested_secondary_zone_only"), + score("new_primary_zone"), score("new_pattern"), score("qualifying_working_load_history"), + score("preferred_exercise"), score("recent_same_exercise"), score("recent_same_pattern"), + score("recent_primary_threshold_on_candidate_primary"), score("recent_secondary_threshold_on_candidate_primary"), + score("repeated_primary_threshold_on_candidate_primary"), score("repeated_secondary_threshold_on_candidate_primary"), + score("any_exposure_flag_on_candidate_secondary_zones")), + duration.int("preparation_seconds", 1, 86_400), + duration.int("setup_and_transition_seconds_per_exercise", 1, 86_400), + duration.int("estimated_seconds_per_repetition", 1, 86_400), + duration.int("custom_min_minutes", 1, 1440)..duration.int("custom_max_minutes", 1, 1440), + duration.array("presets_minutes").ints(1, 1440).also { + check(it.isNotEmpty() && it == it.distinct().sorted()) { "duration presets invalid" } + }, + patterns("upper_push_patterns"), patterns("upper_pull_patterns"), + patterns("lower_extension_patterns"), patterns("lower_flexion_patterns")) + } + + private fun JSONObject.keysExact(expected: Set, where: String) = + check(keys().asSequence().toSet() == expected) { "$where: invalid keys" } + private fun JSONObject.text(key: String) = get(key).let { check(it is String && it.isNotBlank()); it } + private fun JSONObject.obj(key: String) = get(key).let { check(it is JSONObject); it } + private fun JSONObject.array(key: String) = get(key).let { check(it is JSONArray); it } + private fun JSONObject.int(key: String, min: Int, max: Int): Int = get(key).let { + check(it is Int && it in min..max) { "$key: integer outside bounds" }; it + } + private fun JSONObject.long(key: String, min: Long, max: Long): Long = int(key, min.toInt(), max.toInt()).toLong() + private fun JSONArray.objects() = List(length()) { get(it).let { row -> check(row is JSONObject); row } } + private fun JSONArray.strings() = List(length()) { get(it).let { value -> check(value is String && value.isNotBlank()); value } } + private fun JSONArray.ints(min: Int, max: Int) = List(length()) { get(it).let { value -> check(value is Int && value in min..max); value } } +} + +/** org.json accepts duplicate names; this lexical pass makes deployment failure explicit. */ +private object SessionPolicyDuplicateKeys { + fun validate(source: String) = Parser(source).parse() + private class Parser(private val source: String) { + private var at = 0 + fun parse() { value(); ws(); check(at == source.length) } + private fun value() { ws(); check(at < source.length); when (source[at]) { + '{' -> obj(); '[' -> array(); '"' -> string(); 't' -> literal("true"); 'f' -> literal("false"); + 'n' -> literal("null"); else -> number() + } } + private fun obj() { at++; ws(); val keys = mutableSetOf(); if (take('}')) return + while (true) { val key = string(); check(keys.add(key)) { "duplicate JSON key: $key" }; ws(); expect(':'); value(); ws(); if (take('}')) return; expect(','); ws() } } + private fun array() { at++; ws(); if (take(']')) return; while (true) { value(); ws(); if (take(']')) return; expect(',') } } + private fun string(): String { expect('"'); val result = StringBuilder(); while (at < source.length) { val c = source[at++]; when (c) { + '"' -> return result.toString(); '\\' -> { check(at < source.length); val escaped = source[at++]; if (escaped == 'u') { check(at + 4 <= source.length); val hex = source.substring(at, at + 4); check(hex.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }); result.append(hex.toInt(16).toChar()); at += 4 } else { check(escaped in "\"\\/bfnrt"); result.append(escaped) } } + else -> { check(c.code >= 0x20); result.append(c) } + } }; error("truncated JSON string") } + private fun literal(value: String) { check(source.startsWith(value, at)); at += value.length } + private fun number() { if (take('-')) Unit; check(at < source.length && source[at].isDigit()); while (at < source.length && (source[at].isDigit() || source[at] in ".eE+-")) at++ } + private fun ws() { while (at < source.length && source[at].isWhitespace()) at++ } + private fun take(c: Char) = if (at < source.length && source[at] == c) { at++; true } else false + private fun expect(c: Char) = check(take(c)) { "expected $c at $at" } + } +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryModels.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryModels.kt new file mode 100644 index 0000000..1c6bcbb --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryModels.kt @@ -0,0 +1,62 @@ +package com.labfytools.trainlog.data + +import com.labfytools.trainlog.model.SessionExercisePlan + +data class SessionGenerationFormOptions( + val zoneIds: List, + val goalIds: List, + val durationPresets: List, + val customMinutes: IntRange, +) + +data class SessionGenerationRequest( + val zoneId: String, + val goalId: String, + val durationMinutes: Int, + val referenceTime: String, + /** Null means every compatible local context; empty means none available. */ + val availableEquipmentIds: Set? = null, + val preferredExerciseIds: Set = emptySet(), + val excludedExerciseIds: Set = emptySet(), + val excludedPatternIds: Set = emptySet(), +) + +data class SessionGenerationPreviewExercise( + val exerciseId: String, + val exerciseName: String, + val equipmentId: String, + val equipmentName: String, + val primaryZoneId: String, + val primaryZoneName: String, + val patternIds: List, + val patternNames: List, + val plan: SessionExercisePlan, + val estimatedSeconds: Int, + val recency: TrainingRecencyWarning, + val rationaleCodes: List, + val loadSourceSessionId: String?, + val loadSourceOccurrenceId: String?, + val loadSourceStartedAt: String?, +) + +data class SessionGenerationPreview( + val request: SessionGenerationRequest, + val exercises: List, + val estimatedDurationSeconds: Int, + val insufficientResolvedCandidates: Boolean, + val exposure: BodyZoneRecentExposure, + val shortageCodes: List = emptyList(), +) + +sealed interface SessionGenerationResult { + data class Generated(val preview: SessionGenerationPreview) : SessionGenerationResult + data class Invalid(val message: String) : SessionGenerationResult + data class DatabaseError(val message: String) : SessionGenerationResult +} + +sealed interface AcceptGeneratedSessionResult { + data object Accepted : AcceptGeneratedSessionResult + data object ExistingActiveDraft : AcceptGeneratedSessionResult + data class Invalid(val message: String) : AcceptGeneratedSessionResult + data class DatabaseError(val message: String) : AcceptGeneratedSessionResult +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt index 8ed34a1..7d3edf4 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncCatalogInbox.kt @@ -176,19 +176,28 @@ class SyncCatalogInbox( } private fun importPcSessions(directory: DocumentFile): String? { - val file = directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null + /* CONTRACT: a present V3 artifact is authoritative. Invalid V3 must + * surface its error and never fall back to a stale V2 snapshot. */ + val v3 = directory.findFile("trainlog-pc-mobile-export-v3.json") + val file = v3 ?: directory.findFile("trainlog-pc-mobile-export-v2.json") ?: return null return try { val json = appContext.contentResolver.openInputStream(file.uri) ?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } - ?: return "Lecture snapshot séances V2 impossible." - when (val result = repository.applyPcMobileExportV2Json(json)) { + ?: return "Lecture snapshot séances impossible." + val result = if (v3 != null) repository.applyPcMobileExportV3Json(json) + else repository.applyPcMobileExportV2Json(json) + when (result) { is MobileSessionImportResult.Applied -> null is MobileSessionImportResult.Invalid -> result.message - MobileSessionImportResult.DatabaseError -> "Erreur base locale séances V2." + MobileSessionImportResult.DatabaseError -> "Erreur base locale séances." } - } catch (error: Exception) { error.message ?: "Import séances V2 impossible." } + } catch (error: Exception) { error.message ?: "Import séances impossible." } } + /** Test seam for the real filename-priority boundary; production uses the same method. */ + internal fun importPcSessionsFromDirectoryForTest(directory: DocumentFile): String? = + importPcSessions(directory) + private fun importPcBodyZones(directory: DocumentFile): String? { val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null return try { diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt index ca44b1d..f1e48e2 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncExporter.kt @@ -49,9 +49,9 @@ class SyncExporter( val bodyZonesJson: String try { definitionsJson = repository.buildEquipmentDefinitionsJson() - /* V2 is the authoritative mobile session exchange. V1 remains + /* V3 is the authoritative mobile session exchange. V1/V2 remain * readable by desktop for historic devices but is not published. */ - mobileJson = repository.buildMobileExportV2Json() + mobileJson = repository.buildMobileExportV3Json() associationsJson = repository.buildEquipmentAssociationsJson() bodyZonesJson = repository.buildExerciseBodyZonesJson() } catch (error: Exception) { @@ -77,9 +77,9 @@ class SyncExporter( "/Trainlog/" val displayName = - "trainlog-mobile-export-v2.json" + "trainlog-mobile-export-v3.json" - /* Definitions are visible before any v2 file which may reference a + /* Definitions are visible before any session file which may reference a * custom ID; a failed definition write aborts the bundle. */ val definitionsError = writeEquipmentDefinitions(definitionsJson) if (definitionsError != null) return SyncExportResult.Error(definitionsError) diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index a9d35c6..877debb 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -3,8 +3,11 @@ package com.labfytools.trainlog.data import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteConstraintException +import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper +import android.util.JsonReader +import android.util.JsonToken import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.BodyObservationSummary @@ -20,12 +23,15 @@ import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionSummary import com.labfytools.trainlog.model.SessionDetail import com.labfytools.trainlog.model.SessionExerciseDetail +import com.labfytools.trainlog.model.SessionExercisePlan +import com.labfytools.trainlog.model.SessionLoadMode import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.TrackingMode import org.json.JSONArray import org.json.JSONObject import java.text.Normalizer +import java.io.StringReader import java.time.OffsetDateTime import java.util.Locale import java.util.UUID @@ -254,6 +260,10 @@ class TrainlogRepository( private val applicationContext = context.applicationContext private val bodyZones = BodyZoneCatalog.load(applicationContext) private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones) + private val sessionGenerationPolicy = + SessionGenerationPolicyLoader.load(applicationContext, trainingKnowledge, bodyZones) + private val sessionGenerationEngine = + SessionGenerationEngine(sessionGenerationPolicy, trainingKnowledge, bodyZones) private val database = TrainlogDatabaseHelper( applicationContext, @@ -339,6 +349,13 @@ class TrainlogRepository( /** Return manifest sort order; definitions are immutable application assets. */ fun listBodyZones(): List = bodyZones.zones + fun sessionGenerationFormOptions(): SessionGenerationFormOptions = SessionGenerationFormOptions( + zoneIds = sessionGenerationPolicy.zoneExpansion.keys.toList(), + goalIds = sessionGenerationPolicy.goals.keys.toList(), + durationPresets = sessionGenerationPolicy.durationPresets, + customMinutes = sessionGenerationPolicy.customMinutes, + ) + /** Stable-ID lookup; null means the ID is not part of taxonomy V1. */ fun bodyZone(zoneId: String): BodyZone? = bodyZones.lookup(zoneId) @@ -817,7 +834,7 @@ class TrainlogRepository( ): ActiveDraftMutationResult { if ( draft.exercises.any { - !validateSessionExercise(it, draft.sessionType) + !validateSessionExercise(it, draft.sessionType, allowTargetOnly = true) } || (draft.sourceSessionId != null && (draft.sourceSessionId.isBlank() || draft.sessionType != SessionType.MAX_TEST)) || @@ -1084,6 +1101,7 @@ class TrainlogRepository( .dataFields ) put("entry_id", exerciseDraft.entryId) + putSessionPlan(exerciseDraft.plan) val equipmentRowId = lookupEquipmentRowIdOrNull( db, exerciseDraft.equipmentId, @@ -1981,7 +1999,7 @@ class TrainlogRepository( fun buildMobileExportJson(): String = buildMobileExport(1) private fun buildMobileExport(version: Int): String { - require(version == 1 || version == 2) + require(version in 1..3) val root = JSONObject() root.put("format", "trainlog-mobile-export") root.put("version", version) @@ -2010,14 +2028,22 @@ class TrainlogRepository( ).use { sessions -> while (sessions.moveToNext()) { val sessionRowId = sessions.getLong(0) + val startedAt = sessions.getString(2) + /* CONTRACT: current V3 publication admits only the exact + * Trainlog timestamp language consumed by later analysis. */ + check(version != 3 || TrainlogTimestamp.parse(startedAt) != null) { + "started_at persistant invalide pour session_id=${sessions.getString(1)}" + } val session = JSONObject() .put("session_id", sessions.getString(1)) - .put("started_at", sessions.getString(2)) + .put("started_at", startedAt) .put("session_type", sessions.getString(3)) val sessionExercises = JSONArray() db.rawQuery( "SELECT se.id, e.exercise_id, e.name, se.recording_mode, se.tracking_mode, se.data_fields, " + - "se.entry_id,se.position,eq.equipment_id,mr.max_weight_kg " + + "se.entry_id,se.position,eq.equipment_id,mr.max_weight_kg," + + "se.load_mode,se.rest_seconds,se.target_sets,se.target_reps," + + "se.target_duration_seconds,se.target_weight_kg " + "FROM session_exercises AS se JOIN exercises AS e ON e.id = se.exercise_row_id " + "LEFT JOIN equipment AS eq ON eq.id=se.equipment_row_id " + "LEFT JOIN max_results AS mr ON mr.session_exercise_row_id=se.id " + @@ -2028,24 +2054,60 @@ class TrainlogRepository( val sessionExerciseRowId = exerciseCursor.getLong(0) val recording = exerciseCursor.getString(3) val tracking = exerciseCursor.getString(4) + val exportPlan = readSessionPlan(exerciseCursor, 10) + val hasPlan = exportPlan != null + if (version < 3 && exportPlan != null) { + error("Un plan de séance exige l'export mobile V3.") + } + if (exportPlan != null) { + check(recording == "sets" && exerciseCursor.isNull(9) && + exportPlan.sets in 1..MAX_PLAN_SETS && + exportPlan.restSeconds in 0..MAX_PLAN_REST_SECONDS && + if (tracking == "reps") { + exportPlan.reps in 1..MAX_PLAN_REPS && exportPlan.durationSeconds == null + } else { + exportPlan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS && exportPlan.reps == null + }) { "Plan de séance SQLite incohérent" } + check(if (exportPlan.weightKg == null) exportPlan.loadMode == SessionLoadMode.NONE + else exportPlan.weightKg.isFinite() && exportPlan.weightKg > 0.0 && + exportPlan.loadMode != SessionLoadMode.NONE) { + "Mode de charge du plan SQLite incohérent" + } + } val item = JSONObject() .put("exercise_id", exerciseCursor.getString(1)) .put("name", exerciseCursor.getString(2)) .put("recording_mode", recording) .put("tracking_mode", tracking) .put("data_fields", exerciseCursor.getInt(5)) - .put("load_mode", "none") - .put("rest_seconds", 0) + .put("load_mode", if (version == 3) exerciseCursor.getString(10) else "none") + .put("rest_seconds", if (version == 3) exerciseCursor.getInt(11) else 0) if (version == 2) { item.put("entry_id", exerciseCursor.getString(6)) item.put("position", exerciseCursor.getInt(7)) if (exerciseCursor.isNull(8)) item.put("equipment_id", JSONObject.NULL) else item.put("equipment_id", exerciseCursor.getString(8)) } + if (version == 3) { + item.put("entry_id", exerciseCursor.getString(6)) + item.put("position", exerciseCursor.getInt(7)) + if (exerciseCursor.isNull(8)) item.put("equipment_id", JSONObject.NULL) + else item.put("equipment_id", exerciseCursor.getString(8)) + if (!hasPlan) { + item.put("target", JSONObject.NULL) + } else { + val plan = checkNotNull(exportPlan) + val target = JSONObject().put("sets", plan.sets) + plan.reps?.let { target.put("reps", it) } + plan.durationSeconds?.let { target.put("duration_seconds", it) } + plan.weightKg?.let { target.put("weight_kg", it) } + item.put("target", target) + } + } if (!exerciseCursor.isNull(9)) { /* TRAINLOG_FORMAT_V1 is frozen and has no max * result shape. Refuse instead of inventing 1x1. */ - check(version == 2) { + check(version >= 2) { "Un résultat max explicite exige l'export mobile V2." } item.put("max_weight_kg", exerciseCursor.getDouble(9)) @@ -2086,7 +2148,7 @@ class TrainlogRepository( } else { set.put("duration_seconds", setCursor.getInt(1)) } - if (version == 2 && !setCursor.isNull(2)) { + if (version >= 2 && !setCursor.isNull(2)) { set.put("weight_kg", setCursor.getDouble(2)) } sets.put(set) @@ -2116,9 +2178,13 @@ class TrainlogRepository( "left_thigh_cm", "right_thigh_cm", "left_calf_cm", "right_calf_cm", ) while (cursor.moveToNext()) { + val observedAt = cursor.getString(1) + check(version != 3 || TrainlogTimestamp.parse(observedAt) != null) { + "observed_at persistant invalide pour observation_id=${cursor.getString(0)}" + } val item = JSONObject() .put("observation_id", cursor.getString(0)) - .put("observed_at", cursor.getString(1)) + .put("observed_at", observedAt) for (index in names.indices) { val column = index + 2 if (!cursor.isNull(column)) { @@ -2139,12 +2205,25 @@ class TrainlogRepository( */ fun buildMobileExportV2Json(): String = buildMobileExport(2) + /** Current occurrence exchange; planning and actual rows travel atomically. */ + fun buildMobileExportV3Json(): String = buildMobileExport(3) + /** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */ fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult { + return applyPcMobileExportJson(json, 2) + } + + fun applyPcMobileExportV3Json(json: String): MobileSessionImportResult = + applyPcMobileExportJson(json, 3) + + private fun applyPcMobileExportJson(json: String, version: Int): MobileSessionImportResult { + if (!hasStrictJsonShape(json)) { + return MobileSessionImportResult.Invalid("Snapshot séances JSON invalide ou champ dupliqué.") + } val root = try { JSONObject(json) } catch (_: Exception) { return MobileSessionImportResult.Invalid("Snapshot séances JSON invalide.") } - validatePcMobileExportV2(root)?.let { return MobileSessionImportResult.Invalid(it) } + validatePcMobileExport(root, version)?.let { return MobileSessionImportResult.Invalid(it) } val sessions = root.getJSONArray("sessions") val db = database.writableDatabase var sessionsAdded = 0 @@ -2181,7 +2260,7 @@ class TrainlogRepository( if (it.moveToFirst()) it.getLong(0) else null } if (existingRowId != null) { - if (pcSessionV2Matches(db, existingRowId, session)) { + if (pcSessionMatches(db, existingRowId, session, version)) { sessionsSkipped += 1 continue } @@ -2226,6 +2305,20 @@ class TrainlogRepository( put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId) put("position", position); put("recording_mode", recording); put("tracking_mode", tracking) put("data_fields", entry.optInt("data_fields", 0)) + if (version == 3) { + val target = entry.optJSONObject("target") + put("load_mode", entry.getString("load_mode")) + put("rest_seconds", entry.getInt("rest_seconds")) + if (target == null) { + putNull("target_sets"); putNull("target_reps") + putNull("target_duration_seconds"); putNull("target_weight_kg") + } else { + put("target_sets", target.getInt("sets")) + if (target.has("reps")) put("target_reps", target.getInt("reps")) else putNull("target_reps") + if (target.has("duration_seconds")) put("target_duration_seconds", target.getInt("duration_seconds")) else putNull("target_duration_seconds") + if (target.has("weight_kg")) put("target_weight_kg", target.getDouble("weight_kg")) else putNull("target_weight_kg") + } + } if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId) } val occurrence = db.insertOrThrow("session_exercises", null, values) @@ -2299,14 +2392,17 @@ class TrainlogRepository( } finally { db.endTransaction() } } - private fun validatePcMobileExportV2(root: JSONObject): String? { + private fun validatePcMobileExport(root: JSONObject, version: Int): String? { val rootKeys = setOf("format", "version", "generated_at", "exercises", "sessions", "body_observations") if (!root.hasExactKeys(rootKeys) || root.value("format") != "trainlog-mobile-export" || - !root.value("version").isJsonInt(2, 2) || !root.value("generated_at").isNonemptyJsonString() || + !root.value("version").isJsonInt(version, version) || !root.value("generated_at").isNonemptyJsonString() || root.value("exercises") !is JSONArray || root.value("sessions") !is JSONArray || root.value("body_observations") !is JSONArray) { return "Snapshot séances V2 invalide." } + if (version == 3 && TrainlogTimestamp.parse(root.getString("generated_at")) == null) { + return "Snapshot séances V3 invalide: generated_at invalide." + } return try { val exerciseIds = mutableSetOf() val exerciseProfiles = mutableMapOf>() @@ -2340,6 +2436,9 @@ class TrainlogRepository( session.value("session_type") !in setOf("training", "max_test") || entries == null || entries.length() == 0) { return "Session V2 invalide." } + if (version == 3 && TrainlogTimestamp.parse(session.getString("started_at")) == null) { + return "Session V3 invalide: sessions[$sessionIndex].started_at invalide." + } val entryIds = mutableSetOf() val positions = mutableSetOf() for (entryIndex in 0 until entries.length()) { @@ -2347,7 +2446,7 @@ class TrainlogRepository( val recording = entry.value("recording_mode") val tracking = entry.value("tracking_mode") val hasMax = entry.has("max_weight_kg") - val expectedKeys = entryBaseKeys + when { + val expectedKeys = entryBaseKeys + (if (version == 3) setOf("target") else emptySet()) + when { hasMax -> setOf("max_weight_kg") recording == "continuous" -> setOf("continuous") else -> setOf("sets") @@ -2357,7 +2456,7 @@ class TrainlogRepository( val positionValue = entry.value("position") if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) || !exerciseId.isNonemptyJsonString() || exerciseId !in exerciseIds || !entry.value("name").isNonemptyJsonString() || - !validJsonProfile(entry) || entry.value("load_mode") != "none" || !entry.value("rest_seconds").isJsonInt(0, 0) || + !validJsonProfile(entry) || !validEntryPlan(entry, version, recording as String, tracking as String, hasMax) || !positionValue.isJsonInt(0, 100000) || !positions.add((positionValue as Number).toInt()) || !(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) { return "Entrée de séance V2 invalide." @@ -2426,6 +2525,9 @@ class TrainlogRepository( presentMetrics.isEmpty() || presentMetrics.any { !item.value(it).isPositiveJsonNumber() }) { return "Observation corporelle V2 invalide." } + if (version == 3 && TrainlogTimestamp.parse(item.getString("observed_at")) == null) { + return "Observation corporelle V3 invalide: body_observations[$index].observed_at invalide." + } } null } catch (_: Exception) { @@ -2444,6 +2546,74 @@ class TrainlogRepository( !(recording == "sets" && (dataFields as Number).toInt() != 0) } + private fun hasStrictJsonShape(json: String): Boolean = try { + JsonReader(StringReader(json)).use { reader -> + reader.isLenient = false + fun readValue() { + when (reader.peek()) { + JsonToken.BEGIN_OBJECT -> { + reader.beginObject() + val names = mutableSetOf() + while (reader.hasNext()) { + check(names.add(reader.nextName())) { "duplicate JSON key" } + readValue() + } + reader.endObject() + } + JsonToken.BEGIN_ARRAY -> { + reader.beginArray() + while (reader.hasNext()) readValue() + reader.endArray() + } + JsonToken.STRING -> reader.nextString() + JsonToken.NUMBER -> { + val value = reader.nextString().toDouble() + check(value.isFinite()) { "non-finite JSON number" } + } + JsonToken.BOOLEAN -> reader.nextBoolean() + JsonToken.NULL -> reader.nextNull() + else -> error("unexpected JSON token") + } + } + readValue() + check(reader.peek() == JsonToken.END_DOCUMENT) { "trailing JSON" } + } + true + } catch (_: Exception) { + false + } + + private fun validEntryPlan( + entry: JSONObject, + version: Int, + recording: String, + tracking: String, + hasMax: Boolean, + ): Boolean { + if (version == 2) { + return entry.value("load_mode") == "none" && entry.value("rest_seconds").isJsonInt(0, 0) + } + val loadMode = entry.value("load_mode") + val rest = entry.value("rest_seconds") + if (loadMode !in setOf("none", "external", "assistance") || + !rest.isJsonInt(0, MAX_PLAN_REST_SECONDS)) return false + val targetValue = entry.value("target") + if (targetValue === JSONObject.NULL) { + return loadMode == "none" && rest.isJsonInt(0, 0) + } + if (recording != "sets" || hasMax) return false + val target = targetValue as? JSONObject ?: return false + val metric = if (tracking == "reps") "reps" else "duration_seconds" + val otherMetric = if (tracking == "reps") "duration_seconds" else "reps" + val allowed = setOf("sets", metric, "weight_kg") + if (!target.hasOnlyKeys(allowed, setOf("sets", metric)) || target.has(otherMetric) || + !target.value("sets").isJsonInt(1, MAX_PLAN_SETS) || + !target.value(metric).isJsonInt(1, if (tracking == "reps") MAX_PLAN_REPS else MAX_PLAN_DURATION_SECONDS)) return false + val hasWeight = target.has("weight_kg") + if (hasWeight && !target.value("weight_kg").isPositiveJsonNumber()) return false + return if (hasWeight) loadMode == "external" || loadMode == "assistance" else loadMode == "none" + } + private fun JSONObject.value(key: String): Any? = if (has(key)) get(key) else null private fun JSONObject.hasExactKeys(expected: Set): Boolean = keys().asSequence().toSet() == expected private fun JSONObject.hasOnlyKeys(allowed: Set, required: Set): Boolean { @@ -2460,7 +2630,7 @@ class TrainlogRepository( private fun Any?.isNonnegativeJsonNumber(): Boolean = this is Number && toDouble().isFinite() && toDouble() >= 0.0 - private fun pcSessionV2Matches(db: SQLiteDatabase, rowId: Long, session: JSONObject): Boolean { + private fun pcSessionMatches(db: SQLiteDatabase, rowId: Long, session: JSONObject, version: Int): Boolean { val headerMatches = db.rawQuery("SELECT started_at,session_type FROM sessions WHERE id=?", arrayOf(rowId.toString())).use { it.moveToFirst() && it.getString(0) == session.optString("started_at") && it.getString(1) == session.optString("session_type") } @@ -2469,21 +2639,34 @@ class TrainlogRepository( val rows = mutableListOf() val metadata = mutableListOf>() db.rawQuery( - "SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,se.tracking_mode,se.data_fields,eq.equipment_id " + + "SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,se.tracking_mode,se.data_fields,eq.equipment_id," + + "se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg " + "FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN equipment eq ON eq.id=se.equipment_row_id " + "WHERE se.session_row_id=? ORDER BY se.position", arrayOf(rowId.toString())).use { cursor -> while (cursor.moveToNext()) { rows += cursor.getLong(0) - metadata += listOf(cursor.getString(1), cursor.getInt(2), cursor.getString(3), cursor.getString(4), + val base = mutableListOf(cursor.getString(1), cursor.getInt(2), cursor.getString(3), cursor.getString(4), cursor.getString(5), cursor.getInt(6), if (cursor.isNull(7)) null else cursor.getString(7)) + if (version == 2 && (cursor.getString(8) != "none" || cursor.getInt(9) != 0 || + (10..13).any { !cursor.isNull(it) })) return false + if (version == 3) base.addAll(listOf(cursor.getString(8), cursor.getInt(9), + if (cursor.isNull(10)) null else cursor.getInt(10), if (cursor.isNull(11)) null else cursor.getInt(11), + if (cursor.isNull(12)) null else cursor.getInt(12), if (cursor.isNull(13)) null else cursor.getDouble(13))) + metadata += base } } if (rows.size != incoming.length()) return false for (index in rows.indices) { val item = incoming.getJSONObject(index) - val expected = listOf(item.optString("entry_id"), item.optInt("position", -1), item.optString("exercise_id"), + val expected = mutableListOf(item.optString("entry_id"), item.optInt("position", -1), item.optString("exercise_id"), item.optString("recording_mode"), item.optString("tracking_mode"), item.optInt("data_fields", -1), if (item.isNull("equipment_id")) null else item.optString("equipment_id")) + if (version == 3) { + val target = item.optJSONObject("target") + expected.addAll(listOf(item.getString("load_mode"), item.getInt("rest_seconds"), + target?.getInt("sets"), target?.optIntOrNull("reps"), + target?.optIntOrNull("duration_seconds"), target?.optDoubleOrNull("weight_kg"))) + } if (metadata[index] != expected) return false if (item.has("max_weight_kg")) { val current = db.rawQuery( @@ -3039,6 +3222,12 @@ class TrainlogRepository( se.data_fields, eq.equipment_id, eq.display_name, + se.load_mode, + se.rest_seconds, + se.target_sets, + se.target_reps, + se.target_duration_seconds, + se.target_weight_kg, mr.max_weight_kg FROM session_exercises AS se JOIN sessions AS s @@ -3088,7 +3277,8 @@ class TrainlogRepository( cursor.getInt(6) val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7) val equipmentDisplayName = if (cursor.isNull(8)) null else cursor.getString(8) - val maxWeightKg = if (cursor.isNull(9)) null else cursor.getDouble(9) + val plan = readSessionPlan(cursor, 9) + val maxWeightKg = if (cursor.isNull(15)) null else cursor.getDouble(15) if (maxWeightKg != null) { exercises += @@ -3101,6 +3291,7 @@ class TrainlogRepository( recordingMode = recording, trackingMode = tracking, dataFields = dataFields, + plan = plan, maxWeightKg = maxWeightKg, ) } else if ( @@ -3148,6 +3339,7 @@ class TrainlogRepository( tracking, dataFields = dataFields, + plan = plan, continuousDurationSeconds = continuous .getInt(0), @@ -3240,6 +3432,7 @@ class TrainlogRepository( tracking, dataFields = dataFields, + plan = plan, sets = sets, ) } @@ -3252,6 +3445,288 @@ class TrainlogRepository( ) } + /** + * Build one transient suggestion from current runtime identities and the + * complete actual history. No draft/history row is written by this path. + */ + fun generateSessionPreview(request: SessionGenerationRequest): SessionGenerationResult { + if (request.referenceTime.isBlank() || request.durationMinutes !in sessionGenerationPolicy.customMinutes || + request.zoneId !in sessionGenerationPolicy.zoneExpansion || + request.goalId !in sessionGenerationPolicy.goals) { + return SessionGenerationResult.Invalid("Demande de génération invalide.") + } + val db = database.readableDatabase + val ownsTransaction = !db.inTransaction() + return try { + if (ownsTransaction) db.beginTransactionNonExclusive() + val runtime = mutableMapOf, Pair>() + val candidates = mutableListOf() + db.rawQuery( + "SELECT e.exercise_id,e.name,eq.equipment_id,eq.display_name,eq.load_semantics " + + "FROM exercises e CROSS JOIN equipment eq " + + "WHERE e.recording_mode='sets' AND e.tracking_mode='reps' ORDER BY e.exercise_id,eq.equipment_id;", + null, + ).use { cursor -> + while (cursor.moveToNext()) { + val exerciseId = cursor.requiredText(0, "exercise_id") + val equipmentId = cursor.requiredText(2, "equipment_id") + if (request.availableEquipmentIds != null && equipmentId !in request.availableEquipmentIds) continue + val record = trainingKnowledge.getExerciseKnowledge(exerciseId) ?: continue + val interpretation = record.interpretation ?: continue + if (record.resolutionStatus != ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED || + record.confidence !in setOf(KnowledgeConfidence.HIGH, KnowledgeConfidence.MODERATE) || + equipmentId !in record.equipmentIds) continue + val equipment = trainingKnowledge.getEquipmentKnowledge(equipmentId) ?: continue + val runtimeSemantics = parseLoadSemantics(cursor.requiredText(4, "load_semantics")) + if (equipment.catalogLoadSemantics != null && equipment.catalogLoadSemantics != runtimeSemantics) continue + candidates += GenerationCandidate( + exerciseId, equipmentId, interpretation.primaryZoneId, + interpretation.secondaryZoneIds, interpretation.patternIds, + (record.sourceRefs + interpretation.sourceRefs + equipment.sourceRefs).distinct().sorted(), + record.confidence, equipment.catalogLoadSemantics, + ) + runtime[exerciseId to equipmentId] = + cursor.requiredText(1, "exercise_name") to cursor.requiredText(3, "equipment_name") + } + } + if (candidates.size > 64) { + return SessionGenerationResult.Invalid("Trop de contextes compatibles pour une génération bornée.") + } + val engineRequest = GenerationRequest( + request.zoneId, request.goalId, request.durationMinutes, request.referenceTime, + candidates, request.preferredExerciseIds, request.excludedExerciseIds, + request.excludedPatternIds, + ) + val suggestion = sessionGenerationEngine.generate(engineRequest) { visitor -> + streamGenerationHistory(db, visitor) + } + val previewExercises = suggestion.exercises.map { generated -> + val labels = checkNotNull(runtime[generated.exerciseId to generated.equipmentId]) { + "Contexte généré absent de la vue runtime" + } + val zoneName = checkNotNull(bodyZones.lookup(generated.primaryZoneId)) { + "Zone générée inconnue" + }.displayName + SessionGenerationPreviewExercise( + generated.exerciseId, labels.first, generated.equipmentId, labels.second, + generated.primaryZoneId, zoneName, generated.patternIds, + generated.patternIds.map { pattern -> + checkNotNull(trainingKnowledge.getMovementPattern(pattern)).displayNameFr + }, + SessionExercisePlan( + generated.targetSets, reps = generated.targetRepetitions, + weightKg = generated.targetWeightKg, + loadMode = generated.plannedLoadMode, restSeconds = generated.restSeconds, + ), + generated.estimatedSeconds, generated.recency, generated.rationaleCodes, + generated.loadSourceSessionId, generated.loadSourceOccurrenceId, + generated.loadSourceStartedAt, + ) + } + if (ownsTransaction) db.setTransactionSuccessful() + SessionGenerationResult.Generated(SessionGenerationPreview( + request, previewExercises, suggestion.estimatedDurationSeconds, + suggestion.insufficientResolvedCandidates, suggestion.exposure, suggestion.shortageCodes, + )) + } catch (error: IllegalArgumentException) { + SessionGenerationResult.Invalid(error.message ?: "Demande de génération invalide.") + } catch (error: Exception) { + SessionGenerationResult.DatabaseError(error.message ?: "Analyse de l'historique impossible.") + } finally { + if (ownsTransaction && db.inTransaction()) db.endTransaction() + } + } + + /** Atomically install a generated suggestion as the ordinary singleton draft. */ + fun acceptGeneratedSession(preview: SessionGenerationPreview): AcceptGeneratedSessionResult { + if (preview.exercises.isEmpty()) return AcceptGeneratedSessionResult.Invalid("La proposition est vide.") + val db = database.writableDatabase + return try { + db.beginTransaction() + val exists = db.rawQuery("SELECT 1 FROM active_session_draft WHERE id=?;", + arrayOf(ACTIVE_DRAFT_ID.toString())).use { it.moveToFirst() } + if (exists) return AcceptGeneratedSessionResult.ExistingActiveDraft + val exercises = preview.exercises.mapIndexed { index, item -> + check(index <= 100000) + val row = findExerciseRow(db, "exercise_id=?", arrayOf(item.exerciseId)) + ?: return AcceptGeneratedSessionResult.Invalid("Exercice généré introuvable.") + if (row.recordingMode != RecordingMode.SETS || row.trackingMode != TrackingMode.REPS || row.dataFields != 0) + return AcceptGeneratedSessionResult.Invalid("Profil généré devenu incompatible.") + val runtimeEquipment = readRuntimeEquipment(db, item.equipmentId) + ?: return AcceptGeneratedSessionResult.Invalid("Équipement généré introuvable.") + val knowledge = trainingKnowledge.getExerciseKnowledge(item.exerciseId) + val equipmentKnowledge = trainingKnowledge.getEquipmentKnowledge(item.equipmentId) + if (knowledge == null || item.equipmentId !in knowledge.equipmentIds || + equipmentKnowledge == null || equipmentKnowledge.catalogLoadSemantics != runtimeEquipment.second) + return AcceptGeneratedSessionResult.Invalid("Contexte scientifique généré devenu incompatible.") + val profile = readExerciseProfileExact(db, item.exerciseId) + ?: return AcceptGeneratedSessionResult.Invalid("Exercice généré introuvable.") + val draft = SessionExerciseDraft( + entryId = "sxe_" + UUID.randomUUID(), exercise = profile, + equipmentId = item.equipmentId, plan = item.plan, sets = emptyList(), + ) + if (!validateSessionExercise(draft, SessionType.TRAINING, allowTargetOnly = true)) + return AcceptGeneratedSessionResult.Invalid("Cible générée invalide.") + check(runtimeEquipment.first > 0) + draft + } + persistActiveSessionDraft(db, ActiveSessionDraft(exercises = exercises)) + db.setTransactionSuccessful() + AcceptGeneratedSessionResult.Accepted + } catch (error: Exception) { + AcceptGeneratedSessionResult.DatabaseError(error.message ?: "Acceptation de la proposition impossible.") + } finally { + if (db.inTransaction()) db.endTransaction() + } + } + + fun editGeneratedDose( + preview: SessionGenerationPreview, + index: Int, + targetSets: Int, + targetRepetitions: Int, + restSeconds: Int, + manualWeightKg: Double?, + ): SessionGenerationResult { + val current = preview.exercises.getOrNull(index) + ?: return SessionGenerationResult.Invalid("Exercice de proposition introuvable.") + if (targetSets !in 1..MAX_PLAN_SETS || targetRepetitions !in 1..MAX_PLAN_REPS || + restSeconds !in 0..MAX_PLAN_REST_SECONDS || + (manualWeightKg != null && (!manualWeightKg.isFinite() || manualWeightKg <= 0.0))) + return SessionGenerationResult.Invalid("Dose cible invalide.") + val db = database.readableDatabase + val ownsTransaction = !db.inTransaction() + return try { + if (ownsTransaction) db.beginTransactionNonExclusive() + val candidate = generationCandidate(db, current.exerciseId, current.equipmentId) + ?: return SessionGenerationResult.Invalid("Contexte généré devenu incompatible.") + val qualified = sessionGenerationEngine.requalifyDose( + candidate, preview.request.referenceTime, targetSets, targetRepetitions, restSeconds, + ) { visitor -> streamGenerationHistory(db, visitor) } + val semantics = candidate.equipmentLoadSemantics + val plan = if (manualWeightKg == null) { + SessionExercisePlan(qualified.targetSets, reps = qualified.targetRepetitions, + weightKg = qualified.targetWeightKg, loadMode = qualified.plannedLoadMode, + restSeconds = qualified.restSeconds) + } else { + val mode = when (semantics) { + EquipmentLoadSemantics.ASSISTANCE -> SessionLoadMode.ASSISTANCE + EquipmentLoadSemantics.EXTERNAL -> SessionLoadMode.EXTERNAL + else -> return SessionGenerationResult.Invalid("Cet équipement ne porte pas de charge cible manuelle.") + } + SessionExercisePlan(targetSets, reps = targetRepetitions, + weightKg = manualWeightKg, loadMode = mode, restSeconds = restSeconds) + } + val changed = current.copy( + plan = plan, + estimatedSeconds = sessionGenerationEngine.estimateExerciseSeconds( + targetSets, targetRepetitions, restSeconds), + rationaleCodes = if (manualWeightKg == null) listOf(qualified.rationaleCode) + else listOf("manual_target_load"), + loadSourceSessionId = if (manualWeightKg == null) qualified.sourceSessionId else null, + loadSourceOccurrenceId = if (manualWeightKg == null) qualified.sourceOccurrenceId else null, + loadSourceStartedAt = if (manualWeightKg == null) qualified.sourceStartedAt else null, + ) + val exercises = preview.exercises.toMutableList().also { it[index] = changed } + val total = Math.addExact(sessionGenerationPolicy.preparationSeconds, + exercises.fold(0) { sum, exercise -> Math.addExact(sum, exercise.estimatedSeconds) }) + if (ownsTransaction) db.setTransactionSuccessful() + SessionGenerationResult.Generated(preview.copy(exercises = exercises, estimatedDurationSeconds = total)) + } catch (error: IllegalArgumentException) { + SessionGenerationResult.Invalid(error.message ?: "Dose cible invalide.") + } catch (error: Exception) { + SessionGenerationResult.DatabaseError(error.message ?: "Réévaluation de charge impossible.") + } finally { + if (ownsTransaction && db.inTransaction()) db.endTransaction() + } + } + + private fun generationCandidate( + db: SQLiteDatabase, + exerciseId: String, + equipmentId: String, + ): GenerationCandidate? { + val record = trainingKnowledge.getExerciseKnowledge(exerciseId) ?: return null + val interpretation = record.interpretation ?: return null + val equipment = trainingKnowledge.getEquipmentKnowledge(equipmentId) ?: return null + val runtimeEquipment = readRuntimeEquipment(db, equipmentId) ?: return null + if (record.resolutionStatus != ExerciseKnowledgeStatus.RESOLVED_FAMILY_VARIANT_LIMITED || + record.confidence !in setOf(KnowledgeConfidence.HIGH, KnowledgeConfidence.MODERATE) || + equipmentId !in record.equipmentIds || + equipment.catalogLoadSemantics != runtimeEquipment.second) return null + return GenerationCandidate(exerciseId, equipmentId, interpretation.primaryZoneId, + interpretation.secondaryZoneIds, interpretation.patternIds, + (record.sourceRefs + interpretation.sourceRefs + equipment.sourceRefs).distinct().sorted(), + record.confidence, equipment.catalogLoadSemantics) + } + + private fun readRuntimeEquipment( + db: SQLiteDatabase, + equipmentId: String, + ): Pair? = db.rawQuery( + "SELECT id,load_semantics FROM equipment WHERE equipment_id=?;", + arrayOf(equipmentId), + ).use { cursor -> + if (!cursor.moveToFirst()) null else cursor.getLong(0) to + parseLoadSemantics(cursor.requiredText(1, "load_semantics")) + } + + private fun streamGenerationHistory(db: SQLiteDatabase, visitor: (GenerationHistoryRow) -> Unit) { + db.rawQuery( + "SELECT s.session_id,se.entry_id,e.exercise_id,s.started_at,eq.equipment_id," + + "se.recording_mode,se.tracking_mode,se.load_mode,se.rest_seconds," + + "se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg," + + "ps.position,ps.reps,ps.weight_kg,CASE WHEN mr.session_exercise_row_id IS NULL THEN 0 ELSE 1 END " + + "FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " + + "JOIN exercises e ON e.id=se.exercise_row_id " + + "LEFT JOIN equipment eq ON eq.id=se.equipment_row_id " + + "LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " + + "LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id " + + "ORDER BY s.id,se.position,ps.position;", null, + ).use { cursor -> + while (cursor.moveToNext()) { + val recording = parseRecordingMode(cursor.requiredText(5, "recording_mode")) + val tracking = parseTrackingMode(cursor.requiredText(6, "tracking_mode")) + val loadMode = SessionLoadMode.fromWire(cursor.requiredText(7, "load_mode")) + val rest = checkedBoundedInt(cursor.getLong(8), 0, MAX_PLAN_REST_SECONDS, "rest_seconds") + val targetSets = if (cursor.isNull(9)) null else + checkedBoundedInt(cursor.getLong(9), 1, MAX_PLAN_SETS, "target_sets") + val targetReps = if (cursor.isNull(10)) null else + checkedBoundedInt(cursor.getLong(10), 1, MAX_PLAN_REPS, "target_reps") + val targetDuration = if (cursor.isNull(11)) null else + checkedBoundedInt(cursor.getLong(11), 1, MAX_PLAN_DURATION_SECONDS, "target_duration_seconds") + val targetWeight = if (cursor.isNull(12)) null else cursor.getDouble(12).also { + check(it.isFinite() && it > 0.0) { "target_weight_kg corrompu" } + } + val hasExplicitMax = cursor.getInt(16) != 0 + val hasTarget = targetSets != null || targetReps != null || targetDuration != null || targetWeight != null + if (!hasTarget) { + check(loadMode == SessionLoadMode.NONE && rest == 0) { "plan absent incohérent" } + } else { + check(recording == RecordingMode.SETS && !hasExplicitMax) { "cible interdite sur ce passage" } + check(targetSets != null && ((targetReps != null) xor (targetDuration != null))) { "forme de cible corrompue" } + check((tracking == TrackingMode.REPS) == (targetReps != null)) { "métrique de cible corrompue" } + check(if (targetWeight == null) loadMode == SessionLoadMode.NONE + else loadMode == SessionLoadMode.EXTERNAL || loadMode == SessionLoadMode.ASSISTANCE) { + "mode de charge cible incohérent" + } + } + val setPosition = if (cursor.isNull(13)) null else + checkedBoundedInt(cursor.getLong(13), 0, 100000, "set_position") + val repetitions = if (cursor.isNull(14)) null else + checkedBoundedInt(cursor.getLong(14), 0, MAX_PLAN_REPS, "reps") + visitor(GenerationHistoryRow( + cursor.requiredText(0, "session_id"), cursor.requiredText(1, "entry_id"), + cursor.requiredText(2, "exercise_id"), cursor.requiredText(3, "started_at"), + cursor.optionalText(4), recording, tracking, + loadMode, rest, hasTarget, setPosition, repetitions, + if (cursor.isNull(15)) null else cursor.finiteNonNegativeDouble(15, "weight_kg"), + hasExplicitMax, + )) + } + } + } + /** * Compose immutable scientific metadata with exact persisted runtime state. * WHY: names are editable labels, so every join and lookup stays on stable @@ -3526,6 +4001,10 @@ class TrainlogRepository( private fun android.database.Cursor.finiteNonNegativeDouble(index: Int, name: String, strictlyPositive: Boolean = false): Double = getDouble(index).also { check(it.isFinite() && if (strictlyPositive) it > 0.0 else it >= 0.0) { "$name invalide" } } private fun android.database.Cursor.optionalFinitePositiveDouble(index: Int, name: String): Double? = if (isNull(index)) null else finiteNonNegativeDouble(index, name, true) private fun checkedNonNegativeInt(value: Long, name: String): Int { check(value in 0..Int.MAX_VALUE.toLong()) { "$name hors plage" }; return value.toInt() } + private fun checkedBoundedInt(value: Long, minimum: Int, maximum: Int, name: String): Int { + check(value in minimum.toLong()..maximum.toLong()) { "$name hors plage" } + return value.toInt() + } private companion object { const val MAX_OCCURRENCE_PAGE_SIZE = 32 @@ -3692,6 +4171,12 @@ class TrainlogRepository( de.data_fields, eq.equipment_id, de.entry_id, + de.load_mode, + de.rest_seconds, + de.target_sets, + de.target_reps, + de.target_duration_seconds, + de.target_weight_kg, mr.max_weight_kg FROM draft_session_exercises AS de JOIN exercises AS e @@ -3727,7 +4212,8 @@ class TrainlogRepository( ) val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7) val entryId = cursor.getString(8) - val maxWeightKg = if (cursor.isNull(9)) null else cursor.getDouble(9) + val plan = readSessionPlan(cursor, 9) + val maxWeightKg = if (cursor.isNull(15)) null else cursor.getDouble(15) if (maxWeightKg != null) { exercises += @@ -3736,6 +4222,7 @@ class TrainlogRepository( exercise = exercise, equipmentId = equipmentId, maxWeightKg = maxWeightKg, + plan = plan, ) } else if ( exercise.recordingMode == @@ -3763,6 +4250,7 @@ class TrainlogRepository( entryId = entryId, exercise = exercise, equipmentId = equipmentId, + plan = plan, continuousDurationSeconds = item.getInt(0), speedKmh = @@ -3820,6 +4308,7 @@ class TrainlogRepository( entryId = entryId, exercise = exercise, equipmentId = equipmentId, + plan = plan, sets = sets, ) } @@ -3927,6 +4416,7 @@ class TrainlogRepository( exerciseDraft.exercise.dataFields, ) put("entry_id", exerciseDraft.entryId) + putSessionPlan(exerciseDraft.plan) val equipmentRowId = lookupEquipmentRowIdOrNull( db, exerciseDraft.equipmentId, @@ -4076,6 +4566,7 @@ class TrainlogRepository( ) put("data_fields", exerciseDraft.exercise.dataFields) put("entry_id", exerciseDraft.entryId) + putSessionPlan(exerciseDraft.plan) val equipmentRowId = lookupEquipmentRowIdOrNull( db, exerciseDraft.equipmentId, @@ -4228,7 +4719,9 @@ class TrainlogRepository( private fun validateSessionExercise( draft: SessionExerciseDraft, sessionType: SessionType, + allowTargetOnly: Boolean = false, ): Boolean { + if (!validateSessionPlan(draft)) return false val maxWeight = draft.maxWeightKg if (maxWeight != null) { /* INVARIANT: max is a first-class result owned by the movement @@ -4239,7 +4732,8 @@ class TrainlogRepository( draft.sets.isEmpty() && draft.continuousDurationSeconds == 0 && draft.speedKmh == null && - draft.distanceKm == null + draft.distanceKm == null && + draft.plan == null } return when ( @@ -4248,7 +4742,7 @@ class TrainlogRepository( RecordingMode.CONTINUOUS -> { if ( draft.continuousDurationSeconds <= 0 || - draft.sets.isNotEmpty() + draft.sets.isNotEmpty() || draft.plan != null ) { false } else { @@ -4279,7 +4773,7 @@ class TrainlogRepository( RecordingMode.SETS -> { if ( - draft.sets.isEmpty() || + (draft.sets.isEmpty() && !(allowTargetOnly && draft.plan != null)) || draft.continuousDurationSeconds != 0 || draft.speedKmh != null || draft.distanceKm != null @@ -4310,6 +4804,24 @@ class TrainlogRepository( } } + private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean { + val plan = draft.plan ?: return true + if (draft.exercise.recordingMode != RecordingMode.SETS || + plan.sets !in 1..MAX_PLAN_SETS || + plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS || + (plan.weightKg != null && (!plan.weightKg.isFinite() || plan.weightKg <= 0.0))) return false + val metricValid = when (draft.exercise.trackingMode) { + TrackingMode.REPS -> plan.reps in 1..MAX_PLAN_REPS && plan.durationSeconds == null + TrackingMode.DURATION -> plan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS && plan.reps == null + } + val modeValid = if (plan.weightKg == null) { + plan.loadMode == SessionLoadMode.NONE + } else { + plan.loadMode == SessionLoadMode.EXTERNAL || plan.loadMode == SessionLoadMode.ASSISTANCE + } + return metricValid && modeValid + } + private fun lookupExerciseRowId( db: SQLiteDatabase, exerciseId: String, @@ -4450,9 +4962,49 @@ private fun ContentValues.putOptionalString( if (value == null) putNull(key) else put(key, value) } +private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) { + put("load_mode", plan?.loadMode?.wireValue ?: "none") + put("rest_seconds", plan?.restSeconds ?: 0) + if (plan == null) { + putNull("target_sets") + putNull("target_reps") + putNull("target_duration_seconds") + putNull("target_weight_kg") + } else { + put("target_sets", plan.sets) + if (plan.reps == null) putNull("target_reps") else put("target_reps", plan.reps) + if (plan.durationSeconds == null) putNull("target_duration_seconds") + else put("target_duration_seconds", plan.durationSeconds) + putOptionalDouble("target_weight_kg", plan.weightKg) + } +} + +private fun readSessionPlan(cursor: Cursor, start: Int): SessionExercisePlan? { + val loadMode = SessionLoadMode.fromWire(cursor.getString(start)) + val restSeconds = cursor.getInt(start + 1) + if (cursor.isNull(start + 2)) { + check(loadMode == SessionLoadMode.NONE && restSeconds == 0 && + cursor.isNull(start + 3) && cursor.isNull(start + 4) && cursor.isNull(start + 5)) { + "Métadonnées de plan cible incohérentes" + } + return null + } + return SessionExercisePlan( + sets = cursor.getInt(start + 2), + reps = if (cursor.isNull(start + 3)) null else cursor.getInt(start + 3), + durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4), + weightKg = if (cursor.isNull(start + 5)) null else cursor.getDouble(start + 5), + loadMode = loadMode, + restSeconds = restSeconds, + ) +} + private fun JSONObject.optDoubleOrNull(key: String): Double? = if (has(key) && !isNull(key)) getDouble(key) else null +private fun JSONObject.optIntOrNull(key: String): Int? = + if (has(key) && !isNull(key)) getInt(key) else null + private fun equipmentAliasNormalize(value: String): String = Normalizer.normalize(value, Normalizer.Form.NFD) .replace("\\p{M}+".toRegex(), "") @@ -4463,6 +5015,10 @@ private const val ANDROID_DATABASE_NAME = "trainlog-android.db" private const val ACTIVE_DRAFT_ID = 1 private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096 +private const val MAX_PLAN_SETS = 64 +private const val MAX_PLAN_REPS = 10000 +private const val MAX_PLAN_DURATION_SECONDS = 86400 +private const val MAX_PLAN_REST_SECONDS = 86400 private const val ANDROID_LEG_PRESS_LEGACY_ID = "ex_d68a1af1-7247-4fb3-a48b-da8516906a29" private const val DESKTOP_LEG_PRESS_CANONICAL_ID = @@ -4478,7 +5034,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 10, + 11, ) { override fun onConfigure( db: SQLiteDatabase, @@ -4591,6 +5147,15 @@ private class TrainlogDatabaseHelper( version = 10 } + if (version < 11 && newVersion >= 11) { + /* CONTRACT: v11 is additive and never reconstructs prescriptions + * from historical actuals. SQLite defaults make every old row the + * exact targetless none/zero representation. */ + addOccurrencePlanColumns(db, "session_exercises") + addOccurrencePlanColumns(db, "draft_session_exercises") + version = 11 + } + if (version != newVersion) { error( "Unsupported Android DB upgrade " + @@ -4939,6 +5504,15 @@ private class TrainlogDatabaseHelper( REFERENCES equipment(id) ON DELETE SET NULL, entry_id TEXT NOT NULL UNIQUE, + load_mode TEXT NOT NULL DEFAULT 'none' + CHECK(load_mode IN ('none', 'external', 'assistance')), + rest_seconds INTEGER NOT NULL DEFAULT 0 + CHECK(rest_seconds BETWEEN 0 AND 86400), + target_sets INTEGER CHECK(target_sets BETWEEN 1 AND 64), + target_reps INTEGER CHECK(target_reps BETWEEN 1 AND 10000), + target_duration_seconds INTEGER + CHECK(target_duration_seconds BETWEEN 1 AND 86400), + target_weight_kg REAL CHECK(target_weight_kg > 0.0), UNIQUE( session_row_id, position @@ -5009,6 +5583,23 @@ private class TrainlogDatabaseHelper( ) } + private fun addOccurrencePlanColumns(db: SQLiteDatabase, table: String) { + check(table == "session_exercises" || table == "draft_session_exercises") + val present = mutableSetOf() + db.rawQuery("PRAGMA table_info($table);", null).use { cursor -> + while (cursor.moveToNext()) present += cursor.getString(1) + } + fun add(name: String, declaration: String) { + if (name !in present) db.execSQL("ALTER TABLE $table ADD COLUMN $name $declaration;") + } + add("load_mode", "TEXT NOT NULL DEFAULT 'none' CHECK(load_mode IN ('none','external','assistance'))") + add("rest_seconds", "INTEGER NOT NULL DEFAULT 0 CHECK(rest_seconds BETWEEN 0 AND 86400)") + add("target_sets", "INTEGER CHECK(target_sets BETWEEN 1 AND 64)") + add("target_reps", "INTEGER CHECK(target_reps BETWEEN 1 AND 10000)") + add("target_duration_seconds", "INTEGER CHECK(target_duration_seconds BETWEEN 1 AND 86400)") + add("target_weight_kg", "REAL CHECK(target_weight_kg > 0.0)") + } + private fun createBodyTable( db: SQLiteDatabase, @@ -5138,6 +5729,15 @@ private class TrainlogDatabaseHelper( REFERENCES equipment(id) ON DELETE SET NULL, entry_id TEXT NOT NULL UNIQUE, + load_mode TEXT NOT NULL DEFAULT 'none' + CHECK(load_mode IN ('none', 'external', 'assistance')), + rest_seconds INTEGER NOT NULL DEFAULT 0 + CHECK(rest_seconds BETWEEN 0 AND 86400), + target_sets INTEGER CHECK(target_sets BETWEEN 1 AND 64), + target_reps INTEGER CHECK(target_reps BETWEEN 1 AND 10000), + target_duration_seconds INTEGER + CHECK(target_duration_seconds BETWEEN 1 AND 86400), + target_weight_kg REAL CHECK(target_weight_kg > 0.0), UNIQUE(draft_id, position) ); """.trimIndent() diff --git a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt index 1485431..e050b2d 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/model/SessionModels.kt @@ -24,6 +24,32 @@ data class SessionSetDraft( val weightKg: Double? = null, ) +enum class SessionLoadMode(val wireValue: String) { + NONE("none"), + EXTERNAL("external"), + ASSISTANCE("assistance"); + + companion object { + fun fromWire(value: String): SessionLoadMode = + entries.firstOrNull { it.wireValue == value } + ?: error("Mode de charge inconnu: $value") + } +} + +/** + * Planned occurrence metadata is deliberately separate from performed rows. + * A generator may create a target-only draft, while normal completion still + * requires actual work before the occurrence enters completed history. + */ +data class SessionExercisePlan( + val sets: Int, + val reps: Int? = null, + val durationSeconds: Int? = null, + val weightKg: Double? = null, + val loadMode: SessionLoadMode = SessionLoadMode.NONE, + val restSeconds: Int = 0, +) + data class SessionExerciseDraft( /** Stable occurrence identity; exercise_id identifies only the catalogue movement. */ val entryId: String = "sxe_" + java.util.UUID.randomUUID().toString(), @@ -36,6 +62,7 @@ data class SessionExerciseDraft( * mutually exclusive with sets/continuous data. */ val maxWeightKg: Double? = null, + val plan: SessionExercisePlan? = null, val sets: List = emptyList(), val continuousDurationSeconds: Int = 0, val speedKmh: Double? = null, @@ -96,6 +123,7 @@ data class SessionExerciseDetail( val dataFields: Int, /** Explicit max result; null also represents a preserved legacy max entry. */ val maxWeightKg: Double? = null, + val plan: SessionExercisePlan? = null, val sets: List = emptyList(), val continuousDurationSeconds: Int = 0, val speedKmh: Double? = null, diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt index 48931f1..9520144 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/HomeScreen.kt @@ -14,6 +14,7 @@ fun HomeScreen( activeDraft: ActiveSessionDraft?, draftError: String?, onSession: () -> Unit, + onGenerateSession: () -> Unit, onDiscardDraft: () -> Unit, onExercise: () -> Unit, onBody: () -> Unit, @@ -112,6 +113,12 @@ fun HomeScreen( onClick = onSession, ) + TrainlogAction( + label = "Générer une séance", + description = "Préparer une proposition modifiable à partir d'une zone, d'un objectif et d'une durée.", + onClick = onGenerateSession, + ) + TrainlogAction( label = "Enregistrer un exercice", diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt index cf23541..098387b 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionDetailScreen.kt @@ -130,6 +130,15 @@ fun SessionDetailScreen( exercise.equipmentDisplayName?.let { equipment -> TrainlogInfo("Équipement : $equipment", color = colors.muted) } + exercise.plan?.let { plan -> + TrainlogInfo( + "Plan : ${plan.sets} × ${plan.reps ?: plan.durationSeconds} · " + + "repos ${plan.restSeconds} s · " + + (plan.weightKg?.let { "charge cible $it kg" } + ?: "sans charge numérique"), + color = colors.muted, + ) + } if (editingEntryId == exercise.entryId) { TrainlogInputField( label = "Rechercher une machine", diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt new file mode 100644 index 0000000..84478cd --- /dev/null +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionGeneratorScreen.kt @@ -0,0 +1,285 @@ +package com.labfytools.trainlog.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.labfytools.trainlog.data.AcceptGeneratedSessionResult +import com.labfytools.trainlog.data.GenerationWarningLevel +import com.labfytools.trainlog.data.SessionGenerationPreview +import com.labfytools.trainlog.data.SessionGenerationRequest +import com.labfytools.trainlog.data.SessionGenerationResult +import com.labfytools.trainlog.data.TrainlogRepository +import com.labfytools.trainlog.ui.theme.LocalTrainlogColors +import java.time.OffsetDateTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal object SessionGeneratorPreviewController { + fun remove(preview: SessionGenerationPreview, index: Int): SessionGenerationPreview { + val removed = preview.exercises.getOrNull(index) ?: return preview + return preview.copy( + exercises = preview.exercises.filterIndexed { at, _ -> at != index }, + estimatedDurationSeconds = preview.estimatedDurationSeconds - removed.estimatedSeconds, + ) + } + + fun move(preview: SessionGenerationPreview, index: Int, offset: Int): SessionGenerationPreview { + val destination = index + offset + if (index !in preview.exercises.indices || destination !in preview.exercises.indices) return preview + val changed = preview.exercises.toMutableList() + val value = changed.removeAt(index) + changed.add(destination, value) + return preview.copy(exercises = changed) + } +} + +internal object SessionGeneratorFormController { + val goals = listOf( + "general" to "Général", + "strength" to "Force", + "hypertrophy" to "Hypertrophie", + "endurance" to "Endurance locale", + ) + fun duration(text: String, allowed: IntRange): Int? = text.toIntOrNull()?.takeIf { it in allowed } + + fun manualWeight(text: String): Result { + val normalized = text.trim().replace(',', '.') + if (normalized.isEmpty()) return Result.success(null) + val value = normalized.toDoubleOrNull() + return if (value != null && value.isFinite() && value > 0.0) Result.success(value) + else Result.failure(IllegalArgumentException("La charge cible doit être un nombre positif fini.")) + } +} + +@Composable +fun SessionGeneratorScreen( + repository: TrainlogRepository, + onBack: () -> Unit, + onAccepted: () -> Unit, + onExistingDraft: () -> Unit, +) { + val colors = LocalTrainlogColors.current + val scope = rememberCoroutineScope() + val options = remember { repository.sessionGenerationFormOptions() } + val zones = remember { repository.listBodyZones().filter { it.zoneId in options.zoneIds } } + var zoneId by remember { mutableStateOf("full_body") } + var goalId by remember { mutableStateOf("general") } + var durationText by remember { mutableStateOf("30") } + var preview by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(null) } + var warningAcknowledged by remember { mutableStateOf(false) } + var editingIndex by remember { mutableStateOf(null) } + var setsText by remember { mutableStateOf("") } + var repsText by remember { mutableStateOf("") } + var restText by remember { mutableStateOf("") } + var loadText by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + + fun generate(request: SessionGenerationRequest) { + if (busy) return + scope.launch { + busy = true + try { + when (val result = withContext(Dispatchers.IO) { repository.generateSessionPreview(request) }) { + is SessionGenerationResult.Generated -> { + preview = result.preview + warningAcknowledged = result.preview.exposure.warningLevel == GenerationWarningLevel.NONE + message = null + } + is SessionGenerationResult.Invalid -> message = result.message + is SessionGenerationResult.DatabaseError -> message = result.message + } + } finally { + busy = false + } + } + } + + TrainlogScreen(subtitle = "G E N E R E R U N E S E A N C E") { + TrainlogAction("< Retour", "Annuler sans créer de brouillon ni modifier l'historique.", onClick = { + if (!busy) onBack() + }, accent = colors.muted) + val current = preview + if (current == null) { + TrainlogFrame("ZONE CORPORELLE") { + zones.forEach { zone -> + TrainlogAction(zone.displayName, zone.zoneId, + onClick = { zoneId = zone.zoneId }, + accent = if (zone.zoneId == zoneId) colors.success else colors.muted) + } + } + TrainlogFrame("OBJECTIF") { + SessionGeneratorFormController.goals.filter { it.first in options.goalIds }.forEach { (id, label) -> + TrainlogAction(label, id, onClick = { goalId = id }, + accent = if (goalId == id) colors.success else colors.muted) + } + } + TrainlogFrame("DURÉE") { + options.durationPresets.forEach { minutes -> + TrainlogAction("$minutes min", "Durée disponible.", onClick = { durationText = minutes.toString() }, + accent = if (durationText == minutes.toString()) colors.success else colors.muted) + } + TrainlogInputField( + "Durée personnalisée (${options.customMinutes.first} à ${options.customMinutes.last} min)", + durationText, + onValueChange = { durationText = it }, + ) + } + TrainlogAction("Générer", "Analyser l'historique en lecture seule et préparer la proposition.", accent = colors.success, onClick = { + val minutes = SessionGeneratorFormController.duration(durationText, options.customMinutes) + if (minutes == null) message = "La durée doit être comprise entre ${options.customMinutes.first} et ${options.customMinutes.last} minutes." + else generate(SessionGenerationRequest(zoneId, goalId, minutes, OffsetDateTime.now().toString())) + }) + } else { + TrainlogFrame("PROPOSITION") { + TrainlogInfo("Durée estimée : ${current.estimatedDurationSeconds / 60} min") + if (current.insufficientResolvedCandidates) TrainlogInfo( + "La couverture est incomplète faute de contextes résolus disponibles" + + current.shortageCodes.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = " : ", postfix = ".") { generationShortageLabel(it) } + .orEmpty(), + colors.warning, + ) + val exposure = current.exposure + TrainlogInfo("Exposition observée : ${exposure.within24h.primarySetCount} séries principales et ${exposure.within24h.secondarySetCount} secondaires sur 24 h ; ${exposure.within72h.primarySetCount} principales et ${exposure.within72h.secondarySetCount} secondaires sur 72 h.") + exposure.latestStartedAt?.let { TrainlogInfo("Dernière exposition observée : $it", colors.muted) } + if (exposure.warningLevel != GenerationWarningLevel.NONE) { + TrainlogInfo("Attention informative : l'historique montre une exposition récente. Cela ne mesure pas la récupération physiologique.", colors.warning) + if (!warningAcknowledged) TrainlogAction("Continuer malgré l'avertissement", "Conserver la proposition et autoriser son acceptation.", accent = colors.warning, onClick = { + if (!busy) warningAcknowledged = true + }) + } + } + current.exercises.forEachIndexed { index, item -> + TrainlogFrame("${index + 1}. ${item.exerciseName}") { + TrainlogInfo("Équipement : ${item.equipmentName}") + TrainlogInfo("Cible : ${item.plan.sets} × ${item.plan.reps} · repos ${item.plan.restSeconds} s") + TrainlogInfo(if (item.plan.weightKg == null) "Charge cible : aucune charge numérique proposée." + else "Charge cible : ${item.plan.weightKg} kg") + TrainlogInfo("Zone principale : ${item.primaryZoneName}") + TrainlogInfo("Mouvement : ${item.patternNames.joinToString()}") + if (item.recency.recentSameExercise || item.recency.recentSamePattern) + TrainlogInfo("Historique récent similaire détecté ; information uniquement.", colors.warning) + item.loadSourceStartedAt?.let { + TrainlogInfo( + "Charge issue d'une dose réellement observée le $it " + + "(séance ${item.loadSourceSessionId}, passage ${item.loadSourceOccurrenceId}) ; " + + "son applicabilité aujourd'hui reste incertaine.", + colors.muted, + ) + } + TrainlogInfo( + "Raisons : ${item.rationaleCodes.joinToString { generationReasonLabel(it) }}", + colors.muted, + ) + if (editingIndex == index) { + TrainlogInputField("Séries", setsText, onValueChange = { setsText = it }) + TrainlogInputField("Répétitions", repsText, onValueChange = { repsText = it }) + TrainlogInputField("Repos (secondes)", restText, onValueChange = { restText = it }) + TrainlogInputField( + "Charge cible manuelle (vide = réévaluer)", + loadText, + onValueChange = { loadText = it }, + ) + TrainlogAction("Appliquer", "Réestimer la durée et requalifier la charge observée.", accent = colors.success, onClick = { + val parsedWeight = SessionGeneratorFormController.manualWeight(loadText) + if (parsedWeight.isFailure) { + message = parsedWeight.exceptionOrNull()?.message + } else { + val weight = parsedWeight.getOrNull() + if (!busy) scope.launch { + busy = true + try { + when (val result = withContext(Dispatchers.IO) { + repository.editGeneratedDose(current, index, + setsText.toIntOrNull() ?: -1, repsText.toIntOrNull() ?: -1, + restText.toIntOrNull() ?: -1, weight) + }) { + is SessionGenerationResult.Generated -> { preview = result.preview; editingIndex = null; message = null } + is SessionGenerationResult.Invalid -> message = result.message + is SessionGenerationResult.DatabaseError -> message = result.message + } + } finally { + busy = false + } + } + } + }) + } else TrainlogAction("Modifier la cible", "Modifier séries, répétitions, repos ou charge.", onClick = { + if (!busy) { + editingIndex = index; setsText = item.plan.sets.toString(); repsText = item.plan.reps.toString() + restText = item.plan.restSeconds.toString() + // Preserve an explicit user value across later edits. An + // automatic observed value stays display-only: empty asks + // the engine to qualify it again for the changed dose. + loadText = if ("manual_target_load" in item.rationaleCodes) + item.plan.weightKg?.toString().orEmpty() else "" + } + }) + TrainlogAction("Monter", "Déplacer cet exercice avant le précédent.", onClick = { + if (!busy) preview = SessionGeneratorPreviewController.move(current, index, -1) + }, accent = colors.muted) + TrainlogAction("Descendre", "Déplacer cet exercice après le suivant.", onClick = { + if (!busy) preview = SessionGeneratorPreviewController.move(current, index, 1) + }, accent = colors.muted) + TrainlogAction("Retirer", "Retirer cet exercice de la proposition uniquement.", onClick = { + if (!busy) preview = SessionGeneratorPreviewController.remove(current, index) + }, accent = colors.error) + } + } + TrainlogAction("Régénérer", "Relancer les mêmes entrées et le même instant de référence.", onClick = { generate(current.request) }) + TrainlogAction("Accepter et saisir les valeurs réelles", "Créer le brouillon normal sans préremplir les séries réalisées.", accent = colors.success, onClick = { + if (!warningAcknowledged) message = "Confirmez d'abord l'avertissement d'exposition récente." + else if (!busy) scope.launch { + busy = true + try { + when (val result = withContext(Dispatchers.IO) { repository.acceptGeneratedSession(current) }) { + AcceptGeneratedSessionResult.Accepted -> onAccepted() + AcceptGeneratedSessionResult.ExistingActiveDraft -> onExistingDraft() + is AcceptGeneratedSessionResult.Invalid -> message = result.message + is AcceptGeneratedSessionResult.DatabaseError -> message = result.message + } + } finally { + busy = false + } + } + }) + TrainlogAction("Annuler la proposition", "Revenir sans écrire de brouillon.", onClick = { + if (!busy) onBack() + }, accent = colors.muted) + } + if (busy) TrainlogFrame("TRAITEMENT") { TrainlogInfo("Analyse en cours…", colors.muted) } + message?.let { TrainlogFrame("MESSAGE") { TrainlogInfo(it, colors.error) } } + } +} + +private fun generationReasonLabel(code: String): String = when (code) { + "observed_repeated_dose_anchor" -> "dose répétée observée" + "explicit_max_present_no_numeric_prescription" -> "maximum observé sans prescription numérique" + "assistance_numeric_load_omitted" -> "charge d'assistance omise" + "numeric_load_absent" -> "charge numérique absente" + "manual_target_load" -> "charge saisie manuellement" + "preferred_exercise" -> "exercice préféré" + "requested_primary_zone" -> "zone principale demandée" + "requested_secondary_zone" -> "zone secondaire demandée" + "new_exact_pattern" -> "mouvement distinct" + "recent_same_exercise_penalty" -> "même exercice observé récemment" + "recent_same_pattern_penalty" -> "mouvement similaire observé récemment" + else -> code.replace('_', ' ') +} + +private fun generationShortageLabel(code: String): String = when (code) { + "missing_upper_region" -> "région supérieure manquante" + "missing_lower_region" -> "région inférieure manquante" + "missing_core_region" -> "tronc manquant" + "missing_upper_push" -> "poussée du haut du corps manquante" + "missing_upper_pull" -> "tirage du haut du corps manquant" + "missing_lower_extension" -> "extension du bas du corps manquante" + "missing_lower_flexion" -> "flexion du bas du corps manquante" + "fewer_than_max_exercises" -> "moins d'exercices distincts disponibles" + else -> code.replace('_', ' ') +} diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt index f01ca4f..2d56745 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/SessionScreen.kt @@ -297,6 +297,19 @@ fun SessionScreen( color = colors.text, ) + draft.plan?.let { plan -> + TrainlogInfo( + "Plan : ${plan.sets} × ${plan.reps ?: plan.durationSeconds} · " + + "repos ${plan.restSeconds} s · " + + (plan.weightKg?.let { "charge cible $it kg" } + ?: "aucune charge numérique proposée"), + color = colors.muted, + ) + if (draft.sets.isEmpty()) TrainlogInfo( + "Aucune série réalisée saisie : ajoutez les valeurs réellement effectuées.", + color = colors.warning, + ) + } TrainlogAction( label = "Modifier ${draft.exercise.name}", @@ -382,7 +395,11 @@ fun SessionScreen( currentDraft.copy( exercises = editIndex?.let { replacingIndex -> currentDraft.exercises.mapIndexed { index, existing -> - if (index == replacingIndex) draft else existing + /* INVARIANT: normal performed-value edits + * preserve generator planning metadata. */ + if (index == replacingIndex) { + draft.copy(plan = existing.plan) + } else existing } } ?: (currentDraft.exercises + draft), form = diff --git a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt index 0992568..366f19c 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/ui/TrainlogApp.kt @@ -21,6 +21,7 @@ import com.labfytools.trainlog.data.SyncRequestOutbox private enum class TrainlogScreenId { HOME, SESSION, + SESSION_GENERATOR, EXERCISE, BODY, HISTORY, @@ -164,6 +165,10 @@ fun TrainlogApp( } } }, + onGenerateSession = { + draftMessage = null + screen = TrainlogScreenId.SESSION_GENERATOR + }, onDiscardDraft = { when ( val result = @@ -227,6 +232,21 @@ fun TrainlogApp( }, ) + TrainlogScreenId.SESSION_GENERATOR -> + SessionGeneratorScreen( + repository = repository, + onBack = { screen = TrainlogScreenId.HOME }, + onAccepted = { + draftRevision += 1 + screen = TrainlogScreenId.SESSION + }, + onExistingDraft = { + draftMessage = "Une séance est déjà en cours. Reprenez-la ou supprimez-la explicitement depuis l'accueil." + draftRevision += 1 + screen = TrainlogScreenId.HOME + }, + ) + TrainlogScreenId.EXERCISE -> ExerciseScreen( repository = repository, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt new file mode 100644 index 0000000..f9d09e9 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -0,0 +1,83 @@ +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.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AndroidV10PlanningMigrationTest { + private lateinit var context: Context + private lateinit var name: String + + @Before fun setUp() { + context = ApplicationProvider.getApplicationContext() + name = "planning-v10-${UUID.randomUUID()}.db" + } + + @After fun tearDown() { context.deleteDatabase(name) } + + @Test fun structuralVersionTenFixtureMigratesWithoutInferringTargets() { + val path = context.getDatabasePath(name) + path.parentFile?.mkdirs() + SQLiteDatabase.openOrCreateDatabase(path, null).use { db -> + db.execSQL("PRAGMA foreign_keys=ON") + db.execSQL("CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,name TEXT,normalized_name TEXT UNIQUE,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER)") + db.execSQL("CREATE TABLE equipment(id INTEGER PRIMARY KEY,equipment_id TEXT UNIQUE,label_name TEXT,display_name TEXT,equipment_type TEXT,load_semantics TEXT)") + db.execSQL("CREATE TABLE sessions(id INTEGER PRIMARY KEY,session_id TEXT UNIQUE,started_at TEXT,session_type TEXT)") + db.execSQL("CREATE TABLE session_exercises(id INTEGER PRIMARY KEY,session_row_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,exercise_row_id INTEGER REFERENCES exercises(id),position INTEGER,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,equipment_row_id INTEGER REFERENCES equipment(id),entry_id TEXT UNIQUE)") + db.execSQL("CREATE TABLE performed_sets(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id),position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL)") + db.execSQL("CREATE TABLE continuous_activity(id INTEGER PRIMARY KEY,session_exercise_row_id INTEGER REFERENCES session_exercises(id),duration_seconds INTEGER,speed_kmh REAL,distance_km REAL)") + db.execSQL("CREATE TABLE max_results(session_exercise_row_id INTEGER PRIMARY KEY REFERENCES session_exercises(id),max_weight_kg REAL)") + db.execSQL("CREATE TABLE active_session_draft(id INTEGER PRIMARY KEY,session_type TEXT,selected_exercise_row_id INTEGER,selected_exercise_label TEXT,selected_equipment_id TEXT,source_session_id TEXT,weight_text TEXT,max_weight_text TEXT,set_count_text TEXT,reps_text TEXT,duration_text TEXT,speed_text TEXT,distance_text TEXT,updated_at TEXT)") + db.execSQL("CREATE TABLE draft_session_exercises(id INTEGER PRIMARY KEY,draft_id INTEGER REFERENCES active_session_draft(id),exercise_row_id INTEGER REFERENCES exercises(id),position INTEGER,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,equipment_row_id INTEGER REFERENCES equipment(id),entry_id TEXT UNIQUE)") + db.execSQL("CREATE TABLE draft_performed_sets(id INTEGER PRIMARY KEY,draft_exercise_row_id INTEGER REFERENCES draft_session_exercises(id),position INTEGER,reps INTEGER,duration_seconds INTEGER,weight_kg REAL)") + db.execSQL("CREATE TABLE draft_continuous_activity(id INTEGER PRIMARY KEY,draft_exercise_row_id INTEGER REFERENCES draft_session_exercises(id),duration_seconds INTEGER,speed_kmh REAL,distance_km REAL)") + db.execSQL("CREATE TABLE draft_max_results(draft_exercise_row_id INTEGER PRIMARY KEY REFERENCES draft_session_exercises(id),max_weight_kg REAL)") + db.execSQL("CREATE TABLE body_observations(id INTEGER PRIMARY KEY,observation_id TEXT UNIQUE,observed_at TEXT,body_weight_kg REAL,neck_cm REAL,shoulders_cm REAL,chest_cm REAL,waist_cm REAL,hips_cm REAL,left_arm_cm REAL,right_arm_cm REAL,left_forearm_cm REAL,right_forearm_cm REAL,left_thigh_cm REAL,right_thigh_cm REAL,left_calf_cm REAL,right_calf_cm REAL)") + db.execSQL("INSERT INTO exercises VALUES(1,'ex_11111111-1111-4111-8111-111111111111','Fixture','fixture','sets','reps',0)") + db.execSQL("INSERT INTO equipment VALUES(1,'leg_press','', 'Machine','selectorized_machine','external')") + db.execSQL("INSERT INTO sessions VALUES(1,'se_11111111-1111-4111-8111-111111111111','2026-09-01T10:00:00+02:00','max_test')") + db.execSQL("INSERT INTO session_exercises VALUES(1,1,1,0,'sets','reps',0,1,'sxe_a')") + db.execSQL("INSERT INTO session_exercises VALUES(2,1,1,1,'sets','reps',0,1,'sxe_b')") + db.execSQL("INSERT INTO performed_sets VALUES(1,1,0,8,NULL,42.5)") + db.execSQL("INSERT INTO max_results VALUES(2,90.0)") + db.execSQL("INSERT INTO active_session_draft VALUES(1,'training',1,'Fixture','leg_press',NULL,'42,','', '3','8,','30','','','2026-09-02T10:00:00+02:00')") + db.execSQL("INSERT INTO draft_session_exercises VALUES(1,1,1,0,'sets','reps',0,1,'sxe_draft')") + db.execSQL("INSERT INTO draft_performed_sets VALUES(1,1,0,7,NULL,40.0)") + db.execSQL("INSERT INTO body_observations(id,observation_id,observed_at,body_weight_kg) VALUES(1,'bo_1','2026-09-01T08:00:00+02:00',72.0)") + db.execSQL("PRAGMA user_version=10") + } + + TrainlogRepository(context, name).useForTest { it.listSessions() } + SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> + assertEquals(11, 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")) + } + assertEquals(2, scalar(db, "SELECT COUNT(*) FROM session_exercises")) + assertEquals(1, scalar(db, "SELECT COUNT(*) FROM draft_session_exercises")) + assertEquals(1, scalar(db, "SELECT COUNT(*) FROM performed_sets")) + assertEquals(1, scalar(db, "SELECT COUNT(*) FROM max_results")) + assertEquals("42,", text(db, "SELECT weight_text FROM active_session_draft")) + assertEquals(72.0, number(db, "SELECT body_weight_kg FROM body_observations"), 0.0) + db.rawQuery("PRAGMA foreign_key_check", null).use { assertFalse(it.moveToFirst()) } + } + } + + private fun scalar(db: SQLiteDatabase, sql: String): Int = db.rawQuery(sql, null).use { assertTrue(it.moveToFirst()); it.getInt(0) } + private fun text(db: SQLiteDatabase, sql: String): String = db.rawQuery(sql, null).use { assertTrue(it.moveToFirst()); it.getString(0) } + private fun number(db: SQLiteDatabase, sql: String): Double = db.rawQuery(sql, null).use { assertTrue(it.moveToFirst()); it.getDouble(0) } + private inline fun TrainlogRepository.useForTest(block: (TrainlogRepository) -> Unit) { try { block(this) } finally { close() } } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 3d0cdcc..42b4949 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -321,7 +321,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(10, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(11, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt index 8d07c8e..d37f94b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/RealAndroidV9BodyZonesMigrationTest.kt @@ -71,7 +71,7 @@ class RealAndroidV9BodyZonesMigrationTest { SQLiteDatabase.openDatabase( migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE, ).use { migrated -> - assertEquals(10, scalarInt(migrated, "PRAGMA user_version;")) + assertEquals(11, scalarInt(migrated, "PRAGMA user_version;")) assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;")) migrated.rawQuery("PRAGMA foreign_key_check;", null).use { assertFalse(it.moveToFirst()) @@ -81,9 +81,9 @@ class RealAndroidV9BodyZonesMigrationTest { "active_session_draft", "body_observations", "catalog_exercise_equipment", "continuous_activity", "draft_continuous_activity", "draft_max_results", - "draft_performed_sets", "draft_session_exercises", "equipment", + "draft_performed_sets", "equipment", "equipment_aliases", "exercise_equipment", "exercises", - "max_results", "performed_sets", "session_exercises", "sessions", + "max_results", "performed_sets", "sessions", ) historicalTables.forEach { table -> /* Table names are a closed test constant; values remain bound @@ -95,6 +95,17 @@ class RealAndroidV9BodyZonesMigrationTest { "SELECT COUNT(*) FROM (SELECT * FROM before_v9.$table " + "EXCEPT SELECT * FROM main.$table);")) } + for ((table, columns) in listOf( + "session_exercises" to "id,session_row_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id", + "draft_session_exercises" to "id,draft_id,exercise_row_id,position,recording_mode,tracking_mode,data_fields,equipment_row_id,entry_id", + )) { + assertEquals(0, scalarInt(migrated, + "SELECT COUNT(*) FROM (SELECT $columns FROM main.$table EXCEPT SELECT $columns FROM before_v9.$table);")) + assertEquals(0, scalarInt(migrated, + "SELECT COUNT(*) FROM (SELECT $columns FROM before_v9.$table EXCEPT SELECT $columns FROM main.$table);")) + assertEquals(0, scalarInt(migrated, + "SELECT COUNT(*) FROM main.$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;")) + } assertEquals(32, scalarInt(migrated, "SELECT COUNT(*) FROM exercise_body_zones;")) assertEquals(20, scalarInt(migrated, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionGenerationEngineTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionGenerationEngineTest.kt new file mode 100644 index 0000000..b4735c5 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionGenerationEngineTest.kt @@ -0,0 +1,142 @@ +package com.labfytools.trainlog.data + +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.RecordingMode +import com.labfytools.trainlog.model.SessionLoadMode +import com.labfytools.trainlog.model.TrackingMode +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SessionGenerationEngineTest { + private fun JSONArray.strings() = List(length(), ::getString) + + @Test + fun sharedGoldenFixturesMatchEveryPublicOutputField() { + val context = ApplicationProvider.getApplicationContext() + val zones = BodyZoneCatalog.load(context) + val knowledge = TrainingKnowledgeCatalog.load(context) + val engine = SessionGenerationEngine(SessionGenerationPolicyLoader.load(context, knowledge, zones), knowledge, zones) + val root = context.assets.open("session-generation-v1.json").bufferedReader().use { JSONObject(it.readText()) } + assertEquals("trainlog-session-generation-fixtures-v1", root.getString("format")) + assertEquals(1, root.getInt("version")) + + // CONTRACT: named coverage lives in the authored fixture so neither runner can + // silently drop a frozen scoring or group-priority rule from the parity corpus. + val required = root.getJSONArray("required_rule_coverage").strings().toSet() + val cases = root.getJSONArray("cases") + val covered = buildSet { + repeat(cases.length()) { index -> addAll(cases.getJSONObject(index).getJSONArray("covers").strings()) } + } + assertEquals(required, covered) + val templates = root.getJSONObject("expected_exercise_templates") + + repeat(cases.length()) { caseIndex -> + val fixture = cases.getJSONObject(caseIndex) + val id = fixture.getString("id") + val candidatesJson = fixture.getJSONArray("candidates") + val candidates = List(candidatesJson.length()) { index -> candidatesJson.getJSONObject(index).let { row -> + GenerationCandidate( + row.getString("exercise_id"), row.getString("equipment_id"), row.getString("primary_zone_id"), + row.getJSONArray("secondary_zone_ids").strings(), row.getJSONArray("pattern_ids").strings(), + row.getJSONArray("source_ref_ids").strings(), + KnowledgeConfidence.valueOf(row.getString("confidence").uppercase()), + EquipmentLoadSemantics.valueOf(row.getString("equipment_load_semantics").uppercase()), + ) + } } + val history = buildList { + val occurrences = fixture.getJSONArray("history") + repeat(occurrences.length()) { occurrenceIndex -> + val occurrence = occurrences.getJSONObject(occurrenceIndex) + val sets = occurrence.optJSONArray("sets") + if (sets == null) add(historyRow(occurrence, null)) + else repeat(sets.length()) { setIndex -> add(historyRow(occurrence, sets.getJSONObject(setIndex))) } + } + } + val preferred = fixture.optJSONArray("preferred_ids")?.strings()?.toSet().orEmpty() + val result = engine.generate( + GenerationRequest(fixture.getString("zone_id"), fixture.getString("goal_id"), + fixture.getInt("duration_minutes"), fixture.getString("reference_time"), candidates, preferred), + GenerationHistoryReader { visitor -> history.forEach(visitor) }, + ) + assertCompleteResult(id, fixture.getJSONObject("expected"), templates, result) + } + } + + private fun historyRow(occurrence: JSONObject, set: JSONObject?) = GenerationHistoryRow( + occurrence.getString("session_id"), occurrence.getString("occurrence_id"), occurrence.getString("exercise_id"), + occurrence.getString("started_at"), if (occurrence.isNull("equipment_id")) null else occurrence.getString("equipment_id"), + RecordingMode.SETS, TrackingMode.REPS, + SessionLoadMode.valueOf(occurrence.optString("load_mode", "none").uppercase()), + occurrence.optInt("rest_seconds", 0), occurrence.optBoolean("has_any_target", false), + set?.getInt("position"), set?.getInt("repetitions"), set?.optDouble("weight_kg")?.takeUnless { set.isNull("weight_kg") }, + occurrence.optBoolean("explicit_max", false), + ) + + private fun assertCompleteResult(id: String, expected: JSONObject, templates: JSONObject, actual: GeneratedSessionSuggestion) { + assertEquals(id, expected.getInt("estimated_duration_seconds"), actual.estimatedDurationSeconds) + assertEquals(id, expected.getBoolean("insufficient_resolved_candidates"), actual.insufficientResolvedCandidates) + assertEquals(id, expected.getJSONArray("shortage_codes").strings(), actual.shortageCodes) + assertExposure(id, expected.getJSONObject("exposure"), actual.exposure) + val exercises = expected.getJSONArray("exercises") + assertEquals(id, exercises.length(), actual.exercises.size) + repeat(exercises.length()) { index -> + val raw = exercises.getJSONObject(index) + val row = if (raw.has("template")) templates.getJSONObject(raw.getString("template")) else raw + assertExercise("$id exercise $index", row, actual.exercises[index]) + } + } + + private fun assertExposure(id: String, expected: JSONObject, actual: BodyZoneRecentExposure) { + fun window(name: String, value: ExposureWindowSummary) { + val row = expected.getJSONObject(name) + assertEquals(id, row.getInt("primary_set_count"), value.primarySetCount) + assertEquals(id, row.getInt("secondary_set_count"), value.secondarySetCount) + assertEquals(id, row.getInt("session_count"), value.sessionCount) + assertEquals(id, row.getJSONArray("pattern_ids").strings(), value.patternIds) + } + window("within_24h", actual.within24h) + window("within_72h", actual.within72h) + assertEquals(id, expected.getBoolean("recent_exposure"), actual.recentExposure) + assertEquals(id, expected.getBoolean("repeated_exposure"), actual.repeatedExposure) + assertEquals(id, expected.getString("warning_level").uppercase(), actual.warningLevel.name) + assertEquals(id, expected.getInt("unclassified_actual_set_count"), actual.unclassifiedActualSetCount) + val latest = expected.optJSONObject("latest") + assertEquals(id, latest?.getString("started_at"), actual.latestStartedAt) + assertEquals(id, latest?.getString("session_id"), actual.latestSessionId) + assertEquals(id, latest?.getString("occurrence_id"), actual.latestOccurrenceId) + assertEquals(id, latest?.getJSONArray("pattern_ids")?.strings().orEmpty(), actual.latestPatternIds) + } + + private fun assertExercise(id: String, expected: JSONObject, actual: GeneratedSessionExercise) { + assertEquals(id, expected.getString("exercise_id"), actual.exerciseId) + assertEquals(id, expected.getString("equipment_id"), actual.equipmentId) + assertEquals(id, expected.getString("equipment_load_semantics").uppercase(), actual.equipmentLoadSemantics?.name) + assertEquals(id, expected.getString("primary_zone_id"), actual.primaryZoneId) + assertEquals(id, expected.getJSONArray("secondary_zone_ids").strings(), actual.secondaryZoneIds) + assertEquals(id, expected.getJSONArray("pattern_ids").strings(), actual.patternIds) + assertEquals(id, expected.getInt("target_sets"), actual.targetSets) + assertEquals(id, expected.getInt("target_repetitions"), actual.targetRepetitions) + assertEquals(id, expected.getInt("rest_seconds"), actual.restSeconds) + assertEquals(id, if (expected.isNull("target_weight_kg")) null else expected.getDouble("target_weight_kg"), actual.targetWeightKg) + assertEquals(id, expected.getString("planned_load_mode").uppercase(), actual.plannedLoadMode.name) + assertEquals(id, expected.getInt("estimated_seconds"), actual.estimatedSeconds) + assertEquals(id, expected.getString("confidence").uppercase(), actual.confidence.name) + val recency = expected.getJSONObject("recency") + assertEquals(id, recency.getBoolean("recent_same_exercise"), actual.recency.recentSameExercise) + assertEquals(id, recency.getBoolean("recent_same_pattern"), actual.recency.recentSamePattern) + assertEquals(id, expected.getString("exposure_warning_level").uppercase(), actual.exposureWarningLevel.name) + assertEquals(id, expected.getJSONArray("rationale_codes").strings(), actual.rationaleCodes) + assertEquals(id, expected.getJSONArray("source_ref_ids").strings(), actual.sourceRefIds) + val source = expected.optJSONObject("load_source") + assertEquals(id, source?.getString("session_id"), actual.loadSourceSessionId) + assertEquals(id, source?.getString("occurrence_id"), actual.loadSourceOccurrenceId) + assertEquals(id, source?.getString("started_at"), actual.loadSourceStartedAt) + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt new file mode 100644 index 0000000..42d907d --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionGeneratorRepositoryTest.kt @@ -0,0 +1,270 @@ +package com.labfytools.trainlog.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.test.core.app.ApplicationProvider +import com.labfytools.trainlog.model.ActiveSessionDraft +import com.labfytools.trainlog.model.SessionDraft +import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionSetDraft +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.OffsetDateTime +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SessionGeneratorRepositoryTest { + private lateinit var context: Context + private lateinit var databaseName: String + private var repository: TrainlogRepository? = null + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + databaseName = "generator-repository-${UUID.randomUUID()}.db" + } + + @After + fun tearDown() { + repository?.close() + context.deleteDatabase(databaseName) + } + + @Test + fun previewIsReadOnlyAndAcceptCreatesOnlyAnOrdinaryTargetDraft() { + val repo = openRepository() + val before = mutableTableCounts() + + val generated = repo.generateSessionPreview(request()) + assertTrue(generated is SessionGenerationResult.Generated) + val preview = (generated as SessionGenerationResult.Generated).preview + assertTrue(preview.exercises.isNotEmpty()) + assertEquals(before, mutableTableCounts()) + + assertEquals(AcceptGeneratedSessionResult.Accepted, repo.acceptGeneratedSession(preview)) + val loaded = repo.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded + assertEquals(preview.exercises.size, loaded.draft.exercises.size) + loaded.draft.exercises.zip(preview.exercises).forEach { (draft, proposed) -> + assertEquals(proposed.exerciseId, draft.exercise.exerciseId) + assertEquals(proposed.equipmentId, draft.equipmentId) + assertEquals(proposed.plan, draft.plan) + assertTrue(draft.sets.isEmpty()) + } + assertTrue(repo.listSessions().isEmpty()) + + val acceptedCounts = mutableTableCounts() + assertEquals(AcceptGeneratedSessionResult.ExistingActiveDraft, repo.acceptGeneratedSession(preview)) + assertEquals(acceptedCounts, mutableTableCounts()) + + repo.close() + repository = null + val reopened = openRepository() + val restored = (reopened.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft + assertEquals(loaded.draft.exercises.map { it.plan }, restored.exercises.map { it.plan }) + + val withActuals = restored.copy(exercises = restored.exercises.map { exercise -> + val repetitions = requireNotNull(exercise.plan?.reps) + exercise.copy(sets = List(requireNotNull(exercise.plan).sets) { + SessionSetDraft(reps = repetitions, weightKg = exercise.plan.weightKg) + }) + }) + assertEquals(ActiveDraftMutationResult.Saved, reopened.saveActiveSessionDraft(withActuals)) + val completed = reopened.finalizeActiveSessionDraft() + assertTrue(completed is FinalizeActiveDraftResult.Saved) + val sessionId = (completed as FinalizeActiveDraftResult.Saved).sessionId + assertTrue(reopened.loadActiveSessionDraft() is ActiveDraftLoadResult.None) + assertEquals( + preview.exercises.map { it.plan }, + reopened.getSessionDetail(sessionId)!!.exercises.map { it.plan }, + ) + } + + @Test + fun generationStreamsHistoryBeyondLegacyOccurrenceAndSetPreviewLimits() { + val repo = openRepository() + val exercise = exactExercise(repo) + repeat(33) { + val saved = repo.saveSession(SessionDraft(listOf(SessionExerciseDraft( + exercise = exercise, + equipmentId = EQUIPMENT_ID, + sets = listOf(SessionSetDraft(reps = 10), SessionSetDraft(reps = 10)), + )))) + assertTrue(saved is SaveSessionResult.Saved) + } + val before = mutableTableCounts() + val generated = repo.generateSessionPreview(request(referenceTime = OffsetDateTime.now().plusMinutes(1).toString())) + assertTrue(generated is SessionGenerationResult.Generated) + val exposure = (generated as SessionGenerationResult.Generated).preview.exposure + assertEquals(66, exposure.within24h.primarySetCount) + assertEquals(33, exposure.within24h.sessionCount) + assertEquals(before, mutableTableCounts()) + } + + @Test + fun invalidStoredTimestampFailsSpecificallyAndNeverWrites() { + val repo = openRepository() + val exercise = exactExercise(repo) + assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft( + exercise = exercise, + equipmentId = EQUIPMENT_ID, + sets = listOf(SessionSetDraft(reps = 10)), + )))) is SaveSessionResult.Saved) + directDatabase().use { it.execSQL("UPDATE sessions SET started_at='corrupt-time';") } + val before = mutableTableCounts() + + val result = repo.generateSessionPreview(request()) + + assertTrue(result is SessionGenerationResult.DatabaseError) + assertTrue((result as SessionGenerationResult.DatabaseError).message.contains("invalid stored session timestamp")) + assertEquals(before, mutableTableCounts()) + assertTrue(repo.loadActiveSessionDraft() is ActiveDraftLoadResult.None) + } + + @Test + fun doseEditRequalifiesObservedLoadAndDropsItWhenDoseNoLongerQualifies() { + val repo = openRepository() + val exercise = exactExercise(repo) + assertTrue(repo.saveSession(SessionDraft(listOf(SessionExerciseDraft( + exercise = exercise, + equipmentId = EQUIPMENT_ID, + sets = listOf( + SessionSetDraft(reps = 10, weightKg = 50.0), + SessionSetDraft(reps = 10, weightKg = 50.0), + ), + )))) is SaveSessionResult.Saved) + val preview = (repo.generateSessionPreview(request( + referenceTime = OffsetDateTime.now().plusMinutes(1).toString(), + )) as SessionGenerationResult.Generated).preview + assertEquals(50.0, preview.exercises.single().plan.weightKg!!, 0.0) + + val edited = repo.editGeneratedDose( + preview, 0, targetSets = 3, targetRepetitions = 10, + restSeconds = preview.exercises.single().plan.restSeconds, + manualWeightKg = null, + ) + + assertTrue(edited is SessionGenerationResult.Generated) + val changed = (edited as SessionGenerationResult.Generated).preview.exercises.single() + assertEquals(3, changed.plan.sets) + assertNull(changed.plan.weightKg) + assertNull(changed.loadSourceSessionId) + assertTrue(changed.estimatedSeconds > preview.exercises.single().estimatedSeconds) + } + + @Test + fun explicitEmptyEquipmentAvailabilityProducesAnEmptyReadOnlyPreview() { + val repo = openRepository() + val before = mutableTableCounts() + val result = repo.generateSessionPreview(request().copy(availableEquipmentIds = emptySet())) + assertTrue(result is SessionGenerationResult.Generated) + assertTrue((result as SessionGenerationResult.Generated).preview.exercises.isEmpty()) + assertEquals(before, mutableTableCounts()) + assertFalse(result.preview.insufficientResolvedCandidates.not()) + } + + @Test + fun preferencesAndExclusionsPassThroughWithoutCallerCandidateInjection() { + val repo = openRepository() + val before = mutableTableCounts() + val preferred = repo.generateSessionPreview(request().copy( + preferredExerciseIds = setOf(EXERCISE_ID), + )) as SessionGenerationResult.Generated + assertTrue("preferred_exercise" in preferred.preview.exercises.single().rationaleCodes) + + val excluded = repo.generateSessionPreview(request().copy( + preferredExerciseIds = setOf(EXERCISE_ID), + excludedExerciseIds = setOf(EXERCISE_ID), + )) as SessionGenerationResult.Generated + assertTrue(excluded.preview.exercises.isEmpty()) + + val pattern = preferred.preview.exercises.single().patternIds.single() + val excludedPattern = repo.generateSessionPreview(request().copy( + excludedPatternIds = setOf(pattern), + )) as SessionGenerationResult.Generated + assertTrue(excludedPattern.preview.exercises.isEmpty()) + assertEquals(before, mutableTableCounts()) + } + + @Test + fun formOptionsComeFromTheLoadedPolicyAndCanonicalBodyZones() { + val repo = openRepository() + val options = repo.sessionGenerationFormOptions() + assertEquals( + setOf( + "full_body", "upper_body", "chest", "back", "shoulders", "arms", "core", + "lower_body", "glutes", "thighs", "calves", + ), + options.zoneIds.toSet(), + ) + assertEquals(setOf("general", "strength", "hypertrophy", "endurance"), options.goalIds.toSet()) + assertEquals(listOf(30, 45, 60), options.durationPresets) + assertEquals(10..120, options.customMinutes) + assertTrue(options.zoneIds.all { id -> repo.listBodyZones().any { it.zoneId == id } }) + } + + private fun openRepository(): TrainlogRepository = + TrainlogRepository(context, databaseName).also { opened -> + repository = opened + // Opening SQLite is lazy; install the exact runtime half of the + // bundled scientific identity before exercising candidate assembly. + opened.listEquipment() + if (opened.listExercises().none { it.exerciseId == EXERCISE_ID }) { + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put("exercises", JSONArray().put(JSONObject() + .put("exercise_id", EXERCISE_ID) + .put("name", "Leg extension") + .put("recording_mode", "sets") + .put("tracking_mode", "reps") + .put("data_fields", 0))) + check(opened.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + } + } + + private fun exactExercise(repo: TrainlogRepository) = + requireNotNull(repo.listExercises().singleOrNull { it.exerciseId == EXERCISE_ID }) + + private fun request(referenceTime: String = OffsetDateTime.now().plusMinutes(1).toString()) = + SessionGenerationRequest( + zoneId = "thighs", + goalId = "general", + durationMinutes = 30, + referenceTime = referenceTime, + availableEquipmentIds = setOf(EQUIPMENT_ID), + ) + + private fun directDatabase(): SQLiteDatabase = SQLiteDatabase.openDatabase( + context.getDatabasePath(databaseName).absolutePath, + null, + SQLiteDatabase.OPEN_READWRITE, + ) + + private fun mutableTableCounts(): Map = directDatabase().use { db -> + listOf( + "sessions", "session_exercises", "performed_sets", "active_session_draft", + "draft_session_exercises", "draft_performed_sets", + ).associateWith { table -> + db.rawQuery("SELECT COUNT(*) FROM $table;", null).use { cursor -> + check(cursor.moveToFirst()) + cursor.getLong(0) + } + } + } + + private companion object { + const val EXERCISE_ID = "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab" + const val EQUIPMENT_ID = "leg_extension" + } +} diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 2dfc18f..7e1f9e8 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -3,6 +3,7 @@ package com.labfytools.trainlog.data import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.test.core.app.ApplicationProvider +import androidx.documentfile.provider.DocumentFile import com.labfytools.trainlog.model.ActiveSessionDraft import com.labfytools.trainlog.model.BodyObservationDraft import com.labfytools.trainlog.model.ExerciseDataFields @@ -13,6 +14,8 @@ import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionDraftForm import com.labfytools.trainlog.model.SessionExerciseDraft +import com.labfytools.trainlog.model.SessionExercisePlan +import com.labfytools.trainlog.model.SessionLoadMode import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.TrackingMode @@ -1502,7 +1505,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(10, cursor.getInt(0)) + assertEquals(11, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -1629,7 +1632,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(10, cursor.getInt(0)) + assertEquals(11, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) @@ -1850,6 +1853,263 @@ class TrainlogRepositoryDraftTest { } } + @Test + fun planningSurvivesDraftRestartCompletionDetailAndV3Export() { + val first = openRepository() + val reps = createExercise(first, "Plan reps", RecordingMode.SETS, TrackingMode.REPS) + val duration = createExercise(first, "Plan durée", RecordingMode.SETS, TrackingMode.DURATION) + val repsPlan = SessionExercisePlan( + sets = 3, reps = 8, weightKg = 42.5, + loadMode = SessionLoadMode.EXTERNAL, restSeconds = 120, + ) + val durationPlan = SessionExercisePlan( + sets = 2, durationSeconds = 45, restSeconds = 75, + ) + val active = ActiveSessionDraft(exercises = listOf( + SessionExerciseDraft(exercise = reps, plan = repsPlan, + sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0))), + SessionExerciseDraft(exercise = duration, plan = durationPlan, + sets = listOf(SessionSetDraft(durationSeconds = 40))), + )) + assertEquals(ActiveDraftMutationResult.Saved, first.saveActiveSessionDraft(active)) + first.close(); repository = null + + val reopened = openRepository() + assertEquals(listOf(repsPlan, durationPlan), + loadDraft(reopened).exercises.map { it.plan }) + val saved = reopened.finalizeActiveSessionDraft() as FinalizeActiveDraftResult.Saved + assertEquals(listOf(repsPlan, durationPlan), + reopened.getSessionDetail(saved.sessionId)!!.exercises.map { it.plan }) + val entries = JSONObject(reopened.buildMobileExportV3Json()) + .getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises") + assertEquals(3, entries.getJSONObject(0).getJSONObject("target").getInt("sets")) + assertEquals("external", entries.getJSONObject(0).getString("load_mode")) + assertEquals(45, entries.getJSONObject(1).getJSONObject("target").getInt("duration_seconds")) + assertEquals("none", entries.getJSONObject(1).getString("load_mode")) + try { + reopened.buildMobileExportV2Json() + fail("V2 publication silently discarded plans") + } catch (_: IllegalStateException) { } + } + + @Test + fun targetOnlyDraftPersistsButCannotFinalizeWithoutActualWork() { + val repo = openRepository() + val exercise = createExercise(repo, "Plan seul", RecordingMode.SETS, TrackingMode.REPS) + val draft = ActiveSessionDraft(exercises = listOf(SessionExerciseDraft( + exercise = exercise, + plan = SessionExercisePlan(sets = 4, reps = 10, restSeconds = 90), + ))) + assertEquals(ActiveDraftMutationResult.Saved, repo.saveActiveSessionDraft(draft)) + assertEquals(draft.exercises.single().plan, loadDraft(repo).exercises.single().plan) + assertTrue(repo.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Invalid) + assertTrue(repo.loadActiveSessionDraft() is ActiveDraftLoadResult.Loaded) + } + + @Test + fun pcMobileV3ReplaysPlansAndRejectsDivergentStableIdentityAtomically() { + val source = openRepository() + val exercise = createExercise(source, "Import plan", RecordingMode.SETS, TrackingMode.REPS) + assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft( + entryId = "sxe_v3_plan", exercise = exercise, + plan = SessionExercisePlan(sets = 2, reps = 6, weightKg = 18.0, + loadMode = SessionLoadMode.ASSISTANCE, restSeconds = 150), + sets = listOf(SessionSetDraft(reps = 6, weightKg = 20.0)), + )))) is SaveSessionResult.Saved) + val artifact = JSONObject(source.buildMobileExportV3Json()) + val sessionId = artifact.getJSONArray("sessions").getJSONObject(0).getString("session_id") + artifact.getJSONArray("body_observations").put(JSONObject() + .put("observation_id", "bo_v3_time") + .put("observed_at", "2026-03-02t11:30:00.123456789012345678900z") + .put("body_weight_kg", 71.5)) + val beforeMalformedTime = snapshotBusinessTables(context.getDatabasePath(databaseName).path) + listOf Unit>>( + "started_at" to { it.getJSONArray("sessions").getJSONObject(0).put("started_at", "not-a-time") }, + "observed_at" to { it.getJSONArray("body_observations").getJSONObject(0).put("observed_at", "2026-02-30T12:00Z") }, + "generated_at" to { it.put("generated_at", "2026-03-02 12:00:00Z") }, + ).forEach { (_, mutate) -> + val malformed = JSONObject(artifact.toString()); mutate(malformed) + assertTrue(source.applyPcMobileExportV3Json(malformed.toString()) is MobileSessionImportResult.Invalid) + assertEquals(beforeMalformedTime, snapshotBusinessTables(context.getDatabasePath(databaseName).path)) + } + // Exact grammar coverage includes omitted seconds and extreme offsets. + val exactTimes = JSONObject(artifact.toString()).put("generated_at", "2026-03-02t12:00z") + exactTimes.getJSONArray("sessions").getJSONObject(0) + .put("started_at", "2026-03-02T10:00+23:59") + exactTimes.getJSONArray("body_observations").getJSONObject(0) + .put("observed_at", "2026-03-02T11:30:00.1000-23:59") + assertTrue(source.applyPcMobileExportV3Json(exactTimes.toString()) is MobileSessionImportResult.Invalid) + // The existing session differs only in timestamp, so Invalid proves the + // valid spelling crossed structural validation and reached identity conflict. + assertEquals(MobileSessionImportResult.Applied(0, 1, 1, 0), + source.applyPcMobileExportV3Json(artifact.toString())) + assertEquals(SessionLoadMode.ASSISTANCE, + source.getSessionDetail(sessionId)!!.exercises.single().plan!!.loadMode) + val legacy = JSONObject(artifact.toString()).put("version", 2) + val legacyEntry = legacy.getJSONArray("sessions").getJSONObject(0) + .getJSONArray("exercises").getJSONObject(0) + legacyEntry.remove("target") + legacyEntry.put("load_mode", "none").put("rest_seconds", 0) + assertTrue(source.applyPcMobileExportV2Json(legacy.toString()) is MobileSessionImportResult.Invalid) + assertEquals(6, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps) + val duplicateKey = artifact.toString().replace("\"sets\":2", "\"sets\":2,\"sets\":3") + assertTrue(source.applyPcMobileExportV3Json(duplicateKey) is MobileSessionImportResult.Invalid) + listOf<(JSONObject) -> Unit>( + { it.getJSONObject("target").put("sets", 65) }, + { it.getJSONObject("target").put("reps", 10001) }, + { it.put("rest_seconds", 86401) }, + { it.put("load_mode", "none") }, + { it.put("target", JSONObject.NULL) }, + ).forEach { mutate -> + val malformed = JSONObject(artifact.toString()) + val malformedEntry = malformed.getJSONArray("sessions").getJSONObject(0) + .getJSONArray("exercises").getJSONObject(0) + mutate(malformedEntry) + assertTrue(source.applyPcMobileExportV3Json(malformed.toString()) is MobileSessionImportResult.Invalid) + assertEquals(6, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps) + } + artifact.getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises") + .getJSONObject(0).getJSONObject("target").put("reps", 7) + assertTrue(source.applyPcMobileExportV3Json(artifact.toString()) is MobileSessionImportResult.Invalid) + assertEquals(6, source.getSessionDetail(sessionId)!!.exercises.single().plan!!.reps) + + source.close(); repository = null + SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { + it.execSQL("UPDATE sessions SET started_at='bad-stored-time' WHERE session_id=?", arrayOf(sessionId)) + it.execSQL("UPDATE session_exercises SET load_mode='none',rest_seconds=0,target_sets=NULL," + + "target_reps=NULL,target_duration_seconds=NULL,target_weight_kg=NULL WHERE entry_id='sxe_v3_plan'") + } + val reopened = openRepository() + try { + reopened.buildMobileExportV3Json() + fail("V3 export should reject malformed persisted started_at") + } catch (error: IllegalStateException) { + assertTrue(error.message!!.contains("started_at")) + } + reopened.close(); repository = null + SQLiteDatabase.openDatabase(context.getDatabasePath(databaseName).path, null, SQLiteDatabase.OPEN_READWRITE).use { + it.execSQL("UPDATE sessions SET started_at='2026-03-02T10:00:00+01:00' WHERE session_id=?", arrayOf(sessionId)) + it.execSQL("UPDATE body_observations SET observed_at='bad-stored-time' WHERE observation_id='bo_v3_time'") + } + val bodyCorrupt = openRepository() + try { + bodyCorrupt.buildMobileExportV3Json() + fail("V3 export should reject malformed persisted observed_at") + } catch (error: IllegalStateException) { + assertTrue(error.message!!.contains("observed_at")) + } + // V2 behavior remains published and therefore still serializes the + // historical nonempty timestamp without applying the new V3 rule. + assertEquals("bad-stored-time", JSONObject(bodyCorrupt.buildMobileExportV2Json()) + .getJSONArray("body_observations").getJSONObject(0).getString("observed_at")) + } + + @Test + fun pcMobileV3TimestampValidationUsesFreshDestinationAndPreservesExactText() { + val source = openRepository() + val exercise = createExercise(source, "Fresh timestamp", RecordingMode.SETS, TrackingMode.REPS) + assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft( + entryId = "sxe_fresh_time", exercise = exercise, + sets = listOf(SessionSetDraft(reps = 5)), + )))) is SaveSessionResult.Saved) + val artifact = JSONObject(source.buildMobileExportV3Json()) + artifact.getJSONArray("body_observations").put(JSONObject() + .put("observation_id", "bo_fresh_time") + .put("observed_at", "2026-03-02T11:30Z") + .put("body_weight_kg", 70.0)) + source.close(); repository = null + context.deleteDatabase(databaseName) + + val destination = openRepository() + val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1) + .put("exercises", artifact.getJSONArray("exercises")) + assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + listOf( + "started_at" to { value: JSONObject -> value.getJSONArray("sessions").getJSONObject(0).put("started_at", "not-a-time") }, + "observed_at" to { value: JSONObject -> value.getJSONArray("body_observations").getJSONObject(0).put("observed_at", "2026-02-30T12:00Z") }, + "generated_at" to { value: JSONObject -> value.put("generated_at", "2026-03-02 12:00:00Z") }, + ).forEach { (field, mutate) -> + val malformed = JSONObject(artifact.toString()); mutate(malformed) + val result = destination.applyPcMobileExportV3Json(malformed.toString()) + assertTrue(result is MobileSessionImportResult.Invalid) + assertTrue((result as MobileSessionImportResult.Invalid).message.contains(field)) + assertTrue(destination.listSessions().isEmpty()) + assertTrue(destination.listBodyObservations().isEmpty()) + } + + val valid = JSONObject(artifact.toString()).put("generated_at", "2026-03-02t12:00z") + val rawStartedAt = "2026-03-02T10:00:00.123456789012345678900+23:59" + val rawObservedAt = "2026-03-02t11:30-23:59" + valid.getJSONArray("sessions").getJSONObject(0).put("started_at", rawStartedAt) + valid.getJSONArray("body_observations").getJSONObject(0).put("observed_at", rawObservedAt) + assertEquals(MobileSessionImportResult.Applied(1, 0, 1, 0), + destination.applyPcMobileExportV3Json(valid.toString())) + assertEquals(rawStartedAt, destination.listSessions().single().startedAt) + assertEquals(rawObservedAt, destination.listBodyObservations().single().observedAt) + } + + @Test + fun inboxPresentMalformedV3DoesNotFallBackToValidV2() { + val source = openRepository() + val exercise = createExercise(source, "Inbox priority", RecordingMode.SETS, TrackingMode.REPS) + assertTrue(source.saveSession(SessionDraft(listOf(SessionExerciseDraft( + entryId = "sxe_inbox_priority", exercise = exercise, + sets = listOf(SessionSetDraft(reps = 7)), + )))) is SaveSessionResult.Saved) + val validV3 = JSONObject(source.buildMobileExportV3Json()) + val malformedV3 = JSONObject(validV3.toString()) + malformedV3.getJSONArray("sessions").getJSONObject(0).put("started_at", "not-a-time") + val validV2 = JSONObject(validV3.toString()).put("version", 2) + validV2.getJSONArray("sessions").getJSONObject(0).getJSONArray("exercises") + .getJSONObject(0).remove("target") + source.close(); repository = null + context.deleteDatabase(databaseName) + + val destination = openRepository() + val catalog = JSONObject().put("format", "trainlog-pc-catalog").put("version", 1) + .put("exercises", validV3.getJSONArray("exercises")) + assertTrue(destination.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + val directory = Files.createTempDirectory("trainlog-inbox-priority-").toFile() + try { + val v3File = File(directory, "trainlog-pc-mobile-export-v3.json") + v3File.writeText(malformedV3.toString()) + File(directory, "trainlog-pc-mobile-export-v2.json").writeText(validV2.toString()) + val inbox = SyncCatalogInbox(context, destination) + val documentDirectory = DocumentFile.fromFile(directory) + val error = inbox.importPcSessionsFromDirectoryForTest(documentDirectory) + assertTrue(error!!.contains("started_at")) + assertTrue(destination.listSessions().isEmpty()) + assertTrue(v3File.delete()) + assertEquals(null, inbox.importPcSessionsFromDirectoryForTest(documentDirectory)) + assertEquals(1, destination.listSessions().size) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun sharedPythonV3FixtureImportsAndPublishesWithoutChangingPlanOrActuals() { + val repo = openRepository() + val fixture = findRepositoryFile("tests/fixtures/session-mobile-export-v3.json").readText() + val root = JSONObject(fixture) + val catalog = JSONObject() + .put("format", "trainlog-pc-catalog") + .put("version", 1) + .put("exercises", root.getJSONArray("exercises")) + assertTrue(repo.applyPcCatalogJson(catalog.toString()) is PcCatalogImportResult.Applied) + assertEquals(MobileSessionImportResult.Applied(1, 0, 0, 0), + repo.applyPcMobileExportV3Json(fixture)) + val detail = repo.getSessionDetail("se_33333333-3333-4333-8333-333333333333")!! + .exercises.single() + assertEquals(SessionExercisePlan(3, reps = 9, weightKg = 55.5, + loadMode = SessionLoadMode.EXTERNAL, restSeconds = 135), detail.plan) + assertEquals(listOf(52.5, null, 0.0), detail.sets.map { it.weightKg }) + val exported = JSONObject(repo.buildMobileExportV3Json()).getJSONArray("sessions") + .getJSONObject(0).getJSONArray("exercises").getJSONObject(0) + assertEquals(9, exported.getJSONObject("target").getInt("reps")) + assertEquals(3, exported.getJSONArray("sets").length()) + } + private fun openRepository(): TrainlogRepository { return TrainlogRepository(context, databaseName).also { repository = it } } diff --git a/android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt new file mode 100644 index 0000000..d5b20ba --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/ui/SessionGeneratorPreviewControllerTest.kt @@ -0,0 +1,98 @@ +package com.labfytools.trainlog.ui + +import com.labfytools.trainlog.data.BodyZoneRecentExposure +import com.labfytools.trainlog.data.ExposureWindowSummary +import com.labfytools.trainlog.data.GenerationWarningLevel +import com.labfytools.trainlog.data.SessionGenerationPreview +import com.labfytools.trainlog.data.SessionGenerationPreviewExercise +import com.labfytools.trainlog.data.SessionGenerationRequest +import com.labfytools.trainlog.data.TrainingRecencyWarning +import com.labfytools.trainlog.model.SessionExercisePlan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionGeneratorPreviewControllerTest { + @Test + fun formExposesTheCompleteFrenchChoiceSetAndValidatesCustomDuration() { + assertEquals( + listOf("Général", "Force", "Hypertrophie", "Endurance locale"), + SessionGeneratorFormController.goals.map { it.second }, + ) + val allowed = 10..120 + assertEquals(10, SessionGeneratorFormController.duration("10", allowed)) + assertEquals(120, SessionGeneratorFormController.duration("120", allowed)) + assertNull(SessionGeneratorFormController.duration("9", allowed)) + assertNull(SessionGeneratorFormController.duration("121", allowed)) + } + + @Test + fun manualLoadAcceptsFrenchDecimalAndEmptyMeansAutomaticRequalification() { + assertEquals(42.5, SessionGeneratorFormController.manualWeight("42,5").getOrThrow()!!, 0.0) + assertNull(SessionGeneratorFormController.manualWeight(" ").getOrThrow()) + assertTrue(SessionGeneratorFormController.manualWeight("abc").isFailure) + assertTrue(SessionGeneratorFormController.manualWeight("0").isFailure) + assertTrue(SessionGeneratorFormController.manualWeight("Infinity").isFailure) + } + + @Test + fun removeRetainsTheSelectedOrderAndUpdatesOnlyTransientDuration() { + val original = preview() + + val changed = SessionGeneratorPreviewController.remove(original, 1) + + assertEquals(listOf("ex_a", "ex_c"), changed.exercises.map { it.exerciseId }) + assertEquals(650, changed.estimatedDurationSeconds) + assertEquals(listOf("ex_a", "ex_b", "ex_c"), original.exercises.map { it.exerciseId }) + assertEquals(900, original.estimatedDurationSeconds) + } + + @Test + fun moveChangesOnlyTheRequestedPositionAndInvalidMovesAreNoOps() { + val original = preview() + val moved = SessionGeneratorPreviewController.move(original, 2, -1) + + assertEquals(listOf("ex_a", "ex_c", "ex_b"), moved.exercises.map { it.exerciseId }) + assertEquals(original.estimatedDurationSeconds, moved.estimatedDurationSeconds) + assertSame(original, SessionGeneratorPreviewController.move(original, 0, -1)) + assertSame(original, SessionGeneratorPreviewController.remove(original, 9)) + } + + private fun preview(): SessionGenerationPreview { + val request = SessionGenerationRequest("full_body", "general", 30, "2026-09-09T12:00:00Z") + val items = listOf( + exercise("ex_a", 100), exercise("ex_b", 250), exercise("ex_c", 250), + ) + val emptyWindow = ExposureWindowSummary(0, 0, 0, emptyList()) + return SessionGenerationPreview( + request = request, + exercises = items, + estimatedDurationSeconds = 900, + insufficientResolvedCandidates = false, + exposure = BodyZoneRecentExposure( + emptyWindow, emptyWindow, false, false, GenerationWarningLevel.NONE, + null, null, null, emptyList(), 0, + ), + ) + } + + private fun exercise(id: String, seconds: Int) = SessionGenerationPreviewExercise( + exerciseId = id, + exerciseName = id, + equipmentId = "eq_$id", + equipmentName = "Équipement $id", + primaryZoneId = "thighs", + primaryZoneName = "Cuisses", + patternIds = listOf("knee_dominant"), + patternNames = listOf("Dominante genou"), + plan = SessionExercisePlan(2, reps = 10, restSeconds = 90), + estimatedSeconds = seconds, + recency = TrainingRecencyWarning(false, false), + rationaleCodes = listOf("numeric_load_absent"), + loadSourceSessionId = null, + loadSourceOccurrenceId = null, + loadSourceStartedAt = null, + ) +} diff --git a/catalog/session-generation-policy-v1.json b/catalog/session-generation-policy-v1.json new file mode 100644 index 0000000..1cd0548 --- /dev/null +++ b/catalog/session-generation-policy-v1.json @@ -0,0 +1,187 @@ +{ + "format": "trainlog-session-generation-policy-v1", + "version": 1, + "policy_id": "session_generator_v1", + "status": "canonical_frozen", + "scientific_review_date": "2026-09-09", + "confidence": "moderate", + "scope": "Editable single-session resistance-training suggestions for adults; separate from frozen read-only TRAINING_KNOWLEDGE_V1.", + "numeric_rule_status": "All exact defaults, windows, thresholds, scores and caps are practical engineering conventions, not validated individualized prescriptions.", + "source_refs": ["acsm_2009", "acsm_2026_generator", "currier_2023", "grgic_2022", "kassiano_2022", "moran_navarro_2017_generator", "singer_2024_generator", "vieira_2022"], + "additional_references": [ + { + "ref_id": "acsm_2026_generator", + "title": "American College of Sports Medicine Position Stand. Resistance Training Prescription for Muscle Function, Hypertrophy, and Physical Performance in Healthy Adults: An Overview of Reviews", + "authors_or_organization": "Currier BS and colleagues; American College of Sports Medicine", + "year": 2026, + "pmid": "41843416", + "doi": "10.1249/MSS.0000000000003897", + "url": "https://pubmed.ncbi.nlm.nih.gov/41843416/", + "type": "intervention_evidence", + "notes": "Overview of 137 systematic reviews: resistance training improves multiple outcomes; heavier loads favor strength, higher weekly volume favors hypertrophy; many other prescription variables do not consistently alter outcomes.", + "limitations": "Healthy adults and multiweek interventions, not validation of this one-session algorithm; searches current to October 2024. Does not validate an unknown MAX as 1RM.", + "accessed_on": "2026-09-09" + }, + { + "ref_id": "moran_navarro_2017_generator", + "title": "Time course of recovery following resistance training leading or not to failure", + "authors_or_organization": "Moran-Navarro R and colleagues", + "year": 2017, + "pmid": "28965198", + "doi": "10.1007/s00421-017-3725-7", + "url": "https://pubmed.ncbi.nlm.nih.gov/28965198/", + "type": "intervention_evidence", + "notes": "Failure and nonfailure protocols produced different recovery courses even with matched total repetition volume.", + "limitations": "Ten trained men, bench press and squat, selected acute markers; cannot calibrate body-zone recovery from Trainlog history.", + "accessed_on": "2026-09-09" + }, + { + "ref_id": "singer_2024_generator", + "title": "Give it a rest: a systematic review with Bayesian meta-analysis on the effect of inter-set rest interval duration on muscle hypertrophy", + "authors_or_organization": "Singer A and colleagues", + "year": 2024, + "pmid": "39205815", + "doi": "10.3389/fspor.2024.1429789", + "url": "https://pubmed.ncbi.nlm.nih.gov/39205815/", + "type": "intervention_evidence", + "notes": "Nine studies suggest a small hypertrophy advantage to rests exceeding 60 seconds, with broad uncertainty and little detected difference above 90 seconds.", + "limitations": "Small heterogeneous evidence base; does not establish exactly 120 seconds as universally optimal.", + "accessed_on": "2026-09-09" + } + ], + "goals": { + "general": {"sets": 2, "repetitions": 10, "rest_seconds": 90, "sets_range": [1, 3], "repetitions_range": [8, 12], "rest_seconds_range": [60, 120]}, + "strength": {"sets": 3, "repetitions": 6, "rest_seconds": 180, "sets_range": [2, 3], "repetitions_range": [5, 8], "rest_seconds_range": [180, 300]}, + "hypertrophy": {"sets": 3, "repetitions": 10, "rest_seconds": 120, "sets_range": [2, 3], "repetitions_range": [8, 12], "rest_seconds_range": [90, 180]}, + "endurance": {"sets": 2, "repetitions": 16, "rest_seconds": 60, "sets_range": [1, 3], "repetitions_range": [15, 20], "rest_seconds_range": [45, 90]} + }, + "goal_range_interpretation": "Inclusive broad editable guidance, not RM zones, validated optimal intervals or hard physiological limits; default generation uses the single default values unless explicitly overridden, and then recomputes duration and load qualification.", + "zone_expansion": { + "full_body": ["chest", "back", "shoulders", "arms", "core", "glutes", "thighs", "calves"], + "upper_body": ["chest", "back", "shoulders", "arms"], + "chest": ["chest"], + "back": ["back"], + "shoulders": ["shoulders"], + "arms": ["arms"], + "core": ["core"], + "lower_body": ["glutes", "thighs", "calves"], + "glutes": ["glutes"], + "thighs": ["thighs"], + "calves": ["calves"] + }, + "eligibility": { + "recording_mode": "SETS", + "tracking_mode": "REPS", + "knowledge_resolution": "resolved_family_variant_limited", + "allowed_confidence": ["high", "moderate"], + "require_runtime_exercise_id": true, + "require_explicit_equipment_compatibility": true, + "unknown_conditional_and_unlinked_capabilities": "exclude_with_reason", + "scientific_zone_role": "primary_or_secondary", + "persisted_zone_disagreement": "explain_without_mutation", + "equipment_choice": "Among available compatible contexts prefer newest qualifying working-load anchor, then bytewise equipment_id; select one context per exercise. MAX does not select a context." + }, + "load": { + "lookback_seconds": 2419200, + "window": "0 <= reference_instant - occurrence_instant <= lookback_seconds", + "required_context": ["exact_exercise_id", "non_null_exact_equipment_id", "external_load_semantics", "no_known_execution_context_conflict"], + "priority": ["recent_repeated_performed_sets", "absent"], + "working_rule": "In the newest qualifying completed occurrence require at least proposed sets actual rows with positive finite weight and actual repetitions >= proposed repetitions. Reuse the minimum weight among those qualifying rows without increase. An occurrence with fewer qualifying rows cannot establish repeated-dose support.", + "actual_load_mode_compatibility": "Exact known scientifically compatible external equipment is required. Occurrence mode external qualifies; mode none qualifies only for actual-only shape with all targets absent and rest_seconds=0. This applies independently of exchange version and never rewrites stored mode. Explicit assistance or conflicting/unknown equipment never qualifies.", + "planned_load_mode": "Follow normal desktop plan invariants: absent numeric target weight means load_mode none; present target weight means external or assistance. Keep selected equipment resistance semantics separate from planning mode. Generator assistance numeric weight remains absent.", + "working_confidence": "uncertain", + "working_meaning": "Observed repetition completion only; effort, failure, technique, range and physiological readiness are not established. Warmup rows cannot be reliably separated.", + "max_rule": "No numeric prescription from explicit MAX. An occurrence-owned maximum without repetition count does not establish 1RM or any goal-specific repetition capacity. A compatible explicit MAX may be exposed only as context with explicit_max_present_no_numeric_prescription; target weight remains absent unless repeated actual-set evidence qualifies.", + "max_confidence": "uncertain", + "max_only_does_not_create_performed_sets": true, + "assistance": "omit_numeric_load_and_explain_assistance", + "bodyweight_or_unknown_semantics": "omit_numeric_load", + "machine_increment": "not_known; retain a qualifying observed actual load unchanged as an indicative editable value; invent no increment and perform no automatic rounding", + "progression": "none" + }, + "exposure": { + "short_window_seconds": 86400, + "long_window_seconds": 259200, + "window": "0 <= reference_instant - occurrence_instant < window_seconds", + "timestamp_policy": "reuse_exact_TRAINING_KNOWLEDGE_V1_instant_comparison", + "invalid_timestamp": "Invalid reference input fails request validation; any invalid stored timestamp encountered by the required history reader fails analysis explicitly under the existing temporal contract. Return the specific error and no complete exposure or generation result; never skip malformed timestamps as outside-window data.", + "counted_rows": "completed-history actual SETS+REPS rows with repetitions > 0; weight may be absent; deduplicate by occurrence/set identity", + "excluded": ["draft", "target_only", "zero_performed_sets", "explicit_max_only", "continuous_activity", "future_occurrence"], + "zone_source": "resolved_scientific_interpretation; unknown history marked unclassified, never converted from names", + "primary_secondary": "separate integer counts per zone; one set counts once in each explicitly supported role; never sum role counts as equivalent effective sets", + "requested_zone_aggregation": "Expand the requested zone, then evaluate each distinct actual row once: primary if its scientific primary is in the expansion; otherwise secondary if any scientific secondary is in the expansion; otherwise neither. Primary wins over all secondary descendant matches. Do not add leaf counters to form parent counters.", + "summary_consistency": "For each 24h/72h summary, set_count=primary_count+secondary_count is the deduplicated number of recorded rows, not effective physiological sets; session_count is distinct matching session IDs; pattern_ids is the sorted distinct union from exactly those matching rows. Last exposure is the newest matching nonfuture actual-row occurrence across the complete queried history, independently of the windows, with its source session/occurrence IDs and scientific pattern IDs. Unknown or incomplete history cannot produce a complete summary claim.", + "recent_primary_sets_24h_threshold": 1, + "recent_secondary_sets_24h_threshold": 3, + "repeated_primary_sets_72h_threshold": 6, + "repeated_secondary_sets_72h_threshold": 12, + "status": "recent_exposure if either short threshold; repeated_exposure if either long threshold; both flags may coexist; otherwise no_threshold_observed, never recovered", + "warning_levels": { + "none": "No primary or secondary exposure threshold met in a successfully completed analysis; does not mean recovered or safe.", + "notice": "At least one secondary threshold met and no primary threshold met.", + "warning": "At least one primary threshold met, regardless of secondary flags; informational and nonblocking." + }, + "unknown_history": "insufficient_information; zero observed classified sets does not prove no activity", + "user_continuation": "always_available; flags are informational, not a medical restriction or biological recovery estimate" + }, + "selection": { + "max_exercises": 6, + "max_per_exact_pattern": 1, + "duplicate_exercise": "forbidden", + "pattern_overlap": "exclude candidate if any of its exact pattern IDs is already selected", + "optional_preferences": { + "preferred_exercise_ids": "soft preference on exact UUID; duplicate requested IDs have no extra effect", + "excluded_exercise_ids": "hard exclusion on exact UUID; wins over preferred", + "excluded_pattern_ids": "hard exclusion if any scientific pattern ID intersects the supplied known pattern IDs", + "precedence": "Availability, scientific eligibility, explicit exclusions, duplicate/diversity limits and duration fit are applied before coverage priority and score. Preference never creates an unavailable or unreviewed candidate.", + "unknown_ids": "Unknown exercise IDs yield no candidate and are reported as unavailable/unmatched preferences or exclusions; unknown pattern IDs fail request validation." + }, + "recency": { + "same_exercise_window_seconds": 259200, + "same_pattern_window_seconds": 604800, + "window": "0 <= reference_instant - occurrence_instant < window_seconds", + "evidence": "At least one positive-repetition actual row in completed history; same exercise means exact UUID across equipment contexts, same pattern means nonempty intersection of resolved scientific pattern IDs. Count neither targets nor MAX-only occurrences; identity/pattern selection recency never authorizes cross-equipment load transfer.", + "application": "Apply each boolean recency penalty once, additively to existing zone penalties, with no cooldown exclusion. Exact boundary ages are outside their respective windows." + }, + "score": { + "requested_primary_zone": 100, + "requested_secondary_zone_only": 60, + "new_primary_zone": 20, + "new_pattern": 30, + "qualifying_working_load_history": 5, + "preferred_exercise": 15, + "recent_same_exercise": -25, + "recent_same_pattern": -15, + "recent_primary_threshold_on_candidate_primary": -30, + "recent_secondary_threshold_on_candidate_primary": -10, + "repeated_primary_threshold_on_candidate_primary": -20, + "repeated_secondary_threshold_on_candidate_primary": -10, + "any_exposure_flag_on_candidate_secondary_zones": -10 + }, + "algorithm": "Greedy highest total score among eligible candidates fitting remaining duration; recompute coverage bonuses after each selection; tie by bytewise exercise_id then equipment_id. Each boolean score applies at most once. Primary and secondary match base scores are mutually exclusive.", + "group_coverage": "For full_body prefer an as-yet unrepresented upper/lower/core region before repeating a represented region whenever a fitting candidate exists. For upper_body prefer one push and one pull before further upper-body patterns whenever available. For lower_body prefer knee extension/hip extension and knee flexion before other functions whenever available. Apply these as candidate-pool priorities before numeric score.", + "upper_push_patterns": ["horizontal_push", "vertical_push"], + "upper_pull_patterns": ["horizontal_pull", "vertical_pull"], + "lower_extension_patterns": ["knee_dominant", "hip_dominant", "single_joint_knee_extension"], + "lower_flexion_patterns": ["single_joint_knee_flexion"], + "coverage_shortage": "report actual missing patterns/regions; never invent exercises or fill time with redundant variants" + }, + "duration": { + "presets_minutes": [30, 45, 60], + "custom_min_minutes": 10, + "custom_max_minutes": 120, + "preparation_seconds": 300, + "setup_and_transition_seconds_per_exercise": 60, + "estimated_seconds_per_repetition": 4, + "exercise_seconds_formula": "60 + sets * repetitions * 4 + (sets - 1) * rest_seconds", + "session_seconds_formula": "300 + sum(exercise_seconds)", + "budget_rule": "Do not exceed requested estimate, shorten rests, add sets, or duplicate patterns to fill spare time. Return a shorter session with explanation when candidates or diversity are limited.", + "precision": "planning estimate only; preparation is a time allowance, not a performed exercise or individualized warmup protocol" + }, + "guidance": { + "effort": "Use a controllable load and finish with repetitions still possible; adjust downward or stop if the proposed repetitions cannot be completed with the intended technique.", + "load": "Starting suggestion only; confirm equipment and adjust after warming up. No logged value proves today's capability.", + "rest": "Rest longer if needed; actual session time may exceed the estimate.", + "recent_exposure": "Recent recorded exposure; recovery is not measured. Review how you feel; you may continue, modify, or choose another zone." + } +} diff --git a/docs/android.md b/docs/android.md index 6b5fa56..e38cc75 100644 --- a/docs/android.md +++ b/docs/android.md @@ -8,14 +8,18 @@ It is a native Kotlin/Jetpack Compose application with local SQLite persistence. The desktop remains the canonical long-term history and analytics store. -## Session exchange V2 +## Session exchange V3 Completed session occurrences persist an `entry_id`; it is never regenerated -for exchange. Android publishes `trainlog-mobile-export-v2.json` as the active -desktop snapshot and imports `trainlog-pc-mobile-export-v2.json` after the PC +for exchange. Android publishes `trainlog-mobile-export-v3.json` as the active +desktop snapshot and imports `trainlog-pc-mobile-export-v3.json` after the PC catalogue. The artifact preserves occurrence order, continuous metrics, set weights and equipment. The legacy V1 contract remains separate and readable. +V3 preserves ordinary plan metadata atomically with occurrence identity, +equipment, actual sets and MAX. V1/V2 remain readable legacy artifacts and are +never silently rewritten as V3. + ## 2. Implemented navigation ```text @@ -33,7 +37,7 @@ Accueil Android local database version: ```text -10 +11 ``` Domain tables cover: @@ -61,6 +65,21 @@ Schema v9 adds explicit completed/draft MAX results. Schema v10 additively stores direct primary/secondary body-zone relations and their private sync baseline; the taxonomy itself remains the shared manifest asset. +## Session generator V1 + +Home exposes **Générer une séance**. The generator uses the shared frozen +policy for all 11 selectable BODY ZONES, four goals, policy duration presets +and custom bounds. A generated preview is read-only until acceptance and +explains target dose, rest, equipment, observed-load source or absence, +exposure/recency, and explicit shortages. It never claims measured recovery. + +Users can edit, remove, reorder, regenerate or cancel the in-memory proposal. +Acceptance of a nonempty proposal is one transaction into the ordinary active +draft, with target plans and zero actual rows. An existing draft yields the +non-mutating `existing_active_draft` conflict. Empty results cannot be accepted; +nonempty partial results may be accepted and edited normally. Final completion +continues to require actual captured work. + ## 4. Exercise catalog Exercise creation records: @@ -266,17 +285,17 @@ bo_ Android maintains: ```text -Download/Trainlog/trainlog-mobile-export-v2.json +Download/Trainlog/trainlog-mobile-export-v3.json ``` -The V2 snapshot is refreshed after relevant local changes, including exercise, +The V3 snapshot is refreshed after relevant local changes, including exercise, session, body-observation, equipment association and PC-catalog updates. It preserves `entry_id`, occurrence position, optional equipment and actual -per-set weights. Android also publishes the V2 companion +per-set weights and ordinary planning metadata. Android also publishes the V2 companion `trainlog-equipment-associations-v2.json`; its `set` and `cleared` states are targeted by `(session_id, entry_id)`. -Before either V2 artifact, Android publishes its user-created equipment +Before either dependent artifact, Android publishes its user-created equipment definitions as `trainlog-mobile-equipment-definitions-v1.json`. The strict `trainlog-equipment-definitions` v1 format uses stable IDs and the fields `equipment_id`, `display_name`, `label_name`, `equipment_type`, and @@ -416,7 +435,7 @@ During exercise entry, `Machine / équipement (optionnel)` searches the shared catalogue by display name, physical-machine label and aliases. The selected canonical ID belongs to that session exercise entry, is durable in the active draft and completed session, and is visible in session detail. It may be -cleared. The active V2 exchange preserves multiple ordered occurrences of the +cleared. The active V3 exchange preserves multiple ordered occurrences of the same exercise in one session through `entry_id`. The frozen V1 artifacts remain readable only as legacy artifacts and keep their historical one-exercise identity assumptions; V1 is not rewritten to claim V2 support. diff --git a/docs/architecture.md b/docs/architecture.md index 9a3e1f7..47c1257 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,7 +169,7 @@ exercise_body_zone_sync ### Android -Android has an independent local SQLite schema, currently v10. Completed and +Android has an independent local SQLite schema, currently v11. 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. @@ -212,6 +212,27 @@ screen navigation and data ownership remain independent from the header. ## 5. Compatibility boundaries +### Session generator V1 + +`SESSION_GENERATOR_V1=PASS`. `catalog/session-generation-policy-v1.json` is its sole authored +policy source; generated C data and Android asset loading consume that same +policy. The generator composes read-only runtime/knowledge context, complete +history, deterministic selection and explicit uncertainty into an in-memory +proposal. Preview writes nothing. Android acceptance creates the normal +singleton draft atomically; TUI acceptance enters the normal editor. There is +no generated-session persistence silo. + +Exposure counts actual positive-repetition completed SETS+REPS rows only. +The exclusive thresholds are primary/secondary 1/3 at 24 hours and 6/12 at 72 +hours; primary produces `warning`, secondary-only produces `notice`, and `none` +is available only after successful analysis. Invalid stored time fails analysis; +these signals do not estimate recovery. Selection is bounded to six exercises, +uses requested-zone/group coverage, diversity, compatible equipment, exclusions, +preferences, recency and deterministic ID ties, and returns shortages rather +than invented candidates. Numeric weight is only an unchanged minimum observed +qualifying actual external-load dose for the exact exercise/equipment within +28 days; MAX, assistance and unknown context do not prescribe a number. + ### Frozen Trainlog JSON v1 `TRAINLOG_FORMAT_V1` is frozen and remains a compatibility boundary for its @@ -224,6 +245,7 @@ Synchronization uses separate formats: ```text trainlog-mobile-export v1 trainlog-mobile-export v2 +trainlog-mobile-export v3 trainlog-pc-catalog v1 trainlog-equipment-associations v2 trainlog-equipment-definitions v1 @@ -235,8 +257,8 @@ trainlog-sync-receipt v1 A new domain requirement must not be forced into frozen v1 by using notes, synthetic sets, or data loss. -The active occurrence-aware session exchange remains -`trainlog-mobile-export` v2. The separate directional +The active occurrence-aware session exchange is +`trainlog-mobile-export` v3. V2 remains a readable historical artifact. The separate directional `trainlog-equipment-definitions` v1 artifacts carry user-created equipment definitions: `trainlog-mobile-equipment-definitions-v1.json` travels from Android to PC and `trainlog-pc-equipment-definitions-v1.json` travels from PC @@ -307,9 +329,9 @@ b = Android -> PC, then PC -> Android ``` The Android-to-PC direction receives the mobile equipment-definitions v1 -artifact, mobile-export v2, and equipment-associations v2. The PC-to-Android +artifact, mobile-export v3, and equipment-associations v2. The PC-to-Android direction publishes PC equipment-definitions v1 before dependent artifacts, -then publishes the PC catalog v1, PC mobile-export v2 (including completed +then publishes the PC catalog v1, PC mobile-export v3 (including completed sessions and body observations), and equipment-associations v2. Both directions also transfer the same body-zone companion after exercise definitions are established and before completion of the direction. diff --git a/docs/current_state.md b/docs/current_state.md index e64dfd9..c07787d 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -1,6 +1,6 @@ # Current implementation state -Canonical snapshot: 2026-09-09. +Canonical snapshot: 2026-09-10. This document is the compact source of truth for the implemented Trainlog baseline. Detailed behavior belongs in the topic-specific documents. @@ -16,6 +16,7 @@ TRAINLOG_FORMAT_V1=FROZEN DESKTOP_SCHEMA_V11=PASS ANDROID_LOCAL_DATABASE_V10=PASS +ANDROID_LOCAL_DATABASE_V11=PASS ANDROID_SESSION_DRAFT_V1=PASS ANDROID_DRAFT_DURABLE=PASS ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS @@ -63,8 +64,9 @@ BODY_ZONES_TUI_REAL_VALIDATION=PASS BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS TRAINING_KNOWLEDGE_V1=PASS +SESSION_GENERATOR_V1=PASS -DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint) +DESKTOP_TESTS=45/45 PASS (latest validated checkpoint) ANDROID_BUILD=PASS HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS ``` @@ -120,7 +122,7 @@ Primary navigation: Implemented: - native Kotlin/Compose application; -- local SQLite database v10, with non-destructive v3 -> v10 migration; +- local SQLite database v11, with non-destructive v3 -> v11 migration; - one durable active-session draft, Home resume and raw-form restoration; - explicit confirmed discard and atomic completed-save/draft-clear; - exercise creation; @@ -162,7 +164,7 @@ The implemented read-only training-knowledge layer loads six versioned JSON catalogs as the sole authored scientific source, generates the immutable C catalog representation, and loads the same assets on Android. It has no database migration, no auto-seeding, and no synchronization artifact. The -desktop database remains schema v11 and Android remains schema v10. +desktop database remains schema v11 and Android remains schema v11. Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose source-linked science lookups and resolved-candidate filters. Desktop @@ -211,14 +213,14 @@ Artifacts: ```text Android -> PC trainlog-mobile-equipment-definitions-v1.json - trainlog-mobile-export-v2.json + trainlog-mobile-export-v3.json trainlog-equipment-associations-v2.json trainlog-exercise-body-zones-v1.json PC -> Android trainlog-pc-equipment-definitions-v1.json trainlog-pc-catalog-v1.json - trainlog-pc-mobile-export-v2.json + trainlog-pc-mobile-export-v3.json trainlog-equipment-associations-v2.json trainlog-exercise-body-zones-v1.json @@ -287,6 +289,39 @@ No SQLite file is copied. No mounted Android filesystem is required. +## Session generator V1 + +`SESSION_GENERATOR_V1=PASS`. +The frozen separate policy is loaded identically by C and Android. It generates a +read-only, editable proposal for `full_body`, `upper_body`, `lower_body`, or +one of the eight remaining leaf zones, with four goals and policy-owned duration +bounds. It uses complete completed-history evidence, deterministic candidate +selection, explicit shortages, and an observed repeated-dose load only when the +exact exercise and compatible external equipment have a qualifying 28-day +anchor. MAX is context only and never produces a numeric target. + +The Android v10 -> v11 migration adds nullable planning metadata to normal and +draft occurrences. Existing rows receive `load_mode=none`, `rest_seconds=0`, +and null targets without reconstruction. Android acceptance atomically creates +the existing normal singleton draft with no actual sets; an existing draft is a +non-mutating conflict. TUI `g` opens its preview and then the normal editor; +completion still needs actual work. Empty proposals cannot be accepted. +Nonempty partial proposals retain explicit shortages and may enter normal draft +editing. The separate active V3 mobile export preserves plan and actual fields +atomically; V1/V2 remain readable legacy formats. + +The initial [independent engineering delta review](reviews/session_generator_v1_engineering_review.md) +found no findings, and the one deep final audit then found three repairable +blockers. Its bounded repairs and bounded final repair review passed. The final +matrix passed 45/45 Meson tests, named ASan/UBSan 4/4, and Android 75 tests with +zero failures/errors and one known unavailable external real-v9 fixture skip; +the structural v10 migration test executed and passed. Validators, strict C17 +headers, deterministic generation and APK asset byte comparisons passed. Real +desktop preservation is baseline-equal with logical SHA-256 +`5bff76850581cc3abafd583377a5de2c16d6313607a4cacb812376cfebd36cce`, ten +protected catalog/format files unchanged, and an empty index. No real app +upgrade/install or hardware MTP exercise is claimed. + ## Validation checkpoint Desktop: diff --git a/docs/database.md b/docs/database.md index a39c6d2..ced0e2f 100644 --- a/docs/database.md +++ b/docs/database.md @@ -429,7 +429,23 @@ Desktop and Android schema versions are not required to match. Do not synchronize SQLite database files. -## 10. Validation +## 10. Session-generation planning metadata + +Desktop schema remains v11. Android schema v11 adds, through its additive +v10 -> v11 migration, `load_mode`, `rest_seconds`, `target_sets`, +`target_reps`, `target_duration_seconds`, and `target_weight_kg` to both normal +completed and durable-draft occurrences. Existing rows receive mode `none`, +zero rest and NULL targets; no historical plan is reconstructed. Row IDs, +identities, positions, equipment, actual sets, continuous rows, MAX results and +raw partial draft input are preserved. + +Targets are nullable planning metadata separate from actual rows. A plan has +positive sets and exactly one positive repetitions or duration value; a target +weight is finite and positive when present. An absent target has no target shape. +Continuous and explicit-MAX occurrences are targetless. Plan persistence does +not weaken normal completion's requirement for actual work. + +## 11. Validation ```bash meson compile -C build diff --git a/docs/domain/knowledge_system.md b/docs/domain/knowledge_system.md index 815ea69..b7f3167 100644 --- a/docs/domain/knowledge_system.md +++ b/docs/domain/knowledge_system.md @@ -104,10 +104,16 @@ No API infers range of motion, setup, actual force, or comparability of raw kilogram labels. Equipment, resistance semantics and execution context remain explicit. There are no prescriptions in V1. -## Documented future pipeline only +`SESSION_GENERATOR_V1` is a separate frozen policy and implementation layer +over this read-only knowledge boundary. It consumes resolved candidates and +history without mutating the knowledge catalogs; its separate policy and +limitations are documented in [session generation](session_generation.md). -The following is an architectural boundary for later work, not a V1 generator, -scoring algorithm, proposal, database change, or user-interface behavior. +## Session-generation boundary + +The following is the boundary consumed by the separate generator policy. It +does not make this knowledge layer itself a scoring algorithm, proposal, +database change, or user-interface behavior. ```text Session inputs @@ -116,8 +122,8 @@ Session inputs -> real resolved candidates -> explicit availability -> recent history and explicit-MAX context - -> future fatigue/recent-coverage interpretation - -> a future proposal + -> recorded exposure/recency interpretation + -> session-generator proposal ``` Candidate selection must seek diverse movement patterns rather than repeatedly @@ -126,4 +132,5 @@ take the goal, available days, functions/zones, recent frequency, recovery, progression, equipment and history, then reason across sessions. Medical constraints would be explicit inputs; no diagnosis or rehabilitation status is to be inferred. V1 prescribes no exercises, weights, sets, fatigue scores, -progression or schedule. +progression or schedule. Those bounded conventions belong only to the separate +frozen session-generation policy. diff --git a/docs/domain/session_generation.md b/docs/domain/session_generation.md new file mode 100644 index 0000000..b659f6c --- /dev/null +++ b/docs/domain/session_generation.md @@ -0,0 +1,387 @@ +# Session generation policy V1 + +Status: **CANONICAL POLICY / FROZEN**. `SESSION_GENERATOR_V1=PASS` after its +one deep final review, bounded repairs, bounded repair review, and final matrix. +Architecture: [ADVISOR_DECISION=PASS](../reviews/session_generator_v1_architecture_review.md). +Bounded final science: [SESSION_GENERATOR_V1_SCIENTIFIC_REVIEW=PASS](../reviews/session_generator_v1_scientific_delta_review.md). +These decisions settle the separately frozen policy. The completed lifecycle is +recorded by the canonical current-state and review documents. +The originating proposal is retained as historical review evidence and is +superseded for its MAX-derived numeric fallback. The implementation adds Android +planning metadata through v10 -> v11 and a separate mobile-export V3; it does +not change runtime scientific mappings, the desktop schema v11, or +`TRAINLOG_FORMAT_V1`. + +The authored policy is +[`session-generation-policy-v1.json`](../../catalog/session-generation-policy-v1.json). +It extends the application through a separate prescription policy; it does not +change the read-only scope or semantics of `TRAINING_KNOWLEDGE_V1`. +Existing scientific references remain in +[`science-references-v1.json`](../../catalog/science-references-v1.json). +Three additional bibliographic records are local to the new policy so that the +frozen knowledge catalogs and their generated assets remain unchanged. + +## Evidence and intended interpretation + +The 2026 ACSM position stand synthesizes 137 reviews of healthy adults. It +supports resistance training for several adaptations; heavier loads favor +strength and greater weekly volume favors hypertrophy. These findings concern +training over weeks and do not validate a one-session optimizer, an individual's +readiness, or Trainlog's numeric defaults. +[Currier and colleagues, ACSM 2026](https://pubmed.ncbi.nlm.nih.gov/41843416/) + +A large network meta-analysis likewise supports many viable resistance-training +configurations, with higher loads favoring strength and multiple sets appearing +in better-ranked hypertrophy configurations. Rankings are population summaries, +not evidence that one prescription is optimal for everyone. +[Currier and colleagues, 2023](https://pubmed.ncbi.nlm.nih.gov/37414459/) + +The historical ACSM stand described different repetition and rest conventions +for strength, hypertrophy and local muscular endurance. It supplies background +for the direction of the goal templates below, with current reviews taking +precedence. Its progression schedules and percentage-of-1RM prescriptions are +not imported into this generator. +[ACSM 2009](https://pubmed.ncbi.nlm.nih.gov/19204579/) + +| Goal | Proposed sets | Repetitions per set | Inter-set rest | +|---|---:|---:|---:| +| `general` | 2 | 10 | 90 seconds | +| `strength` | 3 | 6 | 180 seconds | +| `hypertrophy` | 3 | 10 | 120 seconds | +| `endurance` | 2 | 16 | 60 seconds | + +Broad editable guidance around those defaults is: + +| Goal | Sets range | Repetitions range | Rest range | +|---|---:|---:|---:| +| `general` | 1–3 | 8–12 | 60–120 seconds | +| `strength` | 2–3 | 5–8 | 180–300 seconds | +| `hypertrophy` | 2–3 | 8–12 | 90–180 seconds | +| `endurance` | 1–3 | 15–20 | 45–90 seconds | + +Endpoints are inclusive practical guidance, not validated optimal intervals or +hard physiological limits. The generator uses the single defaults unless values +are explicitly overridden. An override requires recalculating both duration and +load qualification using the proposed dose; it must not retain a load qualified +for fewer repetitions or fewer sets. Goal ranges overlap because adaptations +are not confined to mutually exclusive repetition bands. The historical +prescription principles above support their direction; these exact endpoints +remain authored conventions, including the conservative cap of three sets. + +These are editable, moderate-volume defaults, **not RM tests**, exclusive +adaptation ranges, weekly targets or proof of effective effort. `endurance` +means local muscular endurance in this bounded resistance-session generator; +it does not prescribe aerobic conditioning. Three sets is not necessarily +better than two for an individual. A strength-labelled session without a +well-characterized challenging load cannot promise strength-specific loading. + +The rest review suggests a small hypertrophy advantage to resting more than +60 seconds, with considerable uncertainty and little detected difference beyond +90 seconds. A 120-second hypertrophy default is therefore a convenient allowance, +not a scientifically exact optimum. Users may rest longer; the time estimate +then changes in practice. +[Singer and colleagues, 2024](https://pubmed.ncbi.nlm.nih.gov/39205815/) + +Failure is not generally required for adaptation in reviewed comparisons, but +this does not mean arbitrarily easy sets provide equivalent stimulus. Trainlog +does not measure proximity to failure, technique or effort. Guidance is to use +a controllable load and finish with repetitions still possible, adjusting +downward or stopping if the intended repetitions and technique cannot be +maintained. No exact repetitions-in-reserve value is inferred or stored. +[Grgic and colleagues](https://pubmed.ncbi.nlm.nih.gov/33497853/) + +## Candidate identity, movement and BODY ZONES + +Candidates require an existing runtime exercise UUID, `SETS + REPS`, a resolved +scientific interpretation with `high` or `moderate` confidence, and an explicitly +compatible available equipment context. Conditional or unknown interpretations +remain excluded with a reason. Flexible equipment and unlinked capabilities +cannot create an exercise identity. No display-name matching is permitted. + +`upper_body` expands to chest, back, shoulders and arms; `lower_body` to glutes, +thighs and calves; `full_body` to those seven leaf zones plus core. This is a +generator request expansion, not an alteration of the standalone `full_body` +catalog zone or a reason to relabel a cardio activity. A requested leaf admits +primary or explicit secondary scientific matches, preferring primary matches. +Scientific and persisted zone disagreements are explained, never silently fixed. + +The existing anatomy and pattern catalogs remain authoritative. Knee extension +and knee flexion, for example, remain distinct functions despite the shared +`thighs` zone. Pattern IDs are programming abstractions grounded in joint actions; +they do not measure force distribution or equal training dose. See +[anatomy and movement](anatomy_and_movement.md) and +[equipment interpretation](exercise_equipment_interpretation.md). + +Selection favors purposeful functional variety, not randomized novelty. The +variation review supports considering systematic variation but is small and +largely restricted to young men; it does not validate any exact rotation or +diversity score. +[Kassiano and colleagues](https://pubmed.ncbi.nlm.nih.gov/35438660/) + +At most six exercises are selected, each exercise UUID once, with at most one +candidate carrying any already-selected exact pattern ID. Thus two rows or two +leg-curl identities do not fill a session with the same pattern. This deliberate +V1 cap may omit useful within-pattern variation; it is not a biological law. + +Optional `preferred_exercise_ids` gives each matching exact UUID a **+15** soft +score bonus, once regardless of duplicate requested IDs. Optional +`excluded_exercise_ids` and `excluded_pattern_ids` are hard exclusions; exclude +a candidate if its UUID is excluded or any of its scientific pattern IDs is +excluded. Exclusion wins when an exercise is both preferred and excluded. +Availability, reviewed scientific eligibility, exclusions, duplicate/diversity +limits and time fit apply before coverage priorities and score. Preference +cannot create an unavailable, conditional or unknown candidate. Unknown exercise +IDs are reported as unavailable/unmatched; unknown pattern IDs are invalid +request input. None of these request options changes the catalog or history. + +Before scoring, prefer candidates in an uncovered region for `full_body` +(upper, lower, core). Region membership uses the candidate's scientific primary +zone. For `upper_body`, prefer an uncovered push/pull class if a fitting candidate +exists. For `lower_body`, similarly prefer the uncovered extension/flexion +classes listed in the policy. If several priority classes remain uncovered, +compare all their fitting candidates by score; once covered or unavailable, +use the whole remaining eligible pool. Region/pattern coverage limitations must +remain visible; they do not certify a balanced program. + +Score each eligible candidate as follows. Apply each boolean term once; the +two base match scores are mutually exclusive. Recompute after each selection. + +| Condition | Points | +|---|---:| +| Scientific primary matches requested expansion | +100 | +| Only a scientific secondary matches | +60 | +| Primary zone not yet selected | +20 | +| Pattern not yet selected | +30 | +| Qualifying repeated-set load history | +5 | +| Explicitly preferred exercise UUID | +15 | +| Same exercise actually performed within 72 hours | -25 | +| Same scientific pattern actually performed within 7 days | -15 | +| Primary-zone primary count reaches 24-hour threshold | -30 | +| Primary-zone secondary count reaches 24-hour threshold | -10 | +| Primary-zone primary count reaches 72-hour threshold | -20 | +| Primary-zone secondary count reaches 72-hour threshold | -10 | +| Any candidate secondary zone has any exposure flag | -10 | + +Take the highest score that fits the remaining time estimate, breaking ties by +bytewise exercise UUID and then equipment ID. Equipment choice prioritizes the +newest qualifying repeated-set anchor, then bytewise equipment ID; MAX does not choose the context. Occurrence recency ties use the shared exact instant +comparator and bytewise session/occurrence identity ordering. These scores and +priorities are deterministic software conventions; none is calibrated to an +adaptation effect size or injury probability. + +Same-exercise recency requires at least one actual positive-repetition performed +row for the exact UUID in completed history with `0 <= age < 259200` seconds. +This selection penalty applies across equipment contexts, while numeric load +reuse remains restricted to the exact equipment context. Same-pattern recency +requires an actual row whose resolved scientific pattern IDs intersect the +candidate's pattern IDs, with `0 <= age < 604800` seconds. Apply each penalty +once, even if several rows or patterns match, and add both to the zone terms. +Targets, MAX-only records and empty occurrences satisfy neither condition. +Exactly 72 hours or 7 days is outside its respective window. These are soft +priorities, not prohibited intervals between exercises. + +For example, otherwise equivalent same-pattern candidates A and B both receive +the pattern penalty, but only recently performed A receives the additional +25-point identity penalty. Even A's 5-point working-history bonus does not erase +that difference; B can rank higher. Purposeful repetition remains permissible +when alternatives are missing, coverage differs or other score terms prevail. + +The current knowledge inventory has no resolved chest exercise and no linked +resolved calf exercise. The custom chest entries remain conditional. A chest +or calf request can therefore produce an explicit shortage; other requests may +be shorter or incomplete. Do not repair these gaps by guessing anatomy from +equipment names. Scientific identity work is a separate future task. + +## Observed-load reuse, explicit MAX and missing load + +Numeric load reuse requires the exact exercise UUID, a non-null exact equipment +ID, external-load semantics and no known conflict in execution context. Different +machines, pulley contexts, load modes and variants never share anchors. Unknown +settings, range of motion and technique remain limitations even when recorded +IDs match. All proposals carry their source occurrence, date, context and +confidence; no numeric result is labelled a verified safe working weight. + +Use this order: + +1. Search completed history within 28 elapsed days, inclusive, for the newest + occurrence with at least the proposed number of actual sets having positive + finite weight and actual repetitions greater than or equal to the proposed + repetitions. Reuse the minimum weight among those qualifying rows. For three + proposed sets of 10, actual sets `10x50, 12x55, 10x50` support 50 kg. Actual + sets `10x50, 8x50, 6x50` do not establish three sets of 10. Targets never + satisfy this condition. The minimum is a conservative choice, not a test of + effort. Other nonqualifying rows remain history, not fabricated successes. +2. If no qualifying repeated dose exists, leave weight absent. A compatible + explicit MAX may be displayed as historical context with + `explicit_max_present_no_numeric_prescription`, but supplies no numeric target. +3. Do not substitute zero, an invented average, another exercise's result, + a percentage of MAX, or a random default. + +Observed-load reuse has confidence **`uncertain`** for today's prescription. +"Successful" or "working" here means only that a qualifying repeated dose was +recorded as performed. Trainlog cannot separate warmup from work reliably, +verify technique, know whether the set ended at failure, certify safety, or +detect all changes in readiness. Reusing observed load does not prove future +completion. An older qualifying occurrence is historical evidence, not automatic +progression over a more recent different performance. Its source must be visible. + +An explicit MAX is an occurrence-owned observed maximum result. Its model does +not contain a repetition count and is mutually exclusive with performed sets. +It therefore cannot be qualified as a measured 1RM from this record alone. +**No percentage prescription follows from an unqualified explicit MAX.** +The original proposal's 50% fallback was rejected by the architecture review +because it contradicts this existing canonical boundary. The same absent-load +fallback applies to every goal. The 28-day actual-anchor cutoff remains a +freshness convention, not a physiological detraining boundary. A MAX-only +occurrence never creates a performed-set count. + +External load and assistance are distinct. V1 omits numeric assistance and +bodyweight proposals. More assistance generally reduces unsupported demand; +neither halving assistance nor subtracting it from body mass is an external-load +prescription. Preserve an assistance explanation and equipment identity. Machine +increment availability is unknown: retain a qualifying observed value unchanged +as an indicative editable value, and invent no increment or rounding. See +[MAX context](programming_foundations.md). + +Legacy Android/V2 actual-only occurrences store planned mode `none`, zero rest +and absent targets even when actual sets have positive weight. They may qualify +only with the exact known compatible external equipment and all repeated-dose +conditions above. This transient compatibility rule also applies to V3 +actual-only rows; it never rewrites history or accepts assistance/unknown context. +Planning mode is separate: an absent-weight generated plan uses `none` under +the existing desktop invariant, retaining equipment resistance context separately. +A present observed target weight uses `external`. + +There is no progression, estimated 1RM, future schedule or automatic load increase. +No automatic MAX-derived fallback or validated personalized intensity is claimed. + +## Recorded exposure and recency + +Use the settled exact timestamp policy of `TRAINING_KNOWLEDGE_V1`. Reference +time is an explicit input. Define age as elapsed seconds between reference and +occurrence instant. Exposure windows are `0 <= age < 86400` and +`0 <= age < 259200`; exactly 24 hours is outside the short window and exactly +72 hours outside the long window. Calendar dates and a universal 48-hour rule +play no role. + +Count actual positive-repetition `SETS + REPS` rows from completed history, +deduplicated by occurrence/set identity. Weight need not be present. Each row +counts once for its resolved scientific primary zone and once for each distinct +explicit secondary zone, in **separate integer counters**. Never sum those +counters into equivalent effective sets or give secondary roles a claimed +fractional biological dose. Parent zones and muscle groups are not counted +again. Unknown or conditional history remains unclassified and visible as a +knowledge gap. Use neither exercise-name guesses nor target counts. + +Requested-zone summaries require an additional aggregation rule: expand the +request, then classify each distinct actual row **once**. Count it as primary +if its scientific primary is inside the expansion; otherwise count it as +secondary if any scientific secondary is inside the expansion. Primary wins +when both primary and secondary descendants match. Never construct a parent +total by adding leaf-zone counters. A row with primary `back` and secondary +`arms` counts once as primary for `upper_body`, and once as secondary for an +`arms` request; the two requested summaries are separate views of the same row. + +Within each 24/72-hour summary, `set_count = primary_count + secondary_count` +is the deduplicated count of actual rows, not equivalent physiological sets. +`session_count` counts distinct matching session IDs, and `pattern_ids` is the +sorted distinct union of scientific patterns from exactly those matching rows. +The latest exposure is the newest matching nonfuture actual-row occurrence in +the complete queried history, even if it is outside both windows. Preserve its +source session/occurrence identities and scientific patterns; use the existing +exact timestamp and identity tie policy. Last exposure is absent when none is +observed, not an inferred recovery date. Reader truncation cannot present an +incomplete latest-exposure or count summary as complete. The same eligible rows, +identity deduplication and temporal comparator underlie selection recency. + +Drafts, targets without performance, zero-set occurrences, MAX-only results, +continuous activity and valid future occurrences contribute zero +to these resistance-set counters. This does not claim MAX attempts or aerobic +activity cause no fatigue; they simply have no comparable recorded set dose. +An occurrence with target three sets but one actual set contributes one. + +| Informational flag | Primary count | Secondary count | +|---|---:|---:| +| `recent_exposure` within 24 hours | at least 1 | or at least 3 | +| `repeated_exposure` within 72 hours | at least 6 | or at least 12 | + +Both flags may be present. Below the thresholds use `no_threshold_observed`, +never `recovered` or `safe`. Missing or unclassified history means insufficient +information. No arbitrary reader preview limit may silently undercount the +windows; paginate or expose incomplete history and withhold a complete-summary +claim. Thresholds are prioritization conventions and warning triggers only. + +Expose exactly one level in addition to the flags: **`warning`** if either +primary threshold is met; otherwise **`notice`** if either secondary threshold +is met; otherwise **`none`** after successful analysis. Secondary-only exposure +never creates `warning`. For example three actual primary sets in 24 hours +produce `warning`; three secondary-only sets produce `notice`; one primary set +produces `warning`, while one secondary-only set reaches no threshold. These +levels express the strength of the recorded direct-versus-indirect targeting +signal, not clinical severity. All remain nonblocking. For grouped requests, +apply the thresholds to the deduplicated requested-zone primary/secondary counts +above, not sums of descendant warnings. + +An invalid reference instant fails request validation. An invalid stored +timestamp encountered by a required history reader **fails analysis explicitly** +under the existing temporal contract: return the specific error and **no complete +exposure or generation result**. Never discard malformed instants as if they +were outside the window. This applies to load anchoring, latest-exposure lookup, +set summaries and recency penalties alike. Missing history and a reader error +are distinct outcomes; the error is not `none`, `notice` or `warning`. + +Acute fatigue differs with failure and protocol. A systematic review found +greater fatigue after failure conditions; a small trained-men experiment found +different recovery courses even when total repetition volume was matched. +Neither permits recovery estimation from only logged set counts and body zones. +[Vieira and colleagues](https://pubmed.ncbi.nlm.nih.gov/34881412/), +[Moran-Navarro and colleagues](https://pubmed.ncbi.nlm.nih.gov/28965198/) + +Explain recent recorded exposure and preserve the user's ability to continue, +modify the proposal or choose another zone. Do not convert flags into a clinical +restriction, recovered percentage or predicted injury risk. No score changes +the saved anatomy or history. + +## Duration and incomplete coverage + +Presets are 30, 45 and 60 minutes; custom input is an integer from 10 to 120 +minutes. Bounds are interface conventions, not exercise-health thresholds. +Estimate 300 seconds for preparation and, per exercise: + +```text +60 + sets * repetitions * 4 + (sets - 1) * rest_seconds +``` + +The 60 seconds allow setup/transition. Four seconds per repetition is a planning +estimate, not a compulsory tempo. Preparation does not create a fake exercise +or performed set and is not an individualized warmup prescription. Sum these +terms without counting rest after the final set. Select only whole exercise +blocks fitting the remaining budget; never shrink rest or add redundant work +to fill the requested duration. Actual equipment queues, setup and rest can +increase time. Show estimated selected duration and any unfilled request. + +For example, one hypertrophy block costs 420 seconds; three blocks plus the +300-second preparation allowance cost 1560 seconds (26 minutes). This is a +duration estimate, not evidence that 26 minutes is an optimal session. + +## Implementation boundary + +Generation must remain read-only until explicit acceptance into the normal +capture flow. Proposed sets, repetitions, rest and load are targets, never +performed history. The accepted architecture adds nullable targets and explicit rest/load fields +to Android draft/completed occurrences through an additive v10-to-v11 migration. +A separate session exchange V3 preserves those normal fields atomically with +actual values and stable identities; V1/V2 remain readable and unchanged. +Preview performs no write. Explicit acceptance creates a normal active draft, +with a non-mutating conflict if another draft exists. See the +[architecture decision](../reviews/session_generator_v1_architecture_review.md). +No notes, fake performed sets, MAX reinterpretation or frozen exchange overload +is permitted. The initial final audit found repairable V3 timestamp, +documentation-bound, and shared-full-parity gaps; its one bounded repair chain +and final validation are complete. + +Scientific confidence is `high` for the anatomy/context distinctions, +`moderate` for broad goal-template interpretation, and `uncertain` for individual +load, effort, recovery and exact heuristic effectiveness. The generator has no +clinical or individualized rehabilitation scope and makes no long-term outcome +guarantee from one session. diff --git a/docs/exchange_format.md b/docs/exchange_format.md index aa8c996..9e7f861 100644 --- a/docs/exchange_format.md +++ b/docs/exchange_format.md @@ -14,6 +14,10 @@ This document defines the frozen Trainlog v1 exchange contract. Gate 1 validation and review are complete; incompatible new semantics require a new, separately versioned format. +The active completed-session synchronization artifact is separately versioned +`trainlog-mobile-export` V3 and is documented in [Synchronization +exchange](sync_exchange.md). It does not redefine this frozen V1 schema. + ## 2. Design goal Trainlog v1 must represent the training patterns required by the initial applications without turning the Android recorder into a complex training platform. diff --git a/docs/exercise_data_model.md b/docs/exercise_data_model.md index 14d0a58..4067e3f 100644 --- a/docs/exercise_data_model.md +++ b/docs/exercise_data_model.md @@ -107,8 +107,9 @@ general name rule. The read surface supports zone lookup, children, ancestors, direct relations, primary/secondary selection and exercises for a zone with optional descendants or primary-only participation. Those exercise IDs compose with existing -performance/MAX history readers, so a future session generator needs no new -duplicated MAX or history storage. No generator or load proposal exists yet. +performance/MAX history readers, so the separate session generator needs no new +duplicated MAX or history storage. The generator's proposals remain optional +planning metadata and do not alter historical actual work. ## 4. Load semantics @@ -160,6 +161,13 @@ Desktop-created set sessions may carry explicit planned targets. Android mobile snapshots can represent actual-only heterogeneous work. +The implemented session generator adds optional target sets/repetitions or +duration, rest and an optional strictly positive target load to ordinary +Android occurrences. These are planning metadata, never actual sets. A target +with no weight uses plan mode `none`; a target with weight records external or +assistance mode. A targetless occurrence has mode `none` and zero rest. +Continuous and explicit-MAX occurrences remain targetless. + Desktop schema v5 therefore allows an imported set session to have no synthetic uniform target: diff --git a/docs/reviews/session_generator_v1_architecture_review.md b/docs/reviews/session_generator_v1_architecture_review.md new file mode 100644 index 0000000..6c7075c --- /dev/null +++ b/docs/reviews/session_generator_v1_architecture_review.md @@ -0,0 +1,177 @@ +# SESSION_GENERATOR_V1 architecture decision + +Date: 2026-09-09. Baseline: `fd955315ccc9bb835a13eb94d206f1e01d893dfb`. +Independent configured advisor / Terra-high, isolated, read-only review: +`ADVISOR_DECISION=PASS`. Implementation and executable validation are pending. +This decision does not declare `SESSION_GENERATOR_V1=PASS`. + +The review read the four retained proposals and the existing knowledge, +programming, persistence, session and exchange contracts. No new scientific +discovery or frozen-contract change is required. The original proposal bytes +are retained in `/tmp/trainlog-session-generator-v1/original-proposal/`. + +## Shared policy and generator + +`catalog/session-generation-policy-v1.json` is the sole authored policy source. +A strict Python validator checks duplicate keys, exact shape, versions, numeric +bounds and references to existing catalogs. Meson generates immutable C policy +data from it; Android strictly loads that same asset. Native C and Kotlin +algorithms use shared production golden fixtures for the complete normalized +output. No independently authored defaults, scores, windows, expansions or +movement groups are permitted. TRAINING_KNOWLEDGE_V1 remains read-only. + +## Load decision + +The proposed numeric `0.50 * MAX` fallback is rejected. The canonical +`docs/domain/programming_foundations.md` states that an explicit MAX lacks a +repetition count and establishes neither 1RM nor a percentage prescription. +Calling that percentage uncertain does not remove the contradiction. + +The newest qualifying completed occurrence within an inclusive 28-day window +may supply an observed load, for the exact exercise and equipment and +compatible external-load semantics. At least the proposed number of actual +sets must have positive finite weight and repetitions at least equal to the +proposed repetitions. Use the minimum weight among qualifying rows, unchanged. +Preserve its source session, occurrence and timestamp. This is observed-dose +evidence, with uncertain applicability today; no progression or safety claim. + +Without that evidence, numeric target weight is absent. MAX may be shown only +as context with `explicit_max_present_no_numeric_prescription`. Assistance, +bodyweight and unknown resistance context never yield an automatic numeric +target. Unknown increments do not authorize rounding an observed value. +Equipment selection uses the newest qualifying working anchor, then bytewise +equipment identity; MAX does not select or numerically prescribe a context. + +## Android persistence + +The authorized Android v10 to v11 migration adds `load_mode`, `rest_seconds`, +`target_sets`, `target_reps`, `target_duration_seconds`, `target_weight_kg` to +both normal completed occurrences and durable draft occurrences. Existing rows +receive `none`, `0` and NULL targets, with no historical reconstruction. +Preserve row IDs, stable identities, positions, equipment references, actual +sets, continuous data, MAX, and raw draft form input. + +Planning metadata is separate from actual rows in Kotlin models and every +draft/detail/completion reader and writer. A planned set occurrence has positive +sets and exactly its profile's positive repetition or duration target. Optional +target weight is finite and positive. An absent target has NULL shape fields. +Continuous and explicit-MAX occurrences remain targetless. Completion retains +the existing atomic completed-session insertion and singleton-draft deletion. +The desktop remains schema v11; its existing occurrence model has plan fields. + +## Session exchange V3 + +Use `format: trainlog-mobile-export`, `version: 3` and directional filenames +`trainlog-mobile-export-v3.json` / `trainlog-pc-mobile-export-v3.json`. +Targets are ordinary occurrence metadata and travel atomically with identity, +equipment, actual sets and MAX. No companion plan store is introduced. + +V3 retains V2 identities, positions, equipment and actual-data shapes and +requires `load_mode`, `rest_seconds`, and `target`. The latter is null or an +object containing positive `sets`, exactly one positive `reps` or +`duration_seconds`, and optional positive finite `weight_kg`. Continuous rows +require null target, mode none and zero rest. MAX retains its actual-data +exclusivity and receives no invented target. + +V1/V2 remain readable with their existing semantics. Never add V3 fields to a +V2 artifact or create a downgrade merely by rewriting its version. Current +publication uses V3 and must not silently produce lossy V2 for planned history. +V3 presence takes priority; invalid/conflicting V3 fails explicitly without a +V2 fallback. Only V3 absence permits legacy selection. Equal stable-ID V3 +replays skip idempotently; divergent content conflicts and rolls back. Legacy +replay cannot erase local nondefault planning metadata. Preserve existing +definition-first and other companion reconciliation rules and suffix selection. +`TRAINLOG_FORMAT_V1` is unchanged. + +## Preview, acceptance and exposure + +Preview writes nothing. Android explicit acceptance atomically creates its +normal singleton draft with targets and no performed sets. Any existing draft +causes a non-mutating `existing_active_draft` conflict. Resume/discard uses the +existing explicit flows. Desktop acceptance passes its in-memory normal draft +to the existing session editor/insertion path. No generated-history silo. + +Reusable read-only exposure and recency services consume complete required +history on one nested-safe read snapshot, with bounded accumulator memory. +The existing context preview page limits cannot bound scientific history. +Count actual positive-repetition SETS+REPS rows only; drafts, targets, MAX-only, +continuous, empty and future occurrences do not create set exposure. Use the +settled exact timestamp parser/comparator. Invalid stored time fails the whole +required analysis; invalid reference time fails request validation. + +Parent-zone counting evaluates each actual row once: any requested descendant +primary match takes precedence over all secondary matches. Preserve separate +primary/secondary counters, distinct session counts, sorted pattern unions and +latest nonfuture exposure across full history. Unresolved science is explicitly +unclassified. Warning thresholds and selection/duration conventions otherwise +remain the retained scientific proposal, subject to bounded final delta review. + +## C17 ownership and bounds + +A focused `session_generation.h` API accepts borrowed call-duration requests +with zone, goal, minutes, explicit reference time and bounded optional ID arrays. +Results own allocations with one release function, or fixed capacities with +explicit failure before any complete-success claim. No retained request pointers +or global state. Output includes selected stable identities/context, targets, +duration, exposure, recency, confidence, stable rationale codes and source IDs. + +Malformed/unknown request zone or pattern/count/reference is INVALID_ARGUMENT; +stored corruption, invalid timestamps, SQL failure or unrepresentable selected +output is DATABASE_ERROR; unsupported schema fails explicitly. Complete sparse +or empty candidate coverage is OK with explicit insufficiency, never an invented +exercise. Preserve existing public ID and timestamp capacities: compare valid +long stored instants in full, but fail if selected text cannot fit. Check all +counts, arithmetic, narrowing and capacities. Required WHY/CONTRACT/INVARIANT +comments belong in the implementation patch. Run early strict standalone C17. + +## Required proof before completion + +Shared C/Android fixtures must cover exact temporal boundaries, malformed time, +deduplicated primary/secondary exposure and session/pattern summaries, selection +ties/diversity/equipment/exclusions/preferences/recency, sparse zones, observed +load anchors and absent MAX/assistance loads. Exercise actual production paths. +Use a structurally real Android v10 fixture; prove migration/restart/completion +preservation and acceptance conflicts. Test V3 round trips in both directions, +plan/actual/identity equality, legacy readability, truthful downgrade rejection, +idempotence/conflict rollback and malformed V3 precedence. Validate previews do +not write, both UI flows, all builds/tests/validators, strict C17, sanitizer paths, +deterministic asset regeneration, bounded scientific review, engineering review, +documentation, one deep final audit and final evidence capture. + +No application database mutation, installed-app operation, Git staging, commit +or push was performed by the advisor review. + +## Bounded representation clarification + +`ADVISOR_CLARIFICATION=PASS` after inspecting existing importers and desktop +schema checks. This clarification resolves planned versus actual load metadata; +it does not change historical values or scientific equipment semantics. + +V2 actual-only history always has planned `load_mode=none`, even with positive +actual weights on known external equipment. A qualifying actual anchor may use +explicit occurrence mode external, or the narrow actual-only shape +`none / rest 0 / all target fields absent` with exact known scientifically +compatible external equipment. Apply the same durable-shape rule to V3; never +infer from the exchange version, display name, family, or unknown equipment. +An explicit assistance/conflicting context cannot qualify. This is transient +actual-load eligibility, not a persisted mode rewrite. + +The desktop existing schema requires a non-null target weight for planned +external/assistance mode and forbids target weight for mode none. Therefore: + +- `target:null` requires mode none and zero rest, including MAX and continuous. +- A non-null target without weight uses mode none and may retain planned rest. +- A non-null target with weight uses external or assistance. +- Generated absent-weight suggestions use planned mode none while retaining + the separate selected equipment's resistance-context information. + +V3 explicit bounds are position 0..100000, target sets 1..64, repetitions +1..10000, duration 1..86400 seconds, rest 0..86400 seconds, target weight +finite and strictly positive. Actual weight retains V2 finite nonnegative +semantics. Reject absent/present mode contradictions before persistence. +These new V3 validations do not change desktop schema or V1/V2 semantics. + +Tests must include the actual-only external-equipment bridge and its missing, +unknown, different and assisted equipment rejections; valid absent-weight +planned mode none with positive rest; rejected targetless non-none/rest shapes; +MAX contradictions; mode/weight contradictions; and every range/nonfinite edge. diff --git a/docs/reviews/session_generator_v1_engineering_review.md b/docs/reviews/session_generator_v1_engineering_review.md new file mode 100644 index 0000000..5b23890 --- /dev/null +++ b/docs/reviews/session_generator_v1_engineering_review.md @@ -0,0 +1,70 @@ +# SESSION_GENERATOR_V1 engineering review + +Date: 2026-09-10. This is the independent bounded engineering delta review, +not the required deep final-reviewer audit. + +## Decision + +The initial independent delta review returned PASS. Its result is retained as +pre-repair evidence, but it did not detect the three repairable blockers found +by the subsequent one deep final audit. Its authorized bounded repair review +returned `BOUNDED_FINAL_REPAIR_REVIEW=PASS`; the engineering lifecycle is +`SESSION_GENERATOR_V1=PASS`. No second broad final review was run. + +## Scope and evidence + +The review covered the C generator/API and complete-history scan, TUI flow, +Android model/migration/repository/engine/preview/acceptance/editor paths, V3 +Python and Android producers/readers, transport precedence, shared fixtures, +and tests. It confirmed the one-policy source, C/Kotlin fixture agreement, +deterministic scoring/coverage and time semantics, role-separated exposure, +observed-only load anchoring and actual-only bridge, MAX/assistance exclusions, +bounded ownership, additive Android planning migration, normal acceptance, and +V3 strictness, idempotency, conflict rollback, and legacy compatibility. + +Final durable evidence passed: Meson 45/45; named ASan/UBSan 4/4; strict C17 +public-header checks; deterministic policy, fixture and knowledge generation; +JSON/import and policy validators; Android 75 tests with zero failures/errors +and one known unavailable external real-v9 fixture skip; and `assembleDebug`. The new +structural Android v10 planning-migration test executed and passed. Generated C, +fixtures, knowledge data, merged assets and packaged assets were byte-identical +where checked. The real desktop v11 database logical hash/counts and protected +catalog/schema files were unchanged; the index was empty. + +No real app upgrade/install and no hardware MTP exercise were performed. The +post-repair matrix is retained under `/tmp/trainlog-session-generator-v1/final-validation/`; +the parent will create `final-complete.patch` and `final-manifest.json` after +its final normal checks. Preservation reports schema v11, integrity `ok`, +logical SHA-256 `5bff76850581cc3abafd583377a5de2c16d6313607a4cacb812376cfebd36cce`, +baseline equality, ten unchanged protected catalog/format files, and an empty +Git index. + +## Contract precision + +An empty proposal cannot be accepted. A nonempty partial proposal retains its +shortage warnings and may enter normal draft editing. Normal completed history +still requires actual work; shortage is not a scientific rejection. + +## Final-audit repair state + +The final audit initially failed on V3 timestamp admission/publication, +current-document V2/V3 contradictions including the rest bound, and incomplete +shared full-output parity assertions. The bounded timestamp repair and the +16-case full normalized C/Kotlin fixture repair are implemented with focused +passing evidence. The bounded repair review verified all three repairs as +`BOUNDED_FINAL_REPAIR_REVIEW=PASS`. These repairs do not erase the initial audit +history and did not authorize another broad final-reviewer run. + +## Remaining lifecycle work + +The one deep final-reviewer audit has already run. Its bounded repair review and +the final normal validation matrix closed the lifecycle: +`SESSION_GENERATOR_V1=PASS`. The policy remains separately canonical/frozen. + +## Retained limits + +Known chest and calf coverage shortages remain explicit rather than inferred. +The policy heuristics do not measure recovery; an explicit MAX is not a 1RM and +never supplies a numeric target. Hardware MTP, installed-app migration, and a +real Android v9 fixture were not available for this validation. The new +structural v10 planning-migration test did execute and pass. diff --git a/docs/reviews/session_generator_v1_final_review.md b/docs/reviews/session_generator_v1_final_review.md new file mode 100644 index 0000000..a3dc924 --- /dev/null +++ b/docs/reviews/session_generator_v1_final_review.md @@ -0,0 +1,81 @@ +# SESSION_GENERATOR_V1 — one deep final audit + +Date: 2026-09-10. Baseline: `fd955315ccc9bb835a13eb94d206f1e01d893dfb`. +Configured final-reviewer / Terra-high, isolated and read-only. +Initial decision: **FINAL_REVIEW=FAIL**. One bounded repair chain is authorized; +do not run a second broad final audit or mark the tranche PASS before repair +verification, documentation synchronization and final validation. + +## Blocking findings + +1. **V3 timestamp validation.** Python `validate_payload()` accepted the shared + V3 fixture after replacing a session start with `not-a-time`. Android's + common V2/V3 validator likewise checked only nonempty session/body timestamps. + Accepted malformed history subsequently fails the generator's correct exact + timestamp reader. Repair V3 import and export in both languages to use the + existing exact Trainlog parser for session and body instants, rejecting before + persistence/publication. Preserve V1/V2 semantics. Test rejection without + mutation and malformed-present-V3 precedence over V2. +2. **Current documentation contradictions.** Architecture and Android docs + retained active-mobile-V2 statements despite V3 implementation, and the V3 + section of `sync_exchange.md` stated duration/rest 1..86400. Rest is actually + 0..86400. Correct current-state references, retaining explicitly historical + V1/V2 and equipment-associations V2 without semantic changes. +3. **Incomplete golden output comparisons.** Shared C/Kotlin fixtures checked + subsets of ordinary and selection outputs. Expand one shared corpus to full + normalized expected results: every selected identity/context/plan/load/source, + rationale/source list, recency and zone/pattern metadata, complete exposure + windows/latest/unclassified state, duration, insufficiency and shortage codes. + Exercise exact ID/equipment-anchor ties and every named coverage/score rule. + Assert the same complete results through both production engines. + +## Positive evidence and scope + +No additional material API/ABI, lifetime, transaction/snapshot cleanup, +scientific identity, or deterministic-selection defect was established in this +audit. Owned bounded C analyzer input copies, complete snapshot history scans, +observed-only external-load anchors, absent numeric MAX, Android normal-draft +acceptance and TUI zero-actual initial plans were reviewed positively. + +Before repair: Meson 45/45, focused ASan/UBSan 4/4, Android 73 tests with zero +failures/errors and one known external real-v9 fixture skip, strict headers, +builds and validators passed. Those passes did not establish the missing full +output assertions or V3 malformed-time rejection. + +Live MTP/device validation remains explicitly outside the automated evidence; +no device installation or real-data migration was performed. This limitation +is not a blocker and does not expand the bounded repair. + +## Repair verification + +The bounded V3 temporal repair is implemented. It validates V3-only root, +session and body timestamps with the settled exact parser before import, +publication or persistence, preserves V1/V2 nonempty-string behavior, and +retains malformed-present-V3 priority over V2. Focused Python and Android +production-path tests passed. + +The bounded full-parity repair is implemented. One shared 16-case corpus now +asserts complete normalized output through both engines, including all named +score/group/tie rules, plans, load provenance, rationale/source lists, exposure, +recency, shortages and duration. Focused C and Android runs passed; production +engines and the frozen policy were not changed. + +Documentation correction for finding 2 records V3 as the active mobile snapshot +and occurrence-aware exchange, retains historical V1/V2 and +equipment-associations V2, and states rest 0..86400 separately from duration +1..86400. The bounded final repair review subsequently verified that paragraph +and returned `BOUNDED_FINAL_REPAIR_REVIEW=PASS`. The one deep audit remains +historically `FINAL_REVIEW=FAIL`; it was not rerun. Its authorized bounded +repair chain, independent verification, and final validation matrix closed all +three findings. + +## Closure + +`BOUNDED_FINAL_REPAIR_REVIEW=PASS` closed the authorized repair chain. The +post-repair validation matrix passed: Meson 45/45, named ASan/UBSan 4/4, Android +75 with zero failures/errors and one unavailable external-v9 fixture skip, the +executed structural Android v10 migration, validators, strict headers, +deterministic regeneration and APK asset comparisons. Therefore +`SESSION_GENERATOR_V1=PASS`. This closure preserves the initial +`FINAL_REVIEW=FAIL` as historical evidence and does not represent a second broad +audit. diff --git a/docs/reviews/session_generator_v1_resume.md b/docs/reviews/session_generator_v1_resume.md new file mode 100644 index 0000000..2c60477 --- /dev/null +++ b/docs/reviews/session_generator_v1_resume.md @@ -0,0 +1,112 @@ +# SESSION_GENERATOR_V1 — active implementation record + +Date: 2026-09-10. Baseline: `fd955315ccc9bb835a13eb94d206f1e01d893dfb` +(`feat(training): complete training knowledge v1`). +Status: **COMPLETED RECORD — `SESSION_GENERATOR_V1=PASS`**. +User authorization includes autonomous implementation, the approved additive +Android migration, new V3 exchange, validation and all required reviews. +No commit, push, staging, destructive Git or installed-app/data reset is allowed. + +## Settled gates — do not restart research + +- `ADVISOR_DECISION=PASS`, including a bounded representation clarification: + [architecture decision](session_generator_v1_architecture_review.md). +- `SESSION_GENERATOR_V1_SCIENTIFIC_REVIEW=PASS` for the final policy delta: + [bounded scientific review](session_generator_v1_scientific_delta_review.md). +- [Canonical policy](../../catalog/session-generation-policy-v1.json) is frozen + for implementation; [domain semantics](../domain/session_generation.md) + incorporates both decisions. The policy remains separately frozen and the + full tranche has passed. +- [Originating review](session_generator_v1_scientific_review.md) is historical + proposal evidence. Its 50%-MAX acceptance and numeric fallback tests are + explicitly superseded. No MAX-derived numeric target or rounding is allowed. + +Numeric load uses the newest qualifying same-exercise/exact-equipment external +actual-dose occurrence within inclusive 28 days, with at least proposed count +of positive finite actual weights at repetitions >= target. Use the minimum +qualifying actual weight unchanged; otherwise absent. Legacy actual-only +none/zero-rest/null-target history can qualify only with exact known compatible +external equipment. It is not rewritten. Assistance and unknown context remain +excluded. Generated absent-weight plans use planned mode none, independently +of the equipment's resistance-context metadata. + +Defaults, ranges, warning thresholds, scoring, coverage, and duration remain +those of the retained scientific proposal. Count performed work only, grouped +zones deduplicated with primary precedence, exact timestamp parsing, explicit +malformed-time failure, no measured-recovery claim. + +## Architecture and implementation state + +Desktop schema remains v11. Android v10 -> v11 adds the six planning columns +only to completed/draft occurrences, with truthful none/0/NULL defaults. +V3 session snapshots own targets atomically with ordinary occurrence data: +`trainlog-mobile-export-v3.json` and `trainlog-pc-mobile-export-v3.json`. +V1/V2 remain readable; V3 presence prevents fallback after failure. A legacy +same-ID replay against nondefault local planning metadata conflicts explicitly. +Preview is read-only. Accept creates the normal Android singleton draft only +if absent; an existing draft conflicts without mutation. Desktop uses its +normal in-memory session entry/editor/insertion path. + +The persistence/V3 worker completed its bounded implementation and recorded +`/tmp/trainlog-session-generator-v1/persistence-v3-handoff.md`: Kotlin +SessionExercisePlan, schema migration, normal plan preservation, producer and +consumer V3, transport precedence, truthful structural fixtures and shared +cross-platform wire fixture. Initial validation passed 35 Android repository/ +migration tests, assembleDebug, five affected Python suites, and sync_direction. +This is an intermediate worker result, not an independent engineering PASS. + +The C/Kotlin engines, strict policy derivation/loading, streaming history, +owned bounded C inputs, full named scoring/coverage, dose edits, Android +repository and editable preview, normal-draft acceptance and TUI flow are +implemented. Independent validation passed Meson 45/45, focused ASan/UBSan +4/4, Android 73 tests with zero failures/errors and one external real-v9 +fixture skip, public C17 headers, validators and deterministic regeneration. +The new structural Android v10 migration test ran and passed without a skip. +The bounded engineering delta review passed. + +Exactly one deep final review ran and found three blocking gaps, retained in +[the final review](session_generator_v1_final_review.md). Its ONE bounded +repair chain is complete. V3-only exact timestamp validation is repaired on +both import/export implementations; focused tests prove rejection before +mutation, supported grammar admission and malformed-present-V3 precedence +over valid V2 through the actual Android inbox directory boundary. Handoff: +`/tmp/trainlog-session-generator-v1/v3-time-repair-handoff.md`. + +The worker `session_generator_full_parity_repair` now owns full normalized +shared golden outputs and both C/Kotlin assertions, including score/coverage +and identity/equipment anchor ties. Production policy and engines remain +outside this test repair unless a demonstrated divergence requires repair. +Current documentation includes the bounded active-V3/rest-zero correction. +No separate generated-session history is authorized. + +## Completion record + +Exactly one deep final audit initially found three repairable gaps. Its one +authorized bounded repair chain closed V3 timestamp admission/publication, +complete shared 16-case C/Kotlin parity, and current V3 documentation. The +bounded repair review returned `BOUNDED_FINAL_REPAIR_REVIEW=PASS`; no second +broad final review ran. The final matrix passed Meson 45/45, named ASan/UBSan +4/4, Android 75 with zero failures/errors and one unavailable external-v9 +fixture skip, validators, strict headers, deterministic regeneration and APK +asset comparisons. The structural Android v10 planning migration executed and +passed. This record has no outstanding executable work; the parent may perform +its final normal checks and evidence capture. + +## Protection and retained evidence + +Ticket evidence root: `/tmp/trainlog-session-generator-v1/`. +Original four proposals: `original-proposal/` beneath that root. +Baseline tracked hashes: `baseline-tracked-hashes.json`. +Baseline real desktop read-only preservation: `baseline-preservation.json`. +Desktop schema v11, integrity ok, no FK violations; 3 sessions, 31 occurrences, +28 performed sets, 19 MAX results, 6 continuous rows, 23 exercises, 3 custom +equipment definitions, 32 exercise-zone rows, 23 zone-sync rows and 1 body +observation. Compare the same logical-dump hashing method after implementation. +No real user DB, installed Android application, keystore, sync history, Git +index, commit or remote has been modified by this tranche. + +The prior session's advisor thread-limit blocker was cleared in this session: +advisor, bounded anatomy review and two implementation workers launched. +There is no current infrastructure blocker. The exposed lifecycle interface +has no close-agent tool; completed review agents were collected and interrupted +using the only available lifecycle action. No role substitution was used. diff --git a/docs/reviews/session_generator_v1_scientific_delta_review.md b/docs/reviews/session_generator_v1_scientific_delta_review.md new file mode 100644 index 0000000..9a19c0d --- /dev/null +++ b/docs/reviews/session_generator_v1_scientific_delta_review.md @@ -0,0 +1,187 @@ +# SESSION_GENERATOR_V1 final scientific delta review + +Date: 2026-09-09. Baseline: `fd955315ccc9bb835a13eb94d206f1e01d893dfb`. +Decision: **SESSION_GENERATOR_V1_SCIENTIFIC_REVIEW=PASS**. +Reviewer: configured anatomie / Astra-high specialist, bounded final policy +review. This clears the scientific policy gate for implementation; it does not +declare implementation, executable validation or `SESSION_GENERATOR_V1=PASS`. + +## Scientific question + +Does the reconciled final policy support an editable single-session suggestion +without deriving prescription from repetition-less MAX, overstating observed +performance, or interpreting authored exposure/selection rules as physiology? + +Yes. No substantive scientific ambiguity remains that requires new research +before encoding this bounded policy. Individual suitability and heuristic +effectiveness remain explicitly uncertain; passing the review does not resolve +those uncertainties through an unsupported efficacy claim. + +Read the anatomy skill, originating scientific review, final domain and policy, +approved architecture record, programming foundations and relevant existing +reference records. Compared the domain and policy against retained original +bytes in `/tmp/trainlog-session-generator-v1/original-proposal/`. Parsed policy +comparison found changes only in `numeric_rule_status`, `eligibility` and +`load`; goals, ranges, exposure, selection, duration, guidance and citations +are unchanged. Architecture/storage/exchange choices were accepted as inputs, +not independently re-reviewed here. + +## Sources + +Existing evidence is reused; no new scientific reference or frozen catalog +change is needed. Source verification was limited to the already cited papers. + +- [ACSM 2009](https://pubmed.ncbi.nlm.nih.gov/19204579/): historical professional + guidance supplies direction for repetition/rest conventions, not proof of the + exact defaults or permission to substitute Trainlog MAX for a measured RM. +- [Singer 2024](https://pubmed.ncbi.nlm.nih.gov/39205815/): the rest synthesis + reports uncertain, modest hypertrophy differences; it does not establish a + universal 120-second optimum. +- [Grgic and colleagues](https://pubmed.ncbi.nlm.nih.gov/33497853/): reviewed + comparisons do not generally require failure for adaptation. This does not + establish equivalent stimulus from arbitrary easy sets. +- [Vieira and colleagues](https://pubmed.ncbi.nlm.nih.gov/34881412/): failure + conditions produce greater acute fatigue in reviewed comparisons; logged + repetitions and BODY ZONES do not measure those experimental outcomes. +- [Kassiano and colleagues](https://pubmed.ncbi.nlm.nih.gov/35438660/): limited + intervention evidence supports considering systematic variation and cautions + against excessive rotation. No exact Trainlog score or recency interval was + validated by this review. + +The five PubMed abstracts above were readable during this delta review. The +originating review's summaries of [ACSM 2026](https://pubmed.ncbi.nlm.nih.gov/41843416/), +[Currier 2023](https://pubmed.ncbi.nlm.nih.gov/37414459/) and +[Moran-Navarro 2017](https://pubmed.ncbi.nlm.nih.gov/28965198/) are reused within +their recorded limits: adult multiweek adaptation evidence and a small acute +recovery experiment do not validate this algorithm. Their direct PubMed opens +returned no readable body during this review; no fresh full-text verification +is claimed. No commercial or EMG evidence underlies this delta. + +## Anatomical finding + +No muscle-role or anatomical identity change is proposed. Existing resolved +exercise interpretations remain the eligibility authority. A BODY ZONE is a +projection of anatomy, not an interchangeable muscle dose or exercise identity. +No additional anatomy discovery or persisted mapping migration is required. + +## Biomechanical finding + +The retained exact exercise, exact non-null equipment, compatible external +resistance and no-known-execution-conflict conditions are appropriate minimum +boundaries for reusing a displayed load. Matching IDs do not establish identical +settings, range, technique or calibration. Assistance and bodyweight do not +become interchangeable external kilograms. Preserving an observed number +unchanged avoids inventing an available increment or calibrated resistance. +This applies the existing [MAX/context foundation](../domain/programming_foundations.md). + +## Exercise interpretation + +**Goal defaults/rest — accepted.** General `2x10/90s`, strength `3x6/180s`, +hypertrophy `3x10/120s` and local muscular endurance `2x16/60s`, with their +unchanged editable ranges, are defensible authored templates. They are not RM +tests, exclusive adaptation bands, weekly prescriptions or optimal individual +doses. A strength label without characterized relative load promises no +strength-specific intensity. Rest can be extended, affecting actual duration. + +**Load anchor — accepted.** Within the inclusive 28-day window, select the +newest qualifying completed occurrence in the required context. At least the +proposed set count must contain positive finite actual weight and repetitions +at least equal to the proposed repetitions. Return the minimum weight across +all qualifying rows unchanged. Do not pool inadequate occurrences into an +invented qualifying session. An increased target dose must be requalified. + +This is a historical performance anchor, not a demonstration of today's +capacity. Qualifying subsets may coexist with nonqualifying rows; warmups may +qualify. The minimum can produce an insufficiently challenging suggestion. +Actual rest, tempo, exercise order and accumulated fatigue are not established +by the qualifying fields. A newer nonqualifying performance does not erase an +older eligible observation, but the older observation cannot be described as +verified current performance or automatic progression. Source and uncertainty +must remain visible. These are limitations of the accepted rule, not new +qualification criteria or a request to infer missing measurements. + +**MAX and equipment — accepted.** An explicit MAX without repetitions supplies +neither 1RM nor goal-specific repetition capacity. Without a qualifying actual +dose, numeric target weight is absent for every goal. MAX may provide context +only, using `explicit_max_present_no_numeric_prescription`; it cannot break +equipment ties or supply a percentage fallback. Context choice follows newest +qualifying actual anchor, then bytewise equipment identity. This review +supersedes the originating review's acceptance of 50% MAX and its obsolete +numeric-MAX-fallback test requirements. + +**Legacy representation clarification — accepted.** The parent relayed the +advisor's final clarification: legacy Android/V2 actual-only occurrences store +plan `load_mode=none`. That value alone establishes no resistance semantics. +Such a row may supply an anchor only with all targets absent and rest zero, +when the exact known scientifically compatible exercise/equipment context +positively establishes external resistance and actual positive weights satisfy +every other anchor condition. Explicit external occurrences remain eligible; +assisted, unknown, missing or conflicting contexts remain excluded. This +bridge does not infer external resistance from a positive number alone, rewrite +history or weaken the scientific repeated-dose requirement. A generated plan +with absent weight may likewise use plan mode `none` while preserving distinct +equipment resistance semantics. Parent-owned architecture/policy wording will +record this representation clarification; no additional research is required. + +**Scoring — accepted as practical inference.** Same-identity and same-pattern +penalties can favor an alternative even when the familiar exercise has a load +anchor. Their interaction with coverage priorities and the history bonus is +explicit and deterministic. Neither repeating within those windows nor choosing +an unanchored alternative is physiologically prohibited. Repeated use of the +generator is not a validated rotation schedule or longitudinal strength +program; specificity and familiar practice can be useful. No score is an +effect size, readiness measure or injury probability. The unchanged diversity +cap and duration estimate remain software conventions with explicit shortages. + +## BODY ZONE mapping + +No mapping changes. Preserve separate primary and secondary actual-row counts, +with primary precedence and per-row deduplication for grouped requests. +Thresholds `1/3` primary/secondary within 24 hours and `6/12` within 72 hours +are informational exposure conventions. Primary threshold gives `warning`; +secondary-only threshold gives `notice`; otherwise `none` only after successful +analysis. These labels are not clinical severity or measured recovery. Missing +or unclassified history remains insufficient information. MAX-only and +continuous activity exclusion means no comparable counted resistance-set dose, +not absence of fatigue from those activities. Invalid required history analysis +must not masquerade as no observed exposure. + +## Confidence + +- `high`: established anatomy/context distinctions and the conclusion that + repetition-less MAX cannot establish 1RM or repetition capacity. +- `moderate`: broad goal-template interpretation as an editable suggestion. +- `uncertain`: today's load/effort/readiness, exact scoring effectiveness, + optimal volume/rest, and outcomes from repeated generator use. + +## Limitations + +This is a final policy delta review, not new broad anatomy research, a clinical +prescription review, or an implementation audit. Retained literature concerns +population interventions or controlled acute protocols. None validates the +28-day freshness cutoff, exposure thresholds, scoring coefficients or duration +formula as personalized physiology. No frozen knowledge payload was changed. + +## Implementation handoff + +Implement the reconciled policy and architecture; no scientific numeric or +eligibility wording correction is required. Preserve the limitations above in +interpretation and explanations. Executable evidence should cover qualifying +and insufficient actual rows, target-dose edits, all-qualifying-row minimum, +newer nonqualifying versus older qualifying occurrence, exact context exclusion, +inclusive 28-day age, absent MAX-derived loads and no invented rounding. +Retain planned-versus-performed separation and existing exact temporal, +exposure/aggregation, deterministic selection and shortage checks. + +Two documentation-only updates belong to the parent, outside this review's +write scope: update the stale policy `status` value that still says architecture +is pending, and link this delta decision from the current domain/status record. +Preserve the originating review as proposal history, with its MAX conclusion +clearly superseded; do not let its former numeric-MAX tests drive implementation. + +Reviewed policy SHA-256 before the representation clarification above: +`912ba800d885916279b5f26a4923f8a39350de69c0c7ff021bfde3ec740d6fe2`. +Reviewed domain SHA-256 before the representation clarification above: +`beffc9a4ed97cd887a2ccd983e22a906dfa507b98c680083d41cee4bee5ecf50`. +Only this new review artifact was written. No production, policy, database, +device, staging, commit or push operation was performed. diff --git a/docs/reviews/session_generator_v1_scientific_review.md b/docs/reviews/session_generator_v1_scientific_review.md new file mode 100644 index 0000000..5008192 --- /dev/null +++ b/docs/reviews/session_generator_v1_scientific_review.md @@ -0,0 +1,142 @@ +# SESSION_GENERATOR_V1 scientific policy review + +Date: 2026-09-09. +Status: **HISTORICAL ORIGINATING PROPOSAL — superseded for implementation**. +The [architecture decision](session_generator_v1_architecture_review.md) and +[final bounded scientific review](session_generator_v1_scientific_delta_review.md) +settle the current policy. In particular, the 50% MAX fallback and its numeric +fallback test requirements below were rejected and MUST NOT be implemented. +The original proposal text is retained as review provenance. +This is the originating scientific specialist's policy review, not an +independent implementation audit or a `SESSION_GENERATOR_V1=PASS` declaration. + +## Scientific question + +Can Trainlog suggest one editable resistance session from existing reviewed +exercise identities, requested BODY ZONE, goal, duration and completed history, +without claiming measured recovery or inventing 1RM meaning? + +Finding: yes, with the explicit knowledge, uncertainty and target/history +boundaries in [the policy](../../catalog/session-generation-policy-v1.json) and +[domain specification](../domain/session_generation.md). + +## Sources and evidence separation + +Reviewed the project anatomy skill, domain anatomy, movement, equipment, +programming foundations and knowledge contracts, plus the exercise, equipment, +movement, BODY ZONE and central scientific reference catalogs. Followed PubMed +records for the existing adaptation, failure/fatigue and variation references +and added the current ACSM stand, a rest review and a recovery experiment. + +- [ACSM 2026](https://pubmed.ncbi.nlm.nih.gov/41843416/): intervention synthesis + and professional position, supporting broad prescription principles rather + than this exact algorithm. +- [Currier 2023](https://pubmed.ncbi.nlm.nih.gov/37414459/) and + [Grgic](https://pubmed.ncbi.nlm.nih.gov/33497853/): multiweek outcome evidence; + no assumption that failure is required or arbitrary low effort equivalent. +- [ACSM 2009](https://pubmed.ncbi.nlm.nih.gov/19204579/): historical goal/rest + conventions; not adopted progression or 1RM percentage rules. +- [Singer 2024](https://pubmed.ncbi.nlm.nih.gov/39205815/): rest-interval evidence + with substantial uncertainty; no exact optimal rest claim. +- [Vieira](https://pubmed.ncbi.nlm.nih.gov/34881412/) and + [Moran-Navarro](https://pubmed.ncbi.nlm.nih.gov/28965198/): acute fatigue and + protocol-dependent recovery, not biological recovery measurement from logs. +- [Kassiano](https://pubmed.ncbi.nlm.nih.gov/35438660/): limited variation + evidence, not randomized exercise selection or a validated diversity score. + +No new EMG-derived primary roles, manufacturer identification, clinical claim +or anatomy mapping is introduced. The policy-local references avoid modifying +the frozen knowledge payload. PubMed abstracts/records provided the current +claims; the ACSM PMC full-text route returned an access challenge and was not +represented as read in full. + +## Settled policy decisions + +1. General `2x10/90s`, strength `3x6/180s`, hypertrophy `3x10/120s`, local + muscular endurance `2x16/60s` are editable defaults, not RM tests or weekly + recommendations. Broad inclusive guidance is respectively 1–3 sets / 8–12 + repetitions / 60–120 seconds; 2–3 / 5–8 / 180–300; 2–3 / 8–12 / 90–180; + and 1–3 / 15–20 / 45–90. Exact endpoints are practical conventions, not + optimal physiological boundaries. Dose edits recompute duration and load + qualification. No progression. +2. Prefer recent repeated actual-dose evidence in the exact exercise and + external equipment context, with an inclusive 28-day freshness window. + At least the proposed set count must meet the proposed repetitions; use the + minimum qualifying weight. "Working" means recorded completion only. +3. A compatible explicit MAX can support a numeric fallback at half the + recorded load, rounded downward to 0.1 kg, in the same freshness window. + This was explicitly examined because MAX lacks repetitions: it is legitimate + only as an **uncertain, unvalidated starting-load convention**, not percent + 1RM, guaranteed safe weight or predicted rep capacity. Same coefficient for + all goals avoids invented goal-specific precision. This distinction must + survive implementation and user-facing presentation. +4. Assistance/bodyweight/unknown context leaves numeric weight absent. +5. Distinct primary and secondary actual-set counters use rolling 24/72-hour + windows with exact timestamps. Thresholds are respectively 1/3 and 6/12 + primary/secondary sets. Any primary threshold produces `warning`, otherwise + any secondary threshold produces `notice`, otherwise `none` after successful + analysis. These are nonblocking targeting/exposure signals, not clinical + severity. Secondary-only exposure never produces `warning`. +6. Primary-zone fit, function diversity, available equipment and existing + history guide deterministic selection. Pattern duplication and missing + science cannot be concealed by invented candidates. +7. Six-exercise cap and the `300 + sum(60 + sets*reps*4 + (sets-1)*rest)` time + estimate are engineering conventions. Incomplete coverage/time is explicit. +8. Proposed targets never become performed rows. Preservation of targets in + Android requires the architecture decision still pending. +9. Exact optional preferred UUIDs add +15 once. Excluded UUIDs or any intersecting + excluded pattern IDs are hard exclusions. Availability, science, exclusion, + diversity and time-fit rules win over preference. Unknown pattern IDs are + request errors; unmatched exercise IDs never create candidates. +10. Actual same-exercise performance within `0 <= age < 259200` seconds adds + -25; actual resolved same-pattern performance within `0 <= age < 604800` + adds -15. Each applies once and adds to zone penalties. They are soft + selection priorities, not biological cooldowns. Equipment context remains + strict for numeric load despite identity recency across equipment. +11. Requested grouped-zone summaries deduplicate each actual row across all + descendant matches; primary wins over secondary. Counts, distinct session + IDs, sorted pattern union and latest exposure use the same eligible-row + and exact temporal contracts; latest exposure is not limited to 24/72 hours. +12. Invalid stored timestamps fail required history analysis explicitly; no + complete exposure or generation result is returned. Never silently discard + malformed instants. Invalid reference time is a request validation error. + +## Limitations and required implementation checks + +- Confidence: `high` for established anatomy/context distinctions; `moderate` + for population-level training-template interpretation; `uncertain` for + today's load suitability and heuristic effectiveness. No universal recovery + clock, clinical restriction or measured readiness is implied. +- Target-only, zero-set, draft, MAX-only and valid future rows cannot create + performed exposure or qualify repeated working dose. Invalid stored time + fails analysis explicitly, including load/recency readers. Missing history + does not mean rested. Data-reader limits cannot silently undercount exposure. +- Load tests must include same-exercise different-equipment exclusion, + assistance exclusion, newer ordinary-dose precedence over MAX, numeric + qualified MAX fallback, old anchors, nonfinite values, and absent load. +- Boundary tests must cover exact 24-hour/72-hour exclusions, inclusive 28-day + load age, exact 7-day pattern-recency exclusion, equivalent offset/fraction + instants, invalid stored timestamp failure and deterministic identity ties. +- Test role-separated counts and shared-zone opposing functions; no duplicate + patterns, no conditional-name guessing and explicit shortages. +- Test parent aggregation where one row matches both a primary and several + secondary descendants: count once as primary, with one matching session; + do not multiply the row through its pattern union. Verify secondary-only + `notice` versus primary `warning` at equal qualifying counts, and no warning + level standing in for an analysis error. Verify exact-ID preference, + preferred-and-excluded conflict, unavailable preferences, excluded patterns + and a valid alternative outranking recently repeated identity. +- Current resolved science has no chest or calves candidate. Sparse proposals + are an honest result; fixing unresolved identities is separate research. +- The model lacks effort, RIR, technique/ROM verification, warmup classification, + machine calibration and reliably selectable increments. Do not hide those + limits behind the term "successful" or a confident precise kilogram number. + +## Handoff and outstanding gate + +Research artifacts are reviewable; production implementation has not been +authorized through this document. The parent reported that the intended +architecture advisor could not be spawned after repeated thread-limit failures. +The Android target/draft/storage architecture remains unresolved; the review +does not substitute for that advisor or authorize a schema/format migration. +No implementation, persistence, build or release success is claimed here. diff --git a/docs/roadmap.md b/docs/roadmap.md index 2e6d772..736fc58 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,6 +16,8 @@ GATE_2=PASS TRAINLOG_FORMAT_V1=FROZEN DESKTOP_SCHEMA_V11=PASS ANDROID_LOCAL_DATABASE_V10=PASS +ANDROID_LOCAL_DATABASE_V11=PASS +SESSION_GENERATOR_V1=PASS DIRECT_MTP_TRANSPORT=PASS BIDIRECTIONAL_SYNC_V1=PASS @@ -31,7 +33,7 @@ BODY_ZONES_V1=PASS BODY_ZONE_SYNC_V1=PASS BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS -DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint) +DESKTOP_TESTS=45/45 PASS (latest validated checkpoint) TUI_NOTCURSES_V1=PASS NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS NOTCURSES_TRUECOLOR_THEME=PASS @@ -57,11 +59,12 @@ The current product baseline includes: - supplied and custom equipment definitions with occurrence-level links; - shared synchronization engine and `trainlog-syncd`. -`BODY_ZONES_V1` is complete infrastructure for later planning: one shared +`BODY_ZONES_V1` is complete infrastructure for session generation: one shared manifest, desktop v11/Android v10 relations, Android/TUI edit and display, descendant-aware filters, unclassified history and one explicit-conflict sync -companion. It does not implement a session generator, custom zones or proposed -loads. +companion. The implemented generator uses this infrastructure; custom zones are +still outside this checkpoint and proposed loads remain editable plans rather +than actual work. `TRAINING_KNOWLEDGE_V1=PASS` is read-only infrastructure. Scientific review, independent temporal review, final engineering review, repair verification, and @@ -69,10 +72,10 @@ final executable validation passed. The temporal contract preserves source text and exact C/Android chronological pagination; one bounded repair chain closed the audit's stale-documentation, Android-loader, Meson-input, and role-only C query findings. -It provides catalog-backed scientific lookup and -runtime context composition without prescriptions, schema changes or catalog -seeding. The documented future session/program input pipeline is deliberately -not a roadmap gate or an implemented generator; see +It provides catalog-backed scientific lookup and runtime context composition +without prescriptions, schema changes or catalog seeding. The implemented +separate session generator consumes that boundary; multi-session programming is +still not a roadmap gate. See [Training knowledge system V1](domain/knowledge_system.md). `EXERCISE_EDIT_V1` is a completed capture correction: Android permits @@ -199,59 +202,15 @@ Gate: EXERCISE_METADATA_V1=PASS ``` -## Session planner v1 +## Session generator v1 -The desktop TUI becomes the canonical session-planning surface. - -A planned session should allow: - -- selecting exercises from the real gym catalog; -- ordering exercises; -- planned sets/repetitions or duration; -- planned rest; -- optional planned load where semantically valid; -- synchronization to Android. - -Android should then open a prepared session and require only actual performance -entry during training. - -The existing Body Zones V1 read APIs can later list exercises by direct or -descendant zone, optionally primary-only, and compose those IDs with existing -latest MAX and performance history. A future generator may use that surface, -but this planner tranche must still define its own selection policy and must not -duplicate MAX/history or infer a load automatically. - -Target workflow: - -```text -Desktop TUI --> prepare session --> sync - -Android --> open planned session --> enter actual values --> save --> sync - -Desktop TUI --> history and analytics -``` - -The feature should also make later support possible for: - -```text -duplicate previous session -reuse a planned session -session templates -``` - -Gates: - -```text -SESSION_PLANNER_V1=PASS -ANDROID_PLANNED_SESSION_ENTRY=PASS -``` +`SESSION_GENERATOR_V1=PASS`. +It provides +a bounded policy-driven generator in both UIs, then hands accepted nonempty +proposals to normal draft/editor flows where actual work is captured separately. +It does not create reusable templates, a planned-session sync product surface, +or a multi-session program. The one deep final audit found repairable gaps; +its bounded repairs, review, and final validation matrix passed. ## Session templates v1 diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index 2bb920a..b93d454 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -17,6 +17,7 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS EQUIPMENT_DEFINITIONS_V1=PASS EXERCISE_RECONCILIATION_V2=PASS EXPLICIT_MAX_RESULTS_V2=PASS +SESSION_GENERATOR_V1=PASS BODY_ZONE_SYNC_V1=PASS BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS @@ -44,13 +45,13 @@ Framework folder grant. | Direction | File | Format | | --- | --- | --- | | Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 | -| Android -> PC | `trainlog-mobile-export-v2.json` | `trainlog-mobile-export` v2 (active) | +| Android -> PC | `trainlog-mobile-export-v3.json` | `trainlog-mobile-export` v3 (active) | | Android -> PC | `trainlog-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 | | Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 | | Android -> PC | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 | | PC -> Android | `trainlog-pc-catalog-v1.json` | `trainlog-pc-catalog` v1 | | PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 | -| PC -> Android | `trainlog-pc-mobile-export-v2.json` | `trainlog-mobile-export` v2 | +| PC -> Android | `trainlog-pc-mobile-export-v3.json` | `trainlog-mobile-export` v3 (active) | | PC -> Android | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 | | PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | @@ -60,7 +61,7 @@ No SQLite file is transferred. Android scoped storage can preserve a prior MTP-created object and create a new artifact with the provider collision suffix, for example -`trainlog-mobile-export-v2 (N).json` or +`trainlog-mobile-export-v3 (N).json` or `trainlog-mobile-equipment-definitions-v1 (N).json`, or `trainlog-equipment-associations-v2 (N).json`, or `trainlog-exercise-body-zones-v1 (N).json`, or @@ -81,6 +82,23 @@ frozen contract. A V1 historical session is reconciled with V2 only when the exercise/order correspondence is unambiguous; otherwise the importer reports a conflict rather than silently overwriting data. +V3 is a separate, strict session artifact. It retains V2 identity, position, +equipment and actual-data shapes and additionally requires `load_mode`, +`rest_seconds`, and `target`. `target` is null or an object with positive +`sets`, exactly one positive `reps` or `duration_seconds`, and optional finite +positive `weight_kg`; bounds are position 0..100000, sets 1..64, reps 1..10000, +duration 1..86400 seconds and rest 0..86400 seconds. Continuous rows require target null, mode none +and zero rest; MAX remains actual-data-exclusive and targetless. Mode/target +contradictions reject before persistence. + +Current publication uses V3 and never emits a lossy V2 rewrite of planned +history. V1/V2 readers retain their contracts. Selected V3 takes priority: +malformed or conflicting V3 fails explicitly with no V2 fallback; only V3 +absence permits legacy selection. Equal stable-ID V3 replay skips idempotently; +divergent content conflicts and rolls back. A legacy replay cannot erase local +nondefault planning data. Existing definition-first, companion reconciliation +and filename-suffix selection rules continue to apply. + ## 4. Equipment definitions V1 User-created equipment definitions use the separate, directional @@ -144,7 +162,7 @@ user intent. A mobile creator ID already reconciled by the immediately preceding V2 exercise import is accepted only with that retained V2 definition as proof, never from a name-only guess. -## 5. Android -> PC mobile snapshot +## 5. Android -> PC mobile snapshot and retained V2 entry shape Header: @@ -163,12 +181,15 @@ sessions body_observations ``` -Android captures its custom-definition V1 companion, this V2 snapshot, body -zones, and the equipment-association companion before it publishes any of -them. It then publishes in that order: definitions, V2 snapshot, body zones, -associations. A malformed -persisted custom definition aborts publication before a V2 file can advertise -its reference; bundled manifest equipment is never copied into definitions V1. +For current publication, Android captures its custom-definition V1 companion, +the mobile V3 snapshot, body zones V1, and the equipment-associations V2 +companion before it publishes any of them. It publishes definitions V1, mobile +V3, body zones V1, then associations V2. A malformed persisted custom +definition aborts publication before a mobile V3 file can advertise its +reference; bundled manifest equipment is never copied into definitions V1. + +The following V2 entry-shape description is retained for legacy-reader +compatibility only; it does not describe current publication. V2 session entries additionally carry: @@ -282,9 +303,9 @@ explicit max result kept separate from sets The importer never invents a uniform target merely to fit desktop persistence. -### Exercise identity reconciliation +### Exercise identity reconciliation retained from V2 -The active V2/catalog path may reconcile distinct `exercise_id` values sharing +The retained V2/catalog reconciliation path may reconcile distinct `exercise_id` values sharing one normalized name only when recording mode and tracking mode are equal, all other represented invariants are compatible, and one bounded `data_fields` mask contains the other. The existing desktop identity is deterministic @@ -415,8 +436,8 @@ Android request The engine has three explicit modes: ```text -a Android -> PC: definition V1 -> mobile V2 -> body zones V1 -> association V2; no publish -p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V2 +a Android -> PC: definition V1 -> mobile V3 -> body zones V1 -> association V2; no publish +p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V3 (including bodies) -> association V2; no receive b bidirectional: complete inbound sequence, then complete outbound sequence ``` @@ -529,7 +550,7 @@ overloading frozen Trainlog JSON v1 `trainlog-equipment-associations`, version `2`. Each row is identified by `(session_id, entry_id)` and contains `exercise_id` as consistency metadata, then either `state: set` with a canonical `equipment_id`, or `state: cleared` -for an intentional removal. In the current V2 flow, the mobile snapshot already +for an intentional removal. In the V2 association contract, the mobile snapshot already carries the occurrence equipment value and the companion validates it. A missing companion conveys no equipment information and cannot clear a previously known choice. Unknown canonical IDs, unknown entries, ambiguous diff --git a/docs/tests.md b/docs/tests.md index f123977..b9e76b8 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -57,7 +57,7 @@ new unique identity -> create ``` That command validates the frozen Trainlog JSON V1 importer contract. Active -mobile V2 synchronization has a separate, stricter safe-reconciliation policy +mobile V3 synchronization has a separate, stricter safe-reconciliation policy covered below; it does not modify the frozen V1 expectation. ## 4. Desktop Meson suite @@ -110,9 +110,25 @@ tui_workflows Validated current suite: ```text -39/39 Meson tests PASS +45/45 Meson tests PASS ``` +`SESSION_GENERATOR_V1` validation covered policy shape and shared fixtures, +full-history exposure/recency boundaries, selection and observed-load anchors, +Android v10 -> v11 planning migration, V3 round trips and malformed-V3 +priority, and ordinary draft/editor acceptance. The checkpoint also passed 4/4 +selected ASan/UBSan tests and Android 73 tests with zero failures/errors; one +known historical real-v9 fixture skipped while the structural v10 migration +test executed and passed. No hardware MTP or real app-upgrade/install validation +is claimed for this checkpoint. + +The completed post-repair matrix passed 45/45 Meson tests, named ASan/UBSan +4/4, and Android 75 tests with zero failures/errors and one unavailable +external Android-v9 fixture skip. The structural Android v10 planning migration +executed and passed. Validators, strict C17 headers, deterministic policy/ +fixture/knowledge regeneration, and APK asset byte comparisons passed. The +earlier sanitizer invocation that selected no tests is not used as evidence. + The desktop executable is additionally smoke-checked in isolated tmux PTYs at 100x30, the exact 72x20 minimum, and the 60x15 fallback; a resize down/up must recover before a clean keyboard quit. Notcurses is verified as the executable's @@ -190,7 +206,7 @@ Notable regression coverage: replay, complete definitions/mobile/associations/body import, outbound publication, and stable second replay; - a production PC-exporter to Android-importer regression over definitions, - catalog, mobile V2, and association V2 artifacts: the fixture includes + catalog, mobile V3, and association V2 artifacts: the fixture includes Marche, Leg press, two Marche occurrences, per-set loads, body data, a durable draft, and custom equipment; after the first import, both the second and third imports report zero additions and an exact snapshot of every diff --git a/docs/tui.md b/docs/tui.md index 17394de..22553ef 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -162,6 +162,18 @@ Persistent duration/rest units remain seconds. Continuous exercise entry asks for duration and configured supplemental fields without set/rest/load prompts. +### Session generator + +On the dashboard, `g` opens the generator. It selects a policy BODY ZONE, goal +and duration, captures one explicit reference time, and renders a SQLite-free +preview containing target sets/repetitions, optional observed load/source, +rest, equipment, primary zone, patterns, exposure and shortage reasons. +Preview, resize and cancel write nothing. A generator result with no exercise +cannot enter persistence; a nonempty partial result remains editable with its +warnings visible. Accepting a preview builds the ordinary normal session draft +with zero actual rows and enters the existing editor. The existing completion +guard still requires actual rows for every SETS exercise. + ## 6. Session history and editing History is keyboard navigable. @@ -271,9 +283,9 @@ b run bidirectional synchronization r refresh device status ``` -`a` imports definitions V1, mobile V2, the body-zone companion and associations +`a` imports definitions V1, mobile V3, the body-zone companion and associations V2 only. `p` publishes definitions V1, catalog V1, the body-zone companion, -mobile V2 (including body observations), and associations V2 only. `b` +mobile V3 (including body observations), and associations V2 only. `b` completes that inbound sequence before beginning the outbound sequence. The direction keys are direct actions: pressing `a`, `p`, or `b` opens one diff --git a/tests/create_pc_android_idempotence_fixture.py b/tests/create_pc_android_idempotence_fixture.py index fda906c..35e53e0 100644 --- a/tests/create_pc_android_idempotence_fixture.py +++ b/tests/create_pc_android_idempotence_fixture.py @@ -21,10 +21,10 @@ LEG_PRESS_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222223" BODY_OBSERVATION_ID = "bo_33333333-3333-4333-8333-333333333333" -def run_export(tool, output, database): +def run_export(tool, output, database, *extra): result = subprocess.run( [sys.executable, str(ROOT / "tools" / tool), str(output), - "--database", str(database)], + "--database", str(database), *extra], text=True, capture_output=True, ) @@ -41,6 +41,7 @@ def main(): connection = sqlite3.connect(database) connection.executescript(SCHEMA) + connection.execute("PRAGMA user_version=11") connection.executemany( "INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode," "recording_mode,data_fields) VALUES(?,?,?,?,?,?);", @@ -119,7 +120,8 @@ def main(): ("export_pc_mobile.py", "trainlog-pc-mobile-export-v2.json"), ("export_equipment_associations.py", "trainlog-equipment-associations-v2.json"), ): - run_export(tool, output_directory / filename, database) + run_export(tool, output_directory / filename, database, + *(('--version', '2') if tool == 'export_pc_mobile.py' else ())) print("PASS pc_android_idempotence_fixture") diff --git a/tests/fixtures/session-generation-v1.json b/tests/fixtures/session-generation-v1.json new file mode 100644 index 0000000..1612ce5 --- /dev/null +++ b/tests/fixtures/session-generation-v1.json @@ -0,0 +1,1672 @@ +{ + "format": "trainlog-session-generation-fixtures-v1", + "version": 1, + "required_rule_coverage": [ + "requested_primary_zone", + "requested_secondary_zone_only", + "new_primary_zone", + "new_pattern", + "qualifying_working_load_history", + "preferred_exercise", + "recent_same_exercise", + "recent_same_pattern", + "recent_primary_threshold_on_candidate_primary", + "recent_secondary_threshold_on_candidate_primary", + "repeated_primary_threshold_on_candidate_primary", + "repeated_secondary_threshold_on_candidate_primary", + "any_exposure_flag_on_candidate_secondary_zones", + "full_body_region_coverage", + "upper_push_pull_coverage", + "lower_extension_flexion_coverage", + "exercise_id_tie_equipment_id", + "exercise_id_tie_newest_anchor" + ], + "expected_exercise_templates": { + "general_seated_row": { + "exercise_id": "ex_33f79331-871c-4eed-babe-346e53a99070", + "equipment_id": "seated_row", + "equipment_load_semantics": "external", + "primary_zone_id": "back", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "horizontal_pull" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "franke_2015", + "lehman_2004", + "openstax_upper" + ], + "load_source": null + }, + "general_shoulder_press": { + "exercise_id": "ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", + "equipment_id": "converging_shoulder_press", + "equipment_load_semantics": "external", + "primary_zone_id": "shoulders", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "vertical_push" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "nih_deltoid", + "openstax_upper" + ], + "load_source": null + }, + "general_leg_extension": { + "exercise_id": "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", + "equipment_id": "leg_extension", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [], + "pattern_ids": [ + "single_joint_knee_extension" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "high", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "openstax_lower" + ], + "load_source": null + }, + "general_leg_curl": { + "exercise_id": "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", + "equipment_id": "seated_leg_curl", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [], + "pattern_ids": [ + "single_joint_knee_flexion" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "high", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "maeo_2021", + "openstax_lower" + ], + "load_source": null + }, + "general_leg_press": { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ], + "load_source": null + }, + "general_leg_press_anchor_60": { + "exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","equipment_load_semantics":"external","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"pattern_ids":["knee_dominant"],"target_sets":2,"target_repetitions":10,"rest_seconds":90,"target_weight_kg":60.0,"planned_load_mode":"external","estimated_seconds":230,"confidence":"moderate","recency":{"recent_same_exercise":false,"recent_same_pattern":false},"exposure_warning_level":"none","rationale_codes":["observed_repeated_dose_anchor","requested_primary_zone","external_equipment_context","new_exact_pattern"],"source_ref_ids":["martin_fuentes_2020","openstax_lower","vigotsky_2018"],"load_source":{"session_id":"se_50000000-0000-4000-8000-000000000000","occurrence_id":"sxe_50000000-0000-4000-8000-000000000000","started_at":"2026-09-01T12:00:00.500Z"} + }, + "general_leg_press_recent_absent": { + "exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","equipment_load_semantics":"external","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"pattern_ids":["knee_dominant"],"target_sets":2,"target_repetitions":10,"rest_seconds":90,"target_weight_kg":null,"planned_load_mode":"none","estimated_seconds":230,"confidence":"moderate","recency":{"recent_same_exercise":true,"recent_same_pattern":true},"exposure_warning_level":"none","rationale_codes":["numeric_load_absent","requested_primary_zone","external_equipment_context","new_exact_pattern","recent_same_exercise_penalty","recent_same_pattern_penalty"],"source_ref_ids":["martin_fuentes_2020","openstax_lower","vigotsky_2018"],"load_source":null + }, + "general_leg_press_max": { + "exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","equipment_load_semantics":"external","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"pattern_ids":["knee_dominant"],"target_sets":2,"target_repetitions":10,"rest_seconds":90,"target_weight_kg":null,"planned_load_mode":"none","estimated_seconds":230,"confidence":"moderate","recency":{"recent_same_exercise":false,"recent_same_pattern":false},"exposure_warning_level":"none","rationale_codes":["explicit_max_present_no_numeric_prescription","requested_primary_zone","external_equipment_context","new_exact_pattern"],"source_ref_ids":["martin_fuentes_2020","openstax_lower","vigotsky_2018"],"load_source":null + }, + "general_hip_adduction": { + "exercise_id": "ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef", "equipment_id": "hip_adduction", "equipment_load_semantics": "external", "primary_zone_id": "thighs", + "secondary_zone_ids": [], "pattern_ids": ["single_joint_hip_adduction"], "target_sets": 2, "target_repetitions": 10, "rest_seconds": 90, "target_weight_kg": null, + "planned_load_mode": "none", "estimated_seconds": 230, "confidence": "high", "recency": {"recent_same_exercise": false, "recent_same_pattern": false}, "exposure_warning_level": "none", + "rationale_codes": ["numeric_load_absent", "requested_primary_zone", "external_equipment_context", "new_exact_pattern"], "source_ref_ids": ["openstax_lower"], "load_source": null + }, + "general_abdominal": { + "exercise_id": "ex_9adf7566-f10c-443c-b63b-681d37665693", "equipment_id": "abdominal", "equipment_load_semantics": "external", "primary_zone_id": "core", + "secondary_zone_ids": [], "pattern_ids": ["trunk_flexion"], "target_sets": 2, "target_repetitions": 10, "rest_seconds": 90, "target_weight_kg": null, + "planned_load_mode": "none", "estimated_seconds": 230, "confidence": "moderate", "recency": {"recent_same_exercise": false, "recent_same_pattern": false}, "exposure_warning_level": "none", + "rationale_codes": ["numeric_load_absent", "requested_primary_zone", "external_equipment_context", "new_exact_pattern"], "source_ref_ids": ["nih_abdominal_wall", "openstax_trunk"], "load_source": null + }, + "general_diverging_row_recent_pattern": { + "exercise_id": "ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", + "equipment_id": "diverging_seated_row", + "equipment_load_semantics": "external", + "primary_zone_id": "back", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "horizontal_pull" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": true + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern", + "recent_same_pattern_penalty" + ], + "source_ref_ids": [ + "franke_2015", + "lehman_2004", + "openstax_upper" + ], + "load_source": null + }, + "general_plate_leg_press": { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "plate_loaded_leg_press", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ], + "load_source": null + }, + "general_plate_leg_press_anchor": { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "plate_loaded_leg_press", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": 90.0, + "planned_load_mode": "external", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "observed_repeated_dose_anchor", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ], + "load_source": { + "session_id": "se_60000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_60000000-0000-4000-8000-000000000000", + "started_at": "2026-09-20T12:00Z" + } + }, + "notice_seated_row": { + "exercise_id": "ex_33f79331-871c-4eed-babe-346e53a99070", + "equipment_id": "seated_row", + "equipment_load_semantics": "external", + "primary_zone_id": "back", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "horizontal_pull" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "notice", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "franke_2015", + "lehman_2004", + "openstax_upper" + ], + "load_source": null + }, + "notice_rear_delt_recent": { + "exercise_id": "ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", + "equipment_id": "rear_delt_pec_fly", + "equipment_load_semantics": "external", + "primary_zone_id": "shoulders", + "secondary_zone_ids": [ + "back" + ], + "pattern_ids": [ + "single_joint_shoulder_horizontal_abduction" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": true, + "recent_same_pattern": true + }, + "exposure_warning_level": "notice", + "rationale_codes": [ + "numeric_load_absent", + "requested_secondary_zone", + "external_equipment_context", + "new_exact_pattern", + "recent_same_exercise_penalty", + "recent_same_pattern_penalty" + ], + "source_ref_ids": [ + "franke_2015", + "nih_deltoid", + "openstax_upper", + "vigotsky_2018" + ], + "load_source": null + } + }, + "cases": [ + { + "id": "anchor_full_output_and_fractional_boundary", + "covers": [ + "requested_primary_zone", + "new_primary_zone", + "new_pattern", + "qualifying_working_load_history", + "recent_same_exercise", + "recent_same_pattern", + "recent_primary_threshold_on_candidate_primary" + ], + "zone_id": "thighs", + "goal_id": "hypertrophy", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00:00.500Z", + "candidates": [ + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ] + } + ], + "history": [ + { + "session_id": "se_10000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_10000000-0000-4000-8000-000000000000", + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "started_at": "2026-09-08T12:00:00.501Z", + "sets": [ + { + "position": 0, + "repetitions": 10, + "weight_kg": 80.0 + }, + { + "position": 1, + "repetitions": 12, + "weight_kg": 85.0 + }, + { + "position": 2, + "repetitions": 10, + "weight_kg": 80.0 + } + ] + } + ], + "expected": { + "estimated_duration_seconds": 720, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 3, + "secondary_set_count": 0, + "session_count": 1, + "pattern_ids": [ + "knee_dominant" + ] + }, + "within_72h": { + "primary_set_count": 3, + "secondary_set_count": 0, + "session_count": 1, + "pattern_ids": [ + "knee_dominant" + ] + }, + "recent_exposure": true, + "repeated_exposure": false, + "warning_level": "warning", + "latest": { + "started_at": "2026-09-08T12:00:00.501Z", + "session_id": "se_10000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_10000000-0000-4000-8000-000000000000", + "pattern_ids": [ + "knee_dominant" + ] + }, + "unclassified_actual_set_count": 0 + }, + "exercises": [ + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "target_sets": 3, + "target_repetitions": 10, + "rest_seconds": 120, + "target_weight_kg": 80.0, + "planned_load_mode": "external", + "estimated_seconds": 420, + "confidence": "moderate", + "recency": { + "recent_same_exercise": true, + "recent_same_pattern": true + }, + "exposure_warning_level": "warning", + "rationale_codes": [ + "observed_repeated_dose_anchor", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern", + "recent_same_exercise_penalty", + "recent_same_pattern_penalty" + ], + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ], + "load_source": { + "session_id": "se_10000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_10000000-0000-4000-8000-000000000000", + "started_at": "2026-09-08T12:00:00.501Z" + } + } + ] + } + }, + { + "id": "exact_24h_exclusive_and_no_anchor", + "covers": [], + "zone_id": "thighs", + "goal_id": "hypertrophy", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00:00.500Z", + "candidates": [ + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ] + } + ], + "history": [ + { + "session_id": "se_20000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_20000000-0000-4000-8000-000000000000", + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "started_at": "2026-09-08T12:00:00.500+00:00", + "sets": [ + { + "position": 0, + "repetitions": 10, + "weight_kg": 80.0 + } + ] + } + ], + "expected": { + "estimated_duration_seconds": 720, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 1, + "secondary_set_count": 0, + "session_count": 1, + "pattern_ids": [ + "knee_dominant" + ] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": { + "started_at": "2026-09-08T12:00:00.500+00:00", + "session_id": "se_20000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_20000000-0000-4000-8000-000000000000", + "pattern_ids": [ + "knee_dominant" + ] + }, + "unclassified_actual_set_count": 0 + }, + "exercises": [ + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "target_sets": 3, + "target_repetitions": 10, + "rest_seconds": 120, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 420, + "confidence": "moderate", + "recency": { + "recent_same_exercise": true, + "recent_same_pattern": true + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern", + "recent_same_exercise_penalty", + "recent_same_pattern_penalty" + ], + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ], + "load_source": null + } + ] + } + }, + { + "id":"inclusive_28_day_anchor","covers":[],"zone_id":"thighs","goal_id":"general","duration_minutes":30,"reference_time":"2026-09-29T12:00:00.500Z", + "candidates":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"pattern_ids":["knee_dominant"],"confidence":"moderate","equipment_load_semantics":"external","source_ref_ids":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}], + "history":[{"session_id":"se_50000000-0000-4000-8000-000000000000","occurrence_id":"sxe_50000000-0000-4000-8000-000000000000","exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","started_at":"2026-09-01T12:00:00.500Z","sets":[{"position":0,"repetitions":10,"weight_kg":60.0},{"position":1,"repetitions":10,"weight_kg":65.0}]}], + "expected":{"estimated_duration_seconds":530,"insufficient_resolved_candidates":true,"shortage_codes":["insufficient_resolved_candidates"],"exposure":{"within_24h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"within_72h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"recent_exposure":false,"repeated_exposure":false,"warning_level":"none","latest":{"started_at":"2026-09-01T12:00:00.500Z","session_id":"se_50000000-0000-4000-8000-000000000000","occurrence_id":"sxe_50000000-0000-4000-8000-000000000000","pattern_ids":["knee_dominant"]},"unclassified_actual_set_count":0},"exercises":[{"template":"general_leg_press_anchor_60"}]} + }, + { + "id":"expired_28_day_anchor","covers":[],"zone_id":"thighs","goal_id":"general","duration_minutes":30,"reference_time":"2026-09-29T12:00:00.500Z", + "candidates":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"pattern_ids":["knee_dominant"],"confidence":"moderate","equipment_load_semantics":"external","source_ref_ids":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}], + "history":[{"session_id":"se_60000000-0000-4000-8000-000000000000","occurrence_id":"sxe_60000000-0000-4000-8000-000000000000","exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","started_at":"2026-09-01T12:00:00.499Z","sets":[{"position":0,"repetitions":10,"weight_kg":60.0},{"position":1,"repetitions":10,"weight_kg":60.0}]}], + "expected":{"estimated_duration_seconds":530,"insufficient_resolved_candidates":true,"shortage_codes":["insufficient_resolved_candidates"],"exposure":{"within_24h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"within_72h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"recent_exposure":false,"repeated_exposure":false,"warning_level":"none","latest":{"started_at":"2026-09-01T12:00:00.499Z","session_id":"se_60000000-0000-4000-8000-000000000000","occurrence_id":"sxe_60000000-0000-4000-8000-000000000000","pattern_ids":["knee_dominant"]},"unclassified_actual_set_count":0},"exercises":[{"template":"general_leg_press"}]} + }, + { + "id":"assistance_history_cannot_anchor_external","covers":[],"zone_id":"thighs","goal_id":"general","duration_minutes":30,"reference_time":"2026-09-09T12:00Z", + "candidates":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"pattern_ids":["knee_dominant"],"confidence":"moderate","equipment_load_semantics":"external","source_ref_ids":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}], + "history":[{"session_id":"se_70000000-0000-4000-8000-000000000000","occurrence_id":"sxe_70000000-0000-4000-8000-000000000000","exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","started_at":"2026-09-08T12:00Z","load_mode":"assistance","sets":[{"position":0,"repetitions":10,"weight_kg":60.0},{"position":1,"repetitions":10,"weight_kg":60.0}]}], + "expected":{"estimated_duration_seconds":530,"insufficient_resolved_candidates":true,"shortage_codes":["insufficient_resolved_candidates"],"exposure":{"within_24h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"within_72h":{"primary_set_count":2,"secondary_set_count":0,"session_count":1,"pattern_ids":["knee_dominant"]},"recent_exposure":false,"repeated_exposure":false,"warning_level":"none","latest":{"started_at":"2026-09-08T12:00Z","session_id":"se_70000000-0000-4000-8000-000000000000","occurrence_id":"sxe_70000000-0000-4000-8000-000000000000","pattern_ids":["knee_dominant"]},"unclassified_actual_set_count":0},"exercises":[{"template":"general_leg_press_recent_absent"}]} + }, + { + "id":"explicit_max_is_context_only","covers":[],"zone_id":"thighs","goal_id":"general","duration_minutes":30,"reference_time":"2026-09-09T12:00Z", + "candidates":[{"exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","primary_zone_id":"thighs","secondary_zone_ids":["glutes"],"pattern_ids":["knee_dominant"],"confidence":"moderate","equipment_load_semantics":"external","source_ref_ids":["martin_fuentes_2020","openstax_lower","vigotsky_2018"]}], + "history":[{"session_id":"se_80000000-0000-4000-8000-000000000000","occurrence_id":"sxe_80000000-0000-4000-8000-000000000000","exercise_id":"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde","equipment_id":"leg_press","started_at":"2026-09-08T12:00Z","explicit_max":true}], + "expected":{"estimated_duration_seconds":530,"insufficient_resolved_candidates":true,"shortage_codes":["insufficient_resolved_candidates"],"exposure":{"within_24h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"within_72h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"recent_exposure":false,"repeated_exposure":false,"warning_level":"none","latest":null,"unclassified_actual_set_count":0},"exercises":[{"template":"general_leg_press_max"}]} + }, + { + "id": "sparse_chest", + "covers": [], + "zone_id": "chest", + "goal_id": "strength", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00Z", + "candidates": [], + "history": [], + "expected": { + "estimated_duration_seconds": 300, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": null, + "unclassified_actual_set_count": 0 + }, + "exercises": [] + } + }, + { + "id": "full_body_regions", + "covers": [ + "full_body_region_coverage" + ], + "zone_id": "full_body", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00Z", + "candidates": [ + { + "exercise_id": "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", + "equipment_id": "leg_extension", + "primary_zone_id": "thighs", + "secondary_zone_ids": [], + "pattern_ids": [ + "single_joint_knee_extension" + ], + "confidence": "high", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "openstax_lower" + ] + }, + { + "exercise_id": "ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", + "equipment_id": "converging_shoulder_press", + "primary_zone_id": "shoulders", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "vertical_push" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "nih_deltoid", + "openstax_upper" + ] + }, + { + "exercise_id": "ex_9adf7566-f10c-443c-b63b-681d37665693", + "equipment_id": "abdominal", + "primary_zone_id": "core", + "secondary_zone_ids": [], + "pattern_ids": [ + "trunk_flexion" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "nih_abdominal_wall", + "openstax_trunk" + ] + } + ], + "history": [], + "expected": { + "estimated_duration_seconds": 990, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": null, + "unclassified_actual_set_count": 0 + }, + "exercises": [ + { + "exercise_id": "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", + "equipment_id": "leg_extension", + "equipment_load_semantics": "external", + "primary_zone_id": "thighs", + "secondary_zone_ids": [], + "pattern_ids": [ + "single_joint_knee_extension" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "high", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "openstax_lower" + ], + "load_source": null + }, + { + "exercise_id": "ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", + "equipment_id": "converging_shoulder_press", + "equipment_load_semantics": "external", + "primary_zone_id": "shoulders", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "vertical_push" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "nih_deltoid", + "openstax_upper" + ], + "load_source": null + }, + { + "exercise_id": "ex_9adf7566-f10c-443c-b63b-681d37665693", + "equipment_id": "abdominal", + "equipment_load_semantics": "external", + "primary_zone_id": "core", + "secondary_zone_ids": [], + "pattern_ids": [ + "trunk_flexion" + ], + "target_sets": 2, + "target_repetitions": 10, + "rest_seconds": 90, + "target_weight_kg": null, + "planned_load_mode": "none", + "estimated_seconds": 230, + "confidence": "moderate", + "recency": { + "recent_same_exercise": false, + "recent_same_pattern": false + }, + "exposure_warning_level": "none", + "rationale_codes": [ + "numeric_load_absent", + "requested_primary_zone", + "external_equipment_context", + "new_exact_pattern" + ], + "source_ref_ids": [ + "nih_abdominal_wall", + "openstax_trunk" + ], + "load_source": null + } + ] + } + }, + { + "id": "upper_push_pull", + "covers": [ + "upper_push_pull_coverage" + ], + "zone_id": "upper_body", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00Z", + "candidates": [ + { + "exercise_id": "ex_33f79331-871c-4eed-babe-346e53a99070", + "equipment_id": "seated_row", + "primary_zone_id": "back", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "horizontal_pull" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "franke_2015", + "lehman_2004", + "openstax_upper" + ] + }, + { + "exercise_id": "ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b", + "equipment_id": "converging_shoulder_press", + "primary_zone_id": "shoulders", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "vertical_push" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "nih_deltoid", + "openstax_upper" + ] + } + ], + "history": [], + "expected": { + "estimated_duration_seconds": 760, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": null, + "unclassified_actual_set_count": 0 + }, + "exercises": [ + {"template": "general_seated_row"}, + {"template": "general_shoulder_press"} + ] + } + }, + { + "id": "lower_extension_flexion", + "covers": [ + "lower_extension_flexion_coverage" + ], + "zone_id": "lower_body", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00Z", + "candidates": [ + { + "exercise_id": "ex_1872246a-39ae-44dc-b58d-f87e90ca49ab", + "equipment_id": "leg_extension", + "primary_zone_id": "thighs", + "secondary_zone_ids": [], + "pattern_ids": [ + "single_joint_knee_extension" + ], + "confidence": "high", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "openstax_lower" + ] + }, + { + "exercise_id": "ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163", + "equipment_id": "seated_leg_curl", + "primary_zone_id": "thighs", + "secondary_zone_ids": [], + "pattern_ids": [ + "single_joint_knee_flexion" + ], + "confidence": "high", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "maeo_2021", + "openstax_lower" + ] + }, + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ] + } + ], + "history": [], + "expected": { + "estimated_duration_seconds": 990, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": null, + "unclassified_actual_set_count": 0 + }, + "exercises": [ + {"template": "general_leg_extension"}, + {"template": "general_leg_curl"}, + {"template": "general_leg_press"} + ] + } + }, + { + "id": "preferred_recent_candidate_loses", + "covers": [ + "preferred_exercise", + "recent_same_exercise", + "recent_same_pattern" + ], + "zone_id": "back", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00Z", + "preferred_ids": [ + "ex_33f79331-871c-4eed-babe-346e53a99070" + ], + "candidates": [ + { + "exercise_id": "ex_1b0c6b8b-b05e-4e6f-8809-5f7d85d668de", + "equipment_id": "diverging_seated_row", + "primary_zone_id": "back", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "horizontal_pull" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "franke_2015", + "lehman_2004", + "openstax_upper" + ] + }, + { + "exercise_id": "ex_33f79331-871c-4eed-babe-346e53a99070", + "equipment_id": "seated_row", + "primary_zone_id": "back", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "horizontal_pull" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "franke_2015", + "lehman_2004", + "openstax_upper" + ] + } + ], + "history": [ + { + "session_id": "se_40000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_40000000-0000-4000-8000-000000000000", + "exercise_id": "ex_33f79331-871c-4eed-babe-346e53a99070", + "equipment_id": "seated_row", + "started_at": "2026-09-08T12:00Z", + "sets": [ + { + "position": 0, + "repetitions": 10 + } + ] + } + ], + "expected": { + "estimated_duration_seconds": 530, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 1, + "secondary_set_count": 0, + "session_count": 1, + "pattern_ids": [ + "horizontal_pull" + ] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": { + "started_at": "2026-09-08T12:00Z", + "session_id": "se_40000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_40000000-0000-4000-8000-000000000000", + "pattern_ids": [ + "horizontal_pull" + ] + }, + "unclassified_actual_set_count": 0 + }, + "exercises": [{"template": "general_diverging_row_recent_pattern"}] + } + }, + { + "id": "equipment_id_tie", + "covers": [ + "exercise_id_tie_equipment_id" + ], + "zone_id": "thighs", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-30T12:00Z", + "candidates": [ + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "plate_loaded_leg_press", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ] + }, + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ] + } + ], + "history": [], + "expected": { + "estimated_duration_seconds": 530, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": null, + "unclassified_actual_set_count": 0 + }, + "exercises": [{"template": "general_leg_press"}] + } + }, + { + "id": "equipment_newest_anchor_wins", + "covers": [ + "exercise_id_tie_newest_anchor", + "qualifying_working_load_history" + ], + "zone_id": "thighs", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-30T12:00Z", + "candidates": [ + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ] + }, + { + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "plate_loaded_leg_press", + "primary_zone_id": "thighs", + "secondary_zone_ids": [ + "glutes" + ], + "pattern_ids": [ + "knee_dominant" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "martin_fuentes_2020", + "openstax_lower", + "vigotsky_2018" + ] + } + ], + "history": [ + { + "session_id": "se_50000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_50000000-0000-4000-8000-000000000000", + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "leg_press", + "started_at": "2026-09-15T12:00Z", + "sets": [ + { + "position": 0, + "repetitions": 10, + "weight_kg": 70.0 + }, + { + "position": 1, + "repetitions": 10, + "weight_kg": 70.0 + } + ] + }, + { + "session_id": "se_60000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_60000000-0000-4000-8000-000000000000", + "exercise_id": "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + "equipment_id": "plate_loaded_leg_press", + "started_at": "2026-09-20T12:00Z", + "sets": [ + { + "position": 0, + "repetitions": 10, + "weight_kg": 90.0 + }, + { + "position": 1, + "repetitions": 10, + "weight_kg": 95.0 + } + ] + } + ], + "expected": { + "estimated_duration_seconds": 530, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "within_72h": { + "primary_set_count": 0, + "secondary_set_count": 0, + "session_count": 0, + "pattern_ids": [] + }, + "recent_exposure": false, + "repeated_exposure": false, + "warning_level": "none", + "latest": { + "started_at": "2026-09-20T12:00Z", + "session_id": "se_60000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_60000000-0000-4000-8000-000000000000", + "pattern_ids": [ + "knee_dominant" + ] + }, + "unclassified_actual_set_count": 0 + }, + "exercises": [{"template": "general_plate_leg_press_anchor"}] + } + }, + { + "id": "complete_secondary_exposure_and_unclassified", + "covers": [], + "zone_id": "arms", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00Z", + "candidates": [], + "history": [ + { + "session_id": "se_70000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_70000000-0000-4000-8000-000000000000", + "exercise_id": "ex_33f79331-871c-4eed-babe-346e53a99070", + "equipment_id": "seated_row", + "started_at": "2026-09-09T11:00Z", + "sets": [ + { + "position": 0, + "repetitions": 10 + }, + { + "position": 1, + "repetitions": 10 + }, + { + "position": 2, + "repetitions": 10 + } + ] + }, + { + "session_id": "se_80000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_80000000-0000-4000-8000-000000000000", + "exercise_id": "ex_ec619fc2-4685-4044-873c-86764bd4a0fe", + "equipment_id": "arm_curl", + "started_at": "2026-09-07T12:00Z", + "sets": [ + { + "position": 0, + "repetitions": 10 + } + ] + }, + { + "session_id": "se_90000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_90000000-0000-4000-8000-000000000000", + "exercise_id": "ex_ffffffff-ffff-4fff-8fff-ffffffffffff", + "equipment_id": null, + "started_at": "2026-09-09T10:00Z", + "sets": [ + { + "position": 0, + "repetitions": 8 + }, + { + "position": 1, + "repetitions": 8 + } + ] + } + ], + "expected": { + "estimated_duration_seconds": 300, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 3, + "session_count": 1, + "pattern_ids": [ + "horizontal_pull" + ] + }, + "within_72h": { + "primary_set_count": 1, + "secondary_set_count": 3, + "session_count": 2, + "pattern_ids": [ + "horizontal_pull", + "single_joint_elbow_flexion" + ] + }, + "recent_exposure": true, + "repeated_exposure": false, + "warning_level": "notice", + "latest": { + "started_at": "2026-09-09T11:00Z", + "session_id": "se_70000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_70000000-0000-4000-8000-000000000000", + "pattern_ids": [ + "horizontal_pull" + ] + }, + "unclassified_actual_set_count": 2 + }, + "exercises": [] + } + }, + { + "id": "six_distinct_patterns_fill_cap", "covers": [], "zone_id": "full_body", "goal_id": "general", "duration_minutes": 30, "reference_time": "2026-09-09T12:00Z", + "candidates": [ + {"exercise_id":"ex_01ff06dd-ad00-46ee-9b46-ed32cabedbef","equipment_id":"hip_adduction","primary_zone_id":"thighs","secondary_zone_ids":[],"pattern_ids":["single_joint_hip_adduction"],"confidence":"high","equipment_load_semantics":"external","source_ref_ids":["openstax_lower"]}, + {"exercise_id":"ex_1872246a-39ae-44dc-b58d-f87e90ca49ab","equipment_id":"leg_extension","primary_zone_id":"thighs","secondary_zone_ids":[],"pattern_ids":["single_joint_knee_extension"],"confidence":"high","equipment_load_semantics":"external","source_ref_ids":["openstax_lower"]}, + {"exercise_id":"ex_33f79331-871c-4eed-babe-346e53a99070","equipment_id":"seated_row","primary_zone_id":"back","secondary_zone_ids":["arms"],"pattern_ids":["horizontal_pull"],"confidence":"moderate","equipment_load_semantics":"external","source_ref_ids":["franke_2015","lehman_2004","openstax_upper"]}, + {"exercise_id":"ex_6dfc7ffd-8891-464e-a995-808baf1b0d7b","equipment_id":"converging_shoulder_press","primary_zone_id":"shoulders","secondary_zone_ids":["arms"],"pattern_ids":["vertical_push"],"confidence":"moderate","equipment_load_semantics":"external","source_ref_ids":["nih_deltoid","openstax_upper"]}, + {"exercise_id":"ex_9adf7566-f10c-443c-b63b-681d37665693","equipment_id":"abdominal","primary_zone_id":"core","secondary_zone_ids":[],"pattern_ids":["trunk_flexion"],"confidence":"moderate","equipment_load_semantics":"external","source_ref_ids":["nih_abdominal_wall","openstax_trunk"]}, + {"exercise_id":"ex_a1ef5047-b44b-4c64-a6ed-c7a3bc13b163","equipment_id":"seated_leg_curl","primary_zone_id":"thighs","secondary_zone_ids":[],"pattern_ids":["single_joint_knee_flexion"],"confidence":"high","equipment_load_semantics":"external","source_ref_ids":["maeo_2021","openstax_lower"]} + ], + "history": [], + "expected": {"estimated_duration_seconds":1680,"insufficient_resolved_candidates":false,"shortage_codes":[],"exposure":{"within_24h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"within_72h":{"primary_set_count":0,"secondary_set_count":0,"session_count":0,"pattern_ids":[]},"recent_exposure":false,"repeated_exposure":false,"warning_level":"none","latest":null,"unclassified_actual_set_count":0},"exercises":[{"template":"general_hip_adduction"},{"template":"general_seated_row"},{"template":"general_abdominal"},{"template":"general_shoulder_press"},{"template":"general_leg_extension"},{"template":"general_leg_curl"}]} + }, + { + "id": "all_remaining_score_inputs", + "covers": [ + "requested_secondary_zone_only", + "recent_primary_threshold_on_candidate_primary", + "recent_secondary_threshold_on_candidate_primary", + "repeated_primary_threshold_on_candidate_primary", + "repeated_secondary_threshold_on_candidate_primary", + "any_exposure_flag_on_candidate_secondary_zones" + ], + "zone_id": "back", + "goal_id": "general", + "duration_minutes": 30, + "reference_time": "2026-09-09T12:00Z", + "candidates": [ + { + "exercise_id": "ex_33f79331-871c-4eed-babe-346e53a99070", + "equipment_id": "seated_row", + "primary_zone_id": "back", + "secondary_zone_ids": [ + "arms" + ], + "pattern_ids": [ + "horizontal_pull" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "franke_2015", + "lehman_2004", + "openstax_upper" + ] + }, + { + "exercise_id": "ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", + "equipment_id": "rear_delt_pec_fly", + "primary_zone_id": "shoulders", + "secondary_zone_ids": [ + "back" + ], + "pattern_ids": [ + "single_joint_shoulder_horizontal_abduction" + ], + "confidence": "moderate", + "equipment_load_semantics": "external", + "source_ref_ids": [ + "franke_2015", + "nih_deltoid", + "openstax_upper", + "vigotsky_2018" + ] + } + ], + "history": [ + { + "session_id": "se_a0000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_a0000000-0000-4000-8000-000000000000", + "exercise_id": "ex_4cd2433e-80b1-478a-b8df-73fc6ef80962", + "equipment_id": "rear_delt_pec_fly", + "started_at": "2026-09-09T11:00Z", + "sets": [ + { + "position": 0, + "repetitions": 10 + }, + { + "position": 1, + "repetitions": 10 + }, + { + "position": 2, + "repetitions": 10 + }, + { + "position": 3, + "repetitions": 10 + }, + { + "position": 4, + "repetitions": 10 + }, + { + "position": 5, + "repetitions": 10 + }, + { + "position": 6, + "repetitions": 10 + }, + { + "position": 7, + "repetitions": 10 + }, + { + "position": 8, + "repetitions": 10 + }, + { + "position": 9, + "repetitions": 10 + }, + { + "position": 10, + "repetitions": 10 + }, + { + "position": 11, + "repetitions": 10 + } + ] + }, + { + "session_id": "se_b0000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_b0000000-0000-4000-8000-000000000000", + "exercise_id": "ex_ec619fc2-4685-4044-873c-86764bd4a0fe", + "equipment_id": "arm_curl", + "started_at": "2026-09-09T10:00Z", + "sets": [ + { + "position": 0, + "repetitions": 10 + } + ] + } + ], + "expected": { + "estimated_duration_seconds": 760, + "insufficient_resolved_candidates": true, + "shortage_codes": [ + "insufficient_resolved_candidates" + ], + "exposure": { + "within_24h": { + "primary_set_count": 0, + "secondary_set_count": 12, + "session_count": 1, + "pattern_ids": [ + "single_joint_shoulder_horizontal_abduction" + ] + }, + "within_72h": { + "primary_set_count": 0, + "secondary_set_count": 12, + "session_count": 1, + "pattern_ids": [ + "single_joint_shoulder_horizontal_abduction" + ] + }, + "recent_exposure": true, + "repeated_exposure": true, + "warning_level": "notice", + "latest": { + "started_at": "2026-09-09T11:00Z", + "session_id": "se_a0000000-0000-4000-8000-000000000000", + "occurrence_id": "sxe_a0000000-0000-4000-8000-000000000000", + "pattern_ids": [ + "single_joint_shoulder_horizontal_abduction" + ] + }, + "unclassified_actual_set_count": 0 + }, + "exercises": [ + {"template": "notice_seated_row"}, + {"template": "notice_rear_delt_recent"} + ] + } + } + ] +} diff --git a/tests/fixtures/session-mobile-export-v3.json b/tests/fixtures/session-mobile-export-v3.json new file mode 100644 index 0000000..12969e3 --- /dev/null +++ b/tests/fixtures/session-mobile-export-v3.json @@ -0,0 +1,42 @@ +{ + "format": "trainlog-mobile-export", + "version": 3, + "generated_at": "2026-09-09T14:00:00+02:00", + "exercises": [ + { + "exercise_id": "ex_33333333-3333-4333-8333-333333333333", + "name": "Fixture V3 partagée", + "recording_mode": "sets", + "tracking_mode": "reps", + "data_fields": 0 + } + ], + "sessions": [ + { + "session_id": "se_33333333-3333-4333-8333-333333333333", + "started_at": "2026-09-09T14:01:00+02:00", + "session_type": "training", + "exercises": [ + { + "entry_id": "sxe_33333333-3333-4333-8333-333333333333", + "position": 0, + "exercise_id": "ex_33333333-3333-4333-8333-333333333333", + "name": "Fixture V3 partagée", + "recording_mode": "sets", + "tracking_mode": "reps", + "data_fields": 0, + "load_mode": "external", + "rest_seconds": 135, + "equipment_id": "leg_press", + "target": {"sets": 3, "reps": 9, "weight_kg": 55.5}, + "sets": [ + {"reps": 9, "weight_kg": 52.5}, + {"reps": 8}, + {"reps": 7, "weight_kg": 0.0} + ] + } + ] + } + ], + "body_observations": [] +} diff --git a/tests/test_exercise_reconciliation.py b/tests/test_exercise_reconciliation.py index a2bf6ba..eb4f060 100644 --- a/tests/test_exercise_reconciliation.py +++ b/tests/test_exercise_reconciliation.py @@ -452,7 +452,8 @@ def main(): (EXPORT_MOBILE, pc_mobile), (EXPORT_ASSOCIATIONS, pc_associations), ): - run(tool, output, "--database", complete_db) + run(tool, output, "--database", complete_db, + *(["--version", "2"] if tool == EXPORT_MOBILE else [])) exported = json.loads(pc_mobile.read_text(encoding="utf-8")) walk = next( diff --git a/tests/test_max_sync.py b/tests/test_max_sync.py index ecb62c7..9629134 100644 --- a/tests/test_max_sync.py +++ b/tests/test_max_sync.py @@ -122,7 +122,7 @@ def main(): "ON se.id=mr.session_exercise_row_id WHERE se.entry_id='sxe_pec'" ).fetchone() == (101.5,) - result = run(EXPORTER, exported, "--database", database) + result = run(EXPORTER, exported, "--database", database, "--version", "2") assert result.returncode == 0, result.stdout + result.stderr round_trip = json.loads(exported.read_text(encoding="utf-8")) values = round_trip["sessions"][0]["exercises"] diff --git a/tests/test_mobile_import_multi_occurrence.py b/tests/test_mobile_import_multi_occurrence.py index 171150e..97118a9 100644 --- a/tests/test_mobile_import_multi_occurrence.py +++ b/tests/test_mobile_import_multi_occurrence.py @@ -201,7 +201,8 @@ def main(): (EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"), ): result = subprocess.run( - [sys.executable, str(tool), str(output), "--database", str(db)], + [sys.executable, str(tool), str(output), "--database", str(db)] + + (["--version", "2"] if tool == MOBILE_EXPORTER else []), text=True, capture_output=True, ) @@ -221,7 +222,7 @@ def main(): con.close() custom_output = root / "custom-mobile-v2.json" exported = subprocess.run( - [sys.executable, str(MOBILE_EXPORTER), str(custom_output), "--database", str(db)], + [sys.executable, str(MOBILE_EXPORTER), str(custom_output), "--database", str(db), "--version", "2"], text=True, capture_output=True, ) diff --git a/tests/test_mobile_import_variable_sets.py b/tests/test_mobile_import_variable_sets.py index 8e8125e..c4754ab 100755 --- a/tests/test_mobile_import_variable_sets.py +++ b/tests/test_mobile_import_variable_sets.py @@ -238,7 +238,7 @@ def run_import( def run_export(output_path: Path, database_path: Path) -> None: result = subprocess.run( [sys.executable, str(EXPORTER), str(output_path), - "--database", str(database_path)], + "--database", str(database_path), "--version", "2"], check=False, capture_output=True, text=True, diff --git a/tests/test_session_exchange_v3.py b/tests/test_session_exchange_v3.py new file mode 100644 index 0000000..b26918c --- /dev/null +++ b/tests/test_session_exchange_v3.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Production V3 session exchange regression coverage.""" +import copy +import json +import sqlite3 +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +IMPORTER = ROOT / "tools/import_mobile_export.py" +EXPORTER = ROOT / "tools/export_pc_mobile.py" +from test_mobile_import_variable_sets import SCHEMA + +V11_SCHEMA = SCHEMA.replace("PRAGMA user_version=10;", "") + """ +CREATE TABLE exercise_body_zones( + exercise_row_id INTEGER NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + zone_id TEXT NOT NULL, role TEXT NOT NULL, + PRIMARY KEY(exercise_row_id,zone_id)); +CREATE TABLE exercise_body_zone_sync( + exercise_row_id INTEGER PRIMARY KEY REFERENCES exercises(id) ON DELETE CASCADE, + synced_primary_zone_id TEXT, synced_secondary_zone_ids TEXT NOT NULL); +PRAGMA user_version=11; +""" + + +def run(command, ok=True): + result = subprocess.run(command, text=True, capture_output=True) + if (result.returncode == 0) != ok: + raise AssertionError(result.stdout + result.stderr) + return result + + +def payload(): + exercises = [ + {"exercise_id": "ex_reps", "name": "Reps", "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0}, + {"exercise_id": "ex_duration", "name": "Duration", "recording_mode": "sets", "tracking_mode": "duration", "data_fields": 0}, + {"exercise_id": "ex_walk", "name": "Walk", "recording_mode": "continuous", "tracking_mode": "duration", "data_fields": 0}, + ] + base = {"format": "trainlog-mobile-export", "version": 3, + "generated_at": "2026-09-09T12:00:00+02:00", "exercises": exercises, + "sessions": [], "body_observations": []} + entries = [ + {"entry_id": "sxe_reps", "position": 0, "exercise_id": "ex_reps", "name": "Reps", + "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, + "load_mode": "external", "rest_seconds": 120, "equipment_id": "leg_press", + "target": {"sets": 3, "reps": 8, "weight_kg": 42.5}, + "sets": [{"reps": 8, "weight_kg": 40.0}, {"reps": 7, "weight_kg": 0.0}]}, + {"entry_id": "sxe_duration", "position": 1, "exercise_id": "ex_duration", "name": "Duration", + "recording_mode": "sets", "tracking_mode": "duration", "data_fields": 0, + "load_mode": "assistance", "rest_seconds": 60, "equipment_id": "leg_press", + "target": {"sets": 2, "duration_seconds": 45, "weight_kg": 15.0}, + "sets": [{"duration_seconds": 40}]}, + {"entry_id": "sxe_unweighted", "position": 2, "exercise_id": "ex_reps", "name": "Reps", + "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, + "load_mode": "none", "rest_seconds": 30, "equipment_id": None, + "target": {"sets": 1, "reps": 12}, "sets": [{"reps": 11}]}, + {"entry_id": "sxe_walk", "position": 3, "exercise_id": "ex_walk", "name": "Walk", + "recording_mode": "continuous", "tracking_mode": "duration", "data_fields": 0, + "load_mode": "none", "rest_seconds": 0, "equipment_id": None, + "target": None, "continuous": {"duration_seconds": 600}}, + ] + base["sessions"] = [{"session_id": "se_v3", "started_at": "2026-09-09T12:01:00+02:00", + "session_type": "training", "exercises": entries}] + base["sessions"].append({"session_id": "se_v3_max", "started_at": "2026-09-09T13:00:00+02:00", + "session_type": "max_test", "exercises": [{ + "entry_id": "sxe_max", "position": 0, "exercise_id": "ex_reps", "name": "Reps", + "recording_mode": "sets", "tracking_mode": "reps", "data_fields": 0, + "load_mode": "none", "rest_seconds": 0, "equipment_id": "leg_press", + "target": None, "max_weight_kg": 100.0}]}) + base["body_observations"] = [{"observation_id": "bo_v3", "observed_at": "2026-09-09T14:00:00+02:00", + "body_weight_kg": 72.5}] + return base + + +def snapshot(db): + with sqlite3.connect(db) as con: + return tuple(con.execute(f"SELECT * FROM {table} ORDER BY rowid").fetchall() + for table in ("exercises", "sessions", "session_exercises", "performed_sets", + "continuous_activity", "body_observations")) + + +def main(): + with tempfile.TemporaryDirectory(prefix="trainlog-v3-") as directory: + root = Path(directory); db = root / "db.sqlite"; artifact = root / "mobile-v3.json" + with sqlite3.connect(db) as con: + con.executescript(V11_SCHEMA) + artifact.write_text(json.dumps(payload()), encoding="utf-8") + first = run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)]) + assert "sessions_imported=2" in first.stdout + replay = run([sys.executable, str(IMPORTER), str(artifact), "--database", str(db)]) + assert "sessions_skipped=2" in replay.stdout + with sqlite3.connect(db) as con: + plans = con.execute("SELECT entry_id,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,target_weight_kg,equipment_id FROM session_exercises ORDER BY id").fetchall() + assert plans == [ + ("sxe_reps", "external", 120, 3, 8, None, 42.5, "leg_press"), + ("sxe_duration", "assistance", 60, 2, None, 45, 15.0, "leg_press"), + ("sxe_unweighted", "none", 30, 1, 12, None, None, None), + ("sxe_walk", "none", 0, None, None, None, None, None), + ("sxe_max", "none", 0, None, None, None, None, "leg_press"), + ] + exported = root / "pc-v3.json" + run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)]) + roundtrip = json.loads(exported.read_text()) + assert roundtrip["version"] == 3 + assert roundtrip["sessions"][0]["exercises"][0]["target"] == {"sets": 3, "reps": 8, "weight_kg": 42.5} + assert roundtrip["sessions"][0]["exercises"][2]["target"] == {"sets": 1, "reps": 12} + assert roundtrip["sessions"][0]["exercises"][3]["target"] is None + assert roundtrip["sessions"][1]["exercises"][0]["max_weight_kg"] == 100.0 + legacy = copy.deepcopy(payload()); legacy["version"] = 2 + for session in legacy["sessions"]: + for entry in session["exercises"]: + entry.pop("target"); entry["load_mode"] = "none"; entry["rest_seconds"] = 0 + legacy_path = root / "legacy-v2.json"; legacy_path.write_text(json.dumps(legacy), encoding="utf-8") + before = snapshot(db) + legacy_replay = run([sys.executable, str(IMPORTER), str(legacy_path), "--database", str(db)], ok=False) + assert "conflit" in (legacy_replay.stdout + legacy_replay.stderr) and snapshot(db) == before, \ + legacy_replay.stdout + legacy_replay.stderr + downgrade = run([sys.executable, str(EXPORTER), str(root / "v2.json"), "--database", str(db), "--version", "2"], ok=False) + assert "export V2 avec plan interdit" in downgrade.stdout + + divergent = copy.deepcopy(payload()); divergent["sessions"][0]["exercises"][0]["target"]["reps"] = 9 + divergent_path = root / "divergent.json"; divergent_path.write_text(json.dumps(divergent), encoding="utf-8") + run([sys.executable, str(IMPORTER), str(divergent_path), "--database", str(db)], ok=False) + assert snapshot(db) == before + + mutations = [ + lambda e: e["target"].update(sets=0), + lambda e: e["target"].update(reps=10001), + lambda e: e.update(rest_seconds=86401), + lambda e: e.update(load_mode="none"), + lambda e: e.update(target=None), + lambda e: e.update(position=100001), + lambda e: e["target"].update(weight_kg=float("nan")), + ] + for index, mutate in enumerate(mutations): + invalid = copy.deepcopy(payload()); mutate(invalid["sessions"][0]["exercises"][0]) + path = root / f"invalid-{index}.json"; path.write_text(json.dumps(invalid), encoding="utf-8") + run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], ok=False) + assert snapshot(db) == before + + # WHY: V3 validation is a pre-transaction boundary. A malformed instant + # must not leave even definitions or body rows behind. + for index, mutate in enumerate(( + lambda document: document["sessions"][0].update(started_at="not-a-time"), + lambda document: document["body_observations"][0].update(observed_at="2026-02-30T12:00Z"), + lambda document: document.update(generated_at="2026-09-09 12:00:00Z"), + )): + invalid_time = copy.deepcopy(payload()); mutate(invalid_time) + path = root / f"invalid-time-{index}.json"; path.write_text(json.dumps(invalid_time), encoding="utf-8") + result = run([sys.executable, str(IMPORTER), str(path), "--database", str(db)], ok=False) + assert "date-heure Trainlog invalide" in result.stderr and snapshot(db) == before + + # The exact shared language includes omitted seconds, lowercase t/z, + # ±23:59 offsets and arbitrary fraction precision. + exact = copy.deepcopy(payload()) + exact["generated_at"] = "2026-09-09t12:00z" + exact["sessions"][0]["started_at"] = "2026-09-09T12:01+23:59" + exact["sessions"][1]["started_at"] = "2026-09-09T13:00:00.123456789012345678900-23:59" + exact["body_observations"][0]["observed_at"] = "2026-09-09t14:00:00.1000z" + exact_db = root / "exact.sqlite" + with sqlite3.connect(exact_db) as con: con.executescript(V11_SCHEMA) + exact_path = root / "exact.json"; exact_path.write_text(json.dumps(exact), encoding="utf-8") + run([sys.executable, str(IMPORTER), str(exact_path), "--database", str(exact_db)]) + + # Published V2 admission remains unchanged, including its historical + # nonempty timestamp rule. + legacy_time = copy.deepcopy(legacy) + legacy_time["sessions"][0]["session_id"] = "se_legacy_bad_time" + legacy_time["sessions"][0]["started_at"] = "not-a-time" + legacy_time["sessions"] = legacy_time["sessions"][:1] + legacy_time["body_observations"] = [] + legacy_db = root / "legacy-time.sqlite" + with sqlite3.connect(legacy_db) as con: con.executescript(V11_SCHEMA) + legacy_time_path = root / "legacy-time.json"; legacy_time_path.write_text(json.dumps(legacy_time), encoding="utf-8") + run([sys.executable, str(IMPORTER), str(legacy_time_path), "--database", str(legacy_db)]) + + # INVARIANT: corrupt stored V3 instants fail before destination write. + sentinel = root / "sentinel.json"; sentinel.write_text("prior-valid-artifact", encoding="utf-8") + with sqlite3.connect(db) as con: + con.execute("UPDATE sessions SET started_at='bad-stored-time' WHERE session_id='se_v3'") + failed_export = run([sys.executable, str(EXPORTER), str(sentinel), "--database", str(db)], ok=False) + assert "started_at" in failed_export.stdout and sentinel.read_text() == "prior-valid-artifact" + with sqlite3.connect(db) as con: + con.execute("UPDATE sessions SET started_at='2026-09-09T12:01:00+02:00' WHERE session_id='se_v3'") + con.execute("UPDATE body_observations SET observed_at='bad-stored-time' WHERE observation_id='bo_v3'") + failed_export = run([sys.executable, str(EXPORTER), str(sentinel), "--database", str(db)], ok=False) + assert "observed_at" in failed_export.stdout and sentinel.read_text() == "prior-valid-artifact" + with sqlite3.connect(db) as con: + con.execute("UPDATE body_observations SET observed_at='2026-09-09T14:00:00+02:00' WHERE observation_id='bo_v3'") + duplicate = artifact.read_text().replace('"sets": 3', '"sets": 3, "sets": 4', 1) + duplicate_path = root / "duplicate.json"; duplicate_path.write_text(duplicate) + rejected = run([sys.executable, str(IMPORTER), str(duplicate_path), "--database", str(db)], ok=False) + assert "dupliqué" in rejected.stderr and snapshot(db) == before + + # The same checked-in artifact is consumed by Robolectric, proving the + # wire shape rather than two independently authored platform fixtures. + shared_db = root / "shared.sqlite" + with sqlite3.connect(shared_db) as con: con.executescript(V11_SCHEMA) + shared = ROOT / "tests/fixtures/session-mobile-export-v3.json" + run([sys.executable, str(IMPORTER), str(shared), "--database", str(shared_db)]) + shared_out = root / "shared-out.json" + run([sys.executable, str(EXPORTER), str(shared_out), "--database", str(shared_db)]) + shared_entry = json.loads(shared_out.read_text())["sessions"][0]["exercises"][0] + assert shared_entry["target"] == {"sets": 3, "reps": 9, "weight_kg": 55.5} + assert shared_entry["sets"] == [{"reps": 9, "weight_kg": 52.5}, {"reps": 8}, {"reps": 7, "weight_kg": 0.0}] + print("PASS session_exchange_v3") + + +if __name__ == "__main__": main() diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index bd89a9d..5437f71 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -1,12 +1,15 @@ #!/usr/bin/env python3 -"""Publish desktop sessions through the occurrence-aware mobile export V2.""" +"""Publish desktop sessions through current planning-aware mobile export V3.""" import argparse import json import os import sqlite3 +import math from datetime import datetime from pathlib import Path +from validate_json import TrainlogSemanticError, parse_timestamp + CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json" @@ -22,34 +25,74 @@ def supplied_equipment_ids(): return {item["id"] for item in catalog["equipment"]} +def validate_plan(entry): + target_sets = entry["target_sets"] + if target_sets is None: + if entry["load_mode"] != "none" or entry["rest_seconds"] != 0 or any( + entry[name] is not None for name in ( + "target_reps", "target_duration_seconds", "target_weight_kg")): + raise ValueError("plan cible SQLite incohérent") + return + if not 1 <= target_sets <= 64 or not 0 <= entry["rest_seconds"] <= 86400: + raise ValueError("plan cible SQLite hors bornes") + reps = entry["target_reps"] + duration = entry["target_duration_seconds"] + if entry["tracking_mode"] == "reps": + if reps is None or not 1 <= reps <= 10000 or duration is not None: + raise ValueError("cible répétitions SQLite incohérente") + elif duration is None or not 1 <= duration <= 86400 or reps is not None: + raise ValueError("cible durée SQLite incohérente") + weight = entry["target_weight_kg"] + if weight is None: + if entry["load_mode"] != "none": + raise ValueError("cible sans poids exige load_mode=none") + elif not math.isfinite(weight) or weight <= 0 or entry["load_mode"] not in ("external", "assistance"): + raise ValueError("cible pondérée SQLite incohérente") + + +def validate_v3_timestamp(value, label): + try: + parse_timestamp(value, label) + except TrainlogSemanticError as error: + raise ValueError(f"{label}: date-heure Trainlog persistée invalide") from error + + def main(): parser = argparse.ArgumentParser() parser.add_argument("output", type=Path) parser.add_argument("--database", type=Path, default=default_database()) + parser.add_argument("--version", type=int, choices=(2, 3), default=3) args = parser.parse_args() con = sqlite3.connect(args.database) con.row_factory = sqlite3.Row try: - # The V2 shape itself does not read body-zone tables. Accept the true - # immediately-previous v10 fixture while production v11 publishes the - # separate body-zone companion. - if con.execute("PRAGMA user_version").fetchone()[0] not in (10, 11): - raise ValueError("schema desktop v10 ou v11 requis") + schema_version = con.execute("PRAGMA user_version").fetchone()[0] + if schema_version != 11 and not (args.version == 2 and schema_version == 10): + raise ValueError("schema desktop v11 requis (v10 accepté pour export V2 explicite)") known_equipment = supplied_equipment_ids() known_equipment.update(row[0] for row in con.execute( "SELECT equipment_id FROM custom_equipment")) - root = {"format": "trainlog-mobile-export", "version": 2, + root = {"format": "trainlog-mobile-export", "version": args.version, "generated_at": datetime.now().astimezone().isoformat(), "exercises": [], "sessions": [], "body_observations": []} for row in con.execute("SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises ORDER BY exercise_id"): root["exercises"].append(dict(row)) for session in con.execute("SELECT id,session_id,started_at,session_type FROM sessions ORDER BY started_at,id"): + # CONTRACT: a V3 producer must not publish history which the exact + # temporal readers reject. V2 export retains its published behavior. + if args.version == 3: + validate_v3_timestamp(session["started_at"], + f"session_id={session['session_id']} started_at") payload = {key: session[key] for key in ("session_id", "started_at", "session_type")} payload["exercises"] = [] # INVARIANT: tracking mode is catalogue metadata. v7 occurrences # retain their stable entry_id but do not duplicate that field. - sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position" + sql = "SELECT se.id,se.entry_id,se.position,se.recording_mode,e.tracking_mode,se.data_fields,se.equipment_id,e.exercise_id,e.name,mr.max_weight_kg,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e ON e.id=se.exercise_row_id LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id WHERE se.session_row_id=? ORDER BY se.position" for entry in con.execute(sql, (session["id"],)): + validate_plan(entry) + if entry["recording_mode"] == "continuous" or entry["max_weight_kg"] is not None: + if entry["target_sets"] is not None: + raise ValueError("continuous/MAX ne peut pas porter de cible") # CONTRACT: references remain in mobile-export v2 unchanged; # definitions-v1 travels first and makes custom IDs resolvable. if entry["equipment_id"] is not None and entry["equipment_id"] not in known_equipment: @@ -58,11 +101,32 @@ def main(): f"session_id={session['session_id']} " f"entry_id={entry['entry_id']}: {entry['equipment_id']}" ) + has_target = entry["target_sets"] is not None + if args.version == 2 and has_target: + raise ValueError( + "export V2 avec plan interdit " + f"session_id={session['session_id']} entry_id={entry['entry_id']}" + ) item = {"entry_id": entry["entry_id"], "position": entry["position"], "exercise_id": entry["exercise_id"], "name": entry["name"], "recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"], - "data_fields": entry["data_fields"], "load_mode": "none", "rest_seconds": 0, + "data_fields": entry["data_fields"], + "load_mode": entry["load_mode"] if args.version == 3 else "none", + "rest_seconds": entry["rest_seconds"] if args.version == 3 else 0, "equipment_id": entry["equipment_id"]} + if args.version == 3: + if not has_target: + if entry["load_mode"] != "none" or entry["rest_seconds"] != 0 or any( + entry[name] is not None for name in ( + "target_reps", "target_duration_seconds", "target_weight_kg")): + raise ValueError("plan cible SQLite incohérent") + item["target"] = None + else: + target = {"sets": entry["target_sets"]} + if entry["target_reps"] is not None: target["reps"] = entry["target_reps"] + if entry["target_duration_seconds"] is not None: target["duration_seconds"] = entry["target_duration_seconds"] + if entry["target_weight_kg"] is not None: target["weight_kg"] = entry["target_weight_kg"] + item["target"] = target if entry["max_weight_kg"] is not None: # CONTRACT: explicit max is an occurrence result, never a # synthetic one-repetition performed set. @@ -86,6 +150,11 @@ def main(): "right_thigh_cm", "left_calf_cm", "right_calf_cm") columns = ",".join(("observation_id", "observed_at") + metric_names) for row in con.execute(f"SELECT {columns} FROM body_observations ORDER BY observed_at,id"): + if args.version == 3: + # INVARIANT: validate every stored instant before touching the + # destination artifact, so corruption cannot clobber a prior file. + validate_v3_timestamp(row["observed_at"], + f"observation_id={row['observation_id']} observed_at") item = {"observation_id": row["observation_id"], "observed_at": row["observed_at"]} item.update({name: row[name] for name in metric_names if row[name] is not None}) root["body_observations"].append(item) diff --git a/tools/generate_session_generation_fixtures.py b/tools/generate_session_generation_fixtures.py new file mode 100644 index 0000000..7eddd97 --- /dev/null +++ b/tools/generate_session_generation_fixtures.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Generate exhaustive C assertions from the shared C/Kotlin golden corpus.""" +import json +import sys +from pathlib import Path + + +def q(value): + return json.dumps(value) + + +def strings(name, values, count_name=None): + count = count_name if count_name is not None else f'{name}_count' + return [f' assert({count}=={len(values)}U);'] + [ + f' assert(strcmp({name}[{index}],{q(value)})==0);' + for index, value in enumerate(values) + ] + + +root = json.loads(Path(sys.argv[1]).read_text()) +assert set(root) == {"format", "version", "required_rule_coverage", "expected_exercise_templates", "cases"} +assert root["format"] == "trainlog-session-generation-fixtures-v1" and root["version"] == 1 +covered = {rule for case in root["cases"] for rule in case["covers"]} +assert covered == set(root["required_rule_coverage"]), (set(root["required_rule_coverage"]) - covered, covered - set(root["required_rule_coverage"])) + +lines = [ + '#include "trainlog/session_generation.h"', '#include ', '#include ', + 'void trainlog_run_generated_session_generation_fixtures(void) {' +] +for case_index, case in enumerate(root["cases"]): + assert set(case) == {"id", "covers", "zone_id", "goal_id", "duration_minutes", "reference_time", "candidates", "history", "expected"} or set(case) == {"id", "covers", "zone_id", "goal_id", "duration_minutes", "reference_time", "candidates", "history", "expected", "preferred_ids"} + lines.append(' { TrainlogSessionGenerationAnalyzer *a=NULL; TrainlogGeneratedSession o;') + for index, candidate in enumerate(case["candidates"]): + for field in ("secondary_zone_ids", "pattern_ids", "source_ref_ids"): + values = candidate[field] + if values: + lines.append(f' static const char *const c{case_index}_{index}_{field}[] = {{{",".join(map(q, values))}}};') + if case["candidates"]: + lines.append(f' TrainlogGenerationCandidate candidates[{len(case["candidates"])}] = {{') + for index, candidate in enumerate(case["candidates"]): + parts = [ + f'.exercise_id={q(candidate["exercise_id"])}', f'.equipment_id={q(candidate["equipment_id"])}', + f'.primary_zone_id={q(candidate["primary_zone_id"])}', f'.confidence={q(candidate["confidence"])}', + f'.equipment_load_semantics={q(candidate["equipment_load_semantics"])}' + ] + for field, pointer, count in (("secondary_zone_ids", "secondary_zone_ids", "secondary_zone_count"), ("pattern_ids", "pattern_ids", "pattern_count"), ("source_ref_ids", "source_ref_ids", "source_ref_count")): + if candidate[field]: + parts += [f'.{pointer}=c{case_index}_{index}_{field}', f'.{count}={len(candidate[field])}U'] + lines.append(' {' + ','.join(parts) + '},') + lines.append(' };') + preferred = case.get("preferred_ids", []) + if preferred: + lines.append(' static const char *const preferred[] = {' + ','.join(map(q, preferred)) + '};') + request = [ + f'.zone_id={q(case["zone_id"])}', f'.goal_id={q(case["goal_id"])}', + f'.duration_minutes={case["duration_minutes"]}', f'.reference_time={q(case["reference_time"])}', + f'.candidates={"candidates" if case["candidates"] else "NULL"}', f'.candidate_count={len(case["candidates"])}U' + ] + if preferred: + request += ['.preferred_exercise_ids=preferred', f'.preferred_count={len(preferred)}U'] + lines += [' TrainlogGenerationRequest request = {' + ','.join(request) + '};', + ' assert(trainlog_session_generation_analyzer_create(&request,&a)==TRAINLOG_STATUS_OK);'] + for occurrence in case["history"]: + sets = occurrence.get("sets", [None]) + for performed in sets: + load_mode = {"none": "TRAINLOG_LOAD_NONE", "external": "TRAINLOG_LOAD_EXTERNAL", "assistance": "TRAINLOG_LOAD_ASSISTANCE"}[occurrence.get("load_mode", "none")] + parts = [ + f'.session_id={q(occurrence["session_id"])}', f'.occurrence_id={q(occurrence["occurrence_id"])}', + f'.exercise_id={q(occurrence["exercise_id"])}', f'.started_at={q(occurrence["started_at"])}', + f'.equipment_id={"NULL" if occurrence["equipment_id"] is None else q(occurrence["equipment_id"])}', + '.recording_mode=TRAINLOG_RECORDING_SETS', '.tracking_mode=TRAINLOG_TRACKING_REPS', + f'.load_mode={load_mode}', f'.rest_seconds={occurrence.get("rest_seconds", 0)}', + f'.has_explicit_max={str(occurrence.get("explicit_max", False)).lower()}' + ] + if performed is not None: + parts += ['.has_actual_set=true', f'.set_position={performed["position"]}U', f'.repetitions={performed["repetitions"]}'] + if "weight_kg" in performed: + parts += ['.has_weight=true', f'.weight_kg={performed["weight_kg"]}'] + lines += [' { TrainlogGenerationHistoryRow h = {' + ','.join(parts) + '};', + ' assert(trainlog_session_generation_analyzer_accept(a,&h)==TRAINLOG_STATUS_OK); }'] + expected = case["expected"] + assert set(expected) == {"estimated_duration_seconds", "insufficient_resolved_candidates", "shortage_codes", "exposure", "exercises"} + lines += [' assert(trainlog_session_generation_analyzer_finish(a,&o)==TRAINLOG_STATUS_OK);', + f' assert(o.estimated_duration_seconds=={expected["estimated_duration_seconds"]});', + f' assert(o.insufficient_resolved_candidates=={str(expected["insufficient_resolved_candidates"]).lower()});'] + lines += strings('o.shortage_codes', expected["shortage_codes"], 'o.shortage_count') + exposure = expected["exposure"] + for json_name, c_name in (("within_24h", "within_24h"), ("within_72h", "within_72h")): + window = exposure[json_name] + lines += [f' assert(o.exposure.{c_name}.primary_set_count=={window["primary_set_count"]}U);', + f' assert(o.exposure.{c_name}.secondary_set_count=={window["secondary_set_count"]}U);', + f' assert(o.exposure.{c_name}.session_count=={window["session_count"]}U);'] + lines += strings(f'o.exposure.{c_name}.pattern_ids', window["pattern_ids"], f'o.exposure.{c_name}.pattern_count') + warning = exposure["warning_level"].upper() + lines += [f' assert(o.exposure.recent_exposure=={str(exposure["recent_exposure"]).lower()});', + f' assert(o.exposure.repeated_exposure=={str(exposure["repeated_exposure"]).lower()});', + f' assert(o.exposure.warning_level==TRAINLOG_GENERATION_WARNING_{warning});', + f' assert(o.exposure.unclassified_actual_set_count=={exposure["unclassified_actual_set_count"]}U);'] + latest = exposure["latest"] + lines.append(f' assert(o.exposure.has_latest=={str(latest is not None).lower()});') + if latest is not None: + lines += [f' assert(strcmp(o.exposure.latest_started_at,{q(latest["started_at"])})==0);', + f' assert(strcmp(o.exposure.latest_session_id,{q(latest["session_id"])})==0);', + f' assert(strcmp(o.exposure.latest_occurrence_id,{q(latest["occurrence_id"])})==0);'] + lines += strings('o.exposure.latest_pattern_ids', latest["pattern_ids"], 'o.exposure.latest_pattern_count') + lines.append(f' assert(o.exercise_count=={len(expected["exercises"])}U);') + for index, expected_exercise in enumerate(expected["exercises"]): + exercise = root["expected_exercise_templates"][expected_exercise["template"]] if set(expected_exercise) == {"template"} else expected_exercise + prefix = f'o.exercises[{index}]' + mode = exercise["planned_load_mode"].upper() + ew = exercise["exposure_warning_level"].upper() + for field in ("exercise_id", "equipment_id", "equipment_load_semantics", "primary_zone_id", "confidence"): + lines.append(f' assert(strcmp({prefix}.{field},{q(exercise[field])})==0);') + lines += strings(f'{prefix}.secondary_zone_ids', exercise["secondary_zone_ids"], f'{prefix}.secondary_zone_count') + lines += strings(f'{prefix}.pattern_ids', exercise["pattern_ids"], f'{prefix}.pattern_count') + lines += [f' assert({prefix}.target_sets=={exercise["target_sets"]});', + f' assert({prefix}.target_repetitions=={exercise["target_repetitions"]});', + f' assert({prefix}.rest_seconds=={exercise["rest_seconds"]});', + f' assert({prefix}.estimated_seconds=={exercise["estimated_seconds"]});', + f' assert({prefix}.planned_load_mode==TRAINLOG_LOAD_{mode});', + f' assert({prefix}.exposure_warning_level==TRAINLOG_GENERATION_WARNING_{ew});', + f' assert({prefix}.recency.recent_same_exercise=={str(exercise["recency"]["recent_same_exercise"]).lower()});', + f' assert({prefix}.recency.recent_same_pattern=={str(exercise["recency"]["recent_same_pattern"]).lower()});'] + weight = exercise["target_weight_kg"] + lines.append(f' assert({prefix}.has_target_weight=={str(weight is not None).lower()});') + if weight is not None: + lines.append(f' assert({prefix}.target_weight_kg=={weight});') + lines += strings(f'{prefix}.rationale_codes', exercise["rationale_codes"], f'{prefix}.rationale_count') + lines += strings(f'{prefix}.source_ref_ids', exercise["source_ref_ids"], f'{prefix}.source_ref_count') + source = exercise["load_source"] + for field, c_field in (("session_id", "load_source_session_id"), ("occurrence_id", "load_source_occurrence_id"), ("started_at", "load_source_started_at")): + lines.append(f' assert(strcmp({prefix}.{c_field},{q(source[field] if source else "")})==0);') + lines += [' trainlog_session_generation_analyzer_destroy(a);', ' }'] +lines.append('}') +Path(sys.argv[2]).write_text('\n'.join(lines) + '\n') diff --git a/tools/generate_session_generation_policy.py b/tools/generate_session_generation_policy.py new file mode 100644 index 0000000..23c6e18 --- /dev/null +++ b/tools/generate_session_generation_policy.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Generate immutable C data from the canonical session-generation policy.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from validate_session_generation_policy import validate + + +def c_string(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{escaped}"' + + +def main() -> None: + policy_path, catalog_dir, output_path = map(Path, sys.argv[1:4]) + root = validate(policy_path, catalog_dir) + lines = [ + "/* Generated from session-generation-policy-v1.json; do not edit. */", + '#include "trainlog/session_generation_policy_internal.h"', + "static const TrainlogSessionGenerationGoalPolicy goal_policies[] = {", + ] + for goal in ("general", "strength", "hypertrophy", "endurance"): + row = root["goals"][goal] + lines.append(" {%s,%d,%d,%d,%d,%d,%d,%d,%d,%d}," % ( + c_string(goal), row["sets"], row["repetitions"], row["rest_seconds"], + *row["sets_range"], *row["repetitions_range"], *row["rest_seconds_range"])) + lines += ["};", "static const int duration_presets_minutes[] = {" + + ",".join(str(value) for value in root["duration"]["presets_minutes"]) + "};", + "static const TrainlogSessionGenerationZoneExpansion zone_expansions[] = {"] + for zone_id, expanded in root["zone_expansion"].items(): + lines.append(" {%s,%s}," % (c_string(zone_id), c_string("\n".join(expanded)))) + lines += ["};", "const TrainlogSessionGenerationPolicy trainlog_session_generation_policy_v1 = {", + f" {root['load']['lookback_seconds']},", + f" {root['exposure']['short_window_seconds']}, {root['exposure']['long_window_seconds']},", + f" {root['selection']['recency']['same_exercise_window_seconds']}, {root['selection']['recency']['same_pattern_window_seconds']},", + f" {root['selection']['max_exercises']}, {root['duration']['custom_min_minutes']}, {root['duration']['custom_max_minutes']},", + " duration_presets_minutes, sizeof(duration_presets_minutes)/sizeof(duration_presets_minutes[0]),", + f" {root['duration']['preparation_seconds']}, {root['duration']['setup_and_transition_seconds_per_exercise']}, {root['duration']['estimated_seconds_per_repetition']},", + f" {root['exposure']['recent_primary_sets_24h_threshold']}, {root['exposure']['recent_secondary_sets_24h_threshold']},", + f" {root['exposure']['repeated_primary_sets_72h_threshold']}, {root['exposure']['repeated_secondary_sets_72h_threshold']},", + " {" + ",".join(str(root["selection"]["score"][key]) for key in ( + "requested_primary_zone", "requested_secondary_zone_only", "new_primary_zone", "new_pattern", + "qualifying_working_load_history", "preferred_exercise", "recent_same_exercise", "recent_same_pattern", + "recent_primary_threshold_on_candidate_primary", "recent_secondary_threshold_on_candidate_primary", + "repeated_primary_threshold_on_candidate_primary", "repeated_secondary_threshold_on_candidate_primary", + "any_exposure_flag_on_candidate_secondary_zones")) + "},", + " " + ",".join(c_string("\n".join(root["selection"][key])) for key in ( + "upper_push_patterns", "upper_pull_patterns", "lower_extension_patterns", "lower_flexion_patterns")) + ",", + " zone_expansions, sizeof(zone_expansions)/sizeof(zone_expansions[0]),", + " goal_policies, sizeof(goal_policies)/sizeof(goal_policies[0])", "};"] + output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index 23eaa69..b23628d 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -8,6 +8,8 @@ import sys import unicodedata from pathlib import Path +from validate_json import TrainlogSemanticError, parse_timestamp + FORMAT = "trainlog-mobile-export" VERSION = 1 @@ -57,6 +59,7 @@ SESSION_EXERCISE_KEYS = { V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | { "entry_id", "position", "equipment_id", "max_weight_kg" } +V3_SESSION_EXERCISE_KEYS = V2_SESSION_EXERCISE_KEYS | {"target"} BODY_BASE_KEYS = { "observation_id", @@ -129,6 +132,16 @@ def require_nonempty_string(value, label): return value +def require_v3_timestamp(value, label): + """Require the exact shared Trainlog timestamp language for V3 only.""" + require_nonempty_string(value, label) + try: + parse_timestamp(value, label) + except TrainlogSemanticError as error: + raise ImportFailure(f"{label}: date-heure Trainlog invalide") from error + return value + + def require_int(value, minimum, maximum, label): if isinstance(value, bool) or not isinstance(value, int): raise ImportFailure( @@ -207,12 +220,19 @@ def normalize_name(value): def load_payload(path): + def reject_duplicate_keys(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ImportFailure(f"champ JSON dupliqué: {key}") + result[key] = value + return result try: with path.open( "r", encoding="utf-8", ) as handle: - payload = json.load(handle) + payload = json.load(handle, object_pairs_hook=reject_duplicate_keys) except (OSError, json.JSONDecodeError) as error: raise ImportFailure( f"lecture JSON impossible: {error}" @@ -230,15 +250,17 @@ def load_payload(path): "format mobile export invalide" ) - if payload["version"] not in (1, 2): + if payload["version"] not in (1, 2, 3): raise ImportFailure( "version mobile export non supportée" ) - require_nonempty_string( - payload["generated_at"], - "generated_at", - ) + # CONTRACT: V1/V2 retain their published nonempty-string admission. V3 is + # the current analysis-bearing artifact and uses the exact Trainlog parser. + if payload["version"] == 3: + require_v3_timestamp(payload["generated_at"], "generated_at") + else: + require_nonempty_string(payload["generated_at"], "generated_at") for key in ( "exercises", @@ -425,12 +447,15 @@ def validate_session_exercise( label, known_exercise_ids, session_type, + version, ): - is_v2 = "entry_id" in item or "position" in item or "equipment_id" in item + is_v2 = version >= 2 + keys = V3_SESSION_EXERCISE_KEYS if version == 3 else ( + V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS) require_exact_keys( item, - V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS, - (V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS) + keys, + keys - {"sets", "continuous", "max_weight_kg"}, label, ) @@ -479,15 +504,30 @@ def validate_session_exercise( f"{label}: snapshot incompatible avec le profil catalogue" ) - if item["load_mode"] != "none": - raise ImportFailure( - f"{label}: mobile export v1 exige load_mode=none" - ) - - if item["rest_seconds"] != 0: - raise ImportFailure( - f"{label}: mobile export v1 exige rest_seconds=0" - ) + if version < 3: + if item["load_mode"] != "none" or item["rest_seconds"] != 0: + raise ImportFailure(f"{label}: mobile export V1/V2 exige none/0") + else: + if item["load_mode"] not in ("none", "external", "assistance"): + raise ImportFailure(f"{label}.load_mode invalide") + require_int(item["rest_seconds"], 0, 86400, f"{label}.rest_seconds") + target = item["target"] + if target is None: + if item["load_mode"] != "none" or item["rest_seconds"] != 0: + raise ImportFailure(f"{label}: target null exige none/0") + else: + if item["recording_mode"] != "sets" or "max_weight_kg" in item: + raise ImportFailure(f"{label}: cible interdite pour continuous/MAX") + metric = "reps" if item["tracking_mode"] == "reps" else "duration_seconds" + require_exact_keys(target, {"sets", metric, "weight_kg"}, {"sets", metric}, f"{label}.target") + require_int(target["sets"], 1, 64, f"{label}.target.sets") + require_int(target[metric], 1, 10000 if metric == "reps" else 86400, f"{label}.target.{metric}") + if "weight_kg" in target: + require_positive_number(target["weight_kg"], f"{label}.target.weight_kg") + if item["load_mode"] not in ("external", "assistance"): + raise ImportFailure(f"{label}: cible pondérée exige external/assistance") + elif item["load_mode"] != "none": + raise ImportFailure(f"{label}: cible sans poids exige none") if "max_weight_kg" in item: if not is_v2 or session_type != "max_test": @@ -632,10 +672,12 @@ def validate_sessions( seen_ids.add(session_id) - require_nonempty_string( - session["started_at"], - f"{label}.started_at", - ) + if payload["version"] == 3: + # INVARIANT: accepted V3 history must remain readable by temporal + # analysis; validation completes before run_import mutates SQLite. + require_v3_timestamp(session["started_at"], f"{label}.started_at") + else: + require_nonempty_string(session["started_at"], f"{label}.started_at") if session["session_type"] not in ( "training", @@ -670,6 +712,7 @@ def validate_sessions( exercise_label, known_exercise_ids, session["session_type"], + payload["version"], ) equipment_id = exercise.get("equipment_id") @@ -677,7 +720,7 @@ def validate_sessions( # or custom definition is already known. Validate before run_import # opens a transaction so an unknown ID cannot create partial history. if ( - payload["version"] == 2 + payload["version"] >= 2 and equipment_id is not None and equipment_id not in known_equipment_ids ): @@ -733,10 +776,10 @@ def validate_body(payload): seen_ids.add(observation_id) - require_nonempty_string( - observation["observed_at"], - f"{label}.observed_at", - ) + if payload["version"] == 3: + require_v3_timestamp(observation["observed_at"], f"{label}.observed_at") + else: + require_nonempty_string(observation["observed_at"], f"{label}.observed_at") present_metrics = ( set(observation.keys()) @@ -1185,6 +1228,18 @@ def session_exists( ) +def occurrence_plan_values(item): + """Map validated V3 target metadata without deriving it from actual rows.""" + target = item.get("target") + if target is None: + return ("none", 0, None, None, None, None) + return ( + item["load_mode"], item["rest_seconds"], target["sets"], + target.get("reps"), target.get("duration_seconds"), + target.get("weight_kg"), + ) + + def import_set_session_exercise( connection, session_row_id, @@ -1204,6 +1259,7 @@ def import_set_session_exercise( equipment_columns = ", equipment_id" if schema_version >= 6 else "" equipment_values = ", ?" if schema_version >= 6 else "" arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position] + arguments.extend(occurrence_plan_values(item)) if schema_version >= 6: arguments.append(item.get("equipment_id")) cursor = connection.execute( @@ -1221,8 +1277,8 @@ def import_set_session_exercise( target_duration_seconds, target_weight_kg, notes""" + equipment_columns + """ ) VALUES( - """ + values + """?, ?, 'sets', ?, ?, 'none', 0, - NULL, NULL, NULL, NULL, NULL""" + equipment_values + """ + """ + values + """?, ?, 'sets', ?, ?, ?, ?, + ?, ?, ?, ?, NULL""" + equipment_values + """ ); """, arguments, @@ -1276,6 +1332,7 @@ def import_continuous_session_exercise( equipment_columns = ", equipment_id" if schema_version >= 6 else "" equipment_values = ", ?" if schema_version >= 6 else "" arguments = ([entry_id] if entry_id is not None else []) + [session_row_id, exercise_row, item["data_fields"], position] + arguments.extend(occurrence_plan_values(item)) if schema_version >= 6: arguments.append(item.get("equipment_id")) cursor = connection.execute( @@ -1293,8 +1350,8 @@ def import_continuous_session_exercise( target_duration_seconds, target_weight_kg, notes""" + equipment_columns + """ ) VALUES( - """ + values + """?, ?, 'continuous', ?, ?, 'none', 0, - NULL, NULL, NULL, NULL, NULL""" + equipment_values + """ + """ + values + """?, ?, 'continuous', ?, ?, ?, ?, + ?, ?, ?, ?, NULL""" + equipment_values + """ ); """, arguments, @@ -1340,8 +1397,7 @@ def import_max_session_exercise( data_fields, position, load_mode, rest_seconds, target_sets, target_reps, target_duration_seconds, target_weight_kg, notes, equipment_id - ) VALUES(?, ?, ?, ?, ?, ?, 'none', 0, - NULL, NULL, NULL, NULL, NULL, ?); + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?); """, ( item["entry_id"], @@ -1350,6 +1406,7 @@ def import_max_session_exercise( item["recording_mode"], item["data_fields"], position, + *occurrence_plan_values(item), item.get("equipment_id"), ), ) @@ -1536,17 +1593,29 @@ def session_semantically_matches( return False rows = connection.execute( "SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode," - "se.data_fields,se.equipment_id FROM session_exercises se JOIN exercises e " + "se.data_fields,se.equipment_id,se.load_mode,se.rest_seconds,se.target_sets,se.target_reps," + "se.target_duration_seconds,se.target_weight_kg FROM session_exercises se JOIN exercises e " "ON e.id=se.exercise_row_id WHERE se.session_row_id=? ORDER BY se.position", (session_row_id,)).fetchall() items = sorted(incoming["exercises"], key=lambda item: item["position"]) if len(rows) != len(items): return False for row, item in zip(rows, items): + if "target" not in item and tuple(row[8:14]) != ("none", 0, None, None, None, None): + # CONTRACT: legacy actual-only exchange has no authority to erase + # or silently ignore locally persisted planning metadata. + return False canonical_exercise_id = exercise_mapping.get(item["exercise_id"]) if tuple(row[1:8]) != (item["entry_id"], item["position"], canonical_exercise_id, item["recording_mode"], item["tracking_mode"], item["data_fields"], item.get("equipment_id")): return False + # V3 equality includes the complete plan beside stable identity and + # actual rows; any target delta is an explicit content conflict. + if "target" in item: + target = item["target"] + expected_plan = occurrence_plan_values(item) + if tuple(row[8:14]) != expected_plan: + return False if "max_weight_kg" in item: current = connection.execute( "SELECT max_weight_kg FROM max_results " diff --git a/tools/validate_session_generation_policy.py b/tools/validate_session_generation_policy.py new file mode 100644 index 0000000..fd57cfd --- /dev/null +++ b/tools/validate_session_generation_policy.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Strict validator for the authored SESSION_GENERATOR_V1 policy.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +class PolicyError(ValueError): + pass + + +def _unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise PolicyError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load(path: Path): + try: + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=_unique) + except (OSError, json.JSONDecodeError) as error: + raise PolicyError(f"{path}: {error}") from error + + +def require(value, message): + if not value: + raise PolicyError(message) + + +def exact(value, keys, where): + require(isinstance(value, dict) and set(value) == set(keys), f"{where}: invalid keys") + + +def integer(value, low, high, where): + require(isinstance(value, int) and not isinstance(value, bool) and low <= value <= high, + f"{where}: integer outside {low}..{high}") + + +def strings(value, known, where): + require(isinstance(value, list) and value and all(isinstance(item, str) and item for item in value), + f"{where}: non-empty string array required") + require(len(value) == len(set(value)), f"{where}: duplicate value") + if known is not None: + require(set(value) <= known, f"{where}: unknown reference") + + +def validate(policy_path: Path, catalog_dir: Path) -> dict: + root = load(policy_path) + top = {"format", "version", "policy_id", "status", "scientific_review_date", "confidence", + "scope", "numeric_rule_status", "source_refs", "additional_references", "goals", + "goal_range_interpretation", "zone_expansion", "eligibility", "load", "exposure", + "selection", "duration", "guidance"} + exact(root, top, "policy") + require(root["format"] == "trainlog-session-generation-policy-v1" and root["version"] == 1 and + root["policy_id"] == "session_generator_v1", "unsupported policy envelope") + + zones_doc = load(catalog_dir / "body-zones-v1.json") + patterns_doc = load(catalog_dir / "movement-patterns-v1.json") + exercises_doc = load(catalog_dir / "exercise-knowledge-v1.json") + equipment_doc = load(catalog_dir / "equipment-knowledge-v1.json") + references_doc = load(catalog_dir / "science-references-v1.json") + zones = {row["zone_id"] for row in zones_doc["zones"]} + patterns = {row["pattern_id"] for row in patterns_doc["movement_patterns"]} + exercises = {row["exercise_id"] for row in exercises_doc["exercises"]} + equipment = {row["equipment_id"] for row in equipment_doc["equipment"]} + references = {row["ref_id"] for row in references_doc["references"]} + + additional = root["additional_references"] + require(isinstance(additional, list), "additional_references: array required") + additional_ids = [] + ref_keys = {"ref_id", "title", "authors_or_organization", "year", "pmid", "doi", "url", + "type", "notes", "limitations", "accessed_on"} + for index, row in enumerate(additional): + exact(row, ref_keys, f"additional_references[{index}]") + additional_ids.append(row["ref_id"]) + integer(row["year"], 1, 9999, f"additional_references[{index}].year") + require(all(isinstance(row[key], str) and row[key] for key in ref_keys - {"year"}), + f"additional_references[{index}]: text required") + require(len(additional_ids) == len(set(additional_ids)) and not (set(additional_ids) & references), + "additional_references: duplicate ID") + references |= set(additional_ids) + strings(root["source_refs"], references, "source_refs") + + goal_keys = {"sets", "repetitions", "rest_seconds", "sets_range", "repetitions_range", + "rest_seconds_range"} + exact(root["goals"], {"general", "strength", "hypertrophy", "endurance"}, "goals") + for goal, row in root["goals"].items(): + exact(row, goal_keys, f"goals.{goal}") + for key, high in (("sets", 64), ("repetitions", 10000), ("rest_seconds", 86400)): + integer(row[key], 1 if key != "rest_seconds" else 0, high, f"goals.{goal}.{key}") + bounds = row[f"{key}_range"] + require(isinstance(bounds, list) and len(bounds) == 2, f"goals.{goal}.{key}_range") + integer(bounds[0], 1 if key != "rest_seconds" else 0, high, f"goals.{goal}.{key}_range") + integer(bounds[1], bounds[0], high, f"goals.{goal}.{key}_range") + require(bounds[0] <= row[key] <= bounds[1], f"goals.{goal}.{key}: default outside range") + + expected_expansions = {"full_body", "upper_body", "chest", "back", "shoulders", "arms", + "core", "lower_body", "glutes", "thighs", "calves"} + exact(root["zone_expansion"], expected_expansions, "zone_expansion") + for key, value in root["zone_expansion"].items(): + strings(value, zones, f"zone_expansion.{key}") + + exposure = root["exposure"] + exact(root["eligibility"], {"recording_mode", "tracking_mode", "knowledge_resolution", "allowed_confidence", + "require_runtime_exercise_id", "require_explicit_equipment_compatibility", + "unknown_conditional_and_unlinked_capabilities", "scientific_zone_role", + "persisted_zone_disagreement", "equipment_choice"}, "eligibility") + exact(root["load"], {"lookback_seconds", "window", "required_context", "priority", "working_rule", + "actual_load_mode_compatibility", "planned_load_mode", "working_confidence", "working_meaning", + "max_rule", "max_confidence", "max_only_does_not_create_performed_sets", "assistance", + "bodyweight_or_unknown_semantics", "machine_increment", "progression"}, "load") + exact(exposure, {"short_window_seconds", "long_window_seconds", "window", "timestamp_policy", + "invalid_timestamp", "counted_rows", "excluded", "zone_source", "primary_secondary", + "requested_zone_aggregation", "summary_consistency", "recent_primary_sets_24h_threshold", + "recent_secondary_sets_24h_threshold", "repeated_primary_sets_72h_threshold", + "repeated_secondary_sets_72h_threshold", "status", "warning_levels", "unknown_history", + "user_continuation"}, "exposure") + for key in ("short_window_seconds", "long_window_seconds", "recent_primary_sets_24h_threshold", + "recent_secondary_sets_24h_threshold", "repeated_primary_sets_72h_threshold", + "repeated_secondary_sets_72h_threshold"): + integer(exposure[key], 1, 2_147_483_647, f"exposure.{key}") + require(exposure["short_window_seconds"] < exposure["long_window_seconds"], "exposure windows invalid") + + selection = root["selection"] + exact(selection, {"max_exercises", "max_per_exact_pattern", "duplicate_exercise", "pattern_overlap", + "optional_preferences", "recency", "score", "algorithm", "group_coverage", "upper_push_patterns", + "upper_pull_patterns", "lower_extension_patterns", "lower_flexion_patterns", "coverage_shortage"}, "selection") + integer(selection["max_exercises"], 1, 64, "selection.max_exercises") + integer(selection["max_per_exact_pattern"], 1, 64, "selection.max_per_exact_pattern") + strings(selection["upper_push_patterns"], patterns, "selection.upper_push_patterns") + strings(selection["upper_pull_patterns"], patterns, "selection.upper_pull_patterns") + strings(selection["lower_extension_patterns"], patterns, "selection.lower_extension_patterns") + strings(selection["lower_flexion_patterns"], patterns, "selection.lower_flexion_patterns") + for key, value in selection["score"].items(): + integer(value, -10000, 10000, f"selection.score.{key}") + integer(selection["recency"]["same_exercise_window_seconds"], 1, 2_147_483_647, "recency exercise") + integer(selection["recency"]["same_pattern_window_seconds"], 1, 2_147_483_647, "recency pattern") + integer(root["load"]["lookback_seconds"], 1, 2_147_483_647, "load.lookback_seconds") + + duration = root["duration"] + exact(duration, {"presets_minutes", "custom_min_minutes", "custom_max_minutes", "preparation_seconds", + "setup_and_transition_seconds_per_exercise", "estimated_seconds_per_repetition", + "exercise_seconds_formula", "session_seconds_formula", "budget_rule", "precision"}, "duration") + exact(root["guidance"], {"effort", "load", "rest", "recent_exposure"}, "guidance") + strings_duration = duration["presets_minutes"] + require(isinstance(strings_duration, list) and strings_duration == sorted(set(strings_duration)), + "duration.presets_minutes invalid") + for value in strings_duration: + integer(value, 1, 1440, "duration.presets_minutes") + for key in ("custom_min_minutes", "custom_max_minutes", "preparation_seconds", + "setup_and_transition_seconds_per_exercise", "estimated_seconds_per_repetition"): + integer(duration[key], 1, 86400, f"duration.{key}") + require(duration["custom_min_minutes"] <= duration["custom_max_minutes"], "duration range invalid") + + # Cross-catalog invariants used by both native engines. + for row in exercises_doc["exercises"]: + strings(row["equipment_ids"], equipment, f"exercise {row['exercise_id']}.equipment_ids") if row["equipment_ids"] else None + require(row["exercise_id"] in exercises, "exercise identity failure") + return root + + +def main() -> int: + policy = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("catalog/session-generation-policy-v1.json") + catalog = Path(sys.argv[2]) if len(sys.argv) > 2 else policy.parent + try: + validate(policy, catalog) + except PolicyError as error: + print(error, file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tui/include/trainlog/session_generation.h b/tui/include/trainlog/session_generation.h new file mode 100644 index 0000000..2f26a74 --- /dev/null +++ b/tui/include/trainlog/session_generation.h @@ -0,0 +1,214 @@ +#ifndef TRAINLOG_SESSION_GENERATION_H +#define TRAINLOG_SESSION_GENERATION_H + +/** + * @file session_generation.h + * @brief Deterministic SESSION_GENERATOR_V1 policy engine and history services. + * + * Requests and history rows are borrowed only for a call. Results contain no + * borrowed pointers and need no release operation. Fixed capacities are part + * of the public ABI: exceeding one fails explicitly rather than truncating a + * result that could be mistaken for a complete scientific analysis. + */ + +#include +#include + +#include "trainlog/model.h" +#include "trainlog/status.h" + +#define TRAINLOG_GENERATOR_MAX_CANDIDATES 64U +#define TRAINLOG_GENERATOR_MAX_EQUIPMENT 8U +#define TRAINLOG_GENERATOR_MAX_PATTERNS 16U +#define TRAINLOG_GENERATOR_MAX_ZONES 16U +#define TRAINLOG_GENERATOR_MAX_SELECTED 6U +#define TRAINLOG_GENERATOR_MAX_SOURCE_REFS 16U + +typedef enum TrainlogGenerationWarningLevel { + TRAINLOG_GENERATION_WARNING_NONE = 0, + TRAINLOG_GENERATION_WARNING_NOTICE, + TRAINLOG_GENERATION_WARNING_WARNING +} TrainlogGenerationWarningLevel; + +typedef struct TrainlogGenerationHistoryRow { + const char *session_id; + const char *occurrence_id; + const char *exercise_id; + const char *started_at; + const char *equipment_id; /* NULL records absent context. */ + TrainlogRecordingMode recording_mode; + TrainlogTrackingMode tracking_mode; + TrainlogLoadMode load_mode; + int rest_seconds; + bool has_target_sets, has_target_reps, has_target_duration, has_target_weight; + bool has_actual_set; + bool has_explicit_max; + size_t set_position; + int repetitions; /* meaningful only for an actual SETS+REPS row */ + bool has_weight; + double weight_kg; +} TrainlogGenerationHistoryRow; + +typedef struct TrainlogGenerationCandidate { + const char *exercise_id; + const char *equipment_id; + const char *primary_zone_id; + const char *const *secondary_zone_ids; + size_t secondary_zone_count; + const char *const *pattern_ids; + size_t pattern_count; + const char *const *source_ref_ids; + size_t source_ref_count; + const char *confidence; /* exactly "high" or "moderate" */ + const char *equipment_load_semantics; /* external, assistance, bodyweight */ +} TrainlogGenerationCandidate; + +typedef struct TrainlogGenerationRequest { + const char *zone_id; + const char *goal_id; + int duration_minutes; + const char *reference_time; + const TrainlogGenerationCandidate *candidates; + size_t candidate_count; + const char *const *preferred_exercise_ids; + size_t preferred_count; + const char *const *excluded_exercise_ids; + size_t excluded_exercise_count; + const char *const *excluded_pattern_ids; + size_t excluded_pattern_count; +} TrainlogGenerationRequest; + +typedef struct TrainlogGenerationDatabaseRequest { + const char *zone_id; + const char *goal_id; + int duration_minutes; + const char *reference_time; + /* NULL means all scientifically compatible supplied equipment. A non-NULL + * array with count zero means explicitly no equipment is available. */ + const char *const *available_equipment_ids; + size_t available_equipment_count; + const char *const *preferred_exercise_ids; + size_t preferred_count; + const char *const *excluded_exercise_ids; + size_t excluded_exercise_count; + const char *const *excluded_pattern_ids; + size_t excluded_pattern_count; +} TrainlogGenerationDatabaseRequest; + +typedef struct TrainlogExposureWindowSummary { + size_t primary_set_count, secondary_set_count, session_count; + char pattern_ids[TRAINLOG_GENERATOR_MAX_PATTERNS][TRAINLOG_ID_MAX + 1U]; + size_t pattern_count; +} TrainlogExposureWindowSummary; + +typedef struct TrainlogBodyZoneRecentExposure { + TrainlogExposureWindowSummary within_24h, within_72h; + bool recent_exposure, repeated_exposure; + TrainlogGenerationWarningLevel warning_level; + bool has_latest; + char latest_started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char latest_session_id[TRAINLOG_ID_MAX + 1U]; + char latest_occurrence_id[TRAINLOG_ID_MAX + 1U]; + char latest_pattern_ids[TRAINLOG_GENERATOR_MAX_PATTERNS][TRAINLOG_ID_MAX + 1U]; + size_t latest_pattern_count; + size_t unclassified_actual_set_count; +} TrainlogBodyZoneRecentExposure; + +typedef struct TrainlogTrainingRecencyWarning { + bool recent_same_exercise; + bool recent_same_pattern; +} TrainlogTrainingRecencyWarning; + +typedef struct TrainlogGeneratedExercise { + char exercise_id[TRAINLOG_ID_MAX + 1U]; + char equipment_id[TRAINLOG_ID_MAX + 1U]; + char primary_zone_id[TRAINLOG_ZONE_ID_MAX + 1U]; + char secondary_zone_ids[TRAINLOG_GENERATOR_MAX_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; + size_t secondary_zone_count; + char pattern_ids[TRAINLOG_GENERATOR_MAX_PATTERNS][TRAINLOG_ID_MAX + 1U]; + size_t pattern_count; + int target_sets, target_repetitions, rest_seconds; + bool has_target_weight; + double target_weight_kg; + TrainlogLoadMode planned_load_mode; + char equipment_load_semantics[32]; + char confidence[16]; + TrainlogTrainingRecencyWarning recency; + TrainlogGenerationWarningLevel exposure_warning_level; + int estimated_seconds; + char load_source_session_id[TRAINLOG_ID_MAX + 1U]; + char load_source_occurrence_id[TRAINLOG_ID_MAX + 1U]; + char load_source_started_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + char rationale_codes[8][64]; + size_t rationale_count; + char source_ref_ids[TRAINLOG_GENERATOR_MAX_SOURCE_REFS][TRAINLOG_ID_MAX + 1U]; + size_t source_ref_count; +} TrainlogGeneratedExercise; + +typedef struct TrainlogGeneratedSession { + TrainlogGeneratedExercise exercises[TRAINLOG_GENERATOR_MAX_SELECTED]; + size_t exercise_count; + int estimated_duration_seconds; + bool insufficient_resolved_candidates; + char shortage_codes[16][64]; + size_t shortage_count; + TrainlogBodyZoneRecentExposure exposure; +} TrainlogGeneratedSession; + +typedef struct TrainlogSessionGenerationAnalyzer TrainlogSessionGenerationAnalyzer; +typedef struct TrainlogDatabase TrainlogDatabase; +typedef TrainlogStatus (*TrainlogGenerationHistoryVisitor)( + void *context, const TrainlogGenerationHistoryRow *row); + +/* Runs visitor over the complete completed-session occurrence history in one + * nested-safe read snapshot. Rows are grouped as required by analyzer_accept; + * no preview/page limit or SQL date predicate is applied. */ +TrainlogStatus trainlog_database_scan_generation_history( + TrainlogDatabase *database, + TrainlogGenerationHistoryVisitor visitor, + void *context +); + +/* Production convenience path. Candidate assembly, runtime-profile checking, + * exact equipment compatibility, and the complete history scan share one + * outer read snapshot. Sparse knowledge is an OK result with insufficiency. */ +TrainlogStatus trainlog_session_generate_from_database( + TrainlogDatabase *database, + const TrainlogGenerationDatabaseRequest *request, + TrainlogGeneratedSession *output +); + +/* Streaming boundary: memory is bounded by candidate/catalog caps, not history + * length. Rows must be grouped by session_id then occurrence_id then position; + * duplicate set identity is a DATABASE_ERROR. Every stored occurrence must be + * offered, including empty/MAX/continuous rows, so malformed time cannot hide. */ +TrainlogStatus trainlog_session_generation_analyzer_create( + const TrainlogGenerationRequest *request, + TrainlogSessionGenerationAnalyzer **output +); +TrainlogStatus trainlog_session_generation_analyzer_accept( + TrainlogSessionGenerationAnalyzer *analyzer, + const TrainlogGenerationHistoryRow *row +); +TrainlogStatus trainlog_session_generation_analyzer_finish( + TrainlogSessionGenerationAnalyzer *analyzer, + TrainlogGeneratedSession *output +); +void trainlog_session_generation_analyzer_destroy(TrainlogSessionGenerationAnalyzer *analyzer); + +/* Pure dose-edit helper: changing sets/repetitions always re-runs anchor + * qualification; it never retains a load qualified for a smaller dose. */ +TrainlogStatus trainlog_session_generation_requalify_dose( + const TrainlogGenerationRequest *request, + size_t selected_index, + int target_sets, + int target_repetitions, + TrainlogSessionGenerationAnalyzer **output_analyzer +); + +/* Checked duration helpers use the policy-authored formula and return + * INVALID_ARGUMENT on invalid dose or integer overflow. */ +TrainlogStatus trainlog_session_generation_exercise_duration( + int target_sets, int target_repetitions, int rest_seconds, int *output_seconds); + +#endif diff --git a/tui/include/trainlog/session_generation_policy_internal.h b/tui/include/trainlog/session_generation_policy_internal.h new file mode 100644 index 0000000..bea00a7 --- /dev/null +++ b/tui/include/trainlog/session_generation_policy_internal.h @@ -0,0 +1,43 @@ +#ifndef TRAINLOG_SESSION_GENERATION_POLICY_INTERNAL_H +#define TRAINLOG_SESSION_GENERATION_POLICY_INTERNAL_H + +#include + +typedef struct TrainlogSessionGenerationGoalPolicy { + const char *id; + int sets, repetitions, rest_seconds; + int sets_min, sets_max, repetitions_min, repetitions_max, rest_min, rest_max; +} TrainlogSessionGenerationGoalPolicy; + +typedef struct TrainlogSessionGenerationScores { + int requested_primary, requested_secondary, new_primary, new_pattern; + int load_history, preferred, recent_exercise, recent_pattern; + int exposure_primary_24h, exposure_secondary_24h; + int exposure_primary_72h, exposure_secondary_72h, secondary_zone_exposure; +} TrainlogSessionGenerationScores; + +typedef struct TrainlogSessionGenerationZoneExpansion { + const char *zone_id; + const char *expanded_zone_ids; +} TrainlogSessionGenerationZoneExpansion; + +typedef struct TrainlogSessionGenerationPolicy { + int load_lookback_seconds, short_window_seconds, long_window_seconds; + int exercise_recency_seconds, pattern_recency_seconds; + int max_exercises, min_minutes, max_minutes; + const int *duration_presets_minutes; + size_t duration_preset_count; + int preparation_seconds, setup_seconds, repetition_seconds; + int primary_24h, secondary_24h, primary_72h, secondary_72h; + TrainlogSessionGenerationScores scores; + const char *upper_push_patterns, *upper_pull_patterns; + const char *lower_extension_patterns, *lower_flexion_patterns; + const TrainlogSessionGenerationZoneExpansion *zone_expansions; + size_t zone_expansion_count; + const TrainlogSessionGenerationGoalPolicy *goals; + size_t goal_count; +} TrainlogSessionGenerationPolicy; + +extern const TrainlogSessionGenerationPolicy trainlog_session_generation_policy_v1; + +#endif diff --git a/tui/meson.build b/tui/meson.build index 3c4b48d..df5695f 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -52,6 +52,23 @@ training_knowledge_generated = custom_target( meson.project_source_root() / 'catalog', '@OUTPUT@'], ) +session_generation_policy_generated = custom_target( + 'session_generation_policy_generated', + input: meson.project_source_root() / 'catalog/session-generation-policy-v1.json', + output: 'session_generation_policy_generated.c', + depend_files: [ + meson.project_source_root() / 'tools/generate_session_generation_policy.py', + meson.project_source_root() / 'tools/validate_session_generation_policy.py', + meson.project_source_root() / 'catalog/body-zones-v1.json', + meson.project_source_root() / 'catalog/movement-patterns-v1.json', + meson.project_source_root() / 'catalog/exercise-knowledge-v1.json', + meson.project_source_root() / 'catalog/equipment-knowledge-v1.json', + meson.project_source_root() / 'catalog/science-references-v1.json', + ], + command: [find_program('python3'), meson.project_source_root() / 'tools/generate_session_generation_policy.py', + '@INPUT@', meson.project_source_root() / 'catalog', '@OUTPUT@'], +) + strict_c_args = [ '-D_POSIX_C_SOURCE=200809L', '-Wconversion', @@ -71,6 +88,7 @@ trainlog_core_sources = files( 'src/usb.c', 'src/mtp.c', 'src/reps.c', + 'src/session_generation.c', 'src/measured_max.c', 'src/sync.c', 'src/sync_history.c', @@ -79,6 +97,7 @@ trainlog_core_sources = files( trainlog_core_sources += equipment_catalog_generated trainlog_core_sources += body_zone_catalog_generated trainlog_core_sources += training_knowledge_generated +trainlog_core_sources += session_generation_policy_generated trainlog_core = static_library( 'trainlog_core', @@ -175,6 +194,27 @@ test_training_knowledge = executable( ) test('training_knowledge', test_training_knowledge) +test_session_generation = executable( + 'test_session_generation', + ['tests/test_session_generation.c', custom_target( + 'session_generation_fixtures_generated', + input: meson.project_source_root() / 'tests/fixtures/session-generation-v1.json', + output: 'session_generation_fixtures_generated.c', + command: [find_program('python3'), meson.project_source_root() / 'tools/generate_session_generation_fixtures.py', '@INPUT@', '@OUTPUT@'], + )], + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('session_generation', test_session_generation) + +test( + 'session_generation_policy_validation', + find_program('python3'), + args: [meson.project_source_root() / 'tools/validate_session_generation_policy.py', + meson.project_source_root() / 'catalog/session-generation-policy-v1.json', + meson.project_source_root() / 'catalog'], +) + test_training_context = executable( 'test_training_context', 'tests/test_training_context.c', @@ -466,6 +506,12 @@ test( args: [meson.project_source_root() / 'tests/test_max_sync.py'], ) +test( + 'session_exchange_v3', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_session_exchange_v3.py'], +) + test( 'equipment_associations_exchange', python3_trainlog_tests, diff --git a/tui/src/database.c b/tui/src/database.c index 20eed52..3c3bdab 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -8,6 +8,7 @@ #include "trainlog/duration.h" #include "trainlog/equipment_catalog.h" #include "trainlog/id.h" +#include "trainlog/session_generation.h" #include "timestamp.h" #include @@ -53,6 +54,121 @@ TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database, b return TRAINLOG_STATUS_OK; } +TrainlogStatus trainlog_database_scan_generation_history( + TrainlogDatabase *database, TrainlogGenerationHistoryVisitor visitor, void *context) +{ + static const char *const SQL = + "SELECT s.session_id,s.started_at,se.entry_id,e.exercise_id,e.recording_mode," + "e.tracking_mode,se.equipment_id,se.load_mode,se.rest_seconds," + "se.target_sets,se.target_reps,se.target_duration_seconds,se.target_weight_kg," + "ps.position,ps.reps,ps.weight_kg,mr.max_weight_kg " + "FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " + "JOIN exercises e ON e.id=se.exercise_row_id " + "LEFT JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " + "LEFT JOIN max_results mr ON mr.session_exercise_row_id=se.id " + "ORDER BY s.session_id COLLATE BINARY,se.entry_id COLLATE BINARY,ps.position;"; + sqlite3_stmt *statement = NULL; + TrainlogStatus status; + int rc; + if (database == NULL || visitor == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + status = trainlog_database_read_snapshot_begin(database); + if (status != TRAINLOG_STATUS_OK) return status; + if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK) { + (void)trainlog_database_read_snapshot_end(database, false); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + while ((rc = sqlite3_step(statement)) == SQLITE_ROW) { + TrainlogGenerationHistoryRow row; + const unsigned char *recording, *tracking, *load; + int index; + (void)memset(&row, 0, sizeof(row)); + /* CONTRACT: SQLite affinity cannot turn corrupt dynamic types into a + * complete scientific result; validate every projected field before + * the borrowed callback row is exposed. */ + for (index = 0; index <= 3; ++index) + if (sqlite3_column_type(statement, index) != SQLITE_TEXT) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; + } + for (index = 4; index <= 5; ++index) + if (sqlite3_column_type(statement, index) != SQLITE_TEXT) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; + } + if (sqlite3_column_type(statement, 6) != SQLITE_NULL && + sqlite3_column_type(statement, 6) != SQLITE_TEXT) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; + } + if (sqlite3_column_type(statement, 7) != SQLITE_TEXT || + sqlite3_column_type(statement, 8) != SQLITE_INTEGER) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; + } + row.session_id = (const char *)sqlite3_column_text(statement, 0); + row.started_at = (const char *)sqlite3_column_text(statement, 1); + row.occurrence_id = (const char *)sqlite3_column_text(statement, 2); + row.exercise_id = (const char *)sqlite3_column_text(statement, 3); + recording = sqlite3_column_text(statement, 4); + tracking = sqlite3_column_text(statement, 5); + row.equipment_id = sqlite3_column_type(statement, 6) == SQLITE_NULL ? NULL : + (const char *)sqlite3_column_text(statement, 6); + load = sqlite3_column_text(statement, 7); + if (strcmp((const char *)recording, "sets") == 0) row.recording_mode = TRAINLOG_RECORDING_SETS; + else if (strcmp((const char *)recording, "continuous") == 0) row.recording_mode = TRAINLOG_RECORDING_CONTINUOUS; + else { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; } + if (strcmp((const char *)tracking, "reps") == 0) row.tracking_mode = TRAINLOG_TRACKING_REPS; + else if (strcmp((const char *)tracking, "duration") == 0) row.tracking_mode = TRAINLOG_TRACKING_DURATION; + else { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; } + if (strcmp((const char *)load, "none") == 0) row.load_mode = TRAINLOG_LOAD_NONE; + else if (strcmp((const char *)load, "external") == 0) row.load_mode = TRAINLOG_LOAD_EXTERNAL; + else if (strcmp((const char *)load, "assistance") == 0) row.load_mode = TRAINLOG_LOAD_ASSISTANCE; + else { status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; } + row.rest_seconds = sqlite3_column_int(statement, 8); + row.has_target_sets = sqlite3_column_type(statement, 9) != SQLITE_NULL; + row.has_target_reps = sqlite3_column_type(statement, 10) != SQLITE_NULL; + row.has_target_duration = sqlite3_column_type(statement, 11) != SQLITE_NULL; + row.has_target_weight = sqlite3_column_type(statement, 12) != SQLITE_NULL; + row.has_actual_set = sqlite3_column_type(statement, 13) != SQLITE_NULL; + row.has_explicit_max = sqlite3_column_type(statement, 16) != SQLITE_NULL; + if (row.has_explicit_max && + ((sqlite3_column_type(statement, 16) != SQLITE_FLOAT && + sqlite3_column_type(statement, 16) != SQLITE_INTEGER) || + !isfinite(sqlite3_column_double(statement, 16)) || + sqlite3_column_double(statement, 16) <= 0.0)) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; + } + if (row.has_actual_set) { + sqlite3_int64 position = sqlite3_column_int64(statement, 13); + if (sqlite3_column_type(statement, 13) != SQLITE_INTEGER || position < 0 || + (uint64_t)position > (uint64_t)SIZE_MAX || + (sqlite3_column_type(statement, 14) != SQLITE_INTEGER && + sqlite3_column_type(statement, 14) != SQLITE_NULL)) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; + } + row.set_position = (size_t)position; + row.repetitions = sqlite3_column_type(statement, 14) == SQLITE_NULL ? 0 : + sqlite3_column_int(statement, 14); + row.has_weight = sqlite3_column_type(statement, 15) != SQLITE_NULL; + if (row.has_weight) { + if (sqlite3_column_type(statement, 15) != SQLITE_FLOAT && + sqlite3_column_type(statement, 15) != SQLITE_INTEGER) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto done; + } + row.weight_kg = sqlite3_column_double(statement, 15); + } + } + status = visitor(context, &row); + if (status != TRAINLOG_STATUS_OK) goto done; + } + status = rc == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; +done: + if (sqlite3_finalize(statement) != SQLITE_OK && status == TRAINLOG_STATUS_OK) + status = TRAINLOG_STATUS_DATABASE_ERROR; + { + TrainlogStatus end_status = trainlog_database_read_snapshot_end(database, + status == TRAINLOG_STATUS_OK); + if (status == TRAINLOG_STATUS_OK) status = end_status; + } + return status; +} + static TrainlogStatus lookup_exercise_row_id( TrainlogDatabase *database, const char *exercise_id, diff --git a/tui/src/session_generation.c b/tui/src/session_generation.c new file mode 100644 index 0000000..9e8886d --- /dev/null +++ b/tui/src/session_generation.c @@ -0,0 +1,1056 @@ +#include "trainlog/session_generation.h" + +#include "trainlog/body_zone_catalog.h" +#include "trainlog/database.h" +#include "trainlog/training_knowledge.h" +#include "trainlog/session_generation_policy_internal.h" +#include "timestamp.h" + +#include +#include +#include +#include +#include + +typedef struct CandidateState { + TrainlogGenerationCandidate candidate; + char exercise_id[TRAINLOG_ID_MAX + 1U]; + char equipment_id[TRAINLOG_ID_MAX + 1U]; + char primary_zone_id[TRAINLOG_ZONE_ID_MAX + 1U]; + char confidence[16]; + char equipment_load_semantics[32]; + char secondary_zone_ids[TRAINLOG_GENERATOR_MAX_ZONES][TRAINLOG_ZONE_ID_MAX + 1U]; + const char *secondary_zone_ptrs[TRAINLOG_GENERATOR_MAX_ZONES]; + char pattern_ids[TRAINLOG_GENERATOR_MAX_PATTERNS][TRAINLOG_ID_MAX + 1U]; + const char *pattern_ptrs[TRAINLOG_GENERATOR_MAX_PATTERNS]; + char source_ref_ids[TRAINLOG_GENERATOR_MAX_SOURCE_REFS][TRAINLOG_ID_MAX + 1U]; + const char *source_ref_ptrs[TRAINLOG_GENERATOR_MAX_SOURCE_REFS]; + bool preferred, excluded, recent_exercise, recent_pattern; + size_t primary_24, secondary_24, primary_72, secondary_72; + size_t secondary_zone_primary_24, secondary_zone_secondary_24; + size_t secondary_zone_primary_72, secondary_zone_secondary_72; + bool has_anchor; + bool has_compatible_explicit_max; + double anchor_weight; + TrainlogTimestampKey anchor_time; + char anchor_time_text[TRAINLOG_TIMESTAMP_MAX + 1U]; + char anchor_session[TRAINLOG_ID_MAX + 1U]; + char anchor_occurrence[TRAINLOG_ID_MAX + 1U]; +} CandidateState; + +struct TrainlogSessionGenerationAnalyzer { + TrainlogGenerationRequest request; + char request_zone_id[TRAINLOG_ZONE_ID_MAX + 1U]; + char *reference_owned; + TrainlogTimestampKey reference; + const TrainlogSessionGenerationGoalPolicy *goal; + TrainlogSessionGenerationGoalPolicy edited_goal; + CandidateState candidates[TRAINLOG_GENERATOR_MAX_CANDIDATES]; + TrainlogBodyZoneRecentExposure exposure; + bool failed; + bool have_latest_key; + TrainlogTimestampKey latest_key; + char current_session[TRAINLOG_ID_MAX + 1U]; + bool current_session_matches_24, current_session_matches_72; + char current_occurrence[TRAINLOG_ID_MAX + 1U]; + char current_occurrence_session[TRAINLOG_ID_MAX + 1U]; + char *current_occurrence_time; + TrainlogTimestampKey current_occurrence_key; + char current_exercise[TRAINLOG_ID_MAX + 1U]; + char current_equipment[TRAINLOG_ID_MAX + 1U]; + bool current_has_equipment; + TrainlogLoadMode current_load_mode; + int current_rest; + bool current_has_targets; + size_t qualifying_sets[TRAINLOG_GENERATOR_MAX_CANDIDATES]; + double qualifying_min[TRAINLOG_GENERATOR_MAX_CANDIDATES]; + bool occurrence_open; + bool have_previous_set; + size_t previous_set_position; +}; + +static bool copy_text(char *output, size_t capacity, const char *value) +{ + size_t length; + if (output == NULL || value == NULL || capacity == 0U) return false; + length = strlen(value); + if (length == 0U || length >= capacity) return false; + (void)memcpy(output, value, length + 1U); + return true; +} + +static char *duplicate_text(const char *value) +{ + size_t length; + char *copy; + if (value == NULL) return NULL; + length = strlen(value); + if (length == SIZE_MAX) return NULL; + copy = malloc(length + 1U); + if (copy != NULL) (void)memcpy(copy, value, length + 1U); + return copy; +} + +static bool candidate_copy(CandidateState *output, const TrainlogGenerationCandidate *input) +{ + size_t index; + if (!copy_text(output->exercise_id, sizeof(output->exercise_id), input->exercise_id) || + !copy_text(output->equipment_id, sizeof(output->equipment_id), input->equipment_id) || + !copy_text(output->primary_zone_id, sizeof(output->primary_zone_id), input->primary_zone_id) || + !copy_text(output->confidence, sizeof(output->confidence), input->confidence) || + !copy_text(output->equipment_load_semantics, sizeof(output->equipment_load_semantics), + input->equipment_load_semantics)) return false; + output->candidate = *input; + output->candidate.exercise_id = output->exercise_id; + output->candidate.equipment_id = output->equipment_id; + output->candidate.primary_zone_id = output->primary_zone_id; + output->candidate.confidence = output->confidence; + output->candidate.equipment_load_semantics = output->equipment_load_semantics; + for (index = 0U; index < input->secondary_zone_count; ++index) { + if (!copy_text(output->secondary_zone_ids[index], sizeof(output->secondary_zone_ids[index]), + input->secondary_zone_ids[index])) return false; + output->secondary_zone_ptrs[index] = output->secondary_zone_ids[index]; + } + for (index = 0U; index < input->pattern_count; ++index) { + if (!copy_text(output->pattern_ids[index], sizeof(output->pattern_ids[index]), + input->pattern_ids[index])) return false; + output->pattern_ptrs[index] = output->pattern_ids[index]; + } + for (index = 0U; index < input->source_ref_count; ++index) { + if (!copy_text(output->source_ref_ids[index], sizeof(output->source_ref_ids[index]), + input->source_ref_ids[index])) return false; + output->source_ref_ptrs[index] = output->source_ref_ids[index]; + } + output->candidate.secondary_zone_ids = output->secondary_zone_ptrs; + output->candidate.pattern_ids = output->pattern_ptrs; + output->candidate.source_ref_ids = output->source_ref_ptrs; + return true; +} + +static bool id_in(const char *id, const char *const *values, size_t count) +{ + size_t index; + for (index = 0U; index < count; ++index) + if (values[index] != NULL && strcmp(id, values[index]) == 0) return true; + return false; +} + +static bool newline_list_has(const char *list, const char *id) +{ + const char *at = list; + size_t id_length = strlen(id); + while (at != NULL && at[0] != '\0') { + const char *end = strchr(at, '\n'); + size_t length = end == NULL ? strlen(at) : (size_t)(end - at); + if (length == id_length && memcmp(at, id, length) == 0) return true; + at = end == NULL ? NULL : end + 1; + } + return false; +} + +static int fraction_compare(const TrainlogTimestampKey *left, const TrainlogTimestampKey *right) +{ + TrainlogTimestampKey a = *left, b = *right; + a.utc_second = 0; + b.utc_second = 0; + return trainlog_timestamp_compare(&a, &b); +} + +static bool nonfuture_within(const TrainlogTimestampKey *instant, + const TrainlogTimestampKey *reference, int seconds, bool inclusive) +{ + int64_t delta; + if (trainlog_timestamp_compare(instant, reference) > 0) return false; + delta = reference->utc_second - instant->utc_second; + if (delta < (int64_t)seconds) return true; + if (delta > (int64_t)seconds) return false; + return inclusive ? fraction_compare(reference, instant) <= 0 + : fraction_compare(reference, instant) < 0; +} + +static bool zone_matches(const char *actual, const char *requested) +{ + size_t index; + for (index = 0U; index < trainlog_session_generation_policy_v1.zone_expansion_count; ++index) { + const TrainlogSessionGenerationZoneExpansion *expansion = + &trainlog_session_generation_policy_v1.zone_expansions[index]; + if (strcmp(expansion->zone_id, requested) == 0) + return newline_list_has(expansion->expanded_zone_ids, actual); + } + return false; +} + +static bool interpretation_secondary_matches( + const TrainlogKnowledgeInterpretation *interpretation, const char *zone) +{ + const char *cursor = interpretation->secondary_zone_ids; + while (cursor != NULL && cursor[0] != '\0') { + const char *end = strchr(cursor, '\n'); + size_t length = end == NULL ? strlen(cursor) : (size_t)(end - cursor); + char id[TRAINLOG_ZONE_ID_MAX + 1U]; + if (length == 0U || length > TRAINLOG_ZONE_ID_MAX) return false; + (void)memcpy(id, cursor, length); id[length] = '\0'; + if (zone_matches(id, zone)) return true; + cursor = end == NULL ? NULL : end + 1; + } + return false; +} + +static bool candidate_has_pattern(const TrainlogGenerationCandidate *candidate, const char *pattern) +{ + return id_in(pattern, candidate->pattern_ids, candidate->pattern_count); +} + +static bool candidate_matches_zone(const TrainlogGenerationCandidate *candidate, const char *zone) +{ + size_t index; + if (zone_matches(candidate->primary_zone_id, zone)) return true; + for (index = 0U; index < candidate->secondary_zone_count; ++index) + if (zone_matches(candidate->secondary_zone_ids[index], zone)) return true; + return false; +} + +static bool interpretation_matches_any_zone(const TrainlogKnowledgeInterpretation *interpretation, + const char *const *zones, size_t zone_count, bool *primary) +{ + size_t index; + *primary = false; + for (index = 0U; index < zone_count; ++index) + if (zone_matches(interpretation->primary_zone_id, zones[index])) { *primary = true; return true; } + for (index = 0U; index < zone_count; ++index) + if (interpretation_secondary_matches(interpretation, zones[index])) return true; + return false; +} + +static bool interpretation_pattern_intersects( + const TrainlogKnowledgeInterpretation *interpretation, + const TrainlogGenerationCandidate *candidate) +{ + size_t index; + for (index = 0U; index < candidate->pattern_count; ++index) + if (newline_list_has(interpretation->pattern_ids, candidate->pattern_ids[index])) return true; + return false; +} + +static bool add_pattern(char output[][TRAINLOG_ID_MAX + 1U], size_t *count, const char *id) +{ + size_t index, insert; + if (id == NULL || id[0] == '\0' || strlen(id) > TRAINLOG_ID_MAX) return false; + for (index = 0U; index < *count; ++index) { + int order = strcmp(id, output[index]); + if (order == 0) return true; + if (order < 0) break; + } + if (*count == TRAINLOG_GENERATOR_MAX_PATTERNS) return false; + insert = index; + for (index = *count; index > insert; --index) + (void)memcpy(output[index], output[index - 1U], sizeof(output[index])); + (void)strcpy(output[insert], id); + ++*count; + return true; +} + +static bool add_interpretation_patterns(char output[][TRAINLOG_ID_MAX + 1U], + size_t *count, const char *list) +{ + const char *cursor = list; + while (cursor != NULL && cursor[0] != '\0') { + const char *end = strchr(cursor, '\n'); + size_t length = end == NULL ? strlen(cursor) : (size_t)(end - cursor); + char id[TRAINLOG_ID_MAX + 1U]; + if (length == 0U || length > TRAINLOG_ID_MAX) return false; + (void)memcpy(id, cursor, length); id[length] = '\0'; + if (!add_pattern(output, count, id)) return false; + cursor = end == NULL ? NULL : end + 1; + } + return true; +} + +static bool candidate_valid(const TrainlogGenerationCandidate *value) +{ + size_t index; + const TrainlogExerciseKnowledge *knowledge; + const TrainlogEquipmentKnowledge *equipment; + if (value == NULL || value->exercise_id == NULL || value->equipment_id == NULL || + value->primary_zone_id == NULL || value->confidence == NULL || + value->equipment_load_semantics == NULL || value->pattern_count == 0U || + value->pattern_count > TRAINLOG_GENERATOR_MAX_PATTERNS || + value->secondary_zone_count > TRAINLOG_GENERATOR_MAX_ZONES || + value->source_ref_count > TRAINLOG_GENERATOR_MAX_SOURCE_REFS || + (strcmp(value->confidence, "high") != 0 && strcmp(value->confidence, "moderate") != 0) || + trainlog_body_zone_catalog_lookup(value->primary_zone_id) == NULL) return false; + knowledge = trainlog_exercise_knowledge_lookup(value->exercise_id); + equipment = trainlog_equipment_knowledge_lookup(value->equipment_id); + if (knowledge == NULL || knowledge->interpretation == NULL || equipment == NULL || + strcmp(knowledge->resolution_status, "resolved_family_variant_limited") != 0 || + !newline_list_has(knowledge->equipment_ids, value->equipment_id) || + equipment->catalog_load_semantics == NULL || + strcmp(equipment->catalog_load_semantics, value->equipment_load_semantics) != 0 || + strcmp(knowledge->interpretation->primary_zone_id, value->primary_zone_id) != 0 || + strcmp(knowledge->confidence, value->confidence) != 0) + return false; + for (index = 0U; index < value->pattern_count; ++index) + if (value->pattern_ids == NULL || value->pattern_ids[index] == NULL || + trainlog_knowledge_movement_pattern_lookup(value->pattern_ids[index]) == NULL || + !newline_list_has(knowledge->interpretation->pattern_ids, value->pattern_ids[index])) return false; + for (index = 0U; index < value->secondary_zone_count; ++index) + if (value->secondary_zone_ids == NULL || value->secondary_zone_ids[index] == NULL || + trainlog_body_zone_catalog_lookup(value->secondary_zone_ids[index]) == NULL || + !newline_list_has(knowledge->interpretation->secondary_zone_ids, + value->secondary_zone_ids[index])) return false; + for (index = 0U; index < value->source_ref_count; ++index) + if (value->source_ref_ids == NULL || value->source_ref_ids[index] == NULL) return false; + return true; +} + +static void finish_session(TrainlogSessionGenerationAnalyzer *analyzer) +{ + if (analyzer->current_session[0] == '\0') return; + if (analyzer->current_session_matches_24) ++analyzer->exposure.within_24h.session_count; + if (analyzer->current_session_matches_72) ++analyzer->exposure.within_72h.session_count; + analyzer->current_session_matches_24 = false; + analyzer->current_session_matches_72 = false; +} + +static void finish_occurrence(TrainlogSessionGenerationAnalyzer *analyzer) +{ + size_t index; + if (!analyzer->occurrence_open) return; + for (index = 0U; index < analyzer->request.candidate_count; ++index) { + CandidateState *state = &analyzer->candidates[index]; + bool actual_only = analyzer->current_load_mode == TRAINLOG_LOAD_NONE && + analyzer->current_rest == 0 && !analyzer->current_has_targets; + bool load_compatible = analyzer->current_load_mode == TRAINLOG_LOAD_EXTERNAL || actual_only; + if (strcmp(state->candidate.exercise_id, analyzer->current_exercise) == 0 && + analyzer->current_has_equipment && + strcmp(state->candidate.equipment_id, analyzer->current_equipment) == 0 && + strcmp(state->candidate.equipment_load_semantics, "external") == 0 && load_compatible && + analyzer->qualifying_sets[index] >= (size_t)analyzer->goal->sets && + nonfuture_within(&analyzer->current_occurrence_key, &analyzer->reference, + trainlog_session_generation_policy_v1.load_lookback_seconds, true) && + (!state->has_anchor || trainlog_timestamp_compare(&analyzer->current_occurrence_key, + &state->anchor_time) > 0)) { + if (!copy_text(state->anchor_time_text, sizeof(state->anchor_time_text), + analyzer->current_occurrence_time)) { + analyzer->failed = true; + break; + } + state->has_anchor = true; + state->anchor_weight = analyzer->qualifying_min[index]; + if (!trainlog_timestamp_parse(state->anchor_time_text, strlen(state->anchor_time_text), + &state->anchor_time)) { + analyzer->failed = true; + break; + } + (void)strcpy(state->anchor_session, analyzer->current_occurrence_session); + (void)strcpy(state->anchor_occurrence, analyzer->current_occurrence); + } + } + free(analyzer->current_occurrence_time); + analyzer->current_occurrence_time = NULL; + analyzer->occurrence_open = false; + analyzer->have_previous_set = false; +} + +TrainlogStatus trainlog_session_generation_analyzer_create( + const TrainlogGenerationRequest *request, TrainlogSessionGenerationAnalyzer **output) +{ + TrainlogSessionGenerationAnalyzer *result; + size_t index, other; + if (output == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output = NULL; + if (request == NULL || request->zone_id == NULL || request->goal_id == NULL || + request->reference_time == NULL || request->candidate_count > TRAINLOG_GENERATOR_MAX_CANDIDATES || + (request->candidate_count > 0U && request->candidates == NULL) || + request->duration_minutes < trainlog_session_generation_policy_v1.min_minutes || + request->duration_minutes > trainlog_session_generation_policy_v1.max_minutes || + trainlog_body_zone_catalog_lookup(request->zone_id) == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + result = calloc(1U, sizeof(*result)); + if (result == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR; + result->request = *request; + result->request.candidates = NULL; + result->request.goal_id = NULL; + result->request.reference_time = NULL; + result->request.preferred_exercise_ids = NULL; + result->request.preferred_count = 0U; + result->request.excluded_exercise_ids = NULL; + result->request.excluded_exercise_count = 0U; + result->request.excluded_pattern_ids = NULL; + result->request.excluded_pattern_count = 0U; + if (!copy_text(result->request_zone_id, sizeof(result->request_zone_id), request->zone_id)) { + free(result); return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + result->request.zone_id = result->request_zone_id; + result->reference_owned = duplicate_text(request->reference_time); + if (result->reference_owned == NULL) { free(result); return TRAINLOG_STATUS_SYSTEM_ERROR; } + if (!trainlog_timestamp_parse(result->reference_owned, strlen(result->reference_owned), &result->reference)) { + free(result->reference_owned); + free(result); return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + for (index = 0U; index < trainlog_session_generation_policy_v1.goal_count; ++index) + if (strcmp(request->goal_id, trainlog_session_generation_policy_v1.goals[index].id) == 0) + result->goal = &trainlog_session_generation_policy_v1.goals[index]; + if (result->goal == NULL) { + free(result->reference_owned); free(result); return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + for (index = 0U; index < request->excluded_pattern_count; ++index) + if (request->excluded_pattern_ids == NULL || request->excluded_pattern_ids[index] == NULL || + trainlog_knowledge_movement_pattern_lookup(request->excluded_pattern_ids[index]) == NULL) { + free(result->reference_owned); free(result); return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + for (index = 0U; index < request->candidate_count; ++index) { + if (!candidate_valid(&request->candidates[index])) { + free(result->reference_owned); free(result); return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + for (other = 0U; other < index; ++other) + if (strcmp(request->candidates[index].exercise_id, request->candidates[other].exercise_id) == 0 && + strcmp(request->candidates[index].equipment_id, request->candidates[other].equipment_id) == 0) { + free(result->reference_owned); free(result); return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + if (!candidate_copy(&result->candidates[index], &request->candidates[index])) { + free(result->reference_owned); free(result); return TRAINLOG_STATUS_INVALID_ARGUMENT; + } + result->candidates[index].preferred = id_in(request->candidates[index].exercise_id, + request->preferred_exercise_ids, request->preferred_count); + result->candidates[index].excluded = id_in(request->candidates[index].exercise_id, + request->excluded_exercise_ids, request->excluded_exercise_count); + for (other = 0U; other < request->excluded_pattern_count; ++other) + if (candidate_has_pattern(&request->candidates[index], request->excluded_pattern_ids[other])) + result->candidates[index].excluded = true; + result->qualifying_min[index] = HUGE_VAL; + } + *output = result; + return TRAINLOG_STATUS_OK; +} + +static TrainlogStatus begin_occurrence(TrainlogSessionGenerationAnalyzer *analyzer, + const TrainlogGenerationHistoryRow *row) +{ + size_t index; + finish_occurrence(analyzer); + if (!copy_text(analyzer->current_occurrence, sizeof(analyzer->current_occurrence), row->occurrence_id) || + !copy_text(analyzer->current_occurrence_session, sizeof(analyzer->current_occurrence_session), row->session_id) || + row->exercise_id == NULL || + !copy_text(analyzer->current_exercise, sizeof(analyzer->current_exercise), row->exercise_id)) + return TRAINLOG_STATUS_DATABASE_ERROR; + analyzer->current_occurrence_time = duplicate_text(row->started_at); + if (analyzer->current_occurrence_time == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR; + if (!trainlog_timestamp_parse(analyzer->current_occurrence_time, + strlen(analyzer->current_occurrence_time), &analyzer->current_occurrence_key)) + return TRAINLOG_STATUS_DATABASE_ERROR; + analyzer->current_has_equipment = row->equipment_id != NULL; + if (analyzer->current_has_equipment && + !copy_text(analyzer->current_equipment, sizeof(analyzer->current_equipment), row->equipment_id)) + return TRAINLOG_STATUS_DATABASE_ERROR; + analyzer->current_load_mode = row->load_mode; + analyzer->current_rest = row->rest_seconds; + analyzer->current_has_targets = row->has_target_sets || row->has_target_reps || + row->has_target_duration || row->has_target_weight; + analyzer->occurrence_open = true; + for (index = 0U; index < analyzer->request.candidate_count; ++index) { + analyzer->qualifying_sets[index] = 0U; + analyzer->qualifying_min[index] = HUGE_VAL; + } + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_session_generation_analyzer_accept( + TrainlogSessionGenerationAnalyzer *analyzer, const TrainlogGenerationHistoryRow *row) +{ + TrainlogTimestampKey key; + const TrainlogExerciseKnowledge *knowledge; + const TrainlogKnowledgeInterpretation *interpretation; + bool primary = false, secondary = false, in24, in72; + size_t index; + if (analyzer == NULL || row == NULL || analyzer->failed || row->session_id == NULL || + row->occurrence_id == NULL || row->exercise_id == NULL || row->started_at == NULL || + !trainlog_timestamp_parse(row->started_at, strlen(row->started_at), &key)) { + if (analyzer != NULL) analyzer->failed = true; + return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (analyzer->current_session[0] == '\0' || strcmp(analyzer->current_session, row->session_id) != 0) { + finish_session(analyzer); + if (!copy_text(analyzer->current_session, sizeof(analyzer->current_session), row->session_id)) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + } + if (!analyzer->occurrence_open || strcmp(analyzer->current_occurrence, row->occurrence_id) != 0) { + TrainlogStatus status = begin_occurrence(analyzer, row); + if (status != TRAINLOG_STATUS_OK) { analyzer->failed = true; return status; } + } else if (strcmp(analyzer->current_occurrence_session, row->session_id) != 0 || + strcmp(analyzer->current_exercise, row->exercise_id) != 0 || + trainlog_timestamp_compare(&analyzer->current_occurrence_key, &key) != 0) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (row->has_explicit_max && row->equipment_id != NULL && + trainlog_timestamp_compare(&key, &analyzer->reference) <= 0) { + for (index = 0U; index < analyzer->request.candidate_count; ++index) { + CandidateState *state = &analyzer->candidates[index]; + if (strcmp(row->exercise_id, state->candidate.exercise_id) == 0 && + strcmp(row->equipment_id, state->candidate.equipment_id) == 0) + state->has_compatible_explicit_max = true; + } + } + if (!row->has_actual_set) return TRAINLOG_STATUS_OK; + if (analyzer->have_previous_set && analyzer->previous_set_position == row->set_position) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + analyzer->have_previous_set = true; + analyzer->previous_set_position = row->set_position; + if (row->recording_mode != TRAINLOG_RECORDING_SETS || row->tracking_mode != TRAINLOG_TRACKING_REPS || + row->repetitions <= 0 || trainlog_timestamp_compare(&key, &analyzer->reference) > 0) return TRAINLOG_STATUS_OK; + knowledge = trainlog_exercise_knowledge_lookup(row->exercise_id); + interpretation = knowledge == NULL ? NULL : knowledge->interpretation; + if (interpretation == NULL) { + ++analyzer->exposure.unclassified_actual_set_count; + } else { + primary = zone_matches(interpretation->primary_zone_id, analyzer->request.zone_id); + secondary = !primary && interpretation_secondary_matches(interpretation, analyzer->request.zone_id); + } + in24 = nonfuture_within(&key, &analyzer->reference, + trainlog_session_generation_policy_v1.short_window_seconds, false); + in72 = nonfuture_within(&key, &analyzer->reference, + trainlog_session_generation_policy_v1.long_window_seconds, false); + if (primary || secondary) { + if (in24) { + if (primary) ++analyzer->exposure.within_24h.primary_set_count; else ++analyzer->exposure.within_24h.secondary_set_count; + analyzer->current_session_matches_24 = true; + if (!add_interpretation_patterns(analyzer->exposure.within_24h.pattern_ids, + &analyzer->exposure.within_24h.pattern_count, interpretation->pattern_ids)) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + } + if (in72) { + if (primary) ++analyzer->exposure.within_72h.primary_set_count; else ++analyzer->exposure.within_72h.secondary_set_count; + analyzer->current_session_matches_72 = true; + if (!add_interpretation_patterns(analyzer->exposure.within_72h.pattern_ids, + &analyzer->exposure.within_72h.pattern_count, interpretation->pattern_ids)) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + } + if (!analyzer->have_latest_key || trainlog_timestamp_compare(&key, + &analyzer->latest_key) > 0 || + (trainlog_timestamp_compare(&key, &analyzer->latest_key) == 0 && + (strcmp(row->session_id, analyzer->exposure.latest_session_id) > 0 || + (strcmp(row->session_id, analyzer->exposure.latest_session_id) == 0 && + strcmp(row->occurrence_id, analyzer->exposure.latest_occurrence_id) > 0)))) { + analyzer->exposure.has_latest = true; + analyzer->have_latest_key = true; + analyzer->exposure.latest_pattern_count = 0U; + if (!copy_text(analyzer->exposure.latest_started_at, sizeof(analyzer->exposure.latest_started_at), row->started_at) || + !copy_text(analyzer->exposure.latest_session_id, sizeof(analyzer->exposure.latest_session_id), row->session_id) || + !copy_text(analyzer->exposure.latest_occurrence_id, sizeof(analyzer->exposure.latest_occurrence_id), row->occurrence_id)) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + if (!trainlog_timestamp_parse(analyzer->exposure.latest_started_at, + strlen(analyzer->exposure.latest_started_at), &analyzer->latest_key)) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + } + if (analyzer->have_latest_key && trainlog_timestamp_compare(&key, &analyzer->latest_key) == 0 && + strcmp(row->session_id, analyzer->exposure.latest_session_id) == 0 && + strcmp(row->occurrence_id, analyzer->exposure.latest_occurrence_id) == 0 && + !add_interpretation_patterns(analyzer->exposure.latest_pattern_ids, + &analyzer->exposure.latest_pattern_count, interpretation->pattern_ids)) { + analyzer->failed = true; return TRAINLOG_STATUS_DATABASE_ERROR; + } + } + for (index = 0U; index < analyzer->request.candidate_count; ++index) { + CandidateState *state = &analyzer->candidates[index]; + bool cp = interpretation != NULL && strcmp(interpretation->primary_zone_id, + state->candidate.primary_zone_id) == 0; + bool cs = !cp && interpretation != NULL && interpretation_secondary_matches( + interpretation, state->candidate.primary_zone_id); + bool secondary_primary = false; + bool secondary_match = interpretation != NULL && interpretation_matches_any_zone( + interpretation, state->candidate.secondary_zone_ids, + state->candidate.secondary_zone_count, &secondary_primary); + if (in24) { if (cp) ++state->primary_24; else if (cs) ++state->secondary_24; } + if (in72) { if (cp) ++state->primary_72; else if (cs) ++state->secondary_72; } + if (secondary_match && in24) { + if (secondary_primary) ++state->secondary_zone_primary_24; + else ++state->secondary_zone_secondary_24; + } + if (secondary_match && in72) { + if (secondary_primary) ++state->secondary_zone_primary_72; + else ++state->secondary_zone_secondary_72; + } + if (strcmp(row->exercise_id, state->candidate.exercise_id) == 0 && + nonfuture_within(&key, &analyzer->reference, + trainlog_session_generation_policy_v1.exercise_recency_seconds, false)) state->recent_exercise = true; + if (interpretation != NULL && interpretation_pattern_intersects(interpretation, &state->candidate) && + nonfuture_within(&key, &analyzer->reference, + trainlog_session_generation_policy_v1.pattern_recency_seconds, false)) state->recent_pattern = true; + if (row->has_weight && isfinite(row->weight_kg) && row->weight_kg > 0.0 && + row->repetitions >= analyzer->goal->repetitions && + strcmp(row->exercise_id, state->candidate.exercise_id) == 0 && row->equipment_id != NULL && + strcmp(row->equipment_id, state->candidate.equipment_id) == 0) { + ++analyzer->qualifying_sets[index]; + if (row->weight_kg < analyzer->qualifying_min[index]) analyzer->qualifying_min[index] = row->weight_kg; + } + } + return TRAINLOG_STATUS_OK; +} + +static bool pattern_overlap(const TrainlogGeneratedSession *output, + const TrainlogGenerationCandidate *candidate) +{ + size_t selected, left, right; + for (selected = 0U; selected < output->exercise_count; ++selected) + for (left = 0U; left < output->exercises[selected].pattern_count; ++left) + for (right = 0U; right < candidate->pattern_count; ++right) + if (strcmp(output->exercises[selected].pattern_ids[left], candidate->pattern_ids[right]) == 0) return true; + return false; +} + +static bool output_has_primary(const TrainlogGeneratedSession *output, const char *zone) +{ + size_t index; + for (index = 0U; index < output->exercise_count; ++index) + if (strcmp(output->exercises[index].primary_zone_id, zone) == 0) return true; + return false; +} + +static int candidate_score(const TrainlogSessionGenerationAnalyzer *analyzer, size_t index, + const TrainlogGeneratedSession *output) +{ + const CandidateState *state = &analyzer->candidates[index]; + const TrainlogSessionGenerationScores *s = &trainlog_session_generation_policy_v1.scores; + int score = zone_matches(state->candidate.primary_zone_id, analyzer->request.zone_id) + ? s->requested_primary : s->requested_secondary; + if (!output_has_primary(output, state->candidate.primary_zone_id)) score += s->new_primary; + if (!pattern_overlap(output, &state->candidate)) score += s->new_pattern; + if (state->has_anchor) score += s->load_history; + if (state->preferred) score += s->preferred; + if (state->recent_exercise) score += s->recent_exercise; + if (state->recent_pattern) score += s->recent_pattern; + if (state->primary_24 >= (size_t)trainlog_session_generation_policy_v1.primary_24h) + score += s->exposure_primary_24h; + if (state->secondary_24 >= (size_t)trainlog_session_generation_policy_v1.secondary_24h) + score += s->exposure_secondary_24h; + if (state->primary_72 >= (size_t)trainlog_session_generation_policy_v1.primary_72h) + score += s->exposure_primary_72h; + if (state->secondary_72 >= (size_t)trainlog_session_generation_policy_v1.secondary_72h) + score += s->exposure_secondary_72h; + if (state->secondary_zone_primary_24 >= (size_t)trainlog_session_generation_policy_v1.primary_24h || + state->secondary_zone_secondary_24 >= (size_t)trainlog_session_generation_policy_v1.secondary_24h || + state->secondary_zone_primary_72 >= (size_t)trainlog_session_generation_policy_v1.primary_72h || + state->secondary_zone_secondary_72 >= (size_t)trainlog_session_generation_policy_v1.secondary_72h) + score += s->secondary_zone_exposure; + return score; +} + +static bool candidate_has_any_pattern(const TrainlogGenerationCandidate *candidate, + const char *patterns) +{ + size_t index; + for (index = 0U; index < candidate->pattern_count; ++index) + if (newline_list_has(patterns, candidate->pattern_ids[index])) return true; + return false; +} + +static int candidate_coverage_priority(const TrainlogSessionGenerationAnalyzer *analyzer, + const TrainlogGeneratedSession *output, const TrainlogGenerationCandidate *candidate) +{ + const TrainlogSessionGenerationPolicy *policy = &trainlog_session_generation_policy_v1; + bool represented_a = false, represented_b = false; + size_t index, pattern; + if (strcmp(analyzer->request.zone_id, "full_body") == 0) { + const char *region = strcmp(candidate->primary_zone_id, "core") == 0 ? "core" : + (strcmp(candidate->primary_zone_id, "chest") == 0 || strcmp(candidate->primary_zone_id, "back") == 0 || + strcmp(candidate->primary_zone_id, "shoulders") == 0 || strcmp(candidate->primary_zone_id, "arms") == 0) ? "upper" : "lower"; + for (index = 0U; index < output->exercise_count; ++index) { + const char *selected = output->exercises[index].primary_zone_id; + const char *selected_region = strcmp(selected, "core") == 0 ? "core" : + (strcmp(selected, "chest") == 0 || strcmp(selected, "back") == 0 || + strcmp(selected, "shoulders") == 0 || strcmp(selected, "arms") == 0) ? "upper" : "lower"; + if (strcmp(region, selected_region) == 0) return 0; + } + return 1; + } + for (index = 0U; index < output->exercise_count; ++index) { + bool has_a = false, has_b = false; + for (pattern = 0U; pattern < output->exercises[index].pattern_count; ++pattern) { + const char *id = output->exercises[index].pattern_ids[pattern]; + if (strcmp(analyzer->request.zone_id, "upper_body") == 0) { + has_a = has_a || newline_list_has(policy->upper_push_patterns, id); + has_b = has_b || newline_list_has(policy->upper_pull_patterns, id); + } else if (strcmp(analyzer->request.zone_id, "lower_body") == 0) { + has_a = has_a || newline_list_has(policy->lower_extension_patterns, id); + has_b = has_b || newline_list_has(policy->lower_flexion_patterns, id); + } + } + represented_a = represented_a || has_a; + represented_b = represented_b || has_b; + } + if (strcmp(analyzer->request.zone_id, "upper_body") == 0) + return ((!represented_a && candidate_has_any_pattern(candidate, policy->upper_push_patterns)) || + (!represented_b && candidate_has_any_pattern(candidate, policy->upper_pull_patterns))) ? 1 : 0; + if (strcmp(analyzer->request.zone_id, "lower_body") == 0) + return ((!represented_a && candidate_has_any_pattern(candidate, policy->lower_extension_patterns)) || + (!represented_b && candidate_has_any_pattern(candidate, policy->lower_flexion_patterns))) ? 1 : 0; + return 0; +} + +static bool candidate_precedes_on_tie(const CandidateState *left, const CandidateState *right) +{ + int exercise_order = strcmp(left->candidate.exercise_id, right->candidate.exercise_id); + if (exercise_order != 0) return exercise_order < 0; + /* CONTRACT: equipment choice for one exercise prefers the newest qualifying + * actual anchor. MAX never reaches CandidateState and cannot break this tie. */ + if (left->has_anchor != right->has_anchor) return left->has_anchor; + if (left->has_anchor) { + int time_order = trainlog_timestamp_compare(&left->anchor_time, &right->anchor_time); + if (time_order != 0) return time_order > 0; + } + return strcmp(left->candidate.equipment_id, right->candidate.equipment_id) < 0; +} + +static int exercise_seconds(const TrainlogSessionGenerationAnalyzer *analyzer) +{ + int64_t result = trainlog_session_generation_policy_v1.setup_seconds + + (int64_t)analyzer->goal->sets * analyzer->goal->repetitions * + trainlog_session_generation_policy_v1.repetition_seconds + + (int64_t)(analyzer->goal->sets - 1) * analyzer->goal->rest_seconds; + return result > INT_MAX ? INT_MAX : (int)result; +} + +TrainlogStatus trainlog_session_generation_analyzer_finish( + TrainlogSessionGenerationAnalyzer *analyzer, TrainlogGeneratedSession *output) +{ + bool used[TRAINLOG_GENERATOR_MAX_CANDIDATES] = {false}; + int budget, per_exercise; + size_t index; + if (analyzer == NULL || output == NULL || analyzer->failed) return TRAINLOG_STATUS_DATABASE_ERROR; + finish_occurrence(analyzer); finish_session(analyzer); + if (analyzer->failed) return TRAINLOG_STATUS_DATABASE_ERROR; + (void)memset(output, 0, sizeof(*output)); + output->exposure = analyzer->exposure; + output->exposure.recent_exposure = output->exposure.within_24h.primary_set_count >= (size_t)trainlog_session_generation_policy_v1.primary_24h || + output->exposure.within_24h.secondary_set_count >= (size_t)trainlog_session_generation_policy_v1.secondary_24h; + output->exposure.repeated_exposure = output->exposure.within_72h.primary_set_count >= (size_t)trainlog_session_generation_policy_v1.primary_72h || + output->exposure.within_72h.secondary_set_count >= (size_t)trainlog_session_generation_policy_v1.secondary_72h; + if (output->exposure.within_24h.primary_set_count >= (size_t)trainlog_session_generation_policy_v1.primary_24h || + output->exposure.within_72h.primary_set_count >= (size_t)trainlog_session_generation_policy_v1.primary_72h) + output->exposure.warning_level = TRAINLOG_GENERATION_WARNING_WARNING; + else if (output->exposure.within_24h.secondary_set_count >= (size_t)trainlog_session_generation_policy_v1.secondary_24h || + output->exposure.within_72h.secondary_set_count >= (size_t)trainlog_session_generation_policy_v1.secondary_72h) + output->exposure.warning_level = TRAINLOG_GENERATION_WARNING_NOTICE; + budget = analyzer->request.duration_minutes * 60; + output->estimated_duration_seconds = trainlog_session_generation_policy_v1.preparation_seconds; + per_exercise = exercise_seconds(analyzer); + while (output->exercise_count < (size_t)trainlog_session_generation_policy_v1.max_exercises && + output->exercise_count < TRAINLOG_GENERATOR_MAX_SELECTED) { + size_t best = SIZE_MAX; + int best_score = INT_MIN; + int best_priority = INT_MIN; + for (index = 0U; index < analyzer->request.candidate_count; ++index) { + CandidateState *state = &analyzer->candidates[index]; + int score; + if (used[index] || state->excluded || pattern_overlap(output, &state->candidate) || + output->estimated_duration_seconds > budget - per_exercise || + !candidate_matches_zone(&state->candidate, analyzer->request.zone_id)) continue; + score = candidate_score(analyzer, index, output); + { + int priority = candidate_coverage_priority(analyzer, output, &state->candidate); + if (best == SIZE_MAX || priority > best_priority || + (priority == best_priority && (score > best_score || + (score == best_score && candidate_precedes_on_tie( + state, &analyzer->candidates[best]))))) { + best = index; best_score = score; best_priority = priority; + } + } + } + if (best == SIZE_MAX) break; + { + CandidateState *state = &analyzer->candidates[best]; + TrainlogGeneratedExercise *generated = &output->exercises[output->exercise_count]; + if (!copy_text(generated->exercise_id, sizeof(generated->exercise_id), state->candidate.exercise_id) || + !copy_text(generated->equipment_id, sizeof(generated->equipment_id), state->candidate.equipment_id) || + !copy_text(generated->primary_zone_id, sizeof(generated->primary_zone_id), state->candidate.primary_zone_id)) + return TRAINLOG_STATUS_DATABASE_ERROR; + generated->target_sets = analyzer->goal->sets; + generated->target_repetitions = analyzer->goal->repetitions; + generated->rest_seconds = analyzer->goal->rest_seconds; + generated->planned_load_mode = state->has_anchor ? TRAINLOG_LOAD_EXTERNAL : TRAINLOG_LOAD_NONE; + generated->recency.recent_same_exercise = state->recent_exercise; + generated->recency.recent_same_pattern = state->recent_pattern; + generated->exposure_warning_level = output->exposure.warning_level; + generated->estimated_seconds = per_exercise; + if (!copy_text(generated->equipment_load_semantics, + sizeof(generated->equipment_load_semantics), state->candidate.equipment_load_semantics) || + !copy_text(generated->confidence, sizeof(generated->confidence), state->candidate.confidence)) + return TRAINLOG_STATUS_DATABASE_ERROR; + for (index = 0U; index < state->candidate.secondary_zone_count; ++index) + if (!copy_text(generated->secondary_zone_ids[index], + sizeof(generated->secondary_zone_ids[index]), state->candidate.secondary_zone_ids[index])) + return TRAINLOG_STATUS_DATABASE_ERROR; + generated->secondary_zone_count = state->candidate.secondary_zone_count; + for (index = 0U; index < state->candidate.pattern_count; ++index) { + if (!copy_text(generated->pattern_ids[index], sizeof(generated->pattern_ids[index]), + state->candidate.pattern_ids[index])) return TRAINLOG_STATUS_DATABASE_ERROR; + } + generated->pattern_count = state->candidate.pattern_count; + for (index = 0U; index < state->candidate.source_ref_count; ++index) { + if (!copy_text(generated->source_ref_ids[index], sizeof(generated->source_ref_ids[index]), + state->candidate.source_ref_ids[index])) return TRAINLOG_STATUS_DATABASE_ERROR; + } + generated->source_ref_count = state->candidate.source_ref_count; + if (state->has_anchor) { + generated->has_target_weight = true; + generated->target_weight_kg = state->anchor_weight; + (void)strcpy(generated->load_source_session_id, state->anchor_session); + (void)strcpy(generated->load_source_occurrence_id, state->anchor_occurrence); + (void)strcpy(generated->load_source_started_at, state->anchor_time_text); + (void)strcpy(generated->rationale_codes[generated->rationale_count++], "observed_repeated_dose_anchor"); + } else if (state->has_compatible_explicit_max) { + (void)strcpy(generated->rationale_codes[generated->rationale_count++], + "explicit_max_present_no_numeric_prescription"); + } else if (strcmp(state->candidate.equipment_load_semantics, "assistance") == 0) { + (void)strcpy(generated->rationale_codes[generated->rationale_count++], "assistance_numeric_load_omitted"); + } else { + (void)strcpy(generated->rationale_codes[generated->rationale_count++], "numeric_load_absent"); + } + if (state->preferred) + (void)strcpy(generated->rationale_codes[generated->rationale_count++], "preferred_exercise"); + (void)strcpy(generated->rationale_codes[generated->rationale_count++], + zone_matches(state->candidate.primary_zone_id, analyzer->request.zone_id) ? + "requested_primary_zone" : "requested_secondary_zone"); + (void)strcpy(generated->rationale_codes[generated->rationale_count++], + strcmp(state->candidate.equipment_load_semantics, "external") == 0 ? + "external_equipment_context" : "non_external_equipment_context"); + (void)strcpy(generated->rationale_codes[generated->rationale_count++], "new_exact_pattern"); + if (state->recent_exercise) + (void)strcpy(generated->rationale_codes[generated->rationale_count++], "recent_same_exercise_penalty"); + if (state->recent_pattern) + (void)strcpy(generated->rationale_codes[generated->rationale_count++], "recent_same_pattern_penalty"); + used[best] = true; + ++output->exercise_count; + output->estimated_duration_seconds += per_exercise; + } + } + output->insufficient_resolved_candidates = output->exercise_count == 0U || + output->exercise_count < (size_t)trainlog_session_generation_policy_v1.max_exercises; + if (output->insufficient_resolved_candidates) { + bool upper = false, lower = false, core = false; + bool push = false, pull = false, extension = false, flexion = false; + size_t selected, pattern; + (void)strcpy(output->shortage_codes[output->shortage_count++], + "insufficient_resolved_candidates"); + for (selected = 0U; selected < output->exercise_count; ++selected) { + const char *zone = output->exercises[selected].primary_zone_id; + core = core || strcmp(zone, "core") == 0; + upper = upper || strcmp(zone, "chest") == 0 || strcmp(zone, "back") == 0 || + strcmp(zone, "shoulders") == 0 || strcmp(zone, "arms") == 0; + lower = lower || strcmp(zone, "glutes") == 0 || strcmp(zone, "thighs") == 0 || + strcmp(zone, "calves") == 0; + for (pattern = 0U; pattern < output->exercises[selected].pattern_count; ++pattern) { + const char *id = output->exercises[selected].pattern_ids[pattern]; + push = push || newline_list_has(trainlog_session_generation_policy_v1.upper_push_patterns, id); + pull = pull || newline_list_has(trainlog_session_generation_policy_v1.upper_pull_patterns, id); + extension = extension || newline_list_has(trainlog_session_generation_policy_v1.lower_extension_patterns, id); + flexion = flexion || newline_list_has(trainlog_session_generation_policy_v1.lower_flexion_patterns, id); + } + } + if (strcmp(analyzer->request.zone_id, "full_body") == 0) { + if (!upper) (void)strcpy(output->shortage_codes[output->shortage_count++], "missing_upper_region"); + if (!lower) (void)strcpy(output->shortage_codes[output->shortage_count++], "missing_lower_region"); + if (!core) (void)strcpy(output->shortage_codes[output->shortage_count++], "missing_core_region"); + } else if (strcmp(analyzer->request.zone_id, "upper_body") == 0) { + if (!push) (void)strcpy(output->shortage_codes[output->shortage_count++], "missing_upper_push"); + if (!pull) (void)strcpy(output->shortage_codes[output->shortage_count++], "missing_upper_pull"); + } else if (strcmp(analyzer->request.zone_id, "lower_body") == 0) { + if (!extension) (void)strcpy(output->shortage_codes[output->shortage_count++], "missing_lower_extension"); + if (!flexion) (void)strcpy(output->shortage_codes[output->shortage_count++], "missing_lower_flexion"); + } + } + return TRAINLOG_STATUS_OK; +} + +void trainlog_session_generation_analyzer_destroy(TrainlogSessionGenerationAnalyzer *analyzer) +{ + if (analyzer == NULL) return; + free(analyzer->current_occurrence_time); + free(analyzer->reference_owned); + free(analyzer); +} + +TrainlogStatus trainlog_session_generation_requalify_dose( + const TrainlogGenerationRequest *request, size_t selected_index, int target_sets, + int target_repetitions, TrainlogSessionGenerationAnalyzer **output_analyzer) +{ + TrainlogStatus status; + TrainlogSessionGenerationAnalyzer *result; + TrainlogGenerationRequest selected_request; + if (request == NULL || selected_index >= request->candidate_count || target_sets < 1 || + target_sets > 64 || target_repetitions < 1 || target_repetitions > 10000) + return TRAINLOG_STATUS_INVALID_ARGUMENT; + selected_request = *request; + selected_request.candidates = &request->candidates[selected_index]; + selected_request.candidate_count = 1U; + status = trainlog_session_generation_analyzer_create(&selected_request, &result); + if (status != TRAINLOG_STATUS_OK) return status; + /* A private per-call goal copy avoids mutating generated immutable policy. */ + result->edited_goal = *result->goal; + result->edited_goal.sets = target_sets; + result->edited_goal.repetitions = target_repetitions; + result->goal = &result->edited_goal; + *output_analyzer = result; + return TRAINLOG_STATUS_OK; +} + +TrainlogStatus trainlog_session_generation_exercise_duration( + int target_sets, int target_repetitions, int rest_seconds, int *output_seconds) +{ + int64_t value; + if (output_seconds == NULL || target_sets < 1 || target_sets > 64 || + target_repetitions < 1 || target_repetitions > 10000 || + rest_seconds < 0 || rest_seconds > 86400) return TRAINLOG_STATUS_INVALID_ARGUMENT; + value = (int64_t)trainlog_session_generation_policy_v1.setup_seconds + + (int64_t)target_sets * target_repetitions * trainlog_session_generation_policy_v1.repetition_seconds + + (int64_t)(target_sets - 1) * rest_seconds; + if (value > INT_MAX) return TRAINLOG_STATUS_INVALID_ARGUMENT; + *output_seconds = (int)value; + return TRAINLOG_STATUS_OK; +} + +typedef struct OwnedCandidate { + TrainlogGenerationCandidate value; + char secondary[TRAINLOG_GENERATOR_MAX_ZONES][TRAINLOG_ID_MAX + 1U]; + const char *secondary_ptrs[TRAINLOG_GENERATOR_MAX_ZONES]; + char patterns[TRAINLOG_GENERATOR_MAX_PATTERNS][TRAINLOG_ID_MAX + 1U]; + const char *pattern_ptrs[TRAINLOG_GENERATOR_MAX_PATTERNS]; + char refs[TRAINLOG_GENERATOR_MAX_SOURCE_REFS][TRAINLOG_ID_MAX + 1U]; + const char *ref_ptrs[TRAINLOG_GENERATOR_MAX_SOURCE_REFS]; +} OwnedCandidate; + +static bool split_ids(const char *list, char storage[][TRAINLOG_ID_MAX + 1U], + const char **pointers, size_t capacity, size_t *output_count) +{ + const char *cursor = list; + size_t count = 0U; + while (cursor != NULL && cursor[0] != '\0') { + const char *end = strchr(cursor, '\n'); + size_t length = end == NULL ? strlen(cursor) : (size_t)(end - cursor); + if (count == capacity || length == 0U || length > TRAINLOG_ID_MAX) return false; + (void)memcpy(storage[count], cursor, length); storage[count][length] = '\0'; + pointers[count] = storage[count]; + ++count; + cursor = end == NULL ? NULL : end + 1; + } + *output_count = count; + return true; +} + +static TrainlogStatus build_database_candidates(TrainlogDatabase *database, + const TrainlogGenerationDatabaseRequest *request, TrainlogExercise **exercises_owner, + OwnedCandidate **owned_output, TrainlogGenerationCandidate **values_output, size_t *count_output) +{ + TrainlogExercise *exercises = NULL; + OwnedCandidate *owned = NULL; + TrainlogGenerationCandidate *values = NULL; + size_t exercise_count = 0U, count = 0U, index; + TrainlogStatus status = trainlog_database_list_exercises(database, NULL, 0U, &exercise_count); + if (status != TRAINLOG_STATUS_OK) return status; + if (exercise_count > 0U) { + exercises = calloc(exercise_count, sizeof(*exercises)); + if (exercises == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR; + status = trainlog_database_list_exercises(database, exercises, exercise_count, &exercise_count); + if (status != TRAINLOG_STATUS_OK) { free(exercises); return status; } + } + owned = calloc(TRAINLOG_GENERATOR_MAX_CANDIDATES, sizeof(*owned)); + values = calloc(TRAINLOG_GENERATOR_MAX_CANDIDATES, sizeof(*values)); + if (owned == NULL || values == NULL) { free(exercises); free(owned); free(values); return TRAINLOG_STATUS_SYSTEM_ERROR; } + for (index = 0U; index < exercise_count; ++index) { + const TrainlogExerciseKnowledge *knowledge; + const char *equipment_cursor; + if (exercises[index].recording_mode != TRAINLOG_RECORDING_SETS || + exercises[index].tracking_mode != TRAINLOG_TRACKING_REPS) continue; + knowledge = trainlog_exercise_knowledge_lookup(exercises[index].exercise_id); + if (knowledge == NULL || knowledge->interpretation == NULL || + strcmp(knowledge->resolution_status, "resolved_family_variant_limited") != 0 || + (strcmp(knowledge->confidence, "high") != 0 && strcmp(knowledge->confidence, "moderate") != 0)) continue; + equipment_cursor = knowledge->equipment_ids; + while (equipment_cursor != NULL && equipment_cursor[0] != '\0') { + const char *end = strchr(equipment_cursor, '\n'); + size_t length = end == NULL ? strlen(equipment_cursor) : (size_t)(end - equipment_cursor); + char equipment_id[TRAINLOG_ID_MAX + 1U]; + const TrainlogEquipmentKnowledge *equipment; + OwnedCandidate *item; + if (length == 0U || length > TRAINLOG_ID_MAX) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto fail; } + (void)memcpy(equipment_id, equipment_cursor, length); equipment_id[length] = '\0'; + if (request->available_equipment_ids != NULL && !id_in(equipment_id, + request->available_equipment_ids, request->available_equipment_count)) { + equipment_cursor = end == NULL ? NULL : end + 1; continue; + } + equipment = trainlog_equipment_knowledge_lookup(equipment_id); + if (equipment == NULL || equipment->catalog_load_semantics == NULL) { + equipment_cursor = end == NULL ? NULL : end + 1; continue; + } + if (count == TRAINLOG_GENERATOR_MAX_CANDIDATES) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto fail; } + item = &owned[count]; + item->value.exercise_id = knowledge->exercise_id; + item->value.equipment_id = equipment->equipment_id; + item->value.primary_zone_id = knowledge->interpretation->primary_zone_id; + item->value.confidence = knowledge->confidence; + item->value.equipment_load_semantics = equipment->catalog_load_semantics; + if (!split_ids(knowledge->interpretation->secondary_zone_ids, item->secondary, + item->secondary_ptrs, TRAINLOG_GENERATOR_MAX_ZONES, &item->value.secondary_zone_count) || + !split_ids(knowledge->interpretation->pattern_ids, item->patterns, + item->pattern_ptrs, TRAINLOG_GENERATOR_MAX_PATTERNS, &item->value.pattern_count) || + !split_ids(knowledge->interpretation->source_refs, item->refs, + item->ref_ptrs, TRAINLOG_GENERATOR_MAX_SOURCE_REFS, &item->value.source_ref_count)) { + status = TRAINLOG_STATUS_DATABASE_ERROR; goto fail; + } + item->value.secondary_zone_ids = item->secondary_ptrs; + item->value.pattern_ids = item->pattern_ptrs; + item->value.source_ref_ids = item->ref_ptrs; + values[count] = item->value; + ++count; + equipment_cursor = end == NULL ? NULL : end + 1; + } + } + *exercises_owner = exercises; *owned_output = owned; *values_output = values; *count_output = count; + return TRAINLOG_STATUS_OK; +fail: + free(exercises); free(owned); free(values); return status; +} + +static TrainlogStatus database_accept_visitor(void *context, const TrainlogGenerationHistoryRow *row) +{ + return trainlog_session_generation_analyzer_accept(context, row); +} + +TrainlogStatus trainlog_session_generate_from_database(TrainlogDatabase *database, + const TrainlogGenerationDatabaseRequest *request, TrainlogGeneratedSession *output) +{ + TrainlogExercise *exercises = NULL; + OwnedCandidate *owned = NULL; + TrainlogGenerationCandidate *candidates = NULL; + TrainlogSessionGenerationAnalyzer *analyzer = NULL; + TrainlogGenerationRequest pure; + size_t count = 0U; + TrainlogStatus status, end_status; + if (database == NULL || request == NULL || output == NULL) return TRAINLOG_STATUS_INVALID_ARGUMENT; + status = trainlog_database_read_snapshot_begin(database); + if (status != TRAINLOG_STATUS_OK) return status; + status = build_database_candidates(database, request, &exercises, &owned, &candidates, &count); + if (status == TRAINLOG_STATUS_OK) { + (void)memset(&pure, 0, sizeof(pure)); + pure.zone_id = request->zone_id; pure.goal_id = request->goal_id; + pure.duration_minutes = request->duration_minutes; pure.reference_time = request->reference_time; + pure.candidates = candidates; pure.candidate_count = count; + pure.preferred_exercise_ids = request->preferred_exercise_ids; pure.preferred_count = request->preferred_count; + pure.excluded_exercise_ids = request->excluded_exercise_ids; pure.excluded_exercise_count = request->excluded_exercise_count; + pure.excluded_pattern_ids = request->excluded_pattern_ids; pure.excluded_pattern_count = request->excluded_pattern_count; + status = trainlog_session_generation_analyzer_create(&pure, &analyzer); + } + if (status == TRAINLOG_STATUS_OK) + status = trainlog_database_scan_generation_history(database, database_accept_visitor, analyzer); + if (status == TRAINLOG_STATUS_OK) + status = trainlog_session_generation_analyzer_finish(analyzer, output); + trainlog_session_generation_analyzer_destroy(analyzer); + free(candidates); free(owned); free(exercises); + end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK); + return status == TRAINLOG_STATUS_OK ? end_status : status; +} diff --git a/tui/src/sync.c b/tui/src/sync.c index 02cc453..479ce53 100644 --- a/tui/src/sync.c +++ b/tui/src/sync.c @@ -29,13 +29,16 @@ #define SYNC_REQUEST_TEXT_MAX 4095U static const char *const MOBILE_EXPORT_NAME = + "trainlog-mobile-export-v3.json"; + +static const char *const MOBILE_EXPORT_V2_NAME = "trainlog-mobile-export-v2.json"; static const char *const MOBILE_EXPORT_V1_NAME = "trainlog-mobile-export-v1.json"; static const char *const PC_MOBILE_EXPORT_NAME = - "trainlog-pc-mobile-export-v2.json"; + "trainlog-pc-mobile-export-v3.json"; static const char *const PC_CATALOG_NAME = "trainlog-pc-catalog-v1.json"; @@ -59,10 +62,10 @@ static const char *const SYNC_RECEIPT_NAME = "trainlog-sync-receipt-v1.json"; static const char *const MOBILE_EXPORT_LOCAL = - "/tmp/trainlog-mobile-export-v2.json"; + "/tmp/trainlog-mobile-export-v3.json"; static const char *const PC_MOBILE_EXPORT_LOCAL = - "/tmp/trainlog-pc-mobile-export-v2.json"; + "/tmp/trainlog-pc-mobile-export-v3.json"; static const char *const PC_CATALOG_LOCAL = "/tmp/trainlog-pc-catalog-v1.json"; @@ -2614,7 +2617,7 @@ TrainlogStatus trainlog_sync_run( bool silence_active = false; bool run_started = false; bool receipt_published = false; - bool mobile_export_is_v2 = false; + bool mobile_export_has_companions = false; if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC || direction > TRAINLOG_SYNC_BIDIRECTIONAL || @@ -2840,7 +2843,7 @@ TrainlogStatus trainlog_sync_run( goto outbound; } - /* Definitions must reconcile before either v2 reference artifact. A + /* Definitions must reconcile before either V3/V2 reference artifact. A * missing file is accepted only for historic snapshots with no custom ID. */ status = sync_receive_current_android_artifact( &device, @@ -2886,14 +2889,16 @@ TrainlogStatus trainlog_sync_run( MOBILE_EXPORT_LOCAL, &ignored_size ); - mobile_export_is_v2 = status == TRAINLOG_STATUS_OK; + if (status == TRAINLOG_STATUS_NOT_FOUND) { + /* CONTRACT: selected V3 content is authoritative. Only absence permits + * V2 selection; an invalid V3 is imported and fails without fallback. */ + status = sync_receive_current_android_artifact( + &device, folder_id, MOBILE_EXPORT_V2_NAME, + MOBILE_EXPORT_LOCAL, &ignored_size); + } + mobile_export_has_companions = status == TRAINLOG_STATUS_OK; - if ( - status != - TRAINLOG_STATUS_OK - ) { - /* Explicit historic fallback only: a V1 file is never mistaken for - * V2, and V2 remains the default path for all current Android apps. */ + if (status == TRAINLOG_STATUS_NOT_FOUND) { status = sync_receive_named(&device, folder_id, MOBILE_EXPORT_V1_NAME, MOBILE_EXPORT_LOCAL, &ignored_size); } @@ -2957,11 +2962,11 @@ TrainlogStatus trainlog_sync_run( ); /* CONTRACT: body zones are one directional-neutral companion. Historic - * V2 publishers may omit it; when present it is applied only after the + * V2 publishers may omit it; when present beside V3/V2 it is applied after * exercise definitions above established every ID or a source-V2-proven * normalized-name alias. The helper consumes the retained mobile snapshot * solely as reconciliation proof; it never infers identity from a name. */ - status = mobile_export_is_v2 + status = mobile_export_has_companions ? sync_receive_current_android_artifact(&device, folder_id, EXERCISE_BODY_ZONES_NAME, EXERCISE_BODY_ZONES_LOCAL, &ignored_size) : TRAINLOG_STATUS_NOT_FOUND; @@ -2986,12 +2991,12 @@ TrainlogStatus trainlog_sync_run( goto finalize; } - /* V1 exports carry no equipment signal. A V2 companion beside a historic + /* V1 exports carry no equipment signal. A V3/V2 companion beside a historic * V1 snapshot belongs to another generation and must not be applied. Its * absence therefore preserves existing associations rather than clearing - * them. Scoped storage may suffix every V2 Android publication, so the V2 + * them. Scoped storage may suffix every current Android publication, so the * path selects its newest candidate instead of an older canonical object. */ - status = mobile_export_is_v2 + status = mobile_export_has_companions ? sync_receive_current_android_artifact( &device, folder_id, @@ -3156,13 +3161,13 @@ outbound: database_path, PC_CATALOG_RESULT, tool_output, sizeof(tool_output)); if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) { - (void)snprintf(output->error, sizeof(output->error), "PC→Android : export séances V2 échoué."); + (void)snprintf(output->error, sizeof(output->error), "PC→Android : export séances V3 échoué."); final_status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finalize; } status = sync_publish_named(&device, folder_id, PC_MOBILE_EXPORT_LOCAL, PC_MOBILE_EXPORT_NAME); if (status != TRAINLOG_STATUS_OK) { - (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication séances V2 échouée."); + (void)snprintf(output->error, sizeof(output->error), "PC→Android : publication séances V3 échouée."); final_status = status; goto finalize; } diff --git a/tui/src/tui.c b/tui/src/tui.c index 8be5a90..d11e32a 100644 --- a/tui/src/tui.c +++ b/tui/src/tui.c @@ -38,6 +38,8 @@ #include "trainlog/sync_history.h" #include "trainlog/sync_screen_action.h" #include "trainlog/reps.h" +#include "trainlog/session_generation.h" +#include "trainlog/session_generation_policy_internal.h" #include "trainlog/theme.h" #include "trainlog/timeutil.h" #include "trainlog/training_knowledge.h" @@ -72,6 +74,7 @@ static TrainlogTerminal *tui_terminal; typedef enum DashboardAction { DASHBOARD_NEW_SESSION = 0, + DASHBOARD_GENERATE_SESSION, DASHBOARD_HISTORY, DASHBOARD_EXERCISES, DASHBOARD_EQUIPMENT, @@ -5491,7 +5494,7 @@ static DashboardAction screen_dashboard( ) { static const char *const footer = - "←→ naviguer Entrée ouvrir 0 accueil F1-F5 accès direct q quitter"; + "←→ naviguer Entrée ouvrir g générer une séance q quitter"; int selected = 0; @@ -5537,6 +5540,10 @@ static DashboardAction screen_dashboard( int width = (int)strlen(primary_nav_labels[index]) + 4; + if (!large_layout && index != selected) { + continue; + } + if (index == selected) { trainlog_terminal_style_on(tui_terminal, TRAINLOG_TEXT_REVERSE | @@ -5551,7 +5558,7 @@ static DashboardAction screen_dashboard( ? nav_top + 1 : trainlog_terminal_rows(tui_terminal) - 3, column, - " %s ", + large_layout ? " %s " : " ← %s → ", primary_nav_labels[index] ); @@ -5638,6 +5645,10 @@ static DashboardAction screen_dashboard( case '1': return DASHBOARD_NEW_SESSION; + case 'g': + case 'G': + return DASHBOARD_GENERATE_SESSION; + case TRAINLOG_KEY_F2: case '2': return DASHBOARD_HISTORY; @@ -6866,6 +6877,19 @@ static void draft_set_summary( sizeof(duration) ); (void)snprintf(output, output_size, "Continu · %s", duration); + } else if (draft->input.set_count == 0U && + draft->input.target_sets > 0 && draft->input.target_reps > 0) { + if (draft->input.target_has_weight) { + (void)snprintf(output, output_size, + "Plan %d×%d · %.2f kg · repos %d s · 0 réalisée", + draft->input.target_sets, draft->input.target_reps, + draft->input.target_weight_kg, draft->input.rest_seconds); + } else { + (void)snprintf(output, output_size, + "Plan %d×%d · charge absente · repos %d s · 0 réalisée", + draft->input.target_sets, draft->input.target_reps, + draft->input.rest_seconds); + } } else { int written = snprintf(output, output_size, "%zu séries · ", draft->input.set_count); @@ -7472,6 +7496,258 @@ static bool persist_draft_replacement( ) == TRAINLOG_STATUS_OK; } +typedef struct TrainlogGeneratorPreviewItem { + char exercise_name[TRAINLOG_NAME_MAX + 1U]; + char equipment_name[TRAINLOG_NAME_MAX + 1U]; + char zone_name[TRAINLOG_NAME_MAX + 1U]; + char movement_name[TRAINLOG_NAME_MAX + 1U]; +} TrainlogGeneratorPreviewItem; + +static const char *generator_goal_label(const char *goal_id) +{ + if (strcmp(goal_id, "general") == 0) return "Général"; + if (strcmp(goal_id, "strength") == 0) return "Force"; + if (strcmp(goal_id, "hypertrophy") == 0) return "Hypertrophie"; + if (strcmp(goal_id, "endurance") == 0) return "Endurance"; + return goal_id; +} + +static bool generator_choose_zone(const TrainlogBodyZone **output) +{ + size_t selected = 0U; + size_t count = trainlog_body_zone_catalog_count(); + if (output == NULL || count == 0U || count > MAX_BODY_ZONES) return false; + for (;;) { + size_t index; + int key; + draw_shell("Générer une séance — zone", "↑↓ choisir Entrée continuer q annuler"); + for (index = 0U; index < count; ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); + if (index == selected) trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_printf(tui_terminal, 3 + (int)index, 4, + " %c %-40.40s ", index == selected ? '>' : ' ', + zone != NULL ? zone->display_name : "?"); + if (index == selected) trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + } + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; + if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : count - 1U; + else if (key == TRAINLOG_KEY_DOWN) selected = selected + 1U < count ? selected + 1U : 0U; + else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { + *output = trainlog_body_zone_catalog_at(selected); + return *output != NULL; + } + } +} + +static bool generator_choose_goal(const TrainlogSessionGenerationGoalPolicy **output) +{ + size_t selected = 0U; + size_t count = trainlog_session_generation_policy_v1.goal_count; + if (output == NULL || count == 0U) return false; + for (;;) { + size_t index; + int key; + draw_shell("Générer une séance — objectif", "↑↓ choisir Entrée continuer q annuler"); + for (index = 0U; index < count; ++index) { + const TrainlogSessionGenerationGoalPolicy *goal = + &trainlog_session_generation_policy_v1.goals[index]; + if (index == selected) trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_printf(tui_terminal, 4 + (int)index, 4, + " %c %-18.18s %d × %d repos %d s ", + index == selected ? '>' : ' ', generator_goal_label(goal->id), + goal->sets, goal->repetitions, goal->rest_seconds); + if (index == selected) trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + } + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; + if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : count - 1U; + else if (key == TRAINLOG_KEY_DOWN) selected = selected + 1U < count ? selected + 1U : 0U; + else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { + *output = &trainlog_session_generation_policy_v1.goals[selected]; + return true; + } + } +} + +static bool generator_choose_duration(int *output_minutes) +{ + size_t selected = 0U; + size_t count = trainlog_session_generation_policy_v1.duration_preset_count; + if (output_minutes == NULL || count == 0U) return false; + for (;;) { + size_t index; + int key; + draw_shell("Générer une séance — durée", "↑↓ choisir Entrée continuer q annuler"); + for (index = 0U; index < count; ++index) { + if (index == selected) trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_printf(tui_terminal, 4 + (int)index, 4, + " %c %d minutes ", index == selected ? '>' : ' ', + trainlog_session_generation_policy_v1.duration_presets_minutes[index]); + if (index == selected) trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + } + if (selected == count) trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_printf(tui_terminal, 4 + (int)count, 4, + " %c Durée personnalisée ", selected == count ? '>' : ' '); + if (selected == count) trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; + if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : count; + else if (key == TRAINLOG_KEY_DOWN) selected = selected < count ? selected + 1U : 0U; + else if (key == '\n' || key == TRAINLOG_KEY_ENTER) { + if (selected < count) { + *output_minutes = trainlog_session_generation_policy_v1.duration_presets_minutes[selected]; + return true; + } + return prompt_int_value(10, "Durée personnalisée (minutes)", + trainlog_session_generation_policy_v1.min_minutes, + trainlog_session_generation_policy_v1.max_minutes, + trainlog_session_generation_policy_v1.min_minutes, output_minutes); + } + } +} + +static int generator_confirm_exposure(const TrainlogBodyZone *zone, + const TrainlogBodyZoneRecentExposure *exposure) +{ + int key; + if (exposure->warning_level == TRAINLOG_GENERATION_WARNING_NONE) return 1; + draw_shell("Zone travaillée récemment", "c continuer z choisir une autre zone q annuler"); + trainlog_terminal_printf(tui_terminal, 4, 4, "%s travaillé récemment.", zone->display_name); + trainlog_terminal_printf(tui_terminal, 6, 4, + "24 h : %zu séries principales, %zu secondaires (%zu séances)", + exposure->within_24h.primary_set_count, exposure->within_24h.secondary_set_count, + exposure->within_24h.session_count); + trainlog_terminal_printf(tui_terminal, 7, 4, + "72 h : %zu séries principales, %zu secondaires (%zu séances)", + exposure->within_72h.primary_set_count, exposure->within_72h.secondary_set_count, + exposure->within_72h.session_count); + if (exposure->has_latest) trainlog_terminal_printf(tui_terminal, 9, 4, + "Dernière exposition réelle : %.48s", exposure->latest_started_at); + trainlog_terminal_printf(tui_terminal, 11, 4, + "Indicateur de récence uniquement; il ne mesure pas la récupération."); + trainlog_terminal_render(tui_terminal); + for (;;) { + key = trainlog_terminal_get_key(tui_terminal); + if (key == 'c' || key == 'C' || key == '\n' || key == TRAINLOG_KEY_ENTER) return 1; + if (key == 'z' || key == 'Z') return 0; + if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return -1; + } +} + +static bool generator_prepare_preview(TrainlogDatabase *database, + const TrainlogGeneratedSession *session, TrainlogGeneratorPreviewItem *items) +{ + size_t index; + for (index = 0U; index < session->exercise_count; ++index) { + TrainlogExercise exercise; + TrainlogResolvedEquipment equipment; + const TrainlogBodyZone *zone; + const TrainlogKnowledgeMovementPattern *pattern = NULL; + if (trainlog_database_get_exercise_profile(database, + session->exercises[index].exercise_id, &exercise) != TRAINLOG_STATUS_OK || + trainlog_database_resolve_equipment(database, + session->exercises[index].equipment_id, &equipment) != TRAINLOG_STATUS_OK) return false; + zone = trainlog_body_zone_catalog_lookup(session->exercises[index].primary_zone_id); + if (session->exercises[index].pattern_count > 0U) + pattern = trainlog_knowledge_movement_pattern_lookup(session->exercises[index].pattern_ids[0]); + (void)snprintf(items[index].exercise_name, sizeof(items[index].exercise_name), "%s", exercise.name); + (void)snprintf(items[index].equipment_name, sizeof(items[index].equipment_name), "%s", equipment.display_name); + (void)snprintf(items[index].zone_name, sizeof(items[index].zone_name), "%s", + zone != NULL ? zone->display_name : session->exercises[index].primary_zone_id); + (void)snprintf(items[index].movement_name, sizeof(items[index].movement_name), "%s", + pattern != NULL ? pattern->display_name_fr : "mouvement non classé"); + } + return true; +} + +static bool generator_preview(const TrainlogGeneratedSession *session, + const TrainlogGeneratorPreviewItem *items) +{ + size_t selected = 0U; + for (;;) { + size_t index, top; + int key; + int visible = (trainlog_terminal_rows(tui_terminal) - 7) / 3; + if (visible < 1) visible = 1; + top = selected >= (size_t)visible ? selected - (size_t)visible + 1U : 0U; + draw_shell("Aperçu de la séance générée", + "↑↓ parcourir a accepter et saisir le réalisé q annuler"); + trainlog_terminal_printf(tui_terminal, 3, 4, + "%zu exercice(s) · estimation %d min · cibles indicatives", + session->exercise_count, session->estimated_duration_seconds / 60); + for (index = top; index < session->exercise_count && index - top < (size_t)visible; ++index) { + const TrainlogGeneratedExercise *exercise = &session->exercises[index]; + int row = 5 + (int)(index - top) * 3; + char weight[64]; + if (exercise->has_target_weight) (void)snprintf(weight, sizeof(weight), + " · %.2f kg observés · %.19s", exercise->target_weight_kg, + exercise->load_source_started_at); + else (void)snprintf(weight, sizeof(weight), " · aucune prescription numérique"); + if (index == selected) trainlog_terminal_style_on(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + trainlog_terminal_printf(tui_terminal, row, 3, "%c %zu. %-31.31s · %-24.24s", + index == selected ? '>' : ' ', index + 1U, items[index].exercise_name, items[index].equipment_name); + trainlog_terminal_printf(tui_terminal, row + 1, 5, + "%d×%d%s · repos %d s", exercise->target_sets, + exercise->target_repetitions, weight, exercise->rest_seconds); + trainlog_terminal_printf(tui_terminal, row + 2, 5, "%.24s · %.32s%s", + items[index].zone_name, items[index].movement_name, + exercise->exposure_warning_level != TRAINLOG_GENERATION_WARNING_NONE ? " · récence" : ""); + if (index == selected) trainlog_terminal_style_off(tui_terminal, + TRAINLOG_TEXT_REVERSE | trainlog_theme_style(TRAINLOG_COLOR_ACCENT)); + } + trainlog_terminal_render(tui_terminal); + key = trainlog_terminal_get_key(tui_terminal); + if (key == 'a' || key == 'A' || key == '\n' || key == TRAINLOG_KEY_ENTER) return true; + if (key == 'q' || key == 'Q' || key == 27 || key == TRAINLOG_KEY_ESCAPE) return false; + if (key == TRAINLOG_KEY_UP) selected = selected > 0U ? selected - 1U : session->exercise_count - 1U; + else if (key == TRAINLOG_KEY_DOWN) selected = selected + 1U < session->exercise_count ? selected + 1U : 0U; + } +} + +static bool generator_build_drafts(TrainlogDatabase *database, + const TrainlogGeneratedSession *session, TrainlogSessionDraftExercise *drafts) +{ + size_t index; + for (index = 0U; index < session->exercise_count; ++index) { + const TrainlogGeneratedExercise *generated = &session->exercises[index]; + TrainlogExercise exercise; + if (trainlog_database_get_exercise_profile(database, generated->exercise_id, + &exercise) != TRAINLOG_STATUS_OK) return false; + (void)memset(&drafts[index], 0, sizeof(drafts[index])); + (void)snprintf(drafts[index].input.exercise_id, + sizeof(drafts[index].input.exercise_id), "%s", generated->exercise_id); + (void)snprintf(drafts[index].input.equipment_id, + sizeof(drafts[index].input.equipment_id), "%s", generated->equipment_id); + (void)snprintf(drafts[index].name, sizeof(drafts[index].name), "%s", exercise.name); + drafts[index].tracking_mode = TRAINLOG_TRACKING_REPS; + drafts[index].input.recording_mode = TRAINLOG_RECORDING_SETS; + drafts[index].input.load_mode = generated->planned_load_mode; + drafts[index].input.target_sets = generated->target_sets; + drafts[index].input.target_reps = generated->target_repetitions; + drafts[index].input.rest_seconds = generated->rest_seconds; + drafts[index].input.target_has_weight = generated->has_target_weight; + drafts[index].input.target_weight_kg = generated->target_weight_kg; + /* INVARIANT: generated dose is a plan. No performed row exists until + * the normal editor records a real metric supplied by the user. */ + drafts[index].input.set_count = 0U; + draft_bind_input(&drafts[index]); + } + return true; +} + static void edit_persisted_session( TrainlogDatabase *database, const char *session_id @@ -7534,6 +7810,149 @@ static void edit_persisted_session( wait_key(); } +static TrainlogStatus persist_new_session_drafts(TrainlogDatabase *database, + TrainlogSessionDraftExercise *drafts, size_t exercise_count, + const char *started_at) +{ + TrainlogSessionExerciseInput *inputs; + TrainlogSessionInput session; + char session_id[TRAINLOG_GENERATED_ID_CAPACITY]; + char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogStatus status; + size_t index; + if (database == NULL || drafts == NULL || started_at == NULL || + exercise_count == 0U || + exercise_count > MAX_SESSION_EXERCISES) return TRAINLOG_STATUS_INVALID_ARGUMENT; + inputs = calloc(exercise_count, sizeof(*inputs)); + if (inputs == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR; + if (trainlog_id_generate("se", session_id, sizeof(session_id)) != TRAINLOG_STATUS_OK || + trainlog_time_now_rfc3339(ended_at, sizeof(ended_at)) != TRAINLOG_STATUS_OK) { + free(inputs); + return TRAINLOG_STATUS_SYSTEM_ERROR; + } + for (index = 0U; index < exercise_count; ++index) { + draft_bind_input(&drafts[index]); + inputs[index] = drafts[index].input; + } + (void)memset(&session, 0, sizeof(session)); + session.session_type = TRAINLOG_SESSION_TRAINING; + (void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id); + (void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at); + (void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", ended_at); + session.exercises = inputs; + session.exercise_count = exercise_count; + status = trainlog_database_insert_session(database, &session); + free(inputs); + return status; +} + +static void generator_show_empty(const TrainlogGeneratedSession *session) +{ + size_t index; + draw_shell("Séance non générée", "Une touche pour revenir"); + trainlog_terminal_printf(tui_terminal, 4, 4, + "Pas assez d’exercices résolus et compatibles pour cette demande."); + for (index = 0U; index < session->shortage_count && index < 6U; ++index) + trainlog_terminal_printf(tui_terminal, 6 + (int)index, 4, "· %.58s", + session->shortage_codes[index]); + trainlog_terminal_printf(tui_terminal, 14, 4, + "Aucune correspondance n’a été inventée; rien ne peut être enregistré."); + wait_key(); +} + +static void screen_session_generator(TrainlogDatabase *database) +{ + const TrainlogBodyZone *zone; + const TrainlogSessionGenerationGoalPolicy *goal; + int duration_minutes; + if (!generator_choose_zone(&zone) || !generator_choose_goal(&goal) || + !generator_choose_duration(&duration_minutes)) return; + for (;;) { + TrainlogGeneratedSession *generated = calloc(1U, sizeof(*generated)); + TrainlogGeneratorPreviewItem *items = calloc(TRAINLOG_GENERATOR_MAX_SELECTED, + sizeof(*items)); + TrainlogGenerationDatabaseRequest request; + char reference_time[TRAINLOG_TIMESTAMP_MAX + 1U]; + TrainlogStatus status; + int exposure_choice; + if (generated == NULL || items == NULL) { + free(generated); free(items); + status_line("Mémoire insuffisante pour générer la séance.", TRAINLOG_COLOR_ERROR); + wait_key(); return; + } + if (trainlog_time_now_rfc3339(reference_time, sizeof(reference_time)) != TRAINLOG_STATUS_OK) { + free(generated); free(items); return; + } + (void)memset(&request, 0, sizeof(request)); + request.zone_id = zone->zone_id; + request.goal_id = goal->id; + request.duration_minutes = duration_minutes; + request.reference_time = reference_time; + /* NULL is deliberate: omitted equipment means all exact compatible + * supplied contexts, distinct from an explicit empty inventory. */ + request.available_equipment_ids = NULL; + status = trainlog_session_generate_from_database(database, &request, generated); + if (status != TRAINLOG_STATUS_OK) { + draw_shell("Génération impossible", "Une touche pour revenir"); + status_line(status == TRAINLOG_STATUS_DATABASE_ERROR + ? "Analyse refusée : horodatage ou historique stocké invalide." + : "La demande de génération est invalide ou incomplète.", + TRAINLOG_COLOR_ERROR); + free(generated); free(items); wait_key(); return; + } + exposure_choice = generator_confirm_exposure(zone, &generated->exposure); + if (exposure_choice < 0) { free(generated); free(items); return; } + if (exposure_choice == 0) { + free(generated); free(items); + if (!generator_choose_zone(&zone)) return; + continue; + } + if (generated->exercise_count == 0U) { + generator_show_empty(generated); + free(generated); free(items); return; + } + if (!generator_prepare_preview(database, generated, items)) { + free(generated); free(items); + status_line("Impossible de résoudre les libellés de l’aperçu.", TRAINLOG_COLOR_ERROR); + wait_key(); return; + } + if (!generator_preview(generated, items)) { + free(generated); free(items); return; + } + { + TrainlogSessionDraftExercise *drafts = calloc(MAX_SESSION_EXERCISES, + sizeof(*drafts)); + char accepted_at[TRAINLOG_TIMESTAMP_MAX + 1U]; + size_t count = generated->exercise_count; + bool built = drafts != NULL && generator_build_drafts(database, generated, drafts); + free(generated); free(items); + if (!built) { + free(drafts); + status_line("Impossible de préparer l’éditeur de séance.", TRAINLOG_COLOR_ERROR); + wait_key(); return; + } + if (trainlog_time_now_rfc3339(accepted_at, sizeof(accepted_at)) != TRAINLOG_STATUS_OK) { + free(drafts); return; + } + if (!edit_session_draft(database, drafts, &count, TRAINLOG_SESSION_TRAINING)) { + free(drafts); + draw_shell("Séance abandonnée", "Une touche pour revenir"); + status_line("Aucune donnée de séance n’a été enregistrée.", TRAINLOG_COLOR_MUTED); + wait_key(); return; + } + status = persist_new_session_drafts(database, drafts, count, accepted_at); + free(drafts); + draw_shell("Fin de séance", "Une touche pour revenir"); + status_line(status == TRAINLOG_STATUS_OK + ? "✓ Séance générée et réalisée enregistrée." + : "Échec lors de l’enregistrement de la séance.", + status == TRAINLOG_STATUS_OK ? TRAINLOG_COLOR_SUCCESS : TRAINLOG_COLOR_ERROR); + wait_key(); + return; + } + } +} + static void screen_new_session( TrainlogDatabase *database ) @@ -12509,6 +12928,9 @@ int trainlog_tui_run(TrainlogDatabase *database) case DASHBOARD_NEW_SESSION: screen_new_session(database); break; + case DASHBOARD_GENERATE_SESSION: + screen_session_generator(database); + break; case DASHBOARD_HISTORY: screen_history(database); break; diff --git a/tui/tests/test_session_generation.c b/tui/tests/test_session_generation.c new file mode 100644 index 0000000..e7ba351 --- /dev/null +++ b/tui/tests/test_session_generation.c @@ -0,0 +1,228 @@ +#include "trainlog/session_generation.h" +#include "trainlog/database.h" + +#include +#include +#include +#include + +void trainlog_run_generated_session_generation_fixtures(void); + +static TrainlogStatus accept_row(void *context, const TrainlogGenerationHistoryRow *row) +{ + return trainlog_session_generation_analyzer_accept(context, row); +} + +static TrainlogGenerationCandidate leg_press_candidate(void) +{ + static const char *const patterns[] = {"knee_dominant"}; + static const char *const refs[] = {"acsm_2009"}; + TrainlogGenerationCandidate result = { + .exercise_id = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + .equipment_id = "leg_press", + .primary_zone_id = "thighs", + .pattern_ids = patterns, + .pattern_count = 1U, + .source_ref_ids = refs, + .source_ref_count = 1U, + .confidence = "moderate", + .equipment_load_semantics = "external", + }; + return result; +} + +static TrainlogGenerationHistoryRow base_row(void) +{ + TrainlogGenerationHistoryRow row = { + .session_id = "se_10000000-0000-4000-8000-000000000000", + .occurrence_id = "sxe_10000000-0000-4000-8000-000000000000", + .exercise_id = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + .started_at = "2026-09-08T12:00:00.500+00:00", + .equipment_id = "leg_press", + .recording_mode = TRAINLOG_RECORDING_SETS, + .tracking_mode = TRAINLOG_TRACKING_REPS, + .load_mode = TRAINLOG_LOAD_NONE, + .rest_seconds = 0, + .has_actual_set = true, + .repetitions = 10, + .has_weight = true, + .weight_kg = 80.0, + }; + return row; +} + +static void test_anchor_exposure_and_boundaries(void) +{ + TrainlogGenerationCandidate candidate = leg_press_candidate(); + TrainlogGenerationRequest request = { + .zone_id = "thighs", .goal_id = "hypertrophy", .duration_minutes = 30, + .reference_time = "2026-09-09T12:00:00.500Z", + .candidates = &candidate, .candidate_count = 1U, + }; + TrainlogSessionGenerationAnalyzer *analyzer = NULL; + TrainlogGeneratedSession result; + TrainlogGenerationHistoryRow row = base_row(); + size_t index; + assert(trainlog_session_generation_analyzer_create(&request, &analyzer) == TRAINLOG_STATUS_OK); + for (index = 0U; index < 3U; ++index) { + row.set_position = index; + row.weight_kg = index == 1U ? 85.0 : 80.0; + assert(accept_row(analyzer, &row) == TRAINLOG_STATUS_OK); + } + assert(trainlog_session_generation_analyzer_finish(analyzer, &result) == TRAINLOG_STATUS_OK); + assert(result.exercise_count == 1U); + assert(result.exercises[0].has_target_weight && result.exercises[0].target_weight_kg == 80.0); + /* Exactly 24h is excluded; it remains the latest full-history exposure. */ + assert(result.exposure.within_24h.primary_set_count == 0U); + assert(result.exposure.within_72h.primary_set_count == 3U); + assert(result.exposure.warning_level == TRAINLOG_GENERATION_WARNING_NONE); + assert(result.exposure.has_latest && strcmp(result.exposure.latest_occurrence_id, row.occurrence_id) == 0); + trainlog_session_generation_analyzer_destroy(analyzer); +} + +static void test_fraction_inside_boundary_and_invalid_time(void) +{ + TrainlogGenerationCandidate candidate = leg_press_candidate(); + TrainlogGenerationRequest request = { + .zone_id = "thighs", .goal_id = "general", .duration_minutes = 30, + .reference_time = "2026-09-09T12:00:00.500Z", + .candidates = &candidate, .candidate_count = 1U, + }; + TrainlogSessionGenerationAnalyzer *analyzer = NULL; + TrainlogGenerationHistoryRow row = base_row(); + TrainlogGeneratedSession result; + row.started_at = "2026-09-08T12:00:00.501Z"; + row.has_weight = false; + assert(trainlog_session_generation_analyzer_create(&request, &analyzer) == TRAINLOG_STATUS_OK); + assert(trainlog_session_generation_analyzer_accept(analyzer, &row) == TRAINLOG_STATUS_OK); + assert(trainlog_session_generation_analyzer_finish(analyzer, &result) == TRAINLOG_STATUS_OK); + assert(result.exposure.within_24h.primary_set_count == 1U); + assert(result.exposure.warning_level == TRAINLOG_GENERATION_WARNING_WARNING); + trainlog_session_generation_analyzer_destroy(analyzer); + + assert(trainlog_session_generation_analyzer_create(&request, &analyzer) == TRAINLOG_STATUS_OK); + row.started_at = "not-a-time"; + assert(trainlog_session_generation_analyzer_accept(analyzer, &row) == TRAINLOG_STATUS_DATABASE_ERROR); + assert(trainlog_session_generation_analyzer_finish(analyzer, &result) == TRAINLOG_STATUS_DATABASE_ERROR); + trainlog_session_generation_analyzer_destroy(analyzer); +} + +static void test_sparse_and_max_only_shape(void) +{ + TrainlogGenerationRequest request = { + .zone_id = "chest", .goal_id = "strength", .duration_minutes = 30, + .reference_time = "2026-09-09T12:00Z", + }; + TrainlogSessionGenerationAnalyzer *analyzer = NULL; + TrainlogGeneratedSession result; + assert(trainlog_session_generation_analyzer_create(&request, &analyzer) == TRAINLOG_STATUS_OK); + assert(trainlog_session_generation_analyzer_finish(analyzer, &result) == TRAINLOG_STATUS_OK); + assert(result.exercise_count == 0U && result.insufficient_resolved_candidates); + trainlog_session_generation_analyzer_destroy(analyzer); +} + +static void test_database_convenience_and_explicit_empty_equipment(void) +{ + TrainlogDatabase *database = NULL; + const TrainlogSetInput sets[] = { + {.reps = 10, .has_weight = true, .weight_kg = 70.0}, + {.reps = 10, .has_weight = true, .weight_kg = 75.0}, + }; + const TrainlogSessionExerciseInput exercise = { + .entry_id = "sxe_30000000-0000-4000-8000-000000000000", + .exercise_id = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", + .equipment_id = "leg_press", + .recording_mode = TRAINLOG_RECORDING_SETS, + .load_mode = TRAINLOG_LOAD_NONE, + .sets = sets, + .set_count = 2U, + }; + const TrainlogSessionInput session = { + .session_id = "se_30000000-0000-4000-8000-000000000000", + .started_at = "2026-09-08T12:00:00Z", + .session_type = TRAINLOG_SESSION_TRAINING, + .exercises = &exercise, + .exercise_count = 1U, + }; + TrainlogGenerationDatabaseRequest request = { + .zone_id = "thighs", .goal_id = "general", .duration_minutes = 30, + .reference_time = "2026-09-09T12:00:00Z", + }; + TrainlogGeneratedSession result; + const char *const no_equipment_storage[] = {NULL}; + assert(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + assert(trainlog_database_insert_exercise_profiled(database, + "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "Leg press", "leg press", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) == TRAINLOG_STATUS_OK); + assert(trainlog_database_insert_session(database, &session) == TRAINLOG_STATUS_OK); + assert(trainlog_session_generate_from_database(database, &request, &result) == TRAINLOG_STATUS_OK); + assert(result.exercise_count == 1U && result.exercises[0].has_target_weight); + request.available_equipment_ids = no_equipment_storage; + request.available_equipment_count = 0U; + assert(trainlog_session_generate_from_database(database, &request, &result) == TRAINLOG_STATUS_OK); + assert(result.exercise_count == 0U && result.insufficient_resolved_candidates); + trainlog_database_close(database); +} + +static void test_analyzer_owns_request_bytes_after_create(void) +{ + char zone[] = "thighs"; + char goal[] = "general"; + char reference[] = "2026-09-09T12:00:00.500Z"; + char exercise[] = "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"; + char equipment[] = "leg_press"; + char primary[] = "thighs"; + char secondary[] = "glutes"; + char pattern[] = "knee_dominant"; + char source[] = "acsm_2009"; + char confidence[] = "moderate"; + char semantics[] = "external"; + const char *secondary_values[] = {secondary}; + const char *pattern_values[] = {pattern}; + const char *source_values[] = {source}; + TrainlogGenerationCandidate candidate = { + .exercise_id=exercise,.equipment_id=equipment,.primary_zone_id=primary, + .secondary_zone_ids=secondary_values,.secondary_zone_count=1U, + .pattern_ids=pattern_values,.pattern_count=1U, + .source_ref_ids=source_values,.source_ref_count=1U, + .confidence=confidence,.equipment_load_semantics=semantics, + }; + TrainlogGenerationRequest request = { + .zone_id=zone,.goal_id=goal,.duration_minutes=30,.reference_time=reference, + .candidates=&candidate,.candidate_count=1U, + }; + TrainlogSessionGenerationAnalyzer *analyzer = NULL; + TrainlogGeneratedSession output; + assert(trainlog_session_generation_analyzer_create(&request, &analyzer) == TRAINLOG_STATUS_OK); + (void)memset(zone, 'x', sizeof(zone)-1U); + (void)memset(goal, 'x', sizeof(goal)-1U); + (void)memset(reference, 'x', sizeof(reference)-1U); + (void)memset(exercise, 'x', sizeof(exercise)-1U); + (void)memset(equipment, 'x', sizeof(equipment)-1U); + (void)memset(primary, 'x', sizeof(primary)-1U); + (void)memset(secondary, 'x', sizeof(secondary)-1U); + (void)memset(pattern, 'x', sizeof(pattern)-1U); + (void)memset(source, 'x', sizeof(source)-1U); + (void)memset(confidence, 'x', sizeof(confidence)-1U); + (void)memset(semantics, 'x', sizeof(semantics)-1U); + assert(trainlog_session_generation_analyzer_finish(analyzer, &output) == TRAINLOG_STATUS_OK); + assert(output.exercise_count == 1U); + assert(strcmp(output.exercises[0].exercise_id, + "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde") == 0); + assert(strcmp(output.exercises[0].secondary_zone_ids[0], "glutes") == 0); + assert(strcmp(output.exercises[0].pattern_ids[0], "knee_dominant") == 0); + assert(strcmp(output.exercises[0].source_ref_ids[0], "acsm_2009") == 0); + trainlog_session_generation_analyzer_destroy(analyzer); +} + +int main(void) +{ + trainlog_run_generated_session_generation_fixtures(); + test_anchor_exposure_and_boundaries(); + test_fraction_inside_boundary_and_invalid_time(); + test_sparse_and_max_only_shape(); + test_database_convenience_and_explicit_empty_equipment(); + test_analyzer_owns_request_bytes_after_create(); + (void)puts("session generation tests passed"); + return 0; +} diff --git a/tui/tests/test_sync_direction.c b/tui/tests/test_sync_direction.c index 6df695d..d70303e 100644 --- a/tui/tests/test_sync_direction.c +++ b/tui/tests/test_sync_direction.c @@ -18,19 +18,19 @@ int main(void) CHECK(plan.receive_android && plan.publish_android); (void)snprintf(entries[0].name, sizeof(entries[0].name), "%s", - "trainlog-mobile-export-v2.json"); + "trainlog-mobile-export-v3.json"); entries[0].item_id = 20U; entries[0].modification_unix_seconds = 100U; (void)snprintf(entries[1].name, sizeof(entries[1].name), "%s", - "trainlog-mobile-export-v2 (25).json"); + "trainlog-mobile-export-v3 (25).json"); entries[1].item_id = 30U; entries[1].modification_unix_seconds = 200U; (void)snprintf(entries[2].name, sizeof(entries[2].name), "%s", - "trainlog-mobile-export-v2 (26).json"); + "trainlog-mobile-export-v3 (26).json"); entries[2].item_id = 10U; entries[2].modification_unix_seconds = 200U; (void)snprintf(entries[3].name, sizeof(entries[3].name), "%s", - "trainlog-mobile-export-v2 (oops).json"); + "trainlog-mobile-export-v3 (oops).json"); entries[3].item_id = 1U; entries[3].modification_unix_seconds = 999U; (void)snprintf(entries[4].name, sizeof(entries[4].name), "%s", diff --git a/tui/tests/test_tui_workflows.c b/tui/tests/test_tui_workflows.c index 4a37185..9328161 100644 --- a/tui/tests/test_tui_workflows.c +++ b/tui/tests/test_tui_workflows.c @@ -84,7 +84,16 @@ void trainlog_terminal_clear_to_end(TrainlogTerminal *terminal) { (void)terminal void trainlog_terminal_cursor_visible(TrainlogTerminal *terminal, bool visible) { (void)terminal; (void)visible; } void trainlog_terminal_draw(TrainlogTerminal *terminal, int row, int column, uint32_t codepoint) { (void)terminal; (void)row; (void)column; (void)codepoint; } void trainlog_terminal_box(TrainlogTerminal *terminal, int top, int left, int bottom, int right) { (void)terminal; (void)top; (void)left; (void)bottom; (void)right; } -int trainlog_terminal_get_key(TrainlogTerminal *terminal) { return terminal->event_index < terminal->event_count ? terminal->events[terminal->event_index++] : TRAINLOG_KEY_NONE; } +int trainlog_terminal_get_key(TrainlogTerminal *terminal) +{ + int key = terminal->event_index < terminal->event_count + ? terminal->events[terminal->event_index++] : TRAINLOG_KEY_NONE; + if (key == TRAINLOG_KEY_RESIZE) { + terminal->rows = 20; + terminal->columns = 72; + } + return key; +} bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, char utf8[5]) { int value = trainlog_terminal_get_key(terminal); @@ -286,13 +295,214 @@ static bool test_knowledge_scrolls_long_lists_at_minimum_terminal(void) return true; } +static size_t generator_zone_events(const char *zone_id, int *events) +{ + size_t index; + for (index = 0U; index < trainlog_body_zone_catalog_count(); ++index) { + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index); + if (zone != NULL && strcmp(zone->zone_id, zone_id) == 0) { + size_t event; + for (event = 0U; event < index; ++event) events[event] = TRAINLOG_KEY_DOWN; + events[index] = TRAINLOG_KEY_ENTER; + return index + 1U; + } + } + return 0U; +} + +static bool seed_generator_leg_press(TrainlogDatabase *database) +{ + return trainlog_database_insert_exercise_profiled(database, + "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "Leg press", "leg press", + TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) == TRAINLOG_STATUS_OK; +} + +static bool test_generator_dashboard_entry_and_policy_choices(void) +{ + TrainlogDatabase *database = NULL; + TrainlogTerminal terminal; + const TrainlogSessionGenerationGoalPolicy *goal = NULL; + int minutes = 0; + const int dashboard_events[] = {'g'}; + const int goal_events[] = {TRAINLOG_KEY_DOWN, TRAINLOG_KEY_ENTER}; + const int duration_events[] = {TRAINLOG_KEY_DOWN, TRAINLOG_KEY_ENTER}; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + script(&terminal, dashboard_events, sizeof(dashboard_events) / sizeof(dashboard_events[0])); + terminal.rows = 20; terminal.columns = 72; tui_terminal = &terminal; + CHECK(screen_dashboard(database) == DASHBOARD_GENERATE_SESSION); + CHECK(strstr(terminal.output, "g générer une séance") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + script(&terminal, goal_events, sizeof(goal_events) / sizeof(goal_events[0])); + CHECK(generator_choose_goal(&goal)); + CHECK(goal == &trainlog_session_generation_policy_v1.goals[1]); + script(&terminal, duration_events, sizeof(duration_events) / sizeof(duration_events[0])); + CHECK(generator_choose_duration(&minutes)); + CHECK(minutes == trainlog_session_generation_policy_v1.duration_presets_minutes[1]); + tui_terminal = NULL; trainlog_database_close(database); return true; +} + +static bool test_generator_plan_drafts_have_no_actual_sets(void) +{ + TrainlogDatabase *database = NULL; + TrainlogGeneratedSession generated; + TrainlogSessionDraftExercise drafts[1]; + char summary[160]; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(seed_generator_leg_press(database)); + (void)memset(&generated, 0, sizeof(generated)); + generated.exercise_count = 1U; + (void)snprintf(generated.exercises[0].exercise_id, + sizeof(generated.exercises[0].exercise_id), "%s", + "ex_b432623f-bfe9-4daf-a653-60ec7fdffbde"); + (void)snprintf(generated.exercises[0].equipment_id, + sizeof(generated.exercises[0].equipment_id), "%s", "leg_press"); + generated.exercises[0].target_sets = 3; + generated.exercises[0].target_repetitions = 10; + generated.exercises[0].rest_seconds = 120; + generated.exercises[0].planned_load_mode = TRAINLOG_LOAD_NONE; + CHECK(generator_build_drafts(database, &generated, drafts)); + CHECK(drafts[0].input.target_sets == 3 && drafts[0].input.target_reps == 10); + CHECK(drafts[0].input.rest_seconds == 120); + CHECK(drafts[0].input.set_count == 0U && drafts[0].input.sets == drafts[0].sets); + draft_set_summary(&drafts[0], summary, sizeof(summary)); + CHECK(strstr(summary, "Plan 3×10") != NULL); + CHECK(strstr(summary, "0 réalisée") != NULL); + trainlog_database_close(database); return true; +} + +static bool test_generator_preview_scrolls_at_minimum_terminal(void) +{ + TrainlogGeneratedSession generated; + TrainlogGeneratorPreviewItem items[TRAINLOG_GENERATOR_MAX_SELECTED]; + TrainlogTerminal terminal; + size_t index; + const int events[] = {TRAINLOG_KEY_RESIZE, TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, + TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, 'q'}; + (void)memset(&generated, 0, sizeof(generated)); + (void)memset(items, 0, sizeof(items)); + generated.exercise_count = TRAINLOG_GENERATOR_MAX_SELECTED; + generated.estimated_duration_seconds = 1800; + for (index = 0U; index < generated.exercise_count; ++index) { + generated.exercises[index].target_sets = 3; + generated.exercises[index].target_repetitions = 10; + generated.exercises[index].rest_seconds = 120; + (void)snprintf(items[index].exercise_name, sizeof(items[index].exercise_name), "Exercice %zu", index + 1U); + (void)snprintf(items[index].equipment_name, sizeof(items[index].equipment_name), "Machine %zu", index + 1U); + (void)snprintf(items[index].zone_name, sizeof(items[index].zone_name), "Cuisses"); + (void)snprintf(items[index].movement_name, sizeof(items[index].movement_name), "Extension du genou"); + } + script(&terminal, events, sizeof(events) / sizeof(events[0])); + terminal.rows = 30; terminal.columns = 100; tui_terminal = &terminal; + CHECK(!generator_preview(&generated, items)); + CHECK(strstr(terminal.output, "Exercice 6") != NULL); + CHECK(strstr(terminal.output, "aucune prescription numérique") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + tui_terminal = NULL; return true; +} + +static bool test_generator_recency_continue_and_cancel(void) +{ + TrainlogBodyZoneRecentExposure exposure; + TrainlogTerminal terminal; + const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup("back"); + const int continue_events[] = {'c'}; + const int cancel_events[] = {'q'}; + (void)memset(&exposure, 0, sizeof(exposure)); + exposure.warning_level = TRAINLOG_GENERATION_WARNING_WARNING; + exposure.within_24h.primary_set_count = 2U; + exposure.within_72h.secondary_set_count = 4U; + exposure.has_latest = true; + (void)snprintf(exposure.latest_started_at, sizeof(exposure.latest_started_at), + "%s", "2026-09-10T08:00:00.250+02:00"); + CHECK(zone != NULL); + script(&terminal, continue_events, sizeof(continue_events) / sizeof(continue_events[0])); + terminal.rows = 20; terminal.columns = 72; tui_terminal = &terminal; + CHECK(generator_confirm_exposure(zone, &exposure) == 1); + CHECK(strstr(terminal.output, "Dos travaillé récemment") != NULL); + CHECK(strstr(terminal.output, "2 séries principales") != NULL); + CHECK(!terminal.coordinate_overflow && !terminal.text_overflow); + script(&terminal, cancel_events, sizeof(cancel_events) / sizeof(cancel_events[0])); + CHECK(generator_confirm_exposure(zone, &exposure) == -1); + tui_terminal = NULL; return true; +} + +static bool test_generator_cancel_preview_writes_nothing(void) +{ + TrainlogDatabase *database = NULL; + TrainlogTerminal terminal; + int events[32]; + size_t count = generator_zone_events("thighs", events); + size_t sessions = 99U; + CHECK(count > 0U); + events[count++] = TRAINLOG_KEY_ENTER; + events[count++] = TRAINLOG_KEY_ENTER; + events[count++] = 'q'; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(seed_generator_leg_press(database)); + script(&terminal, events, count); tui_terminal = &terminal; + screen_session_generator(database); + CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK); + CHECK(sessions == 0U); + CHECK(strstr(terminal.output, "Aperçu de la séance générée") != NULL); + tui_terminal = NULL; trainlog_database_close(database); return true; +} + +static bool test_generator_accept_requires_and_persists_actual(void) +{ + TrainlogDatabase *database = NULL; + TrainlogTerminal terminal; + int events[48]; + size_t count = generator_zone_events("thighs", events); + size_t sessions = 0U; + CHECK(count > 0U); + events[count++] = TRAINLOG_KEY_ENTER; + events[count++] = TRAINLOG_KEY_ENTER; + events[count++] = 'a'; + events[count++] = TRAINLOG_KEY_ENTER; + events[count++] = 'a'; events[count++] = '8'; events[count++] = '\n'; + events[count++] = 'f'; events[count++] = 'f'; events[count++] = 'x'; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(seed_generator_leg_press(database)); + script(&terminal, events, count); tui_terminal = &terminal; + screen_session_generator(database); + CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK); + CHECK(sessions == 1U); + CHECK(strstr(terminal.output, "Valeurs réellement effectuées") != NULL); + CHECK(strstr(terminal.output, "Séance générée et réalisée enregistrée") != NULL); + tui_terminal = NULL; trainlog_database_close(database); return true; +} + +static bool test_generator_empty_knowledge_cannot_save(void) +{ + TrainlogDatabase *database = NULL; + TrainlogTerminal terminal; + const int events[] = {TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER, + TRAINLOG_KEY_ENTER, 'x'}; + size_t sessions = 99U; + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + script(&terminal, events, sizeof(events) / sizeof(events[0])); + tui_terminal = &terminal; screen_session_generator(database); + CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK); + CHECK(sessions == 0U); + CHECK(strstr(terminal.output, "Pas assez d’exercices résolus") != NULL); + CHECK(strstr(terminal.output, "rien ne peut être enregistré") != NULL); + tui_terminal = NULL; trainlog_database_close(database); return true; +} + int main(void) { if (!test_assistance_creation_labels() || !test_duration_creation_starts_empty() || !test_append_requires_actual_and_rolls_back() || !test_empty_sets_cannot_finish() || - !test_knowledge_scrolls_long_lists_at_minimum_terminal()) return 1; + !test_knowledge_scrolls_long_lists_at_minimum_terminal() || + !test_generator_dashboard_entry_and_policy_choices() || + !test_generator_plan_drafts_have_no_actual_sets() || + !test_generator_preview_scrolls_at_minimum_terminal() || + !test_generator_recency_continue_and_cancel() || + !test_generator_cancel_preview_writes_nothing() || + !test_generator_accept_requires_and_persists_actual() || + !test_generator_empty_knowledge_cannot_save()) return 1; (void)printf("PASS tui_workflows\n"); return 0; }