use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
pub const SEARCH_QUALITY_SCHEMA_VERSION: u32 = 1;
const METRIC_EPS: f64 = 1e-9;
const INVALID_DOC_REF: &str = "__invalid_doc_ref__";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Qrel {
pub id: String,
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
pub expected_refs: Vec<String>,
pub k: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservedHit {
pub rank: usize,
pub doc_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trust_tier: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryRun {
pub qrel: Qrel,
pub observed: Vec<ObservedHit>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub realized_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fallback_tier: Option<String>,
pub latency_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueryEvaluation {
pub id: String,
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub requested_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub realized_mode: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_tier: Option<String>,
pub k: usize,
pub expected_refs: Vec<String>,
pub observed_refs: Vec<String>,
pub recall_at_k: f64,
pub precision_at_k: f64,
pub mrr: f64,
pub latency_ms: u64,
pub missing_refs: Vec<String>,
pub unexpected_refs: Vec<String>,
pub passed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AggregateMetrics {
pub query_count: usize,
pub passed_count: usize,
pub failed_count: usize,
pub mean_recall_at_k: f64,
pub mean_precision_at_k: f64,
pub mean_mrr: f64,
pub mean_latency_ms: f64,
pub trust_tier_distribution: BTreeMap<String, usize>,
pub realized_mode_counts: BTreeMap<String, usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QualityReport {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub queries: Vec<QueryEvaluation>,
pub aggregate: AggregateMetrics,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QueryDiff {
pub id: String,
pub recall_delta: f64,
pub precision_delta: f64,
pub mrr_delta: f64,
pub newly_missing_refs: Vec<String>,
pub regressed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RegressionDiff {
pub mean_recall_delta: f64,
pub mean_precision_delta: f64,
pub mean_mrr_delta: f64,
pub per_query: Vec<QueryDiff>,
pub dropped_query_ids: Vec<String>,
pub added_query_ids: Vec<String>,
pub regressed_query_ids: Vec<String>,
pub has_regression: bool,
}
pub fn sanitize_doc_ref(value: &str) -> String {
value
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
.take(128)
.collect()
}
fn canonical_doc_ref(value: &str) -> Option<String> {
let trimmed = value.trim();
let sanitized = sanitize_doc_ref(trimmed);
(!sanitized.is_empty() && sanitized == trimmed).then_some(sanitized)
}
fn distinct(refs: &[String]) -> BTreeSet<&str> {
refs.iter().map(String::as_str).collect()
}
fn relevant_in_topk(expected: &BTreeSet<&str>, observed_ranked: &[String], k: usize) -> usize {
let mut found: BTreeSet<&str> = BTreeSet::new();
for r in observed_ranked.iter().take(k) {
if expected.contains(r.as_str()) {
found.insert(r.as_str());
}
}
found.len()
}
pub fn recall_at_k(expected: &[String], observed_ranked: &[String], k: usize) -> f64 {
let exp = distinct(expected);
if exp.is_empty() {
return 0.0;
}
relevant_in_topk(&exp, observed_ranked, k) as f64 / exp.len() as f64
}
pub fn precision_at_k(expected: &[String], observed_ranked: &[String], k: usize) -> f64 {
let exp = distinct(expected);
let considered = k.min(observed_ranked.len());
if considered == 0 {
return 0.0;
}
relevant_in_topk(&exp, observed_ranked, k) as f64 / considered as f64
}
pub fn reciprocal_rank(expected: &[String], observed_ranked: &[String]) -> f64 {
let exp = distinct(expected);
for (idx, r) in observed_ranked.iter().enumerate() {
if exp.contains(r.as_str()) {
return 1.0 / (idx as f64 + 1.0);
}
}
0.0
}
fn observed_refs_ranked(observed: &[ObservedHit]) -> Vec<String> {
let mut hits: Vec<&ObservedHit> = observed.iter().collect();
hits.sort_by(|a, b| a.rank.cmp(&b.rank).then_with(|| a.doc_ref.cmp(&b.doc_ref)));
hits.into_iter()
.map(|hit| canonical_doc_ref(&hit.doc_ref).unwrap_or_else(|| INVALID_DOC_REF.to_string()))
.collect()
}
pub fn evaluate(run: &QueryRun) -> QueryEvaluation {
let qrel = &run.qrel;
let expected_refs: Vec<String> = qrel
.expected_refs
.iter()
.filter_map(|doc_ref| canonical_doc_ref(doc_ref))
.collect();
let observed_refs = observed_refs_ranked(&run.observed);
let recall = recall_at_k(&expected_refs, &observed_refs, qrel.k);
let precision = precision_at_k(&expected_refs, &observed_refs, qrel.k);
let mrr = reciprocal_rank(&expected_refs, &observed_refs);
let exp_set: BTreeSet<&str> = expected_refs.iter().map(String::as_str).collect();
let topk: BTreeSet<&str> = observed_refs
.iter()
.take(qrel.k)
.map(String::as_str)
.collect();
let expected_sorted: Vec<String> = exp_set.iter().map(|r| (*r).to_string()).collect();
let missing_refs: Vec<String> = exp_set
.difference(&topk)
.map(|r| (*r).to_string())
.collect();
let unexpected_refs: Vec<String> = topk
.difference(&exp_set)
.map(|r| (*r).to_string())
.collect();
let passed = !expected_sorted.is_empty()
&& qrel.k > 0
&& (recall - 1.0).abs() < METRIC_EPS
&& (precision - 1.0).abs() < METRIC_EPS
&& unexpected_refs.is_empty();
QueryEvaluation {
id: qrel.id.clone(),
query: qrel.query.clone(),
requested_mode: qrel.mode.clone(),
realized_mode: run.realized_mode.clone(),
fallback_tier: run.fallback_tier.clone(),
k: qrel.k,
expected_refs: expected_sorted,
observed_refs,
recall_at_k: recall,
precision_at_k: precision,
mrr,
latency_ms: run.latency_ms,
missing_refs,
unexpected_refs,
passed,
}
}
fn mean(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
values.iter().sum::<f64>() / values.len() as f64
}
pub fn build_report(runs: &[QueryRun]) -> QualityReport {
build_report_labeled(runs, None)
}
pub fn build_report_labeled(runs: &[QueryRun], label: Option<String>) -> QualityReport {
let queries: Vec<QueryEvaluation> = runs.iter().map(evaluate).collect();
let recalls: Vec<f64> = queries.iter().map(|q| q.recall_at_k).collect();
let precisions: Vec<f64> = queries.iter().map(|q| q.precision_at_k).collect();
let mrrs: Vec<f64> = queries.iter().map(|q| q.mrr).collect();
let latencies: Vec<f64> = queries.iter().map(|q| q.latency_ms as f64).collect();
let passed_count = queries.iter().filter(|q| q.passed).count();
let mut trust_tier_distribution: BTreeMap<String, usize> = BTreeMap::new();
for run in runs {
for hit in &run.observed {
if let Some(tier) = &hit.trust_tier {
*trust_tier_distribution.entry(tier.clone()).or_insert(0) += 1;
}
}
}
let mut realized_mode_counts: BTreeMap<String, usize> = BTreeMap::new();
for run in runs {
if let Some(mode) = &run.realized_mode {
*realized_mode_counts.entry(mode.clone()).or_insert(0) += 1;
}
}
let aggregate = AggregateMetrics {
query_count: queries.len(),
passed_count,
failed_count: queries.len() - passed_count,
mean_recall_at_k: mean(&recalls),
mean_precision_at_k: mean(&precisions),
mean_mrr: mean(&mrrs),
mean_latency_ms: mean(&latencies),
trust_tier_distribution,
realized_mode_counts,
};
QualityReport {
schema_version: SEARCH_QUALITY_SCHEMA_VERSION,
label,
queries,
aggregate,
}
}
pub fn diff_reports(baseline: &QualityReport, current: &QualityReport) -> RegressionDiff {
let base_by_id: BTreeMap<&str, &QueryEvaluation> = baseline
.queries
.iter()
.map(|q| (q.id.as_str(), q))
.collect();
let cur_by_id: BTreeMap<&str, &QueryEvaluation> =
current.queries.iter().map(|q| (q.id.as_str(), q)).collect();
let dropped_query_ids: Vec<String> = base_by_id
.keys()
.filter(|id| !cur_by_id.contains_key(*id))
.map(|id| (*id).to_string())
.collect();
let added_query_ids: Vec<String> = cur_by_id
.keys()
.filter(|id| !base_by_id.contains_key(*id))
.map(|id| (*id).to_string())
.collect();
let mut per_query: Vec<QueryDiff> = Vec::new();
let mut regressed_query_ids: Vec<String> = Vec::new();
for (id, base) in &base_by_id {
let Some(cur) = cur_by_id.get(id) else {
continue;
};
let recall_delta = cur.recall_at_k - base.recall_at_k;
let precision_delta = cur.precision_at_k - base.precision_at_k;
let mrr_delta = cur.mrr - base.mrr;
let base_expected: BTreeSet<&str> = base.expected_refs.iter().map(String::as_str).collect();
let cur_expected: BTreeSet<&str> = cur.expected_refs.iter().map(String::as_str).collect();
let base_found: BTreeSet<&str> = base
.observed_refs
.iter()
.take(base.k)
.map(String::as_str)
.filter(|r| base_expected.contains(*r))
.collect();
let cur_found: BTreeSet<&str> = cur
.observed_refs
.iter()
.take(cur.k)
.map(String::as_str)
.filter(|r| cur_expected.contains(*r))
.collect();
let newly_missing_refs: Vec<String> = base_found
.difference(&cur_found)
.map(|r| (*r).to_string())
.collect();
let regressed = recall_delta < -METRIC_EPS
|| precision_delta < -METRIC_EPS
|| mrr_delta < -METRIC_EPS
|| !newly_missing_refs.is_empty();
if regressed {
regressed_query_ids.push((*id).to_string());
}
per_query.push(QueryDiff {
id: (*id).to_string(),
recall_delta,
precision_delta,
mrr_delta,
newly_missing_refs,
regressed,
});
}
let has_regression = !regressed_query_ids.is_empty() || !dropped_query_ids.is_empty();
RegressionDiff {
mean_recall_delta: current.aggregate.mean_recall_at_k - baseline.aggregate.mean_recall_at_k,
mean_precision_delta: current.aggregate.mean_precision_at_k
- baseline.aggregate.mean_precision_at_k,
mean_mrr_delta: current.aggregate.mean_mrr - baseline.aggregate.mean_mrr,
per_query,
dropped_query_ids,
added_query_ids,
regressed_query_ids,
has_regression,
}
}
fn cell(opt: &Option<String>) -> &str {
opt.as_deref().unwrap_or("—")
}
pub fn render_markdown(report: &QualityReport) -> String {
let mut out = String::new();
let _ = writeln!(out, "# Search Quality Report");
let _ = writeln!(out);
if let Some(label) = &report.label {
let _ = writeln!(out, "**Suite:** {label}");
let _ = writeln!(out);
}
let agg = &report.aggregate;
let _ = writeln!(out, "**Schema version:** {}", report.schema_version);
let _ = writeln!(
out,
"**Queries:** {} ({} passed, {} failed)",
agg.query_count, agg.passed_count, agg.failed_count
);
let _ = writeln!(out);
let _ = writeln!(out, "## Aggregate");
let _ = writeln!(out);
let _ = writeln!(out, "| Metric | Value |");
let _ = writeln!(out, "| --- | --- |");
let _ = writeln!(out, "| mean recall@k | {:.4} |", agg.mean_recall_at_k);
let _ = writeln!(out, "| mean precision@k | {:.4} |", agg.mean_precision_at_k);
let _ = writeln!(out, "| mean MRR | {:.4} |", agg.mean_mrr);
let _ = writeln!(out, "| mean latency (ms) | {:.1} |", agg.mean_latency_ms);
let _ = writeln!(out);
let _ = writeln!(out, "## Trust-tier distribution");
let _ = writeln!(out);
if agg.trust_tier_distribution.is_empty() {
let _ = writeln!(out, "_no trust verdicts observed_");
} else {
let _ = writeln!(out, "| Tier | Count |");
let _ = writeln!(out, "| --- | --- |");
for (tier, count) in &agg.trust_tier_distribution {
let _ = writeln!(out, "| {tier} | {count} |");
}
}
let _ = writeln!(out);
let _ = writeln!(out, "## Realized search mode");
let _ = writeln!(out);
if agg.realized_mode_counts.is_empty() {
let _ = writeln!(out, "_not reported_");
} else {
let _ = writeln!(out, "| Mode | Queries |");
let _ = writeln!(out, "| --- | --- |");
for (mode, count) in &agg.realized_mode_counts {
let _ = writeln!(out, "| {mode} | {count} |");
}
}
let _ = writeln!(out);
let _ = writeln!(out, "## Per-query");
let _ = writeln!(out);
let _ = writeln!(
out,
"| id | query | mode | recall@k | precision@k | MRR | latency_ms | missing | status |"
);
let _ = writeln!(
out,
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |"
);
for q in &report.queries {
let missing = if q.missing_refs.is_empty() {
"—".to_string()
} else {
q.missing_refs.join(",")
};
let status = if q.passed { "pass" } else { "FAIL" };
let _ = writeln!(
out,
"| {} | {} | {} | {:.4} | {:.4} | {:.4} | {} | {} | {} |",
q.id,
q.query,
cell(&q.realized_mode),
q.recall_at_k,
q.precision_at_k,
q.mrr,
q.latency_ms,
missing,
status
);
}
out
}
pub fn render_diff_markdown(diff: &RegressionDiff) -> String {
let mut out = String::new();
let _ = writeln!(out, "# Search Quality Drift");
let _ = writeln!(out);
let verdict = if diff.has_regression {
"REGRESSION"
} else {
"no regression"
};
let _ = writeln!(out, "**Verdict:** {verdict}");
let _ = writeln!(
out,
"**Mean deltas:** recall {:+.4}, precision {:+.4}, MRR {:+.4}",
diff.mean_recall_delta, diff.mean_precision_delta, diff.mean_mrr_delta
);
if !diff.dropped_query_ids.is_empty() {
let _ = writeln!(
out,
"**Dropped queries:** {}",
diff.dropped_query_ids.join(",")
);
}
if !diff.added_query_ids.is_empty() {
let _ = writeln!(out, "**Added queries:** {}", diff.added_query_ids.join(","));
}
let _ = writeln!(out);
let _ = writeln!(
out,
"| id | Δrecall | Δprecision | ΔMRR | newly_missing | regressed |"
);
let _ = writeln!(out, "| --- | --- | --- | --- | --- | --- |");
for q in &diff.per_query {
let missing = if q.newly_missing_refs.is_empty() {
"—".to_string()
} else {
q.newly_missing_refs.join(",")
};
let _ = writeln!(
out,
"| {} | {:+.4} | {:+.4} | {:+.4} | {} | {} |",
q.id,
q.recall_delta,
q.precision_delta,
q.mrr_delta,
missing,
if q.regressed { "yes" } else { "no" }
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn refs(items: &[&str]) -> Vec<String> {
items.iter().map(|s| (*s).to_string()).collect()
}
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn hit(rank: usize, doc_ref: &str, tier: Option<&str>) -> ObservedHit {
ObservedHit {
rank,
doc_ref: doc_ref.to_string(),
trust_tier: tier.map(str::to_string),
}
}
fn qrel(id: &str, query: &str, expected: &[&str], k: usize) -> Qrel {
Qrel {
id: id.to_string(),
query: query.to_string(),
mode: Some("hybrid".to_string()),
expected_refs: refs(expected),
k,
note: None,
}
}
#[test]
fn recall_perfect_partial_zero_and_empty_judgment() {
assert!(approx(
recall_at_k(&refs(&["a", "b"]), &refs(&["a", "b", "c"]), 5),
1.0
));
assert!(approx(
recall_at_k(&refs(&["a", "b"]), &refs(&["a", "c", "b"]), 2),
0.5
));
assert!(approx(
recall_at_k(&refs(&["x"]), &refs(&["a", "b"]), 5),
0.0
));
assert!(approx(recall_at_k(&[], &refs(&["a"]), 5), 0.0));
}
#[test]
fn precision_divides_by_slots_considered() {
assert!(approx(
precision_at_k(&refs(&["a", "b"]), &refs(&["a", "b", "c"]), 5),
2.0 / 3.0
));
assert!(approx(
precision_at_k(&refs(&["a", "b"]), &refs(&["a", "c", "b"]), 2),
0.5
));
assert!(approx(precision_at_k(&refs(&["a"]), &[], 5), 0.0));
}
#[test]
fn mrr_uses_first_relevant_rank() {
assert!(approx(
reciprocal_rank(&refs(&["a"]), &refs(&["a", "b"])),
1.0
));
assert!(approx(
reciprocal_rank(&refs(&["b"]), &refs(&["a", "b"])),
0.5
));
assert!(approx(
reciprocal_rank(&refs(&["z"]), &refs(&["a", "b"])),
0.0
));
}
#[test]
fn evaluate_reports_missing_and_unexpected_diff() {
let run = QueryRun {
qrel: qrel("q1", "foo", &["a", "b"], 2),
observed: vec![hit(1, "a", Some("unverified")), hit(2, "c", Some("stale"))],
realized_mode: Some("hybrid".to_string()),
fallback_tier: None,
latency_ms: 12,
};
let e = evaluate(&run);
assert!(approx(e.recall_at_k, 0.5));
assert_eq!(e.missing_refs, refs(&["b"]));
assert_eq!(e.unexpected_refs, refs(&["c"]));
assert!(!e.passed, "one expected ref missing → not passed");
assert_eq!(e.observed_refs, refs(&["a", "c"]));
}
#[test]
fn evaluate_full_recall_passes() {
let run = QueryRun {
qrel: qrel("q2", "bar", &["a", "b"], 5),
observed: vec![hit(1, "a", None), hit(2, "b", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 3,
};
let e = evaluate(&run);
assert!(approx(e.recall_at_k, 1.0));
assert!(e.passed);
assert!(e.missing_refs.is_empty());
}
#[test]
fn evaluate_rejects_unexpected_refs_despite_full_recall() {
let run = QueryRun {
qrel: qrel("q-precision", "bar", &["a"], 5),
observed: vec![hit(1, "a", None), hit(2, "spurious", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 3,
};
let evaluation = evaluate(&run);
assert!(approx(evaluation.recall_at_k, 1.0));
assert!(evaluation.precision_at_k < 1.0);
assert_eq!(evaluation.unexpected_refs, refs(&["spurious"]));
assert!(
!evaluation.passed,
"full recall must not hide false positives"
);
}
#[test]
fn evaluate_keeps_invalid_observed_ref_as_unexpected_slot() {
let run = QueryRun {
qrel: qrel("q-invalid-ref", "bar", &["a"], 5),
observed: vec![hit(1, "a", None), hit(2, "///", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 3,
};
let evaluation = evaluate(&run);
assert_eq!(evaluation.unexpected_refs, refs(&[INVALID_DOC_REF]));
assert!(evaluation.precision_at_k < 1.0);
assert!(!evaluation.passed);
}
#[test]
fn evaluate_rejects_empty_expected_refs() {
let run = QueryRun {
qrel: qrel("q-empty", "bar", &[], 5),
observed: Vec::new(),
realized_mode: None,
fallback_tier: None,
latency_ms: 3,
};
let evaluation = evaluate(&run);
assert!(approx(evaluation.recall_at_k, 0.0));
assert!(!evaluation.passed, "empty qrels must never pass vacuously");
}
#[test]
fn evaluate_rejects_noncanonical_expected_refs_without_echoing_them() {
let private = "/private/session/private.user@example.invalid";
let run = QueryRun {
qrel: qrel("q-private-expected", "bar", &[private], 5),
observed: vec![hit(1, private, None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 3,
};
let evaluation = evaluate(&run);
assert!(evaluation.expected_refs.is_empty());
assert_eq!(evaluation.observed_refs, refs(&[INVALID_DOC_REF]));
assert!(!evaluation.passed);
let encoded = serde_json::to_string(&evaluation).unwrap();
assert!(!encoded.contains("private.user"));
}
#[test]
fn evaluate_sorts_observed_by_rank() {
let run = QueryRun {
qrel: qrel("q3", "baz", &["b"], 1),
observed: vec![hit(2, "x", None), hit(1, "b", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
};
let e = evaluate(&run);
assert_eq!(e.observed_refs, refs(&["b", "x"]));
assert!(approx(e.mrr, 1.0));
assert!(approx(e.recall_at_k, 1.0));
}
#[test]
fn build_report_aggregates_means_and_distributions() {
let runs = vec![
QueryRun {
qrel: qrel("a", "qa", &["d1"], 5),
observed: vec![hit(1, "d1", Some("unverified"))],
realized_mode: Some("hybrid".to_string()),
fallback_tier: None,
latency_ms: 10,
},
QueryRun {
qrel: qrel("b", "qb", &["d2", "d3"], 5),
observed: vec![
hit(1, "d3", Some("stale")),
hit(2, "d2", Some("unverified")),
],
realized_mode: Some("hybrid".to_string()),
fallback_tier: Some("lexical".to_string()),
latency_ms: 20,
},
];
let report = build_report(&runs);
assert_eq!(report.aggregate.query_count, 2);
assert_eq!(report.aggregate.passed_count, 2);
assert!(approx(report.aggregate.mean_recall_at_k, 1.0));
assert!(approx(report.aggregate.mean_precision_at_k, 1.0));
assert!(approx(report.aggregate.mean_latency_ms, 15.0));
assert_eq!(
report.aggregate.trust_tier_distribution.get("unverified"),
Some(&2)
);
assert_eq!(
report.aggregate.trust_tier_distribution.get("stale"),
Some(&1)
);
assert_eq!(
report.aggregate.realized_mode_counts.get("hybrid"),
Some(&2)
);
}
#[test]
fn empty_report_has_zero_means_not_nan() {
let report = build_report(&[]);
assert_eq!(report.aggregate.query_count, 0);
assert!(report.aggregate.mean_recall_at_k.is_finite());
assert!(approx(report.aggregate.mean_recall_at_k, 0.0));
assert!(approx(report.aggregate.mean_mrr, 0.0));
}
#[test]
fn build_report_is_deterministic() {
let runs = vec![QueryRun {
qrel: qrel("a", "qa", &["d1", "d2"], 5),
observed: vec![
hit(1, "d1", Some("unverified")),
hit(2, "d2", Some("stale")),
],
realized_mode: Some("hybrid".to_string()),
fallback_tier: None,
latency_ms: 7,
}];
let a = serde_json::to_string(&build_report(&runs)).unwrap();
let b = serde_json::to_string(&build_report(&runs)).unwrap();
assert_eq!(a, b, "same input must serialize identically");
}
#[test]
fn report_round_trips_through_json() {
let runs = vec![QueryRun {
qrel: qrel("a", "qa", &["d1"], 5),
observed: vec![hit(1, "d1", Some("unverified"))],
realized_mode: Some("hybrid".to_string()),
fallback_tier: None,
latency_ms: 5,
}];
let report = build_report(&runs);
let json = serde_json::to_value(&report).unwrap();
let back: QualityReport = serde_json::from_value(json).unwrap();
assert_eq!(back, report);
}
#[test]
fn diff_flags_recall_regression_and_newly_missing() {
let baseline = build_report(&[QueryRun {
qrel: qrel("a", "qa", &["d1", "d2"], 5),
observed: vec![hit(1, "d1", None), hit(2, "d2", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let current = build_report(&[QueryRun {
qrel: qrel("a", "qa", &["d1", "d2"], 5),
observed: vec![hit(1, "d1", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let diff = diff_reports(&baseline, ¤t);
assert!(diff.has_regression);
assert_eq!(diff.regressed_query_ids, refs(&["a"]));
assert_eq!(diff.per_query[0].newly_missing_refs, refs(&["d2"]));
assert!(diff.mean_recall_delta < 0.0);
}
#[test]
fn diff_no_regression_when_improved_or_equal() {
let baseline = build_report(&[QueryRun {
qrel: qrel("a", "qa", &["d1", "d2"], 5),
observed: vec![hit(1, "d1", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let current = build_report(&[QueryRun {
qrel: qrel("a", "qa", &["d1", "d2"], 5),
observed: vec![hit(1, "d1", None), hit(2, "d2", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let diff = diff_reports(&baseline, ¤t);
assert!(!diff.has_regression);
assert!(diff.regressed_query_ids.is_empty());
assert!(diff.mean_recall_delta > 0.0);
}
#[test]
fn diff_reports_dropped_and_added_queries() {
let baseline = build_report(&[QueryRun {
qrel: qrel("a", "qa", &["d1"], 5),
observed: vec![hit(1, "d1", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let current = build_report(&[QueryRun {
qrel: qrel("b", "qb", &["d2"], 5),
observed: vec![hit(1, "d2", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let diff = diff_reports(&baseline, ¤t);
assert_eq!(diff.dropped_query_ids, refs(&["a"]));
assert_eq!(diff.added_query_ids, refs(&["b"]));
assert!(diff.has_regression);
}
#[test]
fn render_markdown_is_deterministic_and_has_sections() {
let runs = vec![QueryRun {
qrel: qrel("a", "qa", &["d1"], 5),
observed: vec![hit(1, "d1", Some("unverified"))],
realized_mode: Some("hybrid".to_string()),
fallback_tier: None,
latency_ms: 9,
}];
let report = build_report(&runs);
let a = render_markdown(&report);
let b = render_markdown(&report);
assert_eq!(a, b);
assert!(a.contains("# Search Quality Report"));
assert!(a.contains("## Aggregate"));
assert!(a.contains("## Trust-tier distribution"));
assert!(a.contains("## Per-query"));
}
#[test]
fn report_holds_no_session_body() {
let private = "private.user@example.invalid";
let runs = vec![QueryRun {
qrel: qrel("p", "privacytopic", &["privacydoc"], 5),
observed: vec![hit(
1,
&format!("/private/session/{private}"),
Some("unverified"),
)],
realized_mode: Some("hybrid".to_string()),
fallback_tier: None,
latency_ms: 4,
}];
let report = build_report(&runs);
let json = serde_json::to_string(&report).unwrap();
let md = render_markdown(&report);
assert_eq!(
report.queries[0].observed_refs,
refs(&[INVALID_DOC_REF]),
"non-canonical metadata must become the stable invalid-ref sentinel"
);
assert!(
!json.contains(private),
"JSON report must not leak body text"
);
assert!(
!json.contains("private.userexample.invalid"),
"JSON report must not leak a separator-stripped private marker"
);
assert!(
!md.contains(private),
"markdown report must not leak body text"
);
}
#[test]
fn sanitize_doc_ref_drops_paths_and_whitespace() {
let dirty = "/home/alice/rollout-foo bar.jsonl 'or'1=1";
let clean = sanitize_doc_ref(dirty);
assert!(!clean.contains('/'), "no path separators: {clean}");
assert!(!clean.contains(' '), "no whitespace: {clean}");
assert!(!clean.contains('\''), "no quotes: {clean}");
assert!(
clean
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')),
"only id-safe chars: {clean}"
);
}
#[test]
fn render_diff_markdown_marks_regression() {
let baseline = build_report(&[QueryRun {
qrel: qrel("a", "qa", &["d1", "d2"], 5),
observed: vec![hit(1, "d1", None), hit(2, "d2", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let current = build_report(&[QueryRun {
qrel: qrel("a", "qa", &["d1", "d2"], 5),
observed: vec![hit(1, "d1", None)],
realized_mode: None,
fallback_tier: None,
latency_ms: 1,
}]);
let diff = diff_reports(&baseline, ¤t);
let md = render_diff_markdown(&diff);
assert!(md.contains("REGRESSION"));
assert!(md.contains("d2"));
}
}