Files
Jyotisha/scripts/research/dasha_shift_per_minute.py
T
Jesse_ChenandClaude Opus 5.5 0f5442cea2 research(rectification): offline R1/R2/R3 — answer-flip tolerance, V1/V2 rerun, dasha shift arithmetic
- R1: flipping 1 answer keeps truth in range 98-100% but cuts head hit by
  a third or more; 2 flips squeeze truth out in 7-10% of ±30/±60 replays
  (two flips = 8 points = SEPARATION_LEAD).
- R2: weights do apply (research scorer == production at V0); V1/V2 are
  identity at ±30/±60 by construction and leave six-question metrics
  unchanged at ±10 -> no_benefit (measured). Supplementary V1n does not
  pass the gate.
- R3: boundary shift is ~3.8 days/minute (1.3-5.9), not 1.1; the 45-day
  gate is ~8-34 minutes. The _representative_pairs hypothesis is refuted
  (all-pairs adds no dated probes); the bottleneck is monthly evaluation.
  New finding recorded as BUG-1048 (investigating): _boundary_windows
  year-straddle exemption and positional zip misalignment bypass the gate.
- Dated errata appended (no deletions) to the 09-14/09-16 briefs and
  research docs; README board row -> 待验收. No production code, scoring,
  thresholds, gates or Skill changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 14:47:48 +08:00

264 lines
10 KiB
Python

#!/usr/bin/env python3
"""R3a (2026-09-26): how far Vimshottari boundaries move per minute of birth time.
Fictional moments only (random instants, no people). Three measurements:
1. analytic: Moon speed (swisseph, sidereal Raman) x lord years / 13.333 deg;
2. finite difference: first-dasha balance at t and t + 1 minute (the 09-26
audit method, reproduced here with the same seed);
3. repo helper: `scripts.rectification.event_probes._vim_start_dates` at t and
t + 20 minutes; all MD/AD starts in [birth+5, birth+80] shift by one
constant, which is divided by 20.
It then derives what the production 45 / 30 day probe gate means in minutes,
and how close two candidates can be for evidence of a given date precision to
separate them. It corrects the 2026-09-14 statement "1 minute ~ 1.1 days",
which used Vimshottari 1 deg ~ 122 days; the correct mean is ~365 days/deg
(120 years over 9 nakshatras = 120 deg).
Run:
python3 scripts/research/dasha_shift_per_minute.py
"""
from __future__ import annotations
import argparse
import json
import random
import statistics
import sys
from datetime import date, timedelta
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
for extra in (ROOT, ROOT / "scripts"):
if str(extra) not in sys.path:
sys.path.insert(0, str(extra))
import swisseph as swe # noqa: E402
from scripts.rectification.event_probes import ( # noqa: E402
MIN_BOUNDARY_DAYS,
REFRESH_MIN_BOUNDARY_DAYS,
_vim_start_dates,
)
from scripts.research.offline_research_20260926_lib import ( # noqa: E402
DAYS_PER_YEAR,
NAKSHATRA_SPAN,
VIM_LORDS,
VIM_YEARS,
distribution,
minutes_for_gap,
nakshatra_lord,
percentile,
shift_days_per_minute,
zip_misaligned,
)
REPORT_JSON = ROOT / "docs" / "research" / "dasha_shift_per_minute_2026_09_26.json"
SAMPLES = 2000
SEED = 7 # same seed as the 09-26 audit sketch, so the 3.8-day median reproduces
REPO_SAMPLES = 300
WINDOWS = (2, 4, 6, 10, 20, 30, 40, 60)
MONTH_DAYS = 30.44
OLD_CLAIM_DAYS_PER_MINUTE = 1.1
OLD_CLAIM_DEG_DAYS = 122.0
def moon(jd: float) -> tuple[float, float]:
values, _flags = swe.calc_ut(jd, swe.MOON, swe.FLG_SIDEREAL | swe.FLG_SPEED)
return float(values[0]), float(values[3])
def balance_days(longitude: float) -> float:
progress = (longitude % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
return (1.0 - progress) * VIM_YEARS[nakshatra_lord(longitude)] * DAYS_PER_YEAR
def sample_instants(n: int, seed: int) -> list[float]:
rng = random.Random(seed)
rows = []
for _ in range(n):
rows.append(swe.julday(1950 + rng.randrange(55), 1, 1, 0.0) + rng.random() * 365.0)
return rows
def jd_to_date(jd: float) -> date:
year, month, day, _hour = swe.revjul(jd)
return date(int(year), int(month), int(day))
def repo_shift(jd: float, minutes: int) -> dict[str, Any] | None:
birth = jd_to_date(jd)
birth_date = birth.isoformat()
lo, hi = birth.year + 5, birth.year + 80
lon_a, _ = moon(jd)
lon_b, _ = moon(jd + minutes / 1440.0)
if int(lon_a // NAKSHATRA_SPAN) != int(lon_b // NAKSHATRA_SPAN):
return {"crossed_nakshatra": True}
left = _vim_start_dates(birth_date, lon_a, lo, hi)
right = _vim_start_dates(birth_date, lon_b, lo, hi)
misaligned = zip_misaligned(left, right)
# Re-align on corresponding boundaries: when a boundary crosses the lower
# or upper year edge (MD and first-AD starts coincide, so up to two
# entries can drop), try offsets -3..+3 and keep the one whose
# signed differences are most constant.
best: list[int] | None = None
for offset in (0, 1, -1, 2, -2, 3, -3):
a = left[offset:] if offset > 0 else left
b = right[-offset:] if offset < 0 else right
diffs = [(one - two).days for one, two in zip(a, b)]
if diffs and (best is None or max(diffs) - min(diffs) < max(best) - min(best)):
best = diffs
diffs = best or []
if not diffs:
return None
return {
"crossed_nakshatra": False,
"per_minute": abs(statistics.median(diffs)) / minutes,
"spread_days": max(diffs) - min(diffs),
"raw_misaligned": misaligned,
"boundaries": len(diffs),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--samples", type=int, default=SAMPLES)
parser.add_argument("--repo-samples", type=int, default=REPO_SAMPLES)
parser.add_argument("--no-write", action="store_true")
args = parser.parse_args()
swe.set_sid_mode(swe.SIDM_RAMAN)
instants = sample_instants(args.samples, SEED)
analytic: list[float] = []
finite: list[float] = []
by_lord: dict[str, list[float]] = {lord: [] for lord in VIM_LORDS}
speeds: list[float] = []
for jd in instants:
lon, speed = moon(jd)
speeds.append(speed)
value = shift_days_per_minute(lon, speed)
analytic.append(value)
by_lord[nakshatra_lord(lon)].append(value)
lon_next, _ = moon(jd + 1 / 1440.0)
if int(lon // NAKSHATRA_SPAN) == int(lon_next // NAKSHATRA_SPAN):
# boundary = birth + balance; birth itself moves +1 minute.
finite.append(abs(balance_days(lon_next) + 1 / 1440.0 - balance_days(lon)))
repo_rows = []
crossed = 0
for jd in instants[: args.repo_samples]:
row = repo_shift(jd, 20)
if row is None:
continue
if row.get("crossed_nakshatra"):
crossed += 1
continue
repo_rows.append(row)
repo_values = [row["per_minute"] for row in repo_rows]
p10, p50, p90 = (percentile(analytic, q) for q in (10, 50, 90))
lo_bound, hi_bound = min(analytic), max(analytic)
gate_minutes = {}
for label, days in (("gate_45", MIN_BOUNDARY_DAYS), ("refresh_30", REFRESH_MIN_BOUNDARY_DAYS)):
gate_minutes[label] = distribution([minutes_for_gap(days, s) for s in analytic], 1)
window_rows = []
for minutes in WINDOWS:
gaps = [minutes * s for s in analytic]
same_year_pass = sum(1 for g in gaps if g >= MIN_BOUNDARY_DAYS) / len(gaps)
window_rows.append({
"candidate_gap_minutes": minutes,
"boundary_gap_days": distribution(gaps, 1),
"share_ge_45_days": round(same_year_pass, 3),
"share_ge_30_days": round(sum(1 for g in gaps if g >= REFRESH_MIN_BOUNDARY_DAYS) / len(gaps), 3),
"old_claim_days": round(minutes * OLD_CLAIM_DAYS_PER_MINUTE, 1),
})
# How close can two candidates be for evidence of a given precision to
# separate them? The event date must fall between the two boundary dates.
# Guaranteed separation needs a boundary gap of about the precision span;
# below that, separation happens only when the gap straddles a calendar
# edge, with probability ~ gap / span (uniform boundary position).
precision_rows = []
for label, span_days in (
("day_exact", 1.0),
("day_pm3", 7.0),
("day_pm7", 15.0),
("month", MONTH_DAYS),
("gate_45_same_year", float(MIN_BOUNDARY_DAYS)),
("year", DAYS_PER_YEAR),
):
precision_rows.append({
"evidence": label,
"span_days": round(span_days, 2),
"min_separable_minutes_p90_shift": round(span_days / p90, 1),
"min_separable_minutes_median_shift": round(span_days / p50, 1),
"min_separable_minutes_p10_shift": round(span_days / p10, 1),
"min_separable_minutes_slowest": round(span_days / lo_bound, 1),
})
straddle = []
for minutes in (2, 6, 10, 20):
gap = minutes * p50
straddle.append({
"candidate_gap_minutes": minutes,
"boundary_gap_days_median": round(gap, 1),
"p_different_month": round(min(1.0, gap / MONTH_DAYS), 3),
"p_different_year": round(min(1.0, gap / DAYS_PER_YEAR), 3),
})
payload = {
"generated_at": "2026-09-26",
"nature": "fictional instants only; pure astronomy + repo Vimshottari helper",
"ayanamsa": "raman (swisseph SIDM_RAMAN)",
"dasha_year_days": DAYS_PER_YEAR,
"samples": len(instants),
"seed": SEED,
"old_claim": {
"days_per_minute": OLD_CLAIM_DAYS_PER_MINUTE,
"vimshottari_days_per_degree": OLD_CLAIM_DEG_DAYS,
"correct_mean_days_per_degree": round(120 * DAYS_PER_YEAR / 120, 2),
"moon_deg_per_minute_mean": round(statistics.fmean(speeds) / 1440.0, 5),
},
"analytic_days_per_minute": distribution(analytic),
"finite_difference_days_per_minute": distribution(finite),
"repo_vim_start_dates_days_per_minute": distribution(repo_values),
"repo_samples": len(repo_rows),
"repo_crossed_nakshatra_in_20_min": crossed,
"repo_max_spread_days": max((row["spread_days"] for row in repo_rows), default=None),
"repo_raw_zip_misaligned_share": round(
sum(1 for row in repo_rows if row["raw_misaligned"]) / max(len(repo_rows), 1), 3),
"by_lord": {
lord: {**distribution(values), "years": VIM_YEARS[lord]}
for lord, values in by_lord.items()
},
"moon_speed_deg_per_day": distribution(speeds, 3),
"theoretical_range_days_per_minute": {
"min": round(lo_bound, 2), "max": round(hi_bound, 2),
},
"gate_minutes": gate_minutes,
"window_table": window_rows,
"precision_table": precision_rows,
"straddle_table": straddle,
"command": "python3 scripts/research/dasha_shift_per_minute.py",
}
if not args.no_write:
REPORT_JSON.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({key: payload[key] for key in (
"analytic_days_per_minute", "finite_difference_days_per_minute",
"repo_vim_start_dates_days_per_minute", "repo_samples", "repo_crossed_nakshatra_in_20_min",
"repo_max_spread_days", "repo_raw_zip_misaligned_share", "gate_minutes",
"theoretical_range_days_per_minute", "old_claim")}, ensure_ascii=False, indent=1))
for row in window_rows:
print(row)
for row in precision_rows:
print(row)
for row in straddle:
print(row)
for lord, row in payload["by_lord"].items():
print(lord, row)
return 0
if __name__ == "__main__":
sys.exit(main())