diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index 8d6fdf1..2681be5 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -44,8 +44,8 @@ pub use column::{Column, Window}; pub use ctx::Ctx; pub use error::KindMismatch; pub use node::{ - zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, - PrimitiveBuilder, + doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node, + NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, }; pub use scalar::{Scalar, ScalarKind, Timestamp}; pub use series_fold::SeriesFold; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index b04f0a8..dd5c1de 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -345,6 +345,35 @@ pub struct NodeSchema { pub inputs: Vec, pub output: Vec, pub params: Vec, + /// One-line meaning of the node — non-load-bearing metadata, shown by + /// introspection surfaces. + pub doc: &'static str, +} + +/// Shape fault of a vocabulary entry's meaning line (C29 gate). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocGateFault { + Empty, + RestatesName, +} + +/// Deterministic shape check for a vocabulary entry's meaning line — +/// string shape only, never content judgement (the engine must not +/// evaluate prose). +pub fn doc_gate(name: &str, doc: &str) -> Result<(), DocGateFault> { + if doc.trim().is_empty() { + return Err(DocGateFault::Empty); + } + fn norm(s: &str) -> String { + s.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() + } + if norm(doc) == norm(name) { + return Err(DocGateFault::RestatesName); + } + Ok(()) } /// The universal composable dataflow unit (C8): a **producer, consumer, or both**. @@ -446,13 +475,14 @@ mod tests { inputs: vec![], output: vec![], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + doc: "test-only schema", }, |_| Box::new(Bare), ); assert_eq!(with.label(), "SMA"); let none = PrimitiveBuilder::new( "Sub", - NodeSchema { inputs: vec![], output: vec![], params: vec![] }, + NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" }, |_| Box::new(Bare), ); assert_eq!(none.label(), "Sub"); @@ -482,7 +512,7 @@ mod tests { fn node_name_strips_namespace_prefix() { let b = PrimitiveBuilder::new( "demo::Identity", - NodeSchema { inputs: vec![], output: vec![], params: vec![] }, + NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" }, |_| unreachable!("never built in this test"), ); assert_eq!(b.node_name(), "identity"); @@ -514,7 +544,7 @@ mod tests { fn primitive_builder_build_runs_the_closure() { let f = PrimitiveBuilder::new( "Bare", - NodeSchema { inputs: vec![], output: vec![], params: vec![] }, + NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" }, |_| Box::new(Bare), ); assert_eq!(f.params(), Vec::::new()); @@ -529,6 +559,7 @@ mod tests { inputs: vec![], output: vec![], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + doc: "test-only schema", }, |_| Box::new(Bare), ); @@ -538,6 +569,21 @@ mod tests { ); } + #[test] + fn schema_carries_declared_doc() { + let b = PrimitiveBuilder::new( + "SMA", + NodeSchema { + inputs: vec![], + output: vec![], + params: vec![], + doc: "simple moving average", + }, + |_| Box::new(Bare), + ); + assert_eq!(b.schema().doc, "simple moving average"); + } + /// A node whose label echoes the param vector its constructor received — lets a /// test read back the positional vector `.build()` reconstructed after binds. struct Probe(Vec); @@ -565,6 +611,7 @@ mod tests { ParamSpec { name: "b".into(), kind: ScalarKind::I64 }, ParamSpec { name: "c".into(), kind: ScalarKind::F64 }, ], + doc: "test-only schema", }, |p| Box::new(Probe(p.to_vec())), ) @@ -641,6 +688,7 @@ mod tests { inputs: vec![], output: vec![], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + doc: "test-only schema", }, |_| Box::new(Bare), ); @@ -659,6 +707,7 @@ mod tests { ParamSpec { name: "dup".into(), kind: ScalarKind::I64 }, ParamSpec { name: "dup".into(), kind: ScalarKind::I64 }, ], + doc: "test-only schema", }, |_| Box::new(Bare), ); @@ -674,6 +723,7 @@ mod tests { inputs: vec![], output: vec![], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + doc: "test-only schema", }, |_| Box::new(Bare), ); @@ -725,6 +775,7 @@ mod tests { inputs: vec![], output: vec![], params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], + doc: "test-only schema", }, |_| Box::new(Bare), ) @@ -746,6 +797,24 @@ mod tests { }) ); } + + #[test] + fn doc_gate_refuses_empty_and_whitespace() { + assert_eq!(doc_gate("EMA", ""), Err(DocGateFault::Empty)); + assert_eq!(doc_gate("EMA", " \t"), Err(DocGateFault::Empty)); + } + + #[test] + fn doc_gate_refuses_name_restatement() { + assert_eq!(doc_gate("EMA", "EMA"), Err(DocGateFault::RestatesName)); + assert_eq!(doc_gate("EMA", "e_m_a"), Err(DocGateFault::RestatesName)); + assert_eq!(doc_gate("rolling_max", "Rolling Max"), Err(DocGateFault::RestatesName)); + } + + #[test] + fn doc_gate_accepts_a_meaning_line() { + assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(())); + } } #[cfg(test)]