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
33 changes: 33 additions & 0 deletions .agents/skills/protocol-yaml/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,39 @@ changes may tune a trial, never restructure it. Web-only; MATLAB ignores `runtim
to run it** until the web runner implements that capability; MATLAB may run it. Leave it out
otherwise.

### `controller:` — sticky controller settings the run must assert (Studio v0.93)

The controller keeps a few **sticky** settings across runs (panel display mode, refresh rate,
Digital IO roles…). Wiring belongs in the rig YAML; what the *experiment* needs goes in a
top-level `controller:` block, and the Studio asserts it before every run (GET → SET → GET,
refuse on mismatch) and writes the before/after snapshot into the run header. Design:
`docs/development/controller-settings-strategy.md`; user doc: `docs/development/controller-settings.md`.

```yaml
rig: "./configs/rigs/bergamo_g6_2x10_2p.yaml"
requires: [controller_block] # a runner without this feature REFUSES the file instead of ignoring the block
controller:
panel_mode: triggered # 0 oneshot | 1 persistent | 2 triggered | 3 gated (names or numbers)
refresh_policy: line_sync_safe # cap the re-stream rate at the rig's limits.max_refresh_hz — OR refresh_hz: <int>
panel_firmware: "2p" # SD footer version (or prefix) the panel image must match; "2p" = the line-sync build
```

- **Most protocols do not need the block.** A course protocol inherits the rig's
`defaults.panel_mode` (persistent); the Studio asserts that too. Only declare a mode when the
experiment depends on it (imaging: `triggered`).
- **A protocol with a `controller:` block is strict**: rig mismatch (its `rig:` vs the session
rig), an unreadable setting, or the wrong panel firmware **blocks the run** (green and blue
buttons). On rigs with `strict: true` in the rig YAML this holds for every protocol.
- `refresh_hz` and `refresh_policy` are mutually exclusive. Prefer the policy: the refresh a
pattern needs follows its grayscale (GS2 streams at 1000 Hz by default, GS16 at 300), so the
Studio derives it from the referenced patterns and the rig cap; a bare number is for refresh
experiments only and must not exceed the rig's `limits.max_refresh_hz`.
- Panel firmware is checked against the SD footer (what image was uploaded) **and** the
session's fleet verify (which panels actually run it). Unverified panels only warn; a failed
verify blocks. Settings → Controller → *Verify panels* runs the fleet check (~1 s per panel).
- The validator prints the block and cross-checks it against the rig file when that is in the repo.
- MATLAB does not implement the block yet; the `requires` token is what makes it refuse.

## Plugins

`plugins:` normally comes from the rig (the Studio pre-fills New protocols from the rig
Expand Down
40 changes: 40 additions & 0 deletions .agents/skills/protocol-yaml/scripts/validate-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,46 @@ if (Array.isArray(exp.requires) && exp.requires.length) {
console.log('ℹ requires: [' + exp.requires.join(', ') + ']' +
(missing.length ? ' — the WEB runner refuses to run this (missing: ' + missing.join(', ') + ')' : ''));
}
// ── controller: block (sticky controller settings asserted before the run) ──────
// docs/development/controller-settings-strategy.md. Malformed values are already in
// `blocking` (collectBlockingErrors); here: what it declares, the capability token, and a
// cross-check against the rig file when it is readable from the repo root.
const cb = exp.controller;
if (cb && cb.declared) {
const parts = [];
if (cb.panel_mode != null) parts.push('panel_mode ' + cb.panel_mode + ' (' + ['oneshot', 'persistent', 'triggered', 'gated'][cb.panel_mode] + ')');
if (cb.refresh_hz != null) parts.push('refresh_hz ' + cb.refresh_hz);
if (cb.refresh_policy) parts.push('refresh_policy ' + cb.refresh_policy);
if (cb.panel_firmware) parts.push('panel_firmware "' + cb.panel_firmware + '"');
console.log('ℹ controller: ' + (parts.join(', ') || '(empty block)') + ' — asserted by the Studio before every run, recorded in the run header');
const rigPath = String(exp.rig_path || '');
const rigFile = rigPath.startsWith('./') || rigPath.startsWith('configs/') ? resolve(rigPath) : null;
if (rigFile && existsSync(rigFile)) {
try {
const { createRequire } = await import('node:module');
const Registry = createRequire(import.meta.url)(resolve('js/plugin-registry.js'));
const rig = Registry.parseRigIo(v3.parseRigYAMLText(readFileSync(rigFile, 'utf8')));
const notes = [];
if (rig.defaults.panel_mode != null && cb.panel_mode != null && rig.defaults.panel_mode !== cb.panel_mode) {
notes.push('protocol panel_mode ' + cb.panel_mode + ' overrides the rig default ' + rig.defaults.panel_mode + ' (allowed; the run asserts the protocol\'s value)');
}
if (cb.refresh_hz != null && rig.limits.max_refresh_hz != null && cb.refresh_hz > rig.limits.max_refresh_hz) {
console.error('✗ BLOCKING: controller.refresh_hz ' + cb.refresh_hz + ' exceeds the rig limit max_refresh_hz ' + rig.limits.max_refresh_hz + ' (' + rigPath + ')');
process.exitCode = 1;
}
if (cb.refresh_policy === 'line_sync_safe' && rig.limits.max_refresh_hz == null) {
console.warn('⚠ controller: refresh_policy line_sync_safe but the rig ' + rigPath + ' declares no limits.max_refresh_hz — nothing to cap at');
}
if (rig.requires.panel_firmware && cb.panel_firmware && rig.requires.panel_firmware !== cb.panel_firmware) {
notes.push('protocol requires panel firmware "' + cb.panel_firmware + '", rig requires "' + rig.requires.panel_firmware + '" — both are checked');
}
for (const w of rig.warnings || []) console.warn('⚠ rig ' + rigPath + ': ' + w);
for (const n of notes) console.log('ℹ ' + n);
console.log('ℹ rig ' + rigPath + ': defaults.panel_mode ' + rig.defaults.panel_mode + ', limits.max_refresh_hz ' + rig.limits.max_refresh_hz +
', requires.panel_firmware ' + JSON.stringify(rig.requires.panel_firmware) + ', strict ' + rig.strict);
} catch (e) { console.warn('⚠ rig ' + rigPath + ' could not be cross-checked: ' + (e && e.message ? e.message : e)); }
}
}
const rcNames = Object.keys(exp.runtime_controls || {});
if (rcNames.length) {
const { createRequire } = await import('node:module');
Expand Down
Loading
Loading