diff --git a/docs/plans/0105-std-vocabulary-roster-macro.md b/docs/plans/0105-std-vocabulary-roster-macro.md new file mode 100644 index 0000000..dd6737d --- /dev/null +++ b/docs/plans/0105-std-vocabulary-roster-macro.md @@ -0,0 +1,221 @@ +# Std-vocabulary roster macro — Implementation Plan + +> **Parent spec:** `docs/specs/0105-std-vocabulary-roster-macro.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to +> run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Collapse the three hand-kept copies of the std-vocabulary roster +(match arms, `std_vocabulary_types()` list, test inline list) into one +declarative-macro source — byte-preserving (same signatures, same 22 ids, +same order), every consumer untouched. + +**Architecture:** One private `macro_rules! std_vocabulary_roster` in +`crates/aura-std/src/vocabulary.rs`, invoked once with the +`"TypeId" => Type` pairs, expanding into both public fns (docs included). +The two unit tests stop carrying a roster copy: the round-trip test +iterates the generated list (oracle: `PrimitiveBuilder::label()`), the +shape test keeps the count pin and non-member checks. + +**Tech Stack:** aura-std only (`vocabulary.rs`). `lib.rs`'s re-export +(`crates/aura-std/src/lib.rs:86`) resolves identically — NO edit there. No +new dependencies; the macro is the crate's first `macro_rules!` +(collision-free, recon-verified); no `missing_docs` gate fires (none is +configured, and the macro supplies `///` on both fns anyway). + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-std/src/vocabulary.rs:27-72` — the two hand-written + fns become the macro + single roster invocation. +- Modify: `crates/aura-std/src/vocabulary.rs:74-151` — the two tests + reshaped (no inline roster copy). + +Everything else is untouched by design: `crates/aura-std/src/lib.rs`, all +consumer call sites (aura-cli `project.rs`/`main.rs`/`graph_construct.rs`, +aura-engine loader + e2e), and every existing pin +(`graph_introspect_vocabulary_lists_the_node_types`, +`unknown_node_type_fails_named`, the `tests/project_load.rs` suite, +`signal_serializes_to_canonical_golden`). + +--- + +### Task 1: The roster macro and the reshaped tests + +**Files:** +- Modify: `crates/aura-std/src/vocabulary.rs:27-72` (fns → macro) +- Modify: `crates/aura-std/src/vocabulary.rs:74-151` (tests) + +Note for the reviewers: this is a **behaviour-preserving refactor** — there +is no new RED test by design; the oracle is the existing suite staying +green unchanged (the two reshaped tests plus the untouched consumer pins). +The module doc (`:1-18`) and the `use crate::{...}` import block +(`:20-25`) stay byte-identical. + +- [ ] **Step 1: Replace the two hand-written fns with the macro + invocation** + +In `crates/aura-std/src/vocabulary.rs`, replace everything from the line + +```rust +/// Resolve a serialized node type identity (the `PrimitiveBuilder::label`, e.g. +``` + +(line 27, the doc comment above `std_vocabulary`) through the closing brace +of `std_vocabulary_types` (line 72, inclusive — i.e. both fns and their doc +comments) with: + +```rust +/// 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),+] + } + }; +} + +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, +} +``` + +(The roster preserves the current 22 keys and their exact current order — +byte-identical `std_vocabulary_types()` output.) + +- [ ] **Step 2: Reshape the two tests** + +In the same file, replace the whole `#[cfg(test)] mod tests { ... }` block +(currently lines 74-151) with: + +```rust +#[cfg(test)] +mod tests { + use super::{std_vocabulary, std_vocabulary_types}; + + /// 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() { + 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!( + builder.label(), + type_id, + "resolver key must round-trip to the same type label" + ); + } + // an unknown id, a construction-arg node, and a sink are all absent + assert!(std_vocabulary("nope").is_none()); + assert!(std_vocabulary("LinComb").is_none()); + 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() { + // 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 roster at exactly 22 entries + assert_eq!(std_vocabulary_types().len(), 22); + } +} +``` + +- [ ] **Step 3: Run the vocabulary unit tests** + +Run: `cargo test -p aura-std std_vocabulary` +Expected: PASS — 2 passed +(`std_vocabulary_resolves_known_and_rejects_unknown`, +`std_vocabulary_types_lists_exactly_the_resolvable_keys`), 0 failed. + +- [ ] **Step 4: Verify the consumers see identical behaviour** + +Run: `cargo test -p aura-cli --test graph_construct` +Expected: PASS — 20 passed, 0 failed (in particular +`graph_introspect_vocabulary_lists_the_node_types` green unchanged). + +--- + +### Task 2: Full regression + +**Files:** none (verification only) + +- [ ] **Step 1: Build** + +Run: `cargo build --workspace` +Expected: clean, 0 errors. + +- [ ] **Step 2: Full suite** + +Run: `cargo test --workspace` +Expected: 0 failed across all targets; total pass count unchanged vs the +cycle-0104 close (884 passed — this cycle adds and removes no test, it only +reshapes the two vocabulary tests in place). + +- [ ] **Step 3: Lint** + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean, 0 warnings. + +- [ ] **Step 4: Doc build** + +Run: `cargo doc --workspace --no-deps 2>&1` +Expected: finishes with 0 warnings (the macro-generated fns carry their +`///` docs).