Skip to content

feat(stage-a-a1): apply camera state through generic host sessions - #11

Draft
muthmann wants to merge 86 commits into
ci/prebuilt-plugin-bundlesfrom
agent/stage-a1-camera-configuration
Draft

muthmann wants to merge 86 commits into
ci/prebuilt-plugin-bundlesfrom
agent/stage-a1-camera-configuration

Conversation

@muthmann

@muthmann muthmann commented Aug 12, 2026

Copy link
Copy Markdown
Owner

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.

  • A1 selects a named host profile, an immutable inline snapshot, or the current complete configuration.
  • For diff_on/diff_off, A1 clones the last host-confirmed complete snapshot and changes only those protocol fields.
  • The host validates the complete configuration through the active camera backend, applies it, and returns only after a fresh generation-bound readback.
  • A1 uses that confirmed reply as the authority. It does not inspect the previous UI state or wait for another user Apply action.
  • A named profile may enable Sensor reading in the same atomic apply.
  • A1 owns only its scientific gates: the confirmed snapshot must enable sensor telemetry and disable STC/Trail.
  • Recording starts only after the drive retargets and camera confirmation have both completed. Success, Stop, and abort restore the pre-run complete configuration.

What changed

  • let A1 protocols select a named host camera profile or immutable inline snapshot
  • add canonical per-point diff_on and diff_off fields for TOML and CSV
  • apply initial and per-point changes through the generic complete-snapshot command
  • remove the stale pre-apply dependency on the UI/context Sensor reading state
  • leave sensor-specific bias limits with the active camera backend instead of duplicating IMX636 ranges in A1
  • fail closed on rejected/missing readback or a confirmed scientifically incompatible snapshot
  • retry restore up to three times and never report an unconfirmed restore as successful
  • store requested values, confirmed offsets, absolute codes, readback age, immutable snapshot, and profile provenance
  • retain backward compatibility for protocols without camera fields
  • centralize the firmware-qualified 0.01–2000 Hz drive range
  • separate the 2 kHz output limit from A1's 16-photodiode-samples-per-cycle measurement gate

Naming

The canonical camera/backend fields are diff_on and diff_off. No ambiguous bias_on or bias_off aliases are introduced.

Validation

  • A1: 126 tests passed
  • A1 clippy with -D warnings: passed
  • all applicable plugin workspace tests: passed
  • AugurRS standalone GUI and CLI checks without default features: passed
  • AugurRS PR #44 CI and Packaging: passed
  • plugin bundle CI: Linux, macOS arm64, and Windows passed; macOS x86_64 is queued for a runner

The 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.

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
@muthmann muthmann changed the title feat(stage-a-a1): apply confirmed camera profiles and bias points feat(stage-a-a1): apply camera state through generic host sessions Aug 12, 2026
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
muthmann force-pushed the agent/stage-a1-camera-configuration branch from e490051 to 1b663e9 Compare August 13, 2026 15:35
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.
…ime crate

A5 depended on the A2 plugin crate with its entrypoint feature disabled.
Building A5 recompiled A2 without the vtable and Cargo uplifted that copy
over the standalone A2 library, so the installed Windows DLL had no
augur_plugin_vtable. Move protocol, resume and runtime to
stage-a-step-acquisition; A2 and A5 each wrap it and export one vtable.
Drop the feature flag, the build-script special case and the .def
workaround. ADR 050.
… campaign

The runner used the default on_discontinuity, which calls reset and thereby
stops the campaign. The host raises SettingsChanged on every settings sync
of any plugin (the operator's own Continue press included) and SourceChanged
when an owner starts or stops the recorder, so the campaign ended before
the first block could dispatch. Ignore discontinuities; only Stop ends a
run. Also keep the optical-state confirmation when the re-applied snapshot
carries an unchanged AOD value.
A missing optical confirmation at an unchanged state (re-typed AOD value,
resumed campaign, closing block after the cutoff) parked the campaign in a
wait that Continue could never satisfy, because no reference was requested.
Every operator wait now goes through the A4 reference flow, and blocks
without an optical state need no confirmation.

A refused or failed reference kept its ID, so the campaign could not be
stopped: the "released" snapshot the stop waited for never comes. Drop the
reference on those outcomes and keep the error.

Continue no longer starts a new campaign when none is running.
… AOD text

The AOD field is provenance only; the camera-lux and photodiode readbacks
are the measured state. An empty field refused Continue and held up the
bench. Record the readback in its place and say so in the status.
…s syncs

A universal hand-off sets the owner's folder, measurement ID and protocol
on the live worker only. The host re-applies the UI mirror's full settings
snapshot on every sync, so a sync between the hand-off and the run (A2, A4)
or at any time during the block (A1, which had no guard at all) replaced
them with the mirror's values: wrong protocol, wrong folder, wrong ID under
the campaign's name. Refuse those keys while a hand-off is pending or a
protocol runs, unchanged values excepted.

Untimed programs now hold the A4 optical reference for four hours instead
of fifteen minutes; the expiry ended the campaign with a cleanup error.
The B3-B5 candidates asked for fo +55, the top of the IMX636 range. On the
bench the sensor reads back code 140 for the requested 142 (factory 87 +
55), the host refuses the unconfirmed point, and every block at that
candidate fails after three attempts. 140 is a code the sensor demonstrably
holds, so request +53. One constant in the generator and the candidate
table; the smoke and bias-selection plans are regenerated.
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