fix(rectification): anchor candidate windows to civil dates across midnight
Independent Staging Quality Gate / validate (push) Successful in 13m27s
Independent Staging Quality Gate / publish (push) Failing after 1h0m1s

Carry explicit local date intervals instead of inferring the day from clock
order. Cluster width, delivery, adoption, and reports keep the actual civil
date; adopted date is stored separately from the reported birth_date.

Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation
thresholds, and Skill version are unchanged. Isolated Linux final-3 gates
passed; four pre-existing Python failures remain. This is not a production
release.
This commit is contained in:
jesse-ux
2026-09-21 02:55:00 +08:00
parent 3be740f84d
commit b85c4a686a
115 changed files with 106484 additions and 315 deletions
+16
View File
@@ -48,6 +48,17 @@ def _report_candidate_range(
for row in candidate_scores
if top_score is not None and float(row.get("score") or 0) == top_score
]
if "candidate_intervals" in request:
from scripts.rectification.candidate_window import candidate_positions, enumerate_candidate_window, intervals_from_positions, interval_union_width
positions = candidate_positions(request, enumerate_candidate_window(request))
selected = [row for row in positions if not top_times or row["time"] in top_times]
parts = intervals_from_positions(selected)
return {
"start_time": selected[0]["time"], "end_time": selected[-1]["time"],
"candidate_intervals": [{"start_at": part["start_at"], "end_at": part["end_at"]} for part in parts],
"representative_time": representative_time or selected[len(selected) // 2]["time"],
"width_minutes": interval_union_width(parts), "representative_is_unique": False,
}
if not top_times:
return {
"start_time": request["start_time"],
@@ -227,6 +238,9 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
static_contexts=built.get("static_contexts") if isinstance(built.get("static_contexts"), list) else None,
)
decision_receipt = build_decision_receipt(request, candidate_decisions, built, diagnostic_values)
from scripts.rectification.candidate_window import candidate_intervals
decision_receipt.update({"candidate_window_contract": "dated-v1", "candidate_intervals": candidate_intervals(request),
"candidate_timezone_offset": request["tz"], "candidate_timezone_id": request.get("timezone_id", "")})
execution_ledger = build_execution_ledger(request, built, diagnostic_values, candidate_decisions)
representative = candidate_decisions[0] if candidate_decisions else None
representative_time = str(representative.get("time") or "")[:5] if representative else None
@@ -495,7 +509,9 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
member_scores = [float(row.get("score") or 0) for row in members]
mean = (sum(member_scores) / len(member_scores)) if member_scores else 0.0
raw_support.append(max(mean - min_day, 0.0))
from scripts.rectification.candidate_window import narrow_candidate_intervals
blocks.append({
"candidate_intervals": narrow_candidate_intervals(request, start_time, end_time),
"period": period,
"start_time": start_time,
"end_time": end_time,
+19 -10
View File
@@ -164,17 +164,18 @@ def context_time(context: dict[str, Any]) -> str | None:
def cluster_contexts_by_signature(
contexts: Sequence[dict[str, Any]],
) -> list[dict[str, Any]]:
buckets: dict[tuple[int | None, ...], list[dict[str, Any]]] = {}
buckets: dict[tuple[Any, ...], list[dict[str, Any]]] = {}
for context in contexts:
if not isinstance(context, dict):
continue
time = context_time(context)
if not time:
continue
buckets.setdefault(feature_signature(context), []).append(context)
buckets.setdefault((context.get("segment_index", 0), *feature_signature(context)), []).append(context)
clusters: list[dict[str, Any]] = []
for signature, members in buckets.items():
ordered = sorted(members, key=lambda item: _clock(str(context_time(item))))
for key, members in buckets.items():
signature = key[1:]
ordered = sorted(members, key=lambda item: item.get("window_index", _clock(str(context_time(item)))))
times = [str(context_time(item)) for item in ordered]
clusters.append({
"signature": signature,
@@ -184,7 +185,7 @@ def cluster_contexts_by_signature(
"representative_time": times[len(times) // 2],
"representative": ordered[len(ordered) // 2],
})
clusters.sort(key=lambda item: _clock(item["representative_time"]))
clusters.sort(key=lambda item: item["representative"].get("window_index", _clock(item["representative_time"])))
return clusters
@@ -271,7 +272,9 @@ def cap_clusters_by_adjacent_merge(
best_index = index
left = work[best_index]
right = work[best_index + 1]
merged_times = sorted(set(left["times"] + right["times"]), key=_clock)
contexts = list(left.get("contexts") or []) + list(right.get("contexts") or [])
order = {context_time(row): row.get("window_index", _clock(str(context_time(row)))) for row in contexts}
merged_times = sorted(set(left["times"] + right["times"]), key=lambda clock: order.get(clock, _clock(clock)))
work[best_index] = {
"signature": left.get("signature"),
"signature_key": f"{left.get('signature_key')}+{right.get('signature_key')}",
@@ -296,7 +299,8 @@ def select_signature_representatives(
context for context in (static_contexts or [])
if isinstance(context, dict) and context_time(context) in by_time
]
if len(contexts) >= 2:
order = {context_time(row): row.get("window_index", _clock(str(context_time(row)))) for row in contexts}
if contexts:
clusters = cluster_contexts_by_signature(contexts)
else:
clusters = _adjacent_score_clusters(list(by_time.values()))
@@ -306,17 +310,22 @@ def select_signature_representatives(
members = [by_time[time] for time in cluster["times"] if time in by_time]
if not members:
continue
best = max(members, key=lambda row: (float(row.get("score") or 0), str(row.get("time"))))
best = max(members, key=lambda row: (float(row.get("score") or 0), order.get(row.get("time"), _clock(str(row.get("time"))))))
positions = [{key: context[key] for key in ("time", "candidate_date", "window_index", "window_offset_minutes", "segment_index")}
for context in cluster.get("contexts", []) if "window_index" in context]
representative_position = next((row for row in positions if row["time"] == best["time"]), {})
representatives.append({
**best,
**representative_position,
"cluster_times": [time for time in cluster["times"] if time in by_time],
**({"cluster_positions": positions} if positions else {}),
})
representatives.sort(key=lambda row: (-float(row.get("score") or 0), str(row.get("time"))))
representatives.sort(key=lambda row: (-float(row.get("score") or 0), order.get(row.get("time"), _clock(str(row.get("time"))))))
return representatives or list(rows)[:1]
def _adjacent_score_clusters(rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
ordered = sorted(rows, key=lambda row: _clock(str(_hhmm(row.get("time")))))
ordered = sorted(rows, key=lambda row: row.get("window_index", _clock(str(_hhmm(row.get("time"))))))
groups: list[list[dict[str, Any]]] = []
for row in ordered:
current = groups[-1] if groups else None
+118
View File
@@ -0,0 +1,118 @@
"""Civil-date candidate windows. Ordinals order samples; offsets measure minutes."""
from __future__ import annotations
import re
from datetime import datetime, timedelta
from typing import Any
_LOCAL_MINUTE = re.compile(r"\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d\Z")
def candidate_intervals(request: dict[str, Any]) -> list[dict[str, str]]:
raw = request.get("candidate_intervals")
if "candidate_intervals" not in request:
start = datetime.fromisoformat(f'{request["birth_date"]}T{request["start_time"]}')
end = datetime.fromisoformat(f'{request["birth_date"]}T{request["end_time"]}')
if end < start:
end += timedelta(days=1)
return [{"start_at": start.isoformat(timespec="minutes"), "end_at": end.isoformat(timespec="minutes")}]
if not isinstance(raw, list) or not 1 <= len(raw) <= 2:
raise ValueError("candidate_intervals must contain one or two intervals")
anchor = datetime.fromisoformat(f'{request["birth_date"]}T00:00')
cleaned, clocks = [], set()
previous_end = None
total = 0
for item in raw:
if not isinstance(item, dict) or set(item) != {"start_at", "end_at"}:
raise ValueError("candidate_intervals must contain start_at/end_at only")
values = [item.get(key) for key in ("start_at", "end_at")]
if any(not isinstance(value, str) or not _LOCAL_MINUTE.fullmatch(value) for value in values):
raise ValueError("candidate_intervals require local YYYY-MM-DDTHH:MM")
start, end = map(datetime.fromisoformat, values)
if end < start or (previous_end is not None and start <= previous_end):
raise ValueError("candidate_intervals must be ordered and non-overlapping")
if start < anchor - timedelta(days=1) or end >= anchor + timedelta(days=2):
raise ValueError("candidate_intervals date out of bounds")
width = int((end - start).total_seconds() // 60) + 1
total += width
if total > 1440 or width < 1:
raise ValueError("candidate_range_out_of_bounds")
for offset in range(width):
clock = (start + timedelta(minutes=offset)).strftime("%H:%M")
if clock in clocks:
raise ValueError("candidate_intervals duplicate clock identity")
clocks.add(clock)
cleaned.append({"start_at": values[0], "end_at": values[1]})
previous_end = end
# Legacy clock envelope remains an inclusion boundary, never a date anchor.
lower, upper = request["start_time"], request["end_time"]
if any(not (lower <= clock <= upper if lower <= upper else clock >= lower or clock <= upper) for clock in clocks):
raise ValueError("candidate_intervals outside clock window")
return cleaned
def narrow_candidate_intervals(request: dict[str, Any], start_time: str, end_time: str) -> list[dict[str, str]]:
result = []
for interval in candidate_intervals(request):
start, end = (datetime.fromisoformat(interval[key]) for key in ("start_at", "end_at"))
run = None
for offset in range(int((end - start).total_seconds() // 60) + 1):
at = start + timedelta(minutes=offset)
clock = at.strftime("%H:%M")
inside = start_time <= clock <= end_time if start_time <= end_time else clock >= start_time or clock <= end_time
if inside:
stamp = at.isoformat(timespec="minutes")
run = {"start_at": run["start_at"] if run else stamp, "end_at": stamp}
elif run:
result.append(run)
run = None
if run:
result.append(run)
return result
def enumerate_candidate_window(request: dict[str, Any]) -> list[datetime]:
raw_step = request.get("minute_step", 1)
if isinstance(raw_step, bool) or not isinstance(raw_step, int) or not 1 <= raw_step <= 15:
raise ValueError("minute_step_out_of_bounds")
moments = []
for interval in candidate_intervals(request):
start, end = (datetime.fromisoformat(interval[key]) for key in ("start_at", "end_at"))
width = int((end - start).total_seconds() // 60) + 1
if not 1 <= width <= 1440:
raise ValueError("candidate_range_out_of_bounds")
moments.extend(start + timedelta(minutes=offset) for offset in range(0, width, raw_step))
return moments
def candidate_positions(request: dict[str, Any], moments: list[datetime]) -> list[dict[str, Any]]:
intervals = candidate_intervals(request)
origin = datetime.fromisoformat(intervals[0]["start_at"])
bounds = [(datetime.fromisoformat(row["start_at"]), datetime.fromisoformat(row["end_at"])) for row in intervals]
return [{"time": at.strftime("%H:%M"), "candidate_date": at.date().isoformat(),
"window_index": index, "window_offset_minutes": int((at - origin).total_seconds() // 60),
"segment_index": next(i for i, (start, end) in enumerate(bounds) if start <= at <= end)}
for index, at in enumerate(moments)]
def intervals_from_positions(positions: list[dict[str, Any]]) -> list[dict[str, Any]]:
groups: dict[int, list[dict[str, Any]]] = {}
for row in sorted(positions, key=lambda item: item["window_index"]):
groups.setdefault(row["segment_index"], []).append(row)
result = []
for segment, members in groups.items():
first, last = members[0], members[-1]
result.append({"segment_index": segment, "start_index": first["window_index"], "end_index": last["window_index"],
"start_offset_minutes": first["window_offset_minutes"], "end_offset_minutes": last["window_offset_minutes"],
"start_at": f'{first["candidate_date"]}T{first["time"]}',
"end_at": f'{last["candidate_date"]}T{last["time"]}'})
return result
def interval_union_width(intervals: list[dict[str, Any]]) -> int:
"""Keep historical within-segment envelope; never fill a declared segment gap."""
groups: dict[int, list[dict[str, Any]]] = {}
for row in sorted(intervals, key=lambda item: item["start_index"]):
groups.setdefault(row["segment_index"], []).append(row)
return sum(max(row["end_offset_minutes"] for row in rows) - min(row["start_offset_minutes"] for row in rows) + 1
for rows in groups.values())
+5 -1
View File
@@ -53,7 +53,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
_REQUEST_FIELDS = frozenset({
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
"ayanamsa", "node_mode", "asked_probe_keys", "declined_domains", "column_times", "minute_step", "blocks",
"refresh_probes",
"refresh_probes", "candidate_intervals",
}) | _REQUEST_PROVENANCE_FIELDS
ASKED_PROBE_KEY_MAX_LENGTH = 200
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
@@ -175,6 +175,7 @@ class RectificationRequest(TypedDict):
refresh_probes: NotRequired[bool]
minute_step: NotRequired[int]
blocks: NotRequired[list[dict[str, Any]]]
candidate_intervals: NotRequired[list[dict[str, str]]]
JsonObject = dict[str, Any]
@@ -406,4 +407,7 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
cleaned_request["minute_step"] = minute_step
if "blocks" in body:
cleaned_request["blocks"] = _normalize_blocks(body, start_time, end_time)
if "candidate_intervals" in body:
from scripts.rectification.candidate_window import candidate_intervals
cleaned_request["candidate_intervals"] = candidate_intervals(body)
return cast(RectificationRequest, cleaned_request)
+12 -1
View File
@@ -156,6 +156,11 @@ def indistinguishable_width_minutes(candidates: Sequence[dict[str, Any]]) -> int
return 0
ranked = sorted(candidates, key=lambda row: int(row.get("rank") or 0))
top = ranked[0]
if all(row.get("cluster_intervals") for row in ranked):
from scripts.rectification.candidate_window import interval_union_width
return max(int(top.get("tied_minute_count") or 1), interval_union_width([
interval for row in ranked for interval in row["cluster_intervals"]
]), 1)
starts: list[int] = []
ends: list[int] = []
for row in ranked:
@@ -428,7 +433,12 @@ def build_candidate_decisions(
for other in all_scores
)
cluster_times, cluster_start, cluster_end = _cluster_span(row)
from scripts.rectification.candidate_window import intervals_from_positions
position = {key: row[key] for key in ("candidate_date", "window_index", "window_offset_minutes", "segment_index") if key in row}
coverage = intervals_from_positions(row.get("cluster_positions", []))
decisions.append({
**position,
**({"cluster_intervals": coverage} if coverage else {}),
"candidate_id": str(uuid5(NAMESPACE_URL, f"{POLICY_VERSION}:{result_id}:{row['time']}")),
"rank": index + 1,
"time": row["time"],
@@ -455,7 +465,8 @@ def _cluster_span(row: dict[str, Any]) -> tuple[list[str], str, str]:
if not times:
clock = str(row.get("time") or "")[:5]
times = [clock] if len(clock) == 5 and clock[2] == ":" else []
times.sort(key=lambda value: int(value[:2]) * 60 + int(value[3:5]))
positions = {item["time"]: item["window_index"] for item in row.get("cluster_positions", [])}
times.sort(key=lambda value: positions.get(value, int(value[:2]) * 60 + int(value[3:5])))
start = times[0] if times else str(row.get("time") or "")[:5]
end = times[-1] if times else start
return times, start, end
+61 -13
View File
@@ -98,8 +98,12 @@ def _features(built: dict[str, Any]) -> list[dict[str, Any]]:
continue
time = _feature_time(feature)
if time:
rows.append(feature)
rows.sort(key=lambda item: _clock(str(_feature_time(item))))
if "candidate_intervals" in built:
position = {key: context[key] for key in ("candidate_date", "window_index", "window_offset_minutes", "segment_index")}
rows.append({**feature, **position})
else:
rows.append(feature)
rows.sort(key=lambda item: item.get("window_index", _clock(str(_feature_time(item)))))
return rows
@@ -224,27 +228,43 @@ def window_scan(
counts: dict[str, set[int]] = {layer: set() for layer in _LAYER_LABEL}
transitions: list[dict[str, Any]] = []
previous: dict[str, int | None] | None = None
for feature in _features(built):
previous_segment: int | None = None
features = _features(built)
if start_minute is not None and end_minute is not None:
features = [feature for feature in features
if start_minute <= _clock(str(_feature_time(feature))) <= end_minute]
segment_bounds: dict[int, tuple[str, str]] = {}
for feature in features:
if "window_index" in feature:
segment = feature["segment_index"]
stamp = f'{feature["candidate_date"]}T{_feature_time(feature)}'
segment_bounds[segment] = (segment_bounds.get(segment, (stamp, stamp))[0], stamp)
for feature in features:
time = _feature_time(feature)
if time and start_minute is not None and end_minute is not None:
clock = _clock(time)
if clock < start_minute or clock > end_minute:
continue
current = {layer: _scan_layer_value(feature, layer) for layer in _LAYER_LABEL}
for layer, bucket in counts.items():
value = current[layer]
if isinstance(value, int):
bucket.add(value)
if previous and time:
dated = "window_index" in feature
segment_start = dated and feature["segment_index"] != previous_segment
if (previous or segment_start) and time:
for layer, label in _LAYER_LABEL.items():
before = previous[layer]
after = current[layer]
if isinstance(before, int) and isinstance(after, int) and before != after:
before = after if segment_start else previous[layer]
if isinstance(before, int) and isinstance(after, int) and (before != after or segment_start):
row = {
"layer": layer,
"at": time,
"user_meaning": f"{label}{time} 发生变化",
}
if dated:
row.update({key: feature[key] for key in ("candidate_date", "window_index", "window_offset_minutes", "segment_index")})
start_at, end_at = segment_bounds[feature["segment_index"]]
row.update(segment_start_at=start_at, segment_end_at=end_at)
if segment_start:
row["segment_start"] = True
row["user_meaning"] = f"{label} 在这一段起点的状态"
from_sign = _sign_name(before)
to_sign = _sign_name(after)
if from_sign and to_sign:
@@ -252,6 +272,7 @@ def window_scan(
row["to_sign"] = to_sign
transitions.append(row)
previous = current
previous_segment = feature.get("segment_index")
payload: dict[str, Any] = {
"scanned": True,
"confirmation_allowed": False,
@@ -472,8 +493,10 @@ def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None:
index = feature.get("ascendant_sign_index")
if not time or not isinstance(index, int) or index < 0 or index > 11:
continue
if current and current["d1_lagna_index"] == index:
if current and current["d1_lagna_index"] == index and current.get("segment_index") == feature.get("segment_index"):
current["end"] = time
if "candidate_date" in feature:
current["end_at"] = f'{feature["candidate_date"]}T{time}'
continue
if current:
intervals.append(current)
@@ -482,6 +505,8 @@ def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None:
"start": time,
"end": time,
"d1_lagna_index": index,
**({"segment_index": feature["segment_index"], "start_at": f'{feature["candidate_date"]}T{time}',
"end_at": f'{feature["candidate_date"]}T{time}'} if "candidate_date" in feature else {}),
"lagna": sign,
"lords": {
"l1": _house_lord_zh(index, 1),
@@ -495,11 +520,16 @@ def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None:
if len(intervals) < 2:
return None
left, right = intervals[0], intervals[1]
def interval_label(interval: dict[str, Any]) -> str:
if "start_at" in interval:
return f"{interval['start_at'].replace('T', ' ')}{interval['end_at'].replace('T', ' ')}"
return f"{interval['start']}-{interval['end']}"
return {
"intervals": intervals[:3],
"user_meaning": (
f"窗口里出现两段本命上升:{left['start']}-{left['end']}{left['lagna']}"
f"{right['start']}-{right['end']}{right['lagna']}"
f"窗口里出现两段本命上升:{interval_label(left)}{left['lagna']}"
f"{interval_label(right)}{right['lagna']}"
"可并列 D9/D10 类型表作校时方法,不是命运承诺,也不能确认唯一分钟。"
),
"unique_minute_claim": False,
@@ -618,6 +648,24 @@ def cluster_scan(
width_minutes: int | None = None,
) -> dict[str, Any]:
"""Scan only the indistinguishable candidate cluster, not the full declared range."""
if "candidate_intervals" in built:
wanted = {str(value)[:5] for value in [*candidate_times, representative_time] if value}
features = _features(built)
selected: dict[int, list[int]] = {}
for feature in features:
if _feature_time(feature) in wanted:
selected.setdefault(feature["segment_index"], []).append(feature["window_offset_minutes"])
if not selected:
return window_scan(built)
bounds = {segment: (min(points), max(points)) for segment, points in selected.items()}
if len(bounds) == 1:
segment, (lo, hi) = next(iter(bounds.items()))
extra = max(int(width_minutes or 0) - (hi - lo + 1), 0)
bounds[segment] = (lo - extra // 2, hi + extra - extra // 2)
contexts = [row for row in built.get("static_contexts") or []
if row.get("segment_index") in bounds
and bounds[row["segment_index"]][0] <= row["window_offset_minutes"] <= bounds[row["segment_index"]][1]]
return window_scan({**built, "static_contexts": contexts})
clocks: list[int] = []
for raw in [*candidate_times, representative_time]:
if isinstance(raw, str) and len(raw) >= 5:
+7 -4
View File
@@ -13,8 +13,8 @@ from scripts.rectification.dasha_transition_proximity import merge_transition_pr
from scripts.rectification.contracts import LifeEvent, RectificationRequest, is_scoreable_event
from scripts.rectification.case_holdout import holdout_event_ids
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-8"
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-9"
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v5"
PRECISION_WEIGHTS = {
"day": 1.0,
"month": 0.8,
@@ -115,7 +115,7 @@ def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_dat
"date": sampled_date, "precision": "day", "summary": event.get("summary", ""),
}],
}
for key in ("ayanamsa", "node_mode", "minute_step"):
for key in ("ayanamsa", "node_mode", "minute_step", "candidate_intervals"):
if key in request:
legacy_request[key] = request[key]
return legacy_request
@@ -307,6 +307,7 @@ def build_event_contribution_matrix(
"date_sensitivity": date_sensitivity,
"missing_layers": sorted(missing_layers),
"static_contexts": static_contexts,
**({"candidate_intervals": request["candidate_intervals"]} if "candidate_intervals" in request else {}),
}
@@ -341,7 +342,7 @@ def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
return int(value) if value.is_integer() else value
spec = {
"version": INPUT_CONTRACT_VERSION,
"version": INPUT_CONTRACT_VERSION if "candidate_intervals" in request else "rectification-calculation-spec-v4",
"birthDate": request["birth_date"],
"candidateRange": {"start": request["start_time"], "end": request["end_time"]},
"latitude": json_number(request["lat"]),
@@ -359,6 +360,8 @@ def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
):
if source in request:
spec[target] = request[source] # type: ignore[literal-required]
if "candidate_intervals" in request:
spec["candidateIntervals"] = request["candidate_intervals"]
return spec