pi-fold 2.0.0 → 3.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.
@@ -12,6 +12,7 @@ import {
12
12
  prepareFold,
13
13
  renderFold,
14
14
  renderFoldParts,
15
+ selectAutomaticChapter,
15
16
  selectAutomaticSpan,
16
17
  setFoldProjectionState,
17
18
  } from "./folding.ts";
@@ -19,11 +20,10 @@ import type { AutomaticRungSelection } from "./folding.ts";
19
20
  import {
20
21
  foldInterval,
21
22
  budgetOccupancy,
22
- hardFenceRatio,
23
23
  mappedByKey,
24
24
  orderedRoots,
25
+ protectedStaleMass,
25
26
  refsProtected,
26
- toolRefsProtected,
27
27
  } from "./measurement.ts";
28
28
  import {
29
29
  childFoldIds,
@@ -33,11 +33,10 @@ import {
33
33
  parsePendingMarks,
34
34
  pendingMarkKey,
35
35
  } from "./persistence.ts";
36
- import { currentTurnRefKeys } from "./transcript.ts";
37
36
  import {
38
37
  ESTIMATED_BYTES_PER_TOKEN,
39
- MAX_WEDGE_ABSORB_TOKENS,
40
38
  ESTIMATED_PLACEHOLDER_OVERHEAD_BYTES,
39
+ MAX_FRONTIER_CUTS_PER_PASS,
41
40
  MAX_PENDING_MARKS,
42
41
  MAX_UNMARKED_CANDIDATES,
43
42
  } from "./policy.ts";
@@ -58,6 +57,7 @@ import {
58
57
  partsForRange,
59
58
  peekedSourceFoldIds,
60
59
  selectAutomaticConsolidations,
60
+ selectAutomaticRefold,
61
61
  spanBytes,
62
62
  deterministicChapterCandidateBrief,
63
63
  deterministicConsolidationBrief,
@@ -185,15 +185,6 @@ export function markFreedBytes(
185
185
  return Math.max(0, bytes(source) - placeholder);
186
186
  }
187
187
 
188
- export function markTouchesCurrentTurn(
189
- state: ActiveContextState,
190
- mark: PendingMark,
191
- currentTurn: ReadonlySet<string>,
192
- ): boolean {
193
- if (!currentTurn.size) return false;
194
- return markSpanRefs(state, mark).refs.some((ref) => currentTurn.has(objectRefKey(ref)));
195
- }
196
-
197
188
  export function markSpanStart(
198
189
  snapshot: ActiveContextSnapshot,
199
190
  state: ActiveContextState,
@@ -210,46 +201,6 @@ export function markSpanStart(
210
201
  return indices.length ? Math.min(...indices) : snapshot.mapped.length;
211
202
  }
212
203
 
213
- export const GUARD_WAIVER_PROTECTED_MARKS = 2;
214
-
215
- export const GUARD_WAIVER_MINIMUM_MARKS = 4;
216
-
217
- export function guardWaiverCount(input: {
218
- snapshot: ActiveContextSnapshot;
219
- ratio: number | null;
220
- guardedMarks: number;
221
- otherApplicableMarks: number;
222
- /**
223
- * THE BOUNDARY IS ITS OWN SIGNAL, and a better one than the occupancy anchor.
224
- *
225
- * The anchor exists to answer "is this session in enough trouble to spend the open
226
- * turn's own evidence": a proxy, because nothing else was asking. At a compaction
227
- * boundary Pi has already answered it. Cancelling the compaction and then applying
228
- * nothing hands Pi's own threshold a byte-identical projection, which is worse than
229
- * letting it compact, because compaction would at least have shed something.
230
- *
231
- * Measured on the sol-20260813 smoke: the boundary fired, cancelled the compaction and
232
- * retained all seven marks at 0.20 occupancy, freeing nothing. The full run reaches the
233
- * anchor 12,576 tokens past its own trigger, so it self-corrects there and this is
234
- * about what happens in between; a smoke, or any session whose budget it never
235
- * approaches, never corrects at all.
236
- *
237
- * Everything else about the waiver is unchanged, and deliberately: the newest marks
238
- * still stay raw, a small guarded set still holds whole, and other applicable work
239
- * still takes precedence over spending the open turn.
240
- */
241
- boundary?: boolean;
242
- }): number {
243
- const { snapshot, ratio, guardedMarks, otherApplicableMarks, boundary } = input;
244
- if (guardedMarks <= 0 || typeof ratio !== "number" || !Number.isFinite(ratio)) return 0;
245
- if (ratio >= hardFenceRatio(snapshot)) return guardedMarks;
246
- const occupancy = budgetOccupancy(snapshot, ratio);
247
- if (occupancy === null || otherApplicableMarks > 0) return 0;
248
- if (occupancy < snapshot.policy.refoldRatio && !boundary) return 0;
249
- if (guardedMarks < GUARD_WAIVER_MINIMUM_MARKS) return 0;
250
- return Math.max(1, guardedMarks - GUARD_WAIVER_PROTECTED_MARKS);
251
- }
252
-
253
204
  export type MarkEligibility = "eligible" | "protected" | "unfulfillable";
254
205
 
255
206
  export function markEligibility(
@@ -268,9 +219,7 @@ export function markEligibility(
268
219
  if (!refs.length) return "unfulfillable";
269
220
  const mapped = new Set(snapshot.mapped.flatMap((item) => item.ref ? [objectRefKey(item.ref)] : []));
270
221
  if (refs.some((ref) => !mapped.has(objectRefKey(ref)))) return "unfulfillable";
271
- const blocked = mark.kind === "tool-result"
272
- ? toolRefsProtected(refs, state, snapshot)
273
- : refsProtected(refs, state, snapshot);
222
+ const blocked = refsProtected(refs, state, snapshot);
274
223
  return blocked ? "protected" : "eligible";
275
224
  }
276
225
 
@@ -278,6 +227,7 @@ export interface MarkAccounting {
278
227
  pending: number;
279
228
  agentMarks: number;
280
229
  ladderMarks: number;
230
+ userMarks: number;
281
231
  freedBytes: number;
282
232
  freedTokens: number;
283
233
  freedBudgetShare: number;
@@ -327,6 +277,7 @@ export function markAccounting(
327
277
  pending: marks.length,
328
278
  agentMarks: marks.filter((mark) => mark.origin === "agent").length,
329
279
  ladderMarks: marks.filter((mark) => mark.origin === "ladder").length,
280
+ userMarks: marks.filter((mark) => mark.origin === "user").length,
330
281
  freedBytes,
331
282
  freedTokens,
332
283
  freedBudgetShare: snapshot.budgetTokens > 0 ? freedTokens / snapshot.budgetTokens : 0,
@@ -442,8 +393,7 @@ export function ephemeralPeekMarks(input: {
442
393
  if (!peeked) continue;
443
394
  if (peeked.some((id) => state.expanded.includes(id) || peekedFoldProtected(snapshot, state, id))) continue;
444
395
  if (refs.some((ref) => claimed.has(objectRefKey(ref))) ||
445
- refs.length > snapshot.policy.maxFoldSourceRefs ||
446
- toolRefsProtected(refs, state, snapshot)) continue;
396
+ refsProtected(refs, state, snapshot)) continue;
447
397
  const candidate: FoldCandidate = {
448
398
  kind: "tool-result",
449
399
  parts: refs.map((ref) => ({ kind: "raw" as const, ref })),
@@ -468,9 +418,7 @@ function peekedFoldProtected(
468
418
  const fold = state.folds.find((item) => item.id === foldId);
469
419
  if (!fold) return false;
470
420
  const refs = flattenFoldRefs(fold, state);
471
- return fold.kind === "tool-result"
472
- ? toolRefsProtected(refs, state, snapshot)
473
- : refsProtected(refs, state, snapshot);
421
+ return refsProtected(refs, state, snapshot);
474
422
  }
475
423
 
476
424
  export function claimedRefKeys(state: ActiveContextState): Set<string> {
@@ -488,6 +436,8 @@ export function claimedRefKeys(state: ActiveContextState): Set<string> {
488
436
  export interface UnmarkedRemainder {
489
437
  spans: number;
490
438
  tokens: number;
439
+ /** Raw bytes, which is what the fold frontier measures itself against. */
440
+ chars: number;
491
441
  share: number;
492
442
  candidates: Array<{ id: string; tokens: number }>;
493
443
  }
@@ -504,6 +454,33 @@ export function unmarkedRemainder(
504
454
  for (const batch of automaticToolBatches(snapshot, state)) {
505
455
  for (const index of batch.indices) members.add(index);
506
456
  }
457
+ // CHAPTERS ARE FOLDABLE MASS TOO (Shane, 2026-08-23).
458
+ //
459
+ // The population used to be tool batches alone, while `selectAutomaticSpan` proposes a
460
+ // chapter whenever no batch is available, so on a chapter-heavy session this reported a
461
+ // remainder of zero over material automatic folding was about to take. It is the reading
462
+ // a deferred commit uses to say whether its mass is HELD or simply absent, which is the
463
+ // one distinction that record exists to draw, and it was answering "absent" for mass in
464
+ // plain view. It also broke the frontier's first cut, which gated on this number before
465
+ // the threshold moved into the selector where it belongs.
466
+ //
467
+ // Walked with the selector rather than re-derived, so the population is what automatic
468
+ // folding can actually reach and cannot drift from it. Walked from an EMPTY claim set,
469
+ // because this is the DENOMINATOR: a chapter already claimed by a mark is still foldable
470
+ // mass, it is just mass that is already spoken for, and the numerator below is what
471
+ // subtracts it. Bounded by the mapped length, which no terminating walk can exceed since
472
+ // every pass claims at least one ref.
473
+ const indexed = mappedByKey(snapshot);
474
+ const walked = new Set<string>();
475
+ for (let pass = 0; pass < snapshot.mapped.length; pass += 1) {
476
+ const chapter = selectAutomaticChapter(snapshot, state, walked);
477
+ if (!chapter) break;
478
+ for (const ref of chapter.sourceRefs) {
479
+ walked.add(objectRefKey(ref));
480
+ const item = indexed.get(objectRefKey(ref));
481
+ if (item) members.add(item.index);
482
+ }
483
+ }
507
484
  const candidates: Array<{ id: string; tokens: number }> = [];
508
485
  let unmarkedBytes = 0;
509
486
  let memberBytes = 0;
@@ -519,11 +496,193 @@ export function unmarkedRemainder(
519
496
  return {
520
497
  spans: candidates.length,
521
498
  tokens: Math.ceil(unmarkedBytes / perToken),
499
+ chars: unmarkedBytes,
522
500
  share: memberBytes > 0 ? unmarkedBytes / memberBytes : 0,
523
501
  candidates: candidates.slice(0, Math.max(0, limit)),
524
502
  };
525
503
  }
526
504
 
505
+ /**
506
+ * THE FOLD FRONTIER: THE RUNTIME CUTS, AND THE AGENT EDITS WHAT IT CUT
507
+ * (Shane, 2026-08-23).
508
+ *
509
+ * The ladder used to stage nothing between commits, and before that it staged a mark on
510
+ * every measured response. Both were answers to the same question, WHEN SHOULD THE AGENT
511
+ * CURATE, and both got it wrong in the same way: they asked the agent to CHOOSE SPANS.
512
+ * Eager staging chose them first and crowded the agent out; standing down left the choice
513
+ * open until the commit, by which time the material was long out of view and the only
514
+ * information available was staleness, which the runtime already has.
515
+ *
516
+ * So the choice moves. As raw material accumulates past `minFoldChars`, the runtime cuts
517
+ * it, stalest first, exactly as a commit would. The cut is a PENDING MARK and pending
518
+ * marks are byte-inert: nothing moves, the projection does not change, the prefix cache is
519
+ * untouched, and the agent keeps seeing the raw material until a commit applies it. What
520
+ * the agent gets is a fold to annotate while the content is still fresh, which is the one
521
+ * thing it knows that the runtime does not.
522
+ *
523
+ * The loop bounds itself twice over. It stops when the unmarked remainder falls under the
524
+ * threshold, and it stops when the selector runs out of members, so a window whose whole
525
+ * remainder is pinned or blacklisted stages nothing and says so by returning empty rather
526
+ * than spinning.
527
+ */
528
+ export function frontierMarks(input: {
529
+ snapshot: ActiveContextSnapshot;
530
+ state: ActiveContextState;
531
+ ordinal: number;
532
+ }): PendingFoldMark[] {
533
+ const { snapshot } = input;
534
+ const claimed = claimedRefKeys(input.state);
535
+ const marks: PendingFoldMark[] = [];
536
+ let state = input.state;
537
+ while (marks.length < MAX_FRONTIER_CUTS_PER_PASS) {
538
+ // THE THRESHOLD IS THE SELECTOR'S OWN, which is `minFoldChars`, and it is read there
539
+ // rather than counted here. The first cut of this measured the unmarked remainder
540
+ // instead, and that reads TOOL BATCHES ONLY: a chapter-only session had a remainder of
541
+ // zero however much raw prose it held, so the frontier never fired on it at all and
542
+ // gate 7's fixture produced no cut. One threshold, in the one place that knows every
543
+ // span kind.
544
+ const candidate = selectAutomaticSpan(snapshot, state, claimed);
545
+ if (!candidate) break;
546
+ const mark = foldMarkFor({
547
+ candidate,
548
+ ...automaticMarkBrief(snapshot, state, candidate),
549
+ origin: "ladder",
550
+ ordinal: input.ordinal,
551
+ });
552
+ const addition = addPendingMark(state, mark);
553
+ if (!addition.added) break;
554
+ state = addition.state;
555
+ marks.push(mark);
556
+ for (const ref of candidate.sourceRefs) claimed.add(objectRefKey(ref));
557
+ }
558
+ return marks;
559
+ }
560
+
561
+ /**
562
+ * The pending mark that already holds a piece of this evidence, if any.
563
+ *
564
+ * The commit fills from the stale end and the depth bound retains what it does not
565
+ * reach, so a ladder mark routinely outlives the commit that staged it. An agent
566
+ * marking the same evidence afterwards would stand a second owner over one span and
567
+ * lose at apply time with a forest invariant for a message. Refusing at the tool says
568
+ * so while the agent can still act on it.
569
+ */
570
+ export function markClaimingRef(
571
+ state: ActiveContextState,
572
+ refs: readonly EvidenceRef[],
573
+ ): { markId: string; entryId: string } | null {
574
+ for (const mark of pendingMarks(state)) {
575
+ if (mark.mark !== "fold") continue;
576
+ const claimed = new Set(markSpanRefs(state, mark).refs.map(objectRefKey));
577
+ for (const ref of refs) {
578
+ if (claimed.has(objectRefKey(ref))) return { markId: mark.id, entryId: ref.entryId };
579
+ }
580
+ }
581
+ return null;
582
+ }
583
+
584
+ export interface WindowClaims {
585
+ /** Tokens standing marks will free at the next commit. */
586
+ markedTokens: number;
587
+ /** Tokens the agent pinned raw: a claim that frees nothing and is never taken. */
588
+ pinnedTokens: number;
589
+ pinnedRefs: number;
590
+ /** Pinned mass as a share of the serving budget, against its cap. */
591
+ pinnedShare: number;
592
+ /** Completed units neither marked nor pinned: what the ladder takes by age. */
593
+ unclaimedTokens: number;
594
+ unclaimedSpans: number;
595
+ }
596
+
597
+ /**
598
+ * A PIN IS A CLAIM, NOT A FOLD (Shane, 2026-08-22: "if they do a pin on raw span I think
599
+ * we also treat that as a marked fold but a pinned one").
600
+ *
601
+ * Read as accounting, that is exactly right and already half true: pinned evidence is
602
+ * refused to every automatic rung, skipped by wedge absorption, and excluded from the
603
+ * unmarked remainder, so the ladder cannot take it and it is never reported as an
604
+ * oversight. What was missing is that the agent could not SEE it, and the whole point of
605
+ * asking an agent to account for its window is that the gaps become visible.
606
+ *
607
+ * A pin cannot count toward the DROP, and that is not a technicality. Pinned bytes stay
608
+ * in the window, so they free nothing; if they counted, an agent could "cover" a commit
609
+ * with pins, the commit would still be short, and it would fold something else anyway.
610
+ * The number would be a lie at the moment it mattered most. So the three buckets are
611
+ * reported side by side and only one of them pays.
612
+ */
613
+ export function windowClaims(input: {
614
+ snapshot: ActiveContextSnapshot;
615
+ state: ActiveContextState;
616
+ budgetTokens: number;
617
+ charsPerToken: number;
618
+ }): WindowClaims {
619
+ const { snapshot, state, budgetTokens } = input;
620
+ const accounting = markAccounting(snapshot, state);
621
+ const remainder = unmarkedRemainder(snapshot, state, input.charsPerToken);
622
+ const pinned = protectedStaleMass(snapshot, state);
623
+ const pinnedTokens = estimatedTokens(pinned.bytes);
624
+ return {
625
+ markedTokens: Math.round(accounting.eligibleFreedBudgetShare * budgetTokens),
626
+ pinnedTokens,
627
+ pinnedRefs: pinned.refs,
628
+ pinnedShare: budgetTokens > 0 ? pinnedTokens / budgetTokens : 0,
629
+ unclaimedTokens: remainder.tokens,
630
+ unclaimedSpans: remainder.spans,
631
+ };
632
+ }
633
+
634
+ export interface CommitCoverage {
635
+ /** The share of the serving budget the next commit is sized to free. */
636
+ targetShare: number;
637
+ targetTokens: number;
638
+ /** What the standing marks will actually free when that commit runs. */
639
+ markedShare: number;
640
+ markedTokens: number;
641
+ /** What the ladder will take by staleness unless the agent marks it first. */
642
+ remainingTokens: number;
643
+ covered: boolean;
644
+ }
645
+
646
+ /**
647
+ * WHAT THE NEXT COMMIT NEEDS, AND HOW MUCH OF IT THE AGENT HAS COVERED.
648
+ *
649
+ * One definition, read by three callers that used to reason about the drop separately:
650
+ * the commit fill that sizes itself to it, the advisory that warns before it, and the
651
+ * mark response that tells the agent where it stands. An agent asked to govern its own
652
+ * context cannot do it against a number nobody tells it.
653
+ *
654
+ * The marked side is the ELIGIBLE share, not every standing mark, because a mark over
655
+ * protected evidence frees nothing at this commit however good it is. That makes
656
+ * `remainingTokens` the honest answer to "what will fold without me".
657
+ *
658
+ * `targetShare` moves as the window grows: it is measured from occupancy NOW, and two
659
+ * large results landing after the reading raise it again. Callers that render it say so.
660
+ */
661
+ export function commitCoverage(input: {
662
+ snapshot: ActiveContextSnapshot;
663
+ state: ActiveContextState;
664
+ usedTokens: number | null;
665
+ budgetTokens: number;
666
+ }): CommitCoverage {
667
+ const { snapshot, state, usedTokens, budgetTokens } = input;
668
+ const targetShare = usedTokens === null || !(budgetTokens > 0)
669
+ ? 0
670
+ : Math.max(0, (usedTokens - snapshot.thresholds.minTarget * budgetTokens) / budgetTokens);
671
+ const accounting = markAccounting(snapshot, state);
672
+ const markedShare = accounting.eligibleFreedBudgetShare;
673
+ const targetTokens = Math.round(targetShare * budgetTokens);
674
+ const markedTokens = Math.round(markedShare * budgetTokens);
675
+ const remainingTokens = Math.max(0, targetTokens - markedTokens);
676
+ return {
677
+ targetShare,
678
+ targetTokens,
679
+ markedShare,
680
+ markedTokens,
681
+ remainingTokens,
682
+ covered: remainingTokens === 0,
683
+ };
684
+ }
685
+
527
686
  export function markedFoldIds(state: ActiveContextState): Set<string> {
528
687
  const ids = new Set<string>();
529
688
  for (const mark of pendingMarks(state)) if (mark.mark === "refold") ids.add(mark.id);
@@ -548,13 +707,11 @@ export function topUpMarks(input: {
548
707
  state: ActiveContextState;
549
708
  ordinal: number;
550
709
  targetShare: number;
551
- excludeRefKeys?: ReadonlySet<string>;
552
710
  eligibleOnly?: boolean;
553
711
  }): PendingFoldMark[] {
554
712
  const { snapshot } = input;
555
713
  const target = input.targetShare;
556
714
  const claimed = claimedRefKeys(input.state);
557
- for (const key of input.excludeRefKeys ?? []) claimed.add(key);
558
715
  const marks: PendingFoldMark[] = [];
559
716
  let state = input.state;
560
717
  const progress = (value: ActiveContextState): number => {
@@ -581,6 +738,50 @@ export function topUpMarks(input: {
581
738
  return marks;
582
739
  }
583
740
 
741
+ /**
742
+ * REFOLD AS THE LAST FILL, NOT THE FIRST (Shane, 2026-08-21).
743
+ *
744
+ * An expanded fold is stale mass sitting in the window, so the commit may reclaim it,
745
+ * but only after raw stale material has been spent. An agent that expands a fold is
746
+ * reading it; taking it back while there is still unfolded material to take is hostile
747
+ * for no gain. Pin holds a span past that point, and a live lease holds it for the
748
+ * generations right after the expand. Stalest first, and never past the freeing target.
749
+ */
750
+ export function refoldMarks(input: {
751
+ snapshot: ActiveContextSnapshot;
752
+ state: ActiveContextState;
753
+ ordinal: number;
754
+ targetShare: number;
755
+ eligibleOnly?: boolean;
756
+ }): PendingMark[] {
757
+ const { snapshot } = input;
758
+ const marks: PendingMark[] = [];
759
+ let state = input.state;
760
+ const claimedFoldIds = markedFoldIds(state);
761
+ const progress = (value: ActiveContextState): number => {
762
+ const accounting = markAccounting(snapshot, value);
763
+ return input.eligibleOnly ? accounting.eligibleFreedBudgetShare : accounting.freedBudgetShare;
764
+ };
765
+ let share = progress(state);
766
+ while (share < input.targetShare) {
767
+ const foldId = selectAutomaticRefold(snapshot, state, claimedFoldIds);
768
+ if (!foldId) break;
769
+ const mark: PendingMark = {
770
+ mark: "refold",
771
+ id: foldId,
772
+ origin: "ladder",
773
+ ordinal: input.ordinal,
774
+ };
775
+ const addition = addPendingMark(state, mark);
776
+ if (!addition.added) break;
777
+ state = addition.state;
778
+ marks.push(mark);
779
+ claimedFoldIds.add(foldId);
780
+ share = progress(state);
781
+ }
782
+ return marks;
783
+ }
784
+
584
785
  export interface AbsorbedWedge {
585
786
  intoMarkId: string;
586
787
  fromMarkId: string;
@@ -588,6 +789,7 @@ export interface AbsorbedWedge {
588
789
  endId: string;
589
790
  entries: number;
590
791
  tokens: number;
792
+ chars: number;
591
793
  }
592
794
 
593
795
  export function absorbWedgeMarks(input: {
@@ -631,8 +833,16 @@ export function absorbWedgeMarks(input: {
631
833
  const gapEnd = interval.start - 1;
632
834
  if (gapStart < 0 || gapEnd < gapStart) continue;
633
835
  if (absorbed.some((item) => item.intoMarkId === mark.id)) continue;
634
- const gapTokens = Math.ceil(spanBytes(snapshot, gapStart, gapEnd + 1) / charsPerToken);
635
- if (gapTokens > MAX_WEDGE_ABSORB_TOKENS) continue;
836
+ // THE GAP FLOOR IS THE FOLD FLOOR (Shane, 2026-08-21). A gap between two folds
837
+ // that could not stand as a fold on its own has no business staying raw: it costs
838
+ // its own bytes and buys nothing, and the neighbour beside it can hold it for the
839
+ // price of one sentence in the brief. So anything under minFoldChars is absorbed
840
+ // and anything at or over it is left alone, because at that size it is a fold the
841
+ // ladder will take on its own terms. This replaced MAX_WEDGE_ABSORB_TOKENS, a
842
+ // separate 256-token constant that let 1,024-char slivers survive between folds.
843
+ const gapBytes = spanBytes(snapshot, gapStart, gapEnd + 1);
844
+ if (gapBytes >= snapshot.thresholds.minFoldChars) continue;
845
+ const gapTokens = Math.ceil(gapBytes / charsPerToken);
636
846
  const gapRefs = [];
637
847
  let usable = true;
638
848
  for (let index = gapStart; index <= gapEnd; index += 1) {
@@ -669,6 +879,7 @@ export function absorbWedgeMarks(input: {
669
879
  endId: gapRefs.at(-1)!.entryId,
670
880
  entries: gapRefs.length,
671
881
  tokens: gapTokens,
882
+ chars: gapBytes,
672
883
  });
673
884
  applied = true;
674
885
  break;
@@ -698,7 +909,6 @@ export interface CommitEpochResult {
698
909
  applied: AppliedMark[];
699
910
  refused: RefusedMark[];
700
911
  retained: PendingMark[];
701
- waived: PendingMark[];
702
912
  }
703
913
 
704
914
  export async function commitPendingMarks(input: {
@@ -707,48 +917,40 @@ export async function commitPendingMarks(input: {
707
917
  generation: number;
708
918
  now?: () => number;
709
919
  retainIneligible?: boolean;
710
- guardCurrentTurn?: boolean;
711
- guardWaiver?: number;
920
+ /**
921
+ * How many bytes this commit needs to free. Marks past it are RETAINED, not applied.
922
+ *
923
+ * This bound did not exist before the frontier (Shane, 2026-08-23), and it did not need
924
+ * to: staging was sized to the drop by `topUpMarks`, so every pending mark was one the
925
+ * commit had already decided it wanted. The frontier stages as material ARRIVES, which
926
+ * is the whole point of it, so by the time a commit fires the pending set is everything
927
+ * the window holds. Applying all of it would cut the window to nothing at the first
928
+ * commit, which is neither what the budget asks for nor what the agent is owed: what it
929
+ * is owed is that material stays raw for as long as there is room for it.
930
+ *
931
+ * Undefined means no bound, which is what the emergency lanes want: a fence or a
932
+ * rollback is not housekeeping and takes everything it can.
933
+ */
934
+ applyTargetBytes?: number;
712
935
  }): Promise<CommitEpochResult> {
936
+ // THE CURRENT-TURN GUARD IS DELETED (Shane, 2026-08-23: "you're using turns as the
937
+ // boundaries, which should not be the case. It should be at the most granular level,
938
+ // which is events"). The guard retained every mark touching a toolResult newer than
939
+ // the last terminal assistant stop, and a terminal stop is a TURN artifact: the first
940
+ // live session (sol-20260823-live rep 1) ran its whole workload as one turn, every
941
+ // assistant response stopping "toolUse", so the guard's "current turn" was the entire
942
+ // window, four band-top commits applied nothing, and the projection fence swept 18
943
+ // marks at 0.958 occupancy down to a 0.031 landing against a 0.40 aim. The waiver
944
+ // machinery existed only to bail the guard out at the fence and the boundary, so it
945
+ // is deleted with it. What protects the working set now is structural and event-level:
946
+ // an incomplete batch is never proposable, the depth bound stops the routine commit at
947
+ // the aim, and the stalest-first cut order leaves the newest events raw.
713
948
  const marks = pendingMarks(input.state);
714
949
  const retained: PendingMark[] = [];
715
- const waived: PendingMark[] = [];
716
- const guardReasons = new Map<string, string>();
717
950
  const held = new Set(input.state.folds.map((fold) => fold.id));
718
951
  const deferred: PendingMark[] = [];
719
952
  const awaitingMintedChild = (mark: PendingMark): boolean =>
720
953
  markSpanRefs(input.state, mark).unresolved?.pending === true;
721
- const currentTurn = input.guardCurrentTurn
722
- ? currentTurnRefKeys(input.snapshot)
723
- : new Set<string>();
724
- if (input.guardCurrentTurn) {
725
- const applicable: PendingMark[] = [];
726
- const guarded: PendingMark[] = [];
727
- for (const mark of marks) {
728
- if (markTouchesCurrentTurn(input.state, mark, currentTurn)) guarded.push(mark);
729
- else applicable.push(mark);
730
- }
731
- const guardedStart = new Map(guarded.map((mark) =>
732
- [pendingMarkKey(mark), markSpanStart(input.snapshot, input.state, mark)] as const));
733
- guarded.sort((left, right) =>
734
- guardedStart.get(pendingMarkKey(left))! - guardedStart.get(pendingMarkKey(right))! ||
735
- left.id.localeCompare(right.id));
736
- const waiverCount = Math.max(0, Math.min(guarded.length, Math.trunc(input.guardWaiver ?? 0)));
737
- for (const [index, mark] of guarded.entries()) {
738
- if (index < waiverCount) {
739
- waived.push(mark);
740
- applicable.push(mark);
741
- continue;
742
- }
743
- retained.push(mark);
744
- guardReasons.set(
745
- pendingMarkKey(mark),
746
- "evidence was gathered in the current turn; the mark stays pending until the turn closes",
747
- );
748
- }
749
- marks.length = 0;
750
- marks.push(...applicable);
751
- }
752
954
  if (input.retainIneligible) {
753
955
  const applicable: PendingMark[] = [];
754
956
  for (const mark of marks) {
@@ -765,8 +967,7 @@ export async function commitPendingMarks(input: {
765
967
  mark: mark.mark,
766
968
  id: mark.id,
767
969
  origin: mark.origin,
768
- reason: guardReasons.get(pendingMarkKey(mark)) ??
769
- "span is still fresh or protected; the mark stays pending until it is eligible",
970
+ reason: "span is still fresh or protected; the mark stays pending until it is eligible",
770
971
  retained: true,
771
972
  }));
772
973
  const minting = new Map(marks.flatMap((mark) =>
@@ -793,6 +994,40 @@ export async function commitPendingMarks(input: {
793
994
  placed.add(key);
794
995
  ordered.push(mark);
795
996
  };
997
+ // THE DEPTH CUT, STALEST FIRST. `base` is already in span order, so taking a prefix of
998
+ // it takes the oldest material and leaves the newest raw, which is the ordering the
999
+ // agent would choose if it were asked. A mark left over here is RETAINED rather than
1000
+ // refused: it is still pending, still briefable, still editable, and the next commit
1001
+ // that needs depth will reach it.
1002
+ const bounded: PendingMark[] = [];
1003
+ if (input.applyTargetBytes !== undefined && input.applyTargetBytes > 0) {
1004
+ let freed = 0;
1005
+ for (const mark of base) {
1006
+ if (freed >= input.applyTargetBytes) {
1007
+ bounded.push(mark);
1008
+ continue;
1009
+ }
1010
+ freed += markFreedBytes(input.snapshot, input.state, mark);
1011
+ }
1012
+ if (bounded.length) {
1013
+ const spare = new Set(bounded.map((mark) => pendingMarkKey(mark)));
1014
+ const kept = base.filter((mark) => !spare.has(pendingMarkKey(mark)));
1015
+ base.length = 0;
1016
+ base.push(...kept);
1017
+ for (const mark of bounded) {
1018
+ retained.push(mark);
1019
+ refused.push({
1020
+ mark: mark.mark,
1021
+ id: mark.id,
1022
+ origin: mark.origin,
1023
+ reason: "the commit reached its target before this mark; it stays pending and " +
1024
+ "the span stays raw until a later commit needs the depth",
1025
+ retained: true,
1026
+ });
1027
+ }
1028
+ state = withPendingMarks(input.state, retained);
1029
+ }
1030
+ }
796
1031
  for (const mark of base) place(mark);
797
1032
  for (const mark of ordered) {
798
1033
  try {
@@ -825,13 +1060,11 @@ export async function commitPendingMarks(input: {
825
1060
  continue;
826
1061
  }
827
1062
  const sourceRefs = span.refs;
828
- const blocked = mark.kind === "tool-result"
829
- ? toolRefsProtected(sourceRefs, state, input.snapshot)
830
- : refsProtected(sourceRefs, state, input.snapshot);
1063
+ const blocked = refsProtected(sourceRefs, state, input.snapshot);
831
1064
  if (blocked) {
832
1065
  throw new Error(
833
1066
  `pending fold mark ${mark.id} covers protected or fresh evidence; ` +
834
- "unprotect that evidence and mark it again",
1067
+ "unpin that evidence and mark it again",
835
1068
  );
836
1069
  }
837
1070
  const prepared = await prepareFold({
@@ -867,7 +1100,7 @@ export async function commitPendingMarks(input: {
867
1100
  state = withPendingMarks(state, [...pendingMarks(state), ...deferred]);
868
1101
  retained.push(...deferred);
869
1102
  }
870
- return { state, applied, refused, retained, waived };
1103
+ return { state, applied, refused, retained };
871
1104
  }
872
1105
 
873
1106
  export function schedulingStatus(input: {