From 953d04a77495ef19c6eb1511626ecb8ebab2e8ba Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 11 Jul 2026 19:37:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(engine):=20composite=20doc=20field=20?= =?UTF-8?q?=E2=80=94=20authoring,=20serde,=20identity,=20model=20emit=20(r?= =?UTF-8?q?efs=20#125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite gains doc: Option (the prose twin of name, a C23 debug symbol): with_doc/doc() on Composite, a doc knob on GraphBuilder, a Tier-1 additive-optional CompositeData field (no format-version bump; absent-field documents keep their exact bytes), identity-stripped like the name. The graph model emits an optional trailing doc fragment in both scopes; json_str is hardened for free text (\n/\t/\r named, control chars as \u00XX — the doc is the first multi-line value through it). e2e: a hand-authored doc'd blueprint renders with the doc embedded; the doc moves the content id but never the identity id. refs #125 --- .../tests/fixtures/doc_blueprint.json | 1 + crates/aura-cli/tests/graph_construct.rs | 73 +++++++++++++++++++ crates/aura-engine/src/blueprint.rs | 19 ++++- crates/aura-engine/src/blueprint_serde.rs | 40 +++++++++- crates/aura-engine/src/builder.rs | 32 +++++++- crates/aura-engine/src/graph_model.rs | 44 +++++++++-- 6 files changed, 198 insertions(+), 11 deletions(-) create mode 100644 crates/aura-cli/tests/fixtures/doc_blueprint.json diff --git a/crates/aura-cli/tests/fixtures/doc_blueprint.json b/crates/aura-cli/tests/fixtures/doc_blueprint.json new file mode 100644 index 0000000..ad5158e --- /dev/null +++ b/crates/aura-cli/tests/fixtures/doc_blueprint.json @@ -0,0 +1 @@ +{"format_version":1,"blueprint":{"name":"sig","doc":"why this graph: single SMA smoke test","nodes":[{"primitive":{"type":"SMA","name":"fast"}}]}} diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index a39c972..61f43f2 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -967,6 +967,79 @@ fn graph_build_gang_op_survives_render_round_trip() { ); } +/// Property (#125, the render seam a hand-authored blueprint document actually +/// drives): a composite's `doc` field is not merely a `model_to_json` unit-level +/// concept — `aura graph ` on a real on-disk blueprint envelope that +/// carries `"doc":"..."` embeds that exact text in the page's inlined +/// `window.AURA_MODEL` JSON. The graph_model.rs golden pins the JSON fragment in +/// isolation; this proves the doc survives the actual file-load -> render CLI +/// pipeline a consumer runs, not just the library function called directly. +#[test] +fn graph_render_embeds_the_hand_authored_composite_doc() { + let out = std::process::Command::new(BIN) + .arg("graph") + .arg(fixture("doc_blueprint.json")) + .output() + .expect("spawn aura graph "); + assert_eq!( + out.status.code(), + Some(0), + "render exit: {:?}\nstderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let html = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert!( + html.contains(r#","doc":"why this graph: single SMA smoke test""#), + "rendered page does not embed the composite doc: {html}" + ); +} + +/// Property (#125, doc-blind identity at the CLI seam): a blueprint document +/// carrying a `doc` and its doc-less twin are the SAME topology by the identity +/// projection (`--identity-id` agrees) but DIFFERENT documents by the canonical +/// projection (`--content-id` differs) — mirroring the established renamed-twin +/// pattern (`graph_introspect_identity_id_bridges_renamed_op_scripts`) for the +/// new field. `blueprint_serde.rs`'s `doc_round_trips_canonically_and_is_ +/// identity_blind` pins this at the library level; this drives the real binary +/// over a real file, the boundary a regression in the CLI's own dispatch could +/// still break even with the library invariant intact. +#[test] +fn graph_doc_field_moves_content_id_but_not_identity_id() { + let doced = fixture("doc_blueprint.json"); + let doceless_text = std::fs::read_to_string(&doced) + .expect("read fixture") + .replace(r#""doc":"why this graph: single SMA smoke test","#, ""); + let dir = temp_cwd("doc-field-ids"); + let doceless = dir.join("doc_blueprint_no_doc.json"); + std::fs::write(&doceless, &doceless_text).expect("write doc-less twin"); + + // `--identity-id` has no FILE slot of its own (main.rs `GraphIntrospectCmd`): + // combine it with `--content-id ` (both id flags share one build) and + // read the two printed lines, content id first (mirrors + // `graph_introspect_content_and_identity_id_combine`). + let (doced_ids, _e, ok1) = + run_in(&dir, &["graph", "introspect", "--content-id", &doced, "--identity-id"]); + let (doceless_ids, _e2, ok2) = run_in( + &dir, + &["graph", "introspect", "--content-id", doceless.to_str().unwrap(), "--identity-id"], + ); + assert_eq!(ok1, Some(0), "doc'd id pair exits 0: {doced_ids}"); + assert_eq!(ok2, Some(0), "doc-less id pair exits 0: {doceless_ids}"); + let doced_lines: Vec<&str> = doced_ids.lines().collect(); + let doceless_lines: Vec<&str> = doceless_ids.lines().collect(); + assert_eq!(doced_lines.len(), 2, "content id then identity id: {doced_ids:?}"); + assert_eq!(doceless_lines.len(), 2, "content id then identity id: {doceless_ids:?}"); + assert_ne!( + doced_lines[0], doceless_lines[0], + "the doc is canonical-byte-bearing (content id moves)" + ); + assert_eq!( + doced_lines[1], doceless_lines[1], + "the identity projection is doc-blind (identity id agrees)" + ); +} + /// The `gang` op's arity refusal (fewer than two members) reads as prose at the /// binary seam, attributed to its op index, never the raw `GangArity` Debug name. #[test] diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 2143530..76540e9 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -169,6 +169,7 @@ pub struct GangMember { /// re-exports one interior `(node, field)` under a boundary name). pub struct Composite { name: String, + doc: Option, nodes: Vec, edges: Vec, input_roles: Vec, @@ -188,13 +189,24 @@ impl Composite { input_roles: Vec, output: Vec, ) -> Self { - Self { name: name.into(), nodes, edges, input_roles, output, gangs: Vec::new() } + Self { name: name.into(), doc: None, nodes, edges, input_roles, output, gangs: Vec::new() } } /// The authored render name (cluster title, #13). Non-load-bearing. pub fn name(&self) -> &str { &self.name } + /// Attach the authored rationale — the prose twin of the `name` beside it + /// (a C23 debug symbol: render/tooltip surface only, stripped from the + /// identity projection, never reaching the flat graph). Fluent (#125). + pub fn with_doc(mut self, doc: impl Into) -> Self { + self.doc = Some(doc.into()); + self + } + /// The authored rationale, if any. Non-load-bearing (C23, #125). + pub fn doc(&self) -> Option<&str> { + self.doc.as_deref() + } /// The interior blueprint items (read-only graph-as-data, C9). pub fn nodes(&self) -> &[BlueprintNode] { &self.nodes @@ -1040,8 +1052,9 @@ fn inline_composite( // cell `point: &[Cell]` arg drives `lower_items` below; the flat graph stays // wired by raw index. // `gangs` is an authoring-time gate only (checked at `with_gangs`); it has - // no runtime representation in the flat graph, same as `name`. - let Composite { name: _, nodes, edges, input_roles, output, gangs: _ } = c; + // no runtime representation in the flat graph, same as `name`. `doc` is + // the prose twin of `name` (#125) and dissolves alongside it. + let Composite { name: _, doc: _, nodes, edges, input_roles, output, gangs: _ } = c; let item_count = nodes.len(); // recursively lower interior items, then rewrite interior edges through them diff --git a/crates/aura-engine/src/blueprint_serde.rs b/crates/aura-engine/src/blueprint_serde.rs index e74c252..a6ae04f 100644 --- a/crates/aura-engine/src/blueprint_serde.rs +++ b/crates/aura-engine/src/blueprint_serde.rs @@ -32,6 +32,12 @@ pub struct BlueprintDoc { #[derive(serde::Serialize, serde::Deserialize)] pub struct CompositeData { pub name: String, + /// The authored rationale (the prose twin of `name`; a C23 debug symbol, + /// #125). Tier-1 additive-optional: omitted when absent, defaulted on + /// load — no `BLUEPRINT_FORMAT_VERSION` bump, and absent-field documents + /// keep their exact pre-change bytes (content ids untouched). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub doc: Option, pub nodes: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub edges: Vec, @@ -83,6 +89,7 @@ fn project(c: &Composite) -> CompositeData { CompositeData { name: c.name().to_string(), + doc: c.doc().map(str::to_string), nodes: c.nodes().iter().map(project_node).collect(), edges: c.edges().to_vec(), input_roles: c.input_roles().to_vec(), @@ -142,6 +149,7 @@ pub fn blueprint_identity_json(c: &Composite) -> Result fn strip_debug_symbols(b: &mut CompositeData) { b.name = String::new(); + b.doc = None; // the prose twin of the name (identity-blind, #171/#125) for node in &mut b.nodes { match node { NodeData::Primitive(p) => { @@ -218,9 +226,13 @@ fn reconstruct( NodeData::Composite(c) => BlueprintNode::Composite(reconstruct(c, resolve)?), }); } - Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone()) + let composite = Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone()) .with_gangs(d.gangs.clone()) - .map_err(LoadError::Gang) + .map_err(LoadError::Gang)?; + Ok(match &d.doc { + Some(doc) => composite.with_doc(doc.clone()), + None => composite, + }) } /// Load a blueprint from canonical JSON, resolving each primitive's type identity @@ -336,6 +348,30 @@ mod tests { assert_eq!(json, golden); } + /// #125: a doc'd composite round-trips byte-stably, the doc is + /// canonical-byte-bearing (content-id-affecting), absent doc emits no key + /// (pre-change bytes untouched), and the identity projection strips it + /// exactly like the name it sits beside. + #[test] + fn doc_round_trips_canonically_and_is_identity_blind() { + let plain = crate::test_fixtures::sink_free_sma_cross_signal(); + let doced = crate::test_fixtures::sink_free_sma_cross_signal() + .with_doc("why this graph: the spread detects trend turns"); + let plain_json = blueprint_to_json(&plain).expect("serializes"); + let doced_json = blueprint_to_json(&doced).expect("serializes"); + assert_ne!(plain_json, doced_json, "the doc is canonical-byte-bearing"); + assert!(doced_json.contains("why this graph"), "doc serialized: {doced_json}"); + assert!(!plain_json.contains("\"doc\""), "absent doc emits no key: {plain_json}"); + let loaded = blueprint_from_json(&doced_json, &|t| aura_std::std_vocabulary(t)).expect("loads"); + assert_eq!(loaded.doc(), Some("why this graph: the spread detects trend turns")); + assert_eq!(doced_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable"); + assert_eq!( + blueprint_identity_json(&doced).expect("identity"), + blueprint_identity_json(&plain).expect("identity"), + "identity form is doc-blind" + ); + } + #[test] fn unknown_node_type_fails_named() { // a valid envelope naming a type outside the vocabulary -> clean, named error diff --git a/crates/aura-engine/src/builder.rs b/crates/aura-engine/src/builder.rs index 01ccea3..c964611 100644 --- a/crates/aura-engine/src/builder.rs +++ b/crates/aura-engine/src/builder.rs @@ -86,6 +86,7 @@ pub enum BuildError { /// and port/field names. See the module docs. pub struct GraphBuilder { name: String, + doc: Option, nodes: Vec, schemas: Vec, edges: Vec<(OutPort, InPort)>, @@ -99,6 +100,7 @@ impl GraphBuilder { pub fn new(name: impl Into) -> Self { Self { name: name.into(), + doc: None, nodes: Vec::new(), schemas: Vec::new(), edges: Vec::new(), @@ -152,6 +154,12 @@ impl GraphBuilder { self.gangs.push((name.to_string(), members.into_iter().collect())); } + /// Attach the authored rationale for the composite under construction + /// (the prose twin of the builder's name; a C23 debug symbol, #125). + pub fn doc(&mut self, doc: impl Into) { + self.doc = Some(doc.into()); + } + /// Resolve every accumulated name to an index and assemble the [`Composite`]. pub fn build(self) -> Result { let mut edges = Vec::with_capacity(self.edges.len()); @@ -222,7 +230,10 @@ impl GraphBuilder { let kind = kind.unwrap_or(ScalarKind::F64); gangs.push(Gang { name: name.clone(), kind, members }); } - let composite = Composite::new(self.name, self.nodes, edges, roles, output); + let mut composite = Composite::new(self.name, self.nodes, edges, roles, output); + if let Some(doc) = self.doc { + composite = composite.with_doc(doc); + } match composite.with_gangs(gangs) { Ok(c) => Ok(c), Err(CompileError::BadGang(f)) => Err(BuildError::BadGang(f)), @@ -340,6 +351,25 @@ mod tests { assert_eq!(built.output(), hand.output()); } + /// #125: the builder's doc knob threads into the built composite; absent + /// knob leaves the field empty. + #[test] + fn builder_doc_threads_into_the_composite() { + let build = |with_doc: bool| { + let mut g = GraphBuilder::new("sma_cross"); + if with_doc { + g.doc("fast/slow spread rationale"); + } + let fast = g.add(Sma::builder().named("fast")); + let price = g.input_role("price"); + g.feed(price, [fast.input("series")]); + g.expose(fast.output("value"), "out"); + g.build().expect("resolves") + }; + assert_eq!(build(true).doc(), Some("fast/slow spread rationale")); + assert_eq!(build(false).doc(), None); + } + #[test] fn builder_harness_compiles_identically_to_hand_wired() { use crate::test_fixtures::composite_sma_cross_harness; diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs index c128e76..1c193a5 100644 --- a/crates/aura-engine/src/graph_model.rs +++ b/crates/aura-engine/src/graph_model.rs @@ -42,8 +42,10 @@ fn firing_str(f: Firing) -> String { } } -/// A JSON string literal: wrap in quotes, escape `"` and `\` (the minimal set -/// sufficient here — model names are author-controlled identifiers). +/// A JSON string literal: wrap in quotes, escape `"`, `\`, and control +/// characters (`\n`/`\t`/`\r` named, the rest as `\u00XX`) — the composite +/// `doc` (#125) is free text and the first multi-line value through here; +/// identifiers pass through unchanged. fn json_str(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); @@ -51,6 +53,10 @@ fn json_str(s: &str) -> String { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), _ => out.push(c), } } @@ -190,14 +196,22 @@ fn gangs_fragment(c: &Composite) -> String { format!(r#","gangs":[{items}]"#) } +/// The optional trailing `,"doc":"…"` fragment (#125): emitted LAST in a +/// scope/def object so no prefix-pinned consumer (starts_with/grep tests, +/// the `window.AURA_MODEL = {"root":` page pin) moves; empty when absent. +fn doc_fragment(doc: Option<&str>) -> String { + doc.map(|d| format!(r#","doc":{}"#, json_str(d))).unwrap_or_default() +} + /// One scope as `{ "nodes": {…}, "edges": [...] }`. fn scope_json(c: &Composite, is_root: bool) -> String { let (nodes, edges) = scope_parts(c, is_root); format!( - r#"{{"nodes":{{{}}},"edges":[{}]{}}}"#, + r#"{{"nodes":{{{}}},"edges":[{}]{}{}}}"#, nodes.join(","), edges.join(","), gangs_fragment(c), + doc_fragment(if is_root { c.doc() } else { None }), ) } @@ -240,10 +254,11 @@ fn composite_def_json(c: &Composite) -> String { )); } format!( - r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]{}}}"#, + r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]{}{}}}"#, nodes.join(","), edges.join(","), gangs_fragment(c), + doc_fragment(c.doc()), ) } @@ -359,6 +374,7 @@ mod tests { }], vec![OutField { node: 2, field: 0, name: "out".into() }], ) + .with_doc("fast/slow SMA spread; the Sub leg is the crossing signal") } /// A small, stable root harness exercising the four model elements: a bound @@ -388,6 +404,7 @@ mod tests { }], vec![], // output: the root ends in a sink, no re-export ) + .with_doc("sample harness: price feeds sma_cross; its spread becomes a recorded bias") } /// A composite with exactly two f64 input roles (each firing `Any`): two `Sma` @@ -593,10 +610,27 @@ mod tests { // Re-capture if an intended model-shape change lands: temporarily add a // `println!("{}", model_to_json(&sample_root()))` test, run with // `-- --nocapture`, and paste the fresh bytes below. - let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##; + let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]],"doc":"sample harness: price feeds sma_cross; its spread becomes a recorded bias"},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]],"doc":"fast/slow SMA spread; the Sub leg is the crossing signal"}}}"##; assert_eq!(model_to_json(&sample_root()), expected); } + /// #125: the doc is free text — the first multi-line value through + /// `json_str`. Control characters must emit as valid JSON escapes and the + /// whole def must stay machine-parseable. + #[test] + fn doc_with_control_characters_emits_valid_json() { + let c = sma_cross().with_doc("line1\nline2\ttabbed\u{1}ctl"); + let def = composite_def_json(&c); + assert!( + def.contains(r#","doc":"line1\nline2\ttabbed\u0001ctl""#), + "control chars escape: {def}" + ); + assert!( + serde_json::from_str::(&def).is_ok(), + "the def with a multi-line doc parses as JSON: {def}" + ); + } + // -- Task 6: structural assertions + mis-wire-differs + read-only ------------ #[test]