feat(generator): add body-zone session generation

This commit is contained in:
fy59 2026-09-10 07:08:20 +02:00
parent fd955315cc
commit 7cb49964e3
66 changed files with 9012 additions and 234 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -113,6 +113,7 @@ class DraftUiTestActivity : ComponentActivity() {
onBody = {},
onHistory = {},
onSync = {},
onGenerateSession = {},
)
}
}

View file

@ -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<String>, val patternIds: List<String>, val sourceRefIds: List<String>,
val confidence: KnowledgeConfidence, val equipmentLoadSemantics: EquipmentLoadSemantics?,
)
data class GenerationRequest(
val zoneId: String, val goalId: String, val durationMinutes: Int, val referenceTime: String,
val candidates: List<GenerationCandidate>, val preferredExerciseIds: Set<String> = emptySet(),
val excludedExerciseIds: Set<String> = emptySet(), val excludedPatternIds: Set<String> = emptySet(),
)
data class ExposureWindowSummary(
val primarySetCount: Int, val secondarySetCount: Int, val sessionCount: Int,
val patternIds: List<String>,
)
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<String>, 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<String>, val patternIds: List<String>,
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<String>,
val sourceRefIds: List<String>, val loadSourceSessionId: String? = null,
val loadSourceOccurrenceId: String? = null, val loadSourceStartedAt: String? = null,
)
data class GeneratedSessionSuggestion(
val exercises: List<GeneratedSessionExercise>, val estimatedDurationSeconds: Int,
val insufficientResolvedCandidates: Boolean, val exposure: BodyZoneRecentExposure,
val shortageCodes: List<String> = 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<String>()
val patterns72 = sortedSetOf<String>()
var latest: Triple<ExactInstant, GenerationHistoryRow, List<String>>? = 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<CandidateState>,
exposure: BodyZoneRecentExposure): GeneratedSessionSuggestion {
val selected = mutableListOf<GeneratedSessionExercise>()
val used = mutableSetOf<Int>()
val usedPatterns = mutableSetOf<String>()
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<GeneratedSessionExercise>): 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<GeneratedSessionExercise>, 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<GenerationHistoryRow> = 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<ExactInstant> {
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]
}
}

View file

@ -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<String, GenerationGoalPolicy>, val zoneExpansion: Map<String, List<String>>,
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<Int>,
val upperPushPatterns: Set<String>, val upperPullPatterns: Set<String>,
val lowerExtensionPatterns: Set<String>, val lowerFlexionPatterns: Set<String>,
)
/** 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<String>, 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<String>(); 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" }
}
}

View file

@ -0,0 +1,62 @@
package com.labfytools.trainlog.data
import com.labfytools.trainlog.model.SessionExercisePlan
data class SessionGenerationFormOptions(
val zoneIds: List<String>,
val goalIds: List<String>,
val durationPresets: List<Int>,
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<String>? = null,
val preferredExerciseIds: Set<String> = emptySet(),
val excludedExerciseIds: Set<String> = emptySet(),
val excludedPatternIds: Set<String> = 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<String>,
val patternNames: List<String>,
val plan: SessionExercisePlan,
val estimatedSeconds: Int,
val recency: TrainingRecencyWarning,
val rationaleCodes: List<String>,
val loadSourceSessionId: String?,
val loadSourceOccurrenceId: String?,
val loadSourceStartedAt: String?,
)
data class SessionGenerationPreview(
val request: SessionGenerationRequest,
val exercises: List<SessionGenerationPreviewExercise>,
val estimatedDurationSeconds: Int,
val insufficientResolvedCandidates: Boolean,
val exposure: BodyZoneRecentExposure,
val shortageCodes: List<String> = 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
}

View file

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

View file

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

View file

@ -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<BodyZone> = 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<String>()
val exerciseProfiles = mutableMapOf<String, Triple<String, String, Int>>()
@ -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<String>()
val positions = mutableSetOf<Int>()
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<String>()
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<String>): Boolean = keys().asSequence().toSet() == expected
private fun JSONObject.hasOnlyKeys(allowed: Set<String>, required: Set<String>): 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<Long>()
val metadata = mutableListOf<List<Any?>>()
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<Any?>(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<Any?>(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<String, String>, Pair<String, String>>()
val candidates = mutableListOf<GenerationCandidate>()
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<Long, EquipmentLoadSemantics>? = 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<String>()
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()

View file

@ -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<SessionSetDraft> = 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<SessionSetDraft> = emptyList(),
val continuousDurationSeconds: Int = 0,
val speedKmh: Double? = null,

View file

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

View file

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

View file

@ -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<Double?> {
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<SessionGenerationPreview?>(null) }
var message by remember { mutableStateOf<String?>(null) }
var warningAcknowledged by remember { mutableStateOf(false) }
var editingIndex by remember { mutableStateOf<Int?>(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('_', ' ')
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<android.content.Context>()
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)
}
}

View file

@ -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<String, Long> = 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"
}
}

View file

@ -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<Pair<String, (JSONObject) -> 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 }
}

View file

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

View file

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

View file

@ -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_<uuid-v4>
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.

View file

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

View file

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

View file

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

View file

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

View file

@ -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` | 13 | 812 | 60120 seconds |
| `strength` | 23 | 58 | 180300 seconds |
| `hypertrophy` | 23 | 812 | 90180 seconds |
| `endurance` | 13 | 1520 | 4590 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.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 13 sets / 812
repetitions / 60120 seconds; 23 / 58 / 180300; 23 / 812 / 90180;
and 13 / 1520 / 4590. 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.

View file

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

View file

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

View file

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

View file

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

View file

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

1672
tests/fixtures/session-generation-v1.json vendored Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <assert.h>', '#include <string.h>',
'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')

View file

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

View file

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

View file

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

View file

@ -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 <stdbool.h>
#include <stddef.h>
#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

View file

@ -0,0 +1,43 @@
#ifndef TRAINLOG_SESSION_GENERATION_POLICY_INTERNAL_H
#define TRAINLOG_SESSION_GENERATION_POLICY_INTERNAL_H
#include <stddef.h>
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

View file

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

View file

@ -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 <math.h>
@ -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,

1056
tui/src/session_generation.c Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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 dexercices 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 na é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 laperç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 na é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 lenregistrement 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;

View file

@ -0,0 +1,228 @@
#include "trainlog/session_generation.h"
#include "trainlog/database.h"
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <string.h>
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;
}

View file

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

View file

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