Merged
test(evals): replace simple retrieval cases with real scenarios #63
test(evals): replace simple retrieval cases with real scenarios #63
Merged
汪超Add on Sep 24
Total: 11 files changed+1489-443
@@ -22,7 +22,8 @@ python3 .agents/skills/knowledge-query/scripts/knowledge_index.py \
22 --knowledge-root . build22 --knowledge-root . build
23python3 .agents/skills/knowledge-query/scripts/knowledge_index.py \23python3 .agents/skills/knowledge-query/scripts/knowledge_index.py \
24 --knowledge-root . verify --level full24 --knowledge-root . verify --level full
25-python3 evals/run_retrieval.py25+# Gate recall/ranking by aggregate targets; query and filter contract errors still fail.
26+python3 evals/run_retrieval.py --quality-gate
26python3 -m unittest discover -s evals -p 'test_*.py'27python3 -m unittest discover -s evals -p 'test_*.py'
27python3 -m unittest discover -s .agents/skills/ops-knowledge-trace-ingest/scripts -p 'test_*.py'28python3 -m unittest discover -s .agents/skills/ops-knowledge-trace-ingest/scripts -p 'test_*.py'
28python3 .agents/skills/ops-knowledge-trace-ingest/scripts/field_notes_lint.py29python3 .agents/skills/ops-knowledge-trace-ingest/scripts/field_notes_lint.py
@@ -9,16 +9,15 @@
9# See LICENSE in the root of the software repository for the full text of the License.9# See LICENSE in the root of the software repository for the full text of the License.
10# ----------------------------------------------------------------------------------------------------------10# ----------------------------------------------------------------------------------------------------------
11 11 
12-"""Run auditable retrieval smoke cases against knowledge-query."""12+"""Run retrieval cases against knowledge-query."""
13 13 
14from __future__ import annotations14from __future__ import annotations
15 15 
16+import argparse
16import json17import json
17import math18import math
18-import shutil
19import subprocess19import subprocess
20import sys20import sys
21-import tempfile
22import time21import time
23from pathlib import Path22from pathlib import Path
24 23 
@@ -27,46 +26,14 @@ import yaml
27 26 
28ROOT = Path(__file__).resolve().parents[1]27ROOT = Path(__file__).resolve().parents[1]
29QUERY = ROOT / ".agents/skills/knowledge-query/scripts/knowledge_query.py"28QUERY = ROOT / ".agents/skills/knowledge-query/scripts/knowledge_query.py"
30-INDEX = ROOT / ".agents/skills/knowledge-query/scripts/knowledge_index.py"
31CASES = ROOT / "evals/retrieval_cases.yaml"29CASES = ROOT / "evals/retrieval_cases.yaml"
32 30 
33 31 
34-def build_synthetic_root(data: dict[str, object], target: Path) -> None:32+def run_cases(
35- shutil.copytree(ROOT / "governance", target / "governance")33+ data: dict[str, object], roots: dict[str, Path], *, quality_gate: bool = False
36- (target / "knowledge").mkdir(parents=True)34+) -> int:
37- for fixture in data.get("synthetic_cards", []):
38- if not isinstance(fixture, dict):
39- raise ValueError("synthetic_cards entries must be objects")
40- path = target / str(fixture["path"])
41- metadata = fixture["metadata"]
42- body = str(fixture["body"]).strip()
43- path.parent.mkdir(parents=True, exist_ok=True)
44- path.write_text(
45- "---\n"
46- + yaml.safe_dump(metadata, allow_unicode=True, sort_keys=False).rstrip()
47- + "\n---\n\n"
48- + body
49- + "\n",
50- encoding="utf-8",
51- )
52- built = subprocess.run(
53- [
54- sys.executable,
55- str(INDEX),
56- "--knowledge-root",
57- str(target),
58- "build",
59- ],
60- check=False,
61- text=True,
62- capture_output=True,
63- )
64- if built.returncode != 0:
65- raise ValueError(f"cannot build synthetic retrieval index: {built.stderr.strip()}")
66- 
67- 
68-def run_cases(data: dict[str, object], roots: dict[str, Path]) -> int:
69 failures: list[str] = []35 failures: list[str] = []
36+ misses: list[str] = []
70 expected_total = 037 expected_total = 0
71 expected_found = 038 expected_found = 0
72 reciprocal_ranks: list[float] = []39 reciprocal_ranks: list[float] = []
@@ -74,12 +41,40 @@ def run_cases(data: dict[str, object], roots: dict[str, Path]) -> int:
74 cases = data.get("cases")41 cases = data.get("cases")
75 if not isinstance(cases, list):42 if not isinstance(cases, list):
76 raise ValueError("retrieval cases must be a list")43 raise ValueError("retrieval cases must be a list")
44+ quality = data.get("quality", {})
45+ if not isinstance(quality, dict):
46+ raise ValueError("quality must be an object")
47+ if quality_gate:
48+ for key in ("min_recall_at_k", "min_mrr"):
49+ value = quality.get(key)
50+ if (
51+ isinstance(value, bool)
52+ or not isinstance(value, (int, float))
53+ or not math.isfinite(value)
54+ or value <= 0
55+ or value >= 1
56+ ):
57+ raise ValueError(f"quality gate requires a valid positive {key}")
77 for case in cases:58 for case in cases:
78 if not isinstance(case, dict):59 if not isinstance(case, dict):
79 raise ValueError("retrieval case entries must be objects")60 raise ValueError("retrieval case entries must be objects")
80 corpus = str(case.get("corpus", "repository"))61 corpus = str(case.get("corpus", "repository"))
81 if corpus not in roots:62 if corpus not in roots:
82 raise ValueError(f"unknown retrieval corpus: {corpus}")63 raise ValueError(f"unknown retrieval corpus: {corpus}")
64+ groups = [[path] for path in case.get("expected", [])] + case.get(
65+ "expected_any", []
66+ )
67+ if any(
68+ not isinstance(group, list)
69+ or not group
70+ or any(not isinstance(path, str) or not path for path in group)
71+ for group in groups
72+ ):
73+ raise ValueError(
74+ f"{case['id']}: expected groups must be nonempty lists of paths"
75+ )
76+ expected = {path for group in groups for path in group}
77+ expected_total += len(groups)
83 command = [78 command = [
84 sys.executable,79 sys.executable,
85 str(QUERY),80 str(QUERY),
@@ -114,71 +109,117 @@ def run_cases(data: dict[str, object], roots: dict[str, Path]) -> int:
114 continue109 continue
115 if completed.returncode != 0:110 if completed.returncode != 0:
116 failures.append(f"{case['id']}: query failed: {completed.stderr.strip()}")111 failures.append(f"{case['id']}: query failed: {completed.stderr.strip()}")
112+ if groups:
113+ reciprocal_ranks.append(0.0)
117 continue114 continue
118 payload = json.loads(completed.stdout)115 payload = json.loads(completed.stdout)
119 expected_count = case.get("expected_count")116 expected_count = case.get("expected_count")
120- count_mismatch = expected_count is not None and payload["count"] != expected_count117+ count_mismatch = (
118+ expected_count is not None and payload["count"] != expected_count
119+ )
121 hit_items = {item["path"]: item for item in payload["hits"]}120 hit_items = {item["path"]: item for item in payload["hits"]}
122 hits = set(hit_items)121 hits = set(hit_items)
123- expected = set(case.get("expected", []))122+ missing = [group for group in groups if not hits.intersection(group)]
124- missing = expected - hits
125 leaked = set(case.get("excluded", [])) & hits123 leaked = set(case.get("excluded", [])) & hits
126- expected_total += len(expected)124+ expected_found += len(groups) - len(missing)
127- expected_found += len(expected & hits)
128 if expected:125 if expected:
129 ranks = [hit_items[path]["rank"] for path in expected if path in hit_items]126 ranks = [hit_items[path]["rank"] for path in expected if path in hit_items]
130 reciprocal_ranks.append(1.0 / min(ranks) if ranks else 0.0)127 reciprocal_ranks.append(1.0 / min(ranks) if ranks else 0.0)
131 missing_fields: dict[str, list[str]] = {}128 missing_fields: dict[str, list[str]] = {}
132- for expected_path in case.get("expected", []):129+ for expected_path in expected:
133 if expected_path not in hit_items:130 if expected_path not in hit_items:
134 continue131 continue
135 fields = case.get("expected_fields", [])132 fields = case.get("expected_fields", [])
136- absent = [field for field in fields if field not in hit_items[expected_path]]133+ absent = [
134+ field for field in fields if field not in hit_items[expected_path]
135+ ]
137 if absent:136 if absent:
138 missing_fields[expected_path] = absent137 missing_fields[expected_path] = absent
139- if missing or leaked or missing_fields or count_mismatch:138+ out_of_scope = []
140- failures.append(139+ filters = case.get("filters", {})
140+ for path, item in hit_items.items():
141+ for option, field in (
142+ ("domain", "domain"),
143+ ("technology", "technology"),
144+ ("type", "profile"),
145+ ("status", "status"),
146+ ):
147+ wanted = filters.get(option)
148+ if not wanted or (option == "status" and wanted == "all"):
149+ continue
150+ if (
151+ option == "technology"
152+ and filters.get("include_shared")
153+ and item.get(field) == "shared"
154+ ):
155+ continue
156+ if item.get(field) != wanted:
157+ out_of_scope.append(f"{path}: {option}")
158+ platforms = filters.get("platform", [])
159+ platforms = [platforms] if isinstance(platforms, str) else platforms
160+ supported = item.get("platforms", [])
161+ if (
162+ platforms
163+ and "agnostic" not in supported
164+ and not set(platforms) <= set(supported)
165+ ):
166+ out_of_scope.append(f"{path}: platform")
167+ if missing or leaked or missing_fields or count_mismatch or out_of_scope:
168+ # Only recall misses may be covered by aggregate quality thresholds.
169+ # All command, count, field and filtering contracts remain mandatory.
170+ reports = (
171+ misses
172+ if quality_gate and not (leaked or missing_fields or count_mismatch or out_of_scope)
173+ else failures
174+ )
175+ reports.append(
141 f"{case['id']}: missing={sorted(missing)} leaked={sorted(leaked)} "176 f"{case['id']}: missing={sorted(missing)} leaked={sorted(leaked)} "
142 f"missing_fields={missing_fields} count={payload['count']} "177 f"missing_fields={missing_fields} count={payload['count']} "
143- f"expected_count={expected_count}"178+ f"expected_count={expected_count} out_of_scope={out_of_scope}"
144 )179 )
145 else:180 else:
146 print(f"PASS {case['id']}")181 print(f"PASS {case['id']}")
147- quality = data.get("quality", {})
148- if not isinstance(quality, dict):
149- raise ValueError("quality must be an object")
150 recall = expected_found / expected_total if expected_total else None182 recall = expected_found / expected_total if expected_total else None
151 mrr = sum(reciprocal_ranks) / len(reciprocal_ranks) if reciprocal_ranks else None183 mrr = sum(reciprocal_ranks) / len(reciprocal_ranks) if reciprocal_ranks else None
184+ if quality_gate and (recall is None or mrr is None):
185+ failures.append("quality: no positive evidence cases to evaluate")
152 ordered_latency = sorted(latencies_ms)186 ordered_latency = sorted(latencies_ms)
153 p95_index = max(0, math.ceil(len(ordered_latency) * 0.95) - 1)187 p95_index = max(0, math.ceil(len(ordered_latency) * 0.95) - 1)
154 p95_ms = ordered_latency[p95_index] if ordered_latency else 0.0188 p95_ms = ordered_latency[p95_index] if ordered_latency else 0.0
155- if recall is not None and recall < float(quality.get("min_recall_at_k", 0.0)):189+ min_recall = float(quality.get("min_recall_at_k", 0.0))
190+ min_mrr = float(quality.get("min_mrr", 0.0))
191+ minimum_operator = ">" if quality_gate else ">="
192+ if recall is not None and (recall < min_recall or (quality_gate and recall == min_recall)):
156 failures.append(193 failures.append(
157- f"quality: recall@k={recall:.3f} below {quality['min_recall_at_k']}"194+ f"quality: recall@k={recall:.3f} must be {minimum_operator} {min_recall}"
158- )
159- if mrr is not None and mrr < float(quality.get("min_mrr", 0.0)):
160- failures.append(f"quality: MRR={mrr:.3f} below {quality['min_mrr']}")
161- if p95_ms > float(quality.get("max_p95_ms", float("inf"))):
162- failures.append(
163- f"quality: p95={p95_ms:.1f}ms above {quality['max_p95_ms']}ms"
164 )195 )
196+ if mrr is not None and (mrr < min_mrr or (quality_gate and mrr == min_mrr)):
197+ failures.append(f"quality: MRR={mrr:.3f} must be {minimum_operator} {min_mrr}")
165 recall_text = f"{recall:.3f}" if recall is not None else "N/A"198 recall_text = f"{recall:.3f}" if recall is not None else "N/A"
166 mrr_text = f"{mrr:.3f}" if mrr is not None else "N/A"199 mrr_text = f"{mrr:.3f}" if mrr is not None else "N/A"
167- print(f"QUALITY recall@k={recall_text} MRR={mrr_text} p95={p95_ms:.1f}ms cases={len(cases)}")200+ print(
201+ f"QUALITY recall@k={recall_text} MRR={mrr_text} p95={p95_ms:.1f}ms cases={len(cases)}"
202+ )
203+ for miss in misses:
204+ print(f"MISS {miss}", file=sys.stderr)
168 for failure in failures:205 for failure in failures:
169 print(f"FAIL {failure}", file=sys.stderr)206 print(f"FAIL {failure}", file=sys.stderr)
170 return 1 if failures else 0207 return 1 if failures else 0
171 208 
172 209 
173def main() -> int:210def main() -> int:
211+ parser = argparse.ArgumentParser(description=__doc__)
212+ parser.add_argument(
213+ "--quality-gate",
214+ action="store_true",
215+ help="Require recall/MRR above thresholds; other assertions still fail.",
216+ )
217+ args = parser.parse_args()
174 try:218 try:
175 data = yaml.safe_load(CASES.read_text(encoding="utf-8"))219 data = yaml.safe_load(CASES.read_text(encoding="utf-8"))
176 if not isinstance(data, dict):220 if not isinstance(data, dict):
177 raise ValueError("retrieval case file must contain an object")221 raise ValueError("retrieval case file must contain an object")
178- with tempfile.TemporaryDirectory() as temporary:222+ return run_cases(data, {"repository": ROOT}, quality_gate=args.quality_gate)
179- synthetic_root = Path(temporary) / "synthetic-repository"
180- build_synthetic_root(data, synthetic_root)
181- return run_cases(data, {"repository": ROOT, "synthetic": synthetic_root})
182 except (OSError, ValueError, yaml.YAMLError) as exc:223 except (OSError, ValueError, yaml.YAMLError) as exc:
183 print(f"FAIL retrieval harness: {exc}", file=sys.stderr)224 print(f"FAIL retrieval harness: {exc}", file=sys.stderr)
184 return 2225 return 2
@@ -281,14 +281,22 @@ class DocumentationContractTests(unittest.TestCase):
281 281 
282 def test_knowledge_cards_use_okf_core_fields(self) -> None:282 def test_knowledge_cards_use_okf_core_fields(self) -> None:
283 root_index = (self.root / "knowledge/index.md").read_text(encoding="utf-8")283 root_index = (self.root / "knowledge/index.md").read_text(encoding="utf-8")
284- root_metadata = yaml.safe_load(root_index.split("---", 2)[1])284+ root_frame = re.match(r"\A---\n(?P<metadata>.*?)\n---\n", root_index, re.DOTALL)
285+ self.assertIsNotNone(root_frame)
286+ root_metadata = yaml.safe_load(root_frame.group("metadata"))
285 self.assertEqual({"okf_version": "0.2"}, root_metadata)287 self.assertEqual({"okf_version": "0.2"}, root_metadata)
286 288 
287 failures: list[str] = []289 failures: list[str] = []
288 for card in sorted((self.root / "knowledge").rglob("*.md")):290 for card in sorted((self.root / "knowledge").rglob("*.md")):
289 if card.name == "index.md":291 if card.name == "index.md":
290 continue292 continue
291- metadata = yaml.safe_load(card.read_text(encoding="utf-8").split("---", 2)[1])293+ frame = re.match(
294+ r"\A---\n(?P<metadata>.*?)\n---\n",
295+ card.read_text(encoding="utf-8"),
296+ re.DOTALL,
297+ )
298+ self.assertIsNotNone(frame, card)
299+ metadata = yaml.safe_load(frame.group("metadata"))
292 if not str(metadata.get("type", "")).strip():300 if not str(metadata.get("type", "")).strip():
293 failures.append(f"{card.relative_to(self.root)}: missing type")301 failures.append(f"{card.relative_to(self.root)}: missing type")
294 if metadata.get("status") not in {"draft", "stable", "deprecated"}:302 if metadata.get("status") not in {"draft", "stable", "deprecated"}:
@@ -617,9 +625,16 @@ class DocumentationContractTests(unittest.TestCase):
617 )625 )
618 failures: list[str] = []626 failures: list[str] = []
619 for card in sorted((self.root / "knowledge").rglob("*.md")):627 for card in sorted((self.root / "knowledge").rglob("*.md")):
620- for number, line in enumerate(card.read_text(encoding="utf-8").splitlines(), start=1):628+ text = card.read_text(encoding="utf-8")
629+ frame = re.match(r"\A---\n.*?\n---\n", text, re.DOTALL)
630+ body = text[frame.end():] if frame else text
631+ for number, line in enumerate(body.splitlines(), start=1):
632+ # A valid link's target and a card's own filename are not prose
633+ # references to a retired card suffix.
634+ line = re.sub(r"\]\([^)]*\)", "]", line)
621 if stale.search(line):635 if stale.search(line):
622- failures.append(f"{card.relative_to(self.root)}:{number}")636+ offset = text[: frame.end()].count("\n") if frame else 0
637+ failures.append(f"{card.relative_to(self.root)}:{number + offset}")
623 self.assertEqual([], failures)638 self.assertEqual([], failures)
624 639 
625 def test_skill_sources_do_not_contain_agent_vendor_metadata(self) -> None:640 def test_skill_sources_do_not_contain_agent_vendor_metadata(self) -> None:
@@ -311,7 +311,6 @@ class RouteContractTests(unittest.TestCase):
311 self.assertEqual(expected | {"knowledge/ops/ascendc", "knowledge/ops/shared"}, actual)311 self.assertEqual(expected | {"knowledge/ops/ascendc", "knowledge/ops/shared"}, actual)
312 self.assertTrue(all((ROOT / path / "index.md").is_file() for path in expected))312 self.assertTrue(all((ROOT / path / "index.md").is_file() for path in expected))
313 not_precreated = {313 not_precreated = {
314- "knowledge/contrib/ascendc",
315 "knowledge/common/toolchains",314 "knowledge/common/toolchains",
316 "knowledge/common/observability",315 "knowledge/common/observability",
317 "knowledge/common/glossaries",316 "knowledge/common/glossaries",
@@ -19,9 +19,10 @@ import subprocess
19import sys19import sys
20import tempfile20import tempfile
21import unittest21import unittest
22-from contextlib import redirect_stdout22+from contextlib import redirect_stderr, redirect_stdout
23from pathlib import Path23from pathlib import Path
24from urllib.parse import unquote24from urllib.parse import unquote
25+from unittest.mock import patch
25 26 
26from evals.run_retrieval import run_cases27from evals.run_retrieval import run_cases
27 28 
@@ -82,6 +83,143 @@ def run_index(root: Path, *arguments: str) -> subprocess.CompletedProcess[str]:
82 )83 )
83 84 
84 85 
86+class RetrievalCaseTests(unittest.TestCase):
87+ def run_mocked_case(self, case, hits, returncode=0, *, quality=None, quality_gate=False):
88+ output, errors = io.StringIO(), io.StringIO()
89+ completed = subprocess.CompletedProcess(
90+ [], returncode, json.dumps({"count": len(hits), "hits": hits}), "query failed"
91+ )
92+ with patch("evals.run_retrieval.subprocess.run", return_value=completed), \
93+ patch("evals.run_retrieval.time.perf_counter", side_effect=[0.0, 1.0]), \
94+ redirect_stdout(output), redirect_stderr(errors):
95+ result = run_cases(
96+ {"cases": [{"id": "fixture", "query": "query", **case}], "quality": quality or {}},
97+ {"repository": ROOT}, quality_gate=quality_gate,
98+ )
99+ return result, output.getvalue(), errors.getvalue()
100+ 
101+ def test_alternatives_do_not_replace_other_required_evidence(self):
102+ case = {"expected": ["c"], "expected_any": [["a", "b"]]}
103+ for paths, expected_result, recall in ((["b", "c"], 0, "1.000"),
104+ (["a", "b"], 1, "0.500"),
105+ (["c"], 1, "0.500")):
106+ with self.subTest(paths=paths):
107+ hits = [{"path": path, "rank": rank} for rank, path in enumerate(paths, 1)]
108+ result, output, _ = self.run_mocked_case(case, hits)
109+ self.assertEqual(expected_result, result)
110+ self.assertIn(f"recall@k={recall}", output)
111+ 
112+ def test_failed_query_stays_in_quality_denominator(self):
113+ result, output, _ = self.run_mocked_case({"expected_any": [["a", "b"]]}, [], 2)
114+ self.assertEqual(1, result)
115+ self.assertIn("recall@k=0.000 MRR=0.000", output)
116+ 
117+ def test_scope_is_checked_even_when_expected_card_is_returned(self):
118+ case = {"expected": ["a"], "filters": {"type": "apis", "platform": ["a2", "a3"]}}
119+ for profile, platforms, expected_result in (
120+ ("apis", ["a2", "a3"], 0), ("apis", ["agnostic"], 0),
121+ ("runbooks", ["a2", "a3"], 1), ("apis", ["a2"], 1),
122+ ):
123+ with self.subTest(profile=profile, platforms=platforms):
124+ result, _, _ = self.run_mocked_case(
125+ case, [{"path": "a", "rank": 1, "profile": profile, "platforms": platforms}]
126+ )
127+ self.assertEqual(expected_result, result)
128+ 
129+ def test_invalid_or_groups_are_not_vacuous_success(self):
130+ for groups in ([[]], ["not-a-list"], [[""]]):
131+ with self.subTest(groups=groups), self.assertRaisesRegex(ValueError, "nonempty"):
132+ self.run_mocked_case({"expected_any": groups}, [])
133+ 
134+ def test_quality_gate_accepts_partial_recall_above_threshold_but_strict_mode_fails(self):
135+ case = {"expected": ["c"], "expected_any": [["a", "b"]]}
136+ quality = {"min_recall_at_k": 0.49, "min_mrr": 0.49}
137+ hits = [{"path": "unrelated", "rank": 1}, {"path": "b", "rank": 2}]
138+ for quality_gate, expected_result in ((False, 1), (True, 0)):
139+ with self.subTest(quality_gate=quality_gate):
140+ result, output, errors = self.run_mocked_case(
141+ case, hits, quality=quality, quality_gate=quality_gate,
142+ )
143+ self.assertEqual(expected_result, result)
144+ self.assertIn("recall@k=0.500 MRR=0.500 p95=1000.0ms", output)
145+ self.assertIn("MISS fixture:" if quality_gate else "FAIL fixture:", errors)
146+ self.assertNotIn("PASS fixture", output)
147+ 
148+ def test_quality_gate_rejects_each_metric_at_or_below_target(self):
149+ quality = {"min_recall_at_k": 0.49, "min_mrr": 0.49}
150+ for key, value, metric in (
151+ ("min_recall_at_k", 0.5, "recall@k"),
152+ ("min_recall_at_k", 0.501, "recall@k"),
153+ ("min_mrr", 0.5, "MRR"),
154+ ("min_mrr", 0.501, "MRR"),
155+ ):
156+ with self.subTest(key=key):
157+ result, _, errors = self.run_mocked_case(
158+ {"expected": ["a", "b"]}, [{"path": "a", "rank": 2}],
159+ quality={**quality, key: value}, quality_gate=True,
160+ )
161+ self.assertEqual(1, result)
162+ self.assertIn(f"FAIL quality: {metric}", errors)
163+ self.assertEqual(1, errors.count("FAIL quality:"))
164+ 
165+ def test_default_mode_keeps_inclusive_quality_thresholds(self):
166+ result, _, errors = self.run_mocked_case(
167+ {"expected": ["a"]}, [{"path": "a", "rank": 1}],
168+ quality={"min_recall_at_k": 1.0, "min_mrr": 1.0},
169+ )
170+ self.assertEqual(0, result)
171+ self.assertEqual("", errors)
172+ 
173+ def test_quality_gate_preserves_hard_contract_failures(self):
174+ quality = {"min_recall_at_k": 0.49, "min_mrr": 0.49}
175+ for assertion, hits, returncode in (
176+ ({}, [], 2),
177+ ({"expected_error": "platform is required"}, [], 2),
178+ ({"expected_count": 0}, [{"path": "a", "rank": 1}], 0),
179+ ({"excluded": ["b"]}, [{"path": "a", "rank": 1}, {"path": "b", "rank": 2}], 0),
180+ ({"expected_fields": ["description"]}, [{"path": "a", "rank": 1}], 0),
181+ ({"filters": {"platform": ["950"]}}, [{"path": "a", "rank": 1, "platforms": ["a2"]}], 0),
182+ ({"filters": {"type": "apis"}}, [{"path": "a", "rank": 1, "profile": "runbooks"}], 0),
183+ ):
184+ with self.subTest(assertion=assertion, returncode=returncode):
185+ result, _, errors = self.run_mocked_case(
186+ {"expected": ["a"], **assertion}, hits, returncode,
187+ quality=quality, quality_gate=True,
188+ )
189+ self.assertEqual(1, result)
190+ self.assertIn("FAIL fixture:", errors)
191+ self.assertNotIn("MISS fixture:", errors)
192+ 
193+ def test_quality_gate_rejects_missing_or_invalid_thresholds(self):
194+ quality = {"min_recall_at_k": 0.5, "min_mrr": 0.5}
195+ for key in quality:
196+ invalid = (None, True, 0, -1, "0.5", float("nan"), float("inf"))
197+ invalid += (1, 1.1)
198+ for value in invalid:
199+ with self.subTest(key=key, value=value), self.assertRaisesRegex(ValueError, key):
200+ self.run_mocked_case(
201+ {"expected": ["a"]}, [],
202+ quality={**quality, key: value}, quality_gate=True,
203+ )
204+ with self.subTest(missing=key), self.assertRaisesRegex(ValueError, key):
205+ self.run_mocked_case(
206+ {"expected": ["a"]}, [],
207+ quality={k: v for k, v in quality.items() if k != key}, quality_gate=True,
208+ )
209+ 
210+ def test_quality_gate_rejects_no_positive_evidence_cases(self):
211+ quality = {"min_recall_at_k": 0.5, "min_mrr": 0.5}
212+ for cases in ([], [{"id": "empty", "query": "query", "expected_count": 0}]):
213+ output, errors = io.StringIO(), io.StringIO()
214+ completed = subprocess.CompletedProcess([], 0, '{"count": 0, "hits": []}', "")
215+ with patch("evals.run_retrieval.subprocess.run", return_value=completed), \
216+ redirect_stdout(output), redirect_stderr(errors):
217+ result = run_cases({"cases": cases, "quality": quality},
218+ {"repository": ROOT}, quality_gate=True)
219+ self.assertEqual(1, result)
220+ self.assertIn("no positive evidence cases", errors.getvalue())
221+ 
222+ 
85class RetrievalIndexTests(unittest.TestCase):223class RetrievalIndexTests(unittest.TestCase):
86 def test_kirin_api_filter_keeps_platform_boundaries(self) -> None:224 def test_kirin_api_filter_keeps_platform_boundaries(self) -> None:
87 with tempfile.TemporaryDirectory() as temporary:225 with tempfile.TemporaryDirectory() as temporary:
@@ -51,7 +51,7 @@ aliases: ["OL-412"]
51 .PrecisionReduceFlag(true)51 .PrecisionReduceFlag(true)
52 .ExtendCfgInfo("opFile.value", "adaptive_avg_pool3d_apt");52 .ExtendCfgInfo("opFile.value", "adaptive_avg_pool3d_apt");
53 this->AICore().AddConfig("ascend950", aicore_config_950);53 this->AICore().AddConfig("ascend950", aicore_config_950);
54- 54+ 
55 OpAICoreConfig ascend310p_config;55 OpAICoreConfig ascend310p_config;
56 ascend310p_config.Input("x")56 ascend310p_config.Input("x")
57 .ParamType(REQUIRED)57 .ParamType(REQUIRED)
@@ -28,6 +28,7 @@ aliases: ["OL-542"]
28 28 
29**Concrete anchor** (the load-bearing line is the per-call clone):29**Concrete anchor** (the load-bearing line is the per-call clone):
30```python30```python
31+```
31 32 
32**孪生卡 ol-646 合并的独有内容**(ol-646-in-place-cann-ops-aclnnapply-mutate-their-inputs.md 已删除):33**孪生卡 ol-646 合并的独有内容**(ol-646-in-place-cann-ops-aclnnapply-mutate-their-inputs.md 已删除):
33 34 
@@ -29,6 +29,7 @@ The two rules are complementary, not contradictory: the question is whether NZ l
29 29 
30**Concrete anchor**:30**Concrete anchor**:
31```python31```python
32+```
32 33 
33**孪生卡 ol-647 合并的独有内容**(ol-647-allow-internal-format-true-reorders-within-the-i.md 已删除):34**孪生卡 ol-647 合并的独有内容**(ol-647-allow-internal-format-true-reorders-within-the-i.md 已删除):
34 35 
@@ -28,6 +28,20 @@ PR,且第 2 步不能修复第 1 步提交本身不可自检的问题;建议
28 合入后 master 新生成器 + 新索引自洽、check.sh 恢复绿色。该说明将随28 合入后 master 新生成器 + 新索引自洽、check.sh 恢复绿色。该说明将随
29 回复提交给 zhanghz1 确认。29 回复提交给 zhanghz1 确认。
30 30 
31+## [11:09] eval | 用真实检索场景替换简单 case
32+ 
33+### Summary
34+ 
35+将 29 条轨迹查询和 34 个人工场景直接迁入 `evals/retrieval_cases.yaml`,沿用 `evals/run_retrieval.py` 和现有 CI 入口。
36+ 
37+### Details
38+ 
39+- 保留 63 例的 query、过滤条件、top5、必要证据及来源定位;原质量阈值保持不变。原脚本增加等价候选 OR 分组和返回范围检查,查询失败仍计入质量分母。
40+- 移除本次新增的设计/复查文档、版本快照及独立评测/提取脚本;README、AGENTS 恢复原状,仅保留这条简短变更日志。此前过程材料仍可从 Git 历史查看。
41+- 迁移一致性核对、5 项定向计分/范围回归和 ruff 检查通过。
42+- `bash check.sh` 的 Lint、导航、索引构建和完整校验通过;63 例为 39 通过、24 例缺必要证据,无范围违规。证据组 recall@5=0.545、MRR=0.477、p95=1143.0ms,均未达到原阈值,门禁在检索阶段失败。
43+- 补跑后续检查:仓库单测 270 项,266 通过、1 跳过、2 失败、1 错误;后三项分别为旧 A5 后缀、既有 contrib 目录、测试 Frontmatter 切分问题,已在原始基线复现。轨迹工具 36 项、正文 HARD 约束、全库 ruff、Shell 语法及归档差异检查通过。原始输出保存在 ignored 的 `artifacts/retrieval/simplified-*-20260924.log`。
44+ 
31## [09:47] update | 索引导航摘要子目录计数修复为仅统计一级子目录45## [09:47] update | 索引导航摘要子目录计数修复为仅统计一级子目录
32 46 
33### Summary47### Summary
@@ -52,4 +66,3 @@ PR,且第 2 步不能修复第 1 步提交本身不可自检的问题;建议
52- 验证:`evals.test_governance.IndexSummaryTests` 9/9 通过66- 验证:`evals.test_governance.IndexSummaryTests` 9/9 通过
53 (7 既有 + 2 新增:`test_deep_nesting_counts_first_level_subdirectories_only`、67 (7 既有 + 2 新增:`test_deep_nesting_counts_first_level_subdirectories_only`、
54 `test_deep_nesting_with_overview_counts_first_level_only`)。68 `test_deep_nesting_with_overview_counts_first_level_only`)。
55- 
@@ -1,5 +1,40 @@
1# 2026-09-281# 2026-09-28
2 2 
3+## [20:17] test | 移除检索 P95 质量门禁
4+ 
5+### Summary
6+ 
7+按请求移除 P95 阈值限制,检索质量门禁只校验 Recall@5 和 MRR;P95 继续输出用于观察,不再导致检查失败。
8+ 
9+### Details
10+ 
11+- `retrieval_cases.yaml` 只保留 `min_recall_at_k` 和 `min_mrr`。
12+- `run_retrieval.py --quality-gate` 不再校验或比较 P95 上限。
13+- 更新质量门禁回归测试,保留 Recall/MRR 的严格阈值和其他硬断言。
14+ 
15+## [19:18] test | 放宽检索 P95 门槛
16+ 
17+### Summary
18+ 
19+按请求将质量门禁的 P95 上限从 1100 ms 调整为严格小于 1200 ms;Recall@5 和 MRR 门槛保持不变。
20+ 
21+### Details
22+ 
23+- `retrieval_cases.yaml` 设置 `max_p95_ms: 1200`。
24+- `run_retrieval.py --quality-gate` 继续使用严格 `<` 比较,等于 1200 ms 时仍失败。
25+ 
26+## [19:14] test | 调整检索基线门槛
27+ 
28+### Summary
29+ 
30+按请求将检索质量门槛改为 Recall@5 > 0.54、MRR > 0.45、P95 < 1100 ms,并保持 case 级硬断言继续阻断检查。
31+ 
32+### Details
33+ 
34+- `retrieval_cases.yaml` 设置 `min_recall_at_k: 0.54`、`min_mrr: 0.45`、`max_p95_ms: 1100`。
35+- `run_retrieval.py --quality-gate` 对 Recall/MRR 使用严格大于、对 P95 使用严格小于;默认非门禁调用仍保留原有包含边界。
36+- 增加等于门槛时失败、默认包含边界和阈值校验测试。
37+ 
3## [17:00] update | !58 重构为索引专用 PR 并 rebase 至 0fe7f05d38## [17:00] update | !58 重构为索引专用 PR 并 rebase 至 0fe7f05d
4 39 
5### Summary40### Summary
@@ -13,6 +48,18 @@
13- 评测机在 0fe7f05d 上复核 `sync-indexes --check`:662 索引 85 stale;再生成 Synchronized 85、unchanged 577,改动路径集与本 PR 85 个文件完全一致,apis/guides 内容与 098cb4a3 逐字节一致;48- 评测机在 0fe7f05d 上复核 `sync-indexes --check`:662 索引 85 stale;再生成 Synchronized 85、unchanged 577,改动路径集与本 PR 85 个文件完全一致,apis/guides 内容与 098cb4a3 逐字节一致;
14- `logs/2026-09-28.md` 按时间降序合并 PR #67 侧 [03:39]/[07:24] 条目、本分支 [09:26] 条目与本条目;`logs/2026-09-22.md` 沿用此前两侧合并版(master 侧在 1e015cdc 后无变化)。49- `logs/2026-09-28.md` 按时间降序合并 PR #67 侧 [03:39]/[07:24] 条目、本分支 [09:26] 条目与本条目;`logs/2026-09-22.md` 沿用此前两侧合并版(master 侧在 1e015cdc 后无变化)。
15 50 
51+## [16:25] test | 将检索门禁改为基线之上的质量阈值
52+ 
53+### Summary
54+ 
55+为检索 case 评测增加显式质量门禁模式,并把阈值设为略优于当前 70 例基线;漏召回只参与聚合指标,查询错误、数量断言、过滤范围和字段断言仍然阻断检查。
56+ 
57+### Details
58+ 
59+- `check.sh` 使用 `evals/run_retrieval.py --quality-gate`;Recall@5 门槛为 0.550,MRR 门槛为 0.460,P95 门槛为 1100 ms。
60+- 当前基线为 Recall@5=0.548、MRR=0.459、P95=1108.4 ms,因此当前基线会被新门禁拒绝,要求后续检索改进后才能通过。
61+- 增加阈值校验、无正向证据拒绝、硬断言保留和聚合门禁回归测试;定向测试 10 项通过。
62+ 
16## [09:26] update | 按维护者意见清理日志并 rebase 最新 master(!58 终审准备)63## [09:26] update | 按维护者意见清理日志并 rebase 最新 master(!58 终审准备)
17 64 
18### Summary65### Summary