master-skill 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/GEMINI.md +1 -1
  5. package/README.md +84 -336
  6. package/README_EN.md +108 -321
  7. package/bin/cli.mjs +249 -9
  8. package/gemini-extension.json +1 -1
  9. package/hooks/session-start +68 -74
  10. package/hooks/session_start.py +152 -0
  11. package/package.json +6 -2
  12. package/prebuilt/{compare → compare-masters}/SKILL.md +21 -2
  13. package/prebuilt/master-ajahn-chah/meta.json +6 -0
  14. package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
  15. package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
  16. package/prebuilt/master-curriculum/SKILL.md +1 -1
  17. package/prebuilt/master-curriculum/references/tiantai.md +1 -1
  18. package/prebuilt/master-debate/SKILL.md +15 -3
  19. package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
  20. package/prebuilt/master-help/SKILL.md +94 -0
  21. package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
  22. package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
  23. package/prebuilt/master-kumarajiva/meta.json +14 -3
  24. package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
  25. package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
  26. package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
  27. package/prebuilt/master-nagarjuna/meta.json +19 -4
  28. package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
  29. package/prebuilt/master-ouyi/meta.json +5 -0
  30. package/prebuilt/master-ouyi/references/teaching.md +3 -3
  31. package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
  32. package/prebuilt/master-tsongkhapa/meta.json +32 -5
  33. package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
  34. package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
  35. package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
  36. package/prebuilt/master-zhiyi/meta.json +2 -2
  37. package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
  38. package/references/teaching-modes.md +8 -1
  39. package/routing.json +209 -0
  40. package/scripts/check-audit-ignores.py +105 -0
  41. package/scripts/check-eval-sdk-surface.py +142 -0
  42. package/scripts/check-gate-liveness.py +421 -0
  43. package/scripts/reaudit-report.py +163 -0
  44. package/scripts/regrade-report.py +157 -0
  45. package/scripts/smoke-eval-sdk.py +174 -0
  46. package/scripts/test-fidelity.py +992 -89
  47. package/scripts/validate-citation-references.py +150 -0
  48. package/scripts/validate-citation-templates.py +176 -0
  49. package/scripts/validate-fidelity.py +6 -1
  50. package/scripts/validate-fixture-terms.py +127 -0
  51. package/scripts/validate-routing.py +254 -0
  52. package/scripts/validate.py +63 -36
  53. package/scripts/verify-adjudication.py +316 -0
  54. package/scripts/verify_citations.py +739 -39
  55. package/skill-catalog.json +83 -20
  56. package/tools/cross_reference.py +44 -10
  57. package/tools/fojin-known-absent.json +14 -0
  58. package/tools/fojin_bridge.py +138 -8
  59. package/tools/rag_query.py +45 -2
  60. package/tools/skill_writer.py +50 -7
  61. package/tools/verify_sources.py +240 -15
  62. package/hooks/tests/test_run_hook.sh +0 -114
  63. package/hooks/tests/test_run_hook_cmd.sh +0 -94
  64. package/hooks/tests/test_session_start.sh +0 -149
  65. package/scripts/tests/test_check_manifest_versions.py +0 -217
  66. package/scripts/tests/test_debate_protocol.py +0 -159
  67. package/scripts/tests/test_injection_hardening.py +0 -174
  68. package/scripts/tests/test_select_fidelity_smoke.py +0 -142
  69. package/scripts/tests/test_validate_citation_contract.py +0 -408
  70. package/scripts/tests/test_validate_cross_critique.py +0 -149
  71. package/scripts/tests/test_validate_curriculum_sources.py +0 -144
  72. package/scripts/tests/test_validate_fidelity.py +0 -59
  73. package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
  74. package/scripts/tests/test_validate_persona_fidelity.py +0 -317
  75. package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
  76. package/scripts/tests/test_validate_workflow.py +0 -265
  77. /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
@@ -23,14 +23,328 @@ import json
23
23
  import os
24
24
  import re
25
25
  import sys
26
+ from concurrent.futures import ThreadPoolExecutor, as_completed
26
27
  from pathlib import Path
27
28
 
28
29
  # verify_citations lives in this same scripts/ dir; reused so the
29
30
  # `must_cite_only_existing_sources` assertion is actually enforced during graded
30
31
  # runs (it was previously schema-validated but never evaluated).
31
- from verify_citations import audit_answer, load_declared_ids
32
+ from _masterpaths import resolve_master_dir
33
+ from verify_citations import (
34
+ audit_answer,
35
+ load_declared_ids,
36
+ load_member_aliases,
37
+ load_title_aliases,
38
+ )
32
39
 
33
40
  PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
41
+ SCHEMA_VERSION = 1
42
+
43
+ # This project ships one prebuilt/ to five hosts (Claude Code, Cursor, Codex
44
+ # CLI, OpenCode, Gemini CLI), but every fidelity number it has produced came
45
+ # from one Anthropic model. A fixture measures whether the prompt induces the
46
+ # right behaviour, and that is a property of the prompt-and-model pair — so
47
+ # provider is an axis of the eval matrix, not a way to spend less.
48
+ #
49
+ # `api` selects the request/response shape. DeepSeek and Gemini both expose
50
+ # OpenAI-compatible endpoints, so one adapter covers them.
51
+ PROVIDERS: dict[str, dict] = {
52
+ "anthropic": {
53
+ "env": "ANTHROPIC_API_KEY",
54
+ "api": "anthropic",
55
+ "base_url": None,
56
+ "default_model": "claude-sonnet-4-6",
57
+ "models_url": "https://docs.claude.com/en/docs/about-claude/models",
58
+ },
59
+ "deepseek": {
60
+ "env": "DEEPSEEK_API_KEY",
61
+ "api": "openai",
62
+ "base_url": "https://api.deepseek.com/v1",
63
+ "default_model": None,
64
+ "models_url": "https://api-docs.deepseek.com/quick_start/pricing",
65
+ },
66
+ "gemini": {
67
+ "env": "GEMINI_API_KEY",
68
+ "api": "openai",
69
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
70
+ "default_model": None,
71
+ "models_url": "https://ai.google.dev/gemini-api/docs/models",
72
+ },
73
+ }
74
+
75
+ # Enough for a non-reasoning model's answer. Reasoning models spend this budget
76
+ # before writing anything — raise it with --max-output-tokens and say so in the
77
+ # report, because a different budget is a different instrument.
78
+ DEFAULT_MAX_OUTPUT_TOKENS = 2048
79
+
80
+ DEFAULT_PROVIDER = "anthropic"
81
+
82
+ # Fixtures are independent, so they can be graded in parallel. 4 is chosen to
83
+ # be useful without being a rate-limit generator: the anthropic and openai
84
+ # SDKs both retry 429s with backoff, but a burst wide enough to exhaust that
85
+ # retry budget turns into api_errors that are indistinguishable from real
86
+ # provider failures in the report. Raise it with --concurrency once you know
87
+ # your account's limits.
88
+ DEFAULT_CONCURRENCY = 4
89
+
90
+ # Per-ATTEMPT ceiling, which is not the same as per-fixture: both SDKs retry,
91
+ # so the wall for one fixture is timeout x (retries + 1).
92
+ #
93
+ # The first version of this set DEFAULT_MAX_RETRIES = 2 — both SDKs' own
94
+ # default — and the commit message claimed "retries are now bounded
95
+ # explicitly". Nothing was bounded. The per-fixture wall stayed at 900s against
96
+ # fidelity-full's 60-minute cap, so four wedged fixtures could still take the
97
+ # sweep; all that changed was that the number became configurable.
98
+ #
99
+ # 180 x (1 + 1) = 360s is a real bound, and it is chosen against the observed
100
+ # distribution rather than picked to look tidy: the 2026-08-18 run averaged
101
+ # ~31s per graded fixture (eval/reports/0.10.1-c697d5d.json), so 180s is nearly
102
+ # six times the mean for a single attempt. One retry still covers the transient
103
+ # 429/529 that retries exist for; a second retry mostly buys latency on calls
104
+ # that were not going to succeed.
105
+ DEFAULT_REQUEST_TIMEOUT = 180.0
106
+ DEFAULT_MAX_RETRIES = 1
107
+
108
+
109
+ def per_fixture_ceiling(timeout: float, retries: int) -> float:
110
+ """Worst-case seconds one fixture can hold, retries included."""
111
+ return timeout * (retries + 1)
112
+
113
+ # Anything shaped like a provider credential, stripped before an error string
114
+ # is written to a report. `eval/reports/0.10.1-c697d5d.json` carries 127 raw
115
+ # provider error strings, committed to a public repo — none leaked a key, but
116
+ # nothing was stopping one. SECURITY.md §3 names this exact risk.
117
+ _SECRET_SHAPES = re.compile(
118
+ r"(sk-[A-Za-z0-9_\-]{12,}"
119
+ r"|AIza[A-Za-z0-9_\-]{20,}"
120
+ r"|Bearer\s+[A-Za-z0-9._\-]{12,}"
121
+ r"|(?i:api[_-]?key)[\"'\s:=]+[A-Za-z0-9._\-]{12,})"
122
+ )
123
+
124
+
125
+ def redact_secrets(text: str) -> str:
126
+ """Blank out credential-shaped substrings before they reach a report.
127
+
128
+ Provider exceptions are stringified straight into the results JSON, which
129
+ is uploaded as a CI artifact and committed under eval/reports/. The error
130
+ text is worth keeping — it is how the credit-exhaustion run was diagnosed
131
+ — but it should not be the one place a key could ride out.
132
+ """
133
+ return _SECRET_SHAPES.sub("[REDACTED]", text)
134
+
135
+
136
+ def resolve_provider(name: str) -> dict:
137
+ """Look up a provider spec, failing with the list of what is available."""
138
+ try:
139
+ return PROVIDERS[name]
140
+ except KeyError:
141
+ known = ", ".join(sorted(PROVIDERS))
142
+ raise ValueError(f"unknown provider {name!r} — known providers: {known}") from None
143
+
144
+
145
+ def resolve_model(provider: str, explicit: str | None) -> str:
146
+ """Pick the model id for a run.
147
+
148
+ Anthropic keeps a default so existing invocations are unchanged. Every other
149
+ provider must be named explicitly: a guessed model id committed to this repo
150
+ would rot silently, and a run that cannot say which model produced it is not
151
+ a reproducible measurement.
152
+ """
153
+ if explicit:
154
+ return explicit
155
+ spec = resolve_provider(provider)
156
+ if spec["default_model"]:
157
+ return spec["default_model"]
158
+ raise ValueError(
159
+ f"provider {provider!r} has no default model — pass --model explicitly. "
160
+ f"Current model ids: {spec['models_url']}"
161
+ )
162
+
163
+
164
+ def build_request(
165
+ provider: str, model: str, system_prompt: str, question: str, max_tokens: int
166
+ ) -> dict:
167
+ """Build the request body for this provider's API shape."""
168
+ spec = resolve_provider(provider)
169
+ if spec["api"] == "anthropic":
170
+ return {
171
+ "model": model,
172
+ "max_tokens": max_tokens,
173
+ # The system prompt is the whole persona — measured at 13,505
174
+ # characters on average, roughly 6.7k tokens — and it is identical
175
+ # for every fixture of the same master, of which there are ~11. A
176
+ # sweep re-sent it 193 times and paid full input price each time.
177
+ #
178
+ # Marked explicitly rather than through the top-level
179
+ # `cache_control` shorthand: that caches the *last* cacheable
180
+ # block, which here is the per-fixture question — the one part
181
+ # that changes every call and can never hit.
182
+ "system": [
183
+ {
184
+ "type": "text",
185
+ "text": system_prompt,
186
+ "cache_control": {"type": "ephemeral"},
187
+ }
188
+ ],
189
+ "messages": [{"role": "user", "content": question}],
190
+ }
191
+ return {
192
+ "model": model,
193
+ "max_tokens": max_tokens,
194
+ "messages": [
195
+ {"role": "system", "content": system_prompt},
196
+ {"role": "user", "content": question},
197
+ ],
198
+ }
199
+
200
+
201
+ def _cache_summary(stats: dict) -> dict:
202
+ """What caching actually saved, priced rather than counted.
203
+
204
+ A hit *rate* is the wrong figure here: the uncached part of a request is
205
+ just the question, a few dozen tokens against a ~6.7k system prompt, so any
206
+ ratio built on it reads ~100% whether caching worked or not — the first
207
+ version of this did exactly that and looked identical with caching off.
208
+ What matters is input spend against the no-cache baseline, at the published
209
+ multipliers (reads 0.1x, writes 1.25x).
210
+ """
211
+ read, created, uncached = stats["read"], stats["created"], stats["uncached"]
212
+ baseline = read + created + uncached
213
+ if not baseline:
214
+ return {**stats, "input_tokens_saved": "N/A"}
215
+ actual = read * 0.1 + created * 1.25 + uncached
216
+ return {
217
+ **stats,
218
+ "baseline_input_tokens": baseline,
219
+ "effective_input_tokens": round(actual),
220
+ "input_tokens_saved": f"{(1 - actual / baseline) * 100:.0f}%",
221
+ }
222
+
223
+
224
+ def _record_cache_usage(stats: dict, response: object) -> None:
225
+ """Accumulate prompt-cache token counts when the provider reports them.
226
+
227
+ Only the Anthropic responses carry these fields; the OpenAI-compatible
228
+ hosts cache automatically and report nothing comparable, so their suites
229
+ show N/A rather than a fabricated zero.
230
+ """
231
+ usage = getattr(response, "usage", None)
232
+ if usage is None:
233
+ return
234
+ stats["created"] += getattr(usage, "cache_creation_input_tokens", 0) or 0
235
+ stats["read"] += getattr(usage, "cache_read_input_tokens", 0) or 0
236
+ stats["uncached"] += getattr(usage, "input_tokens", 0) or 0
237
+
238
+
239
+ def extract_text(provider: str, response: object) -> str:
240
+ """Pull the answer text out of this provider's response object.
241
+
242
+ Raises rather than returning "" on an empty response: a blank answer scored
243
+ as a normal case fails every must_mention and would be recorded as a persona
244
+ defect when it is really a transport problem.
245
+ """
246
+ spec = resolve_provider(provider)
247
+ if spec["api"] == "anthropic":
248
+ blocks = getattr(response, "content", None) or []
249
+ for block in blocks:
250
+ if getattr(block, "type", None) == "text":
251
+ return block.text
252
+ raise ValueError(f"{provider}: response carried no text block")
253
+
254
+ choices = getattr(response, "choices", None) or []
255
+ if not choices:
256
+ raise ValueError(f"{provider}: response carried no choices")
257
+ content = getattr(choices[0].message, "content", None)
258
+ if content is None:
259
+ raise ValueError(f"{provider}: response message had no content")
260
+ return content
261
+
262
+
263
+ def extract_finish_reason(provider: str, response: object) -> str | None:
264
+ """Why the model stopped, in one vocabulary across providers.
265
+
266
+ Anthropic says ``stop_reason: max_tokens``; the OpenAI-compatible hosts say
267
+ ``finish_reason: length``. Both mean the answer was cut off mid-sentence,
268
+ which is an instrument condition and not something a persona did.
269
+ """
270
+ spec = resolve_provider(provider)
271
+ if spec["api"] == "anthropic":
272
+ reason = getattr(response, "stop_reason", None)
273
+ return "length" if reason == "max_tokens" else reason
274
+ choices = getattr(response, "choices", None) or []
275
+ if not choices:
276
+ return None
277
+ return getattr(choices[0], "finish_reason", None)
278
+
279
+
280
+ def truncated_result_entry(
281
+ index: int, test: dict, response_text: str, max_output_tokens: int
282
+ ) -> dict:
283
+ """Record a cut-off answer as unmeasured, the way an API error is recorded.
284
+
285
+ Reasoning models spend the output budget before writing anything: measured
286
+ on `deepseek-v4-pro` at the harness's old hardcoded 2048, reasoning took
287
+ 1,860 tokens and left 250 characters of answer — and on some questions,
288
+ none at all. Scoring that produces `missing_cites` / `must_mention`
289
+ failures that describe the budget, not the prompt. Carries no check fields
290
+ at all, so nothing downstream can mistake it for a graded verdict.
291
+ """
292
+ return {
293
+ "index": index,
294
+ "question": test["q"],
295
+ "difficulty": test.get("difficulty", "unknown"),
296
+ "test_type": test.get("test_type", "fidelity"),
297
+ "status": "truncated",
298
+ "max_output_tokens": max_output_tokens,
299
+ "response": response_text,
300
+ "response_length": len(response_text),
301
+ }
302
+
303
+
304
+ def aggregation_conflicts(suites: list[dict]) -> list[str]:
305
+ """Report why a set of suites must not be pooled into one number.
306
+
307
+ Two models are two instruments. Averaging a Sonnet run with a DeepSeek run
308
+ — or a Sonnet run with an Opus run — produces a figure that describes
309
+ neither. Report per model instead.
310
+ """
311
+ seen = {
312
+ (s.get("provider", DEFAULT_PROVIDER), s.get("model"))
313
+ for s in suites
314
+ if s.get("model")
315
+ }
316
+ if len(seen) <= 1:
317
+ return []
318
+ listed = ", ".join(f"{p}/{m}" for p, m in sorted(seen))
319
+ return [
320
+ "refusing to aggregate across instruments — these suites came from "
321
+ f"different models ({listed}). Report a row per model."
322
+ ]
323
+
324
+
325
+ def suite_common(
326
+ master_name: str, dry_run: bool, outcome: str, provider: str = DEFAULT_PROVIDER
327
+ ) -> dict:
328
+ """Return fields shared by every fidelity JSON v1 suite."""
329
+ return {
330
+ "schema_version": SCHEMA_VERSION,
331
+ "master": master_name,
332
+ "provider": provider,
333
+ "mode": "dry_run" if dry_run else "graded",
334
+ "outcome": outcome,
335
+ }
336
+
337
+
338
+ def suite_error(
339
+ master_name: str, dry_run: bool, message: str, provider: str = DEFAULT_PROVIDER
340
+ ) -> dict:
341
+ """Return a fidelity JSON v1 suite for a precondition or execution error."""
342
+ return {
343
+ **suite_common(master_name, dry_run, "error", provider),
344
+ "total": 0,
345
+ "results": [],
346
+ "error": message,
347
+ }
34
348
 
35
349
 
36
350
  def load_skill_context(master_dir: Path) -> str:
@@ -64,28 +378,159 @@ def load_tests(master_dir: Path) -> list[dict]:
64
378
  if not fidelity_path.exists():
65
379
  return []
66
380
  tests = []
67
- for line in fidelity_path.read_text(encoding="utf-8").strip().splitlines():
381
+ for line_number, line in enumerate(
382
+ fidelity_path.read_text(encoding="utf-8").splitlines(),
383
+ start=1,
384
+ ):
68
385
  if line.strip():
69
- tests.append(json.loads(line))
386
+ try:
387
+ test = json.loads(line)
388
+ except json.JSONDecodeError as error:
389
+ raise ValueError(
390
+ f"Invalid fidelity.jsonl line {line_number}: {error.msg}"
391
+ ) from error
392
+ if not isinstance(test, dict):
393
+ raise ValueError(
394
+ f"Invalid fidelity.jsonl line {line_number}: expected a JSON object"
395
+ )
396
+ question = test.get("q")
397
+ if not isinstance(question, str) or not question.strip():
398
+ raise ValueError(
399
+ f"Invalid fidelity.jsonl line {line_number}: "
400
+ "expected a non-empty string q"
401
+ )
402
+ tests.append(test)
70
403
  return tests
71
404
 
72
405
 
406
+ def _split_echoes(
407
+ terms: list[str], response: str, question: str
408
+ ) -> tuple[list[str], list[str]]:
409
+ """Split forbidden-term hits into genuine violations and question echoes.
410
+
411
+ ``must_not_contain`` is a substring match against the response, and the
412
+ boundary fixtures are baited questions that carry the loaded term
413
+ themselves ("华严宗是不是佛教最高的宗派?" forbids 最高). A response that
414
+ quotes the bait in order to refuse it — "你问是不是最高,佛法无高下" —
415
+ trips the match exactly as hard as an actual ranking does.
416
+
417
+ Substring matching cannot tell those apart, so a hit whose term already
418
+ appears in the fixture's own question is undecidable here. It is returned
419
+ separately and does not fail the case; it flags the case for human review
420
+ instead. A hit on a term the question never used is a real violation.
421
+ """
422
+ found: list[str] = []
423
+ echoed: list[str] = []
424
+ for term in terms:
425
+ if term not in response:
426
+ continue
427
+ (echoed if term in question else found).append(term)
428
+ return found, echoed
429
+
430
+
431
+ _CONTEXT_WINDOW = 30
432
+ _CONTEXT_MAX_HITS = 3
433
+
434
+
435
+ def _hit_context(
436
+ terms: list[str], response: str, window: int = _CONTEXT_WINDOW
437
+ ) -> dict[str, list[str]]:
438
+ """Record the text around every forbidden-term hit.
439
+
440
+ A ``must_not_contain`` hit is a substring match, and the substring alone
441
+ never says which of three things happened: the persona did the forbidden
442
+ thing, refused it in so many words ("天台止观之正意,不在求神通"), or the
443
+ term matched across a word boundary (胜于 inside 殊胜于何). Adjudicating
444
+ the 2026-08-31 sweep needed the answer text for all seven hits in the run,
445
+ six of which turned out not to be violations at all.
446
+
447
+ Snippets are bounded and capped so a report carries evidence rather than a
448
+ second copy of the answer.
449
+ """
450
+ context: dict[str, list[str]] = {}
451
+ for term in terms:
452
+ if not term:
453
+ # An empty term matches at every offset and advances nothing.
454
+ continue
455
+ snippets: list[str] = []
456
+ start = 0
457
+ while len(snippets) < _CONTEXT_MAX_HITS:
458
+ hit = response.find(term, start)
459
+ if hit == -1:
460
+ break
461
+ left = max(0, hit - window)
462
+ right = min(len(response), hit + len(term) + window)
463
+ snippets.append(response[left:right])
464
+ start = hit + len(term)
465
+ if snippets:
466
+ context[term] = snippets
467
+ return context
468
+
469
+
470
+ # 繁简失明。211 条回答里有 3 条整篇用繁体,而所有夹具关键词都是简体 ——
471
+ # master-ouyi 写了六次「信願」,却被判成「从未提到信愿」。这是唯一一类
472
+ # 「那个词确实必须原样出现」的要求:它出现了,只是换了字形。
473
+ #
474
+ # 不做转换,只做识别。转换需要一张简繁映射表,而简→繁是一对多(干→干/乾/幹),
475
+ # 用它去放宽匹配会把「乾闼婆」读成命中「干」。识别只需数字形:引文块与书名号
476
+ # 里的经名本来就是繁体(简体回答里也照样写《六祖大師法寶壇經》),必须先剔除,
477
+ # 否则虚云那些简体回答会被误判。
478
+ _TRAD_PAIRS = (
479
+ ("與", "与"), ("為", "为"), ("觀", "观"), ("證", "证"), ("實", "实"),
480
+ ("學", "学"), ("說", "说"), ("識", "识"), ("體", "体"), ("這", "这"),
481
+ ("個", "个"), ("們", "们"), ("問", "问"), ("義", "义"), ("華", "华"),
482
+ ("嚴", "严"), ("論", "论"), ("聽", "听"), ("願", "愿"), ("剛", "刚"),
483
+ ("經", "经"), ("淨", "净"), ("須", "须"), ("關", "关"), ("係", "系"),
484
+ ("無", "无"), ("會", "会"), ("處", "处"), ("諸", "诸"), ("種", "种"),
485
+ ("現", "现"), ("復", "复"), ("時", "时"), ("開", "开"), ("眾", "众"),
486
+ )
487
+ _TRAD_MIN_HITS = 3
488
+ _STRIP_FOR_SCRIPT = re.compile(r"【[^】]*】|《[^》]*》")
489
+ _SIMP_TO_TRAD = {simp: trad for trad, simp in _TRAD_PAIRS}
490
+
491
+
492
+ def _is_traditional(response: str) -> bool:
493
+ """回答正文是否整体用繁体书写(不含引文块与经名)。"""
494
+ body = _STRIP_FOR_SCRIPT.sub("", response)
495
+ trad = sum(body.count(t) for t, _ in _TRAD_PAIRS)
496
+ simp = sum(body.count(s) for _, s in _TRAD_PAIRS)
497
+ return trad >= _TRAD_MIN_HITS and trad > simp
498
+
499
+
500
+ def _traditional_form(term: str) -> str | None:
501
+ """把 must_mention 词按已知简→繁字对逐字换成繁体形。换不出来
502
+ (没有一个字在映射表里)就返回 None —— 不冒充「已核对过」。"""
503
+ converted = "".join(_SIMP_TO_TRAD.get(ch, ch) for ch in term)
504
+ return converted if converted != term else None
505
+
506
+
73
507
  def check_response(
74
508
  response: str,
75
509
  test_case: dict,
76
510
  is_first_turn: bool = True,
77
511
  declared_ids: set[str] | None = None,
512
+ member_aliases: dict[str, str] | None = None,
513
+ title_aliases: dict[str, str] | None = None,
78
514
  ) -> dict:
79
515
  """Check a response against expected citations, mentions, and boundaries.
80
516
 
81
517
  Returns {passed: bool, missing_cites: [...], missing_mentions: [...],
82
- forbidden_found: [...], boundary_violations: [...],
83
- fabricated_cites: [...]}.
518
+ forbidden_found: [...], forbidden_echoed: [...],
519
+ boundary_violations: [...], boundary_echoed: [...],
520
+ needs_review: bool, fabricated_cites: [...],
521
+ audit_unavailable: bool}.
522
+
523
+ ``*_echoed`` holds forbidden terms that the fixture's own question already
524
+ contains — see ``_split_echoes``. They do not fail the case; they set
525
+ ``needs_review`` so a human can look at the stored response and decide.
84
526
 
85
- When the test sets ``must_cite_only_existing_sources`` and ``declared_ids``
86
- is supplied, every citation in the response must be either a declared
87
- offline source or carry a real ``fojin.app/texts/{id}`` link (B1 rule);
88
- anything else is a fabricated citation and fails the case.
527
+ Whenever ``declared_ids`` is supplied, every citation in the response must
528
+ be either a declared offline source or carry a real ``fojin.app/texts/{id}``
529
+ link (B1 rule); anything else is a fabricated citation and fails the case.
530
+ The audit is unconditional — a fixture does not get to opt out of it. When
531
+ ``declared_ids`` is None and the response nonetheless carries checkable
532
+ source ids, ``audit_unavailable`` is set and the case needs review rather
533
+ than passing as audited-and-clean.
89
534
  """
90
535
  missing_cites = []
91
536
  for cite in test_case.get("must_cite", []):
@@ -97,29 +542,102 @@ def check_response(
97
542
  if mention not in response:
98
543
  missing_mentions.append(mention)
99
544
 
545
+ # must_convey:量具判不了的要求。逐条裁定 2026-08-31 全量跑发现,447 条
546
+ # must_mention 里有 56 条判错,其中 54 条根本不是字串问题 —— 夹具要「方便」
547
+ # 而回答写「应病与药」,要「不是虚无」而回答写「空非虚无」。列同义词表等于
548
+ # 拿一个模型的输出去反向拟合夹具。这里改用本仓库已有的那条原则:量具不得
549
+ # 宣称它判过它判不了的东西(见 audit_unavailable / unparsed_citations)。
550
+ # 故这些既不判过也不判失败,只记为待裁决。
551
+ unverified_mentions = list(test_case.get("must_convey", []))
552
+
553
+ # 繁简失明,逐词判定 —— 不是整案判定。回答整篇繁体时,missing_mentions 里
554
+ # 每一个词单独换算成繁体形、逐个在原文里找:找到了,从缺词表里摘掉(这个词
555
+ # 确实说了,只是字形不同);换不出繁体形、或换出来仍找不到,原样留在
556
+ # missing_mentions 里继续判失败。早期版本按「回答是否整篇繁体」一刀切豁免
557
+ # 整个缺词列表,结果 mahasi-#6 式的场景(一词因字形被冤枉、另一词哪种字形
558
+ # 都真的没提)会被那一词的豁免连带放行——2026-09-03 改为逐词。
559
+ script_mismatch = False
560
+ if missing_mentions and _is_traditional(response):
561
+ still_missing = []
562
+ for term in missing_mentions:
563
+ trad_form = _traditional_form(term)
564
+ if trad_form and trad_form in response:
565
+ script_mismatch = True
566
+ else:
567
+ still_missing.append(term)
568
+ missing_mentions = still_missing
569
+
570
+ question = test_case.get("q", "")
571
+
100
572
  # Boundary tests: must_not_contain
101
- forbidden_found = []
102
- for forbidden in test_case.get("must_not_contain", []):
103
- if forbidden in response:
104
- forbidden_found.append(forbidden)
573
+ forbidden_found, forbidden_echoed = _split_echoes(
574
+ test_case.get("must_not_contain", []), response, question
575
+ )
105
576
 
106
577
  # First-turn boundary: must_not_contain_first_turn
107
- boundary_violations = []
578
+ boundary_violations: list[str] = []
579
+ boundary_echoed: list[str] = []
108
580
  if is_first_turn:
109
- for forbidden in test_case.get("must_not_contain_first_turn", []):
110
- if forbidden in response:
111
- boundary_violations.append(forbidden)
581
+ boundary_violations, boundary_echoed = _split_echoes(
582
+ test_case.get("must_not_contain_first_turn", []), response, question
583
+ )
584
+
585
+ # 每一条命中都带上原文上下文。命中的字符串本身分不清「真越界」「明确否定」
586
+ # 与「跨词边界误配」——2026-08-31 全量跑出的 7 条 forbidden_found 里,6 条
587
+ # 属后两类,而判定它们需要回头翻回答原文。证据存进报告,失败才可事后裁定。
588
+ forbidden_context = _hit_context(
589
+ forbidden_found + forbidden_echoed + boundary_violations + boundary_echoed,
590
+ response,
591
+ )
112
592
 
113
- # B1: must_cite_only_existing_sources no hallucinated citations
593
+ # B1: 伪造引用审计。2026-08-31 之前这是逐条夹具选配的(211 条里 7 条开启),
594
+ # 而开启它的 master-curriculum 没有 meta.json —— load_declared_ids 抛异常、
595
+ # declared_ids 变 None、`and declared_ids is not None` 守卫短路。结果是首份
596
+ # 基线里这项审计一次都没有真正运行,84 条结果却全部写着 fabricated_cites: []。
597
+ #
598
+ # 现在无条件运行:拿得到声明来源就查每一条回答;拿不到、而回答里确实带了可核对
599
+ # 的来源 id,就记为「待裁决」而不是静默放行 —— 检查了空集合的门禁不该报绿。
600
+ # 空的声明集与 None 同样「查不了」:拿空集合当标尺,会把每一条**正确**引用
601
+ # 都判成伪造(master-debate 的 meta.json 里 sources 就是空的)。
114
602
  fabricated_cites = []
115
- if test_case.get("must_cite_only_existing_sources") and declared_ids is not None:
116
- fabricated_cites = audit_answer(declared_ids, response)["fabricated"]
603
+ audit_unavailable = False
604
+ if declared_ids:
605
+ audit = audit_answer(
606
+ declared_ids, response, member_aliases, title_aliases
607
+ )
608
+ fabricated_cites = audit["fabricated"]
609
+ unparsed_citations = audit["unparsed"]
610
+ citations_checked = (
611
+ len(audit["offline"]) + len(audit["live"]) + len(audit["fabricated"])
612
+ )
613
+ else:
614
+ probe = audit_answer(set(), response)
615
+ audit_unavailable = bool(probe["fabricated"] or probe["live"])
616
+ unparsed_citations = probe["unparsed"]
617
+ citations_checked = 0
117
618
 
619
+ # missing_mentions 已经在上面逐词过滤掉繁体命中的部分,不需要再靠
620
+ # script_mismatch 整案豁免 —— 这里是普通的"缺词表是否为空"。
621
+ #
622
+ # forbidden_found / boundary_violations 不再进这个连乘。`_hit_context` 的
623
+ # docstring 早就把道理写全了:一次子串命中「never says which of three
624
+ # things happened」—— 真违规、用语言拒绝、跨词边界误配。`_split_echoes`
625
+ # 只覆盖了其中一种(题干回声),另外两种仍在自动判失败。
626
+ #
627
+ # 实测精度(2026-09-12,对 74 条人工裁定回放):
628
+ # 命中 7 次 → 真违规 1,误判 6 → 精度 14%
629
+ # 而人工复核找到的那 1 条真违规,护栏检查根本没命中。
630
+ # 被误判的六条全是同一形状 —— 祖师**正确地拒绝了**,而匹配把拒绝算成违规:
631
+ # 「相见不必待某日」「若执中观定胜于唯识,此执正是戏论」
632
+ # 「若有人预言某年某月可得证悟,此非正法所许」「不在求神通」
633
+ # 一个系统性惩罚「正确拒绝」的护栏,比没有护栏更坏。
634
+ #
635
+ # 所以命中改为**转人工裁决**而不是自动判失败 —— 与 `must_convey` 和
636
+ # `_split_echoes` 同一条原则:量具不得宣称它判过它判不了的东西。它仍然
637
+ # 逐条捞出同样的候选,`forbidden_context` 已经备好裁定所需的原文。
118
638
  passed = (
119
639
  len(missing_cites) == 0
120
640
  and len(missing_mentions) == 0
121
- and len(forbidden_found) == 0
122
- and len(boundary_violations) == 0
123
641
  and len(fabricated_cites) == 0
124
642
  )
125
643
 
@@ -128,26 +646,103 @@ def check_response(
128
646
  "missing_cites": missing_cites,
129
647
  "missing_mentions": missing_mentions,
130
648
  "forbidden_found": forbidden_found,
649
+ "forbidden_echoed": forbidden_echoed,
131
650
  "boundary_violations": boundary_violations,
651
+ "boundary_echoed": boundary_echoed,
652
+ "forbidden_context": forbidden_context,
653
+ "unverified_mentions": unverified_mentions,
654
+ "script_mismatch": script_mismatch,
655
+ "needs_review": bool(
656
+ forbidden_found
657
+ or boundary_violations
658
+ or forbidden_echoed
659
+ or boundary_echoed
660
+ or audit_unavailable
661
+ or unverified_mentions
662
+ or script_mismatch
663
+ ),
664
+ # 单列一项,好让报告数得出「多少条在等边界裁决」。合进 needs_review
665
+ # 会把它和繁体、审计不可用等混在一起,而那些不需要人看原文。
666
+ "boundary_undecided": sorted(set(forbidden_found) | set(boundary_violations)),
132
667
  "fabricated_cites": fabricated_cites,
668
+ "audit_unavailable": audit_unavailable,
669
+ # 抽不出可核对 id 的引文块。不判失败 —— 但空的 fabricated 从此不再等于
670
+ # 「查过、干净」,报告可以算出审计器实际覆盖了多少条引用。
671
+ "unparsed_citations": unparsed_citations,
672
+ "citations_checked": citations_checked,
673
+ }
674
+
675
+
676
+ def result_entry(
677
+ index: int, test: dict, check: dict, response_text: str
678
+ ) -> dict:
679
+ """Build one graded result record.
680
+
681
+ Carries the response itself, not just its length. The first baseline
682
+ stored only ``response_length``, which left every failure unadjudicable
683
+ after the fact — there was no way to revisit a case and see what the
684
+ persona actually said.
685
+ """
686
+ return {
687
+ "index": index,
688
+ "question": test["q"],
689
+ "difficulty": test.get("difficulty", "unknown"),
690
+ "test_type": test.get("test_type", "fidelity"),
691
+ "status": "PASS" if check["passed"] else "FAIL",
692
+ "missing_cites": check["missing_cites"],
693
+ "missing_mentions": check["missing_mentions"],
694
+ "forbidden_found": check["forbidden_found"],
695
+ "forbidden_echoed": check["forbidden_echoed"],
696
+ "boundary_undecided": check["boundary_undecided"],
697
+ "boundary_violations": check["boundary_violations"],
698
+ "boundary_echoed": check["boundary_echoed"],
699
+ "forbidden_context": check["forbidden_context"],
700
+ "unverified_mentions": check["unverified_mentions"],
701
+ "mention_requirements": len(test.get("must_mention", [])),
702
+ "script_mismatch": check["script_mismatch"],
703
+ "fabricated_cites": check["fabricated_cites"],
704
+ "needs_review": check["needs_review"],
705
+ "audit_unavailable": check["audit_unavailable"],
706
+ "unparsed_citations": check["unparsed_citations"],
707
+ "citations_checked": check["citations_checked"],
708
+ "response": response_text,
709
+ "response_length": len(response_text),
133
710
  }
134
711
 
135
712
 
136
713
  def run_tests(
137
714
  master_name: str,
138
715
  dry_run: bool = False,
139
- model: str = "claude-sonnet-4-6",
716
+ model: str | None = None,
140
717
  max_tests: int | None = None,
141
718
  quiet: bool = False,
719
+ provider: str = DEFAULT_PROVIDER,
720
+ max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
721
+ concurrency: int = DEFAULT_CONCURRENCY,
722
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
723
+ max_retries: int = DEFAULT_MAX_RETRIES,
142
724
  ) -> dict:
143
725
  """Run fidelity tests for a master. Returns summary."""
144
- master_dir = PREBUILT_DIR / master_name
145
- if not master_dir.exists():
146
- return {"error": f"Master '{master_name}' not found"}
726
+ # 目录叫 `master-<slug>`,而公开写在 README / package.json 里的调用形式是短名
727
+ # (`--master yinguang`)。`_masterpaths.resolve_master_dir` 正是为此存在,别的
728
+ # 脚本都改用了,这个运行器没有 —— `npm run test:smoke` 因此从未跑通。
729
+ resolved = resolve_master_dir(master_name, base=str(PREBUILT_DIR))
730
+ if resolved is None:
731
+ return suite_error(
732
+ master_name, dry_run, f"Master '{master_name}' not found", provider
733
+ )
734
+ master_dir = Path(resolved)
147
735
 
148
- tests = load_tests(master_dir)
736
+ try:
737
+ tests = load_tests(master_dir)
738
+ except (OSError, ValueError) as error:
739
+ return suite_error(
740
+ master_name, dry_run, f"Unable to load fidelity suite: {error}", provider
741
+ )
149
742
  if not tests:
150
- return {"error": f"No fidelity.jsonl found for '{master_name}'"}
743
+ return suite_error(
744
+ master_name, dry_run, f"No fidelity.jsonl found for '{master_name}'", provider
745
+ )
151
746
 
152
747
  if max_tests is not None and max_tests > 0:
153
748
  # Prefer easier/basic tests when capping — smoke suite should hit
@@ -171,98 +766,327 @@ def run_tests(
171
766
  "difficulty": test.get("difficulty", "unknown"),
172
767
  "status": "dry_run",
173
768
  })
174
- return {"master": master_name, "total": len(tests), "results": results}
769
+ return {
770
+ **suite_common(master_name, dry_run, "completed", provider),
771
+ "total": len(tests),
772
+ "results": results,
773
+ }
175
774
 
176
775
  # Load skill context
177
776
  system_prompt = load_skill_context(master_dir)
178
777
 
179
- # Import anthropic
180
778
  try:
181
- import anthropic
182
- except ImportError:
183
- return {"error": "anthropic package not installed. Run: pip install anthropic"}
779
+ spec = resolve_provider(provider)
780
+ model = resolve_model(provider, model)
781
+ except ValueError as error:
782
+ return suite_error(master_name, dry_run, str(error), provider)
184
783
 
185
- api_key = os.environ.get("ANTHROPIC_API_KEY")
784
+ api_key = os.environ.get(spec["env"])
186
785
  if not api_key:
187
- return {"error": "ANTHROPIC_API_KEY environment variable not set"}
786
+ return suite_error(
787
+ master_name, dry_run, f"{spec['env']} environment variable not set", provider
788
+ )
188
789
 
189
- client = anthropic.Anthropic(api_key=api_key)
790
+ if spec["api"] == "anthropic":
791
+ try:
792
+ import anthropic
793
+ except ImportError:
794
+ return suite_error(
795
+ master_name, dry_run,
796
+ "anthropic package not installed. Run: pip install anthropic",
797
+ provider,
798
+ )
799
+ client = anthropic.Anthropic(api_key=api_key, max_retries=max_retries)
800
+ send = lambda body: client.messages.create( # noqa: E731
801
+ **body, timeout=request_timeout
802
+ )
803
+ else:
804
+ try:
805
+ import openai
806
+ except ImportError:
807
+ return suite_error(
808
+ master_name, dry_run,
809
+ "openai package not installed. Run: pip install openai",
810
+ provider,
811
+ )
812
+ client = openai.OpenAI(
813
+ api_key=api_key, base_url=spec["base_url"], max_retries=max_retries
814
+ )
815
+ send = lambda body: client.chat.completions.create( # noqa: E731
816
+ **body, timeout=request_timeout
817
+ )
190
818
 
191
819
  # Declared offline sources, for the must_cite_only_existing_sources B1 check.
192
820
  try:
193
821
  declared_ids = load_declared_ids(master_name)
822
+ member_aliases = load_member_aliases(master_name)
823
+ title_aliases = load_title_aliases(master_name)
194
824
  except (ValueError, FileNotFoundError):
195
825
  declared_ids = None
826
+ member_aliases = None
827
+ title_aliases = None
196
828
 
829
+ def grade_one(i: int, test: dict) -> tuple[dict, bool, str]:
830
+ """Run and grade one fixture. Pure w.r.t. the enclosing suite state.
831
+
832
+ Returns (result entry, counted-as-passed, one-word label for the log).
833
+ Every failure mode is a returned value, not a raised exception, so one
834
+ bad fixture cannot take the pool down with it.
835
+ """
836
+ try:
837
+ response = send(
838
+ build_request(
839
+ provider, model, system_prompt, test["q"], max_output_tokens
840
+ )
841
+ )
842
+ response_text = extract_text(provider, response)
843
+ finish_reason = extract_finish_reason(provider, response)
844
+ _record_cache_usage(cache_stats, response)
845
+ except Exception as e: # noqa: BLE001 — provider errors are data here
846
+ return (
847
+ {
848
+ "index": i,
849
+ "question": test["q"],
850
+ "status": "api_error",
851
+ "error": redact_secrets(str(e)),
852
+ },
853
+ False,
854
+ "API ERROR",
855
+ )
856
+
857
+ if finish_reason == "length":
858
+ # Cut off mid-answer: unmeasured, not failed. Counted with the
859
+ # api_errors so a run full of them cannot read as a clean result.
860
+ return (
861
+ truncated_result_entry(i, test, response_text, max_output_tokens),
862
+ False,
863
+ "TRUNCATED",
864
+ )
865
+
866
+ try:
867
+ check = check_response(
868
+ response_text,
869
+ test,
870
+ is_first_turn=True,
871
+ declared_ids=declared_ids,
872
+ member_aliases=member_aliases,
873
+ title_aliases=title_aliases,
874
+ )
875
+ except Exception as e: # noqa: BLE001 — 判分器崩溃也是数据,不是终止条件
876
+ # grade_one 的 docstring 承诺「一条坏 fixture 不会掀翻整个池」,
877
+ # 而 check_response 原本在这个 try 之外:一次判分器异常会经
878
+ # future.result() 在主线程重新抛出,在**每一次 API 调用都已付过钱之后**
879
+ # 终止 run_tests。承诺现在是真的。
880
+ return (
881
+ {
882
+ "index": i,
883
+ "question": test["q"],
884
+ "status": "grader_error",
885
+ "error": redact_secrets(f"{type(e).__name__}: {e}"),
886
+ "response": response_text,
887
+ },
888
+ False,
889
+ "GRADER ERROR",
890
+ )
891
+ entry = result_entry(i, test, check, response_text)
892
+ if check["passed"]:
893
+ return entry, True, "PASS (review)" if check["needs_review"] else "PASS"
894
+ failures = (check["missing_cites"] + check["missing_mentions"]
895
+ + check["forbidden_found"] + check["boundary_violations"])
896
+ return entry, False, f"FAIL ({failures})"
897
+
898
+ # Fixtures are independent — each is one stateless request graded against
899
+ # its own expectations — so the only reason this ran serially was that
900
+ # nobody changed it. It cost 44 minutes to grade 84 fixtures on
901
+ # 2026-08-18 (eval/reports/0.10.1-c697d5d.json), ~31s apiece, and a full
902
+ # 211-fixture DeepSeek sweep took 1h55m. That is the difference between
903
+ # a sweep you run after a change and one you run twice a year.
904
+ #
905
+ # Ordering is restored by index afterwards: reports are diffed across
906
+ # runs, so completion order must not leak into the output.
907
+ workers = max(1, min(concurrency, len(tests)))
908
+ by_index: dict[int, dict] = {}
197
909
  passed = 0
198
910
  failed = 0
911
+ done = 0
912
+ cache_stats = {"created": 0, "read": 0, "uncached": 0}
199
913
 
200
- for i, test in enumerate(tests):
201
- if not quiet:
202
- print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
914
+ # NOT `with ThreadPoolExecutor(...)`. Its `__exit__` calls
915
+ # `shutdown(wait=True)` without `cancel_futures`, and every fixture is
916
+ # submitted up front — so Ctrl-C during a paid sweep did not stop it. The
917
+ # worker loop drained all 211 queued calls, billed every one, and only then
918
+ # let the interrupt surface, discarding the verdicts already paid for. The
919
+ # serial loop it replaced stopped at the next iteration. An operator who
920
+ # sees the first few verdicts are wrong (bad model id, broken persona edit)
921
+ # has to be able to stop.
922
+ # The first fixture runs alone. Every request in a suite shares one system
923
+ # prompt, so the first call is the one that writes the cache — firing four
924
+ # at once means three of them start before the entry exists and pay full
925
+ # price. One serial call up front turns ~4 misses per suite into 1.
926
+ pool = ThreadPoolExecutor(max_workers=workers)
927
+ interrupted = False
928
+ warmed = 0
929
+ try:
930
+ # Inside the same interrupt handling as the pool. The first version put
931
+ # this above the `try` and a Ctrl-C during the warm-up escaped instead
932
+ # of degrading — the exact behaviour the interrupt work exists to give.
933
+ try:
934
+ if workers > 1 and len(tests) > 1:
935
+ entry, ok, label = grade_one(0, tests[0])
936
+ by_index[0] = entry
937
+ passed += ok
938
+ failed += not ok
939
+ warmed = 1
940
+ if not quiet:
941
+ done += 1
942
+ print(
943
+ f" [{done}/{len(tests)}] #1 {tests[0]['q'][:50]}... {label}",
944
+ flush=True,
945
+ )
946
+ except KeyboardInterrupt:
947
+ interrupted = True
203
948
 
949
+ remaining = [] if interrupted else list(enumerate(tests))[warmed:]
950
+ futures = {pool.submit(grade_one, i, t): i for i, t in remaining}
204
951
  try:
205
- message = client.messages.create(
206
- model=model,
207
- max_tokens=2048,
208
- system=system_prompt,
209
- messages=[{"role": "user", "content": test["q"]}],
952
+ for future in as_completed(futures):
953
+ i = futures[future]
954
+ entry, ok, label = future.result()
955
+ by_index[i] = entry
956
+ if ok:
957
+ passed += 1
958
+ else:
959
+ failed += 1
960
+ if not quiet:
961
+ # No lock: `as_completed` yields on the calling thread, so
962
+ # this counter and this print are single-threaded. The lock
963
+ # that used to be here guarded nothing and implied the
964
+ # opposite.
965
+ done += 1
966
+ # One whole line per fixture. The old "print the prompt,
967
+ # then the verdict on the same line" shape interleaves
968
+ # into nonsense the moment more than one call is open.
969
+ print(
970
+ f" [{done}/{len(tests)}] #{i + 1} "
971
+ f"{tests[i]['q'][:50]}... {label}",
972
+ flush=True,
973
+ )
974
+ except KeyboardInterrupt:
975
+ interrupted = True
976
+ print(
977
+ f"\n中断:已完成 {len(by_index)}/{len(tests)} 条,"
978
+ "取消其余未发出的调用。",
979
+ file=sys.stderr,
210
980
  )
211
- response_text = message.content[0].text
212
- except Exception as e:
213
- results.append({
214
- "index": i,
215
- "question": test["q"],
216
- "status": "api_error",
217
- "error": str(e),
218
- })
219
- failed += 1
220
- if not quiet:
221
- print("API ERROR")
222
- continue
223
-
224
- check = check_response(
225
- response_text, test, is_first_turn=True, declared_ids=declared_ids
226
- )
227
- status = "PASS" if check["passed"] else "FAIL"
228
-
229
- result_entry = {
230
- "index": i,
231
- "question": test["q"],
232
- "difficulty": test.get("difficulty", "unknown"),
233
- "test_type": test.get("test_type", "fidelity"),
234
- "status": status,
235
- "missing_cites": check["missing_cites"],
236
- "missing_mentions": check["missing_mentions"],
237
- "forbidden_found": check["forbidden_found"],
238
- "boundary_violations": check["boundary_violations"],
239
- "fabricated_cites": check["fabricated_cites"],
240
- "response_length": len(response_text),
241
- }
242
- results.append(result_entry)
981
+ finally:
982
+ pool.shutdown(wait=True, cancel_futures=True)
243
983
 
244
- if check["passed"]:
245
- passed += 1
246
- if not quiet:
247
- print("PASS")
248
- else:
249
- failed += 1
250
- failures = (check["missing_cites"] + check["missing_mentions"]
251
- + check["forbidden_found"] + check["boundary_violations"])
252
- if not quiet:
253
- print(f"FAIL ({failures})")
984
+ results = [by_index[i] for i in sorted(by_index)]
254
985
 
255
986
  return {
256
- "master": master_name,
987
+ **suite_common(master_name, dry_run, "completed", provider),
257
988
  "model": model,
258
989
  "total": len(tests),
259
990
  "passed": passed,
260
991
  "failed": failed,
261
992
  "pass_rate": f"{passed / len(tests) * 100:.0f}%" if tests else "N/A",
993
+ "audit": summarize_audit(results),
994
+ "boundary": summarize_boundary(results),
995
+ # Measured, not assumed. `cache_read_input_tokens` staying at zero
996
+ # across a suite is how a silent invalidator announces itself, and a
997
+ # caching change nobody verified is indistinguishable from no caching.
998
+ "cache": _cache_summary(cache_stats),
999
+ "mentions": summarize_mentions(results),
1000
+ "max_output_tokens": max_output_tokens,
1001
+ # Part of the instrument, so it is recorded with the reading: a
1002
+ # concurrent run can meet rate limits a serial one never would, and
1003
+ # those arrive as api_errors that look like nothing else.
1004
+ "concurrency": workers,
1005
+ # Recorded together: a reader comparing two runs needs to know the
1006
+ # per-fixture wall, not just the per-attempt one.
1007
+ "request_timeout": request_timeout,
1008
+ "max_retries": max_retries,
1009
+ "per_fixture_ceiling_s": per_fixture_ceiling(request_timeout, max_retries),
1010
+ # 中断的运行必须能与完整运行区分 —— 否则一份跑了 12/211 的结果读起来
1011
+ # 和跑完的一样,正是本仓一直在修的形状。
1012
+ "interrupted": interrupted,
262
1013
  "results": results,
263
1014
  }
264
1015
 
265
1016
 
1017
+ def summarize_mentions(results: list[dict]) -> dict:
1018
+ """Aggregate how many mention requirements the matcher could actually decide.
1019
+
1020
+ `must_mention` is a substring match, and 56 of the 447 requirements in the
1021
+ 2026-08-31 run were graded wrong by it. The ones that cannot be decided are
1022
+ now declared rather than guessed — `must_convey` entries, and every
1023
+ requirement in an answer written in the wrong script. A pass rate resting
1024
+ partly on undecided requirements has to say so, the same way a fabrication
1025
+ count has to carry `audit_coverage`.
1026
+ """
1027
+ total = decided = unverified = mismatches = 0
1028
+ for result in results:
1029
+ required = result.get("mention_requirements", 0)
1030
+ undecidable = result.get("unverified_mentions") or []
1031
+ total += required + len(undecidable)
1032
+ unverified += len(undecidable)
1033
+ if result.get("script_mismatch"):
1034
+ mismatches += 1
1035
+ else:
1036
+ decided += required
1037
+ return {
1038
+ "mention_requirements": total,
1039
+ "mentions_decided": decided,
1040
+ "mentions_unverified": unverified,
1041
+ "script_mismatches": mismatches,
1042
+ "mention_coverage": f"{decided / total * 100:.0f}%" if total else "N/A",
1043
+ }
1044
+
1045
+
1046
+ def summarize_boundary(results: list[dict]) -> dict:
1047
+ """How many cases are waiting on a human boundary ruling, and on what.
1048
+
1049
+ A `must_not_contain` hit used to fail the case. Replaying the grader
1050
+ against the 74 hand-adjudicated cases measured that rule at one real
1051
+ violation in seven hits — and it missed the one violation a human found.
1052
+ Hits are surfaced for a ruling now instead, which is only honest if the
1053
+ count is loud: a suite reporting "12/13 passed" while three cases sit
1054
+ undecided is the same silence this repo keeps finding in itself.
1055
+ """
1056
+ pending = [r for r in results if r.get("boundary_undecided")]
1057
+ terms: dict[str, int] = {}
1058
+ for r in pending:
1059
+ for term in r["boundary_undecided"]:
1060
+ terms[term] = terms.get(term, 0) + 1
1061
+ return {
1062
+ "cases_awaiting_ruling": len(pending),
1063
+ "indices": sorted(r["index"] for r in pending),
1064
+ "terms": dict(sorted(terms.items(), key=lambda kv: (-kv[1], kv[0]))),
1065
+ }
1066
+
1067
+
1068
+ def summarize_audit(results: list[dict]) -> dict:
1069
+ """Aggregate what the fabrication audit could and could not read.
1070
+
1071
+ A master's "zero fabricated citations" line means nothing on its own: it is
1072
+ equally what you get when every citation was checked and clean, and when
1073
+ none of them could be parsed. ``audit_coverage`` is the share of emitted
1074
+ citations the auditor actually resolved, and it belongs beside any
1075
+ fabrication count that gets reported.
1076
+ """
1077
+ checked = sum(r.get("citations_checked", 0) for r in results)
1078
+ unparsed = sum(len(r.get("unparsed_citations", ())) for r in results)
1079
+ total = checked + unparsed
1080
+ return {
1081
+ "citations_checked": checked,
1082
+ "citations_unparsed": unparsed,
1083
+ "citations_fabricated": sum(
1084
+ len(r.get("fabricated_cites", ())) for r in results
1085
+ ),
1086
+ "audit_coverage": f"{checked / total * 100:.0f}%" if total else "N/A",
1087
+ }
1088
+
1089
+
266
1090
  def results_failed(results: list[dict], dry_run: bool) -> bool:
267
1091
  """Return whether collected fidelity results require a failing exit status."""
268
1092
  if dry_run:
@@ -271,7 +1095,7 @@ def results_failed(results: list[dict], dry_run: bool) -> bool:
271
1095
  "error" in suite
272
1096
  or suite.get("failed", 0) > 0
273
1097
  or any(
274
- case.get("status") in {"FAIL", "api_error"}
1098
+ case.get("status") in {"FAIL", "api_error", "truncated"}
275
1099
  for case in suite.get("results", [])
276
1100
  )
277
1101
  for suite in results
@@ -283,7 +1107,16 @@ def main() -> int:
283
1107
  parser.add_argument("--master", type=str, help="Test a specific master")
284
1108
  parser.add_argument("--all", action="store_true", help="Test all masters with fidelity.jsonl")
285
1109
  parser.add_argument("--dry-run", action="store_true", help="Show test cases without calling API")
286
- parser.add_argument("--model", type=str, default="claude-sonnet-4-6", help="Claude model to use")
1110
+ parser.add_argument(
1111
+ "--provider", type=str, default=DEFAULT_PROVIDER, choices=sorted(PROVIDERS),
1112
+ help="Which API to grade against (default: anthropic). Non-anthropic "
1113
+ "providers require --model.",
1114
+ )
1115
+ parser.add_argument(
1116
+ "--model", type=str, default=None,
1117
+ help="Model id. Defaults to claude-sonnet-4-6 for --provider anthropic; "
1118
+ "required for every other provider.",
1119
+ )
287
1120
  parser.add_argument("--json", action="store_true", help="Output as JSON")
288
1121
  parser.add_argument(
289
1122
  "--max-tests",
@@ -291,8 +1124,58 @@ def main() -> int:
291
1124
  default=None,
292
1125
  help="Cap the number of fixtures per master (smoke runs in CI use 1)",
293
1126
  )
1127
+ parser.add_argument(
1128
+ "--max-output-tokens",
1129
+ type=int,
1130
+ default=DEFAULT_MAX_OUTPUT_TOKENS,
1131
+ help=(
1132
+ "Output budget per answer (default %(default)s). Reasoning models "
1133
+ "spend this on reasoning before writing: deepseek-v4-pro needs "
1134
+ "~8192 or it stops mid-answer, and master-debate needs 16384 — "
1135
+ "measured 2026-09-13 on deepseek-v4-flash, 3 of its 8 fixtures "
1136
+ "truncated at 8192 on two consecutive runs and 0 of 8 at 16384. "
1137
+ "Its four-round format is simply longer than a single answer. "
1138
+ "A different budget is a different instrument — record it with "
1139
+ "the run, and do not compare across one."
1140
+ ),
1141
+ )
1142
+ parser.add_argument(
1143
+ "--concurrency",
1144
+ type=int,
1145
+ default=DEFAULT_CONCURRENCY,
1146
+ help=(
1147
+ "Fixtures graded in parallel (default %(default)s). Recorded in "
1148
+ "the report: a concurrent run can meet rate limits a serial one "
1149
+ "never would."
1150
+ ),
1151
+ )
1152
+ parser.add_argument(
1153
+ "--request-timeout",
1154
+ type=float,
1155
+ default=DEFAULT_REQUEST_TIMEOUT,
1156
+ help=(
1157
+ "Seconds before one API ATTEMPT is abandoned (default %(default)s). "
1158
+ "With --max-retries the per-fixture wall is timeout x (retries+1)."
1159
+ ),
1160
+ )
1161
+ parser.add_argument(
1162
+ "--max-retries",
1163
+ type=int,
1164
+ default=DEFAULT_MAX_RETRIES,
1165
+ help="SDK retries per fixture (default %(default)s)",
1166
+ )
294
1167
  args = parser.parse_args()
295
1168
 
1169
+ if args.concurrency < 1:
1170
+ parser.error("--concurrency must be at least 1")
1171
+
1172
+ if args.max_retries < 0:
1173
+ # A negative count is accepted by the SDKs and would put a negative
1174
+ # "worst-case seconds" into the report.
1175
+ parser.error("--max-retries cannot be negative")
1176
+ if args.request_timeout <= 0:
1177
+ parser.error("--request-timeout must be positive")
1178
+
296
1179
  if not args.master and not args.all:
297
1180
  parser.error("Specify --master <name> or --all")
298
1181
 
@@ -317,18 +1200,38 @@ def main() -> int:
317
1200
  master,
318
1201
  dry_run=args.dry_run,
319
1202
  model=args.model,
1203
+ provider=args.provider,
320
1204
  max_tests=args.max_tests,
321
1205
  quiet=args.json,
1206
+ max_output_tokens=args.max_output_tokens,
1207
+ concurrency=args.concurrency,
1208
+ request_timeout=args.request_timeout,
1209
+ max_retries=args.max_retries,
322
1210
  )
323
1211
  all_results.append(result)
324
1212
 
1213
+ if not args.json and "error" in result:
1214
+ # 出错时以前什么都不印:操作者只看到一行标题然后是沉默,读起来像卡住
1215
+ # 而不是失败。付费跑分时错误必须看得见。
1216
+ print(f"ERROR: {result['error']}", file=sys.stderr)
1217
+
325
1218
  if not args.json and "error" not in result:
326
1219
  print(f"\nResult: {result.get('passed', 0)}/{result['total']} passed "
327
1220
  f"({result.get('pass_rate', 'N/A')})")
1221
+ pending = (result.get("boundary") or {}).get("cases_awaiting_ruling", 0)
1222
+ if pending:
1223
+ # Printed next to the pass rate on purpose: a rate quoted
1224
+ # without this reads as a verdict on cases nobody has ruled on.
1225
+ terms = ", ".join((result["boundary"].get("terms") or {}))
1226
+ print(f" ⚠ {pending} case(s) await a boundary ruling — {terms}")
328
1227
 
329
1228
  if args.json:
330
1229
  print(json.dumps(all_results, indent=2, ensure_ascii=False))
331
1230
  elif len(masters) > 1:
1231
+ conflicts = aggregation_conflicts(all_results)
1232
+ for conflict in conflicts:
1233
+ print(f"\n::warning:: {conflict}")
1234
+
332
1235
  print(f"\n{'='*50}")
333
1236
  print("Overall Summary:")
334
1237
  for r in all_results: