refactor(std): std_vocabulary_roster! macro — one source for the vocabulary roster

The three hand-kept copies of the 22-key zero-arg roster in
aura-std/vocabulary.rs (std_vocabulary match arms, std_vocabulary_types
list, the test's inline array) collapse into one private declarative
macro invoked once with the "TypeId" => Type pairs — the resolver and
the enumerable list now agree by construction (the #160 failure mode:
a node resolvable but silently absent from graph introspect
--vocabulary, or vice versa). Byte-preserving: same fn signatures, same
22 ids, same order; lib.rs re-export and every consumer untouched; the
vocabulary stays a closed compiled-in set (invariant 9 / C24 — a
compile-time expansion, no registry). Adding a zero-arg node is now one
roster line plus the conscious count-pin bumps.

Tests: the two unit tests reshaped (round-trip iterates the generated
list with PrimitiveBuilder::label() as the independent oracle; the shape
test keeps the count pin as deliberate friction). The E2E phase added
one cross-boundary pin beyond the plan — kept:
graph_introspect_vocabulary_lists_exactly_the_closed_roster_count checks
the same count through Env::type_ids() and the CLI print loop across the
real process boundary, which no roster-internal test can see.

Accepted residual (documented at the roster site, recorded on #160):
a new zero-arg node never rostered at all stays unguarded — no
enumeration of zero-arg builders exists — and fails safe in both
directions (clean UnknownNodeType on load; merely absent from
--vocabulary).

Verification: cargo build clean; cargo test --workspace 885 passed /
0 failed (884 baseline + 1 new e2e); clippy --all-targets -D warnings
clean; cargo doc --no-deps 0 warnings.

closes #160, refs #180
This commit is contained in:
2026-07-02 21:56:07 +02:00
parent e0c745a9b0
commit 57f401f2ab
2 changed files with 93 additions and 94 deletions
+71 -94
View File
@@ -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<PrimitiveBuilder> {
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<PrimitiveBuilder> {
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);
}
}