Skip to main content

nmbrs_runtime/
wrapper_registry.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-32a — Op Wrapper Registry.
5//!
6//! Single source of truth for the named wrappers that compose
7//! around an adapter's base dispenser: which op-template fields
8//! each owns, what triggers them, what constraints they declare,
9//! and how they describe their assignment to an op.
10//!
11//! The companion module [`crate::wrapper_resolver`] consumes the
12//! registrations submitted via `inventory::submit!` to compute
13//! the per-op composition order. The wrapper implementations
14//! themselves live in [`crate::wrappers`] and
15//! [`crate::validation`] — this module is data, not code.
16
17use nmbrs_workload::model::{ParsedOp, WorkloadPhase};
18
19/// Stable identifier for a wrapper. Used in workload override
20/// directives, CLI flags, and registry lookups. Wrapped around a
21/// `&'static str` so the value is always interned at the
22/// registration site and lookups are pointer-cheap.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct WrapperName(pub &'static str);
25
26impl WrapperName {
27    pub const fn new(name: &'static str) -> Self {
28        Self(name)
29    }
30
31    pub fn as_str(&self) -> &'static str {
32        self.0
33    }
34}
35
36impl std::fmt::Display for WrapperName {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.write_str(self.0)
39    }
40}
41
42/// SRD-92 / ExecUnification — the execution-graph level(s) a wrapper is legal
43/// at. Today every wrapper is op-level; the unified scaffold (Step 5) reads
44/// this to place a layer at the right level once layering goes cross-level.
45/// Kept decoupled from executor's WIP `ShellKind` until the unification lands.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum WrapperLevel {
48    Op,
49    Stanza,
50    Phase,
51    Scenario,
52    Session,
53}
54
55/// SRD-82/92 — the execution unit a wrapper's trigger inspects, generalising
56/// the registry across levels. An op wrapper reads the `Op` variant; a phase
57/// wrapper the `Phase` variant (scenario/session variants join when those
58/// levels wire up). Op-level wrappers guard with [`Self::op`]; the resolver
59/// only offers a wrapper subjects of the level it declares (`applies_at`), so
60/// a well-formed wrapper never sees a foreign subject — the guard is a
61/// belt-and-braces `None` return.
62#[derive(Clone, Copy)]
63pub enum WrapperSubject<'a> {
64    Op(&'a ParsedOp),
65    Phase(&'a WorkloadPhase),
66}
67
68impl<'a> WrapperSubject<'a> {
69    /// The execution level of this subject; pairs with [`WrapperRegistration::applies_at`].
70    pub fn level(&self) -> WrapperLevel {
71        match self {
72            WrapperSubject::Op(_) => WrapperLevel::Op,
73            WrapperSubject::Phase(_) => WrapperLevel::Phase,
74        }
75    }
76
77    /// The op template, if this is an op subject (op-wrapper triggers guard on it).
78    pub fn op(&self) -> Option<&'a ParsedOp> {
79        match self {
80            WrapperSubject::Op(op) => Some(op),
81            _ => None,
82        }
83    }
84
85    /// The phase, if this is a phase subject (phase-wrapper triggers guard on it).
86    pub fn phase(&self) -> Option<&'a WorkloadPhase> {
87        match self {
88            WrapperSubject::Phase(p) => Some(p),
89            _ => None,
90        }
91    }
92
93    /// Uniform "is this wrapper-owned field present?" — the canonical field
94    /// names mapped to each unit's actual storage. Op fields are spread
95    /// across `params`/`condition`/`delay`/`rate`; phase fields are typed
96    /// slots. Used by the parse-time misplaced-field guard.
97    pub fn has_owned_field(&self, field: &str) -> bool {
98        match self {
99            WrapperSubject::Op(op) => match field {
100                "if" => op.condition.is_some(),
101                "delay" => op.delay.is_some(),
102                "while" => op.while_cond.is_some(),
103                "rate" => op.rate.is_some(),
104                _ => op.params.contains_key(field),
105            },
106            WrapperSubject::Phase(p) => match field {
107                "interval" => p.interval.is_some(),
108                "repeat" => p.repeat.is_some(),
109                _ => false,
110            },
111        }
112    }
113}
114
115/// One entry per registered wrapper. Entries are submitted at
116/// link time via `inventory::submit!` and collected at startup
117/// into the [`WrapperRegistry`] view.
118///
119/// The fields here are pure declaration: which fields the
120/// wrapper consumes, when it applies, what relationships it
121/// has to other wrappers, and how it describes its assignment.
122/// Construction of the dispenser layer itself is NOT in the
123/// registration — the cascade in `activity.rs` continues to
124/// hold the per-wrapper `wrap()` calls (each has a different
125/// signature). The registry decides PRESENCE and ORDER; the
126/// cascade looks up the resolved plan and dispatches by name.
127pub struct WrapperRegistration {
128    /// Stable name (`"validate"`, `"poll"`, `"delay"`,
129    /// `"if"`, `"emit"`, `"result"`, `"metrics"`, `"traverse"`).
130    pub name: WrapperName,
131
132    /// Op-template field names this wrapper exclusively owns.
133    /// Listed for parse-time validation: a misplaced field like
134    /// `poll_interval_ms: 5000` on an op without `poll:` becomes
135    /// a hard error pointing at THIS registration, not an opaque
136    /// "unknown param".
137    ///
138    /// Pure data — the parse-time guard reads this; the wrapper
139    /// implementation reads its own fields directly off the
140    /// `ParsedOp`.
141    pub owned_fields: &'static [&'static str],
142
143    /// Predicate over the op template: "does this wrapper apply
144    /// to this op?" Default behaviour: any owned field present.
145    /// Wrappers with no owned fields (e.g. `result`, which fires
146    /// whenever the op declares any `result:` wires) override
147    /// this with their own logic.
148    pub triggers: fn(WrapperSubject) -> bool,
149
150    /// Wrappers that MUST sit inside this one (closer to the
151    /// adapter, called *after* this one per cycle).
152    /// Activates transitively: triggering `validate` pulls in
153    /// `traverse` whether or not a traverse field was declared.
154    pub requires_inner: &'static [WrapperName],
155
156    /// Wrappers that MUST NOT sit outside this one. Hard error
157    /// when the constraint graph would permit any of the listed
158    /// wrappers to wrap this one.
159    pub forbids_outer: &'static [WrapperName],
160
161    /// Wrappers that cannot coexist with this one on a given
162    /// op. Triggering both is a hard error.
163    pub mutually_exclusive_with: &'static [WrapperName],
164
165    /// One-line summary of what this wrapper, configured for
166    /// the given op template, will do at runtime.
167    /// Emitted at Info level once per op-template activation,
168    /// alongside the other wrappers in the resolved plan.
169    ///
170    /// Examples:
171    /// - `validate`: `"validate: min_rows ≥ 1 (strict)"`
172    /// - `poll`:     `"poll: every 5s, timeout 600s, on \`await_empty\`"`
173    /// - `if`:       `"if: cql_dialect == 'cass5'"`
174    ///
175    /// Returns `None` for wrappers that have nothing useful to
176    /// say (e.g. an always-on `traverse` with no per-op
177    /// configuration); operators see the wrappers that actually
178    /// shape behaviour, the boilerplate stays at Debug.
179    ///
180    /// Distinct from `OpDispenser::describe()` which describes
181    /// the *runtime op* — e.g. the CQL statement text — for
182    /// error-context dumps. This describes the *wrapper's
183    /// contribution* for init-time diagnostics.
184    pub describe_assignment: fn(WrapperSubject) -> Option<String>,
185
186    /// SRD-92 / ExecUnification — the execution-graph level(s) this wrapper is
187    /// legal at. Every current wrapper is `&[WrapperLevel::Op]`; the unified
188    /// scaffold reads this to know where a layer may sit. Carried as metadata
189    /// now; consumed when layering goes cross-level (Step 5).
190    pub levels: &'static [WrapperLevel],
191}
192
193inventory::collect!(WrapperRegistration);
194
195impl WrapperRegistration {
196    /// SRD-92 — whether this wrapper is legal at `level`.
197    pub fn applies_at(&self, level: WrapperLevel) -> bool {
198        self.levels.contains(&level)
199    }
200}
201
202#[cfg(test)]
203mod level_tests {
204    use super::*;
205
206    fn no_trigger(_: WrapperSubject) -> bool {
207        false
208    }
209    fn no_describe(_: WrapperSubject) -> Option<String> {
210        None
211    }
212
213    #[test]
214    fn applies_at_reads_declared_levels() {
215        let reg = WrapperRegistration {
216            name: WrapperName::new("t"),
217            owned_fields: &[],
218            triggers: no_trigger,
219            requires_inner: &[],
220            forbids_outer: &[],
221            mutually_exclusive_with: &[],
222            describe_assignment: no_describe,
223            levels: &[WrapperLevel::Op, WrapperLevel::Phase],
224        };
225        assert!(reg.applies_at(WrapperLevel::Op));
226        assert!(reg.applies_at(WrapperLevel::Phase));
227        assert!(!reg.applies_at(WrapperLevel::Session));
228    }
229}
230
231/// Live view over every registered wrapper. Built once at
232/// startup from the `inventory` collection.
233///
234/// The struct is cheap to clone (it borrows the static
235/// registrations) and is passed to the [`crate::wrapper_resolver::WrapperResolver`]
236/// for per-op-template plan computation.
237pub struct WrapperRegistry {
238    entries: Vec<&'static WrapperRegistration>,
239}
240
241impl WrapperRegistry {
242    /// Build the registry from every `inventory::submit!`
243    /// entry currently linked into the binary. Invoked once
244    /// at startup.
245    pub fn from_inventory() -> Self {
246        let mut entries: Vec<&'static WrapperRegistration> =
247            inventory::iter::<WrapperRegistration>().collect();
248        entries.sort_by_key(|r| r.name);
249        Self { entries }
250    }
251
252    /// Iterate every registered wrapper.
253    pub fn iter(&self) -> impl Iterator<Item = &'static WrapperRegistration> + '_ {
254        self.entries.iter().copied()
255    }
256
257    /// Whether `field` is an op-template key declared as owned by ANY
258    /// registered wrapper (its trigger or a knob it consumes). This is
259    /// the single structural source of truth for "this params key is a
260    /// wrapper field" — driven by each wrapper's `owned_fields`
261    /// declaration, not a hand-maintained parallel list. The op
262    /// closed-vocabulary guard consults it so a wrapper field is
263    /// accepted because a wrapper *declares* it, not because it happens
264    /// to also be a CLI param (SRD-32a). Adding a wrapper therefore
265    /// never requires touching `CORE_OP_PARAMS` or the CLI vocabulary.
266    pub fn owns_field(&self, field: &str) -> bool {
267        self.iter().any(|reg| reg.owned_fields.contains(&field))
268    }
269
270    /// Every field name owned by some registered wrapper, deduplicated.
271    /// Exposed for diagnostics (the closed-vocab guard names the full
272    /// accepted wrapper vocabulary) and for the drift-guard test.
273    pub fn all_owned_fields(&self) -> std::collections::BTreeSet<&'static str> {
274        self.iter()
275            .flat_map(|reg| reg.owned_fields.iter().copied())
276            .collect()
277    }
278
279    /// Look up by name. Returns `None` for unknown names; the
280    /// caller surfaces that as a typo diagnostic with a
281    /// closest-match suggestion (see [`closest_match`]).
282    pub fn get(&self, name: WrapperName) -> Option<&'static WrapperRegistration> {
283        self.entries.iter().copied().find(|r| r.name == name)
284    }
285
286    /// Look up by raw string. Convenience for parsing the
287    /// `--wrap-default-order` / `wrappers.order` lists.
288    pub fn get_str(&self, name: &str) -> Option<&'static WrapperRegistration> {
289        self.entries
290            .iter()
291            .copied()
292            .find(|r| r.name.as_str() == name)
293    }
294
295    /// Number of registered wrappers.
296    pub fn len(&self) -> usize {
297        self.entries.len()
298    }
299
300    pub fn is_empty(&self) -> bool {
301        self.entries.is_empty()
302    }
303
304    /// Find the registered wrapper name closest to `query` by
305    /// Levenshtein distance. Used for "did you mean … ?"
306    /// diagnostics on unknown names.
307    pub fn closest_match(&self, query: &str) -> Option<&'static str> {
308        closest_match(query, self.entries.iter().map(|r| r.name.as_str()))
309    }
310
311    /// SRD-32a Push 2 — find every owned field that's present
312    /// on the op template but whose owning wrapper does NOT
313    /// trigger. Returns one (wrapper, field) pair per
314    /// violation; an empty vec means every field is in its
315    /// proper place.
316    ///
317    /// `field_present_on_template` is the predicate the
318    /// caller uses to ask "is field X set on this op?" — it
319    /// abstracts the fact that some wrapper-owned fields
320    /// (e.g. `if`, `delay`) live outside `params:`. In
321    /// practice the owned fields that AREN'T also their
322    /// wrapper's trigger always live under `params:`, so a
323    /// caller can pass a closure over `template.params
324    /// .contains_key`.
325    pub fn misplaced_fields(&self, subject: WrapperSubject) -> Vec<(WrapperName, &'static str)> {
326        let mut out: Vec<(WrapperName, &'static str)> = Vec::new();
327        for reg in self.iter() {
328            // Only wrappers legal at this subject's level can claim its
329            // fields; and a triggered wrapper's fields are correctly placed.
330            if !reg.applies_at(subject.level()) || (reg.triggers)(subject) {
331                continue;
332            }
333            for &field in reg.owned_fields {
334                if subject.has_owned_field(field) {
335                    out.push((reg.name, field));
336                }
337            }
338        }
339        out
340    }
341}
342
343/// Find the closest match in a list of candidate names, using
344/// Levenshtein edit distance. Returns `None` when no candidate
345/// is within distance 3, since beyond that the suggestion is
346/// noise.
347pub fn closest_match<'a>(
348    query: &str,
349    candidates: impl IntoIterator<Item = &'a str>,
350) -> Option<&'a str> {
351    let mut best: Option<(&str, usize)> = None;
352    for c in candidates {
353        let d = levenshtein(query, c);
354        match best {
355            Some((_, prev)) if d >= prev => {}
356            _ => best = Some((c, d)),
357        }
358    }
359    best.filter(|&(_, d)| d <= 3).map(|(s, _)| s)
360}
361
362fn levenshtein(a: &str, b: &str) -> usize {
363    let a: Vec<char> = a.chars().collect();
364    let b: Vec<char> = b.chars().collect();
365    let (n, m) = (a.len(), b.len());
366    if n == 0 {
367        return m;
368    }
369    if m == 0 {
370        return n;
371    }
372    let mut prev: Vec<usize> = (0..=m).collect();
373    let mut curr: Vec<usize> = vec![0; m + 1];
374    for i in 1..=n {
375        curr[0] = i;
376        for j in 1..=m {
377            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
378            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
379        }
380        std::mem::swap(&mut prev, &mut curr);
381    }
382    prev[m]
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn levenshtein_basic() {
391        assert_eq!(levenshtein("", "abc"), 3);
392        assert_eq!(levenshtein("abc", ""), 3);
393        assert_eq!(levenshtein("kitten", "sitting"), 3);
394        assert_eq!(levenshtein("validate", "validatte"), 1);
395    }
396
397    #[test]
398    fn closest_match_finds_typo() {
399        let names = ["validate", "poll", "delay"];
400        assert_eq!(closest_match("validatte", names), Some("validate"));
401        assert_eq!(closest_match("plll", names), Some("poll"));
402        assert_eq!(closest_match("wildly_different", names), None);
403    }
404}