Files
Aura/crates/aura-cli/tests/graph_construct.rs
T
Brummel 257ab0b9f2 feat(std,cli): add the Scale node, retire the r-meanrev demo to data (#159 cut 3)
Cut 3 of the hard-wired demo retirement. Unlike r-sma/r-breakout, r-meanrev could
not round-trip as data: its band computes band_k*sigma (a constant times a stream)
and the closed 22-node std_vocabulary had no way to express it (EqConst is i64->bool,
Bias is clamp(signal/scale,±1), Mul is binary, LinComb is excluded as a
construction-arg node; no Div/Const/constant-emitter). Standard operators are missing
by chance, not by design, so this adds the one r-meanrev needs.

Scale node (aura-std): out = input * factor — one f64 input, one f64 `factor` param,
stateless, warm-up-filtered (the EqConst/Bias shape). A zero-input `Const` emitter was
rejected: the per-cycle eval loop gates every node through an input-firing test
(harness.rs `fires()`), so a node with no input never fires without an engine-core
change; `Scale` fits the existing model and, by IEEE-754 commutativity, reproduces the
retiring LinComb's band byte-for-byte. Rostered (`"Scale" => Scale`); the two
vocabulary count-pins bump 22 -> 23.

r-meanrev migration: r_meanrev_signal(window, band_k) is carved out of the fused
r_meanrev_graph as a #[cfg(test)] price->bias Composite whose band is `Scale` in place
of `LinComb(1)`; production loads the shipped JSON (examples/r_meanrev{,_open}.json),
never a builder. Before the fused builder was deleted, an equivalence test proved the
carved Scale-band signal reproduces its grade byte-for-byte on the synthetic stream
(window=3, band_k=2). Durable survivors: the byte-identity of the examples to the
carve, the loaded-grades-identically-to-carve proof, the closed/open introspect
anchors (mean_window.length, var_window.length, band.factor — band_k is now a
first-class sweepable param), and — re-pointed onto the carve rather than dropped —
the fades-short-above/long-below behavioural test, which becomes the durable
carve-correctness gate the deleted equivalence test used to be.

Deletions + the dead-code cascade the retirement opened: r_meanrev_graph,
r_meanrev_sweep_family, Strategy::RMeanRev (+ all arms). r-meanrev was the last reader
of run_sweep's grid, so the whole vestigial built-in-sweep grid apparatus retires with
it: run_sweep's `grid` param, the sweep call-site grid build, and SweepCmd's
--fast/--slow/--stop-length/--stop-k/--window/--band-k (sma/momentum sweeps build their
own blueprints and ignored these; the fast/slow/stop-* had been vestigial since r-sma's
cut 1b). RGrid the struct SURVIVES — the dissolved `generalize` verb resolves its
candidate from its fast/slow/stop_length/stop_k via generalize_args_from — so only its
window/band_k fields go; its stale doc-comment (r-sma-sweep / persist_traces_r / CLI
flags, all now gone) is rewritten to its true generalize-only role. Also retired as
transitively dead: persist_traces_r and the metrics_object test helper (their only
callers were the deleted families/tests); Add/Gt/Latch/Mul/Sqrt imports are now
#[cfg(test)] (their last production caller was r_meanrev_graph).

Test surface: the two built-in --strategy r-meanrev sweep tests drop; a new negative
pins that --strategy r-meanrev on both sweep and walkforward now falls into the generic
usage error; the grid-flag stray-positional negative is re-pointed to a surviving flag.

Verification (own, not the workflow's report): full `cargo test --workspace` green
(0 failed across 62 result lines); `cargo clippy --workspace --all-targets -- -D
warnings` clean, no dead-code residue; the r-sma + r-breakout anchors and the
dissolved-verb real goldens (generalize still reads RGrid) stay green. Bundled as one
commit (the Scale node's count-pin and the r-meanrev anchors share graph_construct.rs,
so a clean two-commit split was not path-separable); the implement-loop ran all three
plan tasks in one pass and the tree was verified by hand.

refs #159
2026-07-07 22:02:50 +02:00

784 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end coverage for the `aura graph build` / `aura graph introspect`
//! op-script CLI (#157, §C): drives the built `aura` binary over stdin/argv,
//! the exact surface a data-level author uses.
use std::io::Write;
use std::process::{Command, Stdio};
const BIN: &str = env!("CARGO_BIN_EXE_aura");
/// Run `aura <args>` with `stdin_doc` piped in; return (stdout, stderr, success).
fn run(args: &[&str], stdin_doc: &str) -> (String, String, bool) {
let mut child = Command::new(BIN)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura");
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
/// Like `run`, but returns the exact process exit CODE (not just success/failure).
/// The exit-code partition (#175 iteration 2) is a property of the integer, so its
/// tests must assert `Some(1)` vs `Some(2)`, which `run`'s bool cannot express.
fn run_code(args: &[&str], stdin_doc: &str) -> (String, Option<i32>) {
let mut child = Command::new(BIN)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn aura");
child.stdin.take().unwrap().write_all(stdin_doc.as_bytes()).unwrap();
let out = child.wait_with_output().expect("wait aura");
(String::from_utf8_lossy(&out.stderr).into_owned(), out.status.code())
}
const SIGNAL_DOC: &str = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
{"op":"add","type":"Sub"},
{"op":"add","type":"Bias"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"},
{"op":"connect","from":"slow.value","to":"sub.rhs"},
{"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"}
]"#;
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
/// never wired — a 0-cover input slot only the holistic finalize gate can
/// reject. The smallest document that is op-valid yet whole-document-invalid.
const UNCONNECTED_DOC: &str = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"SMA","name":"fast"},
{"op":"add","type":"SMA","name":"slow"},
{"op":"add","type":"Sub"},
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
#[test]
fn graph_build_emits_blueprint_for_a_valid_document() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
assert!(ok, "exit success");
assert!(stdout.contains("\"format_version\":1"), "emits the #155 envelope: {stdout}");
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
}
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
#[test]
fn graph_build_emit_is_byte_canonical_no_trailing_newline() {
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC);
assert!(ok, "exit success");
assert!(!stdout.ends_with('\n'), "no trailing newline on the canonical emit: {stdout:?}");
assert!(stdout.contains("\"format_version\":1"), "still the #155 envelope: {stdout}");
}
#[test]
fn graph_build_fails_fast_at_the_offending_op() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit");
assert!(stdout.is_empty(), "no blueprint emitted on error: {stdout}");
assert!(stderr.contains("op 1 (add)"), "names the failing op: {stderr}");
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}
#[test]
fn graph_introspect_vocabulary_lists_the_node_types() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
assert!(ok, "exit success");
assert!(stdout.contains("SMA"), "lists SMA: {stdout}");
assert!(stdout.contains("Bias"), "lists Bias: {stdout}");
assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}");
}
/// The #160 roster macro's whole point is that `std_vocabulary`'s match arms and
/// `std_vocabulary_types`'s enumerable list can never drift apart — but that
/// guarantee is checked in-process, inside `aura-std`'s own unit tests. This pins
/// the SAME closed-roster count one layer further out, through a code path the
/// roster macro cannot see: `Env::type_ids()` (project std concatenation) and
/// the CLI's println loop, observed across the real process boundary. A bug in
/// that CLI-side plumbing (a dropped or duplicated entry in the concatenation or
/// the print loop) would pass every roster-internal test yet still change what a
/// user actually sees from `aura graph introspect --vocabulary` with no project
/// loaded — this test is the only thing that would catch it.
#[test]
fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], "");
assert!(ok, "exit success");
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
23,
"the std-only (no project) vocabulary has exactly the roster's 23 entries: {stdout}"
);
}
#[test]
fn graph_introspect_node_answers_ports_without_a_build() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
assert!(ok, "exit success");
assert!(stdout.contains("series"), "lists the input port: {stdout}");
assert!(stdout.contains("value"), "lists the output field: {stdout}");
}
#[test]
fn graph_introspect_unwired_reports_open_slots() {
let doc = r#"[
{"op":"add","type":"SMA","name":"fast"},
{"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#;
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--unwired"], doc);
assert!(ok, "exit success");
assert!(stdout.contains("sub.rhs"), "sub.rhs still open: {stdout}");
assert!(!stdout.contains("sub.lhs"), "sub.lhs is covered: {stdout}");
}
/// Property (holistic finalize path, binary contract): a fault only decidable
/// at end-of-document (a 0-covered interior slot) makes `aura graph build` exit
/// non-zero, route the cause to *stderr* as a `finalize:` fault (NOT attributed
/// to any op index), and emit nothing on stdout. The eager fail-fast path (above)
/// already exercises a per-op fault; this pins the iteration's eager/holistic
/// gate split as the binary's observable exit-code + stream-routing contract,
/// not merely the engine `replay` return value.
#[test]
fn graph_build_reports_a_holistic_fault_at_finalize() {
let (stdout, stderr, ok) = run(&["graph", "build"], UNCONNECTED_DOC);
assert!(!ok, "non-zero exit on a finalize fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}");
assert!(stderr.contains("finalize: slot") && stderr.contains("is unconnected"),
"the holistic fault renders by-identifier, got: {stderr}");
}
/// Property (determinism, domain invariant 1, at the CLI seam): the same op-list
/// document piped to `aura graph build` twice yields byte-identical blueprint
/// JSON on stdout. The #155 canonical form has no run-to-run ordering or
/// formatting jitter — a precondition for content-addressing a blueprint (#158).
/// The non-empty guard rules out two equal-but-empty error paths masquerading as
/// agreement.
#[test]
fn graph_build_is_byte_identical_across_runs() {
let (first, _e1, ok1) = run(&["graph", "build"], SIGNAL_DOC);
let (second, _e2, ok2) = run(&["graph", "build"], SIGNAL_DOC);
assert!(ok1 && ok2, "both runs succeed");
assert!(!first.is_empty(), "non-empty blueprint output");
assert_eq!(first, second, "byte-identical blueprint across runs");
}
/// Property (read-only query, clean rejection): `aura graph introspect --node`
/// with a type outside the closed vocabulary exits non-zero, names the offending
/// type on stderr, and prints no partial port listing on stdout. The introspect
/// surface's only negative-path E2E — it fails cleanly rather than half-answering.
#[test]
fn graph_introspect_node_rejects_an_unknown_type() {
let (stdout, stderr, ok) = run(&["graph", "introspect", "--node", "Bogus"], "");
assert!(!ok, "non-zero exit for an unknown type");
assert!(stdout.is_empty(), "no partial port listing emitted: {stdout}");
assert!(stderr.contains("Bogus"), "names the offending type: {stderr}");
}
/// Property (#162 item 2, bind-mismatch prose at the binary seam): an `aura graph
/// build` whose bind value-kind disagrees with the param's declared kind exits
/// non-zero and writes the cause as prose to stderr — naming the param path and
/// both the expected and actual kind — and never leaks the raw `KindMismatch`
/// Debug struct the pre-#162 `{err:?}` presenter exposed. SMA's `length` is i64,
/// so binding an `F64` is the smallest kind disagreement. Pins the user-facing
/// bind-error ergonomics at the binary, not merely the in-process presenter.
#[test]
fn graph_build_renders_a_bind_kind_mismatch_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a bad bind");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(stderr.contains("param fast.length expects I64 but got F64"),
"phrases the bind mismatch as prose: {stderr}");
assert!(!stderr.contains("KindMismatch"),
"does not leak the Debug struct name: {stderr}");
}
/// Property (#162 item 3, discoverable bind form at the binary seam): `aura graph
/// introspect --node` annotates each param with its typed-Scalar bind wrapper, so
/// a hand-author learns the `{"<Kind>": <v>}` value form from the CLI alone
/// without reading source. SMA's `length` is i64, so the shown form is the `I64`
/// wrapper.
#[test]
fn graph_introspect_node_shows_the_typed_bind_form() {
let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], "");
assert!(ok, "exit success");
assert!(stdout.contains("(bind {\"I64\": <v>})"),
"shows the typed-Scalar bind-value form for the i64 param: {stdout}");
}
/// Property (#162 item 1, by-identifier finalize fault at the binary seam, a
/// non-unconnected variant): a fault decidable only at finalize that is NOT a
/// 0-covered slot — here one source role fanning into two slots of differing
/// scalar kind (an `f64` `Sub.lhs` and a `bool` `And.a`); `feed` runs no eager
/// kind check, so the disagreement surfaces only at the holistic finalize gate —
/// renders by *identifier*: `aura graph build` exits non-zero, routes a
/// `finalize:` fault to stderr, and names the offending role ("price") in prose,
/// never a raw machine role index. The companion unconnected-slot test pins one
/// finalize variant; this pins that the by-identifier translation generalises
/// across finalize variants.
#[test]
fn graph_build_renders_role_kind_mismatch_at_finalize_by_identifier() {
let doc = r#"[
{"op":"source","role":"price","kind":"F64"},
{"op":"add","type":"Sub","name":"sub"},
{"op":"add","type":"And","name":"conj"},
{"op":"feed","role":"price","into":["sub.lhs","conj.a"]}
]"#;
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
assert!(!ok, "non-zero exit on a finalize fault");
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
assert!(stderr.contains("finalize:"), "attributes the fault to finalize, not an op: {stderr}");
assert!(stderr.contains("input role price fans into slots of differing kinds"),
"names the offending role by-identifier in prose: {stderr}");
assert!(!stderr.contains("RoleKindMismatch"),
"does not leak the Debug variant name: {stderr}");
}
/// Property (#158 acc 1, the content-id surface): `aura graph introspect --content-id`
/// prints the 64-hex SHA256 of the op-script's canonical blueprint — deterministic across
/// runs and distinct for a structurally different topology. It is the same hash `aura run`
/// stamps as `topology_hash` (both via the one `content_id` primitive over the canonical
/// `blueprint_to_json` bytes), so a data-authored topology has a stable, comparable content
/// id. (Note: an op-script and the Rust `sma_signal` builder produce *different*
/// canonical forms — composite debug-name, an unbound `Bias.scale` — so they are different
/// topologies by the byte definition; content-addressing keys on the canonical form.)
#[test]
fn graph_introspect_content_id_is_deterministic_and_distinguishes() {
let (a, _e, ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let id = a.trim();
assert_eq!(id.len(), 64, "a 64-hex SHA256 content id: {id:?}");
assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
// deterministic: the same op-script yields the same content id.
let (b, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
assert!(ok2, "exit success");
assert_eq!(id, b.trim(), "same op-script -> same content id");
// distinguishes: a structurally different topology (slow length 4 -> 5) differs.
let other = SIGNAL_DOC.replace("\"I64\":4", "\"I64\":5");
let (c, _e3, ok3) = run(&["graph", "introspect", "--content-id"], &other);
assert!(ok3, "exit success");
assert_ne!(id, c.trim(), "a different topology -> a different content id");
}
/// Property (negative): `aura graph introspect --content-id` on a malformed op-list exits
/// non-zero with the cause on stderr and prints no content id — it fails cleanly, never a
/// partial/garbage hash.
#[test]
fn graph_introspect_content_id_rejects_a_bad_document() {
let (stdout, stderr, ok) = run(&["graph", "introspect", "--content-id"], r#"[{"op":"add","type":"Nope"}]"#);
assert!(!ok, "non-zero exit on a bad op-list");
assert!(stdout.is_empty(), "no content id emitted on error: {stdout}");
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}
/// Property (exit-code partition, #175 iteration 2, deviation #8): a malformed
/// op-script piped to `aura graph build` is a RUNTIME failure — bad stdin *content*,
/// exit 1 — NOT the usage code 2. The attribution principle classifies bad
/// piped-stdin data as environment/runtime (the command line was well-formed),
/// distinct from a command-line fault. The sibling `graph_build_fails_fast_at_the_
/// offending_op` pins only `!ok` (non-zero); this pins the exact integer, so a
/// regression that reverted the graph_construct stdin site to the pre-split exit 2
/// fails here.
#[test]
fn graph_build_bad_stdin_content_is_runtime_exit_1() {
let (stderr, code) = run_code(&["graph", "build"], r#"[{"op":"add","type":"Nope"}]"#);
assert_eq!(code, Some(1), "bad stdin content is a runtime failure -> exit 1; stderr: {stderr}");
assert!(stderr.contains("Nope"), "still names the cause: {stderr}");
}
/// Property (exit-code partition, #175 iteration 2): within the SAME `graph
/// introspect` subcommand, an invalid `--node <T>` flag VALUE is a USAGE error (a
/// command-line fault the user must fix in argv) — exit 2 — distinct from bad stdin
/// content on the `--unwired`/`--content-id` branches, which is runtime exit 1. This
/// pins the argv-fault-vs-stdin-fault boundary the attribution principle draws at
/// the exact integer; the sibling `graph_introspect_node_rejects_an_unknown_type`
/// checks only non-zero, and would pass whether the code were 1 or 2.
#[test]
fn graph_introspect_unknown_node_flag_is_usage_exit_2() {
let (stderr, code) = run_code(&["graph", "introspect", "--node", "Bogus"], "");
assert_eq!(code, Some(2), "an invalid --node flag value is a usage error -> exit 2; stderr: {stderr}");
assert!(stderr.contains("Bogus"), "names the bad type: {stderr}");
}
/// Property (#171 acc 1 at the binary seam): `--identity-id` bridges op-scripts
/// that differ only in debug names — the renamed twin keeps the same identity id
/// while its content id moves. Rename via blanket replace: "fast" occurs as the
/// instance name and in every `fast.*` reference, so the replaced document stays
/// internally consistent.
#[test]
fn graph_introspect_identity_id_bridges_renamed_op_scripts() {
let renamed = SIGNAL_DOC.replace("fast", "speedy");
let (ia, _e, ok) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let ia = ia.trim().to_string();
assert_eq!(ia.len(), 64, "a 64-hex identity id: {ia:?}");
assert!(ia.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {ia:?}");
let (ib, _e2, ok2) = run(&["graph", "introspect", "--identity-id"], &renamed);
assert!(ok2, "exit success");
assert_eq!(ia, ib.trim(), "renamed twin -> same identity id");
let (ca, _e3, ok3) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (cb, _e4, ok4) = run(&["graph", "introspect", "--content-id"], &renamed);
assert!(ok3 && ok4, "exit success");
assert_ne!(ca.trim(), cb.trim(), "renamed twin -> different content ids");
}
/// Property (combinable id flags): `--content-id --identity-id` prints both ids,
/// one per line, content id first — each byte-equal to its single-flag output,
/// and the two differ (SIGNAL_DOC carries debug names the identity form blanks).
#[test]
fn graph_introspect_content_and_identity_id_combine() {
let (both, _e, ok) = run(&["graph", "introspect", "--content-id", "--identity-id"], SIGNAL_DOC);
assert!(ok, "exit success");
let lines: Vec<&str> = both.lines().collect();
assert_eq!(lines.len(), 2, "two lines, one id each: {both:?}");
let (content, _e2, ok2) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
let (identity, _e3, ok3) = run(&["graph", "introspect", "--identity-id"], SIGNAL_DOC);
assert!(ok2 && ok3, "exit success");
assert_eq!(lines[0], content.trim(), "content id first");
assert_eq!(lines[1], identity.trim(), "identity id second");
assert_ne!(lines[0], lines[1], "the two ids differ on a debug-named document");
}
/// Property (negative, mirror of the content-id rejection): `--identity-id` on a
/// bad op-list exits non-zero with the cause on stderr and prints no id.
#[test]
fn graph_introspect_identity_id_rejects_a_bad_document() {
let (stdout, stderr, ok) =
run(&["graph", "introspect", "--identity-id"], r#"[{"op":"add","type":"Nope"}]"#);
assert!(!ok, "non-zero exit on a bad op-list");
assert!(stdout.is_empty(), "no identity id emitted on error: {stdout}");
assert!(stderr.contains("Nope"), "names the cause: {stderr}");
}
/// Property (usage gate — the previously untested count!=1 dispatch path):
/// `graph introspect` with no selection flag is a usage fault — exit 2 with the
/// usage line on stderr.
#[test]
fn graph_introspect_no_flag_is_usage_exit_2() {
let (stderr, code) = run_code(&["graph", "introspect"], "");
assert_eq!(code, Some(2), "no selection flag is a usage error -> exit 2; stderr: {stderr}");
assert!(stderr.contains("Usage"), "prints the usage line: {stderr}");
}
/// A fresh, unique working directory for a test that persists content-addressed
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
fn temp_cwd(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
/// Run `aura <args>` in `dir` (no stdin); return (stdout, stderr, exit code).
fn run_in(dir: &std::path::Path, args: &[&str]) -> (String, String, Option<i32>) {
let out = Command::new(BIN)
.args(args)
.current_dir(dir)
.output()
.expect("binary runs");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code(),
)
}
/// The absolute path of a blueprint fixture shipped with this test crate.
fn fixture(name: &str) -> String {
format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// The absolute path of a demo blueprint shipped in the public examples/
/// gallery (#159 copy+prove) — distinct from `fixture()`, which reads the
/// internal tests/fixtures/ copies the emitter builds from.
fn example(name: &str) -> String {
format!("{}/examples/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// The id extracted from a `registered blueprint {id} ({path})` line.
/// Prefix-tolerant across the #194 convention change (the display form dropped
/// the glued `content:` prefix): the id is bare either way.
fn registered_id(stdout: &str) -> String {
stdout
.lines()
.find(|l| l.starts_with("registered blueprint "))
.expect("register line")
.trim_start_matches("registered blueprint ")
.split(' ')
.next()
.expect("id")
.trim_start_matches("content:")
.to_string()
}
/// Property (#196, the on-ramp): `aura graph register <blueprint.json>` parses
/// the document through the vocabulary, canonicalizes, and stores it content-
/// addressed under `runs/blueprints/<id>.json`, printing the id + store path.
#[test]
fn graph_register_stores_and_prints_content_id() {
let dir = temp_cwd("register");
let (stdout, stderr, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
// #194: the register verb prints the bare id — `registered blueprint <id> (<path>)`.
assert!(!stdout.contains("content:"), "register prints the bare id, no content: prefix: {stdout}");
let id = registered_id(&stdout);
assert_eq!(id.len(), 64, "a 64-hex content id: {id:?}");
assert!(id.chars().all(|c| c.is_ascii_hexdigit()), "hex only: {id:?}");
assert!(
dir.join("runs").join("blueprints").join(format!("{id}.json")).is_file(),
"stored under runs/blueprints/<id>.json: {stdout}"
);
}
/// Register is write-once content-addressed: the same document registers to the
/// same id, exit 0 both times (the idempotent `put_blueprint` contract).
#[test]
fn graph_register_is_idempotent() {
let dir = temp_cwd("register-idempotent");
let bp = example("r_sma.json");
let (out1, err1, code1) = run_in(&dir, &["graph", "register", &bp]);
let (out2, err2, code2) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(code1, Some(0), "first register: {out1} {err1}");
assert_eq!(code2, Some(0), "second register: {out2} {err2}");
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
}
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
/// shows. The open fixture leaves exactly the two SMA lengths unbound
/// (`bias.scale` is bound in the document).
#[test]
fn graph_params_lists_raw_axis_namespace() {
let dir = temp_cwd("params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}
/// Property (#196, file-mode --content-id): a blueprint FILE's printed content
/// id is exactly the store key `graph register` prints — the file is shape-
/// discriminated from the op-list form and canonicalized by the blueprint rules.
#[test]
fn graph_content_id_accepts_a_blueprint_file() {
let dir = temp_cwd("content-id-file");
let bp = example("r_sma.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let (id_out, id_err, id_code) = run_in(&dir, &["graph", "introspect", "--content-id", &bp]);
assert_eq!(id_code, Some(0), "content-id: {id_out} {id_err}");
assert_eq!(id_out.trim(), registered_id(&reg_out), "file content id == store key");
}
/// The one-mode guard extends over the new `--params` mode: zero modes and two
/// modes both refuse usage-exit-2, and the usage line names the new mode.
#[test]
fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() {
let dir = temp_cwd("mode-guard");
let (_out0, err0, code0) = run_in(&dir, &["graph", "introspect"]);
assert_eq!(code0, Some(2), "zero modes is usage exit 2: {err0}");
assert!(err0.contains("--params <FILE|ID>"), "usage line names --params: {err0}");
let (_out2, err2, code2) =
run_in(&dir, &["graph", "introspect", "--vocabulary", "--params", "x.json"]);
assert_eq!(code2, Some(2), "two modes is usage exit 2: {err2}");
assert!(err2.contains("Usage: aura graph introspect"), "prints the usage line: {err2}");
}
/// Property (#196, the register->params-by-id round trip): `--params <ID>`
/// with a registered blueprint's content id resolves through the project
/// store and prints exactly the same raw axis namespace `--params <FILE>`
/// prints for the same document — the advertised `<FILE|ID>` addressing is
/// two paths to the identical answer, not merely two accepted shapes.
#[test]
fn graph_params_by_content_id_matches_params_by_file() {
let dir = temp_cwd("params-by-id");
let bp = example("r_sma_open.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let (by_id_out, by_id_err, by_id_code) =
run_in(&dir, &["graph", "introspect", "--params", &id]);
assert_eq!(by_id_code, Some(0), "stdout: {by_id_out} stderr: {by_id_err}");
let (by_file_out, by_file_err, by_file_code) =
run_in(&dir, &["graph", "introspect", "--params", &bp]);
assert_eq!(by_file_code, Some(0), "stdout: {by_file_out} stderr: {by_file_err}");
assert_eq!(by_id_out, by_file_out, "by-id and by-file agree on the raw axis namespace");
assert_eq!(by_id_out, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
}
/// #194 (the prefix trap): a `--params <ID>` target may carry the display
/// `content:` prefix copied verbatim from register/introspect output — the CLI
/// strips it and resolves the bare store address, printing exactly the same raw
/// axis namespace the bare id (and the file) print.
#[test]
fn graph_params_tolerates_content_prefix_on_target() {
let dir = temp_cwd("params-content-prefix");
let bp = example("r_sma_open.json");
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
let id = registered_id(&reg_out);
let (pfx_out, pfx_err, pfx_code) =
run_in(&dir, &["graph", "introspect", "--params", &format!("content:{id}")]);
assert_eq!(pfx_code, Some(0), "content:-prefixed --params must resolve: stdout {pfx_out} stderr {pfx_err}");
assert_eq!(pfx_out, "fast.length:I64\nslow.length:I64\n", "same raw axis namespace as the bare id");
}
/// Property (#196, negative): `--params <ID>` with a well-shaped 64-hex id
/// that was never registered refuses with a store-miss message, not a panic
/// or a silent empty namespace — the registry lookup branch of
/// `resolve_blueprint_text` is exercised.
#[test]
fn graph_params_by_unregistered_id_reports_a_store_miss() {
let dir = temp_cwd("params-missing-id");
let bogus_id = "a".repeat(64);
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", &bogus_id]);
assert_ne!(code, Some(0), "non-zero exit on a store miss: {stdout} {stderr}");
assert!(stdout.is_empty(), "no partial namespace on a miss: {stdout}");
assert!(
stderr.contains(&format!("no blueprint {bogus_id} in the project store")),
"names the miss: {stderr}"
);
}
/// Property (#196, negative): `--params <TARGET>` where TARGET is neither an
/// existing file nor a 64-hex content id refuses naming both readings — the
/// `resolve_blueprint_text` fall-through branch, shared with `campaign run`'s
/// target resolution via `is_content_id`.
#[test]
fn graph_params_target_neither_file_nor_id_is_refused() {
let dir = temp_cwd("params-neither");
let (stdout, stderr, code) = run_in(&dir, &["graph", "introspect", "--params", "not-a-file-or-id"]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no partial namespace on refusal: {stdout}");
assert!(
stderr.contains("'not-a-file-or-id' is neither a readable .json file nor a 64-hex content id"),
"names both readings: {stderr}"
);
}
/// Property (#196, the op-list branch of file-mode --content-id): a FILE that
/// is a JSON *array* (a construction op-list, not a blueprint envelope) is
/// shape-discriminated correctly and produces the SAME content id as piping
/// the identical document to stdin — `composite_from_any`'s `Array` arm is
/// exercised end-to-end, not just its `Object` sibling.
#[test]
fn graph_content_id_accepts_an_op_list_file() {
let dir = temp_cwd("content-id-op-list-file");
let op_list_path = dir.join("op-list.json");
std::fs::write(&op_list_path, SIGNAL_DOC).expect("write op-list fixture");
let (file_out, file_err, file_code) = run_in(
&dir,
&["graph", "introspect", "--content-id", op_list_path.to_str().unwrap()],
);
assert_eq!(file_code, Some(0), "stdout: {file_out} stderr: {file_err}");
let (stdin_out, _e, stdin_ok) = run(&["graph", "introspect", "--content-id"], SIGNAL_DOC);
assert!(stdin_ok, "stdin variant succeeds");
assert_eq!(file_out.trim(), stdin_out.trim(), "op-list file and stdin agree on content id");
}
/// Property (#196, negative): a FILE whose top-level JSON value is neither an
/// object (blueprint envelope) nor an array (op-list) — e.g. a bare string —
/// is refused, exercising `composite_from_any`'s fall-through arm.
#[test]
fn graph_content_id_rejects_a_file_that_is_neither_object_nor_array() {
let dir = temp_cwd("content-id-neither-shape");
let bad_path = dir.join("bad.json");
std::fs::write(&bad_path, "\"just a string\"").expect("write bad fixture");
let (stdout, stderr, code) = run_in(
&dir,
&["graph", "introspect", "--content-id", bad_path.to_str().unwrap()],
);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no content id emitted: {stdout}");
assert!(
stderr.contains("document is neither a blueprint envelope (object) nor an op-list (array)"),
"names the shape refusal: {stderr}"
);
}
/// Property (#196, negative): `register` on a syntactically-valid blueprint
/// envelope that names an unknown node type refuses with the `blueprint_load_prose`
/// `UnknownNodeType` prose (not a panic, not a bare Debug dump) — this refusal
/// path is shared by `register`, `--params`, and file-mode `--content-id` (all
/// route through `blueprint_from_json` -> `blueprint_load_prose`), so exercising
/// it once through `register` closes the whole family.
#[test]
fn graph_register_rejects_an_unknown_node_type_with_prose() {
let dir = temp_cwd("register-unknown-node");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "register", &fixture("unknown_node.json")]);
assert_ne!(code, Some(0), "non-zero exit: {stdout} {stderr}");
assert!(stdout.is_empty(), "no registration line on refusal: {stdout}");
assert!(
stderr.contains("unknown node type \"Nope\""),
"names the unknown node type: {stderr}"
);
}
/// Property (#202 headline, the on-ramp is shape-invariant): `aura graph register`
/// accepts an op-script document (a JSON array) exactly as it accepts a #155
/// blueprint envelope (a JSON object) — it shape-discriminates the array form,
/// builds it through the same one-build tail, and stores it under the SAME content
/// id it stores that op-script's `graph build` envelope under. The content id is
/// shape-invariant (an op-script and its built envelope hash identically), so the
/// two on-ramp shapes must agree on the store key; `register` must not reject the
/// op-script with the raw serde `invalid type: map, expected u32` message the
/// direct-`blueprint_from_json` path currently leaks.
#[test]
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
let dir = temp_cwd("register-op-script");
// The op-script form (a JSON array of ops), written to a file.
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
// Its `graph build` envelope (a JSON object), the shape `register` already accepts.
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
// The envelope shape registers today (green sibling); the op-script must too.
let (env_out, env_err, env_code) =
run_in(&dir, &["graph", "register", envelope.to_str().unwrap()]);
assert_eq!(env_code, Some(0), "register envelope: {env_out} {env_err}");
let (op_out, op_err, op_code) =
run_in(&dir, &["graph", "register", op_script.to_str().unwrap()]);
assert_eq!(op_code, Some(0), "register op-script exits 0: {op_out} {op_err}");
assert_eq!(
registered_id(&op_out),
registered_id(&env_out),
"op-script and its built envelope register to the same content id"
);
}
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
/// introspect --params` accepts an op-script exactly as it accepts a blueprint
/// envelope — the raw axis namespace it prints for the op-script equals the one it
/// prints for that op-script's built envelope, both exit 0. Currently `--params`
/// rejects the op-script with the same raw serde `invalid type: map, expected u32`
/// message `register` does (both route through the direct `blueprint_from_json`).
#[test]
fn graph_params_accepts_an_op_script_matching_its_envelope() {
let dir = temp_cwd("params-op-script");
let op_script = dir.join("op-script.json");
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
let (env_out, env_err, env_code) =
run_in(&dir, &["graph", "introspect", "--params", envelope.to_str().unwrap()]);
assert_eq!(env_code, Some(0), "params on envelope: {env_out} {env_err}");
let (op_out, op_err, op_code) =
run_in(&dir, &["graph", "introspect", "--params", op_script.to_str().unwrap()]);
assert_eq!(op_code, Some(0), "params on op-script exits 0: {op_out} {op_err}");
assert_eq!(op_out, env_out, "op-script and envelope agree on the raw axis namespace");
}
/// Property (#159): the shipped closed example (`examples/r_sma.json`) is
/// genuinely closed, not merely byte-identical to the builder — `graph
/// introspect --params` on it reports zero unbound params. The Cut-1
/// byte-fidelity proof (`shipped_r_sma_examples_are_byte_identical_to_the_builder`,
/// main.rs) only pins the example's bytes to the builder's serialization; it says
/// nothing about whether that serialization is actually closed. This pins the
/// "closed" claim the demo makes to its audience.
#[test]
fn shipped_r_sma_example_is_genuinely_closed() {
let dir = temp_cwd("example-closed-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159): the shipped open example (`examples/r_sma_open.json`) is a
/// usable sweep-axis template from its public gallery location, not just a
/// byte-identical artifact — `graph introspect --params` lists exactly the two
/// unbound SMA lengths, in lowering order, matching the raw axis namespace the
/// internal `sma_signal_open.json` fixture prints (#196). The byte-fidelity
/// proof only checks the example's bytes match the builder; this checks the
/// shipped copy is actually introspectable/bindable from where a consumer runs
/// it.
#[test]
fn shipped_r_sma_open_example_lists_its_axis_namespace() {
let dir = temp_cwd("example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "fast.length:I64\nslow.length:I64\n",
"the raw open params, in order, from the public gallery copy"
);
}
/// Property (#159 cut 2): the shipped closed example (`examples/r_breakout.json`) is
/// genuinely closed — `graph introspect --params` reports zero unbound params.
#[test]
fn shipped_r_breakout_example_is_genuinely_closed() {
let dir = temp_cwd("r-breakout-example-closed-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159 cut 2): the shipped open example (`examples/r_breakout_open.json`)
/// lists its two channel axes, in lowering order (per-node exposure; ganging is #61).
#[test]
fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
let dir = temp_cwd("r-breakout-example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "channel_hi.length:I64\nchannel_lo.length:I64\n",
"the raw open params, in order, from the public gallery copy"
);
}
/// Property (#159 cut 3): the shipped closed example (`examples/r_meanrev.json`) is
/// genuinely closed — `graph introspect --params` reports zero unbound params.
#[test]
fn shipped_r_meanrev_example_is_genuinely_closed() {
let dir = temp_cwd("r-meanrev-example-closed-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
}
/// Property (#159 cut 3): the shipped open example (`examples/r_meanrev_open.json`)
/// lists its raw axis namespace, in lowering (node-add) order.
#[test]
fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
let dir = temp_cwd("r-meanrev-example-open-params");
let (stdout, stderr, code) =
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev_open.json")]);
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
assert_eq!(
stdout, "mean_window.length:I64\nvar_window.length:I64\nband.factor:F64\n",
"the raw open params, in lowering order"
);
}