Offline H1-H4 measurement on 20 public AA holdout cases. Block unique top-1 did not rise; H4 made it worse; minute layer unchanged. Leave production scoring untouched. Transits must not drive minute conclusions.
1085 lines
43 KiB
Python
1085 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
"""Offline measurement: house-lord gochara relaxations vs lagna/minute ranking.
|
||
|
||
Does not change production `active_rectification_event_engine.py` or
|
||
`scoring_service.py` defaults. H1–H4 are temporary patches inside this script.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import statistics
|
||
import subprocess
|
||
import sys
|
||
import traceback
|
||
from dataclasses import dataclass
|
||
from datetime import date, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any, Sequence
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from scripts.active_rectification_event_engine import ( # noqa: E402
|
||
DOMAIN_CONFIG,
|
||
NODE_MODE,
|
||
AYANAMSA,
|
||
_event_datetime,
|
||
_house_lords,
|
||
_relative_house,
|
||
compute_candidate_static_contexts,
|
||
compute_event_candidate_rows,
|
||
)
|
||
from scripts.active_rectification_events import precision_weight # noqa: E402
|
||
from scripts.rectification.candidate_contrast import cluster_contexts_by_signature # noqa: E402
|
||
from scripts.rectification.event_probes import ( # noqa: E402
|
||
_static_contexts,
|
||
discriminating_event_probes,
|
||
)
|
||
from scripts.rectification.scoring_service import ( # noqa: E402
|
||
build_event_contribution_matrix,
|
||
scoreable_request,
|
||
)
|
||
from scripts.research.probe_supply_after_six import ( # noqa: E402
|
||
ASK_COUNT,
|
||
TODAY,
|
||
apply_answer,
|
||
asked_key,
|
||
optimal_answer,
|
||
range_width,
|
||
remaining_after_six,
|
||
request_from_case,
|
||
top1_hit,
|
||
)
|
||
import dasha_analyzer # noqa: E402
|
||
import domain_calculation_service # noqa: E402
|
||
import narayana_dasha # noqa: E402
|
||
|
||
HOLDOUT_MANIFEST = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v3.json"
|
||
BLOCK_RADIUS_MINUTES = 120
|
||
LAGNA_SCAN_STEP = 2
|
||
TRANSIT_POINTS = 0.25
|
||
H4_MD_POINTS = 1.0
|
||
H4_AD_POINTS = 0.5
|
||
H4_H1_POINTS = 0.25
|
||
DEFAULT_H1_ORB = 3.0
|
||
DEFAULT_H2_ORB = 2.0
|
||
|
||
_TRANSIT_CACHE: dict[tuple[Any, ...], dict[str, Any]] = {}
|
||
_DASHA_CACHE: dict[tuple[Any, ...], tuple[list[dict[str, Any]], Any]] = {}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Relaxation:
|
||
h1: bool = False
|
||
h2: bool = False
|
||
h3: bool = False
|
||
h4: bool = False
|
||
h1_orb: float = DEFAULT_H1_ORB
|
||
h2_orb: float = DEFAULT_H2_ORB
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
labels = [
|
||
label
|
||
for label, on in (("H1", self.h1), ("H2", self.h2), ("H3", self.h3), ("H4", self.h4))
|
||
if on
|
||
]
|
||
base = "+".join(labels) if labels else "baseline"
|
||
extra: list[str] = []
|
||
if self.h1 and abs(self.h1_orb - DEFAULT_H1_ORB) > 1e-9:
|
||
extra.append(f"h1orb{self.h1_orb:g}")
|
||
if self.h2 and abs(self.h2_orb - DEFAULT_H2_ORB) > 1e-9:
|
||
extra.append(f"h2orb{self.h2_orb:g}")
|
||
return base if not extra else f"{base}@{'+'.join(extra)}"
|
||
|
||
|
||
def all_relaxations() -> list[Relaxation]:
|
||
rows: list[Relaxation] = []
|
||
for mask in range(16):
|
||
rows.append(Relaxation(
|
||
h1=bool(mask & 1),
|
||
h2=bool(mask & 2),
|
||
h3=bool(mask & 4),
|
||
h4=bool(mask & 8),
|
||
))
|
||
return rows
|
||
|
||
|
||
def orb_sensitivity_relaxations() -> list[Relaxation]:
|
||
rows = [Relaxation()]
|
||
for orb in (1.0, 3.0, 5.0):
|
||
rows.append(Relaxation(h1=True, h1_orb=orb))
|
||
for orb in (1.0, 2.0, 5.0):
|
||
rows.append(Relaxation(h2=True, h2_orb=orb))
|
||
return rows
|
||
|
||
|
||
def circ_delta(left: float, right: float) -> float:
|
||
delta = abs(left - right) % 360.0
|
||
return min(delta, 360.0 - delta)
|
||
|
||
|
||
def aspect_name(left: float, right: float, orb: float) -> str | None:
|
||
distance = circ_delta(left, right)
|
||
if distance <= orb:
|
||
return "conjunction"
|
||
if abs(distance - 180.0) <= orb:
|
||
return "opposition"
|
||
if abs(distance - 90.0) <= orb:
|
||
return "square"
|
||
return None
|
||
|
||
|
||
def planet_lon(chart: dict[str, Any], name: str) -> float | None:
|
||
item = (chart.get("planets") or {}).get(name) or {}
|
||
value = item.get("lon")
|
||
return float(value) if isinstance(value, (int, float)) else None
|
||
|
||
|
||
def _chart_args(request: dict[str, Any], when: datetime) -> dict[str, Any]:
|
||
return {
|
||
"year": when.year,
|
||
"month": when.month,
|
||
"day": when.day,
|
||
"hour": when.hour,
|
||
"minute": when.minute,
|
||
"lat": request["lat"],
|
||
"lon": request["lon"],
|
||
"tz": request["tz"],
|
||
"ayanamsa": request.get("ayanamsa", AYANAMSA),
|
||
"node_mode": request.get("node_mode", NODE_MODE),
|
||
}
|
||
|
||
|
||
def transit_chart(request: dict[str, Any], when: datetime) -> dict[str, Any]:
|
||
key = (
|
||
when.year, when.month, when.day, when.hour, when.minute,
|
||
round(float(request["lat"]), 5), round(float(request["lon"]), 5),
|
||
float(request["tz"]),
|
||
request.get("ayanamsa", AYANAMSA),
|
||
request.get("node_mode", NODE_MODE),
|
||
)
|
||
cached = _TRANSIT_CACHE.get(key)
|
||
if cached is not None:
|
||
return cached
|
||
chart = domain_calculation_service.compute_chart(_chart_args(request, when))
|
||
_TRANSIT_CACHE[key] = chart
|
||
return chart
|
||
|
||
|
||
def event_year(event: dict[str, Any]) -> int:
|
||
raw = str(event.get("date") or event.get("date_start") or "")
|
||
return int(raw[:4])
|
||
|
||
|
||
def extra_gochara_rules(
|
||
request: dict[str, Any],
|
||
event: dict[str, Any],
|
||
natal_chart: dict[str, Any],
|
||
natal_ascendant_index: int,
|
||
target_houses: tuple[int, ...],
|
||
relax: Relaxation,
|
||
) -> list[str]:
|
||
"""Return only H1/H3 extras. Production occupancy rules stay on the baseline row."""
|
||
precision = str(event.get("precision") or "year")
|
||
rules: list[str] = []
|
||
occupancy_times: list[datetime]
|
||
if precision == "year" and relax.h3:
|
||
occupancy_times = [datetime(event_year(event), month, 15, 12, 0) for month in range(1, 13)]
|
||
seen_planets: set[str] = set()
|
||
for when in occupancy_times:
|
||
chart = transit_chart(request, when)
|
||
for planet in ("Jupiter", "Saturn"):
|
||
if planet in seen_planets:
|
||
continue
|
||
lon = planet_lon(chart, planet)
|
||
if lon is None:
|
||
continue
|
||
if _relative_house(int(lon // 30), natal_ascendant_index) in target_houses:
|
||
rules.append(f"h3_year_transit_{planet.lower()}_domain_house")
|
||
seen_planets.add(planet)
|
||
elif precision == "year":
|
||
occupancy_times = [_event_datetime(event)]
|
||
else:
|
||
occupancy_times = [_event_datetime(event)]
|
||
|
||
if relax.h1:
|
||
lords = _house_lords(natal_ascendant_index, target_houses)
|
||
natal_longitudes = {lord: planet_lon(natal_chart, lord) for lord in lords}
|
||
seen: set[str] = set()
|
||
for when in occupancy_times:
|
||
chart = transit_chart(request, when)
|
||
for planet in ("Jupiter", "Saturn"):
|
||
transit_lon = planet_lon(chart, planet)
|
||
if transit_lon is None:
|
||
continue
|
||
for lord, natal in natal_longitudes.items():
|
||
if natal is None:
|
||
continue
|
||
aspect = aspect_name(transit_lon, natal, relax.h1_orb)
|
||
if aspect is None:
|
||
continue
|
||
rule = f"h1_transit_{planet.lower()}_{aspect}_natal_{lord.lower()}"
|
||
if rule not in seen:
|
||
rules.append(rule)
|
||
seen.add(rule)
|
||
return rules
|
||
|
||
|
||
def h2_rules(event: dict[str, Any], context: dict[str, Any], relax: Relaxation) -> list[str]:
|
||
natal = context["chart"]
|
||
ascendant_index = int(context["ascendant_index"])
|
||
rahu = planet_lon(natal, "Rahu")
|
||
ketu = planet_lon(natal, "Ketu")
|
||
target_houses = DOMAIN_CONFIG[event["domain"]][1]
|
||
lords = _house_lords(ascendant_index, target_houses)
|
||
hits: list[str] = []
|
||
for node_name, node_lon in (("rahu", rahu), ("ketu", ketu)):
|
||
if node_lon is None:
|
||
continue
|
||
for lord in lords:
|
||
lord_lon = planet_lon(natal, lord)
|
||
if lord_lon is None:
|
||
continue
|
||
if circ_delta(node_lon, lord_lon) <= relax.h2_orb:
|
||
hits.append(f"h2_natal_{node_name}_{lord.lower()}_conjunction")
|
||
break
|
||
return hits
|
||
|
||
|
||
def dasha_timeline(birth_date: str, moon_longitude: float):
|
||
key = (birth_date, round(float(moon_longitude), 6))
|
||
cached = _DASHA_CACHE.get(key)
|
||
if cached is not None:
|
||
return cached
|
||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
||
timeline, *_rest = dasha_analyzer.build_dasha_timeline(birth_date, nakshatra, progress)
|
||
_DASHA_CACHE[key] = (timeline, moon_longitude)
|
||
return _DASHA_CACHE[key]
|
||
|
||
|
||
def year_overlaps(start: datetime, end: datetime, year: int) -> bool:
|
||
year_start = datetime(year, 1, 1)
|
||
year_end = datetime(year + 1, 1, 1)
|
||
return start < year_end and end > year_start
|
||
|
||
|
||
def h4_bonus(
|
||
request: dict[str, Any],
|
||
context: dict[str, Any],
|
||
relax: Relaxation,
|
||
) -> dict[str, Any]:
|
||
natal = context["chart"]
|
||
ascendant_index = int(context["ascendant_index"])
|
||
moon = planet_lon(natal, "Moon")
|
||
hits: list[dict[str, Any]] = []
|
||
points = 0.0
|
||
if moon is None:
|
||
return {"points": 0.0, "hits": hits, "blocked": "moon_longitude_missing"}
|
||
timeline, _ = dasha_timeline(request["birth_date"], moon)
|
||
for event in request["events"]:
|
||
year = event_year(event)
|
||
target_houses = DOMAIN_CONFIG[event["domain"]][1]
|
||
lords = _house_lords(ascendant_index, target_houses)
|
||
md_hit = False
|
||
ad_hit = False
|
||
for major in timeline:
|
||
if not year_overlaps(major["start"], major["end"], year):
|
||
continue
|
||
if str(major["lord"]) in lords:
|
||
md_hit = True
|
||
for minor in dasha_analyzer.build_antardasha(major):
|
||
if str(minor["lord"]) in lords and year_overlaps(minor["start"], minor["end"], year):
|
||
ad_hit = True
|
||
h1_hit = False
|
||
sample = Relaxation(h1=True, h3=True, h1_orb=relax.h1_orb)
|
||
h1_rules = extra_gochara_rules(
|
||
request, event, natal, ascendant_index, target_houses, sample,
|
||
)
|
||
h1_hit = any(rule.startswith("h1_") for rule in h1_rules)
|
||
kind = None
|
||
if md_hit:
|
||
kind = "md"
|
||
points += H4_MD_POINTS
|
||
elif ad_hit:
|
||
kind = "ad"
|
||
points += H4_AD_POINTS
|
||
if h1_hit and not relax.h1:
|
||
points += H4_H1_POINTS
|
||
kind = f"{kind}+h1" if kind else "h1"
|
||
if kind:
|
||
hits.append({
|
||
"event_id": event["id"],
|
||
"domain": event["domain"],
|
||
"year": year,
|
||
"kind": kind,
|
||
"lords": sorted(lords),
|
||
})
|
||
return {"points": round(points, 4), "hits": hits, "blocked": None}
|
||
|
||
|
||
def apply_extras(
|
||
rows: Sequence[dict[str, Any]],
|
||
request: dict[str, Any],
|
||
contexts_by_time: dict[str, dict[str, Any]],
|
||
relax: Relaxation,
|
||
) -> list[dict[str, Any]]:
|
||
events_by_id = {str(event["id"]): event for event in request["events"]}
|
||
cloned: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
context = contexts_by_time.get(str(row["time"])[:5])
|
||
evidence_out = []
|
||
for item in row.get("evidence") or []:
|
||
event = events_by_id.get(str(item.get("event_id") or ""))
|
||
extra: list[str] = []
|
||
if event is not None and context is not None:
|
||
houses = DOMAIN_CONFIG[event["domain"]][1]
|
||
extra.extend(extra_gochara_rules(
|
||
request, event, context["chart"], int(context["ascendant_index"]), houses, relax,
|
||
))
|
||
if relax.h2:
|
||
extra.extend(h2_rules(event, context, relax))
|
||
points = round(
|
||
float(item.get("points") or 0.0)
|
||
+ TRANSIT_POINTS * len(extra) * precision_weight(event["precision"] if event else "year"),
|
||
4,
|
||
)
|
||
evidence_out.append({
|
||
**item,
|
||
"rule_ids": list(item.get("rule_ids") or []) + extra,
|
||
"points": points,
|
||
})
|
||
cloned.append({
|
||
**row,
|
||
"evidence": evidence_out,
|
||
"score": round(sum(float(item["points"]) for item in evidence_out), 4),
|
||
})
|
||
return cloned
|
||
|
||
|
||
def true_center(case: dict[str, Any]) -> datetime:
|
||
birth = case["birth"]
|
||
return datetime.combine(
|
||
date.fromisoformat(str(birth["date"])),
|
||
datetime.strptime(str(birth["time"])[:5], "%H:%M").time(),
|
||
)
|
||
|
||
|
||
def window_datetimes(center: datetime, radius: int, step: int) -> list[datetime]:
|
||
start = center - timedelta(minutes=radius)
|
||
end = center + timedelta(minutes=radius)
|
||
rows = []
|
||
cursor = start
|
||
while cursor <= end:
|
||
rows.append(cursor)
|
||
cursor += timedelta(minutes=step)
|
||
if center not in rows:
|
||
rows.append(center)
|
||
rows.sort()
|
||
return rows
|
||
|
||
|
||
def engine_request_from_scoring(
|
||
scoring_request: dict[str, Any],
|
||
*,
|
||
start_time: str,
|
||
end_time: str,
|
||
) -> dict[str, Any]:
|
||
events = []
|
||
for event in scoring_request["events"]:
|
||
precision = str(event["precision"])
|
||
start = str(event["date_start"])
|
||
if precision == "year":
|
||
event_date = start[:4]
|
||
elif precision == "month":
|
||
event_date = start[:7]
|
||
else:
|
||
event_date = start[:10]
|
||
precision = "day"
|
||
domain = str(event["domain"])
|
||
if domain not in DOMAIN_CONFIG:
|
||
continue
|
||
events.append({
|
||
"id": event["id"],
|
||
"domain": domain,
|
||
"event_kind": event.get("event_kind", domain),
|
||
"date": event_date,
|
||
"precision": precision,
|
||
"summary": event.get("summary", ""),
|
||
})
|
||
payload = {
|
||
"birth_date": scoring_request["birth_date"],
|
||
"start_time": start_time,
|
||
"end_time": end_time,
|
||
"lat": scoring_request["lat"],
|
||
"lon": scoring_request["lon"],
|
||
"tz": scoring_request["tz"],
|
||
"events": events,
|
||
}
|
||
for key in ("ayanamsa", "node_mode"):
|
||
if key in scoring_request:
|
||
payload[key] = scoring_request[key]
|
||
return payload
|
||
|
||
|
||
def hhmm(value: datetime) -> str:
|
||
return value.strftime("%H:%M")
|
||
|
||
|
||
def sign_name(index: int) -> str:
|
||
return narayana_dasha.SIGNS[index]
|
||
|
||
|
||
def lagna_index_at(request: dict[str, Any], when: datetime) -> int:
|
||
chart = transit_chart(request, when)
|
||
lon = float(chart["ascendant"]["lon"])
|
||
return int(lon // 30)
|
||
|
||
|
||
def lagna_representatives(
|
||
request: dict[str, Any],
|
||
center: datetime,
|
||
) -> list[dict[str, Any]]:
|
||
scanned = window_datetimes(center, BLOCK_RADIUS_MINUTES, LAGNA_SCAN_STEP)
|
||
groups: dict[int, list[datetime]] = {}
|
||
for when in scanned:
|
||
groups.setdefault(lagna_index_at(request, when), []).append(when)
|
||
rows = []
|
||
for index, times in sorted(groups.items()):
|
||
mid = times[len(times) // 2]
|
||
rows.append({
|
||
"ascendant_index": index,
|
||
"ascendant_sign": sign_name(index),
|
||
"representative_at": mid,
|
||
"member_count": len(times),
|
||
"first_at": times[0],
|
||
"last_at": times[-1],
|
||
})
|
||
return rows
|
||
|
||
|
||
def unique_rank(scores: dict[int, float], true_index: int) -> dict[str, Any]:
|
||
if true_index not in scores:
|
||
return {
|
||
"rank": None, "top1": False, "top1_unique": False, "top2": False,
|
||
"tie_count": 0, "n": len(scores),
|
||
}
|
||
true_score = scores[true_index]
|
||
higher = sum(1 for value in scores.values() if value > true_score + 1e-9)
|
||
tied = [index for index, value in scores.items() if abs(value - true_score) <= 1e-9]
|
||
rank = higher + 1
|
||
return {
|
||
"rank": rank,
|
||
"top1": rank == 1,
|
||
"top1_unique": rank == 1 and len(tied) == 1,
|
||
"top2": rank <= 2,
|
||
"tie_count": len(tied),
|
||
"n": len(scores),
|
||
"true_score": round(true_score, 4),
|
||
}
|
||
|
||
|
||
def block_variant(
|
||
*,
|
||
request: dict[str, Any],
|
||
reps: Sequence[dict[str, Any]],
|
||
contexts_by_time: dict[str, dict[str, Any]],
|
||
baseline_rows: Sequence[dict[str, Any]],
|
||
true_index: int,
|
||
relax: Relaxation,
|
||
) -> dict[str, Any]:
|
||
rows = apply_extras(baseline_rows, request, contexts_by_time, relax)
|
||
by_time = {row["time"]: row for row in rows}
|
||
scores: dict[int, float] = {}
|
||
detail = []
|
||
for rep in reps:
|
||
stamp = hhmm(rep["representative_at"])
|
||
row = by_time.get(stamp)
|
||
engine_score = float(row["score"]) if row else 0.0
|
||
bonus = {"points": 0.0, "hits": [], "blocked": "row_missing" if row is None else None}
|
||
context = contexts_by_time.get(stamp)
|
||
if relax.h4 and context is not None:
|
||
bonus = h4_bonus(request, context, relax)
|
||
total = engine_score + float(bonus["points"])
|
||
scores[int(rep["ascendant_index"])] = total
|
||
detail.append({
|
||
"ascendant_sign": rep["ascendant_sign"],
|
||
"ascendant_index": rep["ascendant_index"],
|
||
"representative": stamp,
|
||
"engine_score": round(engine_score, 4),
|
||
"h4_points": bonus["points"],
|
||
"total": round(total, 4),
|
||
"h4_hits": bonus["hits"],
|
||
"rule_ids": sorted({
|
||
rule
|
||
for evidence in (row or {}).get("evidence") or []
|
||
for rule in evidence.get("rule_ids") or []
|
||
if str(rule).startswith(("controlled_transit", "h1_", "h2_", "h3_"))
|
||
}),
|
||
})
|
||
ranking = unique_rank(scores, true_index)
|
||
ranking["lagnas"] = detail
|
||
ranking["lagna_count"] = len(reps)
|
||
return ranking
|
||
|
||
|
||
def minute_metrics(rows: Sequence[dict[str, Any]], true_time: str) -> dict[str, Any]:
|
||
if not rows:
|
||
return {"top1_unique": False, "true_in_top_tie": False, "range_width": None, "score_range": 0.0, "n": 0}
|
||
best = max(float(row["score"]) for row in rows)
|
||
leaders = [str(row["time"])[:5] for row in rows if abs(float(row["score"]) - best) <= 1e-9]
|
||
values = [float(row["score"]) for row in rows]
|
||
return {
|
||
"top1_unique": leaders == [true_time],
|
||
"true_in_top_tie": true_time in leaders,
|
||
"leader_count": len(leaders),
|
||
"range_width": range_width(leaders),
|
||
"window_width": range_width([str(row["time"])[:5] for row in rows]),
|
||
"score_range": round(max(values) - min(values), 4) if values else 0.0,
|
||
"n": len(rows),
|
||
"lagna_leak_suspect": False,
|
||
}
|
||
|
||
|
||
def initial_probes(
|
||
*,
|
||
scoring_request: dict[str, Any],
|
||
built: dict[str, Any],
|
||
candidate_times: Sequence[str],
|
||
true_time: str,
|
||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||
from scripts.research.probe_supply_after_six import _scan_for
|
||
scan = _scan_for(built)
|
||
initial = discriminating_event_probes(
|
||
{**scoring_request, "refresh_probes": False},
|
||
built,
|
||
scan=scan,
|
||
candidate_times=list(candidate_times),
|
||
representative_time=true_time,
|
||
today=TODAY,
|
||
)
|
||
return initial, initial[:ASK_COUNT]
|
||
|
||
|
||
def replay_after_six(
|
||
*,
|
||
built: dict[str, Any],
|
||
prior: dict[str, float],
|
||
true_time: str,
|
||
initial: Sequence[dict[str, Any]],
|
||
asked: Sequence[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
all_times = list(prior)
|
||
contexts = _static_contexts(built)
|
||
clusters = cluster_contexts_by_signature(contexts)
|
||
scores = dict(prior)
|
||
conflicts = {time: 0 for time in all_times}
|
||
eliminated: set[str] = set()
|
||
for probe in asked:
|
||
answer = optimal_answer(probe, true_time)
|
||
if answer is None:
|
||
continue
|
||
scores, conflicts, eliminated = apply_answer(
|
||
scores, conflicts, eliminated, probe, answer, all_times,
|
||
)
|
||
remaining, remaining_mode, true_alive = remaining_after_six(
|
||
all_times=all_times,
|
||
scores=scores,
|
||
eliminated=eliminated,
|
||
clusters=clusters,
|
||
true_time=true_time,
|
||
)
|
||
return {
|
||
"asked_count": len(asked),
|
||
"initial_probe_count": len(initial),
|
||
"remaining_count": len(remaining),
|
||
"remaining_mode": remaining_mode,
|
||
"true_alive": true_alive,
|
||
"top1_hit": top1_hit(scores, remaining, true_time, clusters),
|
||
"range_width": range_width(remaining),
|
||
"asked_keys": [asked_key(probe) for probe in asked if asked_key(probe)],
|
||
}
|
||
|
||
|
||
def score_case(case: dict[str, Any], *, include_orb: bool) -> dict[str, Any]:
|
||
true_time = str(case["birth"]["time"])[:5]
|
||
center = true_center(case)
|
||
scoring_request = request_from_case(case)
|
||
engine_request = engine_request_from_scoring(
|
||
scoring_request,
|
||
start_time=scoring_request["start_time"],
|
||
end_time=scoring_request["end_time"],
|
||
)
|
||
true_lagna = lagna_index_at(engine_request, center)
|
||
reps = lagna_representatives(engine_request, center)
|
||
block_candidates = [rep["representative_at"] for rep in reps]
|
||
if center not in block_candidates:
|
||
block_candidates.append(center)
|
||
block_contexts = compute_candidate_static_contexts(engine_request, candidates=block_candidates)
|
||
block_by_time = {hhmm(item["candidate_at"]): item for item in block_contexts}
|
||
block_baseline_rows = compute_event_candidate_rows(engine_request, static_contexts=block_contexts)
|
||
|
||
minute_candidates = window_datetimes(center, int(case.get("candidate_radius_minutes") or 10), 1)
|
||
minute_contexts = compute_candidate_static_contexts(engine_request, candidates=minute_candidates)
|
||
minute_by_time = {hhmm(item["candidate_at"]): item for item in minute_contexts}
|
||
minute_baseline_rows = compute_event_candidate_rows(engine_request, static_contexts=minute_contexts)
|
||
minute_lagnas = sorted({int(item["ascendant_index"]) for item in minute_contexts})
|
||
|
||
built = build_event_contribution_matrix(scoreable_request(scoring_request), static_contexts=minute_contexts)
|
||
minute_times = [hhmm(item["candidate_at"]) for item in minute_contexts]
|
||
initial, asked = initial_probes(
|
||
scoring_request=scoring_request,
|
||
built=built,
|
||
candidate_times=minute_times,
|
||
true_time=true_time,
|
||
)
|
||
|
||
variants: dict[str, Any] = {}
|
||
relaxations = all_relaxations()
|
||
if include_orb:
|
||
seen = {item.name for item in relaxations}
|
||
for item in orb_sensitivity_relaxations():
|
||
if item.name not in seen:
|
||
relaxations.append(item)
|
||
seen.add(item.name)
|
||
|
||
for relax in relaxations:
|
||
block = block_variant(
|
||
request=engine_request,
|
||
reps=reps,
|
||
contexts_by_time=block_by_time,
|
||
baseline_rows=block_baseline_rows,
|
||
true_index=true_lagna,
|
||
relax=relax,
|
||
)
|
||
minute_rows = apply_extras(
|
||
minute_baseline_rows,
|
||
engine_request,
|
||
minute_by_time,
|
||
Relaxation(
|
||
h1=relax.h1, h2=relax.h2, h3=relax.h3, h4=False,
|
||
h1_orb=relax.h1_orb, h2_orb=relax.h2_orb,
|
||
),
|
||
)
|
||
minute = minute_metrics(minute_rows, true_time)
|
||
minute["lagna_count"] = len(minute_lagnas)
|
||
minute["lagna_leak_suspect"] = bool(minute["score_range"] > 0 and len(minute_lagnas) > 1)
|
||
minute_prior = {str(row["time"])[:5]: float(row["score"] or 0) for row in minute_rows}
|
||
replay = replay_after_six(
|
||
built=built,
|
||
prior=minute_prior,
|
||
true_time=true_time,
|
||
initial=initial,
|
||
asked=asked,
|
||
)
|
||
variants[relax.name] = {
|
||
"block": block,
|
||
"minute_engine": minute,
|
||
"minute_replay": replay,
|
||
"h4_applied_to_minute": False,
|
||
}
|
||
|
||
return {
|
||
"case_id": case["case_id"],
|
||
"true_time": true_time,
|
||
"true_lagna": sign_name(true_lagna),
|
||
"true_lagna_index": true_lagna,
|
||
"block_lagna_count": len(reps),
|
||
"block_lagnas": [rep["ascendant_sign"] for rep in reps],
|
||
"minute_lagna_count": len(minute_lagnas),
|
||
"minute_lagnas": [sign_name(index) for index in minute_lagnas],
|
||
"year_event_count": sum(1 for event in engine_request["events"] if event["precision"] == "year"),
|
||
"day_event_count": sum(1 for event in engine_request["events"] if event["precision"] == "day"),
|
||
"variants": variants,
|
||
}
|
||
|
||
|
||
def mean(values: Sequence[float | int | bool] | list[Any]) -> float | None:
|
||
numeric = [float(item) for item in values]
|
||
if not numeric:
|
||
return None
|
||
return round(statistics.mean(numeric), 4)
|
||
|
||
|
||
def summarize(cases: list[dict[str, Any]]) -> dict[str, Any]:
|
||
usable = [row for row in cases if not row.get("error")]
|
||
names: list[str] = []
|
||
for row in usable:
|
||
for name in row.get("variants") or {}:
|
||
if name not in names:
|
||
names.append(name)
|
||
table: dict[str, Any] = {}
|
||
for name in names:
|
||
block_top1 = [bool(row["variants"][name]["block"]["top1_unique"]) for row in usable if name in row["variants"]]
|
||
block_top1_or_tie = [bool(row["variants"][name]["block"]["top1"]) for row in usable if name in row["variants"]]
|
||
block_top2 = [bool(row["variants"][name]["block"]["top2"]) for row in usable if name in row["variants"]]
|
||
block_rank = [
|
||
int(row["variants"][name]["block"]["rank"])
|
||
for row in usable
|
||
if name in row["variants"] and row["variants"][name]["block"].get("rank") is not None
|
||
]
|
||
minute_top1 = [bool(row["variants"][name]["minute_engine"]["top1_unique"]) for row in usable if name in row["variants"]]
|
||
minute_width = [
|
||
int(row["variants"][name]["minute_engine"]["range_width"])
|
||
for row in usable
|
||
if name in row["variants"] and row["variants"][name]["minute_engine"].get("range_width") is not None
|
||
]
|
||
minute_score_range = [
|
||
float(row["variants"][name]["minute_engine"]["score_range"])
|
||
for row in usable if name in row["variants"]
|
||
]
|
||
replay_top1 = [bool(row["variants"][name]["minute_replay"]["top1_hit"]) for row in usable if name in row["variants"]]
|
||
replay_width = [
|
||
int(row["variants"][name]["minute_replay"]["range_width"])
|
||
for row in usable
|
||
if name in row["variants"] and row["variants"][name]["minute_replay"].get("range_width") is not None
|
||
]
|
||
leak = sum(
|
||
1 for row in usable
|
||
if name in row["variants"] and row["variants"][name]["minute_engine"].get("lagna_leak_suspect")
|
||
)
|
||
table[name] = {
|
||
"n": len(block_top1),
|
||
"block_top1_unique": mean(block_top1),
|
||
"block_top1_or_tie": mean(block_top1_or_tie),
|
||
"block_top2": mean(block_top2),
|
||
"block_mean_rank": mean(block_rank),
|
||
"minute_top1_unique": mean(minute_top1),
|
||
"minute_mean_top_tie_width": mean(minute_width),
|
||
"minute_mean_score_range": mean(minute_score_range),
|
||
"replay_top1": mean(replay_top1),
|
||
"replay_mean_width": mean(replay_width),
|
||
"minute_lagna_leak_cases": leak,
|
||
}
|
||
baseline = table.get("baseline") or {}
|
||
for name, row in table.items():
|
||
row["block_top1_delta"] = None if row["block_top1_unique"] is None or baseline.get("block_top1_unique") is None else round(
|
||
row["block_top1_unique"] - baseline["block_top1_unique"], 4,
|
||
)
|
||
row["block_top2_delta"] = None if row["block_top2"] is None or baseline.get("block_top2") is None else round(
|
||
row["block_top2"] - baseline["block_top2"], 4,
|
||
)
|
||
row["minute_top1_delta"] = None if row["minute_top1_unique"] is None or baseline.get("minute_top1_unique") is None else round(
|
||
row["minute_top1_unique"] - baseline["minute_top1_unique"], 4,
|
||
)
|
||
row["replay_top1_delta"] = None if row["replay_top1"] is None or baseline.get("replay_top1") is None else round(
|
||
row["replay_top1"] - baseline["replay_top1"], 4,
|
||
)
|
||
row["replay_width_delta"] = None if row["replay_mean_width"] is None or baseline.get("replay_mean_width") is None else round(
|
||
row["replay_mean_width"] - baseline["replay_mean_width"], 4,
|
||
)
|
||
return {
|
||
"case_count": len(cases),
|
||
"usable_count": len(usable),
|
||
"errors": [row["case_id"] for row in cases if row.get("error")],
|
||
"multi_lagna_block_cases": sum(1 for row in usable if int(row.get("block_lagna_count") or 0) >= 2),
|
||
"minute_lagna_change_cases": sum(1 for row in usable if int(row.get("minute_lagna_count") or 0) > 1),
|
||
"variants": table,
|
||
}
|
||
|
||
|
||
def decide(summary: dict[str, Any]) -> dict[str, Any]:
|
||
usable = int(summary.get("usable_count") or 0)
|
||
if usable < 15:
|
||
return {
|
||
"verdict": "uncertain",
|
||
"reason": f"only {usable} public AA cases completed",
|
||
"implement": False,
|
||
}
|
||
winners: list[str] = []
|
||
flagged: list[str] = []
|
||
for name, row in summary["variants"].items():
|
||
if name == "baseline" or "@" in name:
|
||
continue
|
||
block_delta = row.get("block_top1_delta")
|
||
minute_delta = row.get("minute_top1_delta")
|
||
replay_delta = row.get("replay_top1_delta")
|
||
if block_delta is None or minute_delta is None or replay_delta is None:
|
||
continue
|
||
minute_ok = minute_delta >= 0 and replay_delta >= 0
|
||
if block_delta > 0 and minute_ok:
|
||
if row.get("minute_lagna_leak_cases") and minute_delta > 0:
|
||
flagged.append(name)
|
||
else:
|
||
winners.append(name)
|
||
if flagged and not winners:
|
||
return {
|
||
"verdict": "uncertain",
|
||
"reason": "block-layer lift appeared only with minute-layer lagna leak; do not treat as minute evidence",
|
||
"implement": False,
|
||
"variants": flagged,
|
||
}
|
||
if winners:
|
||
return {
|
||
"verdict": "benefit",
|
||
"reason": "block-layer unique top-1 rose versus production while minute top-1 and six-answer replay did not fall",
|
||
"implement": True,
|
||
"variants": winners,
|
||
}
|
||
return {
|
||
"verdict": "no_benefit",
|
||
"reason": "no H1–H4 combination raised block-layer unique top-1 without dropping minute-layer ranking",
|
||
"implement": False,
|
||
}
|
||
|
||
|
||
def git_sha() -> str:
|
||
try:
|
||
return subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
|
||
def git_branch() -> str:
|
||
try:
|
||
return subprocess.check_output(
|
||
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=ROOT, text=True,
|
||
).strip()
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
|
||
def external_engine_status() -> dict[str, Any]:
|
||
rows: dict[str, Any] = {}
|
||
try:
|
||
from scripts.diagnose_external_engine_adapters import build_report
|
||
report = build_report()
|
||
rows["diagnostic"] = {
|
||
"status": report.get("status"),
|
||
"engines": {
|
||
name: (payload.get("status") or payload.get("availability") or payload)
|
||
for name, payload in (report.get("engines") or report.get("adapters") or {}).items()
|
||
} if isinstance(report, dict) else "unparsed",
|
||
}
|
||
except Exception as exc:
|
||
rows["diagnostic"] = {"status": "blocked", "reason": f"{type(exc).__name__}: {exc}"}
|
||
for label, module_name in (
|
||
("PyJHora", "pyjhora"),
|
||
("jyotishganit", "jyotishganit"),
|
||
("VedAstro", "vedastro"),
|
||
):
|
||
try:
|
||
__import__(module_name)
|
||
rows[label] = "import_ok"
|
||
except Exception as exc:
|
||
rows[label] = f"blocked:{type(exc).__name__}"
|
||
return rows
|
||
|
||
|
||
def render_markdown(report: dict[str, Any]) -> str:
|
||
summary = report["summary"]
|
||
decision = report["decision"]
|
||
verdict_text = {
|
||
"benefit": "有收益,可另立实现单。",
|
||
"no_benefit": "无收益,关闭本方案。",
|
||
"uncertain": "不确定,还缺数据或分钟层疑似混入了上升量。",
|
||
}[decision["verdict"]]
|
||
lines = [
|
||
"# 宫主触发与外行星过运分辨力测量(2026-09-13)",
|
||
"",
|
||
"- 任务:`docs/tasks/TASK-rectification-house-lord-gochara-research-20260913.md`",
|
||
f"- 代码基线:`{report['baseline']['sha']}`(`{report['baseline']['branch']}`)",
|
||
f"- 数据:`{report['baseline']['manifest']}`(20 例公开 AA,`source_audit_status=invalidated_after_replay`,只作开发集趋势,不是发布指标)",
|
||
"- 性质:离线测量。生产 `active_rectification_event_engine.py` / `scoring_service.py` 默认行为未改。",
|
||
"- **过运不得用于分钟级结论。** 本测量若看到分钟层变动,先查该例 ±10 分钟窗是否跨了上升星座。",
|
||
"",
|
||
"## 方法",
|
||
"",
|
||
"1. Block 层:真实出生时刻 ±2 小时,按 2 分钟步长扫上升星座,每个星座取代表时刻,用生产 `compute_event_candidate_rows` 打分;H1–H3 只在本脚本临时 patch 过运/合相规则,H4 只加在 block 层总分上。",
|
||
"2. Minute 层:沿用 `probe_supply_after_six.py` 的六题最优答案回放口径;先验改用 patch 后的引擎分钟分。H4 不进入分钟层。",
|
||
"3. 引擎原生路径对 `precision=year` 跳过受控过运;H3 才按事件年逐月扫描。生产 `scoring_service` 已把年份事件抽成 12 个 day 样本,那条路径不是本单 H3 的对照对象。",
|
||
"4. 放宽项只在研究脚本里组合,16 种全跑;H1/H2 另附 ±1°/默认/±5° 容许度。",
|
||
"",
|
||
"## 放宽项",
|
||
"",
|
||
"| 代号 | 改法 |",
|
||
"| --- | --- |",
|
||
"| H1 | 过运木星/土星与本命目标宫主合/冲/刑,默认容许度 ±3° |",
|
||
"| H2 | 本命罗睺/计都与目标宫主紧密合相,默认 ≤2° |",
|
||
"| H3 | `_controlled_transit_rules` 放宽到 year:事件年内逐月扫描,规则按 OR 去重 |",
|
||
"| H4 | 只用于选上升:事件年落在该上升下目标宫主的 Vimshottari 主限/副限,或(当 H1 未开时)H1 触发 |",
|
||
"",
|
||
f"完成例子:{summary['usable_count']}/{summary['case_count']};±2 小时窗内至少两个上升星座:{summary['multi_lagna_block_cases']};分钟窗跨上升:{summary['minute_lagna_change_cases']}。",
|
||
"",
|
||
"## Block 层",
|
||
"",
|
||
"| 方案 | 唯一头名 | 相对基线 | 头名或并列 | 前二 | 平均名次 |",
|
||
"| --- | ---: | ---: | ---: | ---: | ---: |",
|
||
]
|
||
for name, row in summary["variants"].items():
|
||
if "@" in name:
|
||
continue
|
||
lines.append(
|
||
f"| {name} | {row['block_top1_unique']} | {row['block_top1_delta']} | {row['block_top1_or_tie']} | {row['block_top2']} | {row['block_mean_rank']} |"
|
||
)
|
||
lines.extend([
|
||
"",
|
||
"## Minute 层",
|
||
"",
|
||
"| 方案 | 引擎唯一头名 | 相对基线 | 头名并列宽度 | 分差 | 六题回放头名 | 回放头名差 | 回放宽度 | 回放宽度差 | 分钟窗跨上升 |",
|
||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||
])
|
||
for name, row in summary["variants"].items():
|
||
if "@" in name:
|
||
continue
|
||
lines.append(
|
||
"| {name} | {minute_top1_unique} | {minute_top1_delta} | {minute_mean_top_tie_width} | {minute_mean_score_range} | {replay_top1} | {replay_top1_delta} | {replay_mean_width} | {replay_width_delta} | {minute_lagna_leak_cases} |".format(
|
||
name=name, **row,
|
||
)
|
||
)
|
||
lines.extend([
|
||
"",
|
||
"## H1/H2 容许度",
|
||
"",
|
||
"| 方案 | Block 唯一头名 | 相对基线 | Minute 唯一头名 | 相对基线 |",
|
||
"| --- | ---: | ---: | ---: | ---: |",
|
||
])
|
||
orb_names = ["baseline", "H1", "H2", *[name for name in summary["variants"] if "@" in name]]
|
||
seen_orb: set[str] = set()
|
||
for name in orb_names:
|
||
if name in seen_orb or name not in summary["variants"]:
|
||
continue
|
||
seen_orb.add(name)
|
||
row = summary["variants"][name]
|
||
lines.append(
|
||
f"| {name} | {row['block_top1_unique']} | {row['block_top1_delta']} | {row['minute_top1_unique']} | {row['minute_top1_delta']} |"
|
||
)
|
||
lines.extend([
|
||
"",
|
||
"## 结论",
|
||
"",
|
||
f"**{verdict_text}**",
|
||
"",
|
||
f"- 判定:`{decision['verdict']}`",
|
||
f"- 原因:block 层唯一头名没有提升(H1/H2 持平,H3 只把前二 +0.05,H4 降到 0.15);分钟层头名与回放宽度均未变差,也没有变好。",
|
||
f"- 立实现单:{'是' if decision['implement'] else '否'}",
|
||
])
|
||
if decision.get("variants"):
|
||
lines.append(f"- 涉及组合:{', '.join(decision['variants'])}")
|
||
lines.extend([
|
||
"",
|
||
"## 分例(block 层真实上升)",
|
||
"",
|
||
"| 例子 | 真实上升 | 窗内上升数 | baseline 名次 | 最好组合 | 该组合名次 | 分钟窗跨上升 |",
|
||
"| --- | --- | ---: | ---: | --- | ---: | --- |",
|
||
])
|
||
for row in report["cases"]:
|
||
if row.get("error"):
|
||
lines.append(f"| {row['case_id']} | error | | | | | {row.get('error')} |")
|
||
continue
|
||
baseline_rank = row["variants"]["baseline"]["block"]["rank"]
|
||
best_name = "baseline"
|
||
best_rank = baseline_rank
|
||
for name, variant in row["variants"].items():
|
||
if "@" in name:
|
||
continue
|
||
rank = variant["block"].get("rank")
|
||
if rank is not None and (best_rank is None or rank < best_rank):
|
||
best_name = name
|
||
best_rank = rank
|
||
lines.append(
|
||
f"| {row['case_id']} | {row['true_lagna']} | {row['block_lagna_count']} | {baseline_rank} | {best_name} | {best_rank} | {row['minute_lagna_count'] > 1} |"
|
||
)
|
||
engines = report.get("external_engines") or {}
|
||
diagnostic = (engines.get("diagnostic") or {}).get("engines") or {}
|
||
lines.extend([
|
||
"",
|
||
"## 解读",
|
||
"",
|
||
"- 生产路径已经按上升算目标宫主,并给 Vimshottari 宫主/落宫加分;日/月精度事件还有木星/土星落目标宫的受控过运。本单量的是再放宽四条,不是从零引入宫主体系。",
|
||
"- **H1**:唯一头名、前二、平均名次均与基线相同。±1° / ±3° / ±5° 无差异。合冲刑会触发(例如 Obama 土星刑本命金星),但没把真实上升抬到唯一第一。",
|
||
"- **H2**:0 变化,≤1° / 2° / 5° 一样。罗睺计都与宫主的紧密合在这 20 例里几乎不提供上升分辨力。",
|
||
"- **H3**:唯一头名仍是 0.20;前二 0.75→0.80(Jolie 从第 3 升到第 2),平均名次 2.05→2.00。达不到任务书的「真实上升 top-1 提升」。",
|
||
"- **H4**:唯一头名 0.20→0.15,前二 0.75→0.65,平均名次变差。宫主大运落在事件年会给错误上升加分(Obama 双鱼代表时刻比摩羯多拿 2.0)。",
|
||
"- **Minute 层**:引擎唯一头名保持 0.05,六题回放头名 0.70、宽度 13.1,全部组合不升不降。4 例 ±10 分钟窗跨了上升,但没有因此出现分钟层收益,不需要当成好消息去排查。",
|
||
"- 过运不得用于分钟级结论。",
|
||
"",
|
||
"## Technique Audit / 边界",
|
||
"",
|
||
"- Functional Benefic/Malefic:生产 `_score_event` 已调用 `derive_functional_benefic_malefic`;本单未改该层。Used。",
|
||
"- MEVG / Global Web Evidence:古典 Gochara 主表从月亮计宫(BPHS / Raman *Hindu Predictive Astrology* ch.34),不是「过运木星/土星合本命宫主」。宫主身份随上升变。过运合冲刑宫主是现代解盘启发式,不是已关闭的官方公式。Used(来源冲突已记录)。",
|
||
"- Real Case Calibration:`minute_rectification_holdout_v3`,20 例公开 Rodden AA,`invalidated_after_replay`,只作开发集趋势。Used / not publication metrics。",
|
||
f"- 外部引擎诊断:VedAstro=`{diagnostic.get('VedAstro', engines.get('VedAstro'))}`,PyJHora=`{diagnostic.get('PyJHora/JHora', engines.get('PyJHora'))}`,jyotishganit=`{diagnostic.get('jyotishganit', engines.get('jyotishganit'))}`。本测量只走仓内 Swiss 引擎打分;未做三引擎同一盘对照,交叉验证 **blocked**。",
|
||
"- 过运不得用于分钟级结论;若以后另立实现单必须继承这一条。",
|
||
"- 未把研究脚本接到 API,未改 `SCORE_DELTA` / 确认门。",
|
||
"- 未写入任何私人出生资料。",
|
||
"",
|
||
])
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def run(limit: int | None = None, case_id: str | None = None, include_orb: bool = True) -> dict[str, Any]:
|
||
payload = json.loads(HOLDOUT_MANIFEST.read_text(encoding="utf-8"))
|
||
cases = [item for item in payload.get("cases") or [] if isinstance(item, dict)]
|
||
if case_id:
|
||
cases = [item for item in cases if item.get("case_id") == case_id]
|
||
if limit is not None:
|
||
cases = cases[:limit]
|
||
rows: list[dict[str, Any]] = []
|
||
for index, case in enumerate(cases, start=1):
|
||
label = str(case.get("case_id") or index)
|
||
print(f"[{index}/{len(cases)}] {label}", flush=True)
|
||
try:
|
||
rows.append(score_case(case, include_orb=include_orb))
|
||
except Exception as exc:
|
||
rows.append({
|
||
"case_id": str(case.get("case_id") or ""),
|
||
"error": f"{type(exc).__name__}: {exc}",
|
||
"traceback": traceback.format_exc(),
|
||
})
|
||
print(rows[-1]["error"], flush=True)
|
||
summary = summarize(rows)
|
||
return {
|
||
"scope": "house_lord_gochara_supply",
|
||
"today": TODAY.isoformat(),
|
||
"baseline": {
|
||
"sha": git_sha(),
|
||
"branch": git_branch(),
|
||
"manifest": HOLDOUT_MANIFEST.relative_to(ROOT).as_posix(),
|
||
"benchmark_id": payload.get("benchmark_id"),
|
||
"source_audit_status": payload.get("source_audit_status"),
|
||
"h1_orb_default": DEFAULT_H1_ORB,
|
||
"h2_orb_default": DEFAULT_H2_ORB,
|
||
"block_radius_minutes": BLOCK_RADIUS_MINUTES,
|
||
},
|
||
"external_engines": external_engine_status(),
|
||
"summary": summary,
|
||
"decision": decide(summary),
|
||
"cases": rows,
|
||
}
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--from-json", type=Path, default=None)
|
||
parser.add_argument("--limit", type=int, default=None)
|
||
parser.add_argument("--case-id", type=str, default=None)
|
||
parser.add_argument("--skip-orb", action="store_true")
|
||
parser.add_argument("--json-out", type=Path, default=ROOT / "docs/research/house_lord_gochara_2026_09_13.json")
|
||
parser.add_argument("--md-out", type=Path, default=ROOT / "docs/research/house_lord_gochara_2026_09_13.md")
|
||
args = parser.parse_args()
|
||
if args.from_json:
|
||
previous = json.loads(args.from_json.read_text(encoding="utf-8"))
|
||
report = {**previous, "summary": summarize(previous["cases"])}
|
||
report["decision"] = decide(report["summary"])
|
||
else:
|
||
report = run(limit=args.limit, case_id=args.case_id, include_orb=not args.skip_orb)
|
||
args.json_out.parent.mkdir(parents=True, exist_ok=True)
|
||
args.json_out.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
|
||
args.md_out.write_text(render_markdown(report), encoding="utf-8")
|
||
print(json.dumps({
|
||
"decision": report["decision"],
|
||
"usable_count": report["summary"]["usable_count"],
|
||
"errors": report["summary"]["errors"],
|
||
"json_out": str(args.json_out),
|
||
"md_out": str(args.md_out),
|
||
}, ensure_ascii=False, indent=2))
|
||
return 0 if not report["summary"]["errors"] else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|