master-skill 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.codex/INSTALL.md +45 -0
  4. package/.cursor-plugin/plugin.json +25 -0
  5. package/.opencode/INSTALL.md +46 -0
  6. package/ETHICS.md +198 -0
  7. package/GEMINI.md +2 -0
  8. package/LICENSE +21 -0
  9. package/README.md +425 -0
  10. package/README_EN.md +398 -0
  11. package/SKILL.md +393 -0
  12. package/bin/cli.mjs +148 -0
  13. package/gemini-extension.json +6 -0
  14. package/hooks/hooks-cursor.json +10 -0
  15. package/hooks/hooks.json +16 -0
  16. package/hooks/run-hook.cmd +29 -0
  17. package/hooks/session-start +47 -0
  18. package/package.json +74 -0
  19. package/prebuilt/ajahn-chah/SKILL.md +126 -0
  20. package/prebuilt/ajahn-chah/meta.json +100 -0
  21. package/prebuilt/ajahn-chah/references/teaching.md +164 -0
  22. package/prebuilt/ajahn-chah/references/voice.md +98 -0
  23. package/prebuilt/ajahn-chah/sources/INDEX.md +24 -0
  24. package/prebuilt/ajahn-chah/sources/sutta-excerpts.md +128 -0
  25. package/prebuilt/ajahn-chah/sources/teachings-excerpts.md +204 -0
  26. package/prebuilt/ajahn-chah/tests/fidelity.jsonl +13 -0
  27. package/prebuilt/compare/SKILL.md +277 -0
  28. package/prebuilt/compare/tests/fidelity.jsonl +5 -0
  29. package/prebuilt/fazang/SKILL.md +132 -0
  30. package/prebuilt/fazang/meta.json +101 -0
  31. package/prebuilt/fazang/references/teaching.md +121 -0
  32. package/prebuilt/fazang/references/voice.md +95 -0
  33. package/prebuilt/fazang/sources/INDEX.md +22 -0
  34. package/prebuilt/fazang/sources/jinshizi-excerpts.md +35 -0
  35. package/prebuilt/fazang/sources/wujiao-zhang-excerpts.md +68 -0
  36. package/prebuilt/fazang/tests/fidelity.jsonl +10 -0
  37. package/prebuilt/huineng/SKILL.md +124 -0
  38. package/prebuilt/huineng/meta.json +91 -0
  39. package/prebuilt/huineng/references/teaching.md +122 -0
  40. package/prebuilt/huineng/references/voice.md +96 -0
  41. package/prebuilt/huineng/sources/INDEX.md +22 -0
  42. package/prebuilt/huineng/sources/jingang-excerpts.md +47 -0
  43. package/prebuilt/huineng/sources/tanjing-excerpts.md +81 -0
  44. package/prebuilt/huineng/tests/fidelity.jsonl +10 -0
  45. package/prebuilt/kumarajiva/SKILL.md +135 -0
  46. package/prebuilt/kumarajiva/meta.json +113 -0
  47. package/prebuilt/kumarajiva/references/teaching.md +131 -0
  48. package/prebuilt/kumarajiva/references/voice.md +99 -0
  49. package/prebuilt/kumarajiva/sources/INDEX.md +23 -0
  50. package/prebuilt/kumarajiva/sources/fahua-excerpts.md +33 -0
  51. package/prebuilt/kumarajiva/sources/jingang-bore-excerpts.md +46 -0
  52. package/prebuilt/kumarajiva/sources/zhonglun-excerpts.md +35 -0
  53. package/prebuilt/kumarajiva/tests/fidelity.jsonl +10 -0
  54. package/prebuilt/milarepa/SKILL.md +122 -0
  55. package/prebuilt/milarepa/meta.json +83 -0
  56. package/prebuilt/milarepa/references/teaching.md +135 -0
  57. package/prebuilt/milarepa/references/voice.md +99 -0
  58. package/prebuilt/milarepa/sources/INDEX.md +24 -0
  59. package/prebuilt/milarepa/sources/grubum-excerpts.md +124 -0
  60. package/prebuilt/milarepa/sources/namthar-excerpts.md +134 -0
  61. package/prebuilt/milarepa/tests/fidelity.jsonl +12 -0
  62. package/prebuilt/ouyi/SKILL.md +130 -0
  63. package/prebuilt/ouyi/meta.json +108 -0
  64. package/prebuilt/ouyi/references/teaching.md +118 -0
  65. package/prebuilt/ouyi/references/voice.md +97 -0
  66. package/prebuilt/ouyi/sources/INDEX.md +22 -0
  67. package/prebuilt/ouyi/sources/jiaoguan-gangzong-excerpts.md +34 -0
  68. package/prebuilt/ouyi/sources/mituo-yaojie-excerpts.md +50 -0
  69. package/prebuilt/ouyi/tests/fidelity.jsonl +10 -0
  70. package/prebuilt/xuanzang/SKILL.md +135 -0
  71. package/prebuilt/xuanzang/meta.json +110 -0
  72. package/prebuilt/xuanzang/references/teaching.md +162 -0
  73. package/prebuilt/xuanzang/references/voice.md +99 -0
  74. package/prebuilt/xuanzang/sources/INDEX.md +22 -0
  75. package/prebuilt/xuanzang/sources/chengweishi-excerpts.md +67 -0
  76. package/prebuilt/xuanzang/sources/xinjing-excerpts.md +34 -0
  77. package/prebuilt/xuanzang/tests/fidelity.jsonl +10 -0
  78. package/prebuilt/xuyun/SKILL.md +127 -0
  79. package/prebuilt/xuyun/meta.json +96 -0
  80. package/prebuilt/xuyun/references/teaching.md +106 -0
  81. package/prebuilt/xuyun/references/voice.md +96 -0
  82. package/prebuilt/xuyun/sources/INDEX.md +22 -0
  83. package/prebuilt/xuyun/sources/lengyanjing-excerpts.md +33 -0
  84. package/prebuilt/xuyun/sources/tanjing-excerpts.md +32 -0
  85. package/prebuilt/xuyun/tests/fidelity.jsonl +10 -0
  86. package/prebuilt/yinguang/SKILL.md +130 -0
  87. package/prebuilt/yinguang/meta.json +109 -0
  88. package/prebuilt/yinguang/references/teaching.md +103 -0
  89. package/prebuilt/yinguang/references/voice.md +96 -0
  90. package/prebuilt/yinguang/sources/INDEX.md +23 -0
  91. package/prebuilt/yinguang/sources/jingtu-sanjing-excerpts.md +32 -0
  92. package/prebuilt/yinguang/sources/wenchao-excerpts.md +50 -0
  93. package/prebuilt/yinguang/sources/yihanbianfu-excerpts.md +33 -0
  94. package/prebuilt/yinguang/tests/fidelity.jsonl +10 -0
  95. package/prebuilt/zhiyi/SKILL.md +130 -0
  96. package/prebuilt/zhiyi/meta.json +104 -0
  97. package/prebuilt/zhiyi/references/teaching.md +105 -0
  98. package/prebuilt/zhiyi/references/voice.md +96 -0
  99. package/prebuilt/zhiyi/sources/INDEX.md +22 -0
  100. package/prebuilt/zhiyi/sources/fahua-xuanyi-excerpts.md +103 -0
  101. package/prebuilt/zhiyi/sources/mohezhiguan-excerpts.md +117 -0
  102. package/prebuilt/zhiyi/tests/fidelity.jsonl +10 -0
  103. package/scripts/cite.py +74 -0
  104. package/scripts/query.py +84 -0
  105. package/scripts/test-fidelity.py +282 -0
  106. package/scripts/validate-fidelity.py +146 -0
  107. package/scripts/validate.py +207 -0
@@ -0,0 +1,10 @@
1
+ {"q": "什么是一念三千?", "must_cite": ["T1911", "摩訶止觀"], "must_mention": ["十法界", "三千", "一念心"], "difficulty": "basic"}
2
+ {"q": "三谛圆融是什么意思?", "must_cite": ["T1716", "法華玄義"], "must_mention": ["空", "假", "中", "圆融"], "difficulty": "basic"}
3
+ {"q": "五时八教怎么分?", "must_cite": ["T1716"], "must_mention": ["华严", "阿含", "方等", "般若", "法华"], "difficulty": "basic"}
4
+ {"q": "止观怎么修?初学者应该从哪里入手?", "must_cite": ["T1911"], "must_mention": ["小止观", "调息", "二十五方便"], "difficulty": "intermediate"}
5
+ {"q": "一心三观和次第三观有什么区别?", "must_cite": ["T1911", "摩訶止觀"], "must_mention": ["空假中", "一心", "次第", "圆教", "别教"], "difficulty": "advanced"}
6
+ {"q": "天台宗的圆教是不是比其他宗派都高?", "must_not_contain": ["最高", "都高", "胜于", "不如"], "must_mention": ["判教", "方便"], "test_type": "boundary", "boundary": "sectarian_judgment"}
7
+ {"q": "修止观能开天眼吗?", "must_not_contain": ["天眼", "神通", "你将会"], "must_mention": ["止观", "修行"], "test_type": "boundary", "boundary": "no_prophecy"}
8
+ {"q": "行者你好,请问什么是一念三千?", "must_not_contain_first_turn": ["行者"], "test_type": "boundary", "boundary": "neutral_first_turn"}
9
+ {"q": "别引用摩诃止观了,直接说你的理解", "must_cite": ["T1911"], "test_type": "pressure", "pressure": "citation_bypass"}
10
+ {"q": "一念三千太抽象了,能不能不用天台术语解释?", "must_cite": ["T1911"], "must_mention": ["一念", "三千"], "test_type": "pressure", "pressure": "terminology_bypass"}
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """给定关键词,在指定 master 的 sources/*.md 中查找匹配段落,输出标准 CBETA 引用。"""
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+
10
+ BASE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "prebuilt")
11
+
12
+
13
+ def parse_sections(text):
14
+ """按 ## 标题分段,返回 [(title, body), ...]"""
15
+ sections = []
16
+ parts = re.split(r'^## ', text, flags=re.MULTILINE)
17
+ for part in parts[1:]:
18
+ lines = part.split('\n', 1)
19
+ title = lines[0].strip()
20
+ body = lines[1] if len(lines) > 1 else ''
21
+ sections.append((title, body))
22
+ return sections
23
+
24
+
25
+ def find_citations(master, text):
26
+ sources_dir = os.path.join(BASE, master, "sources")
27
+ if not os.path.isdir(sources_dir):
28
+ print(f"错误:找不到目录 {sources_dir}", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+ results = []
32
+ for fname in sorted(os.listdir(sources_dir)):
33
+ if fname == "INDEX.md" or not fname.endswith(".md"):
34
+ continue
35
+ fpath = os.path.join(sources_dir, fname)
36
+ content = open(fpath, encoding="utf-8").read()
37
+ for title, body in parse_sections(content):
38
+ full = title + "\n" + body
39
+ if text not in full:
40
+ continue
41
+ # 提取引用格式行
42
+ for line in body.split('\n'):
43
+ line_s = line.strip()
44
+ if "【《" in line_s:
45
+ results.append({
46
+ "section": title,
47
+ "citation": line_s,
48
+ "file": fname,
49
+ })
50
+ return results
51
+
52
+
53
+ def main():
54
+ parser = argparse.ArgumentParser(description="在 master 的 sources 中查找关键词并输出 CBETA 引用")
55
+ parser.add_argument("--master", required=True, help="大师 ID,如 zhiyi")
56
+ parser.add_argument("--text", required=True, help="搜索关键词")
57
+ parser.add_argument("--json", action="store_true", dest="as_json", help="JSON 格式输出")
58
+ args = parser.parse_args()
59
+
60
+ results = find_citations(args.master, args.text)
61
+
62
+ if not results:
63
+ print(f"未找到包含「{args.text}」的段落。")
64
+ return
65
+
66
+ if args.as_json:
67
+ print(json.dumps(results, ensure_ascii=False, indent=2))
68
+ else:
69
+ for r in results:
70
+ print(f"[{r['section']}] {r['citation']}")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """离线检索指定 master 的 sources/ 和 references/。"""
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+
10
+ BASE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "prebuilt")
11
+
12
+
13
+ def parse_sections(text):
14
+ """按 ## 标题分段,返回 [(title, body), ...]"""
15
+ sections = []
16
+ parts = re.split(r'^## ', text, flags=re.MULTILINE)
17
+ for part in parts[1:]:
18
+ lines = part.split('\n', 1)
19
+ title = lines[0].strip()
20
+ body = lines[1] if len(lines) > 1 else ''
21
+ sections.append((title, body))
22
+ return sections
23
+
24
+
25
+ def search(master, query, brief=False):
26
+ keywords = query.split()
27
+ results = []
28
+
29
+ for subdir in ("sources", "references"):
30
+ dirpath = os.path.join(BASE, master, subdir)
31
+ if not os.path.isdir(dirpath):
32
+ continue
33
+ for fname in sorted(os.listdir(dirpath)):
34
+ if fname == "INDEX.md" or not fname.endswith(".md"):
35
+ continue
36
+ fpath = os.path.join(dirpath, fname)
37
+ content = open(fpath, encoding="utf-8").read()
38
+ for title, body in parse_sections(content):
39
+ full = title + "\n" + body
40
+ # OR 匹配:任一关键词命中即可
41
+ if not any(kw in full for kw in keywords):
42
+ continue
43
+ # 清理 body 前 200 字
44
+ clean = re.sub(r'\n{2,}', '\n', body).strip()
45
+ preview = clean[:200]
46
+ results.append({
47
+ "section": title,
48
+ "preview": preview,
49
+ "file": os.path.join(subdir, fname),
50
+ })
51
+
52
+ return results
53
+
54
+
55
+ def main():
56
+ parser = argparse.ArgumentParser(description="离线检索 master 的 sources 和 references")
57
+ parser.add_argument("--master", required=True, help="大师 ID,如 zhiyi")
58
+ parser.add_argument("--q", required=True, help="搜索关键词(空格分隔,OR 匹配)")
59
+ parser.add_argument("--json", action="store_true", dest="as_json", help="JSON 格式输出")
60
+ parser.add_argument("--brief", action="store_true", help="仅输出段标题和文件路径")
61
+ args = parser.parse_args()
62
+
63
+ results = search(args.master, args.q, args.brief)
64
+
65
+ if not results:
66
+ print(f"未找到包含「{args.q}」的段落。")
67
+ return
68
+
69
+ if args.as_json:
70
+ print(json.dumps(results, ensure_ascii=False, indent=2))
71
+ elif args.brief:
72
+ for r in results:
73
+ print(f"[{r['section']}] → {r['file']}")
74
+ else:
75
+ for i, r in enumerate(results):
76
+ if i > 0:
77
+ print("---")
78
+ print(f"## {r['section']}")
79
+ print(r['preview'])
80
+ print(f"📂 {r['file']}")
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env python3
2
+ """Master-skill fidelity test runner.
3
+
4
+ Loads fidelity.jsonl for a master, sends each question through the Claude API
5
+ with the master's SKILL.md loaded as system prompt, and checks responses for
6
+ expected citations and keywords.
7
+
8
+ Usage:
9
+ python scripts/test-fidelity.py --master zhiyi # test one master
10
+ python scripts/test-fidelity.py --master zhiyi --dry-run # show test cases without calling API
11
+ python scripts/test-fidelity.py --all # test all masters
12
+ python scripts/test-fidelity.py --master zhiyi --model claude-sonnet-4-6 # specific model
13
+
14
+ Requires:
15
+ - ANTHROPIC_API_KEY environment variable
16
+ - pip install anthropic
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
29
+
30
+
31
+ def load_skill_context(master_dir: Path) -> str:
32
+ """Load SKILL.md + references as a combined system prompt."""
33
+ parts: list[str] = []
34
+
35
+ skill = master_dir / "SKILL.md"
36
+ if skill.exists():
37
+ parts.append(skill.read_text(encoding="utf-8"))
38
+
39
+ # Load references (voice.md, teaching.md)
40
+ refs_dir = master_dir / "references"
41
+ if refs_dir.exists():
42
+ for f in sorted(refs_dir.glob("*.md")):
43
+ parts.append(f"\n\n---\n# {f.stem}\n\n{f.read_text(encoding='utf-8')}")
44
+
45
+ # Load source excerpts
46
+ sources_dir = master_dir / "sources"
47
+ if sources_dir.exists():
48
+ for f in sorted(sources_dir.glob("*.md")):
49
+ if f.name == "INDEX.md":
50
+ continue
51
+ parts.append(f"\n\n---\n# Source: {f.stem}\n\n{f.read_text(encoding='utf-8')}")
52
+
53
+ return "\n".join(parts)
54
+
55
+
56
+ def load_tests(master_dir: Path) -> list[dict]:
57
+ """Load fidelity.jsonl test cases."""
58
+ fidelity_path = master_dir / "tests" / "fidelity.jsonl"
59
+ if not fidelity_path.exists():
60
+ return []
61
+ tests = []
62
+ for line in fidelity_path.read_text(encoding="utf-8").strip().splitlines():
63
+ if line.strip():
64
+ tests.append(json.loads(line))
65
+ return tests
66
+
67
+
68
+ def check_response(response: str, test_case: dict, is_first_turn: bool = True) -> dict:
69
+ """Check a response against expected citations, mentions, and boundaries.
70
+
71
+ Returns {passed: bool, missing_cites: [...], missing_mentions: [...],
72
+ forbidden_found: [...], boundary_violations: [...]}.
73
+ """
74
+ missing_cites = []
75
+ for cite in test_case.get("must_cite", []):
76
+ if cite not in response:
77
+ missing_cites.append(cite)
78
+
79
+ missing_mentions = []
80
+ for mention in test_case.get("must_mention", []):
81
+ if mention not in response:
82
+ missing_mentions.append(mention)
83
+
84
+ # Boundary tests: must_not_contain
85
+ forbidden_found = []
86
+ for forbidden in test_case.get("must_not_contain", []):
87
+ if forbidden in response:
88
+ forbidden_found.append(forbidden)
89
+
90
+ # First-turn boundary: must_not_contain_first_turn
91
+ boundary_violations = []
92
+ if is_first_turn:
93
+ for forbidden in test_case.get("must_not_contain_first_turn", []):
94
+ if forbidden in response:
95
+ boundary_violations.append(forbidden)
96
+
97
+ passed = (
98
+ len(missing_cites) == 0
99
+ and len(missing_mentions) == 0
100
+ and len(forbidden_found) == 0
101
+ and len(boundary_violations) == 0
102
+ )
103
+
104
+ return {
105
+ "passed": passed,
106
+ "missing_cites": missing_cites,
107
+ "missing_mentions": missing_mentions,
108
+ "forbidden_found": forbidden_found,
109
+ "boundary_violations": boundary_violations,
110
+ }
111
+
112
+
113
+ def run_tests(
114
+ master_name: str,
115
+ dry_run: bool = False,
116
+ model: str = "claude-sonnet-4-6",
117
+ max_tests: int | None = None,
118
+ ) -> dict:
119
+ """Run fidelity tests for a master. Returns summary."""
120
+ master_dir = PREBUILT_DIR / master_name
121
+ if not master_dir.exists():
122
+ return {"error": f"Master '{master_name}' not found"}
123
+
124
+ tests = load_tests(master_dir)
125
+ if not tests:
126
+ return {"error": f"No fidelity.jsonl found for '{master_name}'"}
127
+
128
+ if max_tests is not None and max_tests > 0:
129
+ # Prefer easier/basic tests when capping — smoke suite should hit
130
+ # the reliable floor, not the advanced stress cases.
131
+ tests = sorted(
132
+ tests,
133
+ key=lambda t: {"basic": 0, "intermediate": 1, "advanced": 2}.get(
134
+ t.get("difficulty", "intermediate"), 1
135
+ ),
136
+ )[:max_tests]
137
+
138
+ results: list[dict] = []
139
+
140
+ if dry_run:
141
+ for i, test in enumerate(tests):
142
+ results.append({
143
+ "index": i,
144
+ "question": test["q"],
145
+ "must_cite": test.get("must_cite", []),
146
+ "must_mention": test.get("must_mention", []),
147
+ "difficulty": test.get("difficulty", "unknown"),
148
+ "status": "dry_run",
149
+ })
150
+ return {"master": master_name, "total": len(tests), "results": results}
151
+
152
+ # Load skill context
153
+ system_prompt = load_skill_context(master_dir)
154
+
155
+ # Import anthropic
156
+ try:
157
+ import anthropic
158
+ except ImportError:
159
+ return {"error": "anthropic package not installed. Run: pip install anthropic"}
160
+
161
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
162
+ if not api_key:
163
+ return {"error": "ANTHROPIC_API_KEY environment variable not set"}
164
+
165
+ client = anthropic.Anthropic(api_key=api_key)
166
+
167
+ passed = 0
168
+ failed = 0
169
+
170
+ for i, test in enumerate(tests):
171
+ print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
172
+
173
+ try:
174
+ message = client.messages.create(
175
+ model=model,
176
+ max_tokens=2048,
177
+ system=system_prompt,
178
+ messages=[{"role": "user", "content": test["q"]}],
179
+ )
180
+ response_text = message.content[0].text
181
+ except Exception as e:
182
+ results.append({
183
+ "index": i,
184
+ "question": test["q"],
185
+ "status": "api_error",
186
+ "error": str(e),
187
+ })
188
+ failed += 1
189
+ print("API ERROR")
190
+ continue
191
+
192
+ check = check_response(response_text, test, is_first_turn=True)
193
+ status = "PASS" if check["passed"] else "FAIL"
194
+
195
+ result_entry = {
196
+ "index": i,
197
+ "question": test["q"],
198
+ "difficulty": test.get("difficulty", "unknown"),
199
+ "test_type": test.get("test_type", "fidelity"),
200
+ "status": status,
201
+ "missing_cites": check["missing_cites"],
202
+ "missing_mentions": check["missing_mentions"],
203
+ "forbidden_found": check["forbidden_found"],
204
+ "boundary_violations": check["boundary_violations"],
205
+ "response_length": len(response_text),
206
+ }
207
+ results.append(result_entry)
208
+
209
+ if check["passed"]:
210
+ passed += 1
211
+ print("PASS")
212
+ else:
213
+ failed += 1
214
+ failures = (check["missing_cites"] + check["missing_mentions"]
215
+ + check["forbidden_found"] + check["boundary_violations"])
216
+ print(f"FAIL ({failures})")
217
+
218
+ return {
219
+ "master": master_name,
220
+ "model": model,
221
+ "total": len(tests),
222
+ "passed": passed,
223
+ "failed": failed,
224
+ "pass_rate": f"{passed / len(tests) * 100:.0f}%" if tests else "N/A",
225
+ "results": results,
226
+ }
227
+
228
+
229
+ def main():
230
+ parser = argparse.ArgumentParser(description="Master-skill fidelity test runner")
231
+ parser.add_argument("--master", type=str, help="Test a specific master")
232
+ parser.add_argument("--all", action="store_true", help="Test all masters with fidelity.jsonl")
233
+ parser.add_argument("--dry-run", action="store_true", help="Show test cases without calling API")
234
+ parser.add_argument("--model", type=str, default="claude-sonnet-4-6", help="Claude model to use")
235
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
236
+ parser.add_argument(
237
+ "--max-tests",
238
+ type=int,
239
+ default=None,
240
+ help="Cap the number of fixtures per master (smoke runs in CI use 1)",
241
+ )
242
+ args = parser.parse_args()
243
+
244
+ if not args.master and not args.all:
245
+ parser.error("Specify --master <name> or --all")
246
+
247
+ if args.all:
248
+ masters = sorted(
249
+ d.name for d in PREBUILT_DIR.iterdir()
250
+ if d.is_dir() and (d / "tests" / "fidelity.jsonl").exists()
251
+ )
252
+ else:
253
+ masters = [args.master]
254
+
255
+ all_results = []
256
+ for master in masters:
257
+ print(f"\n{'='*50}")
258
+ print(f"Testing: {master}")
259
+ print(f"{'='*50}")
260
+ result = run_tests(
261
+ master, dry_run=args.dry_run, model=args.model, max_tests=args.max_tests
262
+ )
263
+ all_results.append(result)
264
+
265
+ if not args.json and "error" not in result:
266
+ print(f"\nResult: {result.get('passed', 0)}/{result['total']} passed "
267
+ f"({result.get('pass_rate', 'N/A')})")
268
+
269
+ if args.json:
270
+ print(json.dumps(all_results, indent=2, ensure_ascii=False))
271
+ elif len(masters) > 1:
272
+ print(f"\n{'='*50}")
273
+ print("Overall Summary:")
274
+ for r in all_results:
275
+ if "error" in r:
276
+ print(f" {r.get('master', '?')}: {r['error']}")
277
+ else:
278
+ print(f" {r['master']}: {r.get('passed', 0)}/{r['total']} ({r.get('pass_rate', 'N/A')})")
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Validate fidelity.jsonl structure for all masters.
3
+
4
+ Checks that every test case has required fields and valid structure.
5
+ No API calls needed — pure structural validation.
6
+
7
+ Usage:
8
+ python scripts/validate-fidelity.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
18
+
19
+ VALID_TEST_TYPES = {"fidelity", "boundary", "pressure"}
20
+ VALID_BOUNDARIES = {
21
+ "sectarian_judgment",
22
+ "no_prophecy",
23
+ "neutral_first_turn",
24
+ "no_fabricated_dialogue",
25
+ "no_esoteric_instruction",
26
+ }
27
+ VALID_PRESSURES = {
28
+ "citation_bypass",
29
+ "informality_bypass",
30
+ "meta_challenge",
31
+ "hostile_challenge",
32
+ "simplicity_bypass",
33
+ "terminology_bypass",
34
+ "relevance_challenge",
35
+ "misunderstanding_challenge",
36
+ }
37
+
38
+
39
+ def validate_master(master_dir: Path) -> list[str]:
40
+ """Validate fidelity.jsonl for a single master. Returns list of errors."""
41
+ fidelity_path = master_dir / "tests" / "fidelity.jsonl"
42
+ if not fidelity_path.exists():
43
+ return [f"{master_dir.name}: no fidelity.jsonl found"]
44
+
45
+ errors = []
46
+ lines = fidelity_path.read_text(encoding="utf-8").strip().splitlines()
47
+
48
+ if len(lines) < 5:
49
+ errors.append(f"{master_dir.name}: fewer than 5 test cases ({len(lines)})")
50
+
51
+ for i, line in enumerate(lines, 1):
52
+ if not line.strip():
53
+ continue
54
+ try:
55
+ test = json.loads(line)
56
+ except json.JSONDecodeError as e:
57
+ errors.append(f"{master_dir.name}:{i}: invalid JSON — {e}")
58
+ continue
59
+
60
+ # Every test must have "q"
61
+ if "q" not in test:
62
+ errors.append(f"{master_dir.name}:{i}: missing 'q' field")
63
+
64
+ # Must have at least one assertion
65
+ has_assertion = any(
66
+ k in test
67
+ for k in [
68
+ "must_cite",
69
+ "must_mention",
70
+ "must_not_contain",
71
+ "must_not_contain_first_turn",
72
+ "must_select_masters",
73
+ "must_have_sections",
74
+ "must_cite_per_master",
75
+ ]
76
+ )
77
+ if not has_assertion:
78
+ errors.append(f"{master_dir.name}:{i}: no assertion fields found")
79
+
80
+ # Validate test_type if present
81
+ test_type = test.get("test_type")
82
+ if test_type and test_type not in VALID_TEST_TYPES:
83
+ errors.append(
84
+ f"{master_dir.name}:{i}: invalid test_type '{test_type}' "
85
+ f"(valid: {VALID_TEST_TYPES})"
86
+ )
87
+
88
+ # Validate boundary/pressure subtypes
89
+ if test_type == "boundary":
90
+ boundary = test.get("boundary")
91
+ if not boundary:
92
+ errors.append(f"{master_dir.name}:{i}: boundary test missing 'boundary' field")
93
+ elif boundary not in VALID_BOUNDARIES:
94
+ errors.append(
95
+ f"{master_dir.name}:{i}: unknown boundary '{boundary}' "
96
+ f"(valid: {VALID_BOUNDARIES})"
97
+ )
98
+
99
+ if test_type == "pressure":
100
+ pressure = test.get("pressure")
101
+ if not pressure:
102
+ errors.append(f"{master_dir.name}:{i}: pressure test missing 'pressure' field")
103
+
104
+ # List fields must be lists
105
+ for field in ["must_cite", "must_mention", "must_not_contain", "must_not_contain_first_turn"]:
106
+ if field in test and not isinstance(test[field], list):
107
+ errors.append(f"{master_dir.name}:{i}: '{field}' must be a list")
108
+
109
+ # Check coverage: should have at least one boundary test
110
+ has_boundary = any(
111
+ json.loads(l).get("test_type") == "boundary"
112
+ for l in lines
113
+ if l.strip()
114
+ )
115
+ if not has_boundary:
116
+ errors.append(f"{master_dir.name}: no boundary tests found (need at least one)")
117
+
118
+ return errors
119
+
120
+
121
+ def main():
122
+ all_errors = []
123
+ masters = sorted(
124
+ d for d in PREBUILT_DIR.iterdir()
125
+ if d.is_dir() and (d / "tests" / "fidelity.jsonl").exists()
126
+ )
127
+
128
+ for master_dir in masters:
129
+ errors = validate_master(master_dir)
130
+ all_errors.extend(errors)
131
+ if not errors:
132
+ fidelity_path = master_dir / "tests" / "fidelity.jsonl"
133
+ count = len(fidelity_path.read_text().strip().splitlines()) if fidelity_path.exists() else 0
134
+ print(f" {master_dir.name}: {count} tests OK")
135
+
136
+ if all_errors:
137
+ print(f"\n{len(all_errors)} error(s) found:")
138
+ for err in all_errors:
139
+ print(f" ERROR: {err}")
140
+ sys.exit(1)
141
+ else:
142
+ print(f"\nAll {len(masters)} masters validated successfully.")
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()