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:
@@ -54,6 +54,9 @@ enum OpDoc {
|
||||
as_name: 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 {
|
||||
@@ -68,6 +71,7 @@ impl OpDoc {
|
||||
OpDoc::Expose { .. } => "expose",
|
||||
OpDoc::Tap { .. } => "tap",
|
||||
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::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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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` ->
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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<String>,
|
||||
coverage: HashMap<(usize, usize), usize>,
|
||||
gangs: Vec<Gang>,
|
||||
doc: Option<String>,
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user