- 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
148 lines
5.3 KiB
Python
148 lines
5.3 KiB
Python
"""Pure helpers for the 2026-09-26 offline rectification research (R1/R2/R3).
|
|
|
|
Offline only. Nothing here is imported by production code, and nothing here
|
|
changes scoring, thresholds, probe gates or Skill text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import statistics
|
|
from itertools import combinations
|
|
from random import Random
|
|
from typing import Any, Iterable, Sequence
|
|
|
|
#: Vimshottari lords in nakshatra order starting at Ashwini, and their years.
|
|
VIM_LORDS: tuple[str, ...] = (
|
|
"Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury",
|
|
)
|
|
VIM_YEARS: dict[str, float] = {
|
|
"Ketu": 7.0, "Venus": 20.0, "Sun": 6.0, "Moon": 10.0, "Mars": 7.0,
|
|
"Rahu": 18.0, "Jupiter": 16.0, "Saturn": 19.0, "Mercury": 17.0,
|
|
}
|
|
NAKSHATRA_SPAN = 360.0 / 27.0
|
|
DAYS_PER_YEAR = 365.25 # scripts/dasha_analyzer.py build_dasha_timeline
|
|
FLIP_SEED = 20260926
|
|
PERCENTILES = (0, 10, 25, 50, 75, 90, 100)
|
|
|
|
|
|
def nakshatra_lord(moon_longitude: float) -> str:
|
|
index = int((float(moon_longitude) % 360.0) // NAKSHATRA_SPAN)
|
|
return VIM_LORDS[index % 9]
|
|
|
|
|
|
def shift_days_per_minute(moon_longitude: float, moon_speed_deg_per_day: float) -> float:
|
|
"""Days every Vimshottari boundary moves when birth time moves one minute.
|
|
|
|
The balance of the first dasha is (1 - progress) * years(lord). Every later
|
|
MD/AD/PD start is birth + balance + fixed durations, so all of them move by
|
|
the same amount: d(balance)/d(moon) * d(moon)/d(minute).
|
|
"""
|
|
years = VIM_YEARS[nakshatra_lord(moon_longitude)]
|
|
deg_per_minute = abs(float(moon_speed_deg_per_day)) / 1440.0
|
|
return deg_per_minute / NAKSHATRA_SPAN * years * DAYS_PER_YEAR
|
|
|
|
|
|
def percentile(values: Sequence[float], q: float) -> float | None:
|
|
"""Nearest-rank percentile on a sorted copy (q in 0..100)."""
|
|
items = sorted(float(item) for item in values)
|
|
if not items:
|
|
return None
|
|
if q <= 0:
|
|
return items[0]
|
|
if q >= 100:
|
|
return items[-1]
|
|
rank = int(round(q / 100.0 * (len(items) - 1)))
|
|
return items[rank]
|
|
|
|
|
|
def distribution(values: Sequence[float], digits: int = 2) -> dict[str, Any]:
|
|
items = [float(item) for item in values]
|
|
if not items:
|
|
return {"n": 0}
|
|
row: dict[str, Any] = {"n": len(items), "mean": round(statistics.fmean(items), digits)}
|
|
for q in PERCENTILES:
|
|
row[f"p{q}"] = round(float(percentile(items, q)), digits)
|
|
return row
|
|
|
|
|
|
def minutes_for_gap(days: float, shift_per_minute: float) -> float | None:
|
|
if shift_per_minute <= 0:
|
|
return None
|
|
return float(days) / float(shift_per_minute)
|
|
|
|
|
|
def answerable_indices(answers: Sequence[str | None]) -> list[int]:
|
|
return [index for index, answer in enumerate(answers) if answer in {"yes", "no"}]
|
|
|
|
|
|
def flip(answer: str | None) -> str | None:
|
|
return {"yes": "no", "no": "yes"}.get(str(answer), answer)
|
|
|
|
|
|
def flipped_answers(answers: Sequence[str | None], indices: Iterable[int]) -> list[str | None]:
|
|
chosen = set(indices)
|
|
return [flip(answer) if index in chosen else answer for index, answer in enumerate(answers)]
|
|
|
|
|
|
def sample_flip_sets(
|
|
answers: Sequence[str | None],
|
|
k: int,
|
|
repeats: int,
|
|
*,
|
|
case_id: str,
|
|
radius: int,
|
|
seed: int = FLIP_SEED,
|
|
) -> list[tuple[int, ...]]:
|
|
"""Fixed-seed random flip sets: `repeats` draws of k answered questions.
|
|
|
|
The RNG key includes case, radius, k and repeat index so every draw is
|
|
reproducible on any machine (string seeding is stable across processes).
|
|
"""
|
|
pool = answerable_indices(answers)
|
|
if k <= 0 or len(pool) < k:
|
|
return []
|
|
rows: list[tuple[int, ...]] = []
|
|
for repeat in range(int(repeats)):
|
|
rng = Random(f"{seed}:{case_id}:{radius}:{k}:{repeat}")
|
|
rows.append(tuple(sorted(rng.sample(pool, k))))
|
|
return rows
|
|
|
|
|
|
def exhaustive_flip_sets(answers: Sequence[str | None], k: int) -> list[tuple[int, ...]]:
|
|
pool = answerable_indices(answers)
|
|
if k <= 0 or len(pool) < k:
|
|
return []
|
|
return [tuple(item) for item in combinations(pool, k)]
|
|
|
|
|
|
def rate(rows: Sequence[dict[str, Any]], key: str) -> float | None:
|
|
if not rows:
|
|
return None
|
|
return round(sum(1 for row in rows if row.get(key)) / len(rows), 4)
|
|
|
|
|
|
def median_of(rows: Sequence[dict[str, Any]], key: str) -> float | None:
|
|
values = [row[key] for row in rows if row.get(key) is not None]
|
|
return statistics.median(values) if values else None
|
|
|
|
|
|
def all_pairs(reps: Sequence[dict[str, Any]], clock_of) -> list[tuple[dict[str, Any], dict[str, Any]]]:
|
|
"""Every unordered pair of representatives, sorted by clock (research variant)."""
|
|
ordered = sorted(reps, key=clock_of)
|
|
return [(ordered[i], ordered[j]) for i in range(len(ordered)) for j in range(i + 1, len(ordered))]
|
|
|
|
|
|
def zip_misaligned(left: Sequence[Any], right: Sequence[Any], slack_days: int = 3) -> bool:
|
|
"""True when `_boundary_windows`' positional zip pairs different boundaries.
|
|
|
|
For Vimshottari, two candidates' boundary lists are the same sequence
|
|
shifted by one constant number of days (see `shift_days_per_minute`). When
|
|
one list gains or loses an element at the lower year edge, zip() pairs
|
|
boundary i with boundary i+1 of the other candidate and the signed
|
|
differences stop being constant. `slack_days` absorbs date rounding.
|
|
"""
|
|
diffs = [(two - one).days for one, two in zip(left, right)]
|
|
if not diffs:
|
|
return False
|
|
return max(diffs) - min(diffs) > slack_days
|