246 lines
9.4 KiB
Python
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())
|