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.
- package/.gitattributes +1 -0
- package/CHANGELOG.ja.md +144 -83
- package/CHANGELOG.ko.md +143 -82
- package/CHANGELOG.md +278 -212
- package/CHANGELOG.zh.md +131 -77
- package/docs/installation.md +103 -103
- package/docs/installation.zh.md +100 -100
- package/package.json +1 -1
- package/packages/selector/cordis.patch.yml +5 -6
- package/packages/selector/lib/advisor-state.js +4 -231
- package/packages/selector/lib/client.d.ts +0 -24
- package/packages/selector/lib/client.js +6 -501
- package/packages/selector/lib/index.d.ts +4 -10
- package/packages/selector/lib/index.js +16 -234
- package/packages/selector/lib/pruner.d.ts +13 -248
- package/packages/selector/lib/pruner.js +148 -552
- package/packages/selector/src/client/EstimatorControls.tsx +0 -101
- package/packages/selector/src/client/index.ts +0 -17
- package/packages/selector/src/client/locales.ts +0 -38
- package/packages/selector/src/client/preset-options.ts +3 -2
- package/packages/selector/src/client/settings-section.tsx +8 -17
- package/packages/selector/src/index.ts +24 -271
- package/packages/selector/src/profiles.ts +4 -27
- package/packages/selector/src/pruner/state.ts +2 -25
- package/packages/selector/src/pruner.ts +75 -403
- package/packages/selector/src/runtime/audit.ts +27 -21
- package/packages/selector/src/runtime/config.ts +6 -32
- package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -188
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +149 -133
- package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -419
- package/packages/selector/src/runtime/tokenpilot/benefit.ts +200 -0
- package/packages/selector/src/runtime/types.ts +0 -17
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -223
- package/packages/selector/tests/preset-options-write.client.spec.ts +7 -23
- package/packages/selector/tests/public/package-contract.client.spec.ts +20 -0
- package/packages/selector/tests/runtime/advice-never-withholds.host.spec.ts +232 -0
- package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -272
- package/packages/selector/tests/runtime/advisor.spec.ts +226 -226
- package/packages/selector/tests/runtime/audit.spec.ts +35 -21
- package/packages/selector/tests/runtime/char-basis.spec.ts +30 -30
- package/packages/selector/tests/runtime/deprecated-preset-options.spec.ts +96 -0
- package/packages/selector/tests/runtime/tokenpilot/benefit.spec.ts +217 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +4 -5
- package/packages/selector/tests/settings-seat.client.spec.ts +45 -14
- package/scripts/toolclass-corpus-replay.mjs +281 -281
- package/packages/selector/src/client/ReviewOverlay.tsx +0 -320
- package/packages/selector/src/client/review-scope.ts +0 -16
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +0 -267
- package/packages/selector/src/runtime/tokenpilot/review-queue.ts +0 -231
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +0 -117
- package/packages/selector/src/runtime/tokenpilot/review-storage.ts +0 -122
- package/packages/selector/tests/review-overlay.client.spec.tsx +0 -118
- package/packages/selector/tests/review-routes-registry.host.spec.ts +0 -142
- package/packages/selector/tests/review-routes.host.spec.ts +0 -290
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +0 -393
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +0 -382
- 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
|
-
}
|