feat(0088): §C construction op-script CLI — aura graph build/introspect
Iteration-2 §C of the construction service (#157), the CLI shell over the iteration-1 engine core (ea1ca32+27ac4dc): a declarative, replayable JSON op-list document drives the engine's per-op-fallible construction surface. - graph_construct.rs (new, aura-cli): an `OpDoc` serde DTO (`#[serde(tag="op", rename_all="lowercase")]` + field renames type/as) deserializes the document and maps 1:1 into the serde-free engine `Op` — the wire format is a CLI concern, the engine owns no second representation. Bind values use the typed Scalar form (`{"I64":2}`), kinds the capitalized #155 form (`"F64"`). - `aura graph build`: read the op-list from stdin, replay through std_vocabulary, emit the #155 blueprint JSON on success; on the first failing op print `op N (kind): cause` to stderr (the op-kind recovered from the retained parsed list) / `finalize: cause` for a holistic fault, and exit non-zero. A CLI-side format_op_error phrases each OpError (the engine error types stay Display-free). - `aura graph introspect`: `--vocabulary` (std_vocabulary_types), `--node <T>` (a type's ports/kinds + param paths off the pre-build schema), `--unwired` (a partial document's open slots via GraphSession::unwired) — all build-free. - Two new argv arms over the flat match; the bare `["graph"]` HTML-render arm (sample_blueprint, #159's concern) is left intact. #157 acceptance now demonstrated through the CLI (E2E in tests/graph_construct.rs, 8 tests): a document builds to the #155 blueprint that compiles identical to its Rust-built twin (C1); an invalid op is rejected at the op, named; introspection answers vocabulary / ports+kinds / unwired slots without a build. Full suite green (51 suites); clippy --all-targets -D warnings clean. The JSON op-list reuses #155's Scalar/ScalarKind serde shapes; full harnesses (SimBroker/Recorder construction-arg builders) await #156. Wire-format forks derived + recorded on #157. refs #157
This commit is contained in:
@@ -0,0 +1,295 @@
|
|||||||
|
//! The CLI-side serde front-end for the construction op-script (#157, §C): a
|
||||||
|
//! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op`
|
||||||
|
//! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph
|
||||||
|
//! build` / `aura graph introspect` surfaces that drive these ops through the
|
||||||
|
//! injected `aura_std::std_vocabulary` land in later tasks.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use aura_engine::{blueprint_to_json, replay, GraphSession, Op, OpError, Scalar, ScalarKind};
|
||||||
|
use aura_std::{std_vocabulary, std_vocabulary_types};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// The wire DTO for one construction op — the document's by-identifier shape,
|
||||||
|
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
|
||||||
|
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
|
||||||
|
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "op", rename_all = "lowercase")]
|
||||||
|
enum OpDoc {
|
||||||
|
Source { role: String, kind: ScalarKind },
|
||||||
|
Input { role: String },
|
||||||
|
Add {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
type_id: String,
|
||||||
|
#[serde(rename = "as", default)]
|
||||||
|
as_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
bind: BTreeMap<String, Scalar>,
|
||||||
|
},
|
||||||
|
Feed { role: String, into: Vec<String> },
|
||||||
|
Connect { from: String, to: String },
|
||||||
|
Expose {
|
||||||
|
from: String,
|
||||||
|
#[serde(rename = "as")]
|
||||||
|
as_name: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpDoc {
|
||||||
|
/// The op-kind label for the `op N (kind): cause` message.
|
||||||
|
fn kind_label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
OpDoc::Source { .. } => "source",
|
||||||
|
OpDoc::Input { .. } => "input",
|
||||||
|
OpDoc::Add { .. } => "add",
|
||||||
|
OpDoc::Feed { .. } => "feed",
|
||||||
|
OpDoc::Connect { .. } => "connect",
|
||||||
|
OpDoc::Expose { .. } => "expose",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<OpDoc> for Op {
|
||||||
|
fn from(d: OpDoc) -> Op {
|
||||||
|
match d {
|
||||||
|
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
||||||
|
OpDoc::Input { role } => Op::Input { role },
|
||||||
|
OpDoc::Add { type_id, as_name, bind } => {
|
||||||
|
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
|
||||||
|
}
|
||||||
|
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
||||||
|
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
||||||
|
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phrase one `OpError` as a human cause (the engine error types are
|
||||||
|
/// `Display`-free by convention; this is the CLI's presentation layer). The
|
||||||
|
/// exhaustive match means a new `OpError` variant forces an update here.
|
||||||
|
fn format_op_error(e: &OpError) -> String {
|
||||||
|
match e {
|
||||||
|
OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||||
|
OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"),
|
||||||
|
OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"),
|
||||||
|
OpError::DuplicateRole(s) => format!("duplicate role {s:?}"),
|
||||||
|
OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"),
|
||||||
|
OpError::UnknownRole(s) => format!("unknown role {s:?}"),
|
||||||
|
OpError::MalformedPort(s) => format!("malformed port reference {s:?}"),
|
||||||
|
OpError::UnknownInPort { node, name } => format!("node {node:?} has no input port {name:?}"),
|
||||||
|
OpError::AmbiguousInPort { node, name } => format!("node {node:?} has ambiguous input port {name:?}"),
|
||||||
|
OpError::UnknownOutPort { node, name } => format!("node {node:?} has no output field {name:?}"),
|
||||||
|
OpError::AmbiguousOutPort { node, name } => format!("node {node:?} has ambiguous output field {name:?}"),
|
||||||
|
OpError::KindMismatch { from, to, producer, consumer } => {
|
||||||
|
format!("kind mismatch {from} -> {to} (producer {producer:?}, consumer {consumer:?})")
|
||||||
|
}
|
||||||
|
OpError::SlotAlreadyWired { node, slot } => format!("slot {node}.{slot} already wired"),
|
||||||
|
OpError::BadParam { node, err } => format!("bad param on {node}: {err:?}"),
|
||||||
|
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a JSON op-list document, replay it through `std_vocabulary`, and return
|
||||||
|
/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op
|
||||||
|
/// fault, attributed by the retained op-kind list) / `finalize: cause` (a
|
||||||
|
/// holistic fault past the last op).
|
||||||
|
pub fn build_from_str(doc: &str) -> Result<String, String> {
|
||||||
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||||
|
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
||||||
|
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||||
|
match replay("graph", ops, &std_vocabulary) {
|
||||||
|
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
|
||||||
|
Err((idx, err)) => {
|
||||||
|
let cause = format_op_error(&err);
|
||||||
|
match labels.get(idx) {
|
||||||
|
Some(kind) => Err(format!("op {idx} ({kind}): {cause}")),
|
||||||
|
None => Err(format!("finalize: {cause}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
||||||
|
/// blueprint to stdout — or the cause to stderr and exit non-zero.
|
||||||
|
pub fn build_cmd() {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut doc = String::new();
|
||||||
|
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||||
|
eprintln!("aura: reading stdin: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
match build_from_str(&doc) {
|
||||||
|
Ok(json) => println!("{json}"),
|
||||||
|
Err(msg) => {
|
||||||
|
eprintln!("aura: {msg}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
|
||||||
|
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
|
||||||
|
/// closed vocabulary.
|
||||||
|
pub fn introspect_node(type_id: &str) -> Result<String, String> {
|
||||||
|
let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||||
|
let schema = builder.schema();
|
||||||
|
let mut out = format!("{}\n", builder.label());
|
||||||
|
for port in &schema.inputs {
|
||||||
|
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
||||||
|
}
|
||||||
|
for field in &schema.output {
|
||||||
|
out.push_str(&format!(" out {}:{:?}\n", field.name, field.kind));
|
||||||
|
}
|
||||||
|
for p in builder.params() {
|
||||||
|
out.push_str(&format!(" param {}:{:?}\n", p.name, p.kind));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||||
|
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||||
|
pub fn introspect_unwired(doc: &str) -> Result<String, String> {
|
||||||
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||||
|
let mut session = GraphSession::new("introspect", &std_vocabulary);
|
||||||
|
for (i, d) in docs.into_iter().enumerate() {
|
||||||
|
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
for (slot, kind) in session.unwired() {
|
||||||
|
out.push_str(&format!("{slot}:{kind:?}\n"));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `aura graph introspect`: dispatch the three read-only queries.
|
||||||
|
pub fn introspect_cmd(rest: &[&str]) {
|
||||||
|
match rest {
|
||||||
|
["--vocabulary"] => {
|
||||||
|
for t in std_vocabulary_types() {
|
||||||
|
println!("{t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
["--node", type_id] => match introspect_node(type_id) {
|
||||||
|
Ok(s) => print!("{s}"),
|
||||||
|
Err(m) => {
|
||||||
|
eprintln!("aura: {m}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
["--unwired"] => {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut doc = String::new();
|
||||||
|
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||||
|
eprintln!("aura: reading stdin: {e}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
match introspect_unwired(&doc) {
|
||||||
|
Ok(s) => print!("{s}"),
|
||||||
|
Err(m) => {
|
||||||
|
eprintln!("aura: {m}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("aura: usage: aura graph introspect --vocabulary | --node <T> | --unwired");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The spec's worked-example document deserializes into the op vocabulary and
|
||||||
|
/// maps into engine `Op`s that replay into a compilable signal blueprint —
|
||||||
|
/// the DTO is a faithful front-end over the engine surface.
|
||||||
|
#[test]
|
||||||
|
fn opdoc_document_deserializes_and_replays() {
|
||||||
|
let doc = r#"[
|
||||||
|
{"op":"source","role":"price","kind":"F64"},
|
||||||
|
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
||||||
|
{"op":"add","type":"SMA","as":"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"}
|
||||||
|
]"#;
|
||||||
|
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
|
||||||
|
assert_eq!(docs.len(), 10);
|
||||||
|
assert_eq!(docs[1].kind_label(), "add");
|
||||||
|
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||||
|
// both SMA lengths are bound, so the only open param is bias.scale (f64).
|
||||||
|
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
|
||||||
|
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_from_str_emits_blueprint_json_for_valid_document() {
|
||||||
|
let doc = r#"[
|
||||||
|
{"op":"source","role":"price","kind":"F64"},
|
||||||
|
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
||||||
|
{"op":"add","type":"SMA","as":"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"}
|
||||||
|
]"#;
|
||||||
|
let json = super::build_from_str(doc).expect("valid document builds");
|
||||||
|
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
||||||
|
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_from_str_reports_unknown_node_at_its_op_index() {
|
||||||
|
let doc = r#"[{"op":"add","type":"SMA","as":"fast"},{"op":"add","type":"Nope"}]"#;
|
||||||
|
let err = super::build_from_str(doc).unwrap_err();
|
||||||
|
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_from_str_reports_unconnected_slot_at_finalize() {
|
||||||
|
// sub.rhs left unwired -> the holistic 0-arm fires at the finalize step.
|
||||||
|
let doc = r#"[
|
||||||
|
{"op":"source","role":"price","kind":"F64"},
|
||||||
|
{"op":"add","type":"SMA","as":"fast"},
|
||||||
|
{"op":"add","type":"SMA","as":"slow"},
|
||||||
|
{"op":"add","type":"Sub"},
|
||||||
|
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||||
|
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||||
|
]"#;
|
||||||
|
let err = super::build_from_str(doc).unwrap_err();
|
||||||
|
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
||||||
|
assert!(err.contains("Unconnected"), "names the unconnected slot, got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn introspect_node_lists_ports_kinds_and_params() {
|
||||||
|
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary");
|
||||||
|
assert!(out.contains("series"), "lists the input port: {out}");
|
||||||
|
assert!(out.contains("value"), "lists the output field: {out}");
|
||||||
|
assert!(out.contains("length"), "lists the param path: {out}");
|
||||||
|
assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn introspect_unwired_reports_open_slots_of_a_partial_document() {
|
||||||
|
let doc = r#"[
|
||||||
|
{"op":"add","type":"SMA","as":"fast"},
|
||||||
|
{"op":"add","type":"Sub"},
|
||||||
|
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||||
|
]"#;
|
||||||
|
let out = super::introspect_unwired(doc).expect("partial document introspects");
|
||||||
|
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
|
||||||
|
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
|
||||||
|
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
//! first real-data backtest from the CLI.
|
//! first real-data backtest from the CLI.
|
||||||
|
|
||||||
mod render;
|
mod render;
|
||||||
|
mod graph_construct;
|
||||||
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
|
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
|
||||||
|
|
||||||
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
use aura_core::{zip_params, Cell, Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
||||||
@@ -3311,6 +3312,8 @@ fn main() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||||
|
["graph", "build"] => graph_construct::build_cmd(),
|
||||||
|
["graph", "introspect", rest @ ..] => graph_construct::introspect_cmd(rest),
|
||||||
["sweep", rest @ ..] => match parse_sweep_args(rest) {
|
["sweep", rest @ ..] => match parse_sweep_args(rest) {
|
||||||
Ok((strategy, name, persist, choice, grid)) => {
|
Ok((strategy, name, persist, choice, grid)) => {
|
||||||
run_sweep(strategy, &name, persist, DataSource::from_choice(choice), &grid)
|
run_sweep(strategy, &name, persist, DataSource::from_choice(choice), &grid)
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//! 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNAL_DOC: &str = r#"[
|
||||||
|
{"op":"source","role":"price","kind":"F64"},
|
||||||
|
{"op":"add","type":"SMA","as":"fast","bind":{"length":{"I64":2}}},
|
||||||
|
{"op":"add","type":"SMA","as":"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","as":"fast"},
|
||||||
|
{"op":"add","type":"SMA","as":"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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graph_build_fails_fast_at_the_offending_op() {
|
||||||
|
let doc = r#"[{"op":"add","type":"SMA","as":"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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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","as":"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("Unconnected"), "names the unconnected slot: {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}");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user