@seanhogg/builderforce-memory 2026.9.29 → 2026.9.30

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.
Files changed (104) hide show
  1. package/dist/bridges/AnthropicBridge.d.ts.map +1 -1
  2. package/dist/bridges/AnthropicBridge.js +24 -65
  3. package/dist/bridges/AnthropicBridge.js.map +1 -1
  4. package/dist/bridges/OpenAIBridge.d.ts.map +1 -1
  5. package/dist/bridges/OpenAIBridge.js +12 -46
  6. package/dist/bridges/OpenAIBridge.js.map +1 -1
  7. package/dist/bridges/VertexAIBridge.d.ts.map +1 -1
  8. package/dist/bridges/VertexAIBridge.js +3 -34
  9. package/dist/bridges/VertexAIBridge.js.map +1 -1
  10. package/dist/distillation/DistillationEngine.d.ts +71 -54
  11. package/dist/distillation/DistillationEngine.d.ts.map +1 -1
  12. package/dist/distillation/DistillationEngine.js +112 -95
  13. package/dist/distillation/DistillationEngine.js.map +1 -1
  14. package/dist/distillation/index.d.ts +7 -1
  15. package/dist/distillation/index.d.ts.map +1 -1
  16. package/dist/distillation/index.js +5 -0
  17. package/dist/distillation/index.js.map +1 -1
  18. package/dist/distillation/ports.d.ts +43 -0
  19. package/dist/distillation/ports.d.ts.map +1 -0
  20. package/dist/distillation/ports.js +29 -0
  21. package/dist/distillation/ports.js.map +1 -0
  22. package/dist/eval/EvalHarness.d.ts +2 -1
  23. package/dist/eval/EvalHarness.d.ts.map +1 -1
  24. package/dist/eval/EvalHarness.js +13 -1
  25. package/dist/eval/EvalHarness.js.map +1 -1
  26. package/dist/eval/baseline.d.ts +33 -0
  27. package/dist/eval/baseline.d.ts.map +1 -0
  28. package/dist/eval/baseline.js +41 -0
  29. package/dist/eval/baseline.js.map +1 -0
  30. package/dist/eval/index.d.ts +1 -0
  31. package/dist/eval/index.d.ts.map +1 -1
  32. package/dist/eval/index.js +1 -0
  33. package/dist/eval/index.js.map +1 -1
  34. package/dist/eval/types.d.ts +7 -0
  35. package/dist/eval/types.d.ts.map +1 -1
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/rag/EnterpriseRetriever.d.ts.map +1 -1
  41. package/dist/rag/EnterpriseRetriever.js +2 -1
  42. package/dist/rag/EnterpriseRetriever.js.map +1 -1
  43. package/dist/retrieval/HybridRetriever.d.ts.map +1 -1
  44. package/dist/retrieval/HybridRetriever.js +2 -1
  45. package/dist/retrieval/HybridRetriever.js.map +1 -1
  46. package/dist/retrieval/decay.d.ts +24 -0
  47. package/dist/retrieval/decay.d.ts.map +1 -0
  48. package/dist/retrieval/decay.js +29 -0
  49. package/dist/retrieval/decay.js.map +1 -0
  50. package/dist/retrieval/fusion.d.ts +13 -19
  51. package/dist/retrieval/fusion.d.ts.map +1 -1
  52. package/dist/retrieval/fusion.js +17 -44
  53. package/dist/retrieval/fusion.js.map +1 -1
  54. package/dist/retrieval/index.d.ts +13 -6
  55. package/dist/retrieval/index.d.ts.map +1 -1
  56. package/dist/retrieval/index.js +10 -5
  57. package/dist/retrieval/index.js.map +1 -1
  58. package/dist/retrieval/mmr.d.ts +60 -0
  59. package/dist/retrieval/mmr.d.ts.map +1 -0
  60. package/dist/retrieval/mmr.js +94 -0
  61. package/dist/retrieval/mmr.js.map +1 -0
  62. package/dist/retrieval/weightedFusion.d.ts +64 -0
  63. package/dist/retrieval/weightedFusion.d.ts.map +1 -0
  64. package/dist/retrieval/weightedFusion.js +80 -0
  65. package/dist/retrieval/weightedFusion.js.map +1 -0
  66. package/dist/similarity/index.d.ts +6 -4
  67. package/dist/similarity/index.d.ts.map +1 -1
  68. package/dist/similarity/index.js +5 -3
  69. package/dist/similarity/index.js.map +1 -1
  70. package/dist/wire/index.d.ts +18 -0
  71. package/dist/wire/index.d.ts.map +1 -0
  72. package/dist/wire/index.js +17 -0
  73. package/dist/wire/index.js.map +1 -0
  74. package/dist/wire/sse.d.ts +29 -0
  75. package/dist/wire/sse.d.ts.map +1 -0
  76. package/dist/wire/sse.js +81 -0
  77. package/dist/wire/sse.js.map +1 -0
  78. package/dist/wire/usage.d.ts +46 -0
  79. package/dist/wire/usage.d.ts.map +1 -0
  80. package/dist/wire/usage.js +78 -0
  81. package/dist/wire/usage.js.map +1 -0
  82. package/package.json +11 -3
  83. package/src/bridges/AnthropicBridge.ts +24 -68
  84. package/src/bridges/OpenAIBridge.ts +11 -50
  85. package/src/bridges/VertexAIBridge.ts +3 -32
  86. package/src/distillation/DistillationEngine.ts +176 -141
  87. package/src/distillation/index.ts +15 -1
  88. package/src/distillation/ports.ts +55 -0
  89. package/src/eval/EvalHarness.ts +12 -1
  90. package/src/eval/baseline.ts +51 -0
  91. package/src/eval/index.ts +1 -0
  92. package/src/eval/types.ts +7 -0
  93. package/src/index.ts +5 -2
  94. package/src/rag/EnterpriseRetriever.ts +2 -1
  95. package/src/retrieval/HybridRetriever.ts +2 -1
  96. package/src/retrieval/decay.ts +29 -0
  97. package/src/retrieval/fusion.ts +19 -52
  98. package/src/retrieval/index.ts +24 -6
  99. package/src/retrieval/mmr.ts +126 -0
  100. package/src/retrieval/weightedFusion.ts +101 -0
  101. package/src/similarity/index.ts +6 -4
  102. package/src/wire/index.ts +18 -0
  103. package/src/wire/sse.ts +74 -0
  104. package/src/wire/usage.ts +95 -0
@@ -15,7 +15,8 @@
15
15
  */
16
16
 
17
17
  import { bm25Search, type Bm25Options } from './bm25.js';
18
- import { reciprocalRankFusion, maximalMarginalRelevance, type MmrCandidate } from './fusion.js';
18
+ import { reciprocalRankFusion } from './fusion.js';
19
+ import { maximalMarginalRelevance, type MmrCandidate } from './mmr.js';
19
20
  import { denseSearch } from './hnsw.js';
20
21
 
21
22
  export interface RetrievalCandidate {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Recency decay — exponential half-life down-weighting of an older result's score.
3
+ *
4
+ * `score · e^(−λ·age)` with `λ = ln2 / halfLife`, so a result `halfLife` days old
5
+ * keeps half its score. The maths is here; deciding how old a result IS (a dated
6
+ * file name, an mtime, a `created_at` column) and which results never decay stays
7
+ * with the caller, because that is a property of its corpus.
8
+ *
9
+ * Pure and zero-dependency.
10
+ */
11
+
12
+ /** The decay rate for a half-life in days; 0 (no decay) for a non-positive or non-finite half-life. */
13
+ export function toDecayLambda(halfLifeDays: number): number {
14
+ if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) return 0;
15
+ return Math.LN2 / halfLifeDays;
16
+ }
17
+
18
+ /** The factor in (0,1] a score is multiplied by at `ageInDays` (negative ages count as 0). */
19
+ export function calculateTemporalDecayMultiplier(params: { ageInDays: number; halfLifeDays: number }): number {
20
+ const lambda = toDecayLambda(params.halfLifeDays);
21
+ const clampedAge = Math.max(0, params.ageInDays);
22
+ if (lambda <= 0 || !Number.isFinite(clampedAge)) return 1;
23
+ return Math.exp(-lambda * clampedAge);
24
+ }
25
+
26
+ /** `score` decayed for its age. */
27
+ export function applyTemporalDecayToScore(params: { score: number; ageInDays: number; halfLifeDays: number }): number {
28
+ return params.score * calculateTemporalDecayMultiplier(params);
29
+ }
@@ -1,18 +1,13 @@
1
1
  /**
2
- * Rank fusion + diversity reranking.
3
- *
4
- * • Reciprocal Rank Fusion (RRF) merges the dense (vector) and sparse (BM25)
5
- * rankings into one list without needing the two score scales to be
6
- * commensurable — it fuses on RANK, not raw score. This is the standard,
7
- * parameter-light way to combine hybrid retrieval signals.
8
- * • Maximal Marginal Relevance (MMR) reranks the fused list to trade off
9
- * relevance against novelty, so the top-k isn't five near-duplicate chunks.
2
+ * Reciprocal Rank Fusion (RRF) — merges rankings (dense vector, sparse BM25, …) into
3
+ * one list without needing their score scales to be commensurable: it fuses on
4
+ * RANK, not raw score. The standard, parameter-light way to combine hybrid signals.
5
+ * Score-based fusion lives in {@link ./weightedFusion}; diversity reranking in
6
+ * {@link ./mmr}.
10
7
  *
11
8
  * Pure and zero-dependency.
12
9
  */
13
10
 
14
- import { cosineSimilarity } from '../similarity/index.js';
15
-
16
11
  export interface RankedList {
17
12
  /** Ordered ids, most relevant first. */
18
13
  ids: string[];
@@ -25,60 +20,32 @@ export interface FusedHit {
25
20
  score: number;
26
21
  }
27
22
 
23
+ /** The canonical TREC damping constant for {@link rrfTerm}. */
24
+ export const RRF_DEFAULT_K = 60;
25
+
26
+ /**
27
+ * One list's RRF contribution for the item at zero-based `rank`: `1 / (k + rank + 1)`.
28
+ * Exported for a ranker that blends the rank signal into a score of its own rather
29
+ * than fusing whole lists.
30
+ */
31
+ export function rrfTerm(rank: number, k: number = RRF_DEFAULT_K): number {
32
+ return 1 / (k + rank + 1);
33
+ }
34
+
28
35
  /**
29
36
  * Reciprocal Rank Fusion over any number of ranked lists.
30
37
  * score(d) = Σ_lists weight / (k + rank(d)). `k` (default 60) damps the
31
38
  * contribution of low-ranked items; the canonical TREC value.
32
39
  */
33
- export function reciprocalRankFusion(lists: RankedList[], k = 60): FusedHit[] {
40
+ export function reciprocalRankFusion(lists: RankedList[], k = RRF_DEFAULT_K): FusedHit[] {
34
41
  const acc = new Map<string, number>();
35
42
  for (const list of lists) {
36
43
  const weight = list.weight ?? 1;
37
44
  list.ids.forEach((id, rank) => {
38
- acc.set(id, (acc.get(id) ?? 0) + weight / (k + rank + 1));
45
+ acc.set(id, (acc.get(id) ?? 0) + weight * rrfTerm(rank, k));
39
46
  });
40
47
  }
41
48
  return [...acc.entries()]
42
49
  .map(([id, score]) => ({ id, score }))
43
50
  .sort((a, b) => b.score - a.score);
44
51
  }
45
-
46
- export interface MmrCandidate {
47
- id: string;
48
- vector: Float32Array;
49
- }
50
-
51
- /**
52
- * Maximal Marginal Relevance rerank. Greedily selects up to `topK` candidates,
53
- * each step maximising `λ·sim(query, d) − (1−λ)·max sim(d, already-selected)`.
54
- * λ=1 is pure relevance; lower λ injects diversity. Candidates without vectors
55
- * should be filtered out by the caller (they cannot be MMR-scored).
56
- */
57
- export function maximalMarginalRelevance(
58
- queryVec: Float32Array,
59
- candidates: MmrCandidate[],
60
- topK: number,
61
- lambda = 0.7,
62
- ): string[] {
63
- const remaining = [...candidates];
64
- const selected: MmrCandidate[] = [];
65
- const relevance = new Map<string, number>();
66
- for (const c of remaining) relevance.set(c.id, cosineSimilarity(queryVec, c.vector));
67
-
68
- while (selected.length < topK && remaining.length > 0) {
69
- let bestIdx = 0;
70
- let bestScore = -Infinity;
71
- for (let i = 0; i < remaining.length; i++) {
72
- const c = remaining[i]!;
73
- let maxSimToSelected = 0;
74
- for (const s of selected) {
75
- const sim = cosineSimilarity(c.vector, s.vector);
76
- if (sim > maxSimToSelected) maxSimToSelected = sim;
77
- }
78
- const mmr = lambda * relevance.get(c.id)! - (1 - lambda) * maxSimToSelected;
79
- if (mmr > bestScore) { bestScore = mmr; bestIdx = i; }
80
- }
81
- selected.push(remaining.splice(bestIdx, 1)[0]!);
82
- }
83
- return selected.map(s => s.id);
84
- }
@@ -1,19 +1,37 @@
1
1
  /**
2
- * Retrieval layer — chunking, BM25, rank fusion, and the HybridRetriever.
2
+ * Retrieval layer — chunking, BM25, rank and score fusion, MMR, recency decay, the
3
+ * similarity primitives they rest on, and the HybridRetriever.
3
4
  *
4
- * The classic RAG pieces the memory stack previously lacked (chunking, hybrid
5
- * dense+sparse search, reranking), implemented zero-dependency so they run in the
6
- * browser, Node, and the SSM runtime alike.
5
+ * Zero-dependency and engine-free: this is the `@seanhogg/builderforce-memory/retrieval`
6
+ * subpath, which a Cloudflare Worker, Node and the browser can all import without
7
+ * pulling the WebGPU engine. Every host ranks with THESE functions rather than a copy.
7
8
  */
8
9
 
10
+ export { cosineSimilarity, jaccardSimilarity, tokenize } from '../similarity/index.js';
11
+
9
12
  export { chunkText } from './chunk.js';
10
13
  export type { Chunk, ChunkOptions } from './chunk.js';
11
14
 
12
15
  export { bm25Search, bm25Idf, bm25LengthNorm, bm25TermScore, BM25_DEFAULT_K1, BM25_DEFAULT_B } from './bm25.js';
13
16
  export type { Bm25Doc, Bm25Hit, Bm25Options, Bm25Tokenizer } from './bm25.js';
14
17
 
15
- export { reciprocalRankFusion, maximalMarginalRelevance } from './fusion.js';
16
- export type { RankedList, FusedHit, MmrCandidate } from './fusion.js';
18
+ export { reciprocalRankFusion, rrfTerm, RRF_DEFAULT_K } from './fusion.js';
19
+ export type { RankedList, FusedHit } from './fusion.js';
20
+
21
+ export { mmrSelect, maximalMarginalRelevance, textMaximalMarginalRelevance } from './mmr.js';
22
+ export type { MmrSelectOptions, MmrCandidate, TextMmrItem } from './mmr.js';
23
+
24
+ export {
25
+ DEFAULT_HYBRID_VECTOR_WEIGHT,
26
+ DEFAULT_HYBRID_TEXT_WEIGHT,
27
+ normalizeHybridWeights,
28
+ hybridScore,
29
+ bm25RankToScore,
30
+ fuseHybridArms,
31
+ } from './weightedFusion.js';
32
+ export type { HybridWeights, HybridArmScores } from './weightedFusion.js';
33
+
34
+ export { toDecayLambda, calculateTemporalDecayMultiplier, applyTemporalDecayToScore } from './decay.js';
17
35
 
18
36
  export { hybridRetrieve } from './HybridRetriever.js';
19
37
  export type {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Maximal Marginal Relevance — ONE greedy selection loop, two similarity bases.
3
+ *
4
+ * MMR reranks a relevance-ordered list so the top of it is not five near-duplicates:
5
+ * each step picks the candidate maximising `λ·relevance − (1−λ)·max sim(candidate,
6
+ * already-selected)`. λ=1 is pure relevance; lower λ buys diversity.
7
+ *
8
+ * What differs between callers is only what "similar" means:
9
+ * • {@link maximalMarginalRelevance} — cosine over embedding vectors, relevance =
10
+ * cosine to the query vector. For retrievers that hold vectors;
11
+ * • {@link textMaximalMarginalRelevance} — Jaccard over word tokens, relevance =
12
+ * the candidate's own score min-max normalised onto [0,1]. For retrievers that
13
+ * only have text and a fused score (an FTS5 + sqlite-vec hybrid, say).
14
+ *
15
+ * Both run {@link mmrSelect}, so the selection rule itself exists once.
16
+ *
17
+ * Pure and zero-dependency.
18
+ */
19
+
20
+ import { cosineSimilarity, jaccardSimilarity, tokenize } from '../similarity/index.js';
21
+
22
+ export interface MmrSelectOptions<T> {
23
+ /** Relevance of one candidate, on the same scale as {@link similarity}. */
24
+ relevance: (item: T) => number;
25
+ /** Similarity of two candidates. */
26
+ similarity: (a: T, b: T) => number;
27
+ /** 0 = maximum diversity, 1 = maximum relevance. Default 0.7. */
28
+ lambda?: number;
29
+ /** Stop after this many picks. Default: every candidate. */
30
+ topK?: number;
31
+ /**
32
+ * Breaks an exact MMR tie in favour of the higher value. Without it the earlier
33
+ * candidate wins, so input order decides.
34
+ */
35
+ tieScore?: (item: T) => number;
36
+ }
37
+
38
+ /** Greedy MMR selection. Returns the picked candidates in pick order. */
39
+ export function mmrSelect<T>(items: readonly T[], opts: MmrSelectOptions<T>): T[] {
40
+ const lambda = opts.lambda ?? 0.7;
41
+ const limit = Math.min(opts.topK ?? items.length, items.length);
42
+ const remaining = [...items];
43
+ const relevance = new Map<T, number>();
44
+ for (const item of remaining) relevance.set(item, opts.relevance(item));
45
+
46
+ const selected: T[] = [];
47
+ while (selected.length < limit && remaining.length > 0) {
48
+ let bestIdx = -1;
49
+ let bestScore = -Infinity;
50
+ for (let i = 0; i < remaining.length; i++) {
51
+ const candidate = remaining[i]!;
52
+ let maxSimToSelected = 0;
53
+ for (const s of selected) {
54
+ const sim = opts.similarity(candidate, s);
55
+ if (sim > maxSimToSelected) maxSimToSelected = sim;
56
+ }
57
+ const score = lambda * relevance.get(candidate)! - (1 - lambda) * maxSimToSelected;
58
+ const wins = score > bestScore
59
+ || (score === bestScore && bestIdx >= 0 && opts.tieScore !== undefined
60
+ && opts.tieScore(candidate) > opts.tieScore(remaining[bestIdx]!));
61
+ if (wins) { bestScore = score; bestIdx = i; }
62
+ }
63
+ if (bestIdx < 0) break;
64
+ selected.push(remaining.splice(bestIdx, 1)[0]!);
65
+ }
66
+ return selected;
67
+ }
68
+
69
+ export interface MmrCandidate {
70
+ id: string;
71
+ vector: Float32Array;
72
+ }
73
+
74
+ /**
75
+ * Vector MMR. Selects up to `topK` candidates by cosine relevance to `queryVec`,
76
+ * penalising cosine similarity to what is already selected. Candidates without
77
+ * vectors should be filtered out by the caller (they cannot be MMR-scored).
78
+ */
79
+ export function maximalMarginalRelevance(
80
+ queryVec: Float32Array,
81
+ candidates: MmrCandidate[],
82
+ topK: number,
83
+ lambda = 0.7,
84
+ ): string[] {
85
+ return mmrSelect(candidates, {
86
+ relevance: (c) => cosineSimilarity(queryVec, c.vector),
87
+ similarity: (a, b) => cosineSimilarity(a.vector, b.vector),
88
+ lambda,
89
+ topK,
90
+ }).map((c) => c.id);
91
+ }
92
+
93
+ export interface TextMmrItem {
94
+ id: string;
95
+ /** The candidate's fused relevance score (any scale — it is normalised here). */
96
+ score: number;
97
+ /** The text diversity is judged on. */
98
+ content: string;
99
+ }
100
+
101
+ /**
102
+ * Text MMR. Reorders every item, judging diversity by Jaccard similarity of word
103
+ * tokens, and relevance by the item's `score` min-max normalised onto [0,1] (all
104
+ * equal → 1). An exact tie goes to the higher raw score. `lambda` is clamped to
105
+ * [0,1]; at 1 this is a plain score sort.
106
+ */
107
+ export function textMaximalMarginalRelevance<T extends TextMmrItem>(items: readonly T[], lambda = 0.7): T[] {
108
+ if (items.length <= 1) return [...items];
109
+ const l = Math.max(0, Math.min(1, lambda));
110
+ if (l === 1) return [...items].sort((a, b) => b.score - a.score);
111
+
112
+ const tokens = new Map<string, Set<string>>();
113
+ for (const item of items) tokens.set(item.id, new Set(tokenize(item.content)));
114
+ const tokensOf = (item: T) => tokens.get(item.id) ?? new Set(tokenize(item.content));
115
+
116
+ const scores = items.map((i) => i.score);
117
+ const min = Math.min(...scores);
118
+ const range = Math.max(...scores) - min;
119
+
120
+ return mmrSelect(items, {
121
+ relevance: (item) => (range === 0 ? 1 : (item.score - min) / range),
122
+ similarity: (a, b) => jaccardSimilarity(tokensOf(a), tokensOf(b)),
123
+ lambda: l,
124
+ tieScore: (item) => item.score,
125
+ });
126
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Weighted-score fusion — the ONE formula for fusing a semantic arm with a lexical
3
+ * arm by SCORE, where {@link ./fusion reciprocalRankFusion} fuses by RANK.
4
+ *
5
+ * Score fusion is the choice when two different engines must rank the same way: an
6
+ * on-prem `sqlite-vec` cosine + FTS5/BM25 pair and a cloud pgvector cosine + ILIKE
7
+ * pair answer the same question, and a weighted sum of scores already on roughly
8
+ * [0,1] makes "recall ranks the same everywhere" a property of one function.
9
+ *
10
+ * Union semantics: a candidate found by only one arm scores 0 on the other rather
11
+ * than being dropped, because "the lexical arm did not match" is evidence about the
12
+ * query's wording, not about the candidate's relevance.
13
+ *
14
+ * Pure and zero-dependency.
15
+ */
16
+
17
+ /** Weight on the semantic arm when a caller states none. */
18
+ export const DEFAULT_HYBRID_VECTOR_WEIGHT = 0.7;
19
+ /** Weight on the lexical arm when a caller states none. */
20
+ export const DEFAULT_HYBRID_TEXT_WEIGHT = 0.3;
21
+
22
+ export interface HybridWeights {
23
+ vectorWeight: number;
24
+ textWeight: number;
25
+ }
26
+
27
+ /**
28
+ * Renormalise a pair of weights so it sums to 1 — a fused score is only comparable
29
+ * across queries if the weights always sum the same. A degenerate pair (both zero
30
+ * or non-finite) falls back to the defaults.
31
+ *
32
+ * The pair is a RATIO, so only the lower bound is clamped: `(3, 1)` means "three
33
+ * parts semantic to one part lexical" and must come back as 0.75/0.25, not be
34
+ * capped into an even split. Normalisation is what bounds the result.
35
+ */
36
+ export function normalizeHybridWeights(vectorWeight?: number, textWeight?: number): HybridWeights {
37
+ const clamp = (n: number | undefined, fallback: number): number =>
38
+ typeof n === 'number' && Number.isFinite(n) ? Math.max(0, n) : fallback;
39
+ const v = clamp(vectorWeight, DEFAULT_HYBRID_VECTOR_WEIGHT);
40
+ const t = clamp(textWeight, DEFAULT_HYBRID_TEXT_WEIGHT);
41
+ const sum = v + t;
42
+ if (sum <= 0) {
43
+ return { vectorWeight: DEFAULT_HYBRID_VECTOR_WEIGHT, textWeight: DEFAULT_HYBRID_TEXT_WEIGHT };
44
+ }
45
+ return { vectorWeight: v / sum, textWeight: t / sum };
46
+ }
47
+
48
+ /** One candidate's per-arm scores, both already normalised to roughly [0,1]. */
49
+ export interface HybridArmScores {
50
+ vectorScore?: number;
51
+ textScore?: number;
52
+ }
53
+
54
+ /** The fused score for one candidate under the given (already normalised) weights. */
55
+ export function hybridScore(scores: HybridArmScores, weights: HybridWeights): number {
56
+ const v = Number.isFinite(scores.vectorScore) ? (scores.vectorScore as number) : 0;
57
+ const t = Number.isFinite(scores.textScore) ? (scores.textScore as number) : 0;
58
+ return weights.vectorWeight * v + weights.textWeight * t;
59
+ }
60
+
61
+ /**
62
+ * Map a BM25 rank (lower is better, unbounded — FTS5's `bm25()`) onto a (0,1]
63
+ * score so it can be summed with a cosine similarity. A non-finite rank is treated
64
+ * as very poor rather than as an error.
65
+ */
66
+ export function bm25RankToScore(rank: number): number {
67
+ const normalized = Number.isFinite(rank) ? Math.max(0, rank) : 999;
68
+ return 1 / (1 + normalized);
69
+ }
70
+
71
+ /**
72
+ * Fuse two arms keyed by a caller-supplied id and return the candidates ordered
73
+ * best-first. `merge` builds the output row from the winning payload of each id;
74
+ * later duplicates of the same id keep the first payload seen (arm order decides).
75
+ */
76
+ export function fuseHybridArms<TVector extends { id: string }, TText extends { id: string }, TOut>(params: {
77
+ vector: readonly TVector[];
78
+ text: readonly TText[];
79
+ vectorScoreOf: (row: TVector) => number;
80
+ textScoreOf: (row: TText) => number;
81
+ merge: (row: TVector | TText, score: number) => TOut;
82
+ weights?: Partial<HybridWeights>;
83
+ }): TOut[] {
84
+ const weights = normalizeHybridWeights(params.weights?.vectorWeight, params.weights?.textWeight);
85
+ const byId = new Map<string, { row: TVector | TText; scores: HybridArmScores }>();
86
+ for (const row of params.vector) {
87
+ byId.set(row.id, { row, scores: { vectorScore: params.vectorScoreOf(row), textScore: 0 } });
88
+ }
89
+ for (const row of params.text) {
90
+ const existing = byId.get(row.id);
91
+ if (existing) existing.scores.textScore = params.textScoreOf(row);
92
+ else byId.set(row.id, { row, scores: { vectorScore: 0, textScore: params.textScoreOf(row) } });
93
+ }
94
+ return [...byId.values()]
95
+ .map((entry) => {
96
+ const score = hybridScore(entry.scores, weights);
97
+ return { out: params.merge(entry.row, score), score };
98
+ })
99
+ .sort((a, b) => b.score - a.score)
100
+ .map((entry) => entry.out);
101
+ }
@@ -24,12 +24,14 @@ export function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
24
24
  }
25
25
 
26
26
  /**
27
- * Cosine similarity between two vectors in the range [-1, 1] (0 when either is a
28
- * zero vector). Compares over the shorter length when lengths differ. Vectors
27
+ * Cosine similarity between two vectors in the range [-1, 1] (0 when either is empty
28
+ * or a zero vector). Compares over the shorter length when lengths differ. Vectors
29
29
  * from MambaSession.embed() are already L2-normalised — this reduces to a dot
30
- * product — but we normalise defensively for vectors from other sources.
30
+ * product — but we normalise defensively for vectors from other sources. Takes any
31
+ * numeric array, so a `Float32Array` embedding and a JSON-decoded `number[]` (a
32
+ * pgvector row, a KV entry) go through the same function.
31
33
  */
32
- export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
34
+ export function cosineSimilarity(a: ArrayLike<number>, b: ArrayLike<number>): number {
33
35
  const n = Math.min(a.length, b.length);
34
36
  let dot = 0, na = 0, nb = 0;
35
37
  for (let i = 0; i < n; i++) {
@@ -0,0 +1,18 @@
1
+ /**
2
+ * LLM wire primitives — the protocol-level pieces every client of an
3
+ * OpenAI/Anthropic/Gemini-style HTTP API needs, in ONE place:
4
+ *
5
+ * • Server-Sent-Events `data:` framing — one line rule ({@link parseSseDataLine}),
6
+ * applied to a whole buffered body ({@link parseSseDataFrames}) or to a live
7
+ * byte stream ({@link readSseDataFrames});
8
+ * • token-usage field reading across vendor shapes ({@link readUsageFields}) and
9
+ * the Anthropic streaming usage fold ({@link AnthropicStreamUsage}).
10
+ *
11
+ * The package's bridges and the Builderforce gateway both frame and meter with
12
+ * these, so a fix to the line rule (a spaceless `data:{…}` frame, say) lands once.
13
+ * Zero-dependency and engine-free: the `@seanhogg/builderforce-memory/wire` subpath.
14
+ */
15
+
16
+ export { parseSseDataLine, parseSseDataFrames, readSseDataFrames, isSseDoneLine } from './sse.js';
17
+ export { finiteNumber, readUsageFields, AnthropicStreamUsage } from './usage.js';
18
+ export type { UsageFields, AnthropicUsageTotals } from './usage.js';
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Server-Sent-Events `data:` framing.
3
+ *
4
+ * Every consumer of an SSE LLM stream does the same line-level dance: trim the line,
5
+ * keep only `data:` frames, skip empty payloads, `JSON.parse` the rest and skip
6
+ * anything malformed — and stop at the `[DONE]` sentinel, which ends an
7
+ * OpenAI-style stream (anything after it is not part of the response).
8
+ *
9
+ * The line rule is `slice(5).trim()`, which accepts both `data: {…}` and the
10
+ * spaceless `data:{…}` some providers emit — `slice(6)` silently drops the latter.
11
+ *
12
+ * Pure and defensive: a malformed frame is skipped, never thrown.
13
+ */
14
+
15
+ /**
16
+ * One SSE line's decoded JSON payload, or `undefined` when the line is not a usable
17
+ * data frame (not a `data:` line, the `[DONE]` sentinel, empty, or malformed JSON).
18
+ */
19
+ export function parseSseDataLine(line: string): unknown | undefined {
20
+ const trimmed = line.trim();
21
+ if (!trimmed.startsWith('data:')) return undefined;
22
+ const data = trimmed.slice(5).trim();
23
+ if (!data || data === '[DONE]') return undefined;
24
+ try {
25
+ return JSON.parse(data);
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
31
+ /** True for the `data: [DONE]` line that ends an OpenAI-style stream. */
32
+ export function isSseDoneLine(line: string): boolean {
33
+ const trimmed = line.trim();
34
+ return trimmed.startsWith('data:') && trimmed.slice(5).trim() === '[DONE]';
35
+ }
36
+
37
+ /** Every parsed `data:` frame of a whole, already-buffered SSE body, up to `[DONE]`. */
38
+ export function* parseSseDataFrames(raw: string): Generator<unknown> {
39
+ for (const line of raw.split('\n')) {
40
+ if (isSseDoneLine(line)) return;
41
+ const parsed = parseSseDataLine(line);
42
+ if (parsed !== undefined) yield parsed;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Every parsed `data:` frame of a live SSE byte stream, as it arrives, up to `[DONE]`.
48
+ * A frame split across chunks is held until its line completes; the reader lock is
49
+ * released however iteration ends.
50
+ */
51
+ export async function* readSseDataFrames(body: ReadableStream<Uint8Array>): AsyncGenerator<unknown> {
52
+ const reader = body.getReader();
53
+ const decoder = new TextDecoder();
54
+ let buffer = '';
55
+ try {
56
+ while (true) {
57
+ const { done, value } = await reader.read();
58
+ if (done) break;
59
+ buffer += decoder.decode(value, { stream: true });
60
+ const lines = buffer.split('\n');
61
+ buffer = lines.pop() as string; // split() always yields ≥1 element
62
+ for (const line of lines) {
63
+ if (isSseDoneLine(line)) return;
64
+ const parsed = parseSseDataLine(line);
65
+ if (parsed !== undefined) yield parsed;
66
+ }
67
+ }
68
+ buffer += decoder.decode();
69
+ const tail = parseSseDataLine(buffer);
70
+ if (tail !== undefined) yield tail;
71
+ } finally {
72
+ reader.releaseLock();
73
+ }
74
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Token-usage reading across vendor shapes.
3
+ *
4
+ * Two wire shapes reach a client: OpenAI-style `prompt_tokens`/`completion_tokens`
5
+ * (cached reads under `prompt_tokens_details.cached_tokens`, INCLUDED in
6
+ * `prompt_tokens`) and Anthropic-style `input_tokens`/`output_tokens` (cache reads
7
+ * and writes as separate top-level fields, EXCLUDED from `input_tokens`).
8
+ * {@link readUsageFields} reads either into one set of optional fields, keeping
9
+ * "absent" distinct from 0; each consumer then maps them to its own accounting.
10
+ *
11
+ * Streaming Anthropic responses split usage across events — input and cache on
12
+ * `message_start.message.usage`, output accumulating on `message_delta.usage` — and
13
+ * every count is cumulative, so {@link AnthropicStreamUsage} keeps the largest seen.
14
+ */
15
+
16
+ /** A finite number, or `undefined` for null/undefined/non-numeric input. */
17
+ export function finiteNumber(v: unknown): number | undefined {
18
+ if (v === null || v === undefined) return undefined;
19
+ const n = Number(v);
20
+ return Number.isFinite(n) ? n : undefined;
21
+ }
22
+
23
+ /** Usage as the vendor reported it. Every field is absent when the vendor omitted it. */
24
+ export interface UsageFields {
25
+ /** `prompt_tokens`, else `input_tokens`. */
26
+ promptTokens?: number;
27
+ /** `completion_tokens`, else `output_tokens`. */
28
+ completionTokens?: number;
29
+ totalTokens?: number;
30
+ /** `cache_read_input_tokens`, else `prompt_tokens_details.cached_tokens`. */
31
+ cacheReadTokens?: number;
32
+ /** `cache_creation_input_tokens`. */
33
+ cacheCreationTokens?: number;
34
+ }
35
+
36
+ /** Reads a vendor `usage` object of either shape. Anything that is not an object reads as empty. */
37
+ export function readUsageFields(u: unknown): UsageFields {
38
+ const out: UsageFields = {};
39
+ if (!u || typeof u !== 'object') return out;
40
+ const usage = u as Record<string, unknown>;
41
+ const prompt = finiteNumber(usage['prompt_tokens'] ?? usage['input_tokens']);
42
+ const completion = finiteNumber(usage['completion_tokens'] ?? usage['output_tokens']);
43
+ const total = finiteNumber(usage['total_tokens']);
44
+ if (prompt !== undefined) out.promptTokens = prompt;
45
+ if (completion !== undefined) out.completionTokens = completion;
46
+ if (total !== undefined) out.totalTokens = total;
47
+
48
+ const details = usage['prompt_tokens_details'];
49
+ const cachedFromDetails = details && typeof details === 'object'
50
+ ? finiteNumber((details as Record<string, unknown>)['cached_tokens'])
51
+ : undefined;
52
+ const cacheRead = finiteNumber(usage['cache_read_input_tokens']) ?? cachedFromDetails;
53
+ const cacheCreation = finiteNumber(usage['cache_creation_input_tokens']);
54
+ if (cacheRead !== undefined) out.cacheReadTokens = cacheRead;
55
+ if (cacheCreation !== undefined) out.cacheCreationTokens = cacheCreation;
56
+ return out;
57
+ }
58
+
59
+ /** The totals of one Anthropic stream. Input excludes both cache counts. */
60
+ export interface AnthropicUsageTotals {
61
+ inputTokens: number;
62
+ outputTokens: number;
63
+ cacheReadTokens: number;
64
+ cacheCreationTokens: number;
65
+ /** `message_start.message.model`, when the stream named it. */
66
+ model?: string;
67
+ }
68
+
69
+ /** Folds the usage events of one Anthropic Messages stream. Feed it every parsed frame. */
70
+ export class AnthropicStreamUsage {
71
+ private readonly totals: AnthropicUsageTotals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
72
+
73
+ observe(frame: unknown): void {
74
+ if (!frame || typeof frame !== 'object') return;
75
+ const ev = frame as { type?: unknown; message?: { usage?: unknown; model?: unknown }; usage?: unknown };
76
+ let usage: unknown;
77
+ if (ev.type === 'message_start') {
78
+ usage = ev.message?.usage;
79
+ if (typeof ev.message?.model === 'string') this.totals.model = ev.message.model;
80
+ } else if (ev.type === 'message_delta') {
81
+ usage = ev.usage;
82
+ }
83
+ if (!usage || typeof usage !== 'object') return;
84
+ const u = usage as Record<string, unknown>;
85
+ const keep = (current: number, v: unknown) => Math.max(current, finiteNumber(v) ?? 0);
86
+ this.totals.inputTokens = keep(this.totals.inputTokens, u['input_tokens']);
87
+ this.totals.outputTokens = keep(this.totals.outputTokens, u['output_tokens']);
88
+ this.totals.cacheReadTokens = keep(this.totals.cacheReadTokens, u['cache_read_input_tokens']);
89
+ this.totals.cacheCreationTokens = keep(this.totals.cacheCreationTokens, u['cache_creation_input_tokens']);
90
+ }
91
+
92
+ result(): AnthropicUsageTotals {
93
+ return { ...this.totals };
94
+ }
95
+ }