Files
Aura/docs/specs/fieldtest-0006-substrate.md
T
Brummel 0bf15cb069 fieldtest: cycle-0006 sink recording — 5 examples, 6 findings
First fieldtest of the project. A standalone downstream-consumer crate
(fieldtests/cycle-0006-substrate/) path-depends on the engine crates and
exercises the post-0006 substrate from the public interface only (rustdoc +
specs + ledger, never crates/ source):

  1 custom recording node via the Node contract + Ctx::now()
  2 fan-out/fan-in DAG, 3-arg bootstrap, run() -> ()
  3 two interior streams recorded from one run (the cycle headline)
  4 byte-identical recorded output across two runs (C1 determinism)
  5 mis-wired recording edge rejected (KindMismatch) at bootstrap

Findings: 3 working (carry-on), 2 friction, 1 spec_gap.
  - friction: a nested standalone consumer crate fights the workspace
    resolver (needs an empty [workspace] table — Cargo's own hint).
  - friction: recorder boilerplate is hand-rewritten per author (C9
    deliberately ships no library sink).
  - spec_gap: the public surface never states output: vec![] is THE sink
    declaration, nor defines a Some return paired with empty output.

Spec at docs/specs/fieldtest-0006-substrate.md feeds the 0007 plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:36:50 +02:00

9.5 KiB

Fieldtest — cycle-0006 (sink recording) — 2026-06-04

Status: Draft — awaiting orchestrator triage Author: fieldtester (dispatched by fieldtest skill)

Scope

Cycle 0006 settled "a sink is a role, not a type": recording is done by an ordinary node in its eval, pushing a record to a destination it holds as a field (channel / buffer / chart handle) — an out-of-graph side effect. Three substrate changes shipped: (1) Ctx::now() -> Timestamp (a Copy accessor so a recording node can causally stamp each record with the cycle timestamp); (2) the old single observe: usize recording affordance was removed — Harness::bootstrap dropped its 4th parameter and Harness::run returns () (retention is owned by whoever holds the recording node's destination's read end); (3) the run loop is otherwise an unchanged topological router. No Sink type, no SinkHandler trait, no engine sink registry. The fieldtest exercised this from a standalone downstream consumer crate (fieldtests/cycle-0006-substrate/) that path-depends on the engine crates and uses only the public surface (rustdoc + specs + ledger).

Examples

fieldtests/cycle-0006-substrate/c0006_1_custom_node_now.rs — custom recording node via the Node contract

  • Authors a Recorder node from scratch: schema() declares one f64 input + output: vec![] (pure consumer); eval reads the typed window, calls ctx.now(), sends (now, value) out of graph, returns None. Wired as source -> SMA(2) -> Recorder.
  • Fits the "author a custom node incl. Ctx::now()" axis.
  • Outcome: built, ran, matched expected [(20,11.0),(30,13.0),(40,15.0)] on first try.

fieldtests/cycle-0006-substrate/c0006_2_fanout_dag.rs — multi-stage fan-out / fan-in DAG, bootstrapped + run

  • source -> {SMA(2), SMA(4)} -> Sub(fast-slow) -> Recorder. Uses the post-0006 3-arg bootstrap (no observe) and run() -> (); retention via the channel.
  • Fits the "fan-out/fan-in DAG" and "bootstrap arity / run returns ()" axes.
  • Outcome: built, ran, matched [(4,2.0),(5,2.0),(6,2.0)] on first try.

fieldtests/cycle-0006-substrate/c0006_3_multi_sink_record.rs — many interior streams, one run (headline)

  • Two independent Recorder nodes tap SMA(2) and SMA(4) in one harness, draining to two channels into Vec<(Timestamp, Vec<Scalar>)> (the spec's stated drain shape). Asserts both streams correct AND of different length (sparse, timestamped, no shared cycle index).
  • Fits the "record multiple distinct interior streams of one run" axis (the cycle headline; impossible before 0006).
  • Outcome: built, ran, both streams matched (4 vs 2 records) on first try.

fieldtests/cycle-0006-substrate/c0006_4_determinism.rs — byte-identical recorded output across two runs

  • Two fresh harnesses, identical input through {SMA(2),SMA(4)} -> Sub -> Recorder; compares recorded f64::to_bits() and timestamps for true byte-identity.
  • Fits the "verify determinism (C1)" axis.
  • Outcome: built, ran, byte-identical on first try.

fieldtests/cycle-0006-substrate/c0006_5_reject_mismatch.rs — mis-wired recording edge rejected

  • An i64 producer field bound into a recorder's f64 slot; asserts bootstrap returns BootstrapError::KindMismatch { producer: I64, consumer: F64 } before any data flows. Correct behaviour here is rejection.
  • Fits the recording axis from the failure side (recording adds no new wiring hole).
  • Outcome: built, ran, rejected with the documented error on first try.

Findings

[working] Custom recording node + Ctx::now() is natural and correct

  • Examples: c0006_1, c0006_3, c0006_5.
  • What happened: implementing Node for a user type, declaring output: vec![], reading ctx.f64_in(0), calling ctx.now(), performing an mpsc side effect, and returning None all worked verbatim from the rustdoc signatures and the 0006 spec's worked example. Ctx::now() returned the present cycle's timestamp on every fired cycle; recorded streams were sparse + timestamped exactly as documented.
  • Why working: the new surface was reached for, used as designed, and correct on the first run with no surprises.
  • Recommended action: carry-on.

[working] The cycle headline — many interior streams from one run

  • Example: c0006_3.
  • What happened: two recorders tapped two different-rate indicators in one harness; drained streams were individually correct and independently lengthed (4 vs 2). The thing the cycle exists to enable (more than one recorded stream per run) works.
  • Why working: this is the acceptance evidence the spec promised, reproduced empirically by a downstream consumer.
  • Recommended action: carry-on.

[working] Post-0006 Harness API (3-arg bootstrap, run() -> ()) + determinism + rejection

  • Examples: c0006_2 (3-arg bootstrap, run returns ()), c0006_4 (byte-identical re-run), c0006_5 (KindMismatch rejection).
  • What happened: the shrunk surface is exactly as the rustdoc/ledger state; a consumer holding the channel's read end owns retention with no engine help. Determinism held bit-exactly; a mis-wired recorder edge was rejected at bootstrap.
  • Why working: the substrate shrink introduced no regression and no forbidden failure class (C1/C7 hold from the consumer's vantage).
  • Recommended action: carry-on.

[friction] Standalone consumer crate fights the workspace resolver

  • Example: all (the fixture crate's Cargo.toml).
  • What happened, verbatim: error: current package believes it's in a workspace when it's not: ... this may be fixable by adding ... Alternatively, to keep it out of the workspace, add ... an empty [workspace] table to the package's manifest.
  • Resolution taken: added an empty [workspace] table to the fixture manifest (the keep-it-out option Cargo itself lists; no edit to the engine root manifest). After that, all five binaries built and ran.
  • Why friction: a real downstream research project (C16: "a project is always a Rust crate" depending on aura) created inside or beside this repo hits the same wall. The task completed, but only after a non-obvious one-liner. This is the resolver-fight the carrier explicitly asked be recorded.
  • Recommended action: plan — note in onboarding/docs (or the future aura new scaffolder, an open ledger thread) that a project crate needs its own [workspace] root when nested under the engine repo.

[friction] Recorder boilerplate is hand-rewritten per author

  • Examples: all five (each redeclares a near-identical Recorder: a tx field, a schema with output: vec![], an eval that reads inputs by kind, sends, returns None).
  • What happened: the cycle deliberately ships no Recorder/sink library type (C9 — no speculative surface; the spec calls Recorder "this cycle's test-local fixture, not a shipped type"). So every consumer writes the channel-sink dance, including the per-kind match to pull the newest value of each input.
  • Why friction: it is redundant across authors and the 0006 spec implies a real author writes "their ChartSink, their RegistrySink" each from scratch. The cycle's scope intentionally excludes a library sink, so this is expected friction, not a bug — but it is the natural candidate for the next tidy.
  • Recommended action: plan — consider an aura-std channel/buffer recording block (or a small Recorder helper) once a second consumer confirms the shape, so the boilerplate is written once. Not urgent; C9 correctly kept it out of this cycle.

[spec_gap] No public statement of the canonical sink declaration / Some-with-empty-output

  • Examples: c0006_1, c0006_3, c0006_5 (all declare output: vec![]).
  • What happened: I needed the canonical way to declare a no-output (pure consumer) node. The public surface (rustdoc for NodeSchema/Node; the 0006 spec) shows output: vec![] only implicitly inside the worked example's prose ("a node with output: vec![]"); neither the rustdoc nor the ledger C8 entry states outright that an empty output vec is the sink declaration, nor what the engine does if a node with output: vec![] nonetheless returns Some(row) (ignored? debug-asserts on a width mismatch? UB?). I picked output: vec![] + return None — it matched the worked example and ran correctly — but the "what if a sink returns Some" corner is unconstrained by the public surface, and I did not probe it (probing the engine's reaction would require reading implementation source, which ends the test).
  • Why spec_gap: the design surface is silent on a corner a downstream author can hit by mistake; the natural reading worked but another (a sink that records and forwards via output: vec![] + Some) is left undefined publicly.
  • Recommended action: tighten the design ledger / rustdoc — state explicitly that output: vec![] is the pure-consumer declaration and define the contract for a Some return paired with an empty output (reject at bootstrap, debug-assert, or documented-ignore). The C8 "both" case is documented for a node with a real output port; the empty-output-plus-Some mistake is not.

Recommendation summary

Finding Action
[working] custom node + Ctx::now() carry-on
[working] many interior streams, one run (headline) carry-on
[working] 3-arg bootstrap / run()->() / determinism / rejection carry-on
[friction] consumer crate fights workspace resolver plan
[friction] recorder boilerplate rewritten per author plan
[spec_gap] canonical sink declaration / Some-with-empty-output undefined tighten the design ledger