feat(engine, cli): op-script doc slot -- the vocabulary grows the C29 meaning line

Iteration 3 completion (tasks 5-6): the construction vocabulary gains
the slot its own #125 scope-cut comment anticipated. Op::Doc { text }
is the op-script twin of the builder's .doc(...) -- a closed, typed
metadata construct (C25, no logic content); at most one per script, a
second refuses via OpError::DuplicateDoc (the existing duplicate-fault
family), rendered "a doc op may appear at most once". GraphSession
threads the text through Composite::with_doc at finish, so op-built
composites pass the C29 store gate like builder-built ones.

The op-script register test runs on the described variant
(SIGNAL_DOC_DESCRIBED) for both shapes; the doc-less script now pins
the C29 refusal, and the E2E phase added the RestatesName op-script
case. Full gates: cargo test --workspace green (98 suites, 0 failed);
clippy -D warnings clean.

refs #316
This commit is contained in:
2026-07-23 20:33:00 +02:00
parent 593e233e66
commit 7126886b81
3 changed files with 152 additions and 7 deletions
+6
View File
@@ -54,6 +54,9 @@ enum OpDoc {
as_name: String, as_name: String,
into: Vec<String>, into: Vec<String>,
}, },
/// Declare the composite's one-line meaning (C29, #316) — the op-script
/// twin of the builder's `.doc(...)`.
Doc { text: String },
} }
impl OpDoc { impl OpDoc {
@@ -68,6 +71,7 @@ impl OpDoc {
OpDoc::Expose { .. } => "expose", OpDoc::Expose { .. } => "expose",
OpDoc::Tap { .. } => "tap", OpDoc::Tap { .. } => "tap",
OpDoc::Gang { .. } => "gang", OpDoc::Gang { .. } => "gang",
OpDoc::Doc { .. } => "doc",
} }
} }
} }
@@ -85,6 +89,7 @@ impl From<OpDoc> for Op {
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name }, OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name }, OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into }, 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::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
OpError::Incomplete(ce) => format!("{ce:?}"), OpError::Incomplete(ce) => format!("{ce:?}"),
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
} }
} }
+100 -3
View File
@@ -54,6 +54,40 @@ const SIGNAL_DOC: &str = r#"[
{"op":"expose","from":"bias.bias","as":"bias"} {"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 /// 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 /// 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. /// 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}"); 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` /// The canonical blueprint artifact carries no trailing newline: `aura graph build`
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses), /// 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). /// 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] #[test]
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() { fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
let dir = temp_cwd("register-op-script"); 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"); 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. // 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"); assert!(built, "graph build produces the envelope");
let envelope = dir.join("envelope.json"); let envelope = dir.join("envelope.json");
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture"); 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), registered_id(&env_out),
"op-script and its built envelope register to the same content id" "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 /// 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}"); 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 /// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
/// register` on a hand-corrupted blueprint whose gangs section fails structural /// register` on a hand-corrupted blueprint whose gangs section fails structural
/// validation (a single-member gang) refuses with the `blueprint_load_prose` -> /// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
+46 -4
View File
@@ -42,6 +42,10 @@ pub enum Op {
Tap { from: String, as_name: String }, Tap { from: String, as_name: String },
/// Fuse two or more sibling params into one public knob (#61). /// Fuse two or more sibling params into one public knob (#61).
Gang { as_name: String, into: Vec<String> }, Gang { as_name: String, into: Vec<String> },
/// 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. /// 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), /// A holistic finalize fault (totality / injectivity / unbound root role),
/// wrapping the unchanged engine gate's `CompileError`. /// wrapping the unchanged engine gate's `CompileError`.
Incomplete(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 /// A per-op-fallible blueprint accumulator. Holds the same interior data a
@@ -113,6 +119,7 @@ pub struct GraphSession<'v> {
tap_names: HashSet<String>, tap_names: HashSet<String>,
coverage: HashMap<(usize, usize), usize>, coverage: HashMap<(usize, usize), usize>,
gangs: Vec<Gang>, gangs: Vec<Gang>,
doc: Option<String>,
} }
impl<'v> GraphSession<'v> { impl<'v> GraphSession<'v> {
@@ -135,6 +142,7 @@ impl<'v> GraphSession<'v> {
tap_names: HashSet::new(), tap_names: HashSet::new(),
coverage: HashMap::new(), coverage: HashMap::new(),
gangs: Vec::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::Expose { from, as_name } => self.expose(from, as_name),
Op::Tap { from, as_name } => self.tap(from, as_name), Op::Tap { from, as_name } => self.tap(from, as_name),
Op::Gang { as_name, into } => self.gang(as_name, into), 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() .into_iter()
.map(|(name, source, targets)| Role { name, targets, source }) .map(|(name, source, targets)| Role { name, targets, source })
.collect(); .collect();
// No doc is attached here: the op-script vocabulary deliberately has no // The doc op (C29, #316) threads through `Composite::with_doc`.
// doc-carrying surface yet (#125 scope cut) — an op-built composite let mut c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
// carries no authored rationale until that vocabulary grows a slot.
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
.with_taps(self.taps) .with_taps(self.taps)
.with_gangs(self.gangs) .with_gangs(self.gangs)
.map_err(OpError::Incomplete)?; .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)?; 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 { validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps()).map_err(|e| match e {
CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort { 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] #[test]
fn finish_reports_incomplete_on_unwired_slot() { fn finish_reports_incomplete_on_unwired_slot() {
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm // a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm