diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/dayonly.py b/docs/research/artifacts/rectification-width-funnel-2026-10-16/dayonly.py new file mode 100644 index 00000000..151d4109 --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/dayonly.py @@ -0,0 +1,32 @@ +import json, sys, random +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) +import karaka_interval as K +asc = json.load(open('asc_signs.json')) +cases = json.loads((K.WT/'references/real_case_calibration/minute_rectification_holdout_v5.json').read_text())['cases'] +shard, n = map(int, sys.argv[1].split('/')) +out = [] +def seg(cid, R, sel): + win = {int(k): v for k, v in asc[cid].items() if abs(int(k)) <= R*60} + if len(set(win.values())) < 2: return None + t = win[0]; p = {win[g] for g in sel} + return 'right' if p == {t} else 'tie' if t in p else 'wrong' +for i, c in enumerate(cases): + if i % n != shard: continue + evs = [e for e in c['events'] if e['precision'] == 'day'] + if len(evs) < 3: continue + for R in (10, 30, 60, 75): + for lv in (3, 4): + for km in ('P', 'PS'): + r = K.run_case(c, R, lv, km, [dict(e) for e in evs]) + rng = random.Random(f"day|{c['case_id']}|{R}|{lv}|{km}") + size = 1 if km == 'P' else 2 + pl = [] + for _ in range(30): + q = K.run_case(c, R, lv, km, [{**e, 'karaka_set': set(rng.sample(K.PLANETS, size))} for e in evs]) + pl.append((q['covered_tol'], q['width'], seg(c['case_id'], R, q['sel']))) + out.append(dict(case=c['case_id'], n_ev=len(evs), R=R, lv=lv, km=km, cov=r['covered'], cov_tol=r['covered_tol'], + width=r['width'], window=r['window'], all_expl=r['all_explained'], seg=seg(c['case_id'], R, r['sel']), + pl_cov_tol=sum(p[0] for p in pl)/30, pl_width=sum(p[1] for p in pl)/30, + pl_seg={k: sum(p[2] == k for p in pl)/30 for k in ('right', 'tie', 'wrong')})) +Path(f'day_shard{shard}.json').write_text(json.dumps(out)); print('DONE') diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel.py b/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel.py new file mode 100644 index 00000000..192e3447 --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel.py @@ -0,0 +1,67 @@ +"""Why does the delivered interval stay wide? Per-question funnel on v5 (research harness, static probe list).""" +import json, sys, statistics as st +from pathlib import Path +WT = Path('/workspace/Jyotisha/.worktrees/karaka-interval-20261016') +sys.path.insert(0, str(WT)); sys.path.insert(0, str(WT/'scripts')) +from scripts.research import cluster_width_probe as cwp +from scripts.research import probe_supply_after_six as ps +from scripts.research import cluster_width_lib as cl +cases = json.loads((WT/'references/real_case_calibration/minute_rectification_holdout_v5.json').read_text())['cases'] +shard, n = map(int, sys.argv[1].split('/')) +MAXQ = 12 +out = [] +def clock(t): h, m = map(int, t.split(':')); return h*60+m +for i, case in enumerate(cases): + if i % n != shard: continue + true_time = str(case['birth']['time'])[:5] + for R in (10, 30, 60): + req = cwp.scoring_request_for(case, R) + built = cwp.build_event_contribution_matrix(req) + rows = cwp.score_from_matrix(req, built) + contexts = list(built.get('static_contexts') or []) + window_times = [str(r['time'])[:5] for r in rows] + raw = cwp.raw_signature_clusters(contexts) + probes = cwp.discriminating_event_probes({**req, 'refresh_probes': False}, built, scan=cwp.window_scan(built), + candidate_times=list(window_times), representative_time=true_time, today=cwp.TODAY) + merged, _ = cwp.merge_adjacent_traced(raw, cwp._by_time_rows(rows), max_clusters=cwp.Variant('baseline').max_clusters, + max_peak_delta=cwp.Variant('baseline').max_peak_delta) + public = cwp.public_from_clusters(merged, rows) + reps = [str(r['time'])[:5] for r in public] + size = {str(r['time'])[:5]: len(r.get('cluster_times') or [1]) for r in public} + truth_rep = next((str(r['time'])[:5] for r in public if true_time in (r.get('cluster_times') or [str(r['time'])[:5]])), None) + prior = {t: float(r.get('score') or 0) for t, r in zip(reps, public)} + scores = dict(prior); conflicts = {t: 0 for t in reps}; elim = set() + prior_spread = max(prior.values()) - min(prior.values()) + prior_truth_gap = max(prior.values()) - prior.get(truth_rep, 0) + steps = [] + for q, probe in enumerate(probes[:MAXQ]): + alive = [t for t in reps if t not in elim] + yes, no = ps.outcome_groups(probe) + ans = ps.optimal_answer(probe, true_time) + sides = {} + for t in alive: + d = 'support' if t in yes else 'conflict' if t in no else ps.inherit_direction(t, yes, no) + sides[t] = d + truth_d = 'support' if ans == 'yes' else 'conflict' if ans == 'no' else 'neutral' + same = sum(size[t] for t in alive if sides[t] == truth_d) ; opp = sum(size[t] for t in alive if sides[t] not in (truth_d, 'neutral')) + neu = sum(size[t] for t in alive if sides[t] == 'neutral') + if ans is not None: + scores, conflicts, elim = ps.apply_answer(scores, conflicts, elim, probe, ans, reps) + valid = cl.still_valid_public(public, scores, elim, lead=8) + d = cl.delivery_from_public(valid) + steps.append(dict(q=q+1, answer=ans, same=same, opp=opp, neutral=neu, alive_min=sum(size[t] for t in reps if t not in elim), + span8=d['width'], set8=len(d['times']), domain=probe.get('domain') or probe.get('kind'))) + def deliver(lead, upto): + sc = dict(prior); cf = {t: 0 for t in reps}; el = set() + for probe in probes[:upto]: + a = ps.optimal_answer(probe, true_time) + if a: sc, cf, el = ps.apply_answer(sc, cf, el, probe, a, reps) + v = cl.still_valid_public(public, sc, el, lead=lead); d = cl.delivery_from_public(v) + alive = [t for t in reps if t not in el] + best = max(sc[t] for t in alive); leaders = [t for t in alive if sc[t] == best] + return dict(span=d['width'], set=len(d['times']), cov=true_time in d['times'], top1=leaders == [truth_rep]) + out.append(dict(case=case['case_id'], R=R, true=true_time, n_reps=len(reps), n_probes=len(probes), + prior_spread=prior_spread, prior_truth_gap=prior_truth_gap, steps=steps, + alt={f'L{lead}_Q{q}': deliver(lead, q) for lead in (8, 4, 2) for q in (6, 12)})) + print(case['case_id'], flush=True) +Path(f'funnel_shard{shard}.json').write_text(json.dumps(out)); print('DONE') diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel2.py b/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel2.py new file mode 100644 index 00000000..47e07d01 --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel2.py @@ -0,0 +1,68 @@ +"""Why does the delivered interval stay wide? Per-question funnel on v5 (research harness, static probe list).""" +import json, sys, statistics as st +from pathlib import Path +WT = Path('/workspace/Jyotisha/.worktrees/karaka-interval-20261016') +sys.path.insert(0, str(WT)); sys.path.insert(0, str(WT/'scripts')) +from scripts.research import cluster_width_probe as cwp +from scripts.research import probe_supply_after_six as ps +from scripts.research import cluster_width_lib as cl +cases = json.loads((WT/'references/real_case_calibration/minute_rectification_holdout_v5.json').read_text())['cases'] +shard, n = map(int, sys.argv[1].split('/')) +MAXQ = 12 +out = [] +def clock(t): h, m = map(int, t.split(':')); return h*60+m +for i, case in enumerate(cases): + if i % n != shard: continue + true_time = str(case['birth']['time'])[:5] + for R in (10, 30, 60): + req = cwp.scoring_request_for(case, R) + built = cwp.build_event_contribution_matrix(req) + rows = cwp.score_from_matrix(req, built) + contexts = list(built.get('static_contexts') or []) + window_times = [str(r['time'])[:5] for r in rows] + raw = cwp.raw_signature_clusters(contexts) + probes = cwp.discriminating_event_probes({**req, 'refresh_probes': False}, built, scan=cwp.window_scan(built), + candidate_times=list(window_times), representative_time=true_time, today=cwp.TODAY) + merged, _ = cwp.merge_adjacent_traced(raw, cwp._by_time_rows(rows), max_clusters=cwp.Variant('baseline').max_clusters, + max_peak_delta=cwp.Variant('baseline').max_peak_delta) + public = cwp.public_from_clusters(merged, rows) + reps = [str(r['time'])[:5] for r in public] + size = {str(r['time'])[:5]: len(r.get('cluster_times') or [1]) for r in public} + truth_rep = next((str(r['time'])[:5] for r in public if true_time in (r.get('cluster_times') or [str(r['time'])[:5]])), None) + prior = {t: float(r.get('score') or 0) for t, r in zip(reps, public)} + scores = dict(prior); conflicts = {t: 0 for t in reps}; elim = set() + prior_spread = max(prior.values()) - min(prior.values()) + prior_truth_gap = max(prior.values()) - prior.get(truth_rep, 0) + steps = [] + for q, probe in enumerate(probes[:MAXQ]): + alive = [t for t in reps if t not in elim] + yes, no = ps.outcome_groups(probe) + ans = ps.optimal_answer(probe, true_time) + sides = {} + for t in alive: + d = 'support' if t in yes else 'conflict' if t in no else ps.inherit_direction(t, yes, no) + sides[t] = d + truth_d = 'support' if ans == 'yes' else 'conflict' if ans == 'no' else 'neutral' + same = sum(size[t] for t in alive if sides[t] == truth_d) ; opp = sum(size[t] for t in alive if sides[t] not in (truth_d, 'neutral')) + neu = sum(size[t] for t in alive if sides[t] == 'neutral') + if ans is not None: + scores, conflicts, elim = ps.apply_answer(scores, conflicts, elim, probe, ans, reps) + valid = cl.still_valid_public(public, scores, elim, lead=8) + d = cl.delivery_from_public(valid) + steps.append(dict(q=q+1, answer=ans, same=same, opp=opp, neutral=neu, alive_min=sum(size[t] for t in reps if t not in elim), + span8=d['width'], set8=len(d['times']), domain=probe.get('domain') or probe.get('kind'))) + def deliver(lead, upto, flip=None): + sc = dict(prior); cf = {t: 0 for t in reps}; el = set() + for k, probe in enumerate(probes[:upto]): + a = ps.optimal_answer(probe, true_time) + if a and flip is not None and k == flip: a = 'no' if a == 'yes' else 'yes' + if a: sc, cf, el = ps.apply_answer(sc, cf, el, probe, a, reps) + v = cl.still_valid_public(public, sc, el, lead=lead); d = cl.delivery_from_public(v) + alive = [t for t in reps if t not in el] + best = max(sc[t] for t in alive); leaders = [t for t in alive if sc[t] == best] + return dict(span=d['width'], set=len(d['times']), cov=true_time in d['times'], top1=leaders == [truth_rep]) + out.append(dict(case=case['case_id'], R=R, true=true_time, n_reps=len(reps), n_probes=len(probes), + prior_spread=prior_spread, prior_truth_gap=prior_truth_gap, steps=steps, + alt={f'L{lead}_Q{q}_F{f}': deliver(lead, q, None if f < 0 else f) for lead in (8, 6, 5, 4) for q in (6, 12) for f in (-1, 0, 2, 4)})) + print(case['case_id'], flush=True) +Path(f'funnel2_shard{shard}.json').write_text(json.dumps(out)); print('DONE') diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel_summary.json b/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel_summary.json new file mode 100644 index 00000000..584c3797 --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/funnel_summary.json @@ -0,0 +1,199 @@ +{ + "dataset": "minute_rectification_holdout_v5.json", + "staging_head": "9b7509155", + "harness": "cluster_width_probe static probe list, simulated correct answers; one-wrong = flip answer #1/#3/#5", + "cells": { + "pm10_lead8_q6": { + "n": 77, + "live_minutes_median": 14, + "span_median": 13, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.983, + "top1": 51 + }, + "pm10_lead8_q12": { + "n": 77, + "live_minutes_median": 12, + "span_median": 11, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.987, + "top1": 53 + }, + "pm10_lead6_q6": { + "n": 77, + "live_minutes_median": 10, + "span_median": 11, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.961, + "top1": 51 + }, + "pm10_lead6_q12": { + "n": 77, + "live_minutes_median": 10, + "span_median": 9, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.97, + "top1": 53 + }, + "pm10_lead5_q6": { + "n": 77, + "live_minutes_median": 10, + "span_median": 9, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.926, + "top1": 51 + }, + "pm10_lead5_q12": { + "n": 77, + "live_minutes_median": 10, + "span_median": 9, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.931, + "top1": 53 + }, + "pm10_lead4_q6": { + "n": 77, + "live_minutes_median": 8, + "span_median": 9, + "truth_in_all_correct": 74, + "truth_in_one_wrong_rate": 0.866, + "top1": 51 + }, + "pm10_lead4_q12": { + "n": 77, + "live_minutes_median": 8, + "span_median": 7, + "truth_in_all_correct": 74, + "truth_in_one_wrong_rate": 0.87, + "top1": 53 + }, + "pm30_lead8_q6": { + "n": 77, + "live_minutes_median": 26, + "span_median": 35, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.952, + "top1": 41 + }, + "pm30_lead8_q12": { + "n": 77, + "live_minutes_median": 22, + "span_median": 33, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.961, + "top1": 46 + }, + "pm30_lead6_q6": { + "n": 77, + "live_minutes_median": 20, + "span_median": 33, + "truth_in_all_correct": 75, + "truth_in_one_wrong_rate": 0.892, + "top1": 41 + }, + "pm30_lead6_q12": { + "n": 77, + "live_minutes_median": 18, + "span_median": 25, + "truth_in_all_correct": 76, + "truth_in_one_wrong_rate": 0.926, + "top1": 46 + }, + "pm30_lead5_q6": { + "n": 77, + "live_minutes_median": 16, + "span_median": 31, + "truth_in_all_correct": 73, + "truth_in_one_wrong_rate": 0.823, + "top1": 41 + }, + "pm30_lead5_q12": { + "n": 77, + "live_minutes_median": 14, + "span_median": 15, + "truth_in_all_correct": 75, + "truth_in_one_wrong_rate": 0.883, + "top1": 46 + }, + "pm30_lead4_q6": { + "n": 77, + "live_minutes_median": 12, + "span_median": 19, + "truth_in_all_correct": 72, + "truth_in_one_wrong_rate": 0.732, + "top1": 41 + }, + "pm30_lead4_q12": { + "n": 77, + "live_minutes_median": 12, + "span_median": 13, + "truth_in_all_correct": 75, + "truth_in_one_wrong_rate": 0.818, + "top1": 46 + }, + "pm60_lead8_q6": { + "n": 77, + "live_minutes_median": 42, + "span_median": 71, + "truth_in_all_correct": 75, + "truth_in_one_wrong_rate": 0.931, + "top1": 30 + }, + "pm60_lead8_q12": { + "n": 77, + "live_minutes_median": 38, + "span_median": 59, + "truth_in_all_correct": 75, + "truth_in_one_wrong_rate": 0.952, + "top1": 36 + }, + "pm60_lead6_q6": { + "n": 77, + "live_minutes_median": 32, + "span_median": 59, + "truth_in_all_correct": 75, + "truth_in_one_wrong_rate": 0.857, + "top1": 30 + }, + "pm60_lead6_q12": { + "n": 77, + "live_minutes_median": 28, + "span_median": 51, + "truth_in_all_correct": 75, + "truth_in_one_wrong_rate": 0.883, + "top1": 36 + }, + "pm60_lead5_q6": { + "n": 77, + "live_minutes_median": 28, + "span_median": 53, + "truth_in_all_correct": 73, + "truth_in_one_wrong_rate": 0.74, + "top1": 30 + }, + "pm60_lead5_q12": { + "n": 77, + "live_minutes_median": 22, + "span_median": 39, + "truth_in_all_correct": 74, + "truth_in_one_wrong_rate": 0.814, + "top1": 36 + }, + "pm60_lead4_q6": { + "n": 77, + "live_minutes_median": 22, + "span_median": 45, + "truth_in_all_correct": 68, + "truth_in_one_wrong_rate": 0.606, + "top1": 30 + }, + "pm60_lead4_q12": { + "n": 77, + "live_minutes_median": 16, + "span_median": 27, + "truth_in_all_correct": 70, + "truth_in_one_wrong_rate": 0.701, + "top1": 36 + } + } +} \ No newline at end of file diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/karaka_interval.py b/docs/research/artifacts/rectification-width-funnel-2026-10-16/karaka_interval.py new file mode 100644 index 00000000..b365e737 --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/karaka_interval.py @@ -0,0 +1,109 @@ +"""Author's method on v5: natural-karaka sub-period interval intersection. + +For each candidate birth moment (15 s grid inside truth±R), an event is 'explained' when a +Vimshottari period at level L ruled by the event's karaka overlaps the event's date span. +Delivered set = grid points explaining the maximum number of events (author tolerates +unexplained events). Truth is used only for the window centre (same as production replay) +and for scoring afterwards. +""" +import json, sys, random, calendar, statistics +from datetime import datetime, timedelta +from pathlib import Path +HERE = Path(__file__).parent +WT = Path('/workspace/Jyotisha/.worktrees/karaka-interval-20261016') +sys.path.insert(0, str(WT)); sys.path.insert(0, str(WT/'scripts')); sys.path.insert(0, str(HERE)) +import dasha_analyzer +from scripts.research.nadi_seconds_lib import sky_at, _timeline +from karaka_rules import classify +PLANETS = list(dasha_analyzer.DASHA_ORDER) +STEP_S = 15 + +def event_span(e): + if e['precision'] == 'day': + a = datetime.strptime(e['date'], '%Y-%m-%d'); return a, a + timedelta(days=1) + y, m = map(int, e['date'].split('-')[:2]) + a = datetime(y, m, 1); return a, a + timedelta(days=calendar.monthrange(y, m)[1]) + +def lords_overlapping(timeline, a, b, level): + """Set of lords at `level` (1=MD..4=SD) whose period overlaps [a,b).""" + periods = [p for p in timeline if p['start'] < b and p['end'] > a] + for _ in range(level - 1): + nxt = [] + for p in periods: + nxt += [q for q in dasha_analyzer.build_antardasha(dict(p)) if q['start'] < b and q['end'] > a] + periods = nxt + return {p['lord'] for p in periods} + +def moon_track(birth, radius): + base = datetime.strptime(f"{birth['date']} {birth['time'][:5]}", '%Y-%m-%d %H:%M') + knots = list(range(-radius - 10, radius + 11, 10)) + vals = [] + for k in knots: + vals.append(sky_at(birth, base + timedelta(minutes=k))) + def at(sec): + m = sec / 60.0 + i = min(max(int((m - knots[0]) // 10), 0), len(knots) - 2) + lo, hi = vals[i]['moon_lon'], vals[i + 1]['moon_lon'] + if hi < lo - 180: hi += 360 + f = (m - knots[i]) / 10.0 + return (lo + (hi - lo) * f) % 360.0, vals[i]['asc_index'] if f < .5 else vals[i+1]['asc_index'] + return base, at + +def run_case(case, radius, level, karaka_mode, events_override=None): + birth = case['birth'] + evs = events_override if events_override is not None else [e for e in case['events'] if e['precision'] in ('day', 'month')] + if not evs: return None + base, at = moon_track(birth, radius) + spans = [event_span(e) for e in evs] + ks = [] + for e in evs: + if 'karaka_set' in e: ks.append(e['karaka_set']); continue + _, p, s = classify(e['description']) + ks.append({p} if karaka_mode == 'P' else {p, s}) + grid = list(range(-radius * 60, radius * 60 + 1, STEP_S)) + counts = [] + for sec in grid: + moon, _ = at(sec) + bd = (base + timedelta(seconds=sec)).date().isoformat() + tl = _timeline(bd, moon) + counts.append(sum(bool(lords_overlapping(tl, a, b, level) & k) for (a, b), k in zip(spans, ks))) + best = max(counts) + sel = [g for g, c in zip(grid, counts) if c == best] + runs = 1 + sum(1 for x, y in zip(sel, sel[1:]) if y - x > STEP_S) + width_min = len(sel) * STEP_S / 60.0 + return {'n_events': len(evs), 'best': best, 'all_explained': best == len(evs), + 'covered': 0 in sel, 'width': width_min, 'window': (2 * radius * 60 + STEP_S) / 60.0, + 'runs': runs, 'span': (min(sel) / 60, max(sel) / 60), + 'center_err': abs((min(sel) + max(sel)) / 2 / 60.0), 'counts_truth': counts[grid.index(0)], + 'covered_tol': any(abs(g) <= 150 for g in sel), 'sel': sel} + +if __name__ == '__main__': + cases = json.loads((WT/'references/real_case_calibration/minute_rectification_holdout_v5.json').read_text())['cases'] + radii = [int(x) for x in sys.argv[1].split(',')] + shard, nshard = map(int, sys.argv[3].split('/')) + cases = [c for i, c in enumerate(cases) if i % nshard == shard] + configs = [(lv, km) for lv in (2, 3, 4) for km in ('P', 'PS')] + n_placebo = int(sys.argv[2]) if len(sys.argv) > 2 else 20 + out = [] + for case in cases: + for R in radii: + for lv, km in configs: + r = run_case(case, R, lv, km) + if r is None: continue + # placebo: random karaka per event, same set size + pc = [] + rng = random.Random(f"{case['case_id']}|{R}|{lv}|{km}") + evs = [e for e in case['events'] if e['precision'] in ('day', 'month')] + size = 1 if km == 'P' else 2 + for _ in range(n_placebo): + ov = [{**e, 'karaka_set': set(rng.sample(PLANETS, size))} for e in evs] + q = run_case(case, R, lv, km, ov) + pc.append({'covered': q['covered'], 'covered_tol': q['covered_tol'], 'width': q['width']}) + r.update(case_id=case['case_id'], radius=R, level=lv, karaka=km, + placebo_cov=sum(p['covered'] for p in pc) / len(pc), + placebo_width=statistics.mean(p['width'] for p in pc), + placebo_cov_tol=sum(p['covered_tol'] for p in pc) / len(pc)) + out.append(r) + print(case['case_id'], flush=True) + (HERE / f"karaka_shard{shard}.json").write_text(json.dumps(out, indent=0, default=list)) + print('DONE') diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/karaka_rules.py b/docs/research/artifacts/rectification-width-funnel-2026-10-16/karaka_rules.py new file mode 100644 index 00000000..1de1f990 --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/karaka_rules.py @@ -0,0 +1,36 @@ +"""Pre-registered natural-karaka table (frozen before any run against truth). + +Event -> one primary karaka (author's method: Mercury=exam/maths, Mars=surgery, Ketu=separation). +Rules are ordered; first match wins. Classical natural significations (BPHS / common usage). +""" +import re +RULES = [ + # (tag, regex, primary karaka, secondary karaka) + ("native_death", r"^(died|death|was killed|killed)\b|assassinat", "Saturn", "Mars"), + ("father_event", r"\bfather\b", "Sun", "Saturn"), + ("mother_event", r"\bmother\b", "Moon", "Saturn"), + ("sibling_event", r"\b(brother|sister|sibling)", "Mars", "Saturn"), + ("spouse_death", r"\b(husband|wife)\b.*\b(died|death|killed)", "Venus", "Saturn"), + ("child_birth", r"\b(son|daughter|child|children|twins|baby)\b.*\b(born|birth)|\bbirth of\b|\bgave birth", "Jupiter", "Moon"), + ("separation", r"divorc|separat|break ?up|split|annul|estrang|ended (his|her|their) (relationship|marriage)", "Ketu", "Saturn"), + ("marriage", r"marri|wed|engag|relationship|romance|dating|partner", "Venus", "Jupiter"), + ("finance", r"bankrupt|debt|default|loan|bought|purchas|sold|earn|fortune|forbes|million|deal|invest|franchise|wealth|donat|prize money|inherit", "Jupiter", "Rahu"), + ("award", r"award|prize|oscar|grammy|emmy|medal|honou?r|nominat|nobel|\bwon\b|\bwins?\b|champion|title", "Sun", "Jupiter"), + ("authority", r"elect|president|prime minister|minister|appoint|named|crown|king|queen|governor|senat|deput|chair|director of|head of|knight|ambassador", "Sun", "Saturn"), + ("surgery_injury", r"surg|injur|accident|crash|broke|fractur|wound|burn|operation|amputat|shot\b|stab", "Mars", "Saturn"), + ("illness", r"\bill(ness)?\b|sick|hospital|cancer|stroke|disease|diagnos|tumou?r|polio|addict|overdose|heart|depress|breakdown|tubercul|infect|rehab", "Saturn", "Rahu"), + ("legal_confinement", r"arrest|prison|jail|convict|sentenc|exile|lawsuit|trial|court|indict|charged|impeach|deport|coup|ousted|resign", "Saturn", "Rahu"), + ("higher_degree", r"doctor of|doctorate|phd|ph\.d|juris|law degree|m\.b\.|master'?s degree|postgraduate|dissertation", "Jupiter", "Mercury"), + ("education", r"graduat|school|universit|college|academ|degree|stud(y|ied)|exam|enrol|attend|educat|conservator|institut|lycée|seminar", "Mercury", "Jupiter"), + ("relocation", r"moved|emigrat|immigrat|relocat|settled|returned|migrat|fled|left for|sent to", "Rahu", "Moon"), + ("writing", r"publish|wrote|writ|book|novel|essay|poem|journal", "Mercury", "Jupiter"), + ("arts", r"film|movie|starr|act(ed|ing|ress)|debut|premier|perform|sing|song|album|record|concert|tour|model|show|music|paint|exhibit|danc|theat", "Venus", "Mercury"), + ("career_default", r".", "Saturn", "Sun"), +] +COMPILED = [(t, re.compile(rx, re.I), p, s) for t, rx, p, s in RULES] + +def classify(description: str): + for tag, rx, primary, secondary in COMPILED: + if rx.search(description or ""): + return tag, primary, secondary + raise AssertionError diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/prod_per_case.py b/docs/research/artifacts/rectification-width-funnel-2026-10-16/prod_per_case.py new file mode 100644 index 00000000..b65abc5e --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/prod_per_case.py @@ -0,0 +1,36 @@ +"""Per-case production replay (baseline variant) on v5, current staging code.""" +import json, sys, time, traceback +from pathlib import Path +WT = Path('/workspace/Jyotisha/.worktrees/karaka-interval-20261016') +sys.path.insert(0, str(WT)); sys.path.insert(0, str(WT/'scripts')) +from scripts.research import cluster_width_probe as cwp +HOLD = WT/'references/real_case_calibration/minute_rectification_holdout_v5.json' +cases = json.loads(HOLD.read_text())['cases'] +radii = [int(r) for r in (sys.argv[1] if len(sys.argv)>1 else '10,30,60').split(',')] +out = [] +variant = cwp.Variant('baseline') +t0=time.time() +for case in cases: + cid = case['case_id']; true_time = str(case['birth']['time'])[:5] + for radius in radii: + try: + request = cwp.scoring_request_for(case, radius) + built = cwp.build_event_contribution_matrix(request) + rows = cwp.score_from_matrix(request, built) + contexts = list(built.get('static_contexts') or []) + window_times = [str(r['time'])[:5] for r in rows] + raw = cwp.raw_signature_clusters(contexts) + probes = cwp.discriminating_event_probes({**request,'refresh_probes':False}, built, + scan=cwp.window_scan(built), candidate_times=list(window_times), + representative_time=true_time, today=cwp.TODAY) + s = cwp.score_variant(raw_clusters=raw, rows=rows, variant=variant, true_time=true_time, + window_times=window_times, probes=probes) + r = s['replay'] + out.append({'case_id':cid,'radius':radius,'true':true_time, + 'engine':{k:s['engine'][k] for k in ('top1_hit','coverage','width')}, + 'replay':{k:r.get(k) for k in ('top1_hit','coverage','width','asked','eliminated_count','union_alive_width')}}) + except Exception as e: + out.append({'case_id':cid,'radius':radius,'error':f'{type(e).__name__}: {e}','trace':traceback.format_exc()[-800:]}) + print(cid, round(time.time()-t0), flush=True) + Path('prod_per_case.json').write_text(json.dumps(out, ensure_ascii=False, indent=1)) +print('DONE') diff --git a/docs/research/artifacts/rectification-width-funnel-2026-10-16/seg_placebo.py b/docs/research/artifacts/rectification-width-funnel-2026-10-16/seg_placebo.py new file mode 100644 index 00000000..0b7b3c42 --- /dev/null +++ b/docs/research/artifacts/rectification-width-funnel-2026-10-16/seg_placebo.py @@ -0,0 +1,28 @@ +import json, sys, random +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent)) +import karaka_interval as K +asc = json.load(open('asc_signs.json')) +cases = json.loads((K.WT/'references/real_case_calibration/minute_rectification_holdout_v5.json').read_text())['cases'] +shard, n = map(int, sys.argv[1].split('/')) +out = [] +def outcome(cid, R, sel): + win = {int(k): v for k, v in asc[cid].items() if abs(int(k)) <= R*60} + if len(set(win.values())) < 2: return None + t = win[0]; p = {win[g] for g in sel} + return 'right' if p == {t} else 'tie' if t in p else 'wrong' +for i, c in enumerate(cases): + if i % n != shard: continue + evs = [e for e in c['events'] if e['precision'] in ('day', 'month')] + for R in (30, 60, 75): + for lv in (3, 4): + o = outcome(c['case_id'], R, K.run_case(c, R, lv, 'PS')['sel']) + if o is None: continue + rng = random.Random(f"seg|{c['case_id']}|{R}|{lv}") + pl = [] + for _ in range(40): + ov = [{**e, 'karaka_set': set(rng.sample(K.PLANETS, 2))} for e in evs] + pl.append(outcome(c['case_id'], R, K.run_case(c, R, lv, 'PS', ov)['sel'])) + out.append(dict(case=c['case_id'], R=R, lv=lv, real=o, pl={k: pl.count(k)/40 for k in ('right','tie','wrong')})) +Path(f'seg_shard{shard}.json').write_text(json.dumps(out)) +print('DONE') diff --git a/docs/research/rectification_width_funnel_2026_10_16.md b/docs/research/rectification_width_funnel_2026_10_16.md new file mode 100644 index 00000000..1f2aae33 --- /dev/null +++ b/docs/research/rectification_width_funnel_2026_10_16.md @@ -0,0 +1,71 @@ +# 生时校正:交付区间为什么宽 + 「象征星区间求交」对照(2026-10-16) + +基线 `origin/staging` @ `9b7509155`。数据集 `references/real_case_calibration/minute_rectification_holdout_v5.json`(77 例公开 AA 名人)。脚本在 `docs/research/artifacts/rectification-width-funnel-2026-10-16/`(脚本里 `WT` 指向当时的只读工作树,复跑时改成本地检出路径)。纯研究,不改线上。 + +## 1. 起因 + +产品 10-16 转来一篇外部文章:用自然象征星(Karaka)所主小运求区间交集,把单个案例从 150 分钟收到 3 分钟。产品要求按该方法跑一遍,并与本仓校正对比。之后产品追问:本仓交付 75 分钟,等于没校正,问题在哪。 + +## 2. 象征星区间求交:复现结论 + +做法:用关键词规则把事件映射到象征星(`karaka_rules.py`,先计算 sha256 冻结再跑)。只用日或月精度事件(387 件)。网格 15 秒,取「解释事件数最多」的时刻集合,允许丢掉解释不了的事件,与原文一致。对照组是随机象征星,每例 30 次。 + +| 设置(起始 ±75 分钟) | 交付中位 | 真值在内 | 随机象征星 | +|---|---|---|---| +| 第三层小运 | 17 分钟 | 15/77 | 15.0/77 | +| 第四层小运 | 5 分钟 | 3/77 | 3.4/77 | + +- 只用日精度事件(≥3 件的 43 例):16 组设置无一显著好于随机。第四层还略差于随机(z 低至 −2.2)。 +- 上升星座二选一:第三层小运 z≈1.3,三档窗口方向一致但样本重叠,不显著;第四层无信号。 +- 文章称「1 分钟 = 4.63 天」,只对其个案成立。77 例实测 1.36–5.60 天,中位 3.84 天。 +- 同宽度对比(±60,同为约 4 分钟一段):文章方法 1~2/43,随机划段约 1.2/43,本仓头名簇 16/43。 + +结论:该方法在分钟级上的命中与随机无异,不立单。与 `rectification_nadi_seconds_2026_10_05.md` 同属「更细层小运 + 日精度」路线,同样不过。 + +## 3. 本仓交付区间宽的拆解 + +方法:`funnel.py` / `funnel2.py`,cluster_width_probe 研究回放。说明三点:题目列表一次性生成,不中途刷新;回答按真值模拟(全对);「答错一题」为分别翻转第 1、3、5 题。候选步长 2 分钟,「实际剩余」等于活着的候选戳数 × 2。 + +### 3.1 交付按段内外包络显示 + +`scripts/rectification/candidate_window.py::interval_union_width` 与前端 `unionWindowIntervals` 按 `segment_index` 合并。`segment_index` 是用户声明的窗口段,不是候选簇,所以同一窗口内被排除的空档也被包进去了。 + +| 窗口 | 现在显示(最早–最晚) | 实际剩余分钟 | +|---|---|---| +| ±10 | 13 | 14 | +| ±30 | 35 | 26 | +| ±60 | 71 | 42 | + +### 3.2 题目切不动 + +- 供给:可出题中位 7~8 道。±10 窗口 30/77 例不足 6 道,12 例为 0 道。 +- 切分:Q1–Q2 约 55:45;到 Q5–Q6,真值一侧已占 76–90%,一道题只排掉一两成。 +- 题数从 6 加到 12(静态列表,题够的例子才能问到):±60 实际剩余 42→38 分钟,头名 30→36。 + +### 3.3 进区间门槛 8 分 = 至少两道题反向 + +一道题拉开 4 分(+2 / −2),所以一个候选至少要两道题都站错边才出区间。降门槛的代价是答错一题时容易把真值挤出去: + +| 窗口 | 门槛 / 题数 | 实际剩余 | 全答对真值在内 | 答错一题真值在内 | +|---|---|---|---|---| +| ±10 | 8 / 6(现状) | 14 | 76/77 | 98% | +| ±10 | 6 / 12 | 10 | 76/77 | 97% | +| ±10 | 4 / 12 | 8 | 74/77 | 87% | +| ±30 | 8 / 6(现状) | 26 | 76/77 | 95% | +| ±30 | 6 / 12 | 18 | 76/77 | 93% | +| ±30 | 5 / 12 | 14 | 75/77 | 88% | +| ±30 | 4 / 12 | 12 | 75/77 | 82% | +| ±60 | 8 / 6(现状) | 42 | 75/77 | 93% | +| ±60 | 8 / 12 | 38 | 75/77 | 95% | +| ±60 | 6 / 12 | 28 | 75/77 | 88% | +| ±60 | 4 / 12 | 16 | 70/77 | 70% | + +全表见 `funnel_summary.json`。门槛 4 在 ±30 / ±60 下,答错一题就挤掉两到三成真值,不可用。门槛 6 是折中。 + +### 3.4 物理下限 + +出生时间差 1 分钟,大运边界平均只平移约 3.8 天。用户记忆以月为单位,出题闸 `REFRESH_MIN_BOUNDARY_DAYS=30` 相当于约 8 分钟。所以靠年月问答,最好约 10 分钟,到不了 3–4 分钟。日精度加更细层的路线已两次证伪(纳迪 10-05、象征星本文)。 + +## 4. 去向 + +实现单 `docs/tasks/TASK-rectification-narrower-delivery-20261016.md`:分段显示、按剩余候选对半出题并放宽题数、交付区间门槛 8→6(`MIN_SEPARATION_LEAD` 不动)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1ca3b98d..3f1e557b 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -36,6 +36,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| `TASK-rectification-narrower-delivery-20261016.md` | — | **交付区间收窄**:显示改按活着的簇分段(±60 显示 71→实际 42 分钟)、按剩余候选对半出题并放宽到 12 轮(少数侧 <20% 不出题)、新增 `DELIVERY_RANGE_LEAD=6` 只管进区间(`MIN_SEPARATION_LEAD=8` 不动,写读同口径)。红线:PG 回放真值保留全对 ≥基线−1、答错一题 ≥基线−5pp,不过则该档回 8。证据 `docs/research/rectification_width_funnel_2026_10_16.md`(含象征星区间求交复现=随机)。BUG 1367 起 | 待领取 | — | | —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) | | —(子代理直接执行,无任务书;产品 2026-09-27 授权) | `PROGRESS-bug-1061-cross-layer-pairing-20260927.md` | **双重过运跨层配对按名字数字比对(BUG-1061)**:改为结构化身份配对(D1 第 N 宫↔D9 第 N 宫;D1 第 N 宫主↔同一颗星的 D9 目标);各层结果与 D1/CL golden 逐字节不变;48 样本跨层条目 31→38;数据卡 golden 重采集(乔布斯 6→9、泰勒 9→10) | 已验收(待部署) | `codex/bug-1061-cross-layer-pairing-20260927`(未推送) | | `TASK-rectification-fewer-probes-card-20260926.md` | [PROGRESS](PROGRESS-rectification-fewer-probes-card-20260926.md) | **减少无效追问 + 卡片区间为主**:引导补件离线新增达标 0 → 上限 6→2、定向题问完门槛未达直接出卡;卡片区间为主标题、代表分钟副标题,第一二名差距 ≥5 个百分点才显示百分比,否则写「目前区分不开」。不放宽任何置信度。先做 | 已验收(真机清单欠) | `4e6e8d87` + 验收补改 `7203d94e`(区分不开时隐藏「最可能」),deploy-staging run 2933 已部署;回放真值 ±30/±60 19→20、宽度中位 ±1 分钟、提问 11.4→7.8;Skill 10.0.30;真机清单 `docs/testing/rectification-fewer-probes-card-20260926.md` 未走 | diff --git a/docs/tasks/TASK-rectification-narrower-delivery-20261016.md b/docs/tasks/TASK-rectification-narrower-delivery-20261016.md new file mode 100644 index 00000000..0833e031 --- /dev/null +++ b/docs/tasks/TASK-rectification-narrower-delivery-20261016.md @@ -0,0 +1,89 @@ +# TASK:生时校正交付区间收窄(分段显示 + 对半出题 + 交付门槛 8→6)2026-10-16 + +## 0. 基线 + +- `origin/staging` @ `9b7509155`(开工时以最新 `origin/staging` 为准,并在进度记录里写实测基线 SHA)。 +- 证据与脚本:`docs/research/rectification_width_funnel_2026_10_16.md`,以及 `docs/research/artifacts/rectification-width-funnel-2026-10-16/`(`funnel_summary.json` 是本单的预期数字来源)。 + +## 1. 事故实证 + +产品反馈:前后各 60 分钟的窗口问完题,交付约 75 分钟,用户觉得「跟没校正一样」。v5 回放(77 例、模拟答对)现状: + +| 窗口 | 显示宽度(中位) | 实际剩余分钟(中位) | 真值在区间 | 答错一题真值在区间 | +|---|---|---|---|---| +| ±10 | 13 | 14 | 76/77 | 98% | +| ±30 | 35 | 26 | 76/77 | 95% | +| ±60 | 71 | 42 | 75/77 | 93% | + +## 2. 根因(按符号定位) + +1. **显示按段内外包络。** `scripts/rectification/candidate_window.py::interval_union_width` 的 docstring 写着「Keep historical within-segment envelope」。前端 `frontend/src/lib/rectification-agentic/core/candidate-window.ts::unionWindowIntervals` 按 `segment_index` 把同一段内所有区间合成最早–最晚。`segment_index` 是用户声明的窗口段(只在跨午夜等情况下才有多段),不是候选簇。所以交付卡 `frontend/src/components/rectification-range-delivery.tsx` 的 `rangeLabel`(经 `windowIntervalsLabel`)把中间已出局的空档一起显示了出来。±60 窗口显示 71 分钟,实际只剩 42 分钟。 +2. **题目切不动、题数少。** 可出题中位只有 7~8 道(`scripts/rectification/event_probes.py`:`MAX_PROBES=8`、`REFRESH_MAX_PROBES=12`、`MAX_PROBES_PER_DOMAIN=3`)。Q5–Q6 时剩余候选中 76–90% 与真值同侧,一题只排除一两成。前端轮数上限是 `core/types.ts::DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8`。 +3. **进区间门槛 8 分。** `core/credible-range.ts::unionStillValidIntervals / unionStillValidRange / stillValidCandidates` 用 `MIN_SEPARATION_LEAD = 8`(`core/candidate-separation.ts`)。一道题只拉开 4 分(`SCORE_DELTA` ±2),所以一个候选至少要两道题都反向才出区间。 +4. **物理下限(不修)。** 1 分钟约 3.8 天,加上月级记忆和 `REFRESH_MIN_BOUNDARY_DAYS=30`,问答路线最好约 10 分钟。 + +## 3. 决策记录(产品 2026-10-16 授权) + +- **D1 交付分段显示,推翻「段内外包络」。** 用户可见的范围(交付卡标题、盘型交付 `RectificationSegmentDelivery` 的 `rangeLabel`、旁白和 spoken-grounding 里的范围句、报告里的范围、会话标题)一律按**实际活着的簇区间**显示。间隔 ≤4 分钟的相邻区间合并。合并后超过 3 段时,只显示最靠前的 3 段,再加「等 N 段,共约 X 分钟」。宽度统计(遥测 `width`、交付文案里的「约 X 分钟」)改用实际剩余分钟数。`interval_union_width` 等**决策层**的宽度口径本轮不改(收敛判定、档位、闸门仍按旧口径),在进度记录里列清所有调用点和各自采用的口径。 +- **D2 题数上限放宽 + 按剩余候选对半出题。** `DEFAULT_MAX_DISCRIMINATION_ROUNDS` 从 8 提到 12,探针供给相应放宽(`REFRESH_MAX_PROBES`、每域上限由执行方实测后定,写进进度记录)。每出一题都按当前未淘汰候选的后验质量选题,优先取两侧质量最接近 50:50 的。**少数一侧质量 < 20% 的题不出**,宁可停。这条和 09-26 fewer-probes「减少无效追问」不冲突:那一单管的是引导补件,本单只加能切开剩余候选的点选题。 +- **D3 交付区间门槛 8→6,`MIN_SEPARATION_LEAD` 不动。** 新增常量 `DELIVERY_RANGE_LEAD = 6`,只用于「谁进交付区间」:`credible_range`、`credible_intervals`、`stillValidCandidates` 的交付用途,以及 D1 的显示。下面这些继续用 `MIN_SEPARATION_LEAD = 8`:分离状态(`evaluateCandidateSeparation`)、采用 / 确认 / 熔断门、`segment-summary.ts` 的盘型段汇总(其档位按 8 校准)。 + - 这样不违反 BUG-560 的防复发条「不得改 `MIN_SEPARATION_LEAD=8`」,也不违反 BUG-423 的「不得用改 `MIN_SEPARATION_LEAD` 绕过死锁」。 + - 但 BUG-423 的教训必须遵守:**写盘与读盘的 `credible_range` / `credible_intervals` 必须用同一个 lead。** `inference-adapter.ts` 里 `consistent` 的比对两边都换成 `DELIVERY_RANGE_LEAD`,不得出现一边 8、一边 6。 +- **D4 窗口分档回退。** 验收回放里某一档(±10 / ±30 / ±60)在门槛 6 下过不了 §4 红线 2,该档就回到 8。分档写成一张表,挂在 `DELIVERY_RANGE_LEAD` 旁边。 +- **D5 口径。** 交付文案不得承诺「定到分钟」。可以写「剩余约 X 分钟,分 N 段」。 + +## 4. 硬红线 + +1. 不改引擎打分:`SCORE_DELTA`、`STRONG_CONFLICT_ELIMINATION_COUNT=3`、`MIN_BOUNDARY_DAYS=45` / `REFRESH_MIN_BOUNDARY_DAYS=30`、`_relative_support`、采用 / 确认门全部不动。不放宽任何置信度。 +2. **真值保留**:v5 77 例,用生产代码路径回放(PG 持久回放 `frontend/tests/research/rectification-segment-persisted-replay.ts` + `scripts/research/varga_resolution_persisted_replay.py`,按段选题开关保持默认)。每档同时满足两条: + - 全答对时,真值在交付区间内的例数 ≥ 基线 −1; + - 答错一题(分别翻转第 1、3、5 题)时,真值在区间内的比例 ≥ 基线 −5 个百分点。 + 基线在同一 harness 上用改动前代码实测,不用本单研究数字代替。 +3. 预期(研究回放,静态题单,仅作参照):门槛 6、12 题时实际剩余分钟 ±10 约 10、±30 约 18、±60 约 28。生产回放达不到也不得为了凑数改红线 1 的任何一项,如实报告即可。 +4. 前端按 AGENTS §7:tsc 0、lint 0 error、测试总数不减,既有断言改动写「原值 / 新值 / 原因」,`/` 保持 Static,gzip ±2%。 +5. 校正 Skill 若 bump,必须验证旧 Case 能从列表打开(BUG-621)。 +6. 不动数据库结构;遥测只是写入的数值换口径,若需新列,单独说明并跑 test:db。 + +## 5. 任务分解 + +- **T0 基线**:在开工 SHA 上跑 §4 红线 2 的回放,三档 × {全对, 翻 1/3/5},产出 `docs/research/rectification_narrower_delivery_baseline_2026_10_16.json`。 + - 验收:数字齐全;有可复跑的命令。 +- **T1 分段显示(D1)**:在 Python 与前端各加一个「活着的区间合并」函数(间隔 ≤4 分钟合并),只供显示与遥测使用;按 D1 列出的所有用户可见出口逐个切换;`windowIntervalsLabel` 支持「等 N 段」。 + - 验收: + - 单测覆盖三种情况:单段、跨空档两段、5 段截 3 段; + - 用 golden 中一例 ±60 回放得到的候选构造交付卡,标题段数与实际剩余分钟数和回放一致; + - 列出所有 `interval_union_width` / `unionWindowIntervals` 调用点及各自采用的口径; + - `frontend/DESIGN.md` 与 `frontend/docs/VOICE.md` 同步更新。 +- **T2 对半选题(D2)**:刷新探针时按剩余后验质量的对半程度排序,少数一侧 < 20% 的题不出;放宽轮数和供给上限。 + - 验收: + - 回放报告给出每题「真值同侧占比」中位,Q4–Q8 必须比基线更接近 0.5; + - 平均提问数与问满题的例数写进报告; + - 不出现「少数一侧 < 20%」的题(加断言)。 +- **T3 交付门槛(D3 / D4)**:新增 `DELIVERY_RANGE_LEAD` 及分档表,写盘、读盘同口径。 + - 验收: + - BUG-423 合同测试仍绿,并新增「写 6 读 6」的一致性测试; + - 分离状态、采用门、segment-summary 仍用 8 的断言; + - 回放过 §4 红线 2,否则按 D4 回退并在报告里写明是哪一档。 +- **T4 回放判定**:T1+T2+T3 合起来跑 §4 红线 2 的全套,结果写进 `docs/research/rectification_narrower_delivery_replay_2026_10_16.json`。 + - 验收:三档逐格对照基线,给出实际剩余分钟、显示宽度、头名命中、平均提问数。 +- **T5 记录**:写 `PROGRESS-rectification-narrower-delivery-20261016.md`、`BUG_HISTORY`(3 条:显示包络、选题不对半、交付门槛)、`CHANGELOG`;真机清单写到 `docs/testing/rectification-narrower-delivery-20261016.md`(±30 窗口完整走一次,看段数和题数)。 + +## 6. 让步顺序 + +时间不够时:先交 T0+T1(纯显示,风险最低,单独就能把 ±60 从 71 降到约 42),再交 T3,最后交 T2。任何一项过不了红线 2 就撤掉该项,不能为了保住其他项去放宽红线。 + +## 7. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-narrower-delivery-20261016 .worktrees/rectification-narrower-delivery-20261016 origin/staging +cd .worktrees/rectification-narrower-delivery-20261016 +grep -n "BUG-423\|BUG-560\|BUG-653\|BUG-1143\|BUG-1144" docs/BUG_HISTORY.md # 读完命中记录 +grep -rn "unionWindowIntervals\|interval_union_width\|unionStillValid\|MIN_SEPARATION_LEAD\|DEFAULT_MAX_DISCRIMINATION_ROUNDS" frontend/src scripts tests frontend/tests | wc -l +``` + +本机没有 Docker 时,PG 回放按 `docs/research/` 中盘型口径研究记录的「无 Docker 验收法」(预编译 PG17 + psql / docker 替身)跑。 + +## 8. BUG 编号起点 + +BUG-1367 起,开工时用 `docs/BUG_HISTORY.md` 的最大号核对一遍。