fix(cardio): keep live capture coherent across session type changes
This commit is contained in:
parent
41d0f1ff96
commit
5a7f80049a
5 changed files with 277 additions and 2 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -61,6 +61,16 @@
|
||||||
timeline, calibration provenance and exact raw HR evidence for instruction
|
timeline, calibration provenance and exact raw HR evidence for instruction
|
||||||
events carrying BPM before accepting them.
|
events carrying BPM before accepting them.
|
||||||
|
|
||||||
|
- Fixed live Cardio guidance after changing an already-started manual draft
|
||||||
|
from Training to Cardio. Draft persistence now atomically reconciles the
|
||||||
|
same active heart-rate capture from `session` to `cardio` without replacing
|
||||||
|
its identity or losing existing BPM/RR samples and sequence. A transition
|
||||||
|
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.
|
||||||
|
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
Development opened after stable v0.1.4 release.
|
Development opened after stable v0.1.4 release.
|
||||||
|
|
|
||||||
|
|
@ -10404,6 +10404,21 @@ class TrainlogRepository(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
draft: ActiveSessionDraft,
|
draft: ActiveSessionDraft,
|
||||||
) {
|
) {
|
||||||
|
val persistedSession =
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT session_id,session_kind FROM active_session_draft WHERE id=?",
|
||||||
|
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
persistedSession != null &&
|
||||||
|
persistedSession.second == SessionType.CARDIO.wireValue &&
|
||||||
|
draft.sessionType != SessionType.CARDIO &&
|
||||||
|
hasCardioOnlyFacts(db, persistedSession.first)
|
||||||
|
) {
|
||||||
|
error("A cardio session with guidance or calibration facts cannot change type.")
|
||||||
|
}
|
||||||
val selectedRowId =
|
val selectedRowId =
|
||||||
draft.form.selectedExercise
|
draft.form.selectedExercise
|
||||||
?.let {
|
?.let {
|
||||||
|
|
@ -10464,6 +10479,8 @@ class TrainlogRepository(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reconcileActiveSessionHeartRateContext(db, draft.sessionType)
|
||||||
|
|
||||||
ensureSessionTimelineForPersistedDraft(db, draft)
|
ensureSessionTimelineForPersistedDraft(db, draft)
|
||||||
|
|
||||||
/* CONTRACT: draft persistence currently rebuilds occurrence rows.
|
/* CONTRACT: draft persistence currently rebuilds occurrence rows.
|
||||||
|
|
@ -10630,6 +10647,48 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hasCardioOnlyFacts(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
sessionId: String,
|
||||||
|
): Boolean =
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT 1 FROM cardio_guidance_runs WHERE session_id=? " +
|
||||||
|
"UNION ALL SELECT 1 FROM cardio_calibrations WHERE session_id=? LIMIT 1",
|
||||||
|
arrayOf(sessionId, sessionId),
|
||||||
|
).use { it.moveToFirst() }
|
||||||
|
|
||||||
|
private fun reconcileActiveSessionHeartRateContext(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
sessionType: SessionType,
|
||||||
|
) {
|
||||||
|
val sessionId =
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT session_id FROM active_session_draft WHERE id=?",
|
||||||
|
arrayOf(ACTIVE_DRAFT_ID.toString()),
|
||||||
|
).use { cursor ->
|
||||||
|
check(cursor.moveToFirst())
|
||||||
|
cursor.getString(0)
|
||||||
|
}
|
||||||
|
val expectedContext =
|
||||||
|
if (sessionType == SessionType.CARDIO) {
|
||||||
|
HeartRateContextKind.CARDIO
|
||||||
|
} else {
|
||||||
|
HeartRateContextKind.SESSION
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WHY: the sensor service can open the capture while a new manual draft still has its
|
||||||
|
* default Training type, before the user selects Cardio.
|
||||||
|
* CONTRACT: every successful mutable draft-type save atomically reconciles the same
|
||||||
|
* session's active capture; cardio-only durable facts prevent a later transition away.
|
||||||
|
* INVARIANT: capture identity, samples, RR facts, sequence and the single active slot are
|
||||||
|
* preserved; only the compatible session/cardio context discriminator may change. */
|
||||||
|
db.execSQL(
|
||||||
|
"UPDATE heart_rate_captures SET context_kind=? WHERE active_slot=1 " +
|
||||||
|
"AND context_id=? AND context_kind IN('session','cardio') AND context_kind<>?",
|
||||||
|
arrayOf(expectedContext.wireValue, sessionId, expectedContext.wireValue),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun insertCompletedSession(
|
private fun insertCompletedSession(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
draft: SessionDraft,
|
draft: SessionDraft,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
import com.labfytools.trainlog.model.ActiveSessionDraft
|
||||||
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
import com.labfytools.trainlog.model.CardioGuidanceInstruction
|
||||||
|
|
@ -9,6 +10,7 @@ import com.labfytools.trainlog.model.CardioGuidedPhase
|
||||||
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
import com.labfytools.trainlog.model.CardioPhaseExitCondition
|
||||||
import com.labfytools.trainlog.model.CardioPhaseKind
|
import com.labfytools.trainlog.model.CardioPhaseKind
|
||||||
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
import com.labfytools.trainlog.model.CardioTargetSnapshot
|
||||||
|
import com.labfytools.trainlog.model.HeartRateContextKind
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
import com.labfytools.trainlog.model.RecordingMode
|
||||||
import com.labfytools.trainlog.model.SessionExerciseDraft
|
import com.labfytools.trainlog.model.SessionExerciseDraft
|
||||||
|
|
@ -81,6 +83,201 @@ class CardioGuidanceRepositoryTest {
|
||||||
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
|
return ActiveCardio(repository, name, entry.entryId, exerciseStartedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trainingCaptureTransitionsToCardioWithoutStarvingGuidance() {
|
||||||
|
val name = "guidance-transition-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val exercise =
|
||||||
|
(repository.createExercise(
|
||||||
|
NewExerciseProfile(
|
||||||
|
"Vélo guidé après transition",
|
||||||
|
RecordingMode.CONTINUOUS,
|
||||||
|
TrackingMode.DURATION,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
) as CreateExerciseResult.Created).exercise
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
|
val training =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
val base = OffsetDateTime.parse(checkNotNull(training.startedAt))
|
||||||
|
val firstMeasurement =
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = 101,
|
||||||
|
sensorContactDetected = true,
|
||||||
|
energyExpended = 12,
|
||||||
|
rrIntervals1024 = listOf(611),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
base.plusSeconds(1).toString(),
|
||||||
|
firstMeasurement,
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val originalCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||||
|
assertEquals(HeartRateContextKind.SESSION, originalCapture.contextKind)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||||
|
)
|
||||||
|
val transitionedCapture = checkNotNull(repository.activeHeartRateCapture())
|
||||||
|
assertEquals(originalCapture.captureId, transitionedCapture.captureId)
|
||||||
|
assertEquals(HeartRateContextKind.CARDIO, transitionedCapture.contextKind)
|
||||||
|
|
||||||
|
val entry =
|
||||||
|
SessionExerciseDraft(
|
||||||
|
exercise = exercise,
|
||||||
|
plan = SessionExercisePlan(sets = 0, durationSeconds = 60),
|
||||||
|
)
|
||||||
|
val cardio =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(cardio.copy(exercises = listOf(entry))),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.startActiveSessionExercise(entry.entryId, base.plusSeconds(2).toString()) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
val target = CardioTargetSnapshot(100, 110)
|
||||||
|
val phase =
|
||||||
|
CardioGuidedPhase(
|
||||||
|
phaseId = "cgp_" + UUID.randomUUID(),
|
||||||
|
kind = CardioPhaseKind.WARMUP,
|
||||||
|
target = target,
|
||||||
|
exitCondition = CardioPhaseExitCondition.FixedDuration(60),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.startCardioGuidancePhase(
|
||||||
|
entry.entryId,
|
||||||
|
phase,
|
||||||
|
base.plusSeconds(3).toString(),
|
||||||
|
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val secondAt = base.plusSeconds(4).toString()
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
secondAt,
|
||||||
|
firstMeasurement.copy(bpm = 99),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordCardioGuidanceOutput(
|
||||||
|
phase.phaseId,
|
||||||
|
secondAt,
|
||||||
|
CardioGuidanceOutput(CardioGuidanceInstruction.ACCELERATE, 99, target),
|
||||||
|
) is TrainlogRepository.CardioGuidanceMutationResult.Applied,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(CardioGuidanceInstruction.ACCELERATE),
|
||||||
|
repository.listCardioGuidanceEvents(phase.phaseId).map { it.instruction },
|
||||||
|
)
|
||||||
|
|
||||||
|
val lateReverse =
|
||||||
|
repository.saveActiveSessionDraft(
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||||
|
.draft
|
||||||
|
.copy(sessionType = SessionType.TRAINING),
|
||||||
|
)
|
||||||
|
assertTrue(lateReverse is ActiveDraftMutationResult.Error)
|
||||||
|
assertEquals(
|
||||||
|
SessionType.CARDIO,
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded)
|
||||||
|
.draft
|
||||||
|
.sessionType,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
HeartRateContextKind.CARDIO,
|
||||||
|
checkNotNull(repository.activeHeartRateCapture()).contextKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT sequence,bpm,exercise_entry_id FROM heart_rate_samples " +
|
||||||
|
"WHERE capture_id=? ORDER BY sequence",
|
||||||
|
arrayOf(originalCapture.captureId),
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(0L, cursor.getLong(0))
|
||||||
|
assertEquals(101, cursor.getInt(1))
|
||||||
|
assertTrue(cursor.isNull(2))
|
||||||
|
assertTrue(cursor.moveToNext())
|
||||||
|
assertEquals(1L, cursor.getLong(0))
|
||||||
|
assertEquals(99, cursor.getInt(1))
|
||||||
|
assertEquals(entry.entryId, cursor.getString(2))
|
||||||
|
assertTrue(!cursor.moveToNext())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.discardActiveSessionDraft())
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
assertNull(repository.activeCardioGuidancePhase())
|
||||||
|
assertTrue(repository.listActiveSessionExerciseTimings().isEmpty())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cardioCaptureCanReturnToTrainingBeforeCardioOnlyFactsExist() {
|
||||||
|
val name = "guidance-early-reverse-" + UUID.randomUUID() + ".db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
|
val training =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
val observedAt =
|
||||||
|
OffsetDateTime.parse(checkNotNull(training.startedAt)).plusSeconds(1).toString()
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
observedAt,
|
||||||
|
ParsedHeartRateMeasurement(103, null, null, listOf(600)),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val captureId = checkNotNull(repository.activeHeartRateCapture()).captureId
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(training.copy(sessionType = SessionType.CARDIO)),
|
||||||
|
)
|
||||||
|
val cardio =
|
||||||
|
(repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
|
||||||
|
assertEquals(
|
||||||
|
ActiveDraftMutationResult.Saved,
|
||||||
|
repository.saveActiveSessionDraft(cardio.copy(sessionType = SessionType.TRAINING)),
|
||||||
|
)
|
||||||
|
val reversed = checkNotNull(repository.activeHeartRateCapture())
|
||||||
|
assertEquals(captureId, reversed.captureId)
|
||||||
|
assertEquals(HeartRateContextKind.SESSION, reversed.contextKind)
|
||||||
|
|
||||||
|
SQLiteDatabase.openDatabase(
|
||||||
|
context.getDatabasePath(name).path,
|
||||||
|
null,
|
||||||
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
|
).use { db ->
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT COUNT(*),MIN(bpm) FROM heart_rate_samples WHERE capture_id=?",
|
||||||
|
arrayOf(captureId),
|
||||||
|
).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(1, cursor.getInt(0))
|
||||||
|
assertEquals(103, cursor.getInt(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
|
fun phaseRequiresActiveCardioExerciseAndRecordsOnlyInstructionChanges() {
|
||||||
val active = activeCardio()
|
val active = activeCardio()
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,8 @@ parallel implementation of its rules.
|
||||||
| 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 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 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 |
|
| 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`: complete native 111/111, Android 300 tests (295 pass + 5 historical skips), Web 183/183 + typecheck/build, vc43 in-place private update with schema v31/integrity/data preservation and live CYCPLUS 94–95 BPM foreground capture validated while locked |
|
| 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`: unlock the private phone and visually smoke-test the Android Sommeil, Calibration cardio and active guided-cardio presentation before any stable release decision |
|
| 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 |
|
||||||
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
| 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` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,15 @@ Bluetooth synchronization transport contract.
|
||||||
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1
|
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_0_1_5_DEVICE_VISUAL_REVIEW_V1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The vc45 real-device smoke found that selecting Cardio after live capture had
|
||||||
|
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.
|
||||||
|
|
||||||
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this
|
||||||
cursor: complete predecessor state, imported built-in authorization,
|
cursor: complete predecessor state, imported built-in authorization,
|
||||||
cross-platform draft revision identity and resource-bound admission are closed.
|
cross-platform draft revision identity and resource-bound admission are closed.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue