feat(rectification): 出卡加精度门槛,补经历改成系统点名
Independent Staging Quality Gate / validate (push) Failing after 6m28s
Independent Staging Quality Gate / publish (push) Skipped

宽度超过 10 分钟或头名并列时不再出交付卡,改为按大运边界逐条问、
用类型芯片和年/月选择器录入。跳过的线换问法再问一次;答「这类事
都没有过」的不再问。用户说「没有了」仍立刻给目前范围。Skill 10.0.27。

BUG-740~743
This commit is contained in:
jesse-ux
2026-09-16 18:35:27 +08:00
parent 317e9f1886
commit cfb41daf3d
75 changed files with 4763 additions and 383 deletions
+3 -1
View File
@@ -218,7 +218,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
fingerprint = sha256({
key: value
for key, value in request.items()
if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "column_times", "refresh_probes"}
if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "declined_domains", "column_times", "refresh_probes"}
})
result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}"))
candidate_decisions = build_candidate_decisions(
@@ -517,6 +517,7 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
"precision_stage": {"current": "block_scan"},
"blocks": blocks,
"discriminating_event_probes": [],
"guided_collect_windows": [],
"acceptance_allowed": False,
"selection_allowed": False,
"display_allowed": False,
@@ -524,6 +525,7 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
**receipt,
"precision_stage": {"current": "block_scan"},
"discriminating_event_probes": [],
"guided_collect_windows": [],
"acceptance_allowed": False,
"selection_allowed": False,
},
+23 -1
View File
@@ -52,7 +52,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
})
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode", "asked_probe_keys", "column_times", "minute_step", "blocks",
"ayanamsa", "node_mode", "asked_probe_keys", "declined_domains", "column_times", "minute_step", "blocks",
"refresh_probes",
}) | _REQUEST_PROVENANCE_FIELDS
ASKED_PROBE_KEY_MAX_LENGTH = 200
@@ -169,6 +169,7 @@ class RectificationRequest(TypedDict):
timezone_source: NotRequired[str | None]
local_time_status: NotRequired[str | None]
asked_probe_keys: NotRequired[list[str]]
declined_domains: NotRequired[list[str]]
dropped_asked_probe_keys: NotRequired[int]
column_times: NotRequired[list[str]]
refresh_probes: NotRequired[bool]
@@ -357,6 +358,27 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
cleaned_request["asked_probe_keys"] = cleaned_keys
if dropped:
cleaned_request["dropped_asked_probe_keys"] = dropped
if "declined_domains" in body:
raw_domains = body.get("declined_domains")
if not isinstance(raw_domains, list) or len(raw_domains) > 20:
raise ValueError("declined_domains must contain between 0 and 20 strings")
cleaned_domains: list[str] = []
seen_domains: set[str] = set()
allowed = {
"education", "career", "relocation", "relationship",
"family", "finance", "health_pressure",
}
for index, item in enumerate(raw_domains):
if not isinstance(item, str) or not item.strip():
raise ValueError(f"declined_domains[{index}] must be a non-empty domain name")
domain = item.strip()
if domain not in allowed:
raise ValueError(f"declined_domains[{index}] is not a collect domain")
if domain in seen_domains:
continue
seen_domains.add(domain)
cleaned_domains.append(domain)
cleaned_request["declined_domains"] = cleaned_domains
if "column_times" in body:
raw_times = body.get("column_times")
if not isinstance(raw_times, list) or not 1 <= len(raw_times) <= 64:
+1
View File
@@ -755,6 +755,7 @@ def build_decision_receipt(
"precision_stage": packet["precision_stage"],
"oos_blind_prompts": packet["oos_blind_prompts"],
"discriminating_event_probes": packet.get("discriminating_event_probes") or [],
"guided_collect_windows": packet.get("guided_collect_windows") or [],
"event_clarification_probes": packet.get("event_clarification_probes") or [],
"evidence_collection_probes": packet.get("evidence_collection_probes") or [],
"candidate_contrast_opportunities": packet.get("candidate_contrast_opportunities") or [],
+197
View File
@@ -1779,6 +1779,203 @@ def discriminating_event_probes(
return probes
GUIDED_COLLECT_LIMIT = 6
GUIDED_DOMAIN_ORDER = (
"education",
"career",
"relocation",
"relationship",
"family",
"finance",
"health_pressure",
)
_GUIDED_TRACK_DOMAIN = {
"nara:d9": "relationship",
"nara:d10": "career",
}
def _guided_track_starts(
context: dict[str, Any],
*,
birth_date: str,
lo: int,
hi: int,
include_pratyantar: bool,
varga_narayana: bool,
vim_cache: dict[tuple[Any, ...], list[date]],
narayana_cache: dict[tuple[Any, ...], list[date] | None],
) -> dict[str, list[date]]:
tracks: dict[str, list[date]] = {}
moon = float(context["planet_longitudes"]["Moon"])
vim_key = (*_vim_cache_key(birth_date, moon, lo, hi), include_pratyantar)
if vim_key not in vim_cache:
vim_cache[vim_key] = _vim_start_dates(
birth_date, moon, lo, hi, include_pratyantar=include_pratyantar,
)
tracks["vim"] = vim_cache[vim_key]
natal_key = (*_narayana_cache_key(
int(context["ascendant_index"]), context["planet_longitudes"], birth_date, lo, hi,
), None)
if natal_key not in narayana_cache:
narayana_cache[natal_key] = _narayana_start_dates(
int(context["ascendant_index"]), context["planet_longitudes"], birth_date, lo, hi,
)
natal = narayana_cache[natal_key]
if natal is not None:
tracks["nara"] = natal
if not varga_narayana:
return tracks
for layer in ("d9", "d10"):
raw = _layer_value(context, layer)
if not isinstance(raw, int):
continue
layer_key = (*_narayana_cache_key(
raw, context["planet_longitudes"], birth_date, lo, hi,
), layer)
if layer_key not in narayana_cache:
narayana_cache[layer_key] = _narayana_start_dates(
raw, context["planet_longitudes"], birth_date, lo, hi,
)
dates = narayana_cache[layer_key]
if dates is not None:
tracks[f"nara:{layer}"] = dates
return tracks
def _guided_year_windows(dates: Sequence[date]) -> list[tuple[int, int, int]]:
by_year: dict[int, list[date]] = {}
for item in dates:
by_year.setdefault(item.year, []).append(item)
windows: list[tuple[int, int, int]] = []
for year in sorted(by_year):
months = [item.month for item in by_year[year]]
windows.append((year, min(months), max(months)))
return windows
def guided_collect_windows(
request: dict[str, Any],
built: dict[str, Any],
*,
candidate_times: Sequence[str],
today: date | None = None,
) -> list[dict[str, Any]]:
"""Boundary windows for guided collect. Does not change auto probe generation."""
birth_date = str(request.get("birth_date") or "").strip()
birth_year = _birth_year(birth_date)
if birth_year is None:
return []
try:
datetime.strptime(birth_date, "%Y-%m-%d")
except ValueError:
return []
remaining = _remaining_contexts(built, candidate_times)
if len(remaining) < 2:
remaining = _static_contexts(built)
work = [item for item in remaining if _scoreable(item)]
if len(work) < 2:
return []
declined = {
str(item).strip()
for item in (request.get("declined_domains") or [])
if str(item).strip() in GUIDED_DOMAIN_ORDER
}
now = today or date.today()
lo, hi = birth_year + 5, min(now.year, birth_year + 80)
remaining_layers = _differing_layers(work)
fallback = [
domain
for domain in _probe_domains(
remaining_layers,
[item for item in (request.get("events") or []) if isinstance(item, dict)],
d1_differs="d1" in remaining_layers,
)
if domain not in declined
]
if not fallback:
fallback = [domain for domain in GUIDED_DOMAIN_ORDER if domain not in declined]
if not fallback:
return []
vim_cache: dict[tuple[Any, ...], list[date]] = {}
narayana_cache: dict[tuple[Any, ...], list[date] | None] = {}
starts_by_time: dict[str, dict[str, list[date]]] = {}
for context in work:
time = _context_time(context)
if not time:
continue
starts_by_time[time] = _guided_track_starts(
context,
birth_date=birth_date,
lo=lo,
hi=hi,
include_pratyantar=True,
varga_narayana=True,
vim_cache=vim_cache,
narayana_cache=narayana_cache,
)
rows: list[dict[str, Any]] = []
seen: set[tuple[int, int, int, str]] = set()
unlayered_index = 0
for left, right in _representative_pairs(work):
left_time = str(_context_time(left) or "")
right_time = str(_context_time(right) or "")
left_tracks = starts_by_time.get(left_time) or {}
right_tracks = starts_by_time.get(right_time) or {}
for track in ("vim", "nara", "nara:d9", "nara:d10"):
left_dates = left_tracks.get(track) or []
right_dates = right_tracks.get(track) or []
if not left_dates or not right_dates:
continue
for index, (one, two) in enumerate(zip(left_dates, right_dates)):
if one == two:
continue
gathered: list[date] = []
for context in work:
time = _context_time(context)
dates = (starts_by_time.get(time or "") or {}).get(track) or []
if index < len(dates):
gathered.append(dates[index])
if len(gathered) < 2 or min(gathered) == max(gathered):
continue
ordered = sorted(gathered)
cut = ordered[0] + (ordered[-1] - ordered[0]) / 2
left_n = sum(1 for item in gathered if item <= cut)
right_n = len(gathered) - left_n
if left_n < 1 or right_n < 1:
continue
track_domain = _GUIDED_TRACK_DOMAIN.get(track)
if track_domain and track_domain not in declined:
domain = track_domain
else:
domain = fallback[unlayered_index % len(fallback)]
unlayered_index += 1
if domain in declined:
continue
for year, month_lo, month_hi in _guided_year_windows(gathered):
if year < lo or year > hi:
continue
key = (year, month_lo, month_hi, domain)
if key in seen:
continue
seen.add(key)
rows.append({
"year": year,
"month_lo": month_lo,
"month_hi": month_hi,
"domain": domain,
"split": {"left": left_n, "right": right_n},
})
rows.sort(key=lambda row: (
abs(row["split"]["left"] - row["split"]["right"]),
abs(now.year - int(row["year"])),
-int(row["year"]),
int(row["month_lo"]),
str(row["domain"]),
))
return rows[:GUIDED_COLLECT_LIMIT]
def prospective_windows_for_time(
request: dict[str, Any],
built: dict[str, Any],
@@ -688,6 +688,7 @@ def build_refinement_packet(
"precision_stage": {"current": "block_scan"},
"oos_blind_prompts": [],
"discriminating_event_probes": [],
"guided_collect_windows": [],
"event_clarification_probes": [],
"evidence_collection_probes": [],
"candidate_contrast_opportunities": [],
@@ -708,6 +709,7 @@ def build_refinement_packet(
discriminating_event_probe_set,
event_clarification_probes,
evidence_collection_probes,
guided_collect_windows,
prospective_event_windows,
)
grid_times = list(built.get("candidate_times") or candidate_times)
@@ -731,6 +733,11 @@ def build_refinement_packet(
)
probes = bundle["probes"]
dropped = list(bundle["dropped"])
windows = guided_collect_windows(
request,
built,
candidate_times=probe_times,
)
clarification = event_clarification_probes(request)
collection = evidence_collection_probes(request)
if probe_times == grid_times:
@@ -783,6 +790,7 @@ def build_refinement_packet(
"precision_stage": stage,
"oos_blind_prompts": oos_blind_prompts(request),
"discriminating_event_probes": probes,
"guided_collect_windows": windows,
"event_clarification_probes": clarification,
"evidence_collection_probes": collection,
"candidate_contrast_opportunities": opportunities,
+1
View File
@@ -16,3 +16,4 @@ MAX_EXTERNAL_VALIDATION_WIDTH_MINUTES: Final = int(POLICY["maxExternalValidation
MAX_CONFIRMATION_WIDTH_MINUTES: Final = int(POLICY["maxConfirmationWidthMinutes"])
MIN_CONFIRMATION_MARGIN_PERCENT: Final = int(POLICY["minConfirmationMarginPercent"])
MAX_PLATEAU_ROUNDS: Final = int(POLICY["maxPlateauRounds"])
DELIVERY_MAX_WIDTH_MINUTES: Final = int(POLICY["deliveryMaxWidthMinutes"])
@@ -0,0 +1,339 @@
#!/usr/bin/env python3
"""Offline T2 replay: after six probes, inject guided_collect_windows events.
Does not change production defaults. Writes a compact JSON summary for the
progress note. Not a merge gate.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
import time
import traceback
from calendar import monthrange
from datetime import date
from pathlib import Path
from typing import Any, Sequence
from uuid import NAMESPACE_URL, uuid5
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.active_rectification_event_engine import ( # noqa: E402
AYANAMSA,
NODE_MODE,
compute_candidate_static_contexts,
)
from scripts.rectification.event_probes import ( # noqa: E402
discriminating_event_probes,
guided_collect_windows,
)
from scripts.rectification.refinement_packet import window_scan # noqa: E402
from scripts.rectification.scoring_service import ( # noqa: E402
build_event_contribution_matrix,
score_from_matrix,
scoreable_request,
)
from scripts.rectification_policy import DELIVERY_MAX_WIDTH_MINUTES # noqa: E402
from scripts.research.cluster_width_lib import ( # noqa: E402
SEPARATION_LEAD,
delivery_from_public,
merge_adjacent_traced,
public_from_clusters,
raw_signature_clusters,
still_valid_public,
)
from scripts.research.cluster_width_probe import replay_public # noqa: E402
from scripts.research.minute_resolution_sweep import MINUTE_STEP, scoring_request_for # noqa: E402
from scripts.research.probe_supply_after_six import ASK_COUNT, KIND_BY_DOMAIN # noqa: E402
HOLDOUT = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v4.json"
REPORT_JSON = ROOT / "docs" / "research" / "guided_collect_holdout_2026_09_16.json"
TODAY = date(2026, 9, 16)
RADII = (10, 30, 60)
RANGE_DELIVERY_TIE_PERCENT = 3
def _hhmm(value: object) -> str | None:
text = str(value or "")[:5]
return text if len(text) == 5 and text[2] == ":" else None
def load_cases() -> list[dict[str, Any]]:
payload = json.loads(HOLDOUT.read_text(encoding="utf-8"))
return list(payload.get("cases") or [])
def synthetic_event(window: dict[str, Any], index: int) -> dict[str, Any]:
year = int(window["year"])
month = int(window["month_lo"])
last = monthrange(year, month)[1]
domain = str(window.get("domain") or "career")
kind = KIND_BY_DOMAIN.get(domain, "career_change")
stamp = f"{year:04d}-{month:02d}"
return {
"id": str(uuid5(NAMESPACE_URL, f"guided-collect:{index}:{domain}:{stamp}")),
"domain": domain,
"event_kind": kind,
"date_start": f"{stamp}-01",
"date_end": f"{stamp}-{last:02d}",
"precision": "month",
"summary": f"guided {domain} {stamp}",
}
def posterior_state(
*,
rows: Sequence[dict[str, Any]],
contexts: Sequence[dict[str, Any]],
probes: Sequence[dict[str, Any]],
true_time: str,
) -> dict[str, Any]:
raw = raw_signature_clusters(contexts)
by_time = {stamp: row for row in rows if (stamp := _hhmm(row.get("time")))}
merged, _trace = merge_adjacent_traced(raw, by_time)
public = public_from_clusters(merged, rows)
prior = {stamp: float(row.get("score") or 0) for row in public if (stamp := _hhmm(row.get("time")))}
replay = replay_public(probes=probes, public=public, prior=prior, true_time=true_time)
posterior = []
eliminated = set(replay["eliminated"])
scores = dict(replay["scores"])
for row in public:
stamp = _hhmm(row.get("time"))
if not stamp:
continue
posterior.append({**row, "score": scores.get(stamp, row.get("score") or 0)})
valid = still_valid_public(posterior, scores, eliminated, lead=SEPARATION_LEAD)
delivery = delivery_from_public(valid)
return {
"scores": scores,
"eliminated": eliminated,
"public": posterior,
"valid": valid,
"delivery": delivery,
"merged": merged,
}
def precision_gate(valid: Sequence[dict[str, Any]], scores: dict[str, float]) -> dict[str, Any]:
delivery = delivery_from_public(valid)
width = delivery.get("width")
ranked = sorted(
valid,
key=lambda row: (
-float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)),
str(row.get("time") or ""),
),
)
if not ranked:
return {
"met": False,
"width": width,
"tied_for_first": False,
"gap": None,
"percents": [],
}
if len(ranked) == 1:
return {
"met": width is not None and width <= DELIVERY_MAX_WIDTH_MINUTES,
"width": width,
"tied_for_first": False,
"gap": None,
"percents": [100],
}
s0 = float(scores.get(_hhmm(ranked[0].get("time")) or "", ranked[0].get("score") or 0))
s1 = float(scores.get(_hhmm(ranked[1].get("time")) or "", ranked[1].get("score") or 0))
tied = s0 == s1
total = sum(
max(float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)), 0.0)
for row in ranked
)
percents = []
for row in ranked[:3]:
score = max(float(scores.get(_hhmm(row.get("time")) or "", row.get("score") or 0)), 0.0)
percents.append(round(score / total * 100) if total > 0 else 0)
gap = percents[0] - percents[1]
met = (
width is not None
and width <= DELIVERY_MAX_WIDTH_MINUTES
and gap > RANGE_DELIVERY_TIE_PERCENT
and not tied
)
return {
"met": met,
"width": width,
"tied_for_first": tied,
"gap": gap,
"percents": percents,
}
def remaining_times(state: dict[str, Any]) -> list[str]:
times: list[str] = []
seen: set[str] = set()
for row in state["valid"]:
stamp = _hhmm(row.get("time"))
if stamp and stamp not in seen and stamp not in state["eliminated"]:
seen.add(stamp)
times.append(stamp)
for member in row.get("cluster_times") or []:
clock = _hhmm(member)
if clock and clock not in seen and clock not in state["eliminated"]:
seen.add(clock)
times.append(clock)
return times
def evaluate_case(
case: dict[str, Any],
radius: int,
) -> dict[str, Any]:
true_time = str(case["birth"]["time"])[:5]
request = scoring_request_for(case, radius)
request["ayanamsa"] = AYANAMSA
request["node_mode"] = NODE_MODE
request["minute_step"] = MINUTE_STEP
static_contexts = compute_candidate_static_contexts(request)
built = build_event_contribution_matrix(request, static_contexts=static_contexts)
rows = score_from_matrix(request, built)
times = [stamp for row in rows if (stamp := _hhmm(row.get("time")))]
probes = discriminating_event_probes(
{**request, "refresh_probes": False, "asked_probe_keys": []},
built,
scan=window_scan(built),
candidate_times=times,
representative_time=true_time,
today=TODAY,
)
state = posterior_state(rows=rows, contexts=static_contexts, probes=probes[:ASK_COUNT], true_time=true_time)
gate = precision_gate(state["valid"], state["scores"])
if gate["met"]:
return {
"case_id": case.get("case_id"),
"radius": radius,
"events_needed": 0,
"windows": 0,
"met": True,
"after_six": gate,
}
remaining = remaining_times(state)
windows = guided_collect_windows(
request,
built,
candidate_times=remaining or times,
today=TODAY,
)
extras: list[dict[str, Any]] = []
last_gate = gate
for index, window in enumerate(windows):
extras.append(synthetic_event(window, index))
injected = {**request, "events": list(request["events"]) + extras}
rebuilt = build_event_contribution_matrix(
scoreable_request(injected),
static_contexts=static_contexts,
)
new_rows = score_from_matrix(scoreable_request(injected), rebuilt)
state = posterior_state(
rows=new_rows,
contexts=static_contexts,
probes=probes[:ASK_COUNT],
true_time=true_time,
)
last_gate = precision_gate(state["valid"], state["scores"])
if last_gate["met"]:
return {
"case_id": case.get("case_id"),
"radius": radius,
"events_needed": len(extras),
"windows": len(windows),
"met": True,
"after_six": gate,
"final": last_gate,
}
return {
"case_id": case.get("case_id"),
"radius": radius,
"events_needed": None,
"windows": len(windows),
"met": False,
"after_six": gate,
"final": last_gate,
}
def summarize(rows: Sequence[dict[str, Any]], radius: int) -> dict[str, Any]:
subset = [row for row in rows if row.get("radius") == radius and not row.get("error")]
needed = [int(row["events_needed"]) for row in subset if row.get("events_needed") is not None]
met = sum(1 for row in subset if row.get("met"))
return {
"radius": radius,
"n": len(subset),
"met": met,
"median_events_to_gate": statistics.median(needed) if needed else None,
"unmet": sum(1 for row in subset if not row.get("met")),
"errors": sum(1 for row in rows if row.get("radius") == radius and row.get("error")),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--radii", default="10,30,60")
parser.add_argument("--json-out", default=str(REPORT_JSON))
args = parser.parse_args()
radii = tuple(int(item) for item in str(args.radii).split(",") if item.strip())
cases = load_cases()
if args.limit:
cases = cases[: args.limit]
started = time.perf_counter()
rows: list[dict[str, Any]] = []
for case in cases:
for radius in radii:
label = f"{case.get('case_id')} ±{radius}"
try:
result = evaluate_case(case, radius)
rows.append(result)
print(
f"{label} needed={result.get('events_needed')} "
f"met={result.get('met')} windows={result.get('windows')}",
flush=True,
)
except Exception as exc: # noqa: BLE001
rows.append({
"case_id": case.get("case_id"),
"radius": radius,
"error": f"{type(exc).__name__}: {exc}",
"trace": traceback.format_exc(limit=8),
"met": False,
"events_needed": None,
})
print(f"{label} ERROR {type(exc).__name__}: {exc}", flush=True)
summaries = [summarize(rows, radius) for radius in radii]
payload = {
"generated_at": TODAY.isoformat(),
"holdout": str(HOLDOUT.relative_to(ROOT)).replace("\\", "/"),
"ask_count": ASK_COUNT,
"delivery_max_width_minutes": DELIVERY_MAX_WIDTH_MINUTES,
"tie_percent": RANGE_DELIVERY_TIE_PERCENT,
"elapsed_s": round(time.perf_counter() - started, 1),
"summaries": summaries,
"rows": [
{key: value for key, value in row.items() if key != "trace"}
for row in rows
],
"errors": [row for row in rows if row.get("error")],
}
out = Path(args.json_out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(summaries, ensure_ascii=False, indent=2), flush=True)
print(f"wrote {out} in {payload['elapsed_s']}s", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())