Files
Jyotisha/scripts/research/report_render_split_diff.py
T
jesse-uxandGrok (agent loop) dbba127e71 拆分报告渲染函数的第一阶段,把 34 个无外层依赖的辅助函数搬到 report_render。
同一份真实盘包在屏蔽与不屏蔽 PyJHora 下新旧渲染 sha 都一致,各 24 项且退出码 0。指定 pytest 233 通过、3 跳过。

Co-Authored-By: Grok (agent loop)
2026-10-15 20:59:11 +08:00

368 lines
13 KiB
Python

#!/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())