diff --git a/README.md b/README.md index 793c056e..f0b74237 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,13 @@ This is a **Vedic (Jyotish) astrology analysis system** designed for deep, audit - quick:快速开发守门,适合普通代码/文案修改后先跑:`python3 scripts/run_quality_gate.py --profile quick` - browser:完整浏览器守门,覆盖 runtime smoke 与真实浏览器用户路径:`python3 scripts/run_quality_gate.py --profile browser` -- release:发布前守门,包含关键产品文件未跟踪检查、慢速 golden cases 与 Yoga 逻辑报告:`python3 scripts/run_quality_gate.py --profile release` +- release:发布前守门,包含关键产品文件未跟踪检查、慢速 golden cases、真实案例复验与 Yoga 逻辑报告:`python3 scripts/run_quality_gate.py --profile release` + +### 真实案例复验与准确率边界 + +公开人物样本复验命令:`python3 tests/run_real_case_revalidation.py`。 + +当前复验口径是公开人物样本的出生盘星座级一致率,并对部分带有来源矛盾、时区争议或边界度数的参考行标记为 controversial_reference。这个指标用于验证排盘计算是否稳定,不等同于人生事件预测准确率,也不应被当作个人命运判断的命中率。 ### Prerequisites diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index cd6435db..a89ce076 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -25,6 +25,7 @@ EXTRA_COMPILE_TARGETS = [ ROOT / "scripts" / "audit_fragments.py", ROOT / "scripts" / "deployment_preflight.py", ROOT / "tests" / "run_golden_cases.py", + ROOT / "tests" / "run_real_case_revalidation.py", ROOT / "tests" / "run_frontend_runtime_smoke.py", ] @@ -77,6 +78,7 @@ QUALITY_GATE_PROFILES = { "skip_frontend_click": True, "frontend_click_mode": "core", "check_release_hygiene": False, + "skip_real_cases": True, }, "browser": { "skip_slow": True, @@ -85,6 +87,7 @@ QUALITY_GATE_PROFILES = { "skip_frontend_click": False, "frontend_click_mode": "all", "check_release_hygiene": False, + "skip_real_cases": True, }, "release": { "skip_slow": False, @@ -93,6 +96,7 @@ QUALITY_GATE_PROFILES = { "skip_frontend_click": False, "frontend_click_mode": "all", "check_release_hygiene": True, + "skip_real_cases": False, }, } @@ -252,7 +256,7 @@ def validate_json_files() -> None: def run_profile(args: argparse.Namespace) -> dict: profile = dict(QUALITY_GATE_PROFILES[args.profile]) - for key in ["skip_slow", "skip_yoga_logic", "skip_frontend_runtime", "skip_frontend_click"]: + for key in ["skip_slow", "skip_yoga_logic", "skip_frontend_runtime", "skip_frontend_click", "skip_real_cases"]: if getattr(args, key): profile[key] = True if args.frontend_click_mode: @@ -267,6 +271,7 @@ def main() -> int: parser.add_argument("--skip-yoga-logic", action="store_true", help="Skip Yoga logic comparison report refresh") parser.add_argument("--skip-frontend-runtime", action="store_true", help="Skip frontend build and runtime smoke") parser.add_argument("--skip-frontend-click", action="store_true", help="Skip browser click smoke") + parser.add_argument("--skip-real-cases", action="store_true", help="Skip public real-person chart revalidation") parser.add_argument("--frontend-click-mode", choices=["core", "mobile", "offline", "pdf", "workspace", "mobile-trust", "import-files", "all"], default=None, help="Browser click smoke mode for browser/release profiles") parser.add_argument("--frontend-click-timeout", type=int, default=240, help="Timeout seconds for browser click smoke") parser.add_argument("--all-tests", action="store_true", help="Run every pytest file, including optional-dependency suites") @@ -300,6 +305,8 @@ def main() -> int: ]) if not profile["skip_slow"]: run([PYTHON, "tests/run_golden_cases.py", "--python", PYTHON]) + if not profile["skip_real_cases"]: + run([PYTHON, "tests/run_real_case_revalidation.py", "--python", PYTHON, "--summary"]) if not profile["skip_yoga_logic"]: run([PYTHON, "scripts/validate_logic_v2.py"], optional=True) print("\nQuality gate passed.") diff --git a/tests/run_real_case_revalidation.py b/tests/run_real_case_revalidation.py new file mode 100644 index 00000000..6d59ca19 --- /dev/null +++ b/tests/run_real_case_revalidation.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Revalidate public real-person chart fixtures against the local engine. + +This is a chart-calculation regression gate, not an event_prediction_accuracy +claim. It checks sign-level and optional degree-level agreement for public +reference cases, while keeping known controversial_reference rows visible in +the JSON report. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +ENGINE = ROOT / "scripts" / "jyotish_engine.py" +PUBLIC_CASES_RELATIVE = "tests/celebrity_cases.json" +INDASTRO_CASES_RELATIVE = "tests/indastro_cases.json" +PUBLIC_CASES = ROOT / PUBLIC_CASES_RELATIVE +INDASTRO_CASES = ROOT / INDASTRO_CASES_RELATIVE + +DEFAULT_MIN_PASS_RATE = 0.98 +DEFAULT_DEGREE_TOLERANCE = 1.0 +CONTROVERSIAL_HINTS = [ + "内部矛盾", + "需进一步验证", + "存在重大偏差", + "存在约", + "边界案例", +] + + +def load_cases(path: Path) -> list[dict[str, Any]]: + return json.loads(path.read_text(encoding="utf-8")) + + +def run_engine(case: dict[str, Any], python: str) -> dict[str, Any]: + cmd = [ + python, + str(ENGINE), + "chart", + "--year", + str(case["year"]), + "--month", + str(case["month"]), + "--day", + str(case["day"]), + "--hour", + str(case["hour"]), + "--minute", + str(case["minute"]), + "--lat", + str(case["lat"]), + "--lon", + str(case["lon"]), + "--tz", + str(case["tz"]), + ] + completed = subprocess.run( + cmd, + cwd=ROOT, + text=True, + capture_output=True, + timeout=45, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError(completed.stderr.strip() or completed.stdout.strip()) + return json.loads(completed.stdout) + + +def is_controversial(case: dict[str, Any]) -> bool: + note = case.get("tz_note", "") + return any(hint in note for hint in CONTROVERSIAL_HINTS) + + +def add_check( + checks: list[dict[str, Any]], + label: str, + expected: Any, + actual: Any, + *, + gated: bool, + tolerance: float | None = None, +) -> None: + if expected in (None, ""): + return + if tolerance is None: + passed = actual == expected + else: + passed = actual is not None and abs(float(actual) - float(expected)) <= tolerance + checks.append( + { + "label": label, + "expected": expected, + "actual": actual, + "passed": passed, + "gated": gated, + } + ) + + +def validate_public_case(case: dict[str, Any], chart: dict[str, Any]) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + planets = chart.get("planets", {}) + add_check(checks, "lagna_sign", case.get("known_lagna"), chart.get("ascendant", {}).get("sign"), gated=True) + add_check(checks, "sun_sign", case.get("known_sun_sign"), planets.get("Sun", {}).get("sign"), gated=True) + add_check(checks, "moon_sign", case.get("known_moon_sign"), planets.get("Moon", {}).get("sign"), gated=True) + return summarize_case(case, "public_reference", checks) + + +def validate_indastro_case(case: dict[str, Any], chart: dict[str, Any], tolerance: float) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + planets = chart.get("planets", {}) + gated = not is_controversial(case) + category = "controversial_reference" if not gated else "public_reference" + asc = chart.get("ascendant", {}) + sun = planets.get("Sun", {}) + moon = planets.get("Moon", {}) + + add_check(checks, "lagna_sign", case.get("expected_lagna"), asc.get("sign"), gated=gated) + add_check(checks, "sun_sign", case.get("expected_sun"), sun.get("sign"), gated=gated) + add_check(checks, "moon_sign", case.get("expected_moon"), moon.get("sign"), gated=gated) + add_check(checks, "lagna_degree", case.get("expected_lagna_degree"), asc.get("degree_in_sign"), gated=False, tolerance=tolerance) + add_check(checks, "sun_degree", case.get("expected_sun_degree"), sun.get("degree_in_sign"), gated=False, tolerance=tolerance) + add_check(checks, "moon_degree", case.get("expected_moon_degree"), moon.get("degree_in_sign"), gated=False, tolerance=tolerance) + + result = summarize_case(case, category, checks) + result["source"] = case.get("source", "Indastro.com") + result["note"] = case.get("tz_note", "") + return result + + +def summarize_case(case: dict[str, Any], category: str, checks: list[dict[str, Any]]) -> dict[str, Any]: + gated = [check for check in checks if check["gated"]] + return { + "id": case["id"], + "name": case["name"], + "category": category, + "passed_checks": sum(1 for check in checks if check["passed"]), + "total_checks": len(checks), + "gated_passed_checks": sum(1 for check in gated if check["passed"]), + "gated_total_checks": len(gated), + "checks": checks, + } + + +def build_report(args: argparse.Namespace) -> dict[str, Any]: + results: list[dict[str, Any]] = [] + for case in load_cases(PUBLIC_CASES): + chart = run_engine(case, args.python) + results.append(validate_public_case(case, chart)) + for case in load_cases(INDASTRO_CASES): + chart = run_engine(case, args.python) + results.append(validate_indastro_case(case, chart, args.degree_tolerance)) + + total_checks = sum(item["total_checks"] for item in results) + passed_checks = sum(item["passed_checks"] for item in results) + gated_total_checks = sum(item["gated_total_checks"] for item in results) + gated_passed_checks = sum(item["gated_passed_checks"] for item in results) + pass_rate = gated_passed_checks / gated_total_checks if gated_total_checks else 1.0 + failures = [ + { + "id": item["id"], + "name": item["name"], + "category": item["category"], + "failed_checks": [check for check in item["checks"] if check["gated"] and not check["passed"]], + } + for item in results + if any(check["gated"] and not check["passed"] for check in item["checks"]) + ] + controversial = [item for item in results if item["category"] == "controversial_reference"] + + return { + "valid": pass_rate >= args.min_pass_rate and not failures, + "scope": "public real-person chart revalidation; not event_prediction_accuracy", + "min_pass_rate": args.min_pass_rate, + "pass_rate": round(pass_rate, 4), + "passed_checks": passed_checks, + "total_checks": total_checks, + "gated_passed_checks": gated_passed_checks, + "gated_total_checks": gated_total_checks, + "controversial_reference_cases": len(controversial), + "failures": failures, + "results": results, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run public real-case Jyotish chart revalidation") + parser.add_argument("--python", default=sys.executable) + parser.add_argument("--min-pass-rate", type=float, default=DEFAULT_MIN_PASS_RATE) + parser.add_argument("--degree-tolerance", type=float, default=DEFAULT_DEGREE_TOLERANCE) + parser.add_argument("--summary", action="store_true", help="Print a compact human-readable line before JSON") + args = parser.parse_args() + + report = build_report(args) + if args.summary: + print( + "真实案例复验: " + f"gated={report['gated_passed_checks']}/{report['gated_total_checks']} " + f"all={report['passed_checks']}/{report['total_checks']} " + f"controversial={report['controversial_reference_cases']}" + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if report["valid"] else 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except BrokenPipeError: + raise SystemExit(0) diff --git a/tests/test_frontend_productization.py b/tests/test_frontend_productization.py index 520340d7..a788d29b 100644 --- a/tests/test_frontend_productization.py +++ b/tests/test_frontend_productization.py @@ -1725,6 +1725,39 @@ def test_user_delivery_matrix_is_documented_and_checkable() -> None: assert '[PYTHON, "scripts/deployment_preflight.py"]' in quality_gate +def test_real_case_revalidation_is_release_gate_and_accuracy_boundary() -> None: + runner_path = ROOT / "tests" / "run_real_case_revalidation.py" + assert runner_path.exists() + runner = runner_path.read_text(encoding="utf-8") + quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + + for token in [ + "tests/celebrity_cases.json", + "tests/indastro_cases.json", + "public_reference", + "controversial_reference", + "min_pass_rate", + "event_prediction_accuracy", + "passed_checks", + "total_checks", + ]: + assert token in runner + + assert '"tests/run_real_case_revalidation.py"' in quality_gate + assert '[PYTHON, "tests/run_real_case_revalidation.py"' in quality_gate + assert "--skip-real-cases" in quality_gate + + for token in [ + "真实案例复验", + "公开人物样本", + "星座级一致率", + "不等同于人生事件预测准确率", + "python3 tests/run_real_case_revalidation.py", + ]: + assert token in readme + + def test_user_startup_labels_are_consistent_across_recovery_surfaces() -> None: readme = (ROOT / "README.md").read_text(encoding="utf-8") quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8")