fix(rectification): anchor candidate windows to civil dates across midnight
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user