master-skill 0.11.0 → 0.12.1

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 (69) 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/README.md +48 -55
  5. package/README_EN.md +72 -59
  6. package/bin/cli.mjs +12 -7
  7. package/gemini-extension.json +1 -1
  8. package/hooks/session-start +68 -77
  9. package/hooks/session_start.py +152 -0
  10. package/package.json +5 -2
  11. package/prebuilt/compare-masters/SKILL.md +21 -2
  12. package/prebuilt/master-ajahn-chah/meta.json +6 -0
  13. package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
  14. package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
  15. package/prebuilt/master-curriculum/references/tiantai.md +1 -1
  16. package/prebuilt/master-debate/SKILL.md +14 -2
  17. package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
  18. package/prebuilt/master-help/SKILL.md +9 -1
  19. package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
  20. package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
  21. package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
  22. package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
  23. package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
  24. package/prebuilt/master-ouyi/meta.json +5 -0
  25. package/prebuilt/master-ouyi/references/teaching.md +3 -3
  26. package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
  27. package/prebuilt/master-tsongkhapa/meta.json +6 -0
  28. package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
  29. package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
  30. package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
  31. package/prebuilt/master-zhiyi/meta.json +2 -2
  32. package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
  33. package/scripts/check-audit-ignores.py +105 -0
  34. package/scripts/check-eval-sdk-surface.py +142 -0
  35. package/scripts/check-gate-liveness.py +205 -6
  36. package/scripts/reaudit-report.py +163 -0
  37. package/scripts/regrade-report.py +157 -0
  38. package/scripts/smoke-eval-sdk.py +174 -0
  39. package/scripts/test-fidelity.py +684 -52
  40. package/scripts/validate-citation-references.py +150 -0
  41. package/scripts/validate-citation-templates.py +176 -0
  42. package/scripts/validate-fixture-terms.py +127 -0
  43. package/scripts/verify-adjudication.py +316 -0
  44. package/scripts/verify_citations.py +739 -39
  45. package/tools/cross_reference.py +44 -10
  46. package/tools/fojin-known-absent.json +14 -0
  47. package/tools/fojin_bridge.py +138 -8
  48. package/tools/rag_query.py +45 -2
  49. package/tools/skill_writer.py +50 -7
  50. package/tools/verify_sources.py +240 -15
  51. package/hooks/tests/test_run_hook.sh +0 -114
  52. package/hooks/tests/test_run_hook_cmd.sh +0 -94
  53. package/hooks/tests/test_session_start.sh +0 -149
  54. package/scripts/tests/test_check_gate_liveness.py +0 -232
  55. package/scripts/tests/test_check_manifest_versions.py +0 -217
  56. package/scripts/tests/test_check_response.py +0 -190
  57. package/scripts/tests/test_debate_protocol.py +0 -159
  58. package/scripts/tests/test_fidelity_providers.py +0 -202
  59. package/scripts/tests/test_injection_hardening.py +0 -174
  60. package/scripts/tests/test_select_fidelity_smoke.py +0 -142
  61. package/scripts/tests/test_validate.py +0 -145
  62. package/scripts/tests/test_validate_citation_contract.py +0 -408
  63. package/scripts/tests/test_validate_cross_critique.py +0 -149
  64. package/scripts/tests/test_validate_curriculum_sources.py +0 -144
  65. package/scripts/tests/test_validate_fidelity.py +0 -59
  66. package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
  67. package/scripts/tests/test_validate_persona_fidelity.py +0 -317
  68. package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
  69. package/scripts/tests/test_validate_workflow.py +0 -284
@@ -23,12 +23,19 @@ 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"
34
41
  SCHEMA_VERSION = 1
@@ -65,8 +72,66 @@ PROVIDERS: dict[str, dict] = {
65
72
  },
66
73
  }
67
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
+
68
80
  DEFAULT_PROVIDER = "anthropic"
69
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
+
70
135
 
71
136
  def resolve_provider(name: str) -> dict:
72
137
  """Look up a provider spec, failing with the list of what is available."""
@@ -105,7 +170,22 @@ def build_request(
105
170
  return {
106
171
  "model": model,
107
172
  "max_tokens": max_tokens,
108
- "system": system_prompt,
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
+ ],
109
189
  "messages": [{"role": "user", "content": question}],
110
190
  }
111
191
  return {
@@ -118,6 +198,44 @@ def build_request(
118
198
  }
119
199
 
120
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
+
121
239
  def extract_text(provider: str, response: object) -> str:
122
240
  """Pull the answer text out of this provider's response object.
123
241
 
@@ -142,6 +260,47 @@ def extract_text(provider: str, response: object) -> str:
142
260
  return content
143
261
 
144
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
+
145
304
  def aggregation_conflicts(suites: list[dict]) -> list[str]:
146
305
  """Report why a set of suites must not be pooled into one number.
147
306
 
@@ -269,27 +428,109 @@ def _split_echoes(
269
428
  return found, echoed
270
429
 
271
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
+
272
507
  def check_response(
273
508
  response: str,
274
509
  test_case: dict,
275
510
  is_first_turn: bool = True,
276
511
  declared_ids: set[str] | None = None,
512
+ member_aliases: dict[str, str] | None = None,
513
+ title_aliases: dict[str, str] | None = None,
277
514
  ) -> dict:
278
515
  """Check a response against expected citations, mentions, and boundaries.
279
516
 
280
517
  Returns {passed: bool, missing_cites: [...], missing_mentions: [...],
281
518
  forbidden_found: [...], forbidden_echoed: [...],
282
519
  boundary_violations: [...], boundary_echoed: [...],
283
- needs_review: bool, fabricated_cites: [...]}.
520
+ needs_review: bool, fabricated_cites: [...],
521
+ audit_unavailable: bool}.
284
522
 
285
523
  ``*_echoed`` holds forbidden terms that the fixture's own question already
286
524
  contains — see ``_split_echoes``. They do not fail the case; they set
287
525
  ``needs_review`` so a human can look at the stored response and decide.
288
526
 
289
- When the test sets ``must_cite_only_existing_sources`` and ``declared_ids``
290
- is supplied, every citation in the response must be either a declared
291
- offline source or carry a real ``fojin.app/texts/{id}`` link (B1 rule);
292
- 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.
293
534
  """
294
535
  missing_cites = []
295
536
  for cite in test_case.get("must_cite", []):
@@ -301,6 +542,31 @@ def check_response(
301
542
  if mention not in response:
302
543
  missing_mentions.append(mention)
303
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
+
304
570
  question = test_case.get("q", "")
305
571
 
306
572
  # Boundary tests: must_not_contain
@@ -316,16 +582,62 @@ def check_response(
316
582
  test_case.get("must_not_contain_first_turn", []), response, question
317
583
  )
318
584
 
319
- # B1: must_cite_only_existing_sources — no hallucinated citations
320
- fabricated_cites = []
321
- if test_case.get("must_cite_only_existing_sources") and declared_ids is not None:
322
- fabricated_cites = audit_answer(declared_ids, response)["fabricated"]
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
+ )
323
592
 
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 就是空的)。
602
+ fabricated_cites = []
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
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` 已经备好裁定所需的原文。
324
638
  passed = (
325
639
  len(missing_cites) == 0
326
640
  and len(missing_mentions) == 0
327
- and len(forbidden_found) == 0
328
- and len(boundary_violations) == 0
329
641
  and len(fabricated_cites) == 0
330
642
  )
331
643
 
@@ -337,8 +649,27 @@ def check_response(
337
649
  "forbidden_echoed": forbidden_echoed,
338
650
  "boundary_violations": boundary_violations,
339
651
  "boundary_echoed": boundary_echoed,
340
- "needs_review": bool(forbidden_echoed or 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)),
341
667
  "fabricated_cites": fabricated_cites,
668
+ "audit_unavailable": audit_unavailable,
669
+ # 抽不出可核对 id 的引文块。不判失败 —— 但空的 fabricated 从此不再等于
670
+ # 「查过、干净」,报告可以算出审计器实际覆盖了多少条引用。
671
+ "unparsed_citations": unparsed_citations,
672
+ "citations_checked": citations_checked,
342
673
  }
343
674
 
344
675
 
@@ -362,10 +693,18 @@ def result_entry(
362
693
  "missing_mentions": check["missing_mentions"],
363
694
  "forbidden_found": check["forbidden_found"],
364
695
  "forbidden_echoed": check["forbidden_echoed"],
696
+ "boundary_undecided": check["boundary_undecided"],
365
697
  "boundary_violations": check["boundary_violations"],
366
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"],
367
703
  "fabricated_cites": check["fabricated_cites"],
368
704
  "needs_review": check["needs_review"],
705
+ "audit_unavailable": check["audit_unavailable"],
706
+ "unparsed_citations": check["unparsed_citations"],
707
+ "citations_checked": check["citations_checked"],
369
708
  "response": response_text,
370
709
  "response_length": len(response_text),
371
710
  }
@@ -378,13 +717,21 @@ def run_tests(
378
717
  max_tests: int | None = None,
379
718
  quiet: bool = False,
380
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,
381
724
  ) -> dict:
382
725
  """Run fidelity tests for a master. Returns summary."""
383
- master_dir = PREBUILT_DIR / master_name
384
- if not master_dir.exists():
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:
385
731
  return suite_error(
386
732
  master_name, dry_run, f"Master '{master_name}' not found", provider
387
733
  )
734
+ master_dir = Path(resolved)
388
735
 
389
736
  try:
390
737
  tests = load_tests(master_dir)
@@ -449,8 +796,10 @@ def run_tests(
449
796
  "anthropic package not installed. Run: pip install anthropic",
450
797
  provider,
451
798
  )
452
- client = anthropic.Anthropic(api_key=api_key)
453
- send = lambda body: client.messages.create(**body) # noqa: E731
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
+ )
454
803
  else:
455
804
  try:
456
805
  import openai
@@ -460,54 +809,179 @@ def run_tests(
460
809
  "openai package not installed. Run: pip install openai",
461
810
  provider,
462
811
  )
463
- client = openai.OpenAI(api_key=api_key, base_url=spec["base_url"])
464
- send = lambda body: client.chat.completions.create(**body) # noqa: E731
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
+ )
465
818
 
466
819
  # Declared offline sources, for the must_cite_only_existing_sources B1 check.
467
820
  try:
468
821
  declared_ids = load_declared_ids(master_name)
822
+ member_aliases = load_member_aliases(master_name)
823
+ title_aliases = load_title_aliases(master_name)
469
824
  except (ValueError, FileNotFoundError):
470
825
  declared_ids = None
826
+ member_aliases = None
827
+ title_aliases = None
471
828
 
472
- passed = 0
473
- failed = 0
474
-
475
- for i, test in enumerate(tests):
476
- if not quiet:
477
- print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
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.
478
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
+ """
479
836
  try:
480
837
  response = send(
481
- build_request(provider, model, system_prompt, test["q"], 2048)
838
+ build_request(
839
+ provider, model, system_prompt, test["q"], max_output_tokens
840
+ )
482
841
  )
483
842
  response_text = extract_text(provider, response)
484
- except Exception as e:
485
- results.append({
486
- "index": i,
487
- "question": test["q"],
488
- "status": "api_error",
489
- "error": str(e),
490
- })
491
- failed += 1
492
- if not quiet:
493
- print("API ERROR")
494
- continue
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
+ )
495
856
 
496
- check = check_response(
497
- response_text, test, is_first_turn=True, declared_ids=declared_ids
498
- )
499
- results.append(result_entry(i, test, check, response_text))
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
+ )
500
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)
501
892
  if check["passed"]:
502
- passed += 1
503
- if not quiet:
504
- print("PASS (review)" if check["needs_review"] else "PASS")
505
- else:
506
- failed += 1
507
- failures = (check["missing_cites"] + check["missing_mentions"]
508
- + check["forbidden_found"] + check["boundary_violations"])
509
- if not quiet:
510
- print(f"FAIL ({failures})")
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] = {}
909
+ passed = 0
910
+ failed = 0
911
+ done = 0
912
+ cache_stats = {"created": 0, "read": 0, "uncached": 0}
913
+
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
948
+
949
+ remaining = [] if interrupted else list(enumerate(tests))[warmed:]
950
+ futures = {pool.submit(grade_one, i, t): i for i, t in remaining}
951
+ try:
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,
980
+ )
981
+ finally:
982
+ pool.shutdown(wait=True, cancel_futures=True)
983
+
984
+ results = [by_index[i] for i in sorted(by_index)]
511
985
 
512
986
  return {
513
987
  **suite_common(master_name, dry_run, "completed", provider),
@@ -516,10 +990,103 @@ def run_tests(
516
990
  "passed": passed,
517
991
  "failed": failed,
518
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,
519
1013
  "results": results,
520
1014
  }
521
1015
 
522
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
+
523
1090
  def results_failed(results: list[dict], dry_run: bool) -> bool:
524
1091
  """Return whether collected fidelity results require a failing exit status."""
525
1092
  if dry_run:
@@ -528,7 +1095,7 @@ def results_failed(results: list[dict], dry_run: bool) -> bool:
528
1095
  "error" in suite
529
1096
  or suite.get("failed", 0) > 0
530
1097
  or any(
531
- case.get("status") in {"FAIL", "api_error"}
1098
+ case.get("status") in {"FAIL", "api_error", "truncated"}
532
1099
  for case in suite.get("results", [])
533
1100
  )
534
1101
  for suite in results
@@ -557,8 +1124,58 @@ def main() -> int:
557
1124
  default=None,
558
1125
  help="Cap the number of fixtures per master (smoke runs in CI use 1)",
559
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
+ )
560
1167
  args = parser.parse_args()
561
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
+
562
1179
  if not args.master and not args.all:
563
1180
  parser.error("Specify --master <name> or --all")
564
1181
 
@@ -586,12 +1203,27 @@ def main() -> int:
586
1203
  provider=args.provider,
587
1204
  max_tests=args.max_tests,
588
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,
589
1210
  )
590
1211
  all_results.append(result)
591
1212
 
1213
+ if not args.json and "error" in result:
1214
+ # 出错时以前什么都不印:操作者只看到一行标题然后是沉默,读起来像卡住
1215
+ # 而不是失败。付费跑分时错误必须看得见。
1216
+ print(f"ERROR: {result['error']}", file=sys.stderr)
1217
+
592
1218
  if not args.json and "error" not in result:
593
1219
  print(f"\nResult: {result.get('passed', 0)}/{result['total']} passed "
594
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}")
595
1227
 
596
1228
  if args.json:
597
1229
  print(json.dumps(all_results, indent=2, ensure_ascii=False))