Skip to main content

nmbrs_workload/
construction.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Enumerable workload-construction nodes.
5//!
6//! Every node kind of the workload grammar (workload root,
7//! phase, op, poll, scenario node, …) implements
8//! [`EnumerableNode`]: it names itself with a single
9//! discriminant, enumerates its valid sub-elements WITH their
10//! valid forms, and VALIDATES a runtime AST (partial or
11//! complete) directly. Child node kinds are held as DIRECT
12//! `&'static dyn EnumerableNode` references inside [`Form`] — no
13//! side mapping from names to nodes exists, so a dangling
14//! reference is unrepresentable: the reference graph is checked
15//! by the compiler, the same single-discriminant discipline the
16//! wrapper registry uses at the execution-context layer.
17//!
18//! These tables are the single declaration point for each
19//! element (name + forms + doc together). The flat name lists in
20//! [`crate::vocab`] are PROJECTIONS of these tables (the parser
21//! consumes those projections for its rejection checks), and the
22//! phase table is pinned against the
23//! [`crate::model::WorkloadPhase`] struct itself by a serde
24//! field probe in this module's tests.
25//!
26//! ENFORCEMENT: `parse_workload` runs [`validate_workload`] on
27//! every extends-merged document (the construction gate —
28//! docs/guide/construction_model.md). COHERENCE AXIOM: a
29//! construction that cannot be represented in the synthesizer
30//! fuzz is not yet a valid construction; the fuzzer's deferral
31//! list is the debt register against this axiom.
32//!
33//! Context sensitivity: [`EnumerableNode::elements`] receives
34//! the partial AST node (when the caller has one) and may narrow
35//! — e.g. a phase carrying `poll:` pins `concurrency` to 1, a
36//! phase with inline `ops:` loses the `tags:` selector (one
37//! source of ops, SRD-108 Part A), and an op with `abstract:`
38//! drops the statement payload.
39
40use serde_json::Value;
41
42/// A valid value form for one element. Nested node kinds are
43/// direct references — the grammar graph carries itself.
44#[derive(Clone, Copy)]
45pub enum Form {
46    /// Boolean (elements document accepted 0/1/"on"/"off" sugar).
47    Bool,
48    /// Unsigned integer (numeric strings accepted where the
49    /// element documents it).
50    U64,
51    /// Float.
52    F64,
53    /// Plain string.
54    Str,
55    /// List of strings (globs where documented).
56    StrList,
57    /// Duration: `"2.5h"`, bare seconds, or a `{param}` ref.
58    Duration,
59    /// A `{param}` / iter-var reference accepted alongside the
60    /// literal forms listed beside it.
61    ParamRef,
62    /// A polydat bindings SOURCE block (multi-line grammar).
63    GkSource,
64    /// A single polydat expression (predicates, metric values).
65    GkExpr,
66    /// A capture / result path expression (`/0/field`,
67    /// `rows[*].col` — SRD-70).
68    PathExpr,
69    /// A metric selector (`"family, label=value"`).
70    MetricSelector,
71    /// One of a closed vocabulary of literal values.
72    Vocab(&'static [&'static str]),
73    /// A nested node of the referenced kind.
74    Node(&'static dyn EnumerableNode),
75    /// A map of AUTHOR-CHOSEN names, each value a node of the
76    /// referenced kind (e.g. `phases:` → phase nodes).
77    NamedMap(&'static dyn EnumerableNode),
78    /// A list whose entries are nodes of the referenced kind.
79    ListOf(&'static dyn EnumerableNode),
80    /// A map of author-chosen names whose VALUES all take the
81    /// given form (e.g. `capture:` name → PathExpr).
82    MapOf(&'static Form),
83    /// A list whose entries all take the given form.
84    ListOfForm(&'static Form),
85    /// An open map (author-chosen keys, unmodeled values).
86    FreeMap,
87    /// An open scalar (adapter-defined or free text).
88    FreeScalar,
89}
90
91impl std::fmt::Debug for Form {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Form::Bool => write!(f, "Bool"),
95            Form::U64 => write!(f, "U64"),
96            Form::F64 => write!(f, "F64"),
97            Form::Str => write!(f, "Str"),
98            Form::StrList => write!(f, "StrList"),
99            Form::Duration => write!(f, "Duration"),
100            Form::ParamRef => write!(f, "ParamRef"),
101            Form::GkSource => write!(f, "GkSource"),
102            Form::GkExpr => write!(f, "GkExpr"),
103            Form::PathExpr => write!(f, "PathExpr"),
104            Form::MetricSelector => write!(f, "MetricSelector"),
105            Form::Vocab(v) => write!(f, "Vocab({v:?})"),
106            Form::Node(n) => write!(f, "Node({})", n.kind()),
107            Form::NamedMap(n) => write!(f, "NamedMap({})", n.kind()),
108            Form::ListOf(n) => write!(f, "ListOf({})", n.kind()),
109            Form::MapOf(inner) => write!(f, "MapOf({inner:?})"),
110            Form::ListOfForm(inner) => write!(f, "ListOfForm({inner:?})"),
111            Form::FreeMap => write!(f, "FreeMap"),
112            Form::FreeScalar => write!(f, "FreeScalar"),
113        }
114    }
115}
116
117impl PartialEq for Form {
118    fn eq(&self, other: &Self) -> bool {
119        use Form::*;
120        match (self, other) {
121            (Vocab(a), Vocab(b)) => a == b,
122            (Node(a), Node(b)) | (NamedMap(a), NamedMap(b)) | (ListOf(a), ListOf(b)) => {
123                a.kind() == b.kind()
124            }
125            (MapOf(a), MapOf(b)) | (ListOfForm(a), ListOfForm(b)) => a == b,
126            _ => std::mem::discriminant(self) == std::mem::discriminant(other),
127        }
128    }
129}
130
131/// One valid sub-element of a node: the single declaration point
132/// for its name, accepted forms, and documentation.
133#[derive(Debug, Clone, Copy)]
134pub struct ElementSpec {
135    pub name: &'static str,
136    pub forms: &'static [Form],
137    pub required: bool,
138    /// Serde-alias spellings that satisfy this element (a
139    /// REQUIRED element is present when its name OR any alias
140    /// key is).
141    pub aliases: &'static [&'static str],
142    pub doc: &'static str,
143}
144
145const fn el(name: &'static str, forms: &'static [Form], doc: &'static str) -> ElementSpec {
146    ElementSpec {
147        name,
148        forms,
149        required: false,
150        aliases: &[],
151        doc,
152    }
153}
154
155/// Validation strictness: a PARTIAL model skips required-element
156/// checks (it is still being authored); a COMPLETE model does
157/// not.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum Mode {
160    Partial,
161    Complete,
162}
163
164/// One validation finding, with the AST path it anchors to.
165#[derive(Debug, Clone)]
166pub struct Violation {
167    pub path: String,
168    pub message: String,
169}
170
171/// An enumerable node kind of the workload construction grammar.
172pub trait EnumerableNode: Sync {
173    /// Canonical kind discriminant (`"workload"`, `"phase"`, …).
174    fn kind(&self) -> &'static str;
175    /// One-line description of the node kind.
176    fn doc(&self) -> &'static str;
177    /// Valid sub-elements. `partial` is the (possibly incomplete)
178    /// AST value AT this node when the caller has one; nodes may
179    /// narrow their element set from it. `None` yields the
180    /// unconditioned surface.
181    fn elements(&self, partial: Option<&Value>) -> Vec<ElementSpec>;
182    /// When this node also accepts keys OUTSIDE the enumerated
183    /// set, the reason (open vocabularies are declared, never
184    /// implied). `None` = closed surface.
185    fn open_surface(&self) -> Option<&'static str> {
186        None
187    }
188
189    /// Validate `value` as an instance of this node kind:
190    /// unknown keys on closed surfaces, form conformance per
191    /// element, and (in [`Mode::Complete`]) required elements —
192    /// recursing through nested node references. This is the
193    /// runtime type check of the construction grammar.
194    fn validate(&self, value: &Value, mode: Mode) -> Vec<Violation>
195    where
196        Self: Sized,
197    {
198        let mut out = Vec::new();
199        validate_node(self, value, mode, self.kind(), &mut out);
200        out
201    }
202}
203
204// ─── Node statics (single discriminants, referenced directly) ────
205
206pub static WORKLOAD: WorkloadNode = WorkloadNode;
207pub static PHASE: PhaseNode = PhaseNode;
208pub static OP: OpNode = OpNode;
209pub static POLL: PollNode = PollNode;
210pub static SCENARIO: ScenarioNode = ScenarioNode;
211pub static STOP_WHEN: StopWhenNode = StopWhenNode;
212pub static METRIC: MetricNode = MetricNode;
213pub static ABSTRACT: AbstractNode = AbstractNode;
214pub static EVALUATIONS: EvaluationsNode = EvaluationsNode;
215pub static RELEVANCY: RelevancyNode = RelevancyNode;
216pub static BINDING_CLASSES: BindingClassesNode = BindingClassesNode;
217pub static MEMO: MemoNode = MemoNode;
218pub static OP_POLL: OpPollNode = OpPollNode;
219pub static TRIES: TriesNode = TriesNode;
220pub static BACKOFF: BackoffNode = BackoffNode;
221pub static THROTTLE: ThrottleNode = ThrottleNode;
222pub static DELAY: DelayNode = DelayNode;
223pub static CONTINUE_IF: ContinueIfNode = ContinueIfNode;
224pub static CHECKPOINT: CheckpointNode = CheckpointNode;
225pub static OPTIMIZE: OptimizeNode = OptimizeNode;
226
227/// Every registered node kind — an enumeration surface for
228/// tooling (`describe`, docs). Resolution never goes through
229/// this list: forms hold their child nodes directly.
230pub static ALL_NODES: &[&dyn EnumerableNode] = &[
231    &WORKLOAD,
232    &PHASE,
233    &OP,
234    &POLL,
235    &SCENARIO,
236    &STOP_WHEN,
237    &METRIC,
238    &ABSTRACT,
239    &EVALUATIONS,
240    &RELEVANCY,
241    &BINDING_CLASSES,
242    &MEMO,
243    &OP_POLL,
244    &TRIES,
245    &BACKOFF,
246    &THROTTLE,
247    &DELAY,
248    &CONTINUE_IF,
249    &CHECKPOINT,
250    &OPTIMIZE,
251];
252
253/// Kind lookup over [`ALL_NODES`] — a convenience for external
254/// tooling only.
255pub fn node_by_kind(kind: &str) -> Option<&'static dyn EnumerableNode> {
256    ALL_NODES.iter().copied().find(|n| n.kind() == kind)
257}
258
259/// The workload root node.
260pub fn root() -> &'static dyn EnumerableNode {
261    &WORKLOAD
262}
263
264// ─── Spec tables (single declaration points) ─────────────────────
265
266const STOP_EFFECTS: &[&str] = &["stop", "fail", "abort"];
267
268pub static PHASE_ELEMENTS: &[ElementSpec] = &[
269    el(
270        "cycles",
271        &[Form::U64, Form::ParamRef, Form::Str],
272        "stanza count; string forms: {param}, extent sigils (==ops:N, ===auto) — runtime-parsed",
273    ),
274    el(
275        "concurrency",
276        &[Form::U64, Form::ParamRef, Form::Str],
277        "async fibers ({param} or bare wire name resolves in scope)",
278    ),
279    el(
280        "rate",
281        &[Form::F64, Form::ParamRef],
282        "ops/sec; {param}/iter-var resolves at the phase gather",
283    ),
284    el(
285        "daemon",
286        &[Form::Bool],
287        "runs concurrently with foreground siblings; stopped when they complete",
288    ),
289    el("adapter", &[Form::Str], "adapter override for this phase"),
290    el(
291        "errors",
292        &[Form::Str],
293        "error-routing spec (e.g. \"count,retry\", \".*:counter\")",
294    ),
295    el(
296        "tries",
297        &[Form::U64, Form::Node(&TRIES)],
298        "total attempts; map form adds backoff (carries tries_backoff)",
299    ),
300    el(
301        "interval",
302        &[Form::Duration],
303        "declarative-only today: phase re-run pacing",
304    ),
305    el("repeat", &[Form::U64], "bound for interval"),
306    el(
307        "error_rate_max",
308        &[Form::F64],
309        "opt-in error-rate circuit breaker",
310    ),
311    el(
312        "timeout",
313        &[Form::Duration, Form::ParamRef],
314        "governance bound; expiry = Interrupted+Failed (timeout)",
315    ),
316    el(
317        "stop_when",
318        &[Form::ListOf(&STOP_WHEN)],
319        "SRD-83 stop conditions",
320    ),
321    el(
322        "throttle",
323        &[Form::Bool, Form::Node(&THROTTLE)],
324        "adaptive backpressure governor: windowed attempt-failure fraction walks a dynamic control (true = defaults)",
325    ),
326    el(
327        "tags",
328        &[Form::Str],
329        "tag FILTER selecting ops from blocks (exclusive with inline ops)",
330    ),
331    el(
332        "ops",
333        &[Form::NamedMap(&OP), Form::ListOf(&OP)],
334        "inline op templates (exclusive with tags selector)",
335    ),
336    el("for_each", &[Form::Str], "phase sweep: \"var in expr\""),
337    el(
338        "continue_if",
339        &[Form::GkExpr, Form::Node(&CONTINUE_IF)],
340        "pre-entry gate bounding a for_each sweep",
341    ),
342    el(
343        "loop_scope",
344        &[Form::Vocab(&["clean", "inherit"])],
345        "loop-context seeding for for_each",
346    ),
347    el(
348        "iter_scope",
349        &[Form::Vocab(&["clean", "inherit"])],
350        "iteration seeding for for_each",
351    ),
352    el(
353        "checkpoint",
354        &[
355            Form::Vocab(&["idempotent", "disabled"]),
356            Form::Node(&CHECKPOINT),
357        ],
358        "skip-on-resume eligibility (SRD-44/106)",
359    ),
360    el(
361        "status_metrics",
362        &[Form::StrList],
363        "metric-name globs surfaced on the status line",
364    ),
365    el(
366        "bindings",
367        &[Form::GkSource, Form::FreeMap],
368        "phase-scope polydat bindings (map = legacy form)",
369    ),
370    el(
371        "metrics",
372        &[
373            Form::NamedMap(&METRIC),
374            Form::GkExpr,
375            Form::ListOfForm(&Form::GkExpr),
376        ],
377        "completion-time phase metrics (SRD-40b sugars: bare expr / list)",
378    ),
379    el(
380        "dimensions",
381        &[Form::FreeMap],
382        "label declarations owned by this tier",
383    ),
384    el(
385        "poll",
386        &[Form::Node(&POLL)],
387        "SRD-75 phase-poll loop (forbids concurrency > 1)",
388    ),
389    el(
390        "optimize",
391        &[Form::GkExpr, Form::Node(&OPTIMIZE)],
392        "SRD-86 optimizer dispatch (string = objective sugar)",
393    ),
394    el(
395        "key_metrics",
396        &[Form::FreeMap],
397        "SRD-113 key-metric designations: column -> agg(family), aggregate mandatory",
398    ),
399];
400
401pub static OP_MODEL_ELEMENTS: &[ElementSpec] = &[
402    el("name", &[Form::Str], "implicit — set by the op's map key"),
403    el("description", &[Form::Str], "op description"),
404    el("desc", &[Form::Str], "legacy alias of description"),
405    el(
406        "bindings",
407        &[Form::GkSource, Form::FreeMap],
408        "op-scope polydat bindings (map = legacy form)",
409    ),
410    el(
411        "params",
412        &[Form::FreeMap],
413        "activity-level params excised from adapter fields",
414    ),
415    el("tags", &[Form::FreeMap], "tags for filtering and metadata"),
416    el(
417        "if",
418        &[Form::GkExpr],
419        "per-cycle condition; falsy skips the op",
420    ),
421    el(
422        "delay",
423        &[Form::Str, Form::Node(&DELAY)],
424        "pre/post-op delay wire name(s)",
425    ),
426    el(
427        "evaluations",
428        &[Form::Node(&EVALUATIONS)],
429        "closed-vocab validation/scoring wrapper",
430    ),
431    el(
432        "capture",
433        &[Form::MapOf(&Form::PathExpr)],
434        "wire ← path-expression extraction map",
435    ),
436    el(
437        "metrics",
438        &[
439            Form::NamedMap(&METRIC),
440            Form::GkExpr,
441            Form::ListOfForm(&Form::GkExpr),
442        ],
443        "per-cycle synthetic metrics (SRD-40b sugars: bare expr / list)",
444    ),
445    el(
446        "result",
447        &[Form::FreeMap, Form::GkSource],
448        "SRD-66 result bindings (named paths / source)",
449    ),
450    el(
451        "traverse",
452        &[Form::FreeMap],
453        "customizes the result traversal layer",
454    ),
455    el(
456        "measure",
457        &[Form::FreeMap, Form::Str, Form::ParamRef],
458        "measurement config (string = template form)",
459    ),
460    el(
461        "abstract",
462        &[Form::Node(&ABSTRACT)],
463        "SRD-108 typed interface slot (blueprint side)",
464    ),
465    el(
466        "uses",
467        &[Form::Str],
468        "instantiate the named `op_templates:` definition",
469    ),
470    el(
471        "daemon",
472        &[Form::Bool, Form::U64],
473        "op-level daemon fiber (N = max concurrent)",
474    ),
475    el(
476        "daemon_cancel_grace_ms",
477        &[Form::U64],
478        "daemon cancel grace",
479    ),
480    el("while", &[Form::GkExpr], "op-level loop guard"),
481    el(
482        "rate",
483        &[Form::F64, Form::Str],
484        "op-level pacing (number or rate string like \"5/s\")",
485    ),
486];
487
488/// Activity-level op keys excised from adapter fields by the
489/// model layer (the parser's excision list is this table's name
490/// projection — single source).
491pub static OP_ACTIVITY_ELEMENTS: &[ElementSpec] = &[
492    el("ratio", &[Form::U64], "op dispatch ratio within the stanza"),
493    el("adapter", &[Form::Str], "adapter override for this op"),
494    el("driver", &[Form::Str], "driver alias override"),
495    el(
496        "space",
497        &[Form::Str, Form::ParamRef],
498        "adapter space selector",
499    ),
500    el("instrument", &[Form::Bool], "per-op instrumentation toggle"),
501    el("start-timers", &[Form::StrList], "timer starts"),
502    el("stop-timers", &[Form::StrList], "timer stops"),
503    el(
504        "verify",
505        &[
506            Form::ListOfForm(&Form::FreeMap),
507            Form::FreeMap,
508            Form::GkExpr,
509        ],
510        "response verification clauses (string = predicate expression)",
511    ),
512    el(
513        "relevancy",
514        &[Form::Node(&RELEVANCY)],
515        "legacy top-level shorthand for evaluations.relevancy",
516    ),
517    el("strict", &[Form::Bool], "strict verification"),
518    el(
519        "poll",
520        &[Form::Node(&OP_POLL)],
521        "op-level poll loop (loops ONLY this op)",
522    ),
523    el("poll_interval_ms", &[Form::U64], "legacy op-poll interval"),
524    el("timeout_ms", &[Form::U64], "per-op timeout"),
525    el(
526        "poll_metric_name",
527        &[Form::Str],
528        "legacy op-poll metric name",
529    ),
530    el("emit", &[Form::Str], "emission override"),
531    el(
532        "batch",
533        &[Form::U64, Form::ParamRef, Form::Str],
534        "batch row cap: literal, {param}, or bare wire name",
535    ),
536    el(
537        "max_batch_size",
538        &[Form::U64, Form::GkExpr],
539        "batch byte budget (literal or GK call)",
540    ),
541    el(
542        "batchtype",
543        &[Form::Vocab(&["logged", "unlogged", "counter"])],
544        "CQL batch type",
545    ),
546    el(
547        "memo",
548        &[Form::Node(&MEMO), Form::Str],
549        "operator-facing before/after notes",
550    ),
551    el(
552        "gutter",
553        &[Form::FreeMap, Form::Str],
554        "status-gutter template (runtime-resolved)",
555    ),
556    el(
557        "readout",
558        &[Form::Vocab(&["visible", "hidden"])],
559        "SRD-63 op-level status visibility",
560    ),
561    el("errors", &[Form::Str], "per-op error-routing override"),
562    el(
563        "tries",
564        &[Form::U64, Form::Node(&TRIES)],
565        "per-op total-attempts sigil",
566    ),
567];
568
569pub static OP_STMT_ELEMENTS: &[ElementSpec] = &[
570    el("stmt", &[Form::FreeScalar], "canonical statement payload"),
571    el("op", &[Form::FreeScalar], "statement payload alias"),
572    el("ops", &[Form::FreeScalar], "statement payload alias"),
573    el("operations", &[Form::FreeScalar], "statement payload alias"),
574    el("statement", &[Form::FreeScalar], "statement payload alias"),
575    el("statements", &[Form::FreeScalar], "statement payload alias"),
576];
577
578pub static POLL_ELEMENTS: &[ElementSpec] = &[
579    ElementSpec {
580        name: "until",
581        forms: &[Form::GkExpr],
582        required: true,
583        aliases: &[],
584        doc: "predicate over captures; re-evaluated per iteration",
585    },
586    el(
587        "interval_ms",
588        &[Form::U64, Form::ParamRef],
589        "sleep between iterations (default 1000)",
590    ),
591    el(
592        "timeout_ms",
593        &[Form::U64, Form::ParamRef],
594        "wall-clock cap (default 300000)",
595    ),
596    el(
597        "max_error_retries",
598        &[Form::U64, Form::ParamRef],
599        "tolerated consecutive retryable errors",
600    ),
601    el(
602        "metric_name",
603        &[Form::Str],
604        "gauge written on successful loop exit (unit from suffix)",
605    ),
606    el(
607        "on_timeout",
608        &[Form::Vocab(&["error", "abort"])],
609        "deadline disposition",
610    ),
611    el(
612        "require",
613        &[Form::MetricSelector, Form::StrList],
614        "strict-gate selectors that must resolve",
615    ),
616];
617
618pub static SCENARIO_NODE_ELEMENTS: &[ElementSpec] = &[
619    el(
620        "for_each",
621        &[Form::Str, Form::StrList],
622        "iteration: \"var in expr\" (list = union of sub-spaces)",
623    ),
624    el(
625        "for",
626        &[Form::Str, Form::StrList],
627        "alias of for_each (list = union of sub-spaces)",
628    ),
629    el(
630        "anchor",
631        &[Form::Str],
632        "SRD-113 view anchor: one synthesized table row per iteration",
633    ),
634    el("scenario", &[Form::Str], "include another scenario by name"),
635    el(
636        "scenarios",
637        &[Form::Str, Form::ListOf(&SCENARIO)],
638        "scenario include(s) — plural takes a heterogeneous list",
639    ),
640    el(
641        "for_combinations",
642        &[Form::FreeMap],
643        "cross-product map form: var → expr",
644    ),
645    el(
646        "do_while",
647        &[Form::GkExpr],
648        "run children while true (test after)",
649    ),
650    el(
651        "do_until",
652        &[Form::GkExpr],
653        "run children until true (test after)",
654    ),
655    el(
656        "bindings",
657        &[Form::GkSource, Form::FreeMap],
658        "scenario-node bindings",
659    ),
660    el("set", &[Form::FreeMap], "param shadows over the child tree"),
661    el(
662        "phases",
663        &[Form::ListOf(&SCENARIO)],
664        "child nodes under a structural key",
665    ),
666    el(
667        "counter",
668        &[Form::Str],
669        "loop counter wire for do_while / do_until",
670    ),
671];
672
673pub static STOP_WHEN_ELEMENTS: &[ElementSpec] = &[
674    ElementSpec {
675        name: "when",
676        forms: &[Form::GkExpr, Form::ParamRef],
677        required: true,
678        aliases: &["condition"],
679        doc: "predicate over runtime-state wires; {param} interpolates",
680    },
681    el(
682        "condition",
683        &[Form::GkExpr, Form::ParamRef],
684        "serde alias of when",
685    ),
686    el(
687        "effect",
688        &[Form::Vocab(STOP_EFFECTS)],
689        "disposition on fire (alias: action; default fail)",
690    ),
691    el(
692        "action",
693        &[Form::Vocab(STOP_EFFECTS)],
694        "canonical key for effect",
695    ),
696    el(
697        "each",
698        &[Form::StrList, Form::Str],
699        "detection scope selector(s)",
700    ),
701    el("per", &[Form::StrList, Form::Str], "serde alias of each"),
702    el("trigger", &[Form::Str], "firing trigger"),
703    el("pulse", &[Form::FreeMap, Form::Str], "firing axis"),
704    el(
705        "at",
706        &[Form::Str],
707        "ACTION target scope — where the effect lands",
708    ),
709];
710
711pub static METRIC_ELEMENTS: &[ElementSpec] = &[
712    ElementSpec {
713        name: "value",
714        forms: &[Form::GkExpr],
715        required: true,
716        aliases: &[],
717        doc: "polydat expression over in-scope wires",
718    },
719    el(
720        "family",
721        &[Form::Str],
722        "family override (default: the metric's map key)",
723    ),
724    el(
725        "kind",
726        &[Form::Vocab(&["gauge", "counter", "histogram"])],
727        "instrument kind",
728    ),
729    el("unit", &[Form::Str], "unit hint"),
730    el("format", &[Form::Str], "render format hint"),
731    el(
732        "cell",
733        &[Form::FreeMap],
734        "dimension placement: label → wire (SRD coordinate cells)",
735    ),
736];
737
738pub static ABSTRACT_ELEMENTS: &[ElementSpec] = &[
739    el(
740        "needs",
741        &[Form::MapOf(&Form::FreeScalar)],
742        "wires the blueprint GUARANTEES: name → port-type keyword",
743    ),
744    el(
745        "yields",
746        &[Form::MapOf(&Form::FreeScalar)],
747        "wires the implementation must CAPTURE: name → port-type keyword",
748    ),
749    el(
750        "results",
751        &[Form::MapOf(&Form::FreeScalar)],
752        "wires delivered via result: projections: name → port-type keyword",
753    ),
754];
755
756pub static EVALUATIONS_ELEMENTS: &[ElementSpec] = &[
757    el(
758        "relevancy",
759        &[Form::Node(&RELEVANCY)],
760        "recall/ndcg scoring against ground truth",
761    ),
762    el("verify", &[Form::FreeMap], "field-equality verification"),
763];
764
765pub static RELEVANCY_ELEMENTS: &[ElementSpec] = &[
766    ElementSpec {
767        name: "actual",
768        forms: &[Form::Str],
769        required: true,
770        aliases: &[],
771        doc: "wire carrying retrieved values (bare wire name)",
772    },
773    ElementSpec {
774        name: "expected",
775        forms: &[Form::Str],
776        required: true,
777        aliases: &[],
778        doc: "wire carrying ground truth (bare wire name)",
779    },
780    el(
781        "k",
782        &[Form::U64, Form::Str],
783        "evaluation depth (literal or bare wire name)",
784    ),
785    el(
786        "r",
787        &[Form::U64, Form::Str],
788        "retrieved window (literal or bare wire name)",
789    ),
790    el(
791        "functions",
792        &[Form::ListOfForm(&Form::Vocab(&["recall", "ndcg"]))],
793        "scoring functions",
794    ),
795];
796
797pub static MEMO_ELEMENTS: &[ElementSpec] = &[
798    el(
799        "before",
800        &[Form::Str, Form::ParamRef],
801        "note before dispatch ({wire} interpolation)",
802    ),
803    el(
804        "after",
805        &[Form::Str, Form::ParamRef],
806        "note after completion ({wire} interpolation)",
807    ),
808];
809
810pub static OP_POLL_ELEMENTS: &[ElementSpec] = &[
811    el(
812        "mode",
813        &[Form::Vocab(&["await_empty"])],
814        "loop-until mode over the result body",
815    ),
816    el("until", &[Form::GkExpr], "predicate alternative to mode"),
817    el("interval_ms", &[Form::U64], "sleep between iterations"),
818    el("timeout_ms", &[Form::U64], "wall-clock cap"),
819    el(
820        "max_error_retries",
821        &[Form::U64],
822        "tolerated consecutive retryable errors",
823    ),
824    el("metric_name", &[Form::Str], "gauge on successful exit"),
825    el(
826        "memo",
827        &[Form::Str, Form::ParamRef, Form::Node(&MEMO)],
828        "per-iteration operator note ({wire} interpolation)",
829    ),
830    el(
831        "progress",
832        &[Form::Str, Form::ParamRef],
833        "derived-progress template overriding the cycle bar",
834    ),
835];
836
837pub static TRIES_ELEMENTS: &[ElementSpec] = &[
838    el("count", &[Form::U64], "total attempts"),
839    el(
840        "backoff",
841        &[Form::Node(&BACKOFF)],
842        "retry backoff overrides",
843    ),
844];
845
846pub static THROTTLE_ELEMENTS: &[ElementSpec] = &[
847    el(
848        "high",
849        &[Form::F64],
850        "windowed attempt-failure fraction that triggers back-off (default 0.05)",
851    ),
852    el("low", &[Form::F64], "recovery threshold (default high/5)"),
853    el(
854        "control",
855        &[Form::Vocab(&["concurrency", "rate"])],
856        "the dynamic control the governor walks",
857    ),
858    el(
859        "start",
860        &[Form::F64],
861        "initial offered value (slow-start seed; default = floor — declare higher only for known-robust targets)",
862    ),
863    el("floor", &[Form::F64], "never throttle below this value"),
864    el(
865        "window",
866        &[Form::Duration],
867        "evaluation window (default 2s)",
868    ),
869];
870
871pub static BACKOFF_ELEMENTS: &[ElementSpec] = &[
872    el("ratio", &[Form::F64], "growth ratio"),
873    el("min", &[Form::Duration], "floor"),
874    el("max", &[Form::Duration], "ceiling"),
875];
876
877pub static DELAY_ELEMENTS: &[ElementSpec] = &[
878    el("before", &[Form::Str], "pre-op delay wire name"),
879    el("after", &[Form::Str], "post-op delay wire name"),
880];
881
882pub static CONTINUE_IF_ELEMENTS: &[ElementSpec] = &[
883    ElementSpec {
884        name: "when",
885        forms: &[Form::GkExpr],
886        required: true,
887        aliases: &["condition"],
888        doc: "pre-entry predicate (bare wires via for_iteration scope)",
889    },
890    el("condition", &[Form::GkExpr], "serde alias of when"),
891    el(
892        "each",
893        &[Form::StrList, Form::Str],
894        "evaluation scope selector(s)",
895    ),
896    el("per", &[Form::StrList, Form::Str], "serde alias of each"),
897];
898
899pub static CHECKPOINT_ELEMENTS: &[ElementSpec] = &[
900    el("idempotent", &[Form::Bool], "prereq-class skip eligibility"),
901    el("hashed", &[Form::Bool], "provenance hashing toggle"),
902    el(
903        "verify",
904        &[Form::FreeMap],
905        "verify op-template body run before Skip",
906    ),
907];
908
909pub static OPTIMIZE_ELEMENTS: &[ElementSpec] = &[
910    el("method", &[Form::Str], "optimizer method (default sweep)"),
911    ElementSpec {
912        name: "objective",
913        forms: &[Form::GkExpr],
914        required: true,
915        aliases: &[],
916        doc: "objective wire expression (string sugar = whole block)",
917    },
918    el("servo", &[Form::StrList], "servo axis wires"),
919    el("max_evals", &[Form::U64], "evaluation budget"),
920    el("seed", &[Form::U64], "search seed"),
921];
922
923pub static WORKLOAD_ELEMENTS: &[ElementSpec] = &[
924    el(
925        "description",
926        &[Form::Str],
927        "workload description (required for curated catalog entries)",
928    ),
929    el(
930        "extends",
931        &[Form::Str],
932        "parent document (path or catalog name)",
933    ),
934    el(
935        "implements",
936        &[Form::Str],
937        "blueprint this implementation binds (SRD-108)",
938    ),
939    el(
940        "stick_session",
941        &[Form::Bool],
942        "SRD-106 re-attach-by-default",
943    ),
944    el("params", &[Form::FreeMap], "declared params with defaults"),
945    el(
946        "bindings",
947        &[Form::GkSource, Form::FreeMap],
948        "workload-root polydat bindings (map = legacy name→expr form)",
949    ),
950    el("phases", &[Form::NamedMap(&PHASE)], "phase definitions"),
951    el("scenarios", &[Form::NamedMap(&SCENARIO)], "scenario trees"),
952    el(
953        "ops",
954        &[Form::NamedMap(&OP), Form::ListOf(&OP)],
955        "top-level ops (legacy inline shape)",
956    ),
957    el(
958        "op",
959        &[Form::FreeScalar, Form::FreeMap],
960        "inline single-op shorthand",
961    ),
962    el(
963        "blocks",
964        &[Form::FreeMap],
965        "named op blocks for tag selection",
966    ),
967    el(
968        "op_templates",
969        &[Form::NamedMap(&OP)],
970        "reusable op definitions an op instantiates with `uses:`",
971    ),
972    el("tags", &[Form::FreeMap], "document tags"),
973    el(
974        "stop_when",
975        &[Form::ListOf(&STOP_WHEN)],
976        "workload-shell stop conditions",
977    ),
978    el(
979        "status_metrics",
980        &[Form::StrList],
981        "doc-root status-line default",
982    ),
983    el(
984        "report",
985        &[Form::FreeMap],
986        "SRD-46 report block (directive grammar: crate::report::vocab)",
987    ),
988    el("readouts", &[Form::FreeMap], "SRD-63 readout slot bindings"),
989    el(
990        "wrappers",
991        &[Form::FreeMap],
992        "SRD-32a wrapper-order override",
993    ),
994];
995
996/// GK binding declaration classes usable from workload
997/// `bindings:` blocks (the grammar itself is polydat's; the bare
998/// `name := expr` derived form has no keyword).
999pub static BINDING_CLASS_ELEMENTS: &[ElementSpec] = &[
1000    el("const", &[Form::GkSource], "construction-time constant"),
1001    el(
1002        "cursor",
1003        &[Form::GkSource],
1004        "extent-driving cursor declaration",
1005    ),
1006    el(
1007        "volatile",
1008        &[Form::GkSource],
1009        "re-evaluated per pull; acknowledges non-determinism",
1010    ),
1011    el(
1012        "shared",
1013        &[Form::GkSource],
1014        "phase-scope shared cell (write-through across ops)",
1015    ),
1016    el(
1017        "extern",
1018        &[Form::GkSource],
1019        "externally-written slot (captures, injected wires)",
1020    ),
1021    el(
1022        "input",
1023        &[Form::GkSource],
1024        "kernel coordinate input (kernel-plane; not workload-authored)",
1025    ),
1026];
1027
1028// ─── Node implementations ─────────────────────────────────────────
1029
1030macro_rules! simple_node {
1031    ($ty:ident, $kind:literal, $doc:literal, $table:ident) => {
1032        pub struct $ty;
1033        impl EnumerableNode for $ty {
1034            fn kind(&self) -> &'static str {
1035                $kind
1036            }
1037            fn doc(&self) -> &'static str {
1038                $doc
1039            }
1040            fn elements(&self, _partial: Option<&Value>) -> Vec<ElementSpec> {
1041                $table.to_vec()
1042            }
1043        }
1044    };
1045}
1046
1047simple_node!(
1048    WorkloadNode,
1049    "workload",
1050    "workload document root",
1051    WORKLOAD_ELEMENTS
1052);
1053simple_node!(PollNode, "poll", "SRD-75 phase-poll loop", POLL_ELEMENTS);
1054pub struct ScenarioNode;
1055impl EnumerableNode for ScenarioNode {
1056    fn kind(&self) -> &'static str {
1057        "scenario"
1058    }
1059    fn doc(&self) -> &'static str {
1060        "scenario-tree node (bare string = phase name; object = structural node)"
1061    }
1062    fn elements(&self, _partial: Option<&Value>) -> Vec<ElementSpec> {
1063        SCENARIO_NODE_ELEMENTS.to_vec()
1064    }
1065    fn open_surface(&self) -> Option<&'static str> {
1066        Some(
1067            "legacy command-string entries (`name: \"run …\"`) — string \
1068              values pass through; structural keys are closed and the \
1069              parser rejects unknown ones with non-string values",
1070        )
1071    }
1072}
1073simple_node!(
1074    StopWhenNode,
1075    "stop_when",
1076    "SRD-83 stop condition",
1077    STOP_WHEN_ELEMENTS
1078);
1079simple_node!(
1080    MetricNode,
1081    "metric",
1082    "synthetic metric declaration (SRD-40b)",
1083    METRIC_ELEMENTS
1084);
1085simple_node!(
1086    AbstractNode,
1087    "abstract",
1088    "SRD-108 typed interface slot",
1089    ABSTRACT_ELEMENTS
1090);
1091simple_node!(
1092    EvaluationsNode,
1093    "evaluations",
1094    "post-execution scoring wrapper",
1095    EVALUATIONS_ELEMENTS
1096);
1097simple_node!(
1098    RelevancyNode,
1099    "relevancy",
1100    "recall/ndcg scoring config",
1101    RELEVANCY_ELEMENTS
1102);
1103simple_node!(
1104    BindingClassesNode,
1105    "binding_classes",
1106    "GK binding declaration classes (grammar lives in polydat)",
1107    BINDING_CLASS_ELEMENTS
1108);
1109simple_node!(
1110    MemoNode,
1111    "memo",
1112    "operator-facing memo notes",
1113    MEMO_ELEMENTS
1114);
1115simple_node!(
1116    OpPollNode,
1117    "op_poll",
1118    "op-level poll loop",
1119    OP_POLL_ELEMENTS
1120);
1121simple_node!(TriesNode, "tries", "tries map form", TRIES_ELEMENTS);
1122simple_node!(
1123    BackoffNode,
1124    "backoff",
1125    "retry backoff overrides",
1126    BACKOFF_ELEMENTS
1127);
1128simple_node!(
1129    ThrottleNode,
1130    "throttle",
1131    "adaptive backpressure governor",
1132    THROTTLE_ELEMENTS
1133);
1134simple_node!(DelayNode, "delay", "pre/post-op delay", DELAY_ELEMENTS);
1135simple_node!(
1136    ContinueIfNode,
1137    "continue_if",
1138    "SRD-101 pre-entry gate",
1139    CONTINUE_IF_ELEMENTS
1140);
1141simple_node!(
1142    CheckpointNode,
1143    "checkpoint",
1144    "checkpoint declaration map form",
1145    CHECKPOINT_ELEMENTS
1146);
1147simple_node!(
1148    OptimizeNode,
1149    "optimize",
1150    "SRD-86 optimize block",
1151    OPTIMIZE_ELEMENTS
1152);
1153
1154pub struct PhaseNode;
1155impl EnumerableNode for PhaseNode {
1156    fn kind(&self) -> &'static str {
1157        "phase"
1158    }
1159    fn doc(&self) -> &'static str {
1160        "one measured phase"
1161    }
1162    fn elements(&self, partial: Option<&Value>) -> Vec<ElementSpec> {
1163        let mut out: Vec<ElementSpec> = PHASE_ELEMENTS.to_vec();
1164        if let Some(Value::Object(map)) = partial {
1165            // SRD-75: a poll phase is a serial-cycle loop —
1166            // concurrency is pinned to 1.
1167            if map.contains_key("poll") {
1168                for e in &mut out {
1169                    if e.name == "concurrency" {
1170                        e.forms = &[Form::Vocab(&["1"])];
1171                        e.doc = "pinned to 1 under poll: (SRD-75 serial-cycle loop)";
1172                    }
1173                }
1174            }
1175            // SRD-108 Part A: exactly one source of ops — inline
1176            // `ops:` and the `tags:` selector are exclusive.
1177            if map.contains_key("ops") {
1178                out.retain(|e| e.name != "tags");
1179            } else if map.contains_key("tags") {
1180                out.retain(|e| e.name != "ops");
1181            }
1182        }
1183        out
1184    }
1185}
1186
1187pub struct OpNode;
1188impl EnumerableNode for OpNode {
1189    fn kind(&self) -> &'static str {
1190        "op"
1191    }
1192    fn doc(&self) -> &'static str {
1193        "one op template"
1194    }
1195    fn elements(&self, partial: Option<&Value>) -> Vec<ElementSpec> {
1196        let mut out: Vec<ElementSpec> = OP_MODEL_ELEMENTS.to_vec();
1197        // Activity-level keys share the surface; the `tags` /
1198        // `errors` / `tries` spellings already covered by the
1199        // model table keep the model semantics.
1200        for e in OP_ACTIVITY_ELEMENTS {
1201            if !out.iter().any(|m| m.name == e.name) {
1202                out.push(*e);
1203            }
1204        }
1205        let is_abstract = matches!(partial, Some(Value::Object(m)) if m.contains_key("abstract"));
1206        if !is_abstract {
1207            // Concrete ops carry a statement payload (one of the
1208            // alias spellings); abstract slots carry none — the
1209            // bound implementation contributes it (SRD-108).
1210            out.extend_from_slice(OP_STMT_ELEMENTS);
1211        }
1212        out
1213    }
1214    fn open_surface(&self) -> Option<&'static str> {
1215        Some(
1216            "adapter op-payload fields — per-adapter surface \
1217              (closed for http/testkit via known_op_fields, open for cql)",
1218        )
1219    }
1220}
1221
1222// ─── Runtime type check (validation over any AST) ────────────────
1223
1224fn form_accepts_scalar(form: &Form, v: &Value) -> bool {
1225    match form {
1226        Form::Bool => {
1227            matches!(v, Value::Bool(_))
1228                || matches!(v, Value::Number(n) if n.as_u64().is_some_and(|u| u <= 1))
1229                || matches!(v, Value::String(s)
1230                if matches!(s.as_str(), "true" | "false" | "on" | "off" | "yes" | "no"))
1231        }
1232        Form::U64 => {
1233            matches!(v, Value::Number(n) if n.as_u64().is_some())
1234                || matches!(v, Value::String(s) if s.trim().parse::<u64>().is_ok())
1235        }
1236        Form::F64 => {
1237            v.is_number() || matches!(v, Value::String(s) if s.trim().parse::<f64>().is_ok())
1238        }
1239        Form::Str
1240        | Form::GkSource
1241        | Form::GkExpr
1242        | Form::PathExpr
1243        | Form::MetricSelector
1244        | Form::Duration => v.is_string() || v.is_number(),
1245        Form::ParamRef => matches!(v, Value::String(s) if s.contains('{') && s.contains('}')),
1246        Form::StrList => match v {
1247            Value::Array(items) => items.iter().all(Value::is_string),
1248            Value::String(_) => true,
1249            _ => false,
1250        },
1251        Form::Vocab(words) => {
1252            // Match by canonical rendering: strings (trimmed,
1253            // case-insensitive), numbers, and bools all compare
1254            // against the vocabulary words.
1255            let rendered = match v {
1256                Value::String(s) => s.trim().to_string(),
1257                Value::Number(n) => n.to_string(),
1258                Value::Bool(b) => b.to_string(),
1259                _ => return false,
1260            };
1261            words.iter().any(|w| w.eq_ignore_ascii_case(&rendered))
1262        }
1263        Form::FreeMap => v.is_object(),
1264        Form::FreeScalar => !v.is_object() || v.is_object(), // anything
1265        // Structural forms handled by the recursive walk.
1266        Form::Node(_)
1267        | Form::NamedMap(_)
1268        | Form::ListOf(_)
1269        | Form::MapOf(_)
1270        | Form::ListOfForm(_) => false,
1271    }
1272}
1273
1274fn validate_against_forms(
1275    forms: &[Form],
1276    value: &Value,
1277    mode: Mode,
1278    path: &str,
1279    out: &mut Vec<Violation>,
1280) {
1281    // Accept when ANY declared form matches; structural forms
1282    // recurse and report their own findings.
1283    for form in forms {
1284        match form {
1285            Form::Node(node) => {
1286                if value.is_object() {
1287                    validate_node(*node, value, mode, path, out);
1288                    return;
1289                }
1290            }
1291            Form::NamedMap(node) => {
1292                if let Value::Object(map) = value {
1293                    for (name, child) in map {
1294                        validate_node(*node, child, mode, &format!("{path}.{name}"), out);
1295                    }
1296                    return;
1297                }
1298            }
1299            Form::ListOf(node) => {
1300                if let Value::Array(items) = value {
1301                    for (i, child) in items.iter().enumerate() {
1302                        // Scenario lists accept bare-string
1303                        // shorthands (phase names); only objects
1304                        // are structural nodes.
1305                        if child.is_object() {
1306                            validate_node(*node, child, mode, &format!("{path}[{i}]"), out);
1307                        }
1308                    }
1309                    return;
1310                }
1311            }
1312            Form::MapOf(inner) => {
1313                if let Value::Object(map) = value {
1314                    for (name, child) in map {
1315                        if !form_accepts_scalar(inner, child) {
1316                            out.push(Violation {
1317                                path: format!("{path}.{name}"),
1318                                message: format!("value does not conform to {inner:?}"),
1319                            });
1320                        }
1321                    }
1322                    return;
1323                }
1324            }
1325            Form::ListOfForm(inner) => {
1326                if let Value::Array(items) = value {
1327                    for (i, child) in items.iter().enumerate() {
1328                        if !form_accepts_scalar(inner, child) && !matches!(**inner, Form::FreeMap) {
1329                            out.push(Violation {
1330                                path: format!("{path}[{i}]"),
1331                                message: format!("entry does not conform to {inner:?}"),
1332                            });
1333                        }
1334                    }
1335                    return;
1336                }
1337            }
1338            scalar => {
1339                if form_accepts_scalar(scalar, value) {
1340                    return;
1341                }
1342            }
1343        }
1344    }
1345    out.push(Violation {
1346        path: path.to_string(),
1347        message: format!("value conforms to none of the declared forms {forms:?}"),
1348    });
1349}
1350
1351fn validate_node(
1352    node: &dyn EnumerableNode,
1353    value: &Value,
1354    mode: Mode,
1355    path: &str,
1356    out: &mut Vec<Violation>,
1357) {
1358    if let Value::Array(items) = value {
1359        // A list instance of a structural kind (scenario trees,
1360        // list-form op declarations): each object entry is one
1361        // node; scalar entries are documented shorthands.
1362        for (i, item) in items.iter().enumerate() {
1363            if item.is_object() {
1364                validate_node(node, item, mode, &format!("{path}[{i}]"), out);
1365            }
1366        }
1367        return;
1368    }
1369    let Value::Object(map) = value else {
1370        // Non-object instances of structural nodes are legal for
1371        // kinds with documented scalar shorthands (scenario
1372        // strings, string sugars); the parent's form list already
1373        // vetted the scalar alternative when one exists.
1374        return;
1375    };
1376    let elements = node.elements(Some(value));
1377    for (key, child) in map {
1378        // An empty YAML section (`params:` with nothing under
1379        // it) parses as null; the parser treats it as absent —
1380        // so does the model.
1381        if child.is_null() {
1382            continue;
1383        }
1384        match elements.iter().find(|e| e.name == key.as_str()) {
1385            Some(spec) => {
1386                validate_against_forms(spec.forms, child, mode, &format!("{path}.{key}"), out);
1387            }
1388            None => {
1389                if node.open_surface().is_none() {
1390                    out.push(Violation {
1391                        path: format!("{path}.{key}"),
1392                        message: format!("unknown element on closed node kind '{}'", node.kind()),
1393                    });
1394                }
1395            }
1396        }
1397    }
1398    if mode == Mode::Complete {
1399        for spec in &elements {
1400            let satisfied =
1401                map.contains_key(spec.name) || spec.aliases.iter().any(|a| map.contains_key(*a));
1402            if spec.required && !satisfied {
1403                out.push(Violation {
1404                    path: path.to_string(),
1405                    message: format!(
1406                        "required element '{}' missing on '{}'",
1407                        spec.name,
1408                        node.kind()
1409                    ),
1410                });
1411            }
1412        }
1413    }
1414}
1415
1416/// Validate an entire (possibly partial) workload AST against
1417/// the construction grammar.
1418pub fn validate_workload(ast: &Value, mode: Mode) -> Vec<Violation> {
1419    let mut out = Vec::new();
1420    validate_node(&WORKLOAD, ast, mode, "workload", &mut out);
1421    out
1422}
1423
1424// ─── Discovery over a partial AST ─────────────────────────────────
1425
1426/// Resolve the node kind at `path` inside `partial` (a possibly
1427/// incomplete workload AST) and enumerate its valid sub-elements
1428/// there. Path segments are the YAML keys / author-chosen names
1429/// from the root; list indices are decimal. Returns `None` when
1430/// the path leaves the modeled grammar (e.g. descends into a
1431/// declared-open surface).
1432pub fn discover_at<'v>(
1433    partial: &'v Value,
1434    path: &[&str],
1435) -> Option<(&'static dyn EnumerableNode, Vec<ElementSpec>)> {
1436    let mut node: &'static dyn EnumerableNode = root();
1437    let mut value: Option<&'v Value> = Some(partial);
1438
1439    let mut segs = path.iter().peekable();
1440    while let Some(seg) = segs.next() {
1441        let elements = node.elements(value);
1442        let spec = elements.iter().find(|e| e.name == *seg)?;
1443        let child_value = value.and_then(|v| v.get(*seg));
1444        let mut next: Option<&'static dyn EnumerableNode> = None;
1445        let mut named_map = false;
1446        let mut list = false;
1447        for f in spec.forms {
1448            // When the partial AST already shows the child's
1449            // shape, the matching structural form wins; otherwise
1450            // the first structural form declared.
1451            match f {
1452                Form::Node(n) => {
1453                    next = Some(*n);
1454                }
1455                Form::NamedMap(n) => {
1456                    if next.is_none() || matches!(child_value, Some(v) if v.is_object()) {
1457                        next = Some(*n);
1458                        named_map = true;
1459                        list = false;
1460                    }
1461                }
1462                Form::ListOf(n) => {
1463                    if next.is_none() || matches!(child_value, Some(v) if v.is_array()) {
1464                        next = Some(*n);
1465                        list = true;
1466                        named_map = false;
1467                    }
1468                }
1469                _ => {}
1470            }
1471        }
1472        node = next?;
1473        if named_map || list {
1474            match segs.next() {
1475                Some(name) => {
1476                    value = child_value.and_then(|v| {
1477                        if list {
1478                            name.parse::<usize>().ok().and_then(|i| v.get(i))
1479                        } else {
1480                            v.get(*name)
1481                        }
1482                    });
1483                }
1484                None => {
1485                    return Some((node, node.elements(None)));
1486                }
1487            }
1488        } else {
1489            value = child_value;
1490        }
1491    }
1492    let elements = node.elements(value);
1493    Some((node, elements))
1494}
1495
1496#[cfg(test)]
1497mod tests {
1498    use super::*;
1499
1500    fn names(specs: &[ElementSpec]) -> Vec<&'static str> {
1501        specs.iter().map(|e| e.name).collect()
1502    }
1503
1504    /// Serde field probe: capture the exact static `fields` list
1505    /// the derive passes to `deserialize_struct`, so a table can
1506    /// be pinned against the MODEL TYPE itself.
1507    fn fields_of<'de, T: serde::Deserialize<'de>>() -> Option<&'static [&'static str]> {
1508        use std::cell::Cell;
1509        struct Probe<'a>(&'a Cell<Option<&'static [&'static str]>>);
1510        impl<'de, 'a> serde::Deserializer<'de> for Probe<'a> {
1511            type Error = serde::de::value::Error;
1512            fn deserialize_struct<V>(
1513                self,
1514                _name: &'static str,
1515                fields: &'static [&'static str],
1516                _visitor: V,
1517            ) -> Result<V::Value, Self::Error>
1518            where
1519                V: serde::de::Visitor<'de>,
1520            {
1521                self.0.set(Some(fields));
1522                Err(serde::de::Error::custom("field probe"))
1523            }
1524            fn deserialize_any<V>(self, _visitor: V) -> Result<V::Value, Self::Error>
1525            where
1526                V: serde::de::Visitor<'de>,
1527            {
1528                Err(serde::de::Error::custom("field probe"))
1529            }
1530            serde::forward_to_deserialize_any! {
1531                bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64
1532                char str string bytes byte_buf option unit unit_struct
1533                newtype_struct seq tuple tuple_struct map enum
1534                identifier ignored_any
1535            }
1536        }
1537        let cell = Cell::new(None);
1538        let _ = T::deserialize(Probe(&cell));
1539        cell.get()
1540    }
1541
1542    /// The phase table is pinned to `WorkloadPhase` ITSELF (one
1543    /// documented nesting exception: `tries_backoff` rides
1544    /// inside `tries:`'s map form).
1545    #[test]
1546    fn phase_table_matches_the_model_struct() {
1547        let probed =
1548            fields_of::<crate::model::WorkloadPhase>().expect("WorkloadPhase is a serde struct");
1549        let mut probed: Vec<&str> = probed.to_vec();
1550        probed.retain(|f| *f != "tries_backoff");
1551        probed.sort_unstable();
1552        let mut table = names(PHASE_ELEMENTS);
1553        table.sort_unstable();
1554        assert_eq!(
1555            probed, table,
1556            "PHASE_ELEMENTS drifted from the WorkloadPhase struct — \
1557             update the spec table (and its forms/docs) in lock-step"
1558        );
1559    }
1560
1561    /// Stop-condition and continue-if tables are pinned to their
1562    /// serde structs the same way (aliases are extra table
1563    /// entries by design).
1564    #[test]
1565    fn serde_backed_tables_match_their_structs() {
1566        let probed = fields_of::<crate::model::StopConditionSpec>()
1567            .expect("StopConditionSpec is a serde struct");
1568        for f in probed {
1569            assert!(
1570                STOP_WHEN_ELEMENTS.iter().any(|e| e.name == *f),
1571                "STOP_WHEN_ELEMENTS missing struct field '{f}'"
1572            );
1573        }
1574        let probed =
1575            fields_of::<crate::model::ContinueIfSpec>().expect("ContinueIfSpec is a serde struct");
1576        for f in probed {
1577            assert!(
1578                CONTINUE_IF_ELEMENTS.iter().any(|e| e.name == *f),
1579                "CONTINUE_IF_ELEMENTS missing struct field '{f}'"
1580            );
1581        }
1582    }
1583
1584    #[test]
1585    fn tables_are_duplicate_free_and_nodes_registered() {
1586        let mut kinds = std::collections::BTreeSet::new();
1587        for n in ALL_NODES {
1588            assert!(kinds.insert(n.kind()), "duplicate node kind '{}'", n.kind());
1589        }
1590        for n in ALL_NODES {
1591            let mut seen = std::collections::BTreeSet::new();
1592            for e in n.elements(None) {
1593                assert!(
1594                    seen.insert(e.name),
1595                    "node '{}': duplicate element '{}'",
1596                    n.kind(),
1597                    e.name
1598                );
1599                // Every node referenced from a form is itself in
1600                // ALL_NODES — the enumeration surface is closed
1601                // over the reference graph (references are direct,
1602                // so this cannot dangle; it can only be missing
1603                // from the tooling list).
1604                for f in e.forms {
1605                    if let Form::Node(c) | Form::NamedMap(c) | Form::ListOf(c) = f {
1606                        assert!(
1607                            ALL_NODES.iter().any(|k| k.kind() == c.kind()),
1608                            "kind '{}' not in ALL_NODES",
1609                            c.kind()
1610                        );
1611                    }
1612                }
1613            }
1614        }
1615    }
1616
1617    #[test]
1618    fn discovery_walks_a_partial_ast_with_narrowing() {
1619        let partial: Value = serde_yaml::from_str(
1620            r#"
1621phases:
1622  load:
1623    poll:
1624      until: "x == 1"
1625    ops:
1626      probe:
1627        stmt: "S"
1628"#,
1629        )
1630        .expect("yaml");
1631
1632        let (node, elements) = discover_at(&partial, &[]).expect("root");
1633        assert_eq!(node.kind(), "workload");
1634        assert!(elements.iter().any(|e| e.name == "phases"));
1635
1636        let (node, elements) = discover_at(&partial, &["phases", "load"]).expect("phase");
1637        assert_eq!(node.kind(), "phase");
1638        let conc = elements
1639            .iter()
1640            .find(|e| e.name == "concurrency")
1641            .expect("concurrency");
1642        assert_eq!(conc.forms, &[Form::Vocab(&["1"])]);
1643        assert!(
1644            !elements.iter().any(|e| e.name == "tags"),
1645            "inline ops exclude the tags selector"
1646        );
1647
1648        let (node, elements) = discover_at(&partial, &["phases", "load", "poll"]).expect("poll");
1649        assert_eq!(node.kind(), "poll");
1650        assert!(elements.iter().any(|e| e.name == "until" && e.required));
1651
1652        let (node, elements) =
1653            discover_at(&partial, &["phases", "load", "ops", "probe"]).expect("op");
1654        assert_eq!(node.kind(), "op");
1655        assert!(elements.iter().any(|e| e.name == "stmt"));
1656
1657        let (node, _) = discover_at(&partial, &["phases"]).expect("phase kind");
1658        assert_eq!(node.kind(), "phase");
1659
1660        assert!(discover_at(&partial, &["phases", "load", "nope", "deeper"]).is_none());
1661    }
1662
1663    #[test]
1664    fn abstract_ops_drop_the_statement_payload() {
1665        let partial: Value = serde_yaml::from_str(
1666            r#"
1667phases:
1668  p:
1669    ops:
1670      slot:
1671        abstract:
1672          needs:
1673            q: vec_f32
1674"#,
1675        )
1676        .expect("yaml");
1677        let (_, elements) = discover_at(&partial, &["phases", "p", "ops", "slot"]).expect("op");
1678        assert!(
1679            !elements.iter().any(|e| e.name == "stmt"),
1680            "abstract slots carry no statement payload"
1681        );
1682        assert!(elements.iter().any(|e| e.name == "abstract"));
1683    }
1684
1685    #[test]
1686    fn validation_accepts_a_well_formed_document() {
1687        let ast: Value = serde_yaml::from_str(
1688            r#"
1689description: ok
1690params:
1691  p0: "3"
1692phases:
1693  load:
1694    cycles: 5
1695    concurrency: 2
1696    bindings: |
1697      const c := 1
1698    ops:
1699      ins:
1700        stmt: "X {p0}"
1701        capture:
1702          w: /0/v
1703scenarios:
1704  default:
1705    - load
1706"#,
1707        )
1708        .expect("yaml");
1709        let violations = validate_workload(&ast, Mode::Complete);
1710        assert!(
1711            violations.is_empty(),
1712            "unexpected violations: {violations:#?}"
1713        );
1714    }
1715
1716    #[test]
1717    fn validation_flags_bad_forms_and_missing_required() {
1718        let ast: Value = serde_yaml::from_str(
1719            r#"
1720phases:
1721  load:
1722    rate: "not-a-number"
1723    checkpoint: "bogus-mode"
1724    poll:
1725      interval_ms: 50
1726    metrics:
1727      m0:
1728        kind: "sideways"
1729"#,
1730        )
1731        .expect("yaml");
1732        let violations = validate_workload(&ast, Mode::Complete);
1733        let paths: Vec<&str> = violations.iter().map(|v| v.path.as_str()).collect();
1734        assert!(
1735            paths.iter().any(|p| p.ends_with("rate")),
1736            "rate form violation expected: {violations:#?}"
1737        );
1738        assert!(
1739            paths.iter().any(|p| p.ends_with("checkpoint")),
1740            "checkpoint vocab violation expected"
1741        );
1742        assert!(
1743            paths.iter().any(|p| p.ends_with("kind")),
1744            "metric kind vocab violation expected"
1745        );
1746        // poll.until is required and missing under Complete.
1747        assert!(
1748            violations.iter().any(|v| v.message.contains("'until'")),
1749            "poll.until required-missing expected: {violations:#?}"
1750        );
1751        // Partial mode: same doc, no required-missing findings.
1752        let partial = validate_workload(&ast, Mode::Partial);
1753        assert!(
1754            !partial.iter().any(|v| v.message.contains("required")),
1755            "partial mode must not demand required elements"
1756        );
1757    }
1758}