@aiwayds/dsh-topics-memory 0.18.2 → 0.20.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.
package/src/quality.ts CHANGED
@@ -5,8 +5,12 @@
5
5
  * 1. intent-query build over the observer's last-K ring buffer — kills
6
6
  * verbatim priming at the root (the query is what the model needs, not
7
7
  * what the user typed);
8
- * 2. lexical candidate band (hits + near-misses, recall-oriented) then an
9
- * LLM rerank that releases 0-2 picks, each with a one-line why.
8
+ * 2. lexical candidate band (hits + near-misses, recall-oriented) then a
9
+ * rerank that releases 0-2 picks, each with a one-line why. With
10
+ * `jevEnabled` on (design 2026-09-25 §2) the rerank is a batched System
11
+ * One noul request, probability-band gated; any failure fails open to the
12
+ * legacy LLM rerank below. The same trigger also runs the fastgate
13
+ * SHADOW (zero behavior, decisions.jsonl only).
10
14
  *
11
15
  * Picks rest in a per-session pending slot and inject at the NEXT spliced
12
16
  * (steer message), then the slot is gone (消费即清). Hard bounds: per-call
@@ -23,6 +27,14 @@ import type { ModelCaller } from './distill.ts'
23
27
  import { searchTopics } from './retrieval.ts'
24
28
  import type { RingEntry } from './observer.ts'
25
29
  import type { QueryBuildShape } from './ilog.ts'
30
+ import type { TopicsConfigValue } from './config.ts'
31
+ import { jevAskDual } from './jev/dual.ts'
32
+ import type { JevQuestion } from './jev/client.ts'
33
+ import type { JevDualConfig, JevDualResult } from './jev/dual.ts'
34
+ import { band as jevBand } from './jev/thresholds.ts'
35
+ import type { JevBand } from './jev/thresholds.ts'
36
+ import { digestFor, logVerdicts } from './jev/log.ts'
37
+ import type { JevVerdictRecord } from './jev/log.ts'
26
38
 
27
39
  export const PENDING_TTL_MS = 10 * 60_000
28
40
  /** Hard drift bound: a pending older than this many turn-ends expires. */
@@ -38,6 +50,8 @@ const RING_CHARS = 6000
38
50
  const CANDIDATE_LIMIT = 6
39
51
  /** Over-fetch before the exclude filter, so excluded slugs don't thin the band. */
40
52
  const CANDIDATE_OVERFETCH = 6
53
+ /** Fastgate shadow cap (design §2 row 3): topK hits + near-misses, ≤8 questions. */
54
+ export const FASTGATE_MAX_QUESTIONS = 8
41
55
 
42
56
  export const QUERY_BUILD_PROMPT = [
43
57
  '你是检索查询构建器。输入是最近几轮对话。任务:判断「模型此刻需要什么背景知识」,输出一个用于关键词检索的 query。',
@@ -57,6 +71,124 @@ export const RERANK_PROMPT = [
57
71
  '- slug 必须逐字取自候选列表,禁止编造。',
58
72
  ].join('\n')
59
73
 
74
+ // ---- jev (System One) decision branch, design 2026-09-25 §2 rows 1+3 ----
75
+ //
76
+ // When jevEnabled is on, the rerank step asks the decision model instead of
77
+ // the generative LLM: one batched noul request over the candidate band, the
78
+ // calibrated bands (jev/thresholds: adopt ≥ 0.60 / record 0.10–0.60 / veto
79
+ // < 0.10) decide the picks, and every candidate lands in decisions.jsonl.
80
+ // Any failure falls open to the RERANK_PROMPT path below (red line ②).
81
+ // The fastgate shadow audits the fast lane's OWN lexical disposition from the
82
+ // same sampled turn-end trigger — zero behavior, decisions.jsonl only.
83
+
84
+ /** The candidate slice the jev questions see (content-bearing metadata). */
85
+ interface JevCandidate {
86
+ slug: string
87
+ title: string
88
+ status: string
89
+ tags?: readonly string[]
90
+ description?: string
91
+ conclusion: string
92
+ }
93
+
94
+ /**
95
+ * §6.2 state for one batch: the round's query plus the task frame. Shared by
96
+ * the rerank and the fastgate shadow so both seams speak ONE batch protocol —
97
+ * the calibrated bands are protocol-bound (design §5), a second state shape
98
+ * would need its own sweep.
99
+ */
100
+ export function jevState(query: string): string {
101
+ return `检索 query:${query}\n任务背景:模型正在推进当前会话的工作,需要判断哪些长期记忆 topic 值得注入上下文作参考。`
102
+ }
103
+
104
+ /** Per-candidate question: full candidate inline (title/tags/conclusion
105
+ * excerpt), RERANK_PROMPT's rendering style but compact. */
106
+ function candidateInstructions(c: JevCandidate): string {
107
+ const tags = c.tags !== undefined && c.tags.length > 0 ? `,标签: ${c.tags.join('/')}` : ''
108
+ const desc = c.description !== undefined && c.description.trim() !== '' ? ` ${c.description.trim().slice(0, 80)}` : ''
109
+ return `候选 topic「${c.title}」(slug: ${c.slug},状态: ${c.status}${tags})。${desc}结论摘录:${c.conclusion.slice(0, 160)}。该候选与当前 query 对应的工作真的相关、值得注入模型上下文吗?`
110
+ }
111
+
112
+ /** Two-sided criteria built from the candidate content (calibration protocol:
113
+ * single-sided instructions measurably degrade). */
114
+ function candidateCriteria(c: JevCandidate): { true: string; false: string } {
115
+ return {
116
+ true: `相关——「${c.title}」的记忆对当前工作有实质帮助,应该注入作参考`,
117
+ false: `无关——「${c.title}」与当前工作没有实质交集,注入只是噪音,不应该注入`,
118
+ }
119
+ }
120
+
121
+ /** The noul probability of one answer; undefined when the answer is not a
122
+ * usable number in [0,1] (a 坏答案 — fail-open for the caller). */
123
+ function noulProbability(answer: unknown): number | undefined {
124
+ if (answer === null || typeof answer !== 'object') return undefined
125
+ const p = (answer as { noul?: unknown }).noul
126
+ return typeof p === 'number' && Number.isFinite(p) && p >= 0 && p <= 1 ? p : undefined
127
+ }
128
+
129
+ /** Pace-maker comparison rows for the slow rerank (design §3.4, REVISED
130
+ * 09-27): laya is TELEMETRY ONLY, so its rows are recorded band='record'
131
+ * (its absolute scores never claim adopt) and degraded=false whether or not
132
+ * the primary answered — landing them costs no behavior. An unparseable
133
+ * answer gets no fabricated row. */
134
+ function layaRerankRows(
135
+ at: string,
136
+ state: string,
137
+ metas: { qid: string; slug: string; instructions: string }[],
138
+ answers: Record<string, unknown> | undefined,
139
+ ): JevVerdictRecord[] {
140
+ const recs: JevVerdictRecord[] = []
141
+ for (const m of metas) {
142
+ const probability = noulProbability(answers?.[m.qid])
143
+ if (probability === undefined) continue
144
+ recs.push({
145
+ at,
146
+ lane: 'slowlane-rerank',
147
+ questionId: m.qid,
148
+ qtype: 'noul',
149
+ ref: `slug:${m.slug}`,
150
+ digest: digestFor(state, m.qid, m.instructions),
151
+ probability,
152
+ band: 'record',
153
+ agree: 'n/a',
154
+ backend: 'laya',
155
+ degraded: false,
156
+ })
157
+ }
158
+ return recs
159
+ }
160
+
161
+ /** The config slice jevAsk needs, tolerating bare harnesses (§4: consumers
162
+ * treat undefined as the documented default). */
163
+ function jevConfigOf(cfg: TopicsConfigValue): JevDualConfig {
164
+ return {
165
+ jevBackend: cfg.jevBackend ?? 'zen',
166
+ jevModel: cfg.jevModel ?? '',
167
+ jevTimeoutMs: typeof cfg.jevTimeoutMs === 'number' && cfg.jevTimeoutMs > 0 ? cfg.jevTimeoutMs : 3000,
168
+ jevSecretFile: cfg.jevSecretFile ?? '',
169
+ jevLayaFallback: cfg.jevLayaFallback === true,
170
+ jevLayaUrl: cfg.jevLayaUrl ?? 'http://127.0.0.1:8000/v1/systemone',
171
+ }
172
+ }
173
+
174
+ /** The lexical disposition the fast lane recorded for one candidate — the
175
+ * agree-reconciliation key (§6.2; retrieval.ts hits/nearMisses split). */
176
+ export type FastGateDisposition = 'hit' | 'nearFloor' | 'gate-blocked'
177
+
178
+ export interface FastGateCandidate {
179
+ slug: string
180
+ disposition: FastGateDisposition
181
+ }
182
+
183
+ export interface FastGateShadowInput {
184
+ /** The fast-lane round's query (the claimed text), captured at spliced. */
185
+ query: string
186
+ /** hits + near-misses of that round, with their lexical dispositions. */
187
+ candidates: readonly FastGateCandidate[]
188
+ /** Observer turn count at the trigger — the sampled-cadence clock. */
189
+ turnId: number
190
+ }
191
+
60
192
  export interface PendingInjection {
61
193
  items: { slug: string; why: string }[]
62
194
  computedAt: string
@@ -141,6 +273,8 @@ export function parseJsonObject(raw: string): Record<string, unknown> {
141
273
  export class SlowLane {
142
274
  private pending = new Map<string, PendingInjection>()
143
275
  private inFlight = new Map<string, Promise<void>>()
276
+ /** Same-session shadow serialization — a second trigger never overlaps. */
277
+ private shadowInFlight = new Map<string, Promise<void>>()
144
278
  private readonly service: TopicsService
145
279
  private readonly caller: ModelCaller | undefined
146
280
  /**
@@ -171,6 +305,97 @@ export class SlowLane {
171
305
  /** Drop the pending slot (session teardown, restore boundary). */
172
306
  clear(sessionId: string): void {
173
307
  this.pending.delete(sessionId)
308
+ this.shadowInFlight.delete(sessionId)
309
+ }
310
+
311
+ /**
312
+ * Fastgate shadow (design §2 row 3): fire-and-forget on the SAME sampled
313
+ * turn-end trigger as the slow lane, zero behavior — verdicts land only in
314
+ * decisions.jsonl, reconciled against the lexical disposition. Needs no LLM
315
+ * caller, so it also runs on turns where the slow lane itself yields (distill
316
+ * in flight, no model configured). `dispatch()`'s own guards (lane mode,
317
+ * sampled cadence) are mirrored here so both seams share one cadence.
318
+ */
319
+ dispatchFastGateShadow(sessionId: string, input: FastGateShadowInput): void {
320
+ try {
321
+ const cfg = this.service.cfg
322
+ if (cfg.jevEnabled !== true) return
323
+ if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') return
324
+ if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) return
325
+ if (input.query.trim() === '' || input.candidates.length === 0) return
326
+ if (this.shadowInFlight.has(sessionId)) return // never overlap shadows
327
+ // Serialize behind a same-session produce pipeline (rerank 完成后);
328
+ // standalone when no pipeline is running (无慢道任务时独立跑).
329
+ const prior = this.inFlight.get(sessionId) ?? Promise.resolve()
330
+ const run = prior.then(() => this.fastGateShadow(input)).finally(() => {
331
+ this.shadowInFlight.delete(sessionId)
332
+ })
333
+ this.shadowInFlight.set(sessionId, run)
334
+ void run.catch(() => undefined)
335
+ } catch {
336
+ // contained — the shadow must never break the event handler
337
+ }
338
+ }
339
+
340
+ /** Shadow body: resolve candidate content from the roster, one batched noul
341
+ * request, verdict rows only. A jevAsk failure leaves just its auto-written
342
+ * call row — there is no legacy path to fall back to, and nothing to gate. */
343
+ private async fastGateShadow(input: FastGateShadowInput): Promise<void> {
344
+ const roster = await this.service.roster().catch(() => [])
345
+ const bySlug = new Map(roster.map((r) => [r.slug, r]))
346
+ const state = jevState(input.query)
347
+ const questions: Record<string, JevQuestion> = {}
348
+ const metas: { qid: string; slug: string; instructions: string; disposition: FastGateDisposition }[] = []
349
+ for (const cand of input.candidates.slice(0, FASTGATE_MAX_QUESTIONS)) {
350
+ const meta = bySlug.get(cand.slug)
351
+ if (meta === undefined) continue // vanished between retrieval and the shadow
352
+ const qid = `c${metas.length + 1}`
353
+ const instructions = candidateInstructions(meta)
354
+ questions[qid] = { type: 'noul', instructions, criteria: candidateCriteria(meta) }
355
+ metas.push({ qid, slug: cand.slug, instructions, disposition: cand.disposition })
356
+ }
357
+ if (metas.length === 0) return
358
+ const dual = await jevAskDual({
359
+ state,
360
+ questions,
361
+ config: jevConfigOf(this.service.cfg),
362
+ lane: 'fastgate-shadow',
363
+ fallback: false, // pure shadow — no behavior to fall back
364
+ })
365
+ const at = new Date().toISOString()
366
+ // Verdict rows for BOTH sources when they answered: the primary row set
367
+ // and the laya pace-maker comparison set (backend='laya', excluded from
368
+ // ECE, §6.2). Pure shadow — nothing gates here, so the laya rows land
369
+ // whenever laya answered, primary success or not.
370
+ const sources: { answers: Record<string, unknown> | undefined; backend: string; degraded: boolean }[] = []
371
+ if (dual.ok) sources.push({ answers: dual.answers, backend: 'primary', degraded: false })
372
+ if (dual.layaResult?.ok) sources.push({ answers: dual.layaResult.answers, backend: 'laya', degraded: false })
373
+ const recs: JevVerdictRecord[] = []
374
+ for (const src of sources) {
375
+ for (const m of metas) {
376
+ const probability = noulProbability(src.answers?.[m.qid])
377
+ if (probability === undefined) continue // unparseable answer: no fabricated row
378
+ const b = jevBand(probability, 'rerank')
379
+ // agree reconciliation (§6.2): the ilog disposition, with wouldBlock for
380
+ // the reverse disagreement (lexical let it through, jev lands in the veto
381
+ // band — the other half of the calibration goldmine).
382
+ const agree: JevVerdictRecord['agree'] = m.disposition === 'hit' && b === 'fallback' ? 'wouldBlock' : m.disposition
383
+ recs.push({
384
+ at,
385
+ lane: 'fastgate-shadow',
386
+ questionId: m.qid,
387
+ qtype: 'noul',
388
+ ref: `slug:${m.slug}`,
389
+ digest: digestFor(state, m.qid, m.instructions),
390
+ probability,
391
+ band: b,
392
+ agree,
393
+ backend: src.backend,
394
+ degraded: src.degraded,
395
+ })
396
+ }
397
+ }
398
+ await logVerdicts(recs)
174
399
  }
175
400
 
176
401
  /**
@@ -270,6 +495,18 @@ export class SlowLane {
270
495
  ]
271
496
  })
272
497
  if (payload.length === 0) return
498
+ // ---- jev batch rerank (design §2 row 1) — replaces the LLM rerank ----
499
+ // One batched noul request over the whole band; the calibrated bands
500
+ // decide the picks. A settled jev round is FINAL here (picks or none) —
501
+ // only a jev FAILURE (jevRerank → undefined) falls open to the legacy
502
+ // LLM rerank below (§3.2: 本轮回退旧 LLM rerank, picks 照常产出).
503
+ if (this.service.cfg.jevEnabled === true) {
504
+ const jevItems = await this.jevRerank(query, payload)
505
+ if (jevItems !== undefined) {
506
+ if (jevItems.length > 0) this.settlePending(sessionId, input, jevItems, queryBuild, started)
507
+ return
508
+ }
509
+ }
273
510
  const rerankRaw = await withTimeout(
274
511
  caller({
275
512
  system: RERANK_PROMPT,
@@ -309,15 +546,7 @@ export class SlowLane {
309
546
  if (items.length >= 2) break
310
547
  }
311
548
  if (items.length === 0) return
312
- const route = this.service.cfg
313
- this.pending.set(sessionId, {
314
- items,
315
- computedAt: new Date().toISOString(),
316
- turnId: input.turnId,
317
- queryBuild,
318
- model: `${route.distillProvider}/${route.distillModel}`,
319
- ms: Date.now() - started,
320
- })
549
+ this.settlePending(sessionId, input, items, queryBuild, started)
321
550
  } catch {
322
551
  // timeouts, aborts, model errors — I3: the async product is simply absent
323
552
  } finally {
@@ -325,6 +554,98 @@ export class SlowLane {
325
554
  }
326
555
  }
327
556
 
557
+ /** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
558
+ private settlePending(sessionId: string, input: DispatchInput, items: { slug: string; why: string }[], queryBuild: QueryBuildShape, started: number): void {
559
+ const route = this.service.cfg
560
+ this.pending.set(sessionId, {
561
+ items,
562
+ computedAt: new Date().toISOString(),
563
+ turnId: input.turnId,
564
+ queryBuild,
565
+ model: `${route.distillProvider}/${route.distillModel}`,
566
+ ms: Date.now() - started,
567
+ })
568
+ }
569
+
570
+ /**
571
+ * The jev rerank (design §2 row 1): one batched noul request over the whole
572
+ * candidate band; per-candidate verdict rows (agree: 'n/a' — no lexical
573
+ * disposition applies on this seam). Gate: adopt band (≥ 0.60) ranked by
574
+ * probability, top 2 (the 0-2 picks semantics); record band only logs;
575
+ * fallback band (< 0.10) is a hard veto.
576
+ *
577
+ * Returns undefined on ANY failure — jevAsk !ok (timeout / network / gate /
578
+ * bad shape) or an unparseable probability (坏答案) — so the caller fails
579
+ * open to the legacy LLM rerank (red line ②; the call-layer row already
580
+ * carries fallback=true via jevAsk's flag).
581
+ */
582
+ private async jevRerank(query: string, payload: JevCandidate[]): Promise<{ slug: string; why: string }[] | undefined> {
583
+ const state = jevState(query)
584
+ const questions: Record<string, JevQuestion> = {}
585
+ const metas: { qid: string; slug: string; instructions: string }[] = []
586
+ for (const [i, c] of payload.entries()) {
587
+ const qid = `c${i + 1}`
588
+ const instructions = candidateInstructions(c)
589
+ questions[qid] = { type: 'noul', instructions, criteria: candidateCriteria(c) }
590
+ metas.push({ qid, slug: c.slug, instructions })
591
+ }
592
+ const dual = await jevAskDual({
593
+ state,
594
+ questions,
595
+ config: jevConfigOf(this.service.cfg),
596
+ lane: 'slowlane-rerank',
597
+ fallback: false,
598
+ // fallback stays FALSE here: jevAsk writes the row before the caller
599
+ // knows the outcome, so a constant true would poison the rollback-rate
600
+ // stat. Fail-open occurrence = outcome !== 'ok' (this lane's fallback
601
+ // to the legacy LLM rerank is deterministic per ADR 0018).
602
+ })
603
+ // REVISED 09-27 (real-data evidence): the laya pace-maker is TELEMETRY
604
+ // ONLY. Its within-batch top-1 agreed with the primary 0/14 on real
605
+ // queries (and its negatives sit at 0.63-0.76 — absolute scores unusable),
606
+ // so the earlier degraded-ranking takeover is REVERTED: on primary
607
+ // failure the lane falls open to the legacy LLM rerank exactly as
608
+ // ADR 0018 says. The pace-maker still answers, so its comparison rows
609
+ // land FIRST here (pure telemetry), then the legacy rerank takes over.
610
+ if (!dual.ok) {
611
+ if (dual.layaResult?.ok) {
612
+ await logVerdicts(layaRerankRows(new Date().toISOString(), state, metas, dual.layaResult.answers))
613
+ }
614
+ return undefined
615
+ }
616
+ const scored: { qid: string; slug: string; instructions: string; probability: number; band: JevBand }[] = []
617
+ for (const m of metas) {
618
+ const probability = noulProbability(dual.answers[m.qid])
619
+ if (probability === undefined) return undefined // 坏答案 → fail-open (§1)
620
+ scored.push({ ...m, probability, band: jevBand(probability, 'rerank') })
621
+ }
622
+ const at = new Date().toISOString()
623
+ const recs: JevVerdictRecord[] = scored.map((m) => ({
624
+ at,
625
+ lane: 'slowlane-rerank' as const,
626
+ questionId: m.qid,
627
+ qtype: 'noul' as const,
628
+ ref: `slug:${m.slug}`,
629
+ digest: digestFor(state, m.qid, m.instructions),
630
+ probability: m.probability,
631
+ band: m.band,
632
+ agree: 'n/a' as const,
633
+ backend: dual.source,
634
+ degraded: false,
635
+ }))
636
+ // Pace-maker comparison rows (design §3.4): when the primary drove and
637
+ // laya also answered, log laya's verdicts alongside — the permanently
638
+ // running laya-vs-jev comparison. band='record': laya's absolute scores
639
+ // never claim adopt.
640
+ if (dual.layaResult?.ok) recs.push(...layaRerankRows(at, state, metas, dual.layaResult.answers))
641
+ await logVerdicts(recs)
642
+ return scored
643
+ .filter((m) => m.band === 'adopt')
644
+ .sort((a, b) => b.probability - a.probability)
645
+ .slice(0, 2)
646
+ .map((m) => ({ slug: m.slug, why: `jev 判定相关(p=${m.probability.toFixed(2)})` }))
647
+ }
648
+
328
649
  /**
329
650
  * Consumption point (the next spliced). Returns the pending exactly once
330
651
  * (消费即清) or an expiry reason; `turnId` is the CURRENT observer turn