"""Campaign scheduling decision — pure functions (no I/O). This is the key testable seam for the durable scheduler (ADR-0003), mirroring ``judgement.combine_case_outcome``. Given a campaign's static plan and the current window-clock position, it decides which plan entries are due to spawn now and whether the window has ended. The async loop and Run-spawning side effects live in ``campaign_runner``; this module never touches the database, the clock, or ``time_scale`` semantics beyond the offset mapping below. Idempotency is the caller's responsibility: it passes the set of already-spawned plan-entry indices, and this function excludes them from the due list. """ from dataclasses import dataclass from typing import Iterable from agenteval.models import CampaignPlanEntry def clock_offset(*, elapsed_seconds: float, time_scale: float) -> float: """Map real elapsed wall-clock time to a window offset. ``time_scale`` only changes *when* an entry becomes due — 1.0 is real wall clock (production), larger compresses the window (development). It never touches judgement or report semantics. """ return elapsed_seconds * time_scale @dataclass(frozen=True) class DueEntry: """A plan entry that is due to spawn, tagged with its plan index.""" index: int entry: CampaignPlanEntry @dataclass(frozen=True) class ScheduleDecision: """What the scheduler should do at one clock position.""" due: tuple[DueEntry, ...] finished: bool def decide_schedule( *, plan: list[CampaignPlanEntry], window_seconds: int, clock_offset_seconds: float, spawned_indices: Iterable[int] = (), ) -> ScheduleDecision: """Decide which plan entries are due at ``clock_offset_seconds``. An entry is due once the clock reaches its offset and it hasn't been spawned yet. ``finished`` is True once the clock reaches the window end. """ already = set(spawned_indices) due = tuple( DueEntry(index=i, entry=entry) for i, entry in enumerate(plan) if i not in already and entry.offset_seconds <= clock_offset_seconds ) finished = clock_offset_seconds >= window_seconds return ScheduleDecision(due=due, finished=finished)