@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.
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
+ }