Skip to content

Dropped scheduled dark is typed "light", silently doubling the dark-correction interval #175

Open

Description

@boringethan

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.

Why it wasn't fixed with #114

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

  1. 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).
  2. Add a diagnostic event + WARNING for a missed dark, so a doubled interval is visible in the record rather than inferred.
  3. 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.
  4. 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.
  • Realtime predictor unaffected by a dropped dark.

Activity

  1. boringethan commented on Jul 21, 2026

    @boringethan
    ContributorAuthor

    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

    1. Scope: missing darks only. Contaminated darks (integrity guard fires, boundary used anyway) stay out — separate ticket.
    2. 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.
    3. Representation: a new ranked quality value, wide_interval, reusing the existing single-string mechanism.

    Rank

    ok 0  <  ts_corrected 1  <  nan_filled 2  <  wide_interval 3
    

    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:

    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.

  2. boringethan commented on Jul 21, 2026

    @boringethan
    ContributorAuthor

    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.

  3. boringethan commented on Sep 24, 2026

    @boringethan
    ContributorAuthor

    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.

  4. boringethan commented on Sep 24, 2026

    @boringethan
    ContributorAuthor

    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.

  5. boringethan commented on Oct 1, 2026

    @boringethan
    ContributorAuthor

    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.

  6. boringethan commented on Oct 2, 2026

    @boringethan
    ContributorAuthor

    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.

  7. boringethan commented on Oct 2, 2026

    @boringethan
    ContributorAuthor

    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.

  8. boringethan commented on Oct 2, 2026

    @boringethan
    ContributorAuthor

    Back to In progress: Ethan will hand-test #304 on the bench before it goes to review.

  9. added this to the 1.6.0 milestone on Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions