1use 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
24pub const WRAPPER_NAME: WrapperName = WrapperName::new("validate");
29
30fn 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
82pub const CORE_OP_PARAMS: &[&str] = &[
110 "batch",
112 "batchtype",
113 "max_batch_size",
114 "ratio",
116 "adapter",
118 "driver",
119 "daemon",
125 "daemon_cancel_grace_ms",
126];
127
128#[derive(Debug, Clone)]
130pub struct RelevancyConfig {
131 pub actual_field: String,
133 pub expected_binding: String,
135 pub k: usize,
139 pub r: Option<usize>,
147 pub functions: Vec<RelevancyFn>,
149}
150
151#[derive(Debug, Clone)]
153pub struct AssertionSpec {
154 pub field: String,
156 pub predicate: AssertionPredicate,
158}
159
160#[derive(Debug, Clone)]
162pub enum AssertionPredicate {
163 Eq(String),
165 NotNull,
167 IsNull,
169 Gte(f64),
171 Lte(f64),
173 Contains(String),
175 MalformedBound { key: String, raw: String },
183 MinRows(u64),
189}
190
191impl AssertionSpec {
192 pub fn check(&self, result: &OpResult) -> bool {
194 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 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
251pub struct RunningAgg {
259 pub total_sum: f64,
261 pub total_count: u64,
263 pub window: std::collections::VecDeque<f64>,
265 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
305pub const DEFAULT_RECALL_WINDOW: usize = 10;
308
309#[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
320pub struct ValidationMetrics {
322 pub validations_passed: AtomicU64,
323 pub validations_failed: AtomicU64,
324 pub relevancy_stats: HashMap<String, nmbrs_metrics::summaries::f64stats::F64Stats>,
327 pub running_aggregates: HashMap<String, std::sync::Mutex<RunningAgg>>,
331}
332
333impl ValidationMetrics {
334 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 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 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 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 pub fn passed(&self) -> u64 {
415 self.validations_passed.load(Ordering::Relaxed)
416 }
417
418 pub fn failed(&self) -> u64 {
420 self.validations_failed.load(Ordering::Relaxed)
421 }
422}
423
424pub struct ValidatingDispenser {
433 inner: Arc<dyn OpDispenser>,
434 assertions: Vec<AssertionSpec>,
435 relevancy: Option<RelevancyConfig>,
436 expected_wire_name: Option<String>,
445 actual_projection: Option<ActualProjection>,
452 metrics: Arc<ValidationMetrics>,
453 strict: bool,
455}
456
457struct ActualProjection {
461 segs: Vec<crate::wrappers::result::PathSeg>,
462 target: Option<polydat::ast::PortType>,
463}
464
465type WrappedDispenser = (Arc<dyn OpDispenser>, Option<Arc<ValidationMetrics>>);
469
470impl ValidatingDispenser {
471 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 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 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 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 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 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 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 if let Some(config) = &self.relevancy {
652 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 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 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 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 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 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 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
839fn 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
900fn 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 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
965fn 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
977fn 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
988fn 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 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(), 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
1088const RELEVANCY_VOCAB: &[&str] = &["actual", "expected", "k", "r", "functions"];
1093
1094fn 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 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
1240fn 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 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
1313fn 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
1325fn value_to_u64_for_count(value: polydat::ast::Value) -> Option<u64> {
1329 use polydat::ast::Value;
1330 match value {
1331 Value::U64(n) => Some(n),
1332 Value::F64(f) if f.is_finite() && f >= 0.0 => Some(f as u64),
1333 Value::Bool(true) => Some(1),
1334 Value::Bool(false) => Some(0),
1335 _ => None,
1336 }
1337}
1338
1339fn extract_actual_indices(result: &OpResult, field: &str) -> Vec<i64> {
1347 let Some(body) = &result.body else {
1348 return Vec::new();
1349 };
1350 extract_indices_from_json(&body.to_json(), field)
1351}
1352
1353fn extract_indices_from_json(json: &serde_json::Value, field: &str) -> Vec<i64> {
1355 match json {
1356 serde_json::Value::Array(rows) => rows
1357 .iter()
1358 .filter_map(|row| json_field_as_i64(row.get(field)?))
1359 .collect(),
1360 serde_json::Value::Object(obj) => {
1361 if let Some(rows) = obj.get("rows") {
1362 return extract_indices_from_json(rows, field);
1363 }
1364 obj.get(field)
1365 .and_then(json_field_as_i64)
1366 .into_iter()
1367 .collect()
1368 }
1369 _ => Vec::new(),
1370 }
1371}
1372
1373fn json_field_as_i64(v: &serde_json::Value) -> Option<i64> {
1375 v.as_i64().or_else(|| v.as_str()?.parse().ok())
1376}
1377
1378fn resolve_expected_from_value(value: &polydat::ast::Value) -> Vec<i64> {
1394 match value {
1395 polydat::ast::Value::VecI32(slice) => slice.as_slice().iter().map(|&x| x as i64).collect(),
1397 polydat::ast::Value::VecI64(slice) => slice.as_slice().to_vec(),
1398 polydat::ast::Value::VecF32(slice) => {
1399 slice.as_slice().iter().map(|&x| x as i64).collect()
1404 }
1405 polydat::ast::Value::VecF64(slice) => slice.as_slice().iter().map(|&x| x as i64).collect(),
1406 polydat::ast::Value::Json(j) => match &**j {
1410 serde_json::Value::Array(elems) => elems.iter().filter_map(json_field_as_i64).collect(),
1411 other => json_field_as_i64(other).into_iter().collect(),
1412 },
1413 polydat::ast::Value::Str(s) => parse_int_array(s),
1414 polydat::ast::Value::U64(v) => vec![*v as i64],
1415 _ => {
1416 let s = value.to_display_string();
1418 parse_int_array(&s)
1419 }
1420 }
1421}
1422
1423fn parse_int_array(s: &str) -> Vec<i64> {
1427 let trimmed = s.trim().trim_start_matches('[').trim_end_matches(']');
1428 trimmed
1429 .split(|c: char| c == ',' || c.is_whitespace())
1430 .filter(|s| !s.is_empty())
1431 .filter_map(|s| s.trim().parse::<i64>().ok())
1432 .collect()
1433}
1434
1435fn extract_field_from_json<'a>(
1437 json: &'a serde_json::Value,
1438 field: &str,
1439) -> Option<&'a serde_json::Value> {
1440 match json {
1441 serde_json::Value::Object(obj) => obj.get(field).or_else(|| {
1442 obj.get("rows")
1443 .and_then(|r| r.as_array())
1444 .and_then(|rows| rows.first())
1445 .and_then(|row| row.get(field))
1446 }),
1447 serde_json::Value::Array(rows) => rows.first().and_then(|row| row.get(field)),
1448 _ => None,
1449 }
1450}
1451
1452fn json_value_as_string(v: &serde_json::Value) -> String {
1454 match v {
1455 serde_json::Value::String(s) => s.clone(),
1456 other => other.to_string(),
1457 }
1458}
1459
1460#[cfg(test)]
1461mod tests {
1462 use super::*;
1463 use crate::adapter::ResultBody;
1464 use std::any::Any;
1465
1466 #[derive(Debug)]
1467 struct JsonBody(serde_json::Value);
1468 impl ResultBody for JsonBody {
1469 fn to_json(&self) -> serde_json::Value {
1470 self.0.clone()
1471 }
1472 fn as_any(&self) -> &dyn Any {
1473 self
1474 }
1475 }
1476
1477 #[test]
1478 fn parse_int_array_bracket_format() {
1479 assert_eq!(parse_int_array("[1, 5, 12, 23]"), vec![1, 5, 12, 23]);
1480 }
1481
1482 #[test]
1489 fn core_op_params_disjoint_from_owned_fields() {
1490 let registry = crate::wrapper_registry::WrapperRegistry::from_inventory();
1491 let owned = registry.all_owned_fields();
1492 let dupes: Vec<&str> = CORE_OP_PARAMS
1493 .iter()
1494 .copied()
1495 .filter(|p| owned.contains(p))
1496 .collect();
1497 assert!(
1498 dupes.is_empty(),
1499 "these CORE_OP_PARAMS are already wrapper-owned (remove them — \
1500 the guard accepts them via WrapperRegistry::owns_field): {dupes:?}",
1501 );
1502 }
1503
1504 #[test]
1510 fn wrapper_field_accepted_without_cli_or_core_membership() {
1511 let registry = crate::wrapper_registry::WrapperRegistry::from_inventory();
1512 assert!(
1513 registry.owns_field("readout"),
1514 "readout must be registry-owned"
1515 );
1516 assert!(
1517 registry.owns_field("errors"),
1518 "errors must be registry-owned (was riding the CLI hatch)"
1519 );
1520 assert!(
1521 registry.owns_field("tries"),
1522 "tries must be registry-owned (was riding the CLI hatch)"
1523 );
1524 assert!(
1525 !CORE_OP_PARAMS.contains(&"readout"),
1526 "readout should NOT be in CORE_OP_PARAMS — it's wrapper-owned"
1527 );
1528 }
1529
1530 #[test]
1531 fn parse_int_array_comma_format() {
1532 assert_eq!(parse_int_array("1,5,12,23"), vec![1, 5, 12, 23]);
1533 }
1534
1535 #[test]
1536 fn parse_int_array_space_format() {
1537 assert_eq!(parse_int_array("1 5 12 23"), vec![1, 5, 12, 23]);
1538 }
1539
1540 #[test]
1541 fn parse_int_array_empty() {
1542 assert_eq!(parse_int_array("[]"), Vec::<i64>::new());
1543 assert_eq!(parse_int_array(""), Vec::<i64>::new());
1544 }
1545
1546 #[test]
1547 fn extract_indices_from_json_array() {
1548 let json = serde_json::json!([
1549 {"key": 5, "distance": 0.1},
1550 {"key": 12, "distance": 0.2},
1551 {"key": 3, "distance": 0.3},
1552 ]);
1553 assert_eq!(extract_indices_from_json(&json, "key"), vec![5, 12, 3]);
1554 }
1555
1556 #[test]
1557 fn extract_indices_from_json_rows_wrapper() {
1558 let json = serde_json::json!({
1559 "rows": [
1560 {"key": 5},
1561 {"key": 12},
1562 ]
1563 });
1564 assert_eq!(extract_indices_from_json(&json, "key"), vec![5, 12]);
1565 }
1566
1567 #[test]
1568 fn assertion_not_null() {
1569 let result = OpResult {
1570 body: Some(Box::new(JsonBody(serde_json::json!({"name": "alice"})))),
1571 skipped: false,
1572 };
1573 let spec = AssertionSpec {
1574 field: "name".into(),
1575 predicate: AssertionPredicate::NotNull,
1576 };
1577 assert!(spec.check(&result));
1578
1579 let spec_missing = AssertionSpec {
1580 field: "age".into(),
1581 predicate: AssertionPredicate::NotNull,
1582 };
1583 assert!(!spec_missing.check(&result));
1584 }
1585
1586 #[test]
1587 fn assertion_eq() {
1588 let result = OpResult {
1589 body: Some(Box::new(JsonBody(serde_json::json!({"status": "ok"})))),
1590 skipped: false,
1591 };
1592 let spec = AssertionSpec {
1593 field: "status".into(),
1594 predicate: AssertionPredicate::Eq("ok".into()),
1595 };
1596 assert!(spec.check(&result));
1597
1598 let spec_fail = AssertionSpec {
1599 field: "status".into(),
1600 predicate: AssertionPredicate::Eq("error".into()),
1601 };
1602 assert!(!spec_fail.check(&result));
1603 }
1604
1605 #[test]
1606 fn assertion_gte() {
1607 let result = OpResult {
1608 body: Some(Box::new(JsonBody(serde_json::json!({"balance": 42.5})))),
1609 skipped: false,
1610 };
1611 let spec = AssertionSpec {
1612 field: "balance".into(),
1613 predicate: AssertionPredicate::Gte(0.0),
1614 };
1615 assert!(spec.check(&result));
1616
1617 let spec_fail = AssertionSpec {
1618 field: "balance".into(),
1619 predicate: AssertionPredicate::Gte(100.0),
1620 };
1621 assert!(!spec_fail.check(&result));
1622 }
1623
1624 #[test]
1625 fn assertion_no_body() {
1626 let result = OpResult {
1627 body: None,
1628 skipped: false,
1629 };
1630 let spec = AssertionSpec {
1631 field: "anything".into(),
1632 predicate: AssertionPredicate::IsNull,
1633 };
1634 assert!(spec.check(&result));
1635
1636 let spec_not_null = AssertionSpec {
1637 field: "anything".into(),
1638 predicate: AssertionPredicate::NotNull,
1639 };
1640 assert!(!spec_not_null.check(&result));
1641 }
1642
1643 #[derive(Debug)]
1648 struct CountedBody {
1649 rows: Vec<serde_json::Value>,
1650 }
1651 impl ResultBody for CountedBody {
1652 fn to_json(&self) -> serde_json::Value {
1653 serde_json::Value::Array(self.rows.clone())
1654 }
1655 fn as_any(&self) -> &dyn Any {
1656 self
1657 }
1658 fn element_count(&self) -> u64 {
1659 self.rows.len() as u64
1660 }
1661 }
1662
1663 #[test]
1664 fn assertion_min_rows_passes_when_threshold_met() {
1665 let result = OpResult {
1666 body: Some(Box::new(CountedBody {
1667 rows: vec![
1668 serde_json::json!({"index_name": "vec_idx"}),
1669 serde_json::json!({"index_name": "meta_idx"}),
1670 ],
1671 })),
1672 skipped: false,
1673 };
1674 let spec = AssertionSpec {
1675 field: String::new(),
1676 predicate: AssertionPredicate::MinRows(1),
1677 };
1678 assert!(spec.check(&result));
1679
1680 let spec_two = AssertionSpec {
1681 field: String::new(),
1682 predicate: AssertionPredicate::MinRows(2),
1683 };
1684 assert!(spec_two.check(&result));
1685 }
1686
1687 #[test]
1688 fn assertion_min_rows_fails_when_below_threshold() {
1689 let result = OpResult {
1691 body: Some(Box::new(CountedBody { rows: Vec::new() })),
1692 skipped: false,
1693 };
1694 let spec = AssertionSpec {
1695 field: String::new(),
1696 predicate: AssertionPredicate::MinRows(1),
1697 };
1698 assert!(!spec.check(&result));
1699
1700 let result_none = OpResult {
1702 body: None,
1703 skipped: false,
1704 };
1705 assert!(!spec.check(&result_none));
1706 }
1707
1708 #[test]
1709 fn parse_assertions_min_rows_from_yaml() {
1710 let mut template = nmbrs_workload::model::ParsedOp::simple("await", "test");
1714 template.params.insert(
1715 "verify".into(),
1716 serde_json::json!([
1717 {"min_rows": 1},
1718 ]),
1719 );
1720 let assertions = parse_assertions(&template);
1721 assert_eq!(assertions.len(), 1);
1722 match &assertions[0].predicate {
1723 AssertionPredicate::MinRows(n) => assert_eq!(*n, 1),
1724 other => panic!("expected MinRows(1), got {other:?}"),
1725 }
1726 }
1727
1728 #[test]
1729 fn eq_failure_includes_body_and_distinguishes_absent_vs_not_json() {
1730 let result = OpResult {
1735 body: Some(Box::new(JsonBody(serde_json::json!({
1736 "value": null, "request": {"type": "exec"}
1737 })))),
1738 skipped: false,
1739 };
1740 let spec = AssertionSpec {
1741 field: "status".into(),
1742 predicate: AssertionPredicate::Eq("200".into()),
1743 };
1744 let msg = describe_assertion_failure(&spec, &result);
1745 assert!(
1746 msg.contains("field absent"),
1747 "json-without-field should mark observed as absent, got: {msg}"
1748 );
1749 assert!(
1750 msg.contains("body keys"),
1751 "absent message should enumerate present keys, got: {msg}"
1752 );
1753 assert!(
1754 msg.contains("\"value\"") && msg.contains("\"request\""),
1755 "key list should include both present keys, got: {msg}"
1756 );
1757 assert!(msg.contains("body: "), "body excerpt missing: {msg}");
1758 assert!(
1759 msg.contains("\"request\""),
1760 "body excerpt should echo the actual JSON: {msg}"
1761 );
1762
1763 #[derive(Debug)]
1767 struct PlainBody(String);
1768 impl ResultBody for PlainBody {
1769 fn to_json(&self) -> serde_json::Value {
1770 serde_json::Value::String(self.0.clone())
1771 }
1772 fn as_any(&self) -> &dyn Any {
1773 self
1774 }
1775 fn to_text(&self) -> String {
1776 self.0.clone()
1777 }
1778 }
1779 let text_result = OpResult {
1780 body: Some(Box::new(PlainBody(
1781 "<html><body>404 Not Found</body></html>".into(),
1782 ))),
1783 skipped: false,
1784 };
1785 let msg2 = describe_assertion_failure(&spec, &text_result);
1786 assert!(
1787 msg2.contains("not-json"),
1788 "text body should mark observed as not-json, got: {msg2}"
1789 );
1790 assert!(
1791 msg2.contains("404 Not Found"),
1792 "body excerpt should include the text: {msg2}"
1793 );
1794 }
1795
1796 #[test]
1801 fn eq_failure_with_no_body_explains_situation() {
1802 let result = OpResult {
1803 body: None,
1804 skipped: false,
1805 };
1806 let spec = AssertionSpec {
1807 field: "status".into(),
1808 predicate: AssertionPredicate::Eq("200".into()),
1809 };
1810 let msg = describe_assertion_failure(&spec, &result);
1811 assert!(
1812 msg.contains("no body returned by op"),
1813 "no-body case should explain why the field can't be read, got: {msg}"
1814 );
1815 assert!(
1816 !msg.contains("body: "),
1817 "no-body case should suppress the redundant body excerpt, got: {msg}"
1818 );
1819 }
1820
1821 #[test]
1822 fn min_rows_failure_describes_actual_vs_expected() {
1823 let result = OpResult {
1827 body: Some(Box::new(CountedBody { rows: Vec::new() })),
1828 skipped: false,
1829 };
1830 let spec = AssertionSpec {
1831 field: String::new(),
1832 predicate: AssertionPredicate::MinRows(1),
1833 };
1834 let msg = describe_assertion_failure(&spec, &result);
1835 assert!(msg.contains("min_rows"), "got: {msg}");
1836 assert!(msg.contains("≥1"), "got: {msg}");
1837 assert!(msg.contains("got 0"), "got: {msg}");
1838 }
1839
1840 #[test]
1841 fn validation_metrics_record_relevancy() {
1842 let labels = Labels::of("activity", "test");
1843 let metrics = ValidationMetrics::new(
1844 &labels,
1845 &[RelevancyFn::Recall, RelevancyFn::Precision],
1846 10,
1847 Some(20),
1848 );
1849 assert!(metrics.relevancy_stats.contains_key("recall"));
1853 assert!(metrics.relevancy_stats.contains_key("precision"));
1854 assert!(!metrics.relevancy_stats.contains_key("f1"));
1855
1856 let recall_labels = metrics.relevancy_stats["recall"].labels();
1858 assert_eq!(recall_labels.get("k"), Some("10"));
1859 assert_eq!(recall_labels.get("r"), Some("20"));
1860
1861 metrics.record_relevancy("recall", 0.85);
1862 metrics.record_relevancy("recall", 0.90);
1863 let snap = metrics.relevancy_stats["recall"].snapshot();
1864 assert_eq!(snap.len(), 2);
1865 }
1866
1867 #[test]
1868 fn validation_metrics_r_defaults_to_k() {
1869 let labels = Labels::of("activity", "test");
1870 let metrics = ValidationMetrics::new(&labels, &[RelevancyFn::Recall], 100, None);
1873 let l = metrics.relevancy_stats["recall"].labels();
1874 assert_eq!(l.get("k"), Some("100"));
1875 assert_eq!(l.get("r"), Some("100"));
1876 }
1877
1878 #[test]
1879 fn resolve_expected_string_array_form() {
1880 let v = polydat::ast::Value::Str("[1, 5, 12, 23]".into());
1881 assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12, 23]);
1882 }
1883
1884 #[test]
1885 fn resolve_expected_string_csv_form() {
1886 let v = polydat::ast::Value::Str("1,5,12".into());
1887 assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12]);
1888 }
1889
1890 #[test]
1891 fn resolve_expected_native_veci32_fast_path() {
1892 use polydat::ast::{SliceArc, Value};
1896 let slice = SliceArc::<i32>::from_vec(vec![1, 5, 12, 23, 100]);
1897 let v = Value::VecI32(slice);
1898 assert_eq!(resolve_expected_from_value(&v), vec![1, 5, 12, 23, 100]);
1899 }
1900
1901 #[test]
1902 fn resolve_expected_native_vecf32_fast_path() {
1903 use polydat::ast::{SliceArc, Value};
1906 let slice = SliceArc::<f32>::from_vec(vec![1.0, 2.0, 3.0]);
1907 let v = Value::VecF32(slice);
1908 assert_eq!(resolve_expected_from_value(&v), vec![1, 2, 3]);
1909 }
1910
1911 #[test]
1912 fn parse_relevancy_from_params() {
1913 let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1914 template.params.insert(
1915 "relevancy".into(),
1916 serde_json::json!({
1917 "actual": "key",
1918 "expected": "{ground_truth}",
1919 "k": 10,
1920 "functions": ["recall", "precision", "f1"]
1921 }),
1922 );
1923 let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1924 assert_eq!(config.actual_field, "key");
1925 assert_eq!(config.expected_binding, "{ground_truth}");
1926 assert_eq!(config.k, 10);
1927 assert!(config.r.is_none(), "r should default to None when absent");
1928 assert_eq!(config.functions.len(), 3);
1929 assert_eq!(config.functions[0], RelevancyFn::Recall);
1930 assert_eq!(config.functions[1], RelevancyFn::Precision);
1931 assert_eq!(config.functions[2], RelevancyFn::F1);
1932 }
1933
1934 #[test]
1935 fn parse_relevancy_with_r_for_k_recall_at_r() {
1936 let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1940 template.params.insert(
1941 "relevancy".into(),
1942 serde_json::json!({
1943 "actual": "key",
1944 "expected": "{ground_truth}",
1945 "k": 10,
1946 "r": 100,
1947 "functions": ["recall"],
1948 }),
1949 );
1950 let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1951 assert_eq!(config.k, 10);
1952 assert_eq!(config.r, Some(100));
1953 }
1954
1955 #[test]
1956 fn parse_relevancy_r_accepts_string_form() {
1957 let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1958 template.params.insert(
1959 "relevancy".into(),
1960 serde_json::json!({
1961 "actual": "key",
1962 "expected": "{ground_truth}",
1963 "k": "10",
1964 "r": "100",
1965 }),
1966 );
1967 let config = parse_relevancy(&template, None, None).unwrap().unwrap();
1968 assert_eq!(config.k, 10);
1969 assert_eq!(config.r, Some(100));
1970 }
1971
1972 #[test]
1973 fn parse_relevancy_missing() {
1974 let template = nmbrs_workload::model::ParsedOp::simple("test", "INSERT");
1975 assert!(parse_relevancy(&template, None, None).unwrap().is_none());
1976 }
1977
1978 #[test]
1979 fn parse_relevancy_k_and_r_accept_bare_wire_names() {
1980 let kernel = crate::scope_kernel::ScopeKernel::compile(
1985 "input cycle: u64\n\
1986 const k := 10\n\
1987 const limit := 100\n",
1988 )
1989 .expect("compile scope kernel wires");
1990 let wires: &dyn WireSource = &kernel;
1991
1992 let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
1993 template.params.insert(
1994 "relevancy".into(),
1995 serde_json::json!({
1996 "actual": "key",
1997 "expected": "{ground_truth}",
1998 "k": "k", "r": "limit", "functions": ["recall"],
2001 }),
2002 );
2003 let config = parse_relevancy(&template, None, Some(wires))
2004 .unwrap()
2005 .unwrap();
2006 assert_eq!(config.k, 10, "bare `k:` resolved through wires");
2007 assert_eq!(config.r, Some(100), "bare `r:` resolved through wires");
2008 }
2009
2010 #[test]
2011 fn parse_relevancy_bare_name_falls_through_to_int_parse_when_no_kernel() {
2012 let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT key FROM t");
2016 template.params.insert(
2017 "relevancy".into(),
2018 serde_json::json!({
2019 "actual": "key",
2020 "expected": "{ground_truth}",
2021 "k": "k",
2022 "functions": ["recall"],
2023 }),
2024 );
2025 let err = parse_relevancy(&template, None, None).unwrap_err();
2026 assert!(
2027 err.contains("'k' is not a valid non-negative integer"),
2028 "diagnostic should describe the parse failure: {err}"
2029 );
2030 }
2031
2032 #[test]
2038 fn value_predicates_are_vacuous_without_a_body() {
2039 let result = OpResult {
2040 body: None,
2041 ..Default::default()
2042 };
2043 for predicate in [
2044 AssertionPredicate::Eq("200".into()),
2045 AssertionPredicate::Lte(5.0),
2046 AssertionPredicate::Gte(1.0),
2047 AssertionPredicate::Contains("ok".into()),
2048 AssertionPredicate::IsNull,
2049 ] {
2050 let spec = AssertionSpec {
2051 field: "status".into(),
2052 predicate,
2053 };
2054 assert!(
2055 spec.check(&result),
2056 "a value predicate has nothing to contradict it: {:?}",
2057 spec.predicate
2058 );
2059 }
2060 }
2061
2062 #[test]
2066 fn presence_predicates_still_fail_without_a_body() {
2067 let result = OpResult {
2068 body: None,
2069 ..Default::default()
2070 };
2071 let not_null = AssertionSpec {
2072 field: "status".into(),
2073 predicate: AssertionPredicate::NotNull,
2074 };
2075 assert!(
2076 !not_null.check(&result),
2077 "`is: not_null` is how an author demands the field exist"
2078 );
2079 let min_rows = AssertionSpec {
2080 field: String::new(),
2081 predicate: AssertionPredicate::MinRows(1),
2082 };
2083 assert!(
2084 !min_rows.check(&result),
2085 "`min_rows: 1` is how an author demands a non-empty result"
2086 );
2087 }
2088
2089 #[test]
2093 fn malformed_bounds_fail_even_without_a_body() {
2094 let result = OpResult {
2095 body: None,
2096 ..Default::default()
2097 };
2098 let spec = AssertionSpec {
2099 field: "value".into(),
2100 predicate: AssertionPredicate::MalformedBound {
2101 key: "lte".into(),
2102 raw: "{unresolved}".into(),
2103 },
2104 };
2105 assert!(!spec.check(&result));
2106 }
2107
2108 #[test]
2113 fn numeric_bounds_accept_quoted_numbers() {
2114 let mut template = nmbrs_workload::model::ParsedOp::simple("t", "noop");
2115 template.params.insert(
2116 "verify".into(),
2117 serde_json::json!([
2118 {"field": "value", "lte": "5"},
2119 {"field": "value", "gte": " 2 "},
2120 {"field": "other", "lte": 7},
2121 ]),
2122 );
2123 let a = parse_assertions(&template);
2124 assert!(
2125 matches!(a[0].predicate, AssertionPredicate::Lte(t) if t == 5.0),
2126 "quoted lte must parse: {:?}",
2127 a[0].predicate
2128 );
2129 assert!(
2130 matches!(a[1].predicate, AssertionPredicate::Gte(t) if t == 2.0),
2131 "surrounding whitespace is not a malformed bound: {:?}",
2132 a[1].predicate
2133 );
2134 assert!(
2135 matches!(a[2].predicate, AssertionPredicate::Lte(t) if t == 7.0),
2136 "the unquoted form is unchanged: {:?}",
2137 a[2].predicate
2138 );
2139 }
2140
2141 #[test]
2146 fn non_numeric_bounds_are_malformed_not_zero() {
2147 let mut template = nmbrs_workload::model::ParsedOp::simple("t", "noop");
2148 template.params.insert(
2149 "verify".into(),
2150 serde_json::json!([
2151 {"field": "value", "lte": "{unresolved}"},
2152 {"field": "value", "gte": "abc"},
2153 ]),
2154 );
2155 let a = parse_assertions(&template);
2156 for spec in &a {
2157 match &spec.predicate {
2158 AssertionPredicate::MalformedBound { raw, .. } => {
2159 assert!(
2160 !raw.is_empty(),
2161 "the offending text is carried for the message"
2162 );
2163 }
2164 other => panic!("expected MalformedBound, got {other:?}"),
2165 }
2166 }
2167 let msg = format!("{:?}", a[0].predicate);
2170 assert!(
2171 msg.contains("MalformedBound"),
2172 "the predicate stays malformed all the way to reporting: {msg}"
2173 );
2174 }
2175
2176 #[test]
2177 fn parse_assertions_from_params() {
2178 let mut template = nmbrs_workload::model::ParsedOp::simple("test", "SELECT");
2179 template.params.insert(
2180 "verify".into(),
2181 serde_json::json!([
2182 {"field": "name", "is": "not_null"},
2183 {"field": "balance", "gte": 0},
2184 {"field": "status", "eq": "active"},
2185 ]),
2186 );
2187 let assertions = parse_assertions(&template);
2188 assert_eq!(assertions.len(), 3);
2189 assert_eq!(assertions[0].field, "name");
2190 assert!(matches!(
2191 assertions[0].predicate,
2192 AssertionPredicate::NotNull
2193 ));
2194 assert_eq!(assertions[1].field, "balance");
2195 assert!(matches!(assertions[1].predicate, AssertionPredicate::Gte(v) if v == 0.0));
2196 assert_eq!(assertions[2].field, "status");
2197 assert!(matches!(&assertions[2].predicate, AssertionPredicate::Eq(s) if s == "active"));
2198 }
2199
2200 #[test]
2201 fn parse_assertions_missing() {
2202 let template = nmbrs_workload::model::ParsedOp::simple("test", "INSERT");
2203 assert!(parse_assertions(&template).is_empty());
2204 }
2205}