规则库 3.8.1 + 排盘修复 + 评测体系

- 规则库 3.6→3.8.1(542→548 条):剪枝上锁、R-杂-38 从格临界双轨、
  R-事-10~13 冲刑方向判别、R-禁-23 自造必要条件之禁
- 排盘修复随 3.6.2 已发:港澳时区夏令时双向误拨、晚子时双重换日
- 新增 评测/ 目录(BaziQA 题答分离、判分工具、记分卡、with/without-skill 对照)
  与 scripts/query_rules.py 条款检索;ZIPEC_EVAL 评测额度豁免
- manifest 重建并重新签名;VERSION 保持 3.6.2,不发版不打 tag
This commit is contained in:
2026-10-13 12:34:36 +08:00
parent b20fd5c3c9
commit 1a0eed1999
338 changed files with 36533 additions and 30 deletions
+48
View File
@@ -10,6 +10,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import _license_gate as gate
@pytest.fixture(autouse=True)
def _no_eval_env(monkeypatch):
"""默认清掉评测豁免变量,保证既有用例覆盖的是未豁免口径。"""
monkeypatch.delenv("ZIPEC_EVAL", raising=False)
@pytest.fixture
def root(tmp_path):
return tmp_path
@@ -161,3 +167,45 @@ class TestGrantUses:
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 12
assert gate.check(root) is None
class TestEvalExemption:
"""ZIPEC_EVAL=1:评测复测豁免——直接放行,不签发、不扣次、不创建任何文件。"""
def test_no_license_passes_and_creates_nothing(self, root, monkeypatch):
monkeypatch.setenv("ZIPEC_EVAL", "1")
assert gate.check(root) is None
gate.consume(root)
assert not (root / "data").exists()
def test_public_marker_not_issued(self, root, monkeypatch):
make_public_marker(root)
monkeypatch.setenv("ZIPEC_EVAL", "1")
assert gate.check(root) is None
assert not (root / "data" / ".license.json").exists() # 不就地签发
def test_licensed_copy_not_decremented(self, root, monkeypatch):
make_state(root)
before = (root / "data" / ".license.json").read_text("utf-8")
monkeypatch.setenv("ZIPEC_EVAL", "1")
assert gate.check(root) is None
gate.consume(root)
st = json.loads((root / "data" / ".license.json").read_text("utf-8"))
assert st["remaining_uses"] == 2 # 不扣次
assert (root / "data" / ".license.json").read_text("utf-8") == before
def test_exhausted_copy_passes_under_eval(self, root, monkeypatch):
make_state(root, max_uses=0)
monkeypatch.setenv("ZIPEC_EVAL", "1")
assert gate.check(root) is None
def test_env_unset_keeps_original_behavior(self, root, monkeypatch):
monkeypatch.setenv("ZIPEC_EVAL", "0") # 仅认 "1",其他值不豁免
make_public_marker(root)
assert gate.check(root) is None
assert (root / "data" / ".license.json").exists() # 公众版仍首启签发
def test_env_unset_exhausted_copy_still_denied(self, root, monkeypatch):
monkeypatch.setenv("ZIPEC_EVAL", "0")
make_state(root, max_uses=0)
assert "次数已用尽" in gate.check(root)
+130
View File
@@ -0,0 +1,130 @@
"""规则条款检索(scripts/query_rules.py):索引完整性、关键词命中、
多关键词排序、--id 精确取、--list、退出码。
索引规模基准:rules/[0-9]*.md 共 20 个文件、548 条 `### R-XX-NN` 条款
(2026-10 核,与 grep -c '^### R-' 对拍)。
"""
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import query_rules as qr # noqa: E402
CLAUSES = qr.load_index()
BY_ID = {c.rid: c for c in CLAUSES}
class TestIndexCompleteness:
def test_clause_count_548(self):
assert len(CLAUSES) == 548
def test_ids_unique(self):
assert len(BY_ID) == len(CLAUSES)
def test_twenty_files_covered(self):
files = {c.file for c in CLAUSES}
assert len(files) == 20
assert all(re.match(r"^[0-9].*\.md$", f) for f in files)
def test_every_clause_has_title_body_line(self):
for c in CLAUSES:
assert c.title and c.body and c.line >= 1, c.rid
def test_line_points_at_header(self):
"""抽样核对:条款行号精确指向源文件的 ### 头部行。"""
for rid in ("R-纲-00", "R-运-32", "R-煞-04", "R-附-24"):
c = BY_ID[rid]
src = (ROOT / "rules" / c.file).read_text(encoding="utf-8").splitlines()
assert src[c.line - 1].startswith(f"### {rid} ")
def test_body_excludes_next_heading_and_separator(self):
"""正文截断于下一个 ##/### 标题,尾部 --- 分隔线剥除。"""
c = BY_ID["R-纲-03"]
assert "R-纲-04" not in c.body and "## 3." not in c.body
assert not c.body.rstrip().endswith("---")
last = BY_ID["R-运-39"] # 文件尾条款:截到 EOF
assert "五组引动清单照常逐组核查" in last.body
assert last.body.rstrip().endswith("(综括,无单一原文)")
class TestSearch:
def test_single_keyword_hit(self):
hits = qr.search(CLAUSES, ["天乙贵人"])
assert hits and hits[0].rid == "R-煞-04"
assert hits[0].file == "94_神煞细节.md"
def test_no_match_returns_empty(self):
assert qr.search(CLAUSES, ["不存在词xyz"]) == []
def test_multi_keyword_all_hits_rank_first(self):
"""全命中条款一律排在部分命中之前。"""
hits = qr.search(CLAUSES, ["冲", "婚姻宫"])
n_kw = 2
seq = [sum(1 for kw in ("冲", "婚姻宫") if kw in c.title or kw in c.body)
for c in hits]
assert seq == sorted(seq, reverse=True)
assert seq[0] == n_kw and hits[0].rid == "R-事-13"
assert n_kw in seq # 确有全命中条款
def test_rare_keyword_outranks_common(self):
"""IDF:罕见词(从格)的强命中应压过常见词(破格)的弱命中。"""
hits = qr.search(CLAUSES, ["从格", "破格"])
assert hits[0].rid == "R-杂-38"
def test_title_match_weighted(self):
"""标题命中加权:标题含「墓库」的两条排在仅正文提及者之前。"""
hits = qr.search(CLAUSES, ["墓库"])
assert {h.rid for h in hits[:2]} == {"R-附-18", "R-纲-11"}
assert all("墓库" in h.title for h in hits[:2])
def test_strict_and(self):
hits = qr.search(CLAUSES, ["冲", "婚姻宫"], strict_and=True)
assert hits and all("冲" in c.title + c.body and "婚姻宫" in c.title + c.body
for c in hits)
assert qr.search(CLAUSES, ["从格", "破格"], strict_and=True) == []
class TestMainCLI:
def test_search_exit0_and_output(self, capsys):
assert qr.main(["天乙贵人", "--limit", "3"]) == 0
out = capsys.readouterr().out
assert "## R-煞-04 天乙贵人" in out
assert "94_神煞细节.md:" in out
def test_id_exact(self, capsys):
assert qr.main(["--id", "R-运-32"]) == 0
out = capsys.readouterr().out
assert "## R-运-32 落地义务与候选收敛" in out
assert "90_行运.md:352" in out
def test_id_unknown_exit1(self, capsys):
assert qr.main(["--id", "R-运-99"]) == 1
assert "无命中" in capsys.readouterr().err
def test_no_result_exit1(self, capsys):
assert qr.main(["不存在词xyz"]) == 1
capsys.readouterr()
def test_no_input_exit2(self, capsys):
assert qr.main([]) == 2
assert "ERROR" in capsys.readouterr().err
def test_list(self, capsys):
assert qr.main(["--list"]) == 0
out = capsys.readouterr().out
assert "(548 条)" in out
assert len(re.findall(r"^- R-\S+ ", out, re.M)) == 548
def test_limit(self, capsys):
assert qr.main(["冲", "--limit", "2"]) == 0
out = capsys.readouterr().out
assert len(re.findall(r"^## R-", out, re.M)) == 2
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+2 -2
View File
@@ -13,8 +13,8 @@ sys.path.insert(0, str(SCRIPTS))
import _runtime_guard as guard
ENTRY_SCRIPTS = ("cast_chart.py", "chart_shot.py", "memory_lookup.py",
"memory_save.py", "query_shensha.py", "self_update.py",
"share_export.py", "share_push.py")
"memory_save.py", "query_rules.py", "query_shensha.py",
"self_update.py", "share_export.py", "share_push.py")
def run_with_fake_py39(code: str) -> subprocess.CompletedProcess: