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
1325pub(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
1341fn 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
1355fn 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
1375fn json_field_as_i64(v: &serde_json::Value) -> Option<i64> {
1377 v.as_i64().or_else(|| v.as_str()?.parse().ok())
1378}
1379
1380fn resolve_expected_from_value(value: &polydat::ast::Value) -> Vec<i64> {
1396 match value {
1397 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 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 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 let s = value.to_display_string();
1420 parse_int_array(&s)
1421 }
1422 }
1423}
1424
1425fn 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
1437fn 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
1454fn 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 #[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 #[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 #[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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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", "r": "limit", "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 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 #[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 #[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 #[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 #[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 #[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 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}