dsh-context-compression-improved 0.5.1 → 0.5.2

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 (45) hide show
  1. package/CHANGELOG.ja.md +36 -0
  2. package/CHANGELOG.ko.md +36 -0
  3. package/CHANGELOG.md +38 -0
  4. package/CHANGELOG.zh.md +32 -0
  5. package/package.json +1 -1
  6. package/packages/selector/lib/advisor-state.js +4 -231
  7. package/packages/selector/lib/client.d.ts +0 -24
  8. package/packages/selector/lib/client.js +6 -501
  9. package/packages/selector/lib/index.d.ts +4 -10
  10. package/packages/selector/lib/index.js +16 -234
  11. package/packages/selector/lib/pruner.d.ts +13 -248
  12. package/packages/selector/lib/pruner.js +148 -552
  13. package/packages/selector/src/client/EstimatorControls.tsx +277 -378
  14. package/packages/selector/src/client/index.ts +0 -17
  15. package/packages/selector/src/client/locales.ts +196 -234
  16. package/packages/selector/src/client/preset-options.ts +3 -2
  17. package/packages/selector/src/client/settings-section.tsx +8 -17
  18. package/packages/selector/src/index.ts +463 -710
  19. package/packages/selector/src/profiles.ts +4 -27
  20. package/packages/selector/src/pruner/state.ts +50 -73
  21. package/packages/selector/src/pruner.ts +2402 -2730
  22. package/packages/selector/src/runtime/audit.ts +27 -21
  23. package/packages/selector/src/runtime/config.ts +6 -32
  24. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +16 -0
  25. package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
  26. package/packages/selector/src/runtime/types.ts +0 -17
  27. package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
  28. package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
  29. package/packages/selector/tests/runtime/audit.spec.ts +35 -21
  30. package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
  31. package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
  32. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
  33. package/packages/selector/tests/settings-seat.client.spec.ts +16 -10
  34. package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
  35. package/packages/selector/src/client/review-scope.ts +0 -16
  36. package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
  37. package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
  38. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
  39. package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
  40. package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
  41. package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
  42. package/packages/selector/tests/review-routes.host.spec.ts +0 -290
  43. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
  44. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
  45. package/packages/selector/tests/runtime/tokenpilot/review-queue.spec.ts +0 -168
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * This spec locks WHERE the compression settings panel mounts: exactly ONE
5
5
  * seat — the standalone `settings.section` entry (设置 → 上下文压缩) — and
6
- * explicitly NOT the Plugins-section surfaces. History this pins against: the
7
- * 0.1.5 line first lost every entry (a lazy `ctx.get` of locale/settingsScope
8
- * raced the settings client and apply bailed), then showed the panel twice
9
- * (item card + tab on top of the standalone section). Both were fixed by the
10
- * declarative-inject + single-section shape below. When a future DSH line
11
- * moves the seat again, migrate src/client/index.ts AND this file together.
6
+ * explicitly NOT the Plugins-section surfaces, and NOT a `shell.overlay` float
7
+ * (the retired review panel's seat was removed with the gate). History this
8
+ * pins against: the 0.1.5 line first lost every entry (a lazy `ctx.get` of
9
+ * locale/settingsScope raced the settings client and apply bailed), then showed
10
+ * the panel twice (item card + tab on top of the standalone section). Both were
11
+ * fixed by the declarative-inject + single-section shape below. When a future
12
+ * DSH line moves the seat again, migrate src/client/index.ts AND this file
13
+ * together.
12
14
  */
13
15
  import { describe, expect, it, vi } from 'vitest'
14
16
  import type { Context as ClientContext } from '@deepseek-ai/cordis'
@@ -58,12 +60,16 @@ describe('settings-seat contract (standalone settings.section only)', () => {
58
60
  expect(inject).toEqual(['slots', 'locale', 'settingsScope'])
59
61
  })
60
62
 
61
- it('injects exactly the settings.section seat plus the shell.overlay float', () => {
63
+ it('injects exactly the settings.section seat and nothing else', () => {
62
64
  const { declared, registrations } = collectRegistrations()
63
- expect(declared).toEqual(['settings.section', 'shell.overlay'])
64
- expect(registrations).toHaveLength(2)
65
+ expect(declared).toEqual(['settings.section'])
66
+ expect(registrations).toHaveLength(1)
65
67
  expect(registrations[0]!.slot).toBe('settings.section')
66
- expect(registrations[1]!.slot).toBe('shell.overlay')
68
+ })
69
+
70
+ it('no longer claims a shell.overlay float (the review panel it served is gone)', () => {
71
+ const { declared } = collectRegistrations()
72
+ expect(declared).not.toContain('shell.overlay')
67
73
  })
68
74
 
69
75
  it('pins the section identity (id/order/label/locale) and the panel component', () => {
@@ -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
- }