@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,123 @@
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
+ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { dirname, join } from 'node:path';
19
+ import { metaDir } from "./paths.js";
20
+ /** File NAME inside the bundle's meta dir (path via pipelineEventsFile). */
21
+ export const PIPELINE_EVENTS_FILE = 'pipeline-events.jsonl';
22
+ const COMPACT_LIMIT = 512 * 1024; // ~512KB cap, same as the decisions/ilog sidecars
23
+ /** Whitelist copy bounds: detail entries and their values stay metadata-sized. */
24
+ const DETAIL_MAX_ENTRIES = 16;
25
+ const DETAIL_KEY_MAX_CHARS = 64;
26
+ const DETAIL_VALUE_MAX_CHARS = 256;
27
+ /** Runtime mirror of PipelineEventKind — the closed vocabulary is enforced
28
+ * at the write gate too (a JS caller cannot smuggle an unknown kind in). */
29
+ const KNOWN_KINDS = new Set([
30
+ 'slow-lane-outcome',
31
+ 'distill-op-invalid',
32
+ 'distill-op-retry-exhausted',
33
+ 'link-ref-rejected',
34
+ 'seam-observations-fed',
35
+ 'seam-lesson-status',
36
+ ]);
37
+ export function pipelineEventsFile(bundleDir) {
38
+ return join(metaDir(bundleDir), PIPELINE_EVENTS_FILE);
39
+ }
40
+ // Serialized like jev/log: single-record appends are line-atomic anyway, but
41
+ // the compaction rewrite must not interleave with an append.
42
+ let queue = Promise.resolve();
43
+ function enqueue(op) {
44
+ const run = queue.then(op, op);
45
+ queue = run.catch(() => undefined);
46
+ return run;
47
+ }
48
+ /** All write failures die HERE — never upstream. */
49
+ async function appendJsonl(file, lines) {
50
+ await enqueue(async () => {
51
+ try {
52
+ await mkdir(dirname(file), { recursive: true });
53
+ await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8');
54
+ await compactIfNeeded(file);
55
+ }
56
+ catch {
57
+ // fail-open: a broken ledger must never break the pipeline (§8.1)
58
+ }
59
+ });
60
+ }
61
+ // jev/log compactIfNeeded pattern: ~512KB cap; keep the most recent quarter
62
+ // when exceeded (headroom for the next window).
63
+ async function compactIfNeeded(file) {
64
+ let raw;
65
+ try {
66
+ raw = await readFile(file, 'utf8');
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ if (raw.length <= COMPACT_LIMIT)
72
+ return;
73
+ const lines = raw.split('\n').filter((l) => l.trim() !== '');
74
+ const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)));
75
+ const tmp = `${file}.tmp-${randomUUID()}`;
76
+ try {
77
+ await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8');
78
+ await rename(tmp, file);
79
+ }
80
+ catch {
81
+ // swallow — the next append retries the compaction
82
+ }
83
+ }
84
+ /**
85
+ * Copy one event through the field whitelist onto the ledger. Only `at`,
86
+ * `kind` and a scalar-valued `detail` map can pass; non-scalar detail values
87
+ * (objects/arrays/null/NaN), surplus entries and over-long keys/values are
88
+ * dropped here so no unbounded content can ride in. Write failures are
89
+ * swallowed downstream — this never rejects and never throws.
90
+ */
91
+ export async function recordPipelineEvent(bundleDir, event) {
92
+ if (bundleDir === '' || typeof bundleDir !== 'string')
93
+ return;
94
+ if (event === null || typeof event !== 'object')
95
+ return;
96
+ if (typeof event.kind !== 'string' || !KNOWN_KINDS.has(event.kind))
97
+ return;
98
+ const detail = {};
99
+ if (event.detail !== undefined && typeof event.detail === 'object') {
100
+ for (const [key, value] of Object.entries(event.detail)) {
101
+ if (Object.keys(detail).length >= DETAIL_MAX_ENTRIES)
102
+ break;
103
+ const t = typeof value;
104
+ if (t === 'string') {
105
+ if (value !== '')
106
+ detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value.slice(0, DETAIL_VALUE_MAX_CHARS);
107
+ }
108
+ else if (t === 'number') {
109
+ if (Number.isFinite(value))
110
+ detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value;
111
+ }
112
+ else if (t === 'boolean') {
113
+ detail[key.slice(0, DETAIL_KEY_MAX_CHARS)] = value;
114
+ }
115
+ // objects/arrays/null/undefined never pass the whitelist
116
+ }
117
+ }
118
+ const at = typeof event.at === 'string' && event.at !== '' ? event.at : new Date().toISOString();
119
+ const row = Object.keys(detail).length === 0
120
+ ? { at, kind: event.kind }
121
+ : { at, kind: event.kind, detail };
122
+ await appendJsonl(pipelineEventsFile(bundleDir), [JSON.stringify(row)]);
123
+ }
package/lib/quality.d.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
@@ -24,7 +27,10 @@
24
27
  import type { TopicsService } from './service.ts';
25
28
  import type { ModelCaller } from './distill.ts';
26
29
  import type { RingEntry } from './observer.ts';
27
- import type { QueryBuildShape } from './ilog.ts';
30
+ import type { QueryBuildShape, SlowOutcome } from './ilog.ts';
31
+ /** Re-exported for the ilog writers (spec §8.2): the service layer takes a
32
+ * pending's `outcome` (or lastOutcomeOf) as the record's `slowOutcome`. */
33
+ export type { SlowOutcome };
28
34
  export declare const PENDING_TTL_MS: number;
29
35
  /** Hard drift bound: a pending older than this many turn-ends expires. */
30
36
  export declare const TURN_LAG_LIMIT = 2;
@@ -69,6 +75,13 @@ export interface PendingInjection {
69
75
  queryBuild: QueryBuildShape;
70
76
  model: string;
71
77
  ms: number;
78
+ /**
79
+ * The producing run's disposition (A4 决议 3, spec §8.2) — always
80
+ * 'delivered' here (a pending only exists after a settled run). The
81
+ * service layer copies it into the ilog record's `slowOutcome` at
82
+ * consumption; expiry surfaces as `expired` via ConsumeResult instead.
83
+ */
84
+ outcome: SlowOutcome;
72
85
  }
73
86
  export type ConsumeResult = {
74
87
  pending: PendingInjection;
@@ -94,6 +107,13 @@ export declare class SlowLane {
94
107
  private inFlight;
95
108
  /** Same-session shadow serialization — a second trigger never overlaps. */
96
109
  private shadowInFlight;
110
+ /**
111
+ * The session's most recent slow-lane disposition (A4 决议 3): what a
112
+ * spliced with NO pending reports as its `slowOutcome` — without it a
113
+ * consumed-undefined round cannot say whether the lane never ran or ran
114
+ * and failed. One slot per session, overwritten by every trigger.
115
+ */
116
+ private outcomeBySession;
97
117
  private readonly service;
98
118
  private readonly caller;
99
119
  /**
@@ -109,6 +129,20 @@ export declare class SlowLane {
109
129
  hasPending(sessionId: string): boolean;
110
130
  /** True while the session's produce pipeline is still running (llm-capture guard). */
111
131
  hasInFlight(sessionId: string): boolean;
132
+ /**
133
+ * The session's most recent slow-lane disposition (A4 §3.2): a round whose
134
+ * consume() came up empty writes THIS as its ilog `slowOutcome` (spec §8.2
135
+ * — 「spliced 收到但未 produce 也留痕」). Undefined when the lane never
136
+ * triggered for the session.
137
+ */
138
+ lastOutcomeOf(sessionId: string): SlowOutcome | undefined;
139
+ /**
140
+ * One disposition, two sinks (A4 决议 3): the per-session slot above and a
141
+ * `slow-lane-outcome` row in the pipeline-events ledger. Metadata only
142
+ * (outcome/reason/session/turn/timing — never ring or query text). Never
143
+ * throws, never rejects: traceability must not become a failure source.
144
+ */
145
+ private traceOutcome;
112
146
  /** Drop the pending slot (session teardown, restore boundary). */
113
147
  clear(sessionId: string): void;
114
148
  /**
@@ -126,13 +160,19 @@ export declare class SlowLane {
126
160
  private fastGateShadow;
127
161
  /**
128
162
  * Produce a pending slot for the session's next steer message. Fire-and-
129
- * forget; every guard failure is silent. Policy gates live here (config,
130
- * sampling, in-flight); host gates (delegation, distill yield) live with
131
- * the caller, which owns those services.
163
+ * forget; every guard failure is contained (never breaks the event
164
+ * handler) and QUALIFIED (A4 决议 3): a skipped trigger lands a `no-run`
165
+ * outcome naming the guard, so a later spliced can report why there was
166
+ * nothing to consume. Policy gates live here (config, sampling, in-flight);
167
+ * host gates (delegation, distill yield) live with the caller, which owns
168
+ * those services.
132
169
  */
133
170
  dispatch(sessionId: string, input: DispatchInput): void;
134
171
  private produce;
135
- /** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
172
+ /** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
173
+ * The pending carries its producing run's disposition (A4 §3.2) so the
174
+ * ilog writer can copy it verbatim — a pending only exists when the run
175
+ * delivered, hence the constant. */
136
176
  private settlePending;
137
177
  /**
138
178
  * The jev rerank (design §2 row 1): one batched noul request over the whole
package/lib/quality.js 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
@@ -25,6 +28,7 @@ import { searchTopics } from "./retrieval.js";
25
28
  import { jevAskDual } from "./jev/dual.js";
26
29
  import { band as jevBand } from "./jev/thresholds.js";
27
30
  import { digestFor, logVerdicts } from "./jev/log.js";
31
+ import { recordPipelineEvent } from "./pipeline-events.js";
28
32
  export const PENDING_TTL_MS = 10 * 60_000;
29
33
  /** Hard drift bound: a pending older than this many turn-ends expires. */
30
34
  export const TURN_LAG_LIMIT = 2;
@@ -184,11 +188,33 @@ export function parseJsonObject(raw) {
184
188
  }
185
189
  throw new Error('unbalanced JSON in output');
186
190
  }
191
+ /** The closed outcome for an error that escaped the produce body: our own
192
+ * withTimeout rejections and the pipeline-deadline abort are `timeout`,
193
+ * everything else (model error, bad transport, unexpected throw) is
194
+ * `llm-error`. Raw error text never enters the ledger (jev/log redaction
195
+ * envelope — the outcome class is the observable, not the message). */
196
+ function classifySlowError(e, controller) {
197
+ if (e !== null && typeof e === 'object') {
198
+ const msg = e.message;
199
+ if (typeof msg === 'string' && msg.startsWith('slow-lane call timeout'))
200
+ return 'timeout';
201
+ }
202
+ if (controller.signal.aborted)
203
+ return 'timeout';
204
+ return 'llm-error';
205
+ }
187
206
  export class SlowLane {
188
207
  pending = new Map();
189
208
  inFlight = new Map();
190
209
  /** Same-session shadow serialization — a second trigger never overlaps. */
191
210
  shadowInFlight = new Map();
211
+ /**
212
+ * The session's most recent slow-lane disposition (A4 决议 3): what a
213
+ * spliced with NO pending reports as its `slowOutcome` — without it a
214
+ * consumed-undefined round cannot say whether the lane never ran or ran
215
+ * and failed. One slot per session, overwritten by every trigger.
216
+ */
217
+ outcomeBySession = new Map();
192
218
  service;
193
219
  caller;
194
220
  /**
@@ -212,10 +238,39 @@ export class SlowLane {
212
238
  hasInFlight(sessionId) {
213
239
  return this.inFlight.has(sessionId);
214
240
  }
241
+ /**
242
+ * The session's most recent slow-lane disposition (A4 §3.2): a round whose
243
+ * consume() came up empty writes THIS as its ilog `slowOutcome` (spec §8.2
244
+ * — 「spliced 收到但未 produce 也留痕」). Undefined when the lane never
245
+ * triggered for the session.
246
+ */
247
+ lastOutcomeOf(sessionId) {
248
+ return this.outcomeBySession.get(sessionId);
249
+ }
250
+ /**
251
+ * One disposition, two sinks (A4 决议 3): the per-session slot above and a
252
+ * `slow-lane-outcome` row in the pipeline-events ledger. Metadata only
253
+ * (outcome/reason/session/turn/timing — never ring or query text). Never
254
+ * throws, never rejects: traceability must not become a failure source.
255
+ */
256
+ traceOutcome(sessionId, turnId, outcome, reason, extra = {}) {
257
+ try {
258
+ this.outcomeBySession.set(sessionId, outcome);
259
+ void recordPipelineEvent(this.service.store.root, {
260
+ at: new Date().toISOString(),
261
+ kind: 'slow-lane-outcome',
262
+ detail: { outcome, reason, sessionId, turnId, ...extra },
263
+ });
264
+ }
265
+ catch {
266
+ // contained — the ledger is best-effort by design
267
+ }
268
+ }
215
269
  /** Drop the pending slot (session teardown, restore boundary). */
216
270
  clear(sessionId) {
217
271
  this.pending.delete(sessionId);
218
272
  this.shadowInFlight.delete(sessionId);
273
+ this.outcomeBySession.delete(sessionId);
219
274
  }
220
275
  /**
221
276
  * Fastgate shadow (design §2 row 3): fire-and-forget on the SAME sampled
@@ -318,21 +373,36 @@ export class SlowLane {
318
373
  }
319
374
  /**
320
375
  * Produce a pending slot for the session's next steer message. Fire-and-
321
- * forget; every guard failure is silent. Policy gates live here (config,
322
- * sampling, in-flight); host gates (delegation, distill yield) live with
323
- * the caller, which owns those services.
376
+ * forget; every guard failure is contained (never breaks the event
377
+ * handler) and QUALIFIED (A4 决议 3): a skipped trigger lands a `no-run`
378
+ * outcome naming the guard, so a later spliced can report why there was
379
+ * nothing to consume. Policy gates live here (config, sampling, in-flight);
380
+ * host gates (delegation, distill yield) live with the caller, which owns
381
+ * those services.
324
382
  */
325
383
  dispatch(sessionId, input) {
326
384
  try {
327
385
  const cfg = this.service.cfg;
328
- if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always')
386
+ if (cfg.qualityLane !== 'sampled' && cfg.qualityLane !== 'always') {
387
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'lane-off');
329
388
  return;
330
- if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0)
389
+ }
390
+ if (cfg.qualityLane === 'sampled' && input.turnId % SAMPLED_EVERY !== 0) {
391
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'cadence');
392
+ return;
393
+ }
394
+ if (this.caller === undefined) {
395
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'no-caller');
331
396
  return;
332
- if (this.caller === undefined || this.inFlight.has(sessionId))
397
+ }
398
+ if (this.inFlight.has(sessionId)) {
399
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'in-flight');
333
400
  return;
334
- if (input.ring.length === 0)
401
+ }
402
+ if (input.ring.length === 0) {
403
+ this.traceOutcome(sessionId, input.turnId, 'no-run', 'empty-ring');
335
404
  return;
405
+ }
336
406
  const run = this.produce(sessionId, input).finally(() => {
337
407
  this.inFlight.delete(sessionId);
338
408
  this.onSettle(sessionId);
@@ -347,11 +417,15 @@ export class SlowLane {
347
417
  async produce(sessionId, input) {
348
418
  const caller = this.caller;
349
419
  if (caller === undefined)
350
- return;
420
+ return; // unreachable — dispatch gated it, already traced
351
421
  const started = Date.now();
352
422
  const controller = new AbortController();
353
423
  const deadline = setTimeout(() => controller.abort(), PIPELINE_TIMEOUT_MS);
354
424
  deadline.unref?.();
425
+ // A4 决议 3: every exit qualifies — outcome on the session + the ledger.
426
+ const trace = (outcome, reason) => {
427
+ this.traceOutcome(sessionId, input.turnId, outcome, reason, { ms: Date.now() - started });
428
+ };
355
429
  try {
356
430
  const cfg = this.service.cfg;
357
431
  const ringText = input.ring
@@ -373,18 +447,23 @@ export class SlowLane {
373
447
  built = parseJsonObject(buildRaw);
374
448
  }
375
449
  catch {
376
- return; // unparseable build → no pending, nothing logged, next turn retries
450
+ trace('llm-error', 'build-bad-json'); // unparseable build → no pending, next turn retries
451
+ return;
377
452
  }
378
- if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '')
453
+ if (built.needs !== true || typeof built.query !== 'string' || built.query.trim() === '') {
454
+ trace('no-claim', 'build-no-need'); // the query builder claimed no background need
379
455
  return;
456
+ }
380
457
  const query = built.query.trim();
381
458
  const stripped = Array.isArray(built.ignore)
382
459
  ? built.ignore.filter((s) => typeof s === 'string' && s.trim() !== '').slice(0, 5)
383
460
  : [];
384
461
  const queryBuild = { rawChars: ringText.length, keptChars: query.length, stripped };
385
462
  const roster = await this.service.roster().catch(() => []);
386
- if (roster.length === 0)
463
+ if (roster.length === 0) {
464
+ trace('no-candidates', 'roster-empty');
387
465
  return;
466
+ }
388
467
  // Recall-oriented band: gate OFF (the rerank IS this lane's gate);
389
468
  // candidates = threshold passers + the near-miss band beneath them.
390
469
  const band = searchTopics(query, roster, {
@@ -399,8 +478,10 @@ export class SlowLane {
399
478
  const candidates = [...band.hits, ...band.nearMisses]
400
479
  .filter((c) => !exclude?.has(c.slug))
401
480
  .slice(0, CANDIDATE_LIMIT);
402
- if (candidates.length === 0)
481
+ if (candidates.length === 0) {
482
+ trace('no-candidates', 'band-empty');
403
483
  return;
484
+ }
404
485
  const bySlug = new Map(roster.map((r) => [r.slug, r]));
405
486
  const payload = candidates.flatMap((c) => {
406
487
  const meta = bySlug.get(c.slug);
@@ -417,8 +498,10 @@ export class SlowLane {
417
498
  },
418
499
  ];
419
500
  });
420
- if (payload.length === 0)
501
+ if (payload.length === 0) {
502
+ trace('no-candidates', 'payload-empty');
421
503
  return;
504
+ }
422
505
  // ---- jev batch rerank (design §2 row 1) — replaces the LLM rerank ----
423
506
  // One batched noul request over the whole band; the calibrated bands
424
507
  // decide the picks. A settled jev round is FINAL here (picks or none) —
@@ -427,10 +510,16 @@ export class SlowLane {
427
510
  if (this.service.cfg.jevEnabled === true) {
428
511
  const jevItems = await this.jevRerank(query, payload);
429
512
  if (jevItems !== undefined) {
430
- if (jevItems.length > 0)
513
+ if (jevItems.length > 0) {
431
514
  this.settlePending(sessionId, input, jevItems, queryBuild, started);
515
+ trace('delivered', 'jev-picks');
516
+ }
517
+ else {
518
+ trace('no-candidates', 'jev-zero-picks');
519
+ }
432
520
  return;
433
521
  }
522
+ // jev failure → the legacy rerank below owns the final trace.
434
523
  }
435
524
  const rerankRaw = await withTimeout(caller({
436
525
  system: RERANK_PROMPT,
@@ -450,6 +539,7 @@ export class SlowLane {
450
539
  reranked = parseJsonObject(rerankRaw);
451
540
  }
452
541
  catch {
542
+ trace('llm-error', 'rerank-bad-json');
453
543
  return;
454
544
  }
455
545
  const legal = new Set(payload.map((p) => p.slug));
@@ -472,18 +562,29 @@ export class SlowLane {
472
562
  if (items.length >= 2)
473
563
  break;
474
564
  }
475
- if (items.length === 0)
565
+ if (items.length === 0) {
566
+ trace('no-candidates', 'rerank-zero-picks'); // ran fine, gate picked nothing worth injecting
476
567
  return;
568
+ }
477
569
  this.settlePending(sessionId, input, items, queryBuild, started);
570
+ trace('delivered', 'rerank-picks');
478
571
  }
479
- catch {
480
- // timeouts, aborts, model errors — I3: the async product is simply absent
572
+ catch (e) {
573
+ // RED LINE, A4 决议 3 (revising the old silent catch): the fast lane's
574
+ // fail-silently principle only covers the SYNCHRONOUS seam — this is an
575
+ // async lane, so the failure must be observable while still never
576
+ // rethrowing at the caller (I3 可丢弃性: the async product is absent,
577
+ // but its absence is now qualified, not mute).
578
+ trace(classifySlowError(e, controller), 'call-failed');
481
579
  }
482
580
  finally {
483
581
  clearTimeout(deadline);
484
582
  }
485
583
  }
486
- /** One pending slot per settled pipeline (the 0-2 picks + lane metadata). */
584
+ /** One pending slot per settled pipeline (the 0-2 picks + lane metadata).
585
+ * The pending carries its producing run's disposition (A4 §3.2) so the
586
+ * ilog writer can copy it verbatim — a pending only exists when the run
587
+ * delivered, hence the constant. */
487
588
  settlePending(sessionId, input, items, queryBuild, started) {
488
589
  const route = this.service.cfg;
489
590
  this.pending.set(sessionId, {
@@ -493,6 +594,7 @@ export class SlowLane {
493
594
  queryBuild,
494
595
  model: `${route.distillProvider}/${route.distillModel}`,
495
596
  ms: Date.now() - started,
597
+ outcome: 'delivered',
496
598
  });
497
599
  }
498
600
  /**
@@ -587,10 +689,12 @@ export class SlowLane {
587
689
  const produced = Date.parse(pending.computedAt);
588
690
  if (!Number.isNaN(produced) && Date.now() - produced > PENDING_TTL_MS) {
589
691
  this.pending.delete(sessionId);
692
+ this.traceOutcome(sessionId, turnId, 'expired', 'ttl', { ageMs: Date.now() - produced });
590
693
  return { expired: 'ttl' };
591
694
  }
592
695
  if (turnId - pending.turnId > TURN_LAG_LIMIT) {
593
696
  this.pending.delete(sessionId);
697
+ this.traceOutcome(sessionId, turnId, 'expired', 'turn-lag', { producedTurnId: pending.turnId });
594
698
  return { expired: 'turn-lag' };
595
699
  }
596
700
  this.pending.delete(sessionId);
@@ -12,6 +12,7 @@
12
12
  * @module retrieval
13
13
  */
14
14
  import type { TopicMeta } from './store.ts';
15
+ import type { EmbedResult } from './embedding.ts';
15
16
  export interface RetrievableTopic extends TopicMeta {
16
17
  /** `# Conclusion` section text (may be empty). */
17
18
  conclusion: string;
@@ -120,3 +121,108 @@ export declare function passesGate(s: {
120
121
  * evidence shows what the gate is costing.
121
122
  */
122
123
  export declare function searchTopics(query: string, roster: readonly RetrievableTopic[], cfg?: Partial<RetrievalConfig>): SearchOutcome;
124
+ /**
125
+ * Token cap in tokenize() units — the SAME unit injections.jsonl logs as
126
+ * queryTokenCount (p90=462 baseline; real-query p50=153 stays untouched).
127
+ * Raised or lowered via SanitizeOptions.maxTokens.
128
+ */
129
+ export declare const QUERY_TOKEN_CAP = 96;
130
+ /**
131
+ * Lines longer than this are folded to their head (log-paste guard). Sized
132
+ * so the 96-token cap is the binding constraint, not the fold: 96 latin
133
+ * tokens ≈ 650+ chars, so a legitimate long single-line query passes the
134
+ * fold intact and only the token cap trims it. (实现期定 constant.)
135
+ */
136
+ export declare const MAX_LINE_CHARS = 720;
137
+ export interface SanitizeOptions {
138
+ maxTokens?: number;
139
+ maxLineChars?: number;
140
+ }
141
+ /**
142
+ * Sanitize a raw user query for retrieval (spec §3.3): strip fenced code
143
+ * blocks and URLs, fold line-leading shell prompts, fold over-long lines to
144
+ * their head, cap the distinct-token count. Returns '' when nothing lexical
145
+ * survives — the caller treats that as a zero-token query (natural skip).
146
+ * Pure function; the hot lane only reaches it behind packageMode.
147
+ */
148
+ export declare function sanitizeQuery(raw: string, opts?: SanitizeOptions): string;
149
+ export interface EmbeddingIndexEntry {
150
+ model: string;
151
+ dim: number;
152
+ vec: number[];
153
+ }
154
+ /** slug -> entry, exactly as persisted in meta/embeddings.json. */
155
+ export type EmbeddingIndex = Record<string, EmbeddingIndexEntry>;
156
+ export declare function embeddingIndexPath(bundleDir: string): string;
157
+ /** Load meta/embeddings.json from a bundle dir. Missing file, corrupt JSON,
158
+ * or malformed entries are skipped/empty — never throws (a broken index
159
+ * must degrade to the lexical path, not break retrieval). */
160
+ export declare function loadEmbeddingIndex(bundleDir: string): EmbeddingIndex;
161
+ /** Persist the index in the canonical shape (the rebuild's write side —
162
+ * embedding/rebuilding itself stays with the caller). Offline rebuild
163
+ * path, not the hot lane. */
164
+ export declare function saveEmbeddingIndex(bundleDir: string, index: EmbeddingIndex, mkdir?: boolean): void;
165
+ /**
166
+ * An embed function bound to a config, carrying the RESOLVED model identity
167
+ * the index must match (build once via embedding.ts's `embedderWith`).
168
+ * ensureIndexFresh reads `.model` only — it never calls the function; the
169
+ * rebuild is the caller's job.
170
+ */
171
+ export type EmbedderFn = ((texts: readonly string[]) => Promise<EmbedResult>) & {
172
+ model: string;
173
+ };
174
+ /**
175
+ * Freshness audit: which slugs need (re)embedding — missing from the index,
176
+ * built by a different model, or shape-broken (dim/vec disagree). Returns
177
+ * them in roster order; an EMPTY list means the index covers the roster.
178
+ * A '' embedder model means the semantic layer is off → nothing to maintain.
179
+ *
180
+ * NOTE the freshness boundary: content-change invalidation needs a stored
181
+ * content hash, which the pinned {model,dim,vec} entry shape does not carry
182
+ * — the rebuild trigger policy is spec-实现期定 and stays with the caller.
183
+ */
184
+ export declare function ensureIndexFresh(index: EmbeddingIndex, topics: readonly {
185
+ slug: string;
186
+ }[], embedFn: EmbedderFn): string[];
187
+ export declare const RERANK_FLOOR = 0.2;
188
+ export declare const RERANK_CEILING = 0.3;
189
+ export declare const SEMANTIC_RERANK_REASON = "semantic-rerank";
190
+ export interface RerankedHit extends SearchHit {
191
+ /** Cosine(query, slug-vector), rounded 4dp. Present only on reranked
192
+ * hits; absent = the candidate rode through unchanged. */
193
+ similarity?: number;
194
+ }
195
+ export type RerankPassthroughReason = 'no-candidates' | 'no-query-vector' | 'empty-index' | 'no-rerankable-candidates';
196
+ export interface RerankOptions {
197
+ /** Expected index model; entries built by another model count as
198
+ * vector-less (they ride through at the band tail). */
199
+ expectedModel?: string;
200
+ floor?: number;
201
+ ceiling?: number;
202
+ }
203
+ export interface RerankResult {
204
+ /** Candidates in final order. On passthrough: the input array, same
205
+ * order, no tags — byte-identical ordering, explicit why. */
206
+ hits: RerankedHit[];
207
+ /** true = the semantic layer engaged (order may or may not have moved). */
208
+ reranked: boolean;
209
+ passthroughReason?: RerankPassthroughReason;
210
+ }
211
+ /** Cosine similarity; 0 for dim mismatch or zero vectors (never throws). */
212
+ export declare function cosineSimilarity(a: readonly number[], b: readonly number[]): number;
213
+ /**
214
+ * Reorder the nearFloor band (0.20–0.30 by gateScore) by cosine similarity
215
+ * against the query vector. Band members with a usable index vector sort by
216
+ * similarity (stable — ties keep input order) and gain the
217
+ * `semantic-rerank` reason plus their similarity; everyone else — out of
218
+ * band, unindexed, model-mismatched — passes through UNCHANGED at the tail
219
+ * in input order. Scores are never touched.
220
+ *
221
+ * Degradation is explicit and total: provider/model unconfigured, probe
222
+ * failure, missing query vector or missing index all return the input as-is
223
+ * with `reranked:false` and a passthroughReason (原序透传,不许静默 — the
224
+ * caller logs it into the degraded status). `queryText` rides the signature
225
+ * for provenance/debug parity with the caller's logging; the v1 math is
226
+ * vector-only.
227
+ */
228
+ export declare function rerankNearFloor(queryText: string, queryVec: readonly number[] | null | undefined, nearFloorCandidates: readonly SearchHit[], index: EmbeddingIndex, opts?: RerankOptions): RerankResult;