From f68258b044ee8a515e30912fbe9a58200f4f972c Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 4 Jun 2026 20:55:28 +0200 Subject: [PATCH] =?UTF-8?q?fieldtest:=20cycle-0010=20=E2=80=94=203=20examp?= =?UTF-8?q?les,=206=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-interface-only field test of the walking-skeleton closing seam (#8): the `aura run` CLI and the reusable aura-std::Recorder sink. A standalone consumer crate (path-deps on the three engine crates, as a C16 project would) drives the real binary as a subprocess and wires Recorder via the raw Harness::bootstrap API. Examples (all built from HEAD and ran green): - c0010_1: drive `aura run` as a downstream CLI/jq consumer — single-line JSON report, byte-identical across two runs (C1), bad-args → exit 2 + exact usage on stderr with empty stdout. - c0010_2: wire Recorder onto Sma(3) via raw bootstrap, drain the mpsc::Receiver — rows match the rustdoc warm-up model exactly. - c0010_3: Recorder over [I64, Bool, Timestamp] — the four-kind claim (never exercised by the CLI's f64-only path) holds, verified from rustdoc alone. Findings: 0 bugs, 4 working, 1 spec_gap, 1 friction. - [spec_gap] `aura run ` is accepted (exit 0), not rejected — the arg parse keys only on the first token being `run` and ignores the rest. The cycle_scope's "any other or missing subcommand -> exit 2" does not unambiguously cover `run `; the binary chose the lenient reading. Tracked for a ratify-or-tighten decision. - [friction] verifying the documented {manifest, metrics} JSON nesting needs a hand-rolled string walker — the zero-dep consumer has only to_json() and no typed read-path. Low-priority tidy. Both non-bug findings filed to the forward queue; neither blocks the cycle. --- docs/specs/fieldtest-0010-aura-run.md | 163 ++++++++++++++++++ fieldtests/cycle-0010-aura-run/Cargo.lock | 30 ++++ fieldtests/cycle-0010-aura-run/Cargo.toml | 33 ++++ .../cycle-0010-aura-run/c0010_1_cli_report.rs | 112 ++++++++++++ .../c0010_2_recorder_sink.rs | 84 +++++++++ .../c0010_3_recorder_four_kind.rs | 123 +++++++++++++ 6 files changed, 545 insertions(+) create mode 100644 docs/specs/fieldtest-0010-aura-run.md create mode 100644 fieldtests/cycle-0010-aura-run/Cargo.lock create mode 100644 fieldtests/cycle-0010-aura-run/Cargo.toml create mode 100644 fieldtests/cycle-0010-aura-run/c0010_1_cli_report.rs create mode 100644 fieldtests/cycle-0010-aura-run/c0010_2_recorder_sink.rs create mode 100644 fieldtests/cycle-0010-aura-run/c0010_3_recorder_four_kind.rs diff --git a/docs/specs/fieldtest-0010-aura-run.md b/docs/specs/fieldtest-0010-aura-run.md new file mode 100644 index 0000000..7ed463e --- /dev/null +++ b/docs/specs/fieldtest-0010-aura-run.md @@ -0,0 +1,163 @@ +# Fieldtest — cycle-0010 — 2026-06-04 + +**Status:** Draft — awaiting orchestrator triage +**Author:** fieldtester (dispatched by fieldtest skill) + +## Scope + +Cycle 0010 is the Walking-skeleton milestone's closing seam (#8). It shipped two +public surfaces: + +1. **`aura_std::Recorder`** — a reusable recording-sink node. A pure consumer + (`output: vec![]`) over `kinds.len()` input columns, holding an + `mpsc::Sender<(Timestamp, Vec)>` as its out-of-graph destination + (C7/C8/C22). Constructor: `Recorder::new(kinds: &[ScalarKind], firing: Firing, + tx) -> Self`. Each fired cycle it sends `(ctx.now(), row)` with the newest + value of every column; returns `None` and records nothing until all columns + are warm. Supports all four base scalar kinds. +2. **The `aura run` CLI** (crate `aura-cli`, bin `aura`) — bootstraps a built-in + sample harness (synthetic source → SMA(2)/SMA(4) → Sub → Exposure → SimBroker + → two Recorder sinks), runs it deterministically (C1), and prints the + cycle-0009 `RunReport` as canonical single-line JSON to stdout (exit 0). Any + other or missing subcommand prints `aura: usage: aura run` to stderr, exit 2. + +Tested from the public interface only (rustdoc, design ledger, glossary, project +layout, the observable behaviour of the `aura` binary) — never `crates/*/src`. +The CLI's own functions are `pub(crate)`, so axis (a) drove the real binary as a +subprocess. The build exercised: workspace `cargo build` from HEAD +(`target/debug/aura`) for the binary; `cargo run --manifest-path +fieldtests/cycle-0010-aura-run/Cargo.toml` for the two library-consumer +fixtures. + +## Examples + +### fieldtests/cycle-0010-aura-run/c0010_1_cli_report.rs — `aura run` as a downstream CLI consumer (axis a) +- Spawns the real `aura` binary as a subprocess (path via `$AURA_BIN`, default + the sibling workspace `target/debug/aura`); captures stdout/stderr/exit for + `run`, no-args, an unknown subcommand, and `run` with a trailing junk arg. +- Fits the cycle's headline C14 move: a shell/jq/registry consumer parses the + single-line JSON report and relies on the exit-code + determinism contract. +- Outcome: built (from HEAD) and ran; exit 0 with `{manifest:{commit,params, + window,seed,broker}, metrics:{total_pips,max_drawdown,exposure_sign_flips}}`, + byte-identical across two runs, clean stderr; bad-args → exit 2 + exact usage. + Matched expected. Surfaced one spec_gap (trailing-arg leniency) and one + friction (no JSON parser in the consumer). + +### fieldtests/cycle-0010-aura-run/c0010_2_recorder_sink.rs — `aura_std::Recorder` as a reusable sink block (axis b) +- Wires the shipped `Recorder` onto an `Sma(3)` output via the raw + `Harness::bootstrap(nodes, sources, edges)` API (exactly as a C16 project + would), runs 5 ticks, drains the `mpsc::Receiver`, asserts the recorded + `(Timestamp, Vec)` rows against the rustdoc warm-up model. +- Fits the cycle's first deliverable: Recorder used the way a downstream project + consumes it, not the way the CLI's built-in harness does. +- Outcome: built and ran first try; rows `[(3,[20.0]),(4,[30.0]),(5,[40.0])]` + exactly matched the hand model (no warm-up rows, one row per warm cycle, + ts = `ctx.now()`). Matched expected. + +### fieldtests/cycle-0010-aura-run/c0010_3_recorder_four_kind.rs — Recorder four-kind support (axis c) +- Authors a tiny custom 3-field producer node (i64 counter / bool even? / + timestamp now) in Rust (C17), wires a multi-column + `Recorder::new(&[I64, Bool, Timestamp], …)` over it via three field-wise edges, + runs, drains, asserts the recorded Scalars are `I64`/`Bool`/`Ts`. +- Fits the cycle's four-kind claim: the CLI only ever exercises the f64 path, so + the non-f64 kinds are verifiable from the public rustdoc alone only this way. +- Outcome: built (after one trivial authoring fix — see working finding) and + ran; rows matched `[(100,[I64(1),Bool(false),Ts(100)]),(200,[I64(2),Bool(true), + Ts(200)]),(300,[I64(3),Bool(false),Ts(300)])]` exactly. Matched expected. + +## Findings + +### [working] `aura run` end-to-end: clean single-line JSON, deterministic, jq-parseable +- Example: c0010_1. +- What happened: `aura run` → exit 0, one line of JSON on stdout, empty stderr. + The documented `{manifest, metrics}` nesting and all eight documented fields + (`commit, params, window, seed, broker`; `total_pips, max_drawdown, + exposure_sign_flips`) are present. Two runs are byte-identical (C1). Piped + through `jq`, `.metrics.total_pips` is a number, `.manifest.params | keys` + resolves — a downstream C18/shell consumer can author against it. +- Why working: the headline C14 "run a sim, emit structured metrics" move is + reachable from a real binary, from rustdoc alone, on the first try, with the + determinism and shape the ledger promises. +- Recommended action: carry-on. + +### [working] Bad-args contract exact and stdout-clean +- Example: c0010_1. +- What happened: `aura` (no args) and `aura play` (unknown) each → exit 2, + stderr exactly `aura: usage: aura run\n`, stdout empty (0 bytes). A consumer + keying off exit code never sees a half-report on stdout. +- Why working: matches the cycle_scope contract precisely; the stdout/stderr + split is clean (errors never contaminate the machine-readable channel). +- Recommended action: carry-on. + +### [working] Recorder as a reusable sink: warm-up + row shape match rustdoc +- Example: c0010_2. +- What happened: a downstream wiring of `Recorder` over `Sma(3)` via raw + `Harness::bootstrap`, drained from `mpsc::Receiver`, produced exactly the rows + the rustdoc model predicts — no warm-up rows, one one-element `Vec` + per warm cycle, each tagged `ctx.now()`. The constructor signature on the + surface (`&[ScalarKind]`, `Firing`, `Sender`) is enough to author against with + no source reading. +- Why working: the cycle's primary deliverable is reachable and correct from the + public interface as a standalone consumer block (not just inside the CLI). +- Recommended action: carry-on. + +### [working] Recorder four-kind claim holds on a non-f64 column set +- Example: c0010_3. +- What happened: a `Recorder` over `[I64, Bool, Timestamp]` recorded + `Scalar::I64` / `Scalar::Bool` / `Scalar::Ts` values verbatim, matching the + rustdoc's four-kind claim — a path the CLI never exercises. The trivial + authoring miss (`FieldSpec.name` is `&'static str`, I wrote `.to_string()`) + was caught by the compiler with an exact fix-it; the public `FieldSpec` + rustdoc states `pub name: &'static str` plainly. No source reading needed. +- Why working: the four-kind claim, otherwise unverifiable from the CLI, is + empirically true; the supporting types (`FieldSpec`, `Scalar`, multi-edge + field-wise binding) author cleanly from rustdoc. +- Recommended action: carry-on. + +### [spec_gap] `aura run ` is accepted (exit 0), not rejected +- Example: c0010_1. +- What happened: `aura run extra-garbage` printed the full JSON report and exited + 0 — trailing args after `run` are silently ignored. The cycle_scope states + "Any **other** or missing subcommand prints `aura: usage: aura run` to stderr + and exits 2." +- Why spec_gap: `run ` is neither a clean `run` nor a *different* + subcommand, so the contract does not unambiguously cover it. The binary picked + the lenient reading (key only on the first arg being `run`, ignore the rest); + the strict reading (any extra/unexpected token → usage + exit 2) is equally + plausible and is what a user who typos `aura run --sweep` would expect (they'd + get a silent full run instead of a hint). I asserted the lenient reading the + binary exhibits and recorded the ambiguity rather than treating it as a bug. +- Recommended action: ratify (document the arg-parse contract as + "first-arg-keyed, trailing args ignored") **or** tighten — decide whether + unexpected trailing tokens should be a usage error. A one-line note on the + intended arg contract in the `aura` crate rustdoc closes it. + +### [friction] Verifying the documented JSON nesting needs a hand-rolled walker +- Example: c0010_1. +- What happened: the consumer crate is zero-external-dependency by design (no + serde/serde_json), and `RunReport` exposes only `to_json() -> String` with no + typed accessor. To confirm the documented `{manifest, metrics}` nesting and + field presence, the fixture hand-walks the string (`find("\"key\":")`) — a + consumer authoring real assertions against the report shape either pulls in a + JSON dep or writes this dance. +- Why friction: the task (confirm the structured-face shape) completed, but the + surface offered no programmatic handle on the report's structure short of + re-parsing the string the engine just serialized. The cycle's C14 framing + ("machine-readable") implies a consumer can *inspect* the structure, not only + re-stringify it. +- Recommended action: plan (tidy) — consider a minimal typed read-path + (e.g. `RunReport` already round-trips its own fields; exposing the structured + values, or shipping a tiny documented parse helper, would let consumers assert + without a serde dep). Low priority; the JSON is correct and jq-parseable for + shell consumers today. + +## Recommendation summary + +| Finding | Class | Action | +|---|---|---| +| `aura run` end-to-end JSON + determinism | working | carry-on | +| Bad-args contract exact + stdout-clean | working | carry-on | +| Recorder reusable sink matches rustdoc | working | carry-on | +| Recorder four-kind claim holds | working | carry-on | +| `aura run ` accepted | spec_gap | ratify / tighten the design ledger | +| JSON nesting needs a hand-rolled walker | friction | plan (tidy) | diff --git a/fieldtests/cycle-0010-aura-run/Cargo.lock b/fieldtests/cycle-0010-aura-run/Cargo.lock new file mode 100644 index 0000000..3a44486 --- /dev/null +++ b/fieldtests/cycle-0010-aura-run/Cargo.lock @@ -0,0 +1,30 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aura-core" +version = "0.1.0" + +[[package]] +name = "aura-engine" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "aura-std" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "c0010-fieldtest" +version = "0.0.0" +dependencies = [ + "aura-core", + "aura-engine", + "aura-std", +] diff --git a/fieldtests/cycle-0010-aura-run/Cargo.toml b/fieldtests/cycle-0010-aura-run/Cargo.toml new file mode 100644 index 0000000..0142256 --- /dev/null +++ b/fieldtests/cycle-0010-aura-run/Cargo.toml @@ -0,0 +1,33 @@ +# Standalone downstream-consumer crate for the cycle-0010 fieldtest +# (aura-std::Recorder + the `aura run` CLI). +# +# Like the cycle-0007/0008/0009 fixtures, this is NOT a member of the aura +# workspace — it path-depends on the engine crates exactly as a real research +# project (C16) would, and is built/run via +# `cargo run --manifest-path fieldtests/cycle-0010-aura-run/Cargo.toml --bin ` +# so HEAD source is always what runs. +# Empty [workspace] table: marks this fixture crate as its OWN workspace root. +[workspace] + +[package] +name = "c0010-fieldtest" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +aura-core = { path = "../../crates/aura-core" } +aura-engine = { path = "../../crates/aura-engine" } +aura-std = { path = "../../crates/aura-std" } + +[[bin]] +name = "c0010_1_cli_report" +path = "c0010_1_cli_report.rs" + +[[bin]] +name = "c0010_2_recorder_sink" +path = "c0010_2_recorder_sink.rs" + +[[bin]] +name = "c0010_3_recorder_four_kind" +path = "c0010_3_recorder_four_kind.rs" diff --git a/fieldtests/cycle-0010-aura-run/c0010_1_cli_report.rs b/fieldtests/cycle-0010-aura-run/c0010_1_cli_report.rs new file mode 100644 index 0000000..7b044fd --- /dev/null +++ b/fieldtests/cycle-0010-aura-run/c0010_1_cli_report.rs @@ -0,0 +1,112 @@ +//! Fieldtest c0010 #1 — `aura run` as a downstream CLI consumer (axis a). +//! +//! Question under test: invoking the REAL `aura` binary the way the deferred +//! C18 registry or a shell/jq pipeline would — capture stdout, treat it as the +//! single-line canonical JSON report (manifest + metrics) — does the documented +//! C14 machine-readable face hold? Specifically: +//! - `aura run` -> exit 0, one line of JSON on stdout, nothing on stderr; +//! - the JSON parses as an object with {manifest, metrics} nesting (rustdoc: +//! "prints the run's metrics + manifest as canonical JSON to stdout"); +//! - two runs are byte-identical (C1 determinism, pinned on the surface); +//! - missing / unknown subcommand -> exit 2 + `aura: usage: aura run` on +//! stderr, NOTHING on stdout (cycle_scope bad-args contract). +//! +//! This consumer does NOT read crates/*/src. It only runs the binary and +//! inspects observable behaviour. The binary path comes from $AURA_BIN (a CI +//! consumer would point this at the built artefact); we default to the +//! sibling workspace target/debug/aura so `cargo run` works out of the box. + +use std::process::Command; + +fn aura_bin() -> String { + if let Ok(p) = std::env::var("AURA_BIN") { + return p; + } + // Default: the workspace debug artefact, two levels up from this crate. + let here = env!("CARGO_MANIFEST_DIR"); + format!("{here}/../../target/debug/aura") +} + +/// A bare-bones JSON value walker — enough for a consumer to confirm the +/// documented {manifest, metrics} shape WITHOUT a serde dependency (the +/// workspace is deliberately zero-external-dependency; a downstream consumer +/// would reach for serde_json or jq, but here we only assert structural facts +/// the rustdoc promises). Returns the substring value following `"key":`. +fn raw_value_after<'a>(json: &'a str, key: &str) -> Option<&'a str> { + let needle = format!("\"{key}\":"); + let idx = json.find(&needle)? + needle.len(); + Some(&json[idx..]) +} + +fn run_capture(args: &[&str]) -> (String, String, i32) { + let out = Command::new(aura_bin()) + .args(args) + .output() + .unwrap_or_else(|e| panic!("failed to spawn {}: {e}", aura_bin())); + ( + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + out.status.code().unwrap_or(-1), + ) +} + +fn main() { + println!("using binary: {}", aura_bin()); + + // --- `aura run`: exit 0, one JSON line on stdout, clean stderr --- + let (stdout, stderr, code) = run_capture(&["run"]); + println!("`aura run` exit={code}"); + println!("`aura run` stdout = {stdout:?}"); + assert_eq!(code, 0, "aura run must exit 0"); + assert!(stderr.is_empty(), "aura run must not write stderr, got {stderr:?}"); + + let line = stdout.trim_end_matches('\n'); + assert!(!line.contains('\n'), "report must be a single line of JSON"); + assert!(line.starts_with('{') && line.ends_with('}'), "report is a JSON object"); + + // Documented nesting: a `manifest` object and a `metrics` object. + assert!(raw_value_after(line, "manifest").is_some(), "manifest key present"); + assert!(raw_value_after(line, "metrics").is_some(), "metrics key present"); + // Documented manifest fields (RunManifest rustdoc: commit/params/window/seed/broker). + for k in ["commit", "params", "window", "seed", "broker"] { + assert!(raw_value_after(line, k).is_some(), "manifest.{k} key present"); + } + // Documented metrics fields (RunMetrics rustdoc). + for k in ["total_pips", "max_drawdown", "exposure_sign_flips"] { + assert!(raw_value_after(line, k).is_some(), "metrics.{k} key present"); + } + + // --- determinism: two runs byte-identical (C1) --- + let (stdout2, _, _) = run_capture(&["run"]); + assert_eq!(stdout, stdout2, "two runs must be byte-identical (C1)"); + println!("determinism: two runs byte-identical OK"); + + // --- bad-args contract: missing subcommand --- + let (out, err, code) = run_capture(&[]); + println!("`aura` (no args) exit={code} stderr={err:?}"); + assert_eq!(code, 2, "no subcommand must exit 2"); + assert!(out.is_empty(), "no subcommand must print nothing on stdout, got {out:?}"); + assert_eq!(err.trim_end(), "aura: usage: aura run", "exact usage line on stderr"); + + // --- bad-args contract: unknown subcommand --- + let (out, err, code) = run_capture(&["play"]); + println!("`aura play` exit={code} stderr={err:?}"); + assert_eq!(code, 2, "unknown subcommand must exit 2"); + assert!(out.is_empty(), "unknown subcommand must print nothing on stdout"); + assert_eq!(err.trim_end(), "aura: usage: aura run", "exact usage line on stderr"); + + // --- probe: trailing junk after `run`. The cycle_scope says "Any other or + // missing subcommand prints usage and exits 2"; `run extra` is neither a + // clean `run` nor a different subcommand. What does the binary do? --- + let (out, err, code) = run_capture(&["run", "extra-garbage"]); + println!("`aura run extra-garbage` exit={code} stdout_len={} stderr={err:?}", out.len()); + // We RECORD the behaviour; the reading we assert is the lenient one the + // binary actually exhibits, and flag the spec ambiguity in the report. + if code == 0 { + println!("NOTE: `aura run ` was ACCEPTED (exit 0) — trailing args ignored."); + } else { + println!("NOTE: `aura run ` was REJECTED (exit {code})."); + } + + println!("c0010_1 OK: aura run -> JSON report; determinism + bad-args contract observed"); +} diff --git a/fieldtests/cycle-0010-aura-run/c0010_2_recorder_sink.rs b/fieldtests/cycle-0010-aura-run/c0010_2_recorder_sink.rs new file mode 100644 index 0000000..45c5cce --- /dev/null +++ b/fieldtests/cycle-0010-aura-run/c0010_2_recorder_sink.rs @@ -0,0 +1,84 @@ +//! Fieldtest c0010 #2 — `aura_std::Recorder` as a reusable sink block (axis b). +//! +//! Question under test: a downstream project (C16) wires the SHIPPED +//! `aura_std::Recorder` onto a node's output via the raw `Harness::bootstrap` +//! API, runs the harness, drains the `mpsc::Receiver`, and the recorded +//! `(Timestamp, Vec)` rows match what the public rustdoc led us to +//! expect. The rustdoc says (struct.Recorder): +//! - `Recorder::new(kinds, firing, tx)` — one input column per `kinds` entry; +//! - "Each fired cycle it reads the newest value of every column and sends the +//! row to tx; it returns None during warm-up until all columns have a value." +//! +//! Topology (single f64 source -> SMA(3) -> Recorder[F64]): +//! +//! price --> SMA(3) --> Recorder(tx) (Recorder is a pure consumer, C8) +//! +//! Public-surface facts used (rustdoc only): +//! - aura_std::Sma::new(length): SMA over the last `length` f64 values, None +//! until warm (warms once `length` values have arrived). +//! - aura_std::Recorder::new(&[ScalarKind::F64], Firing::Any, tx). +//! - aura_engine::{Harness, Edge, SourceSpec, Target}. + +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_std::{Recorder, Sma}; + +fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> { + pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect() +} + +fn main() { + let (tx, rx) = mpsc::channel::<(Timestamp, Vec)>(); + + // nodes: 0 = SMA(3), 1 = Recorder over one F64 column. + let mut h = Harness::bootstrap( + vec![ + Box::new(Sma::new(3)), + Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx)), + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![Target { node: 0, slot: 0 }], // price -> SMA + }], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // SMA -> Recorder.in0 + ], + ) + .expect("valid SMA -> Recorder DAG"); + + // 5 ticks. SMA(3) warms at the 3rd tick; the Recorder then records once per + // SMA-fired cycle (Firing::Any over one column already warm). + let prices: &[(i64, f64)] = &[(1, 10.0), (2, 20.0), (3, 30.0), (4, 40.0), (5, 50.0)]; + h.run(vec![f64_stream(prices)]); + + let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); + println!("recorded rows = {rows:?}"); + + // Hand model from rustdoc alone: + // SMA(3) emits None for t=1,2 (warm-up), then: + // t=3 -> mean(10,20,30) = 20.0 + // t=4 -> mean(20,30,40) = 30.0 + // t=5 -> mean(30,40,50) = 40.0 + // The Recorder records one row per fired (warm) cycle, tagged ctx.now(), + // each row a single-element Vec with the F64 column's newest value. + let expected: Vec<(Timestamp, Vec)> = vec![ + (Timestamp(3), vec![Scalar::F64(20.0)]), + (Timestamp(4), vec![Scalar::F64(30.0)]), + (Timestamp(5), vec![Scalar::F64(40.0)]), + ]; + assert_eq!(rows.len(), expected.len(), "one row per warm SMA cycle (no warm-up rows)"); + for (got, want) in rows.iter().zip(expected.iter()) { + assert_eq!(got.0, want.0, "row timestamp = ctx.now()"); + assert_eq!(got.1.len(), 1, "single F64 column => one-element row"); + match (got.1[0], want.1[0]) { + (Scalar::F64(g), Scalar::F64(w)) => { + assert!((g - w).abs() < 1e-9, "recorded f64 {g} ~= {w}"); + } + other => panic!("expected F64 scalar, got {other:?}"), + } + } + + println!("c0010_2 OK: Recorder[F64] drained from mpsc matches the rustdoc model"); +} diff --git a/fieldtests/cycle-0010-aura-run/c0010_3_recorder_four_kind.rs b/fieldtests/cycle-0010-aura-run/c0010_3_recorder_four_kind.rs new file mode 100644 index 0000000..d22bbe2 --- /dev/null +++ b/fieldtests/cycle-0010-aura-run/c0010_3_recorder_four_kind.rs @@ -0,0 +1,123 @@ +//! Fieldtest c0010 #3 — Recorder four-kind support (axis c). +//! +//! The `aura run` CLI only ever uses the f64 Recorder path, so the rustdoc +//! claim that the Recorder supports all four base scalar kinds (F64/I64/Bool/ +//! Timestamp -> Scalar::F64/I64/Bool/Ts in the recorded row) is verified here +//! from the public surface alone, on a NON-f64 column set. +//! +//! There is no shipped aura-std producer of i64/bool/timestamp columns, so we +//! author a tiny custom producer node in Rust (C17 — a downstream project +//! authors its own nodes), emitting a 3-field record (i64, bool, timestamp) +//! each cycle. A multi-column Recorder over [I64, Bool, Timestamp] records it. +//! +//! Topology (single source ticks the clock; the producer emits its own record; +//! three edges bind producer field 0/1/2 into the recorder's slot 0/1/2): +//! +//! clock --> Counter ==(3 fields)==> Recorder[I64, Bool, Timestamp] +//! +//! Public-surface facts used (rustdoc only): +//! - aura_core::{Node, NodeSchema, InputSpec, FieldSpec, Ctx, Scalar, ScalarKind, +//! Firing, Timestamp}: the C8 node contract; a producer's eval returns a +//! borrowed row matching schema().output.len(). +//! - aura_std::Recorder::new(&[I64, Bool, Timestamp], Firing::Any, tx): records +//! the newest value of every column once all columns are warm. + +use std::sync::mpsc; + +use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp}; +use aura_engine::{Edge, Harness, SourceSpec, Target}; +use aura_std::Recorder; + +/// A 3-field producer: one f64 clock input (to tick it), emits +/// (i64 counter, bool even?, timestamp ctx.now()) each cycle. +struct Counter { + n: i64, + row: Vec, +} +impl Counter { + fn new() -> Self { + Counter { n: 0, row: Vec::new() } + } +} +impl Node for Counter { + fn schema(&self) -> NodeSchema { + NodeSchema { + // one f64 clock input so the engine fires this node each price cycle + inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }], + // a 3-column record: i64 / bool / timestamp + output: vec![ + FieldSpec { name: "counter", kind: ScalarKind::I64 }, + FieldSpec { name: "is_even", kind: ScalarKind::Bool }, + FieldSpec { name: "stamp", kind: ScalarKind::Timestamp }, + ], + } + } + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { + if ctx.f64_in(0).is_empty() { + return None; + } + self.n += 1; + self.row = vec![ + Scalar::I64(self.n), + Scalar::Bool(self.n % 2 == 0), + Scalar::Ts(ctx.now()), + ]; + Some(&self.row) + } +} + +fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> { + pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect() +} + +fn main() { + let (tx, rx) = mpsc::channel::<(Timestamp, Vec)>(); + + // nodes: 0 = Counter (3-field producer), 1 = Recorder over [I64, Bool, Timestamp]. + let mut h = Harness::bootstrap( + vec![ + Box::new(Counter::new()), + Box::new(Recorder::new( + &[ScalarKind::I64, ScalarKind::Bool, ScalarKind::Timestamp], + Firing::Any, + tx, + )), + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![Target { node: 0, slot: 0 }], // clock -> Counter + }], + vec![ + // bind each of the producer's three fields into the recorder's three slots + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // i64 counter + Edge { from: 0, to: 1, slot: 1, from_field: 1 }, // bool is_even + Edge { from: 0, to: 1, slot: 2, from_field: 2 }, // timestamp stamp + ], + ) + .expect("valid Counter -> Recorder[I64,Bool,Timestamp] DAG"); + + let clock: &[(i64, f64)] = &[(100, 0.0), (200, 0.0), (300, 0.0)]; + h.run(vec![f64_stream(clock)]); + + let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); + println!("recorded rows = {rows:?}"); + + // Hand model from rustdoc alone: the Counter fires each clock cycle, all + // three recorder columns are warm from the first cycle, so the Recorder + // records 3 rows, each (ctx.now(), [I64(n), Bool(n even), Ts(now)]). + let expected: Vec<(Timestamp, Vec)> = vec![ + (Timestamp(100), vec![Scalar::I64(1), Scalar::Bool(false), Scalar::Ts(Timestamp(100))]), + (Timestamp(200), vec![Scalar::I64(2), Scalar::Bool(true), Scalar::Ts(Timestamp(200))]), + (Timestamp(300), vec![Scalar::I64(3), Scalar::Bool(false), Scalar::Ts(Timestamp(300))]), + ]; + assert_eq!(rows.len(), expected.len(), "one row per clock cycle"); + assert_eq!(rows, expected, "i64/bool/timestamp columns recorded as I64/Bool/Ts scalars"); + + // Spot-check kinds explicitly (the four-kind claim is about the Scalar variant). + let (_, first) = &rows[0]; + assert!(matches!(first[0], Scalar::I64(_)), "col0 is Scalar::I64"); + assert!(matches!(first[1], Scalar::Bool(_)), "col1 is Scalar::Bool"); + assert!(matches!(first[2], Scalar::Ts(_)), "col2 is Scalar::Ts"); + + println!("c0010_3 OK: Recorder records I64/Bool/Timestamp columns per the four-kind claim"); +}