AgentEvalTool/backend/agenteval/evaluation/campaign_scheduler.py
sinohqb c6b102a9b5 feat(campaigns): add scheduling decision and child-Run spawning
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).
2026-07-30 12:06:31 +08:00

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)