Add the pure scheduling seam (campaign_scheduler.decide_schedule) that, given a static plan and window-clock offset, decides which plan entries are due and whether the window ended — mirroring judgement.combine_case_outcome, with time_scale confined to the clock mapping so it never touches judgement/report. The campaign_runner shell maps injected elapsed time to a window offset, spawns due child Runs through the existing EvalEngine.run(existing_run=...) path with campaign_id + RunTrigger.CAMPAIGN, and persists spawned-entry indices per entry for idempotent, restart-recoverable progress. No auto loop yet (ticket 03).
66 lines
2.2 KiB
Python
66 lines
2.2 KiB
Python
"""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)
|