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
+14 -14
View File
@@ -88,19 +88,12 @@ def _event_datetime(event: LifeEvent) -> datetime:
def _candidate_datetimes(request: RectificationEventRequest) -> list[datetime]:
birth_date = date.fromisoformat(request["birth_date"])
start = datetime.combine(birth_date, time.fromisoformat(request["start_time"]))
end = datetime.combine(birth_date, time.fromisoformat(request["end_time"]))
if end < start:
end += timedelta(days=1)
minute_count = int((end - start).total_seconds() // 60) + 1
if minute_count < 1 or minute_count > 1_440:
raise RectificationEventCalculationError("candidate_range_out_of_bounds")
raw_step = request.get("minute_step") if isinstance(request, dict) else None
step = int(raw_step or 1)
if step < 1 or step > 15:
raise RectificationEventCalculationError("minute_step_out_of_bounds")
return [start + timedelta(minutes=offset) for offset in range(0, minute_count, step)]
from scripts.rectification.candidate_window import enumerate_candidate_window
try:
return enumerate_candidate_window(request)
except ValueError as exc:
raise RectificationEventCalculationError(str(exc)) from exc
def _active_vimshottari(
@@ -611,7 +604,11 @@ def compute_candidate_static_contexts(
candidates: Sequence[datetime] | None = None,
) -> list[dict[str, Any]]:
candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request)
return [build_candidate_static_context(request, candidate) for candidate in candidate_datetimes]
from scripts.rectification.candidate_window import candidate_positions
positions = candidate_positions(request, candidate_datetimes)
return [{**build_candidate_static_context(request, candidate), **position}
for candidate, position in zip(candidate_datetimes, positions)]
def _candidate_row(
@@ -737,6 +734,9 @@ def _canonical_input_contract(request: RectificationEventRequest) -> tuple[dict,
"ephemeris_source": "swisseph_calc_ut",
},
}
if "candidate_intervals" in request:
payload["schema_version"] = "rectification-candidate-input-v3"
payload["candidate_intervals"] = request["candidate_intervals"]
normalized = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return payload, hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+22 -2
View File
@@ -88,9 +88,21 @@ def build_flexible_birth_time_profile_from_window(
ayanamsa: str = "raman",
node_mode: str = "mean",
source_reference: Mapping[str, Any],
candidate_intervals: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
start, end = _parse_window(birth_date, start_time, end_time)
normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times)
if candidate_intervals is None:
start, end = _parse_window(birth_date, start_time, end_time)
normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times)
else:
from scripts.rectification.candidate_window import enumerate_candidate_window
moments = enumerate_candidate_window({"birth_date": birth_date, "start_time": start_time,
"end_time": end_time, "candidate_intervals": candidate_intervals})
by_clock = {value.strftime("%H:%M"): value for value in moments}
if not 2 <= len(candidate_times) <= MAX_CANDIDATE_MINUTES or len(set(candidate_times)) != len(candidate_times):
raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_thirty_one")
if any(value not in by_clock for value in candidate_times):
raise FlexibleBirthTimeProfileError("candidate_time_outside_window")
normalized_times = sorted(by_clock[value] for value in candidate_times)
candidates = [
_candidate_from_recast(
candidate_at=value,
@@ -103,6 +115,14 @@ def build_flexible_birth_time_profile_from_window(
for value in normalized_times
]
profile = build_flexible_birth_time_profile(candidates, source_reference=source_reference)
if candidate_intervals is not None:
# Keep civil chronology and declared gaps instead of the legacy clock-sorted envelope.
clocks = [value.strftime("%H:%M") for value in normalized_times]
profile["birth_time_window"].update({"start_time": start_time, "end_time": end_time,
"candidate_times": clocks, "candidate_intervals": deepcopy(candidate_intervals),
"candidate_datetimes": [value.isoformat(timespec="minutes") for value in normalized_times]})
refs = {row["candidate_time"]: row for row in profile["candidate_references"]}
profile["candidate_references"] = [{**refs[value.strftime("%H:%M")], "candidate_date": value.date().isoformat()} for value in normalized_times]
profile["calculation_profile"] = {
"ayanamsa": ayanamsa,
"node_mode": node_mode,
+2 -2
View File
@@ -8951,11 +8951,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
request_body.pop('candidate_times', None)
request = self._rectification_v5_request(request_body)
from scripts.active_rectification_event_engine import _candidate_datetimes
allowed_candidate_times = {value.strftime('%H:%M') for value in _candidate_datetimes(request)}
allowed_candidate_times = {value.strftime('%H:%M'): value for value in _candidate_datetimes(request)}
if any(value not in allowed_candidate_times for value in candidate_times):
raise BadRequest('candidate_times must fall within the V5 candidate range')
birth_day = datetime.strptime(request['birth_date'], '%Y-%m-%d')
selected_events, eligible_event_count, unsupported_events = _select_vedastro_rectification_events(
request['events']
)
@@ -8968,6 +8967,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
raw_reports = []
for role, candidate_time in zip(('primary', 'runner_up'), candidate_times):
birth_day = allowed_candidate_times[candidate_time]
hour, minute = candidate_time.split(':', 1)
candidate_case = {
'year': birth_day.year,
+27 -10
View File
@@ -2241,20 +2241,36 @@ def _build_birth_time_sensitivity(args) -> dict:
}
center = _birth_datetime_from_args(args)
start, representative, end = _birth_time_candidate_window(args, center, accuracy)
declared_range = getattr(args, "candidate_range", None) or {}
dated_intervals = None
dated_moments = None
if isinstance(declared_range, dict) and "candidate_intervals" in declared_range:
from scripts.rectification.candidate_window import candidate_intervals, enumerate_candidate_window
dated_request = {"birth_date": center.date().isoformat(), "start_time": declared_range.get("start_time"),
"end_time": declared_range.get("end_time"), "candidate_intervals": declared_range["candidate_intervals"]}
dated_intervals = candidate_intervals(dated_request)
dated_moments = enumerate_candidate_window(dated_request)
start, end = dated_moments[0], dated_moments[-1]
representative = min(dated_moments, key=lambda value: abs((value-center).total_seconds()))
else:
start, representative, end = _birth_time_candidate_window(args, center, accuracy)
if start == end:
return {
"schema": "jyotish.report_birth_time_sensitivity.v1",
"status": "not_applicable",
"accuracy": "confirmed",
}
minute_count = int((end - start).total_seconds() // 60) + 1
candidate_times = _candidate_minutes(
start,
representative,
end,
coarse=accuracy == "approximate" and minute_count > 31,
)
minute_count = len(dated_moments) if dated_moments is not None else int((end - start).total_seconds() // 60) + 1
if dated_moments is not None:
# Sample the declared-minute ordinal, never the wall-clock gap between segments.
origin = datetime(2000, 1, 1)
sampled = _candidate_minutes(origin, origin + timedelta(minutes=dated_moments.index(representative)),
origin + timedelta(minutes=minute_count-1), coarse=accuracy == "approximate" and minute_count > 31)
candidate_times = [dated_moments[int(clock[:2])*60+int(clock[3:])].strftime("%H:%M") for clock in sampled]
else:
candidate_times = _candidate_minutes(
start, representative, end, coarse=accuracy == "approximate" and minute_count > 31,
)
try:
from flexible_birth_time_profile import build_flexible_birth_time_profile_from_window
from flexible_birth_time_report_support import build_flexible_birth_time_report_support
@@ -2266,9 +2282,10 @@ def _build_birth_time_sensitivity(args) -> dict:
profile = build_flexible_birth_time_profile_from_window(
birth_date=center.strftime("%Y-%m-%d"),
start_time=start.strftime("%H:%M"),
end_time=end.strftime("%H:%M"),
start_time=declared_range["start_time"] if dated_intervals else start.strftime("%H:%M"),
end_time=declared_range["end_time"] if dated_intervals else end.strftime("%H:%M"),
candidate_times=candidate_times,
**({"candidate_intervals": dated_intervals} if dated_intervals is not None else {}),
lat=float(args.lat),
lon=float(args.lon),
tz=float(args.tz),
+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
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Independent-process native A/B and dated-window goldens; not an accuracy benchmark."""
from __future__ import annotations
import argparse
import hashlib
import importlib
import json
import sys
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def load(root: Path):
for name in list(sys.modules):
if name == "scripts" or name.startswith("scripts."):
del sys.modules[name]
sys.path.insert(0, str(root))
importlib.invalidate_caches()
from scripts.rectification.api_service import score_candidates
from scripts.research.probe_supply_after_six import request_from_case
from scripts.rectification.decision_policy import indistinguishable_width_minutes
return score_candidates, request_from_case, indistinguishable_width_minutes
def canonical(value):
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()
def projection(result, width):
new_fields = {"candidate_id", "candidate_date", "window_index", "window_offset_minutes", "segment_index", "cluster_intervals"}
return {
"candidate_scores": result["candidate_scores"],
"matrix": result["event_contribution_matrix"],
"decisions": [{key: value for key, value in row.items() if key not in new_fields} for row in result["candidate_decisions"]],
"width": width(result["candidate_decisions"]),
"confirmation_allowed": result["confirmation_allowed"],
"selection_allowed": result["selection_allowed"],
"representative_time": result["decision_receipt"]["representative_time"],
}
def worker(root: Path, dated: bool):
score, make_request, width = load(root)
dataset = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
result = []
for case in json.loads(dataset.read_text(encoding="utf-8"))["cases"][:3]:
request = make_request(case)
if dated:
request["candidate_intervals"] = [{"start_at": f'{request["birth_date"]}T{request["start_time"]}', "end_at": f'{request["birth_date"]}T{request["end_time"]}'}]
result.append(projection(score(request), width))
print(json.dumps(result))
def independent(root: Path, dated: bool):
completed = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--worker", str(root), *( ["--dated"] if dated else [])],
cwd=root, check=True, capture_output=True, text=True, encoding="utf-8")
return json.loads(completed.stdout)
def main():
if "--worker" in sys.argv:
worker(Path(sys.argv[sys.argv.index("--worker") + 1]), "--dated" in sys.argv)
return
parser = argparse.ArgumentParser()
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--output", type=Path, default=ROOT / "artifacts/midnight-date-anchor")
parser.add_argument("--golden", action="store_true")
parser.add_argument("--golden-path", type=Path, default=ROOT / "frontend/tests/fixtures/rectification-midnight-date-anchor.native.json")
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
dataset = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
cases = json.loads(dataset.read_text(encoding="utf-8"))["cases"][:3]
assert all(case["birth"]["source"]["rodden_rating"] == "AA" for case in cases)
old = independent(args.baseline, False)
current = independent(ROOT, True)
comparisons = []
for case, before, after in zip(cases, old, current):
fields = {key: canonical(before[key]) == canonical(after[key]) for key in before}
comparisons.append({"case_id": case["case_id"], "source": case["birth"]["source"], "fields": fields,
"before_sha256": hashlib.sha256(canonical(before)).hexdigest(), "after_sha256": hashlib.sha256(canonical(after)).hexdigest(),
"candidate_minutes": len(after["candidate_scores"]), "public_clusters": len(after["decisions"]), "width": after["width"]})
print("current", case["case_id"], fields, flush=True)
report = {"baseline": str(args.baseline), "dataset_sha256": hashlib.sha256(dataset.read_bytes()).hexdigest(),
"same_machine_independent_processes": True, "python_executable": sys.executable, "numeric_tolerance": 0, "comparisons": comparisons,
"excluded_fields": "new date/ordinal metadata; result/candidate IDs intentionally change with algorithm identity"}
with (args.output / "independent-process-aa-ab.json").open("x", encoding="utf-8") as stream:
json.dump(report, stream, indent=2)
assert all(all(row["fields"].values()) for row in comparisons), "same-day native A/B changed"
if args.golden:
score, _, _ = load(ROOT)
# Explicitly fictional birth/event facts; response is produced only by the unmocked native engine.
request = {"birth_date": "2000-03-01", "start_time": "23:58", "end_time": "00:02", "lat": 0.0, "lon": 0.0, "tz": 0.0,
"candidate_intervals": [{"start_at": "2000-02-29T23:58", "end_at": "2000-03-01T00:02"}],
"events": [{"id": "00000000-0000-4000-8000-000000000001", "domain": "career", "event_kind": "career_entry",
"date_start": "2020-01-01", "date_end": "2020-01-01", "precision": "day", "summary": "Fictional career entry for date-contract testing"}]}
from scripts.rectification.contracts import normalize_rectification_request
response = score(normalize_rectification_request(request))
target = args.golden_path
with target.open("x", encoding="utf-8") as stream:
json.dump({"provenance": {"kind": "unmocked_native_engine", "facts": "explicitly_fictional", "generator": "scripts/research/midnight_date_anchor_regression.py --baseline <baseline-worktree> --golden", "algorithm": response["algorithm_version"]}, "request": request, "response": response}, stream, ensure_ascii=False, indent=2)
print("golden", target, flush=True)
if __name__ == "__main__":
main()
+2 -2
View File
@@ -30,8 +30,8 @@ from scripts.research.sealed_holdout_rerun import (
OFFSETS = (-30, -20, -15, -10, -8, -5, -3, 0, 3, 5, 8, 10, 15, 20, 30)
RADII = (15, 30, 60)
MINUTE_STEP = 1
FREEZE = ROOT / "docs/research/reported_offset_cross_midnight_2026_09_20.final.freeze.json"
REPORT = ROOT / "docs/research/reported_offset_cross_midnight_2026_09_20.json"
FREEZE = ROOT / "docs/research/reported_offset_midnight_anchor_2026_09_21.freeze.json"
REPORT = ROOT / "docs/research/reported_offset_midnight_anchor_2026_09_21.json"
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
+6 -2
View File
@@ -27,8 +27,8 @@ from scripts.minute_rectification_feature_facts_v4 import build_feature_fact_row
from scripts.minute_rectification_holdout_validator import validate
DATASET = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_cross_midnight_2026_09_20.final.freeze.json"
REPORT = ROOT / "docs/research/sealed_holdout_rerun_cross_midnight_2026_09_20.json"
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_midnight_anchor_2026_09_21.freeze.json"
REPORT = ROOT / "docs/research/sealed_holdout_rerun_midnight_anchor_2026_09_21.json"
LEGACY_REPORT = ROOT / "docs/research/sealed_holdout_rerun_2026_09_20.json"
ARCHIVE = ROOT / "docs/research/history/rectification_pre_cross_midnight_2026_09_20"
PRODUCTION_FILES = [
@@ -38,6 +38,10 @@ PRODUCTION_FILES = [
"scripts/rectification/case_holdout.py",
"scripts/rectification/contracts.py",
"scripts/rectification/event_probes.py",
"scripts/rectification/candidate_window.py",
"scripts/rectification/decision_policy.py",
"scripts/rectification/refinement_packet.py",
"scripts/rectification/api_service.py",
]
RESEARCH_FILES = [
"scripts/research/reported_offset_sweep.py",