Files
Jyotisha/scripts/benchmark_yoga_coverage.py

246 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""
Yoga 覆盖率 benchmark:对比当前 skill JSON 规则库与 PyJHora yoga.py。
目标:
1. 统计当前 references/yoga_rules.json 规则数量和分类分布
2. 若本机存在 PyJHora 安装,提取 jhora.horoscope.chart.yoga.py 中的唯一 Yoga 名称
3. 用名称归一化 + alias 表做近似覆盖匹配
4. 输出覆盖率、疑似缺失项和疑似重复项,作为后续补规则依据
说明:
- 这是覆盖 benchmark,不判断每条规则的数学条件是否与 PyJHora 完全等价。
- PyJHora 大量函数是同一 Yoga 的编号变体;本脚本会把函数名归一为唯一 Yoga 名称。
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Iterable
SKILL_ROOT = Path(__file__).resolve().parents[1]
RULES_FILE = SKILL_ROOT / "references" / "yoga_rules.json"
# 常见拼写/转写差异。左侧为 PyJHora normalized key,右侧为 skill 中的可能 normalized key。
ALIASES = {
"sasa": {"shasha", "sasha"},
"maalavya": {"malavya"},
"maalaa": {"mala"},
"maala": {"mala"},
"gadaa": {"gada"},
"kedaara": {"kedara"},
"naukaa": {"nauka"},
"chaapa": {"chapa"},
"kaahala": {"kahala"},
"kalaanidhi": {"kalanidhi"},
"sreenaatha": {"sreenatha", "srinatha"},
"vasumathi": {"vasumati"},
"subha": {"shubha"},
"dharidhra": {"daridra"},
"budha_aditya": {"budhaditya", "nipuna"},
"chandra_mangala": {"chandra_mangal"},
"gaja_kesari": {"gajakesari"},
"harihara_brahma": {"harihara_brahma", "hari_hara_brahma"},
"siva": {"shiva"},
"vesi": {"vesi"},
"vosi": {"vosi"},
"sunaphaa": {"sunapha"},
"anaphaa": {"anapha"},
"ubhayachara": {"ubhayachari"},
"duradhara": {"duradhara"},
}
SKIP_FUNCTIONS = {
"get_yoga_resources",
"get_yoga_details",
"get_yoga_details_for_all_charts",
"grihanasa_yoga_planet_positions",
"are_lords_exchanged",
}
def normalize_name(name: str) -> str:
"""Normalize Yoga names for approximate matching."""
s = name.lower()
s = re.sub(r"\([^)]*\)", " ", s)
s = s.replace("&", " and ")
s = re.sub(r"[^a-z0-9]+", "_", s)
s = re.sub(r"_+", "_", s).strip("_")
for suffix in ("_yoga", "_graha", "_classic", "_calculation", "_calc"):
if s.endswith(suffix):
s = s[: -len(suffix)]
s = re.sub(r"_\d+$", "", s)
return s
def extract_skill_names(rules: list[dict]) -> tuple[set[str], dict[str, list[str]]]:
keys: set[str] = set()
reverse: dict[str, list[str]] = defaultdict(list)
for rule in rules:
raw_names = [rule.get("name", ""), rule.get("name_cn", ""), rule.get("id", "")]
for raw in raw_names:
key = normalize_name(raw)
if not key:
continue
keys.add(key)
reverse[key].append(rule.get("id", "?"))
# 对英文名进一步拆出核心 token 组合,处理 “Gaja Kesari Yoga (Classic)” 这类名称
en = rule.get("name", "")
key = normalize_name(en)
if key:
parts = [p for p in key.split("_") if p not in {"yoga", "classic", "from", "lord", "lords", "combination"}]
if parts:
compact = "_".join(parts[:3])
keys.add(compact)
reverse[compact].append(rule.get("id", "?"))
return keys, reverse
def extract_external_benchmark_names(external_benchmark_yoga_file: Path) -> set[str]:
content = external_benchmark_yoga_file.read_text(encoding="utf-8", errors="ignore")
funcs = re.findall(r"^def ([a-zA-Z_][a-zA-Z0-9_]*)\(", content, re.MULTILINE)
names: set[str] = set()
for fn in funcs:
if fn in SKIP_FUNCTIONS:
continue
if fn.startswith("_"):
# PyJHora 的内部 calculation 函数大多也是 Yoga;保留 *_yoga*_calculation
if "yoga" not in fn:
continue
if "_from_" in fn or "_get_" in fn or "_is_" in fn:
continue
base = fn
base = re.sub(r"^_+", "", base)
base = base.replace("_calculation", "").replace("_calc", "")
base = re.sub(r"_\d+$", "", base)
if base.endswith("_yoga"):
base = base[:-5]
key = normalize_name(base)
if key and key not in SKIP_FUNCTIONS:
names.add(key)
return names
def find_external_benchmark_yoga_file(explicit: str | None = None) -> Path | None:
if explicit:
p = Path(explicit).expanduser().resolve()
return p if p.exists() else None
candidates: list[Path] = []
for root in sys.path:
if not root:
continue
p = Path(root) / "jhora" / "horoscope" / "chart" / "yoga.py"
if p.exists():
candidates.append(p)
# 常见 WorkBuddy isolated venv 位置
home = Path.home()
candidates.extend(home.glob(".workbuddy/binaries/python/envs/*/lib/python*/site-packages/jhora/horoscope/chart/yoga.py"))
return candidates[0] if candidates else None
def covered(py_key: str, skill_keys: set[str]) -> bool:
if py_key in skill_keys:
return True
compact_py = py_key.replace("_", "")
compact_skill = {k.replace("_", "") for k in skill_keys}
if compact_py in compact_skill:
return True
for alias in ALIASES.get(py_key, set()):
if alias in skill_keys or alias.replace("_", "") in compact_skill:
return True
return False
def main() -> int:
parser = argparse.ArgumentParser(description="Benchmark Yoga coverage against PyJHora yoga.py")
parser.add_argument("--pyjhora-yoga-file", help="Path to PyJHora jhora/horoscope/chart/yoga.py")
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
parser.add_argument("--show-missing", type=int, default=60, help="How many missing items to display")
args = parser.parse_args()
data = json.loads(RULES_FILE.read_text(encoding="utf-8"))
all_rules = data.get("rules", [])
rules = [r for r in all_rules if r.get("enabled", True)]
ids = [r.get("id") for r in all_rules]
dup_ids = sorted([i for i, c in Counter(ids).items() if c > 1])
categories = Counter(r.get("category", "unknown") for r in rules)
strength_values = Counter(r.get("strength", "?") for r in rules)
skill_keys, skill_reverse = extract_skill_names(rules)
external_benchmark_file = find_external_benchmark_yoga_file(args.external_benchmark_yoga_file)
py_names: set[str] = set()
missing: list[str] = []
coverage_pct = None
if external_benchmark_file:
py_names = extract_external_benchmark_names(external_benchmark_file)
missing = sorted([name for name in py_names if not covered(name, skill_keys)])
coverage_pct = round((len(py_names) - len(missing)) / len(py_names) * 100, 2) if py_names else None
result = {
"rules_file": str(RULES_FILE),
"total_rules": len(all_rules),
"enabled_rules": len(rules),
"disabled_rules": len(all_rules) - len(rules),
"declared_total_rules": data.get("total_rules"),
"declared_total_enabled_rules": data.get("total_enabled_rules"),
"schema_version": data.get("schema_version"),
"duplicate_ids": dup_ids,
"categories": dict(categories.most_common()),
"strength_values": dict(strength_values),
"skill_normalized_name_keys": len(skill_keys),
"external_benchmark_yoga_file": str(external_benchmark_file) if external_benchmark_file else None,
"external_benchmark_unique_yoga_names": len(py_names) if external_benchmark_file else None,
"matched_unique_yoga_names": (len(py_names) - len(missing)) if external_benchmark_file else None,
"coverage_pct": coverage_pct,
"missing_count": len(missing) if external_benchmark_file else None,
"missing": missing,
}
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
print("🧘 Yoga Coverage Benchmark")
print("=" * 60)
print(f"规则文件: {RULES_FILE}")
print(f"Schema: {data.get('schema_version')}")
print(f"规则总数: {len(all_rules)} (enabled={len(rules)}, disabled={len(all_rules) - len(rules)}, declared={data.get('total_rules')})")
print(f"重复 ID: {len(dup_ids)}" + (f"{dup_ids}" if dup_ids else ""))
print(f"Strength 值: {dict(strength_values)}")
print("\n分类统计:")
for cat, count in categories.most_common():
print(f" {cat:16s} {count:3d}")
print("\nPyJHora 对比:")
if not external_benchmark_file:
print(" 未找到 PyJHora yoga.py;仅完成本地 JSON 统计。")
print(" 可用 --pyjhora-yoga-file 指定路径。")
else:
print(f" yoga.py: {external_benchmark_file}")
print(f" PyJHora 唯一 Yoga 名称: {len(py_names)}")
print(f" 已匹配: {len(py_names) - len(missing)}")
print(f" 疑似缺失: {len(missing)}")
print(f" 名称覆盖率: {coverage_pct}%")
if missing:
print(f"\n疑似缺失 Top {min(args.show_missing, len(missing))}:")
for i, name in enumerate(missing[: args.show_missing], 1):
print(f" {i:3d}. {name}")
if len(missing) > args.show_missing:
print(f" ... 还有 {len(missing) - args.show_missing}")
print("\n说明: 此 benchmark 为名称覆盖率,不等同于逐条数学逻辑等价验证。")
return 0
if __name__ == "__main__":
raise SystemExit(main())