@aiwayds/dsh-topics-memory 0.22.0 → 0.24.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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * meta/pipeline-events.jsonl — the pipeline-level anomaly ledger (spec §8.1,
3
+ * A2/A6 BL-1 revised: the "记 wtf 账" mechanism never existed, this IS the
4
+ * explicit ledger). One line JSON per event; always on, no config key
5
+ * (恒开无键 — observability is not a gray-release feature).
6
+ *
7
+ * Discipline = decisions.jsonl (src/jev/log.ts) verbatim: field-whitelist
8
+ * copy on write (extra caller fields cannot leak in, detail values must be
9
+ * scalar), 512KB auto-compaction keeping the most recent quarter, and write
10
+ * failures swallowed silently — a broken sink must never break the pipeline.
11
+ * Unlike jev/log this writer takes the bundle root explicitly (callers like
12
+ * the slow lane already hold it; tests point it at a tmp dir).
13
+ *
14
+ * @module pipeline-events
15
+ */
16
+
17
+ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
18
+ import { randomUUID } from 'node:crypto'
19
+ import { dirname, join } from 'node:path'
20
+ import { metaDir } from './paths.ts'
21
+
22
+ /** Closed vocabulary, spec §8.1 + W-C wave start set. `slow-lane-outcome` is
23
+ * the slow lane's per-run disposition row (quality.ts); the distill/link
24
+ * kinds are the A2/A6 producers' contract — they import this module. The
25
+ * two `seam-*` kinds are the B-line dual-write audit trail (卷二 §6.2:
26
+ * caller-identity audit — opts.actor — rides every seam call). */
27
+ export type PipelineEventKind = 'slow-lane-outcome' | 'distill-op-invalid' | 'distill-op-retry-exhausted' | 'link-ref-rejected' | 'seam-observations-fed' | 'seam-lesson-status'
28
+
29
+ /** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
30
+ export const PIPELINE_EVENTS_FILE = 'pipeline-events.jsonl'
31
+
32
+ export interface PipelineEvent {
33
+ at: string
34
+ kind: PipelineEventKind
35
+ /** Bounded scalar metadata only — never conversation or conclusion text. */
36
+ detail?: Record<string, string | number | boolean>
37
+ }
38
+
39
+ const COMPACT_LIMIT = 512 * 1024 // ~512KB cap, same as the decisions/ilog sidecars
40
+ /** Whitelist copy bounds: detail entries and their values stay metadata-sized. */
41
+ const DETAIL_MAX_ENTRIES = 16
42
+ const DETAIL_KEY_MAX_CHARS = 64
43
+ const DETAIL_VALUE_MAX_CHARS = 256
44
+
45
+ /** Runtime mirror of PipelineEventKind — the closed vocabulary is enforced
46
+ * at the write gate too (a JS caller cannot smuggle an unknown kind in). */
47
+ const KNOWN_KINDS: ReadonlySet<string> = new Set<string>([
48
+ 'slow-lane-outcome',
49
+ 'distill-op-invalid',
50
+ 'distill-op-retry-exhausted',
51
+ 'link-ref-rejected',
52
+ 'seam-observations-fed',
53
+ 'seam-lesson-status',
54
+ ])
55
+
56
+ export function pipelineEventsFile(bundleDir: string): string {
57
+ return join(metaDir(bundleDir), PIPELINE_EVENTS_FILE)
58
+ }
59
+
60
+ // Serialized like jev/log: single-record appends are line-atomic anyway, but
61
+ // the compaction rewrite must not interleave with an append.
62
+ let queue: Promise<unknown> = Promise.resolve()
63
+
64
+ function enqueue<T>(op: () => Promise<T>): Promise<T> {
65
+ const run = queue.then(op, op)
66
+ queue = run.catch(() => undefined)
67
+ return run
68
+ }
69
+
70
+ /** All write failures die HERE — never upstream. */
71
+ async function appendJsonl(file: string, lines: readonly string[]): Promise<void> {
72
+ await enqueue(async () => {
73
+ try {
74
+ await mkdir(dirname(file), { recursive: true })
75
+ await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8')
76
+ await compactIfNeeded(file)
77
+ } catch {
78
+ // fail-open: a broken ledger must never break the pipeline (§8.1)
79
+ }
80
+ })
81
+ }
82
+
83
+ // jev/log compactIfNeeded pattern: ~512KB cap; keep the most recent quarter
84
+ // when exceeded (headroom for the next window).
85
+ async function compactIfNeeded(file: string): Promise<void> {
86
+ let raw: string
87
+ try {
88
+ raw = await readFile(file, 'utf8')
89
+ } catch {
90
+ return
91
+ }
92
+ if (raw.length <= COMPACT_LIMIT) return
93
+ const lines = raw.split('\n').filter((l) => l.trim() !== '')
94
+ const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)))
95
+ const tmp = `${file}.tmp-${randomUUID()}`
96
+ try {
97
+ await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8')
98
+ await rename(tmp, file)
99
+ } catch {
100
+ // swallow — the next append retries the compaction
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Copy one event through the field whitelist onto the ledger. Only `at`,
106
+ * `kind` and a scalar-valued `detail` map can pass; non-scalar detail values
107
+ * (objects/arrays/null/NaN), surplus entries and over-long keys/values are
108
+ * dropped here so no unbounded content can ride in. Write failures are
109
+ * swallowed downstream — this never rejects and never throws.
110
+ */
111
+ export async function recordPipelineEvent(bundleDir: string, event: PipelineEvent): Promise<void> {
112
+ if (bundleDir === '' || typeof bundleDir !== 'string') return
113
+ if (event === null || typeof event !== 'object') return
114
+ if (typeof event.kind !== 'string' || !KNOWN_KINDS.has(event.kind)) return
115
+ const detail: Record<string, string | number | boolean> = {}
116
+ if (event.detail !== undefined && typeof event.detail === 'object') {
117
+ for (const [key, value] of Object.entries(event.detail)) {
118
+ if (Object.keys(detail).length >= DETAIL_MAX_ENTRIES) break
119
+ const t = typeof value
120
+ if (t === 'string') {
121
+ if ((value as string) !== '') detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = (value as string).slice(0, DETAIL_VALUE_MAX_CHARS)
122
+ } else if (t === 'number') {
123
+ if (Number.isFinite(value)) detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value
124
+ } else if (t === 'boolean') {
125
+ detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value
126
+ }
127
+ // objects/arrays/null/undefined never pass the whitelist
128
+ }
129
+ }
130
+ const at = typeof event.at === 'string' && event.at !== '' ? event.at : new Date().toISOString()
131
+ const row =
132
+ Object.keys(detail).length === 0
133
+ ? { at, kind: event.kind }
134
+ : { at, kind: event.kind, detail }
135
+ await appendJsonl(pipelineEventsFile(bundleDir), [JSON.stringify(row)])
136
+ }
package/src/quality.ts CHANGED
@@ -15,8 +15,11 @@
15
15
  * Picks rest in a per-session pending slot and inject at the NEXT spliced
16
16
  * (steer message), then the slot is gone (消费即清). Hard bounds: per-call
17
17
  * 20s, pipeline 45s, pending TTL 10min, turn-lag ≤2. Every failure is
18
- * contained and silent — I3 (可丢弃性): async products may be missing, never
19
- * blocking. The lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
18
+ * contained — async products may be missing, never blocking (I3 可丢弃性) —
19
+ * but no longer SILENT (A4 决议 3): each run lands a `slow-lane-outcome`
20
+ * disposition (SlowOutcome) on the session and in the pipeline-events
21
+ * ledger, so 「没跑」and 「跑了全败」are distinguishable in the log. The
22
+ * lexical re-gate at consumption is SHADOW-ONLY (v4 B3): it
20
23
  * records verdicts into ilog and never blocks a pick.
21
24
  *
22
25
  * @module quality
@@ -26,7 +29,7 @@ import type { TopicsService } from './service.ts'
26
29
  import type { ModelCaller } from './distill.ts'
27
30
  import { searchTopics } from './retrieval.ts'
28
31
  import type { RingEntry } from './observer.ts'
29
- import type { QueryBuildShape } from './ilog.ts'
32
+ import type { QueryBuildShape, SlowOutcome } from './ilog.ts'
30
33
  import type { TopicsConfigValue } from './config.ts'
31
34
  import { jevAskDual } from './jev/dual.ts'
32
35
  import type { JevQuestion } from './jev/client.ts'
@@ -35,6 +38,11 @@ import { band as jevBand } from './jev/thresholds.ts'
35
38
  import type { JevBand } from './jev/thresholds.ts'
36
39
  import { digestFor, logVerdicts } from './jev/log.ts'
37
40
  import type { JevVerdictRecord } from './jev/log.ts'
41
+ import { recordPipelineEvent } from './pipeline-events.ts'
42
+
43
+ /** Re-exported for the ilog writers (spec §8.2): the service layer takes a
44
+ * pending's `outcome` (or lastOutcomeOf) as the record's `slowOutcome`. */
45
+ export type { SlowOutcome }
38
46
 
39
47
  export const PENDING_TTL_MS = 10 * 60_000
40
48
  /** Hard drift bound: a pending older than this many turn-ends expires. */
@@ -197,6 +205,13 @@ export interface PendingInjection {
197
205
  queryBuild: QueryBuildShape
198
206
  model: string
199
207
  ms: number
208
+ /**
209
+ * The producing run's disposition (A4 决议 3, spec §8.2) — always
210
+ * 'delivered' here (a pending only exists after a settled run). The
211
+ * service layer copies it into the ilog record's `slowOutcome` at
212
+ * consumption; expiry surfaces as `expired` via ConsumeResult instead.
213
+ */
214
+ outcome: SlowOutcome
200
215
  }
201
216
 
202
217
  export type ConsumeResult = { pending: PendingInjection } | { expired: 'ttl' | 'turn-lag' } | undefined
@@ -270,11 +285,32 @@ export function parseJsonObject(raw: string): Record<string, unknown> {
270
285
  throw new Error('unbalanced JSON in output')
271
286
  }
272
287
 
288
+ /** The closed outcome for an error that escaped the produce body: our own
289
+ * withTimeout rejections and the pipeline-deadline abort are `timeout`,
290
+ * everything else (model error, bad transport, unexpected throw) is
291
+ * `llm-error`. Raw error text never enters the ledger (jev/log redaction
292
+ * envelope — the outcome class is the observable, not the message). */
293
+ function classifySlowError(e: unknown, controller: AbortController): SlowOutcome {
294
+ if (e !== null && typeof e === 'object') {
295
+ const msg = (e as { message?: unknown }).message
296
+ if (typeof msg === 'string' && msg.startsWith('slow-lane call timeout')) return 'timeout'
297
+ }
298
+ if (controller.signal.aborted) return 'timeout'
299
+ return 'llm-error'
300
+ }
301
+
273
302
  export class SlowLane {
274
303
  private pending = new Map<string, PendingInjection>()
275
304
  private inFlight = new Map<string, Promise<void>>()
276
305
  /** Same-session shadow serialization — a second trigger never overlaps. */
277
306
  private shadowInFlight = new Map<string, Promise<void>>()
307
+ /**
308
+ * The session's most recent slow-lane disposition (A4 决议 3): what a
309
+ * spliced with NO pending reports as its `slowOutcome` — without it a
310
+ * consumed-undefined round cannot say whether the lane never ran or ran
311
+ * and failed. One slot per session, overwritten by every trigger.
312
+ */
313
+ private outcomeBySession = new Map<string, SlowOutcome>()
278
314
  private readonly service: TopicsService
279
315
  private readonly caller: ModelCaller | undefined
280
316
  /**
@@ -302,10 +338,40 @@ export class SlowLane {
302
338
  return this.inFlight.has(sessionId)
303
339
  }
304
340
 
341
+ /**
342
+ * The session's most recent slow-lane disposition (A4 §3.2): a round whose
343
+ * consume() came up empty writes THIS as its ilog `slowOutcome` (spec §8.2
344
+ * — 「spliced 收到但未 produce 也留痕」). Undefined when the lane never
345
+ * triggered for the session.
346
+ */
347
+ lastOutcomeOf(sessionId: string): SlowOutcome | undefined {
348
+ return this.outcomeBySession.get(sessionId)
349
+ }
350
+
351
+ /**
352
+ * One disposition, two sinks (A4 决议 3): the per-session slot above and a
353
+ * `slow-lane-outcome` row in the pipeline-events ledger. Metadata only
354
+ * (outcome/reason/session/turn/timing — never ring or query text). Never
355
+ * throws, never rejects: traceability must not become a failure source.
356
+ */
357
+ private traceOutcome(sessionId: string, turnId: number, outcome: SlowOutcome, reason: string, extra: Record<string, string | number | boolean> = {}): void {
358
+ try {
359
+ this.outcomeBySession.set(sessionId, outcome)
360
+ void recordPipelineEvent(this.service.store.root, {
361
+ at: new Date().toISOString(),
362
+ kind: 'slow-lane-outcome',
363
+ detail: { outcome, reason, sessionId, turnId, ...extra },
364
+ })
365
+ } catch {
366
+ // contained — the ledger is best-effort by design
367
+ }
368
+ }
369
+
305
370
  /** Drop the pending slot (session teardown, restore boundary). */
306
371
  clear(sessionId: string): void {
307
372
  this.pending.delete(sessionId)
308
373
  this.shadowInFlight.delete(sessionId)
374
+ this.outcomeBySession.delete(sessionId)
309
375
  }
310
376
 
311
377
  /**
@@ -400,17 +466,36 @@ export class SlowLane {
400
466
 
401
467
  /**
402
468
  * Produce a pending slot for the session's next steer message. Fire-and-
403
- * forget; every guard failure is silent. Policy gates live here (config,
404
- * sampling, in-flight); host gates (delegation, distill yield) live with
405
- * the caller, which owns those services.
469
+ * forget; every guard failure is contained (never breaks the event
470
+ * handler) and QUALIFIED (A4 决议 3): a skipped trigger lands a `no-run`
471
+ * outcome naming the guard, so a later spliced can report why there was
472
+ * nothing to consume. Policy gates live here (config, sampling, in-flight);
473
+ * host gates (delegation, distill yield) live with the caller, which owns
474
+ * those services.
406
475
  */
407
476
  dispatch(sessionId: string, input: DispatchInput): void {
408
477
  try {
409
478
  const cfg = this.service.cfg
410
- if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') return
411
- if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) return
412
- if (this.caller === undefined || this.inFlight.has(sessionId)) return
413
- if (input.ring.length === 0) return
479
+ if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') {
480
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'lane-off')
481
+ return
482
+ }
483
+ if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) {
484
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'cadence')
485
+ return
486
+ }
487
+ if (this.caller === undefined) {
488
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'no-caller')
489
+ return
490
+ }
491
+ if (this.inFlight.has(sessionId)) {
492
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'in-flight')
493
+ return
494
+ }
495
+ if (input.ring.length === 0) {
496
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'empty-ring')
497
+ return
498
+ }
414
499
  const run = this.produce(sessionId, input).finally(() => {
415
500
  this.inFlight.delete(sessionId)
416
501
  this.onSettle(sessionId)
@@ -424,11 +509,15 @@ export class SlowLane {
424
509
 
425
510
  private async produce(sessionId: string, input: DispatchInput): Promise<void> {
426
511
  const caller = this.caller
427
- if (caller === undefined) return
512
+ if (caller === undefined) return // unreachable — dispatch gated it, already traced
428
513
  const started = Date.now()
429
514
  const controller = new AbortController()
430
515
  const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS)
431
516
  deadline.unref?.()
517
+ // A4 决议 3: every exit qualifies — outcome on the session + the ledger.
518
+ const trace = (outcome: SlowOutcome, reason: string): void => {
519
+ this.traceOutcome(sessionId, input.turnId, outcome, reason, { ms: Date.now() - started })
520
+ }
432
521
  try {
433
522
  const cfg = this.service.cfg
434
523
  const ringText = input.ring
@@ -453,9 +542,13 @@ export class SlowLane {
453
542
  try {
454
543
  built = parseJsonObject(buildRaw)
455
544
  } catch {
456
- return // unparseable build → no pending, nothing logged, next turn retries
545
+ trace('llm-error', 'build-bad-json') // unparseable build → no pending, next turn retries
546
+ return
547
+ }
548
+ if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') {
549
+ trace('no-claim', 'build-no-need') // the query builder claimed no background need
550
+ return
457
551
  }
458
- if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') return
459
552
  const query = built.query.trim()
460
553
  const stripped = Array.isArray(built.ignore)
461
554
  ? built.ignore.filter((s): s is string => typeof s === 'string' && s.trim() !== '').slice(0, 5)
@@ -463,7 +556,10 @@ export class SlowLane {
463
556
  const queryBuild: QueryBuildShape = { rawChars: ringText.length, keptChars: query.length, stripped }
464
557
 
465
558
  const roster = await this.service.roster().catch(() => [])
466
- if (roster.length === 0) return
559
+ if (roster.length === 0) {
560
+ trace('no-candidates', 'roster-empty')
561
+ return
562
+ }
467
563
  // Recall-oriented band: gate OFF (the rerank IS this lane's gate);
468
564
  // candidates = threshold passers + the near-miss band beneath them.
469
565
  const band = searchTopics(query, roster, {
@@ -478,7 +574,10 @@ export class SlowLane {
478
574
  const candidates = [...band.hits, ...band.nearMisses]
479
575
  .filter((c) => !exclude?.has(c.slug))
480
576
  .slice(0, CANDIDATE_LIMIT)
481
- if (candidates.length === 0) return
577
+ if (candidates.length === 0) {
578
+ trace('no-candidates', 'band-empty')
579
+ return
580
+ }
482
581
  const bySlug = new Map(roster.map((r) => [r.slug, r]))
483
582
  const payload = candidates.flatMap((c) => {
484
583
  const meta = bySlug.get(c.slug)
@@ -494,7 +593,10 @@ export class SlowLane {
494
593
  },
495
594
  ]
496
595
  })
497
- if (payload.length === 0) return
596
+ if (payload.length === 0) {
597
+ trace('no-candidates', 'payload-empty')
598
+ return
599
+ }
498
600
  // ---- jev batch rerank (design §2 row 1) — replaces the LLM rerank ----
499
601
  // One batched noul request over the whole band; the calibrated bands
500
602
  // decide the picks. A settled jev round is FINAL here (picks or none) —
@@ -503,9 +605,15 @@ export class SlowLane {
503
605
  if (this.service.cfg.jevEnabled === true) {
504
606
  const jevItems = await this.jevRerank(query, payload)
505
607
  if (jevItems !== undefined) {
506
- if (jevItems.length > 0) this.settlePending(sessionId, input, jevItems, queryBuild, started)
608
+ if (jevItems.length > 0) {
609
+ this.settlePending(sessionId, input, jevItems, queryBuild, started)
610
+ trace('delivered', 'jev-picks')
611
+ } else {
612
+ trace('no-candidates', 'jev-zero-picks')
613
+ }
507
614
  return
508
615
  }
616
+ // jev failure → the legacy rerank below owns the final trace.
509
617
  }
510
618
  const rerankRaw = await withTimeout(
511
619
  caller({
@@ -528,6 +636,7 @@ export class SlowLane {
528
636
  try {
529
637
  reranked = parseJsonObject(rerankRaw)
530
638
  } catch {
639
+ trace('llm-error', 'rerank-bad-json')
531
640
  return
532
641
  }
533
642
  const legal = new Set(payload.map((p) => p.slug))
@@ -545,16 +654,28 @@ export class SlowLane {
545
654
  items.push({ slug, why: why.trim().slice(0, 200) })
546
655
  if (items.length >= 2) break
547
656
  }
548
- if (items.length === 0) return
657
+ if (items.length === 0) {
658
+ trace('no-candidates', 'rerank-zero-picks') // ran fine, gate picked nothing worth injecting
659
+ return
660
+ }
549
661
  this.settlePending(sessionId, input, items, queryBuild, started)
550
- } catch {
551
- // timeouts, aborts, model errors — I3: the async product is simply absent
662
+ trace('delivered', 'rerank-picks')
663
+ } catch (e) {
664
+ // RED LINE, A4 决议 3 (revising the old silent catch): the fast lane's
665
+ // fail-silently principle only covers the SYNCHRONOUS seam — this is an
666
+ // async lane, so the failure must be observable while still never
667
+ // rethrowing at the caller (I3 可丢弃性: the async product is absent,
668
+ // but its absence is now qualified, not mute).
669
+ trace(classifySlowError(e, controller), 'call-failed')
552
670
  } finally {
553
671
  clearTimeout(deadline)
554
672
  }
555
673
  }
556
674
 
557
- /** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
675
+ /** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
676
+ * The pending carries its producing run's disposition (A4 §3.2) so the
677
+ * ilog writer can copy it verbatim — a pending only exists when the run
678
+ * delivered, hence the constant. */
558
679
  private settlePending(sessionId: string, input: DispatchInput, items: { slug: string; why: string }[], queryBuild: QueryBuildShape, started: number): void {
559
680
  const route = this.service.cfg
560
681
  this.pending.set(sessionId, {
@@ -564,6 +685,7 @@ export class SlowLane {
564
685
  queryBuild,
565
686
  model: `${route.distillProvider}/${route.distillModel}`,
566
687
  ms: Date.now() - started,
688
+ outcome: 'delivered',
567
689
  })
568
690
  }
569
691
 
@@ -657,10 +779,12 @@ export class SlowLane {
657
779
  const produced = Date.parse(pending.computedAt)
658
780
  if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
659
781
  this.pending.delete(sessionId)
782
+ this.traceOutcome(sessionId, turnId, 'expired', 'ttl', { ageMs: Date.now() - produced })
660
783
  return { expired: 'ttl' }
661
784
  }
662
785
  if (turnId - pending.turnId > TURN_LAG_LIMIT) {
663
786
  this.pending.delete(sessionId)
787
+ this.traceOutcome(sessionId, turnId, 'expired', 'turn-lag', { producedTurnId: pending.turnId })
664
788
  return { expired: 'turn-lag' }
665
789
  }
666
790
  this.pending.delete(sessionId)