From 7a1dfb1ce2fd05c3e26b7ff6318f58ac7df608f2 Mon Sep 17 00:00:00 2001 From: dorkmo <1923070+dorkmo@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:39:03 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(image):=20F11=20=E2=80=94=20gate=20the?= =?UTF-8?q?=20per-gap=20keyframe=20request=20(default=20unchanged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap-tolerant canvas requests a keyframe on EVERY base_seq gap, including a single lost delta frame (two such on-air events in the F10 surgical window, each granted a multi-frame keyframe train; the 10 s KeyframeRequester throttle is the only damper). F10's 0x6C stale-tile path now detects and repairs exactly that damage tile-by-tile, making the per-gap keyframe largely redundant. Canvas now reports structured causes on CanvasUpdate (seq_gap, tile_error) so the policy layer never string-matches reasons; web_ui suppresses the req_keyframe publish only when the request is PURELY gap-driven and LIFETRAC_KF_ON_SEQ_GAP=0. Cold start, grid mismatch, and tile-decode errors always pass through. Suppressions log a countable "kf-on-gap suppressed (F11)" line for the bench A/B. Default stays ON per the F10 protocol: measure the A/B on air first, flip in a separate commit. 10 new tests; suite 1103/2446. Co-Authored-By: Claude Fable 5 --- .../base_station/image_pipeline/canvas.py | 10 + .../base_station/tests/test_kf_on_seq_gap.py | 171 ++++++++++++++++++ .../DESIGN-CONTROLLER/base_station/web_ui.py | 21 ++- 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/image_pipeline/canvas.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/image_pipeline/canvas.py index 3f5b9344..674c57dc 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/image_pipeline/canvas.py +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/image_pipeline/canvas.py @@ -61,6 +61,13 @@ class CanvasUpdate: updated_indices: list[int] = field(default_factory=list) request_keyframe: bool = False reason: str = "" + # F11: structured causes so the POLICY layer (web_ui) can gate the + # seq-gap keyframe request without parsing reason strings. A gap and a + # tile error can co-occur in one frame; `reason` keeps only whichever + # fired first, so these flags are the reliable discriminator. Canvas + # stays mechanism-only: it always REPORTS, the caller decides. + seq_gap: bool = False + tile_error: bool = False class Canvas: @@ -138,6 +145,7 @@ def apply(self, frame: TileDeltaFrame) -> CanvasUpdate: # Now we apply the tiles AND request a keyframe in parallel # so the operator still sees motion immediately. update.request_keyframe = True + update.seq_gap = True update.reason = ( f"base_seq gap: got {frame.base_seq}, expected {expected}") self._last_base_seq = frame.base_seq @@ -156,6 +164,7 @@ def apply(self, frame: TileDeltaFrame) -> CanvasUpdate: LOG.warning("canvas: dropping tile %d codec=%d: %s", tile.index, frame.codec, exc) update.request_keyframe = True + update.tile_error = True update.reason = update.reason or f"codec_decode_error: {exc}" continue except Exception as exc: @@ -163,6 +172,7 @@ def apply(self, frame: TileDeltaFrame) -> CanvasUpdate: "(unexpected transcode failure)", tile.index, frame.codec) update.request_keyframe = True + update.tile_error = True update.reason = update.reason or ( f"tile_apply_error: {type(exc).__name__}: {exc}") continue diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py new file mode 100644 index 00000000..2e680429 --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py @@ -0,0 +1,171 @@ +"""F11 — gating the per-gap keyframe request behind LIFETRAC_KF_ON_SEQ_GAP. + +The gap-tolerant canvas (IP-PlanRev) requests a keyframe on EVERY base_seq +gap, including a single lost delta frame. F10's 0x6C stale-tile path now +detects and repairs exactly that damage tile-by-tile, so the per-gap +keyframe is largely redundant — but per the F10 protocol the default stays +ON until the A/B is measured on air. These tests pin the gate mechanics: + +- Canvas reports structured causes (seq_gap / tile_error) so web_ui's + policy never string-matches reasons. +- web_ui suppresses the publish ONLY for pure-gap requests with the gate + off; cold start, grid mismatch, and tile errors always pass through. +""" + +import os +import sys +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from image_pipeline import codec_decode # noqa: E402 +from image_pipeline.canvas import Canvas # noqa: E402 +from image_pipeline.frame_format import ( # noqa: E402 + CODEC_MONO_G4, + CODEC_WEBP, + TileBlob, + TileDeltaFrame, +) + +try: + import paho.mqtt.client # noqa: F401 + import fastapi # noqa: F401 +except ImportError: + raise unittest.SkipTest("paho-mqtt + fastapi required for web_ui import") + +with mock.patch("paho.mqtt.client.Client") as _mqtt_class: + _instance = _mqtt_class.return_value + _instance.connect = mock.MagicMock() + _instance.loop_start = mock.MagicMock() + _instance.subscribe = mock.MagicMock() + _instance.publish = mock.MagicMock() + import importlib + import web_ui + importlib.reload(web_ui) # rebind module-level mqtt stub + + +def _frame(seq, indices, *, keyframe=False, codec=CODEC_WEBP, + grid=(12, 8, 32)): + gw, gh, px = grid + return TileDeltaFrame( + frame_kind=1 if keyframe else 0, base_seq=seq, + grid_w=gw, grid_h=gh, tile_px=px, + changed_indices=list(indices), + tiles=[TileBlob(index=i, tx=i % gw, ty=i // gw, blob=b"RIFFwebp") + for i in indices], + codec=codec, + ) + + +class CanvasStructuredCauseTests(unittest.TestCase): + def setUp(self) -> None: + self.canvas = Canvas(clock_ms=lambda: 1000) + self.canvas.apply(_frame(0, range(96), keyframe=True)) + + def test_clean_delta_sets_no_flags(self) -> None: + upd = self.canvas.apply(_frame(1, [3])) + self.assertFalse(upd.request_keyframe) + self.assertFalse(upd.seq_gap) + self.assertFalse(upd.tile_error) + + def test_gap_sets_seq_gap_only(self) -> None: + upd = self.canvas.apply(_frame(5, [3])) # expected 1 + self.assertTrue(upd.request_keyframe) + self.assertTrue(upd.seq_gap) + self.assertFalse(upd.tile_error) + self.assertEqual(upd.updated_indices, [3], "gap still applies tiles") + + def test_tile_error_sets_tile_error_only(self) -> None: + boom = mock.Mock(side_effect=ValueError("bad blob")) + with mock.patch.dict(codec_decode._TRANSCODERS, + {CODEC_MONO_G4: boom}): + upd = self.canvas.apply(_frame(1, [3], codec=CODEC_MONO_G4)) + self.assertTrue(upd.request_keyframe) + self.assertFalse(upd.seq_gap) + self.assertTrue(upd.tile_error) + + def test_gap_plus_tile_error_sets_both(self) -> None: + boom = mock.Mock(side_effect=ValueError("bad blob")) + with mock.patch.dict(codec_decode._TRANSCODERS, + {CODEC_MONO_G4: boom}): + upd = self.canvas.apply(_frame(7, [3], codec=CODEC_MONO_G4)) + self.assertTrue(upd.seq_gap) + self.assertTrue(upd.tile_error) + + def test_cold_start_sets_neither_flag(self) -> None: + cold = Canvas(clock_ms=lambda: 0) + upd = cold.apply(_frame(4, [0])) + self.assertTrue(upd.request_keyframe) + self.assertFalse(upd.seq_gap) + self.assertFalse(upd.tile_error) + + +class WebUiGateTests(unittest.TestCase): + """Drive web_ui._ingest_tile_delta with the reassembler stubbed to + hand back a fully-parsed frame, and count req_keyframe publishes.""" + + KF_TOPIC = "lifetrac/v25/cmd/req_keyframe" + + def setUp(self) -> None: + self._saved_gate = web_ui._KF_ON_SEQ_GAP + self._saved_canvas = web_ui._image_canvas + web_ui._image_canvas = Canvas(clock_ms=lambda: 1000) + web_ui._image_publisher.canvas = web_ui._image_canvas + web_ui.mqtt_client.publish = mock.MagicMock() + + def tearDown(self) -> None: + web_ui._KF_ON_SEQ_GAP = self._saved_gate + web_ui._image_canvas = self._saved_canvas + web_ui._image_publisher.canvas = self._saved_canvas + + def _ingest(self, frame) -> None: + with mock.patch.object(web_ui._image_reassembler, "feed", + return_value=frame): + web_ui._ingest_tile_delta(b"\x00") + + def _kf_publishes(self): + return [c for c in web_ui.mqtt_client.publish.call_args_list + if c.args and c.args[0] == self.KF_TOPIC] + + def test_gate_off_suppresses_pure_gap(self) -> None: + web_ui._KF_ON_SEQ_GAP = False + self._ingest(_frame(0, range(96), keyframe=True)) + self._ingest(_frame(5, [3])) # gap: expected 1 + self.assertEqual(self._kf_publishes(), []) + self.assertFalse(web_ui._image_publisher.needs_keyframe) + # The gap's own tiles were still applied. + self.assertEqual(web_ui._image_canvas._tiles[3].arrived_ms, 1000) + + def test_gate_on_default_still_requests_on_gap(self) -> None: + web_ui._KF_ON_SEQ_GAP = True + self._ingest(_frame(0, range(96), keyframe=True)) + self._ingest(_frame(5, [3])) + pubs = self._kf_publishes() + self.assertEqual(len(pubs), 1) + self.assertIn(b"base_seq gap", pubs[0].args[1]) + + def test_gate_off_still_requests_on_cold_start(self) -> None: + web_ui._KF_ON_SEQ_GAP = False + self._ingest(_frame(4, [0])) # no keyframe yet + pubs = self._kf_publishes() + self.assertEqual(len(pubs), 1) + self.assertIn(b"before any keyframe", pubs[0].args[1]) + + def test_gate_off_still_requests_on_gap_with_tile_error(self) -> None: + web_ui._KF_ON_SEQ_GAP = False + self._ingest(_frame(0, range(96), keyframe=True)) + boom = mock.Mock(side_effect=ValueError("bad blob")) + with mock.patch.dict(codec_decode._TRANSCODERS, + {CODEC_MONO_G4: boom}): + self._ingest(_frame(7, [3], codec=CODEC_MONO_G4)) + self.assertEqual(len(self._kf_publishes()), 1) + + def test_default_env_is_gate_on(self) -> None: + self.assertTrue(self._saved_gate, + "F11 protocol: default must stay ON until the " + "on-air A/B is measured") + + +if __name__ == "__main__": + unittest.main() diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py index 4c5bec25..519404ad 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py @@ -1076,6 +1076,17 @@ def _on_mqtt_connect(_c, _u, _flags, rc): "3.0")) TILE_STALE_TOPIC = "lifetrac/v25/cmd/tile_stale" +# F11 (2026-08-01, from the F10 acceptance §5 observation): the gap-tolerant +# canvas requests a keyframe on EVERY base_seq gap — including a single lost +# delta frame — and each granted request costs a multi-frame keyframe train +# at the current link budget. F10's stale-tile reporting now detects and +# repairs exactly that damage tile-by-tile at ~zero extra airtime, so the +# per-gap keyframe is largely redundant. Gate it, DEFAULT UNCHANGED (on), +# per the F10 protocol: measure the A/B on air first, then flip in a +# separate commit. Cold start, grid mismatch, and tile-decode errors are +# NOT gated — those are the keyframe request's legitimate jobs. +_KF_ON_SEQ_GAP = os.environ.get("LIFETRAC_KF_ON_SEQ_GAP", "1") == "1" + def compute_stale_tiles(canvas, now_ms: int, stale_after_ms: int) -> list: """Pure: indices of tiles that have not refreshed within the horizon. @@ -1151,7 +1162,15 @@ def _ingest_tile_delta(payload: bytes) -> None: ) _image_publisher.canvas = _image_canvas update = _image_canvas.apply(frame) - if update.request_keyframe: + # F11: a request caused ONLY by a base_seq gap is suppressed when + # the gate is off — the gap's tiles were still applied (gap-tolerant + # merge) and whatever the lost frames staled is repaired by the 0x6C + # stale-tile path. Any co-occurring tile error un-suppresses. + suppress_gap_kf = (update.request_keyframe and update.seq_gap + and not update.tile_error and not _KF_ON_SEQ_GAP) + if suppress_gap_kf: + logging.info("kf-on-gap suppressed (F11): %s", update.reason) + if update.request_keyframe and not suppress_gap_kf: _image_publisher.needs_keyframe = True _image_publisher.last_keyframe_reason = update.reason try: From 24f9e3272614f0f5d330bd2b5e478cc9f7b8fd11 Mon Sep 17 00:00:00 2001 From: dorkmo <1923070+dorkmo@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:48:02 -0500 Subject: [PATCH 2/3] =?UTF-8?q?feat(image):=20F11=20default=20flip=20?= =?UTF-8?q?=E2=80=94=20per-gap=20keyframe=20requests=20off=20after=20A/B?= =?UTF-8?q?=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-air A/B (bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md), gate off at the F10 acceptance operating point: 4 induced gaps + 2 natural single-frame air losses -> 6 suppressions, ZERO keyframe requests, and a fully healthy canvas — short-gap losses re-swept below the 30 s stale horizon, gap stragglers repaired by the 0x6C path in 1-2 report periods. The ungated cold-start request verified live mid-stream (published 3 s after a canvas-cold web_ui restart, radiated, keyframe granted). Control is the F10 surgical window: every gap requested, two keyframe trains granted. Also: the suppression counter logs at WARNING (uvicorn leaves the root logger at its default, so info was silently dropped — caught on the bench when three real suppressions produced zero log lines). LIFETRAC_KF_ON_SEQ_GAP=1 restores the old behaviour. Suite 1103/2446. Co-Authored-By: Claude Fable 5 --- LifeTrac-v25/DESIGN-CONTROLLER/TODO.md | 24 ++++--- .../base_station/tests/test_kf_on_seq_gap.py | 10 +-- .../DESIGN-CONTROLLER/base_station/web_ui.py | 23 +++++-- .../F11_kf_on_gap_2026-08-02/RESULTS.md | 66 +++++++++++++++++++ .../F11_kf_on_gap_2026-08-02/tap_base.log | 60 +++++++++++++++++ 5 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md create mode 100644 LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/tap_base.log diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/TODO.md b/LifeTrac-v25/DESIGN-CONTROLLER/TODO.md index dd059304..71f0cc66 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/TODO.md +++ b/LifeTrac-v25/DESIGN-CONTROLLER/TODO.md @@ -1972,14 +1972,22 @@ acceptance steps passed — full record in dual-broker retained-override trap (bench rule: clear retained control state on BOTH brokers between campaigns), and the deployment rule `LIFETRAC_TILE_STALE_AFTER_MS ≥ ~1.25 × n_tiles/(SWEEP_STEP × fps)`. -- [ ] **F11 (candidate, from F10 acceptance §5): demote web_ui's seq-gap - keyframe request.** The gap-tolerant apply requests a keyframe on EVERY - base_seq gap — including a single lost delta frame (two such on-air - events in the surgical window, each costing a ~2.4 KB keyframe at the - 243 B/frame budget; the 10 s KeyframeRequester throttle is the only - damper). F10's stale reporting makes this largely redundant: staleness - is now detected and repaired tile-by-tile. Same protocol as F10: gate it - behind an env (default unchanged), measure the A/B on air, then flip. +- [x] **F11 DONE + VERIFIED ON AIR 2026-08-02 (branch `f11-kf-on-seq-gap`) + — web_ui's per-gap keyframe request demoted, default OFF.** The + gap-tolerant apply requested a keyframe on EVERY base_seq gap, including + a single lost delta frame; F10's stale reporting made it redundant. + Implementation: structured causes on CanvasUpdate (`seq_gap`, + `tile_error` — no reason-string matching), suppression only for + pure-gap requests under `LIFETRAC_KF_ON_SEQ_GAP=0`; cold start, grid + mismatch, and tile-decode errors always pass through. On-air A/B in + `bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md`: gate-off, 4 + induced gaps + 2 natural single-frame losses → 6 suppressions, ZERO + keyframe requests, canvas fully healthy (short gaps re-swept below the + stale horizon; gap stragglers repaired by 0x6C in 1–2 report periods); + ungated cold-start request verified live mid-stream. Control = the F10 + surgical window (every gap requested, two keyframe trains granted). + Default flipped to 0 after the pass, per the measure-then-flip + protocol; `=1` restores the old behaviour. ### RS-11 — Next-session sequencing, and the one instrument that gates it (added 2026-07-29) diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py index 2e680429..6d3c0f59 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py @@ -161,10 +161,12 @@ def test_gate_off_still_requests_on_gap_with_tile_error(self) -> None: self._ingest(_frame(7, [3], codec=CODEC_MONO_G4)) self.assertEqual(len(self._kf_publishes()), 1) - def test_default_env_is_gate_on(self) -> None: - self.assertTrue(self._saved_gate, - "F11 protocol: default must stay ON until the " - "on-air A/B is measured") + def test_default_env_is_gate_off(self) -> None: + self.assertFalse( + self._saved_gate, + "F11 default flipped OFF after the on-air A/B passed " + "2026-08-02 (6 suppressions, zero keyframes, canvas healthy " + "— see bench-evidence/F11_kf_on_gap_2026-08-02)") if __name__ == "__main__": diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py index 519404ad..0e8cb0be 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/web_ui.py @@ -1081,11 +1081,19 @@ def _on_mqtt_connect(_c, _u, _flags, rc): # delta frame — and each granted request costs a multi-frame keyframe train # at the current link budget. F10's stale-tile reporting now detects and # repairs exactly that damage tile-by-tile at ~zero extra airtime, so the -# per-gap keyframe is largely redundant. Gate it, DEFAULT UNCHANGED (on), -# per the F10 protocol: measure the A/B on air first, then flip in a -# separate commit. Cold start, grid mismatch, and tile-decode errors are -# NOT gated — those are the keyframe request's legitimate jobs. -_KF_ON_SEQ_GAP = os.environ.get("LIFETRAC_KF_ON_SEQ_GAP", "1") == "1" +# per-gap keyframe is largely redundant. Cold start, grid mismatch, and +# tile-decode errors are NOT gated — those are the keyframe request's +# legitimate jobs. +# +# Default flipped to 0 after the on-air A/B PASSED 2026-08-02 +# (bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md): gate-off, 4 +# induced gaps + 2 natural single-frame air losses → 6 suppressions, +# ZERO keyframe requests, canvas fully healthy (F10 repaired the +# stragglers in 1-2 report periods); the ungated cold-start request +# verified live mid-stream. Control (gate-on) is the F10 acceptance +# surgical window: every gap requested, two keyframe trains granted. +# Set LIFETRAC_KF_ON_SEQ_GAP=1 to restore the old behaviour. +_KF_ON_SEQ_GAP = os.environ.get("LIFETRAC_KF_ON_SEQ_GAP", "0") == "1" def compute_stale_tiles(canvas, now_ms: int, stale_after_ms: int) -> list: @@ -1169,7 +1177,10 @@ def _ingest_tile_delta(payload: bytes) -> None: suppress_gap_kf = (update.request_keyframe and update.seq_gap and not update.tile_error and not _KF_ON_SEQ_GAP) if suppress_gap_kf: - logging.info("kf-on-gap suppressed (F11): %s", update.reason) + # warning, not info: uvicorn leaves the root logger at its + # WARNING default, so an info here is silently dropped — and + # this line is the bench A/B's suppression counter (F11). + logging.warning("kf-on-gap suppressed (F11): %s", update.reason) if update.request_keyframe and not suppress_gap_kf: _image_publisher.needs_keyframe = True _image_publisher.last_keyframe_reason = update.reason diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md b/LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md new file mode 100644 index 00000000..43d40fa6 --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md @@ -0,0 +1,66 @@ +# F11 on-air A/B — per-gap keyframe request demoted (2026-08-02) + +**SHA under test:** branch `f11-kf-on-seq-gap` (gate commit `7a1dfb1c` on +main @ 02a84f2e). **Verdict: PASS — default flipped to OFF** in the +follow-up commit, per the measure-then-flip protocol (F9 precedent). + +## 1. What was measured + +web_ui's gap-tolerant canvas requested a keyframe on EVERY base_seq gap, +including a single lost delta frame. F10's 0x6C stale-tile path now detects +and repairs exactly that damage tile-by-tile, so F11 gates the per-gap +request behind `LIFETRAC_KF_ON_SEQ_GAP`. Suppression applies ONLY to +pure-gap requests (structured `seq_gap`/`tile_error` causes on +CanvasUpdate); cold start, grid mismatch, and tile-decode errors always +pass through. + +Setup identical to the F10 acceptance (camera feed, DTS profile 2, v3 +depth 2, smooth pacing, base-board web_ui with 30 s stale horizon / 3 s +period), gate OFF. All times host/base UTC. + +## 2. Control (A side): F10 acceptance surgical window, gate ON + +From `bench-evidence/F10_tile_stale_acceptance_2026-08-01/RESULTS.md` §5: +three seq-gap events (one 18-frame resume gap + two natural single-frame +air losses) → three req_keyframe publishes → two 0x60 radiations (10 s +throttle) → keyframe trains granted, each a multi-frame cost at the +243 B/frame budget. + +## 3. Treatment (B side): gate OFF, this session + +- **Cold start still keyframes (keep-path, on air):** f11_webui restarted + mid-stream 01:44:30 with an empty canvas; "delta arrived before any + keyframe" published 01:44:33, radiated (two 0x60 attempts, converged) — + the ungated path works. +- **Six gap events, six suppressions, ZERO keyframe requests:** + - Gaps 1–3 induced (3 s rx stops at 01:41:03 / 01:41:47 / 01:42:32): + req_keyframe count in the broker tap stayed 0 (pre-log-fix, the + suppression line was swallowed by the root logger's WARNING default — + fixed to logging.warning and re-verified). + - Gap 4 induced (01:45:27): `kf-on-gap suppressed (F11): base_seq gap: + got 205, expected 194` (11 frames). + - Two NATURAL single-frame air losses in the same window — the exact + event class the control paid keyframes for: `got 234, expected 233` + and `got 6, expected 5` — both suppressed. +- **Canvas health under suppression:** short-gap losses re-swept within + the 24 s rotation (below the 30 s stale horizon — no report even + needed); gap-4 stragglers crossed the horizon and F10 repaired them in + 1–2 report periods (1–2 tile bitmaps at 01:45:54–01:46:12, then + silence). No lingering staleness, no keyframe. +- **Radiation audit:** all four 0x60 radiations in the session log are + accounted for — two at stream acquisition (01:39:43, rx daemon's own + requester at relaunch) and two for the mid-stream cold start. None + gap-driven. + +## 4. Decision + +Every keyframe the old behaviour would have paid across six gap events +was avoided with zero canvas harm; the repair burden moved to F10's +existing tile-by-tile path exactly as designed. Default flipped: +`LIFETRAC_KF_ON_SEQ_GAP=0`; set `=1` to restore the old behaviour. + +## 5. Raw artifacts + +- `tap_base.log` (this directory) — broker tap: tile_stale + req_keyframe. +- Suppression lines quoted inline from `docker logs f11_webui`. +- Untracked working copy at repo root: `_f11_tap_base.log`. diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/tap_base.log b/LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/tap_base.log new file mode 100644 index 00000000..08a7122f --- /dev/null +++ b/LifeTrac-v25/DESIGN-CONTROLLER/bench-evidence/F11_kf_on_gap_2026-08-02/tap_base.log @@ -0,0 +1,60 @@ +2026-08-02T01:39:46 lifetrac/v25/cmd/tile_stale 170084ffffffffffffffffffffff +2026-08-02T01:39:49 lifetrac/v25/cmd/tile_stale 1d0000c0ffffffffffffffffffff +2026-08-02T01:39:52 lifetrac/v25/cmd/tile_stale 2300000000f9ffffffffffffffff +2026-08-02T01:39:55 lifetrac/v25/cmd/tile_stale 290000000000e0ffffffffffffff +2026-08-02T01:39:58 lifetrac/v25/cmd/tile_stale 2f00000000000000efffffffffff +2026-08-02T01:40:01 lifetrac/v25/cmd/tile_stale 3500000000000000008cffffffff +2026-08-02T01:40:04 lifetrac/v25/cmd/tile_stale 3b000000000000000000c0f8ffff +2026-08-02T01:40:07 lifetrac/v25/cmd/tile_stale 410000000000000000000000c8ff +2026-08-02T01:40:25 lifetrac/v25/cmd/tile_stale 6500000000001800000000000000 +2026-08-02T01:40:28 lifetrac/v25/cmd/tile_stale 6b00000000001800000000000000 +2026-08-02T01:40:34 lifetrac/v25/cmd/tile_stale 7700000000000000000020040000 +2026-08-02T01:40:37 lifetrac/v25/cmd/tile_stale 7d00000000000000000020040000 +2026-08-02T01:40:55 lifetrac/v25/cmd/tile_stale a100000000000080200000000000 +2026-08-02T01:40:58 lifetrac/v25/cmd/tile_stale a700000000000080200000000000 +2026-08-02T01:41:07 lifetrac/v25/cmd/tile_stale b100000002010000000000000000 +2026-08-02T01:41:10 lifetrac/v25/cmd/tile_stale bf00670802010000000000000000 +2026-08-02T01:41:13 lifetrac/v25/cmd/tile_stale c500e73f8e030000000000000800 +2026-08-02T01:41:16 lifetrac/v25/cmd/tile_stale cb008037bc060002000000000800 +2026-08-02T01:41:19 lifetrac/v25/cmd/tile_stale d1000034bc060002000000000800 +2026-08-02T01:41:22 lifetrac/v25/cmd/tile_stale d700000030040002001100000000 +2026-08-02T01:41:25 lifetrac/v25/cmd/tile_stale dd00000000000002001100000000 +2026-08-02T01:41:28 lifetrac/v25/cmd/tile_stale e300000000000000001100000000 +2026-08-02T01:41:31 lifetrac/v25/cmd/tile_stale e900000000000000001100000000 +2026-08-02T01:41:34 lifetrac/v25/cmd/tile_stale ef00100000000000001000000000 +2026-08-02T01:41:37 lifetrac/v25/cmd/tile_stale f500100000000000001000000000 +2026-08-02T01:41:55 lifetrac/v25/cmd/tile_stale 190000003004000000000080a701 +2026-08-02T01:41:58 lifetrac/v25/cmd/tile_stale 1f0000c43100000000000080a7ff +2026-08-02T01:42:01 lifetrac/v25/cmd/tile_stale 260000d4010000000000000000fe +2026-08-02T01:42:04 lifetrac/v25/cmd/tile_stale 2b0000d0410000000000000000fe +2026-08-02T01:42:07 lifetrac/v25/cmd/tile_stale 30000010404000000000000000c0 +2026-08-02T01:42:10 lifetrac/v25/cmd/tile_stale 38000000404000000000000000c0 +2026-08-02T01:42:13 lifetrac/v25/cmd/tile_stale 3e000000404000000000000000c0 +2026-08-02T01:42:16 lifetrac/v25/cmd/tile_stale 44000000004000000000000000c0 +2026-08-02T01:42:40 lifetrac/v25/cmd/tile_stale 7300671802010000802000000000 +2026-08-02T01:42:43 lifetrac/v25/cmd/tile_stale 7a00e7d802010000806a1f030000 +2026-08-02T01:42:46 lifetrac/v25/cmd/tile_stale 800080c100000000006a1f030000 +2026-08-02T01:42:49 lifetrac/v25/cmd/tile_stale 8600008100000000004a1f030000 +2026-08-02T01:42:52 lifetrac/v25/cmd/tile_stale 8b00000100000000000010000000 +2026-08-02T01:43:16 lifetrac/v25/cmd/tile_stale bc00240000000000000000000000 +2026-08-02T01:43:19 lifetrac/v25/cmd/tile_stale c200240000000000000000000000 +2026-08-02T01:44:33 lifetrac/v25/cmd/req_keyframe 64656c74612061727269766564206265666f726520616e79206b65796672616d65 +2026-08-02T01:44:33 lifetrac/v25/cmd/req_keyframe 64656c74612061727269766564206265666f726520616e79206b65796672616d65 +2026-08-02T01:44:36 lifetrac/v25/cmd/tile_stale 5a00fffbfe7ffbf7fffffffffbfb +2026-08-02T01:44:39 lifetrac/v25/cmd/tile_stale 6000eff9fe7ffbb7a3effffffbc1 +2026-08-02T01:44:42 lifetrac/v25/cmd/tile_stale 660000d83e7ffbb7a3efeffffbc1 +2026-08-02T01:44:45 lifetrac/v25/cmd/tile_stale 6c0000000078fbb7a3efeffffbc1 +2026-08-02T01:44:48 lifetrac/v25/cmd/tile_stale 72000000000000b6a3efeffffbc1 +2026-08-02T01:44:51 lifetrac/v25/cmd/tile_stale 780000000000000080efeffffbc1 +2026-08-02T01:44:54 lifetrac/v25/cmd/tile_stale 7e000000000000000000e0fffbc1 +2026-08-02T01:44:57 lifetrac/v25/cmd/tile_stale 840000000000000000000000cac1 +2026-08-02T01:45:36 lifetrac/v25/cmd/tile_stale d20010020080000808000000023a +2026-08-02T01:45:39 lifetrac/v25/cmd/tile_stale d800ff430080000808100000023a +2026-08-02T01:45:42 lifetrac/v25/cmd/tile_stale df00efc102000000001000000000 +2026-08-02T01:45:45 lifetrac/v25/cmd/tile_stale e400efc102000000001000000000 +2026-08-02T01:45:48 lifetrac/v25/cmd/tile_stale ea00008102000000000000000000 +2026-08-02T01:45:51 lifetrac/v25/cmd/tile_stale f000000102000000000000000000 +2026-08-02T01:45:54 lifetrac/v25/cmd/tile_stale f600000100000000000000000000 +2026-08-02T01:45:57 lifetrac/v25/cmd/tile_stale fd00000100000000000000000000 +2026-08-02T01:46:09 lifetrac/v25/cmd/tile_stale 1500000000002800000000000000 +2026-08-02T01:46:12 lifetrac/v25/cmd/tile_stale 1b00000000002000000000000000 From b392b83b832e6758891b6ac35fb2e93aab443e82 Mon Sep 17 00:00:00 2001 From: dorkmo <1923070+dorkmo@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:59:37 -0500 Subject: [PATCH 3/3] =?UTF-8?q?test(image):=20address=20PR=20#91=20review?= =?UTF-8?q?=20=E2=80=94=20stale=20docstring,=20misleading=20test=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module docstring still said the default stays ON pending the A/B; it now records the measured flip. test_gate_on_default_still_requests_on_gap forces the gate on rather than exercising the default, so "default" is dropped from the name. Co-Authored-By: Claude Fable 5 --- .../base_station/tests/test_kf_on_seq_gap.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py index 6d3c0f59..d8e0a0c8 100644 --- a/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py +++ b/LifeTrac-v25/DESIGN-CONTROLLER/base_station/tests/test_kf_on_seq_gap.py @@ -1,10 +1,12 @@ """F11 — gating the per-gap keyframe request behind LIFETRAC_KF_ON_SEQ_GAP. -The gap-tolerant canvas (IP-PlanRev) requests a keyframe on EVERY base_seq +The gap-tolerant canvas (IP-PlanRev) requested a keyframe on EVERY base_seq gap, including a single lost delta frame. F10's 0x6C stale-tile path now detects and repairs exactly that damage tile-by-tile, so the per-gap -keyframe is largely redundant — but per the F10 protocol the default stays -ON until the A/B is measured on air. These tests pin the gate mechanics: +keyframe is largely redundant. Per the F10 protocol the gate shipped with +the default ON, and the default was flipped OFF after the on-air A/B +passed 2026-08-02 (bench-evidence/F11_kf_on_gap_2026-08-02/RESULTS.md). +These tests pin the gate mechanics: - Canvas reports structured causes (seq_gap / tile_error) so web_ui's policy never string-matches reasons. @@ -137,7 +139,7 @@ def test_gate_off_suppresses_pure_gap(self) -> None: # The gap's own tiles were still applied. self.assertEqual(web_ui._image_canvas._tiles[3].arrived_ms, 1000) - def test_gate_on_default_still_requests_on_gap(self) -> None: + def test_gate_on_still_requests_on_gap(self) -> None: web_ui._KF_ON_SEQ_GAP = True self._ingest(_frame(0, range(96), keyframe=True)) self._ingest(_frame(5, [3]))