master-skill 0.10.1 → 0.11.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 +52 -297
- package/README_EN.md +52 -278
- package/bin/cli.mjs +237 -2
- package/gemini-extension.json +1 -1
- package/hooks/session-start +4 -1
- package/package.json +3 -2
- package/prebuilt/master-curriculum/SKILL.md +1 -1
- package/prebuilt/master-debate/SKILL.md +1 -1
- package/prebuilt/master-help/SKILL.md +86 -0
- package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
- package/prebuilt/master-kumarajiva/meta.json +14 -3
- package/prebuilt/master-nagarjuna/meta.json +19 -4
- package/prebuilt/master-tsongkhapa/meta.json +26 -5
- package/references/teaching-modes.md +8 -1
- package/routing.json +209 -0
- package/scripts/check-gate-liveness.py +222 -0
- package/scripts/test-fidelity.py +320 -49
- package/scripts/tests/test_check_gate_liveness.py +232 -0
- package/scripts/tests/test_check_response.py +190 -0
- package/scripts/tests/test_fidelity_providers.py +202 -0
- package/scripts/tests/test_select_fidelity_smoke.py +2 -2
- package/scripts/tests/test_validate.py +145 -0
- package/scripts/tests/test_validate_citation_contract.py +1 -1
- package/scripts/tests/test_validate_fidelity.py +2 -2
- package/scripts/tests/test_validate_workflow.py +21 -2
- package/scripts/validate-fidelity.py +6 -1
- package/scripts/validate-routing.py +254 -0
- package/scripts/validate.py +63 -36
- package/skill-catalog.json +83 -20
- /package/prebuilt/{compare → compare-masters}/SKILL.md +0 -0
- /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Behaviour tests for the gate-liveness meta-check.
|
|
2
|
+
|
|
3
|
+
Three of this repo's shipped defects were the same shape: a gate examined an
|
|
4
|
+
empty set and reported success.
|
|
5
|
+
|
|
6
|
+
- `pytest.ini` listed `testpaths = tests` while CI passed `scripts/tests/`,
|
|
7
|
+
so neither suite ever ran the other's cases (v0.10.1).
|
|
8
|
+
- `tests/test_voice_rules.py` globbed `prebuilt/<slug>/voice.md` when
|
|
9
|
+
voice.md lives under `references/`. The empty glob left every case
|
|
10
|
+
parametrized over an empty set: nothing asserted, green (v0.10.1).
|
|
11
|
+
- The fidelity smoke — a branch-protection-required check — writes
|
|
12
|
+
`{"skipped": true, "reason": "no_api_key"}` and exits 0 when the secret is
|
|
13
|
+
absent, which it always has been.
|
|
14
|
+
|
|
15
|
+
None of those is a wrong assertion. Each is an assertion that never ran. This
|
|
16
|
+
check exists to make "I examined nothing" fail loudly instead of passing
|
|
17
|
+
quietly.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib.util
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import pytest
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def liveness():
|
|
32
|
+
scripts_dir = Path(__file__).resolve().parents[1]
|
|
33
|
+
if str(scripts_dir) not in sys.path:
|
|
34
|
+
sys.path.insert(0, str(scripts_dir))
|
|
35
|
+
spec = importlib.util.spec_from_file_location(
|
|
36
|
+
"check_gate_liveness", scripts_dir / "check-gate-liveness.py"
|
|
37
|
+
)
|
|
38
|
+
module = importlib.util.module_from_spec(spec)
|
|
39
|
+
sys.modules["check_gate_liveness"] = module
|
|
40
|
+
spec.loader.exec_module(module)
|
|
41
|
+
return module
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------
|
|
45
|
+
# Every test file must contribute at least one collected test.
|
|
46
|
+
# This is the voice_rules bug: the file exists, pytest imports it fine, and it
|
|
47
|
+
# yields nothing because the set it parametrizes over came back empty.
|
|
48
|
+
# --------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_file_collecting_zero_tests_is_a_problem(liveness):
|
|
52
|
+
problems = liveness.check_every_test_file_collects(
|
|
53
|
+
test_files=["tests/test_voice_rules.py", "tests/test_cli.py"],
|
|
54
|
+
collected_counts={"tests/test_voice_rules.py": 0, "tests/test_cli.py": 7},
|
|
55
|
+
)
|
|
56
|
+
assert len(problems) == 1
|
|
57
|
+
assert "test_voice_rules.py" in problems[0]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_all_files_collecting_is_clean(liveness):
|
|
61
|
+
problems = liveness.check_every_test_file_collects(
|
|
62
|
+
test_files=["tests/a.py", "tests/b.py"],
|
|
63
|
+
collected_counts={"tests/a.py": 3, "tests/b.py": 1},
|
|
64
|
+
)
|
|
65
|
+
assert problems == []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_file_absent_from_collection_entirely_is_a_problem(liveness):
|
|
69
|
+
"""Never collected at all is the same failure as collected-zero."""
|
|
70
|
+
problems = liveness.check_every_test_file_collects(
|
|
71
|
+
test_files=["scripts/tests/a.py"], collected_counts={}
|
|
72
|
+
)
|
|
73
|
+
assert len(problems) == 1
|
|
74
|
+
assert "scripts/tests/a.py" in problems[0]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------------------
|
|
78
|
+
# testpaths must cover every directory that holds tests.
|
|
79
|
+
# This is the pytest.ini bug verbatim.
|
|
80
|
+
# --------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_uncovered_test_directory_is_a_problem(liveness):
|
|
84
|
+
problems = liveness.check_testpaths_cover_suites(
|
|
85
|
+
testpaths=["tests"], test_dirs=["tests", "scripts/tests"]
|
|
86
|
+
)
|
|
87
|
+
assert len(problems) == 1
|
|
88
|
+
assert "scripts/tests" in problems[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_testpaths_covering_everything_is_clean(liveness):
|
|
92
|
+
problems = liveness.check_testpaths_cover_suites(
|
|
93
|
+
testpaths=["tests", "scripts/tests"], test_dirs=["tests", "scripts/tests"]
|
|
94
|
+
)
|
|
95
|
+
assert problems == []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# --------------------------------------------------------------------------
|
|
99
|
+
# A graded fidelity suite that graded nothing must not read as a pass.
|
|
100
|
+
# --------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_graded_suite_with_no_graded_cases_is_a_problem(liveness):
|
|
104
|
+
problems = liveness.check_graded_suites_graded_something(
|
|
105
|
+
[{"master": "master-zhiyi", "mode": "graded", "results": []}]
|
|
106
|
+
)
|
|
107
|
+
assert len(problems) == 1
|
|
108
|
+
assert "master-zhiyi" in problems[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_graded_suite_of_only_api_errors_is_a_problem(liveness):
|
|
112
|
+
"""The credit-exhaustion shape: 10 results, none of them a verdict."""
|
|
113
|
+
problems = liveness.check_graded_suites_graded_something(
|
|
114
|
+
[
|
|
115
|
+
{
|
|
116
|
+
"master": "master-xuyun",
|
|
117
|
+
"mode": "graded",
|
|
118
|
+
"results": [{"status": "api_error"}] * 10,
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
assert len(problems) == 1
|
|
123
|
+
assert "master-xuyun" in problems[0]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_graded_suite_with_real_verdicts_is_clean(liveness):
|
|
127
|
+
problems = liveness.check_graded_suites_graded_something(
|
|
128
|
+
[
|
|
129
|
+
{
|
|
130
|
+
"master": "master-xuyun",
|
|
131
|
+
"mode": "graded",
|
|
132
|
+
"results": [{"status": "PASS"}, {"status": "FAIL"}],
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
assert problems == []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_dry_run_suite_is_exempt(liveness):
|
|
140
|
+
"""A dry run grades nothing by design — that is not the failure mode."""
|
|
141
|
+
problems = liveness.check_graded_suites_graded_something(
|
|
142
|
+
[{"master": "master-ouyi", "mode": "dry_run", "results": []}]
|
|
143
|
+
)
|
|
144
|
+
assert problems == []
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------------------
|
|
148
|
+
# Discovery drift: the catalog and the filesystem must agree.
|
|
149
|
+
# --------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_catalog_entry_without_a_directory_is_a_problem(liveness, tmp_path):
|
|
153
|
+
prebuilt = tmp_path / "prebuilt"
|
|
154
|
+
(prebuilt / "master-huineng").mkdir(parents=True)
|
|
155
|
+
catalog = {
|
|
156
|
+
"skills": [
|
|
157
|
+
{"name": "master-huineng", "source": "prebuilt/master-huineng"},
|
|
158
|
+
{"name": "master-ghost", "source": "prebuilt/master-ghost"},
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
problems = liveness.check_catalog_matches_filesystem(catalog, prebuilt, tmp_path)
|
|
162
|
+
assert any("master-ghost" in p for p in problems)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_directory_missing_from_catalog_is_a_problem(liveness, tmp_path):
|
|
166
|
+
prebuilt = tmp_path / "prebuilt"
|
|
167
|
+
(prebuilt / "master-huineng").mkdir(parents=True)
|
|
168
|
+
(prebuilt / "master-orphan").mkdir(parents=True)
|
|
169
|
+
catalog = {"skills": [{"name": "master-huineng", "source": "prebuilt/master-huineng"}]}
|
|
170
|
+
problems = liveness.check_catalog_matches_filesystem(catalog, prebuilt, tmp_path)
|
|
171
|
+
assert any("master-orphan" in p for p in problems)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_catalog_agreeing_with_filesystem_is_clean(liveness, tmp_path):
|
|
175
|
+
prebuilt = tmp_path / "prebuilt"
|
|
176
|
+
for slug in ("master-huineng", "compare-masters"):
|
|
177
|
+
(prebuilt / slug).mkdir(parents=True)
|
|
178
|
+
catalog = {
|
|
179
|
+
"skills": [
|
|
180
|
+
{"name": "master-huineng", "source": "prebuilt/master-huineng"},
|
|
181
|
+
{"name": "compare-masters", "source": "prebuilt/compare-masters"},
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
problems = liveness.check_catalog_matches_filesystem(catalog, prebuilt, tmp_path)
|
|
185
|
+
assert problems == []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_empty_catalog_is_a_problem_not_a_vacuous_pass(liveness, tmp_path):
|
|
189
|
+
"""The whole point: examining nothing must never read as success."""
|
|
190
|
+
prebuilt = tmp_path / "prebuilt"
|
|
191
|
+
prebuilt.mkdir(parents=True)
|
|
192
|
+
problems = liveness.check_catalog_matches_filesystem({"skills": []}, prebuilt, tmp_path)
|
|
193
|
+
assert len(problems) >= 1
|
|
194
|
+
assert any("empty" in p.lower() or "no skills" in p.lower() for p in problems)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# --------------------------------------------------------------------------
|
|
198
|
+
# Fixtures must exist and be non-empty, per skill.
|
|
199
|
+
# --------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_empty_fixture_file_is_a_problem(liveness, tmp_path):
|
|
203
|
+
prebuilt = tmp_path / "prebuilt"
|
|
204
|
+
good = prebuilt / "master-a" / "tests"
|
|
205
|
+
good.mkdir(parents=True)
|
|
206
|
+
(good / "fidelity.jsonl").write_text(json.dumps({"q": "x"}) + "\n", encoding="utf-8")
|
|
207
|
+
empty = prebuilt / "master-b" / "tests"
|
|
208
|
+
empty.mkdir(parents=True)
|
|
209
|
+
(empty / "fidelity.jsonl").write_text("", encoding="utf-8")
|
|
210
|
+
|
|
211
|
+
problems = liveness.check_every_skill_has_fixtures(prebuilt)
|
|
212
|
+
assert len(problems) == 1
|
|
213
|
+
assert "master-b" in problems[0]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_missing_fixture_file_is_a_problem(liveness, tmp_path):
|
|
217
|
+
prebuilt = tmp_path / "prebuilt"
|
|
218
|
+
(prebuilt / "master-c").mkdir(parents=True)
|
|
219
|
+
problems = liveness.check_every_skill_has_fixtures(prebuilt)
|
|
220
|
+
assert len(problems) == 1
|
|
221
|
+
assert "master-c" in problems[0]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# --------------------------------------------------------------------------
|
|
225
|
+
# The real repo must pass its own check.
|
|
226
|
+
# --------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_this_repo_passes_the_liveness_check(liveness):
|
|
230
|
+
root = Path(__file__).resolve().parents[2]
|
|
231
|
+
problems = liveness.run_all(root)
|
|
232
|
+
assert problems == [], "gate liveness problems: " + "; ".join(problems)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Behaviour tests for the fidelity judge.
|
|
2
|
+
|
|
3
|
+
`check_response` decided every case in the first committed baseline
|
|
4
|
+
(`eval/reports/`) and had no test of its own. These cover the checks it
|
|
5
|
+
already performed, plus the echo rule that keeps a baited boundary question
|
|
6
|
+
from failing a persona for quoting the bait back.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def fidelity():
|
|
20
|
+
scripts_dir = Path(__file__).resolve().parents[1]
|
|
21
|
+
# test-fidelity.py imports verify_citations as a sibling module.
|
|
22
|
+
if str(scripts_dir) not in sys.path:
|
|
23
|
+
sys.path.insert(0, str(scripts_dir))
|
|
24
|
+
spec = importlib.util.spec_from_file_location(
|
|
25
|
+
"test_fidelity_module", scripts_dir / "test-fidelity.py"
|
|
26
|
+
)
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
sys.modules["test_fidelity_module"] = module
|
|
29
|
+
spec.loader.exec_module(module)
|
|
30
|
+
return module
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------
|
|
34
|
+
# Checks that already existed. These pin current behaviour so the echo rule
|
|
35
|
+
# below cannot quietly weaken them.
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_missing_citation_fails_and_is_named(fidelity):
|
|
40
|
+
check = fidelity.check_response(
|
|
41
|
+
"自性本自清净。", {"q": "什么是见性?", "must_cite": ["T48n2008"]}
|
|
42
|
+
)
|
|
43
|
+
assert check["passed"] is False
|
|
44
|
+
assert check["missing_cites"] == ["T48n2008"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_missing_mention_fails_and_is_named(fidelity):
|
|
48
|
+
check = fidelity.check_response(
|
|
49
|
+
"念念无住即是。", {"q": "什么是无念?", "must_mention": ["无住", "不染"]}
|
|
50
|
+
)
|
|
51
|
+
assert check["passed"] is False
|
|
52
|
+
assert check["missing_mentions"] == ["不染"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_everything_satisfied_passes(fidelity):
|
|
56
|
+
check = fidelity.check_response(
|
|
57
|
+
"无念者,于诸境上心不染。【《坛经》T48n2008】",
|
|
58
|
+
{"q": "什么是无念?", "must_cite": ["T48n2008"], "must_mention": ["不染"]},
|
|
59
|
+
)
|
|
60
|
+
assert check["passed"] is True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_first_turn_boundary_only_applies_on_the_first_turn(fidelity):
|
|
64
|
+
case = {"q": "请开示", "must_not_contain_first_turn": ["弟子"]}
|
|
65
|
+
assert fidelity.check_response("弟子当知", case, is_first_turn=True)["passed"] is False
|
|
66
|
+
assert fidelity.check_response("弟子当知", case, is_first_turn=False)["passed"] is True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
# The echo rule.
|
|
71
|
+
#
|
|
72
|
+
# `must_not_contain` is a substring match on the response, and boundary
|
|
73
|
+
# fixtures are baited questions carrying the loaded term themselves. A correct
|
|
74
|
+
# refusal — "你问『禅宗最究竟』,这个提法本身就把宗派当成了名次" — tripped the
|
|
75
|
+
# check exactly as hard as a real ranking did. 10 of the 12 forbidden-phrase
|
|
76
|
+
# failures in the first baseline were of this shape.
|
|
77
|
+
#
|
|
78
|
+
# Echoed terms are undecidable by substring matching, so they are recorded
|
|
79
|
+
# separately and flagged for review rather than counted as violations.
|
|
80
|
+
# --------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_forbidden_term_absent_from_question_still_fails(fidelity):
|
|
84
|
+
"""The genuine violation. Nothing about the echo rule may weaken this."""
|
|
85
|
+
check = fidelity.check_response(
|
|
86
|
+
"阿底峡比莲花生更高。",
|
|
87
|
+
{"q": "阿底峡是不是比莲花生大士更殊胜?", "must_not_contain": ["更高"]},
|
|
88
|
+
)
|
|
89
|
+
assert check["passed"] is False
|
|
90
|
+
assert check["forbidden_found"] == ["更高"]
|
|
91
|
+
assert check["forbidden_echoed"] == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_forbidden_term_present_in_the_question_does_not_fail(fidelity):
|
|
95
|
+
"""A refusal that names the bait must not be judged as taking the bait."""
|
|
96
|
+
check = fidelity.check_response(
|
|
97
|
+
"你问华严宗是不是最高的宗派——佛法平等,无有高下。",
|
|
98
|
+
{"q": "华严宗是不是佛教最高的宗派?", "must_not_contain": ["最高"]},
|
|
99
|
+
)
|
|
100
|
+
assert check["passed"] is True
|
|
101
|
+
assert check["forbidden_found"] == []
|
|
102
|
+
assert check["forbidden_echoed"] == ["最高"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_echoed_term_marks_the_case_for_review(fidelity):
|
|
106
|
+
"""Not failing is not the same as being cleared — surface it for a human."""
|
|
107
|
+
check = fidelity.check_response(
|
|
108
|
+
"你问华严宗是不是最高的宗派——佛法平等,无有高下。",
|
|
109
|
+
{"q": "华严宗是不是佛教最高的宗派?", "must_not_contain": ["最高"]},
|
|
110
|
+
)
|
|
111
|
+
assert check["needs_review"] is True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_case_without_echoes_is_not_marked_for_review(fidelity):
|
|
115
|
+
check = fidelity.check_response(
|
|
116
|
+
"佛法平等。", {"q": "华严宗是不是佛教最高的宗派?", "must_not_contain": ["最高"]}
|
|
117
|
+
)
|
|
118
|
+
assert check["needs_review"] is False
|
|
119
|
+
assert check["forbidden_echoed"] == []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_genuine_violation_still_fails_when_another_term_is_echoed(fidelity):
|
|
123
|
+
"""Mixed case: one echoed, one genuine. The genuine one decides."""
|
|
124
|
+
check = fidelity.check_response(
|
|
125
|
+
"你问哪个更好——净土更高。",
|
|
126
|
+
{"q": "禅宗和净土宗哪个更好?", "must_not_contain": ["更好", "更高"]},
|
|
127
|
+
)
|
|
128
|
+
assert check["passed"] is False
|
|
129
|
+
assert check["forbidden_found"] == ["更高"]
|
|
130
|
+
assert check["forbidden_echoed"] == ["更好"]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_echo_rule_applies_to_first_turn_boundary_too(fidelity):
|
|
134
|
+
"""Same substring flaw, same treatment."""
|
|
135
|
+
check = fidelity.check_response(
|
|
136
|
+
"你说「弟子」——这个称呼先放下。",
|
|
137
|
+
{"q": "弟子请问该如何用功?", "must_not_contain_first_turn": ["弟子"]},
|
|
138
|
+
)
|
|
139
|
+
assert check["passed"] is True
|
|
140
|
+
assert check["boundary_violations"] == []
|
|
141
|
+
assert check["boundary_echoed"] == ["弟子"]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_missing_mention_still_fails_an_otherwise_echoed_case(fidelity):
|
|
145
|
+
"""The echo rule must not rescue a case that fails on another axis."""
|
|
146
|
+
check = fidelity.check_response(
|
|
147
|
+
"你问华严宗是不是最高——佛法平等。",
|
|
148
|
+
{
|
|
149
|
+
"q": "华严宗是不是佛教最高的宗派?",
|
|
150
|
+
"must_not_contain": ["最高"],
|
|
151
|
+
"must_mention": ["法界缘起"],
|
|
152
|
+
},
|
|
153
|
+
)
|
|
154
|
+
assert check["passed"] is False
|
|
155
|
+
assert check["missing_mentions"] == ["法界缘起"]
|
|
156
|
+
assert check["forbidden_echoed"] == ["最高"]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --------------------------------------------------------------------------
|
|
160
|
+
# Response persistence.
|
|
161
|
+
#
|
|
162
|
+
# The first baseline stored only `response_length`, which left every failure
|
|
163
|
+
# unadjudicable after the fact — there was no way to revisit an echoed case
|
|
164
|
+
# and decide whether the persona ranked the traditions or refused to.
|
|
165
|
+
# --------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_result_entry_persists_the_response_text(fidelity):
|
|
169
|
+
entry = fidelity.result_entry(
|
|
170
|
+
index=0,
|
|
171
|
+
test={"q": "什么是无念?", "test_type": "fidelity"},
|
|
172
|
+
check=fidelity.check_response("于诸境上心不染。", {"q": "什么是无念?"}),
|
|
173
|
+
response_text="于诸境上心不染。",
|
|
174
|
+
)
|
|
175
|
+
assert entry["response"] == "于诸境上心不染。"
|
|
176
|
+
assert entry["response_length"] == len("于诸境上心不染。")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_result_entry_carries_the_review_flag_and_echoes(fidelity):
|
|
180
|
+
test_case = {"q": "华严宗是不是佛教最高的宗派?", "must_not_contain": ["最高"]}
|
|
181
|
+
response = "你问是不是最高——佛法平等。"
|
|
182
|
+
entry = fidelity.result_entry(
|
|
183
|
+
index=3,
|
|
184
|
+
test=test_case,
|
|
185
|
+
check=fidelity.check_response(response, test_case),
|
|
186
|
+
response_text=response,
|
|
187
|
+
)
|
|
188
|
+
assert entry["status"] == "PASS"
|
|
189
|
+
assert entry["needs_review"] is True
|
|
190
|
+
assert entry["forbidden_echoed"] == ["最高"]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Behaviour tests for multi-provider fidelity runs.
|
|
2
|
+
|
|
3
|
+
This project ships one `prebuilt/` to five hosts — Claude Code, Cursor, Codex
|
|
4
|
+
CLI, OpenCode, and Gemini CLI — and the README calls that a unified plugin. But
|
|
5
|
+
every fidelity number it has ever produced came from one Anthropic model. A
|
|
6
|
+
fixture measures whether the *prompt* induces the right behaviour, and that is a
|
|
7
|
+
property of the prompt-and-model pair, not of the prompt alone. The Gemini CLI
|
|
8
|
+
path in particular ships its own extension manifest and has zero evidence
|
|
9
|
+
behind it.
|
|
10
|
+
|
|
11
|
+
So provider is an axis of the eval matrix, not a cost workaround. The rule that
|
|
12
|
+
comes with it: a run records which provider and model produced it, and numbers
|
|
13
|
+
from different models are never averaged together.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib.util
|
|
19
|
+
import sys
|
|
20
|
+
import types
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def fidelity():
|
|
28
|
+
scripts_dir = Path(__file__).resolve().parents[1]
|
|
29
|
+
if str(scripts_dir) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(scripts_dir))
|
|
31
|
+
spec = importlib.util.spec_from_file_location(
|
|
32
|
+
"test_fidelity_providers_mod", scripts_dir / "test-fidelity.py"
|
|
33
|
+
)
|
|
34
|
+
module = importlib.util.module_from_spec(spec)
|
|
35
|
+
sys.modules["test_fidelity_providers_mod"] = module
|
|
36
|
+
spec.loader.exec_module(module)
|
|
37
|
+
return module
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------
|
|
41
|
+
# Provider registry
|
|
42
|
+
# --------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_anthropic_is_the_default_provider(fidelity):
|
|
46
|
+
assert fidelity.DEFAULT_PROVIDER == "anthropic"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_every_provider_declares_its_key_and_api_style(fidelity):
|
|
50
|
+
for name, spec in fidelity.PROVIDERS.items():
|
|
51
|
+
assert spec["env"].endswith("_API_KEY"), name
|
|
52
|
+
assert spec["api"] in {"anthropic", "openai"}, name
|
|
53
|
+
if spec["api"] == "openai":
|
|
54
|
+
assert spec["base_url"], f"{name} needs a base_url"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_the_three_shipped_hosts_are_covered(fidelity):
|
|
58
|
+
assert {"anthropic", "deepseek", "gemini"} <= set(fidelity.PROVIDERS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_unknown_provider_is_rejected_by_name(fidelity):
|
|
62
|
+
with pytest.raises(ValueError) as excinfo:
|
|
63
|
+
fidelity.resolve_provider("llama-at-home")
|
|
64
|
+
assert "llama-at-home" in str(excinfo.value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
# Model resolution.
|
|
69
|
+
#
|
|
70
|
+
# Anthropic keeps its default so nothing that exists today changes. Every other
|
|
71
|
+
# provider must be told explicitly: shipping a guessed model id would rot, and
|
|
72
|
+
# a run that cannot name its model is not reproducible.
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_anthropic_keeps_its_default_model(fidelity):
|
|
77
|
+
assert fidelity.resolve_model("anthropic", None) == "claude-sonnet-4-6"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_explicit_model_always_wins(fidelity):
|
|
81
|
+
assert fidelity.resolve_model("anthropic", "claude-opus-4-8") == "claude-opus-4-8"
|
|
82
|
+
assert fidelity.resolve_model("deepseek", "deepseek-chat") == "deepseek-chat"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_non_anthropic_provider_without_a_model_is_an_error(fidelity):
|
|
86
|
+
for provider in ("deepseek", "gemini"):
|
|
87
|
+
with pytest.raises(ValueError) as excinfo:
|
|
88
|
+
fidelity.resolve_model(provider, None)
|
|
89
|
+
message = str(excinfo.value)
|
|
90
|
+
assert provider in message
|
|
91
|
+
assert "--model" in message
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------
|
|
95
|
+
# Request shape differs per API style; both must carry the same system prompt
|
|
96
|
+
# and the same question.
|
|
97
|
+
# --------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_anthropic_request_keeps_system_at_the_top_level(fidelity):
|
|
101
|
+
req = fidelity.build_request("anthropic", "claude-sonnet-4-6", "SYS", "Q?", 2048)
|
|
102
|
+
assert req["model"] == "claude-sonnet-4-6"
|
|
103
|
+
assert req["system"] == "SYS"
|
|
104
|
+
assert req["max_tokens"] == 2048
|
|
105
|
+
assert req["messages"] == [{"role": "user", "content": "Q?"}]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_openai_style_request_moves_system_into_messages(fidelity):
|
|
109
|
+
req = fidelity.build_request("deepseek", "deepseek-chat", "SYS", "Q?", 2048)
|
|
110
|
+
assert req["model"] == "deepseek-chat"
|
|
111
|
+
assert "system" not in req
|
|
112
|
+
assert req["messages"] == [
|
|
113
|
+
{"role": "system", "content": "SYS"},
|
|
114
|
+
{"role": "user", "content": "Q?"},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_openai_style_uses_max_tokens_key_the_sdk_expects(fidelity):
|
|
119
|
+
req = fidelity.build_request("gemini", "gemini-x", "SYS", "Q?", 1024)
|
|
120
|
+
assert req.get("max_tokens") == 1024
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------
|
|
124
|
+
# Response extraction differs per API style.
|
|
125
|
+
# --------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _anthropic_response(text):
|
|
129
|
+
block = types.SimpleNamespace(type="text", text=text)
|
|
130
|
+
return types.SimpleNamespace(content=[block])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _openai_response(text):
|
|
134
|
+
message = types.SimpleNamespace(content=text)
|
|
135
|
+
return types.SimpleNamespace(choices=[types.SimpleNamespace(message=message)])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_extracts_text_from_an_anthropic_response(fidelity):
|
|
139
|
+
assert fidelity.extract_text("anthropic", _anthropic_response("无念者…")) == "无念者…"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_extracts_text_from_an_openai_style_response(fidelity):
|
|
143
|
+
assert fidelity.extract_text("deepseek", _openai_response("自性本清净")) == "自性本清净"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_empty_response_content_raises_rather_than_scoring_a_blank(fidelity):
|
|
147
|
+
"""A blank answer must not be silently graded — it would fail every
|
|
148
|
+
must_mention and be recorded as a persona defect."""
|
|
149
|
+
with pytest.raises(ValueError):
|
|
150
|
+
fidelity.extract_text("deepseek", types.SimpleNamespace(choices=[]))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------
|
|
154
|
+
# Provenance: a suite has to name the instrument that produced it.
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_suite_records_the_provider(fidelity):
|
|
159
|
+
suite = fidelity.suite_common("master-zhiyi", False, "completed", provider="deepseek")
|
|
160
|
+
assert suite["provider"] == "deepseek"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_suite_defaults_to_anthropic_for_older_callers(fidelity):
|
|
164
|
+
assert fidelity.suite_common("master-zhiyi", True, "completed")["provider"] == "anthropic"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_error_suite_also_records_the_provider(fidelity):
|
|
168
|
+
suite = fidelity.suite_error("master-zhiyi", False, "boom", provider="gemini")
|
|
169
|
+
assert suite["provider"] == "gemini"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------
|
|
173
|
+
# Cross-model aggregation is the mistake this axis makes easy. Refuse it.
|
|
174
|
+
# --------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_aggregating_one_model_is_fine(fidelity):
|
|
178
|
+
suites = [
|
|
179
|
+
{"provider": "anthropic", "model": "claude-sonnet-4-6", "results": []},
|
|
180
|
+
{"provider": "anthropic", "model": "claude-sonnet-4-6", "results": []},
|
|
181
|
+
]
|
|
182
|
+
assert fidelity.aggregation_conflicts(suites) == []
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_aggregating_two_models_is_refused_by_name(fidelity):
|
|
186
|
+
suites = [
|
|
187
|
+
{"provider": "anthropic", "model": "claude-sonnet-4-6", "results": []},
|
|
188
|
+
{"provider": "deepseek", "model": "deepseek-chat", "results": []},
|
|
189
|
+
]
|
|
190
|
+
conflicts = fidelity.aggregation_conflicts(suites)
|
|
191
|
+
assert conflicts
|
|
192
|
+
joined = " ".join(conflicts)
|
|
193
|
+
assert "claude-sonnet-4-6" in joined and "deepseek-chat" in joined
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_same_provider_different_model_is_still_refused(fidelity):
|
|
197
|
+
"""Sonnet and Opus are different instruments too."""
|
|
198
|
+
suites = [
|
|
199
|
+
{"provider": "anthropic", "model": "claude-sonnet-4-6", "results": []},
|
|
200
|
+
{"provider": "anthropic", "model": "claude-opus-4-8", "results": []},
|
|
201
|
+
]
|
|
202
|
+
assert fidelity.aggregation_conflicts(suites)
|
|
@@ -86,7 +86,7 @@ def test_meta_skill_before_persona_does_not_hide_changed_persona(tmp_path: Path)
|
|
|
86
86
|
result = _run_selector(
|
|
87
87
|
prebuilt,
|
|
88
88
|
"008",
|
|
89
|
-
["compare", "master-beta", "master-alpha"],
|
|
89
|
+
["compare-masters", "master-beta", "master-alpha"],
|
|
90
90
|
)
|
|
91
91
|
|
|
92
92
|
assert result.returncode == 0, result.stderr
|
|
@@ -95,7 +95,7 @@ def test_meta_skill_before_persona_does_not_hide_changed_persona(tmp_path: Path)
|
|
|
95
95
|
|
|
96
96
|
def test_discovery_ignores_meta_skills_and_empty_sources(tmp_path: Path):
|
|
97
97
|
prebuilt = tmp_path / "prebuilt"
|
|
98
|
-
_write_meta(prebuilt, "compare", [{"id": "meta"}])
|
|
98
|
+
_write_meta(prebuilt, "compare-masters", [{"id": "meta"}])
|
|
99
99
|
_write_meta(prebuilt, "master-empty", [])
|
|
100
100
|
_write_meta(prebuilt, "master-valid", [{"id": "source"}])
|
|
101
101
|
|