fix(rectification): honor declared birth-time uncertainty and split windows over two hours (BUG-571–573)
Intake stores how sure the user is; rectification now searches that range, offers a one-click widen when event fit is low at the edge, and trisects windows longer than two hours before the minute grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -65,6 +65,38 @@ def _report_candidate_range(
|
||||
}
|
||||
|
||||
|
||||
def _window_span_minutes(start_time: str, end_time: str) -> int:
|
||||
start = _clock_minutes(start_time)
|
||||
end = _clock_minutes(end_time)
|
||||
return end - start if end >= start else 1440 - start + end
|
||||
|
||||
|
||||
def _adaptive_minute_step(start_time: str, end_time: str) -> int:
|
||||
width = _window_span_minutes(start_time, end_time)
|
||||
if width > 360:
|
||||
return 10
|
||||
if width > 180:
|
||||
return 5
|
||||
return 2
|
||||
|
||||
|
||||
def _block_scan_periods(request: RectificationRequest) -> list[tuple[str, str, str]]:
|
||||
custom = request.get("blocks")
|
||||
if isinstance(custom, list) and custom:
|
||||
periods: list[tuple[str, str, str]] = []
|
||||
for item in custom:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
label = str(item.get("period") or item.get("label") or "")
|
||||
start_time = str(item.get("start_time") or "")[:5]
|
||||
end_time = str(item.get("end_time") or "")[:5]
|
||||
if label and start_time and end_time:
|
||||
periods.append((label, start_time, end_time))
|
||||
if periods:
|
||||
return periods
|
||||
return list(BLOCK_SCAN_PERIODS)
|
||||
|
||||
|
||||
def _report_evidence(
|
||||
request: RectificationRequest,
|
||||
built: dict[str, Any],
|
||||
@@ -402,9 +434,12 @@ def _clock_in_declared_period(clock: str, start_time: str, end_time: str) -> boo
|
||||
def _normalize_relative_support(raw: Sequence[float]) -> list[float]:
|
||||
floored = [max(0.0, float(value)) for value in raw]
|
||||
total = sum(floored)
|
||||
if not floored:
|
||||
return []
|
||||
if total <= 0:
|
||||
return [20.0 for _ in floored]
|
||||
shares = [round(100.0 * value / total, 1) for value in floored]
|
||||
shares = [round(100.0 / len(floored), 1) for _ in floored]
|
||||
else:
|
||||
shares = [round(100.0 * value / total, 1) for value in floored]
|
||||
delta = round(100.0 - sum(shares), 1)
|
||||
if shares:
|
||||
shares[shares.index(max(shares))] = round(shares[shares.index(max(shares))] + delta, 1)
|
||||
@@ -412,10 +447,16 @@ def _normalize_relative_support(raw: Sequence[float]) -> list[float]:
|
||||
|
||||
|
||||
def block_scan(request: RectificationRequest) -> dict[str, Any]:
|
||||
"""Aggregate 24h event scores into the five declared birth-time periods."""
|
||||
step = int(request.get("minute_step") or 10)
|
||||
if step <= 1:
|
||||
step = 10
|
||||
"""Aggregate event scores into declared periods or caller-supplied sub-blocks."""
|
||||
requested_step = request.get("minute_step")
|
||||
if isinstance(requested_step, int) and requested_step > 1:
|
||||
step = requested_step
|
||||
else:
|
||||
step = _adaptive_minute_step(request["start_time"], request["end_time"])
|
||||
if not request.get("blocks"):
|
||||
step = int(request.get("minute_step") or 10)
|
||||
if step <= 1:
|
||||
step = 10
|
||||
scoring_request = {**request, "minute_step": step}
|
||||
scored = score_candidates(scoring_request)
|
||||
events_by_id = {
|
||||
@@ -429,9 +470,10 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
|
||||
]
|
||||
day_scores = [float(row.get("score") or 0) for row in rows]
|
||||
min_day = min(day_scores) if day_scores else 0.0
|
||||
periods = _block_scan_periods(request)
|
||||
raw_support: list[float] = []
|
||||
blocks: list[dict[str, Any]] = []
|
||||
for period, start_time, end_time in BLOCK_SCAN_PERIODS:
|
||||
for period, start_time, end_time in periods:
|
||||
members = [
|
||||
row for row in rows
|
||||
if _clock_in_declared_period(str(row.get("time") or "")[:5], start_time, end_time)
|
||||
|
||||
@@ -52,10 +52,88 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
|
||||
})
|
||||
_REQUEST_FIELDS = frozenset({
|
||||
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
|
||||
"ayanamsa", "node_mode", "asked_probe_keys", "minute_step",
|
||||
"ayanamsa", "node_mode", "asked_probe_keys", "minute_step", "blocks",
|
||||
}) | _REQUEST_PROVENANCE_FIELDS
|
||||
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
|
||||
_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z")
|
||||
_MINUTES_PER_DAY = 24 * 60
|
||||
|
||||
|
||||
def _clock_minutes(value: str) -> int:
|
||||
hour, minute = value.split(":", 1)
|
||||
return int(hour) * 60 + int(minute)
|
||||
|
||||
|
||||
def _clock_in_window(clock: str, start_time: str, end_time: str) -> bool:
|
||||
current = _clock_minutes(clock)
|
||||
start = _clock_minutes(start_time)
|
||||
end = _clock_minutes(end_time)
|
||||
if start <= end:
|
||||
return start <= current <= end
|
||||
return current >= start or current <= end
|
||||
|
||||
|
||||
def _inclusive_minutes(start_time: str, end_time: str) -> list[int]:
|
||||
start = _clock_minutes(start_time)
|
||||
end = _clock_minutes(end_time)
|
||||
span = end - start if end >= start else _MINUTES_PER_DAY - start + end
|
||||
return [(start + offset) % _MINUTES_PER_DAY for offset in range(span + 1)]
|
||||
|
||||
|
||||
def _block_contained(block_start: str, block_end: str, window_start: str, window_end: str) -> bool:
|
||||
window = set(_inclusive_minutes(window_start, window_end))
|
||||
return all(minute in window for minute in _inclusive_minutes(block_start, block_end))
|
||||
|
||||
|
||||
def _ranges_overlap(left_start: str, left_end: str, right_start: str, right_end: str) -> bool:
|
||||
left = set(_inclusive_minutes(left_start, left_end))
|
||||
right = set(_inclusive_minutes(right_start, right_end))
|
||||
shared = left & right
|
||||
if not shared:
|
||||
return False
|
||||
endpoints = {
|
||||
_clock_minutes(left_start),
|
||||
_clock_minutes(left_end),
|
||||
_clock_minutes(right_start),
|
||||
_clock_minutes(right_end),
|
||||
}
|
||||
interior = shared - endpoints
|
||||
if interior:
|
||||
return True
|
||||
# Adjacent blocks may share a single endpoint minute; more than that is overlap.
|
||||
return len(shared) > 1
|
||||
|
||||
|
||||
def _normalize_blocks(body: dict[str, Any], start_time: str, end_time: str) -> list[dict[str, str]]:
|
||||
raw_blocks = body.get("blocks")
|
||||
if not isinstance(raw_blocks, list) or not 1 <= len(raw_blocks) <= 5:
|
||||
raise ValueError("blocks must contain between 1 and 5 items")
|
||||
cleaned: list[dict[str, str]] = []
|
||||
for index, raw in enumerate(raw_blocks):
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"blocks[{index}] must be an object")
|
||||
label = raw.get("label") or raw.get("period")
|
||||
block_start, block_end = raw.get("start_time"), raw.get("end_time")
|
||||
if not isinstance(label, str) or not label.strip() or len(label.strip()) > 40:
|
||||
raise ValueError(f"blocks[{index}].label must be a non-empty string up to 40 characters")
|
||||
if not isinstance(block_start, str) or not _CLOCK.fullmatch(block_start):
|
||||
raise ValueError(f"blocks[{index}].start_time must be HH:MM")
|
||||
if not isinstance(block_end, str) or not _CLOCK.fullmatch(block_end):
|
||||
raise ValueError(f"blocks[{index}].end_time must be HH:MM")
|
||||
if block_start == block_end:
|
||||
raise ValueError(f"blocks[{index}] start_time and end_time must differ")
|
||||
if not _block_contained(block_start, block_end, start_time, end_time):
|
||||
raise ValueError(f"blocks[{index}] must fall inside the request window")
|
||||
for previous in cleaned:
|
||||
if _ranges_overlap(previous["start_time"], previous["end_time"], block_start, block_end):
|
||||
raise ValueError("blocks must not overlap")
|
||||
cleaned.append({
|
||||
"label": label.strip(),
|
||||
"period": label.strip(),
|
||||
"start_time": block_start,
|
||||
"end_time": block_end,
|
||||
})
|
||||
return cleaned
|
||||
|
||||
|
||||
class LifeEvent(TypedDict):
|
||||
@@ -90,6 +168,7 @@ class RectificationRequest(TypedDict):
|
||||
local_time_status: NotRequired[str | None]
|
||||
asked_probe_keys: NotRequired[list[str]]
|
||||
minute_step: NotRequired[int]
|
||||
blocks: NotRequired[list[dict[str, Any]]]
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
@@ -273,4 +352,6 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
raise ValueError("minute_step must be an integer from 1 to 15")
|
||||
if minute_step != 1:
|
||||
cleaned_request["minute_step"] = minute_step
|
||||
if "blocks" in body:
|
||||
cleaned_request["blocks"] = _normalize_blocks(body, start_time, end_time)
|
||||
return cast(RectificationRequest, cleaned_request)
|
||||
|
||||
Reference in New Issue
Block a user