diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9615a..c0c6326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/ProgramSessionCardioReadinessTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramSessionCardioReadinessTest.kt new file mode 100644 index 0000000..948e684 --- /dev/null +++ b/android/app/src/test/java/com/labfytools/trainlog/data/ProgramSessionCardioReadinessTest.kt @@ -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() + } + } +} diff --git a/docs/current_state.md b/docs/current_state.md index dad0191..1869863 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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` | diff --git a/docs/roadmap.md b/docs/roadmap.md index bc6506b..73e5da8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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,