同一份真实盘包在屏蔽与不屏蔽 PyJHora 下新旧渲染 sha 都一致,各 24 项且退出码 0。指定 pytest 233 通过、3 跳过。 Co-Authored-By: Grok (agent loop)
368 lines
13 KiB
Python
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())
|