master-skill 0.11.0 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +48 -55
- package/README_EN.md +72 -59
- package/bin/cli.mjs +12 -7
- package/gemini-extension.json +1 -1
- package/hooks/session-start +68 -77
- package/hooks/session_start.py +152 -0
- package/package.json +5 -2
- package/prebuilt/compare-masters/SKILL.md +21 -2
- package/prebuilt/master-ajahn-chah/meta.json +6 -0
- package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-curriculum/references/tiantai.md +1 -1
- package/prebuilt/master-debate/SKILL.md +14 -2
- package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-help/SKILL.md +9 -1
- package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-ouyi/meta.json +5 -0
- package/prebuilt/master-ouyi/references/teaching.md +3 -3
- package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-tsongkhapa/meta.json +6 -0
- package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
- package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
- package/prebuilt/master-zhiyi/meta.json +2 -2
- package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
- package/scripts/check-audit-ignores.py +105 -0
- package/scripts/check-eval-sdk-surface.py +142 -0
- package/scripts/check-gate-liveness.py +205 -6
- package/scripts/reaudit-report.py +163 -0
- package/scripts/regrade-report.py +157 -0
- package/scripts/smoke-eval-sdk.py +174 -0
- package/scripts/test-fidelity.py +684 -52
- package/scripts/validate-citation-references.py +150 -0
- package/scripts/validate-citation-templates.py +176 -0
- package/scripts/validate-fixture-terms.py +127 -0
- package/scripts/verify-adjudication.py +316 -0
- package/scripts/verify_citations.py +739 -39
- package/tools/cross_reference.py +44 -10
- package/tools/fojin-known-absent.json +14 -0
- package/tools/fojin_bridge.py +138 -8
- package/tools/rag_query.py +45 -2
- package/tools/skill_writer.py +50 -7
- package/tools/verify_sources.py +240 -15
- package/hooks/tests/test_run_hook.sh +0 -114
- package/hooks/tests/test_run_hook_cmd.sh +0 -94
- package/hooks/tests/test_session_start.sh +0 -149
- package/scripts/tests/test_check_gate_liveness.py +0 -232
- package/scripts/tests/test_check_manifest_versions.py +0 -217
- package/scripts/tests/test_check_response.py +0 -190
- package/scripts/tests/test_debate_protocol.py +0 -159
- package/scripts/tests/test_fidelity_providers.py +0 -202
- package/scripts/tests/test_injection_hardening.py +0 -174
- package/scripts/tests/test_select_fidelity_smoke.py +0 -142
- package/scripts/tests/test_validate.py +0 -145
- package/scripts/tests/test_validate_citation_contract.py +0 -408
- package/scripts/tests/test_validate_cross_critique.py +0 -149
- package/scripts/tests/test_validate_curriculum_sources.py +0 -144
- package/scripts/tests/test_validate_fidelity.py +0 -59
- package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
- package/scripts/tests/test_validate_persona_fidelity.py +0 -317
- package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
- package/scripts/tests/test_validate_workflow.py +0 -284
package/scripts/test-fidelity.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
#
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
384
|
-
|
|
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(
|
|
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(
|
|
464
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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(
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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))
|