cutctx 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cutctx/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
cutctx/__init__.py ADDED
@@ -0,0 +1,105 @@
1
+ """CutCtx — the suite's one answer to "this transcript has outgrown the window it must fit".
2
+
3
+ Given a transcript and a token budget, CutCtx decides — deterministically, explicably, and without
4
+ touching a model, a database or a disk — which turns to keep, mask, summarize or drop, and applies
5
+ that decision to produce a compacted view plus an auditable account of what was done to it.
6
+
7
+ The shape is **two-phase**, and it is the thing to understand first:
8
+
9
+ >>> from cutctx import CompactionBudget, CompactionExecutor, DropOldestPolicy
10
+ >>> from cutctx import Role, Transcript, TranscriptTurn
11
+ >>> transcript = Transcript((
12
+ ... TranscriptTurn("s", Role.SYSTEM, "rules", 10),
13
+ ... TranscriptTurn("a", Role.USER, "old question", 40),
14
+ ... TranscriptTurn("b", Role.ASSISTANT, "old answer", 40),
15
+ ... ))
16
+ >>> plan = DropOldestPolicy().decide(transcript, CompactionBudget(60, protected_recent_turns=1))
17
+ >>> plan.tokens_before, plan.tokens_after_estimate, plan.budget_unmet
18
+ (90, 50, False)
19
+ >>> view = CompactionExecutor().apply(transcript, plan)
20
+ >>> view.transcript.turn_ids()
21
+ ('s', 'b')
22
+ >>> transcript.turn_ids() # the input is a value, and values do not change
23
+ ('s', 'a', 'b')
24
+
25
+ **plan → fulfil → apply.** The middle step exists because CutCtx never calls a model
26
+ (:doc:`ADR-0052 <adr>`). A plan that wants a span summarized carries a
27
+ :class:`SummarizationRequest`; the *application* fulfils it through its own governed inference
28
+ path — PromptCadence via LoadCoach, IdeaPress via its inference port — and hands the text back to
29
+ :meth:`~cutctx.executor.CompactionExecutor.apply` in ``summaries``. Skip the middle step and
30
+ :class:`SummaryMissing` says so by name, loudly, rather than quietly dropping a reduction the
31
+ plan's arithmetic already counted.
32
+
33
+ Anything not listed in ``__all__`` is private and may change without a version bump, whatever its
34
+ module happens to be named — :mod:`cutctx._invariants` included, though every policy routes
35
+ through it.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from cutctx.__about__ import __version__
41
+ from cutctx.errors import (
42
+ BudgetUnsatisfiable,
43
+ CompactionError,
44
+ PlanTranscriptMismatch,
45
+ SummaryMissing,
46
+ )
47
+ from cutctx.estimator import CharRatioEstimator, TokenEstimator
48
+ from cutctx.executor import CompactionExecutor
49
+ from cutctx.policies import (
50
+ DEFAULT_PLACEHOLDER,
51
+ GROUP_ID_PREFIX,
52
+ DropOldestPolicy,
53
+ ObservationMaskingPolicy,
54
+ PolicyChain,
55
+ SummarizingPolicy,
56
+ default_chain,
57
+ )
58
+ from cutctx.types import (
59
+ EMPTY_METADATA,
60
+ SUMMARY_TURN_ID_PREFIX,
61
+ Action,
62
+ CompactedTranscript,
63
+ CompactionBudget,
64
+ CompactionPlan,
65
+ CompactionPolicy,
66
+ CompactionReport,
67
+ Role,
68
+ SummarizationRequest,
69
+ Transcript,
70
+ TranscriptTurn,
71
+ TurnAction,
72
+ TurnReplacement,
73
+ )
74
+
75
+ __all__ = [
76
+ "default_chain",
77
+ "SummarizingPolicy",
78
+ "PolicyChain",
79
+ "ObservationMaskingPolicy",
80
+ "GROUP_ID_PREFIX",
81
+ "DEFAULT_PLACEHOLDER",
82
+ "EMPTY_METADATA",
83
+ "SUMMARY_TURN_ID_PREFIX",
84
+ "Action",
85
+ "BudgetUnsatisfiable",
86
+ "CharRatioEstimator",
87
+ "CompactedTranscript",
88
+ "CompactionBudget",
89
+ "CompactionError",
90
+ "CompactionExecutor",
91
+ "CompactionPlan",
92
+ "CompactionPolicy",
93
+ "CompactionReport",
94
+ "DropOldestPolicy",
95
+ "PlanTranscriptMismatch",
96
+ "Role",
97
+ "SummarizationRequest",
98
+ "SummaryMissing",
99
+ "TokenEstimator",
100
+ "Transcript",
101
+ "TranscriptTurn",
102
+ "TurnAction",
103
+ "TurnReplacement",
104
+ "__version__",
105
+ ]
cutctx/_invariants.py ADDED
@@ -0,0 +1,529 @@
1
+ """The rules of spec §11, in one place, enforced by validation and not by convention.
2
+
3
+ **Why this module is private and still the most public thing in the package.** Every policy
4
+ CutCtx will ever ship routes through it: row E1 adds observation masking, summarization and
5
+ ``PolicyChain`` against it, PromptCadence wires it in at row I1, and IdeaPress's prose reduction
6
+ order becomes a chain over it at row J3. It is where a rule is written once, so that the fourth
7
+ policy cannot get it subtly different from the first.
8
+
9
+ The invariants
10
+ --------------
11
+
12
+ 1. **A plan is a total function over its transcript.** ``actions`` names every turn exactly once,
13
+ in transcript order. A turn a plan forgot is the silent bug: it would be neither kept nor
14
+ dropped, and what happened to it would depend on which loop read the plan.
15
+ 2. **The untouchable set is untouched** (contract 2). Every ``SYSTEM`` turn, every ``pinned``
16
+ turn, and the last ``protected_recent_turns`` turns take :attr:`~cutctx.types.Action.KEEP` —
17
+ not ``MASK``, not ``SUMMARIZE``, not ``DROP``.
18
+ 3. **A tool exchange travels together** (contract 3). See :func:`exchanges`.
19
+ 4. **A summary group and its request agree** exactly, in both directions.
20
+ 5. **The arithmetic follows from the actions.** ``tokens_before`` and ``tokens_after_estimate``
21
+ are recomputed here and compared, so a plan cannot carry a number its own actions do not
22
+ produce, and a report that copies them cannot drift from a plan that earned them.
23
+ 6. **The budget outcome is trichotomous and honest.** Either the untouchable set alone exceeds
24
+ the budget and :class:`~cutctx.errors.BudgetUnsatisfiable` is raised before any plan exists, or
25
+ a plan exists and ``budget_unmet`` is exactly ``tokens_after_estimate > max_tokens``. There is
26
+ no fourth case, and no plan that claims to fit while being over.
27
+
28
+ How contract 3 is read
29
+ ----------------------
30
+
31
+ Contract 3 says a tool call and its result are "masked together, summarized in the same group, or
32
+ dropped as a pair — never separated, because an orphaned call or result is a malformed transcript
33
+ to every provider". The operative prohibition is *separation*, and what separates is **removal**:
34
+ :attr:`~cutctx.types.Action.KEEP` and :attr:`~cutctx.types.Action.MASK` leave the turn in the view
35
+ at its own position, so masking a tool result beside a kept call orphans nothing.
36
+
37
+ Read the other way it would forbid the masking policy the spec itself ships — "masks TOOL-result
38
+ bodies beyond the N most recent … reasoning stays, bulk goes" (spec §7) masks a result while
39
+ keeping the assistant turn that called it. So the rule enforced here is:
40
+
41
+ Within one exchange, either every member is retained (``KEEP``/``MASK``, mixed freely), or
42
+ every member is removed by the **same** action — all ``DROP``, or all ``SUMMARIZE`` into the
43
+ **same** group.
44
+
45
+ That is stricter than "never orphaned" by one step (it also forbids dropping half an exchange and
46
+ summarizing the other half, which orphans nothing but describes nothing either), and it is exactly
47
+ the three cases the contract enumerates.
48
+
49
+ Protection propagates through an exchange
50
+ -----------------------------------------
51
+
52
+ A consequence worth stating, because it is not obvious and it is load-bearing: if **any** member
53
+ of an exchange is untouchable, no member may be removed. A tool result sitting in the protected
54
+ tail whose call is forty turns back makes that call undroppable — the pair would be separated
55
+ otherwise. :func:`removable_turn_ids` is the set that survives this closure, and it is what a
56
+ policy may act on.
57
+
58
+ The closure does **not** move the :class:`~cutctx.errors.BudgetUnsatisfiable` threshold. That
59
+ threshold is the strict untouchable set of contract 2, whose turns can never be reduced by any
60
+ means; a turn locked only by the closure is still maskable, so refusing on its account would
61
+ refuse budgets that row E1's policies can meet.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ from typing import TYPE_CHECKING
67
+
68
+ from baseaicore import ValidationError
69
+
70
+ from cutctx.errors import BudgetUnsatisfiable
71
+ from cutctx.types import Action, CompactionPlan, Role
72
+
73
+ if TYPE_CHECKING:
74
+ from collections.abc import Iterable, Mapping, Sequence
75
+
76
+ from cutctx.types import CompactionBudget, SummarizationRequest, Transcript, TurnAction
77
+
78
+ __all__ = [
79
+ "build_plan",
80
+ "exchanges",
81
+ "removable_turn_ids",
82
+ "require_satisfiable_budget",
83
+ "untouchable_tokens",
84
+ "untouchable_turn_ids",
85
+ "validate_plan",
86
+ ]
87
+
88
+ _REMOVAL_ACTIONS = frozenset({Action.DROP, Action.SUMMARIZE})
89
+ """The actions that take a turn out of the view, and so are the ones that can orphan a pair."""
90
+
91
+ _ID_SAMPLE_LIMIT = 10
92
+ """How many ids an error's ``details`` carries. A 2 000-turn mismatch is not made clearer by
93
+ 2 000 ids, and ``details`` travels into API error envelopes."""
94
+
95
+
96
+ def exchanges(transcript: Transcript) -> tuple[tuple[str, ...], ...]:
97
+ """Return the tool exchanges of a transcript: the units that must travel together.
98
+
99
+ An **exchange** is every turn sharing one ``tool_call_id`` — the assistant turn that issued
100
+ the calls and every ``TOOL`` turn carrying a result, however far apart they sit. A turn with
101
+ no ``tool_call_id`` is an exchange of one, and so is a result whose call is already gone.
102
+
103
+ ``tool_call_id`` is a correlation id rather than a per-call one, which is what makes this a
104
+ partition rather than a graph: one assistant turn may issue several calls, so the call/result
105
+ relation is not one-to-one, and the transitive closure of a per-call relation would be this
106
+ same partition anyway (see :class:`~cutctx.types.TranscriptTurn`, "Multi-call turns"). A
107
+ partition needs no traversal order, and so has nothing for determinism to pin down.
108
+
109
+ Args:
110
+ transcript: The transcript to partition.
111
+
112
+ Returns:
113
+ The exchanges, each a tuple of turn ids in transcript order, ordered by the position of
114
+ each exchange's earliest turn. Every turn id appears in exactly one exchange.
115
+ """
116
+ members: dict[str, list[str]] = {}
117
+ order: list[str] = []
118
+ for turn in transcript.turns:
119
+ # Namespaced so that a tool_call_id equal to some other turn's id cannot merge two
120
+ # exchanges that share nothing.
121
+ key = (
122
+ f"c\x00{turn.tool_call_id}" if turn.tool_call_id is not None else f"t\x00{turn.turn_id}"
123
+ )
124
+ if key not in members:
125
+ members[key] = []
126
+ order.append(key)
127
+ members[key].append(turn.turn_id)
128
+ return tuple(tuple(members[key]) for key in order)
129
+
130
+
131
+ def untouchable_turn_ids(transcript: Transcript, budget: CompactionBudget) -> frozenset[str]:
132
+ """Return the turns contract 2 forbids masking, summarizing or dropping.
133
+
134
+ Three sources, unioned: every :attr:`~cutctx.types.Role.SYSTEM` turn, every ``pinned`` turn,
135
+ and the last ``budget.protected_recent_turns`` turns of the transcript.
136
+
137
+ Every ``SYSTEM`` turn, not "the" system turn: the spec's singular describes the usual case,
138
+ and a transcript that carries two — an application that appends an instruction mid-run — has
139
+ two turns whose loss would change what the model was told. Protecting both is the reading that
140
+ cannot silently discard instructions.
141
+
142
+ Args:
143
+ transcript: The transcript.
144
+ budget: The budget, for its ``protected_recent_turns``.
145
+
146
+ Returns:
147
+ The untouchable turn ids. Possibly the whole transcript; possibly empty.
148
+ """
149
+ protected_from = len(transcript.turns) - budget.protected_recent_turns
150
+ return frozenset(
151
+ turn.turn_id
152
+ for index, turn in enumerate(transcript.turns)
153
+ if turn.role is Role.SYSTEM or turn.pinned or index >= protected_from
154
+ )
155
+
156
+
157
+ def untouchable_tokens(transcript: Transcript, budget: CompactionBudget) -> int:
158
+ """Return the estimated tokens of the untouchable set — the floor no policy can go below."""
159
+ untouchable = untouchable_turn_ids(transcript, budget)
160
+ return sum(turn.token_estimate for turn in transcript.turns if turn.turn_id in untouchable)
161
+
162
+
163
+ def removable_turn_ids(transcript: Transcript, budget: CompactionBudget) -> frozenset[str]:
164
+ """Return the turns a policy may remove — the untouchable set closed over tool exchanges.
165
+
166
+ A turn is removable when its whole exchange is free of untouchable turns. Removing any other
167
+ turn would either touch the untouchable set (contract 2) or separate a pair (contract 3).
168
+
169
+ This is the set every policy should work from. Masking is *not* limited by it — a maskable
170
+ turn is any turn outside :func:`untouchable_turn_ids`, since masking removes nothing.
171
+
172
+ Args:
173
+ transcript: The transcript.
174
+ budget: The budget, for its ``protected_recent_turns``.
175
+
176
+ Returns:
177
+ The removable turn ids.
178
+ """
179
+ untouchable = untouchable_turn_ids(transcript, budget)
180
+ removable: set[str] = set()
181
+ for exchange in exchanges(transcript):
182
+ if not untouchable.intersection(exchange):
183
+ removable.update(exchange)
184
+ return frozenset(removable)
185
+
186
+
187
+ def require_satisfiable_budget(transcript: Transcript, budget: CompactionBudget) -> None:
188
+ """Refuse a budget smaller than the turns no policy is allowed to reduce.
189
+
190
+ Called before a policy does any work, and again when the plan is constructed, so a policy that
191
+ forgets it still cannot produce a plan for a contradictory budget.
192
+
193
+ Args:
194
+ transcript: The transcript.
195
+ budget: The budget.
196
+
197
+ Raises:
198
+ BudgetUnsatisfiable: If the untouchable turns' combined estimate exceeds
199
+ ``budget.max_tokens``. ``details`` names **both** numbers and the count of turns
200
+ behind the first, because "budget too small" without the figures leaves an operator
201
+ unable to choose between raising the budget and unpinning something. Equality is
202
+ satisfiable: a budget that exactly fits the untouchable set admits the plan that keeps
203
+ it and drops everything else.
204
+ """
205
+ floor = untouchable_tokens(transcript, budget)
206
+ if floor > budget.max_tokens:
207
+ untouchable = untouchable_turn_ids(transcript, budget)
208
+ raise BudgetUnsatisfiable(
209
+ f"The untouchable turns alone are estimated at {floor} tokens, which exceeds the "
210
+ f"budget of {budget.max_tokens}. {len(untouchable)} turns are untouchable: every "
211
+ f"SYSTEM turn, every pinned turn, and the last {budget.protected_recent_turns}. "
212
+ "Raise max_tokens, unpin turns, or shorten the protected tail — no compaction can "
213
+ "fit this budget without violating an invariant.",
214
+ details={
215
+ "untouchable_tokens": floor,
216
+ "max_tokens": budget.max_tokens,
217
+ "untouchable_turn_count": len(untouchable),
218
+ "protected_recent_turns": budget.protected_recent_turns,
219
+ },
220
+ )
221
+
222
+
223
+ def estimate_after(
224
+ transcript: Transcript,
225
+ actions: Sequence[TurnAction],
226
+ summarization_requests: Sequence[SummarizationRequest],
227
+ ) -> int:
228
+ """Return the estimated tokens of the view these actions would produce.
229
+
230
+ The single computation of the "after" figure. The plan carries its result, the report copies
231
+ the plan's, and the executor builds a view whose per-turn estimates add up to the same number
232
+ — one derivation, so the drift the development plan names as this phase's likely failure mode
233
+ has nowhere to happen.
234
+
235
+ Per action: ``KEEP`` contributes the turn's own estimate, ``MASK`` its replacement's,
236
+ ``DROP`` and ``SUMMARIZE`` nothing. Each summarization request contributes its
237
+ ``target_tokens`` once, for the one summary turn it becomes.
238
+
239
+ Args:
240
+ transcript: The transcript planned over.
241
+ actions: One action per turn.
242
+ summarization_requests: The plan's requests.
243
+
244
+ Returns:
245
+ The estimated token cost of the applied view.
246
+ """
247
+ by_id = {turn.turn_id: turn for turn in transcript.turns}
248
+ total = 0
249
+ for action in actions:
250
+ if action.action is Action.KEEP:
251
+ total += by_id[action.turn_id].token_estimate
252
+ elif action.replacement is not None:
253
+ total += action.replacement.token_estimate
254
+ return total + sum(request.target_tokens for request in summarization_requests)
255
+
256
+
257
+ def build_plan(
258
+ *,
259
+ transcript: Transcript,
260
+ budget: CompactionBudget,
261
+ actions: Sequence[TurnAction],
262
+ summarization_requests: Sequence[SummarizationRequest] = (),
263
+ policy_name: str,
264
+ policy_version: str,
265
+ estimator_ratio: float | None = None,
266
+ ) -> CompactionPlan:
267
+ """Build a validated plan from a policy's decisions — the way every policy makes a plan.
268
+
269
+ Does the two things no policy should do for itself: the token arithmetic
270
+ (:func:`estimate_after`) and the ``budget_unmet`` determination. A policy that constructs a
271
+ :class:`~cutctx.types.CompactionPlan` directly is validated identically — validation is on the
272
+ constructor, not here — but it would be writing figures this function derives and the
273
+ validator immediately recomputes, so the only thing direct construction can achieve is being
274
+ rejected.
275
+
276
+ Args:
277
+ transcript: The transcript planned over.
278
+ budget: The budget planned against.
279
+ actions: One action per turn, in transcript order.
280
+ summarization_requests: The requests the caller must fulfil, ordered by the position of
281
+ each group's earliest turn.
282
+ policy_name: The deciding policy's name.
283
+ policy_version: The deciding policy's version.
284
+ estimator_ratio: The character-ratio default's ``chars_per_token`` when it produced an
285
+ estimate on this plan; ``None`` otherwise.
286
+
287
+ Returns:
288
+ The validated plan.
289
+
290
+ Raises:
291
+ BudgetUnsatisfiable: If the untouchable turns alone exceed the budget.
292
+ ValidationError: If any invariant of spec §11 is broken.
293
+ """
294
+ after = estimate_after(transcript, actions, summarization_requests)
295
+ return CompactionPlan(
296
+ actions=tuple(actions),
297
+ summarization_requests=tuple(summarization_requests),
298
+ tokens_before=transcript.token_estimate(),
299
+ tokens_after_estimate=after,
300
+ policy_name=policy_name,
301
+ policy_version=policy_version,
302
+ estimator_ratio=estimator_ratio,
303
+ budget_unmet=after > budget.max_tokens,
304
+ transcript=transcript,
305
+ budget=budget,
306
+ )
307
+
308
+
309
+ def validate_plan(plan: CompactionPlan, transcript: Transcript, budget: CompactionBudget) -> None:
310
+ """Refuse a plan that breaks any rule in this module's list.
311
+
312
+ Called from :meth:`cutctx.types.CompactionPlan.__post_init__`, which is why there is no
313
+ unvalidated plan anywhere: the transcript and budget are constructor arguments, so a caller
314
+ cannot reach a plan object without handing over what it takes to check it.
315
+
316
+ Args:
317
+ plan: The plan under construction.
318
+ transcript: The transcript it is a plan for.
319
+ budget: The budget it is a plan against.
320
+
321
+ Raises:
322
+ BudgetUnsatisfiable: If the untouchable turns alone exceed the budget. Checked first, so
323
+ an impossible budget is reported as impossible rather than as whatever the policy did
324
+ about it.
325
+ ValidationError: If the plan does not cover the transcript exactly once in order, acts on
326
+ an untouchable turn, splits a tool exchange, disagrees with its own summarization
327
+ requests, or carries arithmetic its actions do not produce.
328
+ """
329
+ require_satisfiable_budget(transcript, budget)
330
+ _validate_coverage(plan.actions, transcript)
331
+ by_turn = {action.turn_id: action for action in plan.actions}
332
+ _validate_untouchable(by_turn, transcript, budget)
333
+ _validate_exchanges(by_turn, transcript)
334
+ _validate_summary_groups(plan, transcript)
335
+ _validate_arithmetic(plan, transcript, budget)
336
+
337
+
338
+ def _validate_coverage(actions: Sequence[TurnAction], transcript: Transcript) -> None:
339
+ """Rule 1: the actions name every turn exactly once, in transcript order."""
340
+ planned = tuple(action.turn_id for action in actions)
341
+ expected = transcript.turn_ids()
342
+ if planned == expected:
343
+ return
344
+ unplanned = sorted(set(expected) - set(planned))
345
+ unknown = sorted(set(planned) - set(expected))
346
+ repeated = sorted({turn_id for turn_id in planned if planned.count(turn_id) > 1})
347
+ raise ValidationError(
348
+ "A plan must name every turn of its transcript exactly once, in transcript order; this "
349
+ f"one names {len(planned)} actions for {len(expected)} turns "
350
+ f"({len(unplanned)} unplanned, {len(unknown)} unknown, {len(repeated)} repeated"
351
+ + (", order differs)" if not (unplanned or unknown or repeated) else ")")
352
+ + ". A turn the plan forgot would be neither kept nor dropped.",
353
+ details={
354
+ # `unplanned`: in the transcript, forgotten by the plan. `unknown`: named by the plan,
355
+ # absent from the transcript. Two different defects with two different fixes.
356
+ "unplanned_turn_ids": unplanned[:_ID_SAMPLE_LIMIT],
357
+ "unknown_turn_ids": unknown[:_ID_SAMPLE_LIMIT],
358
+ "repeated_turn_ids": repeated[:_ID_SAMPLE_LIMIT],
359
+ },
360
+ )
361
+
362
+
363
+ def _validate_untouchable(
364
+ by_turn: Mapping[str, TurnAction], transcript: Transcript, budget: CompactionBudget
365
+ ) -> None:
366
+ """Rule 2: every untouchable turn is kept, whole and in place."""
367
+ touched = sorted(
368
+ turn_id
369
+ for turn_id in untouchable_turn_ids(transcript, budget)
370
+ if by_turn[turn_id].action is not Action.KEEP
371
+ )
372
+ if touched:
373
+ raise ValidationError(
374
+ f"{len(touched)} untouchable turns are acted on: a SYSTEM turn, a pinned turn and "
375
+ f"any of the last {budget.protected_recent_turns} turns must be KEEP (spec §11 "
376
+ "contract 2). A budget that cannot be met without touching them raises "
377
+ "BudgetUnsatisfiable instead.",
378
+ details={
379
+ "touched_turn_ids": touched[:_ID_SAMPLE_LIMIT],
380
+ "actions": [by_turn[t].action.value for t in touched[:_ID_SAMPLE_LIMIT]],
381
+ "protected_recent_turns": budget.protected_recent_turns,
382
+ },
383
+ )
384
+
385
+
386
+ def _validate_exchanges(by_turn: Mapping[str, TurnAction], transcript: Transcript) -> None:
387
+ """Rule 3: within an exchange, all retained, or all removed by one identical action."""
388
+ for exchange in exchanges(transcript):
389
+ removed = [t for t in exchange if by_turn[t].action in _REMOVAL_ACTIONS]
390
+ if not removed:
391
+ continue
392
+ if len(removed) != len(exchange):
393
+ retained = [t for t in exchange if t not in set(removed)]
394
+ raise ValidationError(
395
+ f"A tool exchange of {len(exchange)} turns is split: {len(removed)} removed, "
396
+ f"{len(retained)} retained. An orphaned call or result is a malformed transcript "
397
+ "to every provider (spec §11 contract 3), so an exchange is removed whole or not "
398
+ "at all.",
399
+ details={
400
+ "removed_turn_ids": sorted(removed)[:_ID_SAMPLE_LIMIT],
401
+ "retained_turn_ids": sorted(retained)[:_ID_SAMPLE_LIMIT],
402
+ },
403
+ )
404
+ signatures = {(by_turn[t].action, by_turn[t].summary_group) for t in exchange}
405
+ if len(signatures) != 1:
406
+ raise ValidationError(
407
+ f"A tool exchange of {len(exchange)} turns is removed by more than one action or "
408
+ "into more than one summary group. Contract 3's three cases are 'masked together, "
409
+ "summarized in the same group, or dropped as a pair' — a half-dropped, "
410
+ "half-summarized exchange is none of them.",
411
+ details={
412
+ "turn_ids": sorted(exchange)[:_ID_SAMPLE_LIMIT],
413
+ "actions": sorted({by_turn[t].action.value for t in exchange}),
414
+ "summary_groups": sorted({by_turn[t].summary_group or "" for t in exchange})[
415
+ :_ID_SAMPLE_LIMIT
416
+ ],
417
+ },
418
+ )
419
+
420
+
421
+ def _validate_summary_groups(plan: CompactionPlan, transcript: Transcript) -> None:
422
+ """Rule 4: every group named by an action has a request, covering exactly those turns."""
423
+ requests = plan.summarization_requests
424
+ group_ids = [request.group_id for request in requests]
425
+ if len(set(group_ids)) != len(group_ids):
426
+ raise ValidationError(
427
+ "A plan's summarization requests must have distinct group_ids; a repeat would make "
428
+ "the summaries mapping ambiguous at apply time.",
429
+ details={"group_ids": sorted(group_ids)[:_ID_SAMPLE_LIMIT]},
430
+ )
431
+
432
+ planned_groups: dict[str, list[str]] = {}
433
+ for action in plan.actions:
434
+ if action.summary_group is not None:
435
+ planned_groups.setdefault(action.summary_group, []).append(action.turn_id)
436
+
437
+ if set(planned_groups) != set(group_ids):
438
+ raise ValidationError(
439
+ "Every summary group named by an action needs a SummarizationRequest, and every "
440
+ "request needs turns; the two sets disagree. A group without a request could not be "
441
+ "fulfilled; a request without turns would spend a model call to fold nothing.",
442
+ details={
443
+ "groups_without_request": sorted(set(planned_groups) - set(group_ids))[
444
+ :_ID_SAMPLE_LIMIT
445
+ ],
446
+ "requests_without_turns": sorted(set(group_ids) - set(planned_groups))[
447
+ :_ID_SAMPLE_LIMIT
448
+ ],
449
+ },
450
+ )
451
+
452
+ for request in requests:
453
+ if tuple(planned_groups[request.group_id]) != request.turn_ids:
454
+ raise ValidationError(
455
+ f"Summarization request {request.group_id!r} covers turns the plan's actions do "
456
+ "not, or covers them in another order. The request and the actions are two "
457
+ "statements of one fact and must agree exactly, in transcript order.",
458
+ details={
459
+ "group_id": request.group_id,
460
+ "request_turn_ids": list(request.turn_ids)[:_ID_SAMPLE_LIMIT],
461
+ "action_turn_ids": planned_groups[request.group_id][:_ID_SAMPLE_LIMIT],
462
+ },
463
+ )
464
+
465
+ _validate_summary_turn_ids(requests, transcript.turn_ids())
466
+
467
+ order = {turn_id: index for index, turn_id in enumerate(transcript.turn_ids())}
468
+ positions = [order[request.turn_ids[0]] for request in requests]
469
+ if positions != sorted(positions):
470
+ raise ValidationError(
471
+ "Summarization requests must be ordered by the position of each group's earliest "
472
+ "turn. Their order is part of the plan's bytes, and a plan whose order came from a "
473
+ "dict's insertion history is not byte-identical on re-derivation (contract 4).",
474
+ details={"group_ids": list(group_ids)[:_ID_SAMPLE_LIMIT]},
475
+ )
476
+
477
+
478
+ def _validate_summary_turn_ids(
479
+ requests: Iterable[SummarizationRequest], existing: Iterable[str]
480
+ ) -> None:
481
+ """Rule 4, continued: a summary turn's derived id must be free."""
482
+ taken = set(existing)
483
+ collisions = sorted(
484
+ request.group_id for request in requests if request.summary_turn_id in taken
485
+ )
486
+ if collisions:
487
+ raise ValidationError(
488
+ f"{len(collisions)} summarization groups would produce a summary turn whose id "
489
+ "already belongs to a turn in the transcript. The summary turn's id is derived from "
490
+ "the group id rather than generated, because a counter or a random source would make "
491
+ "two applications of one plan differ (contract 4) — so the collision is refused here "
492
+ "instead.",
493
+ details={"group_ids": collisions[:_ID_SAMPLE_LIMIT]},
494
+ )
495
+
496
+
497
+ def _validate_arithmetic(
498
+ plan: CompactionPlan, transcript: Transcript, budget: CompactionBudget
499
+ ) -> None:
500
+ """Rules 5 and 6: the figures follow from the actions, and the verdict follows from them."""
501
+ before = transcript.token_estimate()
502
+ if plan.tokens_before != before:
503
+ raise ValidationError(
504
+ f"tokens_before is {plan.tokens_before} but the transcript's turns are estimated at "
505
+ f"{before}. The 'before' figure is the transcript's own sum, not a policy's opinion.",
506
+ details={"declared": plan.tokens_before, "transcript": before},
507
+ )
508
+ after = estimate_after(transcript, plan.actions, plan.summarization_requests)
509
+ if plan.tokens_after_estimate != after:
510
+ raise ValidationError(
511
+ f"tokens_after_estimate is {plan.tokens_after_estimate} but the plan's own actions "
512
+ f"produce a view estimated at {after}. A report copies these figures from the plan, "
513
+ "so a plan allowed to carry a figure its actions do not produce is a report that "
514
+ "silently misstates what the model was shown.",
515
+ details={"declared": plan.tokens_after_estimate, "derived": after},
516
+ )
517
+ unmet = after > budget.max_tokens
518
+ if plan.budget_unmet != unmet:
519
+ raise ValidationError(
520
+ f"budget_unmet is {plan.budget_unmet} but the plan's estimate of {after} tokens "
521
+ f"against a budget of {budget.max_tokens} makes it {unmet}. The flag is derived, not "
522
+ "declared: a plan that could claim to fit while being over is exactly the silent "
523
+ "truncation ADR-0023 refuses.",
524
+ details={
525
+ "declared": plan.budget_unmet,
526
+ "tokens_after_estimate": after,
527
+ "max_tokens": budget.max_tokens,
528
+ },
529
+ )