Coverage for mcp/mission/checkpoints.py: 100%
35 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 15:07 +0000
1"""Pure cadence resolver for the Mission progress-checkpoint schedule.
3Two pure functions, both deliberately deterministic and side-effect-free:
5* :func:`should_evaluate_now` answers "should the engine produce a real
6 Verdict on this iteration, or emit a synthetic ``cadence_skip``?" given
7 the session, the current iteration index, and the wall-clock value the
8 caller has already measured. The function performs no I/O, reads no
9 clocks, and consults no globals — every input it needs is on the call
10 signature. That keeps the verdict-cascade unit-testable from the
11 outside without monkeypatching ``datetime.now``.
13* :func:`mark_checkpoint` records "we just produced a real Verdict at this
14 wall-clock value" by writing ``session["last_checkpoint_at"]`` in place.
15 The engine calls it after every Decide_Phase whose verdict was *not* a
16 cadence-skip — that is, every Decide_Phase where the loop actually
17 consulted the Criteria — so the next ``every_t_seconds`` check measures
18 the right window.
20Cadence semantics (per the validators in :mod:`mcp.mission.validation`):
22* ``every_iteration`` — every iteration evaluates. Always returns True.
23* ``every_n_iterations`` — evaluates on iterations whose 1-indexed
24 position is divisible by ``n``. Concretely the function returns True
25 iff ``(iteration_index + 1) % n == 0`` — so with ``n=3`` and a 0-indexed
26 ``iteration_index``, the evaluating iterations are 2, 5, 8, … (the
27 third, sixth, ninth iteration in the run). This matches Requirement
28 6.3's wording ``iteration_index % n == n - 1``: the two formulations
29 are algebraically identical and we use the ``+1`` form here because it
30 matches the operator's mental model of "every Nth iteration".
31* ``every_t_seconds`` — evaluates when ``now - last_checkpoint_at >= t``
32 seconds. The first call returns True (no prior checkpoint) so the loop
33 always produces a real Verdict on iteration 0 regardless of cadence.
34 Subsequent calls compare ``now`` against the stored timestamp.
35* ``on_event`` — evaluates when the most recent Iteration's Observation
36 carries an event whose ``event_name`` matches the cadence's configured
37 ``event_name``. A missing observation, an empty events list, or the
38 absence of any prior iteration all return False.
40Time-zone handling for ``every_t_seconds``: the stored
41``last_checkpoint_at`` is parsed with :meth:`datetime.fromisoformat`. If
42the parsed value has no tzinfo, we treat it as UTC — every other piece of
43the Mission state writes ISO-8601 UTC (the audit emitters use
44``datetime.now(UTC).isoformat()``), so a missing tzinfo is almost always
45the result of a manual fixture write rather than a real on-disk session.
46The caller is expected to pass a tz-aware ``now``; if both ends are
47naive the comparison still works because Python forbids subtracting an
48aware from a naive datetime, which would raise — and that's a bug we
49want loud rather than silent.
50"""
52from __future__ import annotations
54from datetime import UTC, datetime, timedelta
56from .types import SessionState
59def should_evaluate_now(
60 session: SessionState,
61 iteration_index: int,
62 now: datetime,
63) -> bool:
64 """Return True iff Decide_Phase should consult the Criteria this iteration.
66 The four cadence kinds are dispatched on
67 ``session["checkpoint_cadence"]["kind"]``. The validators in
68 :mod:`mcp.mission.validation` guarantee the kind is one of the four
69 supported values and that the kind-specific keys (``n``, ``t``,
70 ``event_name``) are present and well-typed, so this function does no
71 defensive validation of its own — it trusts the validated session and
72 fails noisily on a malformed payload.
73 """
74 cadence = session["checkpoint_cadence"]
75 kind = cadence["kind"]
77 if kind == "every_iteration":
78 # Every iteration evaluates — the loop's default behaviour and the
79 # cheapest cadence to reason about.
80 return True
82 if kind == "every_n_iterations":
83 # Validators guarantee ``n`` is a positive int. The +1 form matches
84 # the operator's "every Nth iteration" mental model: with n=3 the
85 # evaluating 0-indexed iterations are 2, 5, 8, … i.e. (idx+1) % n == 0.
86 n = cadence["n"]
87 return (iteration_index + 1) % n == 0
89 if kind == "every_t_seconds":
90 # First call has no prior checkpoint — evaluate so the loop always
91 # produces a real Verdict on iteration 0 regardless of cadence.
92 last_iso = session.get("last_checkpoint_at")
93 if not last_iso:
94 return True
95 last = datetime.fromisoformat(last_iso)
96 # Treat a missing tzinfo as UTC. Every Mission writer emits
97 # tz-aware ISO-8601 UTC; this branch covers hand-written fixtures
98 # and historical states that pre-date the convention.
99 if last.tzinfo is None:
100 last = last.replace(tzinfo=UTC)
101 t_seconds = cadence["t"]
102 return now - last >= timedelta(seconds=t_seconds)
104 if kind == "on_event":
105 # Need at least one completed iteration to have an Observation.
106 iterations = session.get("iterations") or []
107 if not iterations:
108 return False
109 latest = iterations[-1]
110 observation = latest.get("observation")
111 if not observation:
112 return False
113 events = observation.get("events") or []
114 target = cadence["event_name"]
115 # Match on the ``event_name`` field of any event entry. We don't
116 # constrain the rest of the event shape — the Observe_Phase
117 # contract carries arbitrary event payloads — so any entry whose
118 # ``event_name`` matches is sufficient to fire the cadence.
119 return any(
120 isinstance(event, dict) and event.get("event_name") == target for event in events
121 )
123 # Unreachable when validators have been applied. Surface the bad value
124 # rather than silently returning False so a malformed session shows up
125 # immediately in the caller's traceback.
126 raise ValueError(f"unknown checkpoint cadence kind: {kind!r}")
129def mark_checkpoint(session: SessionState, now: datetime) -> None:
130 """Record that a real (non-cadence-skip) Verdict just fired at ``now``.
132 Updates ``session["last_checkpoint_at"]`` in place to the ISO-8601
133 serialisation of ``now``. Called by the engine after every
134 Decide_Phase whose verdict was produced by consulting the Criteria,
135 so the next ``every_t_seconds`` check measures from the most recent
136 real checkpoint rather than from session start. Caller is responsible
137 for skipping this call on cadence-skip iterations.
138 """
139 session["last_checkpoint_at"] = now.isoformat()
142__all__ = ["mark_checkpoint", "should_evaluate_now"]