#!/usr/bin/env python3 """Byte-compare phase-1 report rendering against a baseline worktree. The current tree builds real-engine packets and pickles them outside the repo. A baseline checkout renders those same packets. Baseline-vs-baseline runs first. """ from __future__ import annotations import argparse import copy import hashlib import json import os import pickle import subprocess import sys import tempfile from pathlib import Path from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[2] PRODUCTION_BODY = { "format": "markdown", "edition": "full_data", "include_fact_tables": True, "languages": ["zh", "en"], } FOURTH = { "name": "虚构丁", "year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "second": 0, "lat": 28.6139, "lon": 77.2090, "tz": 5.5, "node_mode": "mean", "ayanamsa": "lahiri", "today": "2026-10-13", "transit_date": "2026-10-13", "target_year": 2026, "age": 36, "birth_time_accuracy": "confirmed", } def _ensure_hashseed() -> None: if os.environ.get("PYTHONHASHSEED") == "0": return env = dict(os.environ) env["PYTHONHASHSEED"] = "0" raise SystemExit(subprocess.call([sys.executable, *sys.argv], env=env)) def _activate(root: Path) -> None: root = root.resolve() script_dir = str(Path(__file__).resolve().parent) while script_dir in sys.path: sys.path.remove(script_dir) folders = [str(root / "jyotish_vedic"), str(root), str(root / "scripts")] for folder in folders: if not Path(folder).exists(): continue while folder in sys.path: sys.path.remove(folder) sys.path.insert(0, folder) def _mask_pyjhora() -> None: import importlib root = Path(tempfile.mkdtemp(prefix="nojhora-")) package = root / "jhora" package.mkdir() (package / "__init__.py").write_text('raise ImportError("masked")\n', encoding="utf-8") sys.path.insert(0, str(root)) prior = os.environ.get("PYTHONPATH", "") os.environ["PYTHONPATH"] = str(root) + (os.pathsep + prior if prior else "") for name in list(sys.modules): if name == "jhora" or name.startswith("jhora."): del sys.modules[name] importlib.invalidate_caches() def _sha(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def _freeze_clocks() -> None: import datetime as dt_mod real = dt_mod.datetime fixed_utc = real(2026, 10, 13, 0, 0, 0) fixed_local = real(2026, 10, 13, 8, 0, 0) class Frozen(real): @classmethod def now(cls, tz=None): if tz is not None: return fixed_utc.replace(tzinfo=tz) return fixed_local @classmethod def utcnow(cls): return fixed_utc for module in list(sys.modules.values()): if module is None: continue current = getattr(module, "datetime", None) if isinstance(current, type) and current.__name__ == "datetime": module.datetime = Frozen def _cases() -> list[tuple[str, dict]]: from tests.test_report_english_edition import CASES ordered = [(name, dict(CASES[name])) for name in ("day", "night", "south")] ordered.append(("fourth", dict(FOURTH))) return ordered def _subset_pack_id(packet: dict) -> str | None: manifest = packet.get("report_pack_manifest") if isinstance(packet, dict) else None packs = manifest.get("packs") if isinstance(manifest, dict) else None if not isinstance(packs, list): return None for item in packs: if isinstance(item, dict) and item.get("id") and item.get("id") != "full": return str(item["id"]) return None def _build_records() -> list[dict]: from scripts.jyotish_engine import ( _apply_pl9_pack_selection, build_professional_report_reference_packet, cmd_full_reading, ) from scripts.professional_report_reference import _export_args records = [] for name, case in _cases(): print(f"build {name}", file=sys.stderr, flush=True) reading = cmd_full_reading(SimpleNamespace(**case)) packet = build_professional_report_reference_packet(reading, _export_args(case), []) records.append({"id": f"{name}/reference", "kind": "pl9", "payload": packet}) stripped = copy.deepcopy(packet) if isinstance(stripped, dict): stripped.pop("personal_report_producer", None) records.append({"id": f"{name}/no-producer", "kind": "pl9", "payload": stripped}) pack_id = _subset_pack_id(packet if isinstance(packet, dict) else {}) if pack_id: selected = _apply_pl9_pack_selection(copy.deepcopy(packet), [pack_id]) records.append({"id": f"{name}/pack/{pack_id}", "kind": "pl9", "payload": selected}) else: print(f"skip pack {name}: no non-full pack", file=sys.stderr) records.append({"id": f"{name}/full-data-zh", "kind": "full", "payload": packet}) english = copy.deepcopy(packet) if isinstance(english, dict): english["report_language"] = "en" records.append({"id": f"{name}/full-data-en", "kind": "full", "payload": english}) records.append({ "id": f"{name}/api-production", "kind": "api", "payload": {"birth": case, "reading": reading}, }) return records def _render_text(kind: str, payload) -> str: if kind == "pl9": from scripts.jyotish_engine import render_pl9_markdown return render_pl9_markdown(copy.deepcopy(payload)) if kind == "full": from scripts.pl9_full_data_export import render_full_data_markdown return render_full_data_markdown(copy.deepcopy(payload)) if kind == "api": from scripts.professional_report_reference import build_professional_report_reference birth = payload["birth"] reading = copy.deepcopy(payload["reading"]) class Handler: def _high_rigor_birth_payload(self, _body): return dict(birth) def _compute_full_reading_for_thematic(self, _birth): return reading response = build_professional_report_reference(Handler(), dict(PRODUCTION_BODY)) return json.dumps(response, ensure_ascii=False, default=str) raise RuntimeError(f"unknown kind {kind}") def _render_records(records: list[dict]) -> list[dict]: import scripts.jyotish_engine # noqa: F401 import scripts.pl9_full_data_export # noqa: F401 import scripts.professional_report_reference # noqa: F401 _freeze_clocks() rendered = [] for record in records: text = _render_text(record["kind"], record["payload"]) if not isinstance(text, str): raise RuntimeError(f"{record['id']} did not render to text") rendered.append({"id": record["id"], "sha": _sha(text), "text": text}) return rendered def _diff_context(left: str, right: str) -> str: left_lines = left.splitlines() or [left] right_lines = right.splitlines() or [right] limit = min(len(left_lines), len(right_lines)) index = next((i for i in range(limit) if left_lines[i] != right_lines[i]), limit) start = max(0, index - 2) rows = [] for line_no in range(start, start + 5): sample = left_lines[line_no] if line_no < len(left_lines) else "" other = right_lines[line_no] if line_no < len(right_lines) else "" if sample == other and line_no != index: rows.append(f" {line_no + 1}: {sample}") continue cut = _window(sample, other) rows.append(f"- {line_no + 1}: {cut[0]}") rows.append(f"+ {line_no + 1}: {cut[1]}") return "\n".join(rows) def _window(left: str, right: str) -> tuple[str, str]: if len(left) <= 240 and len(right) <= 240: return left, right limit = min(len(left), len(right)) index = next((i for i in range(limit) if left[i] != right[i]), limit) start = max(0, index - 80) stop = index + 80 return left[start:stop], right[start:stop] def _invoke_baseline(baseline: Path, pickle_path: Path, result_path: Path, mask: bool) -> subprocess.CompletedProcess[str]: command = [ sys.executable, str(Path(__file__).resolve()), "--render-only", "--pickle", str(pickle_path), "--engine-root", str(baseline), "--result", str(result_path), ] if mask: command.append("--mask-pyjhora") env = dict(os.environ) env["PYTHONHASHSEED"] = "0" env["JYOTISH_REPORT_REFERENCE_CACHE"] = "0" env["JYOTISH_REPORT_RENDER_FAST"] = "0" return subprocess.run( command, cwd=str(baseline), env=env, text=True, capture_output=True, check=False, ) def _load_result(path: Path) -> list[dict]: return pickle.loads(path.read_bytes()) def _print_shas(rows: list[dict]) -> None: for row in rows: print(f"{row['id']}\t{row['sha']}") def _compare(label: str, left: list[dict], right: list[dict]) -> int: if [row["id"] for row in left] != [row["id"] for row in right]: print(f"{label}: record ids differ", file=sys.stderr) return 1 failed = 0 for lhs, rhs in zip(left, right): if lhs["sha"] == rhs["sha"]: continue failed += 1 print(f"{label} mismatch {lhs['id']} {lhs['sha']} vs {rhs['sha']}", file=sys.stderr) print(_diff_context(lhs["text"], rhs["text"]), file=sys.stderr) return 1 if failed else 0 def _render_only(args: argparse.Namespace) -> int: if args.mask_pyjhora: _mask_pyjhora() _activate(Path(args.engine_root)) os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0" os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0" records = pickle.loads(Path(args.pickle).read_bytes()) rendered = _render_records(records) Path(args.result).write_bytes(pickle.dumps(rendered, protocol=pickle.HIGHEST_PROTOCOL)) return 0 def main() -> int: _ensure_hashseed() parser = argparse.ArgumentParser() parser.add_argument("--baseline", type=Path) parser.add_argument("--mask-pyjhora", action="store_true") parser.add_argument("--render-only", action="store_true") parser.add_argument("--pickle", type=Path) parser.add_argument("--engine-root", type=Path) parser.add_argument("--result", type=Path) args = parser.parse_args() if args.render_only: return _render_only(args) if args.baseline is None: print("missing --baseline", file=sys.stderr) return 2 if args.mask_pyjhora: _mask_pyjhora() _activate(ROOT) os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0" os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0" records = _build_records() temp_dir = Path(tempfile.mkdtemp(prefix="pl9-render-split-")) try: pickle_path = temp_dir / "packets.pkl" pickle_path.write_bytes(pickle.dumps(records, protocol=pickle.HIGHEST_PROTOCOL)) first_path = temp_dir / "baseline-a.pkl" second_path = temp_dir / "baseline-b.pkl" first = _invoke_baseline(args.baseline, pickle_path, first_path, args.mask_pyjhora) second = _invoke_baseline(args.baseline, pickle_path, second_path, args.mask_pyjhora) if first.returncode != 0 or second.returncode != 0: print(first.stderr[-4000:], file=sys.stderr) print(second.stderr[-4000:], file=sys.stderr) print(first.stdout[-1000:], file=sys.stderr) return first.returncode or second.returncode or 1 baseline_a = _load_result(first_path) baseline_b = _load_result(second_path) if _compare("baseline-vs-baseline", baseline_a, baseline_b) != 0: print("基线对基线两次 sha 不同,存在非确定性,停止", file=sys.stderr) return 2 current = _render_records(pickle.loads(pickle_path.read_bytes())) if _compare("current-vs-baseline", current, baseline_a) != 0: return 1 _print_shas(current) print(f"ok items={len(current)} masked={int(args.mask_pyjhora)}") return 0 finally: for child in temp_dir.glob("*"): try: child.unlink() except OSError: pass try: temp_dir.rmdir() except OSError: pass if __name__ == "__main__": raise SystemExit(main())