Skip to content

Dev - #17

Open
ShaggydogUK wants to merge 452 commits into
tim2000s:devfrom
ShaggydogUK:dev
Open

Dev#17
ShaggydogUK wants to merge 452 commits into
tim2000s:devfrom
ShaggydogUK:dev

Conversation

@ShaggydogUK

Copy link
Copy Markdown

No description provided.

Tim Street and others added 30 commits June 2, 2026 23:37
Add 6 new RT fields populated at end of invoke():
  hrBpmLatest, hrBpmAvg5m, hrBpmAvg15m   — duration-weighted HR observations
  hrReadingsCount15m                     — data-quality signal
  sleepState                             — AWAKE | PRE_SLEEP | SLEEPING
  sleepStateEnteredAtMs                  — for dwell-time computation

These ride the existing devicestatus uploader to NS at one observation per
Boost cycle (~5 min), sufficient for retrospective sleep-model tuning.

No new sync plumbing — the RT object already flows to NS as part of
openaps.suggested.
V3MLG3 is the V4.4.4 acting algorithm. Add the same sleep state evaluation +
HR/sleep telemetry as the V1 plugin, sharing the same SleepStateDetector
and SharedPreferences-backed state (ApsBoostSleepState StringKey).

The two plugins are not active simultaneously, so they share state safely.

isNightModeActiveImpl() in V3MLG3 now honours ApsBoostNightModeAutoBySleep
in the same HYBRID way: sleep state extends night-mode active window past
nightEnd and engages it early via PRE_SLEEP.
New SleepHistoryTracker maintains a rolling 28-day record of (sleep_start_ms,
wake_ms) sessions persisted to StringKey.ApsBoostSleepHistory.

Aggregation uses *circular* mean (vector sum on unit circle, atan2 back) so
the wrap-around case is handled correctly:
  avg of [22:00, 02:00] = 00:00, not 12:00

Once ≥7 closed sessions exist in the window, the plugins override
configured nightStart / nightEnd with the learned circular-mean clock
times when calling SleepStateDetector. PRE_SLEEP then engages
preSleepLeadMin (default 60) before the learned sleep onset, not the
fixed configured time.

Sessions are recorded on transitions:
  AWAKE→SLEEPING  → onSleepStart (opens session)
  SLEEPING→AWAKE  → onWake (closes session + trim to 28-day window)

Also: SleepStateDetector now allows AWAKE→SLEEPING when the user qualifies
during the PRE_SLEEP window (previously required outer window), so an
unusually-early sleep onset is detected promptly.

Telemetry: sleepLearnedStartMin, sleepLearnedWakeMin,
sleepLearnedDurationMin, sleepLearnedSessionCount on RT for NS upload.
rT.reason includes 'learned=HH:mm→HH:mm/Nd' when active.
Extend SleepHistoryTracker.Session with two p10 (10th-percentile) HR
summaries computed at SLEEPING→AWAKE transition:

  sleepHrP10     — over the just-ended sleep period (deep-sleep floor)
  daytimeHrP10   — over the awake period preceding this sleep
                   (since previous session's wakeMs)

Aggregate returns:
  restingHrBpm   — median of valid sleepHrP10s (≥7 sessions)  → true resting
  daytimeHrBpm   — median of valid daytimeHrP10s (≥7 sessions) → active baseline

p10 helper requires ≥30 samples per session (≈30 min of 1-min Wear OS HR)
to be meaningful; otherwise that session contributes null.

Wiring:
  - SleepStateDetector now uses (restingHrBpm ?? configuredHrResting) so
    the SLEEPING qualifier and wake threshold scale to the user's actual
    deep-sleep floor (default cap = restingHr × 1.15, wake = × 1.25).
  - HrActivityCalculator.classify in both plugins now uses
    (daytimeHrBpm ?? configuredHrResting) as the Karvonen HRR baseline.
    Cached cycle-to-cycle in @volatile field (5-min lag acceptable —
    baseline changes slowly).

Telemetry: hrLearnedRestingBpm, hrLearnedDaytimeBpm on RT → NS.

Storage: per-session p10 ints add ~50B/session × 28 = ~1.4KB total.
Add HealthConnectHrIngest @singleton that reads HeartRateRecord from
Android Health Connect on a polling cadence and writes through
PersistenceLayer.insertOrUpdateHeartRate.

Why: Garmin Connect IQ data fields only execute when on the active watch
screen. The user's overnight HR is invisible to AAPS even though it
flows continuously into Garmin Connect on the phone. Health Connect is
the convergence point — Garmin / Wear OS both write to it, AAPS just
needs to read.

Behaviour:
  - Throttled internally; default 5 min between syncs (knob:
    ApsBoostHealthConnectPollMin)
  - Each Boost cycle triggers a non-blocking syncIfDue(); coroutine on
    Dispatchers.IO
  - First run pulls last 60 min; subsequent runs use
    LongNonKey.ApsBoostHealthConnectLastSyncMs as the high-water mark
  - HC samples are 1-min weighted into AAPS HR rows (timestamp from
    sample, duration = 60_000 ms, device = HC metadata)
  - No-op if disabled, no SDK, or permission not granted; logs and
    never throws into the calling cycle

Wiring:
  - Hooked at the top of both V1 and V3MLG3 invoke()
  - Settings UI: opt-in toggle + poll interval next to sleep prefs

Manifest:
  - <uses-permission android:name="android.permission.health.READ_HEART_RATE" />
  - <queries> entry for com.google.android.apps.healthdata
…st is on

When ApsBoostHealthConnectHrEnabled is true, GarminPlugin's private
receiveHeartRate sink early-exits before calling loopHub.storeHeartRate.
HC becomes the single source of truth — avoids duplicate HR rows at
overlapping timestamps (Garmin path vs HC path writing the same wall-clock
samples with different device labels).

Gate is in the private sink so both URI (HTTP) and IQ-message entry points
are covered. When HC is disabled (default), Garmin side-channel behaves
exactly as before.
Add HealthConnectPermissionActivity (single-purpose, translucent, no-history)
that wires the standard PermissionController.createRequestPermissionResultContract
ActivityResultLauncher and finishes when the user grants/denies.

Add a "Grant Health Connect access" Preference under Night Mode for both
V1 and V3MLG3 plugins. Tap launches the Activity which immediately shows
the HC consent dialog. Toasts on grant/deny/SDK-missing.

Before this commit, the only path to grant the permission was via the
Health Connect app, but HC only lists apps that have actively requested
permissions — chicken-and-egg. Tapping this preference is the missing
"first ask" so AAPS appears in the HC permissions list.

Activity registered in the APS module's AndroidManifest.xml.
Health Connect's permission flow refuses to surface the consent UI unless
the app declares an Activity it can deep-link into for "Why does this app
want this data?". Without the manifest entry the request silently denies.

Add HealthConnectPrivacyActivity (programmatically-built plain-text screen,
no resource files needed) and the two intent filters HC looks for:

  - android.intent.action.VIEW_PERMISSION_USAGE
    + category android.intent.category.HEALTH_PERMISSIONS  (Android 14+)
  - androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE      (Android 13 and below)

Activity is exported=true (required — the OS launches it on the user's
behalf, gated by START_VIEW_PERMISSION_USAGE). The displayed text states
what HR is used for and that data stays on-device.

After this commit the existing "Grant Health Connect access" button in
Boost Night Mode preferences should produce the system consent dialog
instead of the silent-deny path.
Brings the 16 upstream patch-release commits since 3.4.2.1 (merge-base
1393357, Mar 31 2026 → tag 3.4.2.2, Apr 10 2026). All changes are
pump-driver bug-fixes and CI; nothing touches Boost-specific code paths.

What's pulled in:
  - Medtrum patch activation/deactivation/priming flow improvements
  - Equil: setLastDataTime on decode, don't enforce password
  - Medtronic: getRefreshTime method
  - CI: tag/branch build workflows, JDK version mapping
  - Version bump to 3.4.2.2

Files touched: pump/medtrum/*, pump/equil/*, pump/medtronic/*, CI scripts.
No conflicts. Boost-V4.4.2-tier-release branch.
…HR platforms

Garmin (and other consumer-health platforms that go through Health Connect)
batch HR sync overnight rather than streaming it. The result is 0–3% HR
coverage during sleep hours and a 67% catchup burst at wake. The existing
SleepStateDetector required continuous HR-value qualification, so SLEEPING
was never reached on these devices.

Two new signals using the transmission pattern itself:

1. Drought-qualified sleep candidacy: when avgHr is null AND we've seen no
   fresh HR sample for ≥droughtThresholdMin (default 30), AND in outer night
   window AND steps + meal gates pass, the cycle counts as a sleep candidate.
   Existing HR-value path still wins when live HR is available.

2. Transmission-resume wake: in SLEEPING, if ≥3 fresh samples arrive in the
   last 15 min AND the prior drought was ≥droughtThresholdMin, transition
   AWAKE immediately. The burst-size threshold prevents false wakes from
   stray mid-night syncs.

State.lastFreshHrSampleMs added (persisted across cycles + restarts) to
track the most recent live sample timestamp. Backwards-compatible: existing
call sites unchanged, existing serialized state deserializes cleanly.

Verified empirically against 48h of Tim's Nightscout data — drought signature
matches actual sleep periods; 08:00 BST sync burst matches wake.
Adds an NS-uploaded field that records which qualifier promoted the most
recent SLEEPING entry: "hr" (HR-value path, avgHr ≤ resting × 1.15) or
"drought" (no live HR for ≥droughtThresholdMin — the batched-HR-platform
fallback shipped in 95ce6f7).

Lets us validate which path is actually firing in production rather than
inferring from coverage patterns. Confirmed today (2026-06-06) that Tim's
narrowing of Garmin's watch-side sleep window to 02:00–06:00 BST lifted
target-window HR coverage from 5.5% → 69.4%, so we expect mostly "hr"
entries for Tim going forward, with "drought" as the inner-window fallback.

Field set at SLEEPING transitions in both AWAKE→SLEEPING and PRE_SLEEP→
SLEEPING branches; left null in AWAKE/PRE_SLEEP states (default carries
through unset State() constructions at exit transitions, which also clears
the prior value).

Serialized/deserialized for persistence across plugin restarts. Wired into
both OpenAPSBoostPlugin (V1) and OpenAPSBoostV3MLG3Plugin (V4.4.x). RT field
auto-uploads via existing devicestatus encoder.
Replaces the 28-user 240m-horizon model (LOUO 0.6796) with v9-90m, trained on
32 users (+4 V5+ML users: Tim/Joost/user4/Simon) with a 90-minute hypo target
window instead of 4h.

LOUO AUC vs v8 baseline (240m horizon):
  Tim    0.6213 → 0.7586  (+0.14)
  Joost  0.6637 → 0.8759  (+0.21)
  user4  0.5970 → 0.8218  (+0.22)
  Simon  0.6827 → 0.8538  (+0.17)
  mean   0.6742 → 0.817   (+0.14)

Backtest on real-world 21d data (Tim/Joost/Simon):
  AUC: Tim +0.003, Joost +0.022, Simon +0.018
  False-positive reduction at risk>=0.3 (riskScale damping):
    Tim −59%, Joost −87%, Simon −75%
  v9 strictly dominates v8 (no regressions worth blocking on)

Model file: 50 trees, depth 4, num_leaves 15 — same architecture as v8;
only the label horizon changed. JSON shape unchanged so BoostRiskModel.kt
walks it without code changes.
Mirrors the V1 wiring from the prior commit so the V4.4.x plugin uses the
same v12 model on assets/boost/ rather than getting a feature-size mismatch
and null mlHypoRisk. Same dual-path approach: 8-feature legacy fallback for
v9-vintage models, 53-feature v12 path otherwise.

Changes:
  - DetermineBasalBoostV3MLG3.kt: add mlRingBuffer field, timeSinceLastSmbMin
    parameter, rewrite ML call site to dual-path inference.
  - OpenAPSBoostV3MLG3Plugin.kt: extend the recent-SMB persistence query to
    derive both 60-min volume and minutes-since-last-SMB from a single 12-hr
    window; pass both through to determine_basal.

Tim is currently the only user on V4.4.x; he's planning to switch to V1
(which is Boost-ML-Beta acting algo with the same v12 ML retrofit). This
keeps him covered with ML hypo-risk gating until the switch.
… load failures

Issue: shipped v12 53-feature model produces null mlHypoRisk on Tim's device
despite dual-path code being present in the APK. Meal model loads + fires
normally; risk model silently returns null. Suspected load-time JSON parse
failure or stuck loadAttempted flag that we can't diagnose without device
logs.

Recovery:
  - Asset reverted to v9-90m (8-feature, 50-tree, ~85KB) — known working.
  - Dual-path code stays in place; v9's 8 features routes to legacy branch.
  - Diagnostic logging added to both BoostRiskModel copies (V1 + V3ML):
    * raw byte size logged at load entry
    * per-tree parse error caught + logged before re-throw
    * exception class name included alongside message
  - Same diagnostic logging will help us pin v12's failure mode next attempt.

Mirrored to AndroidAPS Boost-ML-Beta in same commit on that branch.
The AAPS log export only captures D and I level lines; error-level logs
(used by aapsLogger.error) are filtered out. To diagnose why the v12
53-feature hypo model silently fails to load on Tim's device, mirror every
diagnostic failure path to aapsLogger.info with a "DIAG" prefix so it
shows up in the log export. The error-level call is retained for AAPS'
internal system log.

Assets reverted to v12-90m (53 features, 100 trees, depth 5) on top of
the same dual-path inference code. Next time AAPS restarts, the first
inference cycle that reaches the ML block will trigger lazy-load, and
either:
  - Success: I/APS "BoostRiskModel loaded: 100 trees, 53 features"
  - Failure: I/APS "BoostRiskModel DIAG load FAILED ... ExceptionClass: msg"
  - Per-tree: I/APS "BoostRiskModel DIAG tree N parse failed: ..."

Applies to V1 (openAPSBoost/) and V3MLG3 (openAPSBoostV3ML/) copies.
The 6-cycle lookback ring buffer lives on the @singleton determine-basal
classes, so it survived across cycles in-process, but StringKey.ApsBoostMlRingBuffer
(added to persist it across process restarts) was never read or written, and
BoostMlFeatureBuilder.serialize/deserializeBuffer were dead code. After every
AAPS restart the lag1..lag5 features cold-started (zero-imputed) for ~6 cycles.

Wire it up following the sleep/V5-state pattern (plugin orchestrates the
Preferences I/O; the compute class stays pure). Applied to BOTH the active
V3MLG3 (V4.4.x) path and the openAPSBoost V1 path:
  - DetermineBasal*: loadMlRingBufferOnce(raw) restores from storage once per
    process; serializeMlRingBuffer() exposes the live buffer.
  - Plugins: load before determine_basal, persist in the .also block.

Drop StringKey.ApsBoostMlRecentSmbLog — it had no backing data structure and
recent-SMB stats are reconstructed from PersistenceLayer.getBoluses each cycle,
so it never needed persisting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ings

The V1 (openAPSBoost) plugin enforces ApsBoostCumulativeSmbCap60Min (default
1.5 U) but never added it to its preference screen, so V1 users had a silently
active SMB-stacking cap with no way to see or change it. V3MLG3 already exposes
it. Add the same AdaptiveDoublePreference row to V1's Boost Controls list,
immediately after Boost Max IOB, matching V3MLG3's placement. Strings already
existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SMB-only hybrid: V1 keeps basal, predictions, and all safety gates; V5's
observe-confirm-commit finalDose REPLACES V1's SMB only on cycles V1 permits
one (microBolusAllowed), via the existing runShadow hook in the .also block.
Override flows through downstream applyBolusConstraints unchanged.

- BooleanKey.ApsBoostV5ActiveDosing (default off, defaultedBySM) — instant revert.
- runShadow() now returns V5Decision?; takes activeMode/microBolusAllowed/flatBGsDetected.
- enableSmbPreChecks wired to V1's real SMB permission in active mode (outer
  safety envelope); shadow mode keeps the permissive value (telemetry continuity).
- sensorQualityOk wired to !flatBGsDetected in active mode.
- User-adjustable dose caps: DoubleKey.ApsBoostV5ConfirmedCapU (1.0) /
  ApsBoostV5CommittedCapU (0.25) plumbed into applyStateDoseCap so the operator
  can tighten V5 live during alpha. Settings + strings added to V1 Safety screen.
- Reason marker records V1's would-be dose for V5-vs-V1 comparison.

DEFERRED (documented fast-follows): postActionRiskCheck projected-risk lambda
(damper-only; covered by active iobHeadroomBrake + maxIOB clamp + min_guard),
profileSwitched/pumpDisconnected/loopSuspended/timeJump reset triggers.

Built in isolated worktree off Boost-AAPS-core f0673db. AndroidAPS mirror deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…MMITTED)

On a two-phase meal (rise → mid-meal decel → second rise) V5 went COMMITTED→
RECOVERING on the deceleration and had no path back to active dosing, dribbling
at 0.4× through the entire second climb (BG ran to 192 on 2026-06-12's first
DynISF meal). Add a RECOVERING→COMMITTED transition when the meal genuinely
re-accelerates while still well above target (deltaAccl>10, delta>3, offset>20,
age>=1). Resumes COMMITTED (1.0×), NOT a new CONFIRMED commit-shot — committedInSession
untouched, so Fix 6's multi-CONFIRMED guard is fully preserved. Purely additive;
bounded by all Phase-3 gates + the COMMITTED dose cap. Hysteresis (−5..+10 accl
dead-band + min-age) prevents flicker. +unit tests for re-engage + no-regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rward-only

Garmin writes overnight HR into Health Connect as a backdated MORNING BATCH.
The forward-only reader advanced its lastSyncedMs marker to "now" on every empty
overnight poll, so when the batch landed (timestamps in the past) it fell before
the marker and was permanently skipped — ~2/84 overnight HR cycles despite HC
holding the data. Resting HR never learned; sleep fell back to the `drought`
qualifier.

Fix: each poll re-reads a fixed lookback window (18h) and dedupes via an in-memory
ConcurrentHashMap keySet of ingested sample timestamps (pruned to the window),
instead of reading forward from a high-water mark. Backdated inserts are now caught;
insertOrUpdateHeartRate is idempotent so re-reads are safe. lastSyncedMs kept for
diagnostics only.

Caveat: this recovers resting-HR learning + schedule accuracy from the morning
batch; live overnight HR-triggered sleep still depends on Garmin writing to HC in
near-real-time (which it apparently does not), so that remains a Garmin limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ration brake

Sleep gate: V5's SMB override is suppressed when sleepState==SLEEPING → V1's
oref1/Boost SMB stands (already respects night mode). V5 still computes shadow
telemetry; only the dose override is gated. Robust to overnight CGM artifacts
tripping V5 (the 03:54 case). Reason marker "V5 suppressed (SLEEPING)".

Deceleration brake (V1-anchored): re-spec decelerationBrake to ease off as
delta_accl crosses below zero — the first sign insulin is biting — instead of
only at delta_accl<-10. Graduated 1.0@accl=0 → 0.30@accl<=-15, with a delta>8
velocity fallback (mirrors V1's tier gating: delta_accl>0 with delta>8 fallback,
DetermineBasalBoost.kt:1505/1526). IOB gate dropped (iobHeadroomBrake covers IOB).
Signed `delta` plumbed into Phase3Inputs.

Also: fix pre-existing CalibrationBlockTest (stale OpenAPSBoostPlugin constructor —
add boostRiskModel/boostMealModel/boostIsfShadow/boostV5Plugin/healthConnectHrIngest
mocks) to unblock the plugins:aps test source set. +unit tests for the brake.

Per V5-vs-V1 analysis, session-cap/holding-taper dropped (V5≈V1, no excess) and
the adaptive learner deferred to V6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
90g meal 2026-06-14: V5's commit wanted budget 1.84 × 1.8 = 3.31U but was
clipped to the 2.0 ceiling, delaying coverage (peak 255). Default stays 1.0U;
range to 5.0U so higher-insulin-need users / big meals aren't capped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ wire Sensitivity knob

V6 #1 — anticipatory pre-meal low target (shadow-first):
- MealTimeLearner learns habitual meal times from fresh V5 CONFIRMED commits
  (rolling 60d, circular-clustered into trusted modes ≥6 events / ≥4 distinct days).
- OpenAPSBoostPlugin applies a low target (default 72 mg/dL) during the learned
  pre-meal window (opens leadMax min before, closes 45 min before the meal),
  LOWER-ONLY, gated by exercise / post-exercise-recovery override.
- ApsBoostV6PreMealTarget toggle (default OFF = shadow: logs "WOULD apply" to
  reason for validation, no dosing change). Settings: target mg/dL + lead min.

V6 tim2000s#2 — wire the previously-unused Sensitivity knob:
- ApsBoostV5Sensitivity (0.8–1.2) now multiplies the aggression budget, threaded
  buildInputs→V5Inputs→decide→aggressionBudget. Default 1.0 = no-op. Live per-user
  lever for the future nightly learner (motivated by backtest User-D over-dosing).

Tests: MealTimeLearnerTest (clustering, circular wrap, confidence, window edges,
prune, round-trip), AggressionBudgetSensitivityTest (no-op default, scaling, clamp, floor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…insulin at hypo-risk

The "Hypo Caution" knob (1.0–2.0) raised the mlHypoRiskScale floor (0.5×knob), which
made the scale HIGHER at elevated ML hypo-risk → MORE dosing. At knob 2.0 the floor hit
1.0 and the hypo-risk damper was effectively disabled. So dialling up "Hypo Caution"
for safety did the opposite. Tim runs this live.

Fix: knob now scales the reduction UP and lowers the floor (0.50/knob: 0.50@1.0 → 0.25@2.0).
Higher caution ⇒ deeper/wider backoff ⇒ less insulin when risk is elevated. knob 1.0 is an
exact no-op vs the prior calibration. Also the per-user lever that closes the backtest's
User-D pre-severe-low over-dosing (all of which sits in the mid risk band 0.30–0.60).

Test: HypoCautionDirectionTest (no-op at 1.0, monotonic less-with-more-caution, lowered
floor, and an explicit guard that knob 2 never doses more than knob 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1/V5 preference split

V5 was a hidden showInList{false} sidecar with an unreachable settings screen, while its
active toggle/caps/V6 pre-meal settings were bolted into the V1 Safety screen. Now:

- Shared engine: OpenAPSBoostPlugin.invoke() body → runEngine(initiator, fallback, v5Active).
  invoke() = runEngine(v5Active=false). The SMB override gate now keys off v5Active instead of
  the ApsBoostV5ActiveDosing pref. runShadow() still runs either way (telemetry preserved).
- OpenAPSBoostV5Plugin is now selectable (showInList{config.APS}, default isEnabled). Its
  invoke() delegates to Provider<OpenAPSBoostPlugin>.runEngine(v5Active=true) and copies
  lastAPSResult/lastAPSRun. (Provider breaks the V1↔V5 DI cycle.)
- Preference split: extracted addBoostEngineCategories(...) shared builder. V1 screen = engine
  only (V5/V6 controls stripped). V5 screen = engine + a "Boost V5/V6 controls" sub-screen
  (Aggression, HypoCaution, Sensitivity, Confirmed/Committed caps, V6 pre-meal enable/target/lead).
- ApsBoostV5ActiveDosing toggle retired (selecting "Boost V5" IS active). Key kept for back-compat.

Migration: select APS = "Boost V5" instead of "Boost" + toggle. All setting values persist (keys
unchanged). No basal/prediction logic added to V5 — it still inherits the engine via OapsProfileBoost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tim2000s and others added 26 commits July 21, 2026 23:04
…se (CRITICAL safety)

Incident: during zone-3 exercise (HR 104 bpm, HRR 42%) with 64 steps in the
trailing windows, the loop classified RESTING, fired the INACTIVE branch and
raised the profile to 130% while BG was falling 12 mg/dL per 5 min. Two defects
compounded:

1. Classifier dead zone — the RESISTANCE branch required lowSteps (<30), so 30-99
   steps + zone 3/4 HR matched no branch and fell through to RESTING. Widened to
   !moderateSteps (<100 steps), zone 3-4 → RESISTANCE. (HrActivityCalculator)

2. INACTIVE branch ignored an elevated HR — it only checked STRESS before adding
   insulin. Added a belt-and-braces guard keyed on the HR ZONE directly
   (inactivitySuppressedByElevatedHr, zone >= 3): when it holds, suppress the
   profile-raise entirely and raise the target to 160 instead. Never add basal
   while the heart says exercise, whatever the fused state says. (OpenAPSBoostPlugin)

Tests: dead-zone regression (64 steps + zone3 → RESISTANCE), suppression helper
(zone3+ true / zone1-2 / null false), and the RESTING-fallback test repointed to
a genuinely-resting input (its old input was the buggy behaviour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tCPKdr97rkLLuR7FGL6TU
(cherry picked from commit 12b589d)
…se (CRITICAL safety)

Incident: during zone-3 exercise (HR 104 bpm, HRR 42%) with 64 steps in the
trailing windows, the loop classified RESTING, fired the INACTIVE branch and
raised the profile to 130% while BG was falling 12 mg/dL per 5 min. Two defects
compounded:

1. Classifier dead zone — the RESISTANCE branch required lowSteps (<30), so 30-99
   steps + zone 3/4 HR matched no branch and fell through to RESTING. Widened to
   !moderateSteps (<100 steps), zone 3-4 → RESISTANCE. (HrActivityCalculator)

2. INACTIVE branch ignored an elevated HR — it only checked STRESS before adding
   insulin. Added a belt-and-braces guard keyed on the HR ZONE directly
   (inactivitySuppressedByElevatedHr, zone >= 3): when it holds, suppress the
   profile-raise entirely and raise the target to 160 instead. Never add basal
   while the heart says exercise, whatever the fused state says. (OpenAPSBoostPlugin)

Tests: dead-zone regression (64 steps + zone3 → RESISTANCE), suppression helper
(zone3+ true / zone1-2 / null false), and the RESTING-fallback test repointed to
a genuinely-resting input (its old input was the buggy behaviour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tCPKdr97rkLLuR7FGL6TU
(cherry picked from commit 12b589d)
…onfirm-crash hole

2026-07-23 user-H incident (twice in one afternoon, loop then disabled by
the user): post-rescue tier block demotes T3/T5 to T7/T8, but T7/T8 apply
no fast-carb scaling, and delta-weighted dynamic ISF inflates insulinReq
mid-rebound (3.55U SMB at BG 97, 25 min after a 67 mg/dL low; second
insulin-driven hypo followed). The V6 post-rescue cap inherited the
unrestrained dose ("capped from 4.0U to V1's 3.55U").

Fix: apply the graduated scale (0.3 below BG 120, linear to 1.0 at 170)
to the FINAL microBolus whenever the 45-min post-rescue window is active,
COB == 0 and bg < 170 — independent of tier and of the delta_accl>25
trigger, skipping cycles the in-tier scaling already handled. No velocity
escape inside the window (Fix D contamination argument). Restraint-only.

DB pricing (price_composed_guard.py, refreshed to t=now, 103k dosing
cycles, 9 users): 34% [95% CI 32-37] of removed insulin sits directly
ahead of a second low <70 (07-04 cap shipped at 27%; other levers 14-19%).
Cost: 9% genuine post-hypo meals, median 0.80U under-delivery, episodes
full dosing was not containing (median peak 240). Leave-one-out floor 27%
(drop user D). Incident replay: 9.90U -> 3.05U.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit 51e7663)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit d721e9b)
…V6 and V7

Builds the anticipation architecture spec's shadow stack (read-only, doses nothing),
in the shared engine host (OpenAPSBoostPlugin.runEngine) so it runs identically for
plain Boost, V5/V6, and the V7-shadow line.

- AnticipationHabitModel: per-user onset-hazard table (hour-of-week + recency), circular-
  smoothed, per-slot confidence-blended with a cross-user prior; refit offline (not in-loop
  learning). Exercise pure-per-user; meal hybrid via a breakfast/lunch/dinner prior.
- AnticipationOnsetStore: persisted rolling exercise/meal onset history (StringKey JSON,
  V7 idiom, ~56d window), tolerant deserialize.
- RetractableArm: generic arm→confirm/back-out state machine (retractability = the safety
  mechanism); confirm = event appears, veto = post-rescue window.
- AnticipationShadow: orchestrator — onset edge-detect, ~6h refit, predict p(onset) @45min,
  run both arms, emit anticip=... tag. Never throws.
- StringKey ApsBoostAnticipHistory; extractor anticip= parser + 14 DB columns.
- 11 pure unit tests green (habit model, store, arm, orchestrator).

Phase 3 (per-user pricing) + Phase 4 (within-user trial) remain per the two-test bar;
population gating + live action are logged-not-enforced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit 5ea788a)
(cherry picked from commit 43f8266)
…ly-2026 changes

The four dev-facing docs/tools were ~49-72 commits behind. Brought current:
- Composed post-rescue REBOUND GUARD (scales SMB in the 45-min post-low window,
  closes the confirm-crash) — README §3, tuning-guide safety layer, simulator
  v6Override (now modelled, only ever removes insulin).
- HR-exercise INACTIVE safety fix (stop adding insulin during elevated-HR exercise)
  — README §3/§8, tuning-guide safety layer, simulator activity-state note.
- Aggression ceiling 1.3 → 1.6 — README §4/§5, tuning-guide spectrum + scenarios,
  simulator slider max.
- A2 confirm-sooner + C5 velocity-budget-floor opt-ins, and the auto-config-managed-
  switch convention (insulin-adding levers gated to well-controlled) — README §4/§5,
  tuning guide.
- Per-user ANTICIPATION shadow (read-only, learns routine, doses nothing) — README
  §3, tuning guide, analyser note.
- Corrected stale README §8 exercise-into-correction claim; neutralised a NS-host
  placeholder. All three tools' JS syntax-checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit 5c230e0)
…nt the 3 levers; fix accuracy

Reworked the monolithic README per new-user feedback:
- Slim README = what it is, one-glance how-it-works, GETTING STARTED SAFELY (shadow-first
  1-2-3), and THE THREE LEVERS as a clear table (what each is + how up/down changes dosing),
  with everything else flagged advanced-and-auto-set. Links out to the tools + detail pages.
- Split the deep sections into separate pages: docs/v6-how-it-works.md, v6-advanced-settings.md
  (the 'set for you on install' page — auto-config rules + every advanced setting),
  v6-heart-rate-and-sleep.md, v6-safety-and-validation.md.
- Tuning guide now cross-links to the README levers + the advanced-settings reference where
  settings are discussed.

Accuracy fixes (verified against code via review):
- HR-exercise guard: 'stops adding insulin' -> 'suppresses the inactivity profile-raise and
  lifts target to ~160' (it is the inactivity branch, not a global stop).
- Auto-config insulin-adding switches gated by the STRICT well-controlled cut (TBR70<1.5% AND
  <54<0.3%), not 'consensus targets'; composed-brake floor correctly described as a live
  runtime gate (TBR63<2.0% AND <70<3.5%), NOT auto-config-managed.
- 'one-shot flag' -> per-knob resolution; raise-guard also names the severe TBR54>=1% co-guard.
- Aggression budget = per-cycle sizing base (30% floor), not a burst ceiling; non-meal cap
  notes the velocity-budget path; minGuardBG = configured LGS threshold (default 80).
- A2/C5 shorthand replaced with real toggle names (Aggressive early confirm / Velocity budget).
- Version naming reconciled (V6 label vs V5 code/keys). 0 broken links; JS + PII checked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit 4ccf256)
…rnal A2/C5 codes from user docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
…m, fix nits

- Sensitivity lever: rewrite to make clear it is an OVERALL-STRENGTH dial for the whole
  engine, explicitly NOT insulin sensitivity/ISF despite the name (new-user confusion).
- 'Moving earlier in the cycle': where the concept is introduced (how-it-works trade-off,
  Aggressive early confirm setting, ML meal-likelihood note) now states plainly that
  confirming/dosing earlier means acting on more speculative CGM evidence — a rise that
  might still fizzle — trading a lower peak for a higher chance of dosing into a non-meal.
- Nits: 'three dials, not twenty-four' -> 'just three dials (far fewer than before)';
  composed-brake-floor condition corrected to eventualBG >= target+20 (matches code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit a923280)
…g to go-straight-to-V6

Per product decision: users should go straight to Boost V6, not start on the V1
engine. Auto-config already seeds V6's dials + caps from the user's own recent dosing
history read from the LOCAL AndroidAPS database (present via in-place upgrade, DB
restore, or NSClient backfilling ~100 days from Nightscout) — from prior AAPS/oref OR
Boost V1 — so no V1-shadow warm-up is needed.

- Code: move the single APS .setDefault() flag from OpenAPSSMBPlugin to
  OpenAPSBoostV5Plugin (Boost V6). PluginStore force-enables the default and disables
  other APS plugins, so a fresh DB selects V6 directly. (Exactly one default allowed.)
- Docs: rewrote README 'Getting started' (go straight to V6; seed from historic DB
  history; make sure NSClient/DB restore is present; shadow 'Boost' plugin + Analyser
  now OPTIONAL not a required warm-up). Reconciled the two 'shadow is the supported
  mode' statements (README footer + safety page) to 'experimental algorithm; shadow
  optional; supported path is V6 active seeded from your history'. The dev-process
  'shadow-first for heuristic CHANGES' guidance stays (different sense). Advanced-
  settings auto-config intro now states the data source (local DB via upgrade/restore/
  NS-sync) and that there is no separate NS import inside Boost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit 6b376cf)
…/table-cell emphasis

Per feedback that bold was excessive throughout. Kept: bullet/numbered lead-ins, short
**Label:** list intros, and the first (name) cell of tables. Stripped: mid-sentence
emphasis in prose, mid-bullet bold, and non-first table cells. Bold spans cut roughly
in half across all five doc pages (README 58->18, advanced 76->27, how-it-works 41->19,
safety 46->21, hr-sleep 15->3). Multi-line-safe (protect-then-strip); marker balance
verified, italics + links intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit e29a2b3)
…eal engine

Reclaims V1's ~15-min-earlier acceleration response (backtesting/scripts/
2026-07-v1-acceleration: delta_accl>10 leads V6 CONFIRMED by 15 min at 98% recall;
the early bolus is fizzle-safe by size, pure fizzle-low +0.9% no excess). Restores it
as a small primer during OBSERVING, delivered as an advance on the CONFIRMED
commit-shot.

Mechanics (DetermineBasalBoostV5.decide()):
- ONCE per OBSERVING session, on delta_accl>10 + rising + every floor clear (recentLow
  >=80, awake, !exercise, !post-rescue) + maxIOB headroom. Acceleration-scaled
  (1 + (accl-10)/20, capped 2x base = inside V1's proven fizzle-safe envelope).
- Bolus mode: folded into finalDose, exempt from the non-meal v1-cap at the seam.
- ADDITIVE up to the fizzle-safe base (primerCapU); the scaled EXCESS is netted off the
  CONFIRMED shot then COMMITTED holds (move-not-add; net-extra converges to the base).
- Session accumulators (primerAppliedU, netting residual) in V5PersistedState, persisted
  in V5StateStore, reset on IDLE. The follow-through that crashed V1 is guarded by V6's
  existing brake/RECOVERING.

Routing (auto-config managed): well-controlled -> bolus; everyone else -> RETRACTABLE
temp-basal fallback (additive-only raise, never cancels a protective zero-temp, expires
= retracts). USER OVERRIDE ApsBoostV5PrimerBolusMode forces the bolus. primerCapU sized
from the user's own routine SMB (U200 auto-scaled), smaller for hypo-prone (C/tim
residual). Keys ApsBoostV5PrimerCapU/PrimerTbrFallback/PrimerBolusMode + UI + strings.

8 unit tests (PrimerTest): fires-in-observing/scaled/additive/residual, floors gate,
no-accel, temp-basal-not-folded, once-per-session, netting subtracted, IDLE reset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tCPKdr97rkLLuR7FGL6TU
(cherry picked from commit 3aa9a4d)
(cherry picked from commit 3b482d8)
applyAutoConfig() writes each managed double via an EXPLICIT resolve() call; PrimerCapU
was added to managedDoubleKeys/Knobs (so the derivation runs) but its resolve() call
was missing, so the cap was never persisted and never resolved — it stayed at 0 and
re-derived uselessly every cycle. The tbr-fallback BOOLEAN applied (boolean path loops
the list), so a tbr-routed user got the routing but no cap → primer delivered nothing.
Caught by Tim on-device.

Add the resolve() call (NOT raise-guarded — a tbr-routed non-well-controlled user still
needs a non-zero cap to size the retractable temp-basal). Update the exact-key-list test
+ add a regression guard asserting PrimerCapU is applied and > 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tCPKdr97rkLLuR7FGL6TU
(cherry picked from commit 5c17220)
(cherry picked from commit 65d25d8)
…ainst the commit-shot

Tim's rule (tested: the one lever that survives — cap/taper both trade seeds 1:1, this
loses none). Keep every primer firing, but at CONFIRM net the ACCUMULATED primer IOB
beyond one base off the commit-shot, so the meal's net-extra insulin is bounded to one
base no matter how many fizzles preceded it (backtesting: 87% of confirms follow >=1
primer, 57% would net off >0.1U, median 0.39U).

- V5PersistedState: primerIobU (cross-session on-board primer insulin, wall-clock decayed
  exp(-dt/90min), NOT reset on IDLE) + primerIobUpdatedMs. V5Inputs.nowMs (dateUtil.now()).
- decide(): accumulate every primer into primerIobU; at the CONFIRMED transition set the
  netting residual = max(0, primerIobU - primerCap) and consume the credited excess
  (primerIobU := min(primerIobU, cap)) so a second meal can't re-credit it. Residual spent
  down against CONFIRMED then COMMITTED holds (unchanged landing). Replaces the old
  session-scoped scaled-excess netting.
- Only ever REMOVES insulin (safe-signed). Does NOT touch pure-fizzle clusters that never
  confirm (their mild intermediate dip is unaddressed, by design).
- V5StateStore persists the two new fields. PrimerTest updated + confirm-net test added.

backtesting/scripts/2026-07-primer-clustering (clustering, cap-sweep, taper, confirm-net)
to follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tCPKdr97rkLLuR7FGL6TU
(cherry picked from commit 6c439c8)
(cherry picked from commit 35831c3)
The V1-acceleration early primer shipped to V6-shadow but was never promoted to dev,
so it was undocumented. Now promoting it (3 cherry-picks) + this doc entry: auto-config-
sized per-user (fraction of Committed cap, capped 0.6U), default 0.0/off, confirm-net
bounds a meal's total net-extra to ~one base, and safety-routed (bolus for well-
controlled, retractable temp-basal otherwise). Fizzle-safe (+0.9%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nnt1MR3aRXXFHjxUjMSh8w
(cherry picked from commit b3a4cb2)
…gle source of truth)

Combines the activity-driven sleep-in backstop with the night-mode state machine.
Previously two parallel mechanisms with DIFFERENT thresholds: SleepStateDetector
(HR+steps+clock) and StepFeed.sleepInActive (steps-only morning backstop, its own
sleepInSteps pref) — they could disagree at e.g. 150 morning steps.

Now unified in the detector:
- SLEEPING is HELD past nightEnd through the lie-in window [nightEnd, nightEnd+
  sleepInHours]; the hard boundary exit moves to lieInEnd (== nightEnd when merge off).
- Strong steps-alone wake uses the UNIFIED sleepInStepsThreshold (= ApsBoostSleepInSteps),
  releasing the lie-in on real morning movement regardless of HR (past the alarm), and
  still gated on drought-established during the core night (both-required guard holds).
- Plugin passes sleepInSteps/sleepInHours into the detector; the standalone
  StepFeed.sleepInActive gate is DEMOTED to a failsafe that only fires when
  auto-by-sleep is OFF (clock-only night mode).

+3 lie-in tests (holds past nightEnd, releases on threshold steps, hard boundary).
V7-SHADOW ONLY per Tim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pkjoau3i62iYTq1ncXUjoe
(cherry picked from commit 3111f79)
(cherry picked from commit 0a91ed5)
…IOB + fix cap reset

(a) IOB total now counts bolus + max(0, basal): negative temp-basal IOB (loop zero-
temping a real descent) no longer shrinks the total and arms the gate during a genuine
insulin-driven low. (b) Consecutive-compression counter latches while the pattern
persists instead of resetting on cap-trip, so a sustained low is followed after ~15 min
and cannot re-arm. +1 test (negative basal -> followed, not masked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQgjnRqZuN6rKxYaLJZMV
(cherry picked from commit 7dfb3f5)
(cherry picked from commit f23111b)
The learned wake trained a circular mean over ~40 sessions and clamped it to ±90min of the configured
NightModeEnd — but symmetrically, so for a highly-variable waker (real wake 04:30–09:30) the early-biased
learned mean (05:51) dragged a sensible 07:30–08:00 configured night-end DOWN to ~06:00, lifting sleep
protection ~2h before the user's own setting (the unsafe direction, into the dawn window). Real-time
HR/step detection mostly holds SLEEPING past it, but a marginal-HR morning could tip a false early AWAKE.

Fix: clampToConfiguredBand gains allowEarlier (default true, unchanged for night-START where later-to-bed
drift is expected/safe); the night-END call passes allowEarlier=false so learning may only push the wake
bound LATER than configured, never earlier. Genuine early wakes are still caught by real-time detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tCPKdr97rkLLuR7FGL6TU
(cherry picked from commit 660be24)
(cherry picked from commit 4169a17)
…oostSleepInSteps)

PRE_SLEEP had NO activity-based exit — its only route to AWAKE was the nightEnd clock boundary. So a user
up and active before nightEnd (e.g. 05:00, 90 steps/5min, BG rising) was pinned in PRE_SLEEP — night-mode
SMB suppression on — until the clock, even though clearly awake. High steps also block the slide to
SLEEPING, so the state froze. The night-end clamp fix (660be24) made this worse by extending the
boundary later.

Fix: PRE_SLEEP now exits to AWAKE immediately when 60-min cumulative step growth clears the user's
ApsBoostSleepInSteps threshold (constant fallback 250) — ungated by clock/drought, since PRE_SLEEP is not
confirmed sleep so activity is decisive. Same signal the SLEEPING steps-alone wake uses; threshold hoisted
so both branches share it. Not a genuine sleep→wake, so wakeReason stays null (won't train the wake learner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tCPKdr97rkLLuR7FGL6TU
(cherry picked from commit 89f0740)
(cherry picked from commit d356b9e)
…e shipping line

All experimental shadow layers logged on ONE device (2026-07-29 review): V7 5399
rows, Twin 3325, antBackout 2566, accelMeal 262 for a single user and ZERO for the
other eight. No V7/Twin/anticipation conclusion could be cross-user, so the shadow
pipeline could not meet the GroupKFold bar the analysis side enforces.

Ports from Boost-V7-shadow, unchanged:
- openAPSBoostV7/  — residual tracker, distributional sizer, shadow seam + tests
- openAPSBoostTwin/ — physiological model, EnKF, shadow, withdrawal, anticipation
  back-out + tests
- openAPSBoostTing/ — risk-limited smoothing planner (pure) + test
- OpenAPSBoostPlugin seams: V7/Twin/back-out invocation, accelMeal onset detector,
  post-meal plateau nudge
- RT boostV7_* fields, ApsBoostV7ResidualPools key, UKF bucketing parity test

READ-ONLY. Every seam is failure-swallowed twice (each shadow's own runCatching
plus a belt-and-braces one at the call site) and delivered dosing is bit-identical
with or without them. Twin telemetry rides in the reason string rather than an RT
field, which is the existing fix for the ART VerifyError register-allocation crash.

Deliberately NOT taken from V7-shadow: OpenAPSBoostV5Plugin/OpenAPSSMBPlugin, which
differ only because V7-shadow predates the V6-default-engine move. Copying them
would have reverted Boost V6 as the default APS plugin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTN6FX7LQH621CQC1bhk8C
(cherry picked from commit 639280c)
…load

Adds LoopHub.loopStatus (CLOSED/LGS/OPEN/SUSPEND/DISCONN/DISABLED/SUPERBOLUS from runningMode)
and lastLoopEpochMs (loop.lastRun.lastAPSRun), emitted on the newest /sgv.json element alongside
the existing iob/cob/tbr. Lets the Boost Garmin face show the IOB + loop tier. Read-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQgjnRqZuN6rKxYaLJZMV
(cherry picked from commit 2b31c67)
(cherry picked from commit 12366a2)
LoopHub.variableSensInUnits = APS variable_sens (fallback profile ISF), converted to the user's
glucose units; emitted as 'isf' on the newest reading. Feeds the ISF column of the Boost BG-Ring
Garmin face.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PBQgjnRqZuN6rKxYaLJZMV
(cherry picked from commit 6bb4fd7)
(cherry picked from commit c433e0c)
Reported live: EXERCISE "INACTIVE - 130%" at 06:09 during sleep-in, BG 71 and falling, after an
overnight descent from ~200. INACTIVE raises the profile (ApsBoostInactivityPct, factory 130),
which scales DynISF by 100/130, lifts basal 30% and scales the Boost SMB tiers — it ADDS
insulin. That is intended for a genuinely sedentary WAKING user and is left alone. It must
never coincide with sleep, and it did.

WHY THE EXISTING GUARDS DID NOT STOP IT. A sleeping user has near-zero steps by definition, so
`recentSteps60Min < inactivitySteps` is satisfied every night; the branch reads "asleep" as
"sedentary". Two shipped protections should have caught it and neither could:

1. `sleepInActive` is `nowMs in nightEndMs until (nightEndMs + sleepInMs)` — the lie-in window
   opens AT night end. At 06:09 with a 07:00 end it is false by construction, so the failsafe
   cannot engage before dawn. The core night was never its job.
2. `lieInFailsafeEngages` stood down whenever `autoBySleepActive && detectorSleeping`, deferring
   to night-mode suppression. But `isInNightSleepPeriod()` returns false on its FIRST line when
   ApsBoostNightModeEnabled is false, which ships false — so the detector is never consulted and
   the failsafe deferred to a suppressor that was not running. A correctly-SLEEPING detector was
   what disarmed the backstop.

FIX. Make the exclusion a property of the predicate rather than of the gate above it, since that
gate has several defeat paths:

- `inactivityEligible` gains `sleepInActive` and `asleep` and returns false for either. `asleep`
  is SLEEPING or PRE_SLEEP — the user is in bed for both — which covers the core night that the
  lie-in window cannot reach.
- `lieInFailsafeEngages` gains `nightModeEnabled`, so it only defers to night-mode suppression
  when that suppression is actually running.
- Split `inactivityStepsMet` out of `inactivityEligible` so the plugin can tell "moving" from
  "sedentary because asleep", and breadcrumb the second into the NS reason rather than falling
  through silently.

This also closes the 250/499 band, where sleepInSteps (250) and inactivitySteps (500) read the
same blended count and disagree — the lie-in stands down as awake-enough while INACTIVE fires as
sedentary-enough. The detector now covers it.

Both changes only ever WITHHOLD insulin, so the direction is safe and no backtest is needed to
justify it. The genuinely-sedentary-and-awake raise is unchanged, and is covered by a test.

6 tests added: suppression during lie-in, suppression on detector sleep, the 250-499 band, the
raise still firing when awake, the failsafe engaging with night mode off, and it still standing
down when night mode is genuinely suppressing. Existing call sites updated for both signatures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTN6FX7LQH621CQC1bhk8C
(cherry picked from commit c18346f)
…ode is set to

Follows the sleep/INACTIVE exclusion in c18346f. That fix keyed on the sleep detector, which
covers irregular sleep but produces a state on only about two thirds of cycles in the field and
none at all for some users. So "INACTIVE never fires overnight" still depended on the detector
having data, or on the user having enabled night mode.

THE SEPARATION. ApsBoostNightModeEnabled answers "should Boost dose overnight?", which is a
policy choice. The start and end times answer "when is this user overnight?", which is a fact
about them. isInNightSleepPeriod() returns false on its first line when the flag is off, so
switching the policy off also made the system forget when night was. The INACTIVE branch raises
the profile — lower ISF, higher basal, larger SMB tiers, all adding insulin — and needs the
fact, not the policy.

New NightWindow.contains() reads ApsBoostNightModeStart/End (defaults 22:00 and 07:00, or
whatever the user has configured) REGARDLESS of the enable flag, and INACTIVE is suppressed
inside it. No new preference, no sensor dependency: it works with no HR, no steps and no
detector, which is what makes the guarantee hold by default rather than by configuration.

Three independent exclusions now, none of which covers every case alone:
  inNightWindow  the configured clock window — always available, covers the common case
  asleep         detector SLEEPING/PRE_SLEEP — covers sleep outside the window, when it has data
  sleepInActive  the morning lie-in — steps-based, covers oversleeping past the alarm

isInNightSleepPeriod() now delegates its window arithmetic to the same helper, so there is one
source of truth rather than two copies to drift apart. Its equal-times behaviour is unchanged:
an empty window, not a whole day, since the wrap branch would otherwise union to cover
everything and silently stop Boost dosing (the 2026-07-02 incident).

Measured exposure this removes, from the pre-fix backtest: 6.3% of all sleeping cycles across
the cohort ran on a raised profile, and for user I every sleeping cycle logged did.

5 tests added: the wrap past midnight including 06:09 from the original report, a non-wrapping
window, equal times as empty, inclusive start with exclusive end, and INACTIVE suppressed inside
the window. Suite 690 tests, 0 failures. The suppression reason now names which of the three
exclusions fired, so it stays auditable in Nightscout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BTN6FX7LQH621CQC1bhk8C
(cherry picked from commit 2b3b986)
tim2000s added a commit that referenced this pull request Oct 8, 2026
Patch number on the released line for fixes that only withhold or bound
insulin, except where noted in the individual commits: tranche release
bounded by the caps and gates (#1), pre-meal target off at night, while
asleep, under a temp target and after a low (#8), V6 error falls back to
base oref (#9), primer respects running low temps and V6's hard gates
(#10), missing heart rate blocks the INACTIVE raise (#12), the sleep
boundary exit no longer reopens INACTIVE or the gate for a cycle (#18),
percent-scale divisor no longer shrinks above 180 (#4), SMB clamped to
max_iob (#5), G3 hold kept after a recent low (#19), spike override
limited to Tier 8 outside post-rescue and ML downgrade (#20), one confirm
per meal session (#7), stale meal state reset (#17), and exercise
classification restored inside the night window (#11, a regression from
the overnight gate fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RS6G2gfvdzoJAx9wPCeA4
tim2000s added a commit that referenced this pull request Oct 8, 2026
Patch number on the released line for fixes that only withhold or bound
insulin, except where noted in the individual commits: tranche release
bounded by the caps and gates (#1), pre-meal target off at night, while
asleep, under a temp target and after a low (#8), V6 error falls back to
base oref (#9), primer respects running low temps and V6's hard gates
(#10), missing heart rate blocks the INACTIVE raise (#12), the sleep
boundary exit no longer reopens INACTIVE or the gate for a cycle (#18),
percent-scale divisor no longer shrinks above 180 (#4), SMB clamped to
max_iob (#5), G3 hold kept after a recent low (#19), spike override
limited to Tier 8 outside post-rescue and ML downgrade (#20), one confirm
per meal session (#7), stale meal state reset (#17), and exercise
classification restored inside the night window (#11, a regression from
the overnight gate fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RS6G2gfvdzoJAx9wPCeA4
(cherry picked from commit a3cc1af)
tim2000s added a commit that referenced this pull request Oct 8, 2026
Patch number on the released line for fixes that only withhold or bound
insulin, except where noted in the individual commits: tranche release
bounded by the caps and gates (#1), pre-meal target off at night, while
asleep, under a temp target and after a low (#8), V6 error falls back to
base oref (#9), primer respects running low temps and V6's hard gates
(#10), missing heart rate blocks the INACTIVE raise (#12), the sleep
boundary exit no longer reopens INACTIVE or the gate for a cycle (#18),
percent-scale divisor no longer shrinks above 180 (#4), SMB clamped to
max_iob (#5), G3 hold kept after a recent low (#19), spike override
limited to Tier 8 outside post-rescue and ML downgrade (#20), one confirm
per meal session (#7), stale meal state reset (#17), and exercise
classification restored inside the night window (#11, a regression from
the overnight gate fix).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014RS6G2gfvdzoJAx9wPCeA4
(cherry picked from commit a3cc1af)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants