dsh-autotier 0.1.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 (60) hide show
  1. package/AGENTS.md +93 -0
  2. package/CHANGELOG.md +85 -0
  3. package/LICENSE +201 -0
  4. package/README.es.md +247 -0
  5. package/README.hi.md +241 -0
  6. package/README.md +245 -0
  7. package/README.pt.md +246 -0
  8. package/README.zh.md +221 -0
  9. package/SECURITY.md +55 -0
  10. package/THIRD_PARTY_NOTICES.md +63 -0
  11. package/cordis.patch.yml +125 -0
  12. package/docs/preset-row.md +61 -0
  13. package/docs/supporting-lanes.md +45 -0
  14. package/lib/index.js +2848 -0
  15. package/lib/types/command.d.ts +17 -0
  16. package/lib/types/command.d.ts.map +1 -0
  17. package/lib/types/config.d.ts +94 -0
  18. package/lib/types/config.d.ts.map +1 -0
  19. package/lib/types/guard-rules.d.ts +97 -0
  20. package/lib/types/guard-rules.d.ts.map +1 -0
  21. package/lib/types/guard.d.ts +70 -0
  22. package/lib/types/guard.d.ts.map +1 -0
  23. package/lib/types/index.d.ts +60 -0
  24. package/lib/types/index.d.ts.map +1 -0
  25. package/lib/types/intent.d.ts +179 -0
  26. package/lib/types/intent.d.ts.map +1 -0
  27. package/lib/types/judge.d.ts +50 -0
  28. package/lib/types/judge.d.ts.map +1 -0
  29. package/lib/types/policy.d.ts +109 -0
  30. package/lib/types/policy.d.ts.map +1 -0
  31. package/lib/types/routing.d.ts +135 -0
  32. package/lib/types/routing.d.ts.map +1 -0
  33. package/lib/types/schema.d.ts +134 -0
  34. package/lib/types/schema.d.ts.map +1 -0
  35. package/lib/types/service.d.ts +67 -0
  36. package/lib/types/service.d.ts.map +1 -0
  37. package/lib/types/state.d.ts +46 -0
  38. package/lib/types/state.d.ts.map +1 -0
  39. package/lib/types/tiers.d.ts +103 -0
  40. package/lib/types/tiers.d.ts.map +1 -0
  41. package/lib/types/tools.d.ts +26 -0
  42. package/lib/types/tools.d.ts.map +1 -0
  43. package/lib/types/types.d.ts +96 -0
  44. package/lib/types/types.d.ts.map +1 -0
  45. package/package.json +179 -0
  46. package/src/command.ts +73 -0
  47. package/src/config.ts +358 -0
  48. package/src/guard-rules.ts +303 -0
  49. package/src/guard.ts +285 -0
  50. package/src/index.ts +149 -0
  51. package/src/intent.ts +484 -0
  52. package/src/judge.ts +150 -0
  53. package/src/policy.ts +246 -0
  54. package/src/routing.ts +575 -0
  55. package/src/schema.ts +295 -0
  56. package/src/service.ts +131 -0
  57. package/src/state.ts +134 -0
  58. package/src/tiers.ts +212 -0
  59. package/src/tools.ts +128 -0
  60. package/src/types.ts +120 -0
package/src/intent.ts ADDED
@@ -0,0 +1,484 @@
1
+ /**
2
+ * The deterministic intent layer: a zero-token classifier (declarative rule
3
+ * table, explicit-intent patterns, bilingual keyword scoring, structural
4
+ * signals) plus the fingerprint posterior table that learns per-shape win rates
5
+ * from terminal outcomes.
6
+ *
7
+ * Everything in this module is pure and synchronous, so the whole classification
8
+ * matrix is unit-testable without a host. The low-confidence judge lives in
9
+ * `judge.ts` and is consulted only when this layer reports low confidence.
10
+ *
11
+ * @module dsh-autotier/intent
12
+ */
13
+
14
+ import type { ResolvedRule, ScenarioToggles } from './config.ts'
15
+ import type { Scenario, TierId } from './types.ts'
16
+
17
+ /** One declarative rule row, already resolved by `resolveConfig`. */
18
+ export interface CompiledRule {
19
+ readonly id: string
20
+ readonly patterns: readonly RegExp[]
21
+ readonly tools: readonly string[]
22
+ readonly cwd: string
23
+ readonly tier: 'cheap' | 'strong'
24
+ readonly priority: number
25
+ }
26
+
27
+ /** Compile the resolved rule table, ordered by descending priority. */
28
+ export function compileRules(rules: readonly ResolvedRule[]): CompiledRule[] {
29
+ return rules
30
+ .map(rule => ({
31
+ id: rule.id,
32
+ patterns: rule.when.patterns.map(pattern => new RegExp(pattern, 'u')),
33
+ tools: rule.when.tools,
34
+ cwd: rule.when.cwd,
35
+ tier: rule.tier,
36
+ priority: rule.priority,
37
+ }))
38
+ .sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id))
39
+ }
40
+
41
+ /** Input facts the classifier reads. All are derived from the live session. */
42
+ export interface IntentInput {
43
+ /** The newest user message text. */
44
+ readonly text: string
45
+ /** Tool names already used in this session (advisory depth signal). */
46
+ readonly toolNames: readonly string[]
47
+ /** Whether the newest user message carries an image block. */
48
+ readonly hasImage: boolean
49
+ /** Number of messages in the session (turn-depth signal). */
50
+ readonly messageCount: number
51
+ /** Workspace path, matched against rule `when.cwd` prefixes. */
52
+ readonly cwd: string
53
+ }
54
+
55
+ /** Structural signals computed from the input. */
56
+ export interface IntentSignals {
57
+ readonly chars: number
58
+ readonly estTokens: number
59
+ /** How many of the token bands `[4000, 12000, 30000]` the input crosses. */
60
+ readonly tokenBands: number
61
+ readonly fences: number
62
+ readonly toolCalls: number
63
+ readonly messageCount: number
64
+ readonly hardHints: number
65
+ /** 0..8; `>= 3` escalates to the strong tier regardless of scenario. */
66
+ readonly score: number
67
+ }
68
+
69
+ /** Why a classification short-circuited before keyword scoring. */
70
+ export type ShortCircuit = 'image' | 'long-text' | 'greeting' | 'explicit'
71
+
72
+ /** The classifier's verdict. */
73
+ export interface IntentResult {
74
+ readonly scenario: Scenario
75
+ readonly tier: TierId
76
+ /** 0..1; the rule layer decides alone at or above `intent.ruleThreshold`. */
77
+ readonly confidence: number
78
+ /** Keyword score that produced the confidence. */
79
+ readonly keywordScore: number
80
+ readonly signals: IntentSignals
81
+ readonly reasons: readonly string[]
82
+ readonly shortCircuit: ShortCircuit | undefined
83
+ /** Fingerprint of this shape: `scenario|tokenBand|fenceBand`. */
84
+ readonly fingerprint: string
85
+ }
86
+
87
+ /** One declarative-rule hit. */
88
+ export interface RuleHit {
89
+ readonly id: string
90
+ readonly tier: 'cheap' | 'strong'
91
+ }
92
+
93
+ /**
94
+ * Evaluate the declarative rule table. Rules are already sorted by descending
95
+ * priority; the first match wins. A guard denial always outranks a rule (the
96
+ * guard runs on `tools/pre-execute`, after the routing decision, and denies
97
+ * regardless of tier).
98
+ *
99
+ * @param rules - compiled rules.
100
+ * @param input - the live input facts.
101
+ * @returns the winning hit, or null when no rule matches.
102
+ */
103
+ export function evaluateRules(rules: readonly CompiledRule[], input: IntentInput): RuleHit | null {
104
+ for (const rule of rules) {
105
+ if (rule.cwd !== '' && !input.cwd.startsWith(rule.cwd)) continue
106
+ const toolHit = rule.tools.some(tool => input.toolNames.includes(tool))
107
+ const patternHit = rule.patterns.some(pattern => pattern.test(input.text))
108
+ if (rule.tools.length > 0 && rule.patterns.length > 0) {
109
+ if (!toolHit || !patternHit) continue
110
+ } else if (rule.tools.length > 0) {
111
+ if (!toolHit) continue
112
+ } else if (!patternHit) {
113
+ continue
114
+ }
115
+ return { id: rule.id, tier: rule.tier }
116
+ }
117
+ return null
118
+ }
119
+
120
+ /** Which tier each scenario belongs to by default. */
121
+ const SCENARIO_TIER: Record<Scenario, TierId> = {
122
+ coding: 'cheap',
123
+ review: 'strong',
124
+ planning: 'strong',
125
+ retrieval: 'cheap',
126
+ batch: 'cheap',
127
+ daily: 'cheap',
128
+ longText: 'strong',
129
+ multimodal: 'strong',
130
+ }
131
+
132
+ /** Scenario evaluation order: specific/expensive first, `daily` last (tie-break order). */
133
+ const SCENARIO_ORDER: readonly Scenario[] = ['planning', 'review', 'coding', 'batch', 'retrieval', 'longText', 'multimodal', 'daily']
134
+
135
+ /**
136
+ * Bilingual keyword tables. ASCII terms are matched with word boundaries
137
+ * (case-insensitive); CJK terms are matched as substrings and a scenario needs
138
+ * at least two distinct CJK hits before they count, so a single two-character
139
+ * word cannot drag a turn into a scenario.
140
+ */
141
+ const KEYWORDS: Record<Scenario, readonly string[]> = {
142
+ coding: ['implement', 'refactor', 'fix', 'bug', 'test', 'function', 'class', 'endpoint', 'api', '代码', '实现', '修复', '重构', '测试', '函数', '接口', '编译', '报错'],
143
+ review: ['review', 'audit', 'security', 'vulnerability', 'hardening', '审查', '审计', '安全', '漏洞', '评审'],
144
+ planning: ['plan', 'design', 'architect', 'architecture', 'roadmap', 'migrate', 'migration', '规划', '设计', '架构', '方案', '迁移'],
145
+ retrieval: ['where', 'find', 'search', 'locate', 'grep', '查找', '搜索', '定位', '在哪'],
146
+ batch: ['batch', 'bulk', 'every', 'rename', '批量', '全部', '每个', '遍历'],
147
+ daily: ['hello', 'hi', 'hey', 'thanks', 'thank', 'weather', '你好', '您好', '谢谢', '天气'],
148
+ longText: [],
149
+ multimodal: [],
150
+ }
151
+
152
+ /** Whole-string greetings classify as daily with high confidence. */
153
+ const GREETING = /^(?:hi|hello|hey|thanks|thank you|你好|您好|谢谢|早上好|晚上好)[\s!.。,,!!]*$/iu
154
+
155
+ /** Explicit intent patterns that short-circuit keyword scoring. */
156
+ const EXPLICIT: readonly { scenario: Scenario; confidence: number; pattern: RegExp }[] = [
157
+ { scenario: 'planning', confidence: 0.97, pattern: /^(?:please\s+)?(?:plan|design|architect)\b/iu },
158
+ { scenario: 'planning', confidence: 0.95, pattern: /(?:规划|架构设计|方案设计|技术方案|从零实现)/u },
159
+ { scenario: 'review', confidence: 0.96, pattern: /\b(?:code review|review the|audit the|security review)\b/iu },
160
+ { scenario: 'review', confidence: 0.94, pattern: /(?:代码审查|安全审计|评审一下)/u },
161
+ { scenario: 'batch', confidence: 0.95, pattern: /\b(?:batch|bulk)\b/iu },
162
+ { scenario: 'batch', confidence: 0.94, pattern: /(?:批量处理|批量修改|批量重命名)/u },
163
+ ]
164
+
165
+ /** Words that mark a multi-step or whole-system request. */
166
+ const HARD_HINTS: readonly string[] = [
167
+ 'and then', 'step by step', 'multi-step', 'end to end', 'end-to-end', 'entire', 'all of',
168
+ 'migrate', 'refactor', 'architecture', 'production', '从零', '完整', '整个', '多步', '端到端', '全流程',
169
+ ]
170
+
171
+ /** Count non-overlapping occurrences of a fence marker. */
172
+ function countFences(text: string): number {
173
+ return (text.match(/```/gu) ?? []).length
174
+ }
175
+
176
+ /** Count ASCII word-boundary hits, case-insensitive. */
177
+ function asciiHits(text: string, term: string): number {
178
+ const pattern = new RegExp(`\\b${term.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}\\b`, 'giu')
179
+ return (text.match(pattern) ?? []).length
180
+ }
181
+
182
+ /** Count CJK substring hits. */
183
+ function cjkHits(text: string, term: string): number {
184
+ let count = 0
185
+ let index = text.indexOf(term)
186
+ while (index !== -1) {
187
+ count += 1
188
+ index = text.indexOf(term, index + term.length)
189
+ }
190
+ return count
191
+ }
192
+
193
+ /** Score one scenario against the text, applying the CJK co-occurrence rule. */
194
+ function scoreScenario(text: string, scenario: Scenario): number {
195
+ let asciiScore = 0
196
+ let cjkScore = 0
197
+ let cjkTerms = 0
198
+ for (const term of KEYWORDS[scenario]) {
199
+ const isAscii = /^[\x20-\x7E]+$/u.test(term)
200
+ const hits = isAscii ? asciiHits(text, term) : cjkHits(text, term)
201
+ if (hits === 0) continue
202
+ const weight = term.length > 3 ? 2 : 1
203
+ if (isAscii) asciiScore += hits * weight
204
+ else {
205
+ cjkScore += hits * weight
206
+ cjkTerms += 1
207
+ }
208
+ }
209
+ // A single CJK word is too weak a signal; two distinct ones may score.
210
+ return asciiScore + (cjkTerms >= 2 ? cjkScore : 0)
211
+ }
212
+
213
+ /** Compute the structural signal vector. */
214
+ export function computeSignals(input: IntentInput): IntentSignals {
215
+ const chars = input.text.length
216
+ const estTokens = Math.ceil(chars / 4)
217
+ const tokenBands = [4_000, 12_000, 30_000].filter(band => estTokens >= band).length
218
+ const fences = countFences(input.text)
219
+ const toolCalls = input.toolNames.length
220
+ const lower = input.text.toLowerCase()
221
+ const hardHints = HARD_HINTS.filter(hint => lower.includes(hint)).length > 0 ? 1 : 0
222
+ let score = tokenBands
223
+ if (toolCalls >= 1) score += 1
224
+ if (toolCalls >= 4) score += 1
225
+ if (fences >= 1) score += 1
226
+ if (fences >= 4) score += 1
227
+ score += hardHints
228
+ if (input.messageCount >= 12) score += 1
229
+ if (input.messageCount >= 30) score += 1
230
+ return {
231
+ chars,
232
+ estTokens,
233
+ tokenBands,
234
+ fences,
235
+ toolCalls,
236
+ messageCount: input.messageCount,
237
+ hardHints,
238
+ score,
239
+ }
240
+ }
241
+
242
+ /** The token band index used in a fingerprint. */
243
+ function tokenBand(estTokens: number): number {
244
+ if (estTokens < 4_000) return 0
245
+ if (estTokens < 12_000) return 1
246
+ if (estTokens < 30_000) return 2
247
+ return 3
248
+ }
249
+
250
+ /** The fence band index used in a fingerprint. */
251
+ function fenceBand(fences: number): number {
252
+ if (fences === 0) return 0
253
+ if (fences < 4) return 1
254
+ return 2
255
+ }
256
+
257
+ /** Build the fingerprint key for one classification. */
258
+ export function fingerprintOf(scenario: Scenario, signals: IntentSignals): string {
259
+ return `${scenario}|${tokenBand(signals.estTokens)}|${fenceBand(signals.fences)}`
260
+ }
261
+
262
+ /** Options for {@link classifyIntent}. */
263
+ export interface ClassifyOptions {
264
+ readonly rules?: readonly CompiledRule[];
265
+ readonly scenarios: Required<ScenarioToggles>
266
+ /** Score at or above which structural signals force the strong tier. */
267
+ readonly signalThreshold?: number
268
+ }
269
+
270
+ /**
271
+ * Classify one user input. Deterministic and token-free; the caller decides
272
+ * whether the returned confidence warrants a judge call.
273
+ *
274
+ * @param input - the live input facts.
275
+ * @param options - compiled rules and scenario switches.
276
+ * @returns the verdict with its reasons.
277
+ */
278
+ export function classifyIntent(input: IntentInput, options: ClassifyOptions): IntentResult {
279
+ const signals = computeSignals(input)
280
+ const reasons: string[] = []
281
+ const enabled = (scenario: Scenario): boolean => options.scenarios[scenario]
282
+ const finish = (scenario: Scenario, confidence: number, shortCircuit: ShortCircuit | undefined, keywordScore: number): IntentResult => {
283
+ const tier: TierId = signals.score >= (options.signalThreshold ?? 3) ? 'strong' : SCENARIO_TIER[scenario]
284
+ if (signals.score >= (options.signalThreshold ?? 3) && SCENARIO_TIER[scenario] === 'cheap') {
285
+ reasons.push(`structural signals (score ${String(signals.score)}) force the strong tier`)
286
+ }
287
+ return {
288
+ scenario,
289
+ tier,
290
+ confidence,
291
+ keywordScore,
292
+ signals,
293
+ reasons,
294
+ shortCircuit,
295
+ fingerprint: fingerprintOf(scenario, signals),
296
+ }
297
+ }
298
+
299
+ if (input.hasImage && enabled('multimodal')) {
300
+ reasons.push('the message carries an image')
301
+ return finish('multimodal', 0.98, 'image', 0)
302
+ }
303
+ if (signals.chars > 12_000 && enabled('longText')) {
304
+ reasons.push(`long input (${String(signals.chars)} chars)`)
305
+ return finish('longText', 0.96, 'long-text', 0)
306
+ }
307
+ if (GREETING.test(input.text.trim()) && enabled('daily')) {
308
+ reasons.push('whole-message greeting')
309
+ return finish('daily', 0.92, 'greeting', 0)
310
+ }
311
+ for (const entry of EXPLICIT) {
312
+ if (enabled(entry.scenario) && entry.pattern.test(input.text)) {
313
+ reasons.push(`explicit ${entry.scenario} intent`)
314
+ return finish(entry.scenario, entry.confidence, 'explicit', 0)
315
+ }
316
+ }
317
+ let best: { scenario: Scenario; score: number } = { scenario: 'daily', score: 0 }
318
+ for (const scenario of SCENARIO_ORDER) {
319
+ if (!enabled(scenario)) continue
320
+ const score = scoreScenario(input.text, scenario)
321
+ if (score > best.score) best = { scenario, score }
322
+ }
323
+ const keywordScore = best.score
324
+ const scenario = keywordScore === 0 ? 'daily' : best.scenario
325
+ const confidence = keywordScore === 0 ? 0.25 : Math.min(0.96, 0.52 + keywordScore * 0.1)
326
+ if (keywordScore > 0) reasons.push(`keyword score ${String(keywordScore)} for ${scenario}`)
327
+ else reasons.push('no keyword matched; defaulting to the cheap tier')
328
+ return finish(scenario, confidence, undefined, keywordScore)
329
+ }
330
+
331
+ // ---- fingerprint posteriors -------------------------------------------------
332
+
333
+ /** One fingerprint's outcome counters. */
334
+ export interface Posterior {
335
+ cheapOK: number
336
+ cheapN: number
337
+ strongOK: number
338
+ strongN: number
339
+ /** Epoch millis of the last observation. */
340
+ lastSeen: number
341
+ /** How many observations this key has accumulated (drives the half-life decay). */
342
+ observations: number
343
+ /** How many exploration probes were spent on this key. */
344
+ probeN: number
345
+ }
346
+
347
+ /** The verdict a posterior yields. */
348
+ export type PosteriorVerdict = 'strong' | 'cheap' | null
349
+
350
+ /** Wilson score interval lower bound for `ok` successes in `n` trials. */
351
+ export function wilsonLowerBound(ok: number, n: number, z = 1.96): number {
352
+ if (n <= 0) return 0
353
+ const phat = ok / n
354
+ const denom = 1 + (z * z) / n
355
+ const centre = phat + (z * z) / (2 * n)
356
+ const margin = z * Math.sqrt((phat * (1 - phat) + (z * z) / (4 * n)) / n)
357
+ return Math.max(0, (centre - margin) / denom)
358
+ }
359
+
360
+ /** Options for {@link PosteriorTable}. */
361
+ export interface PosteriorOptions {
362
+ /** Observations between two half-life decays (default 10). */
363
+ readonly halfLife?: number
364
+ /** Cold-start threshold: below this total the table abstains (default 8). */
365
+ readonly coldStart?: number
366
+ /** Exploration probability (default 0.05). */
367
+ readonly epsilon?: number
368
+ /** LRU capacity (default 2000). */
369
+ readonly capacity?: number
370
+ /** Deterministic random source for tests. */
371
+ readonly random?: () => number
372
+ }
373
+
374
+ /**
375
+ * Per-fingerprint win-rate posteriors. Labels come from terminal task outcomes
376
+ * only (never from the router's own judge call), and every write decays old
377
+ * counts once per half-life so a shape's reputation can recover.
378
+ */
379
+ export class PosteriorTable {
380
+ private readonly entries = new Map<string, Posterior>()
381
+ private readonly halfLife: number
382
+ private readonly coldStart: number
383
+ private readonly epsilon: number
384
+ private readonly capacity: number
385
+ private readonly random: () => number
386
+
387
+ /** @param options - tuning knobs; defaults match the design table. */
388
+ constructor(options: PosteriorOptions = {}) {
389
+ this.halfLife = options.halfLife ?? 10
390
+ this.coldStart = options.coldStart ?? 8
391
+ this.epsilon = options.epsilon ?? 0.05
392
+ this.capacity = options.capacity ?? 2_000
393
+ this.random = options.random ?? Math.random
394
+ }
395
+
396
+ /** Number of tracked fingerprints. */
397
+ get size(): number {
398
+ return this.entries.size
399
+ }
400
+
401
+ /** Read one posterior without decaying it. */
402
+ get(key: string): Posterior | undefined {
403
+ return this.entries.get(key)
404
+ }
405
+
406
+ /** Record one terminal outcome for a fingerprint and tier. */
407
+ record(key: string, tier: TierId, ok: boolean, now: number): void {
408
+ const existing = this.entries.get(key)
409
+ const base: Posterior = existing ?? {
410
+ cheapOK: 0,
411
+ cheapN: 0,
412
+ strongOK: 0,
413
+ strongN: 0,
414
+ lastSeen: now,
415
+ observations: 0,
416
+ probeN: 0,
417
+ }
418
+ // Decay before the write so a key's reputation is bounded by its recent
419
+ // history, then count the new observation.
420
+ if (existing !== undefined && existing.observations > 0 && existing.observations % this.halfLife === 0) {
421
+ base.cheapOK /= 2
422
+ base.cheapN /= 2
423
+ base.strongOK /= 2
424
+ base.strongN /= 2
425
+ }
426
+ if (tier === 'cheap') {
427
+ base.cheapN += 1
428
+ if (ok) base.cheapOK += 1
429
+ } else {
430
+ base.strongN += 1
431
+ if (ok) base.strongOK += 1
432
+ }
433
+ base.lastSeen = now
434
+ base.observations += 1
435
+ this.entries.delete(key)
436
+ this.entries.set(key, base)
437
+ while (this.entries.size > this.capacity) {
438
+ const oldest = this.entries.keys().next().value
439
+ if (oldest === undefined) break
440
+ this.entries.delete(oldest)
441
+ }
442
+ }
443
+
444
+ /** Count one exploration probe on a key. */
445
+ probe(key: string): void {
446
+ const entry = this.entries.get(key)
447
+ if (entry !== undefined) entry.probeN += 1
448
+ }
449
+
450
+ /**
451
+ * The table's opinion about one fingerprint, without the exploration roll.
452
+ * @param key - the fingerprint.
453
+ * @returns the base verdict and whether an exploration probe is warranted.
454
+ */
455
+ opinion(key: string): { verdict: PosteriorVerdict; explore: boolean } {
456
+ const entry = this.entries.get(key)
457
+ if (entry === undefined) return { verdict: null, explore: false }
458
+ if (entry.cheapN + entry.strongN < this.coldStart) return { verdict: null, explore: false }
459
+ if (entry.cheapN === 0) return { verdict: entry.strongN > 0 ? 'strong' : null, explore: false }
460
+ const healthy = wilsonLowerBound(entry.cheapOK, entry.cheapN) > 0.5
461
+ return healthy ? { verdict: 'cheap', explore: true } : { verdict: 'strong', explore: false }
462
+ }
463
+
464
+ /**
465
+ * The table's opinion about one fingerprint. The exploration roll is a
466
+ * decision point, so callers take it once per user input and reuse the
467
+ * result for every step of that input's turn.
468
+ * @param key - the fingerprint.
469
+ * @returns `'strong'`, `'cheap'`, or `null` when the table abstains.
470
+ */
471
+ verdict(key: string): PosteriorVerdict {
472
+ const { verdict, explore } = this.opinion(key)
473
+ if (verdict === 'cheap' && explore && this.random() < this.epsilon) {
474
+ this.probe(key)
475
+ return 'strong'
476
+ }
477
+ return verdict
478
+ }
479
+
480
+ /** Snapshot every key, newest first, for `/tier status` and diagnostics. */
481
+ snapshot(): { key: string; posterior: Posterior }[] {
482
+ return [...this.entries.entries()].reverse().map(([key, posterior]) => ({ key, posterior: { ...posterior } }))
483
+ }
484
+ }
package/src/judge.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The low-confidence judge: a cheap model classifies intent only. It is called
3
+ * when the deterministic layer's confidence falls below `intent.ruleThreshold`,
4
+ * never on cooldown, and never while the previous calls are failing — the
5
+ * classifier's own verdict is always the fallback.
6
+ *
7
+ * @module dsh-autotier/judge
8
+ */
9
+
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import { BlockAssembler, createUserMessage, type GenerateOptions } from '@deepseek-ai/dsh-llm'
12
+ import type { ResolvedConfig } from './config.ts'
13
+ import type { Scenario, TierId } from './types.ts'
14
+
15
+ /** The label vocabulary the judge is asked to choose from. */
16
+ export const JUDGE_LABELS: readonly { readonly label: string; readonly scenario: Scenario }[] = [
17
+ { label: 'coding', scenario: 'coding' },
18
+ { label: 'review', scenario: 'review' },
19
+ { label: 'planning', scenario: 'planning' },
20
+ { label: 'retrieval', scenario: 'retrieval' },
21
+ { label: 'batch', scenario: 'batch' },
22
+ { label: 'daily', scenario: 'daily' },
23
+ { label: 'longText', scenario: 'longText' },
24
+ { label: 'multimodal', scenario: 'multimodal' },
25
+ ]
26
+
27
+ /** Which tier a judged scenario lands on. */
28
+ const LABEL_TIER: Record<Scenario, TierId> = {
29
+ coding: 'cheap',
30
+ review: 'strong',
31
+ planning: 'strong',
32
+ retrieval: 'cheap',
33
+ batch: 'cheap',
34
+ daily: 'cheap',
35
+ longText: 'strong',
36
+ multimodal: 'strong',
37
+ }
38
+
39
+ /** The judge's answer. */
40
+ export interface JudgeOutcome {
41
+ /** Whether the call produced a usable label. */
42
+ readonly ok: boolean
43
+ readonly scenario: Scenario | undefined
44
+ readonly tier: TierId | undefined
45
+ /** Short diagnostic; safe for logs (no prompt text). */
46
+ readonly detail: string
47
+ }
48
+
49
+ /** The route the judge call uses. */
50
+ export interface JudgeRoute {
51
+ readonly provider: string
52
+ readonly model: string
53
+ }
54
+
55
+ /**
56
+ * Resolve the judge route: the configured model, else the first catalog model
57
+ * whose id contains `flash` on the cheap tier's provider.
58
+ * @param ctx - the plugin context (reads `ctx.llm`).
59
+ * @param config - the resolved configuration.
60
+ * @returns the route, or undefined when no candidate exists.
61
+ */
62
+ export async function resolveJudgeRoute(ctx: Context, config: ResolvedConfig): Promise<JudgeRoute | undefined> {
63
+ const provider = config.tiers.cheap.provider
64
+ if (config.intent.judge.model !== '') {
65
+ return { provider, model: config.intent.judge.model }
66
+ }
67
+ try {
68
+ const models = await ctx.llm.listModels(provider)
69
+ const flash = models.find(model => model.id.toLowerCase().includes('flash'))
70
+ if (flash !== undefined) return { provider, model: flash.id }
71
+ const first = models[0]
72
+ return first === undefined ? undefined : { provider, model: first.id }
73
+ } catch {
74
+ return undefined
75
+ }
76
+ }
77
+
78
+ /** Extract the first label that appears in the judge's answer. */
79
+ export function parseJudgeLabel(answer: string): Scenario | undefined {
80
+ const text = answer.trim().toLowerCase()
81
+ if (text === '') return undefined
82
+ for (const entry of JUDGE_LABELS) {
83
+ const label = entry.label.toLowerCase()
84
+ if (new RegExp(`(^|[^a-z])${label}([^a-z]|$)`, 'u').test(text)) return entry.scenario
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ /**
90
+ * Run one judge call.
91
+ * @param ctx - the plugin context (reads `ctx.llm`).
92
+ * @param config - the resolved configuration.
93
+ * @param text - the newest user message text.
94
+ * @param signal - the turn's abort signal; the judge adds its own timeout.
95
+ * @returns the outcome; a failure is reported, never thrown.
96
+ */
97
+ export async function runJudge(
98
+ ctx: Context,
99
+ config: ResolvedConfig,
100
+ text: string,
101
+ signal: AbortSignal,
102
+ ): Promise<JudgeOutcome> {
103
+ const route = await resolveJudgeRoute(ctx, config)
104
+ if (route === undefined) return { ok: false, scenario: undefined, tier: undefined, detail: 'no judge model available' }
105
+ const labels = JUDGE_LABELS.map(entry => entry.label).join(', ')
106
+ const prompt = [
107
+ 'Classify the user request into exactly one label.',
108
+ `Labels: ${labels}`,
109
+ 'Reply with the label only, no punctuation or explanation.',
110
+ '',
111
+ `Request: ${text.slice(0, 2_000)}`,
112
+ ].join('\n')
113
+ const timeout = AbortSignal.timeout(config.intent.judge.timeoutMs)
114
+ const fused = AbortSignal.any([signal, timeout])
115
+ const options: GenerateOptions = {
116
+ provider: route.provider,
117
+ model: route.model,
118
+ messages: [createUserMessage({
119
+ content: [{ type: 'text', text: prompt }],
120
+ source: { kind: 'plugin', plugin: 'dsh-autotier' },
121
+ })],
122
+ temperature: config.intent.judge.temperature,
123
+ maxTokens: config.intent.judge.maxTokens,
124
+ signal: fused,
125
+ }
126
+ const assembler = new BlockAssembler()
127
+ try {
128
+ for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
129
+ } catch (error) {
130
+ return {
131
+ ok: false,
132
+ scenario: undefined,
133
+ tier: undefined,
134
+ detail: timeout.aborted ? 'judge timed out' : `judge failed: ${error instanceof Error ? error.message : String(error)}`,
135
+ }
136
+ }
137
+ const finish = assembler.finish
138
+ if (finish.kind === 'error' || finish.kind === 'aborted') {
139
+ return { ok: false, scenario: undefined, tier: undefined, detail: `judge stream ended with ${finish.kind}` }
140
+ }
141
+ const answer = assembler.blocks()
142
+ .filter((block): block is Extract<ReturnType<BlockAssembler['blocks']>[number], { type: 'text' }> => block.type === 'text')
143
+ .map(block => block.text)
144
+ .join(' ')
145
+ const scenario = parseJudgeLabel(answer)
146
+ if (scenario === undefined) {
147
+ return { ok: false, scenario: undefined, tier: undefined, detail: 'judge answer carried no known label' }
148
+ }
149
+ return { ok: true, scenario, tier: LABEL_TIER[scenario], detail: `judge chose ${scenario}` }
150
+ }