Skip to main content

nmbrs_runtime/
validation.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Result validation and relevancy measurement (SRD 47).
5//!
6//! Provides `ValidatingDispenser`, a composable op dispenser wrapper
7//! that verifies operation results against expected values and computes
8//! information retrieval metrics (recall@k, precision@k, etc.).
9//!
10//! Applied only to templates that declare `verify:` or `relevancy:`
11//! blocks — zero overhead for templates without validation.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17use nmbrs_metrics::labels::Labels;
18
19use crate::adapter::{ExecutionError, OpDispenser, OpResult, WrappingDispenser};
20use crate::relevancy::{self, RelevancyFn};
21use crate::wires::WireSource;
22use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
23
24/// SRD-32a wrapper name for the validation layer. Exposed at
25/// the module level so other wrappers' `forbids_outer` /
26/// `requires_inner` slices can reference it without depending
27/// on `wrapper_registrations` for the constant.
28pub const WRAPPER_NAME: WrapperName = WrapperName::new("validate");
29
30/// Trigger: op carries `verify:` or `relevancy:`.
31fn wrapper_triggers(s: WrapperSubject) -> bool {
32    let Some(template) = s.op() else {
33        return false;
34    };
35    template.params.contains_key("verify") || template.params.contains_key("relevancy")
36}
37
38fn wrapper_describe_assignment(s: WrapperSubject) -> Option<String> {
39    let template = s.op()?;
40    let strict = template
41        .params
42        .get("strict")
43        .and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true")))
44        .unwrap_or(false);
45    let mut parts: Vec<String> = Vec::new();
46    if let Some(v) = template.params.get("verify") {
47        parts.push(format!(
48            "verify={}",
49            crate::wrapper_registrations::short_value(v)
50        ));
51    }
52    if let Some(v) = template.params.get("relevancy") {
53        parts.push(format!(
54            "relevancy={}",
55            crate::wrapper_registrations::short_value(v)
56        ));
57    }
58    if parts.is_empty() {
59        return None;
60    }
61    let body = parts.join(", ");
62    Some(if strict {
63        format!("validate: {body} (strict)")
64    } else {
65        format!("validate: {body}")
66    })
67}
68
69inventory::submit! {
70    WrapperRegistration {
71        name: WRAPPER_NAME,
72        owned_fields: &["verify", "relevancy", "strict"],
73        triggers: wrapper_triggers,
74        requires_inner: &[crate::wrappers::traverse::NAME],
75        forbids_outer: &[],
76        mutually_exclusive_with: &[],
77        describe_assignment: wrapper_describe_assignment,
78        levels: &[crate::wrapper_registry::WrapperLevel::Op],
79    }
80}
81
82// =========================================================================
83// Validation configuration (parsed from workload YAML at init time)
84// =========================================================================
85
86/// Closed vocabulary of op-template `params:` keys the runtime
87/// itself consumes (validation, batching, polling, op weighting,
88/// adapter selection). Joined at validation time with
89/// [`crate::runner::KNOWN_PARAMS`] (workload/CLI-level keys that
90/// get blast-merged into every op's params during parse) and the
91/// adapter's
92/// [`crate::adapter::DriverAdapter::known_op_params`] declarations
93/// to form the full allow-list. Unknown keys are rejected at op
94/// setup time so silent-ignore traps like `evaluations: { relevancy: ... }`
95/// (a wrapper key the runtime never reads) cannot hide a
96/// misconfigured op.
97/// Core op-template params consumed directly by the runtime or the
98/// adapter layer that are NOT owned by any wrapper. Wrapper fields
99/// (`verify`, `poll`, `memo`, `readout`, `errors`, `tries`, `while`,
100/// `rate`, `fields`, …) are deliberately absent: the op
101/// closed-vocabulary guard accepts them via
102/// [`crate::wrapper_registry::WrapperRegistry::owns_field`], which
103/// derives the wrapper vocabulary structurally from each wrapper's
104/// `owned_fields` declaration. Keeping them out of this list is the
105/// point — one source of truth per field, so adding a wrapper never
106/// requires editing here. The drift-guard test
107/// `core_op_params_disjoint_from_owned_fields` fails if a wrapper
108/// field creeps back in.
109pub const CORE_OP_PARAMS: &[&str] = &[
110    // Batching (no wrapper owns these — consumed by the batch path).
111    "batch",
112    "batchtype",
113    "max_batch_size",
114    // Op weighting (activity-level dispatch weight, not a wrapper).
115    "ratio",
116    // Adapter selection.
117    "adapter",
118    "driver",
119    // Daemon-op declaration. `daemon` marks an op for cycle-pool
120    // dispatch onto a daemon fiber (with per-op-name cap);
121    // `daemon_cancel_grace_ms` overrides the phase-exit drain budget.
122    // (The loop / rate primitives `while` / `rate` ARE wrapper-owned
123    // and come from the registry.)
124    "daemon",
125    "daemon_cancel_grace_ms",
126];
127
128/// Configuration for relevancy measurement on a single op template.
129#[derive(Debug, Clone)]
130pub struct RelevancyConfig {
131    /// Column/field name to extract actual result indices from.
132    pub actual_field: String,
133    /// Polydat binding name that produces ground truth indices.
134    pub expected_binding: String,
135    /// Recall window — number of top results used in the @k
136    /// metric computation. The first `k` of `actual` are
137    /// compared against the first `k` of `expected`.
138    pub k: usize,
139    /// Optional retrieval window. When set, declares "we
140    /// retrieved exactly `r` results, but score using only the
141    /// first `k` of them" (the "k-recall@r" semantic). Runtime
142    /// asserts `actual.len() == r`. When `None`, no separate
143    /// retrieval window is enforced — actual is truncated to
144    /// `k` for the comparison and any length is accepted (the
145    /// pre-`r` behavior).
146    pub r: Option<usize>,
147    /// Which functions to compute.
148    pub functions: Vec<RelevancyFn>,
149}
150
151/// A single field assertion from a `verify:` block.
152#[derive(Debug, Clone)]
153pub struct AssertionSpec {
154    /// Field name to check in the result.
155    pub field: String,
156    /// The predicate to apply.
157    pub predicate: AssertionPredicate,
158}
159
160/// Predicate for field assertion checks.
161#[derive(Debug, Clone)]
162pub enum AssertionPredicate {
163    /// Field must equal this value (string comparison).
164    Eq(String),
165    /// Field must not be null/absent.
166    NotNull,
167    /// Field must be null/absent.
168    IsNull,
169    /// Numeric: field >= threshold.
170    Gte(f64),
171    /// Numeric: field <= threshold.
172    Lte(f64),
173    /// String: field contains substring.
174    Contains(String),
175    /// A numeric bound (`gte:` / `lte:`) whose value is not a number.
176    ///
177    /// Carried rather than dropped, and ALWAYS fails: the previous
178    /// `as_f64().unwrap_or(0.0)` silently turned `lte: "5"` — or any
179    /// unresolved placeholder — into `<= 0`, which then failed against
180    /// perfectly good data with a message quoting a threshold the author
181    /// never wrote. A bound nobody can evaluate must say so, not pick zero.
182    MalformedBound { key: String, raw: String },
183    /// Body-level: result must contain at least N rows
184    /// (`element_count() >= N`). Use to make a SELECT
185    /// failsafe — empty result sets surface as a hard error
186    /// instead of silently passing per-row assertions vacuously.
187    /// Field name is ignored for this predicate.
188    MinRows(u64),
189}
190
191impl AssertionSpec {
192    /// Check this assertion against a result.
193    pub fn check(&self, result: &OpResult) -> bool {
194        // Body-level predicates are evaluated against the result's
195        // shape rather than a per-row field value. Handled before
196        // field extraction.
197        if let AssertionPredicate::MinRows(n) = &self.predicate {
198            let row_count = result.body.as_ref().map(|b| b.element_count()).unwrap_or(0);
199            return row_count >= *n;
200        }
201
202        let json = match &result.body {
203            Some(body) => body.to_json(),
204            // No body at all. A field predicate constrains the VALUE of
205            // something in the response, so with no response it is vacuous —
206            // it passes rather than failing. A succeeding op that returns
207            // nothing (HTTP 204, or `on_timeout: accept`, where the server is
208            // still working and the poll layer is the real observer) must not
209            // be failed by a `field: status, eq: 200` clause that was written
210            // to check a body when one arrives.
211            //
212            // Requiring a body is available, but must be ASKED for, and the
213            // two spellings already exist:
214            //   `is: not_null`  — this field must be present
215            //   `min_rows: 1`   — the result must carry at least one row
216            // Both of those constrain PRESENCE, not value, so both still fail
217            // here. That is the whole distinction: value predicates go quiet
218            // when there is nothing to read; presence predicates are exactly
219            // the ones that shouldn't.
220            None => {
221                return !matches!(
222                    self.predicate,
223                    AssertionPredicate::NotNull | AssertionPredicate::MalformedBound { .. }
224                );
225            }
226        };
227
228        let field_val = extract_field_from_json(&json, &self.field);
229
230        match &self.predicate {
231            AssertionPredicate::NotNull => field_val.is_some(),
232            AssertionPredicate::IsNull => field_val.is_none(),
233            AssertionPredicate::Eq(expected) => {
234                field_val.is_some_and(|v| json_value_as_string(v) == *expected)
235            }
236            AssertionPredicate::Gte(threshold) => field_val
237                .and_then(|v| v.as_f64())
238                .is_some_and(|v| v >= *threshold),
239            AssertionPredicate::Lte(threshold) => field_val
240                .and_then(|v| v.as_f64())
241                .is_some_and(|v| v <= *threshold),
242            AssertionPredicate::Contains(substr) => {
243                field_val.is_some_and(|v| json_value_as_string(v).contains(substr.as_str()))
244            }
245            AssertionPredicate::MalformedBound { .. } => false,
246            AssertionPredicate::MinRows(_) => unreachable!("MinRows handled in early-return above"),
247        }
248    }
249}
250
251// =========================================================================
252// ValidationMetrics
253// =========================================================================
254
255/// Running aggregate per relevancy metric. Holds both the all-time
256/// running mean and a bounded sliding window so progress views can
257/// show recent trend alongside the cumulative average.
258pub struct RunningAgg {
259    /// Sum of every score ever recorded (for all-time mean).
260    pub total_sum: f64,
261    /// Count of scores recorded (for all-time mean).
262    pub total_count: u64,
263    /// Recent scores, newest at the back. Capped at `window_size`.
264    pub window: std::collections::VecDeque<f64>,
265    /// Upper bound on `window` length.
266    pub window_size: usize,
267}
268
269impl RunningAgg {
270    pub fn new(window_size: usize) -> Self {
271        Self {
272            total_sum: 0.0,
273            total_count: 0,
274            window: std::collections::VecDeque::with_capacity(window_size),
275            window_size,
276        }
277    }
278
279    pub fn record(&mut self, score: f64) {
280        self.total_sum += score;
281        self.total_count += 1;
282        if self.window.len() == self.window_size {
283            self.window.pop_front();
284        }
285        self.window.push_back(score);
286    }
287
288    pub fn window_mean(&self) -> f64 {
289        if self.window.is_empty() {
290            0.0
291        } else {
292            self.window.iter().sum::<f64>() / self.window.len() as f64
293        }
294    }
295
296    pub fn total_mean(&self) -> f64 {
297        if self.total_count == 0 {
298            0.0
299        } else {
300            self.total_sum / self.total_count as f64
301        }
302    }
303}
304
305/// Default size of the moving-average window. Chosen to match the
306/// common recall@10 "last 10" semantic the user's workloads expect.
307pub const DEFAULT_RECALL_WINDOW: usize = 10;
308
309/// Live snapshot of a relevancy metric: window mean, all-time mean,
310/// how many scores have been recorded, and the current window size.
311#[derive(Debug, Clone)]
312pub struct RelevancyLive {
313    pub name: String,
314    pub window_mean: f64,
315    pub total_mean: f64,
316    pub total_count: u64,
317    pub window_len: usize,
318}
319
320/// Metrics for result validation, shared across fibers.
321pub struct ValidationMetrics {
322    pub validations_passed: AtomicU64,
323    pub validations_failed: AtomicU64,
324    /// Per-function lossless f64 statistics accumulators for relevancy scores.
325    /// Exact precision — no quantization, no bucket rounding.
326    pub relevancy_stats: HashMap<String, nmbrs_metrics::summaries::f64stats::F64Stats>,
327    /// Live aggregates (moving-window + all-time) per relevancy metric.
328    /// These read non-destructively, so progress views can sample them
329    /// every frame without disturbing the exact-precision stats above.
330    pub running_aggregates: HashMap<String, std::sync::Mutex<RunningAgg>>,
331}
332
333impl ValidationMetrics {
334    /// Create metrics for the given relevancy functions.
335    ///
336    /// The metric family name is the bare function name
337    /// (`recall`, `precision`, `f1`, …) — `k` and `r` are
338    /// carried as labels rather than baked into the
339    /// family identifier. This collapses every per-`k`
340    /// variant into a single family that consumers can
341    /// query with `recall{k="10",r="10"}` instead of the
342    /// awkward `recall_at_10` synthesised name. `r`
343    /// defaults to `k` when the relevancy config doesn't
344    /// declare it (`r:` was unset → first-k semantics).
345    pub fn new(labels: &Labels, functions: &[RelevancyFn], k: usize, r: Option<usize>) -> Self {
346        let r_value = r.unwrap_or(k);
347        let stats_labels = labels
348            .with("k", k.to_string())
349            .with("r", r_value.to_string());
350        let mut stats = HashMap::new();
351        let mut running = HashMap::new();
352        for func in functions {
353            let metric_name = func.metric_name().to_string();
354            stats.insert(
355                metric_name.clone(),
356                nmbrs_metrics::summaries::f64stats::F64Stats::new(
357                    stats_labels.with("name", &metric_name),
358                ),
359            );
360            running.insert(
361                metric_name.clone(),
362                std::sync::Mutex::new(RunningAgg::new(DEFAULT_RECALL_WINDOW)),
363            );
364        }
365        Self {
366            validations_passed: AtomicU64::new(0),
367            validations_failed: AtomicU64::new(0),
368            relevancy_stats: stats,
369            running_aggregates: running,
370        }
371    }
372
373    /// Create metrics with no relevancy functions (assertions only).
374    pub fn assertions_only() -> Self {
375        Self {
376            validations_passed: AtomicU64::new(0),
377            validations_failed: AtomicU64::new(0),
378            relevancy_stats: HashMap::new(),
379            running_aggregates: HashMap::new(),
380        }
381    }
382
383    /// Record a relevancy score for a named function. Updates both the
384    /// lossless stats accumulator and the live running aggregate.
385    pub fn record_relevancy(&self, metric_name: &str, score: f64) {
386        if let Some(stats) = self.relevancy_stats.get(metric_name) {
387            stats.record(score);
388        }
389        if let Some(agg) = self.running_aggregates.get(metric_name) {
390            let mut a = agg.lock().unwrap_or_else(|e| e.into_inner());
391            a.record(score);
392        }
393    }
394
395    /// Snapshot all live relevancy aggregates without disturbing the
396    /// accumulators. Safe to call every frame from the progress thread.
397    pub fn live_snapshot(&self) -> Vec<RelevancyLive> {
398        let mut out = Vec::with_capacity(self.running_aggregates.len());
399        for (name, agg) in &self.running_aggregates {
400            let a = agg.lock().unwrap_or_else(|e| e.into_inner());
401            out.push(RelevancyLive {
402                name: name.clone(),
403                window_mean: a.window_mean(),
404                total_mean: a.total_mean(),
405                total_count: a.total_count,
406                window_len: a.window.len(),
407            });
408        }
409        out.sort_by(|x, y| x.name.cmp(&y.name));
410        out
411    }
412
413    /// Get pass count.
414    pub fn passed(&self) -> u64 {
415        self.validations_passed.load(Ordering::Relaxed)
416    }
417
418    /// Get fail count.
419    pub fn failed(&self) -> u64 {
420        self.validations_failed.load(Ordering::Relaxed)
421    }
422}
423
424// =========================================================================
425// ValidatingDispenser
426// =========================================================================
427
428/// Op dispenser wrapper that validates results after execution.
429///
430/// Applied only to templates that declare `verify:` or `relevancy:`
431/// blocks. Zero overhead for templates without validation.
432pub struct ValidatingDispenser {
433    inner: Arc<dyn OpDispenser>,
434    assertions: Vec<AssertionSpec>,
435    relevancy: Option<RelevancyConfig>,
436    /// Pre-stripped wire name for the relevancy `expected`
437    /// binding. The workload author writes either
438    /// `expected: ground_truth` (bare) or `expected: "{ground_truth}"`
439    /// (text-template legacy form, the braces are stripped at
440    /// wrap-time). Cycle-time reads go through
441    /// `ctx.wires.get(&expected_wire_name)` for a typed,
442    /// snapshot-free value — same canonical-scope contract the
443    /// MetricsDispenser uses. None when no `relevancy:` block.
444    expected_wire_name: Option<String>,
445    /// SRD-109 Part 3 — pre-parsed projection for `actual:` when
446    /// it names a result-binding path entry on this op (the
447    /// `results:` interface delivery). The validator evaluates it
448    /// directly from the op result: the `result` wrapper sits
449    /// OUTSIDE validation in the cascade, so its wire write lands
450    /// after this layer reads.
451    actual_projection: Option<ActualProjection>,
452    metrics: Arc<ValidationMetrics>,
453    /// If true, assertion failures become ExecutionError::Op.
454    strict: bool,
455}
456
457/// Pre-parsed `actual:` projection: the result-binding path plus
458/// the interface-declared type (when the wire fills an
459/// `abstract: results:` contract).
460struct ActualProjection {
461    segs: Vec<crate::wrappers::result::PathSeg>,
462    target: Option<polydat::ast::PortType>,
463}
464
465/// Result of [`ValidatingDispenser::wrap`]: the (possibly-wrapped)
466/// dispenser plus the live [`ValidationMetrics`] handle when a
467/// `relevancy:` block was declared (`None` otherwise).
468type WrappedDispenser = (Arc<dyn OpDispenser>, Option<Arc<ValidationMetrics>>);
469
470impl ValidatingDispenser {
471    /// Wrap a dispenser with validation, registering the relevancy
472    /// `expected` binding into the supplied scope fixture.
473    ///
474    /// Returns the inner dispenser unchanged if neither `verify:`
475    /// nor `relevancy:` are declared.
476    pub fn wrap(
477        inner: Arc<dyn OpDispenser>,
478        template: &nmbrs_workload::model::ParsedOp,
479        labels: &Labels,
480        program: Option<&polydat::kernel::PolydatProgram>,
481        fx: &mut crate::fixture::ScopeFixture,
482    ) -> Result<WrappedDispenser, String> {
483        // SRD-68 Push 5c-cleanup: validation wrapper does its own
484        // construction-time resolution of `{name}` placeholders
485        // in op.params against the dispenser's canonical kernel.
486        // This replaces the legacy `resolve_placeholders_in_params_only`
487        // bulk pass at the executor layer — each wrapper now
488        // resolves against its own dispenser's canonical kernel
489        // rather than a shared activity-layer parent. Per-cycle
490        // binding names (e.g. `relevancy.expected = "{ground_truth}"`)
491        // pass through unchanged; the wrapper registers them on
492        // the fixture for cycle-time pulls below.
493        let template_owned: nmbrs_workload::model::ParsedOp;
494        let canonical_kernel = inner.canonical_kernel();
495        let template = if let Some(canonical) = &canonical_kernel {
496            let mut t = template.clone();
497            crate::scope::resolve_placeholders_in_op_params(&mut t, canonical.as_ref())?;
498            template_owned = t;
499            &template_owned
500        } else {
501            template
502        };
503        let assertions = parse_assertions(template);
504        // Bare wire-name forms in `k:` / `r:` resolve against the
505        // canonical kernel at wrap time (one-shot, same as the
506        // pre-existing `{k}` text-template substitution). The
507        // canonical kernel reads as a `WireSource` through
508        // `KernelWires` (SRD-68 Push 1); no per-cycle freshness because
509        // k / r are phase-constants by contract.
510        let canonical_wires = canonical_kernel
511            .as_ref()
512            .map(|k| crate::wires::KernelWires(k.as_ref()));
513        let wires_for_parse: Option<&dyn WireSource> =
514            canonical_wires.as_ref().map(|w| w as &dyn WireSource);
515        let relevancy = parse_relevancy(template, program, wires_for_parse)?;
516        let strict = template
517            .params
518            .get("strict")
519            .and_then(|v| v.as_bool())
520            .unwrap_or(false);
521
522        if assertions.is_empty() && relevancy.is_none() {
523            return Ok((inner, None));
524        }
525
526        // Validate at wrap-time that the relevancy `expected`
527        // binding exists on the op-template kernel — surfacing
528        // missing-wire diagnostics before any cycle runs. The
529        // returned PullHandle is unused (cycle-time reads go
530        // through ctx.wires.get on the bare wire name), so we
531        // only keep the name; same canonical-scope contract as
532        // the post-SRD-68 MetricsDispenser.
533        //
534        // Tolerate either `expected: ground_truth` (bare, the
535        // canonical post-refactor shape) or `expected: "{ground_truth}"`
536        // (the legacy text-template form — braces strip).
537        let expected_wire_name = match &relevancy {
538            Some(cfg) => {
539                let name = cfg
540                    .expected_binding
541                    .trim_matches(|c| c == '{' || c == '}')
542                    .to_string();
543                fx.register_pull(&name).map_err(|e| {
544                    format!("op '{op}' relevancy.expected: {e}", op = template.name,)
545                })?;
546                Some(name)
547            }
548            None => None,
549        };
550
551        let metrics = Arc::new(match &relevancy {
552            Some(config) => ValidationMetrics::new(labels, &config.functions, config.k, config.r),
553            None => ValidationMetrics::assertions_only(),
554        });
555
556        // SRD-109 Part 3 — when `actual:` names a result-binding
557        // path entry on this op, pre-parse the projection so the
558        // per-cycle read evaluates it directly from the result
559        // body (see the `actual_projection` field doc for why the
560        // wire write can't be read from this layer).
561        let actual_projection = match &relevancy {
562            Some(cfg) => {
563                let mut raw: Option<String> = None;
564                if let Some(spec) = template.result.as_ref() {
565                    spec.walk_fragments(|frag| {
566                        if let nmbrs_workload::model::ResultFragment::Named { name, source } = frag
567                            && name == cfg.actual_field
568                        {
569                            let s = source.trim();
570                            if s != "count" && s != "ok" && !s.contains('(') {
571                                raw = Some(s.to_string());
572                            }
573                        }
574                    });
575                }
576                match raw {
577                    Some(path) => {
578                        let segs =
579                            crate::wrappers::result::parse_path_expr(&path).map_err(|e| {
580                                format!(
581                                    "op '{op}' relevancy.actual '{field}': result \
582                                 binding path: {e}",
583                                    op = template.name,
584                                    field = cfg.actual_field
585                                )
586                            })?;
587                        let target = template
588                            .abstract_interface
589                            .as_ref()
590                            .and_then(|i| i.results.get(&cfg.actual_field))
591                            .and_then(|kw| polydat::ast::PortType::from_keyword(kw));
592                        Some(ActualProjection { segs, target })
593                    }
594                    None => None,
595                }
596            }
597            None => None,
598        };
599
600        let wrapper = Arc::new(Self {
601            inner,
602            assertions,
603            relevancy,
604            expected_wire_name,
605            actual_projection,
606            metrics: metrics.clone(),
607            strict,
608        });
609        Ok((wrapper, Some(metrics)))
610    }
611}
612
613impl WrappingDispenser for ValidatingDispenser {}
614
615impl OpDispenser for ValidatingDispenser {
616    /// Expose the wrapped dispenser so `describe()` can
617    /// walk through this layer to reach the adapter
618    /// (raw / prepared / batch). Without this override,
619    /// the default trait method returns `None`, the
620    /// describe walk stops here, and the error-context
621    /// dump loses the CQL statement text.
622    fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
623        Some(self.inner.as_ref())
624    }
625
626    fn execute<'a>(
627        &'a self,
628        cycle: u64,
629        ctx: &'a crate::fixture::ExecCtx<'a>,
630    ) -> std::pin::Pin<
631        Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
632    > {
633        Box::pin(async move {
634            let result = self.inner.execute(cycle, ctx).await?;
635
636            // Phase 1: Field assertions. Track failed predicates
637            // so the strict-mode error can say which check failed
638            // (especially helpful for body-level checks like
639            // `min_rows: 1` where the user gets clear feedback that
640            // the result was empty rather than a generic "validation
641            // failed" message).
642            let mut failed_assertions: Vec<String> = Vec::new();
643            for assertion in &self.assertions {
644                if !assertion.check(&result) {
645                    failed_assertions.push(describe_assertion_failure(assertion, &result));
646                }
647            }
648            let all_pass = failed_assertions.is_empty();
649
650            // Phase 2: Relevancy metrics
651            if let Some(config) = &self.relevancy {
652                // SRD-109 Part 3 / SRD-70: `actual:` resolution
653                // order — (1) a result-binding projection declared
654                // on this op (evaluated directly from the result
655                // body; the `result` wrapper's wire write lands
656                // outside this layer), (2) a live wire (captures
657                // and per-cycle bindings are fresh here), (3) the
658                // legacy result-column walk for workloads that
659                // predate the interface.
660                let actual_ordered = if let Some(proj) = &self.actual_projection {
661                    let projected = result.body.as_ref().and_then(|b| {
662                        crate::wrappers::result::evaluate_path_value(
663                            &b.to_json(),
664                            &proj.segs,
665                            proj.target,
666                        )
667                    });
668                    projected
669                        .as_ref()
670                        .map(resolve_expected_from_value)
671                        .unwrap_or_default()
672                } else {
673                    match ctx.wires.get(&config.actual_field) {
674                        Some(v) if !matches!(v, polydat::ast::Value::None) => {
675                            resolve_expected_from_value(&v)
676                        }
677                        _ => extract_actual_indices(&result, &config.actual_field),
678                    }
679                };
680                // Single read path: ground truth comes from
681                // `ctx.wires.get(name)` — a live, snapshot-free read
682                // through the per-fiber op-template kernel. The wire
683                // name was validated at wrap-time. Same canonical-scope
684                // contract MetricsDispenser uses.
685                let name = self.expected_wire_name.as_deref().expect(
686                    "ValidatingDispenser invariant violated: relevancy is \
687                     configured but expected_wire_name was not stored. \
688                     Construct via ValidatingDispenser::wrap.",
689                );
690                let raw_value = ctx.wires.get(name);
691                let expected_raw = raw_value
692                    .as_ref()
693                    .map(resolve_expected_from_value)
694                    .unwrap_or_default();
695
696                // Hard error if ground truth or actual results are empty
697                if expected_raw.is_empty() {
698                    let available: Vec<String> = ctx.wires.names().collect();
699                    return Err(ExecutionError::Op(crate::adapter::AdapterError {
700                        error_name: "relevancy_error".into(),
701                        message: format!(
702                            "relevancy: no ground truth for '{name}'. \
703                             Available wires: {available:?}. \
704                             Ensure the binding exists in the Polydat program.",
705                        ),
706                        retryable: false,
707                    }));
708                }
709                if actual_ordered.is_empty() && result.body.is_some() {
710                    // Log on first occurrence only
711                    if self.metrics.passed() + self.metrics.failed() == 0 {
712                        let indent = crate::scene_tree::running_phase_indent();
713                        crate::observer::log(
714                            crate::observer::LogLevel::Warn,
715                            &format!(
716                                "{indent}relevancy: no values extracted for field '{}' from result",
717                                config.actual_field
718                            ),
719                        );
720                        if let Some(body) = &result.body {
721                            let preview =
722                                serde_json::to_string(&body.to_json()).unwrap_or_default();
723                            crate::observer::log(
724                                crate::observer::LogLevel::Warn,
725                                &format!(
726                                    "{indent}  result preview: {}",
727                                    &preview[..preview.len().min(300)]
728                                ),
729                            );
730                        }
731                    }
732                }
733
734                // k-recall@r contract: when the workload declares
735                // `r:` in the relevancy block, it has asserted
736                // that the database query was sized to retrieve
737                // exactly that many results. Mismatch is a
738                // correctness signal — the recall measurement is
739                // only meaningful if the retrieval window matches
740                // what the operator asked for. Fail the op rather
741                // than silently producing a recall figure for an
742                // off-by-one retrieval window.
743                if let Some(r) = config.r
744                    && actual_ordered.len() != r
745                {
746                    return Err(ExecutionError::Op(crate::adapter::AdapterError {
747                        error_name: "relevancy_error".into(),
748                        message: format!(
749                            "relevancy: expected exactly r={r} results from \
750                                 retrieval, got {} (k-recall@r contract). Either \
751                                 size the query LIMIT to {r}, or remove the `r:` \
752                                 declaration to fall back to first-k semantics.",
753                            actual_ordered.len(),
754                        ),
755                        retryable: false,
756                    }));
757                }
758
759                // k-recall@r semantics: the recall metric counts
760                // how many of the *top-k* ground-truth items appear
761                // anywhere in the *top-r* returned, divided by k.
762                // Truncating `actual` to `k` (the pre-`r:` shape)
763                // computes top-k ∩ top-k instead, which collapses
764                // to ~K/R when the server post-filters and the
765                // returned top-K rarely contains true neighbours —
766                // that's the "stuck at 0.1" symptom for r=10×k.
767                // When `r` is unset, fall back to k (the legacy
768                // "first-k" behaviour).
769                let r_window = config.r.unwrap_or(config.k);
770                let expected_sorted = relevancy::truncate_and_sort(&expected_raw, config.k);
771                let actual_sorted = relevancy::truncate_and_sort(&actual_ordered, r_window);
772
773                for func in &config.functions {
774                    let score =
775                        func.compute(&expected_sorted, &actual_sorted, &actual_ordered, config.k);
776                    self.metrics.record_relevancy(func.metric_name(), score);
777                    // Generic observability point: a relevancy
778                    // score has been computed. The trace fires
779                    // for ANY relevancy config (recall, precision,
780                    // F1, MRR, AP) — no knowledge of the
781                    // workload's domain. Labels carry whatever
782                    // dimensions the surrounding scope tree
783                    // pushed (phase, profile, optimize_for, k,
784                    // r), so `--trace=` routing/filtering is
785                    // entirely config-driven.
786                    if crate::observer::trace_enabled() {
787                        let intersect =
788                            crate::relevancy::intersection_count(&expected_sorted, &actual_sorted);
789                        let stats_labels = self
790                            .metrics
791                            .relevancy_stats
792                            .get(func.metric_name())
793                            .map(|s| s.labels().clone())
794                            .unwrap_or_default();
795                        crate::observer::trace(
796                            &stats_labels,
797                            &format!(
798                                "event=relevancy.score cycle={cycle} \
799                                 fn={func} k={k} r={r} \
800                                 gt_card={gt} actual_card={ac} \
801                                 intersect={inter} score={score:.6}",
802                                func = func.metric_name(),
803                                k = config.k,
804                                r = config.r.unwrap_or(config.k),
805                                gt = expected_sorted.len(),
806                                ac = actual_sorted.len(),
807                                inter = intersect,
808                            ),
809                        );
810                    }
811                }
812            }
813
814            if all_pass {
815                self.metrics
816                    .validations_passed
817                    .fetch_add(1, Ordering::Relaxed);
818            } else {
819                self.metrics
820                    .validations_failed
821                    .fetch_add(1, Ordering::Relaxed);
822                if self.strict {
823                    return Err(ExecutionError::Op(crate::adapter::AdapterError {
824                        error_name: "validation_failed".into(),
825                        message: format!(
826                            "result validation failed (strict mode): {}",
827                            failed_assertions.join("; "),
828                        ),
829                        retryable: false,
830                    }));
831                }
832            }
833
834            Ok(result)
835        })
836    }
837}
838
839// =========================================================================
840// YAML parsing
841// =========================================================================
842
843/// Render a one-line description of an assertion that failed
844/// against `result`. Used by the strict-mode error message so
845/// the workload author sees exactly which predicate fired,
846/// what the resolved field value was (or why it couldn't be
847/// resolved), AND the body excerpt — without all three the
848/// operator has to manually re-run with logging cranked up
849/// just to figure out what came back.
850///
851/// The body excerpt is suppressed when the body is `None` —
852/// observed_field_repr's "<no body returned by op>" already
853/// communicates that case, and a trailing `; body: <no body>`
854/// duplicates the same signal.
855fn describe_assertion_failure(assertion: &AssertionSpec, result: &OpResult) -> String {
856    let body_tail = match &result.body {
857        Some(_) => format!("; body: {}", body_excerpt(result)),
858        None => String::new(),
859    };
860    let observed_repr = observed_field_repr(&assertion.field, result);
861    match &assertion.predicate {
862        AssertionPredicate::MinRows(n) => {
863            let got = result.body.as_ref().map(|b| b.element_count()).unwrap_or(0);
864            format!("min_rows: expected ≥{n}, got {got}{body_tail}")
865        }
866        AssertionPredicate::Eq(expected) => format!(
867            "field '{}' eq '{}' failed (observed: {observed_repr}){body_tail}",
868            assertion.field, expected
869        ),
870        AssertionPredicate::NotNull => format!(
871            "field '{}' must not be null (observed: {observed_repr}){body_tail}",
872            assertion.field
873        ),
874        AssertionPredicate::IsNull => format!(
875            "field '{}' must be null (observed: {observed_repr}){body_tail}",
876            assertion.field
877        ),
878        AssertionPredicate::Gte(t) => format!(
879            "field '{}' >= {t} failed (observed: {observed_repr}){body_tail}",
880            assertion.field
881        ),
882        AssertionPredicate::MalformedBound { key, raw } => format!(
883            "field '{}': `{key}: {raw}` is not a number — a numeric bound \
884                 must be a number (`{key}: 5`) or a string holding one \
885                 (`{key}: \"5\"`). If that is a `{{placeholder}}`, it did not \
886                 resolve.",
887            assertion.field
888        ),
889        AssertionPredicate::Lte(t) => format!(
890            "field '{}' <= {t} failed (observed: {observed_repr}){body_tail}",
891            assertion.field
892        ),
893        AssertionPredicate::Contains(sub) => format!(
894            "field '{}' contains '{}' failed (observed: {observed_repr}){body_tail}",
895            assertion.field, sub
896        ),
897    }
898}
899
900/// Best-effort description of the observed value of a field.
901/// Returns the JSON-extracted value (string-quoted if it is a
902/// JSON string) when the field is addressable; otherwise a
903/// diagnostic that surfaces *why* the field couldn't be read,
904/// so the operator can tell whether to fix the field name or
905/// the upstream response shape:
906///
907/// - `<no body returned by op — verify clause cannot read
908///   fields>` when the op produced no body at all. Often a
909///   sign the verify clause is structurally wrong for this
910///   op kind (DDL, flush, anything that doesn't return rows).
911/// - `<field absent; body keys: [a, b, c]>` when the body is
912///   a JSON object but doesn't contain the requested field —
913///   shows what IS there so the author can pick a real name.
914/// - `<field absent; body is a JSON array of N elements>`
915///   when the body is an array (`field:` addresses the first
916///   element by convention; if the array is empty there's
917///   nothing to address).
918/// - `<not-json: {short repr}>` when the body parses to a
919///   scalar or non-addressable JSON value (e.g. an HTML error
920///   page or a plain-text response). Shows the first chars
921///   so the operator can recognise the upstream's actual
922///   payload.
923/// - The extracted value otherwise, truncated for log
924///   readability.
925fn observed_field_repr(field: &str, result: &OpResult) -> String {
926    let Some(body) = &result.body else {
927        return "<no body returned by op — verify clause cannot read fields>".to_string();
928    };
929    let json = body.to_json();
930    if let Some(v) = extract_field_from_json(&json, field) {
931        let repr = match v {
932            serde_json::Value::String(s) => format!("\"{s}\""),
933            other => other.to_string(),
934        };
935        return truncate_for_message(&repr, 160);
936    }
937    // Field couldn't be resolved. The "why" guides the fix —
938    // either the field name is wrong (the body has different
939    // keys) or the response shape is wrong (not a structured
940    // JSON object at all).
941    match &json {
942        serde_json::Value::Object(map) => {
943            let keys: Vec<&str> = map.keys().map(|s| s.as_str()).collect();
944            if keys.is_empty() {
945                "<field absent; body is an empty JSON object>".to_string()
946            } else {
947                format!("<field absent; body keys: {keys:?}>")
948            }
949        }
950        serde_json::Value::Array(arr) => {
951            format!(
952                "<field absent; body is a JSON array of {} element{}>",
953                arr.len(),
954                if arr.len() == 1 { "" } else { "s" },
955            )
956        }
957        serde_json::Value::Null => "<field absent; body is JSON null>".to_string(),
958        other => {
959            let short = truncate_for_message(&other.to_string(), 80);
960            format!("<not-json: {short}>")
961        }
962    }
963}
964
965/// A short excerpt of the response body, embedded in
966/// assertion-failure messages so the operator can see what
967/// actually came back. Caller is responsible for skipping the
968/// excerpt when `result.body` is `None` — see
969/// `describe_assertion_failure`.
970fn body_excerpt(result: &OpResult) -> String {
971    let Some(body) = &result.body else {
972        return "<no body>".to_string();
973    };
974    truncate_for_message(&body.to_text(), 512)
975}
976
977/// Truncate a string to `max` chars with an ellipsis tail so
978/// failure messages stay readable. Avoids splitting inside a
979/// UTF-8 codepoint by using `char_indices`.
980fn truncate_for_message(s: &str, max: usize) -> String {
981    if s.chars().count() <= max {
982        return s.to_string();
983    }
984    let cut: String = s.chars().take(max.saturating_sub(1)).collect();
985    format!("{cut}…")
986}
987
988/// Parse `verify:` block from a ParsedOp's params.
989///
990/// Expected YAML structure:
991/// ```yaml
992/// verify:
993///   - field: name
994///     is: not_null
995///   - field: balance
996///     gte: 0
997///   - field: data
998///     eq: "expected_value"
999///   - min_rows: 1     # body-level: result must have ≥1 row
1000/// ```
1001///
1002/// Most predicates target a named field within each result row
1003/// and require `field:`. The body-level `min_rows:` predicate is
1004/// the exception — it asserts on the result's row count rather
1005/// than any specific field, so it has no `field:` key.
1006/// A `gte:` / `lte:` threshold, from either a JSON number or a string
1007/// holding one.
1008///
1009/// YAML makes the string form easy to reach by accident — quoting, or a
1010/// `{placeholder}` that substitutes to text — and the two spellings plainly
1011/// mean the same bound, so both are accepted. What is NOT accepted is
1012/// anything else: `None` here becomes a
1013/// [`AssertionPredicate::MalformedBound`] that fails loudly, rather than the
1014/// old silent `0.0`.
1015fn numeric_bound(v: &serde_json::Value) -> Option<f64> {
1016    v.as_f64()
1017        .or_else(|| v.as_str().and_then(|s| s.trim().parse::<f64>().ok()))
1018}
1019
1020fn parse_assertions(template: &nmbrs_workload::model::ParsedOp) -> Vec<AssertionSpec> {
1021    let Some(verify) = template.params.get("verify") else {
1022        return Vec::new();
1023    };
1024
1025    let Some(items) = verify.as_array() else {
1026        return Vec::new();
1027    };
1028
1029    let mut assertions = Vec::new();
1030    for item in items {
1031        let Some(obj) = item.as_object() else {
1032            continue;
1033        };
1034
1035        // Body-level predicates first: `min_rows: N` doesn't take
1036        // a `field:` because it asserts on the body's row count.
1037        if let Some(v) = obj.get("min_rows") {
1038            let n = v.as_u64().unwrap_or(0);
1039            assertions.push(AssertionSpec {
1040                field: String::new(), // ignored for MinRows
1041                predicate: AssertionPredicate::MinRows(n),
1042            });
1043            continue;
1044        }
1045
1046        let Some(field) = obj.get("field").and_then(|v| v.as_str()) else {
1047            continue;
1048        };
1049
1050        let predicate = if let Some(v) = obj.get("eq") {
1051            AssertionPredicate::Eq(json_value_as_string(v))
1052        } else if let Some(v) = obj.get("gte") {
1053            match numeric_bound(v) {
1054                Some(t) => AssertionPredicate::Gte(t),
1055                None => AssertionPredicate::MalformedBound {
1056                    key: "gte".into(),
1057                    raw: json_value_as_string(v),
1058                },
1059            }
1060        } else if let Some(v) = obj.get("lte") {
1061            match numeric_bound(v) {
1062                Some(t) => AssertionPredicate::Lte(t),
1063                None => AssertionPredicate::MalformedBound {
1064                    key: "lte".into(),
1065                    raw: json_value_as_string(v),
1066                },
1067            }
1068        } else if let Some(v) = obj.get("contains") {
1069            AssertionPredicate::Contains(json_value_as_string(v))
1070        } else if let Some(v) = obj.get("is") {
1071            match v.as_str().unwrap_or("").to_lowercase().as_str() {
1072                "not_null" | "notnull" => AssertionPredicate::NotNull,
1073                "null" => AssertionPredicate::IsNull,
1074                _ => continue,
1075            }
1076        } else {
1077            continue;
1078        };
1079
1080        assertions.push(AssertionSpec {
1081            field: field.to_string(),
1082            predicate,
1083        });
1084    }
1085    assertions
1086}
1087
1088/// Allowed sub-keys under `relevancy:`. Anything outside this
1089/// vocabulary is a hard error — silent acceptance of typos
1090/// (e.g. `relevency:`) is exactly the failure mode SRD-15 §
1091/// "Strict mode" rules out.
1092const RELEVANCY_VOCAB: &[&str] = &["actual", "expected", "k", "r", "functions"];
1093
1094/// Parse `relevancy:` block from a ParsedOp's params.
1095///
1096/// **Strict.** Required fields (`actual`, `expected`) missing →
1097/// error. Unknown sub-keys → error. Non-numeric `k`/`r` → error.
1098/// Surviving `{name}` placeholders → error (they should already
1099/// have been resolved by the SRD-16 single read path before this
1100/// function runs).
1101///
1102/// Expected YAML structure:
1103/// ```yaml
1104/// relevancy:
1105///   actual: key
1106///   expected: "{ground_truth}"
1107///   k: 10                       # or "{k}" — resolved beforehand
1108///   r: 64                       # optional
1109///   functions: [recall, …]
1110/// ```
1111fn parse_relevancy(
1112    template: &nmbrs_workload::model::ParsedOp,
1113    _program: Option<&polydat::kernel::PolydatProgram>,
1114    wires: Option<&dyn WireSource>,
1115) -> Result<Option<RelevancyConfig>, String> {
1116    let Some(rel) = template.params.get("relevancy") else {
1117        return Ok(None);
1118    };
1119    let obj = rel.as_object().ok_or_else(|| {
1120        format!(
1121            "op '{}': relevancy: expected a mapping, got {kind}",
1122            template.name,
1123            kind = match rel {
1124                serde_json::Value::Null => "null",
1125                serde_json::Value::Bool(_) => "boolean",
1126                serde_json::Value::Number(_) => "number",
1127                serde_json::Value::String(_) => "string",
1128                serde_json::Value::Array(_) => "array",
1129                _ => "unknown",
1130            },
1131        )
1132    })?;
1133
1134    // Closed-vocabulary check.
1135    for k in obj.keys() {
1136        if !RELEVANCY_VOCAB.contains(&k.as_str()) {
1137            return Err(format!(
1138                "op '{op}' relevancy: unknown key '{k}'. Allowed: [{vocab}]",
1139                op = template.name,
1140                vocab = RELEVANCY_VOCAB.join(", "),
1141            ));
1142        }
1143    }
1144
1145    let actual_field = obj
1146        .get("actual")
1147        .and_then(|v| v.as_str())
1148        .ok_or_else(|| {
1149            format!(
1150                "op '{}' relevancy: missing required field 'actual' (string column name)",
1151                template.name,
1152            )
1153        })?
1154        .to_string();
1155    let expected_binding = obj
1156        .get("expected")
1157        .and_then(|v| v.as_str())
1158        .ok_or_else(|| {
1159            format!(
1160                "op '{}' relevancy: missing required field 'expected' (binding reference)",
1161                template.name,
1162            )
1163        })?
1164        .to_string();
1165
1166    let k_label = format!("op '{}' relevancy.k", template.name);
1167    let k = parse_count_param(obj.get("k"), &k_label, wires)?.ok_or_else(|| {
1168        format!(
1169            "op '{}' relevancy: missing required field 'k' (integer)",
1170            template.name,
1171        )
1172    })? as usize;
1173
1174    let r_label = format!("op '{}' relevancy.r", template.name);
1175    let r: Option<usize> = parse_count_param(obj.get("r"), &r_label, wires)?.map(|n| n as usize);
1176
1177    if let Some(rv) = r
1178        && rv < k
1179    {
1180        return Err(format!(
1181            "op '{op}' relevancy: r={rv} is smaller than k={k}; \
1182                 the k-recall@r contract requires r >= k",
1183            op = template.name,
1184        ));
1185    }
1186
1187    let functions: Vec<RelevancyFn> = match obj.get("functions") {
1188        None => vec![RelevancyFn::Recall],
1189        Some(serde_json::Value::Array(arr)) => {
1190            let mut out: Vec<RelevancyFn> = Vec::new();
1191            for (i, v) in arr.iter().enumerate() {
1192                let name = v.as_str().ok_or_else(|| {
1193                    format!(
1194                        "op '{op}' relevancy.functions[{i}]: expected a string, got {kind}",
1195                        op = template.name,
1196                        kind = match v {
1197                            serde_json::Value::Null => "null",
1198                            serde_json::Value::Bool(_) => "boolean",
1199                            serde_json::Value::Number(_) => "number",
1200                            serde_json::Value::Array(_) => "array",
1201                            serde_json::Value::Object(_) => "object",
1202                            _ => "unknown",
1203                        },
1204                    )
1205                })?;
1206                let func = RelevancyFn::parse(name).ok_or_else(|| {
1207                    format!(
1208                        "op '{op}' relevancy.functions[{i}]: unknown function '{name}'",
1209                        op = template.name,
1210                    )
1211                })?;
1212                out.push(func);
1213            }
1214            if out.is_empty() {
1215                return Err(format!(
1216                    "op '{op}' relevancy.functions: empty list — declare at least one \
1217                     function or remove the field to default to [recall]",
1218                    op = template.name,
1219                ));
1220            }
1221            out
1222        }
1223        Some(_) => {
1224            return Err(format!(
1225                "op '{op}' relevancy.functions: expected an array of strings",
1226                op = template.name,
1227            ));
1228        }
1229    };
1230
1231    Ok(Some(RelevancyConfig {
1232        actual_field,
1233        expected_binding,
1234        k,
1235        r,
1236        functions,
1237    }))
1238}
1239
1240/// Parse a YAML count parameter (`k:` or `r:`) — strict.
1241/// Accepts only a JSON integer or a literal numeric string.
1242/// `{name}` placeholders are *not* resolved here: the SRD-16
1243/// single read path means every placeholder must already have
1244/// been resolved by
1245/// [`crate::scope::resolve_placeholders_in_op_params`] (called
1246/// from `ValidatingDispenser::wrap` against the dispenser's own
1247/// canonical kernel — SRD-68 Push 5c-cleanup). Surviving `{…}`
1248/// shapes are a workload bug and surface as `Err`. The caller
1249/// decides whether the parameter is required or optional —
1250/// `None` here means "absent," not "unresolvable."
1251fn parse_count_param(
1252    val: Option<&serde_json::Value>,
1253    field_label: &str,
1254    wires: Option<&dyn WireSource>,
1255) -> Result<Option<u64>, String> {
1256    let Some(v) = val else {
1257        return Ok(None);
1258    };
1259    if let Some(n) = v.as_u64() {
1260        return Ok(Some(n));
1261    }
1262    let s = match v.as_str() {
1263        Some(s) => s,
1264        None => {
1265            return Err(format!(
1266                "{field_label}: expected an integer or numeric string, got {kind}",
1267                kind = match v {
1268                    serde_json::Value::Null => "null",
1269                    serde_json::Value::Bool(_) => "boolean",
1270                    serde_json::Value::Array(_) => "array",
1271                    serde_json::Value::Object(_) => "object",
1272                    _ => "unsupported value",
1273                },
1274            ));
1275        }
1276    };
1277    let trimmed = s.trim();
1278    if trimmed.starts_with('{') && trimmed.ends_with('}') {
1279        return Err(format!(
1280            "{field_label}: '{trimmed}' was not resolved before parameter parsing — \
1281             this is a placeholder-resolution bug, not a config-time issue. The \
1282             single-read-path resolver should have substituted it from the kernel."
1283        ));
1284    }
1285    // Bare wire-name form: post-SRD-68 follow-up. When `k:` or `r:`
1286    // names a bare identifier and the canonical kernel knows it,
1287    // read the value at wrap time. Same one-shot evaluation as the
1288    // legacy `{k}` text-template form, but without the placeholder
1289    // wrapper. Numeric strings ("100") fall through to the parse
1290    // below — they're literals, not wire references.
1291    if let Some(wires) = wires
1292        && is_bare_ident(trimmed)
1293        && let Some(value) = wires.get(trimmed)
1294    {
1295        return value_to_u64_for_count(value)
1296            .ok_or_else(|| {
1297                format!(
1298                    "{field_label}: wire '{trimmed}' resolved but its value is not \
1299             coercible to a non-negative integer"
1300                )
1301            })
1302            .map(Some);
1303    }
1304    match trimmed.parse::<u64>() {
1305        Ok(n) => Ok(Some(n)),
1306        Err(_) => Err(format!(
1307            "{field_label}: '{trimmed}' is not a valid non-negative integer \
1308             (and not declared as a wire name on the op-template kernel)"
1309        )),
1310    }
1311}
1312
1313/// Predicate for a bare Polydat identifier (single ident-shaped token).
1314/// Inlined locally to avoid pulling the `crate::wires::is_bare_ident`
1315/// pub-but-unexported helper into this file's surface.
1316fn is_bare_ident(s: &str) -> bool {
1317    let mut chars = s.chars();
1318    match chars.next() {
1319        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1320        _ => return false,
1321    }
1322    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1323}
1324
1325/// Coerce a kernel `Value` to a non-negative integer suitable for
1326/// `k:` / `r:` count fields. U64 / F64 (non-negative) accepted;
1327/// other variants signal a type mismatch the caller surfaces.
1328fn value_to_u64_for_count(value: polydat::ast::Value) -> Option<u64> {
1329    use polydat::ast::Value;
1330    match value {
1331        Value::U64(n) => Some(n),
1332        Value::F64(f) if f.is_finite() && f >= 0.0 => Some(f as u64),
1333        Value::Bool(true) => Some(1),
1334        Value::Bool(false) => Some(0),
1335        _ => None,
1336    }
1337}
1338
1339// =========================================================================
1340// Result extraction
1341// =========================================================================
1342
1343/// Extract integer indices from a result body for relevancy comparison.
1344///
1345/// Tries adapter-native downcast first, then falls back to JSON extraction.
1346fn extract_actual_indices(result: &OpResult, field: &str) -> Vec<i64> {
1347    let Some(body) = &result.body else {
1348        return Vec::new();
1349    };
1350    extract_indices_from_json(&body.to_json(), field)
1351}
1352
1353/// Extract integer values for a named field from JSON result structure.
1354fn extract_indices_from_json(json: &serde_json::Value, field: &str) -> Vec<i64> {
1355    match json {
1356        serde_json::Value::Array(rows) => rows
1357            .iter()
1358            .filter_map(|row| json_field_as_i64(row.get(field)?))
1359            .collect(),
1360        serde_json::Value::Object(obj) => {
1361            if let Some(rows) = obj.get("rows") {
1362                return extract_indices_from_json(rows, field);
1363            }
1364            obj.get(field)
1365                .and_then(json_field_as_i64)
1366                .into_iter()
1367                .collect()
1368        }
1369        _ => Vec::new(),
1370    }
1371}
1372
1373/// Coerce a JSON value to i64: native integer, or parse from string.
1374fn json_field_as_i64(v: &serde_json::Value) -> Option<i64> {
1375    v.as_i64().or_else(|| v.as_str()?.parse().ok())
1376}
1377
1378/// Extract integer ground-truth indices from a typed `Value`.
1379///
1380/// Single read path: at cycle time, [`ValidatingDispenser::execute`]
1381/// reads the value via `ctx.wires.get(name)` (a live read through
1382/// the per-fiber op-template kernel — same canonical-scope contract
1383/// MetricsDispenser uses), then hands it here for type-aware
1384/// extraction.
1385///
1386/// Fast path for the typed-array vector data path (SRD 53
1387/// §"Native vector PortType"): when the binding produces
1388/// `Value::VecI32` / `Value::VecF32` (the shape
1389/// `neighbor_indices_at` and friends emit), the slice is read
1390/// directly with no string round-trip. String fallback covers
1391/// legacy bindings that emit `Value::Str("[1, 5, 12, ...]")` or
1392/// `Value::Str("1,5,12,...")`.
1393fn resolve_expected_from_value(value: &polydat::ast::Value) -> Vec<i64> {
1394    match value {
1395        // Typed-slice fast paths — zero-copy read, no parse.
1396        polydat::ast::Value::VecI32(slice) => slice.as_slice().iter().map(|&x| x as i64).collect(),
1397        polydat::ast::Value::VecI64(slice) => slice.as_slice().to_vec(),
1398        polydat::ast::Value::VecF32(slice) => {
1399            // Vector ground-truth indices are integer-valued
1400            // by domain. Truncating fractional parts is the
1401            // correct semantic; anything else would mean the
1402            // dataset's index column is mis-typed at the source.
1403            slice.as_slice().iter().map(|&x| x as i64).collect()
1404        }
1405        polydat::ast::Value::VecF64(slice) => slice.as_slice().iter().map(|&x| x as i64).collect(),
1406        // SRD-70 projection landed as structural JSON (mixed or
1407        // string-typed columns): integer-valued leaves extract,
1408        // numeric strings parse, anything else drops.
1409        polydat::ast::Value::Json(j) => match &**j {
1410            serde_json::Value::Array(elems) => elems.iter().filter_map(json_field_as_i64).collect(),
1411            other => json_field_as_i64(other).into_iter().collect(),
1412        },
1413        polydat::ast::Value::Str(s) => parse_int_array(s),
1414        polydat::ast::Value::U64(v) => vec![*v as i64],
1415        _ => {
1416            // Display-string fallback for anything else.
1417            let s = value.to_display_string();
1418            parse_int_array(&s)
1419        }
1420    }
1421}
1422
1423/// Parse a string containing integers into a Vec<i64>.
1424///
1425/// Handles formats: `[1, 5, 12]`, `1,5,12`, `1 5 12`.
1426fn parse_int_array(s: &str) -> Vec<i64> {
1427    let trimmed = s.trim().trim_start_matches('[').trim_end_matches(']');
1428    trimmed
1429        .split(|c: char| c == ',' || c.is_whitespace())
1430        .filter(|s| !s.is_empty())
1431        .filter_map(|s| s.trim().parse::<i64>().ok())
1432        .collect()
1433}
1434
1435/// Extract a field from JSON by name, checking both top-level and row arrays.
1436fn extract_field_from_json<'a>(
1437    json: &'a serde_json::Value,
1438    field: &str,
1439) -> Option<&'a serde_json::Value> {
1440    match json {
1441        serde_json::Value::Object(obj) => obj.get(field).or_else(|| {
1442            obj.get("rows")
1443                .and_then(|r| r.as_array())
1444                .and_then(|rows| rows.first())
1445                .and_then(|row| row.get(field))
1446        }),
1447        serde_json::Value::Array(rows) => rows.first().and_then(|row| row.get(field)),
1448        _ => None,
1449    }
1450}
1451
1452/// Convert a JSON value to its string representation for comparison.
1453fn json_value_as_string(v: &serde_json::Value) -> String {
1454    match v {
1455        serde_json::Value::String(s) => s.clone(),
1456        other => other.to_string(),
1457    }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462    use super::*;
1463    use crate::adapter::ResultBody;
1464    use std::any::Any;
1465
1466    #[derive(Debug)]
1467    struct JsonBody(serde_json::Value);
1468    impl ResultBody for JsonBody {
1469        fn to_json(&self) -> serde_json::Value {
1470            self.0.clone()
1471        }
1472        fn as_any(&self) -> &dyn Any {
1473            self
1474        }
1475    }
1476
1477    #[test]
1478    fn parse_int_array_bracket_format() {
1479        assert_eq!(parse_int_array("[1, 5, 12, 23]"), vec![1, 5, 12, 23]);
1480    }
1481
1482    /// SRD-32a unification drift-guard: `CORE_OP_PARAMS` is the
1483    /// NON-wrapper core vocabulary. Wrapper fields are accepted by the
1484    /// op closed-vocab guard via the registry's `owns_field`, so a
1485    /// wrapper field appearing in `CORE_OP_PARAMS` is duplication that
1486    /// reintroduces the very two-lists-to-maintain trap this unification
1487    /// removed. Fail loudly if one creeps back in.
1488    #[test]
1489    fn core_op_params_disjoint_from_owned_fields() {
1490        let registry = crate::wrapper_registry::WrapperRegistry::from_inventory();
1491        let owned = registry.all_owned_fields();
1492        let dupes: Vec<&str> = CORE_OP_PARAMS
1493            .iter()
1494            .copied()
1495            .filter(|p| owned.contains(p))
1496            .collect();
1497        assert!(
1498            dupes.is_empty(),
1499            "these CORE_OP_PARAMS are already wrapper-owned (remove them — \
1500             the guard accepts them via WrapperRegistry::owns_field): {dupes:?}",
1501        );
1502    }
1503
1504    /// The type-safe replacement for the CLI-coincidence hole: a
1505    /// wrapper field that is NEITHER a CLI param NOR in CORE_OP_PARAMS
1506    /// must still be recognized, purely because a wrapper declares it.
1507    /// `readout` is exactly such a field (opt-in op status, never CLI-
1508    /// settable), so it is the live regression witness.
1509    #[test]
1510    fn wrapper_field_accepted_without_cli_or_core_membership() {
1511        let registry = crate::wrapper_registry::WrapperRegistry::from_inventory();
1512        assert!(
1513            registry.owns_field("readout"),
1514            "readout must be registry-owned"
1515        );
1516        assert!(
1517            registry.owns_field("errors"),
1518            "errors must be registry-owned (was riding the CLI hatch)"
1519        );
1520        assert!(
1521            registry.owns_field("tries"),
1522            "tries must be registry-owned (was riding the CLI hatch)"
1523        );
1524        assert!(
1525            !CORE_OP_PARAMS.contains(&"readout"),
1526            "readout should NOT be in CORE_OP_PARAMS — it's wrapper-owned"
1527        );
1528    }
1529
1530    #[test]
1531    fn parse_int_array_comma_format() {
1532        assert_eq!(parse_int_array("1,5,12,23"), vec![1, 5, 12, 23]);
1533    }
1534
1535    #[test]
1536    fn parse_int_array_space_format() {
1537        assert_eq!(parse_int_array("1 5 12 23"), vec![1, 5, 12, 23]);
1538    }
1539
1540    #[test]
1541    fn parse_int_array_empty() {
1542        assert_eq!(parse_int_array("[]"), Vec::<i64>::new());
1543        assert_eq!(parse_int_array(""), Vec::<i64>::new());
1544    }
1545
1546    #[test]
1547    fn extract_indices_from_json_array() {
1548        let json = serde_json::json!([
1549            {"key": 5, "distance": 0.1},
1550            {"key": 12, "distance": 0.2},
1551            {"key": 3, "distance": 0.3},
1552        ]);
1553        assert_eq!(extract_indices_from_json(&json, "key"), vec![5, 12, 3]);
1554    }
1555
1556    #[test]
1557    fn extract_indices_from_json_rows_wrapper() {
1558        let json = serde_json::json!({
1559            "rows": [
1560                {"key": 5},
1561                {"key": 12},
1562            ]
1563        });
1564        assert_eq!(extract_indices_from_json(&json, "key"), vec![5, 12]);
1565    }
1566
1567    #[test]
1568    fn assertion_not_null() {
1569        let result = OpResult {
1570            body: Some(Box::new(JsonBody(serde_json::json!({"name": "alice"})))),
1571            skipped: false,
1572        };
1573        let spec = AssertionSpec {
1574            field: "name".into(),
1575            predicate: AssertionPredicate::NotNull,
1576        };
1577        assert!(spec.check(&result));
1578
1579        let spec_missing = AssertionSpec {
1580            field: "age".into(),
1581            predicate: AssertionPredicate::NotNull,
1582        };
1583        assert!(!spec_missing.check(&result));
1584    }
1585
1586    #[test]
1587    fn assertion_eq() {
1588        let result = OpResult {
1589            body: Some(Box::new(JsonBody(serde_json::json!({"status": "ok"})))),
1590            skipped: false,
1591        };
1592        let spec = AssertionSpec {
1593            field: "status".into(),
1594            predicate: AssertionPredicate::Eq("ok".into()),
1595        };
1596        assert!(spec.check(&result));
1597
1598        let spec_fail = AssertionSpec {
1599            field: "status".into(),
1600            predicate: AssertionPredicate::Eq("error".into()),
1601        };
1602        assert!(!spec_fail.check(&result));
1603    }
1604
1605    #[test]
1606    fn assertion_gte() {
1607        let result = OpResult {
1608            body: Some(Box::new(JsonBody(serde_json::json!({"balance": 42.5})))),
1609            skipped: false,
1610        };
1611        let spec = AssertionSpec {
1612            field: "balance".into(),
1613            predicate: AssertionPredicate::Gte(0.0),
1614        };
1615        assert!(spec.check(&result));
1616
1617        let spec_fail = AssertionSpec {
1618            field: "balance".into(),
1619            predicate: AssertionPredicate::Gte(100.0),
1620        };
1621        assert!(!spec_fail.check(&result));
1622    }
1623
1624    #[test]
1625    fn assertion_no_body() {
1626        let result = OpResult {
1627            body: None,
1628            skipped: false,
1629        };
1630        let spec = AssertionSpec {
1631            field: "anything".into(),
1632            predicate: AssertionPredicate::IsNull,
1633        };
1634        assert!(spec.check(&result));
1635
1636        let spec_not_null = AssertionSpec {
1637            field: "anything".into(),
1638            predicate: AssertionPredicate::NotNull,
1639        };
1640        assert!(!spec_not_null.check(&result));
1641    }
1642
1643    /// Body whose `element_count()` reflects the number of items
1644    /// in a JSON array — exercises body-level predicates like
1645    /// `MinRows` that read from `element_count` rather than
1646    /// inspecting fields.
1647    #[derive(Debug)]
1648    struct CountedBody {
1649        rows: Vec<serde_json::Value>,
1650    }
1651    impl ResultBody for CountedBody {
1652        fn to_json(&self) -> serde_json::Value {
1653            serde_json::Value::Array(self.rows.clone())
1654        }
1655        fn as_any(&self) -> &dyn Any {
1656            self
1657        }
1658        fn element_count(&self) -> u64 {
1659            self.rows.len() as u64
1660        }
1661    }
1662
1663    #[test]
1664    fn assertion_min_rows_passes_when_threshold_met() {
1665        let result = OpResult {
1666            body: Some(Box::new(CountedBody {
1667                rows: vec![
1668                    serde_json::json!({"index_name": "vec_idx"}),
1669                    serde_json::json!({"index_name": "meta_idx"}),
1670                ],
1671            })),
1672            skipped: false,
1673        };
1674        let spec = AssertionSpec {
1675            field: String::new(),
1676            predicate: AssertionPredicate::MinRows(1),
1677        };
1678        assert!(spec.check(&result));
1679
1680        let spec_two = AssertionSpec {
1681            field: String::new(),
1682            predicate: AssertionPredicate::MinRows(2),
1683        };
1684        assert!(spec_two.check(&result));
1685    }
1686
1687    #[test]
1688    fn assertion_min_rows_fails_when_below_threshold() {
1689        // Empty array: element_count = 0, MinRows(1) must fail.
1690        let result = OpResult {
1691            body: Some(Box::new(CountedBody { rows: Vec::new() })),
1692            skipped: false,
1693        };
1694        let spec = AssertionSpec {
1695            field: String::new(),
1696            predicate: AssertionPredicate::MinRows(1),
1697        };
1698        assert!(!spec.check(&result));
1699
1700        // No body at all: element_count defaults to 0 → MinRows(1) fails.
1701        let result_none = OpResult {
1702            body: None,
1703            skipped: false,
1704        };
1705        assert!(!spec.check(&result_none));
1706    }
1707
1708    #[test]
1709    fn parse_assertions_min_rows_from_yaml() {
1710        // SRD-40b-shaped failsafe verify form:
1711        //   verify:
1712        //     - min_rows: 1
1713        let mut template = nmbrs_workload::model::ParsedOp::simple("await", "test");
1714        template.params.insert(
1715            "verify".into(),
1716            serde_json::json!([
1717                {"min_rows": 1},
1718            ]),
1719        );
1720        let assertions = parse_assertions(&template);
1721        assert_eq!(assertions.len(), 1);
1722        match &assertions[0].predicate {
1723            AssertionPredicate::MinRows(n) => assert_eq!(*n, 1),
1724            other => panic!("expected MinRows(1), got {other:?}"),
1725        }
1726    }
1727
1728    #[test]
1729    fn eq_failure_includes_body_and_distinguishes_absent_vs_not_json() {
1730        // JSON body that has fields but lacks the one we asked
1731        // for: observed reads `<field absent; …>` and lists the
1732        // keys that ARE present so the operator can pick a real
1733        // field name without re-running the workload.
1734        let result = OpResult {
1735            body: Some(Box::new(JsonBody(serde_json::json!({
1736                "value": null, "request": {"type": "exec"}
1737            })))),
1738            skipped: false,
1739        };
1740        let spec = AssertionSpec {
1741            field: "status".into(),
1742            predicate: AssertionPredicate::Eq("200".into()),
1743        };
1744        let msg = describe_assertion_failure(&spec, &result);
1745        assert!(
1746            msg.contains("field absent"),
1747            "json-without-field should mark observed as absent, got: {msg}"
1748        );
1749        assert!(
1750            msg.contains("body keys"),
1751            "absent message should enumerate present keys, got: {msg}"
1752        );
1753        assert!(
1754            msg.contains("\"value\"") && msg.contains("\"request\""),
1755            "key list should include both present keys, got: {msg}"
1756        );
1757        assert!(msg.contains("body: "), "body excerpt missing: {msg}");
1758        assert!(
1759            msg.contains("\"request\""),
1760            "body excerpt should echo the actual JSON: {msg}"
1761        );
1762
1763        // Plain-text body: observed reads `<not-json: …>` with a
1764        // short preview so the operator can recognise the actual
1765        // payload (HTML error page, plain-text response, etc.).
1766        #[derive(Debug)]
1767        struct PlainBody(String);
1768        impl ResultBody for PlainBody {
1769            fn to_json(&self) -> serde_json::Value {
1770                serde_json::Value::String(self.0.clone())
1771            }
1772            fn as_any(&self) -> &dyn Any {
1773                self
1774            }
1775            fn to_text(&self) -> String {
1776                self.0.clone()
1777            }
1778        }
1779        let text_result = OpResult {
1780            body: Some(Box::new(PlainBody(
1781                "<html><body>404 Not Found</body></html>".into(),
1782            ))),
1783            skipped: false,
1784        };
1785        let msg2 = describe_assertion_failure(&spec, &text_result);
1786        assert!(
1787            msg2.contains("not-json"),
1788            "text body should mark observed as not-json, got: {msg2}"
1789        );
1790        assert!(
1791            msg2.contains("404 Not Found"),
1792            "body excerpt should include the text: {msg2}"
1793        );
1794    }
1795
1796    /// When the op produced no body at all, the message must
1797    /// communicate that clearly (not just '<no body>' twice),
1798    /// AND must NOT duplicate the same '<no body>' phrase in
1799    /// both the observed-field slot and the body excerpt.
1800    #[test]
1801    fn eq_failure_with_no_body_explains_situation() {
1802        let result = OpResult {
1803            body: None,
1804            skipped: false,
1805        };
1806        let spec = AssertionSpec {
1807            field: "status".into(),
1808            predicate: AssertionPredicate::Eq("200".into()),
1809        };
1810        let msg = describe_assertion_failure(&spec, &result);
1811        assert!(
1812            msg.contains("no body returned by op"),
1813            "no-body case should explain why the field can't be read, got: {msg}"
1814        );
1815        assert!(
1816            !msg.contains("body: "),
1817            "no-body case should suppress the redundant body excerpt, got: {msg}"
1818        );
1819    }
1820
1821    #[test]
1822    fn min_rows_failure_describes_actual_vs_expected() {
1823        // Strict-mode error renderer should name the predicate
1824        // and the actual count so the user sees "expected ≥1, got 0"
1825        // rather than a generic "validation failed".
1826        let result = OpResult {
1827            body: Some(Box::new(CountedBody { rows: Vec::new() })),
1828            skipped: false,
1829        };
1830        let spec = AssertionSpec {
1831            field: String::new(),
1832            predicate: AssertionPredicate::MinRows(1),
1833        };
1834        let msg = describe_assertion_failure(&spec, &result);
1835        assert!(msg.contains("min_rows"), "got: {msg}");
1836        assert!(msg.contains("≥1"), "got: {msg}");
1837        assert!(msg.contains("got 0"), "got: {msg}");
1838    }
1839
1840    #[test]
1841    fn validation_metrics_record_relevancy() {
1842        let labels = Labels::of("activity", "test");
1843        let metrics = ValidationMetrics::new(
1844            &labels,
1845            &[RelevancyFn::Recall, RelevancyFn::Precision],
1846            10,
1847            Some(20),
1848        );
1849        // Family names are bare function names; `k` and
1850        // `r` ride on the F64Stats's labels so consumers
1851        // can query e.g. `recall{k="10",r="20"}`.
1852        assert!(metrics.relevancy_stats.contains_key("recall"));
1853        assert!(metrics.relevancy_stats.contains_key("precision"));
1854        assert!(!metrics.relevancy_stats.contains_key("f1"));
1855
1856        // The F64Stats labels carry k and r.
1857        let recall_labels = metrics.relevancy_stats["recall"].labels();
1858        assert_eq!(recall_labels.get("k"), Some("10"));
1859        assert_eq!(recall_labels.get("r"), Some("20"));
1860
1861        metrics.record_relevancy("recall", 0.85);
1862        metrics.record_relevancy("recall", 0.90);
1863        let snap = metrics.relevancy_stats["recall"].snapshot();
1864        assert_eq!(snap.len(), 2);
1865    }
1866
1867    #[test]
1868    fn validation_metrics_r_defaults_to_k() {
1869        let labels = Labels::of("activity", "test");
1870        // No `r:` in the relevancy config → the metric's
1871        // `r` label equals `k` (legacy first-k semantics).
1872        let metrics = ValidationMetrics::new(&labels, &[RelevancyFn::Recall], 100, None);
1873        let l = metrics.relevancy_stats["recall"].labels();
1874        assert_eq!(l.get("k"), Some("100"));
1875        assert_eq!(l.get("r"), Some("100"));
1876    }
1877
1878    #[test]
1879    fn resolve_expected_string_array_form() {
1880        let v = polydat::ast::Value::Str("[1, 5, 12, 23]".into());
1881        assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12, 23]);
1882    }
1883
1884    #[test]
1885    fn resolve_expected_string_csv_form() {
1886        let v = polydat::ast::Value::Str("1,5,12".into());
1887        assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12]);
1888    }
1889
1890    #[test]
1891    fn resolve_expected_native_veci32_fast_path() {
1892        // The fast path the dataset accessors emit
1893        // (`neighbor_indices_at` etc. → Value::VecI32). No
1894        // string round-trip happens; the slice is read directly.
1895        use polydat::ast::{SliceArc, Value};
1896        let slice = SliceArc::<i32>::from_vec(vec![1, 5, 12, 23, 100]);
1897        let v = Value::VecI32(slice);
1898        assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12, 23, 100]);
1899    }
1900
1901    #[test]
1902    fn resolve_expected_native_vecf32_fast_path() {
1903        // VecF32 ground truth (rare but legal — some datasets
1904        // store ranks as floats). Truncates to i64.
1905        use polydat::ast::{SliceArc, Value};
1906        let slice = SliceArc::<f32>::from_vec(vec![1.0, 2.0, 3.0]);
1907        let v = Value::VecF32(slice);
1908        assert_eq!(resolve_expected_from_value(&v), vec![1, 2, 3]);
1909    }
1910
1911    #[test]
1912    fn parse_relevancy_from_params() {
1913        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1914        template.params.insert(
1915            "relevancy".into(),
1916            serde_json::json!({
1917                "actual": "key",
1918                "expected": "{ground_truth}",
1919                "k": 10,
1920                "functions": ["recall", "precision", "f1"]
1921            }),
1922        );
1923        let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1924        assert_eq!(config.actual_field, "key");
1925        assert_eq!(config.expected_binding, "{ground_truth}");
1926        assert_eq!(config.k, 10);
1927        assert!(config.r.is_none(), "r should default to None when absent");
1928        assert_eq!(config.functions.len(), 3);
1929        assert_eq!(config.functions[0], RelevancyFn::Recall);
1930        assert_eq!(config.functions[1], RelevancyFn::Precision);
1931        assert_eq!(config.functions[2], RelevancyFn::F1);
1932    }
1933
1934    #[test]
1935    fn parse_relevancy_with_r_for_k_recall_at_r() {
1936        // 10-recall@100: retrieve 100, score against the first
1937        // 10. The `r` field is parsed alongside `k` from the
1938        // same numeric/`{name}` shapes.
1939        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1940        template.params.insert(
1941            "relevancy".into(),
1942            serde_json::json!({
1943                "actual": "key",
1944                "expected": "{ground_truth}",
1945                "k": 10,
1946                "r": 100,
1947                "functions": ["recall"],
1948            }),
1949        );
1950        let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1951        assert_eq!(config.k, 10);
1952        assert_eq!(config.r, Some(100));
1953    }
1954
1955    #[test]
1956    fn parse_relevancy_r_accepts_string_form() {
1957        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1958        template.params.insert(
1959            "relevancy".into(),
1960            serde_json::json!({
1961                "actual": "key",
1962                "expected": "{ground_truth}",
1963                "k": "10",
1964                "r": "100",
1965            }),
1966        );
1967        let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1968        assert_eq!(config.k, 10);
1969        assert_eq!(config.r, Some(100));
1970    }
1971
1972    #[test]
1973    fn parse_relevancy_missing() {
1974        let template = nmbrs_workload::model::ParsedOp::simple("test", "INSERT");
1975        assert!(parse_relevancy(&template, None, None).unwrap().is_none());
1976    }
1977
1978    #[test]
1979    fn parse_relevancy_k_and_r_accept_bare_wire_names() {
1980        // Post-SRD-68 follow-up: `k: k` (bare wire-name) resolves
1981        // against the canonical kernel at wrap time. Same one-shot
1982        // evaluation as the `{k}` text-template form but without
1983        // the placeholder braces.
1984        let kernel = crate::scope_kernel::ScopeKernel::compile(
1985            "input cycle: u64\n\
1986             const k := 10\n\
1987             const limit := 100\n",
1988        )
1989        .expect("compile scope kernel wires");
1990        let wires: &dyn WireSource = &kernel;
1991
1992        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1993        template.params.insert(
1994            "relevancy".into(),
1995            serde_json::json!({
1996                "actual": "key",
1997                "expected": "{ground_truth}",
1998                "k": "k",            // bare wire-name
1999                "r": "limit",        // bare wire-name
2000                "functions": ["recall"],
2001            }),
2002        );
2003        let config = parse_relevancy(&template, None, Some(wires))
2004            .unwrap()
2005            .unwrap();
2006        assert_eq!(config.k, 10, "bare `k:` resolved through wires");
2007        assert_eq!(config.r, Some(100), "bare `r:` resolved through wires");
2008    }
2009
2010    #[test]
2011    fn parse_relevancy_bare_name_falls_through_to_int_parse_when_no_kernel() {
2012        // Without a canonical kernel (no wires available), a bare
2013        // identifier in `k:` errors clearly — it can't be a wire
2014        // reference and isn't a valid integer either.
2015        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
2016        template.params.insert(
2017            "relevancy".into(),
2018            serde_json::json!({
2019                "actual": "key",
2020                "expected": "{ground_truth}",
2021                "k": "k",
2022                "functions": ["recall"],
2023            }),
2024        );
2025        let err = parse_relevancy(&template, None, None).unwrap_err();
2026        assert!(
2027            err.contains("'k' is not a valid non-negative integer"),
2028            "diagnostic should describe the parse failure: {err}"
2029        );
2030    }
2031
2032    /// A succeeding op that returns NO body must not be failed by a value
2033    /// predicate. `on_timeout: accept` (the server is still working; the poll
2034    /// layer observes it) and HTTP 204 both land here, and a
2035    /// `field: status, eq: 200` clause written for the normal response was
2036    /// failing them — it cost a live benchmark run at tier 6.
2037    #[test]
2038    fn value_predicates_are_vacuous_without_a_body() {
2039        let result = OpResult {
2040            body: None,
2041            ..Default::default()
2042        };
2043        for predicate in [
2044            AssertionPredicate::Eq("200".into()),
2045            AssertionPredicate::Lte(5.0),
2046            AssertionPredicate::Gte(1.0),
2047            AssertionPredicate::Contains("ok".into()),
2048            AssertionPredicate::IsNull,
2049        ] {
2050            let spec = AssertionSpec {
2051                field: "status".into(),
2052                predicate,
2053            };
2054            assert!(
2055                spec.check(&result),
2056                "a value predicate has nothing to contradict it: {:?}",
2057                spec.predicate
2058            );
2059        }
2060    }
2061
2062    /// Presence predicates are the explicit "a body is required" spelling, so
2063    /// they must still fail when none arrives — otherwise there would be no
2064    /// way to demand one.
2065    #[test]
2066    fn presence_predicates_still_fail_without_a_body() {
2067        let result = OpResult {
2068            body: None,
2069            ..Default::default()
2070        };
2071        let not_null = AssertionSpec {
2072            field: "status".into(),
2073            predicate: AssertionPredicate::NotNull,
2074        };
2075        assert!(
2076            !not_null.check(&result),
2077            "`is: not_null` is how an author demands the field exist"
2078        );
2079        let min_rows = AssertionSpec {
2080            field: String::new(),
2081            predicate: AssertionPredicate::MinRows(1),
2082        };
2083        assert!(
2084            !min_rows.check(&result),
2085            "`min_rows: 1` is how an author demands a non-empty result"
2086        );
2087    }
2088
2089    /// A malformed bound can never pass, body or no body — otherwise a typo
2090    /// would turn into a silently-skipped check on exactly the ops that
2091    /// return nothing.
2092    #[test]
2093    fn malformed_bounds_fail_even_without_a_body() {
2094        let result = OpResult {
2095            body: None,
2096            ..Default::default()
2097        };
2098        let spec = AssertionSpec {
2099            field: "value".into(),
2100            predicate: AssertionPredicate::MalformedBound {
2101                key: "lte".into(),
2102                raw: "{unresolved}".into(),
2103            },
2104        };
2105        assert!(!spec.check(&result));
2106    }
2107
2108    /// A quoted numeric bound means the same as an unquoted one. YAML makes
2109    /// the string form easy to reach — quoting, or a `{placeholder}` that
2110    /// substitutes to text — and it used to silently become `<= 0`, failing
2111    /// good data against a threshold nobody wrote. Cost a live benchmark run.
2112    #[test]
2113    fn numeric_bounds_accept_quoted_numbers() {
2114        let mut template = nmbrs_workload::model::ParsedOp::simple("t", "noop");
2115        template.params.insert(
2116            "verify".into(),
2117            serde_json::json!([
2118                {"field": "value", "lte": "5"},
2119                {"field": "value", "gte": " 2 "},
2120                {"field": "other", "lte": 7},
2121            ]),
2122        );
2123        let a = parse_assertions(&template);
2124        assert!(
2125            matches!(a[0].predicate, AssertionPredicate::Lte(t) if t == 5.0),
2126            "quoted lte must parse: {:?}",
2127            a[0].predicate
2128        );
2129        assert!(
2130            matches!(a[1].predicate, AssertionPredicate::Gte(t) if t == 2.0),
2131            "surrounding whitespace is not a malformed bound: {:?}",
2132            a[1].predicate
2133        );
2134        assert!(
2135            matches!(a[2].predicate, AssertionPredicate::Lte(t) if t == 7.0),
2136            "the unquoted form is unchanged: {:?}",
2137            a[2].predicate
2138        );
2139    }
2140
2141    /// A bound that is not a number at all must FAIL LOUDLY and name itself —
2142    /// never default to zero, which reads as a real threshold in the failure
2143    /// message and sends the reader hunting for a data problem that does not
2144    /// exist.
2145    #[test]
2146    fn non_numeric_bounds_are_malformed_not_zero() {
2147        let mut template = nmbrs_workload::model::ParsedOp::simple("t", "noop");
2148        template.params.insert(
2149            "verify".into(),
2150            serde_json::json!([
2151                {"field": "value", "lte": "{unresolved}"},
2152                {"field": "value", "gte": "abc"},
2153            ]),
2154        );
2155        let a = parse_assertions(&template);
2156        for spec in &a {
2157            match &spec.predicate {
2158                AssertionPredicate::MalformedBound { raw, .. } => {
2159                    assert!(
2160                        !raw.is_empty(),
2161                        "the offending text is carried for the message"
2162                    );
2163                }
2164                other => panic!("expected MalformedBound, got {other:?}"),
2165            }
2166        }
2167        // And the rendered failure names the malformed bound rather than
2168        // quoting a threshold the author never wrote.
2169        let msg = format!("{:?}", a[0].predicate);
2170        assert!(
2171            msg.contains("MalformedBound"),
2172            "the predicate stays malformed all the way to reporting: {msg}"
2173        );
2174    }
2175
2176    #[test]
2177    fn parse_assertions_from_params() {
2178        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT");
2179        template.params.insert(
2180            "verify".into(),
2181            serde_json::json!([
2182                {"field": "name", "is": "not_null"},
2183                {"field": "balance", "gte": 0},
2184                {"field": "status", "eq": "active"},
2185            ]),
2186        );
2187        let assertions = parse_assertions(&template);
2188        assert_eq!(assertions.len(), 3);
2189        assert_eq!(assertions[0].field, "name");
2190        assert!(matches!(
2191            assertions[0].predicate,
2192            AssertionPredicate::NotNull
2193        ));
2194        assert_eq!(assertions[1].field, "balance");
2195        assert!(matches!(assertions[1].predicate, AssertionPredicate::Gte(v) if v == 0.0));
2196        assert_eq!(assertions[2].field, "status");
2197        assert!(matches!(&assertions[2].predicate, AssertionPredicate::Eq(s) if s == "active"));
2198    }
2199
2200    #[test]
2201    fn parse_assertions_missing() {
2202        let template = nmbrs_workload::model::ParsedOp::simple("test", "INSERT");
2203        assert!(parse_assertions(&template).is_empty());
2204    }
2205}