Files
Jyotisha/scripts/research/angle_timing_research.py
T

218 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Angle-timing research runner (BUG-1141).
A1: for each technique × orb, compare where the truth minute ranks inside its
window when events keep their real dates versus shuffled dates. Pre-registered
tests: TR / SP / SA × orbs 1° / 2° / 3° (9 tests), primary radius ±30, with
±10 / ±60 reported. Score per candidate = number of events with a contact within
the orb; truth rank = share of other window minutes scoring higher (ties half);
0.5 = chance. Real mean rank is compared with the shuffled-date distribution.
A2 (only for techniques significant in A1): see run_a2.
Usage:
PYTHONHASHSEED=0 python3 -m scripts.research.angle_timing_research --stages a1 \
--shuffles 200 --json-out docs/research/angle_timing_a1_2026_10_01.json
"""
from __future__ import annotations
import argparse
import platform
import sys
import time
from pathlib import Path
from random import Random
from typing import Any
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.research import angle_timing_lib as at # noqa: E402
RADII = (10, 30, 60)
PRIMARY_RADIUS = 30
N_TESTS = len(at.TECHNIQUES) * len(at.ORBS)
BONFERRONI_PERCENTILE = 100.0 * (1.0 - 0.05 / N_TESTS)
UNCORRECTED_PERCENTILE = 95.0
def ranks_for(chart: at.CaseChart, raw_events: list[dict[str, Any]] | None) -> dict[str, dict[str, dict[str, float]]]:
events = chart.events(raw_events)
out: dict[str, dict[str, dict[str, float]]] = {}
for tech in at.TECHNIQUES:
seps = chart.separations(tech, events)
out[tech] = {}
for orb in at.ORBS:
scores = at.hit_counts(seps, orb) if seps.shape[0] else np.zeros(len(chart.offsets))
out[tech][str(orb)] = {str(r): at.truth_rank(scores, r) for r in RADII}
return out
def run_a1(cases: list[dict[str, Any]], shuffles: int, log) -> dict[str, Any]:
charts = [at.CaseChart(case) for case in cases]
real = {chart.case_id: ranks_for(chart, None) for chart in charts}
usable = {chart.case_id: {t: int(chart.separations(t, chart.events()).shape[0]) for t in at.TECHNIQUES} for chart in charts}
shuffled_means: dict[str, dict[str, dict[str, list[float]]]] = {
t: {str(o): {str(r): [] for r in RADII} for o in at.ORBS} for t in at.TECHNIQUES}
started = time.perf_counter()
for rep in range(shuffles):
per_case = []
for chart in charts:
rng = Random(f"angle-timing|{chart.case_id}|{rep}")
per_case.append(ranks_for(chart, at.shuffled_events(chart.case, rng)))
for t in at.TECHNIQUES:
for o in at.ORBS:
for r in RADII:
shuffled_means[t][str(o)][str(r)].append(round(float(np.mean([pc[t][str(o)][str(r)] for pc in per_case])), 6))
if (rep + 1) % 20 == 0:
log(f"shuffle {rep + 1}/{shuffles} ({time.perf_counter() - started:.0f}s)")
table: dict[str, Any] = {}
for t in at.TECHNIQUES:
table[t] = {}
for o in at.ORBS:
table[t][str(o)] = {}
for r in RADII:
real_mean = round(float(np.mean([real[cid][t][str(o)][str(r)] for cid in real])), 6)
dist = np.array(shuffled_means[t][str(o)][str(r)])
# percentile: share of shuffles the real dates beat (lower rank is better)
pct = round(100.0 * float(((dist > real_mean).sum() + 0.5 * (dist == real_mean).sum()) / len(dist)), 3)
table[t][str(o)][str(r)] = {
"real_mean_rank": real_mean,
"shuffle_mean": round(float(dist.mean()), 6),
"shuffle_sd": round(float(dist.std(ddof=1)), 6) if len(dist) > 1 else 0.0,
"shuffle_p05": round(float(np.percentile(dist, 5)), 6),
"percentile_beaten": pct,
"significant_uncorrected": bool(pct >= UNCORRECTED_PERCENTILE),
"significant_bonferroni": bool(pct >= BONFERRONI_PERCENTILE),
}
primary = {t: {str(o): table[t][str(o)][str(PRIMARY_RADIUS)] for o in at.ORBS} for t in at.TECHNIQUES}
significant = sorted(f"{t}@{o}" for t in at.TECHNIQUES for o in at.ORBS
if primary[t][str(o)]["significant_bonferroni"])
return {
"tests": N_TESTS,
"primary_radius": PRIMARY_RADIUS,
"bonferroni_percentile": round(BONFERRONI_PERCENTILE, 4),
"uncorrected_percentile": UNCORRECTED_PERCENTILE,
"shuffles": shuffles,
"table": table,
"significant_primary_bonferroni": significant,
"usable_events_per_case": usable,
"real_per_case": real,
"shuffled_means": shuffled_means,
}
def _precision_filter(raw_events: list[dict[str, Any]], precision: str) -> list[dict[str, Any]]:
return [e for e in raw_events if str(e.get("precision")) == precision]
def run_jitter_null(cases: list[dict[str, Any]], shuffles: int, log) -> dict[str, Any]:
"""Second null (age-structure preserving): real ranks vs events moved ±1-3 years, all radii."""
charts = [at.CaseChart(case) for case in cases]
real = [ranks_for(chart, None) for chart in charts]
dist: dict[str, dict[str, dict[str, list[float]]]] = {t: {str(o): {str(r): [] for r in RADII} for o in at.ORBS} for t in at.TECHNIQUES}
for rep in range(shuffles):
per = [ranks_for(c, at.jittered_events(c.case, Random(f"angle-timing-jitter|{c.case_id}|{rep}"))) for c in charts]
for t in at.TECHNIQUES:
for o in at.ORBS:
for r in RADII:
dist[t][str(o)][str(r)].append(round(float(np.mean([pc[t][str(o)][str(r)] for pc in per])), 6))
if (rep + 1) % 10 == 0:
log(f"jitter {rep + 1}/{shuffles}")
table: dict[str, Any] = {}
for t in at.TECHNIQUES:
table[t] = {}
for o in at.ORBS:
table[t][str(o)] = {}
for r in RADII:
real_mean = round(float(np.mean([x[t][str(o)][str(r)] for x in real])), 6)
d = np.array(dist[t][str(o)][str(r)])
pct = round(100.0 * float(((d > real_mean).sum() + 0.5 * (d == real_mean).sum()) / len(d)), 3)
table[t][str(o)][str(r)] = {"real_mean_rank": real_mean, "jitter_mean": round(float(d.mean()), 6),
"percentile_beaten": pct}
return {"shuffles": shuffles, "null": "each event moved 1-3 years earlier/later, precision kept", "table": table}
def run_subsets(cases: list[dict[str, Any]], shuffles: int, log) -> dict[str, Any]:
"""Robustness: real vs shuffled by event precision and by LMT era (primary radius only)."""
charts = [at.CaseChart(case) for case in cases]
lmt = {chart.case_id for chart in charts if chart.local.year < 1900}
subsets: dict[str, tuple[list[at.CaseChart], str | None]] = {
"precision=day": (charts, "day"), "precision=month": (charts, "month"), "precision=year": (charts, "year"),
"era=lmt_before_1900": ([c for c in charts if c.case_id in lmt], None),
"era=1900_and_later": ([c for c in charts if c.case_id not in lmt], None),
}
out: dict[str, Any] = {}
for name, (members, precision) in subsets.items():
def ranks(chart: at.CaseChart, raw: list[dict[str, Any]]):
events = _precision_filter(raw, precision) if precision else raw
return ranks_for(chart, events)
real = [ranks(c, list(c.case["events"])) for c in members]
dist: dict[str, dict[str, list[float]]] = {t: {str(o): [] for o in at.ORBS} for t in at.TECHNIQUES}
for rep in range(shuffles):
per = [ranks(c, at.shuffled_events(c.case, Random(f"angle-timing-sub|{name}|{c.case_id}|{rep}"))) for c in members]
for t in at.TECHNIQUES:
for o in at.ORBS:
dist[t][str(o)].append(round(float(np.mean([pc[t][str(o)][str(PRIMARY_RADIUS)] for pc in per])), 6))
out[name] = {"cases": len(members), "table": {}}
for t in at.TECHNIQUES:
out[name]["table"][t] = {}
for o in at.ORBS:
real_mean = round(float(np.mean([r[t][str(o)][str(PRIMARY_RADIUS)] for r in real])), 6)
d = np.array(dist[t][str(o)])
pct = round(100.0 * float(((d > real_mean).sum() + 0.5 * (d == real_mean).sum()) / len(d)), 3)
out[name]["table"][t][str(o)] = {"real_mean_rank": real_mean, "shuffle_mean": round(float(d.mean()), 6),
"percentile_beaten": pct}
log(f"subset {name} done")
return {"radius": PRIMARY_RADIUS, "shuffles": shuffles, "subsets": out,
"note": "descriptive robustness; significance is judged only on the pre-registered A1 tests"}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--stages", default="a1")
parser.add_argument("--shuffles", type=int, default=200)
parser.add_argument("--subset-shuffles", type=int, default=50)
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--json-out", required=True)
parser.add_argument("--quiet", action="store_true")
args = parser.parse_args()
log = (lambda _m: None) if args.quiet else (lambda m: print(m, file=sys.stderr, flush=True))
cases = at.load_cases()
if args.limit:
cases = cases[: args.limit]
payload: dict[str, Any] = {
"dataset": "v5",
"holdout_sha256": at.sha256_of(at.HOLDOUT_V5),
"case_count": len(cases),
"zodiac": "tropical (aspects are zodiac-independent)",
"node": "mean",
"techniques": {
"TR": "transit Saturn / Jupiter / mean Rahu-Ketu (+Mars for day events) to natal ASC / MC, 0/90/180",
"SP": "secondary-progressed ASC / MC (quotidian date method, same location) to natal Sun..Saturn, 0/90/180",
"SA": "solar-arc ASC / MC to natal Sun..Saturn, 0/90/180",
},
"orbs": list(at.ORBS),
"radii": list(RADII),
"event_cutoff": at.EVENT_CUTOFF.isoformat(),
"python_version": platform.python_version(),
}
stages = {s.strip() for s in args.stages.split(",") if s.strip()}
if "a1" in stages:
payload["a1"] = run_a1(cases, args.shuffles, log)
if "jitter" in stages:
payload["jitter_null"] = run_jitter_null(cases, args.subset_shuffles, log)
if "subsets" in stages:
payload["subsets"] = run_subsets(cases, args.subset_shuffles, log)
out = Path(args.json_out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(at.stable_json(payload), encoding="utf-8")
log(f"wrote {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())