@aiwayds/dsh-topics-memory 0.22.0 → 0.23.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
@@ -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)
package/src/retrieval.ts CHANGED
@@ -12,8 +12,11 @@
12
12
  * @module retrieval
13
13
  */
14
14
 
15
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { join } from 'node:path'
15
17
  import type { TopicMeta } from './store.ts'
16
18
  import { dependsSlugs } from './okf.ts'
19
+ import type { EmbedResult } from './embedding.ts'
17
20
 
18
21
  export interface RetrievableTopic extends TopicMeta {
19
22
  /** `# Conclusion` section text (may be empty). */
@@ -340,3 +343,335 @@ export function searchTopics(query: string, roster: readonly RetrievableTopic[],
340
343
  }
341
344
  return { hits, nearMisses, rosterSize: roster.length }
342
345
  }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // Injection-quality line (wave/injection-quality, spec §2.1/§3.1/§3.3) —
349
+ // CAPABILITY ONLY. None of this is wired into searchTopics or any service
350
+ // path; the caller gates it on packageMode ≠ 'off' (Phase 2 wiring). With
351
+ // the config keys at their defaults every code path below is unreachable
352
+ // from the hot lane, so default-off behavior stays byte-for-byte.
353
+ // ---------------------------------------------------------------------------
354
+
355
+ // --- ① Query sanitization (A4 决议 4, spec §3.3) ---------------------------
356
+ // Pure, LLM-free, millisecond-scale. Treats the queryTokenCount p90=462
357
+ // paste-pollution tail: fenced code blocks, URLs, line-leading shell
358
+ // prompts, over-long lines, and a hard token cap.
359
+
360
+ /**
361
+ * Token cap in tokenize() units — the SAME unit injections.jsonl logs as
362
+ * queryTokenCount (p90=462 baseline; real-query p50=153 stays untouched).
363
+ * Raised or lowered via SanitizeOptions.maxTokens.
364
+ */
365
+ export const QUERY_TOKEN_CAP = 96
366
+
367
+ /**
368
+ * Lines longer than this are folded to their head (log-paste guard). Sized
369
+ * so the 96-token cap is the binding constraint, not the fold: 96 latin
370
+ * tokens ≈ 650+ chars, so a legitimate long single-line query passes the
371
+ * fold intact and only the token cap trims it. (实现期定 constant.)
372
+ */
373
+ export const MAX_LINE_CHARS = 720
374
+
375
+ export interface SanitizeOptions {
376
+ maxTokens?: number
377
+ maxLineChars?: number
378
+ }
379
+
380
+ const FENCE_OPEN = /^[ \t]*(```|~~~)/
381
+ // Line-leading shell prompt: `user@host:~/repo$ `, `C:\> `, or a bare
382
+ // `$ `/`# `/`% `/`> `/`❯ ` marker. Requires trailing whitespace so words
383
+ // like `$PATH` or `#tag` survive untouched.
384
+ const LINE_PROMPT = /^[ \t]*(?:[\w.@:/\\~-]*[$#%>❯➜])[ \t]+/
385
+ const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'`)\]]+/gi
386
+ // Chunks the cap walks: one latin/digit word (or hyphenated compound) per
387
+ // match, or one CJK run (sliced when a run alone busts the cap).
388
+ const CHUNK_RUN = new RegExp(`${WORD_RUN.source}|${CJK_RUN.source}`, 'gu')
389
+
390
+ /** Remove fenced code blocks wholesale (markers AND body): fenced code is
391
+ * the paste-pollution payload; the prose around it is the actual query. */
392
+ function stripFencedBlocks(text: string): string {
393
+ const out: string[] = []
394
+ let open: string | null = null
395
+ for (const line of text.split('\n')) {
396
+ const m = line.match(FENCE_OPEN)
397
+ if (open === null) {
398
+ if (m !== null) open = m[1]
399
+ else out.push(line)
400
+ } else if (m !== null && m[1] === open) {
401
+ open = null // closing fence — block fully dropped
402
+ }
403
+ // inside an open fence: drop the line
404
+ }
405
+ return out.join('\n')
406
+ }
407
+
408
+ /** Cap the sanitized text to ≤ cap distinct retrieval tokens. Fast path:
409
+ * at or under the cap the text passes through VERBATIM (no re-chunking —
410
+ * hyphens, punctuation and spacing survive byte-exact; retrieval scores
411
+ * tokens, not bytes, but the string may be echoed into logs/injection).
412
+ * Only an over-cap text rebuilds from chunks: head-first (a query's intent
413
+ * leads; pollution trails), with CJK runs sliced at the character level so
414
+ * a single giant run truncates instead of being dropped whole. */
415
+ function capTokens(text: string, cap: number): string {
416
+ const total = tokenize(text).length
417
+ if (total === 0) return '' // nothing lexical survives — caller sees a zero-token query
418
+ if (total <= cap) return text
419
+ const seen = new Set<string>()
420
+ const kept: string[] = []
421
+ for (const chunk of text.match(CHUNK_RUN) ?? []) {
422
+ const fresh = tokenize(chunk).filter((t) => !seen.has(t))
423
+ if (fresh.length === 0) continue // already-covered chunk adds nothing
424
+ if (seen.size + fresh.length <= cap) {
425
+ for (const t of fresh) seen.add(t)
426
+ kept.push(chunk)
427
+ continue
428
+ }
429
+ const room = cap - seen.size
430
+ if (room <= 0) break
431
+ // Over budget: CJK runs truncate exactly (slice room+1 chars → ≤ room
432
+ // fresh bigrams, so the cap invariant holds). Latin chunks are single
433
+ // tokens by construction — they never reach here with room > 0, but if
434
+ // one ever does, dropping it beats busting the cap.
435
+ if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(chunk)) {
436
+ kept.push(chunk.slice(0, room + 1))
437
+ }
438
+ break
439
+ }
440
+ return kept.join(' ')
441
+ }
442
+
443
+ /**
444
+ * Sanitize a raw user query for retrieval (spec §3.3): strip fenced code
445
+ * blocks and URLs, fold line-leading shell prompts, fold over-long lines to
446
+ * their head, cap the distinct-token count. Returns '' when nothing lexical
447
+ * survives — the caller treats that as a zero-token query (natural skip).
448
+ * Pure function; the hot lane only reaches it behind packageMode.
449
+ */
450
+ export function sanitizeQuery(raw: string, opts: SanitizeOptions = {}): string {
451
+ const cap = opts.maxTokens ?? QUERY_TOKEN_CAP
452
+ const maxLineChars = opts.maxLineChars ?? MAX_LINE_CHARS
453
+ if (raw === '') return ''
454
+ let text = stripFencedBlocks(raw)
455
+ text = text.replace(URL_RE, ' ')
456
+ text = text
457
+ .split('\n')
458
+ .map((line) => {
459
+ let l = line.replace(LINE_PROMPT, '')
460
+ if (l.length > maxLineChars) l = l.slice(0, maxLineChars)
461
+ return l
462
+ })
463
+ .join('\n')
464
+ text = text.replace(/\s+/g, ' ').trim()
465
+ if (text === '') return ''
466
+ return capTokens(text, cap)
467
+ }
468
+
469
+ // --- ② Embedding index (A4 决议 2, spec §3.1) -------------------------------
470
+ // meta/embeddings.json: { slug: { model, dim, vec } }. Loading and auditing
471
+ // never throws; a missing/corrupt index reads as EMPTY, which routes every
472
+ // semantic path into the explicit passthrough below.
473
+
474
+ export interface EmbeddingIndexEntry {
475
+ model: string
476
+ dim: number
477
+ vec: number[]
478
+ }
479
+
480
+ /** slug -> entry, exactly as persisted in meta/embeddings.json. */
481
+ export type EmbeddingIndex = Record<string, EmbeddingIndexEntry>
482
+
483
+ export function embeddingIndexPath(bundleDir: string): string {
484
+ return join(bundleDir, 'meta', 'embeddings.json')
485
+ }
486
+
487
+ function parseEmbeddingIndexEntry(value: unknown): EmbeddingIndexEntry | null {
488
+ if (typeof value !== 'object' || value === null) return null
489
+ const e = value as Record<string, unknown>
490
+ if (typeof e.model !== 'string' || e.model === '') return null
491
+ if (typeof e.dim !== 'number' || !Number.isInteger(e.dim) || e.dim <= 0) return null
492
+ if (!Array.isArray(e.vec) || e.vec.length !== e.dim) return null
493
+ if (!e.vec.every((n) => typeof n === 'number' && Number.isFinite(n))) return null
494
+ return { model: e.model, dim: e.dim, vec: e.vec as number[] }
495
+ }
496
+
497
+ /** Load meta/embeddings.json from a bundle dir. Missing file, corrupt JSON,
498
+ * or malformed entries are skipped/empty — never throws (a broken index
499
+ * must degrade to the lexical path, not break retrieval). */
500
+ export function loadEmbeddingIndex(bundleDir: string): EmbeddingIndex {
501
+ let raw: string
502
+ try {
503
+ raw = readFileSync(embeddingIndexPath(bundleDir), 'utf8')
504
+ } catch {
505
+ return {}
506
+ }
507
+ try {
508
+ const json: unknown = JSON.parse(raw)
509
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) return {}
510
+ const out: EmbeddingIndex = {}
511
+ for (const [slug, entry] of Object.entries(json as Record<string, unknown>)) {
512
+ if (slug === '') continue
513
+ const parsed = parseEmbeddingIndexEntry(entry)
514
+ if (parsed !== null) out[slug] = parsed
515
+ }
516
+ return out
517
+ } catch {
518
+ return {}
519
+ }
520
+ }
521
+
522
+ /** Persist the index in the canonical shape (the rebuild's write side —
523
+ * embedding/rebuilding itself stays with the caller). Offline rebuild
524
+ * path, not the hot lane. */
525
+ export function saveEmbeddingIndex(bundleDir: string, index: EmbeddingIndex, mkdir = true): void {
526
+ if (mkdir) mkdirSync(join(bundleDir, 'meta'), { recursive: true })
527
+ writeFileSync(embeddingIndexPath(bundleDir), JSON.stringify(index))
528
+ }
529
+
530
+ /**
531
+ * An embed function bound to a config, carrying the RESOLVED model identity
532
+ * the index must match (build once via embedding.ts's `embedderWith`).
533
+ * ensureIndexFresh reads `.model` only — it never calls the function; the
534
+ * rebuild is the caller's job.
535
+ */
536
+ export type EmbedderFn = ((texts: readonly string[]) => Promise<EmbedResult>) & { model: string }
537
+
538
+ /**
539
+ * Freshness audit: which slugs need (re)embedding — missing from the index,
540
+ * built by a different model, or shape-broken (dim/vec disagree). Returns
541
+ * them in roster order; an EMPTY list means the index covers the roster.
542
+ * A '' embedder model means the semantic layer is off → nothing to maintain.
543
+ *
544
+ * NOTE the freshness boundary: content-change invalidation needs a stored
545
+ * content hash, which the pinned {model,dim,vec} entry shape does not carry
546
+ * — the rebuild trigger policy is spec-实现期定 and stays with the caller.
547
+ */
548
+ export function ensureIndexFresh(index: EmbeddingIndex, topics: readonly { slug: string }[], embedFn: EmbedderFn): string[] {
549
+ if ((embedFn.model ?? '') === '') return []
550
+ const stale: string[] = []
551
+ for (const t of topics) {
552
+ const e = index[t.slug]
553
+ if (e === undefined || e.model !== embedFn.model || e.dim !== e.vec.length || !(e.dim > 0)) stale.push(t.slug)
554
+ }
555
+ return stale
556
+ }
557
+
558
+ // --- ③ nearFloor semantic rerank (A4 决议 2, spec §3.1) ---------------------
559
+ // Cosine reordering of the nearFloor band ONLY (0.20–0.30 — the 93%
560
+ // near-miss mass). Thresholds, gateScore and the structural gate are
561
+ // untouched: the reranker reorders and tags, never re-scores.
562
+
563
+ export const RERANK_FLOOR = 0.2
564
+ export const RERANK_CEILING = 0.3
565
+ export const SEMANTIC_RERANK_REASON = 'semantic-rerank'
566
+
567
+ export interface RerankedHit extends SearchHit {
568
+ /** Cosine(query, slug-vector), rounded 4dp. Present only on reranked
569
+ * hits; absent = the candidate rode through unchanged. */
570
+ similarity?: number
571
+ }
572
+
573
+ export type RerankPassthroughReason =
574
+ | 'no-candidates' // nothing to rank
575
+ | 'no-query-vector' // embed failed upstream — the explicit degradation
576
+ | 'empty-index' // meta/embeddings.json absent/corrupt
577
+ | 'no-rerankable-candidates' // band candidates lack usable index vectors
578
+
579
+ export interface RerankOptions {
580
+ /** Expected index model; entries built by another model count as
581
+ * vector-less (they ride through at the band tail). */
582
+ expectedModel?: string
583
+ floor?: number
584
+ ceiling?: number
585
+ }
586
+
587
+ export interface RerankResult {
588
+ /** Candidates in final order. On passthrough: the input array, same
589
+ * order, no tags — byte-identical ordering, explicit why. */
590
+ hits: RerankedHit[]
591
+ /** true = the semantic layer engaged (order may or may not have moved). */
592
+ reranked: boolean
593
+ passthroughReason?: RerankPassthroughReason
594
+ }
595
+
596
+ /** Cosine similarity; 0 for dim mismatch or zero vectors (never throws). */
597
+ export function cosineSimilarity(a: readonly number[], b: readonly number[]): number {
598
+ if (a.length === 0 || a.length !== b.length) return 0
599
+ let dot = 0
600
+ let na = 0
601
+ let nb = 0
602
+ for (let i = 0; i < a.length; i += 1) {
603
+ dot += a[i]! * b[i]!
604
+ na += a[i]! * a[i]!
605
+ nb += b[i]! * b[i]!
606
+ }
607
+ if (na === 0 || nb === 0) return 0
608
+ return dot / Math.sqrt(na * nb)
609
+ }
610
+
611
+ /**
612
+ * Reorder the nearFloor band (0.20–0.30 by gateScore) by cosine similarity
613
+ * against the query vector. Band members with a usable index vector sort by
614
+ * similarity (stable — ties keep input order) and gain the
615
+ * `semantic-rerank` reason plus their similarity; everyone else — out of
616
+ * band, unindexed, model-mismatched — passes through UNCHANGED at the tail
617
+ * in input order. Scores are never touched.
618
+ *
619
+ * Degradation is explicit and total: provider/model unconfigured, probe
620
+ * failure, missing query vector or missing index all return the input as-is
621
+ * with `reranked:false` and a passthroughReason (原序透传,不许静默 — the
622
+ * caller logs it into the degraded status). `queryText` rides the signature
623
+ * for provenance/debug parity with the caller's logging; the v1 math is
624
+ * vector-only.
625
+ */
626
+ export function rerankNearFloor(
627
+ queryText: string,
628
+ queryVec: readonly number[] | null | undefined,
629
+ nearFloorCandidates: readonly SearchHit[],
630
+ index: EmbeddingIndex,
631
+ opts: RerankOptions = {},
632
+ ): RerankResult {
633
+ void queryText // v1 math is vector-only — see doc above
634
+ if (nearFloorCandidates.length === 0) {
635
+ return { hits: [], reranked: false, passthroughReason: 'no-candidates' }
636
+ }
637
+ if (!Array.isArray(queryVec) || queryVec.length === 0) {
638
+ return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-query-vector' }
639
+ }
640
+ if (Object.keys(index).length === 0) {
641
+ return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'empty-index' }
642
+ }
643
+ const floor = opts.floor ?? RERANK_FLOOR
644
+ const ceiling = opts.ceiling ?? RERANK_CEILING
645
+ const simOf = new Map<SearchHit, number | null>()
646
+ for (const hit of nearFloorCandidates) {
647
+ const entry = index[hit.slug]
648
+ const usable =
649
+ entry !== undefined &&
650
+ entry.dim === entry.vec.length &&
651
+ entry.dim > 0 &&
652
+ entry.dim === queryVec.length &&
653
+ (opts.expectedModel === undefined || entry.model === opts.expectedModel)
654
+ simOf.set(hit, usable ? cosineSimilarity(queryVec, entry.vec) : null)
655
+ }
656
+ const band = nearFloorCandidates.filter((h) => {
657
+ const sim = simOf.get(h)
658
+ return sim !== null && sim !== undefined && h.gateScore !== undefined && h.gateScore >= floor && h.gateScore < ceiling
659
+ })
660
+ if (band.length === 0) {
661
+ return { hits: [...nearFloorCandidates], reranked: false, passthroughReason: 'no-rerankable-candidates' }
662
+ }
663
+ const rerankedSet = new Set(band)
664
+ const tail = nearFloorCandidates.filter((h) => !rerankedSet.has(h))
665
+ // Stable sort: V8's Array#sort keeps input order for equal similarities.
666
+ const ordered = [...band].sort((x, y) => (simOf.get(y) as number) - (simOf.get(x) as number))
667
+ return {
668
+ hits: [
669
+ ...ordered.map((h) => {
670
+ const sim = simOf.get(h) as number
671
+ return { ...h, reasons: [...h.reasons, SEMANTIC_RERANK_REASON], similarity: Math.round(sim * 10_000) / 10_000 }
672
+ }),
673
+ ...tail.map((h) => ({ ...h })),
674
+ ],
675
+ reranked: true,
676
+ }
677
+ }