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

1"""Pure cadence resolver for the Mission progress-checkpoint schedule. 

2 

3Two pure functions, both deliberately deterministic and side-effect-free: 

4 

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``. 

12 

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. 

19 

20Cadence semantics (per the validators in :mod:`mcp.mission.validation`): 

21 

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. 

39 

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""" 

51 

52from __future__ import annotations 

53 

54from datetime import UTC, datetime, timedelta 

55 

56from .types import SessionState 

57 

58 

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. 

65 

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"] 

76 

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 

81 

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 

88 

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) 

103 

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 ) 

122 

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}") 

127 

128 

129def mark_checkpoint(session: SessionState, now: datetime) -> None: 

130 """Record that a real (non-cadence-skip) Verdict just fired at ``now``. 

131 

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() 

140 

141 

142__all__ = ["mark_checkpoint", "should_evaluate_now"]