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.
1328pub(crate) fn 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::I64(n) => u64::try_from(n).ok(),
1333        Value::Str(s) => crate::runner::parse_count(&s),
1334        Value::F64(f) if f.is_finite() && f >= 0.0 => Some(f as u64),
1335        Value::Bool(true) => Some(1),
1336        Value::Bool(false) => Some(0),
1337        _ => None,
1338    }
1339}
1340
1341// =========================================================================
1342// Result extraction
1343// =========================================================================
1344
1345/// Extract integer indices from a result body for relevancy comparison.
1346///
1347/// Tries adapter-native downcast first, then falls back to JSON extraction.
1348fn extract_actual_indices(result: &OpResult, field: &str) -> Vec<i64> {
1349    let Some(body) = &result.body else {
1350        return Vec::new();
1351    };
1352    extract_indices_from_json(&body.to_json(), field)
1353}
1354
1355/// Extract integer values for a named field from JSON result structure.
1356fn extract_indices_from_json(json: &serde_json::Value, field: &str) -> Vec<i64> {
1357    match json {
1358        serde_json::Value::Array(rows) => rows
1359            .iter()
1360            .filter_map(|row| json_field_as_i64(row.get(field)?))
1361            .collect(),
1362        serde_json::Value::Object(obj) => {
1363            if let Some(rows) = obj.get("rows") {
1364                return extract_indices_from_json(rows, field);
1365            }
1366            obj.get(field)
1367                .and_then(json_field_as_i64)
1368                .into_iter()
1369                .collect()
1370        }
1371        _ => Vec::new(),
1372    }
1373}
1374
1375/// Coerce a JSON value to i64: native integer, or parse from string.
1376fn json_field_as_i64(v: &serde_json::Value) -> Option<i64> {
1377    v.as_i64().or_else(|| v.as_str()?.parse().ok())
1378}
1379
1380/// Extract integer ground-truth indices from a typed `Value`.
1381///
1382/// Single read path: at cycle time, [`ValidatingDispenser::execute`]
1383/// reads the value via `ctx.wires.get(name)` (a live read through
1384/// the per-fiber op-template kernel — same canonical-scope contract
1385/// MetricsDispenser uses), then hands it here for type-aware
1386/// extraction.
1387///
1388/// Fast path for the typed-array vector data path (SRD 53
1389/// §"Native vector PortType"): when the binding produces
1390/// `Value::VecI32` / `Value::VecF32` (the shape
1391/// `neighbor_indices_at` and friends emit), the slice is read
1392/// directly with no string round-trip. String fallback covers
1393/// legacy bindings that emit `Value::Str("[1, 5, 12, ...]")` or
1394/// `Value::Str("1,5,12,...")`.
1395fn resolve_expected_from_value(value: &polydat::ast::Value) -> Vec<i64> {
1396    match value {
1397        // Typed-slice fast paths — zero-copy read, no parse.
1398        polydat::ast::Value::VecI32(slice) => slice.as_slice().iter().map(|&x| x as i64).collect(),
1399        polydat::ast::Value::VecI64(slice) => slice.as_slice().to_vec(),
1400        polydat::ast::Value::VecF32(slice) => {
1401            // Vector ground-truth indices are integer-valued
1402            // by domain. Truncating fractional parts is the
1403            // correct semantic; anything else would mean the
1404            // dataset's index column is mis-typed at the source.
1405            slice.as_slice().iter().map(|&x| x as i64).collect()
1406        }
1407        polydat::ast::Value::VecF64(slice) => slice.as_slice().iter().map(|&x| x as i64).collect(),
1408        // SRD-70 projection landed as structural JSON (mixed or
1409        // string-typed columns): integer-valued leaves extract,
1410        // numeric strings parse, anything else drops.
1411        polydat::ast::Value::Json(j) => match &**j {
1412            serde_json::Value::Array(elems) => elems.iter().filter_map(json_field_as_i64).collect(),
1413            other => json_field_as_i64(other).into_iter().collect(),
1414        },
1415        polydat::ast::Value::Str(s) => parse_int_array(s),
1416        polydat::ast::Value::U64(v) => vec![*v as i64],
1417        _ => {
1418            // Display-string fallback for anything else.
1419            let s = value.to_display_string();
1420            parse_int_array(&s)
1421        }
1422    }
1423}
1424
1425/// Parse a string containing integers into a Vec<i64>.
1426///
1427/// Handles formats: `[1, 5, 12]`, `1,5,12`, `1 5 12`.
1428fn parse_int_array(s: &str) -> Vec<i64> {
1429    let trimmed = s.trim().trim_start_matches('[').trim_end_matches(']');
1430    trimmed
1431        .split(|c: char| c == ',' || c.is_whitespace())
1432        .filter(|s| !s.is_empty())
1433        .filter_map(|s| s.trim().parse::<i64>().ok())
1434        .collect()
1435}
1436
1437/// Extract a field from JSON by name, checking both top-level and row arrays.
1438fn extract_field_from_json<'a>(
1439    json: &'a serde_json::Value,
1440    field: &str,
1441) -> Option<&'a serde_json::Value> {
1442    match json {
1443        serde_json::Value::Object(obj) => obj.get(field).or_else(|| {
1444            obj.get("rows")
1445                .and_then(|r| r.as_array())
1446                .and_then(|rows| rows.first())
1447                .and_then(|row| row.get(field))
1448        }),
1449        serde_json::Value::Array(rows) => rows.first().and_then(|row| row.get(field)),
1450        _ => None,
1451    }
1452}
1453
1454/// Convert a JSON value to its string representation for comparison.
1455fn json_value_as_string(v: &serde_json::Value) -> String {
1456    match v {
1457        serde_json::Value::String(s) => s.clone(),
1458        other => other.to_string(),
1459    }
1460}
1461
1462#[cfg(test)]
1463mod tests {
1464    use super::*;
1465    use crate::adapter::ResultBody;
1466    use std::any::Any;
1467
1468    #[derive(Debug)]
1469    struct JsonBody(serde_json::Value);
1470    impl ResultBody for JsonBody {
1471        fn to_json(&self) -> serde_json::Value {
1472            self.0.clone()
1473        }
1474        fn as_any(&self) -> &dyn Any {
1475            self
1476        }
1477    }
1478
1479    #[test]
1480    fn parse_int_array_bracket_format() {
1481        assert_eq!(parse_int_array("[1, 5, 12, 23]"), vec![1, 5, 12, 23]);
1482    }
1483
1484    /// SRD-32a unification drift-guard: `CORE_OP_PARAMS` is the
1485    /// NON-wrapper core vocabulary. Wrapper fields are accepted by the
1486    /// op closed-vocab guard via the registry's `owns_field`, so a
1487    /// wrapper field appearing in `CORE_OP_PARAMS` is duplication that
1488    /// reintroduces the very two-lists-to-maintain trap this unification
1489    /// removed. Fail loudly if one creeps back in.
1490    #[test]
1491    fn core_op_params_disjoint_from_owned_fields() {
1492        let registry = crate::wrapper_registry::WrapperRegistry::from_inventory();
1493        let owned = registry.all_owned_fields();
1494        let dupes: Vec<&str> = CORE_OP_PARAMS
1495            .iter()
1496            .copied()
1497            .filter(|p| owned.contains(p))
1498            .collect();
1499        assert!(
1500            dupes.is_empty(),
1501            "these CORE_OP_PARAMS are already wrapper-owned (remove them — \
1502             the guard accepts them via WrapperRegistry::owns_field): {dupes:?}",
1503        );
1504    }
1505
1506    /// The type-safe replacement for the CLI-coincidence hole: a
1507    /// wrapper field that is NEITHER a CLI param NOR in CORE_OP_PARAMS
1508    /// must still be recognized, purely because a wrapper declares it.
1509    /// `readout` is exactly such a field (opt-in op status, never CLI-
1510    /// settable), so it is the live regression witness.
1511    #[test]
1512    fn wrapper_field_accepted_without_cli_or_core_membership() {
1513        let registry = crate::wrapper_registry::WrapperRegistry::from_inventory();
1514        assert!(
1515            registry.owns_field("readout"),
1516            "readout must be registry-owned"
1517        );
1518        assert!(
1519            registry.owns_field("errors"),
1520            "errors must be registry-owned (was riding the CLI hatch)"
1521        );
1522        assert!(
1523            registry.owns_field("tries"),
1524            "tries must be registry-owned (was riding the CLI hatch)"
1525        );
1526        assert!(
1527            !CORE_OP_PARAMS.contains(&"readout"),
1528            "readout should NOT be in CORE_OP_PARAMS — it's wrapper-owned"
1529        );
1530    }
1531
1532    #[test]
1533    fn parse_int_array_comma_format() {
1534        assert_eq!(parse_int_array("1,5,12,23"), vec![1, 5, 12, 23]);
1535    }
1536
1537    #[test]
1538    fn parse_int_array_space_format() {
1539        assert_eq!(parse_int_array("1 5 12 23"), vec![1, 5, 12, 23]);
1540    }
1541
1542    #[test]
1543    fn parse_int_array_empty() {
1544        assert_eq!(parse_int_array("[]"), Vec::<i64>::new());
1545        assert_eq!(parse_int_array(""), Vec::<i64>::new());
1546    }
1547
1548    #[test]
1549    fn extract_indices_from_json_array() {
1550        let json = serde_json::json!([
1551            {"key": 5, "distance": 0.1},
1552            {"key": 12, "distance": 0.2},
1553            {"key": 3, "distance": 0.3},
1554        ]);
1555        assert_eq!(extract_indices_from_json(&json, "key"), vec![5, 12, 3]);
1556    }
1557
1558    #[test]
1559    fn extract_indices_from_json_rows_wrapper() {
1560        let json = serde_json::json!({
1561            "rows": [
1562                {"key": 5},
1563                {"key": 12},
1564            ]
1565        });
1566        assert_eq!(extract_indices_from_json(&json, "key"), vec![5, 12]);
1567    }
1568
1569    #[test]
1570    fn assertion_not_null() {
1571        let result = OpResult {
1572            body: Some(Box::new(JsonBody(serde_json::json!({"name": "alice"})))),
1573            skipped: false,
1574        };
1575        let spec = AssertionSpec {
1576            field: "name".into(),
1577            predicate: AssertionPredicate::NotNull,
1578        };
1579        assert!(spec.check(&result));
1580
1581        let spec_missing = AssertionSpec {
1582            field: "age".into(),
1583            predicate: AssertionPredicate::NotNull,
1584        };
1585        assert!(!spec_missing.check(&result));
1586    }
1587
1588    #[test]
1589    fn assertion_eq() {
1590        let result = OpResult {
1591            body: Some(Box::new(JsonBody(serde_json::json!({"status": "ok"})))),
1592            skipped: false,
1593        };
1594        let spec = AssertionSpec {
1595            field: "status".into(),
1596            predicate: AssertionPredicate::Eq("ok".into()),
1597        };
1598        assert!(spec.check(&result));
1599
1600        let spec_fail = AssertionSpec {
1601            field: "status".into(),
1602            predicate: AssertionPredicate::Eq("error".into()),
1603        };
1604        assert!(!spec_fail.check(&result));
1605    }
1606
1607    #[test]
1608    fn assertion_gte() {
1609        let result = OpResult {
1610            body: Some(Box::new(JsonBody(serde_json::json!({"balance": 42.5})))),
1611            skipped: false,
1612        };
1613        let spec = AssertionSpec {
1614            field: "balance".into(),
1615            predicate: AssertionPredicate::Gte(0.0),
1616        };
1617        assert!(spec.check(&result));
1618
1619        let spec_fail = AssertionSpec {
1620            field: "balance".into(),
1621            predicate: AssertionPredicate::Gte(100.0),
1622        };
1623        assert!(!spec_fail.check(&result));
1624    }
1625
1626    #[test]
1627    fn assertion_no_body() {
1628        let result = OpResult {
1629            body: None,
1630            skipped: false,
1631        };
1632        let spec = AssertionSpec {
1633            field: "anything".into(),
1634            predicate: AssertionPredicate::IsNull,
1635        };
1636        assert!(spec.check(&result));
1637
1638        let spec_not_null = AssertionSpec {
1639            field: "anything".into(),
1640            predicate: AssertionPredicate::NotNull,
1641        };
1642        assert!(!spec_not_null.check(&result));
1643    }
1644
1645    /// Body whose `element_count()` reflects the number of items
1646    /// in a JSON array — exercises body-level predicates like
1647    /// `MinRows` that read from `element_count` rather than
1648    /// inspecting fields.
1649    #[derive(Debug)]
1650    struct CountedBody {
1651        rows: Vec<serde_json::Value>,
1652    }
1653    impl ResultBody for CountedBody {
1654        fn to_json(&self) -> serde_json::Value {
1655            serde_json::Value::Array(self.rows.clone())
1656        }
1657        fn as_any(&self) -> &dyn Any {
1658            self
1659        }
1660        fn element_count(&self) -> u64 {
1661            self.rows.len() as u64
1662        }
1663    }
1664
1665    #[test]
1666    fn assertion_min_rows_passes_when_threshold_met() {
1667        let result = OpResult {
1668            body: Some(Box::new(CountedBody {
1669                rows: vec![
1670                    serde_json::json!({"index_name": "vec_idx"}),
1671                    serde_json::json!({"index_name": "meta_idx"}),
1672                ],
1673            })),
1674            skipped: false,
1675        };
1676        let spec = AssertionSpec {
1677            field: String::new(),
1678            predicate: AssertionPredicate::MinRows(1),
1679        };
1680        assert!(spec.check(&result));
1681
1682        let spec_two = AssertionSpec {
1683            field: String::new(),
1684            predicate: AssertionPredicate::MinRows(2),
1685        };
1686        assert!(spec_two.check(&result));
1687    }
1688
1689    #[test]
1690    fn assertion_min_rows_fails_when_below_threshold() {
1691        // Empty array: element_count = 0, MinRows(1) must fail.
1692        let result = OpResult {
1693            body: Some(Box::new(CountedBody { rows: Vec::new() })),
1694            skipped: false,
1695        };
1696        let spec = AssertionSpec {
1697            field: String::new(),
1698            predicate: AssertionPredicate::MinRows(1),
1699        };
1700        assert!(!spec.check(&result));
1701
1702        // No body at all: element_count defaults to 0 → MinRows(1) fails.
1703        let result_none = OpResult {
1704            body: None,
1705            skipped: false,
1706        };
1707        assert!(!spec.check(&result_none));
1708    }
1709
1710    #[test]
1711    fn parse_assertions_min_rows_from_yaml() {
1712        // SRD-40b-shaped failsafe verify form:
1713        //   verify:
1714        //     - min_rows: 1
1715        let mut template = nmbrs_workload::model::ParsedOp::simple("await", "test");
1716        template.params.insert(
1717            "verify".into(),
1718            serde_json::json!([
1719                {"min_rows": 1},
1720            ]),
1721        );
1722        let assertions = parse_assertions(&template);
1723        assert_eq!(assertions.len(), 1);
1724        match &assertions[0].predicate {
1725            AssertionPredicate::MinRows(n) => assert_eq!(*n, 1),
1726            other => panic!("expected MinRows(1), got {other:?}"),
1727        }
1728    }
1729
1730    #[test]
1731    fn eq_failure_includes_body_and_distinguishes_absent_vs_not_json() {
1732        // JSON body that has fields but lacks the one we asked
1733        // for: observed reads `<field absent; …>` and lists the
1734        // keys that ARE present so the operator can pick a real
1735        // field name without re-running the workload.
1736        let result = OpResult {
1737            body: Some(Box::new(JsonBody(serde_json::json!({
1738                "value": null, "request": {"type": "exec"}
1739            })))),
1740            skipped: false,
1741        };
1742        let spec = AssertionSpec {
1743            field: "status".into(),
1744            predicate: AssertionPredicate::Eq("200".into()),
1745        };
1746        let msg = describe_assertion_failure(&spec, &result);
1747        assert!(
1748            msg.contains("field absent"),
1749            "json-without-field should mark observed as absent, got: {msg}"
1750        );
1751        assert!(
1752            msg.contains("body keys"),
1753            "absent message should enumerate present keys, got: {msg}"
1754        );
1755        assert!(
1756            msg.contains("\"value\"") && msg.contains("\"request\""),
1757            "key list should include both present keys, got: {msg}"
1758        );
1759        assert!(msg.contains("body: "), "body excerpt missing: {msg}");
1760        assert!(
1761            msg.contains("\"request\""),
1762            "body excerpt should echo the actual JSON: {msg}"
1763        );
1764
1765        // Plain-text body: observed reads `<not-json: …>` with a
1766        // short preview so the operator can recognise the actual
1767        // payload (HTML error page, plain-text response, etc.).
1768        #[derive(Debug)]
1769        struct PlainBody(String);
1770        impl ResultBody for PlainBody {
1771            fn to_json(&self) -> serde_json::Value {
1772                serde_json::Value::String(self.0.clone())
1773            }
1774            fn as_any(&self) -> &dyn Any {
1775                self
1776            }
1777            fn to_text(&self) -> String {
1778                self.0.clone()
1779            }
1780        }
1781        let text_result = OpResult {
1782            body: Some(Box::new(PlainBody(
1783                "<html><body>404 Not Found</body></html>".into(),
1784            ))),
1785            skipped: false,
1786        };
1787        let msg2 = describe_assertion_failure(&spec, &text_result);
1788        assert!(
1789            msg2.contains("not-json"),
1790            "text body should mark observed as not-json, got: {msg2}"
1791        );
1792        assert!(
1793            msg2.contains("404 Not Found"),
1794            "body excerpt should include the text: {msg2}"
1795        );
1796    }
1797
1798    /// When the op produced no body at all, the message must
1799    /// communicate that clearly (not just '<no body>' twice),
1800    /// AND must NOT duplicate the same '<no body>' phrase in
1801    /// both the observed-field slot and the body excerpt.
1802    #[test]
1803    fn eq_failure_with_no_body_explains_situation() {
1804        let result = OpResult {
1805            body: None,
1806            skipped: false,
1807        };
1808        let spec = AssertionSpec {
1809            field: "status".into(),
1810            predicate: AssertionPredicate::Eq("200".into()),
1811        };
1812        let msg = describe_assertion_failure(&spec, &result);
1813        assert!(
1814            msg.contains("no body returned by op"),
1815            "no-body case should explain why the field can't be read, got: {msg}"
1816        );
1817        assert!(
1818            !msg.contains("body: "),
1819            "no-body case should suppress the redundant body excerpt, got: {msg}"
1820        );
1821    }
1822
1823    #[test]
1824    fn min_rows_failure_describes_actual_vs_expected() {
1825        // Strict-mode error renderer should name the predicate
1826        // and the actual count so the user sees "expected ≥1, got 0"
1827        // rather than a generic "validation failed".
1828        let result = OpResult {
1829            body: Some(Box::new(CountedBody { rows: Vec::new() })),
1830            skipped: false,
1831        };
1832        let spec = AssertionSpec {
1833            field: String::new(),
1834            predicate: AssertionPredicate::MinRows(1),
1835        };
1836        let msg = describe_assertion_failure(&spec, &result);
1837        assert!(msg.contains("min_rows"), "got: {msg}");
1838        assert!(msg.contains("≥1"), "got: {msg}");
1839        assert!(msg.contains("got 0"), "got: {msg}");
1840    }
1841
1842    #[test]
1843    fn validation_metrics_record_relevancy() {
1844        let labels = Labels::of("activity", "test");
1845        let metrics = ValidationMetrics::new(
1846            &labels,
1847            &[RelevancyFn::Recall, RelevancyFn::Precision],
1848            10,
1849            Some(20),
1850        );
1851        // Family names are bare function names; `k` and
1852        // `r` ride on the F64Stats's labels so consumers
1853        // can query e.g. `recall{k="10",r="20"}`.
1854        assert!(metrics.relevancy_stats.contains_key("recall"));
1855        assert!(metrics.relevancy_stats.contains_key("precision"));
1856        assert!(!metrics.relevancy_stats.contains_key("f1"));
1857
1858        // The F64Stats labels carry k and r.
1859        let recall_labels = metrics.relevancy_stats["recall"].labels();
1860        assert_eq!(recall_labels.get("k"), Some("10"));
1861        assert_eq!(recall_labels.get("r"), Some("20"));
1862
1863        metrics.record_relevancy("recall", 0.85);
1864        metrics.record_relevancy("recall", 0.90);
1865        let snap = metrics.relevancy_stats["recall"].snapshot();
1866        assert_eq!(snap.len(), 2);
1867    }
1868
1869    #[test]
1870    fn validation_metrics_r_defaults_to_k() {
1871        let labels = Labels::of("activity", "test");
1872        // No `r:` in the relevancy config → the metric's
1873        // `r` label equals `k` (legacy first-k semantics).
1874        let metrics = ValidationMetrics::new(&labels, &[RelevancyFn::Recall], 100, None);
1875        let l = metrics.relevancy_stats["recall"].labels();
1876        assert_eq!(l.get("k"), Some("100"));
1877        assert_eq!(l.get("r"), Some("100"));
1878    }
1879
1880    #[test]
1881    fn resolve_expected_string_array_form() {
1882        let v = polydat::ast::Value::Str("[1, 5, 12, 23]".into());
1883        assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12, 23]);
1884    }
1885
1886    #[test]
1887    fn resolve_expected_string_csv_form() {
1888        let v = polydat::ast::Value::Str("1,5,12".into());
1889        assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12]);
1890    }
1891
1892    #[test]
1893    fn resolve_expected_native_veci32_fast_path() {
1894        // The fast path the dataset accessors emit
1895        // (`neighbor_indices_at` etc. → Value::VecI32). No
1896        // string round-trip happens; the slice is read directly.
1897        use polydat::ast::{SliceArc, Value};
1898        let slice = SliceArc::<i32>::from_vec(vec![1, 5, 12, 23, 100]);
1899        let v = Value::VecI32(slice);
1900        assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12, 23, 100]);
1901    }
1902
1903    #[test]
1904    fn resolve_expected_native_vecf32_fast_path() {
1905        // VecF32 ground truth (rare but legal — some datasets
1906        // store ranks as floats). Truncates to i64.
1907        use polydat::ast::{SliceArc, Value};
1908        let slice = SliceArc::<f32>::from_vec(vec![1.0, 2.0, 3.0]);
1909        let v = Value::VecF32(slice);
1910        assert_eq!(resolve_expected_from_value(&v), vec![1, 2, 3]);
1911    }
1912
1913    #[test]
1914    fn parse_relevancy_from_params() {
1915        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1916        template.params.insert(
1917            "relevancy".into(),
1918            serde_json::json!({
1919                "actual": "key",
1920                "expected": "{ground_truth}",
1921                "k": 10,
1922                "functions": ["recall", "precision", "f1"]
1923            }),
1924        );
1925        let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1926        assert_eq!(config.actual_field, "key");
1927        assert_eq!(config.expected_binding, "{ground_truth}");
1928        assert_eq!(config.k, 10);
1929        assert!(config.r.is_none(), "r should default to None when absent");
1930        assert_eq!(config.functions.len(), 3);
1931        assert_eq!(config.functions[0], RelevancyFn::Recall);
1932        assert_eq!(config.functions[1], RelevancyFn::Precision);
1933        assert_eq!(config.functions[2], RelevancyFn::F1);
1934    }
1935
1936    #[test]
1937    fn parse_relevancy_with_r_for_k_recall_at_r() {
1938        // 10-recall@100: retrieve 100, score against the first
1939        // 10. The `r` field is parsed alongside `k` from the
1940        // same numeric/`{name}` shapes.
1941        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1942        template.params.insert(
1943            "relevancy".into(),
1944            serde_json::json!({
1945                "actual": "key",
1946                "expected": "{ground_truth}",
1947                "k": 10,
1948                "r": 100,
1949                "functions": ["recall"],
1950            }),
1951        );
1952        let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1953        assert_eq!(config.k, 10);
1954        assert_eq!(config.r, Some(100));
1955    }
1956
1957    #[test]
1958    fn parse_relevancy_r_accepts_string_form() {
1959        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1960        template.params.insert(
1961            "relevancy".into(),
1962            serde_json::json!({
1963                "actual": "key",
1964                "expected": "{ground_truth}",
1965                "k": "10",
1966                "r": "100",
1967            }),
1968        );
1969        let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1970        assert_eq!(config.k, 10);
1971        assert_eq!(config.r, Some(100));
1972    }
1973
1974    #[test]
1975    fn parse_relevancy_missing() {
1976        let template = nmbrs_workload::model::ParsedOp::simple("test", "INSERT");
1977        assert!(parse_relevancy(&template, None, None).unwrap().is_none());
1978    }
1979
1980    #[test]
1981    fn parse_relevancy_k_and_r_accept_bare_wire_names() {
1982        // Post-SRD-68 follow-up: `k: k` (bare wire-name) resolves
1983        // against the canonical kernel at wrap time. Same one-shot
1984        // evaluation as the `{k}` text-template form but without
1985        // the placeholder braces.
1986        let kernel = crate::scope_kernel::ScopeKernel::compile(
1987            "input cycle: u64\n\
1988             const k := 10\n\
1989             const limit := 100\n",
1990        )
1991        .expect("compile scope kernel wires");
1992        let wires: &dyn WireSource = &kernel;
1993
1994        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1995        template.params.insert(
1996            "relevancy".into(),
1997            serde_json::json!({
1998                "actual": "key",
1999                "expected": "{ground_truth}",
2000                "k": "k",            // bare wire-name
2001                "r": "limit",        // bare wire-name
2002                "functions": ["recall"],
2003            }),
2004        );
2005        let config = parse_relevancy(&template, None, Some(wires))
2006            .unwrap()
2007            .unwrap();
2008        assert_eq!(config.k, 10, "bare `k:` resolved through wires");
2009        assert_eq!(config.r, Some(100), "bare `r:` resolved through wires");
2010    }
2011
2012    #[test]
2013    fn parse_relevancy_bare_name_falls_through_to_int_parse_when_no_kernel() {
2014        // Without a canonical kernel (no wires available), a bare
2015        // identifier in `k:` errors clearly — it can't be a wire
2016        // reference and isn't a valid integer either.
2017        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
2018        template.params.insert(
2019            "relevancy".into(),
2020            serde_json::json!({
2021                "actual": "key",
2022                "expected": "{ground_truth}",
2023                "k": "k",
2024                "functions": ["recall"],
2025            }),
2026        );
2027        let err = parse_relevancy(&template, None, None).unwrap_err();
2028        assert!(
2029            err.contains("'k' is not a valid non-negative integer"),
2030            "diagnostic should describe the parse failure: {err}"
2031        );
2032    }
2033
2034    /// A succeeding op that returns NO body must not be failed by a value
2035    /// predicate. `on_timeout: accept` (the server is still working; the poll
2036    /// layer observes it) and HTTP 204 both land here, and a
2037    /// `field: status, eq: 200` clause written for the normal response was
2038    /// failing them — it cost a live benchmark run at tier 6.
2039    #[test]
2040    fn value_predicates_are_vacuous_without_a_body() {
2041        let result = OpResult {
2042            body: None,
2043            ..Default::default()
2044        };
2045        for predicate in [
2046            AssertionPredicate::Eq("200".into()),
2047            AssertionPredicate::Lte(5.0),
2048            AssertionPredicate::Gte(1.0),
2049            AssertionPredicate::Contains("ok".into()),
2050            AssertionPredicate::IsNull,
2051        ] {
2052            let spec = AssertionSpec {
2053                field: "status".into(),
2054                predicate,
2055            };
2056            assert!(
2057                spec.check(&result),
2058                "a value predicate has nothing to contradict it: {:?}",
2059                spec.predicate
2060            );
2061        }
2062    }
2063
2064    /// Presence predicates are the explicit "a body is required" spelling, so
2065    /// they must still fail when none arrives — otherwise there would be no
2066    /// way to demand one.
2067    #[test]
2068    fn presence_predicates_still_fail_without_a_body() {
2069        let result = OpResult {
2070            body: None,
2071            ..Default::default()
2072        };
2073        let not_null = AssertionSpec {
2074            field: "status".into(),
2075            predicate: AssertionPredicate::NotNull,
2076        };
2077        assert!(
2078            !not_null.check(&result),
2079            "`is: not_null` is how an author demands the field exist"
2080        );
2081        let min_rows = AssertionSpec {
2082            field: String::new(),
2083            predicate: AssertionPredicate::MinRows(1),
2084        };
2085        assert!(
2086            !min_rows.check(&result),
2087            "`min_rows: 1` is how an author demands a non-empty result"
2088        );
2089    }
2090
2091    /// A malformed bound can never pass, body or no body — otherwise a typo
2092    /// would turn into a silently-skipped check on exactly the ops that
2093    /// return nothing.
2094    #[test]
2095    fn malformed_bounds_fail_even_without_a_body() {
2096        let result = OpResult {
2097            body: None,
2098            ..Default::default()
2099        };
2100        let spec = AssertionSpec {
2101            field: "value".into(),
2102            predicate: AssertionPredicate::MalformedBound {
2103                key: "lte".into(),
2104                raw: "{unresolved}".into(),
2105            },
2106        };
2107        assert!(!spec.check(&result));
2108    }
2109
2110    /// A quoted numeric bound means the same as an unquoted one. YAML makes
2111    /// the string form easy to reach — quoting, or a `{placeholder}` that
2112    /// substitutes to text — and it used to silently become `<= 0`, failing
2113    /// good data against a threshold nobody wrote. Cost a live benchmark run.
2114    #[test]
2115    fn numeric_bounds_accept_quoted_numbers() {
2116        let mut template = nmbrs_workload::model::ParsedOp::simple("t", "noop");
2117        template.params.insert(
2118            "verify".into(),
2119            serde_json::json!([
2120                {"field": "value", "lte": "5"},
2121                {"field": "value", "gte": " 2 "},
2122                {"field": "other", "lte": 7},
2123            ]),
2124        );
2125        let a = parse_assertions(&template);
2126        assert!(
2127            matches!(a[0].predicate, AssertionPredicate::Lte(t) if t == 5.0),
2128            "quoted lte must parse: {:?}",
2129            a[0].predicate
2130        );
2131        assert!(
2132            matches!(a[1].predicate, AssertionPredicate::Gte(t) if t == 2.0),
2133            "surrounding whitespace is not a malformed bound: {:?}",
2134            a[1].predicate
2135        );
2136        assert!(
2137            matches!(a[2].predicate, AssertionPredicate::Lte(t) if t == 7.0),
2138            "the unquoted form is unchanged: {:?}",
2139            a[2].predicate
2140        );
2141    }
2142
2143    /// A bound that is not a number at all must FAIL LOUDLY and name itself —
2144    /// never default to zero, which reads as a real threshold in the failure
2145    /// message and sends the reader hunting for a data problem that does not
2146    /// exist.
2147    #[test]
2148    fn non_numeric_bounds_are_malformed_not_zero() {
2149        let mut template = nmbrs_workload::model::ParsedOp::simple("t", "noop");
2150        template.params.insert(
2151            "verify".into(),
2152            serde_json::json!([
2153                {"field": "value", "lte": "{unresolved}"},
2154                {"field": "value", "gte": "abc"},
2155            ]),
2156        );
2157        let a = parse_assertions(&template);
2158        for spec in &a {
2159            match &spec.predicate {
2160                AssertionPredicate::MalformedBound { raw, .. } => {
2161                    assert!(
2162                        !raw.is_empty(),
2163                        "the offending text is carried for the message"
2164                    );
2165                }
2166                other => panic!("expected MalformedBound, got {other:?}"),
2167            }
2168        }
2169        // And the rendered failure names the malformed bound rather than
2170        // quoting a threshold the author never wrote.
2171        let msg = format!("{:?}", a[0].predicate);
2172        assert!(
2173            msg.contains("MalformedBound"),
2174            "the predicate stays malformed all the way to reporting: {msg}"
2175        );
2176    }
2177
2178    #[test]
2179    fn parse_assertions_from_params() {
2180        let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT");
2181        template.params.insert(
2182            "verify".into(),
2183            serde_json::json!([
2184                {"field": "name", "is": "not_null"},
2185                {"field": "balance", "gte": 0},
2186                {"field": "status", "eq": "active"},
2187            ]),
2188        );
2189        let assertions = parse_assertions(&template);
2190        assert_eq!(assertions.len(), 3);
2191        assert_eq!(assertions[0].field, "name");
2192        assert!(matches!(
2193            assertions[0].predicate,
2194            AssertionPredicate::NotNull
2195        ));
2196        assert_eq!(assertions[1].field, "balance");
2197        assert!(matches!(assertions[1].predicate, AssertionPredicate::Gte(v) if v == 0.0));
2198        assert_eq!(assertions[2].field, "status");
2199        assert!(matches!(&assertions[2].predicate, AssertionPredicate::Eq(s) if s == "active"));
2200    }
2201
2202    #[test]
2203    fn parse_assertions_missing() {
2204        let template = nmbrs_workload::model::ParsedOp::simple("test", "INSERT");
2205        assert!(parse_assertions(&template).is_empty());
2206    }
2207}