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
+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]