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}