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 +1 -0
- cutctx/__init__.py +105 -0
- cutctx/_invariants.py +529 -0
- cutctx/errors.py +89 -0
- cutctx/estimator.py +78 -0
- cutctx/executor.py +238 -0
- cutctx/policies/__init__.py +32 -0
- cutctx/policies/chain.py +401 -0
- cutctx/policies/drop_oldest.py +98 -0
- cutctx/policies/masking.py +242 -0
- cutctx/policies/summarizing.py +302 -0
- cutctx/py.typed +0 -0
- cutctx/types.py +734 -0
- cutctx-0.1.0.dist-info/METADATA +167 -0
- cutctx-0.1.0.dist-info/RECORD +17 -0
- cutctx-0.1.0.dist-info/WHEEL +4 -0
- cutctx-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
+
)
|