dsh-context-compression-improved 0.4.0-beta.1 → 0.5.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.
Files changed (71) hide show
  1. package/CHANGELOG.ja.md +68 -36
  2. package/CHANGELOG.ko.md +67 -35
  3. package/CHANGELOG.md +195 -134
  4. package/CHANGELOG.zh.md +64 -36
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +1 -1
  8. package/README.zh.md +1 -1
  9. package/docs/installation.ja.md +2 -2
  10. package/docs/installation.ko.md +2 -2
  11. package/docs/installation.md +103 -78
  12. package/docs/installation.zh.md +100 -77
  13. package/docs/repair-log.md +54 -0
  14. package/package.json +1 -1
  15. package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
  16. package/packages/selector/lib/client.d.ts +7 -0
  17. package/packages/selector/lib/client.js +33 -3
  18. package/packages/selector/lib/index.d.ts +7 -0
  19. package/packages/selector/lib/index.js +112 -3
  20. package/packages/selector/lib/pruner.d.ts +128 -1
  21. package/packages/selector/lib/pruner.js +2802 -1374
  22. package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
  23. package/packages/selector/src/client/index.ts +1 -1
  24. package/packages/selector/src/client/preset-options.ts +2 -0
  25. package/packages/selector/src/index.ts +129 -49
  26. package/packages/selector/src/profiles.ts +48 -0
  27. package/packages/selector/src/pruner/content.ts +18 -5
  28. package/packages/selector/src/pruner/state.ts +3 -0
  29. package/packages/selector/src/pruner/types.ts +23 -5
  30. package/packages/selector/src/pruner.ts +297 -162
  31. package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
  32. package/packages/selector/src/runtime/audit.ts +40 -2
  33. package/packages/selector/src/runtime/config.ts +88 -1
  34. package/packages/selector/src/runtime/measurement.ts +31 -2
  35. package/packages/selector/src/runtime/reducers.ts +1115 -97
  36. package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
  37. package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
  38. package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
  39. package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
  40. package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
  41. package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
  42. package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
  43. package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
  44. package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
  45. package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
  46. package/packages/selector/src/runtime/toolclass.ts +103 -0
  47. package/packages/selector/src/runtime/types.ts +37 -0
  48. package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
  49. package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
  50. package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
  51. package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
  52. package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
  53. package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
  54. package/packages/selector/tests/runtime/audit.spec.ts +88 -1
  55. package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
  56. package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
  57. package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
  58. package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
  59. package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
  60. package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
  61. package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
  62. package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
  63. package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
  64. package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
  65. package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
  66. package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
  67. package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
  68. package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
  69. package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
  70. package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
  71. package/scripts/toolclass-corpus-replay.mjs +281 -0
@@ -0,0 +1,241 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { codePointLength } from '../../src/runtime/config.ts'
3
+ import {
4
+ documentSectionSummaries,
5
+ reduceFreshToolResult,
6
+ searchNodeSummaries,
7
+ type ReducerInput,
8
+ } from '../../src/runtime/reducers.ts'
9
+ import {
10
+ buildRankUserPrompt,
11
+ mergeRanking,
12
+ parseRanking,
13
+ rankNodes,
14
+ sideChannelGate,
15
+ type RankedNode,
16
+ } from '../../src/runtime/tokenpilot/sidechannel.ts'
17
+
18
+ const SOURCE_REF = 'session://s1/event/9'
19
+
20
+ function input(text: string, budgetChars = 4_000): ReducerInput {
21
+ return {
22
+ toolName: 'grep_search',
23
+ argumentsText: '{"pattern":"config"}',
24
+ text,
25
+ budgetChars,
26
+ sourceRef: SOURCE_REF,
27
+ isError: false,
28
+ }
29
+ }
30
+
31
+ function stubChannel(answer: string | undefined, calls: { count: number }): Pick<{ ask: unknown }, never> & {
32
+ ask: (request: { system: string, user: string, signal: AbortSignal }) => Promise<string | undefined>
33
+ askAudited: (request: { system: string, user: string, signal: AbortSignal }) => Promise<{ text?: string, audit: { ok: boolean, latencyMs: number } }>
34
+ identity: () => string | undefined
35
+ } {
36
+ return {
37
+ async ask(request) {
38
+ calls.count += 1
39
+ void request
40
+ return answer
41
+ },
42
+ async askAudited(request) {
43
+ const text = await this.ask(request)
44
+ return { ...(text === undefined ? {} : { text }), audit: { ok: text !== undefined, latencyMs: 1 } }
45
+ },
46
+ identity: () => 'stub',
47
+ }
48
+ }
49
+
50
+ describe('rank parsing and merging (TS4)', () => {
51
+ it('keeps only valid identifiers in returned order', () => {
52
+ const ranked = parseRanking('b.ts, junk, a.ts, b.ts', new Set(['a.ts', 'b.ts']))
53
+ expect(ranked).toEqual(['b.ts', 'a.ts'])
54
+ })
55
+ it('resolves undefined for empty or garbage answers (fail-open)', () => {
56
+ expect(parseRanking(undefined, new Set(['a.ts']))).toBeUndefined()
57
+ expect(parseRanking(' ', new Set(['a.ts']))).toBeUndefined()
58
+ expect(parseRanking('I cannot rank these files', new Set(['a.ts']))).toBeUndefined()
59
+ })
60
+ it('appends unmentioned nodes in their original order (existence intact)', () => {
61
+ const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]
62
+ expect(mergeRanking(nodes, ['c'])).toEqual([{ id: 'c' }, { id: 'a' }, { id: 'b' }])
63
+ expect(mergeRanking(nodes, undefined)).toEqual(nodes)
64
+ })
65
+ })
66
+
67
+ describe('cost gate (R-10)', () => {
68
+ it('does not call when everything fits or there is a single node', () => {
69
+ expect(sideChannelGate(true, 500)).toBe(false)
70
+ expect(sideChannelGate(false, 1)).toBe(false)
71
+ expect(sideChannelGate(false, 2)).toBe(true)
72
+ })
73
+ })
74
+
75
+ describe('node summaries (SC8: samples ride with identifiers)', () => {
76
+ it('search summaries carry path, count, and one content line', () => {
77
+ const text = ['a.ts:1: alpha config', 'a.ts:2: beta', 'b.ts:7: gamma'].join('\n')
78
+ const nodes = searchNodeSummaries(text)
79
+ expect(nodes).toHaveLength(2)
80
+ const a = nodes.find(node => node.id === 'a.ts')!
81
+ expect(a.count).toBe(2)
82
+ expect(a.sample).toContain('alpha config')
83
+ })
84
+ it('document summaries carry heading, mass, and the first content line (not only titles)', () => {
85
+ const text = [
86
+ '# Guide',
87
+ 'Intro sentence everyone should read.',
88
+ '## Configuration',
89
+ ...Array.from({ length: 20 }, (_, i) => `config line ${String(i)}`),
90
+ ].join('\n')
91
+ const sections = documentSectionSummaries(text)
92
+ expect(sections).toHaveLength(2)
93
+ const config = sections.find(section => section.id === 'Configuration')!
94
+ expect(config.level).toBe(2)
95
+ expect(config.chars).toBeGreaterThan(100)
96
+ expect(config.sample).toContain('config line 0')
97
+ })
98
+ it('the rank prompt embeds query, reasoning, and samples (AD11 intent carrier)', () => {
99
+ const nodes: RankedNode[] = [{ id: 'a.ts', detail: '2 hits', sample: 'alpha config line' }]
100
+ const prompt = buildRankUserPrompt(nodes, 'find loading config', 'the user wants the config loader')
101
+ expect(prompt).toContain('query: find loading config')
102
+ expect(prompt).toContain('reasoning: the user wants the config loader')
103
+ expect(prompt).toContain('a.ts | 2 hits | alpha config line')
104
+ })
105
+ })
106
+
107
+ describe('S1a ranked search fold', () => {
108
+ const rows = [
109
+ ...Array.from({ length: 40 }, (_, i) => `fat.ts:${String(i + 1)}: fat row ${String(i)}`),
110
+ ...Array.from({ length: 40 }, (_, i) => `star.ts:${String(i + 1)}: star row ${String(i)}`),
111
+ ]
112
+
113
+ it('ranked file fills L2 first while L1 stays complete (existence lossless)', () => {
114
+ const tight = input(rows.join('\n'), 1_500)
115
+ const mechanical = reduceFreshToolResult(tight)!
116
+ const ranked = reduceFreshToolResult(tight, { files: ['star.ts'] })!
117
+ expect(ranked.reducer).toBe('search-by-file')
118
+ // L1 locator sets survive untouched for both files.
119
+ const locatorOf = (text: string, path: string): string | undefined =>
120
+ text.split('\n').find(line => line.startsWith(`## ${path}`))
121
+ for (const text of [mechanical.text, ranked.text]) {
122
+ expect(locatorOf(text, 'fat.ts')!.match(/L\d+/gu)).toHaveLength(40)
123
+ expect(locatorOf(text, 'star.ts')!.match(/L\d+/gu)).toHaveLength(40)
124
+ }
125
+ // Ranking only changes which rows the quota shows first.
126
+ const countRows = (text: string, path: string): number =>
127
+ text.split('\n').filter(line => line.startsWith(`${path}:`)).length
128
+ expect(countRows(ranked.text, 'star.ts')).toBeGreaterThanOrEqual(countRows(mechanical.text, 'star.ts'))
129
+ })
130
+
131
+ it('counter-proof: no ranking reproduces the mechanical output byte-for-byte', () => {
132
+ const tight = input(rows.join('\n'), 1_500)
133
+ const mechanical = reduceFreshToolResult(tight)!.text
134
+ expect(reduceFreshToolResult(tight, {})!.text).toBe(mechanical)
135
+ expect(reduceFreshToolResult(tight, { files: [] })!.text).toBe(mechanical)
136
+ })
137
+
138
+ it('an LM ranking with unknown paths changes nothing', () => {
139
+ const tight = input(rows.join('\n'), 1_500)
140
+ const mechanical = reduceFreshToolResult(tight)!.text
141
+ expect(reduceFreshToolResult(tight, { files: ['nope.ts'] })!.text).toBe(mechanical)
142
+ })
143
+ })
144
+
145
+ describe('S1b ranked document fold', () => {
146
+ function doc(): string {
147
+ const parts: string[] = ['# Guide', 'Intro line.']
148
+ for (const [name, size] of [['Small', 6] as const, ['Target Section', 40] as const, ['Appendix', 30] as const]) {
149
+ parts.push(`## ${name}`)
150
+ parts.push(...Array.from({ length: size }, (_, i) => `${name} body line ${String(i)} with ordinary prose.`))
151
+ }
152
+ return parts.join('\n')
153
+ }
154
+
155
+ function docInput(budget: number): ReducerInput {
156
+ return { ...input(doc(), budget), toolName: 'mcp_fetch' }
157
+ }
158
+
159
+ it('selected section keeps full content; unselected keep heading plus first line', () => {
160
+ const budget = 4_000
161
+ const ranked = reduceFreshToolResult(docInput(budget), { sections: ['Target Section'] })
162
+ expect(ranked).not.toBeNull()
163
+ expect(ranked!.reducer).toBe('doc-skeleton')
164
+ const text = ranked!.text
165
+ // All headings survive (existence is never the channel's to remove).
166
+ for (const heading of ['Guide', 'Small', 'Target Section', 'Appendix']) {
167
+ expect(text).toContain(heading)
168
+ }
169
+ // Selected: full content fits. Unselected: first line only.
170
+ expect(text).toContain('Target Section body line 39')
171
+ expect(text).not.toContain('Appendix body line 29')
172
+ expect(text).toContain('Appendix body line 0')
173
+ expect(codePointLength(text)).toBeLessThanOrEqual(budget)
174
+ })
175
+
176
+ it('fills the ranked section partially when the budget cannot hold it whole', () => {
177
+ const tight = reduceFreshToolResult(docInput(1_600), { sections: ['Target Section'] })!
178
+ expect(codePointLength(tight.text)).toBeLessThanOrEqual(1_600)
179
+ // More than the first-line floor of the ranked section survives.
180
+ expect(tight.text.split('\n').filter(line => line.startsWith('Target Section body line')).length).toBeGreaterThan(1)
181
+ // Unselected sections still show their first line (mechanical floor).
182
+ expect(tight.text).toContain('Appendix body line 0')
183
+ })
184
+
185
+ it('counter-proof: no ranking reproduces the mechanical skeleton byte-for-byte', () => {
186
+ const budget = 1_600
187
+ const mechanical = reduceFreshToolResult(docInput(budget))!.text
188
+ expect(reduceFreshToolResult(docInput(budget), { sections: [] })!.text).toBe(mechanical)
189
+ })
190
+ })
191
+
192
+ describe('S1 orchestrator', () => {
193
+ it('makes exactly ONE call and applies the ranking', async () => {
194
+ const calls = { count: 0 }
195
+ const channel = stubChannel('b.ts, a.ts', calls)
196
+ const outcome = await rankNodes(
197
+ [
198
+ { id: 'a.ts', detail: '3 hits', sample: 'alpha' },
199
+ { id: 'b.ts', detail: '9 hits', sample: 'beta' },
200
+ ],
201
+ new Set(['a.ts', 'b.ts']),
202
+ 'find the loader',
203
+ 'wants config loading',
204
+ channel,
205
+ AbortSignal.timeout(1_000),
206
+ )
207
+ expect(calls.count).toBe(1)
208
+ expect(outcome.ranking).toEqual(['b.ts', 'a.ts'])
209
+ expect(outcome.audit.ok).toBe(true)
210
+ })
211
+ it('falls back with an audit record when the channel returns nothing', async () => {
212
+ const calls = { count: 0 }
213
+ const outcome = await rankNodes(
214
+ [
215
+ { id: 'a.ts', detail: '3 hits', sample: 'alpha' },
216
+ { id: 'b.ts', detail: '9 hits', sample: 'beta' },
217
+ ],
218
+ new Set(['a.ts', 'b.ts']),
219
+ 'find the loader',
220
+ undefined,
221
+ stubChannel(undefined, calls),
222
+ AbortSignal.timeout(1_000),
223
+ )
224
+ expect(outcome.ranking).toBeUndefined()
225
+ expect(outcome.audit.ok).toBe(false)
226
+ expect(outcome.audit.reason).toContain('falling back')
227
+ })
228
+ it('gate stays closed for a single node or an empty query (zero calls)', async () => {
229
+ const calls = { count: 0 }
230
+ const outcome = await rankNodes(
231
+ [{ id: 'a.ts', detail: '3 hits', sample: 'alpha' }],
232
+ new Set(['a.ts']),
233
+ 'find the loader',
234
+ undefined,
235
+ stubChannel('a.ts', calls),
236
+ AbortSignal.timeout(1_000),
237
+ )
238
+ expect(calls.count).toBe(0)
239
+ expect(outcome.ranking).toBeUndefined()
240
+ })
241
+ })
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ historicalPlaceholder,
4
+ looksLikeMinified,
5
+ looksLikeDocument,
6
+ reduceFreshToolResult,
7
+ type ReducerInput,
8
+ } from '../../src/runtime/reducers.ts'
9
+
10
+ const input = (overrides: Partial<ReducerInput> & Pick<ReducerInput, 'text'>): ReducerInput => ({
11
+ toolName: overrides.toolName ?? 'read',
12
+ argumentsText: overrides.argumentsText ?? '{}',
13
+ text: overrides.text,
14
+ budgetChars: overrides.budgetChars ?? 4_000,
15
+ sourceRef: overrides.sourceRef ?? 'session://probe/event/1',
16
+ isError: overrides.isError ?? false,
17
+ ...(overrides.codeSkeleton === undefined ? {} : { codeSkeleton: overrides.codeSkeleton }),
18
+ })
19
+
20
+ /** task_4b (G6): a large read-class result must present its skeleton (the
21
+ * natural table of contents) instead of head-only truncation, WITHOUT the
22
+ * orthogonal `codeSkeleton` user gate. Acceptance: the first step carries
23
+ * structure lines/headings and NO head boilerplate, and every elision marker
24
+ * is line-addressable via start_line. */
25
+ describe('TOC-first for large read results (task_4b)', () => {
26
+ const sourceLines = [
27
+ 'import { a } from "./a.ts"',
28
+ 'import { b } from "./b.ts"',
29
+ 'import { c } from "./c.ts"',
30
+ ...Array.from({ length: 60 }, (_, index) => [
31
+ `export function handler${String(index)}(value: number): number {`,
32
+ // Body lines must be UNIQUE across handlers — exact repeats fold in the
33
+ // normalizer (R11), which would shrink the content view below the
34
+ // TOC threshold and change the dispatch this suite pins.
35
+ ...Array.from({ length: 40 }, (_, line) => ` value += ${String(line)}; // handler ${String(index)} padded body line`),
36
+ ' return value',
37
+ '}',
38
+ ]).flat(),
39
+ ]
40
+ const guttered = sourceLines.map((line, index) => `${String(index + 1)}: ${line}`).join('\n')
41
+
42
+ it('skeletons a large guttered read without the codeSkeleton gate', () => {
43
+ const output = reduceFreshToolResult(input({ text: guttered, budgetChars: 8_000 }))
44
+ expect(output).not.toBeNull()
45
+ expect(output!.reducer).toBe('hypa-code-skeleton')
46
+ // task_4c/G7: the elided-line count rides the structured telemetry for the
47
+ // audit record — never printed into the replacement text.
48
+ expect(output!.elidedLines).toBeGreaterThan(0)
49
+ expect(output!.text).not.toContain('elided_lines')
50
+ // Structure lines survive (the directory)…
51
+ expect(output!.text).toContain('export function handler0(')
52
+ // …while the head-of-file boilerplate does NOT dominate the first step.
53
+ expect(output!.text).not.toContain('value += 0; // handler 0 padded body line')
54
+ })
55
+
56
+ it('keeps every elision marker line-addressable (start_line present)', () => {
57
+ const output = reduceFreshToolResult(input({ text: guttered, budgetChars: 8_000 }))
58
+ expect(output!.text).toMatch(/\[... lines \d+-\d+ elided \(\d+ lines\) ...\]/)
59
+ expect(output!.text).toContain('start_line')
60
+ // The host continuation footer is never dropped by the envelope.
61
+ expect(output!.text).not.toBe('')
62
+ })
63
+
64
+ it('reports the whole original span as elided for aged placeholders (task_4c)', () => {
65
+ const text = ['line one', 'line two', 'line three', 'line four', 'line five'].join('\n')
66
+ const placeholder = historicalPlaceholder({
67
+ toolName: 'read',
68
+ sourceRef: 'session://probe/aged',
69
+ charsBefore: text.length,
70
+ isError: false,
71
+ text,
72
+ })
73
+ expect(placeholder.reducer).toBe('historical-tool-result-aging')
74
+ expect(placeholder.elidedLines).toBe(5)
75
+ })
76
+
77
+ it('degrades to head/tail when the skeleton is mostly elision markers', () => {
78
+ // Code-shaped (structure + import evidence) but structurally POOR: 20
79
+ // tiny `fn` signatures each followed by 100 unique filler lines → the
80
+ // skeleton output is dominated by elision markers, so the TOC guard must
81
+ // fail open and the result must land on the prose/head pair.
82
+ const flat = [
83
+ 'use crate::a;',
84
+ 'use crate::b;',
85
+ 'use crate::c;',
86
+ ...Array.from({ length: 20 }, (_, index) => [
87
+ `fn fx${String(index)}(v: u32) {`,
88
+ ...Array.from({ length: 100 }, (_, line) => ` v = tick(${String(index)}, ${String(line)});`),
89
+ '}',
90
+ ]).flat(),
91
+ ]
92
+ const flatText = flat.join('\n')
93
+ expect(flatText.length).toBeGreaterThan(8_000)
94
+ const output = reduceFreshToolResult(input({ text: flatText, budgetChars: 4_000 }))
95
+ expect(output).not.toBeNull()
96
+ expect(output!.reducer).toBe('pi-head')
97
+ })
98
+ })
99
+
100
+ /** task_15 (R10-B): bundled/minified JS — line-anchored reducers cannot see
101
+ * inside a 135k-character line; the useful first answer is a declaration
102
+ * directory, never half a statement. */
103
+ describe('bundled/minified JS directory (task_15)', () => {
104
+ const bundleLines = [
105
+ '/*! license header */',
106
+ `(()=>{var __webpack_exports__={};${Array.from({ length: 30 }, (_, index) =>
107
+ `function handleWidget${String(index)}(a,b){return a+b};`).join('')}export{handleWidget0};const CONFIG_VALUE=42;})();`,
108
+ '//# sourceMappingURL=index.js.map',
109
+ ]
110
+ const bundle = bundleLines.join('\n')
111
+
112
+ it('detects the minified form (R10a)', () => {
113
+ expect(looksLikeMinified(bundle)).toBe(true)
114
+ })
115
+
116
+ it('still detects a 53-line webui bundle with one 135k line', () => {
117
+ const giant = `var x=1;${'y'.repeat(135_000)};`
118
+ expect(looksLikeMinified([giant, giant].join('\n'))).toBe(true)
119
+ })
120
+
121
+ it('emits a declaration directory with symbols and the source-map note (R10b/R10c)', () => {
122
+ const output = reduceFreshToolResult(input({
123
+ toolName: 'read',
124
+ text: bundle,
125
+ budgetChars: 3_000,
126
+ }))
127
+ expect(output).not.toBeNull()
128
+ expect(output!.reducer).toBe('bundled-js-directory')
129
+ expect(output!.text).toContain('handleWidget0')
130
+ expect(output!.text).toContain('CONFIG_VALUE')
131
+ expect(output!.text).toContain('source map present')
132
+ // Never hands back half a statement of bundle body.
133
+ expect(output!.text).not.toContain('return a+b')
134
+ })
135
+
136
+ it('fails open when the bundle declares nothing extractable', () => {
137
+ const opaque = `${'q'.repeat(30_000)};`
138
+ const output = reduceFreshToolResult(input({ text: opaque, budgetChars: 2_000 }))
139
+ if (output !== null) expect(output.reducer).not.toBe('bundled-js-directory')
140
+ })
141
+ })
142
+
143
+ /** R4/RK-4: looksLikeDocument keeps its `^#{1,6}\s+\S` anchoring but scans
144
+ * beyond line 400 (C26), and pure logs still fail (C27). */
145
+ describe('looksLikeDocument window (C26/C27)', () => {
146
+ it('accepts a document whose ≥3 headings sit at lines 401–600', () => {
147
+ const lines = Array.from({ length: 595 }, () => 'plain prose line with words only')
148
+ lines[400] = '# Chapter Four'
149
+ lines[499] = '# Chapter Five'
150
+ lines[588] = '# Chapter Six'
151
+ expect(looksLikeDocument(lines.join('\n'))).toBe(true)
152
+ })
153
+
154
+ it('still rejects pure logs without heading lines (C27)', () => {
155
+ const log = Array.from({ length: 500 }, (_, index) =>
156
+ `2026-09-19T10:${String(index % 60).padStart(2, '0')} INFO request ${String(index)} handled in ${String(index)}ms`).join('\n')
157
+ expect(looksLikeDocument(log)).toBe(false)
158
+ })
159
+ })
@@ -66,6 +66,18 @@ describe('tokenpilot-inspired preset', () => {
66
66
  reviewTimeoutTurns: 6,
67
67
  cacheHitDiscountAlpha: 0.1,
68
68
  reviewHighImpactTokens: 4000,
69
+ // Advisory advisor ships off with its documented defaults. The
70
+ // resolved matrix intentionally gains this group so it follows the
71
+ // estimator's resolved-consumption pattern; see
72
+ // .agents/plans/ctx-relevance-advisor/spec.md decision record.
73
+ advisor: {
74
+ mode: '',
75
+ timeoutMs: 8000,
76
+ refreshTurns: 8,
77
+ scoreThreshold: 0.35,
78
+ sampleLimit: 16,
79
+ minTokens: 250,
80
+ },
69
81
  })
70
82
  // Other profiles never carry the capability matrix.
71
83
  expect(resolvePolicy(config, 'balanced').presetOptions).toBeUndefined()
@@ -82,6 +82,28 @@ describe('computeBenefit', () => {
82
82
  )
83
83
  expect(result.expectedSaving).toBeCloseTo(0)
84
84
  })
85
+
86
+ it('waives the refill penalty for a refill-exempt batch: payback 0, saving α·R·Ŝ', () => {
87
+ // Fresh-stage shaping: the content never entered the KV cache, so no
88
+ // refill penalty applies — the whole discounted recovery is pure gain.
89
+ const result = computeBenefit(
90
+ [{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
91
+ { alpha: 0.1, tailTokens: 4000, remainingTurns: 12, refillPenaltyExempt: true },
92
+ )
93
+ expect(result.recoveredTokens).toBe(3600)
94
+ expect(result.penaltyTokens).toBe(0)
95
+ expect(result.paybackTurns).toBe(0)
96
+ // α·R·max(0, Ŝ − 0) = 360·12
97
+ expect(result.expectedSaving).toBeCloseTo(4320)
98
+ })
99
+
100
+ it('keeps the refill penalty when the exemption flag is absent', () => {
101
+ const result = computeBenefit(
102
+ [{ sourceSeq: 7, tokensBefore: 4000, tokensAfter: 400 }],
103
+ { alpha: 0.1, tailTokens: 4000 },
104
+ )
105
+ expect(result.penaltyTokens).toBeCloseTo(3600)
106
+ })
85
107
  })
86
108
 
87
109
  describe('proposalId', () => {
@@ -196,4 +218,176 @@ describe('classifyCandidates', () => {
196
218
  )
197
219
  expect(result.review[0]!.kind).toBe('estimator')
198
220
  })
221
+
222
+ it('degenerates to the per-candidate behavior for a single-candidate batch', () => {
223
+ // payback = 1000/400 = 2.5: Ŝ = 8 → 2.5 > 2 and ≤ 3 → review, one item.
224
+ const result = classifyCandidates(
225
+ [candidate({ tokensBefore: 1400, tokensAfter: 1000 })],
226
+ { ...TRIAGE, remainingTurns: 8 },
227
+ )
228
+ expect(result.auto).toEqual([])
229
+ expect(result.review).toHaveLength(1)
230
+ expect(result.review[0]!.items).toHaveLength(1)
231
+ })
232
+ })
233
+
234
+ /** Fresh-stage batches are exempt from the tail-refill penalty: their content
235
+ * never entered the KV cache, so shaping it before the first request causes
236
+ * no cache break. The same batch that history pricing would drop must land
237
+ * auto. Fixture: α=0.1, tail=64,000 → history penalty = 57,600; batch
238
+ * R = 2×450 = 900 → history payback = 57,600/90 = 640 > 3, Ŝ unknown →
239
+ * drop. Fresh pricing: penalty = 0 → payback = 0 → auto. */
240
+ describe('classifyCandidates (fresh-stage refill exemption)', () => {
241
+ const FRESH = {
242
+ alpha: 0.1,
243
+ tailTokens: 64_000,
244
+ reviewHighImpactTokens: 4_000,
245
+ }
246
+
247
+ it('lands a fresh batch auto that history pricing would drop', () => {
248
+ const batch = [
249
+ candidate({ sourceSeq: 1, tokensBefore: 2000, tokensAfter: 1550 }),
250
+ candidate({ sourceSeq: 2, tokensBefore: 2000, tokensAfter: 1550 }),
251
+ ]
252
+ const history = classifyCandidates(batch, FRESH)
253
+ expect(history.drop.map(entry => entry.sourceSeq)).toEqual([1, 2])
254
+ const fresh = classifyCandidates(batch, { ...FRESH, stage: 'fresh' })
255
+ expect(fresh.auto.map(entry => entry.sourceSeq)).toEqual([1, 2])
256
+ expect(fresh.review).toEqual([])
257
+ expect(fresh.drop).toEqual([])
258
+ })
259
+
260
+ it('still routes a high-impact fresh candidate to review', () => {
261
+ const result = classifyCandidates(
262
+ [candidate({ tokensBefore: 5000, tokensAfter: 4000 })],
263
+ { ...FRESH, stage: 'fresh' },
264
+ )
265
+ expect(result.auto).toEqual([])
266
+ expect(result.review).toHaveLength(1)
267
+ })
268
+
269
+ it('defaults to history pricing when stage is omitted', () => {
270
+ const result = classifyCandidates(
271
+ [candidate({ sourceSeq: 1, tokensBefore: 2000, tokensAfter: 1550 })],
272
+ FRESH,
273
+ )
274
+ expect(result.drop.map(entry => entry.sourceSeq)).toEqual([1])
275
+ })
276
+ })
277
+
278
+ /** R1 batch-level benefit: one merged mutation pays the tail refill ONCE.
279
+ * Fixture: α=0.02, tail=64,000, Ŝ=60. Per candidate R = 50,000 − 500 = 49,500.
280
+ * Batch: R = 247,500 → payback = 62,720 / 4,950 ≈ 12.67 ≤ 0.25·60 = 15 → auto.
281
+ * Per candidate: payback = 62,720 / 990 ≈ 63.35 > 3 → every one drops. */
282
+ describe('classifyCandidates (batch-level benefit, R1)', () => {
283
+ const BATCH = {
284
+ alpha: 0.02,
285
+ tailTokens: 64_000,
286
+ reviewHighImpactTokens: 200_000,
287
+ remainingTurns: 60,
288
+ }
289
+
290
+ function batchCandidate(seq: number, overrides: { tokensBefore?: number, tokensAfter?: number, reducer?: string } = {}) {
291
+ return candidate({
292
+ sourceSeq: seq,
293
+ tokensBefore: overrides.tokensBefore ?? 50_000,
294
+ tokensAfter: overrides.tokensAfter ?? 500,
295
+ ...overrides.reducer !== undefined ? { reducer: overrides.reducer } : {},
296
+ })
297
+ }
298
+
299
+ it('pays the refill penalty once per batch: 5×50k lands auto', () => {
300
+ const result = classifyCandidates(
301
+ [1, 2, 3, 4, 5].map(seq => batchCandidate(seq)),
302
+ BATCH,
303
+ )
304
+ expect(result.auto.map(entry => entry.sourceSeq)).toEqual([1, 2, 3, 4, 5])
305
+ expect(result.review).toEqual([])
306
+ expect(result.drop).toEqual([])
307
+ })
308
+
309
+ it('counter-proof: priced per candidate the same five would all drop', () => {
310
+ // Under per-candidate pricing each candidate pays the full refill alone:
311
+ // payback = 62,720 / (0.02·49,500) ≈ 63.35 > 3 → drop. The batch-level
312
+ // classifier above lands the identical five in auto.
313
+ const perCandidate = computeBenefit(
314
+ [{ sourceSeq: 1, tokensBefore: 50_000, tokensAfter: 500 }],
315
+ BATCH,
316
+ )
317
+ expect(perCandidate.paybackTurns).toBeGreaterThan(3)
318
+ })
319
+
320
+ it('a zero-recovery candidate is priced out before the batch verdict', () => {
321
+ // Batch of five real candidates + one growing one: the five land auto
322
+ // (payback ≈ 12.67) and the zero-recovery one never joins the pricing.
323
+ const result = classifyCandidates(
324
+ [
325
+ ...[1, 2, 3, 4, 5].map(seq => batchCandidate(seq)),
326
+ batchCandidate(6, { tokensBefore: 500, tokensAfter: 500 }),
327
+ ],
328
+ BATCH,
329
+ )
330
+ expect(result.auto.map(entry => entry.sourceSeq)).toEqual([1, 2, 3, 4, 5])
331
+ expect(result.drop.map(entry => entry.sourceSeq)).toEqual([6])
332
+ })
333
+
334
+ it('high impact covers the whole batch: one ≥H candidate sends everything to review', () => {
335
+ const result = classifyCandidates(
336
+ [batchCandidate(1), batchCandidate(2, { tokensBefore: 250_000 })],
337
+ BATCH,
338
+ )
339
+ expect(result.auto).toEqual([])
340
+ expect(result.drop).toEqual([])
341
+ expect(result.review).toHaveLength(1)
342
+ expect(result.review[0]!.items.map(item => item.seq)).toEqual([1, 2])
343
+ })
344
+
345
+ /** Edge-band review fixture: batch R = 3×430,000 = 1,290,000 →
346
+ * perTurn = 0.02·R = 25,800 → payback = 62,720/25,800 ≈ 2.43.
347
+ * Ŝ = 8 → 0.25·Ŝ = 2: payback ∈ (2, 3] → review. No candidate is
348
+ * high-impact (600,000 < reviewHighImpactTokens 1,000,000). */
349
+ const EDGE_REVIEW = {
350
+ alpha: 0.02,
351
+ tailTokens: 64_000,
352
+ reviewHighImpactTokens: 1_000_000,
353
+ remainingTurns: 8,
354
+ }
355
+
356
+ function edgeCandidate(seq: number, overrides: { tokensBefore?: number, tokensAfter?: number, reducer?: string } = {}) {
357
+ return candidate({
358
+ sourceSeq: seq,
359
+ tokensBefore: overrides.tokensBefore ?? 600_000,
360
+ tokensAfter: overrides.tokensAfter ?? 170_000,
361
+ ...overrides.reducer !== undefined ? { reducer: overrides.reducer } : {},
362
+ })
363
+ }
364
+
365
+ it('groups one proposal per kind and the id covers every item digest', () => {
366
+ const result = classifyCandidates(
367
+ [1, 2, 3].map(seq => edgeCandidate(seq, { reducer: 'dedupe-pointer' })),
368
+ EDGE_REVIEW,
369
+ )
370
+ expect(result.review).toHaveLength(1)
371
+ const skeleton = result.review[0]!
372
+ expect(skeleton.kind).toBe('dedup')
373
+ expect(skeleton.items).toHaveLength(3)
374
+ expect(skeleton.id).toBe(proposalId(skeleton.items.map(item => item.digest)))
375
+ })
376
+
377
+ it('splits review proposals by kind when the batch mixes reducers', () => {
378
+ const result = classifyCandidates(
379
+ [
380
+ edgeCandidate(1, { reducer: 'dedupe-pointer' }),
381
+ edgeCandidate(2, { reducer: 'dedupe-pointer' }),
382
+ edgeCandidate(3, { reducer: 'native-whole-result' }),
383
+ ],
384
+ EDGE_REVIEW,
385
+ )
386
+ expect(result.review).toHaveLength(2)
387
+ const digests = result.review.flatMap(skeleton => skeleton.items.map(item => item.digest))
388
+ expect(digests).toHaveLength(3)
389
+ for (const skeleton of result.review) {
390
+ expect(skeleton.id).toBe(proposalId(skeleton.items.map(item => item.digest)))
391
+ }
392
+ })
199
393
  })