research(rectification): nadi second-level study helpers and N0 checks (BUG-1240)

Five-level Vimshottari and equal-division D150 helpers, plus a runner that
refuses to score until the preregistration file exists. First three lords
match the production chain on the v5 events. No production files changed.
This commit is contained in:
jesse-ux
2026-10-05 23:26:05 +08:00
committed by Jesse_Chen
parent 45ec6e55a1
commit c40f23c20e
5 changed files with 1441 additions and 1 deletions
+569
View File
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
"""Offline falsification helpers for second-scale nadi / sookshma / prana claims.
Research only. This module imports production chart and Vimshottari functions
and does not change them. Fitters and rankers never read ``true_minute``.
The five-level lords are the production chain
(``dasha_analyzer.build_antardasha`` applied twice more past pratyantar).
That is the same recursion ``_active_vimshottari`` uses for the first three
levels, including its midnight date anchor and 365.25-day year.
``calculate_five_level_dasha`` stays available as a side check; it is not the
scoring chain, because its 365.25636-day year does not reconcile to zero
against the production lords.
Classical D150 order (movable direct, fixed reverse, dual from the middle)
is marked ``variant_unverified``. No Chandra Kala Nadi verse text is stored.
"""
from __future__ import annotations
import hashlib
import json
import math
from collections.abc import Mapping, Sequence
from datetime import date, datetime, timedelta
from pathlib import Path
from random import Random
from typing import Any
import dasha_analyzer
import divisional_charts_extended
import domain_calculation_service
import narayana_dasha
from scripts.active_rectification_event_engine import (
AYANAMSA,
DOMAIN_CONFIG,
NODE_MODE,
_active_vimshottari,
_event_datetime,
_house_lords,
)
ROOT = Path(__file__).resolve().parents[2]
HOLDOUT_V5 = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v5.json"
PREREG_PATH = ROOT / "docs" / "research" / "nadi_seconds_preregistration_2026_10_05.json"
EVENT_CUTOFF = date(2026, 9, 14)
LEVELS = ("md", "ad", "pd", "sookshma", "prana")
# Structural reading only. See classical_nadi_index.
CLASSICAL_NADI_STATUS = "variant_unverified"
KM_PER_DEGREE_LAT = 111.32
_VARGA = divisional_charts_extended.DivisionalChartsCalculator()
_SKY_CACHE: dict[tuple, dict[str, Any]] = {}
def load_cases(path: Path = HOLDOUT_V5) -> list[dict[str, Any]]:
return list(json.loads(path.read_text(encoding="utf-8"))["cases"])
def canonical_bytes(payload: Any) -> bytes:
return (
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ "\n"
).encode("utf-8")
def sha256_bytes(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()
def file_sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def round6(value: float) -> float:
return round(float(value), 6)
def subdivide(period: Mapping[str, Any]) -> list[dict[str, Any]]:
"""One production antardasha split. Adjacent rows abut by construction."""
return list(dasha_analyzer.build_antardasha(dict(period)))
def chain_at(birth_date: str, moon_longitude: float, event_at: datetime) -> list[dict[str, Any]]:
"""Five nested production periods active at ``event_at``.
``birth_date`` is the candidate's local calendar date (YYYY-MM-DD), the
same anchor ``_active_vimshottari`` passes through.
"""
return _chain_from_timeline(_timeline(birth_date, moon_longitude), event_at)
def five_lords(birth_date: str, moon_longitude: float, event_at: datetime) -> tuple[str, str, str, str, str]:
return tuple(str(period["lord"]) for period in chain_at(birth_date, moon_longitude, event_at)) # type: ignore[return-value]
def production_three(birth_date: str, moon_longitude: float, event_at: datetime) -> tuple[str, str, str]:
return _active_vimshottari(birth_date, float(moon_longitude) % 360.0, event_at)
def d150_equal(longitude: float) -> dict[str, Any]:
"""Equal-slice D150 via the repository ``calc_custom_varga`` (N=150)."""
row = _VARGA.calc_custom_varga(float(longitude) % 360.0, 150)
sign = str(row["sign"])
return {
"part_index": int(row["part_index"]),
"sign": sign,
"sign_idx": int(row["sign_idx"]),
"sign_lord": narayana_dasha.SIGN_LORDS[sign],
"source": "calc_custom_varga",
}
def classical_nadi_index(longitude: float) -> dict[str, Any]:
"""Reorder the 150 equal slices. Not a gate input.
Movable / fixed / dual follow ``DivisionalChartsCalculator`` sign classes.
Movable: part 0..149 in longitude order. Fixed: 149 down to 0.
Dual: start at the middle index 75 and wrap. The dual start is a
structural reading of "从中段起排", not a quotation, so the status is
``variant_unverified``. Named nadi verses are not used.
"""
longitude = float(longitude) % 360.0
sign_index = int(longitude // 30.0) % 12
part = int(d150_equal(longitude)["part_index"])
if sign_index in _VARGA.MOVABLE_SIGNS:
index = part
order = "direct"
elif sign_index in _VARGA.FIXED_SIGNS:
index = 149 - part
order = "reverse"
else:
index = (part + 75) % 150
order = "from_middle"
return {
"index": index,
"equal_part": part,
"sign_index": sign_index,
"order": order,
"status": CLASSICAL_NADI_STATUS,
}
def domain_target_lords(ascendant_index: int, domain: str) -> frozenset[str]:
houses = DOMAIN_CONFIG[domain][1]
return frozenset(_house_lords(int(ascendant_index), houses))
def explained(lords: Sequence[str], targets: frozenset[str], rule: str, segment_lord: str | None = None) -> bool:
"""Binary 'this second explains this event' for one pre-registered rule.
A4 / A5: level 4 or 5 lord is a domain house lord.
B1..B5: at least that many of the five levels are domain house lords.
N5A: equal-division D150 lagna sign lord is a domain house lord.
N5B: that same sign lord appears in any of the five levels.
"""
lords = tuple(lords)
if rule == "A4":
return lords[3] in targets
if rule == "A5":
return lords[4] in targets
if rule.startswith("B") and rule[1:].isdigit():
need = int(rule[1:])
return sum(lord in targets for lord in lords) >= need
if rule == "N5A":
return segment_lord is not None and segment_lord in targets
if rule == "N5B":
return segment_lord is not None and segment_lord in set(lords)
raise ValueError(f"unknown rule {rule}")
def fit_count(
lord_rows: Sequence[Sequence[str]],
domains: Sequence[str],
ascendant_index: int,
rule: str,
segment_lord: str | None = None,
) -> int:
"""How many events ``rule`` explains. No clock and no truth label."""
total = 0
for lords, domain in zip(lord_rows, domains, strict=True):
targets = domain_target_lords(ascendant_index, domain)
if explained(lords, targets, rule, segment_lord):
total += 1
return total
def rank_offsets(counts: Mapping[int, int]) -> list[int]:
"""Offsets tied for the highest count, ascending. Ties are all kept."""
if not counts:
return []
best = max(counts.values())
return sorted(offset for offset, count in counts.items() if count == best)
def candidate_moment(birth: Mapping[str, Any], offset_seconds: int) -> datetime:
base = datetime.strptime(f"{birth['date']} {str(birth['time'])[:5]}", "%Y-%m-%d %H:%M")
return base + timedelta(seconds=int(offset_seconds))
def sky_at(
birth: Mapping[str, Any],
moment: datetime,
*,
ayanamsa: str = AYANAMSA,
node_mode: str = NODE_MODE,
longitude: float | None = None,
) -> dict[str, Any]:
"""Moon and ascendant from the production chart. Seconds are passed through."""
lat = round(float(birth["latitude"]), 6)
lon = round(float(birth["longitude"] if longitude is None else longitude), 6)
tz = round(float(birth["timezone_offset"]), 4)
key = (moment.year, moment.month, moment.day, moment.hour, moment.minute, moment.second, lat, lon, tz, str(ayanamsa), str(node_mode))
cached = _SKY_CACHE.get(key)
if cached is not None:
return cached
chart = domain_calculation_service.compute_chart({
"year": moment.year,
"month": moment.month,
"day": moment.day,
"hour": moment.hour,
"minute": moment.minute,
"second": moment.second,
"lat": lat,
"lon": lon,
"tz": tz,
"ayanamsa": ayanamsa,
"node_mode": node_mode,
})
moon = float(chart["planets"]["Moon"]["lon"]) % 360.0
asc = float(chart["ascendant"]["lon"]) % 360.0
rahu = chart.get("planets", {}).get("Rahu") or {}
rahu_lon = float(rahu["lon"]) % 360.0 if isinstance(rahu.get("lon"), (int, float)) else None
sky = {
"birth_date": moment.date().isoformat(),
"moon_lon": moon,
"asc_lon": asc,
"asc_index": int(asc // 30.0) % 12,
"rahu_lon": rahu_lon,
}
_SKY_CACHE[key] = sky
return sky
def shift_longitude_km(longitude: float, latitude: float, kilometers_east: float) -> float:
"""Move the birth longitude by an east-west ground distance.
One degree of longitude is ``111.32 * cos(latitude)`` kilometres.
Near a pole the east-west degree collapses and the shift is refused.
"""
scale = KM_PER_DEGREE_LAT * math.cos(math.radians(float(latitude)))
if abs(scale) < 1e-3:
raise ValueError("east-west shift is undefined this close to a pole")
moved = float(longitude) + float(kilometers_east) / scale
return ((moved + 180.0) % 360.0) - 180.0
def day_events(case: Mapping[str, Any]) -> list[dict[str, Any]]:
"""Day-precision events copied without truth labels or narrative text."""
rows = []
for event in case["events"]:
if event.get("precision") != "day":
continue
rows.append({
"id": str(event["id"]),
"domain": str(event["domain"]),
"date": str(event["date"])[:10],
"precision": "day",
})
return rows
def _clamp_life_date(day: date, birth: date, cutoff: date) -> date:
guard = 0
while day <= birth and guard < 160:
day = day + timedelta(days=365)
guard += 1
guard = 0
while day > cutoff and guard < 160:
day = day - timedelta(days=365)
guard += 1
if birth < day <= cutoff:
return day
fallback = birth + timedelta(days=400)
if fallback > cutoff:
fallback = cutoff
if fallback <= birth:
fallback = birth + timedelta(days=1)
return fallback
def placebo_shift_dates(
events: Sequence[Mapping[str, Any]],
birth_date: str,
*,
seed: str,
low_days: int,
high_days: int,
cutoff: date = EVENT_CUTOFF,
) -> list[dict[str, Any]]:
"""Shift each day-event date by a seeded ±[low, high] day offset.
Domain stays. A result outside (birth, cutoff] is flipped, then repaired
by 365-day steps. The seed includes the case id so case order cannot
change one case's offsets.
"""
rng = Random(seed)
birth = date.fromisoformat(birth_date)
shifted = []
for event in events:
original = date.fromisoformat(str(event["date"])[:10])
magnitude = rng.randint(int(low_days), int(high_days))
sign = rng.choice((-1, 1))
candidate = original + timedelta(days=sign * magnitude)
if not (birth < candidate <= cutoff):
candidate = original + timedelta(days=-sign * magnitude)
if not (birth < candidate <= cutoff):
candidate = _clamp_life_date(original + timedelta(days=magnitude), birth, cutoff)
shifted.append({**event, "date": candidate.isoformat()})
return shifted
def placebo_swap_dates(
cases: Sequence[Mapping[str, Any]],
*,
seed: int,
cutoff: date = EVENT_CUTOFF,
) -> dict[str, list[dict[str, Any]]]:
"""Permute day-event dates across cases. Domains stay on the recipient event."""
slots: list[tuple[str, dict[str, Any]]] = []
for case in cases:
for event in day_events(case):
slots.append((str(case["case_id"]), event))
slots.sort(key=lambda item: (item[0], item[1]["id"]))
dates = [item[1]["date"] for item in slots]
order = list(range(len(dates)))
Random(int(seed)).shuffle(order)
by_case: dict[str, list[dict[str, Any]]] = {}
births = {str(case["case_id"]): str(case["birth"]["date"]) for case in cases}
for index, (case_id, event) in enumerate(slots):
raw = date.fromisoformat(dates[order[index]])
clamped = _clamp_life_date(raw, date.fromisoformat(births[case_id]), cutoff)
by_case.setdefault(case_id, []).append({**event, "date": clamped.isoformat()})
return by_case
def event_moments(events: Sequence[Mapping[str, Any]]) -> list[datetime]:
return [_event_datetime(event) for event in events] # type: ignore[arg-type]
def _timeline(birth_date: str, moon_longitude: float) -> list[dict[str, Any]]:
nakshatra, progress, _pada = dasha_analyzer.lon_to_nakshatra(float(moon_longitude) % 360.0)
timeline, _elapsed, _remaining, _lord = dasha_analyzer.build_dasha_timeline(
birth_date, nakshatra, progress,
)
return timeline
def _chain_from_timeline(timeline: Sequence[Mapping[str, Any]], event_at: datetime) -> list[dict[str, Any]]:
_index, major = dasha_analyzer.find_current(list(timeline), event_at)
levels = [major]
for _depth in range(4):
levels.append(dasha_analyzer.find_current_sub(subdivide(levels[-1]), event_at))
return levels
def lords_for_events(
birth_date: str,
moon_longitude: float,
events: Sequence[Mapping[str, Any]],
) -> list[tuple[str, str, str, str, str]]:
"""Five lords for every event. The mahadasha timeline is built once."""
timeline = _timeline(birth_date, moon_longitude)
rows = []
for moment in event_moments(events):
rows.append(tuple(str(period["lord"]) for period in _chain_from_timeline(timeline, moment))) # type: ignore[arg-type]
return rows
def counts_by_offset(
skies: Mapping[int, Mapping[str, Any]],
events: Sequence[Mapping[str, Any]],
rule: str,
*,
use_segment_lord: bool,
) -> dict[int, int]:
domains = [str(event["domain"]) for event in events]
counts: dict[int, int] = {}
for offset, sky in skies.items():
rows = lords_for_events(str(sky["birth_date"]), float(sky["moon_lon"]), events)
segment_lord = str(d150_equal(float(sky["asc_lon"]))["sign_lord"]) if use_segment_lord else None
counts[int(offset)] = fit_count(rows, domains, int(sky["asc_index"]), rule, segment_lord)
return counts
def mean_rate(counts: Mapping[int, int], offsets: Sequence[int], event_count: int) -> float | None:
if event_count <= 0 or not offsets:
return None
total = sum(counts[offset] for offset in offsets)
return total / (len(list(offsets)) * event_count)
def fraction_explaining_all(counts: Mapping[int, int], offsets: Sequence[int], event_count: int) -> float | None:
if event_count <= 0 or not offsets:
return None
hits = sum(1 for offset in offsets if counts[offset] == event_count)
return hits / len(list(offsets))
def paired_sign_flip_p(
differences: Sequence[float],
*,
seed: int,
permutations: int,
) -> dict[str, Any]:
"""One-sided paired permutation: share of sign-flips with mean >= observed.
p = (count + 1) / (permutations + 1), counting the observed assignment.
"""
values = [float(item) for item in differences]
n = len(values)
if n == 0:
return {"n": 0, "mean": None, "p": None, "extreme": None, "permutations": permutations}
observed = sum(values) / n
rng = Random(int(seed))
extreme = 0
for _ in range(int(permutations)):
total = 0.0
for value in values:
total += value if rng.randrange(2) == 0 else -value
if total / n >= observed - 1e-15:
extreme += 1
return {
"n": n,
"mean": round6(observed),
"p": round6((extreme + 1) / (int(permutations) + 1)),
"extreme": extreme,
"permutations": int(permutations),
}
def bootstrap_mean_ci(
values: Sequence[float],
*,
seed: int,
resamples: int,
) -> dict[str, Any]:
"""Percentile 95% interval of the mean. Passes only when the lower bound is > 0."""
pool = [float(item) for item in values]
n = len(pool)
if n == 0:
return {"n": 0, "mean": None, "low": None, "high": None, "excludes_zero_positive": False}
rng = Random(int(seed))
means = []
for _ in range(int(resamples)):
draw = 0.0
for _item in range(n):
draw += pool[rng.randrange(n)]
means.append(draw / n)
means.sort()
low_index = int(0.025 * (len(means) - 1))
high_index = int(0.975 * (len(means) - 1))
low = means[low_index]
high = means[high_index]
return {
"n": n,
"mean": round6(sum(pool) / n),
"low": round6(low),
"high": round6(high),
"excludes_zero_positive": bool(low > 0.0),
}
def minute_of(clock: str) -> int:
return int(str(clock)[3:5])
def rounded_to_five_minutes(clock: str) -> bool:
return minute_of(clock) % 5 == 0
def clock_delta_minutes(stamp: str, recorded: str) -> int:
def _clock(value: str) -> int:
hours, minutes = str(value)[:5].split(":")
return int(hours) * 60 + int(minutes)
delta = (_clock(stamp) - _clock(recorded)) % 1440
return delta - 1440 if delta > 720 else delta
def leaders_within(offsets: Sequence[int], limit_seconds: int) -> bool:
"""True only when every tied leader is inside the limit. An empty tie is a miss."""
return bool(offsets) and all(abs(int(offset)) <= int(limit_seconds) for offset in offsets)
def truth_audit(cases: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"""Evaluator-side audit. This function is allowed to read the recorded clock.
It does not read ``true_minute``; the recorded minute is ``birth.time``.
"""
rounded: list[str] = []
unrounded: list[str] = []
minute_hist: dict[str, int] = {}
per_case = []
for case in cases:
clock = str(case["birth"]["time"])[:5]
minute = f"{minute_of(clock):02d}"
minute_hist[minute] = minute_hist.get(minute, 0) + 1
case_id = str(case["case_id"])
(rounded if rounded_to_five_minutes(clock) else unrounded).append(case_id)
day_count = sum(1 for event in case["events"] if event.get("precision") == "day")
per_case.append({"case_id": case_id, "minute": minute, "day_events": day_count})
day_counts = [row["day_events"] for row in per_case]
return {
"cases": len(per_case),
"rounded_5min_count": len(rounded),
"unrounded_count": len(unrounded),
"rounded_5min_case_ids": rounded,
"unrounded_case_ids": unrounded,
"minute_histogram": minute_hist,
"day_event_total": sum(day_counts),
"day_events_ge_3": sum(1 for count in day_counts if count >= 3),
"day_events_ge_4": sum(1 for count in day_counts if count >= 4),
"day_events_ge_5": sum(1 for count in day_counts if count >= 5),
"day_events_zero": sum(1 for count in day_counts if count == 0),
"per_case": per_case,
}
def reconcile_case(case: Mapping[str, Any]) -> dict[str, Any]:
"""First three lords versus ``_active_vimshottari`` for every event at the recorded second."""
moment = candidate_moment(case["birth"], 0)
sky = sky_at(case["birth"], moment)
mismatches = []
for event in case["events"]:
event_at = _event_datetime(event) # type: ignore[arg-type]
ours = five_lords(sky["birth_date"], sky["moon_lon"], event_at)
theirs = production_three(sky["birth_date"], sky["moon_lon"], event_at)
if ours[:3] != theirs:
mismatches.append(str(event["id"]))
return {
"case_id": str(case["case_id"]),
"events": len(case["events"]),
"mismatches": mismatches,
}
def public_fields(case: Mapping[str, Any]) -> dict[str, Any]:
"""Copy the birth clock, place, and events a fitter may see."""
birth = case["birth"]
return {
"case_id": str(case["case_id"]),
"birth": {
"date": str(birth["date"]),
"time": str(birth["time"])[:5],
"latitude": float(birth["latitude"]),
"longitude": float(birth["longitude"]),
"timezone_offset": float(birth["timezone_offset"]),
},
"events": [
{
"id": str(event["id"]),
"domain": str(event["domain"]),
"date": str(event["date"]),
"precision": str(event["precision"]),
}
for event in case["events"]
],
}
+676
View File
@@ -0,0 +1,676 @@
#!/usr/bin/env python3
"""Run the pre-registered nadi / sookshma / prana falsification study.
Reads ``docs/research/nadi_seconds_preregistration_2026_10_05.json`` and
refuses to score without it. The output JSON has no timestamps, so two runs
with ``PYTHONHASHSEED=0`` are byte-identical. This script does not import
``true_minute``.
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from datetime import date
from pathlib import Path
from random import Random
from typing import Any, Mapping, Sequence
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.research import nadi_seconds_lib as lib # noqa: E402
REPORT = ROOT / "docs" / "research" / "nadi_seconds_results_2026_10_05.json"
def load_prereg() -> dict[str, Any]:
if not lib.PREREG_PATH.is_file():
raise SystemExit(f"missing preregistration: {lib.PREREG_PATH}")
return json.loads(lib.PREREG_PATH.read_text(encoding="utf-8"))
def offsets_of(radius: int, step: int) -> list[int]:
return list(range(-int(radius), int(radius) + 1, int(step)))
def truth_minute_offsets() -> list[int]:
return list(range(0, 60))
def collect_skies(birth: Mapping[str, Any], offsets: Sequence[int], **kwargs: Any) -> dict[int, dict[str, Any]]:
return {int(offset): lib.sky_at(birth, lib.candidate_moment(birth, int(offset)), **kwargs) for offset in offsets}
def lord_bundle(skies: Mapping[int, Mapping[str, Any]], events: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
lords: dict[int, list] = {}
asc: dict[int, int] = {}
segment: dict[int, str] = {}
part: dict[int, int] = {}
for offset, sky in skies.items():
lords[int(offset)] = lib.lords_for_events(str(sky["birth_date"]), float(sky["moon_lon"]), events)
asc[int(offset)] = int(sky["asc_index"])
row = lib.d150_equal(float(sky["asc_lon"]))
segment[int(offset)] = str(row["sign_lord"])
part[int(offset)] = int(row["part_index"])
return {"lords": lords, "asc": asc, "segment": segment, "part": part}
def rule_counts(bundle: Mapping[str, Any], events: Sequence[Mapping[str, Any]], rule: str) -> dict[int, int]:
domains = [str(event["domain"]) for event in events]
use_segment = rule.startswith("N5")
counts: dict[int, int] = {}
for offset, rows in bundle["lords"].items():
targets = [lib.domain_target_lords(bundle["asc"][offset], domain) for domain in domains]
segment = bundle["segment"][offset] if use_segment else None
counts[offset] = sum(
lib.explained(lords, target, rule, segment)
for lords, target in zip(rows, targets, strict=True)
)
return counts
def rule_bits(bundle: Mapping[str, Any], events: Sequence[Mapping[str, Any]], rule: str) -> dict[int, list[bool]]:
domains = [str(event["domain"]) for event in events]
use_segment = rule.startswith("N5")
bits: dict[int, list[bool]] = {}
for offset, rows in bundle["lords"].items():
targets = [lib.domain_target_lords(bundle["asc"][offset], domain) for domain in domains]
segment = bundle["segment"][offset] if use_segment else None
bits[offset] = [
lib.explained(lords, target, rule, segment)
for lords, target in zip(rows, targets, strict=True)
]
return bits
def mean_defined(values: Sequence[float | None]) -> float | None:
nums = [float(value) for value in values if value is not None]
if not nums:
return None
return lib.round6(sum(nums) / len(nums))
def rate(flags: Sequence[bool]) -> float | None:
if not flags:
return None
return lib.round6(sum(bool(flag) for flag in flags) / len(flags))
def split_rates(rows: Sequence[Mapping[str, Any]], key: str, rounded: set[str]) -> dict[str, float | None]:
return {
"all": rate([bool(row[key]) for row in rows]),
"rounded_5min": rate([bool(row[key]) for row in rows if row["case_id"] in rounded]),
"unrounded": rate([bool(row[key]) for row in rows if row["case_id"] not in rounded]),
"n_all": len(rows),
"n_rounded_5min": sum(1 for row in rows if row["case_id"] in rounded),
"n_unrounded": sum(1 for row in rows if row["case_id"] not in rounded),
}
def delivery_offsets(start: str | None, end: str | None, recorded: str, radius_minutes: int) -> list[int]:
"""One-second offsets covering each delivered clock minute, clipped to the search radius."""
if not start or not end:
return []
cap = int(radius_minutes) * 60
start_min = lib.clock_delta_minutes(start, recorded)
end_min = lib.clock_delta_minutes(end, recorded)
def clip(lo: int, hi: int) -> list[int]:
lo = max(lo, -cap)
hi = min(hi, cap)
if lo > hi:
return []
return list(range(lo, hi + 1))
if start_min <= end_min:
return clip(start_min * 60, end_min * 60 + 59)
return sorted(set(clip(start_min * 60, cap) + clip(-cap, end_min * 60 + 59)))
def six_probe(case: Mapping[str, Any], radius: int) -> dict[str, Any]:
"""futile_collect_stop_replay d1: six probes, no guided-window injection."""
from scripts.active_rectification_event_engine import AYANAMSA, NODE_MODE, compute_candidate_static_contexts
from scripts.rectification.event_probes import discriminating_event_probes
from scripts.rectification.refinement_packet import window_scan
from scripts.rectification.scoring_service import build_event_contribution_matrix, score_from_matrix
from scripts.research.fewer_probes_card_replay import _outcome
from scripts.research.guided_collect_holdout_replay import _hhmm, posterior_state
from scripts.research.minute_resolution_sweep import MINUTE_STEP, scoring_request_for
from scripts.research.probe_supply_after_six import ASK_COUNT, TODAY
recorded = str(case["birth"]["time"])[:5]
request = scoring_request_for(dict(case), radius)
request["ayanamsa"] = AYANAMSA
request["node_mode"] = NODE_MODE
request["minute_step"] = MINUTE_STEP
static_contexts = compute_candidate_static_contexts(request)
built = build_event_contribution_matrix(request, static_contexts=static_contexts)
rows = score_from_matrix(request, built)
times = [stamp for row in rows if (stamp := _hhmm(row.get("time")))]
probes = discriminating_event_probes(
{**request, "refresh_probes": False, "asked_probe_keys": []},
built,
scan=window_scan(built),
candidate_times=times,
representative_time=recorded,
today=TODAY,
)[:ASK_COUNT]
state = posterior_state(rows=rows, contexts=static_contexts, probes=probes, true_time=recorded)
outcome = _outcome(state, recorded)
scores = state["scores"]
valid = list(state["valid"])
if not valid:
leaders: list[str] = []
else:
def _score(row: Mapping[str, Any]) -> float:
stamp = _hhmm(row.get("time")) or ""
return float(scores.get(stamp, row.get("score") or 0))
best = max(_score(row) for row in valid)
leaders = sorted({
str(row.get("time"))[:5]
for row in valid
if abs(_score(row) - best) <= 1e-9 and str(row.get("time") or "")[:5]
})
deltas = [lib.clock_delta_minutes(stamp, recorded) for stamp in leaders]
return {
"start": outcome["start"],
"end": outcome["end"],
"width": outcome["width"],
"truth_in_range": bool(outcome["truth_in_range"]),
"leader_times": leaders,
"all_within_1min": bool(deltas) and all(abs(delta) <= 1 for delta in deltas),
"any_exact": recorded in leaders,
"error": None,
}
def gate_p(result: Mapping[str, Any]) -> bool:
if result.get("mean") is None or result.get("extreme") is None:
return False
exact = (int(result["extreme"]) + 1) / (int(result["permutations"]) + 1)
return bool(result["mean"] > 0 and exact < 0.05)
def summarize_rule(
rows: Sequence[Mapping[str, Any]],
*,
seed: int,
permutations: int,
) -> dict[str, Any]:
diffs = [float(row["diff"]) for row in rows]
test = lib.paired_sign_flip_p(diffs, seed=seed, permutations=permutations)
return {
**test,
"pass": gate_p(test),
"truth_mean": mean_defined([row["truth"] for row in rows]),
"placebo_mean": mean_defined([row["placebo"] for row in rows]),
"window_mean": mean_defined([row["window"] for row in rows]),
"fraction_grid_explains_all_mean": mean_defined([row["fraction_all"] for row in rows]),
}
def n2_case(
bits: Mapping[int, Sequence[bool]],
coarse: Sequence[int],
events: Sequence[Mapping[str, Any]],
placebo_bits: Mapping[int, Sequence[bool]],
*,
case_id: str,
seed: str,
) -> dict[str, Any]:
deltas_random = []
deltas_placebo = []
held_rates = []
for index, event in enumerate(events):
counts = {
offset: sum(flag for event_index, flag in enumerate(flags) if event_index != index)
for offset, flags in bits.items()
if offset in coarse
}
leaders = [offset for offset in lib.rank_offsets(counts) if offset in set(coarse)]
if not leaders:
continue
held = sum(1 for offset in leaders if bits[offset][index]) / len(leaders)
rng = Random(f"{seed}:{case_id}:{event['id']}")
if len(leaders) >= len(coarse):
sample = list(coarse)
else:
sample = rng.sample(list(coarse), len(leaders))
random_rate = sum(1 for offset in sample if bits[offset][index]) / len(sample)
placebo_rate = sum(1 for offset in leaders if placebo_bits[offset][index]) / len(leaders)
held_rates.append(held)
deltas_random.append(held - random_rate)
deltas_placebo.append(held - placebo_rate)
return {
"held_rate": mean_defined(held_rates),
"delta_random": mean_defined(deltas_random),
"delta_placebo": mean_defined(deltas_placebo),
}
def level_change(base: Sequence[Sequence[str]], other: Sequence[Sequence[str]], index: int) -> float | None:
if not base or len(base) != len(other):
return None
changed = sum(left[index] != right[index] for left, right in zip(base, other, strict=True))
return lib.round6(changed / len(base))
def build(prereg: Mapping[str, Any], *, limit: int, radii: Sequence[int]) -> dict[str, Any]:
cases = [lib.public_fields(case) for case in lib.load_cases()]
if limit:
cases = cases[:limit]
grids = prereg["grids"]
coarse = offsets_of(grids["coarse_radius_seconds"], grids["coarse_step_seconds"])
fine = offsets_of(grids["fine_radius_seconds"], grids["fine_step_seconds"])
truth_offsets = truth_minute_offsets()
union = sorted(set(coarse) | set(fine) | set(truth_offsets))
rules = list(prereg["rules"]["computed"])
primary = list(prereg["rules"]["n1_primary"])
n1_min = int(prereg["samples"]["n1_min_day_events"])
n2_min = int(prereg["samples"]["n2_min_day_events"])
permutations = int(prereg["tests"]["permutations"])
resamples = int(prereg["tests"]["bootstrap_resamples"])
shift_low = int(prereg["placebo"]["shift_low_days"])
shift_high = int(prereg["placebo"]["shift_high_days"])
swap = lib.placebo_swap_dates(cases, seed=int(prereg["placebo"]["swap_seed"]))
rounded = {case_id for case_id in lib.truth_audit(cases)["rounded_5min_case_ids"]}
audit = lib.truth_audit(cases)
recon = [lib.reconcile_case(case) for case in cases]
n1_rows: dict[str, list[dict[str, Any]]] = {rule: [] for rule in primary}
n1_placebos = ("P1", "P2")
n5_rows: dict[str, list[dict[str, Any]]] = {rule: [] for rule in prereg["rules"]["n5_primary"]}
n2_rows = []
n3a_rows = []
n4_rows = []
fraction_rows: dict[str, list[float]] = {rule: [] for rule in ("A4", "A5", "B1", "B2")}
case_fit: list[dict[str, Any]] = []
for index, case in enumerate(cases, start=1):
events = lib.day_events(case)
birth = case["birth"]
print(f"nadi {index}/{len(cases)} {case['case_id']} day_events={len(events)}", flush=True)
skies = collect_skies(birth, union)
real = lord_bundle(skies, events) if events else None
shifted = lib.placebo_shift_dates(
events,
birth["date"],
seed=prereg["placebo"]["shift_seed_template"].format(case_id=case["case_id"]),
low_days=shift_low,
high_days=shift_high,
) if events else []
p1 = lord_bundle(skies, shifted) if shifted else None
swapped = swap.get(case["case_id"], [])
p2 = lord_bundle(skies, swapped) if swapped else None
fit_row: dict[str, Any] = {
"case_id": case["case_id"],
"day_events": len(events),
"rounded_5min": case["case_id"] in rounded,
}
if real is not None:
for rule in rules:
if rule.startswith("N5") and rule not in prereg["rules"]["n5_primary"] and rule not in primary:
continue
counts = rule_counts(real, events, rule)
truth = lib.mean_rate(counts, truth_offsets, len(events))
window = lib.mean_rate(counts, coarse, len(events))
fraction = lib.fraction_explaining_all(counts, coarse, len(events))
fit_row[rule] = {
"truth": None if truth is None else lib.round6(truth),
"window": None if window is None else lib.round6(window),
"fraction_all": None if fraction is None else lib.round6(fraction),
}
if rule in fraction_rows and fraction is not None and len(events) >= n1_min:
fraction_rows[rule].append(fraction)
if rule in primary and len(events) >= n1_min and p1 is not None and p2 is not None:
for label, bundle in (("P1", p1), ("P2", p2)):
placebo_rate = lib.mean_rate(rule_counts(bundle, shifted if label == "P1" else swapped, rule), truth_offsets, len(events))
n1_rows[rule].append({
"case_id": case["case_id"],
"placebo": label,
"truth": lib.round6(truth or 0.0),
"placebo_rate": None if placebo_rate is None else lib.round6(placebo_rate),
"diff": None if placebo_rate is None else lib.round6((truth or 0.0) - placebo_rate),
"window": None if window is None else lib.round6(window),
"fraction_all": None if fraction is None else lib.round6(fraction),
})
if rule in n5_rows and len(events) >= n1_min and p1 is not None and p2 is not None:
for label, bundle in (("P1", p1), ("P2", p2)):
placebo_rate = lib.mean_rate(rule_counts(bundle, shifted if label == "P1" else swapped, rule), truth_offsets, len(events))
n5_rows[rule].append({
"case_id": case["case_id"],
"placebo": label,
"truth": lib.round6(truth or 0.0),
"placebo_rate": None if placebo_rate is None else lib.round6(placebo_rate),
"diff": None if placebo_rate is None else lib.round6((truth or 0.0) - placebo_rate),
"window": None if window is None else lib.round6(window),
"fraction_all": None if fraction is None else lib.round6(fraction),
})
if len(events) >= n2_min and p1 is not None:
real_bits = rule_bits(real, events, prereg["rules"]["n2_primary"])
p1_bits = rule_bits(p1, shifted, prereg["rules"]["n2_primary"])
# P1 bits are aligned to shifted events, same index as real events.
n2_rows.append({
"case_id": case["case_id"],
"rounded_5min": case["case_id"] in rounded,
**n2_case(
real_bits, coarse, events, p1_bits,
case_id=case["case_id"], seed=str(prereg["tests"]["n2_random_second_seed"]),
),
})
a5_counts = rule_counts(real, events, prereg["rules"]["n3_primary"]) if events else {}
leaders = lib.rank_offsets({offset: a5_counts.get(offset, 0) for offset in coarse}) if events else []
n3a_rows.append({
"case_id": case["case_id"],
"rounded_5min": case["case_id"] in rounded,
"day_events": len(events),
"hit_60s": lib.leaders_within(leaders, 60) if events else False,
"hit_120s": lib.leaders_within(leaders, 120) if events else False,
"tied_leaders": len(leaders),
})
else:
n3a_rows.append({
"case_id": case["case_id"],
"rounded_5min": case["case_id"] in rounded,
"day_events": 0,
"hit_60s": False,
"hit_120s": False,
"tied_leaders": len(coarse),
})
case_fit.append(fit_row)
# N4 at the recorded second only.
if events:
base_sky = skies[0]
base_lords = lib.lords_for_events(base_sky["birth_date"], base_sky["moon_lon"], events)
base_part = int(lib.d150_equal(base_sky["asc_lon"])["part_index"])
perturbations: dict[str, Any] = {}
for name in prereg["n4"]["ayanamsas"]:
if name == prereg["ayanamsa"]:
continue
sky = lib.sky_at(birth, lib.candidate_moment(birth, 0), ayanamsa=name)
other = lib.lords_for_events(sky["birth_date"], sky["moon_lon"], events)
perturbations[f"ayanamsa:{name}"] = {
"level4": level_change(base_lords, other, 3),
"level5": level_change(base_lords, other, 4),
"d150_changed": int(lib.d150_equal(sky["asc_lon"])["part_index"]) != base_part,
}
for seconds in prereg["n4"]["time_offsets_seconds"]:
sky = skies.get(int(seconds)) or lib.sky_at(birth, lib.candidate_moment(birth, int(seconds)))
other = lib.lords_for_events(sky["birth_date"], sky["moon_lon"], events)
perturbations[f"time:{int(seconds)}"] = {
"level4": level_change(base_lords, other, 3),
"level5": level_change(base_lords, other, 4),
"d150_changed": int(lib.d150_equal(sky["asc_lon"])["part_index"]) != base_part,
}
for km in prereg["n4"]["longitude_shifts_km"]:
try:
moved = lib.shift_longitude_km(float(birth["longitude"]), float(birth["latitude"]), float(km))
sky = lib.sky_at(birth, lib.candidate_moment(birth, 0), longitude=moved)
other = lib.lords_for_events(sky["birth_date"], sky["moon_lon"], events)
perturbations[f"east_km:{km}"] = {
"level4": level_change(base_lords, other, 3),
"level5": level_change(base_lords, other, 4),
"d150_changed": int(lib.d150_equal(sky["asc_lon"])["part_index"]) != base_part,
"blocked": False,
}
except ValueError:
perturbations[f"east_km:{km}"] = {"blocked": True}
node_sky = lib.sky_at(birth, lib.candidate_moment(birth, 0), node_mode="true")
perturbations["node:true"] = {
"moon_changed": abs(float(node_sky["moon_lon"]) - float(base_sky["moon_lon"])) > 1e-6,
"asc_changed": abs(float(node_sky["asc_lon"]) - float(base_sky["asc_lon"])) > 1e-6,
"rahu_changed": node_sky["rahu_lon"] != base_sky["rahu_lon"],
"level5": level_change(base_lords, lib.lords_for_events(node_sky["birth_date"], node_sky["moon_lon"], events), 4),
}
n4_rows.append({"case_id": case["case_id"], "day_events": len(events), "perturbations": perturbations})
def pack_comparisons(bucket: Mapping[str, list[dict[str, Any]]], seed_base: int) -> dict[str, Any]:
packed = {}
cursor = 0
for rule, rows in bucket.items():
packed[rule] = {}
for label in n1_placebos:
subset = [
{
"diff": row["diff"],
"truth": row["truth"],
"placebo": row["placebo_rate"],
"window": row["window"],
"fraction_all": row["fraction_all"],
}
for row in rows
if row["placebo"] == label and row["diff"] is not None
]
packed[rule][label] = summarize_rule(subset, seed=seed_base + cursor, permutations=permutations)
cursor += 1
return packed
n1 = pack_comparisons(n1_rows, int(prereg["tests"]["n1_permutation_seed_base"]))
n5 = pack_comparisons(n5_rows, int(prereg["tests"]["n5_permutation_seed_base"]))
n1_pass = all(item["pass"] for rule in n1.values() for item in rule.values())
n5_pass = all(item["pass"] for rule in n5.values() for item in rule.values())
n2_values = [float(row["delta_random"]) for row in n2_rows if row["delta_random"] is not None]
n2_placebo_values = [float(row["delta_placebo"]) for row in n2_rows if row["delta_placebo"] is not None]
n2_ci = lib.bootstrap_mean_ci(n2_values, seed=int(prereg["tests"]["n2_bootstrap_seed"]), resamples=resamples)
n2_placebo_ci = lib.bootstrap_mean_ci(
n2_placebo_values, seed=int(prereg["tests"]["n2_bootstrap_seed"]) + 1, resamples=resamples,
)
uniform_60 = lib.round6(sum(1 for offset in coarse if abs(offset) <= 60) / len(coarse))
uniform_120 = lib.round6(sum(1 for offset in coarse if abs(offset) <= 120) / len(coarse))
# N3b. Separate from the fit loop so a replay failure does not drop N1.
n3b_rows = []
gate_radius = int(prereg["n3_protocol"]["radius_gate"])
for index, case in enumerate(cases, start=1):
print(f"replay {index}/{len(cases)} {case['case_id']}", flush=True)
by_radius = {}
for radius in radii:
try:
snapshot = six_probe(case, radius)
except Exception as exc: # noqa: BLE001 — recorded as a miss, message dropped
snapshot = {
"start": None,
"end": None,
"width": None,
"truth_in_range": False,
"leader_times": [],
"all_within_1min": False,
"any_exact": False,
"error": type(exc).__name__,
}
traceback.print_exc(limit=2)
events = lib.day_events(case)
span = delivery_offsets(snapshot["start"], snapshot["end"], case["birth"]["time"], radius)
if events and span:
skies = collect_skies(case["birth"], span)
bundle = lord_bundle(skies, events)
counts = rule_counts(bundle, events, prereg["rules"]["n3_primary"])
leaders = lib.rank_offsets(counts)
nadi_hit = lib.leaders_within(leaders, int(prereg["n3_protocol"]["nadi_limit_seconds"]))
tied = len(leaders)
else:
nadi_hit = False
tied = 0
by_radius[str(radius)] = {
"truth_in_range": snapshot["truth_in_range"],
"width": snapshot["width"],
"baseline_all_within_1min": snapshot["all_within_1min"],
"baseline_any_exact": snapshot["any_exact"],
"nadi_all_within_60s": nadi_hit,
"nadi_tied_leaders": tied,
"improvement": int(nadi_hit) - int(snapshot["all_within_1min"]),
"error": snapshot["error"],
}
n3b_rows.append({
"case_id": case["case_id"],
"rounded_5min": case["case_id"] in rounded,
"radii": by_radius,
})
def n3b_summary(radius: int) -> dict[str, Any]:
key = str(radius)
improvements = [int(row["radii"][key]["improvement"]) for row in n3b_rows]
inside = sum(1 for row in n3b_rows if row["radii"][key]["truth_in_range"])
ci = lib.bootstrap_mean_ci(improvements, seed=int(prereg["tests"]["n3_bootstrap_seed"]) + radius, resamples=resamples)
return {
"radius": radius,
"truth_in_range": f"{inside}/{len(n3b_rows)}",
"truth_in_range_count": inside,
"truth_in_range_pass": inside >= 76 if len(n3b_rows) == 77 else None,
"baseline_all_within_1min": split_rates(
[{"case_id": row["case_id"], "hit": row["radii"][key]["baseline_all_within_1min"]} for row in n3b_rows],
"hit", rounded,
),
"baseline_any_exact": rate([row["radii"][key]["baseline_any_exact"] for row in n3b_rows]),
"nadi_all_within_60s": split_rates(
[{"case_id": row["case_id"], "hit": row["radii"][key]["nadi_all_within_60s"]} for row in n3b_rows],
"hit", rounded,
),
"improvement_ci": ci,
"pass": bool(ci["excludes_zero_positive"] and len(n3b_rows) == 77 and inside >= 76),
}
def n4_aggregate(label: str, field: str) -> dict[str, Any]:
values = []
d150_flags = []
weights = []
for row in n4_rows:
item = row["perturbations"].get(label) or {}
if item.get("blocked") or item.get(field) is None:
continue
values.append(float(item[field]))
weights.append(int(row["day_events"]))
if "d150_changed" in item:
d150_flags.append(bool(item["d150_changed"]))
event_rate = None
if weights and sum(weights):
event_rate = lib.round6(sum(value * weight for value, weight in zip(values, weights, strict=True)) / sum(weights))
return {
"event_rate": event_rate,
"case_rate": rate(d150_flags) if field == "level5" else None,
"d150_case_rate": rate(d150_flags),
"cases": len(values),
}
wording_hits = []
for name in prereg["n4"]["ayanamsas"]:
if name == prereg["ayanamsa"]:
continue
label = f"ayanamsa:{name}"
level5 = n4_aggregate(label, "level5")
wording_hits.append({
"switch": f"{prereg['ayanamsa']}->{name}",
"level5_event_rate": level5["event_rate"],
"d150_case_rate": level5["d150_case_rate"],
"over_20pct": bool(
(level5["event_rate"] is not None and level5["event_rate"] > 0.20)
or (level5["d150_case_rate"] is not None and level5["d150_case_rate"] > 0.20)
),
})
wording_ban = any(item["over_20pct"] for item in wording_hits)
n3a = {
"grid": "coarse",
"uniform_within_60s": uniform_60,
"uniform_within_120s": uniform_120,
"published_engine_prior_top1_pm10": 0.18,
"hit_60s": split_rates(
[{"case_id": row["case_id"], "hit": row["hit_60s"]} for row in n3a_rows], "hit", rounded,
),
"hit_120s": split_rates(
[{"case_id": row["case_id"], "hit": row["hit_120s"]} for row in n3a_rows], "hit", rounded,
),
}
n3b = {str(radius): n3b_summary(radius) for radius in radii}
n3_pass = bool(n3b.get(str(gate_radius), {}).get("pass"))
return {
"prereg_sha256": lib.file_sha256(lib.PREREG_PATH),
"library_sha256": lib.file_sha256(Path(__file__).with_name("nadi_seconds_lib.py")),
"runner_sha256": lib.file_sha256(Path(__file__)),
"dataset_sha256": lib.file_sha256(lib.HOLDOUT_V5),
"swisseph": __import__("swisseph").version,
"limit": limit,
"n0": {
"audit": {key: value for key, value in audit.items() if key != "per_case"},
"per_case_day_events": audit["per_case"],
"reconciliation_mismatch_cases": [row["case_id"] for row in recon if row["mismatches"]],
"reconciliation_events": sum(row["events"] for row in recon),
},
"n1": {
"pass": n1_pass and not limit,
"comparisons": n1,
"fraction_grid_explains_all_mean": {rule: mean_defined(values) for rule, values in fraction_rows.items()},
"cases": case_fit,
},
"n2": {
"pass": bool(n2_ci["excludes_zero_positive"]) and not limit,
"versus_random": n2_ci,
"versus_placebo_date": n2_placebo_ci,
"cases": n2_rows,
},
"n3": {
"pass": n3_pass and not limit,
"n3a": n3a,
"n3b": n3b,
"cases_n3a": n3a_rows,
"cases_n3b": n3b_rows,
},
"n4": {
"wording_ban_second_scale": wording_ban,
"ayanamsa_switches": wording_hits,
"time": {str(seconds): n4_aggregate(f"time:{int(seconds)}", "level5") for seconds in prereg["n4"]["time_offsets_seconds"]},
"time_level4": {str(seconds): n4_aggregate(f"time:{int(seconds)}", "level4") for seconds in prereg["n4"]["time_offsets_seconds"]},
"longitude_km": {str(km): n4_aggregate(f"east_km:{km}", "level5") for km in prereg["n4"]["longitude_shifts_km"]},
"longitude_km_d150": {str(km): n4_aggregate(f"east_km:{km}", "level5")["d150_case_rate"] for km in prereg["n4"]["longitude_shifts_km"]},
"node_mean_to_true": {
"moon_changed_cases": sum(1 for row in n4_rows if row["perturbations"]["node:true"]["moon_changed"]),
"asc_changed_cases": sum(1 for row in n4_rows if row["perturbations"]["node:true"]["asc_changed"]),
"rahu_changed_cases": sum(1 for row in n4_rows if row["perturbations"]["node:true"]["rahu_changed"]),
"level5_event_rate": n4_aggregate("node:true", "level5")["event_rate"],
},
"cases": n4_rows,
},
"n5": {
"pass": n5_pass and not limit,
"named_text": "blocked",
"named_text_reason": "no legal Chandra Kala Nadi source in the repository",
"classical_index_status": lib.CLASSICAL_NADI_STATUS,
"comparisons": n5,
},
"gates": {
"n1": n1_pass and not limit,
"n2": bool(n2_ci["excludes_zero_positive"]) and not limit,
"n3": n3_pass and not limit,
"n4_blocks_second_scale_wording": wording_ban,
"n5": n5_pass and not limit,
},
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--radii", default="10,30,60")
parser.add_argument("--out", default=str(REPORT))
args = parser.parse_args()
prereg = load_prereg()
radii = tuple(int(item) for item in str(args.radii).split(",") if item.strip())
payload = build(prereg, limit=int(args.limit), radii=radii)
text = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(text, encoding="utf-8")
print(json.dumps(payload["gates"], ensure_ascii=False, sort_keys=True), flush=True)
print(f"wrote {out}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())