dsh-context-compression-improved 0.5.1 → 0.5.3

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.
Files changed (57) hide show
  1. package/.gitattributes +1 -0
  2. package/CHANGELOG.ja.md +144 -83
  3. package/CHANGELOG.ko.md +143 -82
  4. package/CHANGELOG.md +278 -212
  5. package/CHANGELOG.zh.md +131 -77
  6. package/docs/installation.md +103 -103
  7. package/docs/installation.zh.md +100 -100
  8. package/package.json +1 -1
  9. package/packages/selector/cordis.patch.yml +5 -6
  10. package/packages/selector/lib/advisor-state.js +4 -231
  11. package/packages/selector/lib/client.d.ts +0 -24
  12. package/packages/selector/lib/client.js +6 -501
  13. package/packages/selector/lib/index.d.ts +4 -10
  14. package/packages/selector/lib/index.js +16 -234
  15. package/packages/selector/lib/pruner.d.ts +13 -248
  16. package/packages/selector/lib/pruner.js +148 -552
  17. package/packages/selector/src/client/EstimatorControls.tsx +0 -101
  18. package/packages/selector/src/client/index.ts +0 -17
  19. package/packages/selector/src/client/locales.ts +0 -38
  20. package/packages/selector/src/client/preset-options.ts +3 -2
  21. package/packages/selector/src/client/settings-section.tsx +8 -17
  22. package/packages/selector/src/index.ts +24 -271
  23. package/packages/selector/src/profiles.ts +4 -27
  24. package/packages/selector/src/pruner/state.ts +2 -25
  25. package/packages/selector/src/pruner.ts +75 -403
  26. package/packages/selector/src/runtime/audit.ts +27 -21
  27. package/packages/selector/src/runtime/config.ts +6 -32
  28. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
  29. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -133
  30. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
  31. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  32. package/packages/selector/src/runtime/types.ts +0 -17
  33. package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
  34. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  35. package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
  36. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  37. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
  38. package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
  39. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  40. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
  41. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  42. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  43. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  44. package/packages/selector/tests/settings-seat.client.spec.ts +45 -14
  45. package/scripts/toolclass-corpus-replay.mjs +281 -281
  46. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  47. package/packages/selector/src/client/review-scope.ts +0 -16
  48. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  49. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  50. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  51. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  52. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  53. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  54. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  55. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  56. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  57. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -1,320 +0,0 @@
1
- /**
2
- * TokenPilot-inspired R4: the review floating window.
3
- *
4
- * Mounted on the host `shell.overlay` slot (dsh-tidychat precedent: the layer
5
- * is click-through by default and only the card opts back in), showing a
6
- * bottom-right badge while any session has pending proposals and a card with
7
- * the four-state summary row plus one row per proposal. Every 10s it polls the
8
- * review-queue route; when the queue is empty or review mode is off the
9
- * component renders null, so it never disturbs the session.
10
- *
11
- * Styles carry the `dsh-cc-review-` prefix and ride a one-shot style tag.
12
- */
13
- import { useEffect, useState } from 'react'
14
- import type { ReactElement } from 'react'
15
- import type { SettingsScopeLike } from './review-scope.ts'
16
-
17
- const QUEUE_ROUTES = [
18
- '/api/dsh-context-compression-improved/review-queue',
19
- '/endpoint/dsh-context-compression-improved/review-queue',
20
- ] as const
21
- const DECIDE_ROUTES = [
22
- '/api/dsh-context-compression-improved/review-decide',
23
- '/endpoint/dsh-context-compression-improved/review-decide',
24
- ] as const
25
-
26
- export interface PendingProposal {
27
- readonly sessionId: string
28
- readonly id: string
29
- readonly kind: string
30
- readonly items: readonly { readonly seq: number, readonly tokensBefore: number, readonly tokensAfter: number }[]
31
- readonly benefit: {
32
- readonly recoveredTokens: number
33
- readonly paybackTurns?: number
34
- readonly expectedSaving?: number
35
- }
36
- }
37
-
38
- export interface ReviewSummary {
39
- readonly autoApplied: number
40
- readonly reviewApplied: number
41
- readonly expired: number
42
- readonly voided: number
43
- }
44
-
45
- interface ReviewOverlayProps {
46
- /** Bound settings scope; supplies the reviewMode switch. */
47
- scope: SettingsScopeLike
48
- t: (key: string) => string
49
- }
50
-
51
- const CSS = `
52
- .dsh-cc-review-badge {
53
- position: fixed;
54
- right: 20px;
55
- bottom: 20px;
56
- z-index: 70;
57
- pointer-events: auto;
58
- box-sizing: border-box;
59
- min-width: 34px;
60
- height: 34px;
61
- padding: 0 10px;
62
- border-radius: 17px;
63
- border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4));
64
- background: var(--dsw-alias-bg-layer-3, #fff);
65
- color: var(--dsw-alias-label-primary, #222);
66
- font-size: 13px;
67
- display: flex;
68
- align-items: center;
69
- justify-content: center;
70
- gap: 6px;
71
- cursor: pointer;
72
- box-shadow: 0 6px 18px rgba(0, 0, 0, 0.14);
73
- }
74
- .dsh-cc-review-card {
75
- position: fixed;
76
- right: 20px;
77
- bottom: 62px;
78
- z-index: 70;
79
- pointer-events: auto;
80
- box-sizing: border-box;
81
- width: min(420px, calc(100vw - 40px));
82
- max-height: min(60vh, 520px);
83
- overflow: auto;
84
- background: var(--dsw-alias-bg-layer-3, #fff);
85
- border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4));
86
- border-radius: 12px;
87
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
88
- padding: 12px 14px;
89
- color: var(--dsw-alias-label-primary, #222);
90
- font-size: 13px;
91
- }
92
- .dsh-cc-review-title {
93
- font-weight: 600;
94
- margin: 0 0 6px;
95
- font-size: 13px;
96
- }
97
- .dsh-cc-review-summary {
98
- display: flex;
99
- flex-wrap: wrap;
100
- gap: 4px 12px;
101
- color: var(--dsw-alias-label-tertiary, #888);
102
- font-size: 12px;
103
- margin-bottom: 8px;
104
- }
105
- .dsh-cc-review-row {
106
- border-top: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25));
107
- padding: 8px 0;
108
- }
109
- .dsh-cc-review-row-meta {
110
- color: var(--dsw-alias-label-tertiary, #888);
111
- font-size: 12px;
112
- margin-bottom: 4px;
113
- }
114
- .dsh-cc-review-actions {
115
- display: flex;
116
- gap: 8px;
117
- }
118
- .dsh-cc-review-btn {
119
- appearance: none;
120
- border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.4));
121
- background: transparent;
122
- color: inherit;
123
- border-radius: 6px;
124
- padding: 3px 10px;
125
- font-size: 12px;
126
- cursor: pointer;
127
- }
128
- .dsh-cc-review-btn-primary {
129
- background: var(--dsw-alias-state-business-primary, #3b82f6);
130
- border-color: transparent;
131
- color: #fff;
132
- }
133
- `
134
-
135
- function injectOnce(): () => void {
136
- const tag = document.createElement('style')
137
- tag.setAttribute('data-plugin-css', 'dsh-context-compression-improved-review')
138
- tag.textContent = CSS
139
- document.head.appendChild(tag)
140
- return () => { tag.remove() }
141
- }
142
-
143
- async function fetchJson(route: string, init?: RequestInit): Promise<unknown> {
144
- const response = await fetch(route, { headers: { 'cache-control': 'no-cache' }, ...init })
145
- if (!response.ok) return undefined
146
- return response.json()
147
- }
148
-
149
- async function pollQueue(): Promise<{ pending: PendingProposal[], summary?: ReviewSummary | undefined } | undefined> {
150
- for (const route of QUEUE_ROUTES) {
151
- const body = await fetchJson(route) as {
152
- ok?: boolean
153
- total?: number
154
- pending?: PendingProposal[]
155
- summary?: ReviewSummary
156
- } | undefined
157
- if (body?.ok === true) {
158
- return { pending: body.pending ?? [], summary: body.summary }
159
- }
160
- }
161
- return undefined
162
- }
163
-
164
- async function postDecide(proposal: PendingProposal, decision: string): Promise<boolean> {
165
- for (const route of DECIDE_ROUTES) {
166
- try {
167
- const response = await fetch(route, {
168
- method: 'POST',
169
- headers: { 'content-type': 'application/json' },
170
- body: JSON.stringify({ sessionId: proposal.sessionId, proposalId: proposal.id, decision }),
171
- })
172
- if (response.status !== 404) return response.ok
173
- } catch {
174
- // Try the next prefix.
175
- }
176
- }
177
- return false
178
- }
179
-
180
- /**
181
- * Slot factory helper: the client entry is a .ts file and cannot carry JSX,
182
- * so the element construction lives here.
183
- */
184
- export function renderReviewOverlay(
185
- scope: SettingsScopeLike,
186
- t: (key: string) => string,
187
- ): ReactElement {
188
- return <ReviewOverlay scope={scope} t={t} />
189
- }
190
-
191
- /**
192
- * The floating window itself: renders null (and stays silent) while review
193
- * mode is off or nothing is pending.
194
- */
195
- export function ReviewOverlay({ scope, t }: ReviewOverlayProps) {
196
- const [reviewMode, setReviewMode] = useState(false)
197
- const [pending, setPending] = useState<PendingProposal[]>([])
198
- const [summary, setSummary] = useState<ReviewSummary | undefined>()
199
- const [expanded, setExpanded] = useState(false)
200
- const [busy, setBusy] = useState(false)
201
-
202
- useEffect(injectOnce, [])
203
- useEffect(() => {
204
- const pull = (): void => {
205
- try {
206
- const snapshot = scope.getSnapshot()
207
- setReviewMode(snapshot.status === 'ready' && snapshot.value?.presetOptions?.reviewMode === true)
208
- } catch {
209
- setReviewMode(false)
210
- }
211
- }
212
- pull()
213
- let unsubscribe: () => void
214
- try {
215
- unsubscribe = scope.subscribe(pull)
216
- } catch {
217
- unsubscribe = (): void => {}
218
- }
219
- return unsubscribe
220
- }, [scope])
221
-
222
- useEffect(() => {
223
- if (!reviewMode) return
224
- let alive = true
225
- let timer: ReturnType<typeof setTimeout> | undefined
226
- const tick = (): void => {
227
- void pollQueue().then((result) => {
228
- if (!alive) return
229
- setPending(result?.pending ?? [])
230
- setSummary(result?.summary)
231
- timer = setTimeout(tick, 10_000)
232
- })
233
- }
234
- tick()
235
- return () => {
236
- alive = false
237
- if (timer !== undefined) clearTimeout(timer)
238
- }
239
- }, [reviewMode])
240
-
241
- if (!reviewMode || pending.length === 0) return null
242
-
243
- const decide = (proposal: PendingProposal, decision: string): void => {
244
- setBusy(true)
245
- void postDecide(proposal, decision).then(() => {
246
- return pollQueue().then((result) => {
247
- setPending(result?.pending ?? [])
248
- setSummary(result?.summary)
249
- setBusy(false)
250
- })
251
- }).catch(() => { setBusy(false) })
252
- }
253
-
254
- const seqRange = (proposal: PendingProposal): string => {
255
- const seqs = proposal.items.map(item => item.seq)
256
- const min = Math.min(...seqs)
257
- const max = Math.max(...seqs)
258
- return min === max ? `#${String(min)}` : `#${String(min)}–#${String(max)}`
259
- }
260
-
261
- return (
262
- <>
263
- {expanded ? (
264
- <div className="dsh-cc-review-card">
265
- <p className="dsh-cc-review-title">{t('review.title')}</p>
266
- {summary !== undefined ? (
267
- <div className="dsh-cc-review-summary">
268
- <span>{t('review.summary.autoApplied')}: {String(summary.autoApplied)}</span>
269
- <span>{t('review.summary.reviewApplied')}: {String(summary.reviewApplied)}</span>
270
- <span>{t('review.summary.expired')}: {String(summary.expired)}</span>
271
- <span>{t('review.summary.voided')}: {String(summary.voided)}</span>
272
- </div>
273
- ) : null}
274
- {pending.map(proposal => (
275
- <div className="dsh-cc-review-row" key={proposal.id}>
276
- <div className="dsh-cc-review-row-meta">
277
- {proposal.kind} · {seqRange(proposal)} · R ≈ {String(proposal.benefit.recoveredTokens)}
278
- {proposal.benefit.paybackTurns !== undefined
279
- ? ` · ${t('review.row.payback')}: ${String(Math.round(proposal.benefit.paybackTurns * 100) / 100)}`
280
- : ''}
281
- {proposal.benefit.expectedSaving !== undefined
282
- ? ` · ${t('review.row.expectedSaving')}: ${String(Math.round(proposal.benefit.expectedSaving))} (${t('review.row.estimated')})`
283
- : ''}
284
- </div>
285
- <div className="dsh-cc-review-actions">
286
- <button
287
- type="button" className="dsh-cc-review-btn dsh-cc-review-btn-primary"
288
- disabled={busy}
289
- onClick={() => { decide(proposal, 'approved') }}
290
- >
291
- {t('review.action.approve')}
292
- </button>
293
- <button
294
- type="button" className="dsh-cc-review-btn"
295
- disabled={busy}
296
- onClick={() => { decide(proposal, 'rejected') }}
297
- >
298
- {t('review.action.reject')}
299
- </button>
300
- <button
301
- type="button" className="dsh-cc-review-btn"
302
- disabled={busy}
303
- onClick={() => { decide(proposal, 'ignored') }}
304
- >
305
- {t('review.action.ignore')}
306
- </button>
307
- </div>
308
- </div>
309
- ))}
310
- </div>
311
- ) : null}
312
- <button
313
- type="button" className="dsh-cc-review-badge"
314
- onClick={() => { setExpanded(value => !value) }}
315
- >
316
- {t('review.badge')} {String(pending.length)}
317
- </button>
318
- </>
319
- )
320
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * The minimal face of the bound settings scope the review overlay consumes —
3
- * structural, so tests can stub it without the settings transport.
4
- */
5
-
6
- export interface SettingsScopeLike {
7
- getSnapshot(): {
8
- status: string
9
- value?: {
10
- presetOptions?: {
11
- reviewMode?: boolean | undefined
12
- } | undefined
13
- } | undefined
14
- }
15
- subscribe(listener: () => void): () => void
16
- }
@@ -1,267 +0,0 @@
1
- /**
2
- * TokenPilot-inspired R4: benefit model for the human-gated review pipeline.
3
- *
4
- * Pure functions only: the classifier needs no I/O, no session state, and no
5
- * host services, so every decision is unit-testable and audit-replayable.
6
- *
7
- * The cost model follows the TokenPilot paper's cache-accounting view: one
8
- * merged mutation pays a one-time tail KV-cache refill penalty of
9
- * `(1−α)·tailTokens`, and every later turn recovers the reclaimed tokens at
10
- * the cache-hit discount `α`:
11
- *
12
- * ```
13
- * R = Σ(tokensBefore − tokensAfter) // net reclaimed tokens
14
- * paybackTurns = (1−α)·tailTokens / (α·R) // one-time refill / per-turn saving
15
- * expectedSaving = α·R·max(0, Ŝ − paybackTurns) // Ŝ = estimated remaining turns
16
- * ```
17
- *
18
- * The refill penalty only models mutations of already-cached context. A
19
- * fresh-stage batch (shaped before its first request) is exempt via
20
- * `refillPenaltyExempt`: payback is 0 and every reclaimed token saves from
21
- * the very first turn.
22
- *
23
- * `expectedSaving` is only produced when Ŝ is known (the estimator answered
24
- * with `expectedRemainingTurns`); it is never fabricated from a guess.
25
- */
26
- import { createHash } from 'node:crypto'
27
- import type { ContentBlock } from '@deepseek-ai/dsh-llm'
28
- import { dedupeHash, flattenPlainText } from './dedup.ts'
29
-
30
- /** The minimal per-candidate face the benefit model consumes. */
31
- export interface BenefitCandidate {
32
- readonly sourceSeq: number
33
- readonly tokensBefore: number
34
- readonly tokensAfter: number
35
- }
36
-
37
- export interface BenefitInput {
38
- /** Cache-hit discount rate α ∈ (0,1); validated upstream by config parsing. */
39
- readonly alpha: number
40
- /** Token mass of the protected tail that must be refilled after a mutation. */
41
- readonly tailTokens: number
42
- /** Estimated remaining turns Ŝ; `undefined` keeps expectedSaving out of the result. */
43
- readonly remainingTurns?: number | undefined
44
- /** True for fresh-stage batches: their content was never served, so it is
45
- * not in the KV cache and shaping it causes no cache break — no refill
46
- * penalty applies and the whole discounted recovery is pure gain. */
47
- readonly refillPenaltyExempt?: boolean | undefined
48
- }
49
-
50
- export interface BenefitEstimate {
51
- /** Net reclaimed tokens across the batch; may be ≤ 0 when a batch is not worth it. */
52
- readonly recoveredTokens: number
53
- /** The one-time cache-refill penalty the merged mutation pays: (1−α)·tailTokens. */
54
- readonly penaltyTokens: number
55
- /** Turns of discounted recovery needed to recoup the penalty; `undefined` when α·R ≤ 0. */
56
- readonly paybackTurns?: number
57
- /** Discounted net benefit over the remaining session; omitted when Ŝ is unknown. */
58
- readonly expectedSaving?: number
59
- }
60
-
61
- /**
62
- * Aggregate the batch-level benefit of a set of reduction candidates.
63
- *
64
- * Individual candidates whose replacement would grow the context contribute
65
- * zero recovery (they never make a batch look better than dropping them).
66
- */
67
- export function computeBenefit(candidates: readonly BenefitCandidate[], input: BenefitInput): BenefitEstimate {
68
- const { alpha, tailTokens, remainingTurns } = input
69
- let recoveredTokens = 0
70
- for (const candidate of candidates) {
71
- recoveredTokens += Math.max(0, candidate.tokensBefore - candidate.tokensAfter)
72
- }
73
- const penaltyTokens = input.refillPenaltyExempt === true ? 0 : (1 - alpha) * tailTokens
74
- const perTurnSaving = alpha * recoveredTokens
75
- if (perTurnSaving <= 0) {
76
- return remainingTurns === undefined
77
- ? { recoveredTokens, penaltyTokens }
78
- : { recoveredTokens, penaltyTokens, expectedSaving: -penaltyTokens }
79
- }
80
- const paybackTurns = penaltyTokens / perTurnSaving
81
- if (remainingTurns === undefined) {
82
- return { recoveredTokens, penaltyTokens, paybackTurns }
83
- }
84
- return {
85
- recoveredTokens,
86
- penaltyTokens,
87
- paybackTurns,
88
- expectedSaving: perTurnSaving * Math.max(0, remainingTurns - paybackTurns),
89
- }
90
- }
91
-
92
- /**
93
- * Stable proposal identity: the sha-256 of the serialized item digests, cut to
94
- * 12 hex chars. Stable across re-enqueues of the same content so a repeated
95
- * classification cannot duplicate a pending proposal.
96
- */
97
- export function proposalId(itemDigests: readonly string[]): string {
98
- const hash = createHash('sha256')
99
- for (const digest of itemDigests) hash.update(digest)
100
- hash.update(String(itemDigests.length))
101
- return hash.digest('hex').slice(0, 12)
102
- }
103
-
104
- /** Human-facing reduction kind carried by every review proposal. */
105
- export type ProposalKind = 'estimator' | 'dedup' | 'read-state'
106
-
107
- /** The minimal candidate face the triage classifier consumes. */
108
- export interface ClassifiableCandidate {
109
- readonly sourceSeq: number
110
- readonly tokensBefore: number
111
- readonly tokensAfter: number
112
- /** Compression primitive that planned this replacement. */
113
- readonly component: string
114
- /** Reducer id (`dedupe-pointer`, `superseded-read-whole-result`, …). */
115
- readonly reducer: string
116
- /** Content to freeze into the proposal digest. */
117
- readonly content: readonly ContentBlock[]
118
- }
119
-
120
- export interface TriageInput {
121
- /** Cache-hit discount rate α ∈ (0,1); validated upstream by config parsing. */
122
- readonly alpha: number
123
- /** Token mass of the protected tail that must be refilled after a mutation. */
124
- readonly tailTokens: number
125
- /** Candidates at or above this token impact skip triage and always enter review. */
126
- readonly reviewHighImpactTokens: number
127
- /** Estimated remaining turns Ŝ; `undefined` keeps the edge band closed. */
128
- readonly remainingTurns?: number | undefined
129
- /** Seqs whose reduction came from the estimator channel; overrides the kind. */
130
- readonly estimatorSeqs?: ReadonlySet<number> | undefined
131
- /** Landing stage of the batch: `'fresh'` batches are priced without the
132
- * tail-refill penalty (first-exposure shaping causes no cache break);
133
- * `'history'` batches — already-served content — pay it in full. */
134
- readonly stage?: 'fresh' | 'history' | undefined
135
- }
136
-
137
- /** One frozen item inside a review proposal: metadata and digest, never content. */
138
- export interface ProposalItem {
139
- readonly seq: number
140
- readonly component: string
141
- readonly kind: ProposalKind
142
- readonly tokensBefore: number
143
- readonly tokensAfter: number
144
- /** Content sha-256 frozen at enqueue time and re-checked at the apply point. */
145
- readonly digest: string
146
- }
147
-
148
- /** Proposal fields derivable at classification time; queue fields attach at enqueue. */
149
- export interface ProposalSkeleton {
150
- readonly id: string
151
- readonly kind: ProposalKind
152
- readonly items: readonly ProposalItem[]
153
- readonly benefit: BenefitEstimate
154
- }
155
-
156
- export interface ClassificationResult {
157
- /** Clearly profitable candidates that keep the existing automatic path. */
158
- readonly auto: readonly ClassifiableCandidate[]
159
- /** Edge-band or high-impact candidates folded into review proposal skeletons. */
160
- readonly review: readonly ProposalSkeleton[]
161
- /** Negative-benefit candidates the pipeline keeps discarding. */
162
- readonly drop: readonly ClassifiableCandidate[]
163
- }
164
-
165
- /**
166
- * Canonical content digest reused from the dedup hash: plain-text results hash
167
- * through the dedupe canonicalization; rich blocks fall back to canonical JSON
168
- * so every candidate is freezable.
169
- */
170
- export function contentDigest(content: readonly ContentBlock[]): string {
171
- const text = flattenPlainText(content)
172
- return dedupeHash(text ?? JSON.stringify(content), 'trim-eol')
173
- }
174
-
175
- function proposalKindFor(candidate: ClassifiableCandidate, estimatorSeqs: ReadonlySet<number> | undefined): ProposalKind {
176
- if (estimatorSeqs?.has(candidate.sourceSeq) === true) return 'estimator'
177
- if (candidate.reducer === 'dedupe-pointer') return 'dedup'
178
- return 'read-state'
179
- }
180
-
181
- /**
182
- * Triage planned replacements into the three review-mode buckets, pricing the
183
- * pass as ONE merged mutation (R1): the tail KV-cache refill penalty is a
184
- * property of the landing event, not of any single candidate, so it must be
185
- * paid exactly once per batch. Pricing per candidate overstates the payback
186
- * N-fold and starves every real batch out of the auto path.
187
- *
188
- * Pipeline: zero/negative-recovery candidates are priced out first (they never
189
- * make a batch look better), the surviving batch is priced once through
190
- * `computeBenefit`, the verdict is a batch decision, and any high-impact
191
- * candidate (`tokensBefore ≥ reviewHighImpactTokens`) covers the whole batch
192
- * into review — splitting the batch would pay a second cache break that the
193
- * accounting does not model. Review skeletons are grouped one proposal per
194
- * kind; a proposal id covers every item digest.
195
- *
196
- * Batch verdict bands (identical thresholds to the per-candidate model):
197
- * - any high-impact candidate, or α too small to price a payback → review;
198
- * - `paybackTurns ≤ 1`, or Ŝ known and `paybackTurns ≤ 0.25·Ŝ` → auto;
199
- * - Ŝ known and `paybackTurns ∈ (1, 3]` → review;
200
- * - everything else (Ŝ unknown with a slow payback) → drop.
201
- *
202
- * Stage asymmetry: a `'fresh'` batch is exempt from the tail-refill penalty
203
- * (`refillPenaltyExempt`) — its content was never served, so compressing it
204
- * breaks no cache and payback is 0 — while a `'history'` batch mutates
205
- * already-cached context and pays `(1−α)·tailTokens` in full. Without this
206
- * exemption every realistic fresh batch prices into the drop band and the
207
- * auto bucket stays structurally unreachable.
208
- */
209
- export function classifyCandidates(
210
- candidates: readonly ClassifiableCandidate[],
211
- input: TriageInput,
212
- ): ClassificationResult {
213
- const drop: ClassifiableCandidate[] = []
214
- const usable: ClassifiableCandidate[] = []
215
- for (const candidate of candidates) {
216
- if (Math.max(0, candidate.tokensBefore - candidate.tokensAfter) <= 0) {
217
- drop.push(candidate)
218
- continue
219
- }
220
- usable.push(candidate)
221
- }
222
- if (usable.length === 0) return { auto: [], review: [], drop }
223
-
224
- const benefit = computeBenefit(usable, {
225
- alpha: input.alpha,
226
- tailTokens: input.tailTokens,
227
- ...input.remainingTurns !== undefined ? { remainingTurns: input.remainingTurns } : {},
228
- refillPenaltyExempt: input.stage === 'fresh',
229
- })
230
- const payback = benefit.paybackTurns
231
- const highImpact = usable.some(candidate => candidate.tokensBefore >= input.reviewHighImpactTokens)
232
- let verdict: 'auto' | 'review' | 'drop'
233
- if (highImpact || payback === undefined) {
234
- // High impact covers the whole batch; α too small to price a payback has
235
- // no discounted recovery to argue from, so a human decides.
236
- verdict = 'review'
237
- } else if (payback <= 1
238
- || (input.remainingTurns !== undefined && payback <= 0.25 * input.remainingTurns)) {
239
- verdict = 'auto'
240
- } else if (input.remainingTurns !== undefined && payback <= 3) {
241
- verdict = 'review'
242
- } else {
243
- verdict = 'drop'
244
- }
245
- if (verdict === 'auto') return { auto: usable, review: [], drop }
246
- if (verdict === 'drop') return { auto: [], review: [], drop: [...drop, ...usable] }
247
-
248
- const itemsByKind = new Map<ProposalKind, ProposalItem[]>()
249
- for (const candidate of usable) {
250
- const item: ProposalItem = {
251
- seq: candidate.sourceSeq,
252
- component: candidate.component,
253
- kind: proposalKindFor(candidate, input.estimatorSeqs),
254
- tokensBefore: candidate.tokensBefore,
255
- tokensAfter: candidate.tokensAfter,
256
- digest: contentDigest(candidate.content),
257
- }
258
- const bucket = itemsByKind.get(item.kind) ?? []
259
- bucket.push(item)
260
- itemsByKind.set(item.kind, bucket)
261
- }
262
- const review: ProposalSkeleton[] = []
263
- for (const [kind, items] of itemsByKind) {
264
- review.push({ id: proposalId(items.map(item => item.digest)), kind, items, benefit })
265
- }
266
- return { auto: [], review, drop }
267
- }