feat(engine): composite doc field — authoring, serde, identity, model emit (refs #125)

Composite gains doc: Option<String> (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
This commit is contained in:
2026-07-11 19:37:45 +02:00
parent c8b5acf6f0
commit 953d04a774
6 changed files with 198 additions and 11 deletions
+16 -3
View File
@@ -169,6 +169,7 @@ pub struct GangMember {
/// re-exports one interior `(node, field)` under a boundary name).
pub struct Composite {
name: String,
doc: Option<String>,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Role>,
@@ -188,13 +189,24 @@ impl Composite {
input_roles: Vec<Role>,
output: Vec<OutField>,
) -> 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<String>) -> 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
+38 -2
View File
@@ -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<String>,
pub nodes: Vec<NodeData>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<Edge>,
@@ -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<String, SerializeError>
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
+31 -1
View File
@@ -86,6 +86,7 @@ pub enum BuildError {
/// and port/field names. See the module docs.
pub struct GraphBuilder {
name: String,
doc: Option<String>,
nodes: Vec<BlueprintNode>,
schemas: Vec<NodeSchema>,
edges: Vec<(OutPort, InPort)>,
@@ -99,6 +100,7 @@ impl GraphBuilder {
pub fn new(name: impl Into<String>) -> 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<String>) {
self.doc = Some(doc.into());
}
/// Resolve every accumulated name to an index and assemble the [`Composite`].
pub fn build(self) -> Result<Composite, BuildError> {
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;
+39 -5
View File
@@ -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::<serde_json::Value>(&def).is_ok(),
"the def with a multi-line doc parses as JSON: {def}"
);
}
// -- Task 6: structural assertions + mis-wire-differs + read-only ------------
#[test]