diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 7c6207e..3cd343e 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -104,6 +104,28 @@ fn graph_introspect_vocabulary_lists_the_node_types() { assert!(!stdout.contains("Recorder"), "a sink is not in the zero-arg vocabulary: {stdout}"); } +/// The #160 roster macro's whole point is that `std_vocabulary`'s match arms and +/// `std_vocabulary_types`'s enumerable list can never drift apart — but that +/// guarantee is checked in-process, inside `aura-std`'s own unit tests. This pins +/// the SAME closed-roster count one layer further out, through a code path the +/// roster macro cannot see: `Env::type_ids()` (project ∪ std concatenation) and +/// the CLI's println loop, observed across the real process boundary. A bug in +/// that CLI-side plumbing (a dropped or duplicated entry in the concatenation or +/// the print loop) would pass every roster-internal test yet still change what a +/// user actually sees from `aura graph introspect --vocabulary` with no project +/// loaded — this test is the only thing that would catch it. +#[test] +fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() { + let (stdout, _stderr, ok) = run(&["graph", "introspect", "--vocabulary"], ""); + assert!(ok, "exit success"); + let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!( + lines.len(), + 22, + "the std-only (no project) vocabulary has exactly the roster's 22 entries: {stdout}" + ); +} + #[test] fn graph_introspect_node_answers_ports_without_a_build() { let (stdout, _stderr, ok) = run(&["graph", "introspect", "--node", "SMA"], ""); diff --git a/crates/aura-std/src/vocabulary.rs b/crates/aura-std/src/vocabulary.rs index 1633644..a9be21d 100644 --- a/crates/aura-std/src/vocabulary.rs +++ b/crates/aura-std/src/vocabulary.rs @@ -24,92 +24,76 @@ use crate::{ }; use aura_core::PrimitiveBuilder; -/// Resolve a serialized node type identity (the `PrimitiveBuilder::label`, e.g. -/// `"SMA"`) to a fresh, unbound builder from the node's own factory. Returns -/// `None` for any identity outside the zero-arg `aura-std` vocabulary. -pub fn std_vocabulary(type_id: &str) -> Option { - Some(match type_id { - "Add" => Add::builder(), - "And" => And::builder(), - "Bias" => Bias::builder(), - "CarryCost" => CarryCost::builder(), - "ConstantCost" => ConstantCost::builder(), - "Delay" => Delay::builder(), - "EMA" => Ema::builder(), - "EqConst" => EqConst::builder(), - "FixedStop" => FixedStop::builder(), - "Gt" => Gt::builder(), - "Latch" => Latch::builder(), - "LongOnly" => LongOnly::builder(), - "Mul" => Mul::builder(), - "PositionManagement" => PositionManagement::builder(), - "Resample" => Resample::builder(), - "RollingMax" => RollingMax::builder(), - "RollingMin" => RollingMin::builder(), - "Sizer" => Sizer::builder(), - "SMA" => Sma::builder(), - "Sqrt" => Sqrt::builder(), - "Sub" => Sub::builder(), - "VolSlippageCost" => VolSlippageCost::builder(), - _ => return None, - }) +/// The single roster source (#160): one `"TypeId" => Type` line per zero-arg +/// node, expanded into BOTH the `std_vocabulary` match and the +/// `std_vocabulary_types` list — the two surfaces cannot drift apart, and +/// adding a zero-arg node to the vocabulary is exactly one line here (plus the +/// conscious count-pin bump in the tests). What this cannot guard: a new +/// zero-arg node never rostered at all — that fails safe (clean +/// `LoadError::UnknownNodeType` on load, merely absent from `--vocabulary`; +/// #160). +macro_rules! std_vocabulary_roster { + ($($type_id:literal => $ty:ty),+ $(,)?) => { + /// Resolve a serialized node type identity (the `PrimitiveBuilder::label`, + /// e.g. `"SMA"`) to a fresh, unbound builder from the node's own factory. + /// Returns `None` for any identity outside the zero-arg `aura-std` + /// vocabulary. + pub fn std_vocabulary(type_id: &str) -> Option { + Some(match type_id { + $($type_id => <$ty>::builder(),)+ + _ => return None, + }) + } + + /// The enumerable companion to [`std_vocabulary`]: the closed set of + /// zero-arg type identities the resolver answers. A `fn(&str)->Option<…>` + /// resolver cannot be listed, so build-free vocabulary introspection + /// (#157) reads this. Generated from the same roster as the match arms + /// (#160) — the two surfaces agree by construction. + pub fn std_vocabulary_types() -> &'static [&'static str] { + &[$($type_id),+] + } + }; } -/// The enumerable companion to [`std_vocabulary`]: the closed set of zero-arg -/// type identities the resolver answers. A `fn(&str)->Option<…>` resolver cannot -/// be listed, so build-free vocabulary introspection (#157) reads this. Kept in -/// lockstep with the `std_vocabulary` match arms by maintainer discipline — a -/// type added there must be added here. The reverse (an arm with no entry here) -/// is not test-enforced and cannot be, from a `fn` resolver; it fails safe (the -/// type stays load-resolvable, only unlisted; #160). -pub fn std_vocabulary_types() -> &'static [&'static str] { - &[ - "Add", "And", "Bias", "CarryCost", "ConstantCost", "Delay", "EMA", - "EqConst", "FixedStop", "Gt", "Latch", "LongOnly", "Mul", - "PositionManagement", "Resample", "RollingMax", "RollingMin", "Sizer", - "SMA", "Sqrt", "Sub", "VolSlippageCost", - ] +std_vocabulary_roster! { + "Add" => Add, + "And" => And, + "Bias" => Bias, + "CarryCost" => CarryCost, + "ConstantCost" => ConstantCost, + "Delay" => Delay, + "EMA" => Ema, + "EqConst" => EqConst, + "FixedStop" => FixedStop, + "Gt" => Gt, + "Latch" => Latch, + "LongOnly" => LongOnly, + "Mul" => Mul, + "PositionManagement" => PositionManagement, + "Resample" => Resample, + "RollingMax" => RollingMax, + "RollingMin" => RollingMin, + "Sizer" => Sizer, + "SMA" => Sma, + "Sqrt" => Sqrt, + "Sub" => Sub, + "VolSlippageCost" => VolSlippageCost, } #[cfg(test)] mod tests { - use super::std_vocabulary; + use super::{std_vocabulary, std_vocabulary_types}; - /// Every key in the closed vocabulary resolves to a builder that carries - /// that exact type label back, and only the zero-arg keys do. The label - /// round-trip is the property: a typo in any match key (the silent- - /// unresolvable failure this resolver guards against) breaks it, as does a - /// key mapped to the wrong builder; construction-arg nodes and sinks stay - /// absent so the loader fails cleanly rather than guessing. + /// Every rostered key resolves to a builder that carries that exact type + /// label back, and only the rostered keys do. The builder's own `label()` + /// is the independent oracle: a typo'd roster key fails the lookup-label + /// agreement even though the match arms and the list share the same + /// (mistyped) literal; construction-arg nodes and sinks stay absent so the + /// loader fails cleanly rather than guessing. #[test] fn std_vocabulary_resolves_known_and_rejects_unknown() { - // every zero-arg key round-trips: lookup -> builder -> same type label. - // (this list is the resolver's whole vocabulary; a missing/typo'd arm - // fails the lookup, a mis-mapped arm fails the label assertion.) - for type_id in [ - "Add", - "And", - "Bias", - "CarryCost", - "ConstantCost", - "Delay", - "EMA", - "EqConst", - "FixedStop", - "Gt", - "Latch", - "LongOnly", - "Mul", - "PositionManagement", - "Resample", - "RollingMax", - "RollingMin", - "Sizer", - "SMA", - "Sqrt", - "Sub", - "VolSlippageCost", - ] { + for &type_id in std_vocabulary_types() { let builder = std_vocabulary(type_id).unwrap_or_else(|| panic!("{type_id} is in the vocabulary")); assert_eq!( @@ -124,28 +108,21 @@ mod tests { assert!(std_vocabulary("Recorder").is_none()); } + /// List <-> resolver agreement holds BY CONSTRUCTION since the #160 roster + /// macro (one source expands into both surfaces); what stays pinned here is + /// the roster's SHAPE. The count pin is deliberate friction: any roster + /// line added or dropped trips it, making a vocabulary change a conscious + /// act. The residual #160 drift — a new zero-arg node never rostered at + /// all — is not catchable here (no enumeration of zero-arg builders + /// exists); it fails safe (clean `UnknownNodeType` on load, merely absent + /// from `--vocabulary`). #[test] fn std_vocabulary_types_lists_exactly_the_resolvable_keys() { - use super::std_vocabulary_types; - // every listed type resolves to a builder carrying that exact label - for &type_id in std_vocabulary_types() { - let builder = std_vocabulary(type_id) - .unwrap_or_else(|| panic!("{type_id} listed but unresolvable")); - assert_eq!( - builder.label(), - type_id, - "listed type must round-trip to its label" - ); - } - // the list is the WHOLE resolvable set: a couple of known non-members stay out + // known non-members stay out of the list assert!(!std_vocabulary_types().contains(&"LinComb")); // construction-arg node assert!(!std_vocabulary_types().contains(&"Recorder")); // sink assert!(!std_vocabulary_types().contains(&"nope")); - // count guard: pins the list at exactly 22 entries, so a dropped or added - // list entry trips this; with the round-trip loop above, the list is - // exactly the 22 listed resolvable keys. The reverse drift — a resolver - // arm with no list entry — is not catchable from a `fn(&str)` resolver - // (not enumerable); it fails safe (load-resolvable, only unlisted; #160). + // count guard: pins the roster at exactly 22 entries assert_eq!(std_vocabulary_types().len(), 22); } }