research(rectification): angle-timing A1 — real vs shuffled and age-preserving jittered dates, 0/9 tests significant (BUG-1141)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-01 10:55:46 +08:00
co-authored by Claude Opus 5.5
parent f507570448
commit 915c3e829d
6 changed files with 11010 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
{
"case_count": 77,
"dataset": "v5",
"event_cutoff": "2026-09-14",
"holdout_sha256": "ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a",
"jitter_null": {
"null": "each event moved 1-3 years earlier/later, precision kept",
"shuffles": 50,
"table": {
"SA": {
"1.0": {
"10": {
"jitter_mean": 0.493909,
"percentile_beaten": 8.0,
"real_mean_rank": 0.532143
},
"30": {
"jitter_mean": 0.506271,
"percentile_beaten": 0.0,
"real_mean_rank": 0.556061
},
"60": {
"jitter_mean": 0.527833,
"percentile_beaten": 0.0,
"real_mean_rank": 0.584632
}
},
"2.0": {
"10": {
"jitter_mean": 0.489409,
"percentile_beaten": 2.0,
"real_mean_rank": 0.547403
},
"30": {
"jitter_mean": 0.509255,
"percentile_beaten": 0.0,
"real_mean_rank": 0.574675
},
"60": {
"jitter_mean": 0.535249,
"percentile_beaten": 0.0,
"real_mean_rank": 0.592154
}
},
"3.0": {
"10": {
"jitter_mean": 0.521558,
"percentile_beaten": 88.0,
"real_mean_rank": 0.491883
},
"30": {
"jitter_mean": 0.537597,
"percentile_beaten": 66.0,
"real_mean_rank": 0.526082
},
"60": {
"jitter_mean": 0.561689,
"percentile_beaten": 72.0,
"real_mean_rank": 0.548647
}
}
},
"SP": {
"1.0": {
"10": {
"jitter_mean": 0.473714,
"percentile_beaten": 0.0,
"real_mean_rank": 0.521429
},
"30": {
"jitter_mean": 0.477385,
"percentile_beaten": 7.0,
"real_mean_rank": 0.511255
},
"60": {
"jitter_mean": 0.488568,
"percentile_beaten": 10.0,
"real_mean_rank": 0.518074
}
},
"2.0": {
"10": {
"jitter_mean": 0.495104,
"percentile_beaten": 25.0,
"real_mean_rank": 0.512338
},
"30": {
"jitter_mean": 0.500193,
"percentile_beaten": 30.0,
"real_mean_rank": 0.512446
},
"60": {
"jitter_mean": 0.515154,
"percentile_beaten": 32.0,
"real_mean_rank": 0.526407
}
},
"3.0": {
"10": {
"jitter_mean": 0.4975,
"percentile_beaten": 71.0,
"real_mean_rank": 0.485065
},
"30": {
"jitter_mean": 0.501734,
"percentile_beaten": 60.0,
"real_mean_rank": 0.49513
},
"60": {
"jitter_mean": 0.516921,
"percentile_beaten": 56.0,
"real_mean_rank": 0.511959
}
}
},
"TR": {
"1.0": {
"10": {
"jitter_mean": 0.519,
"percentile_beaten": 50.0,
"real_mean_rank": 0.516558
},
"30": {
"jitter_mean": 0.520831,
"percentile_beaten": 62.0,
"real_mean_rank": 0.513745
},
"60": {
"jitter_mean": 0.524524,
"percentile_beaten": 62.0,
"real_mean_rank": 0.520833
}
},
"2.0": {
"10": {
"jitter_mean": 0.497753,
"percentile_beaten": 62.0,
"real_mean_rank": 0.494481
},
"30": {
"jitter_mean": 0.505554,
"percentile_beaten": 70.0,
"real_mean_rank": 0.492749
},
"60": {
"jitter_mean": 0.510961,
"percentile_beaten": 60.0,
"real_mean_rank": 0.504437
}
},
"3.0": {
"10": {
"jitter_mean": 0.496377,
"percentile_beaten": 20.0,
"real_mean_rank": 0.512013
},
"30": {
"jitter_mean": 0.502379,
"percentile_beaten": 52.0,
"real_mean_rank": 0.499892
},
"60": {
"jitter_mean": 0.509171,
"percentile_beaten": 54.0,
"real_mean_rank": 0.507143
}
}
}
}
},
"node": "mean",
"orbs": [
1.0,
2.0,
3.0
],
"python_version": "3.13.5",
"radii": [
10,
30,
60
],
"techniques": {
"SA": "solar-arc ASC / MC to natal Sun..Saturn, 0/90/180",
"SP": "secondary-progressed ASC / MC (quotidian date method, same location) to natal Sun..Saturn, 0/90/180",
"TR": "transit Saturn / Jupiter / mean Rahu-Ketu (+Mars for day events) to natal ASC / MC, 0/90/180"
},
"zodiac": "tropical (aspects are zodiac-independent)"
}
@@ -0,0 +1,311 @@
{
"case_count": 77,
"dataset": "v5",
"event_cutoff": "2026-09-14",
"holdout_sha256": "ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a",
"node": "mean",
"orbs": [
1.0,
2.0,
3.0
],
"python_version": "3.13.5",
"radii": [
10,
30,
60
],
"subsets": {
"note": "descriptive robustness; significance is judged only on the pre-registered A1 tests",
"radius": 30,
"shuffles": 50,
"subsets": {
"era=1900_and_later": {
"cases": 70,
"table": {
"SA": {
"1.0": {
"percentile_beaten": 16.0,
"real_mean_rank": 0.542976,
"shuffle_mean": 0.511831
},
"2.0": {
"percentile_beaten": 2.0,
"real_mean_rank": 0.572738,
"shuffle_mean": 0.510495
},
"3.0": {
"percentile_beaten": 30.0,
"real_mean_rank": 0.529167,
"shuffle_mean": 0.506257
}
},
"SP": {
"1.0": {
"percentile_beaten": 44.0,
"real_mean_rank": 0.509167,
"shuffle_mean": 0.50255
},
"2.0": {
"percentile_beaten": 54.0,
"real_mean_rank": 0.492857,
"shuffle_mean": 0.493438
},
"3.0": {
"percentile_beaten": 66.0,
"real_mean_rank": 0.484167,
"shuffle_mean": 0.495136
}
},
"TR": {
"1.0": {
"percentile_beaten": 60.0,
"real_mean_rank": 0.495714,
"shuffle_mean": 0.498952
},
"2.0": {
"percentile_beaten": 74.0,
"real_mean_rank": 0.482976,
"shuffle_mean": 0.501769
},
"3.0": {
"percentile_beaten": 74.0,
"real_mean_rank": 0.483452,
"shuffle_mean": 0.508669
}
}
}
},
"era=lmt_before_1900": {
"cases": 7,
"table": {
"SA": {
"1.0": {
"percentile_beaten": 2.0,
"real_mean_rank": 0.686905,
"shuffle_mean": 0.500762
},
"2.0": {
"percentile_beaten": 20.0,
"real_mean_rank": 0.594048,
"shuffle_mean": 0.503024
},
"3.0": {
"percentile_beaten": 60.0,
"real_mean_rank": 0.495238,
"shuffle_mean": 0.506714
}
},
"SP": {
"1.0": {
"percentile_beaten": 24.0,
"real_mean_rank": 0.532143,
"shuffle_mean": 0.466405
},
"2.0": {
"percentile_beaten": 0.0,
"real_mean_rank": 0.708333,
"shuffle_mean": 0.485786
},
"3.0": {
"percentile_beaten": 6.0,
"real_mean_rank": 0.604762,
"shuffle_mean": 0.468667
}
},
"TR": {
"1.0": {
"percentile_beaten": 2.0,
"real_mean_rank": 0.694048,
"shuffle_mean": 0.51519
},
"2.0": {
"percentile_beaten": 19.0,
"real_mean_rank": 0.590476,
"shuffle_mean": 0.505405
},
"3.0": {
"percentile_beaten": 0.0,
"real_mean_rank": 0.664286,
"shuffle_mean": 0.498833
}
}
}
},
"precision=day": {
"cases": 77,
"table": {
"SA": {
"1.0": {
"percentile_beaten": 12.0,
"real_mean_rank": 0.518506,
"shuffle_mean": 0.497093
},
"2.0": {
"percentile_beaten": 10.0,
"real_mean_rank": 0.537229,
"shuffle_mean": 0.503887
},
"3.0": {
"percentile_beaten": 44.0,
"real_mean_rank": 0.509199,
"shuffle_mean": 0.501392
}
},
"SP": {
"1.0": {
"percentile_beaten": 4.0,
"real_mean_rank": 0.553788,
"shuffle_mean": 0.494117
},
"2.0": {
"percentile_beaten": 16.0,
"real_mean_rank": 0.529004,
"shuffle_mean": 0.501039
},
"3.0": {
"percentile_beaten": 14.0,
"real_mean_rank": 0.520887,
"shuffle_mean": 0.501656
}
},
"TR": {
"1.0": {
"percentile_beaten": 54.0,
"real_mean_rank": 0.49513,
"shuffle_mean": 0.497506
},
"2.0": {
"percentile_beaten": 69.0,
"real_mean_rank": 0.480519,
"shuffle_mean": 0.498214
},
"3.0": {
"percentile_beaten": 44.0,
"real_mean_rank": 0.50487,
"shuffle_mean": 0.500452
}
}
}
},
"precision=month": {
"cases": 77,
"table": {
"SA": {
"1.0": {
"percentile_beaten": 16.0,
"real_mean_rank": 0.521104,
"shuffle_mean": 0.498039
},
"2.0": {
"percentile_beaten": 12.0,
"real_mean_rank": 0.529329,
"shuffle_mean": 0.503056
},
"3.0": {
"percentile_beaten": 94.0,
"real_mean_rank": 0.474675,
"shuffle_mean": 0.506771
}
},
"SP": {
"1.0": {
"percentile_beaten": 60.0,
"real_mean_rank": 0.491017,
"shuffle_mean": 0.499344
},
"2.0": {
"percentile_beaten": 64.0,
"real_mean_rank": 0.49145,
"shuffle_mean": 0.498271
},
"3.0": {
"percentile_beaten": 22.0,
"real_mean_rank": 0.512446,
"shuffle_mean": 0.497991
}
},
"TR": {
"1.0": {
"percentile_beaten": 8.0,
"real_mean_rank": 0.524784,
"shuffle_mean": 0.499639
},
"2.0": {
"percentile_beaten": 16.0,
"real_mean_rank": 0.517532,
"shuffle_mean": 0.498294
},
"3.0": {
"percentile_beaten": 50.0,
"real_mean_rank": 0.499784,
"shuffle_mean": 0.499158
}
}
}
},
"precision=year": {
"cases": 77,
"table": {
"SA": {
"1.0": {
"percentile_beaten": 20.0,
"real_mean_rank": 0.529978,
"shuffle_mean": 0.504385
},
"2.0": {
"percentile_beaten": 2.0,
"real_mean_rank": 0.557251,
"shuffle_mean": 0.502032
},
"3.0": {
"percentile_beaten": 10.0,
"real_mean_rank": 0.545887,
"shuffle_mean": 0.507058
}
},
"SP": {
"1.0": {
"percentile_beaten": 80.0,
"real_mean_rank": 0.474026,
"shuffle_mean": 0.49887
},
"2.0": {
"percentile_beaten": 62.0,
"real_mean_rank": 0.492316,
"shuffle_mean": 0.499013
},
"3.0": {
"percentile_beaten": 62.0,
"real_mean_rank": 0.488961,
"shuffle_mean": 0.495091
}
},
"TR": {
"1.0": {
"percentile_beaten": 50.0,
"real_mean_rank": 0.5,
"shuffle_mean": 0.5
},
"2.0": {
"percentile_beaten": 50.0,
"real_mean_rank": 0.5,
"shuffle_mean": 0.5
},
"3.0": {
"percentile_beaten": 50.0,
"real_mean_rank": 0.5,
"shuffle_mean": 0.5
}
}
}
}
}
},
"techniques": {
"SA": "solar-arc ASC / MC to natal Sun..Saturn, 0/90/180",
"SP": "secondary-progressed ASC / MC (quotidian date method, same location) to natal Sun..Saturn, 0/90/180",
"TR": "transit Saturn / Jupiter / mean Rahu-Ketu (+Mars for day events) to natal ASC / MC, 0/90/180"
},
"zodiac": "tropical (aspects are zodiac-independent)"
}
+24
View File
@@ -251,3 +251,27 @@ def stable_json(payload: Any) -> str:
def sha256_of(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def jittered_events(case: dict[str, Any], rng: Random) -> list[dict[str, Any]]:
"""Second null: each event moved 1-3 years earlier or later (same precision, month/day kept),
which keeps the age structure of real biographies; clamped to after birth and before the cutoff."""
birth = birth_local(case).date()
out = []
for raw in case["events"]:
text = str(raw.get("date") or "")
p = str(raw.get("precision") or "")
try:
year = int(text[:4])
except ValueError:
out.append(dict(raw)); continue
shift = rng.choice((-1, 1)) * rng.randint(1, 3)
new_year = min(max(year + shift, birth.year + 1), EVENT_CUTOFF.year - 1)
if p == "day" and len(text) >= 10:
m, d = int(text[5:7]), int(text[8:10]); d = min(d, calendar.monthrange(new_year, m)[1]); new = f"{new_year:04d}-{m:02d}-{d:02d}"
elif p == "month" and len(text) >= 7:
new = f"{new_year:04d}-{text[5:7]}"
else:
new = f"{new_year:04d}"
out.append({**raw, "date": new})
return out
+217
View File
@@ -0,0 +1,217 @@
"""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())
+18
View File
@@ -81,3 +81,21 @@ def test_separation_matrices_have_one_row_per_usable_event():
assert tr.shape[0] == sum(1 for e in events if e.precision != "year")
assert sp.shape == (len(events), len(chart.offsets))
assert np.isfinite(sp).all() and (sp >= 0).all() and (sp <= 45).all()
def test_a1_runner_is_deterministic_and_reports_every_preregistered_test():
from scripts.research import angle_timing_research as runner
cases = at.load_cases()[:2]
first = runner.run_a1(cases, 3, lambda _m: None)
second = runner.run_a1(cases, 3, lambda _m: None)
assert at.stable_json(first) == at.stable_json(second)
assert first["tests"] == 9
assert first["bonferroni_percentile"] == pytest.approx(100 * (1 - 0.05 / 9), abs=1e-3)
for tech in at.TECHNIQUES:
for orb in at.ORBS:
for radius in runner.RADII:
cell = first["table"][tech][str(orb)][str(radius)]
assert 0.0 <= cell["real_mean_rank"] <= 1.0
assert 0.0 <= cell["percentile_beaten"] <= 100.0
assert len(first["shuffled_means"][tech][str(orb)][str(radius)]) == 3