feat(stage-a-a1): apply camera state through generic host sessions - #11
Draft
muthmann wants to merge 30 commits into
Draft
feat(stage-a-a1): apply camera state through generic host sessions#11muthmann wants to merge 30 commits into
muthmann wants to merge 30 commits into
Conversation
Both device owners cap the automation lease TTL they hand out at 60 s — a dead-man switch, and correct — but the clamp is silent: the request comes back `Applied`, so A1 believed it held the drive for a whole survey when it held it for a minute. Renewing once per point was therefore only ever right for points shorter than the cap. The shipped example protocol has a 40 s row, every row also pays the camera and photodiode handshakes, and `acquire_photodiode` asks for `duration_s + 60 s`, so any longer recording outlived its own leases. Past the deadline the modulation owner does what an expired lease must do — STOP, output off — and that single event surfaced as three unrelated-looking faults at once: the owner refusing every later command for want of a lease, the sidecar refusing to write because the photodiode had no fresh optical summary, and a "no trigger signal" line that read exactly like an unplugged EXT_TRIGGER cable but was the drive being off. A1 now heartbeats both leases against `expires_at_unix_ms` from the owner's own snapshot — which both owners already published and A1 never read — renewing once less than 20 s of the granted window is left, no more often than every 2 s. The owners' cap is untouched: raising it to survey length would fix the symptom by deleting the safety property that motivated it. Also fixes `on_discontinuity` asking `recording.is_active() || sweep.is_some()` to decide whether a SourceChanged was self-inflicted. Starting and stopping the host recorder raises it twice per recording, and between two points of a protocol or a ladder neither is true — so the run's own boundary was treated as an idle-time reset and wiped the survey's pilot windows, background floor and response curve mid-run. It now asks `automation_active()`: the same set `request_stop` winds down. Refs ADR 029.
The compacted `<stem>.sensor.json` depends on a companion CSV the host writes only when its own "Record sensor monitoring" switch is on. A1 cannot set that switch and cannot query it, so an absent readout was indistinguishable from a camera with no monitoring block — and the sidecar field's own doc comment said exactly that, which is what made the absence so hard to trace. A finished run that produced no readout now says so in the panel and names the switch, instead of leaving a survey to discover months later that it kept none of its bench conditions. The single-point readings in `[sensor]` ride the context bus and are unaffected either way.
Installing a plugin required a Rust toolchain, a sibling augur-rs checkout and a working cargo, which made every measurement PC a development machine. Build all runtime plugins on each pull request and each push to main for macOS arm64/x86_64, Linux x86_64 and Windows x86_64, staged in the exact layout ~/.augur/plugins/ expects, so installing is a copy. main also publishes a rolling plugins-latest release, because workflow artifacts need a login and expire after 90 days while a bench should be able to curl a URL. The workspace depends on the host by path, so the job lays out two sibling checkouts. build-runtime-plugins.sh patches a git source whenever it finds a sibling augur-rs checkout; with path deps that patch matches nothing but still costs a fetch, so the checkout's .git is dropped right after cloning. CI calls the repository's own build and install scripts instead of restating the install layout in YAML — those scripts already own plugin discovery, library naming, A1's protocols folder and the macOS install-name rewrite. Plugins are dlopened into the host process, so pin rust-toolchain.toml to the same 1.95.0 augur-rs pins and read the channel out of that file rather than naming a version in the workflow. Every bundle carries a BUILD-INFO.txt with the augur-rs revision and rustc version behind it, which is what makes an ABI mismatch reported from the bench answerable.
Two defects the first run exposed, both independent of the plugin sources. The Linux job borrowed augur-rs/.github/scripts/install-linux-deps.sh from the host checkout to avoid keeping a second dependency list. That script does not exist on every augur-rs revision the job can be pointed at, so the Linux build failed on the value of augur_rs_ref rather than on anything in this repository. It was also a superset: it installs the X11/Wayland/GL stack for the GUI, which no plugin crate links. Install what the plugins actually need instead — pkg-config and libudev-dev for serialport. setup-rust-toolchain injects RUSTFLAGS="-D warnings" by default. That is right for a lint job and wrong for one that ships artifacts: a dead-code warning in one plugin would have denied the bench a bundle for all of them. Lint gating belongs in its own job. Also record what the run proved about the repository itself: these plugins do not compile against augur-rs main, which lacks the TableSchema, host-view and dataset-descriptor API they use.
augur-rs main still has a two-field TableSchema and no Scatter3dFromTable, HostDatasetDescriptor.relations/display or HostViewRegistry.actions, all of which the plugins in this repository already use. Defaulting AUGUR_RS_REF to main is therefore a guaranteed red build that never hands the bench a bundle. Default to the open host branch that does carry the API instead, and record the coupling in the brief and the ADR. BUILD-INFO.txt already names the exact host ref and SHA behind every library, so this stays visible rather than becoming folklore. Move the default back to main in the same commit that the host API lands there.
The eveSMLM chain expressed its stage dependencies directly: fitting depended on the candidates crate, post-processing on the fitting crate. Plugin crates are cdylibs that each export augur_plugin_vtable, so linking one plugin's rlib into another pulled that symbol in twice. Apple's linker tolerates the duplicate. rust-lld and MSVC's link.exe do not: rust-lld: error: duplicate symbol: augur_plugin_vtable LNK2005: augur_plugin_vtable already defined … fatal error LNK1169 That went unnoticed for as long as the only build machine was a Mac. The first CI run on four platforms found it: macOS produced a complete bundle while Linux and Windows failed to link, which also denied the bench a Windows bundle for the Stage-A plugins, since the build is all-or-nothing. Move everything that crosses a stage boundary into evesmlm-types, a plain library crate that exports no vtable — the wire contract plus the current-localization dataset and registry builders that both fitting and post-processing publish. Plugin-private state stays with its plugin: the candidate tracker's TrackedCluster moves back into the candidates crate. Each plugin still re-exports the names it used to own, so downstream use paths keep compiling. This generalizes what stage-a-plugin-contract already does for the Stage-A owners, and replaces the repo convention that shared types belong in the producing plugin's crate.
Port discovery filtered candidates by the two Unix name patterns (`cu.usbmodem`, `ttyACM`) before probing. Windows names no device — every port is `COMn` — so an attached, correctly driven Teensy was filtered out before any probe could run, and both plugins reported "no USB serial device found (looked for usbmodem/ttyACM)": the two things Windows cannot produce. Move the filter into `stage-a-io::transport::candidate_ports()`, where it is platform-aware: the callout node on macOS, `ttyACM*` on Linux, and every USB-classified port on Windows, falling back to the whole list when the OS classifies nothing. What identifies the device is still the probe (HELLO on the command port, PDA1 frames on the stream port); the filter only keeps probes off unrelated ports. Also open every port with DTR asserted. macOS and Linux do this implicitly, Windows does not, so a sketch gating on `if (Serial)` would stay silent even once the right port was found. The filter existed in four places in two implementations; it is now one function with unit tests covering both platform branches, and the failure message names the ports the OS actually enumerated. Refs ADR 032
Excel's "CSV UTF-8" — the obvious save format on a Windows bench — writes a UTF-8 byte-order mark. Unstripped it becomes part of the first header cell, so `mean_u` stops matching `mean_u` and the protocol is refused for missing a required column that is plainly there. The TOML form fails its parse outright. Neither message points at an invisible character. Strip the BOM once for both readers. CRLF was already handled by `str::lines()`; it now has a test so it stays that way. Refs ADR 027
The contract gains the A2 step-latency acquisition configuration and its `PrepareA2` command, so the modulation owner can put the firmware into mode A2 without A2 reaching past it. The photodiode side gains an explicit detector placement. Until now the rejected-port complement was the only geometry the estimator knew, and it is the one geometry in which a DC dark level cancels. A direct sample of the camera or emission path does not cancel it, so those placements carry a captured lamp-off dark reference and refuse `a` until one exists. The splitter fraction travels as provenance only: a constant fraction cancels out of a log contrast and must not be used to rescale it.
The modulation owner is the only crate that speaks to the command port, so putting the board into A2 mode belongs here rather than in A2 itself. The shipped A1 laboratory protocols are parsed as fixtures and checked against the coupled calibrated-drive limits, so a protocol that asks for a `ū`/`a` pair the drive cannot reach is caught here instead of at the bench.
The detector could only be read in the PBS rejected port, where a DC dark level cancels out of the complement. A direct sample of the camera or emission path has no complement to cancel against, so those placements need a real lamp-off reference and refuse `a` until one is captured. The dark value is activated by an explicit button, not by the numeric field alone: a settings replay from the UI mirror would otherwise be able to overwrite a captured reference with a stale draft. Placement is the scientific geometry and is kept separate from the RAW/EXCITATION chart mode, which is only a display choice. The shipped A1 laboratory protocols are parsed as fixtures and checked against the production ring at the bench sample rate, so a sub-hertz rung that could not retain two whole cycles is caught here.
A1 copied the host's camera snapshot, readback, ROI, mask and bias codes into its own sidecar. The copies were not independent measurements and could disagree with the host TOML sitting next to the same RAW, so the v2 sidecar cross-references that file instead of duplicating it and keeps only the conditions that actually move during a run. What the sidecar could not say before, it now says. `[protocol]` names the schedule that produced the point — name, author version, source file, SHA-256 and row identity — and the source is archived once per content hash into the measurement folder, because a path on the operator's disk is not durable provenance. `[depth]` separates the commanded, measured and analysed `a` instead of overlapping names for the three. `[photodiode]` records the detector geometry. The complement model is only correct in the rejected port; applying it to a direct camera or emission-path sample is a scientific error, so the placement travels with every point rather than being assumed. The eight laboratory protocols now in use ship as fixtures. Refs ADR 039.
A2 measures how long the fluorescence chain takes to answer a step. It owns no hardware: the Teensy belongs to the modulation owner and the detector to the photodiode owner, so A2 drives both over the service plane and never opens a port itself. The protocol file is the aggregate root. Optical configuration, the qualified hardware gates, controller settings and the ordered recording rows are validated together before anything happens, because a bring-up file that is half filled in is useful to read and must not be runnable — an explicit TBD gate refuses preflight rather than silently defaulting. Dark rows and stepped rows are separate acquisition types: a dark row forces the modulation safe and carries no trigger-count gate, so a missing trigger cannot be reported as a dark measurement. The plugin records acquisition provenance and live integrity evidence only. Censored first-event fits stay offline, where the model can be chosen with the data in hand. Refs ADR 038.
A4 walks a protocol of diff_on/diff_off pairs at one fixed optical condition and records a RAW file at each, with enough provenance to read an event rate against a threshold setting months later. Every point is confirmed against the sensor's own bias readback before it records. A point whose absolute codes disagree with the row, or whose confirming reading is missing or older than the change, is skipped: a threshold file that is wrong about which codes were live on the die is wrong in a way nobody can detect afterwards. Refusals and flags are split on purpose. The event filters being off, the codes being confirmed and the file being whole are gates, because without them the number means nothing. Temperature drift, illumination drift and event rate are flags that mark a point and keep it — whether a 2 °C drift invalidated a threshold is a judgement to make later with the file in hand, and a runner that discarded the point would have thrown away the evidence for making it. A limit whose quantity could not be measured is flagged rather than passed. The survey runs on the host's generic camera-configuration session: it opens by having the host confirm the configuration the bench is on, and builds every point by cloning that snapshot and setting exactly two fields, so fo, hpf, refr, the ROI, the mask and the trigger are carried forward unchanged. A test asserts that equality field by field. Refs ADR 035, augur-rs ADR 037.
muthmann
force-pushed
the
agent/stage-a1-camera-configuration
branch
from
August 13, 2026 15:35
e490051 to
1b663e9
Compare
The bench time a survey still needs was announced once, in the transient message, and the first point's own message overwrote it a second later. An operator who looked away had no way to get it back. It belongs on the protocol's own status line, where it stays: the points still to run, settling included, counting the point in flight whole. `total_seconds` becomes `remaining_seconds(0)`, and one shared formatter gives both the opening message and the status line the same units.
…the point A 297-point protocol recorded 38 points and skipped over 250, each with the same host rejection: the camera could not be opened before a transport timeout. Every rejection cost a measurement point, and the run kept going to the end of the file, so a bench run could complete with almost no data. Retry the same point after 1, 2 and 4 s when the rejection proves that no recording had started — the host refused in `recording_start_failed` while the camera was still being started, and no RAW path exists. Any other outcome stays unretryable, because a partly written recording must not be repeated silently. When the retries are used up, the run ends in a controlled way at that point instead of skipping ahead. A restoration that stays unconfirmed no longer drops the run state: the camera-configuration ownership is kept, the failure stays on screen and Stop retries the restoration. The drive lease is released while the run waits for that recovery, so a failed camera does not hold the modulation owner as well.
muthmann
force-pushed
the
agent/stage-a1-camera-configuration
branch
from
September 7, 2026 13:27
80c1675 to
71242a4
Compare
…t anchor A protocol run stopped at point 12 because A1 could not write a quantitative sidecar: the photodiode sampled a direct path with no lamp-off dark reference. That gate is static — it depends on the placement and its dark provenance, not on the drive — so it would have failed identically at every one of the remaining points, and eleven recordings had already been taken under it. Check it at the button press, next to the range, window and sample-rate checks, and refuse the whole protocol in the photodiode owner's own words, naming the open-loop way out. The estimator's other gates need a running drive and stay per-point. The abort message no longer counts camera start retries when there were none. Naming retries that never happened sent the operator to the wrong subsystem: most of these are a refused sidecar, not the camera. Documented in ADR 043 with the failure policy the retry work introduced.
Three protocol runs stopped at the first failed recording, each for a different reason: a refused camera start, a photodiode window with no trigger markers, a recording whose files were not all finalized. Only the first was specific to one point; none of them justified ending a 297-point survey, and the previous policy of skipping every failure ran to the end of the file with almost no data. Repeat every failed point after 1, 2 and 4 s. The recording coordinator is idle by then, so a repeat cannot collide with a recording the host still holds. A point whose retries are used up is lost with the owner's own reason, and the survey goes on; three lost points in a row end the run, because at that scale the bench failed rather than the point. Every way a point can be lost counts towards the same streak, so the camera's special case and its outcome classification are gone. "not every file was finalized" now names what was missing — the camera RAW, the PDQ, the photodiode's own verdict — because that line is the whole report an unattended run leaves behind for the point it lost.
…ng it The photodiode measures `a` from windows bounded by phase-0 markers, and the controller stamps those only in `mode=A1`. In `mode=A2` the optical comparator drives the camera trigger instead and writes `source=2` markers. A live STATUS read `mode=A2 trigger_source=PD_COMPARATOR cmp_armed=1 stream_marker_drops=9306`: many camera triggers, no phase-0 markers, every point refusing its quantitative sidecar. `PrepareA1` existed in the contract and in the modulation owner, but nothing ever sent it, so A1 ran in whatever mode the controller was left in. Send it once per protocol run, with the lease and before the first point's drive commands; a refusal ends the run, because without A1 mode there is nothing to measure against. The command was unsendable as well: `CONFIG` accepts mode, rate_hz, block_samples, raw and summary and answers anything else with `SYNTAX unknown_config_field`, while the builder added wave, freq_mhz, center_dac and amplitude_dac. `A1AcquisitionConfigV1` now carries what the verb carries, the controller's bounds live in the contract crate, and the rate A1 states is the one the photodiode already streams at — CONFIG carries the rate with the mode, so any other value would reconfigure the photodiode owner's sampler behind its back. Documented in ADR 044, including the two A2 divergences it leaves open.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Dependency
Depends on muthmann/augur-rs#44. That PR supplies a generic complete camera-configuration transaction, profile store, generation-bound sensor readback, rollback, and restore. AugurRS remains a standalone recorder and profile editor with no plugins installed.
This is a stacked PR against
ci/prebuilt-plugin-bundles, the exact parent of this branch, so the committed diff contains only the current A1 work.Architecture
A1 embeds into generic host capabilities. The host has no A1 IDs, field whitelist, workflow metadata interpretation, or scientific policy.
diff_on/diff_off, A1 clones the last host-confirmed complete snapshot and changes only those protocol fields.What changed
diff_onanddiff_offfields for TOML and CSVNaming
The canonical camera/backend fields are
diff_onanddiff_off. No ambiguousbias_onorbias_offaliases are introduced.Validation
-D warnings: passedThe firmware-qualified output ceiling remains 2 kHz. At 20 kSa/s, A1's measurement limit is 1.25 kHz; at 500 kSa/s it is 31.25 kHz, while output remains limited to 2 kHz. Final EVK4 readback timing and analog photodiode bandwidth/ENOB remain bench-verification items.