feat(generator): add body-zone session generation
This commit is contained in:
parent
fd955315cc
commit
7cb49964e3
66 changed files with 9012 additions and 234 deletions
|
|
@ -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
|
completed history and mobile export. Preserve raw partial form input and use
|
||||||
an explicit, non-destructive migration for future Android schema changes.
|
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
|
## 7. Synchronization architecture
|
||||||
|
|
||||||
Desktop access to Android uses physical-device discovery with `libudev` and
|
Desktop access to Android uses physical-device discovery with `libudev` and
|
||||||
|
|
|
||||||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -9,6 +9,13 @@ Detailed implementation chronology remains available in Git history and
|
||||||
|
|
||||||
### Added
|
### 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:
|
- `TRAINING_KNOWLEDGE_V1` read-only scientific knowledge infrastructure:
|
||||||
six authored, versioned JSON catalogs with cited references; deterministic C
|
six authored, versioned JSON catalogs with cited references; deterministic C
|
||||||
generation; Android immutable asset loading; stable-ID catalog queries; and
|
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
|
- desktop SQLite schema v7 and Android SQLite schema v7 preserve historic rows
|
||||||
while adding durable occurrence identities and occurrence-level equipment;
|
while adding durable occurrence identities and occurrence-level equipment;
|
||||||
- the active Android↔PC completed-session exchange is V2; frozen V1 artifacts
|
- the then-active Android↔PC completed-session exchange was V2; frozen V1
|
||||||
remain readable as historical formats and are not redefined for repeated
|
artifacts remain readable as historical formats and are not redefined for
|
||||||
occurrences;
|
repeated occurrences; the current active artifact is separately versioned V3;
|
||||||
- synchronization invokes each local helper with the explicit XDG-resolved
|
- synchronization invokes each local helper with the explicit XDG-resolved
|
||||||
desktop database path and records the concrete equipment-import failure;
|
desktop database path and records the concrete equipment-import failure;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ TRAINLOG_FORMAT_V1=FROZEN
|
||||||
DESKTOP_SCHEMA_V11=PASS
|
DESKTOP_SCHEMA_V11=PASS
|
||||||
ANDROID_LOCAL_WORKFLOWS=PASS
|
ANDROID_LOCAL_WORKFLOWS=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V10=PASS
|
ANDROID_LOCAL_DATABASE_V10=PASS
|
||||||
|
ANDROID_LOCAL_DATABASE_V11=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
EXERCISE_EDIT_V1=PASS
|
EXERCISE_EDIT_V1=PASS
|
||||||
ANDROID_BANNER_PARITY_V1=PASS
|
ANDROID_BANNER_PARITY_V1=PASS
|
||||||
|
|
@ -42,10 +43,11 @@ BODY_ZONE_SYNC_V1=PASS
|
||||||
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
|
BODY_ZONES_DESKTOP_REAL_MIGRATION=PASS
|
||||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint)
|
DESKTOP_TESTS=45/45 PASS (latest validated checkpoint)
|
||||||
ANDROID_BUILD=PASS
|
ANDROID_BUILD=PASS
|
||||||
|
|
||||||
TRAINING_KNOWLEDGE_V1=PASS
|
TRAINING_KNOWLEDGE_V1=PASS
|
||||||
|
SESSION_GENERATOR_V1=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
`TRAINING_KNOWLEDGE_V1` has passed its bounded scientific review, independent
|
`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
|
Schema migrations are additive and preserve existing capture data. See
|
||||||
[Android behavior](docs/android.md) and [validation](docs/tests.md).
|
[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
|
equipment catalogue, per-occurrence equipment links, durable occurrence
|
||||||
identities, custom-equipment definition support, explicit MAX results and
|
identities, custom-equipment definition support, explicit MAX results and
|
||||||
stable-source Test max resumption, then direct primary/secondary body-zone
|
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
|
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.
|
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
|
`entry_id`, per-set weights and equipment associations. Frozen V1 artifacts
|
||||||
remain readable as legacy artifacts; they are not silently redefined as V2.
|
remain readable as legacy artifacts; they are not silently redefined as V2.
|
||||||
Exercise-zone metadata travels separately in the sole bidirectional
|
Exercise-zone metadata travels separately in the sole bidirectional
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ android {
|
||||||
* are the canonical shared sources. Android must not fork either
|
* are the canonical shared sources. Android must not fork either
|
||||||
* taxonomy into Kotlin constants. */
|
* taxonomy into Kotlin constants. */
|
||||||
assets.directories.add("../../catalog")
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,7 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
onBody = {},
|
onBody = {},
|
||||||
onHistory = {},
|
onHistory = {},
|
||||||
onSync = {},
|
onSync = {},
|
||||||
|
onGenerateSession = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -176,19 +176,28 @@ class SyncCatalogInbox(
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importPcSessions(directory: DocumentFile): String? {
|
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 {
|
return try {
|
||||||
val json = appContext.contentResolver.openInputStream(file.uri)
|
val json = appContext.contentResolver.openInputStream(file.uri)
|
||||||
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }
|
||||||
?: return "Lecture snapshot séances V2 impossible."
|
?: return "Lecture snapshot séances impossible."
|
||||||
when (val result = repository.applyPcMobileExportV2Json(json)) {
|
val result = if (v3 != null) repository.applyPcMobileExportV3Json(json)
|
||||||
|
else repository.applyPcMobileExportV2Json(json)
|
||||||
|
when (result) {
|
||||||
is MobileSessionImportResult.Applied -> null
|
is MobileSessionImportResult.Applied -> null
|
||||||
is MobileSessionImportResult.Invalid -> result.message
|
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? {
|
private fun importPcBodyZones(directory: DocumentFile): String? {
|
||||||
val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null
|
val file = directory.findFile("trainlog-exercise-body-zones-v1.json") ?: return null
|
||||||
return try {
|
return try {
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,9 @@ class SyncExporter(
|
||||||
val bodyZonesJson: String
|
val bodyZonesJson: String
|
||||||
try {
|
try {
|
||||||
definitionsJson = repository.buildEquipmentDefinitionsJson()
|
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. */
|
* readable by desktop for historic devices but is not published. */
|
||||||
mobileJson = repository.buildMobileExportV2Json()
|
mobileJson = repository.buildMobileExportV3Json()
|
||||||
associationsJson = repository.buildEquipmentAssociationsJson()
|
associationsJson = repository.buildEquipmentAssociationsJson()
|
||||||
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
bodyZonesJson = repository.buildExerciseBodyZonesJson()
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
|
|
@ -77,9 +77,9 @@ class SyncExporter(
|
||||||
"/Trainlog/"
|
"/Trainlog/"
|
||||||
|
|
||||||
val displayName =
|
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. */
|
* custom ID; a failed definition write aborts the bundle. */
|
||||||
val definitionsError = writeEquipmentDefinitions(definitionsJson)
|
val definitionsError = writeEquipmentDefinitions(definitionsJson)
|
||||||
if (definitionsError != null) return SyncExportResult.Error(definitionsError)
|
if (definitionsError != null) return SyncExportResult.Error(definitionsError)
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ package com.labfytools.trainlog.data
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
import android.database.sqlite.SQLiteConstraintException
|
||||||
|
import android.database.Cursor
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import android.database.sqlite.SQLiteOpenHelper
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
|
import android.util.JsonReader
|
||||||
|
import android.util.JsonToken
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationSummary
|
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.SessionSummary
|
||||||
import com.labfytools.trainlog.model.SessionDetail
|
import com.labfytools.trainlog.model.SessionDetail
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDetail
|
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.SessionSetDraft
|
||||||
import com.labfytools.trainlog.model.SessionType
|
import com.labfytools.trainlog.model.SessionType
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
|
import java.io.StringReader
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -254,6 +260,10 @@ class TrainlogRepository(
|
||||||
private val applicationContext = context.applicationContext
|
private val applicationContext = context.applicationContext
|
||||||
private val bodyZones = BodyZoneCatalog.load(applicationContext)
|
private val bodyZones = BodyZoneCatalog.load(applicationContext)
|
||||||
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
|
private val trainingKnowledge = TrainingKnowledgeCatalog.load(applicationContext, bodyZones)
|
||||||
|
private val sessionGenerationPolicy =
|
||||||
|
SessionGenerationPolicyLoader.load(applicationContext, trainingKnowledge, bodyZones)
|
||||||
|
private val sessionGenerationEngine =
|
||||||
|
SessionGenerationEngine(sessionGenerationPolicy, trainingKnowledge, bodyZones)
|
||||||
private val database =
|
private val database =
|
||||||
TrainlogDatabaseHelper(
|
TrainlogDatabaseHelper(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
|
|
@ -339,6 +349,13 @@ class TrainlogRepository(
|
||||||
/** Return manifest sort order; definitions are immutable application assets. */
|
/** Return manifest sort order; definitions are immutable application assets. */
|
||||||
fun listBodyZones(): List<BodyZone> = bodyZones.zones
|
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. */
|
/** Stable-ID lookup; null means the ID is not part of taxonomy V1. */
|
||||||
fun bodyZone(zoneId: String): BodyZone? = bodyZones.lookup(zoneId)
|
fun bodyZone(zoneId: String): BodyZone? = bodyZones.lookup(zoneId)
|
||||||
|
|
||||||
|
|
@ -817,7 +834,7 @@ class TrainlogRepository(
|
||||||
): ActiveDraftMutationResult {
|
): ActiveDraftMutationResult {
|
||||||
if (
|
if (
|
||||||
draft.exercises.any {
|
draft.exercises.any {
|
||||||
!validateSessionExercise(it, draft.sessionType)
|
!validateSessionExercise(it, draft.sessionType, allowTargetOnly = true)
|
||||||
} ||
|
} ||
|
||||||
(draft.sourceSessionId != null &&
|
(draft.sourceSessionId != null &&
|
||||||
(draft.sourceSessionId.isBlank() || draft.sessionType != SessionType.MAX_TEST)) ||
|
(draft.sourceSessionId.isBlank() || draft.sessionType != SessionType.MAX_TEST)) ||
|
||||||
|
|
@ -1084,6 +1101,7 @@ class TrainlogRepository(
|
||||||
.dataFields
|
.dataFields
|
||||||
)
|
)
|
||||||
put("entry_id", exerciseDraft.entryId)
|
put("entry_id", exerciseDraft.entryId)
|
||||||
|
putSessionPlan(exerciseDraft.plan)
|
||||||
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
||||||
db,
|
db,
|
||||||
exerciseDraft.equipmentId,
|
exerciseDraft.equipmentId,
|
||||||
|
|
@ -1981,7 +1999,7 @@ class TrainlogRepository(
|
||||||
fun buildMobileExportJson(): String = buildMobileExport(1)
|
fun buildMobileExportJson(): String = buildMobileExport(1)
|
||||||
|
|
||||||
private fun buildMobileExport(version: Int): String {
|
private fun buildMobileExport(version: Int): String {
|
||||||
require(version == 1 || version == 2)
|
require(version in 1..3)
|
||||||
val root = JSONObject()
|
val root = JSONObject()
|
||||||
root.put("format", "trainlog-mobile-export")
|
root.put("format", "trainlog-mobile-export")
|
||||||
root.put("version", version)
|
root.put("version", version)
|
||||||
|
|
@ -2010,14 +2028,22 @@ class TrainlogRepository(
|
||||||
).use { sessions ->
|
).use { sessions ->
|
||||||
while (sessions.moveToNext()) {
|
while (sessions.moveToNext()) {
|
||||||
val sessionRowId = sessions.getLong(0)
|
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()
|
val session = JSONObject()
|
||||||
.put("session_id", sessions.getString(1))
|
.put("session_id", sessions.getString(1))
|
||||||
.put("started_at", sessions.getString(2))
|
.put("started_at", startedAt)
|
||||||
.put("session_type", sessions.getString(3))
|
.put("session_type", sessions.getString(3))
|
||||||
val sessionExercises = JSONArray()
|
val sessionExercises = JSONArray()
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT se.id, e.exercise_id, e.name, se.recording_mode, se.tracking_mode, se.data_fields, " +
|
"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 " +
|
"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 equipment AS eq ON eq.id=se.equipment_row_id " +
|
||||||
"LEFT JOIN max_results AS mr ON mr.session_exercise_row_id=se.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 sessionExerciseRowId = exerciseCursor.getLong(0)
|
||||||
val recording = exerciseCursor.getString(3)
|
val recording = exerciseCursor.getString(3)
|
||||||
val tracking = exerciseCursor.getString(4)
|
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()
|
val item = JSONObject()
|
||||||
.put("exercise_id", exerciseCursor.getString(1))
|
.put("exercise_id", exerciseCursor.getString(1))
|
||||||
.put("name", exerciseCursor.getString(2))
|
.put("name", exerciseCursor.getString(2))
|
||||||
.put("recording_mode", recording)
|
.put("recording_mode", recording)
|
||||||
.put("tracking_mode", tracking)
|
.put("tracking_mode", tracking)
|
||||||
.put("data_fields", exerciseCursor.getInt(5))
|
.put("data_fields", exerciseCursor.getInt(5))
|
||||||
.put("load_mode", "none")
|
.put("load_mode", if (version == 3) exerciseCursor.getString(10) else "none")
|
||||||
.put("rest_seconds", 0)
|
.put("rest_seconds", if (version == 3) exerciseCursor.getInt(11) else 0)
|
||||||
if (version == 2) {
|
if (version == 2) {
|
||||||
item.put("entry_id", exerciseCursor.getString(6))
|
item.put("entry_id", exerciseCursor.getString(6))
|
||||||
item.put("position", exerciseCursor.getInt(7))
|
item.put("position", exerciseCursor.getInt(7))
|
||||||
if (exerciseCursor.isNull(8)) item.put("equipment_id", JSONObject.NULL)
|
if (exerciseCursor.isNull(8)) item.put("equipment_id", JSONObject.NULL)
|
||||||
else item.put("equipment_id", exerciseCursor.getString(8))
|
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)) {
|
if (!exerciseCursor.isNull(9)) {
|
||||||
/* TRAINLOG_FORMAT_V1 is frozen and has no max
|
/* TRAINLOG_FORMAT_V1 is frozen and has no max
|
||||||
* result shape. Refuse instead of inventing 1x1. */
|
* result shape. Refuse instead of inventing 1x1. */
|
||||||
check(version == 2) {
|
check(version >= 2) {
|
||||||
"Un résultat max explicite exige l'export mobile V2."
|
"Un résultat max explicite exige l'export mobile V2."
|
||||||
}
|
}
|
||||||
item.put("max_weight_kg", exerciseCursor.getDouble(9))
|
item.put("max_weight_kg", exerciseCursor.getDouble(9))
|
||||||
|
|
@ -2086,7 +2148,7 @@ class TrainlogRepository(
|
||||||
} else {
|
} else {
|
||||||
set.put("duration_seconds", setCursor.getInt(1))
|
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))
|
set.put("weight_kg", setCursor.getDouble(2))
|
||||||
}
|
}
|
||||||
sets.put(set)
|
sets.put(set)
|
||||||
|
|
@ -2116,9 +2178,13 @@ class TrainlogRepository(
|
||||||
"left_thigh_cm", "right_thigh_cm", "left_calf_cm", "right_calf_cm",
|
"left_thigh_cm", "right_thigh_cm", "left_calf_cm", "right_calf_cm",
|
||||||
)
|
)
|
||||||
while (cursor.moveToNext()) {
|
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()
|
val item = JSONObject()
|
||||||
.put("observation_id", cursor.getString(0))
|
.put("observation_id", cursor.getString(0))
|
||||||
.put("observed_at", cursor.getString(1))
|
.put("observed_at", observedAt)
|
||||||
for (index in names.indices) {
|
for (index in names.indices) {
|
||||||
val column = index + 2
|
val column = index + 2
|
||||||
if (!cursor.isNull(column)) {
|
if (!cursor.isNull(column)) {
|
||||||
|
|
@ -2139,12 +2205,25 @@ class TrainlogRepository(
|
||||||
*/
|
*/
|
||||||
fun buildMobileExportV2Json(): String = buildMobileExport(2)
|
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. */
|
/** Apply the same V2 session artifact emitted by desktop, keyed by entry_id. */
|
||||||
fun applyPcMobileExportV2Json(json: String): MobileSessionImportResult {
|
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) {
|
val root = try { JSONObject(json) } catch (_: Exception) {
|
||||||
return MobileSessionImportResult.Invalid("Snapshot séances JSON invalide.")
|
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 sessions = root.getJSONArray("sessions")
|
||||||
val db = database.writableDatabase
|
val db = database.writableDatabase
|
||||||
var sessionsAdded = 0
|
var sessionsAdded = 0
|
||||||
|
|
@ -2181,7 +2260,7 @@ class TrainlogRepository(
|
||||||
if (it.moveToFirst()) it.getLong(0) else null
|
if (it.moveToFirst()) it.getLong(0) else null
|
||||||
}
|
}
|
||||||
if (existingRowId != null) {
|
if (existingRowId != null) {
|
||||||
if (pcSessionV2Matches(db, existingRowId, session)) {
|
if (pcSessionMatches(db, existingRowId, session, version)) {
|
||||||
sessionsSkipped += 1
|
sessionsSkipped += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -2226,6 +2305,20 @@ class TrainlogRepository(
|
||||||
put("entry_id", entryId); put("session_row_id", rowId); put("exercise_row_id", exerciseRow.rowId)
|
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("position", position); put("recording_mode", recording); put("tracking_mode", tracking)
|
||||||
put("data_fields", entry.optInt("data_fields", 0))
|
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)
|
if (equipmentRowId == null) putNull("equipment_row_id") else put("equipment_row_id", equipmentRowId)
|
||||||
}
|
}
|
||||||
val occurrence = db.insertOrThrow("session_exercises", null, values)
|
val occurrence = db.insertOrThrow("session_exercises", null, values)
|
||||||
|
|
@ -2299,14 +2392,17 @@ class TrainlogRepository(
|
||||||
} finally { db.endTransaction() }
|
} 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")
|
val rootKeys = setOf("format", "version", "generated_at", "exercises", "sessions", "body_observations")
|
||||||
if (!root.hasExactKeys(rootKeys) || root.value("format") != "trainlog-mobile-export" ||
|
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("exercises") !is JSONArray || root.value("sessions") !is JSONArray ||
|
||||||
root.value("body_observations") !is JSONArray) {
|
root.value("body_observations") !is JSONArray) {
|
||||||
return "Snapshot séances V2 invalide."
|
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 {
|
return try {
|
||||||
val exerciseIds = mutableSetOf<String>()
|
val exerciseIds = mutableSetOf<String>()
|
||||||
val exerciseProfiles = mutableMapOf<String, Triple<String, String, Int>>()
|
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) {
|
session.value("session_type") !in setOf("training", "max_test") || entries == null || entries.length() == 0) {
|
||||||
return "Session V2 invalide."
|
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 entryIds = mutableSetOf<String>()
|
||||||
val positions = mutableSetOf<Int>()
|
val positions = mutableSetOf<Int>()
|
||||||
for (entryIndex in 0 until entries.length()) {
|
for (entryIndex in 0 until entries.length()) {
|
||||||
|
|
@ -2347,7 +2446,7 @@ class TrainlogRepository(
|
||||||
val recording = entry.value("recording_mode")
|
val recording = entry.value("recording_mode")
|
||||||
val tracking = entry.value("tracking_mode")
|
val tracking = entry.value("tracking_mode")
|
||||||
val hasMax = entry.has("max_weight_kg")
|
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")
|
hasMax -> setOf("max_weight_kg")
|
||||||
recording == "continuous" -> setOf("continuous")
|
recording == "continuous" -> setOf("continuous")
|
||||||
else -> setOf("sets")
|
else -> setOf("sets")
|
||||||
|
|
@ -2357,7 +2456,7 @@ class TrainlogRepository(
|
||||||
val positionValue = entry.value("position")
|
val positionValue = entry.value("position")
|
||||||
if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) ||
|
if (!entry.hasExactKeys(expectedKeys) || !entryId.isNonemptyJsonString() || !entryIds.add(entryId as String) ||
|
||||||
!exerciseId.isNonemptyJsonString() || exerciseId !in exerciseIds || !entry.value("name").isNonemptyJsonString() ||
|
!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()) ||
|
!positionValue.isJsonInt(0, 100000) || !positions.add((positionValue as Number).toInt()) ||
|
||||||
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
|
!(entry.value("equipment_id") === JSONObject.NULL || entry.value("equipment_id").isNonemptyJsonString())) {
|
||||||
return "Entrée de séance V2 invalide."
|
return "Entrée de séance V2 invalide."
|
||||||
|
|
@ -2426,6 +2525,9 @@ class TrainlogRepository(
|
||||||
presentMetrics.isEmpty() || presentMetrics.any { !item.value(it).isPositiveJsonNumber() }) {
|
presentMetrics.isEmpty() || presentMetrics.any { !item.value(it).isPositiveJsonNumber() }) {
|
||||||
return "Observation corporelle V2 invalide."
|
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
|
null
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|
@ -2444,6 +2546,74 @@ class TrainlogRepository(
|
||||||
!(recording == "sets" && (dataFields as Number).toInt() != 0)
|
!(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.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.hasExactKeys(expected: Set<String>): Boolean = keys().asSequence().toSet() == expected
|
||||||
private fun JSONObject.hasOnlyKeys(allowed: Set<String>, required: Set<String>): Boolean {
|
private fun JSONObject.hasOnlyKeys(allowed: Set<String>, required: Set<String>): Boolean {
|
||||||
|
|
@ -2460,7 +2630,7 @@ class TrainlogRepository(
|
||||||
private fun Any?.isNonnegativeJsonNumber(): Boolean =
|
private fun Any?.isNonnegativeJsonNumber(): Boolean =
|
||||||
this is Number && toDouble().isFinite() && toDouble() >= 0.0
|
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 {
|
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")
|
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 rows = mutableListOf<Long>()
|
||||||
val metadata = mutableListOf<List<Any?>>()
|
val metadata = mutableListOf<List<Any?>>()
|
||||||
db.rawQuery(
|
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 " +
|
"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 ->
|
"WHERE se.session_row_id=? ORDER BY se.position", arrayOf(rowId.toString())).use { cursor ->
|
||||||
while (cursor.moveToNext()) {
|
while (cursor.moveToNext()) {
|
||||||
rows += cursor.getLong(0)
|
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))
|
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
|
if (rows.size != incoming.length()) return false
|
||||||
for (index in rows.indices) {
|
for (index in rows.indices) {
|
||||||
val item = incoming.getJSONObject(index)
|
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),
|
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 (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 (metadata[index] != expected) return false
|
||||||
if (item.has("max_weight_kg")) {
|
if (item.has("max_weight_kg")) {
|
||||||
val current = db.rawQuery(
|
val current = db.rawQuery(
|
||||||
|
|
@ -3039,6 +3222,12 @@ class TrainlogRepository(
|
||||||
se.data_fields,
|
se.data_fields,
|
||||||
eq.equipment_id,
|
eq.equipment_id,
|
||||||
eq.display_name,
|
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
|
mr.max_weight_kg
|
||||||
FROM session_exercises AS se
|
FROM session_exercises AS se
|
||||||
JOIN sessions AS s
|
JOIN sessions AS s
|
||||||
|
|
@ -3088,7 +3277,8 @@ class TrainlogRepository(
|
||||||
cursor.getInt(6)
|
cursor.getInt(6)
|
||||||
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
|
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
|
||||||
val equipmentDisplayName = if (cursor.isNull(8)) null else cursor.getString(8)
|
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) {
|
if (maxWeightKg != null) {
|
||||||
exercises +=
|
exercises +=
|
||||||
|
|
@ -3101,6 +3291,7 @@ class TrainlogRepository(
|
||||||
recordingMode = recording,
|
recordingMode = recording,
|
||||||
trackingMode = tracking,
|
trackingMode = tracking,
|
||||||
dataFields = dataFields,
|
dataFields = dataFields,
|
||||||
|
plan = plan,
|
||||||
maxWeightKg = maxWeightKg,
|
maxWeightKg = maxWeightKg,
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
|
|
@ -3148,6 +3339,7 @@ class TrainlogRepository(
|
||||||
tracking,
|
tracking,
|
||||||
dataFields =
|
dataFields =
|
||||||
dataFields,
|
dataFields,
|
||||||
|
plan = plan,
|
||||||
continuousDurationSeconds =
|
continuousDurationSeconds =
|
||||||
continuous
|
continuous
|
||||||
.getInt(0),
|
.getInt(0),
|
||||||
|
|
@ -3240,6 +3432,7 @@ class TrainlogRepository(
|
||||||
tracking,
|
tracking,
|
||||||
dataFields =
|
dataFields =
|
||||||
dataFields,
|
dataFields,
|
||||||
|
plan = plan,
|
||||||
sets = sets,
|
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.
|
* Compose immutable scientific metadata with exact persisted runtime state.
|
||||||
* WHY: names are editable labels, so every join and lookup stays on stable
|
* 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.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 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 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 {
|
private companion object {
|
||||||
const val MAX_OCCURRENCE_PAGE_SIZE = 32
|
const val MAX_OCCURRENCE_PAGE_SIZE = 32
|
||||||
|
|
@ -3692,6 +4171,12 @@ class TrainlogRepository(
|
||||||
de.data_fields,
|
de.data_fields,
|
||||||
eq.equipment_id,
|
eq.equipment_id,
|
||||||
de.entry_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
|
mr.max_weight_kg
|
||||||
FROM draft_session_exercises AS de
|
FROM draft_session_exercises AS de
|
||||||
JOIN exercises AS e
|
JOIN exercises AS e
|
||||||
|
|
@ -3727,7 +4212,8 @@ class TrainlogRepository(
|
||||||
)
|
)
|
||||||
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
|
val equipmentId = if (cursor.isNull(7)) null else cursor.getString(7)
|
||||||
val entryId = cursor.getString(8)
|
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) {
|
if (maxWeightKg != null) {
|
||||||
exercises +=
|
exercises +=
|
||||||
|
|
@ -3736,6 +4222,7 @@ class TrainlogRepository(
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
equipmentId = equipmentId,
|
equipmentId = equipmentId,
|
||||||
maxWeightKg = maxWeightKg,
|
maxWeightKg = maxWeightKg,
|
||||||
|
plan = plan,
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
|
|
@ -3763,6 +4250,7 @@ class TrainlogRepository(
|
||||||
entryId = entryId,
|
entryId = entryId,
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
equipmentId = equipmentId,
|
equipmentId = equipmentId,
|
||||||
|
plan = plan,
|
||||||
continuousDurationSeconds =
|
continuousDurationSeconds =
|
||||||
item.getInt(0),
|
item.getInt(0),
|
||||||
speedKmh =
|
speedKmh =
|
||||||
|
|
@ -3820,6 +4308,7 @@ class TrainlogRepository(
|
||||||
entryId = entryId,
|
entryId = entryId,
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
equipmentId = equipmentId,
|
equipmentId = equipmentId,
|
||||||
|
plan = plan,
|
||||||
sets = sets,
|
sets = sets,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3927,6 +4416,7 @@ class TrainlogRepository(
|
||||||
exerciseDraft.exercise.dataFields,
|
exerciseDraft.exercise.dataFields,
|
||||||
)
|
)
|
||||||
put("entry_id", exerciseDraft.entryId)
|
put("entry_id", exerciseDraft.entryId)
|
||||||
|
putSessionPlan(exerciseDraft.plan)
|
||||||
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
||||||
db,
|
db,
|
||||||
exerciseDraft.equipmentId,
|
exerciseDraft.equipmentId,
|
||||||
|
|
@ -4076,6 +4566,7 @@ class TrainlogRepository(
|
||||||
)
|
)
|
||||||
put("data_fields", exerciseDraft.exercise.dataFields)
|
put("data_fields", exerciseDraft.exercise.dataFields)
|
||||||
put("entry_id", exerciseDraft.entryId)
|
put("entry_id", exerciseDraft.entryId)
|
||||||
|
putSessionPlan(exerciseDraft.plan)
|
||||||
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
val equipmentRowId = lookupEquipmentRowIdOrNull(
|
||||||
db,
|
db,
|
||||||
exerciseDraft.equipmentId,
|
exerciseDraft.equipmentId,
|
||||||
|
|
@ -4228,7 +4719,9 @@ class TrainlogRepository(
|
||||||
private fun validateSessionExercise(
|
private fun validateSessionExercise(
|
||||||
draft: SessionExerciseDraft,
|
draft: SessionExerciseDraft,
|
||||||
sessionType: SessionType,
|
sessionType: SessionType,
|
||||||
|
allowTargetOnly: Boolean = false,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
if (!validateSessionPlan(draft)) return false
|
||||||
val maxWeight = draft.maxWeightKg
|
val maxWeight = draft.maxWeightKg
|
||||||
if (maxWeight != null) {
|
if (maxWeight != null) {
|
||||||
/* INVARIANT: max is a first-class result owned by the movement
|
/* INVARIANT: max is a first-class result owned by the movement
|
||||||
|
|
@ -4239,7 +4732,8 @@ class TrainlogRepository(
|
||||||
draft.sets.isEmpty() &&
|
draft.sets.isEmpty() &&
|
||||||
draft.continuousDurationSeconds == 0 &&
|
draft.continuousDurationSeconds == 0 &&
|
||||||
draft.speedKmh == null &&
|
draft.speedKmh == null &&
|
||||||
draft.distanceKm == null
|
draft.distanceKm == null &&
|
||||||
|
draft.plan == null
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (
|
return when (
|
||||||
|
|
@ -4248,7 +4742,7 @@ class TrainlogRepository(
|
||||||
RecordingMode.CONTINUOUS -> {
|
RecordingMode.CONTINUOUS -> {
|
||||||
if (
|
if (
|
||||||
draft.continuousDurationSeconds <= 0 ||
|
draft.continuousDurationSeconds <= 0 ||
|
||||||
draft.sets.isNotEmpty()
|
draft.sets.isNotEmpty() || draft.plan != null
|
||||||
) {
|
) {
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4279,7 +4773,7 @@ class TrainlogRepository(
|
||||||
|
|
||||||
RecordingMode.SETS -> {
|
RecordingMode.SETS -> {
|
||||||
if (
|
if (
|
||||||
draft.sets.isEmpty() ||
|
(draft.sets.isEmpty() && !(allowTargetOnly && draft.plan != null)) ||
|
||||||
draft.continuousDurationSeconds != 0 ||
|
draft.continuousDurationSeconds != 0 ||
|
||||||
draft.speedKmh != null ||
|
draft.speedKmh != null ||
|
||||||
draft.distanceKm != 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(
|
private fun lookupExerciseRowId(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
exerciseId: String,
|
exerciseId: String,
|
||||||
|
|
@ -4450,9 +4962,49 @@ private fun ContentValues.putOptionalString(
|
||||||
if (value == null) putNull(key) else put(key, value)
|
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? =
|
private fun JSONObject.optDoubleOrNull(key: String): Double? =
|
||||||
if (has(key) && !isNull(key)) getDouble(key) else null
|
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 =
|
private fun equipmentAliasNormalize(value: String): String =
|
||||||
Normalizer.normalize(value, Normalizer.Form.NFD)
|
Normalizer.normalize(value, Normalizer.Form.NFD)
|
||||||
.replace("\\p{M}+".toRegex(), "")
|
.replace("\\p{M}+".toRegex(), "")
|
||||||
|
|
@ -4463,6 +5015,10 @@ private const val ANDROID_DATABASE_NAME =
|
||||||
"trainlog-android.db"
|
"trainlog-android.db"
|
||||||
private const val ACTIVE_DRAFT_ID = 1
|
private const val ACTIVE_DRAFT_ID = 1
|
||||||
private const val MAX_DRAFT_FORM_TEXT_LENGTH = 4096
|
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 =
|
private const val ANDROID_LEG_PRESS_LEGACY_ID =
|
||||||
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
|
"ex_d68a1af1-7247-4fb3-a48b-da8516906a29"
|
||||||
private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
private const val DESKTOP_LEG_PRESS_CANONICAL_ID =
|
||||||
|
|
@ -4478,7 +5034,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
10,
|
11,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -4591,6 +5147,15 @@ private class TrainlogDatabaseHelper(
|
||||||
version = 10
|
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) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
"Unsupported Android DB upgrade " +
|
"Unsupported Android DB upgrade " +
|
||||||
|
|
@ -4939,6 +5504,15 @@ private class TrainlogDatabaseHelper(
|
||||||
REFERENCES equipment(id)
|
REFERENCES equipment(id)
|
||||||
ON DELETE SET NULL,
|
ON DELETE SET NULL,
|
||||||
entry_id TEXT NOT NULL UNIQUE,
|
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(
|
UNIQUE(
|
||||||
session_row_id,
|
session_row_id,
|
||||||
position
|
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(
|
private fun createBodyTable(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -5138,6 +5729,15 @@ private class TrainlogDatabaseHelper(
|
||||||
REFERENCES equipment(id)
|
REFERENCES equipment(id)
|
||||||
ON DELETE SET NULL,
|
ON DELETE SET NULL,
|
||||||
entry_id TEXT NOT NULL UNIQUE,
|
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)
|
UNIQUE(draft_id, position)
|
||||||
);
|
);
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,32 @@ data class SessionSetDraft(
|
||||||
val weightKg: Double? = null,
|
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(
|
data class SessionExerciseDraft(
|
||||||
/** Stable occurrence identity; exercise_id identifies only the catalogue movement. */
|
/** Stable occurrence identity; exercise_id identifies only the catalogue movement. */
|
||||||
val entryId: String = "sxe_" + java.util.UUID.randomUUID().toString(),
|
val entryId: String = "sxe_" + java.util.UUID.randomUUID().toString(),
|
||||||
|
|
@ -36,6 +62,7 @@ data class SessionExerciseDraft(
|
||||||
* mutually exclusive with sets/continuous data.
|
* mutually exclusive with sets/continuous data.
|
||||||
*/
|
*/
|
||||||
val maxWeightKg: Double? = null,
|
val maxWeightKg: Double? = null,
|
||||||
|
val plan: SessionExercisePlan? = null,
|
||||||
val sets: List<SessionSetDraft> = emptyList(),
|
val sets: List<SessionSetDraft> = emptyList(),
|
||||||
val continuousDurationSeconds: Int = 0,
|
val continuousDurationSeconds: Int = 0,
|
||||||
val speedKmh: Double? = null,
|
val speedKmh: Double? = null,
|
||||||
|
|
@ -96,6 +123,7 @@ data class SessionExerciseDetail(
|
||||||
val dataFields: Int,
|
val dataFields: Int,
|
||||||
/** Explicit max result; null also represents a preserved legacy max entry. */
|
/** Explicit max result; null also represents a preserved legacy max entry. */
|
||||||
val maxWeightKg: Double? = null,
|
val maxWeightKg: Double? = null,
|
||||||
|
val plan: SessionExercisePlan? = null,
|
||||||
val sets: List<SessionSetDraft> = emptyList(),
|
val sets: List<SessionSetDraft> = emptyList(),
|
||||||
val continuousDurationSeconds: Int = 0,
|
val continuousDurationSeconds: Int = 0,
|
||||||
val speedKmh: Double? = null,
|
val speedKmh: Double? = null,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ fun HomeScreen(
|
||||||
activeDraft: ActiveSessionDraft?,
|
activeDraft: ActiveSessionDraft?,
|
||||||
draftError: String?,
|
draftError: String?,
|
||||||
onSession: () -> Unit,
|
onSession: () -> Unit,
|
||||||
|
onGenerateSession: () -> Unit,
|
||||||
onDiscardDraft: () -> Unit,
|
onDiscardDraft: () -> Unit,
|
||||||
onExercise: () -> Unit,
|
onExercise: () -> Unit,
|
||||||
onBody: () -> Unit,
|
onBody: () -> Unit,
|
||||||
|
|
@ -112,6 +113,12 @@ fun HomeScreen(
|
||||||
onClick = onSession,
|
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(
|
TrainlogAction(
|
||||||
label =
|
label =
|
||||||
"Enregistrer un exercice",
|
"Enregistrer un exercice",
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,15 @@ fun SessionDetailScreen(
|
||||||
exercise.equipmentDisplayName?.let { equipment ->
|
exercise.equipmentDisplayName?.let { equipment ->
|
||||||
TrainlogInfo("Équipement : $equipment", color = colors.muted)
|
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) {
|
if (editingEntryId == exercise.entryId) {
|
||||||
TrainlogInputField(
|
TrainlogInputField(
|
||||||
label = "Rechercher une machine",
|
label = "Rechercher une machine",
|
||||||
|
|
|
||||||
|
|
@ -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('_', ' ')
|
||||||
|
}
|
||||||
|
|
@ -297,6 +297,19 @@ fun SessionScreen(
|
||||||
color =
|
color =
|
||||||
colors.text,
|
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(
|
TrainlogAction(
|
||||||
label = "Modifier ${draft.exercise.name}",
|
label = "Modifier ${draft.exercise.name}",
|
||||||
|
|
@ -382,7 +395,11 @@ fun SessionScreen(
|
||||||
currentDraft.copy(
|
currentDraft.copy(
|
||||||
exercises = editIndex?.let { replacingIndex ->
|
exercises = editIndex?.let { replacingIndex ->
|
||||||
currentDraft.exercises.mapIndexed { index, existing ->
|
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),
|
} ?: (currentDraft.exercises + draft),
|
||||||
form =
|
form =
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import com.labfytools.trainlog.data.SyncRequestOutbox
|
||||||
private enum class TrainlogScreenId {
|
private enum class TrainlogScreenId {
|
||||||
HOME,
|
HOME,
|
||||||
SESSION,
|
SESSION,
|
||||||
|
SESSION_GENERATOR,
|
||||||
EXERCISE,
|
EXERCISE,
|
||||||
BODY,
|
BODY,
|
||||||
HISTORY,
|
HISTORY,
|
||||||
|
|
@ -164,6 +165,10 @@ fun TrainlogApp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onGenerateSession = {
|
||||||
|
draftMessage = null
|
||||||
|
screen = TrainlogScreenId.SESSION_GENERATOR
|
||||||
|
},
|
||||||
onDiscardDraft = {
|
onDiscardDraft = {
|
||||||
when (
|
when (
|
||||||
val result =
|
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 ->
|
TrainlogScreenId.EXERCISE ->
|
||||||
ExerciseScreen(
|
ExerciseScreen(
|
||||||
repository = repository,
|
repository = repository,
|
||||||
|
|
|
||||||
|
|
@ -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() } }
|
||||||
|
}
|
||||||
|
|
@ -321,7 +321,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
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 ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ class RealAndroidV9BodyZonesMigrationTest {
|
||||||
SQLiteDatabase.openDatabase(
|
SQLiteDatabase.openDatabase(
|
||||||
migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE,
|
migratedPath.path, null, SQLiteDatabase.OPEN_READWRITE,
|
||||||
).use { migrated ->
|
).use { migrated ->
|
||||||
assertEquals(10, scalarInt(migrated, "PRAGMA user_version;"))
|
assertEquals(11, scalarInt(migrated, "PRAGMA user_version;"))
|
||||||
assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;"))
|
assertEquals("ok", scalarString(migrated, "PRAGMA integrity_check;"))
|
||||||
migrated.rawQuery("PRAGMA foreign_key_check;", null).use {
|
migrated.rawQuery("PRAGMA foreign_key_check;", null).use {
|
||||||
assertFalse(it.moveToFirst())
|
assertFalse(it.moveToFirst())
|
||||||
|
|
@ -81,9 +81,9 @@ class RealAndroidV9BodyZonesMigrationTest {
|
||||||
"active_session_draft", "body_observations",
|
"active_session_draft", "body_observations",
|
||||||
"catalog_exercise_equipment", "continuous_activity",
|
"catalog_exercise_equipment", "continuous_activity",
|
||||||
"draft_continuous_activity", "draft_max_results",
|
"draft_continuous_activity", "draft_max_results",
|
||||||
"draft_performed_sets", "draft_session_exercises", "equipment",
|
"draft_performed_sets", "equipment",
|
||||||
"equipment_aliases", "exercise_equipment", "exercises",
|
"equipment_aliases", "exercise_equipment", "exercises",
|
||||||
"max_results", "performed_sets", "session_exercises", "sessions",
|
"max_results", "performed_sets", "sessions",
|
||||||
)
|
)
|
||||||
historicalTables.forEach { table ->
|
historicalTables.forEach { table ->
|
||||||
/* Table names are a closed test constant; values remain bound
|
/* Table names are a closed test constant; values remain bound
|
||||||
|
|
@ -95,6 +95,17 @@ class RealAndroidV9BodyZonesMigrationTest {
|
||||||
"SELECT COUNT(*) FROM (SELECT * FROM before_v9.$table " +
|
"SELECT COUNT(*) FROM (SELECT * FROM before_v9.$table " +
|
||||||
"EXCEPT SELECT * FROM main.$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,
|
assertEquals(32, scalarInt(migrated,
|
||||||
"SELECT COUNT(*) FROM exercise_body_zones;"))
|
"SELECT COUNT(*) FROM exercise_body_zones;"))
|
||||||
assertEquals(20, scalarInt(migrated,
|
assertEquals(20, scalarInt(migrated,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.labfytools.trainlog.data
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.sqlite.SQLiteDatabase
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.BodyObservationDraft
|
import com.labfytools.trainlog.model.BodyObservationDraft
|
||||||
import com.labfytools.trainlog.model.ExerciseDataFields
|
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.SessionDraft
|
||||||
import com.labfytools.trainlog.model.SessionDraftForm
|
import com.labfytools.trainlog.model.SessionDraftForm
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
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.SessionSetDraft
|
||||||
import com.labfytools.trainlog.model.SessionType
|
import com.labfytools.trainlog.model.SessionType
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
|
|
@ -1502,7 +1505,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(10, cursor.getInt(0))
|
assertEquals(11, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -1629,7 +1632,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
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 ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
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 {
|
private fun openRepository(): TrainlogRepository {
|
||||||
return TrainlogRepository(context, databaseName).also { repository = it }
|
return TrainlogRepository(context, databaseName).also { repository = it }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
187
catalog/session-generation-policy-v1.json
Normal file
187
catalog/session-generation-policy-v1.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
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
|
Completed session occurrences persist an `entry_id`; it is never regenerated
|
||||||
for exchange. Android publishes `trainlog-mobile-export-v2.json` as the active
|
for exchange. Android publishes `trainlog-mobile-export-v3.json` as the active
|
||||||
desktop snapshot and imports `trainlog-pc-mobile-export-v2.json` after the PC
|
desktop snapshot and imports `trainlog-pc-mobile-export-v3.json` after the PC
|
||||||
catalogue. The artifact preserves occurrence order, continuous metrics, set
|
catalogue. The artifact preserves occurrence order, continuous metrics, set
|
||||||
weights and equipment. The legacy V1 contract remains separate and readable.
|
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
|
## 2. Implemented navigation
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -33,7 +37,7 @@ Accueil
|
||||||
Android local database version:
|
Android local database version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
10
|
11
|
||||||
```
|
```
|
||||||
|
|
||||||
Domain tables cover:
|
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
|
stores direct primary/secondary body-zone relations and their private sync
|
||||||
baseline; the taxonomy itself remains the shared manifest asset.
|
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
|
## 4. Exercise catalog
|
||||||
|
|
||||||
Exercise creation records:
|
Exercise creation records:
|
||||||
|
|
@ -266,17 +285,17 @@ bo_<uuid-v4>
|
||||||
Android maintains:
|
Android maintains:
|
||||||
|
|
||||||
```text
|
```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
|
session, body-observation, equipment association and PC-catalog updates. It
|
||||||
preserves `entry_id`, occurrence position, optional equipment and actual
|
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
|
`trainlog-equipment-associations-v2.json`; its `set` and `cleared` states are
|
||||||
targeted by `(session_id, entry_id)`.
|
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
|
definitions as `trainlog-mobile-equipment-definitions-v1.json`. The strict
|
||||||
`trainlog-equipment-definitions` v1 format uses stable IDs and the fields
|
`trainlog-equipment-definitions` v1 format uses stable IDs and the fields
|
||||||
`equipment_id`, `display_name`, `label_name`, `equipment_type`, and
|
`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
|
catalogue by display name, physical-machine label and aliases. The selected
|
||||||
canonical ID belongs to that session exercise entry, is durable in the active
|
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
|
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
|
same exercise in one session through `entry_id`. The frozen V1 artifacts remain
|
||||||
readable only as legacy artifacts and keep their historical one-exercise
|
readable only as legacy artifacts and keep their historical one-exercise
|
||||||
identity assumptions; V1 is not rewritten to claim V2 support.
|
identity assumptions; V1 is not rewritten to claim V2 support.
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ exercise_body_zone_sync
|
||||||
|
|
||||||
### Android
|
### 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;
|
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
|
resuming a completed Test max records its stable source session in the one
|
||||||
durable draft.
|
durable draft.
|
||||||
|
|
@ -212,6 +212,27 @@ screen navigation and data ownership remain independent from the header.
|
||||||
|
|
||||||
## 5. Compatibility boundaries
|
## 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
|
### Frozen Trainlog JSON v1
|
||||||
|
|
||||||
`TRAINLOG_FORMAT_V1` is frozen and remains a compatibility boundary for its
|
`TRAINLOG_FORMAT_V1` is frozen and remains a compatibility boundary for its
|
||||||
|
|
@ -224,6 +245,7 @@ Synchronization uses separate formats:
|
||||||
```text
|
```text
|
||||||
trainlog-mobile-export v1
|
trainlog-mobile-export v1
|
||||||
trainlog-mobile-export v2
|
trainlog-mobile-export v2
|
||||||
|
trainlog-mobile-export v3
|
||||||
trainlog-pc-catalog v1
|
trainlog-pc-catalog v1
|
||||||
trainlog-equipment-associations v2
|
trainlog-equipment-associations v2
|
||||||
trainlog-equipment-definitions v1
|
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,
|
A new domain requirement must not be forced into frozen v1 by using notes,
|
||||||
synthetic sets, or data loss.
|
synthetic sets, or data loss.
|
||||||
|
|
||||||
The active occurrence-aware session exchange remains
|
The active occurrence-aware session exchange is
|
||||||
`trainlog-mobile-export` v2. The separate directional
|
`trainlog-mobile-export` v3. V2 remains a readable historical artifact. The separate directional
|
||||||
`trainlog-equipment-definitions` v1 artifacts carry user-created equipment
|
`trainlog-equipment-definitions` v1 artifacts carry user-created equipment
|
||||||
definitions: `trainlog-mobile-equipment-definitions-v1.json` travels from
|
definitions: `trainlog-mobile-equipment-definitions-v1.json` travels from
|
||||||
Android to PC and `trainlog-pc-equipment-definitions-v1.json` travels from PC
|
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
|
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,
|
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.
|
sessions and body observations), and equipment-associations v2.
|
||||||
Both directions also transfer the same body-zone companion after exercise
|
Both directions also transfer the same body-zone companion after exercise
|
||||||
definitions are established and before completion of the direction.
|
definitions are established and before completion of the direction.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Current implementation state
|
# 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
|
This document is the compact source of truth for the implemented Trainlog
|
||||||
baseline. Detailed behavior belongs in the topic-specific documents.
|
baseline. Detailed behavior belongs in the topic-specific documents.
|
||||||
|
|
@ -16,6 +16,7 @@ TRAINLOG_FORMAT_V1=FROZEN
|
||||||
|
|
||||||
DESKTOP_SCHEMA_V11=PASS
|
DESKTOP_SCHEMA_V11=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V10=PASS
|
ANDROID_LOCAL_DATABASE_V10=PASS
|
||||||
|
ANDROID_LOCAL_DATABASE_V11=PASS
|
||||||
ANDROID_SESSION_DRAFT_V1=PASS
|
ANDROID_SESSION_DRAFT_V1=PASS
|
||||||
ANDROID_DRAFT_DURABLE=PASS
|
ANDROID_DRAFT_DURABLE=PASS
|
||||||
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
ANDROID_DRAFT_BACKGROUND_SURVIVAL=PASS
|
||||||
|
|
@ -63,8 +64,9 @@ BODY_ZONES_TUI_REAL_VALIDATION=PASS
|
||||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
||||||
|
|
||||||
TRAINING_KNOWLEDGE_V1=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
|
ANDROID_BUILD=PASS
|
||||||
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
HARDWARE_SYNC_VALIDATION=HISTORICAL_PASS
|
||||||
```
|
```
|
||||||
|
|
@ -120,7 +122,7 @@ Primary navigation:
|
||||||
Implemented:
|
Implemented:
|
||||||
|
|
||||||
- native Kotlin/Compose application;
|
- 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;
|
- one durable active-session draft, Home resume and raw-form restoration;
|
||||||
- explicit confirmed discard and atomic completed-save/draft-clear;
|
- explicit confirmed discard and atomic completed-save/draft-clear;
|
||||||
- exercise creation;
|
- 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
|
catalogs as the sole authored scientific source, generates the immutable C
|
||||||
catalog representation, and loads the same assets on Android. It has no
|
catalog representation, and loads the same assets on Android. It has no
|
||||||
database migration, no auto-seeding, and no synchronization artifact. The
|
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
|
Desktop `training_knowledge.h` and Android `TrainingKnowledgeCatalog` expose
|
||||||
source-linked science lookups and resolved-candidate filters. Desktop
|
source-linked science lookups and resolved-candidate filters. Desktop
|
||||||
|
|
@ -211,14 +213,14 @@ Artifacts:
|
||||||
```text
|
```text
|
||||||
Android -> PC
|
Android -> PC
|
||||||
trainlog-mobile-equipment-definitions-v1.json
|
trainlog-mobile-equipment-definitions-v1.json
|
||||||
trainlog-mobile-export-v2.json
|
trainlog-mobile-export-v3.json
|
||||||
trainlog-equipment-associations-v2.json
|
trainlog-equipment-associations-v2.json
|
||||||
trainlog-exercise-body-zones-v1.json
|
trainlog-exercise-body-zones-v1.json
|
||||||
|
|
||||||
PC -> Android
|
PC -> Android
|
||||||
trainlog-pc-equipment-definitions-v1.json
|
trainlog-pc-equipment-definitions-v1.json
|
||||||
trainlog-pc-catalog-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-equipment-associations-v2.json
|
||||||
trainlog-exercise-body-zones-v1.json
|
trainlog-exercise-body-zones-v1.json
|
||||||
|
|
||||||
|
|
@ -287,6 +289,39 @@ No SQLite file is copied.
|
||||||
|
|
||||||
No mounted Android filesystem is required.
|
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
|
## Validation checkpoint
|
||||||
|
|
||||||
Desktop:
|
Desktop:
|
||||||
|
|
|
||||||
|
|
@ -429,7 +429,23 @@ Desktop and Android schema versions are not required to match.
|
||||||
|
|
||||||
Do not synchronize SQLite database files.
|
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
|
```bash
|
||||||
meson compile -C build
|
meson compile -C build
|
||||||
|
|
|
||||||
|
|
@ -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
|
kilogram labels. Equipment, resistance semantics and execution context remain
|
||||||
explicit. There are no prescriptions in V1.
|
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,
|
## Session-generation boundary
|
||||||
scoring algorithm, proposal, database change, or user-interface behavior.
|
|
||||||
|
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
|
```text
|
||||||
Session inputs
|
Session inputs
|
||||||
|
|
@ -116,8 +122,8 @@ Session inputs
|
||||||
-> real resolved candidates
|
-> real resolved candidates
|
||||||
-> explicit availability
|
-> explicit availability
|
||||||
-> recent history and explicit-MAX context
|
-> recent history and explicit-MAX context
|
||||||
-> future fatigue/recent-coverage interpretation
|
-> recorded exposure/recency interpretation
|
||||||
-> a future proposal
|
-> session-generator proposal
|
||||||
```
|
```
|
||||||
|
|
||||||
Candidate selection must seek diverse movement patterns rather than repeatedly
|
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
|
progression, equipment and history, then reason across sessions. Medical
|
||||||
constraints would be explicit inputs; no diagnosis or rehabilitation status is
|
constraints would be explicit inputs; no diagnosis or rehabilitation status is
|
||||||
to be inferred. V1 prescribes no exercises, weights, sets, fatigue scores,
|
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.
|
||||||
|
|
|
||||||
387
docs/domain/session_generation.md
Normal file
387
docs/domain/session_generation.md
Normal 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` | 1–3 | 8–12 | 60–120 seconds |
|
||||||
|
| `strength` | 2–3 | 5–8 | 180–300 seconds |
|
||||||
|
| `hypertrophy` | 2–3 | 8–12 | 90–180 seconds |
|
||||||
|
| `endurance` | 1–3 | 15–20 | 45–90 seconds |
|
||||||
|
|
||||||
|
Endpoints are inclusive practical guidance, not validated optimal intervals or
|
||||||
|
hard physiological limits. The generator uses the single defaults unless values
|
||||||
|
are explicitly overridden. An override requires recalculating both duration and
|
||||||
|
load qualification using the proposed dose; it must not retain a load qualified
|
||||||
|
for fewer repetitions or fewer sets. Goal ranges overlap because adaptations
|
||||||
|
are not confined to mutually exclusive repetition bands. The historical
|
||||||
|
prescription principles above support their direction; these exact endpoints
|
||||||
|
remain authored conventions, including the conservative cap of three sets.
|
||||||
|
|
||||||
|
These are editable, moderate-volume defaults, **not RM tests**, exclusive
|
||||||
|
adaptation ranges, weekly targets or proof of effective effort. `endurance`
|
||||||
|
means local muscular endurance in this bounded resistance-session generator;
|
||||||
|
it does not prescribe aerobic conditioning. Three sets is not necessarily
|
||||||
|
better than two for an individual. A strength-labelled session without a
|
||||||
|
well-characterized challenging load cannot promise strength-specific loading.
|
||||||
|
|
||||||
|
The rest review suggests a small hypertrophy advantage to resting more than
|
||||||
|
60 seconds, with considerable uncertainty and little detected difference beyond
|
||||||
|
90 seconds. A 120-second hypertrophy default is therefore a convenient allowance,
|
||||||
|
not a scientifically exact optimum. Users may rest longer; the time estimate
|
||||||
|
then changes in practice.
|
||||||
|
[Singer and colleagues, 2024](https://pubmed.ncbi.nlm.nih.gov/39205815/)
|
||||||
|
|
||||||
|
Failure is not generally required for adaptation in reviewed comparisons, but
|
||||||
|
this does not mean arbitrarily easy sets provide equivalent stimulus. Trainlog
|
||||||
|
does not measure proximity to failure, technique or effort. Guidance is to use
|
||||||
|
a controllable load and finish with repetitions still possible, adjusting
|
||||||
|
downward or stopping if the intended repetitions and technique cannot be
|
||||||
|
maintained. No exact repetitions-in-reserve value is inferred or stored.
|
||||||
|
[Grgic and colleagues](https://pubmed.ncbi.nlm.nih.gov/33497853/)
|
||||||
|
|
||||||
|
## Candidate identity, movement and BODY ZONES
|
||||||
|
|
||||||
|
Candidates require an existing runtime exercise UUID, `SETS + REPS`, a resolved
|
||||||
|
scientific interpretation with `high` or `moderate` confidence, and an explicitly
|
||||||
|
compatible available equipment context. Conditional or unknown interpretations
|
||||||
|
remain excluded with a reason. Flexible equipment and unlinked capabilities
|
||||||
|
cannot create an exercise identity. No display-name matching is permitted.
|
||||||
|
|
||||||
|
`upper_body` expands to chest, back, shoulders and arms; `lower_body` to glutes,
|
||||||
|
thighs and calves; `full_body` to those seven leaf zones plus core. This is a
|
||||||
|
generator request expansion, not an alteration of the standalone `full_body`
|
||||||
|
catalog zone or a reason to relabel a cardio activity. A requested leaf admits
|
||||||
|
primary or explicit secondary scientific matches, preferring primary matches.
|
||||||
|
Scientific and persisted zone disagreements are explained, never silently fixed.
|
||||||
|
|
||||||
|
The existing anatomy and pattern catalogs remain authoritative. Knee extension
|
||||||
|
and knee flexion, for example, remain distinct functions despite the shared
|
||||||
|
`thighs` zone. Pattern IDs are programming abstractions grounded in joint actions;
|
||||||
|
they do not measure force distribution or equal training dose. See
|
||||||
|
[anatomy and movement](anatomy_and_movement.md) and
|
||||||
|
[equipment interpretation](exercise_equipment_interpretation.md).
|
||||||
|
|
||||||
|
Selection favors purposeful functional variety, not randomized novelty. The
|
||||||
|
variation review supports considering systematic variation but is small and
|
||||||
|
largely restricted to young men; it does not validate any exact rotation or
|
||||||
|
diversity score.
|
||||||
|
[Kassiano and colleagues](https://pubmed.ncbi.nlm.nih.gov/35438660/)
|
||||||
|
|
||||||
|
At most six exercises are selected, each exercise UUID once, with at most one
|
||||||
|
candidate carrying any already-selected exact pattern ID. Thus two rows or two
|
||||||
|
leg-curl identities do not fill a session with the same pattern. This deliberate
|
||||||
|
V1 cap may omit useful within-pattern variation; it is not a biological law.
|
||||||
|
|
||||||
|
Optional `preferred_exercise_ids` gives each matching exact UUID a **+15** soft
|
||||||
|
score bonus, once regardless of duplicate requested IDs. Optional
|
||||||
|
`excluded_exercise_ids` and `excluded_pattern_ids` are hard exclusions; exclude
|
||||||
|
a candidate if its UUID is excluded or any of its scientific pattern IDs is
|
||||||
|
excluded. Exclusion wins when an exercise is both preferred and excluded.
|
||||||
|
Availability, reviewed scientific eligibility, exclusions, duplicate/diversity
|
||||||
|
limits and time fit apply before coverage priorities and score. Preference
|
||||||
|
cannot create an unavailable, conditional or unknown candidate. Unknown exercise
|
||||||
|
IDs are reported as unavailable/unmatched; unknown pattern IDs are invalid
|
||||||
|
request input. None of these request options changes the catalog or history.
|
||||||
|
|
||||||
|
Before scoring, prefer candidates in an uncovered region for `full_body`
|
||||||
|
(upper, lower, core). Region membership uses the candidate's scientific primary
|
||||||
|
zone. For `upper_body`, prefer an uncovered push/pull class if a fitting candidate
|
||||||
|
exists. For `lower_body`, similarly prefer the uncovered extension/flexion
|
||||||
|
classes listed in the policy. If several priority classes remain uncovered,
|
||||||
|
compare all their fitting candidates by score; once covered or unavailable,
|
||||||
|
use the whole remaining eligible pool. Region/pattern coverage limitations must
|
||||||
|
remain visible; they do not certify a balanced program.
|
||||||
|
|
||||||
|
Score each eligible candidate as follows. Apply each boolean term once; the
|
||||||
|
two base match scores are mutually exclusive. Recompute after each selection.
|
||||||
|
|
||||||
|
| Condition | Points |
|
||||||
|
|---|---:|
|
||||||
|
| Scientific primary matches requested expansion | +100 |
|
||||||
|
| Only a scientific secondary matches | +60 |
|
||||||
|
| Primary zone not yet selected | +20 |
|
||||||
|
| Pattern not yet selected | +30 |
|
||||||
|
| Qualifying repeated-set load history | +5 |
|
||||||
|
| Explicitly preferred exercise UUID | +15 |
|
||||||
|
| Same exercise actually performed within 72 hours | -25 |
|
||||||
|
| Same scientific pattern actually performed within 7 days | -15 |
|
||||||
|
| Primary-zone primary count reaches 24-hour threshold | -30 |
|
||||||
|
| Primary-zone secondary count reaches 24-hour threshold | -10 |
|
||||||
|
| Primary-zone primary count reaches 72-hour threshold | -20 |
|
||||||
|
| Primary-zone secondary count reaches 72-hour threshold | -10 |
|
||||||
|
| Any candidate secondary zone has any exposure flag | -10 |
|
||||||
|
|
||||||
|
Take the highest score that fits the remaining time estimate, breaking ties by
|
||||||
|
bytewise exercise UUID and then equipment ID. Equipment choice prioritizes the
|
||||||
|
newest qualifying repeated-set anchor, then bytewise equipment ID; MAX does not choose the context. Occurrence recency ties use the shared exact instant
|
||||||
|
comparator and bytewise session/occurrence identity ordering. These scores and
|
||||||
|
priorities are deterministic software conventions; none is calibrated to an
|
||||||
|
adaptation effect size or injury probability.
|
||||||
|
|
||||||
|
Same-exercise recency requires at least one actual positive-repetition performed
|
||||||
|
row for the exact UUID in completed history with `0 <= age < 259200` seconds.
|
||||||
|
This selection penalty applies across equipment contexts, while numeric load
|
||||||
|
reuse remains restricted to the exact equipment context. Same-pattern recency
|
||||||
|
requires an actual row whose resolved scientific pattern IDs intersect the
|
||||||
|
candidate's pattern IDs, with `0 <= age < 604800` seconds. Apply each penalty
|
||||||
|
once, even if several rows or patterns match, and add both to the zone terms.
|
||||||
|
Targets, MAX-only records and empty occurrences satisfy neither condition.
|
||||||
|
Exactly 72 hours or 7 days is outside its respective window. These are soft
|
||||||
|
priorities, not prohibited intervals between exercises.
|
||||||
|
|
||||||
|
For example, otherwise equivalent same-pattern candidates A and B both receive
|
||||||
|
the pattern penalty, but only recently performed A receives the additional
|
||||||
|
25-point identity penalty. Even A's 5-point working-history bonus does not erase
|
||||||
|
that difference; B can rank higher. Purposeful repetition remains permissible
|
||||||
|
when alternatives are missing, coverage differs or other score terms prevail.
|
||||||
|
|
||||||
|
The current knowledge inventory has no resolved chest exercise and no linked
|
||||||
|
resolved calf exercise. The custom chest entries remain conditional. A chest
|
||||||
|
or calf request can therefore produce an explicit shortage; other requests may
|
||||||
|
be shorter or incomplete. Do not repair these gaps by guessing anatomy from
|
||||||
|
equipment names. Scientific identity work is a separate future task.
|
||||||
|
|
||||||
|
## Observed-load reuse, explicit MAX and missing load
|
||||||
|
|
||||||
|
Numeric load reuse requires the exact exercise UUID, a non-null exact equipment
|
||||||
|
ID, external-load semantics and no known conflict in execution context. Different
|
||||||
|
machines, pulley contexts, load modes and variants never share anchors. Unknown
|
||||||
|
settings, range of motion and technique remain limitations even when recorded
|
||||||
|
IDs match. All proposals carry their source occurrence, date, context and
|
||||||
|
confidence; no numeric result is labelled a verified safe working weight.
|
||||||
|
|
||||||
|
Use this order:
|
||||||
|
|
||||||
|
1. Search completed history within 28 elapsed days, inclusive, for the newest
|
||||||
|
occurrence with at least the proposed number of actual sets having positive
|
||||||
|
finite weight and actual repetitions greater than or equal to the proposed
|
||||||
|
repetitions. Reuse the minimum weight among those qualifying rows. For three
|
||||||
|
proposed sets of 10, actual sets `10x50, 12x55, 10x50` support 50 kg. Actual
|
||||||
|
sets `10x50, 8x50, 6x50` do not establish three sets of 10. Targets never
|
||||||
|
satisfy this condition. The minimum is a conservative choice, not a test of
|
||||||
|
effort. Other nonqualifying rows remain history, not fabricated successes.
|
||||||
|
2. If no qualifying repeated dose exists, leave weight absent. A compatible
|
||||||
|
explicit MAX may be displayed as historical context with
|
||||||
|
`explicit_max_present_no_numeric_prescription`, but supplies no numeric target.
|
||||||
|
3. Do not substitute zero, an invented average, another exercise's result,
|
||||||
|
a percentage of MAX, or a random default.
|
||||||
|
|
||||||
|
Observed-load reuse has confidence **`uncertain`** for today's prescription.
|
||||||
|
"Successful" or "working" here means only that a qualifying repeated dose was
|
||||||
|
recorded as performed. Trainlog cannot separate warmup from work reliably,
|
||||||
|
verify technique, know whether the set ended at failure, certify safety, or
|
||||||
|
detect all changes in readiness. Reusing observed load does not prove future
|
||||||
|
completion. An older qualifying occurrence is historical evidence, not automatic
|
||||||
|
progression over a more recent different performance. Its source must be visible.
|
||||||
|
|
||||||
|
An explicit MAX is an occurrence-owned observed maximum result. Its model does
|
||||||
|
not contain a repetition count and is mutually exclusive with performed sets.
|
||||||
|
It therefore cannot be qualified as a measured 1RM from this record alone.
|
||||||
|
**No percentage prescription follows from an unqualified explicit MAX.**
|
||||||
|
The original proposal's 50% fallback was rejected by the architecture review
|
||||||
|
because it contradicts this existing canonical boundary. The same absent-load
|
||||||
|
fallback applies to every goal. The 28-day actual-anchor cutoff remains a
|
||||||
|
freshness convention, not a physiological detraining boundary. A MAX-only
|
||||||
|
occurrence never creates a performed-set count.
|
||||||
|
|
||||||
|
External load and assistance are distinct. V1 omits numeric assistance and
|
||||||
|
bodyweight proposals. More assistance generally reduces unsupported demand;
|
||||||
|
neither halving assistance nor subtracting it from body mass is an external-load
|
||||||
|
prescription. Preserve an assistance explanation and equipment identity. Machine
|
||||||
|
increment availability is unknown: retain a qualifying observed value unchanged
|
||||||
|
as an indicative editable value, and invent no increment or rounding. See
|
||||||
|
[MAX context](programming_foundations.md).
|
||||||
|
|
||||||
|
Legacy Android/V2 actual-only occurrences store planned mode `none`, zero rest
|
||||||
|
and absent targets even when actual sets have positive weight. They may qualify
|
||||||
|
only with the exact known compatible external equipment and all repeated-dose
|
||||||
|
conditions above. This transient compatibility rule also applies to V3
|
||||||
|
actual-only rows; it never rewrites history or accepts assistance/unknown context.
|
||||||
|
Planning mode is separate: an absent-weight generated plan uses `none` under
|
||||||
|
the existing desktop invariant, retaining equipment resistance context separately.
|
||||||
|
A present observed target weight uses `external`.
|
||||||
|
|
||||||
|
There is no progression, estimated 1RM, future schedule or automatic load increase.
|
||||||
|
No automatic MAX-derived fallback or validated personalized intensity is claimed.
|
||||||
|
|
||||||
|
## Recorded exposure and recency
|
||||||
|
|
||||||
|
Use the settled exact timestamp policy of `TRAINING_KNOWLEDGE_V1`. Reference
|
||||||
|
time is an explicit input. Define age as elapsed seconds between reference and
|
||||||
|
occurrence instant. Exposure windows are `0 <= age < 86400` and
|
||||||
|
`0 <= age < 259200`; exactly 24 hours is outside the short window and exactly
|
||||||
|
72 hours outside the long window. Calendar dates and a universal 48-hour rule
|
||||||
|
play no role.
|
||||||
|
|
||||||
|
Count actual positive-repetition `SETS + REPS` rows from completed history,
|
||||||
|
deduplicated by occurrence/set identity. Weight need not be present. Each row
|
||||||
|
counts once for its resolved scientific primary zone and once for each distinct
|
||||||
|
explicit secondary zone, in **separate integer counters**. Never sum those
|
||||||
|
counters into equivalent effective sets or give secondary roles a claimed
|
||||||
|
fractional biological dose. Parent zones and muscle groups are not counted
|
||||||
|
again. Unknown or conditional history remains unclassified and visible as a
|
||||||
|
knowledge gap. Use neither exercise-name guesses nor target counts.
|
||||||
|
|
||||||
|
Requested-zone summaries require an additional aggregation rule: expand the
|
||||||
|
request, then classify each distinct actual row **once**. Count it as primary
|
||||||
|
if its scientific primary is inside the expansion; otherwise count it as
|
||||||
|
secondary if any scientific secondary is inside the expansion. Primary wins
|
||||||
|
when both primary and secondary descendants match. Never construct a parent
|
||||||
|
total by adding leaf-zone counters. A row with primary `back` and secondary
|
||||||
|
`arms` counts once as primary for `upper_body`, and once as secondary for an
|
||||||
|
`arms` request; the two requested summaries are separate views of the same row.
|
||||||
|
|
||||||
|
Within each 24/72-hour summary, `set_count = primary_count + secondary_count`
|
||||||
|
is the deduplicated count of actual rows, not equivalent physiological sets.
|
||||||
|
`session_count` counts distinct matching session IDs, and `pattern_ids` is the
|
||||||
|
sorted distinct union of scientific patterns from exactly those matching rows.
|
||||||
|
The latest exposure is the newest matching nonfuture actual-row occurrence in
|
||||||
|
the complete queried history, even if it is outside both windows. Preserve its
|
||||||
|
source session/occurrence identities and scientific patterns; use the existing
|
||||||
|
exact timestamp and identity tie policy. Last exposure is absent when none is
|
||||||
|
observed, not an inferred recovery date. Reader truncation cannot present an
|
||||||
|
incomplete latest-exposure or count summary as complete. The same eligible rows,
|
||||||
|
identity deduplication and temporal comparator underlie selection recency.
|
||||||
|
|
||||||
|
Drafts, targets without performance, zero-set occurrences, MAX-only results,
|
||||||
|
continuous activity and valid future occurrences contribute zero
|
||||||
|
to these resistance-set counters. This does not claim MAX attempts or aerobic
|
||||||
|
activity cause no fatigue; they simply have no comparable recorded set dose.
|
||||||
|
An occurrence with target three sets but one actual set contributes one.
|
||||||
|
|
||||||
|
| Informational flag | Primary count | Secondary count |
|
||||||
|
|---|---:|---:|
|
||||||
|
| `recent_exposure` within 24 hours | at least 1 | or at least 3 |
|
||||||
|
| `repeated_exposure` within 72 hours | at least 6 | or at least 12 |
|
||||||
|
|
||||||
|
Both flags may be present. Below the thresholds use `no_threshold_observed`,
|
||||||
|
never `recovered` or `safe`. Missing or unclassified history means insufficient
|
||||||
|
information. No arbitrary reader preview limit may silently undercount the
|
||||||
|
windows; paginate or expose incomplete history and withhold a complete-summary
|
||||||
|
claim. Thresholds are prioritization conventions and warning triggers only.
|
||||||
|
|
||||||
|
Expose exactly one level in addition to the flags: **`warning`** if either
|
||||||
|
primary threshold is met; otherwise **`notice`** if either secondary threshold
|
||||||
|
is met; otherwise **`none`** after successful analysis. Secondary-only exposure
|
||||||
|
never creates `warning`. For example three actual primary sets in 24 hours
|
||||||
|
produce `warning`; three secondary-only sets produce `notice`; one primary set
|
||||||
|
produces `warning`, while one secondary-only set reaches no threshold. These
|
||||||
|
levels express the strength of the recorded direct-versus-indirect targeting
|
||||||
|
signal, not clinical severity. All remain nonblocking. For grouped requests,
|
||||||
|
apply the thresholds to the deduplicated requested-zone primary/secondary counts
|
||||||
|
above, not sums of descendant warnings.
|
||||||
|
|
||||||
|
An invalid reference instant fails request validation. An invalid stored
|
||||||
|
timestamp encountered by a required history reader **fails analysis explicitly**
|
||||||
|
under the existing temporal contract: return the specific error and **no complete
|
||||||
|
exposure or generation result**. Never discard malformed instants as if they
|
||||||
|
were outside the window. This applies to load anchoring, latest-exposure lookup,
|
||||||
|
set summaries and recency penalties alike. Missing history and a reader error
|
||||||
|
are distinct outcomes; the error is not `none`, `notice` or `warning`.
|
||||||
|
|
||||||
|
Acute fatigue differs with failure and protocol. A systematic review found
|
||||||
|
greater fatigue after failure conditions; a small trained-men experiment found
|
||||||
|
different recovery courses even when total repetition volume was matched.
|
||||||
|
Neither permits recovery estimation from only logged set counts and body zones.
|
||||||
|
[Vieira and colleagues](https://pubmed.ncbi.nlm.nih.gov/34881412/),
|
||||||
|
[Moran-Navarro and colleagues](https://pubmed.ncbi.nlm.nih.gov/28965198/)
|
||||||
|
|
||||||
|
Explain recent recorded exposure and preserve the user's ability to continue,
|
||||||
|
modify the proposal or choose another zone. Do not convert flags into a clinical
|
||||||
|
restriction, recovered percentage or predicted injury risk. No score changes
|
||||||
|
the saved anatomy or history.
|
||||||
|
|
||||||
|
## Duration and incomplete coverage
|
||||||
|
|
||||||
|
Presets are 30, 45 and 60 minutes; custom input is an integer from 10 to 120
|
||||||
|
minutes. Bounds are interface conventions, not exercise-health thresholds.
|
||||||
|
Estimate 300 seconds for preparation and, per exercise:
|
||||||
|
|
||||||
|
```text
|
||||||
|
60 + sets * repetitions * 4 + (sets - 1) * rest_seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
The 60 seconds allow setup/transition. Four seconds per repetition is a planning
|
||||||
|
estimate, not a compulsory tempo. Preparation does not create a fake exercise
|
||||||
|
or performed set and is not an individualized warmup prescription. Sum these
|
||||||
|
terms without counting rest after the final set. Select only whole exercise
|
||||||
|
blocks fitting the remaining budget; never shrink rest or add redundant work
|
||||||
|
to fill the requested duration. Actual equipment queues, setup and rest can
|
||||||
|
increase time. Show estimated selected duration and any unfilled request.
|
||||||
|
|
||||||
|
For example, one hypertrophy block costs 420 seconds; three blocks plus the
|
||||||
|
300-second preparation allowance cost 1560 seconds (26 minutes). This is a
|
||||||
|
duration estimate, not evidence that 26 minutes is an optimal session.
|
||||||
|
|
||||||
|
## Implementation boundary
|
||||||
|
|
||||||
|
Generation must remain read-only until explicit acceptance into the normal
|
||||||
|
capture flow. Proposed sets, repetitions, rest and load are targets, never
|
||||||
|
performed history. The accepted architecture adds nullable targets and explicit rest/load fields
|
||||||
|
to Android draft/completed occurrences through an additive v10-to-v11 migration.
|
||||||
|
A separate session exchange V3 preserves those normal fields atomically with
|
||||||
|
actual values and stable identities; V1/V2 remain readable and unchanged.
|
||||||
|
Preview performs no write. Explicit acceptance creates a normal active draft,
|
||||||
|
with a non-mutating conflict if another draft exists. See the
|
||||||
|
[architecture decision](../reviews/session_generator_v1_architecture_review.md).
|
||||||
|
No notes, fake performed sets, MAX reinterpretation or frozen exchange overload
|
||||||
|
is permitted. The initial final audit found repairable V3 timestamp,
|
||||||
|
documentation-bound, and shared-full-parity gaps; its one bounded repair chain
|
||||||
|
and final validation are complete.
|
||||||
|
|
||||||
|
Scientific confidence is `high` for the anatomy/context distinctions,
|
||||||
|
`moderate` for broad goal-template interpretation, and `uncertain` for individual
|
||||||
|
load, effort, recovery and exact heuristic effectiveness. The generator has no
|
||||||
|
clinical or individualized rehabilitation scope and makes no long-term outcome
|
||||||
|
guarantee from one session.
|
||||||
|
|
@ -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,
|
validation and review are complete; incompatible new semantics require a new,
|
||||||
separately versioned format.
|
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
|
## 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.
|
Trainlog v1 must represent the training patterns required by the initial applications without turning the Android recorder into a complex training platform.
|
||||||
|
|
|
||||||
|
|
@ -107,8 +107,9 @@ general name rule.
|
||||||
The read surface supports zone lookup, children, ancestors, direct relations,
|
The read surface supports zone lookup, children, ancestors, direct relations,
|
||||||
primary/secondary selection and exercises for a zone with optional descendants
|
primary/secondary selection and exercises for a zone with optional descendants
|
||||||
or primary-only participation. Those exercise IDs compose with existing
|
or primary-only participation. Those exercise IDs compose with existing
|
||||||
performance/MAX history readers, so a future session generator needs no new
|
performance/MAX history readers, so the separate session generator needs no new
|
||||||
duplicated MAX or history storage. No generator or load proposal exists yet.
|
duplicated MAX or history storage. The generator's proposals remain optional
|
||||||
|
planning metadata and do not alter historical actual work.
|
||||||
|
|
||||||
## 4. Load semantics
|
## 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.
|
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
|
Desktop schema v5 therefore allows an imported set session to have no synthetic
|
||||||
uniform target:
|
uniform target:
|
||||||
|
|
||||||
|
|
|
||||||
177
docs/reviews/session_generator_v1_architecture_review.md
Normal file
177
docs/reviews/session_generator_v1_architecture_review.md
Normal 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.
|
||||||
70
docs/reviews/session_generator_v1_engineering_review.md
Normal file
70
docs/reviews/session_generator_v1_engineering_review.md
Normal 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.
|
||||||
81
docs/reviews/session_generator_v1_final_review.md
Normal file
81
docs/reviews/session_generator_v1_final_review.md
Normal 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.
|
||||||
112
docs/reviews/session_generator_v1_resume.md
Normal file
112
docs/reviews/session_generator_v1_resume.md
Normal 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.
|
||||||
187
docs/reviews/session_generator_v1_scientific_delta_review.md
Normal file
187
docs/reviews/session_generator_v1_scientific_delta_review.md
Normal 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.
|
||||||
142
docs/reviews/session_generator_v1_scientific_review.md
Normal file
142
docs/reviews/session_generator_v1_scientific_review.md
Normal 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 1–3 sets / 8–12
|
||||||
|
repetitions / 60–120 seconds; 2–3 / 5–8 / 180–300; 2–3 / 8–12 / 90–180;
|
||||||
|
and 1–3 / 15–20 / 45–90. Exact endpoints are practical conventions, not
|
||||||
|
optimal physiological boundaries. Dose edits recompute duration and load
|
||||||
|
qualification. No progression.
|
||||||
|
2. Prefer recent repeated actual-dose evidence in the exact exercise and
|
||||||
|
external equipment context, with an inclusive 28-day freshness window.
|
||||||
|
At least the proposed set count must meet the proposed repetitions; use the
|
||||||
|
minimum qualifying weight. "Working" means recorded completion only.
|
||||||
|
3. A compatible explicit MAX can support a numeric fallback at half the
|
||||||
|
recorded load, rounded downward to 0.1 kg, in the same freshness window.
|
||||||
|
This was explicitly examined because MAX lacks repetitions: it is legitimate
|
||||||
|
only as an **uncertain, unvalidated starting-load convention**, not percent
|
||||||
|
1RM, guaranteed safe weight or predicted rep capacity. Same coefficient for
|
||||||
|
all goals avoids invented goal-specific precision. This distinction must
|
||||||
|
survive implementation and user-facing presentation.
|
||||||
|
4. Assistance/bodyweight/unknown context leaves numeric weight absent.
|
||||||
|
5. Distinct primary and secondary actual-set counters use rolling 24/72-hour
|
||||||
|
windows with exact timestamps. Thresholds are respectively 1/3 and 6/12
|
||||||
|
primary/secondary sets. Any primary threshold produces `warning`, otherwise
|
||||||
|
any secondary threshold produces `notice`, otherwise `none` after successful
|
||||||
|
analysis. These are nonblocking targeting/exposure signals, not clinical
|
||||||
|
severity. Secondary-only exposure never produces `warning`.
|
||||||
|
6. Primary-zone fit, function diversity, available equipment and existing
|
||||||
|
history guide deterministic selection. Pattern duplication and missing
|
||||||
|
science cannot be concealed by invented candidates.
|
||||||
|
7. Six-exercise cap and the `300 + sum(60 + sets*reps*4 + (sets-1)*rest)` time
|
||||||
|
estimate are engineering conventions. Incomplete coverage/time is explicit.
|
||||||
|
8. Proposed targets never become performed rows. Preservation of targets in
|
||||||
|
Android requires the architecture decision still pending.
|
||||||
|
9. Exact optional preferred UUIDs add +15 once. Excluded UUIDs or any intersecting
|
||||||
|
excluded pattern IDs are hard exclusions. Availability, science, exclusion,
|
||||||
|
diversity and time-fit rules win over preference. Unknown pattern IDs are
|
||||||
|
request errors; unmatched exercise IDs never create candidates.
|
||||||
|
10. Actual same-exercise performance within `0 <= age < 259200` seconds adds
|
||||||
|
-25; actual resolved same-pattern performance within `0 <= age < 604800`
|
||||||
|
adds -15. Each applies once and adds to zone penalties. They are soft
|
||||||
|
selection priorities, not biological cooldowns. Equipment context remains
|
||||||
|
strict for numeric load despite identity recency across equipment.
|
||||||
|
11. Requested grouped-zone summaries deduplicate each actual row across all
|
||||||
|
descendant matches; primary wins over secondary. Counts, distinct session
|
||||||
|
IDs, sorted pattern union and latest exposure use the same eligible-row
|
||||||
|
and exact temporal contracts; latest exposure is not limited to 24/72 hours.
|
||||||
|
12. Invalid stored timestamps fail required history analysis explicitly; no
|
||||||
|
complete exposure or generation result is returned. Never silently discard
|
||||||
|
malformed instants. Invalid reference time is a request validation error.
|
||||||
|
|
||||||
|
## Limitations and required implementation checks
|
||||||
|
|
||||||
|
- Confidence: `high` for established anatomy/context distinctions; `moderate`
|
||||||
|
for population-level training-template interpretation; `uncertain` for
|
||||||
|
today's load suitability and heuristic effectiveness. No universal recovery
|
||||||
|
clock, clinical restriction or measured readiness is implied.
|
||||||
|
- Target-only, zero-set, draft, MAX-only and valid future rows cannot create
|
||||||
|
performed exposure or qualify repeated working dose. Invalid stored time
|
||||||
|
fails analysis explicitly, including load/recency readers. Missing history
|
||||||
|
does not mean rested. Data-reader limits cannot silently undercount exposure.
|
||||||
|
- Load tests must include same-exercise different-equipment exclusion,
|
||||||
|
assistance exclusion, newer ordinary-dose precedence over MAX, numeric
|
||||||
|
qualified MAX fallback, old anchors, nonfinite values, and absent load.
|
||||||
|
- Boundary tests must cover exact 24-hour/72-hour exclusions, inclusive 28-day
|
||||||
|
load age, exact 7-day pattern-recency exclusion, equivalent offset/fraction
|
||||||
|
instants, invalid stored timestamp failure and deterministic identity ties.
|
||||||
|
- Test role-separated counts and shared-zone opposing functions; no duplicate
|
||||||
|
patterns, no conditional-name guessing and explicit shortages.
|
||||||
|
- Test parent aggregation where one row matches both a primary and several
|
||||||
|
secondary descendants: count once as primary, with one matching session;
|
||||||
|
do not multiply the row through its pattern union. Verify secondary-only
|
||||||
|
`notice` versus primary `warning` at equal qualifying counts, and no warning
|
||||||
|
level standing in for an analysis error. Verify exact-ID preference,
|
||||||
|
preferred-and-excluded conflict, unavailable preferences, excluded patterns
|
||||||
|
and a valid alternative outranking recently repeated identity.
|
||||||
|
- Current resolved science has no chest or calves candidate. Sparse proposals
|
||||||
|
are an honest result; fixing unresolved identities is separate research.
|
||||||
|
- The model lacks effort, RIR, technique/ROM verification, warmup classification,
|
||||||
|
machine calibration and reliably selectable increments. Do not hide those
|
||||||
|
limits behind the term "successful" or a confident precise kilogram number.
|
||||||
|
|
||||||
|
## Handoff and outstanding gate
|
||||||
|
|
||||||
|
Research artifacts are reviewable; production implementation has not been
|
||||||
|
authorized through this document. The parent reported that the intended
|
||||||
|
architecture advisor could not be spawned after repeated thread-limit failures.
|
||||||
|
The Android target/draft/storage architecture remains unresolved; the review
|
||||||
|
does not substitute for that advisor or authorize a schema/format migration.
|
||||||
|
No implementation, persistence, build or release success is claimed here.
|
||||||
|
|
@ -16,6 +16,8 @@ GATE_2=PASS
|
||||||
TRAINLOG_FORMAT_V1=FROZEN
|
TRAINLOG_FORMAT_V1=FROZEN
|
||||||
DESKTOP_SCHEMA_V11=PASS
|
DESKTOP_SCHEMA_V11=PASS
|
||||||
ANDROID_LOCAL_DATABASE_V10=PASS
|
ANDROID_LOCAL_DATABASE_V10=PASS
|
||||||
|
ANDROID_LOCAL_DATABASE_V11=PASS
|
||||||
|
SESSION_GENERATOR_V1=PASS
|
||||||
|
|
||||||
DIRECT_MTP_TRANSPORT=PASS
|
DIRECT_MTP_TRANSPORT=PASS
|
||||||
BIDIRECTIONAL_SYNC_V1=PASS
|
BIDIRECTIONAL_SYNC_V1=PASS
|
||||||
|
|
@ -31,7 +33,7 @@ BODY_ZONES_V1=PASS
|
||||||
BODY_ZONE_SYNC_V1=PASS
|
BODY_ZONE_SYNC_V1=PASS
|
||||||
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
BODY_ZONES_ANDROID_DEVICE_VALIDATION=PASS
|
||||||
|
|
||||||
DESKTOP_TESTS=42/42 PASS (recorded validation checkpoint)
|
DESKTOP_TESTS=45/45 PASS (latest validated checkpoint)
|
||||||
TUI_NOTCURSES_V1=PASS
|
TUI_NOTCURSES_V1=PASS
|
||||||
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
NCURSESW_REMOVED_FROM_ACTIVE_TUI=PASS
|
||||||
NOTCURSES_TRUECOLOR_THEME=PASS
|
NOTCURSES_TRUECOLOR_THEME=PASS
|
||||||
|
|
@ -57,11 +59,12 @@ The current product baseline includes:
|
||||||
- supplied and custom equipment definitions with occurrence-level links;
|
- supplied and custom equipment definitions with occurrence-level links;
|
||||||
- shared synchronization engine and `trainlog-syncd`.
|
- 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,
|
manifest, desktop v11/Android v10 relations, Android/TUI edit and display,
|
||||||
descendant-aware filters, unclassified history and one explicit-conflict sync
|
descendant-aware filters, unclassified history and one explicit-conflict sync
|
||||||
companion. It does not implement a session generator, custom zones or proposed
|
companion. The implemented generator uses this infrastructure; custom zones are
|
||||||
loads.
|
still outside this checkpoint and proposed loads remain editable plans rather
|
||||||
|
than actual work.
|
||||||
|
|
||||||
`TRAINING_KNOWLEDGE_V1=PASS` is read-only infrastructure. Scientific review,
|
`TRAINING_KNOWLEDGE_V1=PASS` is read-only infrastructure. Scientific review,
|
||||||
independent temporal review, final engineering review, repair verification, and
|
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
|
and exact C/Android chronological pagination; one bounded repair chain closed
|
||||||
the audit's stale-documentation, Android-loader, Meson-input, and role-only C
|
the audit's stale-documentation, Android-loader, Meson-input, and role-only C
|
||||||
query findings.
|
query findings.
|
||||||
It provides catalog-backed scientific lookup and
|
It provides catalog-backed scientific lookup and runtime context composition
|
||||||
runtime context composition without prescriptions, schema changes or catalog
|
without prescriptions, schema changes or catalog seeding. The implemented
|
||||||
seeding. The documented future session/program input pipeline is deliberately
|
separate session generator consumes that boundary; multi-session programming is
|
||||||
not a roadmap gate or an implemented generator; see
|
still not a roadmap gate. See
|
||||||
[Training knowledge system V1](domain/knowledge_system.md).
|
[Training knowledge system V1](domain/knowledge_system.md).
|
||||||
|
|
||||||
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits
|
`EXERCISE_EDIT_V1` is a completed capture correction: Android permits
|
||||||
|
|
@ -199,59 +202,15 @@ Gate:
|
||||||
EXERCISE_METADATA_V1=PASS
|
EXERCISE_METADATA_V1=PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session planner v1
|
## Session generator v1
|
||||||
|
|
||||||
The desktop TUI becomes the canonical session-planning surface.
|
`SESSION_GENERATOR_V1=PASS`.
|
||||||
|
It provides
|
||||||
A planned session should allow:
|
a bounded policy-driven generator in both UIs, then hands accepted nonempty
|
||||||
|
proposals to normal draft/editor flows where actual work is captured separately.
|
||||||
- selecting exercises from the real gym catalog;
|
It does not create reusable templates, a planned-session sync product surface,
|
||||||
- ordering exercises;
|
or a multi-session program. The one deep final audit found repairable gaps;
|
||||||
- planned sets/repetitions or duration;
|
its bounded repairs, review, and final validation matrix passed.
|
||||||
- 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 templates v1
|
## Session templates v1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ EQUIPMENT_ASSOCIATIONS_V2=PASS
|
||||||
EQUIPMENT_DEFINITIONS_V1=PASS
|
EQUIPMENT_DEFINITIONS_V1=PASS
|
||||||
EXERCISE_RECONCILIATION_V2=PASS
|
EXERCISE_RECONCILIATION_V2=PASS
|
||||||
EXPLICIT_MAX_RESULTS_V2=PASS
|
EXPLICIT_MAX_RESULTS_V2=PASS
|
||||||
|
SESSION_GENERATOR_V1=PASS
|
||||||
BODY_ZONE_SYNC_V1=PASS
|
BODY_ZONE_SYNC_V1=PASS
|
||||||
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
|
BODY_ZONE_SYNC_V1_LIVE_DEVICE=PASS
|
||||||
|
|
||||||
|
|
@ -44,13 +45,13 @@ Framework folder grant.
|
||||||
| Direction | File | Format |
|
| Direction | File | Format |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Android -> PC | `trainlog-mobile-export-v1.json` | `trainlog-mobile-export` v1 |
|
| 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-mobile-equipment-definitions-v1.json` | `trainlog-equipment-definitions` v1 |
|
||||||
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
| Android -> PC | `trainlog-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
||||||
| Android -> PC | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
|
| 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-catalog-v1.json` | `trainlog-pc-catalog` v1 |
|
||||||
| PC -> Android | `trainlog-pc-equipment-definitions-v1.json` | `trainlog-equipment-definitions` 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-equipment-associations-v2.json` | `trainlog-equipment-associations` v2 |
|
||||||
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
|
| 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 |
|
| 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
|
Android scoped storage can preserve a prior MTP-created object and create a
|
||||||
new artifact with the provider collision suffix, for example
|
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-mobile-equipment-definitions-v1 (N).json`, or
|
||||||
`trainlog-equipment-associations-v2 (N).json`, or
|
`trainlog-equipment-associations-v2 (N).json`, or
|
||||||
`trainlog-exercise-body-zones-v1 (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
|
exercise/order correspondence is unambiguous; otherwise the importer reports
|
||||||
a conflict rather than silently overwriting data.
|
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
|
## 4. Equipment definitions V1
|
||||||
|
|
||||||
User-created equipment definitions use the separate, directional
|
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
|
preceding V2 exercise import is accepted only with that retained V2 definition
|
||||||
as proof, never from a name-only guess.
|
as proof, never from a name-only guess.
|
||||||
|
|
||||||
## 5. Android -> PC mobile snapshot
|
## 5. Android -> PC mobile snapshot and retained V2 entry shape
|
||||||
|
|
||||||
Header:
|
Header:
|
||||||
|
|
||||||
|
|
@ -163,12 +181,15 @@ sessions
|
||||||
body_observations
|
body_observations
|
||||||
```
|
```
|
||||||
|
|
||||||
Android captures its custom-definition V1 companion, this V2 snapshot, body
|
For current publication, Android captures its custom-definition V1 companion,
|
||||||
zones, and the equipment-association companion before it publishes any of
|
the mobile V3 snapshot, body zones V1, and the equipment-associations V2
|
||||||
them. It then publishes in that order: definitions, V2 snapshot, body zones,
|
companion before it publishes any of them. It publishes definitions V1, mobile
|
||||||
associations. A malformed
|
V3, body zones V1, then associations V2. A malformed persisted custom
|
||||||
persisted custom definition aborts publication before a V2 file can advertise
|
definition aborts publication before a mobile V3 file can advertise its
|
||||||
its reference; bundled manifest equipment is never copied into definitions V1.
|
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:
|
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.
|
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
|
one normalized name only when recording mode and tracking mode are equal, all
|
||||||
other represented invariants are compatible, and one bounded `data_fields`
|
other represented invariants are compatible, and one bounded `data_fields`
|
||||||
mask contains the other. The existing desktop identity is deterministic
|
mask contains the other. The existing desktop identity is deterministic
|
||||||
|
|
@ -415,8 +436,8 @@ Android request
|
||||||
The engine has three explicit modes:
|
The engine has three explicit modes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
a Android -> PC: definition V1 -> mobile V2 -> body zones V1 -> association V2; no publish
|
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 V2
|
p PC -> Android: definition V1 -> catalog V1 -> body zones V1 -> mobile V3
|
||||||
(including bodies) -> association V2; no receive
|
(including bodies) -> association V2; no receive
|
||||||
b bidirectional: complete inbound sequence, then complete outbound sequence
|
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
|
`trainlog-equipment-associations`, version `2`. Each row is identified by
|
||||||
`(session_id, entry_id)` and contains `exercise_id` as consistency metadata,
|
`(session_id, entry_id)` and contains `exercise_id` as consistency metadata,
|
||||||
then either `state: set` with a canonical `equipment_id`, or `state: cleared`
|
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
|
carries the occurrence equipment value and the companion validates it. A
|
||||||
missing companion conveys no equipment information and cannot clear a
|
missing companion conveys no equipment information and cannot clear a
|
||||||
previously known choice. Unknown canonical IDs, unknown entries, ambiguous
|
previously known choice. Unknown canonical IDs, unknown entries, ambiguous
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ new unique identity -> create
|
||||||
```
|
```
|
||||||
|
|
||||||
That command validates the frozen Trainlog JSON V1 importer contract. Active
|
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.
|
covered below; it does not modify the frozen V1 expectation.
|
||||||
|
|
||||||
## 4. Desktop Meson suite
|
## 4. Desktop Meson suite
|
||||||
|
|
@ -110,9 +110,25 @@ tui_workflows
|
||||||
Validated current suite:
|
Validated current suite:
|
||||||
|
|
||||||
```text
|
```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
|
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
|
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
|
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
|
replay, complete definitions/mobile/associations/body import, outbound
|
||||||
publication, and stable second replay;
|
publication, and stable second replay;
|
||||||
- a production PC-exporter to Android-importer regression over definitions,
|
- 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
|
Marche, Leg press, two Marche occurrences, per-set loads, body data, a
|
||||||
durable draft, and custom equipment; after the first import, both the second
|
durable draft, and custom equipment; after the first import, both the second
|
||||||
and third imports report zero additions and an exact snapshot of every
|
and third imports report zero additions and an exact snapshot of every
|
||||||
|
|
|
||||||
16
docs/tui.md
16
docs/tui.md
|
|
@ -162,6 +162,18 @@ Persistent duration/rest units remain seconds.
|
||||||
Continuous exercise entry asks for duration and configured supplemental fields
|
Continuous exercise entry asks for duration and configured supplemental fields
|
||||||
without set/rest/load prompts.
|
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
|
## 6. Session history and editing
|
||||||
|
|
||||||
History is keyboard navigable.
|
History is keyboard navigable.
|
||||||
|
|
@ -271,9 +283,9 @@ b run bidirectional synchronization
|
||||||
r refresh device status
|
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,
|
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.
|
completes that inbound sequence before beginning the outbound sequence.
|
||||||
|
|
||||||
The direction keys are direct actions: pressing `a`, `p`, or `b` opens one
|
The direction keys are direct actions: pressing `a`, `p`, or `b` opens one
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ LEG_PRESS_ENTRY_ID = "sxe_22222222-2222-4222-8222-222222222223"
|
||||||
BODY_OBSERVATION_ID = "bo_33333333-3333-4333-8333-333333333333"
|
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(
|
result = subprocess.run(
|
||||||
[sys.executable, str(ROOT / "tools" / tool), str(output),
|
[sys.executable, str(ROOT / "tools" / tool), str(output),
|
||||||
"--database", str(database)],
|
"--database", str(database), *extra],
|
||||||
text=True,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
@ -41,6 +41,7 @@ def main():
|
||||||
|
|
||||||
connection = sqlite3.connect(database)
|
connection = sqlite3.connect(database)
|
||||||
connection.executescript(SCHEMA)
|
connection.executescript(SCHEMA)
|
||||||
|
connection.execute("PRAGMA user_version=11")
|
||||||
connection.executemany(
|
connection.executemany(
|
||||||
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,"
|
"INSERT INTO exercises(exercise_id,name,normalized_name,tracking_mode,"
|
||||||
"recording_mode,data_fields) VALUES(?,?,?,?,?,?);",
|
"recording_mode,data_fields) VALUES(?,?,?,?,?,?);",
|
||||||
|
|
@ -119,7 +120,8 @@ def main():
|
||||||
("export_pc_mobile.py", "trainlog-pc-mobile-export-v2.json"),
|
("export_pc_mobile.py", "trainlog-pc-mobile-export-v2.json"),
|
||||||
("export_equipment_associations.py", "trainlog-equipment-associations-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")
|
print("PASS pc_android_idempotence_fixture")
|
||||||
|
|
||||||
|
|
|
||||||
1672
tests/fixtures/session-generation-v1.json
vendored
Normal file
1672
tests/fixtures/session-generation-v1.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
42
tests/fixtures/session-mobile-export-v3.json
vendored
Normal file
42
tests/fixtures/session-mobile-export-v3.json
vendored
Normal 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": []
|
||||||
|
}
|
||||||
|
|
@ -452,7 +452,8 @@ def main():
|
||||||
(EXPORT_MOBILE, pc_mobile),
|
(EXPORT_MOBILE, pc_mobile),
|
||||||
(EXPORT_ASSOCIATIONS, pc_associations),
|
(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"))
|
exported = json.loads(pc_mobile.read_text(encoding="utf-8"))
|
||||||
walk = next(
|
walk = next(
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ def main():
|
||||||
"ON se.id=mr.session_exercise_row_id WHERE se.entry_id='sxe_pec'"
|
"ON se.id=mr.session_exercise_row_id WHERE se.entry_id='sxe_pec'"
|
||||||
).fetchone() == (101.5,)
|
).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
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
round_trip = json.loads(exported.read_text(encoding="utf-8"))
|
round_trip = json.loads(exported.read_text(encoding="utf-8"))
|
||||||
values = round_trip["sessions"][0]["exercises"]
|
values = round_trip["sessions"][0]["exercises"]
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,8 @@ def main():
|
||||||
(EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"),
|
(EQUIPMENT_EXPORTER, equipment_output, "EQUIPMENT_ASSOCIATIONS_EXPORT=PASS"),
|
||||||
):
|
):
|
||||||
result = subprocess.run(
|
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,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
@ -221,7 +222,7 @@ def main():
|
||||||
con.close()
|
con.close()
|
||||||
custom_output = root / "custom-mobile-v2.json"
|
custom_output = root / "custom-mobile-v2.json"
|
||||||
exported = subprocess.run(
|
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,
|
text=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ def run_import(
|
||||||
def run_export(output_path: Path, database_path: Path) -> None:
|
def run_export(output_path: Path, database_path: Path) -> None:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, str(EXPORTER), str(output_path),
|
[sys.executable, str(EXPORTER), str(output_path),
|
||||||
"--database", str(database_path)],
|
"--database", str(database_path), "--version", "2"],
|
||||||
check=False,
|
check=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|
|
||||||
211
tests/test_session_exchange_v3.py
Normal file
211
tests/test_session_exchange_v3.py
Normal 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()
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||||
|
|
||||||
|
|
||||||
CATALOG_PATH = Path(__file__).resolve().parents[1] / "catalog" / "equipment-v1.json"
|
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"]}
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("output", type=Path)
|
parser.add_argument("output", type=Path)
|
||||||
parser.add_argument("--database", type=Path, default=default_database())
|
parser.add_argument("--database", type=Path, default=default_database())
|
||||||
|
parser.add_argument("--version", type=int, choices=(2, 3), default=3)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
con = sqlite3.connect(args.database)
|
con = sqlite3.connect(args.database)
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
# The V2 shape itself does not read body-zone tables. Accept the true
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
# immediately-previous v10 fixture while production v11 publishes the
|
if schema_version != 11 and not (args.version == 2 and schema_version == 10):
|
||||||
# separate body-zone companion.
|
raise ValueError("schema desktop v11 requis (v10 accepté pour export V2 explicite)")
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (10, 11):
|
|
||||||
raise ValueError("schema desktop v10 ou v11 requis")
|
|
||||||
known_equipment = supplied_equipment_ids()
|
known_equipment = supplied_equipment_ids()
|
||||||
known_equipment.update(row[0] for row in con.execute(
|
known_equipment.update(row[0] for row in con.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"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(),
|
"generated_at": datetime.now().astimezone().isoformat(),
|
||||||
"exercises": [], "sessions": [], "body_observations": []}
|
"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"):
|
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))
|
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"):
|
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 = {key: session[key] for key in ("session_id", "started_at", "session_type")}
|
||||||
payload["exercises"] = []
|
payload["exercises"] = []
|
||||||
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
|
# INVARIANT: tracking mode is catalogue metadata. v7 occurrences
|
||||||
# retain their stable entry_id but do not duplicate that field.
|
# 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"],)):
|
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;
|
# CONTRACT: references remain in mobile-export v2 unchanged;
|
||||||
# definitions-v1 travels first and makes custom IDs resolvable.
|
# definitions-v1 travels first and makes custom IDs resolvable.
|
||||||
if entry["equipment_id"] is not None and entry["equipment_id"] not in known_equipment:
|
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"session_id={session['session_id']} "
|
||||||
f"entry_id={entry['entry_id']}: {entry['equipment_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"],
|
item = {"entry_id": entry["entry_id"], "position": entry["position"],
|
||||||
"exercise_id": entry["exercise_id"], "name": entry["name"],
|
"exercise_id": entry["exercise_id"], "name": entry["name"],
|
||||||
"recording_mode": entry["recording_mode"], "tracking_mode": entry["tracking_mode"],
|
"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"]}
|
"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:
|
if entry["max_weight_kg"] is not None:
|
||||||
# CONTRACT: explicit max is an occurrence result, never a
|
# CONTRACT: explicit max is an occurrence result, never a
|
||||||
# synthetic one-repetition performed set.
|
# synthetic one-repetition performed set.
|
||||||
|
|
@ -86,6 +150,11 @@ def main():
|
||||||
"right_thigh_cm", "left_calf_cm", "right_calf_cm")
|
"right_thigh_cm", "left_calf_cm", "right_calf_cm")
|
||||||
columns = ",".join(("observation_id", "observed_at") + metric_names)
|
columns = ",".join(("observation_id", "observed_at") + metric_names)
|
||||||
for row in con.execute(f"SELECT {columns} FROM body_observations ORDER BY observed_at,id"):
|
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 = {"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})
|
item.update({name: row[name] for name in metric_names if row[name] is not None})
|
||||||
root["body_observations"].append(item)
|
root["body_observations"].append(item)
|
||||||
|
|
|
||||||
135
tools/generate_session_generation_fixtures.py
Normal file
135
tools/generate_session_generation_fixtures.py
Normal 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')
|
||||||
58
tools/generate_session_generation_policy.py
Normal file
58
tools/generate_session_generation_policy.py
Normal 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()
|
||||||
|
|
@ -8,6 +8,8 @@ import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from validate_json import TrainlogSemanticError, parse_timestamp
|
||||||
|
|
||||||
|
|
||||||
FORMAT = "trainlog-mobile-export"
|
FORMAT = "trainlog-mobile-export"
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
@ -57,6 +59,7 @@ SESSION_EXERCISE_KEYS = {
|
||||||
V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
|
V2_SESSION_EXERCISE_KEYS = SESSION_EXERCISE_KEYS | {
|
||||||
"entry_id", "position", "equipment_id", "max_weight_kg"
|
"entry_id", "position", "equipment_id", "max_weight_kg"
|
||||||
}
|
}
|
||||||
|
V3_SESSION_EXERCISE_KEYS = V2_SESSION_EXERCISE_KEYS | {"target"}
|
||||||
|
|
||||||
BODY_BASE_KEYS = {
|
BODY_BASE_KEYS = {
|
||||||
"observation_id",
|
"observation_id",
|
||||||
|
|
@ -129,6 +132,16 @@ def require_nonempty_string(value, label):
|
||||||
return value
|
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):
|
def require_int(value, minimum, maximum, label):
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
|
|
@ -207,12 +220,19 @@ def normalize_name(value):
|
||||||
|
|
||||||
|
|
||||||
def load_payload(path):
|
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:
|
try:
|
||||||
with path.open(
|
with path.open(
|
||||||
"r",
|
"r",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
) as handle:
|
) as handle:
|
||||||
payload = json.load(handle)
|
payload = json.load(handle, object_pairs_hook=reject_duplicate_keys)
|
||||||
except (OSError, json.JSONDecodeError) as error:
|
except (OSError, json.JSONDecodeError) as error:
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
f"lecture JSON impossible: {error}"
|
f"lecture JSON impossible: {error}"
|
||||||
|
|
@ -230,15 +250,17 @@ def load_payload(path):
|
||||||
"format mobile export invalide"
|
"format mobile export invalide"
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload["version"] not in (1, 2):
|
if payload["version"] not in (1, 2, 3):
|
||||||
raise ImportFailure(
|
raise ImportFailure(
|
||||||
"version mobile export non supportée"
|
"version mobile export non supportée"
|
||||||
)
|
)
|
||||||
|
|
||||||
require_nonempty_string(
|
# CONTRACT: V1/V2 retain their published nonempty-string admission. V3 is
|
||||||
payload["generated_at"],
|
# the current analysis-bearing artifact and uses the exact Trainlog parser.
|
||||||
"generated_at",
|
if payload["version"] == 3:
|
||||||
)
|
require_v3_timestamp(payload["generated_at"], "generated_at")
|
||||||
|
else:
|
||||||
|
require_nonempty_string(payload["generated_at"], "generated_at")
|
||||||
|
|
||||||
for key in (
|
for key in (
|
||||||
"exercises",
|
"exercises",
|
||||||
|
|
@ -425,12 +447,15 @@ def validate_session_exercise(
|
||||||
label,
|
label,
|
||||||
known_exercise_ids,
|
known_exercise_ids,
|
||||||
session_type,
|
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(
|
require_exact_keys(
|
||||||
item,
|
item,
|
||||||
V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS,
|
keys,
|
||||||
(V2_SESSION_EXERCISE_KEYS if is_v2 else SESSION_EXERCISE_KEYS)
|
keys
|
||||||
- {"sets", "continuous", "max_weight_kg"},
|
- {"sets", "continuous", "max_weight_kg"},
|
||||||
label,
|
label,
|
||||||
)
|
)
|
||||||
|
|
@ -479,15 +504,30 @@ def validate_session_exercise(
|
||||||
f"{label}: snapshot incompatible avec le profil catalogue"
|
f"{label}: snapshot incompatible avec le profil catalogue"
|
||||||
)
|
)
|
||||||
|
|
||||||
if item["load_mode"] != "none":
|
if version < 3:
|
||||||
raise ImportFailure(
|
if item["load_mode"] != "none" or item["rest_seconds"] != 0:
|
||||||
f"{label}: mobile export v1 exige load_mode=none"
|
raise ImportFailure(f"{label}: mobile export V1/V2 exige none/0")
|
||||||
)
|
else:
|
||||||
|
if item["load_mode"] not in ("none", "external", "assistance"):
|
||||||
if item["rest_seconds"] != 0:
|
raise ImportFailure(f"{label}.load_mode invalide")
|
||||||
raise ImportFailure(
|
require_int(item["rest_seconds"], 0, 86400, f"{label}.rest_seconds")
|
||||||
f"{label}: mobile export v1 exige rest_seconds=0"
|
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 "max_weight_kg" in item:
|
||||||
if not is_v2 or session_type != "max_test":
|
if not is_v2 or session_type != "max_test":
|
||||||
|
|
@ -632,10 +672,12 @@ def validate_sessions(
|
||||||
|
|
||||||
seen_ids.add(session_id)
|
seen_ids.add(session_id)
|
||||||
|
|
||||||
require_nonempty_string(
|
if payload["version"] == 3:
|
||||||
session["started_at"],
|
# INVARIANT: accepted V3 history must remain readable by temporal
|
||||||
f"{label}.started_at",
|
# 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 (
|
if session["session_type"] not in (
|
||||||
"training",
|
"training",
|
||||||
|
|
@ -670,6 +712,7 @@ def validate_sessions(
|
||||||
exercise_label,
|
exercise_label,
|
||||||
known_exercise_ids,
|
known_exercise_ids,
|
||||||
session["session_type"],
|
session["session_type"],
|
||||||
|
payload["version"],
|
||||||
)
|
)
|
||||||
|
|
||||||
equipment_id = exercise.get("equipment_id")
|
equipment_id = exercise.get("equipment_id")
|
||||||
|
|
@ -677,7 +720,7 @@ def validate_sessions(
|
||||||
# or custom definition is already known. Validate before run_import
|
# or custom definition is already known. Validate before run_import
|
||||||
# opens a transaction so an unknown ID cannot create partial history.
|
# opens a transaction so an unknown ID cannot create partial history.
|
||||||
if (
|
if (
|
||||||
payload["version"] == 2
|
payload["version"] >= 2
|
||||||
and equipment_id is not None
|
and equipment_id is not None
|
||||||
and equipment_id not in known_equipment_ids
|
and equipment_id not in known_equipment_ids
|
||||||
):
|
):
|
||||||
|
|
@ -733,10 +776,10 @@ def validate_body(payload):
|
||||||
|
|
||||||
seen_ids.add(observation_id)
|
seen_ids.add(observation_id)
|
||||||
|
|
||||||
require_nonempty_string(
|
if payload["version"] == 3:
|
||||||
observation["observed_at"],
|
require_v3_timestamp(observation["observed_at"], f"{label}.observed_at")
|
||||||
f"{label}.observed_at",
|
else:
|
||||||
)
|
require_nonempty_string(observation["observed_at"], f"{label}.observed_at")
|
||||||
|
|
||||||
present_metrics = (
|
present_metrics = (
|
||||||
set(observation.keys())
|
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(
|
def import_set_session_exercise(
|
||||||
connection,
|
connection,
|
||||||
session_row_id,
|
session_row_id,
|
||||||
|
|
@ -1204,6 +1259,7 @@ def import_set_session_exercise(
|
||||||
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||||
equipment_values = ", ?" 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 = ([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:
|
if schema_version >= 6:
|
||||||
arguments.append(item.get("equipment_id"))
|
arguments.append(item.get("equipment_id"))
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
|
|
@ -1221,8 +1277,8 @@ def import_set_session_exercise(
|
||||||
target_duration_seconds,
|
target_duration_seconds,
|
||||||
target_weight_kg, notes""" + equipment_columns + """
|
target_weight_kg, notes""" + equipment_columns + """
|
||||||
) VALUES(
|
) VALUES(
|
||||||
""" + values + """?, ?, 'sets', ?, ?, 'none', 0,
|
""" + values + """?, ?, 'sets', ?, ?, ?, ?,
|
||||||
NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
|
?, ?, ?, ?, NULL""" + equipment_values + """
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
arguments,
|
arguments,
|
||||||
|
|
@ -1276,6 +1332,7 @@ def import_continuous_session_exercise(
|
||||||
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
equipment_columns = ", equipment_id" if schema_version >= 6 else ""
|
||||||
equipment_values = ", ?" 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 = ([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:
|
if schema_version >= 6:
|
||||||
arguments.append(item.get("equipment_id"))
|
arguments.append(item.get("equipment_id"))
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
|
|
@ -1293,8 +1350,8 @@ def import_continuous_session_exercise(
|
||||||
target_duration_seconds,
|
target_duration_seconds,
|
||||||
target_weight_kg, notes""" + equipment_columns + """
|
target_weight_kg, notes""" + equipment_columns + """
|
||||||
) VALUES(
|
) VALUES(
|
||||||
""" + values + """?, ?, 'continuous', ?, ?, 'none', 0,
|
""" + values + """?, ?, 'continuous', ?, ?, ?, ?,
|
||||||
NULL, NULL, NULL, NULL, NULL""" + equipment_values + """
|
?, ?, ?, ?, NULL""" + equipment_values + """
|
||||||
);
|
);
|
||||||
""",
|
""",
|
||||||
arguments,
|
arguments,
|
||||||
|
|
@ -1340,8 +1397,7 @@ def import_max_session_exercise(
|
||||||
data_fields, position, load_mode, rest_seconds,
|
data_fields, position, load_mode, rest_seconds,
|
||||||
target_sets, target_reps, target_duration_seconds,
|
target_sets, target_reps, target_duration_seconds,
|
||||||
target_weight_kg, notes, equipment_id
|
target_weight_kg, notes, equipment_id
|
||||||
) VALUES(?, ?, ?, ?, ?, ?, 'none', 0,
|
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?);
|
||||||
NULL, NULL, NULL, NULL, NULL, ?);
|
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
item["entry_id"],
|
item["entry_id"],
|
||||||
|
|
@ -1350,6 +1406,7 @@ def import_max_session_exercise(
|
||||||
item["recording_mode"],
|
item["recording_mode"],
|
||||||
item["data_fields"],
|
item["data_fields"],
|
||||||
position,
|
position,
|
||||||
|
*occurrence_plan_values(item),
|
||||||
item.get("equipment_id"),
|
item.get("equipment_id"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -1536,17 +1593,29 @@ def session_semantically_matches(
|
||||||
return False
|
return False
|
||||||
rows = connection.execute(
|
rows = connection.execute(
|
||||||
"SELECT se.id,se.entry_id,se.position,e.exercise_id,se.recording_mode,e.tracking_mode,"
|
"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()
|
"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"])
|
items = sorted(incoming["exercises"], key=lambda item: item["position"])
|
||||||
if len(rows) != len(items):
|
if len(rows) != len(items):
|
||||||
return False
|
return False
|
||||||
for row, item in zip(rows, items):
|
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"])
|
canonical_exercise_id = exercise_mapping.get(item["exercise_id"])
|
||||||
if tuple(row[1:8]) != (item["entry_id"], item["position"], canonical_exercise_id,
|
if tuple(row[1:8]) != (item["entry_id"], item["position"], canonical_exercise_id,
|
||||||
item["recording_mode"], item["tracking_mode"],
|
item["recording_mode"], item["tracking_mode"],
|
||||||
item["data_fields"], item.get("equipment_id")):
|
item["data_fields"], item.get("equipment_id")):
|
||||||
return False
|
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:
|
if "max_weight_kg" in item:
|
||||||
current = connection.execute(
|
current = connection.execute(
|
||||||
"SELECT max_weight_kg FROM max_results "
|
"SELECT max_weight_kg FROM max_results "
|
||||||
|
|
|
||||||
180
tools/validate_session_generation_policy.py
Normal file
180
tools/validate_session_generation_policy.py
Normal 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())
|
||||||
214
tui/include/trainlog/session_generation.h
Normal file
214
tui/include/trainlog/session_generation.h
Normal 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
|
||||||
43
tui/include/trainlog/session_generation_policy_internal.h
Normal file
43
tui/include/trainlog/session_generation_policy_internal.h
Normal 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
|
||||||
|
|
@ -52,6 +52,23 @@ training_knowledge_generated = custom_target(
|
||||||
meson.project_source_root() / 'catalog', '@OUTPUT@'],
|
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 = [
|
strict_c_args = [
|
||||||
'-D_POSIX_C_SOURCE=200809L',
|
'-D_POSIX_C_SOURCE=200809L',
|
||||||
'-Wconversion',
|
'-Wconversion',
|
||||||
|
|
@ -71,6 +88,7 @@ trainlog_core_sources = files(
|
||||||
'src/usb.c',
|
'src/usb.c',
|
||||||
'src/mtp.c',
|
'src/mtp.c',
|
||||||
'src/reps.c',
|
'src/reps.c',
|
||||||
|
'src/session_generation.c',
|
||||||
'src/measured_max.c',
|
'src/measured_max.c',
|
||||||
'src/sync.c',
|
'src/sync.c',
|
||||||
'src/sync_history.c',
|
'src/sync_history.c',
|
||||||
|
|
@ -79,6 +97,7 @@ trainlog_core_sources = files(
|
||||||
trainlog_core_sources += equipment_catalog_generated
|
trainlog_core_sources += equipment_catalog_generated
|
||||||
trainlog_core_sources += body_zone_catalog_generated
|
trainlog_core_sources += body_zone_catalog_generated
|
||||||
trainlog_core_sources += training_knowledge_generated
|
trainlog_core_sources += training_knowledge_generated
|
||||||
|
trainlog_core_sources += session_generation_policy_generated
|
||||||
|
|
||||||
trainlog_core = static_library(
|
trainlog_core = static_library(
|
||||||
'trainlog_core',
|
'trainlog_core',
|
||||||
|
|
@ -175,6 +194,27 @@ test_training_knowledge = executable(
|
||||||
)
|
)
|
||||||
test('training_knowledge', test_training_knowledge)
|
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 = executable(
|
||||||
'test_training_context',
|
'test_training_context',
|
||||||
'tests/test_training_context.c',
|
'tests/test_training_context.c',
|
||||||
|
|
@ -466,6 +506,12 @@ test(
|
||||||
args: [meson.project_source_root() / 'tests/test_max_sync.py'],
|
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(
|
test(
|
||||||
'equipment_associations_exchange',
|
'equipment_associations_exchange',
|
||||||
python3_trainlog_tests,
|
python3_trainlog_tests,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#include "trainlog/duration.h"
|
#include "trainlog/duration.h"
|
||||||
#include "trainlog/equipment_catalog.h"
|
#include "trainlog/equipment_catalog.h"
|
||||||
#include "trainlog/id.h"
|
#include "trainlog/id.h"
|
||||||
|
#include "trainlog/session_generation.h"
|
||||||
#include "timestamp.h"
|
#include "timestamp.h"
|
||||||
|
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
|
|
@ -53,6 +54,121 @@ TrainlogStatus trainlog_database_read_snapshot_end(TrainlogDatabase *database, b
|
||||||
return TRAINLOG_STATUS_OK;
|
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(
|
static TrainlogStatus lookup_exercise_row_id(
|
||||||
TrainlogDatabase *database,
|
TrainlogDatabase *database,
|
||||||
const char *exercise_id,
|
const char *exercise_id,
|
||||||
|
|
|
||||||
1056
tui/src/session_generation.c
Normal file
1056
tui/src/session_generation.c
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -29,13 +29,16 @@
|
||||||
#define SYNC_REQUEST_TEXT_MAX 4095U
|
#define SYNC_REQUEST_TEXT_MAX 4095U
|
||||||
|
|
||||||
static const char *const MOBILE_EXPORT_NAME =
|
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";
|
"trainlog-mobile-export-v2.json";
|
||||||
|
|
||||||
static const char *const MOBILE_EXPORT_V1_NAME =
|
static const char *const MOBILE_EXPORT_V1_NAME =
|
||||||
"trainlog-mobile-export-v1.json";
|
"trainlog-mobile-export-v1.json";
|
||||||
|
|
||||||
static const char *const PC_MOBILE_EXPORT_NAME =
|
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 =
|
static const char *const PC_CATALOG_NAME =
|
||||||
"trainlog-pc-catalog-v1.json";
|
"trainlog-pc-catalog-v1.json";
|
||||||
|
|
@ -59,10 +62,10 @@ static const char *const SYNC_RECEIPT_NAME =
|
||||||
"trainlog-sync-receipt-v1.json";
|
"trainlog-sync-receipt-v1.json";
|
||||||
|
|
||||||
static const char *const MOBILE_EXPORT_LOCAL =
|
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 =
|
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 =
|
static const char *const PC_CATALOG_LOCAL =
|
||||||
"/tmp/trainlog-pc-catalog-v1.json";
|
"/tmp/trainlog-pc-catalog-v1.json";
|
||||||
|
|
@ -2614,7 +2617,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
bool silence_active = false;
|
bool silence_active = false;
|
||||||
bool run_started = false;
|
bool run_started = false;
|
||||||
bool receipt_published = 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 ||
|
if (output == NULL || direction < TRAINLOG_SYNC_ANDROID_TO_PC ||
|
||||||
direction > TRAINLOG_SYNC_BIDIRECTIONAL ||
|
direction > TRAINLOG_SYNC_BIDIRECTIONAL ||
|
||||||
|
|
@ -2840,7 +2843,7 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto outbound;
|
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. */
|
* missing file is accepted only for historic snapshots with no custom ID. */
|
||||||
status = sync_receive_current_android_artifact(
|
status = sync_receive_current_android_artifact(
|
||||||
&device,
|
&device,
|
||||||
|
|
@ -2886,14 +2889,16 @@ TrainlogStatus trainlog_sync_run(
|
||||||
MOBILE_EXPORT_LOCAL,
|
MOBILE_EXPORT_LOCAL,
|
||||||
&ignored_size
|
&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 (
|
if (status == TRAINLOG_STATUS_NOT_FOUND) {
|
||||||
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. */
|
|
||||||
status = sync_receive_named(&device, folder_id, MOBILE_EXPORT_V1_NAME,
|
status = sync_receive_named(&device, folder_id, MOBILE_EXPORT_V1_NAME,
|
||||||
MOBILE_EXPORT_LOCAL, &ignored_size);
|
MOBILE_EXPORT_LOCAL, &ignored_size);
|
||||||
}
|
}
|
||||||
|
|
@ -2957,11 +2962,11 @@ TrainlogStatus trainlog_sync_run(
|
||||||
);
|
);
|
||||||
|
|
||||||
/* CONTRACT: body zones are one directional-neutral companion. Historic
|
/* 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
|
* exercise definitions above established every ID or a source-V2-proven
|
||||||
* normalized-name alias. The helper consumes the retained mobile snapshot
|
* normalized-name alias. The helper consumes the retained mobile snapshot
|
||||||
* solely as reconciliation proof; it never infers identity from a name. */
|
* 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,
|
? sync_receive_current_android_artifact(&device, folder_id,
|
||||||
EXERCISE_BODY_ZONES_NAME, EXERCISE_BODY_ZONES_LOCAL, &ignored_size)
|
EXERCISE_BODY_ZONES_NAME, EXERCISE_BODY_ZONES_LOCAL, &ignored_size)
|
||||||
: TRAINLOG_STATUS_NOT_FOUND;
|
: TRAINLOG_STATUS_NOT_FOUND;
|
||||||
|
|
@ -2986,12 +2991,12 @@ TrainlogStatus trainlog_sync_run(
|
||||||
goto finalize;
|
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
|
* V1 snapshot belongs to another generation and must not be applied. Its
|
||||||
* absence therefore preserves existing associations rather than clearing
|
* 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. */
|
* 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(
|
? sync_receive_current_android_artifact(
|
||||||
&device,
|
&device,
|
||||||
folder_id,
|
folder_id,
|
||||||
|
|
@ -3156,13 +3161,13 @@ outbound:
|
||||||
database_path,
|
database_path,
|
||||||
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
|
PC_CATALOG_RESULT, tool_output, sizeof(tool_output));
|
||||||
if (status != TRAINLOG_STATUS_OK || strstr(tool_output, "PC_MOBILE_EXPORT=PASS") == NULL) {
|
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;
|
final_status = TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
status = sync_publish_named(&device, folder_id, PC_MOBILE_EXPORT_LOCAL, PC_MOBILE_EXPORT_NAME);
|
status = sync_publish_named(&device, folder_id, PC_MOBILE_EXPORT_LOCAL, PC_MOBILE_EXPORT_NAME);
|
||||||
if (status != TRAINLOG_STATUS_OK) {
|
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;
|
final_status = status;
|
||||||
goto finalize;
|
goto finalize;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
426
tui/src/tui.c
426
tui/src/tui.c
|
|
@ -38,6 +38,8 @@
|
||||||
#include "trainlog/sync_history.h"
|
#include "trainlog/sync_history.h"
|
||||||
#include "trainlog/sync_screen_action.h"
|
#include "trainlog/sync_screen_action.h"
|
||||||
#include "trainlog/reps.h"
|
#include "trainlog/reps.h"
|
||||||
|
#include "trainlog/session_generation.h"
|
||||||
|
#include "trainlog/session_generation_policy_internal.h"
|
||||||
#include "trainlog/theme.h"
|
#include "trainlog/theme.h"
|
||||||
#include "trainlog/timeutil.h"
|
#include "trainlog/timeutil.h"
|
||||||
#include "trainlog/training_knowledge.h"
|
#include "trainlog/training_knowledge.h"
|
||||||
|
|
@ -72,6 +74,7 @@ static TrainlogTerminal *tui_terminal;
|
||||||
|
|
||||||
typedef enum DashboardAction {
|
typedef enum DashboardAction {
|
||||||
DASHBOARD_NEW_SESSION = 0,
|
DASHBOARD_NEW_SESSION = 0,
|
||||||
|
DASHBOARD_GENERATE_SESSION,
|
||||||
DASHBOARD_HISTORY,
|
DASHBOARD_HISTORY,
|
||||||
DASHBOARD_EXERCISES,
|
DASHBOARD_EXERCISES,
|
||||||
DASHBOARD_EQUIPMENT,
|
DASHBOARD_EQUIPMENT,
|
||||||
|
|
@ -5491,7 +5494,7 @@ static DashboardAction screen_dashboard(
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
static const char *const footer =
|
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;
|
int selected = 0;
|
||||||
|
|
||||||
|
|
@ -5537,6 +5540,10 @@ static DashboardAction screen_dashboard(
|
||||||
int width =
|
int width =
|
||||||
(int)strlen(primary_nav_labels[index]) + 4;
|
(int)strlen(primary_nav_labels[index]) + 4;
|
||||||
|
|
||||||
|
if (!large_layout && index != selected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (index == selected) {
|
if (index == selected) {
|
||||||
trainlog_terminal_style_on(tui_terminal,
|
trainlog_terminal_style_on(tui_terminal,
|
||||||
TRAINLOG_TEXT_REVERSE |
|
TRAINLOG_TEXT_REVERSE |
|
||||||
|
|
@ -5551,7 +5558,7 @@ static DashboardAction screen_dashboard(
|
||||||
? nav_top + 1
|
? nav_top + 1
|
||||||
: trainlog_terminal_rows(tui_terminal) - 3,
|
: trainlog_terminal_rows(tui_terminal) - 3,
|
||||||
column,
|
column,
|
||||||
" %s ",
|
large_layout ? " %s " : " ← %s → ",
|
||||||
primary_nav_labels[index]
|
primary_nav_labels[index]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -5638,6 +5645,10 @@ static DashboardAction screen_dashboard(
|
||||||
case '1':
|
case '1':
|
||||||
return DASHBOARD_NEW_SESSION;
|
return DASHBOARD_NEW_SESSION;
|
||||||
|
|
||||||
|
case 'g':
|
||||||
|
case 'G':
|
||||||
|
return DASHBOARD_GENERATE_SESSION;
|
||||||
|
|
||||||
case TRAINLOG_KEY_F2:
|
case TRAINLOG_KEY_F2:
|
||||||
case '2':
|
case '2':
|
||||||
return DASHBOARD_HISTORY;
|
return DASHBOARD_HISTORY;
|
||||||
|
|
@ -6866,6 +6877,19 @@ static void draft_set_summary(
|
||||||
sizeof(duration)
|
sizeof(duration)
|
||||||
);
|
);
|
||||||
(void)snprintf(output, output_size, "Continu · %s", 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 {
|
} else {
|
||||||
int written = snprintf(output, output_size, "%zu séries · ",
|
int written = snprintf(output, output_size, "%zu séries · ",
|
||||||
draft->input.set_count);
|
draft->input.set_count);
|
||||||
|
|
@ -7472,6 +7496,258 @@ static bool persist_draft_replacement(
|
||||||
) == TRAINLOG_STATUS_OK;
|
) == 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(
|
static void edit_persisted_session(
|
||||||
TrainlogDatabase *database,
|
TrainlogDatabase *database,
|
||||||
const char *session_id
|
const char *session_id
|
||||||
|
|
@ -7534,6 +7810,149 @@ static void edit_persisted_session(
|
||||||
wait_key();
|
wait_key();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static TrainlogStatus persist_new_session_drafts(TrainlogDatabase *database,
|
||||||
|
TrainlogSessionDraftExercise *drafts, size_t exercise_count,
|
||||||
|
const char *started_at)
|
||||||
|
{
|
||||||
|
TrainlogSessionExerciseInput *inputs;
|
||||||
|
TrainlogSessionInput session;
|
||||||
|
char session_id[TRAINLOG_GENERATED_ID_CAPACITY];
|
||||||
|
char ended_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
TrainlogStatus status;
|
||||||
|
size_t index;
|
||||||
|
if (database == NULL || drafts == NULL || started_at == NULL ||
|
||||||
|
exercise_count == 0U ||
|
||||||
|
exercise_count > MAX_SESSION_EXERCISES) return TRAINLOG_STATUS_INVALID_ARGUMENT;
|
||||||
|
inputs = calloc(exercise_count, sizeof(*inputs));
|
||||||
|
if (inputs == NULL) return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
if (trainlog_id_generate("se", session_id, sizeof(session_id)) != TRAINLOG_STATUS_OK ||
|
||||||
|
trainlog_time_now_rfc3339(ended_at, sizeof(ended_at)) != TRAINLOG_STATUS_OK) {
|
||||||
|
free(inputs);
|
||||||
|
return TRAINLOG_STATUS_SYSTEM_ERROR;
|
||||||
|
}
|
||||||
|
for (index = 0U; index < exercise_count; ++index) {
|
||||||
|
draft_bind_input(&drafts[index]);
|
||||||
|
inputs[index] = drafts[index].input;
|
||||||
|
}
|
||||||
|
(void)memset(&session, 0, sizeof(session));
|
||||||
|
session.session_type = TRAINLOG_SESSION_TRAINING;
|
||||||
|
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", session_id);
|
||||||
|
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", started_at);
|
||||||
|
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", ended_at);
|
||||||
|
session.exercises = inputs;
|
||||||
|
session.exercise_count = exercise_count;
|
||||||
|
status = trainlog_database_insert_session(database, &session);
|
||||||
|
free(inputs);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void generator_show_empty(const TrainlogGeneratedSession *session)
|
||||||
|
{
|
||||||
|
size_t index;
|
||||||
|
draw_shell("Séance non générée", "Une touche pour revenir");
|
||||||
|
trainlog_terminal_printf(tui_terminal, 4, 4,
|
||||||
|
"Pas assez d’exercices résolus et compatibles pour cette demande.");
|
||||||
|
for (index = 0U; index < session->shortage_count && index < 6U; ++index)
|
||||||
|
trainlog_terminal_printf(tui_terminal, 6 + (int)index, 4, "· %.58s",
|
||||||
|
session->shortage_codes[index]);
|
||||||
|
trainlog_terminal_printf(tui_terminal, 14, 4,
|
||||||
|
"Aucune correspondance n’a été inventée; rien ne peut être enregistré.");
|
||||||
|
wait_key();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void screen_session_generator(TrainlogDatabase *database)
|
||||||
|
{
|
||||||
|
const TrainlogBodyZone *zone;
|
||||||
|
const TrainlogSessionGenerationGoalPolicy *goal;
|
||||||
|
int duration_minutes;
|
||||||
|
if (!generator_choose_zone(&zone) || !generator_choose_goal(&goal) ||
|
||||||
|
!generator_choose_duration(&duration_minutes)) return;
|
||||||
|
for (;;) {
|
||||||
|
TrainlogGeneratedSession *generated = calloc(1U, sizeof(*generated));
|
||||||
|
TrainlogGeneratorPreviewItem *items = calloc(TRAINLOG_GENERATOR_MAX_SELECTED,
|
||||||
|
sizeof(*items));
|
||||||
|
TrainlogGenerationDatabaseRequest request;
|
||||||
|
char reference_time[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
TrainlogStatus status;
|
||||||
|
int exposure_choice;
|
||||||
|
if (generated == NULL || items == NULL) {
|
||||||
|
free(generated); free(items);
|
||||||
|
status_line("Mémoire insuffisante pour générer la séance.", TRAINLOG_COLOR_ERROR);
|
||||||
|
wait_key(); return;
|
||||||
|
}
|
||||||
|
if (trainlog_time_now_rfc3339(reference_time, sizeof(reference_time)) != TRAINLOG_STATUS_OK) {
|
||||||
|
free(generated); free(items); return;
|
||||||
|
}
|
||||||
|
(void)memset(&request, 0, sizeof(request));
|
||||||
|
request.zone_id = zone->zone_id;
|
||||||
|
request.goal_id = goal->id;
|
||||||
|
request.duration_minutes = duration_minutes;
|
||||||
|
request.reference_time = reference_time;
|
||||||
|
/* NULL is deliberate: omitted equipment means all exact compatible
|
||||||
|
* supplied contexts, distinct from an explicit empty inventory. */
|
||||||
|
request.available_equipment_ids = NULL;
|
||||||
|
status = trainlog_session_generate_from_database(database, &request, generated);
|
||||||
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
|
draw_shell("Génération impossible", "Une touche pour revenir");
|
||||||
|
status_line(status == TRAINLOG_STATUS_DATABASE_ERROR
|
||||||
|
? "Analyse refusée : horodatage ou historique stocké invalide."
|
||||||
|
: "La demande de génération est invalide ou incomplète.",
|
||||||
|
TRAINLOG_COLOR_ERROR);
|
||||||
|
free(generated); free(items); wait_key(); return;
|
||||||
|
}
|
||||||
|
exposure_choice = generator_confirm_exposure(zone, &generated->exposure);
|
||||||
|
if (exposure_choice < 0) { free(generated); free(items); return; }
|
||||||
|
if (exposure_choice == 0) {
|
||||||
|
free(generated); free(items);
|
||||||
|
if (!generator_choose_zone(&zone)) return;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (generated->exercise_count == 0U) {
|
||||||
|
generator_show_empty(generated);
|
||||||
|
free(generated); free(items); return;
|
||||||
|
}
|
||||||
|
if (!generator_prepare_preview(database, generated, items)) {
|
||||||
|
free(generated); free(items);
|
||||||
|
status_line("Impossible de résoudre les libellés de l’aperçu.", TRAINLOG_COLOR_ERROR);
|
||||||
|
wait_key(); return;
|
||||||
|
}
|
||||||
|
if (!generator_preview(generated, items)) {
|
||||||
|
free(generated); free(items); return;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
TrainlogSessionDraftExercise *drafts = calloc(MAX_SESSION_EXERCISES,
|
||||||
|
sizeof(*drafts));
|
||||||
|
char accepted_at[TRAINLOG_TIMESTAMP_MAX + 1U];
|
||||||
|
size_t count = generated->exercise_count;
|
||||||
|
bool built = drafts != NULL && generator_build_drafts(database, generated, drafts);
|
||||||
|
free(generated); free(items);
|
||||||
|
if (!built) {
|
||||||
|
free(drafts);
|
||||||
|
status_line("Impossible de préparer l’éditeur de séance.", TRAINLOG_COLOR_ERROR);
|
||||||
|
wait_key(); return;
|
||||||
|
}
|
||||||
|
if (trainlog_time_now_rfc3339(accepted_at, sizeof(accepted_at)) != TRAINLOG_STATUS_OK) {
|
||||||
|
free(drafts); return;
|
||||||
|
}
|
||||||
|
if (!edit_session_draft(database, drafts, &count, TRAINLOG_SESSION_TRAINING)) {
|
||||||
|
free(drafts);
|
||||||
|
draw_shell("Séance abandonnée", "Une touche pour revenir");
|
||||||
|
status_line("Aucune donnée de séance n’a été enregistrée.", TRAINLOG_COLOR_MUTED);
|
||||||
|
wait_key(); return;
|
||||||
|
}
|
||||||
|
status = persist_new_session_drafts(database, drafts, count, accepted_at);
|
||||||
|
free(drafts);
|
||||||
|
draw_shell("Fin de séance", "Une touche pour revenir");
|
||||||
|
status_line(status == TRAINLOG_STATUS_OK
|
||||||
|
? "✓ Séance générée et réalisée enregistrée."
|
||||||
|
: "Échec lors de l’enregistrement de la séance.",
|
||||||
|
status == TRAINLOG_STATUS_OK ? TRAINLOG_COLOR_SUCCESS : TRAINLOG_COLOR_ERROR);
|
||||||
|
wait_key();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void screen_new_session(
|
static void screen_new_session(
|
||||||
TrainlogDatabase *database
|
TrainlogDatabase *database
|
||||||
)
|
)
|
||||||
|
|
@ -12509,6 +12928,9 @@ int trainlog_tui_run(TrainlogDatabase *database)
|
||||||
case DASHBOARD_NEW_SESSION:
|
case DASHBOARD_NEW_SESSION:
|
||||||
screen_new_session(database);
|
screen_new_session(database);
|
||||||
break;
|
break;
|
||||||
|
case DASHBOARD_GENERATE_SESSION:
|
||||||
|
screen_session_generator(database);
|
||||||
|
break;
|
||||||
case DASHBOARD_HISTORY:
|
case DASHBOARD_HISTORY:
|
||||||
screen_history(database);
|
screen_history(database);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
228
tui/tests/test_session_generation.c
Normal file
228
tui/tests/test_session_generation.c
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -18,19 +18,19 @@ int main(void)
|
||||||
CHECK(plan.receive_android && plan.publish_android);
|
CHECK(plan.receive_android && plan.publish_android);
|
||||||
|
|
||||||
(void)snprintf(entries[0].name, sizeof(entries[0].name), "%s",
|
(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].item_id = 20U;
|
||||||
entries[0].modification_unix_seconds = 100U;
|
entries[0].modification_unix_seconds = 100U;
|
||||||
(void)snprintf(entries[1].name, sizeof(entries[1].name), "%s",
|
(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].item_id = 30U;
|
||||||
entries[1].modification_unix_seconds = 200U;
|
entries[1].modification_unix_seconds = 200U;
|
||||||
(void)snprintf(entries[2].name, sizeof(entries[2].name), "%s",
|
(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].item_id = 10U;
|
||||||
entries[2].modification_unix_seconds = 200U;
|
entries[2].modification_unix_seconds = 200U;
|
||||||
(void)snprintf(entries[3].name, sizeof(entries[3].name), "%s",
|
(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].item_id = 1U;
|
||||||
entries[3].modification_unix_seconds = 999U;
|
entries[3].modification_unix_seconds = 999U;
|
||||||
(void)snprintf(entries[4].name, sizeof(entries[4].name), "%s",
|
(void)snprintf(entries[4].name, sizeof(entries[4].name), "%s",
|
||||||
|
|
|
||||||
|
|
@ -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_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_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; }
|
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])
|
bool trainlog_terminal_read_unicode(TrainlogTerminal *terminal, int *codepoint, char utf8[5])
|
||||||
{
|
{
|
||||||
int value = trainlog_terminal_get_key(terminal);
|
int value = trainlog_terminal_get_key(terminal);
|
||||||
|
|
@ -286,13 +295,214 @@ static bool test_knowledge_scrolls_long_lists_at_minimum_terminal(void)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static size_t generator_zone_events(const char *zone_id, int *events)
|
||||||
|
{
|
||||||
|
size_t index;
|
||||||
|
for (index = 0U; index < trainlog_body_zone_catalog_count(); ++index) {
|
||||||
|
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_at(index);
|
||||||
|
if (zone != NULL && strcmp(zone->zone_id, zone_id) == 0) {
|
||||||
|
size_t event;
|
||||||
|
for (event = 0U; event < index; ++event) events[event] = TRAINLOG_KEY_DOWN;
|
||||||
|
events[index] = TRAINLOG_KEY_ENTER;
|
||||||
|
return index + 1U;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool seed_generator_leg_press(TrainlogDatabase *database)
|
||||||
|
{
|
||||||
|
return trainlog_database_insert_exercise_profiled(database,
|
||||||
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde", "Leg press", "leg press",
|
||||||
|
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) == TRAINLOG_STATUS_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_generator_dashboard_entry_and_policy_choices(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogTerminal terminal;
|
||||||
|
const TrainlogSessionGenerationGoalPolicy *goal = NULL;
|
||||||
|
int minutes = 0;
|
||||||
|
const int dashboard_events[] = {'g'};
|
||||||
|
const int goal_events[] = {TRAINLOG_KEY_DOWN, TRAINLOG_KEY_ENTER};
|
||||||
|
const int duration_events[] = {TRAINLOG_KEY_DOWN, TRAINLOG_KEY_ENTER};
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
script(&terminal, dashboard_events, sizeof(dashboard_events) / sizeof(dashboard_events[0]));
|
||||||
|
terminal.rows = 20; terminal.columns = 72; tui_terminal = &terminal;
|
||||||
|
CHECK(screen_dashboard(database) == DASHBOARD_GENERATE_SESSION);
|
||||||
|
CHECK(strstr(terminal.output, "g générer une séance") != NULL);
|
||||||
|
CHECK(!terminal.coordinate_overflow && !terminal.text_overflow);
|
||||||
|
script(&terminal, goal_events, sizeof(goal_events) / sizeof(goal_events[0]));
|
||||||
|
CHECK(generator_choose_goal(&goal));
|
||||||
|
CHECK(goal == &trainlog_session_generation_policy_v1.goals[1]);
|
||||||
|
script(&terminal, duration_events, sizeof(duration_events) / sizeof(duration_events[0]));
|
||||||
|
CHECK(generator_choose_duration(&minutes));
|
||||||
|
CHECK(minutes == trainlog_session_generation_policy_v1.duration_presets_minutes[1]);
|
||||||
|
tui_terminal = NULL; trainlog_database_close(database); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_generator_plan_drafts_have_no_actual_sets(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogGeneratedSession generated;
|
||||||
|
TrainlogSessionDraftExercise drafts[1];
|
||||||
|
char summary[160];
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(seed_generator_leg_press(database));
|
||||||
|
(void)memset(&generated, 0, sizeof(generated));
|
||||||
|
generated.exercise_count = 1U;
|
||||||
|
(void)snprintf(generated.exercises[0].exercise_id,
|
||||||
|
sizeof(generated.exercises[0].exercise_id), "%s",
|
||||||
|
"ex_b432623f-bfe9-4daf-a653-60ec7fdffbde");
|
||||||
|
(void)snprintf(generated.exercises[0].equipment_id,
|
||||||
|
sizeof(generated.exercises[0].equipment_id), "%s", "leg_press");
|
||||||
|
generated.exercises[0].target_sets = 3;
|
||||||
|
generated.exercises[0].target_repetitions = 10;
|
||||||
|
generated.exercises[0].rest_seconds = 120;
|
||||||
|
generated.exercises[0].planned_load_mode = TRAINLOG_LOAD_NONE;
|
||||||
|
CHECK(generator_build_drafts(database, &generated, drafts));
|
||||||
|
CHECK(drafts[0].input.target_sets == 3 && drafts[0].input.target_reps == 10);
|
||||||
|
CHECK(drafts[0].input.rest_seconds == 120);
|
||||||
|
CHECK(drafts[0].input.set_count == 0U && drafts[0].input.sets == drafts[0].sets);
|
||||||
|
draft_set_summary(&drafts[0], summary, sizeof(summary));
|
||||||
|
CHECK(strstr(summary, "Plan 3×10") != NULL);
|
||||||
|
CHECK(strstr(summary, "0 réalisée") != NULL);
|
||||||
|
trainlog_database_close(database); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_generator_preview_scrolls_at_minimum_terminal(void)
|
||||||
|
{
|
||||||
|
TrainlogGeneratedSession generated;
|
||||||
|
TrainlogGeneratorPreviewItem items[TRAINLOG_GENERATOR_MAX_SELECTED];
|
||||||
|
TrainlogTerminal terminal;
|
||||||
|
size_t index;
|
||||||
|
const int events[] = {TRAINLOG_KEY_RESIZE, TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN,
|
||||||
|
TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, TRAINLOG_KEY_DOWN, 'q'};
|
||||||
|
(void)memset(&generated, 0, sizeof(generated));
|
||||||
|
(void)memset(items, 0, sizeof(items));
|
||||||
|
generated.exercise_count = TRAINLOG_GENERATOR_MAX_SELECTED;
|
||||||
|
generated.estimated_duration_seconds = 1800;
|
||||||
|
for (index = 0U; index < generated.exercise_count; ++index) {
|
||||||
|
generated.exercises[index].target_sets = 3;
|
||||||
|
generated.exercises[index].target_repetitions = 10;
|
||||||
|
generated.exercises[index].rest_seconds = 120;
|
||||||
|
(void)snprintf(items[index].exercise_name, sizeof(items[index].exercise_name), "Exercice %zu", index + 1U);
|
||||||
|
(void)snprintf(items[index].equipment_name, sizeof(items[index].equipment_name), "Machine %zu", index + 1U);
|
||||||
|
(void)snprintf(items[index].zone_name, sizeof(items[index].zone_name), "Cuisses");
|
||||||
|
(void)snprintf(items[index].movement_name, sizeof(items[index].movement_name), "Extension du genou");
|
||||||
|
}
|
||||||
|
script(&terminal, events, sizeof(events) / sizeof(events[0]));
|
||||||
|
terminal.rows = 30; terminal.columns = 100; tui_terminal = &terminal;
|
||||||
|
CHECK(!generator_preview(&generated, items));
|
||||||
|
CHECK(strstr(terminal.output, "Exercice 6") != NULL);
|
||||||
|
CHECK(strstr(terminal.output, "aucune prescription numérique") != NULL);
|
||||||
|
CHECK(!terminal.coordinate_overflow && !terminal.text_overflow);
|
||||||
|
tui_terminal = NULL; return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_generator_recency_continue_and_cancel(void)
|
||||||
|
{
|
||||||
|
TrainlogBodyZoneRecentExposure exposure;
|
||||||
|
TrainlogTerminal terminal;
|
||||||
|
const TrainlogBodyZone *zone = trainlog_body_zone_catalog_lookup("back");
|
||||||
|
const int continue_events[] = {'c'};
|
||||||
|
const int cancel_events[] = {'q'};
|
||||||
|
(void)memset(&exposure, 0, sizeof(exposure));
|
||||||
|
exposure.warning_level = TRAINLOG_GENERATION_WARNING_WARNING;
|
||||||
|
exposure.within_24h.primary_set_count = 2U;
|
||||||
|
exposure.within_72h.secondary_set_count = 4U;
|
||||||
|
exposure.has_latest = true;
|
||||||
|
(void)snprintf(exposure.latest_started_at, sizeof(exposure.latest_started_at),
|
||||||
|
"%s", "2026-09-10T08:00:00.250+02:00");
|
||||||
|
CHECK(zone != NULL);
|
||||||
|
script(&terminal, continue_events, sizeof(continue_events) / sizeof(continue_events[0]));
|
||||||
|
terminal.rows = 20; terminal.columns = 72; tui_terminal = &terminal;
|
||||||
|
CHECK(generator_confirm_exposure(zone, &exposure) == 1);
|
||||||
|
CHECK(strstr(terminal.output, "Dos travaillé récemment") != NULL);
|
||||||
|
CHECK(strstr(terminal.output, "2 séries principales") != NULL);
|
||||||
|
CHECK(!terminal.coordinate_overflow && !terminal.text_overflow);
|
||||||
|
script(&terminal, cancel_events, sizeof(cancel_events) / sizeof(cancel_events[0]));
|
||||||
|
CHECK(generator_confirm_exposure(zone, &exposure) == -1);
|
||||||
|
tui_terminal = NULL; return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_generator_cancel_preview_writes_nothing(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogTerminal terminal;
|
||||||
|
int events[32];
|
||||||
|
size_t count = generator_zone_events("thighs", events);
|
||||||
|
size_t sessions = 99U;
|
||||||
|
CHECK(count > 0U);
|
||||||
|
events[count++] = TRAINLOG_KEY_ENTER;
|
||||||
|
events[count++] = TRAINLOG_KEY_ENTER;
|
||||||
|
events[count++] = 'q';
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(seed_generator_leg_press(database));
|
||||||
|
script(&terminal, events, count); tui_terminal = &terminal;
|
||||||
|
screen_session_generator(database);
|
||||||
|
CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sessions == 0U);
|
||||||
|
CHECK(strstr(terminal.output, "Aperçu de la séance générée") != NULL);
|
||||||
|
tui_terminal = NULL; trainlog_database_close(database); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_generator_accept_requires_and_persists_actual(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogTerminal terminal;
|
||||||
|
int events[48];
|
||||||
|
size_t count = generator_zone_events("thighs", events);
|
||||||
|
size_t sessions = 0U;
|
||||||
|
CHECK(count > 0U);
|
||||||
|
events[count++] = TRAINLOG_KEY_ENTER;
|
||||||
|
events[count++] = TRAINLOG_KEY_ENTER;
|
||||||
|
events[count++] = 'a';
|
||||||
|
events[count++] = TRAINLOG_KEY_ENTER;
|
||||||
|
events[count++] = 'a'; events[count++] = '8'; events[count++] = '\n';
|
||||||
|
events[count++] = 'f'; events[count++] = 'f'; events[count++] = 'x';
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(seed_generator_leg_press(database));
|
||||||
|
script(&terminal, events, count); tui_terminal = &terminal;
|
||||||
|
screen_session_generator(database);
|
||||||
|
CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sessions == 1U);
|
||||||
|
CHECK(strstr(terminal.output, "Valeurs réellement effectuées") != NULL);
|
||||||
|
CHECK(strstr(terminal.output, "Séance générée et réalisée enregistrée") != NULL);
|
||||||
|
tui_terminal = NULL; trainlog_database_close(database); return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool test_generator_empty_knowledge_cannot_save(void)
|
||||||
|
{
|
||||||
|
TrainlogDatabase *database = NULL;
|
||||||
|
TrainlogTerminal terminal;
|
||||||
|
const int events[] = {TRAINLOG_KEY_ENTER, TRAINLOG_KEY_ENTER,
|
||||||
|
TRAINLOG_KEY_ENTER, 'x'};
|
||||||
|
size_t sessions = 99U;
|
||||||
|
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
|
||||||
|
script(&terminal, events, sizeof(events) / sizeof(events[0]));
|
||||||
|
tui_terminal = &terminal; screen_session_generator(database);
|
||||||
|
CHECK(trainlog_database_session_count(database, &sessions) == TRAINLOG_STATUS_OK);
|
||||||
|
CHECK(sessions == 0U);
|
||||||
|
CHECK(strstr(terminal.output, "Pas assez d’exercices résolus") != NULL);
|
||||||
|
CHECK(strstr(terminal.output, "rien ne peut être enregistré") != NULL);
|
||||||
|
tui_terminal = NULL; trainlog_database_close(database); return true;
|
||||||
|
}
|
||||||
|
|
||||||
int main(void)
|
int main(void)
|
||||||
{
|
{
|
||||||
if (!test_assistance_creation_labels() ||
|
if (!test_assistance_creation_labels() ||
|
||||||
!test_duration_creation_starts_empty() ||
|
!test_duration_creation_starts_empty() ||
|
||||||
!test_append_requires_actual_and_rolls_back() ||
|
!test_append_requires_actual_and_rolls_back() ||
|
||||||
!test_empty_sets_cannot_finish() ||
|
!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");
|
(void)printf("PASS tui_workflows\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue