Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/adr/020-stage-a-a1-depth-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,16 @@ Everything that records an `a` records which source produced it:

`measured_a` keeps its historical meaning — a number the photodiode actually
measured — so an open-loop run simply carries no `measured_a`, rather than
carrying a commanded value under that name. A `q_p(a, f)` fit that pools the two
carrying a commanded value under that name. The whole `[optical]` block follows
it: an open-loop run measured none of those quantities, so the section is empty
rather than absent-with-excuses, and **the sidecar is still written**. This is
the point at which the implementation had drifted from the decision — the
sidecar refused any run without a fresh optical summary "from a confirmed
`I_tot` anchor", regardless of source, which made the commanded source unable to
save a single recording on the very bench it exists for. A forty-point protocol
drove every one of its points and threw them all away at metadata-save time.
Whether a run *can* be described is now asked before the camera starts, not
after it stops. A `q_p(a, f)` fit that pools the two
sources without looking at `depth_a_source` would be pooling two different error
budgets; the field is there so that cannot happen silently.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,37 @@ client's — to decide how much of it to grant.
- A lease A1 loses anyway (owner restart, an operator disconnect) is not
papered over. The heartbeat goes quiet because the owner's snapshot no longer
names A1 as the holder, and the runner's own retarget reports the real
failure in its own words.
failure in its own words. See *Amendment* below for what "reports" turned out
to have to mean for a protocol.
- Renewal replies are not routed to any runner. An unmatched `request_id`
already falls through `on_service_reply` untouched, so a heartbeat cannot
be mistaken for a point's retarget outcome.
- The owners were left alone. Raising `MAX_LEASE_TTL_MS` to survey length would
have fixed the symptom by deleting the safety property that motivated it.

## Amendment (2026-08-10) — a protocol re-takes a lease it lost

"Report the real failure in its own words" was the right instinct and the wrong
end state for an unattended run. A rejected retarget is a *per-point* failure:
the point is skipped, the run steps to the next one, and that one is rejected
identically, because nothing about advancing the index gives the lease back. One
expiry mid-survey therefore cost every row after it — a forty-point protocol
reporting `3/40 recorded — 37 skipped (… the modulation owner requires an active
automation lease)` after an hour on the bench.

A protocol that is rejected with `LeaseRequired` / `LeaseExpired` /
`LeaseMismatch` now re-acquires **the same lease id** — so it is that lease
continuing rather than a second one — announces it on the status line, and
repeats the point from the top, restating all three axes. Bounded to one retry
per point: a lease the owner will not give back still ends as a named skip
rather than a spin, and a lease held by *somebody else* (`LeaseBusy`) is never
taken from them.

This does not weaken the dead-man switch, and it does not hide the fault: the
loss is stated when it happens, and the survey's own report still names it if
the retry fails. What changed is that a recoverable interruption no longer
costs the bench time of every point that follows it.

## Also fixed here

`on_discontinuity` asked `recording.is_active() || sweep.is_some()` to decide
Expand Down
25 changes: 24 additions & 1 deletion docs/features/stage-a-a1.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,16 @@ quantitative A1 sidecar without a fresh photodiode optical summary`, and a
`Camera: … no trigger signal` line that looked exactly like an unplugged
`EXT_TRIGGER` cable but was the drive being off.

**A lease lost anyway is taken again, once per point** (ADR 029). The heartbeat
can only renew what the owner still advertises, so a lease that ends behind its
back — an expiry the owner decided during a long point, an owner that restarted
— left every remaining retarget rejected with `the modulation owner requires an
active automation lease`. Reporting that per point meant one expiry cost the
whole rest of the survey. A protocol now re-acquires its lease (same lease id,
so it is the same lease continuing), says so on the status line, and repeats the
point from the top. One retry per point: a lease the owner will not give back
still ends as a named skip rather than a spin.

**Naming.** Files share an `<id>_<timestamp>[_role][_point]` stem under an `<id>/`
subfolder:

Expand Down Expand Up @@ -453,7 +463,10 @@ internal `u_g`/`u_c`, requested `a`, `V_null` and `V_peak`); the depth this run
was driven and judged by with its provenance (`depth_a`, `depth_a_source`); the
photodiode-measured `a`, extrema, geometric pedestal,
headroom, clip fractions, ADC id, and the learned `I_tot` anchor with its
provenance (ADR 024); ROI +
provenance (ADR 024) — the whole `[optical]` block being present only for a
**photodiode-sourced** run, since an open-loop one measured none of it and
records no `measured_a` rather than a commanded value under that name (ADR 020);
ROI +
masked-pixel count + `N_valid`; the `[sensor]` bench conditions (die temperature,
pixel dead time, illumination — ADR 022);
trigger info (marker-anchored, marker count,
Expand All @@ -463,6 +476,16 @@ additionally records the frozen ON/OFF windows and the **background** run the
floor `q0`, so returning to a measurement (folder + id) auto-reloads them for
the `q_p` plot.

**What the sidecar needs is checked before the camera rolls, not after.** A
photodiode-sourced run cannot be described without a fresh optical summary from
a confirmed `I_tot` anchor, and that used to be discovered by `write_sidecar` —
i.e. once the recording had already run its full duration, and, in a protocol,
once every row of the survey had. The same question is now asked in
`begin_recording` (the row is refused before the camera and the PDQ start) and
in `begin_protocol` (the whole survey is refused at the press), phrased as the
photodiode owner's own reason plus the way past it — switch **Depth `a` source**
to the commanded drive and work open loop.

**Mechanism.** A small control-plane state machine in `process_control` starts
the host camera recorder first and waits for its receipt. Only after the host
has completed the Preview → Recording switch does A1 connect and lease the
Expand Down
Loading
Loading