test(cardio): prove program session heart-rate timeline readiness

This commit is contained in:
fy59 2026-09-23 18:54:29 +02:00
parent 5a7f80049a
commit 21f56e3986
4 changed files with 503 additions and 9 deletions

View file

@ -68,8 +68,18 @@
away from Cardio remains possible before Cardio-only facts exist, but is
rejected once durable guidance or calibration facts would make it
ambiguous. Repository coverage reproduces the real ordering through an
active exercise and guidance event; a private vc46 device re-smoke remains
required.
active exercise and guidance event. The private vc46 re-smoke retained the
original capture and more than 31 existing samples across the transition,
then displayed a measured 92 BPM and `ACCÉLÈRE`; the Sommeil and Calibration
cardio screens also rendered without creating persistent data.
- Added one end-to-end tomorrow-session readiness regression. A synchronized
Program session retains its stable session and Program identities through
edits, two explicit exercise intervals, pre-exercise/inter-exercise/active
heart-rate ownership, finalization, full-generation publication, desktop
import, replay and correlated ACK. Raw observation timestamps, BPM, RR,
nullable stable exercise ownership and capture/session bounds are compared
on both platforms without inferring ownership from time overlap.
- Added factual synchronized heart-rate timelines to the local Web. One bounded C17 read model and loopback GET endpoint now project raw BPM/RR measurements with canonical workout exercise intervals or Sleep Diary/medication markers. Cardio history additionally presents resolved phase targets, instruction changes and calibration peak/recovery facts. History and Sleep reuse one responsive SVG component; full-sample min/max/mean statistics remain exact while only very large chart projections retain bounded bucket extrema. No sleep stage, HRV, readiness or medical inference is added.

View file

@ -0,0 +1,474 @@
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.HeartRateContextKind
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.time.OffsetDateTime
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ProgramSessionCardioReadinessTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun programSessionPreservesTimelineAndHeartRateOwnershipThroughDesktopReplay() {
val androidName = "program-cardio-readiness-${UUID.randomUUID()}.db"
val android = TrainlogRepository(context, androidName)
val root = Files.createTempDirectory("trainlog-program-cardio-readiness-").toFile()
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val desktopDb = java.io.File(root, "desktop.sqlite")
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
fun measurement(bpm: Int, rr: Int) =
ParsedHeartRateMeasurement(
bpm = bpm,
sensorContactDetected = true,
energyExpended = null,
rrIntervals1024 = listOf(rr),
)
try {
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val programSessionId = "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
val programExerciseId = "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd"
val catalog =
JSONObject()
.put("format", "trainlog-pc-catalog")
.put("version", 1)
.put(
"exercises",
JSONArray().put(
JSONObject()
.put("exercise_id", programExerciseId)
.put("name", "Program exercise fixture")
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", 0),
),
)
.toString()
assertTrue(android.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
val program =
JSONObject()
.put("program_id", programId)
.put("revision_id", "pgr_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("title", "Tomorrow readiness")
.put("note", JSONObject.NULL)
.put("state", "active")
.put("start_date", "2026-09-24")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-23T16:00:00Z")
.put("updated_at", "2026-09-23T16:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", programSessionId)
.put("title", "Session A")
.put("session_type", "training")
.put("planned_for", "2026-09-24")
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray().put(
JSONObject()
.put(
"entry_id",
"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
)
.put("exercise_id", programExerciseId)
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 1)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL),
),
),
),
)
val programs =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-23T16:01:00Z")
.put("programs", JSONArray().put(program))
.put("deletions", JSONArray())
.toString()
assertEquals(
ProgramsImportResult.Applied(1, 0, 0),
android.applyProgramsV1Json(programs),
)
val exerciseB =
(android.createExercise(
NewExerciseProfile(
"Readiness exercise B",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
assertEquals(
StartProgramSessionResult.Started,
android.startSyncedProgramSession(programId, programSessionId),
)
val opened = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
val sessionId = checkNotNull(opened.draft.sessionId)
val sessionStartedAt = checkNotNull(opened.draft.startedAt)
assertEquals(programId, opened.draft.sourceProgramId)
assertEquals(programSessionId, opened.draft.sourceProgramSessionId)
val exerciseA = opened.draft.exercises.single()
val exerciseBEntry =
SessionExerciseDraft(
exercise = exerciseB,
sets = listOf(SessionSetDraft(reps = 10, weightKg = 25.0)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
android.saveActiveSessionDraft(
opened.draft.copy(
exercises =
listOf(
exerciseA.copy(
sets = listOf(SessionSetDraft(reps = 8, weightKg = 40.0)),
),
exerciseBEntry,
),
),
),
)
val edited = android.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(sessionId, edited.draft.sessionId)
assertEquals(sessionStartedAt, edited.draft.startedAt)
assertEquals(programId, edited.draft.sourceProgramId)
assertEquals(programSessionId, edited.draft.sourceProgramSessionId)
val preExerciseAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
preExerciseAt,
measurement(91, 675),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val capture = checkNotNull(android.activeHeartRateCapture())
assertEquals(HeartRateContextKind.SESSION, capture.contextKind)
assertEquals(sessionId, capture.contextId)
assertEquals(sessionStartedAt, capture.startedAt)
val exerciseAStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(exerciseA.entryId, exerciseAStartedAt) is
TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseASampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseASampleAt,
measurement(112, 548),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseAEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(exerciseA.entryId, exerciseAEndedAt) is
TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val betweenExercisesAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
betweenExercisesAt,
measurement(98, 620),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val exerciseBStartedAt = OffsetDateTime.now().toString()
assertTrue(
android.startActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBStartedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Started,
)
val exerciseBSampleAt = OffsetDateTime.now().toString()
assertTrue(
android.recordLiveHeartRateForActiveSession(
"Synthetic CYCPLUS",
exerciseBSampleAt,
measurement(119, 525),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val activeFinalize = android.finalizeActiveSessionDraft()
assertTrue(activeFinalize is FinalizeActiveDraftResult.Invalid)
assertTrue(
(activeFinalize as FinalizeActiveDraftResult.Invalid)
.message
.startsWith("ACTIVE_EXERCISE:"),
)
val exerciseBEndedAt = OffsetDateTime.now().toString()
assertTrue(
android.finishActiveSessionExercise(
exerciseBEntry.entryId,
exerciseBEndedAt,
) is TrainlogRepository.SessionExerciseTimingResult.Finished,
)
val finalized = android.finalizeActiveSessionDraft()
assertTrue(finalized is FinalizeActiveDraftResult.Saved)
assertEquals(sessionId, (finalized as FinalizeActiveDraftResult.Saved).sessionId)
assertNull(android.activeHeartRateCapture())
val timeline = JSONObject(android.buildSessionTimelineV1Json())
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
val sessionEndedAt = timelineSession.getString("ended_at")
assertEquals(sessionId, timelineSession.getString("session_id"))
assertEquals(sessionStartedAt, timelineSession.getString("started_at"))
assertTrue(OffsetDateTime.parse(sessionEndedAt) > OffsetDateTime.parse(sessionStartedAt))
val timelineExercises = timelineSession.getJSONArray("exercises")
assertEquals(2, timelineExercises.length())
assertEquals(exerciseA.entryId, timelineExercises.getJSONObject(0).getString("entry_id"))
assertEquals(exerciseAStartedAt, timelineExercises.getJSONObject(0).getString("started_at"))
assertEquals(exerciseAEndedAt, timelineExercises.getJSONObject(0).getString("ended_at"))
assertEquals(
exerciseBEntry.entryId,
timelineExercises.getJSONObject(1).getString("entry_id"),
)
assertEquals(exerciseBStartedAt, timelineExercises.getJSONObject(1).getString("started_at"))
assertEquals(exerciseBEndedAt, timelineExercises.getJSONObject(1).getString("ended_at"))
val heart = JSONObject(android.buildHeartRateV1Json())
val heartCapture = heart.getJSONArray("captures").getJSONObject(0)
assertEquals(capture.captureId, heartCapture.getString("capture_id"))
assertEquals("session", heartCapture.getString("context_kind"))
assertEquals(sessionId, heartCapture.getString("context_id"))
assertEquals(sessionStartedAt, heartCapture.getString("started_at"))
assertEquals(sessionEndedAt, heartCapture.getString("ended_at"))
val samples = heartCapture.getJSONArray("samples")
assertEquals(4, samples.length())
val expectedOwnership =
listOf(null, exerciseA.entryId, null, exerciseBEntry.entryId)
val expectedTimes =
listOf(preExerciseAt, exerciseASampleAt, betweenExercisesAt, exerciseBSampleAt)
val expectedRr = listOf(675, 548, 620, 525)
for (index in 0 until samples.length()) {
val sample = samples.getJSONObject(index)
assertEquals(index.toLong(), sample.getLong("sequence"))
assertEquals(expectedTimes[index], sample.getString("observed_at"))
if (expectedOwnership[index] == null) {
assertTrue(sample.isNull("exercise_entry_id"))
} else {
assertEquals(expectedOwnership[index], sample.getString("exercise_entry_id"))
}
assertEquals(expectedRr[index], sample.getJSONArray("rr_intervals_1024").getInt(0))
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(androidName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
db.rawQuery(
"SELECT source_program_id,source_program_session_id FROM sessions " +
"WHERE session_id=?",
arrayOf(sessionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(programId, cursor.getString(0))
assertEquals(programSessionId, cursor.getString(1))
}
}
run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(android)
val captured = service.capture(root, desktopPeer)
val published = service.publish(captured, java.io.File(root, "objects"))
val publishedTimeline =
JSONObject(java.io.File(published, "session-timeline-v1.json").readText())
val publishedHeart =
JSONObject(java.io.File(published, "heart-rate-v1.json").readText())
assertEquals(sessionId, publishedTimeline.getJSONArray("sessions").getJSONObject(0).getString("session_id"))
assertEquals(capture.captureId, publishedHeart.getJSONArray("captures").getJSONObject(0).getString("capture_id"))
val firstAck = java.io.File(root, "desktop-ack-first.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
firstAck.absolutePath,
)
val countsBeforeReplay =
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim()
assertEquals("2|4|4", countsBeforeReplay)
val replayAck = java.io.File(root, "desktop-ack-replay.json")
run(
"python3",
generationTool,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
replayAck.absolutePath,
)
assertEquals(
countsBeforeReplay,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT (SELECT count(*) FROM session_exercise_timeline WHERE session_id='$sessionId') || '|' || " +
"(SELECT count(*) FROM heart_rate_samples WHERE capture_id='${capture.captureId}') || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals WHERE capture_id='${capture.captureId}');",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(firstAck.readBytes()),
)
assertEquals(
0,
JSONObject(android.buildSessionTimelineV1Json()).getJSONArray("sessions").length(),
)
assertEquals(
0,
JSONObject(android.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
val desktopFacts =
JSONObject(
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT json_object(" +
"'program_id',x.program_id,'program_session_id',x.program_session_id," +
"'started_at',s.started_at,'ended_at',s.ended_at) FROM sessions s " +
"JOIN program_session_executions x ON x.session_id=s.session_id " +
"WHERE s.session_id='$sessionId' AND x.state='completed';",
).trim(),
)
assertEquals(programId, desktopFacts.getString("program_id"))
assertEquals(programSessionId, desktopFacts.getString("program_session_id"))
assertEquals(sessionStartedAt, desktopFacts.getString("started_at"))
assertEquals(sessionEndedAt, desktopFacts.getString("ended_at"))
assertEquals(
listOf(
"$exerciseAStartedAt|$exerciseAEndedAt|${exerciseA.entryId}",
"$exerciseBStartedAt|$exerciseBEndedAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT started_at,ended_at,entry_id FROM session_exercise_timeline " +
"WHERE session_id='$sessionId' ORDER BY started_at,entry_id;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
"session|$sessionId|$sessionStartedAt|$sessionEndedAt",
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT context_kind,context_id,started_at,ended_at " +
"FROM heart_rate_captures WHERE capture_id='${capture.captureId}';",
).trim(),
)
assertEquals(
listOf(
"0|$preExerciseAt|",
"1|$exerciseASampleAt|${exerciseA.entryId}",
"2|$betweenExercisesAt|",
"3|$exerciseBSampleAt|${exerciseBEntry.entryId}",
),
run(
"sqlite3",
"-separator",
"|",
desktopDb.absolutePath,
"SELECT sequence,observed_at,COALESCE(exercise_entry_id,'') " +
"FROM heart_rate_samples " +
"WHERE capture_id='${capture.captureId}' ORDER BY sequence;",
).lineSequence().filter { it.isNotBlank() }.toList(),
)
assertEquals(
expectedRr,
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT value_1024 FROM heart_rate_rr_intervals " +
"WHERE capture_id='${capture.captureId}' ORDER BY sample_sequence,rr_index;",
).lineSequence().filter { it.isNotBlank() }.map { it.toInt() }.toList(),
)
} finally {
android.close()
context.deleteDatabase(androidName)
root.deleteRecursively()
}
}
}

View file

@ -151,13 +151,14 @@ parallel implementation of its rules.
| Cardio BLE diagnostics | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1=PASS`: real CYCPLUS H2 validated on Android; `0x180D` + `0x2A37`, Battery Service, approximately 1 Hz notifications and one raw RR interval per observed notification; contact and energy fields absent in the observed payloads |
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=PASS`: existing Sleep Diary V1 has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; the private vc46 Sommeil screen rendered correctly and was inspected without creating a night or capture |
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
| Cardio calibration | `TRAINLOG_CARDIO_CALIBRATION_V1=PASS`: reserved system Calibration cardio exercise, real measured observed peak, ≥3 min recovery with bounded +1/+2/+3 min factual samples, immutable profile and Android→desktop calibration companion |
| Cardio BPM guidance | `TRAINLOG_CARDIO_BPM_GUIDANCE_V1=PASS`: absolute/calibration-derived immutable BPM targets, 2 BPM hysteresis, 3 s confirmation, stale-signal suspension, durable phase/condition history, Android live guidance UI and strict Android→desktop `trainlog-cardio-guidance` V1 sync |
| Cardio Web visualization | `TRAINLOG_CARDIO_WEB_VISUALIZATION_V1=PASS`: one bounded factual heart-rate timeline read model/HTTP endpoint powers Web History and Sleep with raw BPM/RR, real session/exercise/Sleep markers, guided targets/instructions and calibration facts; no sleep-stage or medical inference |
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: native 111/111; Android 303 tests (298 pass + 5 historical skips) and debug APK build; Web 183/183 + typecheck/build baseline. The vc45 device review proved that a Training → Cardio draft transition stranded the already-active HR capture; repository persistence now reconciles that capture atomically with sample continuity, and a vc46 re-smoke remains required. |
| Current operational cursor | `TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1`: deploy the separately built vc46 candidate to the private phone and re-smoke Sommeil, Calibration cardio and the corrected active guided-cardio Training → Cardio transition before any stable release decision |
| 0.1.5 validation | `TRAINLOG_0_1_5_VALIDATION_V1=SOFTWARE_PASS / DEVICE_VISUAL_PASS`: native 111/111; Android 304 tests (299 pass + 5 historical skips) and debug APK build; Web 183/183 + typecheck/build baseline. Private vc46 preserves the live HR capture and samples across Training → Cardio, drives measured guidance, and renders Sommeil and Calibration cardio without mutation. No stable release is authorized by this status. |
| Tomorrow session readiness | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: the private vc46 is clean on schema v31 with fresh CYCPLUS BPM and the next Program session is unambiguous; one combined regression proves Program provenance, factual two-exercise timeline/HR ownership, finalization, full generation, desktop import, replay and correlated ACK |
| Current operational cursor | `TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS`: tomorrow's recording path is ready; any stable 0.1.5 tag or release remains a separate explicit decision |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
Bluetooth synchronization transport contract.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_TOMORROW_SESSION_CARDIO_READINESS_V1=PASS
```
The vc45 real-device smoke found that selecting Cardio after live capture had
@ -77,9 +77,18 @@ already started on the default Training draft left the durable capture tagged
as `session`, starving Cardio guidance despite fresh global BPM. Repository
persistence now reconciles the same capture atomically while retaining its
identity, sample order and raw BPM/RR facts; automated coverage reproduces the
transition through an active exercise and guidance event. The cursor remains
pending until the corrected flow is re-smoked on the separately deployed vc46
candidate.
transition through an active exercise and guidance event. The vc46 re-smoke
then passed with the same capture, preserved pre-transition samples, measured
guidance, and non-mutating Sommeil/Calibration screen checks.
Tomorrow's next synchronized Program session is unambiguously
`S1 D — Haut du corps machines` on 2026-09-24 with seven occurrences. The
private phone remains on Android schema v31 with integrity/FK checks clean and
no active draft, timeline, HR capture, guidance phase or calibration. A single
combined regression now proves Program start and provenance through two
explicit exercise intervals, four HR ownership positions, finalization, full
generation, desktop import, idempotent replay and correlated ACK. This
readiness result does not authorize a stable 0.1.5 tag or release.
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
cursor: complete predecessor state, imported built-in authorization,