pi-fold 0.1.0 → 1.0.0
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.
- package/README.md +97 -7
- package/extensions/active-context.ts +2252 -182
- package/extensions/lib/advisory.ts +171 -14
- package/extensions/lib/canonical.ts +20 -0
- package/extensions/lib/curation.ts +437 -0
- package/extensions/lib/folding.ts +468 -22
- package/extensions/lib/instrumentation.ts +333 -0
- package/extensions/lib/measurement.ts +111 -0
- package/extensions/lib/persistence.ts +158 -2
- package/extensions/lib/policy.ts +380 -4
- package/extensions/lib/scheduling.ts +923 -0
- package/extensions/lib/selection.ts +437 -12
- package/extensions/lib/surfacing.ts +349 -0
- package/extensions/lib/tool-surface.ts +52 -3
- package/extensions/lib/transcript.ts +58 -4
- package/package.json +2 -2
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import { objectRefKey } from "../json.ts";
|
|
2
|
+
import {
|
|
3
|
+
bytes,
|
|
4
|
+
clone,
|
|
5
|
+
messageRole,
|
|
6
|
+
} from "./canonical.ts";
|
|
7
|
+
import {
|
|
8
|
+
commitPreparedFold,
|
|
9
|
+
pinnedPeekMass,
|
|
10
|
+
prepareFold,
|
|
11
|
+
renderFold,
|
|
12
|
+
renderFoldParts,
|
|
13
|
+
setFoldProjectionState,
|
|
14
|
+
} from "./folding.ts";
|
|
15
|
+
import type { AutomaticRungSelection } from "./folding.ts";
|
|
16
|
+
import {
|
|
17
|
+
foldInterval,
|
|
18
|
+
hardFenceRatio,
|
|
19
|
+
orderedRoots,
|
|
20
|
+
refsProtected,
|
|
21
|
+
toolRefsProtected,
|
|
22
|
+
} from "./measurement.ts";
|
|
23
|
+
import {
|
|
24
|
+
flattenFoldRefs,
|
|
25
|
+
foldIdFor,
|
|
26
|
+
parsePendingMarks,
|
|
27
|
+
pendingMarkKey,
|
|
28
|
+
} from "./persistence.ts";
|
|
29
|
+
import { currentTurnRefKeys } from "./transcript.ts";
|
|
30
|
+
import {
|
|
31
|
+
EPOCH_COMMIT_TARGET_WINDOW_SHARE,
|
|
32
|
+
EPOCH_ELIGIBLE_SHARE_COMMIT_THRESHOLD,
|
|
33
|
+
EPOCH_MAX_TOPUP_MARKS,
|
|
34
|
+
ESTIMATED_BYTES_PER_TOKEN,
|
|
35
|
+
MAX_WEDGE_ABSORB_TOKENS,
|
|
36
|
+
ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES,
|
|
37
|
+
MAX_PENDING_MARKS,
|
|
38
|
+
MAX_UNMARKED_CANDIDATES,
|
|
39
|
+
} from "./policy.ts";
|
|
40
|
+
import type {
|
|
41
|
+
ActiveContextSnapshot,
|
|
42
|
+
ActiveContextState,
|
|
43
|
+
FoldCandidate,
|
|
44
|
+
MarkOrigin,
|
|
45
|
+
PendingFoldMark,
|
|
46
|
+
PendingMark,
|
|
47
|
+
} from "./policy.ts";
|
|
48
|
+
import {
|
|
49
|
+
automaticToolBrief,
|
|
50
|
+
candidateSourceRefs,
|
|
51
|
+
chapterRangeIsUnitAligned,
|
|
52
|
+
partsForRange,
|
|
53
|
+
spanBytes,
|
|
54
|
+
deterministicChapterCandidateBrief,
|
|
55
|
+
deterministicConsolidationBrief,
|
|
56
|
+
resultCall,
|
|
57
|
+
selectAutomaticToolBatch,
|
|
58
|
+
toolCallArguments,
|
|
59
|
+
} from "./selection.ts";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Two-phase fold scheduling.
|
|
63
|
+
*
|
|
64
|
+
* A provider prefix cache is positional: editing the middle of the window
|
|
65
|
+
* invalidates every byte after the edit, so per-turn folding pays a full uncached
|
|
66
|
+
* prefix over and over. Nothing here changes what a fold MEANS. A fold decision
|
|
67
|
+
* becomes a MARK, which moves no projection byte, and a later COMMIT applies every
|
|
68
|
+
* pending mark through the same preparation and commit machinery an immediate fold
|
|
69
|
+
* uses, in one rewrite.
|
|
70
|
+
*
|
|
71
|
+
* Every function is deterministic: transcript ordinals and positions only, never a
|
|
72
|
+
* wall clock and never randomness.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
export function pendingMarks(state: Pick<ActiveContextState, "pendingMarks">): PendingMark[] {
|
|
76
|
+
return state.pendingMarks ? clone(state.pendingMarks) : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Property ORDER is load-bearing: the state digest is a stable stringify, so an
|
|
81
|
+
* appended key would replay as digest drift. Rebuild the tail of the record in the
|
|
82
|
+
* same order `parseActiveContextState` produces.
|
|
83
|
+
*/
|
|
84
|
+
export function withPendingMarks(
|
|
85
|
+
state: ActiveContextState,
|
|
86
|
+
marks: readonly PendingMark[],
|
|
87
|
+
): ActiveContextState {
|
|
88
|
+
const next = marks.length ? parsePendingMarks(clone(marks) as PendingMark[]) : [];
|
|
89
|
+
const head = { ...state };
|
|
90
|
+
delete head.surfacing;
|
|
91
|
+
delete head.pendingMarks;
|
|
92
|
+
delete head.advisory;
|
|
93
|
+
delete head.prepared;
|
|
94
|
+
return {
|
|
95
|
+
...head,
|
|
96
|
+
...(state.surfacing?.length ? { surfacing: clone(state.surfacing) } : {}),
|
|
97
|
+
...(next.length ? { pendingMarks: next } : {}),
|
|
98
|
+
...(state.advisory ? { advisory: clone(state.advisory) } : {}),
|
|
99
|
+
...(state.prepared ? { prepared: clone(state.prepared) } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MarkAddition {
|
|
104
|
+
state: ActiveContextState;
|
|
105
|
+
added: boolean;
|
|
106
|
+
reason: string | null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Idempotent by mark key, bounded, and never silently dropping the oldest mark. */
|
|
110
|
+
export function addPendingMark(state: ActiveContextState, mark: PendingMark): MarkAddition {
|
|
111
|
+
const marks = pendingMarks(state);
|
|
112
|
+
if (marks.some((existing) => pendingMarkKey(existing) === pendingMarkKey(mark))) {
|
|
113
|
+
return { state, added: false, reason: `${mark.mark} mark ${mark.id} is already pending` };
|
|
114
|
+
}
|
|
115
|
+
if (marks.length >= MAX_PENDING_MARKS) {
|
|
116
|
+
return {
|
|
117
|
+
state,
|
|
118
|
+
added: false,
|
|
119
|
+
reason: `at most ${MAX_PENDING_MARKS} marks may be pending; commit the epoch first`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { state: withPendingMarks(state, [...marks, mark]), added: true, reason: null };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function estimatedTokens(sizeInBytes: number): number {
|
|
126
|
+
return Math.max(0, Math.ceil(sizeInBytes / ESTIMATED_BYTES_PER_TOKEN));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Transcript-position ordering used for marks; identical to the surfacing ordinal. */
|
|
130
|
+
export function markOrdinal(snapshot: Pick<ActiveContextSnapshot, "mapped">): number {
|
|
131
|
+
return snapshot.mapped.length;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Estimated bytes a mark would remove from the projection. Fold marks use a
|
|
136
|
+
* placeholder estimate rather than a trial fold: the number is presentational, so
|
|
137
|
+
* paying a full preparation per mark on every status call is not worth exactness.
|
|
138
|
+
*/
|
|
139
|
+
export function markFreedBytes(
|
|
140
|
+
snapshot: ActiveContextSnapshot,
|
|
141
|
+
state: ActiveContextState,
|
|
142
|
+
mark: PendingMark,
|
|
143
|
+
): number {
|
|
144
|
+
if (mark.mark === "refold") {
|
|
145
|
+
const fold = state.folds.find((item) => item.id === mark.id);
|
|
146
|
+
if (!fold || !state.expanded.includes(mark.id)) return 0;
|
|
147
|
+
const expanded = renderFold(fold, state, snapshot);
|
|
148
|
+
const folded = renderFold(fold, {
|
|
149
|
+
...state,
|
|
150
|
+
expanded: state.expanded.filter((id) => id !== mark.id),
|
|
151
|
+
}, snapshot);
|
|
152
|
+
return expanded && folded ? Math.max(0, bytes(expanded) - bytes(folded)) : 0;
|
|
153
|
+
}
|
|
154
|
+
const source = renderFoldParts(mark.parts, state, snapshot);
|
|
155
|
+
if (!source) return 0;
|
|
156
|
+
const placeholders = mark.kind === "tool-result" ? mark.parts.length : 1;
|
|
157
|
+
const placeholder = placeholders * (mark.brief.length + ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES);
|
|
158
|
+
return Math.max(0, bytes(source) - placeholder);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether a pending mark covers any evidence the current excursion just gathered. */
|
|
162
|
+
export function markTouchesCurrentTurn(
|
|
163
|
+
state: ActiveContextState,
|
|
164
|
+
mark: PendingMark,
|
|
165
|
+
currentTurn: ReadonlySet<string>,
|
|
166
|
+
): boolean {
|
|
167
|
+
if (!currentTurn.size) return false;
|
|
168
|
+
const refs = mark.mark === "refold"
|
|
169
|
+
? (() => {
|
|
170
|
+
const fold = state.folds.find((item) => item.id === mark.id);
|
|
171
|
+
return fold ? flattenFoldRefs(fold, state) : [];
|
|
172
|
+
})()
|
|
173
|
+
: candidateSourceRefs(mark.parts, state);
|
|
174
|
+
return refs.some((ref) => currentTurn.has(objectRefKey(ref)));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* How many of the newest guarded marks the pressure waiver still protects. The agent's
|
|
179
|
+
* MOST RECENT reads are the ones an in-flight excursion is about to work from, so they
|
|
180
|
+
* are the last thing surrendered; everything older is fair game once the window is the
|
|
181
|
+
* bigger risk.
|
|
182
|
+
*/
|
|
183
|
+
export const GUARD_WAIVER_PROTECTED_MARKS = 2;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* How many marks the guard must be holding before the pressure waiver releases any.
|
|
187
|
+
* Without a floor the waiver degrades into exactly the per-pass folding epoch
|
|
188
|
+
* scheduling exists to prevent: the batch drains, one new mark is guarded on the next
|
|
189
|
+
* pass, and releasing that one mark buys a whole prefix invalidation. Below the fence
|
|
190
|
+
* the waiver only ever releases a BATCH. The fence itself ignores this floor.
|
|
191
|
+
*/
|
|
192
|
+
export const GUARD_WAIVER_MINIMUM_MARKS = 4;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Survivability outranks the guard.
|
|
196
|
+
*
|
|
197
|
+
* The current-turn guard is correct while there is room to wait, and fatal once there
|
|
198
|
+
* is not: a workload that never emits a terminal assistant message has ONE turn, so
|
|
199
|
+
* the boundary never advances and every mark is guarded forever. Measured 2026-08-06
|
|
200
|
+
* (rep 11): 58 assistant messages, all stopReason "toolUse", ZERO terminal ones, so
|
|
201
|
+
* the automatic commit applied nothing all run; the only marks that ever landed came
|
|
202
|
+
* from the agent's own two explicit commits. The window reached 359,625 tokens and the
|
|
203
|
+
* next request went out at ~458k estimated and was rejected by the provider.
|
|
204
|
+
*
|
|
205
|
+
* So: the guard holds in full while the commit has other work. It is waived only when
|
|
206
|
+
* it would STARVE the commit -- no other mark could be applied -- and pressure has
|
|
207
|
+
* reached the backstop, and then only for the OLDEST guarded marks. At the hard fence
|
|
208
|
+
* every guarded mark is waived, because the only useful action left is the fold that
|
|
209
|
+
* keeps the request transmissible.
|
|
210
|
+
*/
|
|
211
|
+
export function guardWaiverCount(input: {
|
|
212
|
+
snapshot: ActiveContextSnapshot;
|
|
213
|
+
ratio: number | null;
|
|
214
|
+
guardedMarks: number;
|
|
215
|
+
/** Marks this commit can apply WITHOUT the waiver. While any exist, the guard holds. */
|
|
216
|
+
otherApplicableMarks: number;
|
|
217
|
+
}): number {
|
|
218
|
+
const { snapshot, ratio, guardedMarks, otherApplicableMarks } = input;
|
|
219
|
+
if (guardedMarks <= 0 || typeof ratio !== "number" || !Number.isFinite(ratio)) return 0;
|
|
220
|
+
if (ratio >= hardFenceRatio(snapshot)) return guardedMarks;
|
|
221
|
+
if (ratio < snapshot.policy.refoldRatio || otherApplicableMarks > 0) return 0;
|
|
222
|
+
if (guardedMarks < GUARD_WAIVER_MINIMUM_MARKS) return 0;
|
|
223
|
+
return Math.max(1, guardedMarks - GUARD_WAIVER_PROTECTED_MARKS);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type MarkEligibility = "eligible" | "protected" | "unfulfillable";
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether a pending mark can be applied right now.
|
|
230
|
+
*
|
|
231
|
+
* "protected" is a WAITING state, not a failure: the span is fresh or the agent
|
|
232
|
+
* protected it, and both conditions expire. "unfulfillable" is terminal: the evidence
|
|
233
|
+
* or the fold the mark names has left the branch, so no later commit can honour it.
|
|
234
|
+
* Collapsing the two is what made a refusal look like a drop.
|
|
235
|
+
*/
|
|
236
|
+
export function markEligibility(
|
|
237
|
+
snapshot: ActiveContextSnapshot,
|
|
238
|
+
state: ActiveContextState,
|
|
239
|
+
mark: PendingMark,
|
|
240
|
+
): MarkEligibility {
|
|
241
|
+
if (mark.mark === "refold") {
|
|
242
|
+
const fold = state.folds.find((item) => item.id === mark.id);
|
|
243
|
+
if (!fold || !foldInterval(fold, state, snapshot)) return "unfulfillable";
|
|
244
|
+
return state.expanded.includes(mark.id) ? "eligible" : "unfulfillable";
|
|
245
|
+
}
|
|
246
|
+
const refs = candidateSourceRefs(mark.parts, state);
|
|
247
|
+
if (!refs.length) return "unfulfillable";
|
|
248
|
+
const mapped = new Set(snapshot.mapped.flatMap((item) => item.ref ? [objectRefKey(item.ref)] : []));
|
|
249
|
+
if (refs.some((ref) => !mapped.has(objectRefKey(ref)))) return "unfulfillable";
|
|
250
|
+
const blocked = mark.kind === "tool-result"
|
|
251
|
+
? toolRefsProtected(refs, state, snapshot)
|
|
252
|
+
: refsProtected(refs, state, snapshot);
|
|
253
|
+
return blocked ? "protected" : "eligible";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface MarkAccounting {
|
|
257
|
+
pending: number;
|
|
258
|
+
agentMarks: number;
|
|
259
|
+
ladderMarks: number;
|
|
260
|
+
freedBytes: number;
|
|
261
|
+
freedTokens: number;
|
|
262
|
+
freedWindowShare: number;
|
|
263
|
+
rewriteTokens: number;
|
|
264
|
+
/** Marks a commit could apply right now. */
|
|
265
|
+
eligibleMarks: number;
|
|
266
|
+
/** Marks whose span is still fresh or protected, waiting rather than lost. */
|
|
267
|
+
retainedMarks: number;
|
|
268
|
+
eligibleFreedBytes: number;
|
|
269
|
+
eligibleFreedTokens: number;
|
|
270
|
+
/** The ROI signal: eligible marked mass as a share of the truthful window. */
|
|
271
|
+
eligibleFreedWindowShare: number;
|
|
272
|
+
/** Raw peek mass no reclamation can take: pinned reads and protected results. */
|
|
273
|
+
pinnedBytes: number;
|
|
274
|
+
/** How many peek results that mass is spread over. */
|
|
275
|
+
pinnedResults: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* What a commit of the current marks would cost and free. `rewriteTokens` is the
|
|
280
|
+
* prefix a commit invalidates: everything from the earliest marked message to the
|
|
281
|
+
* end of the window, which is exactly what a positional cache must re-send.
|
|
282
|
+
*/
|
|
283
|
+
export function markAccounting(
|
|
284
|
+
snapshot: ActiveContextSnapshot,
|
|
285
|
+
state: ActiveContextState,
|
|
286
|
+
): MarkAccounting {
|
|
287
|
+
const marks = pendingMarks(state);
|
|
288
|
+
const indexByKey = new Map(snapshot.mapped.flatMap((item) =>
|
|
289
|
+
item.ref ? [[objectRefKey(item.ref), item.index] as const] : []));
|
|
290
|
+
let freedBytes = 0;
|
|
291
|
+
let eligibleFreedBytes = 0;
|
|
292
|
+
let eligibleMarks = 0;
|
|
293
|
+
let retainedMarks = 0;
|
|
294
|
+
let earliest = -1;
|
|
295
|
+
for (const mark of marks) {
|
|
296
|
+
const freed = markFreedBytes(snapshot, state, mark);
|
|
297
|
+
freedBytes += freed;
|
|
298
|
+
const eligibility = markEligibility(snapshot, state, mark);
|
|
299
|
+
if (eligibility === "eligible") {
|
|
300
|
+
eligibleMarks += 1;
|
|
301
|
+
eligibleFreedBytes += freed;
|
|
302
|
+
} else if (eligibility === "protected") retainedMarks += 1;
|
|
303
|
+
const fold = mark.mark === "refold" ? state.folds.find((item) => item.id === mark.id) : null;
|
|
304
|
+
const refs = mark.mark === "refold"
|
|
305
|
+
? (fold ? flattenFoldRefs(fold, state) : [])
|
|
306
|
+
: candidateSourceRefs(mark.parts, state);
|
|
307
|
+
for (const ref of refs) {
|
|
308
|
+
const index = indexByKey.get(objectRefKey(ref));
|
|
309
|
+
if (index === undefined) continue;
|
|
310
|
+
if (earliest < 0 || index < earliest) earliest = index;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const rewriteBytes = earliest < 0
|
|
314
|
+
? 0
|
|
315
|
+
: bytes(snapshot.messages.slice(earliest));
|
|
316
|
+
const freedTokens = estimatedTokens(freedBytes);
|
|
317
|
+
const eligibleFreedTokens = estimatedTokens(eligibleFreedBytes);
|
|
318
|
+
const pinned = pinnedPeekMass(snapshot, state);
|
|
319
|
+
return {
|
|
320
|
+
pending: marks.length,
|
|
321
|
+
agentMarks: marks.filter((mark) => mark.origin === "agent").length,
|
|
322
|
+
ladderMarks: marks.filter((mark) => mark.origin === "ladder").length,
|
|
323
|
+
freedBytes,
|
|
324
|
+
freedTokens,
|
|
325
|
+
freedWindowShare: snapshot.contextWindow > 0 ? freedTokens / snapshot.contextWindow : 0,
|
|
326
|
+
rewriteTokens: estimatedTokens(rewriteBytes),
|
|
327
|
+
eligibleMarks,
|
|
328
|
+
retainedMarks,
|
|
329
|
+
eligibleFreedBytes,
|
|
330
|
+
eligibleFreedTokens,
|
|
331
|
+
eligibleFreedWindowShare: snapshot.contextWindow > 0
|
|
332
|
+
? eligibleFreedTokens / snapshot.contextWindow
|
|
333
|
+
: 0,
|
|
334
|
+
pinnedBytes: pinned.bytes,
|
|
335
|
+
pinnedResults: pinned.results,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The commit trigger.
|
|
341
|
+
*
|
|
342
|
+
* The ROI half asks the economic question: commit when the marks that could apply
|
|
343
|
+
* RIGHT NOW would free enough to pay for the rewrite. The pressure half reuses the
|
|
344
|
+
* ladder's own refold threshold and stays underneath as the SAFETY backstop -- it
|
|
345
|
+
* fires when the window is nearly full, which is the worst moment to discover the
|
|
346
|
+
* marks are worth nothing (the rep4 abort fired there with zero pending marks). The
|
|
347
|
+
* agent's own commit is authoritative and immediate regardless of either.
|
|
348
|
+
*/
|
|
349
|
+
export function epochCommitDue(
|
|
350
|
+
snapshot: ActiveContextSnapshot,
|
|
351
|
+
ratio: number | null,
|
|
352
|
+
eligibleShare: number | null = null,
|
|
353
|
+
): boolean {
|
|
354
|
+
if (typeof eligibleShare === "number" && Number.isFinite(eligibleShare) &&
|
|
355
|
+
eligibleShare >= EPOCH_ELIGIBLE_SHARE_COMMIT_THRESHOLD) return true;
|
|
356
|
+
return typeof ratio === "number" && Number.isFinite(ratio) && ratio >= snapshot.policy.refoldRatio;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function foldMarkFor(input: {
|
|
360
|
+
candidate: FoldCandidate;
|
|
361
|
+
brief: string;
|
|
362
|
+
briefProvenance: PendingFoldMark["briefProvenance"];
|
|
363
|
+
origin: MarkOrigin;
|
|
364
|
+
ordinal: number;
|
|
365
|
+
}): PendingFoldMark {
|
|
366
|
+
const { candidate } = input;
|
|
367
|
+
return {
|
|
368
|
+
mark: "fold",
|
|
369
|
+
// The same identity the committed fold will get, so a repeated decision is
|
|
370
|
+
// inert rather than a second fold of the same span.
|
|
371
|
+
id: foldIdFor(candidate.kind, candidate.parts),
|
|
372
|
+
kind: candidate.kind,
|
|
373
|
+
parts: clone(candidate.parts),
|
|
374
|
+
brief: input.brief,
|
|
375
|
+
briefProvenance: clone(input.briefProvenance),
|
|
376
|
+
origin: input.origin,
|
|
377
|
+
ordinal: input.ordinal,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The deterministic brief a ladder decision would have folded with. */
|
|
382
|
+
export function ladderBrief(
|
|
383
|
+
snapshot: ActiveContextSnapshot,
|
|
384
|
+
state: ActiveContextState,
|
|
385
|
+
candidate: FoldCandidate,
|
|
386
|
+
): string {
|
|
387
|
+
if (candidate.kind === "tool-result") return automaticToolBrief(snapshot, candidate);
|
|
388
|
+
if (candidate.kind === "consolidation") return deterministicConsolidationBrief(candidate, state);
|
|
389
|
+
return deterministicChapterCandidateBrief(snapshot, candidate);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Turn one automatic rung selection into a mark. Prepared chapters are excluded on
|
|
394
|
+
* purpose: that rung only fires at the hard provider fence, which is already inside
|
|
395
|
+
* a commit epoch, and deferring it would strand a model-generated brief.
|
|
396
|
+
*/
|
|
397
|
+
export function ladderSelectionMark(input: {
|
|
398
|
+
snapshot: ActiveContextSnapshot;
|
|
399
|
+
state: ActiveContextState;
|
|
400
|
+
selection: AutomaticRungSelection;
|
|
401
|
+
ordinal: number;
|
|
402
|
+
}): PendingMark | null {
|
|
403
|
+
const { selection } = input;
|
|
404
|
+
if (selection.kind === "refold") {
|
|
405
|
+
return { mark: "refold", id: selection.foldId, origin: "ladder", ordinal: input.ordinal };
|
|
406
|
+
}
|
|
407
|
+
if (selection.kind !== "tool" && selection.kind !== "chapter" && selection.kind !== "consolidation") {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
return foldMarkFor({
|
|
411
|
+
candidate: selection.candidate,
|
|
412
|
+
brief: ladderBrief(input.snapshot, input.state, selection.candidate),
|
|
413
|
+
briefProvenance: { kind: "deterministic" },
|
|
414
|
+
origin: "ladder",
|
|
415
|
+
ordinal: input.ordinal,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Peek is the ephemeral read: look, decide, discard. Its own tool result is a
|
|
421
|
+
* completed read batch, so at the next commit it folds away automatically, unless
|
|
422
|
+
* the agent committed to the fold it peeked by expanding or protecting it.
|
|
423
|
+
*/
|
|
424
|
+
export function ephemeralPeekMarks(input: {
|
|
425
|
+
snapshot: ActiveContextSnapshot;
|
|
426
|
+
state: ActiveContextState;
|
|
427
|
+
ordinal: number;
|
|
428
|
+
}): PendingFoldMark[] {
|
|
429
|
+
const { snapshot, state } = input;
|
|
430
|
+
const claimed = claimedRefKeys(state);
|
|
431
|
+
const groups = new Map<number, Array<{ index: number; call: NonNullable<ReturnType<typeof resultCall>> }>>();
|
|
432
|
+
for (const item of snapshot.mapped) {
|
|
433
|
+
if (messageRole(item.message) !== "toolResult" || !item.ref) continue;
|
|
434
|
+
const call = resultCall(snapshot, item.index, true);
|
|
435
|
+
if (!call || call.name !== snapshot.toolName) continue;
|
|
436
|
+
const group = groups.get(call.assistantIndex) ?? [];
|
|
437
|
+
group.push({ index: item.index, call });
|
|
438
|
+
groups.set(call.assistantIndex, group);
|
|
439
|
+
}
|
|
440
|
+
const marks: PendingFoldMark[] = [];
|
|
441
|
+
for (const group of [...groups.entries()].sort(([left], [right]) => left - right)) {
|
|
442
|
+
const entries = group[1];
|
|
443
|
+
const expected = entries[0].call.batch;
|
|
444
|
+
const ids = new Set(entries.map(({ call }) => call.id));
|
|
445
|
+
if (ids.size !== expected.length || expected.some((id) => !ids.has(id))) continue;
|
|
446
|
+
const peeked: string[] = [];
|
|
447
|
+
let everyCallIsPeek = true;
|
|
448
|
+
for (const { call } of entries) {
|
|
449
|
+
const args = toolCallArguments(snapshot, call.assistantIndex, call.id);
|
|
450
|
+
const action = args && typeof args === "object" && !Array.isArray(args)
|
|
451
|
+
? (args as Record<string, unknown>).action
|
|
452
|
+
: undefined;
|
|
453
|
+
const id = args && typeof args === "object" && !Array.isArray(args)
|
|
454
|
+
? (args as Record<string, unknown>).id
|
|
455
|
+
: undefined;
|
|
456
|
+
if (action !== "peek" || typeof id !== "string" || !id) { everyCallIsPeek = false; break; }
|
|
457
|
+
peeked.push(id);
|
|
458
|
+
}
|
|
459
|
+
if (!everyCallIsPeek) continue;
|
|
460
|
+
// The agent committed to what it peeked: leave the read in the raw window.
|
|
461
|
+
if (peeked.some((id) => state.expanded.includes(id) || peekedFoldProtected(snapshot, state, id))) continue;
|
|
462
|
+
const refs = entries
|
|
463
|
+
.sort((left, right) => left.index - right.index)
|
|
464
|
+
.map(({ index }) => snapshot.mapped[index].ref!);
|
|
465
|
+
if (refs.some((ref) => claimed.has(objectRefKey(ref))) ||
|
|
466
|
+
refs.length > snapshot.policy.maxFoldSourceRefs ||
|
|
467
|
+
toolRefsProtected(refs, state, snapshot)) continue;
|
|
468
|
+
const candidate: FoldCandidate = {
|
|
469
|
+
kind: "tool-result",
|
|
470
|
+
parts: refs.map((ref) => ({ kind: "raw" as const, ref })),
|
|
471
|
+
sourceRefs: refs,
|
|
472
|
+
};
|
|
473
|
+
marks.push(foldMarkFor({
|
|
474
|
+
candidate,
|
|
475
|
+
brief: automaticToolBrief(snapshot, candidate),
|
|
476
|
+
briefProvenance: { kind: "deterministic" },
|
|
477
|
+
// A peek is an agent-initiated read; attributing its disposal to the ladder
|
|
478
|
+
// would deflate the agent-vs-ladder mark ratio the adjudication reads.
|
|
479
|
+
origin: "agent",
|
|
480
|
+
ordinal: input.ordinal,
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
return marks;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function peekedFoldProtected(
|
|
487
|
+
snapshot: ActiveContextSnapshot,
|
|
488
|
+
state: ActiveContextState,
|
|
489
|
+
foldId: string,
|
|
490
|
+
): boolean {
|
|
491
|
+
const fold = state.folds.find((item) => item.id === foldId);
|
|
492
|
+
if (!fold) return false;
|
|
493
|
+
const refs = flattenFoldRefs(fold, state);
|
|
494
|
+
return fold.kind === "tool-result"
|
|
495
|
+
? toolRefsProtected(refs, state, snapshot)
|
|
496
|
+
: refsProtected(refs, state, snapshot);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Every evidence key already spoken for by a fold or a pending fold mark. */
|
|
500
|
+
export function claimedRefKeys(state: ActiveContextState): Set<string> {
|
|
501
|
+
const keys = new Set<string>();
|
|
502
|
+
for (const fold of state.folds) {
|
|
503
|
+
for (const part of fold.parts) if (part.kind === "raw") keys.add(objectRefKey(part.ref));
|
|
504
|
+
}
|
|
505
|
+
for (const mark of pendingMarks(state)) {
|
|
506
|
+
if (mark.mark !== "fold") continue;
|
|
507
|
+
for (const ref of candidateSourceRefs(mark.parts, state)) keys.add(objectRefKey(ref));
|
|
508
|
+
}
|
|
509
|
+
return keys;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** What is still on the table after the marks in hand: the steering number and its parts. */
|
|
513
|
+
export interface UnmarkedRemainder {
|
|
514
|
+
/** Stale tool results outside the fresh tail that no fold and no pending mark owns. */
|
|
515
|
+
spans: number;
|
|
516
|
+
tokens: number;
|
|
517
|
+
/** Unmarked stale tokens as a share of the non-fresh window. The steering number. */
|
|
518
|
+
share: number;
|
|
519
|
+
/** The largest few, by reclaim value, so the next batch is one read away. */
|
|
520
|
+
candidates: Array<{ id: string; tokens: number }>;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The unmarked remainder, as an AGGREGATE plus a bounded head.
|
|
525
|
+
*
|
|
526
|
+
* An exhaustive list is what the status index already is, and re-rendering it is what
|
|
527
|
+
* this build removed from the projection. What an agent needs to decide the NEXT batch
|
|
528
|
+
* is one percentage and the few largest names; everything else is a total.
|
|
529
|
+
*/
|
|
530
|
+
export function unmarkedRemainder(
|
|
531
|
+
snapshot: ActiveContextSnapshot,
|
|
532
|
+
state: ActiveContextState,
|
|
533
|
+
charsPerToken: number,
|
|
534
|
+
limit = MAX_UNMARKED_CANDIDATES,
|
|
535
|
+
): UnmarkedRemainder {
|
|
536
|
+
const perToken = Number.isFinite(charsPerToken) && charsPerToken > 0 ? charsPerToken : ESTIMATED_BYTES_PER_TOKEN;
|
|
537
|
+
const claimed = claimedRefKeys(state);
|
|
538
|
+
const candidates: Array<{ id: string; tokens: number }> = [];
|
|
539
|
+
let unmarkedBytes = 0;
|
|
540
|
+
let staleBytes = 0;
|
|
541
|
+
for (const item of snapshot.mapped) {
|
|
542
|
+
if (!item.ref || messageRole(item.message) !== "toolResult") continue;
|
|
543
|
+
if (snapshot.toolProtectedIndices.has(item.index)) continue;
|
|
544
|
+
const size = bytes(item.message);
|
|
545
|
+
staleBytes += size;
|
|
546
|
+
if (claimed.has(objectRefKey(item.ref))) continue;
|
|
547
|
+
unmarkedBytes += size;
|
|
548
|
+
candidates.push({ id: item.ref.entryId, tokens: Math.ceil(size / perToken) });
|
|
549
|
+
}
|
|
550
|
+
candidates.sort((left, right) => right.tokens - left.tokens || (left.id < right.id ? -1 : 1));
|
|
551
|
+
return {
|
|
552
|
+
spans: candidates.length,
|
|
553
|
+
tokens: Math.ceil(unmarkedBytes / perToken),
|
|
554
|
+
share: staleBytes > 0 ? unmarkedBytes / staleBytes : 0,
|
|
555
|
+
candidates: candidates.slice(0, Math.max(0, limit)),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Every fold id already spoken for by a pending refold mark. */
|
|
560
|
+
export function markedFoldIds(state: ActiveContextState): Set<string> {
|
|
561
|
+
const ids = new Set<string>();
|
|
562
|
+
for (const mark of pendingMarks(state)) if (mark.mark === "refold") ids.add(mark.id);
|
|
563
|
+
return ids;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Agent judgment leads; automation guarantees the floor. If the marks the agent
|
|
568
|
+
* made would free less than the target share of the window, the ladder adds the
|
|
569
|
+
* stalest unprotected eligible tool batches until they do.
|
|
570
|
+
*/
|
|
571
|
+
export function topUpMarks(input: {
|
|
572
|
+
snapshot: ActiveContextSnapshot;
|
|
573
|
+
state: ActiveContextState;
|
|
574
|
+
ordinal: number;
|
|
575
|
+
targetShare?: number;
|
|
576
|
+
/** Evidence keys the top-up may never propose, e.g. the current excursion's reads. */
|
|
577
|
+
excludeRefKeys?: ReadonlySet<string>;
|
|
578
|
+
/** Measure progress against ELIGIBLE mass, so pinned or protected marks never count. */
|
|
579
|
+
eligibleOnly?: boolean;
|
|
580
|
+
}): PendingFoldMark[] {
|
|
581
|
+
const { snapshot } = input;
|
|
582
|
+
const target = input.targetShare ?? EPOCH_COMMIT_TARGET_WINDOW_SHARE;
|
|
583
|
+
const claimed = claimedRefKeys(input.state);
|
|
584
|
+
for (const key of input.excludeRefKeys ?? []) claimed.add(key);
|
|
585
|
+
const marks: PendingFoldMark[] = [];
|
|
586
|
+
let state = input.state;
|
|
587
|
+
const progress = (value: ActiveContextState): number => {
|
|
588
|
+
const accounting = markAccounting(snapshot, value);
|
|
589
|
+
return input.eligibleOnly ? accounting.eligibleFreedWindowShare : accounting.freedWindowShare;
|
|
590
|
+
};
|
|
591
|
+
let share = progress(state);
|
|
592
|
+
for (let attempt = 0; attempt < EPOCH_MAX_TOPUP_MARKS && share < target; attempt += 1) {
|
|
593
|
+
const candidate = selectAutomaticToolBatch(snapshot, state, 1, claimed)[0];
|
|
594
|
+
if (!candidate) break;
|
|
595
|
+
const mark = foldMarkFor({
|
|
596
|
+
candidate,
|
|
597
|
+
brief: automaticToolBrief(snapshot, candidate),
|
|
598
|
+
briefProvenance: { kind: "deterministic" },
|
|
599
|
+
origin: "ladder",
|
|
600
|
+
ordinal: input.ordinal,
|
|
601
|
+
});
|
|
602
|
+
const addition = addPendingMark(state, mark);
|
|
603
|
+
if (!addition.added) break;
|
|
604
|
+
state = addition.state;
|
|
605
|
+
marks.push(mark);
|
|
606
|
+
for (const ref of candidate.sourceRefs) claimed.add(objectRefKey(ref));
|
|
607
|
+
share = progress(state);
|
|
608
|
+
}
|
|
609
|
+
return marks;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** One sliver a commit swallowed into its later neighbour, reported never inferred. */
|
|
613
|
+
export interface AbsorbedWedge {
|
|
614
|
+
/** The mark that grew backward to cover it. */
|
|
615
|
+
intoMarkId: string;
|
|
616
|
+
startId: string;
|
|
617
|
+
endId: string;
|
|
618
|
+
entries: number;
|
|
619
|
+
tokens: number;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Wedge absorption. THE ANTI-LCM PIN LIVES HERE.
|
|
624
|
+
*
|
|
625
|
+
* A sliver of stale raw content hugging a fold boundary is a crumb nothing will ever
|
|
626
|
+
* reclaim: it is below the minimum chapter size, so no chapter rung will take it, and
|
|
627
|
+
* it is not a tool batch, so no tool rung will either. Absorbing it costs nothing
|
|
628
|
+
* because it happens INSIDE a commit the epoch has already paid for -- it grows a
|
|
629
|
+
* pending mark backward rather than performing a mutation of its own.
|
|
630
|
+
*
|
|
631
|
+
* What it must never do is erode deliberate non-sequential curation. An agent holding
|
|
632
|
+
* folds at 10:20, 40:55 and 60:70 with raw spans between them chose that shape, and a
|
|
633
|
+
* mechanism that swallowed those gaps would delete the curation element outright. So
|
|
634
|
+
* the threshold is tiny, measured in the session's own calibrated tokens, and NOT
|
|
635
|
+
* pressure-scaled: above it, a gap stays raw permanently no matter how full the window
|
|
636
|
+
* gets or how ragged the projection looks.
|
|
637
|
+
*/
|
|
638
|
+
export function absorbWedgeMarks(input: {
|
|
639
|
+
snapshot: ActiveContextSnapshot;
|
|
640
|
+
state: ActiveContextState;
|
|
641
|
+
/** The session's measured serialized chars per token; never a fixed constant. */
|
|
642
|
+
charsPerToken: number;
|
|
643
|
+
/** Evidence absorption may never touch, e.g. the current excursion's own reads. */
|
|
644
|
+
excludeRefKeys?: ReadonlySet<string>;
|
|
645
|
+
maxTokens?: number;
|
|
646
|
+
}): { state: ActiveContextState; absorbed: AbsorbedWedge[] } {
|
|
647
|
+
const { snapshot } = input;
|
|
648
|
+
const maxTokens = input.maxTokens ?? MAX_WEDGE_ABSORB_TOKENS;
|
|
649
|
+
const charsPerToken = Number.isFinite(input.charsPerToken) && input.charsPerToken > 0
|
|
650
|
+
? input.charsPerToken
|
|
651
|
+
: ESTIMATED_BYTES_PER_TOKEN;
|
|
652
|
+
const exclude = input.excludeRefKeys ?? new Set<string>();
|
|
653
|
+
let state = input.state;
|
|
654
|
+
const absorbed: AbsorbedWedge[] = [];
|
|
655
|
+
const indexByKey = new Map(snapshot.mapped.flatMap((item) =>
|
|
656
|
+
item.ref ? [[objectRefKey(item.ref), item.index] as const] : []));
|
|
657
|
+
const markInterval = (mark: PendingFoldMark): { start: number; end: number } | null => {
|
|
658
|
+
const indices = candidateSourceRefs(mark.parts, state)
|
|
659
|
+
.map((ref) => indexByKey.get(objectRefKey(ref)))
|
|
660
|
+
.filter((index): index is number => index !== undefined);
|
|
661
|
+
return indices.length ? { start: Math.min(...indices), end: Math.max(...indices) } : null;
|
|
662
|
+
};
|
|
663
|
+
// One pass, oldest first. Each absorption rewrites exactly one mark, and a mark that
|
|
664
|
+
// has already grown is not a candidate to grow again in the same commit.
|
|
665
|
+
for (let guard = 0; guard < MAX_PENDING_MARKS; guard += 1) {
|
|
666
|
+
const marks = pendingMarks(state);
|
|
667
|
+
const occupied = [
|
|
668
|
+
...orderedRoots(state, snapshot).map((root) => ({ start: root.start, end: root.end })),
|
|
669
|
+
...marks.flatMap((mark) => {
|
|
670
|
+
if (mark.mark !== "fold") return [];
|
|
671
|
+
const interval = markInterval(mark);
|
|
672
|
+
return interval ? [interval] : [];
|
|
673
|
+
}),
|
|
674
|
+
].sort((left, right) => left.start - right.start);
|
|
675
|
+
let applied = false;
|
|
676
|
+
for (const mark of marks) {
|
|
677
|
+
if (mark.mark !== "fold") continue;
|
|
678
|
+
const interval = markInterval(mark);
|
|
679
|
+
if (!interval) continue;
|
|
680
|
+
const previous = occupied.filter((item) => item.end < interval.start).at(-1);
|
|
681
|
+
const gapStart = previous ? previous.end + 1 : -1;
|
|
682
|
+
const gapEnd = interval.start - 1;
|
|
683
|
+
if (gapStart < 0 || gapEnd < gapStart) continue;
|
|
684
|
+
if (absorbed.some((item) => item.intoMarkId === mark.id)) continue;
|
|
685
|
+
const gapTokens = Math.ceil(spanBytes(snapshot, gapStart, gapEnd + 1) / charsPerToken);
|
|
686
|
+
// The whole discriminator, in one line: above this, the gap is the agent's.
|
|
687
|
+
if (gapTokens > maxTokens) continue;
|
|
688
|
+
const gapRefs = [];
|
|
689
|
+
let usable = true;
|
|
690
|
+
for (let index = gapStart; index <= gapEnd; index += 1) {
|
|
691
|
+
const ref = snapshot.mapped[index]?.ref;
|
|
692
|
+
if (!ref || exclude.has(objectRefKey(ref))) { usable = false; break; }
|
|
693
|
+
gapRefs.push(ref);
|
|
694
|
+
}
|
|
695
|
+
if (!usable || !gapRefs.length) continue;
|
|
696
|
+
if (refsProtected(gapRefs, state, snapshot)) continue;
|
|
697
|
+
// Structural safety: the grown span must still be a valid closed chapter, which
|
|
698
|
+
// is what keeps every tool call paired with its result in the projection.
|
|
699
|
+
if (!chapterRangeIsUnitAligned(snapshot, gapStart, interval.end)) continue;
|
|
700
|
+
const parts = partsForRange(snapshot, state, gapStart, interval.end, new Set<FoldKind>(["tool-result"]));
|
|
701
|
+
if (!parts) continue;
|
|
702
|
+
const grown = foldMarkFor({
|
|
703
|
+
candidate: { kind: "chapter", parts, sourceRefs: candidateSourceRefs(parts, state) },
|
|
704
|
+
brief: `${mark.brief} It also holds ${gapRefs.length} short adjacent entry(s) absorbed at commit.`
|
|
705
|
+
.slice(0, snapshot.policy.maxBriefChars),
|
|
706
|
+
briefProvenance: mark.briefProvenance,
|
|
707
|
+
origin: mark.origin,
|
|
708
|
+
ordinal: mark.ordinal,
|
|
709
|
+
});
|
|
710
|
+
const kept = pendingMarks(state).filter((item) => pendingMarkKey(item) !== pendingMarkKey(mark));
|
|
711
|
+
if (kept.some((item) => pendingMarkKey(item) === pendingMarkKey(grown))) continue;
|
|
712
|
+
state = withPendingMarks(state, [...kept, grown]);
|
|
713
|
+
absorbed.push({
|
|
714
|
+
intoMarkId: grown.id,
|
|
715
|
+
startId: gapRefs[0].entryId,
|
|
716
|
+
endId: gapRefs.at(-1)!.entryId,
|
|
717
|
+
entries: gapRefs.length,
|
|
718
|
+
tokens: gapTokens,
|
|
719
|
+
});
|
|
720
|
+
applied = true;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
if (!applied) break;
|
|
724
|
+
}
|
|
725
|
+
return { state, absorbed };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export interface AppliedMark {
|
|
729
|
+
mark: PendingMark["mark"];
|
|
730
|
+
id: string;
|
|
731
|
+
origin: MarkOrigin;
|
|
732
|
+
foldId: string;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export interface RefusedMark {
|
|
736
|
+
mark: PendingMark["mark"];
|
|
737
|
+
id: string;
|
|
738
|
+
origin: MarkOrigin;
|
|
739
|
+
reason: string;
|
|
740
|
+
/** Whether the mark survives as pending rather than being discarded. */
|
|
741
|
+
retained: boolean;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export interface CommitEpochResult {
|
|
745
|
+
state: ActiveContextState;
|
|
746
|
+
applied: AppliedMark[];
|
|
747
|
+
refused: RefusedMark[];
|
|
748
|
+
retained: PendingMark[];
|
|
749
|
+
/** Guarded marks the pressure waiver released into this commit, oldest first. */
|
|
750
|
+
waived: PendingMark[];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Apply every pending mark. This is the ONE mutation of the epoch: it reuses
|
|
755
|
+
* prepareFold/commitPreparedFold exactly as the immediate path does, so a committed
|
|
756
|
+
* mark and an immediate fold produce the same fold record.
|
|
757
|
+
*/
|
|
758
|
+
export async function commitPendingMarks(input: {
|
|
759
|
+
snapshot: ActiveContextSnapshot;
|
|
760
|
+
state: ActiveContextState;
|
|
761
|
+
generation: number;
|
|
762
|
+
now?: () => number;
|
|
763
|
+
/**
|
|
764
|
+
* Keep marks the commit could not apply YET. A mark is a standing decision; dropping
|
|
765
|
+
* it because the span happened to be fresh at commit time silently discards the
|
|
766
|
+
* agent's judgment and leaves nothing for the next commit to act on.
|
|
767
|
+
*/
|
|
768
|
+
retainIneligible?: boolean;
|
|
769
|
+
/**
|
|
770
|
+
* Never fold evidence the CURRENT excursion gathered. A commit that runs between
|
|
771
|
+
* an agent's read and its use of that read hands the agent a window where its own
|
|
772
|
+
* just-gathered evidence is a placeholder. Such marks stay pending for the next
|
|
773
|
+
* commit rather than being applied or dropped.
|
|
774
|
+
*/
|
|
775
|
+
guardCurrentTurn?: boolean;
|
|
776
|
+
/**
|
|
777
|
+
* How many guarded marks the pressure waiver releases anyway, oldest first. The
|
|
778
|
+
* guard protects an in-flight excursion; it may never cost the session its ability
|
|
779
|
+
* to send a request at all. See `guardWaiverCount`.
|
|
780
|
+
*/
|
|
781
|
+
guardWaiver?: number;
|
|
782
|
+
}): Promise<CommitEpochResult> {
|
|
783
|
+
const marks = pendingMarks(input.state);
|
|
784
|
+
const retained: PendingMark[] = [];
|
|
785
|
+
const waived: PendingMark[] = [];
|
|
786
|
+
const guardReasons = new Map<string, string>();
|
|
787
|
+
const currentTurn = input.guardCurrentTurn
|
|
788
|
+
? currentTurnRefKeys(input.snapshot)
|
|
789
|
+
: new Set<string>();
|
|
790
|
+
if (input.guardCurrentTurn) {
|
|
791
|
+
const applicable: PendingMark[] = [];
|
|
792
|
+
const guarded: PendingMark[] = [];
|
|
793
|
+
for (const mark of marks) {
|
|
794
|
+
if (markTouchesCurrentTurn(input.state, mark, currentTurn)) guarded.push(mark);
|
|
795
|
+
else applicable.push(mark);
|
|
796
|
+
}
|
|
797
|
+
// Oldest first: the newest reads are the ones the excursion is about to use, so
|
|
798
|
+
// they are the last evidence the waiver surrenders.
|
|
799
|
+
guarded.sort((left, right) => left.ordinal - right.ordinal || left.id.localeCompare(right.id));
|
|
800
|
+
const waiverCount = Math.max(0, Math.min(guarded.length, Math.trunc(input.guardWaiver ?? 0)));
|
|
801
|
+
for (const [index, mark] of guarded.entries()) {
|
|
802
|
+
if (index < waiverCount) {
|
|
803
|
+
waived.push(mark);
|
|
804
|
+
applicable.push(mark);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
retained.push(mark);
|
|
808
|
+
guardReasons.set(
|
|
809
|
+
pendingMarkKey(mark),
|
|
810
|
+
"evidence was gathered in the current turn; the mark stays pending until the turn closes",
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
marks.length = 0;
|
|
814
|
+
marks.push(...applicable);
|
|
815
|
+
}
|
|
816
|
+
if (input.retainIneligible) {
|
|
817
|
+
const applicable: PendingMark[] = [];
|
|
818
|
+
for (const mark of marks) {
|
|
819
|
+
if (markEligibility(input.snapshot, input.state, mark) === "protected") retained.push(mark);
|
|
820
|
+
else applicable.push(mark);
|
|
821
|
+
}
|
|
822
|
+
marks.length = 0;
|
|
823
|
+
marks.push(...applicable);
|
|
824
|
+
}
|
|
825
|
+
let state = withPendingMarks(input.state, retained);
|
|
826
|
+
const applied: AppliedMark[] = [];
|
|
827
|
+
const refused: RefusedMark[] = retained.map((mark) => ({
|
|
828
|
+
mark: mark.mark,
|
|
829
|
+
id: mark.id,
|
|
830
|
+
origin: mark.origin,
|
|
831
|
+
reason: guardReasons.get(pendingMarkKey(mark)) ??
|
|
832
|
+
"span is still fresh or protected; the mark stays pending until it is eligible",
|
|
833
|
+
retained: true,
|
|
834
|
+
}));
|
|
835
|
+
// Oldest first, then by id: a chapter mark that absorbs an earlier tool-result
|
|
836
|
+
// mark must find that child already folded, which is transcript order.
|
|
837
|
+
const ordered = [...marks].sort((left, right) =>
|
|
838
|
+
left.ordinal - right.ordinal || left.id.localeCompare(right.id));
|
|
839
|
+
for (const mark of ordered) {
|
|
840
|
+
try {
|
|
841
|
+
if (mark.mark === "refold") {
|
|
842
|
+
const fold = state.folds.find((item) => item.id === mark.id);
|
|
843
|
+
if (!fold || !foldInterval(fold, state, input.snapshot)) {
|
|
844
|
+
throw new Error(`fold ${mark.id} is no longer active in the context event`);
|
|
845
|
+
}
|
|
846
|
+
if (!state.expanded.includes(mark.id)) throw new Error(`fold ${mark.id} is already folded`);
|
|
847
|
+
state = setFoldProjectionState(state, mark.id, "folded");
|
|
848
|
+
applied.push({ mark: "refold", id: mark.id, origin: mark.origin, foldId: mark.id });
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const parts = clone(mark.parts);
|
|
852
|
+
const sourceRefs = candidateSourceRefs(parts, state);
|
|
853
|
+
const blocked = mark.kind === "tool-result"
|
|
854
|
+
? toolRefsProtected(sourceRefs, state, input.snapshot)
|
|
855
|
+
: refsProtected(sourceRefs, state, input.snapshot);
|
|
856
|
+
if (blocked) {
|
|
857
|
+
throw new Error(
|
|
858
|
+
`pending fold mark ${mark.id} covers protected or fresh evidence; ` +
|
|
859
|
+
"unprotect that evidence and mark it again",
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
const prepared = await prepareFold({
|
|
863
|
+
candidate: { kind: mark.kind, parts, sourceRefs },
|
|
864
|
+
snapshot: input.snapshot,
|
|
865
|
+
state,
|
|
866
|
+
generation: input.generation,
|
|
867
|
+
brief: mark.brief,
|
|
868
|
+
briefProvenance: mark.briefProvenance,
|
|
869
|
+
now: input.now,
|
|
870
|
+
});
|
|
871
|
+
state = commitPreparedFold({
|
|
872
|
+
prepared,
|
|
873
|
+
snapshot: input.snapshot,
|
|
874
|
+
state,
|
|
875
|
+
generation: input.generation,
|
|
876
|
+
});
|
|
877
|
+
applied.push({ mark: "fold", id: mark.id, origin: mark.origin, foldId: prepared.id });
|
|
878
|
+
} catch (error) {
|
|
879
|
+
refused.push({
|
|
880
|
+
mark: mark.mark,
|
|
881
|
+
id: mark.id,
|
|
882
|
+
origin: mark.origin,
|
|
883
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
884
|
+
retained: false,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return { state, applied, refused, retained, waived };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** The scheduling block reported by the status action. */
|
|
892
|
+
export function schedulingStatus(input: {
|
|
893
|
+
snapshot: ActiveContextSnapshot;
|
|
894
|
+
state: ActiveContextState;
|
|
895
|
+
mode: string;
|
|
896
|
+
ratio: number | null;
|
|
897
|
+
}): Record<string, unknown> {
|
|
898
|
+
const accounting = markAccounting(input.snapshot, input.state);
|
|
899
|
+
const threshold = EPOCH_ELIGIBLE_SHARE_COMMIT_THRESHOLD;
|
|
900
|
+
return {
|
|
901
|
+
mode: input.mode,
|
|
902
|
+
...accounting,
|
|
903
|
+
targetWindowShare: EPOCH_COMMIT_TARGET_WINDOW_SHARE,
|
|
904
|
+
commitDue: epochCommitDue(input.snapshot, input.ratio, accounting.eligibleFreedWindowShare),
|
|
905
|
+
commitTrigger: {
|
|
906
|
+
mode: "eligible-share",
|
|
907
|
+
eligibleShareThreshold: threshold,
|
|
908
|
+
eligibleShare: accounting.eligibleFreedWindowShare,
|
|
909
|
+
backstopRatio: input.snapshot.policy.refoldRatio,
|
|
910
|
+
pressureDue: epochCommitDue(input.snapshot, input.ratio),
|
|
911
|
+
roiDue: accounting.eligibleFreedWindowShare >= threshold,
|
|
912
|
+
},
|
|
913
|
+
commitRatio: input.snapshot.policy.refoldRatio,
|
|
914
|
+
marks: pendingMarks(input.state).map((mark) => ({
|
|
915
|
+
mark: mark.mark,
|
|
916
|
+
id: mark.id,
|
|
917
|
+
origin: mark.origin,
|
|
918
|
+
ordinal: mark.ordinal,
|
|
919
|
+
eligibility: markEligibility(input.snapshot, input.state, mark),
|
|
920
|
+
...(mark.mark === "fold" ? { kind: mark.kind, brief: mark.brief } : {}),
|
|
921
|
+
})),
|
|
922
|
+
};
|
|
923
|
+
}
|