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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/GEMINI.md +1 -1
- package/README.md +84 -336
- package/README_EN.md +108 -321
- package/bin/cli.mjs +249 -9
- package/gemini-extension.json +1 -1
- package/hooks/session-start +68 -74
- package/hooks/session_start.py +152 -0
- package/package.json +6 -2
- package/prebuilt/{compare → 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/SKILL.md +1 -1
- package/prebuilt/master-curriculum/references/tiantai.md +1 -1
- package/prebuilt/master-debate/SKILL.md +15 -3
- package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
- package/prebuilt/master-help/SKILL.md +94 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
- package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
- package/prebuilt/master-kumarajiva/meta.json +14 -3
- 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/meta.json +19 -4
- 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 +32 -5
- 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/references/teaching-modes.md +8 -1
- package/routing.json +209 -0
- package/scripts/check-audit-ignores.py +105 -0
- package/scripts/check-eval-sdk-surface.py +142 -0
- package/scripts/check-gate-liveness.py +421 -0
- 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 +992 -89
- package/scripts/validate-citation-references.py +150 -0
- package/scripts/validate-citation-templates.py +176 -0
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-fixture-terms.py +127 -0
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/scripts/verify-adjudication.py +316 -0
- package/scripts/verify_citations.py +739 -39
- package/skill-catalog.json +83 -20
- 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_manifest_versions.py +0 -217
- package/scripts/tests/test_debate_protocol.py +0 -159
- package/scripts/tests/test_injection_hardening.py +0 -174
- package/scripts/tests/test_select_fidelity_smoke.py +0 -142
- 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 -265
- /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
package/scripts/test-fidelity.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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: [...],
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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:
|
|
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
|
-
|
|
116
|
-
|
|
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 =
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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("
|
|
784
|
+
api_key = os.environ.get(spec["env"])
|
|
186
785
|
if not api_key:
|
|
187
|
-
return
|
|
786
|
+
return suite_error(
|
|
787
|
+
master_name, dry_run, f"{spec['env']} environment variable not set", provider
|
|
788
|
+
)
|
|
188
789
|
|
|
189
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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(
|
|
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:
|