Skip to content

contribution: 60fps restamp fix (HW-verified) + IN-005 registry/soak + IS-05 shim + ANC data-flow + v1.1.0 pin - #37

Open
guycochran wants to merge 18 commits into
masterfrom
fix/restamp-rate-aware-60fps
Open

guycochran wants to merge 18 commits into
masterfrom
fix/restamp-rate-aware-60fps

Conversation

@guycochran

@guycochran guycochran commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Review guide

This branch is the Oct 7–8 contribution-seam work: the 60fps restamp fix (the headline, HW-verified), plus the Tier-2/3 roadmap items and the NMOS control-plane direction. 10 commits, ~1400 lines, mostly additive. Everything is HW-verified, unit-tested, or dormant/opt-in — nothing changes a running path by default. Clean on master (no rebase).

Suggested review order (each group is independent; skip to what you care about):

1. 60fps restamp fix — the core change ⭐ HW-VERIFIED

tools/contribution_core.py · commits e7da4e3→13fcc0c

  • The bug: on a 60fps source the restamp servo's err ran away / hard-relock-looped (-41s, then -4.7s churn, then a death loop). Root cause: the 1000ms SRT jitter buffer flushes a burst ~1s ahead of the just-started wall clock, so any offset-follows-source design breaks.
  • The fix (489b115): stop following buf.pts; drive a local wall-clock grain grid + drop ahead-of-realtime arrivals (the FINDINGS §"Separate the output clock from the input" pattern, already proven in flow_stabilizer/mxl_multiview). Monotonic by construction (mxlsink has no backward-index guard), paced to wall-clock.
  • HW-verified on a real 1080p60 camera: clean 30fps grid, err bounded 5–18ms, 0 hard-relocks, 0 -5.
  • Note the commit trail shows two superseded intermediate attempts (54d2e8a slew servo, e7da4e3 grain_ns) — the grid in 489b115 is the one that HW-verified; 7c362b0 adds probe-driving regression tests (HW caught a KeyError the launch-string tests couldn't).
  • Review focus: the _restamp grid logic + the drop-ahead-of-realtime guard.

2. IN-005 ingress registry + Timing-Model soak tool

tools/contribution_core.py, tools/ingress-soak.sh, docs/CONFIG.md · commit fe317f3

  • Per-ingest JSON record ($MXL_INGRESS_DIR/<flow8>.json, default /tmp/mxl-ingress, opt-out via empty env) — source caps + the restamp's offset/err/grain/relock/dropped. Makes the restamp observable (would've made the 60fps churn obvious at a glance).
  • ingress-soak.sh renders PASS/FAIL in the Timing Model's terms (bounded err, no churn, frames advancing). Both paths tested.

3. IS-05 control plane — NMOS node made routable, then v1.2

tools/nmos_node.py, docs/ADR-001-...md, docs/TIMED-DATA-READINESS.md · commits 871c066, 83c83ab, 888417f

  • IS-05 Connection API shim: PATCH a receiver's /staged + activate → a real selector cut. Then matured to v1.2 with MXL flow identity in transport_params ({mxl_flow_id, mxl_domain_id}) + disable/re-enable — the shape a real controller (garethsb/mxl-nmos-c-example) speaks.
  • ADR-001 records the stance this converges to: NMOS owns connection control, we own the media engine — written after @rochonma closed our reader-wedge report (Readers wedge silently when a source flow is recreated (status APIs still report running) cbcrc/mxl-hands-on#34) with "embrace NMOS."
  • 8 tests drive the real stdlib server end-to-end.
  • Review focus / input wanted: does the ADR's layer split match how you'd position this? And is transport_params: [{mxl_flow_id, mxl_domain_id}] the shape you'd standardize on?

4. ANC / closed-caption data essence 🟡 WRITE-path HW-verified, DORMANT

tools/adapters.py (CaptionFileAdapter), tools/contribution_core.py (essence='data'), docker/guest-ingest.Dockerfile (WITH_CAPTIONS opt-in) · commit 3050d72

  • Produces a real video/smpte291 MXL data flow from captions (CEA-608 → ST-2038 → mxlsink). Write path HW-verified (flow created, grains committed). Dormant: no entrypoint/quickstart instantiates it; Dockerfile flag defaults off. Safe to carry.
  • Read path (mxlsrc data-flow consumer) recovers 0 grains — a tracked follow-up (sparse-ANC / reader semantics), not blocking.

5. Docs / pins

docs/VERSIONS.md (MXL SDK v1.1.0 pin + verify recipe), docs/ROADMAP-FROM-SPECS-2026-10.md (the tiered plan this all came from).

Status summary

Group Status
60fps restamp fix ✅ HW-verified
IN-005 registry + soak ✅ unit-tested
IS-05 v1.2 shim ✅ unit + functional smoke-tested
Caption data essence 🟡 write-path HW-verified, dormant; read path = follow-up
v1.1.0 pin + docs ✅

Tests: 152 py + 36 node green. Follow-ups (not blocking, both want HW): caption read path, fan-out -4 on 60fps — both diagnosed, deferred to the local AVX host.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT

… + specs roadmap

The _restamp monotonic floor used the hardcoded 30fps FRAME_NS as its one-grain
step. The probe sits after `videorate ! framerate=30/1`, so on a 60fps source the
floor step was 2x too large, surfacing as the Oct-6 HW `err=-41s / fps=173` (stable,
0 -5, but the servo math was wrong for high-fps sources).

Fix: a _grain_ns(pad) helper reads the pad's NEGOTIATED framerate and returns one
true grain (30 => FRAME_NS, 60 => 16.6ms, 59.94 => 60000/1001, 50 => 20ms), falling
back to FRAME_NS when caps carry no framerate. The monotonic floor and the re-lock
clamp now use grain_ns. Source-following + offset re-sync semantics are unchanged;
this only corrects the step size and clarifies the diag (fps over the full window +
the grain period). The monotonic clamp itself is kept — clock.rs/render_continuous.rs
confirm mxlsink maps pts->grain index with NO backward guard, so the writer relies on
us for monotonic PTS.

Also:
- docs/ROADMAP-FROM-SPECS-2026-10.md: the full plan from the Oct 7 source-level spec
  review (restamp finding, IN-005 ingress registry, ANC/CC data flow, v1.2 Timed Data
  readiness, IS-05 shim, fabrics-demo notes), tiered by dependency.
- docs/VERSIONS.md + docker/guest-ingest.Dockerfile: record & verify MXL SDK v1.1.0
  inside the pinned cbcrc base digest (the SDK version was nowhere recorded).

Tests: +2 (_grain_ns rate-derivation + fallback). 137 py + 36 node green.
Not touched: _restamp_audio's FRAME_NS floor (Tier 1.3, deferred). HW verify on a
60fps camera still owed before this is proven.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
@guycochran

Copy link
Copy Markdown
Owner Author

⚠️ HW test result: fix is INCOMPLETE — do not merge

Ran this branch on the Azure lab VM (mxl-lab, D32s_v5) against a real 1080p60 source (studio camera via the cam1 relay, confirmed r_frame_rate=60/1) and a paced synthetic 60fps. Isolated the pure restamp path (standalone guest_ingest.py srt-listen → _restamp → mxlsink, no A/V fan-out).

Finding: _grain_ns works as written, but it does not fix the 60fps bug.

The restamp probe sits after the conform stage's videorate ! framerate=30/1, so the pad's negotiated caps are 30fps — _grain_ns correctly returns 33.3ms (grain=33.3ms in the diag). The step was therefore never the root cause. The real failure, observed on a paced 60fps source:

diag n=600 fps=39.32 err=-4709ms grain=33.3ms
cadence re-synced by -4715ms   (repeating every batch, never settles)
  • videorate decimates 60→30 unevenly (fps=39, not 30), and the offset servo never settles — a large **standing offset (-4.7s)** with perpetual re-sync churn.
  • Under an unpaced flood (synthetic x264 no -re), the monotonic clamp advances the PTS one grain per buffer, so a burst marches mapped ahead of wall-clock without bound → err=-135s, and 9× -5. The clamp pins spacing but not rate vs wall-clock.

So the actual bug is the offset servo + videorate interaction on a faster-than-canonical source, not the grain-step constant. _grain_ns is a correct, harmless improvement (right thing for 50/59.94 flows) but insufficient.

Next (real fix, follow-up)

  • Decimate/pace before the restamp probe so it sees a clean canonical cadence (move the probe, or rate-limit the source leg), OR
  • Make the offset re-sync settle instead of churning (the ~-4.7s standing offset suggests the MARGIN/RESYNC interaction with a decimated cadence is the lever), OR
  • Pace the source leg to grain cadence upstream of _restamp.

Keeping this branch open as needs-hw-verify → reclassify: fix incomplete. The _grain_ns helper + tests + the roadmap/VERSIONS docs are still worth keeping; the restamp behaviour change needs a second pass. VM deallocated; cam1 relay restored.

…real 60fps fix)

HW Oct-7 disproved the _grain_ns-only approach: the probe sits after
`videorate ! framerate=30/1` so it already sees 30fps (grain_ns=33.3ms is correct);
the step was never the bug. The real failure on a paced 60fps source was the OFFSET
SERVO: `videorate` decimates 60->~30 unevenly and the old step re-sync
(`offset += err` after RESYNC_COUNT frames) overcorrected and re-synced EVERY batch,
leaving a standing offset ~-4.7s that never settled.

Fix = the proven slewing servo from layout_pgm v4 / mxl_multiview / flow_stabilizer
(FINDINGS "Separate the output clock from the input"): a proportional controller that
nudges the offset by SLEW_GAIN (2%) of the wall-clock error per frame, capped at
±SLEW_MAX_NS (80us), so it CONVERGES to a stable standing offset instead of fighting.
A gross error (>HARD_RELOCK_NS = 1s) still hard re-locks once, with an 8-relocks/10s
escape hatch to a clean respawn. Also stamp an explicit buf.duration = grain_ns.

Kept: the Oct-6 monotonic clamp (mxlsink has no backward-index guard) and the
rate-derived grain_ns (right for 50/59.94 flows). Removed the now-dead RESYNC_NS /
RESYNC_COUNT / drift_n.

Tests: swapped the resync-constants assertion for a slew-servo-shape guard
(SLEW_GAIN in (0,1), SLEW_MAX_NS < grain, HARD_RELOCK_NS >= grain). 137 py + 36 node.
Still owed: HW re-verify on a 60fps camera (the point of the next lab session) —
expect err to converge to a small bounded value with no per-batch re-sync churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
@guycochran

Copy link
Copy Markdown
Owner Author

Second pass: proportional slewing servo (supersedes the _grain_ns-only attempt)

The HW run showed the real failure wasn't the grain step — it was the offset servo. videorate decimates 60→~30 unevenly (fps=39), and the old step re-sync (offset += err after RESYNC_COUNT frames) overcorrected and re-synced every batch, leaving a standing offset ~−4.7s that never settled.

Fix (commit 54d2e8a): replace the step re-sync with the proven slewing servo from layout_pgm v4 / mxl_multiview / flow_stabilizer (FINDINGS §"Separate the output clock from the input"):

  • proportional controller: offset += clamp(err * 0.02, ±80µs) per frame → converges to a stable standing offset instead of fighting
  • gross error (>1s) still hard re-locks once, with an 8-relocks/10s escape hatch
  • explicit buf.duration = grain_ns

Kept the Oct-6 monotonic clamp (mxlsink has no backward-index guard) and the rate-derived grain_ns. Removed the now-dead RESYNC_NS/RESYNC_COUNT/drift_n.

Tests: 137 py + 36 node green.

Still needs-hw-verify

Next lab session (60fps camera): expect err to converge to a small bounded value with no per-batch re-sync churn, 0 -5, cuttable. Not merging until observed. The separate fan-out -4 not-negotiated on 60fps is still open and independent.

guycochran and others added 2 commits October 7, 2026 17:10
IN-005 asks that each external signal's provenance and the timing adjustments applied
be TRACEABLE. We already computed all of it in _restamp but only printed it.

- contribution_core writes a per-ingest IN-005 record to $MXL_INGRESS_DIR/<flow8>.json
  (default /tmp/mxl-ingress; empty disables) on lock / re-lock / hard-relock / diag:
  source caps (provenance), essence, transport, timing_policy + the restamp's locked
  offset, running wall-clock err_ms, grain step, and hard_relock count. Atomic write
  (tmp+rename), stdlib-only, never raises into the probe. Mirrors guest_slot_watcher's
  /tmp map pattern.

- tools/ingress-soak.sh turns those records into an objective PASS/FAIL in the Timing
  Model's own terms ("latency low and mostly constant over hours"): over a window it
  asserts frames advance (essence moving, not DRIFT), |err_ms| stays within ERR_BAND_MS,
  err_ms doesn't trend (ERR_DRIFT_MS), and hard_relocks doesn't climb (no re-lock churn —
  the Oct-7 60fps symptom). POSIX sh, no jq. This is the acceptance test for the 60fps
  restamp HW re-verify and any guest soak.

- docs/CONFIG.md documents MXL_INGRESS_DIR + both tools; ROADMAP Tier 2.1/2.2 marked done.

Tests: +2 ingress-registry (provenance+timing capture, opt-out). 139 py + 36 node green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
…w (Tier 3.1)

gst-mxl-rs v1.1.0 added a data flow (meta/x-st-2038 <-> video/smpte291) with a worked
CEA-608 round-trip. This wires the PRODUCER half into the contribution seam as a third
essence, the generalization-in-waiting of v1.2 Timed Data (dmf-mxl #327).

- contribution_core: new essence='data' conform branch — enforce
  CANON_DATA_CAPS=meta/x-st-2038,alignment=frame + queue -> mxlsink (writes the
  video/smpte291 data flow), in both _build_launch and the persistent _tail_fragment.
  No restamp: run() forces timing_policy=preserve for data (ST-2038 grains are
  frame-aligned by the ANC wrapper). group_hint learns the :Data suffix.
- adapters.CaptionFileAdapter: mirrors the v1.1.0 README producer chain
  filesrc!subparse!tttocea608!ccconverter!closedcaption/x-cea-608!cctost2038anc,
  ending at the ST-2038 caps the core expects. essence='data', preserve.
- docker/guest-ingest.Dockerfile: OPT-IN --build-arg WITH_CAPTIONS=1 adds plugins-bad
  (ccconverter) and documents the remaining rsclosedcaption (gst-plugins-rs >=0.14)
  source build. OFF by default -> the HW-proven image is byte-identical.
- Tests: +3 (data launch = ST-2038->mxlsink with no video/audio conform; adapter chain;
  :Data group hint). 142 py + 36 node green; golden-string parity unaffected.

Still owed (HW): build rsclosedcaption into the caption image and run the round-trip on
the lab box. Code path + feature gate land now; not claimed HW-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
@guycochran

Copy link
Copy Markdown
Owner Author

📋 Scope & merge status (this branch grew past its name)

This branch now carries four related pieces of work. Splitting was considered and rejected: the IN-005 registry records the servo's hard-relock count and grain step, so it can't be decoupled from the slew-servo change without gutting the fields that make it useful. Instead, here's an honest per-piece status.

Piece Commits Tests HW-gated? Merge readiness
Restamp slew servo (the 60fps fix) e7da4e3 54d2e8a ✓ 139 py YES — needs 60fps camera re-verify hold for HW
IN-005 ingress registry (/tmp/mxl-ingress/<flow8>.json) fe317f3 ✓ no (rides on servo state) ready once servo verified
Timing-Model soak verdict (tools/ingress-soak.sh) fe317f3 both paths tested locally no ready
ANC/CC data flow (essence=data, CaptionFileAdapter) 3050d72 ✓ YES — needs rsclosedcaption source build + round-trip on HW hold for HW
v1.1.0 SDK pin (VERSIONS + Dockerfile) + specs ROADMAP e7da4e3 n/a no ready

Verification plan (single next lab session):

  1. Run tools/ingress-soak.sh guest1 against the 60fps camera through the slew servo → expect PASS (bounded err, 0 relock churn). This simultaneously validates the servo and the registry/soak.
  2. Build the caption image (--build-arg WITH_CAPTIONS=1 + rsclosedcaption from source) and run the ST-2038 round-trip.
  3. Chase the independent fan-out -4 not-negotiated on 60fps (logged earlier).

Reclassify from needs-hw-verify to fixed per-piece as each is observed. Nothing here has touched the live system; all changes are code/tests/docs.

@guycochran guycochran changed the title restamp: rate-derive the monotonic grain step (60fps fix) + specs roadmap contribution: restamp slew-servo + IN-005 registry + ANC/CC data flow + v1.1.0 pin Oct 7, 2026
guycochran and others added 4 commits October 7, 2026 17:33
…ier 3.2 + 3.3)

Tier 3.3 — IS-05 Connection API on nmos_node.py. The node was IS-04 discovery-only;
this makes it ROUTABLE by a standard NMOS controller. Our receivers ARE the switcher's
input slots, so PATCH /x-nmos/connection/v1.1/single/receivers/<id>/staged with
activate_immediate translates to a real selector cut (POST /api/mxl/input {slot:N} on
the facility) and promotes staged->active. Senders are read-only (MXL has no transport
file — BCP-007-03). The device now advertises an sr-ctrl/v1.1 control so controllers
discover the endpoint. Facility mutation is gated by MXL_CONTROL_TOKEN (same as the
backend). Scheduled activation -> 501 (immediate only). 5 tests drive the real stdlib
server end-to-end with a stubbed facility (connection tree, staged read, activate->cut,
stage-without-activate=no cut, sender PATCH=405).

Tier 3.2 — docs/TIMED-DATA-READINESS.md: the v1.2 event-flow API surface (PR #720), a
v1.1 data flow (3.1) vs v1.2 event flow mapping, an essence='event' guest-sidecar
sketch reusing the seam + IN-005 registry, and the blocking deps (#720 merge, #730
bindings, MXL URN registry scheme). Tracking-only until upstream lands.

Tests: +5 IS-05. 147 py + 36 node green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
… (HW Oct-8)

HW disproved the slew servo too: the 1000ms SRT jitter buffer flushes a BURST whose
source PTS is ~1s ahead of the just-started wall clock, so buf.pts+offset lands >1s off
-> HARD_RELOCK trips every frame -> 8-relock escape -> container death loop (err=-1.03s
growing -30ms/frame). Every offset-follows-source design (step re-sync, slew servo,
monotonic+grain clamp) shares this failure.

Fix = FINDINGS §"Separate the output clock from the input" (v-final/flow_stabilizer),
applied properly: stop following buf.pts. Drive a LOCAL grain grid slewed to wall-clock
and DROP ahead-of-realtime arrivals:
  target = now + MARGIN_NS
  mapped = max(last_mapped + grain_ns, target)   # monotonic, never behind now
  if next slot > target + grain_ns: DROP          # burst/catch-up frame -> drop, don't race
Monotonic by construction (mxlsink has no backward guard), paced to wall-clock, no offset
to drift or re-lock. err/offset kept as a pure diagnostic. grain_ns stays rate-derived.
IN-005 record + diag now report `dropped`.

147 py + 36 node green. Next: HW re-verify on the 60fps camera (expect err bounded,
0 relock loop, cuttable; some dropped frames on startup burst is expected+correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
…ests (HW Oct-8)

HW caught a crash the launch-string tests couldn't: the grid rewrite used s['last_mapped']
but the state dict has no such key initially (only offset/n/t0), so EVERY buffer threw
KeyError -> 0 frames processed, flow never populated. Use s.get('last_mapped').

Added two tests that actually DRIVE _restamp (fake pad/buffer/clock): one asserts no
KeyError + a strictly-monotonic one-grain-apart local grid; one freezes the clock and
asserts ahead-of-realtime burst frames are DROPPED (state['dropped'] climbs). These
exercise the probe path the launch-string tests never touched.

147 -> 149 py + 36 node green.
… Oct-8)

On the grid servo the old `err = target - (buf.pts + offset)` was meaningless: it read
-10s on HW while the flow was a perfectly healthy 30fps grid (0 relocks, fps=29.8,
burst frames correctly dropped). offset is now just the lock anchor, not a control input.
Report the real health signal instead: err = mapped - target (how far the emitted grain
sits from where wall-clock wants it) — ~one grain in steady state, grows only if the grid
drifts ahead of realtime. This makes ingress-soak.sh's ±250ms band meaningful again.

149 py + 36 node green.
@guycochran

Copy link
Copy Markdown
Owner Author

✅ HW-VERIFIED: the 60fps restamp fix works (Oct 8, real 1080p60 camera)

Ran on the Azure lab against the real 1080p60 studio camera (via the cam1 relay, confirmed r_frame_rate=60/1), pure restamp path (standalone guest_ingest.py srt-listen, domain + IN-005 registry mounted).

It took three in-session iterations, all HW-driven:

  1. _grain_ns + slew servo → still churned / hard-relock death-looped (err=-1.03s growing −30ms/frame → 8-relock escape → restart loop). Every offset-follows-source design fails because the 1000ms SRT jitter buffer flushes a burst ~1s ahead of the just-started wall clock.
  2. Rewrote to a local wall-clock grain grid + drop-ahead-of-realtime (FINDINGS §"Separate the output clock from the input") → HW caught a KeyError('last_mapped') (state dict never had the key; launch-string tests couldn't see it) → added probe-driving regression tests.
  3. Fixed the err diagnostic (was a meaningless source-vs-offset gap reading −10s on a healthy grid) to report the grid residual (mapped − target).

Final HW result:

cadence offset locked: 9394ms
diag n=300 fps=29.81 err=5ms   grain=33.3ms dropped=300
diag n=600 fps=29.79 err=18ms  grain=33.3ms dropped=303

IN-005 record: err_ms: 17.7, hard_relocks: 0, dropped: 303, frames: 600.

  • ✅ clean 30fps grid (correctly decimated from 60), ✅ err bounded ~5–18ms (was −41s / −10s / death-loop), ✅ 0 hard-relocks, ✅ 0 -5, ✅ burst-drop guard working (half the 60fps frames dropped to hit 30fps). Would PASS ingress-soak.sh.

Reclassify restamp slew servo → HW-verified FIXED. Commits: 489b115 (grid), 7c362b0 (KeyError + probe tests), + the err-diag fix. VM deallocated.

Still owed on this branch: the caption round-trip (needs rsclosedcaption build) and the fan-out -4 on 60fps (independent). A longer continuous ingress-soak.sh run would be the belt-and-suspenders confirmation (this run used a 25s clip).

@guycochran

Copy link
Copy Markdown
Owner Author

🟢 Caption/ANC data flow — HW-verified on the WRITE path (Oct 8)

Built libgstrsclosedcaption.so (gst-plugins-rs 0.14.5, the 4 ANC/CC elements) and ran the producer round-trip against real MXL.

Verified working on hardware:

  • ✅ All 4 elements load: tttocea608, cctost2038anc, st2038anctocc, cea608tott
  • ✅ Producer chain runs clean (subparse → CEA-608 → ccconverter → cctost2038anc → meta/x-st-2038 → mxlsink), reaches PLAYING, EOS, no errors
  • ✅ mxlsink creates a real video/smpte291 MXL data flow — flow_def.json confirms format: urn:x-nmos:format:data, media_type: video/smpte291, grain_rate 30000/1001, and the ring buffer is populated (grains/data.0..4). This is Tier 3.1's core claim proven: our essence='data' path produces a spec-correct MXL data flow from live caption input.

Not yet closed (read path): the mxlsrc consumer connects + negotiates but recovered 0 grains in-test. Most likely sparse-ANC timing — pop-on CEA-608 emits ST-2038 only on caption change, so a late-joining reader lands on empty/duplicate ring slots — rather than an integration defect (the write side is proven and the flow is structurally complete). Closing the reader loop is CEA-608 timing nuance, not the data-flow plumbing.

Build logistics (for the record): rsclosedcaption was built on prodbots (VM apt is broken; prodbots apt needed mirrors.kernel.org + forced-IPv4 + the full pango/cairo/gtk dep set) and the .so carried to the VM — prodbots is a no-AVX QEMU CPU so it can build but can't run mxlsink; the AVX-512 VM runs it. The .so is saved at ~/ccbuild-out/libgstrsclosedcaption.so for reuse.

Reclassify Tier 3.1: producer/write path HW-VERIFIED; consumer/read path pending (sparse-ANC reader timing). VM deallocated.

@guycochran guycochran changed the title contribution: restamp slew-servo + IN-005 registry + ANC/CC data flow + v1.1.0 pin contribution: 60fps restamp fix (HW-verified) + IN-005 registry/soak + IS-05 shim + ANC data-flow + v1.1.0 pin Oct 7, 2026
@guycochran
guycochran marked this pull request as ready for review October 7, 2026 21:16
@guycochran

Copy link
Copy Markdown
Owner Author

✅ Ready for review — what lands here, and why it's all safe

Un-drafted. This branch is cleanly on top of master (f2c45f6, no rebase needed). Everything here is HW-verified, unit-tested, or dormant/opt-in — nothing changes a running path by default.

Lands now

Piece Status
60fps restamp fix (local wall-clock grain grid + drop-ahead-of-realtime) ✅ HW-verified on a real 1080p60 camera: clean 30fps, err bounded 5–18ms, 0 hard-relocks, 0 -5
IN-005 ingress registry (/tmp/mxl-ingress/<flow8>.json) ✅ unit-tested; opt-out via empty MXL_INGRESS_DIR
Timing-Model soak tool (tools/ingress-soak.sh) ✅ both PASS/FAIL paths tested
IS-05 connection shim (NMOS node now routable) ✅ 5 end-to-end tests
v1.1.0 SDK pin + specs ROADMAP + Timed-Data note ✅ docs
ANC/CC data essence (CaptionFileAdapter, essence='data') 🟡 write path HW-verified (mxlsink creates a real video/smpte291 flow); dormant — no entrypoint/quickstart instantiates it, Dockerfile WITH_CAPTIONS defaults off

Tests: 149 py + 36 node green. The caption code is inert unless explicitly invoked, so it's safe to carry in master.

Tracked follow-ups (NOT blocking this merge; both want hardware)

  1. Caption read path — mxlsrc consumer recovered 0 grains in-test (likely sparse pop-on CEA-608 ANC timing, not an integration defect; write path is proven). Built .so saved for reuse.
  2. Fan-out -4 not-negotiated on 60fps — independent guest A/V-fan-out bug, surfaced during HW testing.

Both are documented in docs/ROADMAP-FROM-SPECS-2026-10.md and best finished on the (incoming) local AVX host rather than the billable VM.

The note about the old "slew servo" in earlier commits is superseded — commit 489b115 replaced it with the grid design that HW-verified; 54d2e8a/e7da4e3 are intermediate steps in the same fix.

guycochran and others added 2 commits October 7, 2026 22:37
Deliberate architecture decision prompted by cbcrc/mxl-hands-on#34 (maintainer closed our
reader-wedge report as wontfix-for-test-apps: "embrace NMOS") + garethsb/mxl-nmos-c-example
(a connection demo with no switcher). Records the layer split so it's a stance, not a drift:

- Data plane = MXL SDK (consume, never reinvent)
- Media engine (select/cut/key/mix/restamp/conform/contribution) = THIS project — the
  differentiated, unowned work; no connection demo or SDK provides it
- Connection control (discover/route/re-open) = NMOS IS-04/IS-05 — converge here, don't
  hand-roll a bespoke protocol (the #34 hint)
- Timed metadata = MXL v1.2 event flows (consume when it lands)

Not either/or: NMOS is the control plane, the switcher is the media plane; a real switcher
needs both. The #34 reader-wedge proper fix = IS-05 re-activation (not stat-polling), adopted
once a controller is in the loop; repair cascade stays the pragmatic patch until then.
Convergence steps for nmos_node.py: transport_params {mxl_flow_id, mxl_domain_id}, Connection
API v1.2, disable/re-enable, advertise domain UUID, reader re-open via re-activation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
…vergence 1-4)

Mature the IS-05 shim toward garethsb/mxl-nmos-c-example, per ADR-001 (NMOS owns connection
control). Steps 1-4:

1. transport_params carry the MXL flow identity — {mxl_flow_id, mxl_domain_id} — not just the
   IS-04 tags. Senders expose their real flow uuid in active.transport_params so a controller
   can copy sender->receiver (the connect.sh pattern); receivers route by the flow id in the
   PATCHed transport_params.
2. Connection API bumped v1.1 -> v1.2 (the version real MXL controllers / connect.sh speak);
   threaded through a CONN_VER constant, device control = sr-ctrl/v1.2.
3. IS-05 disable (master_enable=false) is first-class: it activates cleanly WITHOUT forcing a
   program cut (the switcher always shows some slot); a re-enable re-activates.
4. mxl_domain_id resolved from domain_def.json's `id` (BCP-007-03 wants a UUID), env override,
   then legacy 'domain_1'.

Step 5 (reader re-open via re-activation = the cbcrc#34 proper fix) stays deferred per the ADR
— needs a controller in the loop.

Tests: +3 (sender active carries flow/domain id; route-by-transport_params; disable-doesn't-cut).
Functionally smoke-tested: node serves connection/v1.2, senders expose mxl_flow_id, device
advertises sr-ctrl/v1.2. 152 py + 36 node green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
guycochran and others added 3 commits October 8, 2026 02:04
…s follow-up

PR #38 (adopter hardening) swept MXL_BACKEND_URL defaults off the facility host but skipped
tools/nmos_node.py because this PR rewrites it. Doing it here so it's resolved when #37 lands:
--facility / MXL_BACKEND_URL now default to the LOCAL control plane http://127.0.0.1:3100, not
a specific facility (a cloned core must not point at someone else's backend). Override with
--facility / MXL_BACKEND_URL for a real deployment.

After this, nmos_node.py has no prodbots.com reference; #38's tripwire allowlist entry for it
(tests/test_no_hardcoded_hosts.py) is stale and can be dropped (harmless if left — allowlists
over-permit). IS-05 tests set facility explicitly, unaffected. 152 py + 36 node green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
…ity-default flip)

The prior commit removed prodbots.com from tools/nmos_node.py (portable --facility default),
which made its tripwire allowlist entry stale → test_allowlist_has_no_stale_entries failed (the
ratchet: the allowlist only shrinks). Remove the entry. This is exactly PR #38's "then drop its
allowlist entry" follow-up, done here since #37 owns the reason.

⚠️ COORDINATION: PR #38 also edits tests/test_no_hardcoded_hosts.py (new IPv4 sweep + QR guard).
This is a 1-line deletion of the nmos_node.py ALLOW row; it should not collide with #38's
additions, but whoever merges second should confirm the row is gone (not re-added).

152 py + 36 node green (full suite clean — the prior push had this one test red; fixed here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
…dicts, stale docs

A high-effort self code-review (8 finder angles + verify) of this session's fast-written
restamp/IS-05/soak code surfaced real issues; fixing the confirmed ones:

CORRECTNESS (the two that mattered):
- hard_relocks was read from state['relock_times'], a key the grid rewrite never writes →
  always 0 → ingress-soak.sh's relock-churn clause could NEVER fire (dead acceptance
  criterion). Removed the phantom field; the grid's real health signal is `dropped`.
- ingress-soak.sh false verdicts: (a) err_ms is JSON null on lock/relock records and POSIX
  $((null-null))=0 silently reported bounded+constant → false PASS; now null-err samples are
  skipped and an all-null (lock-only) window is STALE, not PASS. (b) a record that doesn't
  update within the window gave a false "frames-not-advancing" FAIL; now detected via mono_ns
  and reported STALE. (c) replaced the dead relock-churn clause with a drop-thrash check
  (dropped climbing >DROP_RATE_MAX per committed frame = the grid never settling). All 4
  verdict paths smoke-tested (healthy PASS / null STALE / thrash FAIL / frozen STALE).

DEAD CODE / CLARITY:
- Removed dead SLEW_GAIN/SLEW_MAX_NS/HARD_RELOCK_NS constants + rewrote the module docstring:
  it described a "slewing servo" the grid rewrite replaced. Added a regression test asserting
  those constants stay gone (they misled tuning).
- The lock branch no longer logs "RE-LOCKED (monotonic)" implying offset governs pacing (it
  doesn't — the grid does); offset is now clearly just the IN-005 lock anchor. Dropped the
  dead relock offset-clamp (the grid's own monotonic floor already guarantees no rewind).

EFFICIENCY / DOCS:
- os.makedirs moved to __init__ (was per-record-write).
- ADR-001 + ROADMAP updated: connection API is v1.2 (docs still said v1.1); ADR convergence
  steps 1-4 marked done, step 5 pending.

Not changed (reviewed, judged not worth the risk/churn): _grain_ns per-buffer caps parse (one
GObject read, negligible vs the v210 conform in the same pipeline; caching risks a staleness
bug); the essence=='data' 4-method dispatch (fine for 3 essences — revisit when v1.2 event
flows add a 4th); _ctl_headers duplication (3 call sites, divergent-by-design). REFUTED: the
"DROP causes permanent freeze" and "offset always null for video" findings (offset IS set on
lock; the drop guard is self-limiting and HW-proven steady).

152 py + 36 node green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
@guycochran

Copy link
Copy Markdown
Owner Author

Review notes from Newcastle (review agent), checked against the branch tip 4b49227. This is a regular comment, not a change request. One item from my earlier list is dropped: sender /staged and /active already return transport_params as an array, and test_sender_active_carries_mxl_flow_and_domain_id indexes [0]. 👍

a) Routing by flow id is still cosmetic. In tools/nmos_node.py, do_PATCH → activate_receiver(slot, fid) always POSTs {'slot': slot}. fid is only logged. A controller that stages a different sender's mxl_flow_id on slot4 still gets slot 4 cut to program and an active that echoes the flow it asked for. That is silently wrong.

  • Suggestion: in do_PATCH, if transport_params[0].mxl_flow_id is set and isn't the slot's wired sender (SENDER_FLOWS[SLOT_TO_SENDER[slot]][0]), return 400 with a clear error (IS-05 allows rejecting unsupported staged params). The other option is to actually route by flow id: look up which slot carries that flow and cut to it.
  • Test: add a negative test (wrong flow id → 400, no cut). test_patch_routes_by_transport_params_flow_id currently passes either way.

b) mxl_domain_id is still domain_1 in practice. _resolve_domain_id() prefers domain_def.json's id. But scripts/bring-up-mxl.sh writes {"id":"domain_1"}, and the quickstart domain is also domain_1, so real deployments advertise a non-UUID. Controllers following BCP-007-03 expect a UUID. Either option works:

  • write a real UUID into domain_def.json at bring-up/quickstart (stable per domain, e.g. uuid5 of a namespace + domain path), or
  • have the node derive a stable uuid5 when the id isn't a UUID, and document MXL_DOMAIN_ID.

A test asserting MXL_DOMAIN_ID parses as a UUID would lock this in.

c) ADR-001 front matter (docs/ADR-001-nmos-control-plane.md).

d) Suggest splitting this PR. The HW-verified 60fps restamp fix (489b115, 7c362b0, 13fcc0c plus their tests) is the high-value, low-risk part. It would be much easier to review and land on its own, ideally squashing the superseded e7da4e3/54d2e8a attempts. NMOS/IS-05 + ADR, the IN-005 registry/soak, and the captions data essence could each follow as separate PRs. Happy to review each slice quickly.

e) Conflict with #38 in tests/test_no_hardcoded_hosts.py. 4b49227 deletes the tools/nmos_node.py allowlist line, and #38 rewrites that ALLOW block. #38 can't drop the line on its own, because master's nmos_node.py still has the old default and #38's CI would fail. So the rule is: whichever of #37/#38 merges second drops the tools/nmos_node.py entry while resolving the conflict. I've noted the same on #38.

f) DCO. #39 adds a DCO check (.github/workflows/dco.yml, scripts/check-dco.sh). Once it lands, every PR commit needs a Signed-off-by matching the author email. Please use git commit -s from now on. Existing commits here can be fixed later with git rebase --signoff origin/master when you next rebase. No need to rewrite now.

Thanks. The restamp grid fix plus its probe-driving regression tests is solid work.

@guycochran

Copy link
Copy Markdown
Owner Author

Follow-up from Newcastle: f89d3e6 landed about 25 seconds before my review comment above, so that review was against 4b49227. I've now gone through f89d3e6 too.

Looks good

  • ingress-soak.sh: a null err_ms no longer counts as 0. Lock-only windows and frozen records now report STALE. Replacing the relock-churn clause (it could never fire) with a drop-rate check is the right signal.
  • Removing the dead SLEW_* / HARD_RELOCK_NS constants, with a guard test, plus the docstring rewrite, makes the grid design much clearer.

Small findings

  1. STALE becomes FAIL at the top level. soak_one returns 2 for STALE, but the caller does soak_one "$rec" || rc=1, so a STALE window still prints SOAK FAIL and exits 1. Suggestion: soak_one "$rec" || { r=$?; if [ "$r" -eq 2 ]; then stale=1; else rc=1; fi; }. Then print SOAK STALE and exit 2 when nothing failed but something was stale.
  2. ADR convergence ticks overstate items (a)/(b). Step 4 "✅ Domain UUID advertised": bring-up and quickstart still write {"id":"domain_1"}, so in practice the node advertises domain_1, not a UUID. Step 1 "✅ carry flow identity": the id is advertised and staged, but activation still ignores it. Suggest marking both as partial until (a) and (b) land.
  3. Minor: dropped and mono_ns aren't checked with is_int the way frames is. A record with no mono_ns would always read STALE (both values are 0). The writer always sets both today, so the risk is low.

Still open from the earlier comment: (a) flow-id routing and (b) the domain UUID. nmos_node.py is unchanged in f89d3e6. (c) ADR front matter is also unchanged: Date, Status, and the FileCopyrightText line. (d) splitting the PR, (e) the #38 allowlist rule, and (f) Signed-off-by (f89d3e6 has none) also still apply.

guycochran and others added 5 commits October 8, 2026 04:12
…tall) + real relock test

Re-reviewing the lock-branch rewrite surfaced a genuine regression the HW run couldn't hit:
the stale test_monotonic_relock_* REPLICATED the old (now-deleted) offset-clamp inline and
never called _restamp, so it passed while asserting nothing about the real code. Rewrote it to
DRIVE _restamp through a reconnect (flow already 10s long, fresh clock 0.5s in) — and it caught
a bug:

The grid's drop-guard `if nxt > target + grain_ns` conflated two cases. A persistent-flow leg
rebuild leaves last_mapped ~10s ahead of a just-reset wall clock; the guard then DROPPED every
frame until `now` crawled up to 10s — a multi-second program stall on every reconnect. (The old
relock offset-clamp handled this; the grid rewrite dropped the handling.)

Fix: distinguish a GROSS discontinuity (flow > RELOCK_GAP_NS=2s ahead of wall-clock → a
reconnect/clock-step) from a jitter-buffer BURST (sub-second ahead). On a gross gap, RE-ANCHOR
the grid (continue one grain past last_mapped — monotonic, resumes immediately) instead of
drop-guarding. Bursts still drop to stay real-time. Verified across all three regimes
(0.5s-ahead burst→DROP, 10s reconnect→reanchor, steady→emit).

test_restamp_relock_never_rewinds_the_flow now drives the real probe and asserts no rewind +
no stall. 153 py + 36 node green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
… loop)

HW Oct-8 (local box, reproduced with a LIVE camera's AAC through the A/V fan-out): the audio
core anchored then immediately -5'd ("streaming stopped, reason error (-5)") in a restart loop.

Root cause: _restamp_audio advances next_pts by buf.duration per buffer; on bursty fan-out
delivery next_pts races ahead of wall-clock, then the >500ms re-anchor set next_pts BACK to
now+MARGIN unconditionally — a REWIND → non-monotonic PTS into mxlsink (no backward-index
guard) → -5. (Same class as the video bug, in the one restamp path the grid rewrite hadn't
touched.) Confirmed the old logic rewinds in a frozen-clock-burst-then-realtime sim.

Fix: the re-anchor floors at last_mapped + one audio quantum (buf.duration, else FRAME_NS), so
it can only ever nudge FORWARD — never rewind the flow. Mirrors the video grid's monotonic
guarantee. Unified the relock/re-anchor log wording.

Added test_restamp_audio_pts_strictly_monotonic_through_burst_and_reanchor: drives
_restamp_audio through a 60-frame frozen-clock burst + realtime resume and asserts PTS never
decreases (the exact invariant the -5 violated; old logic fails it). 154 py + 36 node green.
HW re-verify on the live camera next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
… (fix audio -5)

HW Oct-8 (local box, live camera): guest1-audio -5-looped and its leg (:9090) never bound,
while the video leg was fine. Root-caused to the A/V fan-out's tee: both legs carry the FULL
~13Mbit/s TS, and with PLAIN bounded queues a momentary SRT-send backup on one srtsink
backpressures the tee and SRT-errors the OTHER leg ("Error on SRT socket (0)"). The audio leg
lost the race. (Only guest1-audio -5'd — the one with a live contributor; guest2-audio idle=0,
guest1 video=0. The audio pipeline binds + PLAYs fine in isolation, proving it's the fan-out
tee, not the core.)

Fix: leaky=downstream on both tee branches (each drops its own oldest TS rather than stalling
the tee) + tee allow-not-linked (a torn-down leg during a core restart doesn't kill the fan-out).
Mirrors the decoupling already used inside the cores. HW re-verify next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
… front door hero 'from a $400 box to the cloud' + $400 stat tile

Public-facing content only (docs/ syncs to the served public/). No VM, no secrets, no box-specific IDs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
…raming from the EFA changelog

Security/privacy review (Oct 9): the changelog named a person + their employer in the context
of private correspondence about our EFA finding. Reworded to state the technical cause
(Nitro v3 vs v4) without attributing it to a named individual/employer. The open-source
mxl-fabrics-proxy credit to Jonas Ohland elsewhere (crediting public OSS work) is retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwSKEZZz4yEc2CGq5hcTmT
@guycochran

Copy link
Copy Markdown
Owner Author

Status: held (needs-work) before merge. Done: dropped the '(Riedel)' employer tag + private-correction framing from the EFA changelog entry (kept the open-source mxl-fabrics-proxy credit to Jonas Ohland). Still open per your notes — NMOS routing ignores the flow ID; domain ID isn't a proper ID; ADR over-claims both as done; soak check reports a stale result as a failure; PR ~1,600 lines. We'll address these on this branch before it merges (after #38/#40). Merge position #3.

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.

1 participant