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