You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Dropped scheduled dark is typed "light", silently doubling the dark-correction interval #175
Split out of #114 (item 4), which was deliberately scoped to the NaN-laundering fix only (PR #174).
Problem
timestamp_repair.py:341 hardcodes "frame_type": "light" for every synthetic NaN-fill row it inserts for a frame-id gap. If the dropped frame was a scheduled dark (abs_id == discard_count + 1, or (abs_id - 1) % dark_interval == 0 — see classify.py:_is_dark), the fill row enters PendingInterval as a NaN light sample. The dark boundary that should have closed the interval never arrives, so the interval silently spans to the next scheduled dark — the dark-correction interval doubles, with no log and no diagnostic event.
Since PR #174 the corrupted fill row at least emits as NaN rather than as a finite BFI, so the symptom is now missing data instead of manufactured data. But the doubled interval is still silent: every light frame in the widened interval gets its dark baseline linearly interpolated across twice the intended span, and nothing tells the operator or the analyst.
Typing the fill row "dark" on its own makes things worse. The row's u1/std are NaN, so:
DarkHistory.append stores a NaN DarkObservation -> HybridRealtimePredictor produces NaN baselines for the realtime path until the ring buffer rotates it out.
PendingInterval.set_right_dark / set_left_dark take it as an interval boundary -> LinearInterpolation gets d_prev.u1 or d_next.u1 NaN -> every frame in both adjacent intervals corrects to NaN.
That trades a silent doubled interval for two entirely NaN intervals plus a poisoned realtime predictor.
Proposed work
dark.py — reject non-finite dark observations: don't append them to DarkHistory, don't accept them as an interval boundary. Decide and document the fallback (most likely: treat as a missed dark, keep the interval open to the next real dark, but emit an explicit event).
Add a diagnostic event + WARNING for a missed dark, so a doubled interval is visible in the record rather than inferred.
timestamp_repair.py — classify a synthetic fill row as "dark" when its abs id lands on the dark schedule, so the missed dark is explicit rather than mislabeled.
Consider surfacing the count of missed darks in the scan summary / session_meta.
Needs 1 before 3, or the poisoning above.
Tests
Dropped scheduled dark -> fill row typed dark, missed-dark event emitted, interval handling explicit, no NaN corrected frames leaking from the boundary.
Non-finite dark observation never reaches DarkHistory or an interval boundary.
Design agreed — scope narrowed, and a simpler detection approach than this issue proposes
Worked through the policy with Ethan. Spec written to docs/superpowers/specs/2026-07-21-missed-dark-policy-design.md (pending review).
Decisions
Scope: missing darks only. Contaminated darks (integrity guard fires, boundary used anyway) stay out — separate ticket.
Disposition: keep and flag, not discard. A single missed dark degrades ~1200 frames / 30 s of a scan, because the whole widened interval is re-interpolated, not just frames near the gap. We emit them all, marked degraded. Nothing is thrown away.
Representation: a new ranked quality value, wide_interval, reusing the existing single-string mechanism.
Highest, because it is the only flag that silently biases the aggregate: a nan_filled camera emits NaN and is dropped by spatial_side_average's nanmean on its own, whereas a wide-interval camera contributes real-looking numbers built on a stretched baseline. If nan_filled could mask it, the side-average row would hide the one thing actually skewing the value.
Detection — supersedes items 3 and 4 of this issue
DarkCorrectionStage.__init__ takes no discard_count or dark_interval; it has no knowledge of the dark schedule and only reacts to frames the classifier typed "dark". Pass the schedule in, and when an interval closes as [left_abs, right_abs], count scheduled dark positions falling strictly inside it. One or more ⇒ that many darks were missed, and we know which abs_ids.
This is better than re-typing schedule-aligned fill rows as "dark" as originally proposed here:
Catches a missed dark from any cause (dropped frame, camera dropout, stale-rejected frame), not just the NaN-fill path.
The schedule predicate gets extracted from classify.py:_is_dark into one shared function both stages import — two copies would drift and regrow this bug.
Diagnostics
New MissedDarkWarning event alongside DarkIntegrityWarning, carrying side, cam_id, the expected abs_ids, the actual [left_abs, right_abs], and the count. Sinks already route and tally diagnostics events, so it lands in the scan summary next to TerminalDarkResult and TimestampMisalignmentWindow with no sink changes.
Two findings worth recording
The flag only reaches the scan DB.5c3e107 refactor(pipeline): drop quality column from corrected CSV is already on next and removed both the column and sinks._QUALITY_RANK. quality now survives only in session_data.quality. Anyone working from the corrected CSV sees degraded data with no indication — the MissedDarkWarning and scan summary are the independent second surface. Accepted limitation, documented in the spec.
_QUALITY_RANK.get(fq, 0) means an unrecognised quality string silently ranks as ok — i.e. a stale consumer would treat wide_interval as the best quality, the exact inversion of intent. Mitigated by a single shared rank definition. Checked the app repos: neither has independent quality logic (all hits are bundled omotion copies in packaged dist/ builds), so no app-side change is needed, but packaged builds carry the old 3-value rank until rebuilt.
Explicitly out of scope
Contaminated darks · any tolerance for "how wide is too wide" · timestamp_repair changes · terminal-dark-missing (keeps its current discard behaviour; genuinely different condition with no right boundary at all).
Follow-up, not blocking
Quantifying the actual drift cost of a doubled interval. Not needed here since we keep-and-flag unconditionally, but it is what would justify a future tolerance-based discard. Not measurable from scans on hand — a 20 s scan at dark_interval 600 contains only two darks (abs_id 10, 601). A ~2 min scan gives 5-6 darks and would answer it.
Moving back to Next Up — taking another pass at the design; the current approach came out more complicated than it needs to be.
Draft PR #179 and its branch feature/175-missed-dark-policy are kept as-is for reference (all 15 commits, 589 tests green). Not abandoning the work, just rethinking the shape before it goes forward.
Spec and plan are on the branch at docs/superpowers/specs/2026-07-21-missed-dark-policy-design.md and docs/superpowers/plans/2026-07-21-missed-dark-policy.md. The three review findings folded into #179 (factory schedule forwarding, the regression-blind factory test, and DarkFrameHoldStage hardcoding quality="ok") are worth carrying into whatever the next approach is regardless of how the rest changes.
Cross-link: the contaminated-dark case this issue deliberately scoped out ("separate ticket") is now #292. That's the guard firing and the lit frame being used as the boundary anyway. Both come from positional-only dark identification, and the missed-dark handling designed here (keep the interval open to the next good dark, emit an explicit event, flag the degraded interval) is the natural fallback for a rejected dark too. Whoever picks up either should look at both.
Deprioritized along with #292 (Ethan, 2026-09-24). A scheduled dark goes missing when frames are lost, and #293 (host buffering) and #294 (clock-based resync) should make that rare. This stays in Backlog with draft PR #179; revisit if field logs still show missed darks after those ship.
Moving to Ready. The 2026-09-24 note still applies: sdk#293 (host buffering) and sdk#294 (clock-based resync) should make a missed dark rare, and draft PR #179 is the starting point.
Starting work with a much smaller shape than draft #179, per the July note that it came out too complicated.
Detection lives entirely in DarkCorrectionStage, at interval close. The stage gets one extra constructor argument, dark_interval (forwarded by the factory, the same value the classifier uses). When an interval closes as [left_abs, right_abs], the stage counts scheduled dark positions (abs_id ≡ 1 mod dark_interval) strictly inside it. Every position after the first scheduled dark follows that rule, and an interval's left edge is always a dark, so discard_count isn't needed. One or more inside means that many darks were missed, from any cause: dropped frame, camera dropout or stale rejection. The same check runs on the terminal interval at scan stop.
On a missed dark:
logger.warning naming the side/cam, the missed abs_ids and the actual interval;
a new MissedDarkWarning diagnostics event (side, cam_id, left_abs, right_abs, missed_abs_ids), routed and tallied like DarkIntegrityWarning with no sink changes;
every light frame in that interval gets quality = "wide_interval" (keep-and-flag, as agreed). It's added at the top of side_avg._QUALITY_RANK so a side-average row can't hide it behind nan_filled.
Not changing:timestamp_repair (fill rows stay "light"; a NaN light sample just corrects to NaN, so it is inert), classify.py (no schedule extraction), DarkHistory (no NaN dark can reach it, since fill rows are never typed dark). From #179's review findings I'm carrying the factory forwarding plus a factory test that actually fails if the value isn't forwarded.
PR up: #304. Implemented as described in the previous comment, plus the DarkFrameHoldStage row now inherits wide_interval from its neighbours instead of hardcoding ok (a #179 review finding). 12 files, +268/−10, most of it tests; 427 software-only tests pass. Draft #179 is superseded by this and can be closed once this merges.
Activity
boringethan commentedon Jul 21, 2026
Design agreed — scope narrowed, and a simpler detection approach than this issue proposes
Worked through the policy with Ethan. Spec written to
docs/superpowers/specs/2026-07-21-missed-dark-policy-design.md(pending review).Decisions
qualityvalue,wide_interval, reusing the existing single-string mechanism.Rank
Highest, because it is the only flag that silently biases the aggregate: a
nan_filledcamera emits NaN and is dropped byspatial_side_average'snanmeanon its own, whereas a wide-interval camera contributes real-looking numbers built on a stretched baseline. Ifnan_filledcould mask it, the side-average row would hide the one thing actually skewing the value.Detection — supersedes items 3 and 4 of this issue
DarkCorrectionStage.__init__takes nodiscard_countordark_interval; it has no knowledge of the dark schedule and only reacts to frames the classifier typed"dark". Pass the schedule in, and when an interval closes as[left_abs, right_abs], count scheduled dark positions falling strictly inside it. One or more ⇒ that many darks were missed, and we know which abs_ids.This is better than re-typing schedule-aligned fill rows as
"dark"as originally proposed here:timestamp_repairchange at all.DarkObservationpoisoningDarkHistoryor becoming an interval boundary — the exact hazard that got item 4 deferred out of Corrected/batch pipeline launders invalid data into finite BFI (NaN mean -> contrast 0 -> BFI 10.0 rails side average) #114.The schedule predicate gets extracted from
classify.py:_is_darkinto one shared function both stages import — two copies would drift and regrow this bug.Diagnostics
New
MissedDarkWarningevent alongsideDarkIntegrityWarning, carrying side, cam_id, the expected abs_ids, the actual[left_abs, right_abs], and the count. Sinks already route and tally diagnostics events, so it lands in the scan summary next toTerminalDarkResultandTimestampMisalignmentWindowwith no sink changes.Two findings worth recording
5c3e107 refactor(pipeline): drop quality column from corrected CSVis already onnextand removed both the column andsinks._QUALITY_RANK.qualitynow survives only insession_data.quality. Anyone working from the corrected CSV sees degraded data with no indication — theMissedDarkWarningand scan summary are the independent second surface. Accepted limitation, documented in the spec._QUALITY_RANK.get(fq, 0)means an unrecognised quality string silently ranks asok— i.e. a stale consumer would treatwide_intervalas the best quality, the exact inversion of intent. Mitigated by a single shared rank definition. Checked the app repos: neither has independent quality logic (all hits are bundledomotioncopies in packageddist/builds), so no app-side change is needed, but packaged builds carry the old 3-value rank until rebuilt.Explicitly out of scope
Contaminated darks · any tolerance for "how wide is too wide" ·
timestamp_repairchanges · terminal-dark-missing (keeps its current discard behaviour; genuinely different condition with no right boundary at all).Follow-up, not blocking
Quantifying the actual drift cost of a doubled interval. Not needed here since we keep-and-flag unconditionally, but it is what would justify a future tolerance-based discard. Not measurable from scans on hand — a 20 s scan at
dark_interval600 contains only two darks (abs_id 10, 601). A ~2 min scan gives 5-6 darks and would answer it.boringethan commentedon Jul 21, 2026
Moving back to Next Up — taking another pass at the design; the current approach came out more complicated than it needs to be.
Draft PR #179 and its branch
feature/175-missed-dark-policyare kept as-is for reference (all 15 commits, 589 tests green). Not abandoning the work, just rethinking the shape before it goes forward.Spec and plan are on the branch at
docs/superpowers/specs/2026-07-21-missed-dark-policy-design.mdanddocs/superpowers/plans/2026-07-21-missed-dark-policy.md. The three review findings folded into #179 (factory schedule forwarding, the regression-blind factory test, and DarkFrameHoldStage hardcoding quality="ok") are worth carrying into whatever the next approach is regardless of how the rest changes.boringethan commentedon Sep 24, 2026
Cross-link: the contaminated-dark case this issue deliberately scoped out ("separate ticket") is now #292. That's the guard firing and the lit frame being used as the boundary anyway. Both come from positional-only dark identification, and the missed-dark handling designed here (keep the interval open to the next good dark, emit an explicit event, flag the degraded interval) is the natural fallback for a rejected dark too. Whoever picks up either should look at both.
boringethan commentedon Sep 24, 2026
Deprioritized along with #292 (Ethan, 2026-09-24). A scheduled dark goes missing when frames are lost, and #293 (host buffering) and #294 (clock-based resync) should make that rare. This stays in Backlog with draft PR #179; revisit if field logs still show missed darks after those ship.
boringethan commentedon Oct 1, 2026
Moving to Ready. The 2026-09-24 note still applies: sdk#293 (host buffering) and sdk#294 (clock-based resync) should make a missed dark rare, and draft PR #179 is the starting point.
boringethan commentedon Oct 2, 2026
Starting work with a much smaller shape than draft #179, per the July note that it came out too complicated.
Detection lives entirely in
DarkCorrectionStage, at interval close. The stage gets one extra constructor argument,dark_interval(forwarded by the factory, the same value the classifier uses). When an interval closes as[left_abs, right_abs], the stage counts scheduled dark positions (abs_id ≡ 1 mod dark_interval) strictly inside it. Every position after the first scheduled dark follows that rule, and an interval's left edge is always a dark, sodiscard_countisn't needed. One or more inside means that many darks were missed, from any cause: dropped frame, camera dropout or stale rejection. The same check runs on the terminal interval at scan stop.On a missed dark:
logger.warningnaming the side/cam, the missed abs_ids and the actual interval;MissedDarkWarningdiagnostics event (side, cam_id, left_abs, right_abs, missed_abs_ids), routed and tallied likeDarkIntegrityWarningwith no sink changes;quality = "wide_interval"(keep-and-flag, as agreed). It's added at the top ofside_avg._QUALITY_RANKso a side-average row can't hide it behindnan_filled.Not changing:
timestamp_repair(fill rows stay"light"; a NaN light sample just corrects to NaN, so it is inert),classify.py(no schedule extraction),DarkHistory(no NaN dark can reach it, since fill rows are never typed dark). From #179's review findings I'm carrying the factory forwarding plus a factory test that actually fails if the value isn't forwarded.boringethan commentedon Oct 2, 2026
PR up: #304. Implemented as described in the previous comment, plus the
DarkFrameHoldStagerow now inheritswide_intervalfrom its neighbours instead of hardcodingok(a #179 review finding). 12 files, +268/−10, most of it tests; 427 software-only tests pass. Draft #179 is superseded by this and can be closed once this merges.boringethan commentedon Oct 2, 2026
Back to In progress: Ethan will hand-test #304 on the bench before it goes to review.