//! 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` subcommands here drive these ops through the //! injected `aura_vocabulary::std_vocabulary`. use std::collections::BTreeMap; use std::path::Path; use aura_engine::{ blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError, CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind, }; use serde::Deserialize; // `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in // production code; tests still exercise it directly. #[cfg(test)] use aura_vocabulary::std_vocabulary; /// The op-list reference `aura graph build --help` appends (#323): the nine /// op kinds with their fields and one worked element each. Lives beside /// [`OpDoc`] so a new op variant is one screen away from its help line. pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order): {"op":"source","role":"price","kind":"F64"} declare a bound root input role of a scalar kind {"op":"input","role":"price"} declare an open input role (wired by an enclosing graph) {"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}} instantiate a node ("name" optional; "bind" maps param -> typed scalar) {"op":"feed","role":"price","into":["fast.series","slow.series"]} wire a role into one or more input slots {"op":"connect","from":"fast.value","to":"sub.lhs"} wire a node output into an input slot {"op":"expose","from":"sub.value","as":"bias"} name a graph output field {"op":"tap","from":"sub.value","as":"spread"} declare a recordable tap on a wire (expose's output-side twin) {"op":"gang","as":"length","into":["fast.length","slow.length"]} fuse two or more sibling params into one public knob {"op":"doc","text":"..."} declare the composite's one-line meaning (C29) Node types and their ports: aura graph introspect --vocabulary | --node "#; /// 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", deny_unknown_fields)] enum OpDoc { Source { role: String, kind: ScalarKind }, Input { role: String }, Add { #[serde(rename = "type")] type_id: String, // `name` mirrors the builder's `.named("fast")`; `add`'s node identifier is a // naming, not an aliasing (contrast `expose`'s `as`, which is a real alias). #[serde(rename = "name", default)] as_name: Option, #[serde(default)] bind: BTreeMap, }, Feed { role: String, into: Vec }, Connect { from: String, to: String }, Expose { from: String, #[serde(rename = "as")] as_name: String, }, Tap { from: String, #[serde(rename = "as")] as_name: String, }, Gang { #[serde(rename = "as")] as_name: String, into: Vec, }, /// Declare the composite's one-line meaning (C29, #316) — the op-script /// twin of the builder's `.doc(...)`. Doc { text: 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", OpDoc::Tap { .. } => "tap", OpDoc::Gang { .. } => "gang", OpDoc::Doc { .. } => "doc", } } } impl From 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 }, OpDoc::Tap { from, as_name } => Op::Tap { from, as_name }, OpDoc::Gang { as_name, into } => Op::Gang { as_name, into }, OpDoc::Doc { text } => Op::Doc { text }, } } } /// 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::DuplicateTap(s) => format!("duplicate tap 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::WouldCycle { from, to } => format!("connecting {from} -> {to} would close a cycle"), OpError::BadParam { node, err } => match err { BindOpError::UnknownParam(p) => format!("node {node} has no param {p:?}"), BindOpError::AmbiguousParam(p) => format!("node {node} has ambiguous param {p:?}"), BindOpError::KindMismatch { param, expected, got } => { format!("param {node}.{param} expects {expected:?} but got {got:?}") } }, OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"), OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"), OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"), OpError::GangKindMismatch { member, expected, got } => { format!("gang: member `{member}` is {got:?}, expected {expected:?}") } OpError::AlreadyGanged { node, param } => { format!("gang: `{node}.{param}` is already ganged") } OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"), OpError::Incomplete(ce) => format!("{ce:?}"), OpError::DuplicateDoc => "a doc op may appear at most once".to_string(), } } /// Parse a JSON op-list document and replay it through the env's vocabulary into /// a built `Composite` — 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). fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result { let docs: Vec = 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 = docs.into_iter().map(Op::from).collect(); replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| { let mut cause = format_op_error(&err); // #244: the op-script path reports an unresolvable namespaced type as // `OpError::UnknownNodeType`, a different error family than the // blueprint LOAD path's `LoadError::UnknownNodeType` — so without this, // `unresolved_namespace_hint` is never consulted here. Same tier hint, // appended the same way. if let OpError::UnknownNodeType(t) = &err && let Some(hint) = tier_hint_for_type_id(t, env) { cause.push_str(" — "); cause.push_str(&hint); } match labels.get(idx) { Some(kind) => format!("op {idx} ({kind}): {cause}"), None => format!("finalize: {cause}"), } }) } /// Parse a JSON op-list document, replay it through the env's vocabulary, and /// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`. pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result { let composite = composite_from_str(doc, env)?; blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")) } /// `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(env: &aura_runner::project::Env) { 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(1); } match build_from_str(&doc, env) { // `print!`, not `println!`: the canonical artifact is exactly the library // `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value // with no trailing newline. The CLI is a transport; it must not frame the // canonical bytes (#164). Ok(json) => print!("{json}"), Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(1); } } } /// `aura graph introspect --node `: 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, env: &aura_runner::project::Env) -> Result { let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let schema = builder.schema(); // C29 (#315): the head line carries the node's one-line meaning. let mut out = format!("{} — {}\n", builder.label(), schema.doc); 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 {}:{:?} (bind {{\"{:?}\": }})\n", p.name, p.kind, 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, env: &aura_runner::project::Env) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let resolver = |t: &str| env.resolve(t); let mut session = GraphSession::new("introspect", &resolver); 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 read-only queries. Exactly one of /// `--vocabulary` / `--node ` / `--unwired` / `--params ` / the id /// group must be set; zero or more than one is the usage error (exit 2). The id /// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may /// combine (one build, both ids, content id first). pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) { let count = cmd.vocabulary as usize + cmd.node.is_some() as usize + cmd.unwired as usize + cmd.folds as usize + cmd.params.is_some() as usize + (cmd.content_id.is_some() || cmd.identity_id) as usize; if count != 1 { eprintln!( "aura: Usage: aura graph introspect --vocabulary | --node | --unwired | --folds | --params | --content-id [FILE] | --identity-id (the two id flags may be combined)" ); std::process::exit(2); } if cmd.vocabulary { // C29 (#315): each type id carries its schema's one-line meaning — // bare names teach nothing (What do Latch, When, Select, Bias do?). // A rostered id that fails to resolve is an invariant breach; better // loud than a silent C29-incomplete bare row. for t in env.type_ids() { let b = env.resolve(t).expect("every rostered type id resolves to a builder"); println!("{t:<18} {}", b.schema().doc); } } else if cmd.folds { // The fold vocabulary binds at graph-declared taps (C27), so the // graph introspect namespace is its discovery surface (#315). #332: // this renders the fold-REGISTRY roster — the same roster `aura run // --tap TAP=FOLD` resolves labels against (aura-runner's layered // `FoldRegistry`) — not the aura-std `FoldKind` table: labels here // must be exactly what `--tap` accepts (lowercase), including the // `record` entry that has no `FoldKind` counterpart. for (label, doc) in aura_runner::FoldRegistry::core().roster() { println!("{label:<7} — {doc}"); } } else if let Some(type_id) = cmd.node.as_deref() { match introspect_node(type_id, env) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); std::process::exit(2); } } } else if cmd.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(1); } match introspect_unwired(&doc, env) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); std::process::exit(1); } } } else if let Some(target) = cmd.params.as_deref() { // --params (#196): the RAW composite param space — exactly the // namespace campaign axes are validated against (`validate_campaign_refs` // checks the raw space; the wrapped `--list-axes` namespace on `aura sweep` // is the sweep-verb view, not the campaign view). match params_lines(target, env) { Ok(s) => print!("{s}"), Err(m) => { eprintln!("aura: {m}"); std::process::exit(1); } } } else { // --content-id [FILE] / --identity-id (combinable): one build, then each // requested id on its own line, content id first. With a FILE value the // document is read from it, shape-discriminated (#196): a JSON array is a // construction op-list, a JSON object the #155 blueprint envelope — so a // registered blueprint's printed id is exactly its store key. Without a // FILE the op-list is read from stdin (the pre-#196 behaviour, unchanged). // The content id (#158) is the SHA256 of the same `blueprint_to_json` // bytes `graph build` emits; the identity id (#171) the SHA256 of the // debug-name-blind `blueprint_identity_json` form — both via the one // shared `crate::content_id` primitive `topology_hash` also uses, so all // surfaces agree by construction. let composite = match cmd.content_id.as_ref() { Some(Some(file)) => { let text = match std::fs::read_to_string(file) { Ok(t) => t, Err(e) => { eprintln!("aura: cannot read {}: {e}", file.display()); std::process::exit(1); } }; match composite_from_any(&text, env) { Ok(c) => c, Err(m) => { eprintln!("aura: {m}"); std::process::exit(1); } } } _ => { 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(1); } match composite_from_str(&doc, env) { Ok(c) => c, Err(m) => { eprintln!("aura: {m}"); std::process::exit(1); } } } }; if cmd.content_id.is_some() { match blueprint_to_json(&composite) { Ok(json) => println!("{}", crate::content_id(&json)), Err(e) => { eprintln!("aura: serialize error: {e:?}"); std::process::exit(1); } } } if cmd.identity_id { match blueprint_identity_json(&composite) { Ok(json) => println!("{}", crate::content_id(&json)), Err(e) => { eprintln!("aura: serialize error: {e:?}"); std::process::exit(1); } } } } } /// Phrase a blueprint-document `LoadError` as prose (Debug-leak-free; the /// engine error types are `Display`-free by convention — this is the CLI's /// presentation layer, like `format_op_error`). `pub(crate)`: shared by the /// `graph build` path (below) and `aura run`'s loaded-blueprint branch /// (main.rs) — env-agnostic on purpose, so both callers get identical wording /// (#184). pub(crate) fn blueprint_load_prose(e: &LoadError) -> String { match e { LoadError::Json(err) => format!("blueprint document is not valid JSON: {err}"), LoadError::UnsupportedVersion { found, supported } => { format!("blueprint format_version {found} is unsupported (this build reads {supported})") } LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"), LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)), } } /// Phrase a gang-table `CompileError` as prose (Debug-leak-free, mirroring /// `blueprint_load_prose`'s convention). `with_gangs` — the only producer of /// `LoadError::Gang` — always yields `CompileError::BadGang`; a bare fallback /// covers the type's other variants without ever printing a raw `{e:?}`. fn gang_fault_prose(e: &CompileError) -> String { use aura_engine::GangFault as F; let CompileError::BadGang(fault) = e else { return "malformed gang table".to_string(); }; match fault { F::BadName { gang } => format!("gang {gang:?} has an empty or dotted name"), F::TooFewMembers { gang } => format!("gang {gang:?} has fewer than two members"), F::NodeOutOfRange { gang, node } => { format!("gang {gang:?} references out-of-range node {node}") } F::NotAPrimitive { gang, node } => { format!("gang {gang:?} member node {node} is not a primitive") } F::NoOpenParam { gang, node, name } => { format!("gang {gang:?} member node {node} has no open param {name:?}") } F::PosMismatch { gang, node, expected, got } => format!( "gang {gang:?} member node {node} declares position {got} but the param sits at {expected}" ), F::KindMismatch { gang, node, name, expected, got } => format!( "gang {gang:?} member node {node} param {name:?} is {got:?} but the gang expects {expected:?}" ), F::MemberInTwoGangs { gang, node, pos } => format!( "gang {gang:?} member (node {node}, pos {pos}) already belongs to another gang" ), } } /// (#185/#241/#244) When an unresolved type id LOOKS project-namespaced /// (`::`), the likeliest causes are environmental, tiered: outside any /// project the hint points at the missing `Aura.toml`; inside a data-only /// project it points at the missing node crate. Bare std ids (no `::`) and /// invocations with a loaded node crate get no hint (the vocabulary error /// carries the message). Shared core: both the blueprint LOAD path /// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph /// build` path (`composite_from_str`, over `OpError`) call this same helper, /// so the tier texts cannot drift between the two error families. fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option { if !type_id.contains("::") { return None; } match env.provenance() { None => Some( "type id looks project-namespaced but no Aura.toml was found — run inside a project directory" .to_string(), ), Some(p) if p.namespace.is_none() => Some( "type id looks project-namespaced but this project binds no node crate — attach one with `aura nodes new `" .to_string(), ), Some(_) => None, } } /// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the /// blueprint LOAD path's entry point over `LoadError`. pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option { match e { LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env), _ => None, } } /// Validate a blueprint-slot document — the `sweep`/`walkforward`/`mc` /// loaded-blueprint branches' dispatch-boundary check (#210 c0110 fieldtest /// finding: the sweep slot used to print the raw `{e:?}` Debug leak). Single- /// sourced here so the three dual-grammar subcommands cannot diverge (#184's /// convention, extended). Two refusals, checked in order: /// /// 1. The document parses as JSON but is an op-script ARRAY (#157), not a /// built blueprint envelope — refused with a targeted hint pointing at /// `aura graph build`, since `blueprint_from_json` would otherwise surface /// a confusing internal serde shape mismatch. /// 2. `blueprint_from_json` fails to load the envelope — phrased house-style /// via [`blueprint_load_prose`] (+ [`unresolved_namespace_hint`] where it /// applies), never the raw `LoadError` Debug form. /// /// `Ok(())` means the document loaded cleanly; callers that only need the /// validation (not the `Composite`) re-parse `doc` themselves afterward /// (`blueprint_from_json` is cheap and infallible at that point). pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> { if matches!(serde_json::from_str::(doc), Ok(serde_json::Value::Array(_))) { return Err( "this is an op-script (an op array), not a built blueprint — run \ 'aura graph build' first" .to_string(), ); } blueprint_from_json(doc, &|t| env.resolve(t)).map(|_| ()).map_err(|e| { let mut msg = blueprint_load_prose(&e); if let Some(hint) = unresolved_namespace_hint(&e, env) { msg.push_str(" — "); msg.push_str(&hint); } msg }) } /// #196: build a `Composite` from a document that is EITHER a #155 blueprint /// envelope (a JSON object: `format_version` + `blueprint`) OR a construction /// op-list (a JSON array) — shape-discriminated on the top-level JSON type, /// each canonicalized by its own rules. pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result { let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?; match value { serde_json::Value::Array(_) => composite_from_str(text, env), serde_json::Value::Object(_) => blueprint_from_json(text, &|t| env.resolve(t)).map_err(|e| { let mut msg = blueprint_load_prose(&e); if let Some(hint) = unresolved_namespace_hint(&e, env) { msg.push_str(" — "); msg.push_str(&hint); } msg }), _ => Err("document is neither a blueprint envelope (object) nor an op-list (array)".into()), } } /// Resolve a blueprint document's bytes from a file path or a 64-hex content /// id in the project store (the campaign-run target-addressing convention). /// The id shape is `aura_runner::axes::is_content_id` — the same predicate /// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces /// cannot drift apart on what counts as a store address. fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result { // A CLI arg tolerates the `content:` display prefix (#194); doc ref // fields stay bare-only. let target = target .strip_prefix("content:") .filter(|t| aura_runner::axes::is_content_id(t)) .unwrap_or(target); let path = Path::new(target); if path.is_file() { return std::fs::read_to_string(path) .map_err(|e| format!("cannot read {}: {e}", path.display())); } if aura_runner::axes::is_content_id(target) { return match env.registry().get_blueprint(target) { Ok(Some(json)) => Ok(json), Ok(None) => Err(format!("no blueprint {target} in the project store")), Err(e) => Err(e.to_string()), }; } Err(format!("'{target}' is neither a readable .json file nor a 64-hex content id")) } /// `aura graph introspect --params ` (#196): one `{name}:{kind:?}` /// line per open param of the RAW composite (no harness wrap) — the /// campaign-axis namespace `validate_campaign_refs` checks axes against. The /// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`), /// the same form `introspect --node` already uses for param kinds. fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result { use std::fmt::Write as _; let text = resolve_blueprint_text(target, env)?; // Shape-discriminated like file-mode --content-id: an op-script (array) // builds through the one-build tail, an envelope (object) loads (#202). let composite = composite_from_any(&text, env)?; let mut out = String::new(); for p in composite.param_space() { let _ = writeln!(out, "{}:{:?}", p.name, p.kind); } Ok(out) } /// `aura graph register ` (#196): parse the blueprint through /// the project vocabulary, canonicalize, content-address, and store — the /// `process register` pattern, printing the store path so the trail is /// followable. Prose to stderr + exit 1 on any refusal. pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) { match register_blueprint(file, env) { Ok(line) => println!("{line}"), Err(m) => { eprintln!("aura: {m}"); std::process::exit(1); } } } fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result { let text = std::fs::read_to_string(file) .map_err(|e| format!("cannot read {}: {e}", file.display()))?; // Shape-discriminated like file-mode --content-id (#202): either shape // canonicalizes to the envelope form, so the stored id is shape-invariant. let composite = composite_from_any(&text, env)?; let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?; let id = crate::content_id(&canonical); let registry = env.registry(); registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?; Ok(format!( "registered blueprint {id} ({})", registry.blueprint_path(&id).display() )) } #[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","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"} ]"#; let docs: Vec = serde_json::from_str(doc).expect("document parses"); assert_eq!(docs.len(), 10); assert_eq!(docs[1].kind_label(), "add"); let ops: Vec = 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","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"} ]"#; let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds"); assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}"); assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}"); } /// `add` names a node via `name` (mirroring the builder's `.named("fast")`), /// not the old SQL-ish `as`: the identifier resolves in later ops and the /// emitted #155 blueprint carries it. #[test] fn add_names_node_via_name_key() { let doc = r#"[ {"op":"source","role":"price","kind":"F64"}, {"op":"add","type":"SMA","name":"fast"}, {"op":"feed","role":"price","into":["fast.series"]}, {"op":"expose","from":"fast.value","as":"out"} ]"#; let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds"); assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}"); } #[test] fn build_from_str_reports_unknown_node_at_its_op_index() { let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#; let err = super::build_from_str(doc, &aura_runner::project::Env::std()).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","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"} ]"#; let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err(); assert!(err.starts_with("finalize: "), "finalize fault, got: {err}"); assert!(err.contains("sub.rhs") && err.contains("is unconnected"), "names the unconnected slot by-identifier, got: {err}"); assert!(!err.contains("node:"), "no raw machine index in the message, got: {err}"); } #[test] fn introspect_node_lists_ports_kinds_and_params() { let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).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", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type"); } /// A bind whose value-kind mismatches the param reads as prose, like the connect /// mismatch — not a Debug struct. #[test] fn build_from_str_bad_bind_kind_reads_as_prose() { let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#; let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err(); assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64"); } /// A bind to a param the node does not declare reads as prose naming the node /// and the unknown param — not a Debug struct. #[test] fn build_from_str_unknown_bind_param_reads_as_prose() { let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#; let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err(); assert_eq!(err, "op 0 (add): node fast has no param \"window\""); } /// The ambiguous-param branch of the bind-error presenter is phrased as prose /// naming the node and the ambiguous param. No std primitive carries two /// same-named params, so the branch is unreachable end-to-end and is pinned on /// the presenter directly. #[test] fn ambiguous_bind_param_reads_as_prose() { let msg = format_op_error(&OpError::BadParam { node: "fast".to_string(), err: BindOpError::AmbiguousParam("length".to_string()), }); assert_eq!(msg, "node fast has ambiguous param \"length\""); } /// `introspect --node` shows the typed-Scalar bind-value form so a hand-author /// does not have to read source to learn the `{"I64": }` wrapping. #[test] fn introspect_node_shows_the_bind_form() { let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary"); assert!(out.contains("(bind {\"I64\": })"), "shows the bind-value form: {out}"); } #[test] fn introspect_unwired_reports_open_slots_of_a_partial_document() { let doc = r#"[ {"op":"add","type":"SMA","name":"fast"}, {"op":"add","type":"Sub"}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).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}"); } /// The `tap` op-doc parses (externally tagged, `"as"`-renamed like `expose`) /// and `aura graph build` emits a blueprint whose serialized `taps` names the /// wire — the name-addressed authoring path the fieldtest wanted, no raw index. #[test] fn tap_opdoc_builds_a_blueprint_carrying_the_tap() { let doc = 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":"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":"tap","from":"fast.value","as":"fast_ma"}, {"op":"expose","from":"sub.value","as":"bias"} ]"#; // the tap-doc kind_label registers as "tap" let docs: Vec = serde_json::from_str(doc).expect("document parses"); assert_eq!(docs[7].kind_label(), "tap"); // and the built blueprint carries the tap (an un-tapped composite emits no // `taps` key, so its presence is the proof the op threaded through) let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds"); assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}"); assert!(json.contains("fast_ma"), "the tap names the wire: {json}"); } }