Add real case revalidation gate
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user