fieldtest: cycle-0010 — 3 examples, 6 findings
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 <trailing-arg>` 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 <junk>`; 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.
This commit is contained in:
@@ -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<Scalar>)>` 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<Scalar>)` 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<Scalar>`
|
||||||
|
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 <trailing-arg>` 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 <junk>` 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 <trailing-arg>` accepted | spec_gap | ratify / tighten the design ledger |
|
||||||
|
| JSON nesting needs a hand-rolled walker | friction | plan (tidy) |
|
||||||
+30
@@ -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",
|
||||||
|
]
|
||||||
@@ -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 <name>`
|
||||||
|
# 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"
|
||||||
@@ -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 <extra>` was ACCEPTED (exit 0) — trailing args ignored.");
|
||||||
|
} else {
|
||||||
|
println!("NOTE: `aura run <extra>` was REJECTED (exit {code}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("c0010_1 OK: aura run -> JSON report; determinism + bad-args contract observed");
|
||||||
|
}
|
||||||
@@ -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<Scalar>)` 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<Scalar>)>();
|
||||||
|
|
||||||
|
// 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<Scalar>)> = 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<Scalar> with the F64 column's newest value.
|
||||||
|
let expected: Vec<(Timestamp, Vec<Scalar>)> = 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");
|
||||||
|
}
|
||||||
@@ -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<Scalar>,
|
||||||
|
}
|
||||||
|
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<Scalar>)>();
|
||||||
|
|
||||||
|
// 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<Scalar>)> = 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<Scalar>)> = 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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user