from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone import time from typing import Callable from .models import ContractError, ErrorCode class BudgetExpired(ContractError): def __init__(self, phase: str, operation: str, deadline_scope: str): super().__init__(ErrorCode.DEADLINE_EXPIRED, operation, f"{phase}:{deadline_scope}") self.phase = phase self.operation = operation self.deadline_scope = deadline_scope @dataclass class Budget: total_ms: int close_reserve_ms: int observed_at_utc: str | None = None batch_increment_ms: int = 240_000 clock: Callable[[], float] = time.monotonic utc_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc) def __post_init__(self) -> None: if self.total_ms <= 0 or self.close_reserve_ms < 0 or self.close_reserve_ms >= self.total_ms: raise ValueError("invalid budget") if self.batch_increment_ms <= 0: raise ValueError("invalid batch increment") mono_now = self.clock() startup_elapsed = 0.0 if self.observed_at_utc is not None: try: observed = datetime.fromisoformat(self.observed_at_utc.replace("Z", "+00:00")) if observed.tzinfo is None: raise ValueError("timezone required") wall_now = self.utc_now() if wall_now.tzinfo is None: raise ValueError("UTC clock must be aware") startup_elapsed = (wall_now.astimezone(timezone.utc) - observed.astimezone(timezone.utc)).total_seconds() except (TypeError, ValueError, OverflowError) as exc: raise BudgetExpired("startup", "parse_observed_at", "work") from exc if startup_elapsed < -2.0: raise BudgetExpired("startup", "observed_at_in_future", "work") startup_elapsed = max(0.0, startup_elapsed) self._start = mono_now - startup_elapsed self._batch_close_deadline = self._start + self.total_ms / 1000 self._batch_work_deadline = self._batch_close_deadline - self.close_reserve_ms / 1000 self._item_close_deadline: float | None = None self._item_work_deadline: float | None = None self._active_item = 0 self._item_scope_active = False self._previous_terminal: float | None = None @property def elapsed_ms(self) -> int: return max(0, round((self.clock() - self._start) * 1000)) def remaining_ms(self, *, close: bool = False, batch_close: bool = False) -> int: if close and batch_close: raise ValueError("close scopes are mutually exclusive") if batch_close: deadline = self._batch_close_deadline elif close: deadline = (self._item_close_deadline if self._item_scope_active else self._batch_close_deadline) else: deadline = (self._item_work_deadline if self._item_scope_active else self._batch_work_deadline) if deadline is None: raise ValueError("deadline unavailable") return max(0, int((deadline - self.clock()) * 1000)) def require(self, phase: str, operation: str, requested_ms: int, *, close: bool = False, batch_close: bool = False) -> int: remaining = self.remaining_ms(close=close, batch_close=batch_close) if remaining <= 0: scope = "batch_close" if batch_close else "item_close" if close else "work" raise BudgetExpired(phase, operation, scope) return min(remaining, max(1, requested_ms)) def checkpoint(self, phase: str, operation: str, *, close: bool = False, batch_close: bool = False) -> None: self.require(phase, operation, 1, close=close, batch_close=batch_close) def begin_item(self, index: int) -> None: """Bind I01 to observed+600s and each later item to prior item terminal+240s.""" if index < 1: raise ValueError("item order") if self._item_scope_active and index == self._active_item: return expected = 1 if self._active_item == 0 else self._active_item + 1 if index != expected or (index > 1 and self._previous_terminal is None): raise ValueError("previous item terminal required") now = self.clock() if index == 1: candidate = self._start + 600.0 else: candidate = self._previous_terminal + self.batch_increment_ms / 1000 self._item_close_deadline = min(candidate, self._batch_close_deadline) self._item_work_deadline = self._item_close_deadline - self.close_reserve_ms / 1000 self._active_item = index self._item_scope_active = True self.checkpoint(f"item_{index}", "begin") @property def active_item_deadline(self) -> float | None: return self._item_close_deadline @property def active_item_work_deadline(self) -> float | None: return self._item_work_deadline def complete_item(self, index: int) -> bool: """Record the prior terminal anchor and report whether it met item_close.""" if not self._item_scope_active or index != self._active_item: raise ValueError("active item required") now = self.clock() within_deadline = bool(self._item_close_deadline is not None and now < self._item_close_deadline) self._previous_terminal = now self._item_scope_active = False return within_deadline @property def work_deadline_reached(self) -> bool: deadline = (self._item_work_deadline if self._item_scope_active else self._batch_work_deadline) return bool(deadline is not None and self.clock() >= deadline) @property def close_deadline_reached(self) -> bool: deadline = (self._item_close_deadline if self._item_scope_active else self._batch_close_deadline) return bool(deadline is not None and self.clock() >= deadline) @property def batch_close_deadline_reached(self) -> bool: return self.clock() >= self._batch_close_deadline