diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 0b2d5c8..5b025f5 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -54,6 +54,9 @@ enum OpDoc { 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 { @@ -68,6 +71,7 @@ impl OpDoc { OpDoc::Expose { .. } => "expose", OpDoc::Tap { .. } => "tap", OpDoc::Gang { .. } => "gang", + OpDoc::Doc { .. } => "doc", } } } @@ -85,6 +89,7 @@ impl From for Op { 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 }, } } } @@ -129,6 +134,7 @@ fn format_op_error(e: &OpError) -> String { } 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(), } } diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 0f66f24..86e34a6 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -54,6 +54,40 @@ const SIGNAL_DOC: &str = r#"[ {"op":"expose","from":"bias.bias","as":"bias"} ]"#; +/// SIGNAL_DOC with the C29 doc op — the register-reaching variant (a store +/// write requires a described composite; build-only tests keep SIGNAL_DOC). +const SIGNAL_DOC_DESCRIBED: &str = r#"[ + {"op":"doc","text":"fast/slow SMA difference clamped into a directional bias"}, + {"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"} +]"#; + +/// SIGNAL_DOC with a `doc` op whose text merely restates the op-script +/// composite's name ("graph", the literal `replay("graph", ..)` gives every +/// CLI-built op-script) -- the RestatesName arm of the C29 shape gate, +/// reached via the op-script route rather than a builder-authored blueprint. +const SIGNAL_DOC_NAME_RESTATED: &str = r#"[ + {"op":"doc","text":"Graph"}, + {"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. @@ -74,6 +108,21 @@ fn graph_build_emits_blueprint_for_a_valid_document() { assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}"); } +/// C29 (#316): the op-script `doc` op's text lands verbatim in the built +/// blueprint's `doc` field -- `graph build` is a pure translation from ops to +/// the canonical envelope, so a doc op is not consumed or reworded on the way +/// through, only threaded (the shape gate is a separate, register-time check; +/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`). +#[test] +fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() { + let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED); + assert!(ok, "exit success"); + assert!( + stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#), + "the doc op's exact text appears in the blueprint's doc field: {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). @@ -688,11 +737,12 @@ fn graph_register_rejects_an_unknown_node_type_with_prose() { #[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. + // The op-script form (a JSON array of ops), written to a file. Register + // requires a described composite (C29), so the described twin is used here. let op_script = dir.join("op-script.json"); - std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture"); + std::fs::write(&op_script, SIGNAL_DOC_DESCRIBED).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); + let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED); assert!(built, "graph build produces the envelope"); let envelope = dir.join("envelope.json"); std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture"); @@ -710,6 +760,33 @@ fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() { registered_id(&env_out), "op-script and its built envelope register to the same content id" ); + + // C29: the doc-less op-script form refuses at register like any other + // doc-less composite entering the store. + let docless_script = dir.join("docless-script.json"); + std::fs::write(&docless_script, SIGNAL_DOC).expect("write op-script fixture"); + let (dl_out, dl_err, dl_code) = + run_in(&dir, &["graph", "register", docless_script.to_str().unwrap()]); + assert_eq!(dl_code, Some(1), "doc-less op-script refuses: {dl_out} {dl_err}"); + assert!(dl_err.contains("carries no doc"), "stderr names the rule: {dl_err}"); + assert!(dl_err.contains("C29"), "stderr cites the contract: {dl_err}"); +} + +/// C29: an op-script's `doc` op is not a bare presence checkbox -- its text +/// goes through the same shape gate a builder-authored doc does. A doc that +/// merely restates the composite's name (here "graph", every CLI op-script's +/// literal name) refuses at register exactly like the builder-authored +/// RestatesName case already pinned at the registry-unit layer, but reached +/// end-to-end through the op-script CLI seam this time. +#[test] +fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() { + let dir = temp_cwd("register-op-script-restated-doc"); + let restated_script = dir.join("restated-doc-script.json"); + std::fs::write(&restated_script, SIGNAL_DOC_NAME_RESTATED).expect("write op-script fixture"); + let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]); + assert_eq!(code, Some(1), "name-restating doc refuses: {out} {err}"); + assert!(err.contains("merely restates"), "stderr names the rule: {err}"); + assert!(err.contains("C29"), "stderr cites the contract: {err}"); } /// Property (#202, the same on-ramp seam at the sibling verb): `aura graph @@ -1198,6 +1275,26 @@ fn graph_build_reports_gang_kind_mismatch_as_prose() { assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}"); } +/// The `doc` op's duplicate refusal (a second meaning line in one op-script) +/// reads as prose at the binary seam, attributed to the second `doc` op's +/// index -- never the raw `DuplicateDoc` Debug variant name. +#[test] +fn graph_build_reports_duplicate_doc_fault_as_prose() { + let doc = r#"[ + {"op":"source","role":"price","kind":"F64"}, + {"op":"doc","text":"first"}, + {"op":"doc","text":"second"} + ]"#; + let (stdout, stderr, ok) = run(&["graph", "build"], doc); + assert!(!ok, "non-zero exit on a duplicate doc fault"); + assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}"); + assert!( + stderr.contains("op 2 (doc): a doc op may appear at most once"), + "names the op + cause as prose: {stderr}" + ); + assert!(!stderr.contains("DuplicateDoc"), "does not leak the Debug variant name: {stderr}"); +} + /// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph /// register` on a hand-corrupted blueprint whose gangs section fails structural /// validation (a single-member gang) refuses with the `blueprint_load_prose` -> diff --git a/crates/aura-engine/src/construction.rs b/crates/aura-engine/src/construction.rs index dacc0c4..a6606ab 100644 --- a/crates/aura-engine/src/construction.rs +++ b/crates/aura-engine/src/construction.rs @@ -42,6 +42,10 @@ pub enum Op { Tap { from: String, as_name: String }, /// Fuse two or more sibling params into one public knob (#61). Gang { as_name: String, into: Vec }, + /// Declare the composite's one-line meaning (C29, #316) — the op-script + /// twin of the builder's `.doc(...)`. At most one per script; a second + /// is a fault (a declarative script carries no last-wins ambiguity). + Doc { text: String }, } /// A per-op construction fault, by-identifier so the cause names the op. @@ -92,6 +96,8 @@ pub enum OpError { /// A holistic finalize fault (totality / injectivity / unbound root role), /// wrapping the unchanged engine gate's `CompileError`. Incomplete(CompileError), + /// A second `doc` op — the meaning line is declared at most once. + DuplicateDoc, } /// A per-op-fallible blueprint accumulator. Holds the same interior data a @@ -113,6 +119,7 @@ pub struct GraphSession<'v> { tap_names: HashSet, coverage: HashMap<(usize, usize), usize>, gangs: Vec, + doc: Option, } impl<'v> GraphSession<'v> { @@ -135,6 +142,7 @@ impl<'v> GraphSession<'v> { tap_names: HashSet::new(), coverage: HashMap::new(), gangs: Vec::new(), + doc: None, } } @@ -150,6 +158,13 @@ impl<'v> GraphSession<'v> { Op::Expose { from, as_name } => self.expose(from, as_name), Op::Tap { from, as_name } => self.tap(from, as_name), Op::Gang { as_name, into } => self.gang(as_name, into), + Op::Doc { text } => { + if self.doc.is_some() { + return Err(OpError::DuplicateDoc); + } + self.doc = Some(text); + Ok(()) + } } } @@ -424,13 +439,14 @@ impl<'v> GraphSession<'v> { .into_iter() .map(|(name, source, targets)| Role { name, targets, source }) .collect(); - // No doc is attached here: the op-script vocabulary deliberately has no - // doc-carrying surface yet (#125 scope cut) — an op-built composite - // carries no authored rationale until that vocabulary grows a slot. - let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out) + // The doc op (C29, #316) threads through `Composite::with_doc`. + let mut c = Composite::new(self.name, self.nodes, self.edges, roles, self.out) .with_taps(self.taps) .with_gangs(self.gangs) .map_err(OpError::Incomplete)?; + if let Some(t) = self.doc { + c = c.with_doc(t); + } check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?; validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps()).map_err(|e| match e { CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort { @@ -866,6 +882,32 @@ mod tests { ); } + /// C29 (#316): the doc op sets the composite's meaning line — the + /// op-script twin of the builder's .doc(...). + #[test] + fn doc_op_sets_the_composite_doc() { + let mut s = scaffold(); + s.apply(Op::Doc { text: "a described op-built composite".into() }).unwrap(); + s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }) + .unwrap(); + s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap(); + s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap(); + s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap(); + let c = s.finish().expect("finishes"); + assert_eq!(c.doc(), Some("a described op-built composite")); + } + + /// A second doc op is a fault, not a silent overwrite. + #[test] + fn duplicate_doc_op_is_refused() { + let mut s = scaffold(); + s.apply(Op::Doc { text: "first".into() }).unwrap(); + assert_eq!( + s.apply(Op::Doc { text: "second".into() }), + Err(OpError::DuplicateDoc) + ); + } + #[test] fn finish_reports_incomplete_on_unwired_slot() { // a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm