@thomasfosterau/pulpit 0.0.0 → 0.1.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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1271 -1
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +138 -4
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
package/src/search.ts ADDED
@@ -0,0 +1,540 @@
1
+ /**
2
+ * **Hybrid search** (pulpit#204) — the parts of the apps' search pipeline
3
+ * that are not SQL: reciprocal rank fusion over ranked branches, a reranker
4
+ * seam, the branch seam a ranked query fills under the leaf's read scope,
5
+ * and the embedding pipeline as seams.
6
+ *
7
+ * The behavioural spec is website's `apps/api/src/operations/search.ts`
8
+ * (full text, trigram, vector and literal-id branches fused with RRF) and
9
+ * gardeneria's `search.ts` and `packages/ai/src/{embeddings,reranker}.ts`,
10
+ * with both apps' `packages/jobs/src/embedding/queue.ts` and
11
+ * `cron/embedding-sweep.ts`.
12
+ *
13
+ * ## What the kernel owns
14
+ *
15
+ * - **Fusion.** {@link fuseRankings} is pure: each branch's rank list
16
+ * becomes a score `weight / (k + rank)` per hit, summed across branches,
17
+ * ordered by score and then by id (code point), and capped. A
18
+ * {@link RerankerSeam} may reorder the fused candidates, never add one.
19
+ * - **Scope on every branch.** A {@link SearchBranch} is a store seam (the
20
+ * ranked query is the application's SQL) handed the leaf's
21
+ * `searchScopeFilter` (`./subtype`), which its SQL must apply, the
22
+ * live-row rule included, so no branch can rank a row `list` hides
23
+ * (`SEC-AUTH-ENUM-001`). {@link hybridSearch} hands every branch the same
24
+ * scope. `./testing`'s `scopeCheckedBranch` wraps a branch and fails
25
+ * {@link SearchScopeLeak} on a hit outside the scope, so an application's
26
+ * suite can prove its own branches. The fused hits are still re-read
27
+ * through `hydrateSearchHits`, so a stale index never leaks a row either.
28
+ * - **Embeddings.** {@link enqueueEmbeddings} queues a document on its first
29
+ * insert (`onDocumentInsert`, inside the `put`'s transaction), and
30
+ * {@link embedPending} / {@link sweepEmbeddings} embed the pending
31
+ * documents in batches (`PERF-DB-001`): one {@link EmbeddingStore} read,
32
+ * one `getMany`, one {@link Embedder} call and one write per batch. The
33
+ * model, the document's text and the vector column are the application's.
34
+ *
35
+ * `/ask`-style retrieval stays application code over these reads.
36
+ *
37
+ * @copyright Thomas Foster
38
+ * @module pulpit/search
39
+ * @since 0.1.0
40
+ */
41
+
42
+ import * as Arr from "effect/Array";
43
+ import * as Effect from "effect/Effect";
44
+ import * as HashMap from "effect/HashMap";
45
+ import * as HashSet from "effect/HashSet";
46
+ import * as Option from "effect/Option";
47
+ import * as Order from "effect/Order";
48
+ import * as Schema from "effect/Schema";
49
+ import { onDocumentInsert, type DocumentHash, type DocumentStore } from "./documents.ts";
50
+ import type { ListFilter } from "./filter.ts";
51
+ import { byCodePoint } from "./internal/list-order.ts";
52
+ import { MAX_PAGE_LIMIT } from "./subtype.ts";
53
+
54
+ // ── Fusion ────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * The reciprocal rank fusion constant both apps use: a hit at rank `r`
58
+ * scores `weight / (RRF_K + r)`.
59
+ *
60
+ * @since 0.1.0
61
+ */
62
+ export const RRF_K = 60;
63
+
64
+ /**
65
+ * One hit a branch ranked: the node id. A branch's hits are in rank order,
66
+ * best first.
67
+ *
68
+ * @since 0.1.0
69
+ */
70
+ export interface RankedHit {
71
+ readonly id: string;
72
+ }
73
+
74
+ /**
75
+ * One branch's ranked hits, as {@link fuseRankings} reads them.
76
+ *
77
+ * @since 0.1.0
78
+ */
79
+ export interface BranchRanking {
80
+ /** The branch's name, as {@link FusedHit.ranks} keys it. */
81
+ readonly name: string;
82
+ /**
83
+ * The branch's weight, `1` when omitted. A weight that is not a finite,
84
+ * non-negative number scores nothing (fails closed).
85
+ */
86
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is weight 1
87
+ readonly weight?: number | undefined;
88
+ /** Best first. A repeated id keeps its first, best rank. */
89
+ readonly hits: ReadonlyArray<RankedHit>;
90
+ }
91
+
92
+ /**
93
+ * One fused hit: its id, its summed score and its 1-based rank in each
94
+ * branch that ranked it.
95
+ *
96
+ * @since 0.1.0
97
+ */
98
+ export interface FusedHit {
99
+ readonly id: string;
100
+ readonly score: number;
101
+ readonly ranks: { readonly [branch: string]: number };
102
+ }
103
+
104
+ /**
105
+ * {@link fuseRankings}'s options.
106
+ *
107
+ * @since 0.1.0
108
+ */
109
+ export interface FusionOptions {
110
+ /** The RRF constant; {@link RRF_K} when omitted or not a finite, positive number. */
111
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is RRF_K
112
+ readonly k?: number | undefined;
113
+ /** How many fused hits to return, clamped to `[1, MAX_PAGE_LIMIT]`; `MAX_PAGE_LIMIT` when omitted. */
114
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is MAX_PAGE_LIMIT
115
+ readonly limit?: number | undefined;
116
+ /** How many of each branch's hits count, clamped as `limit` is; `MAX_PAGE_LIMIT` when omitted. */
117
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is MAX_PAGE_LIMIT
118
+ readonly perBranch?: number | undefined;
119
+ }
120
+
121
+ /** A count clamped to `[1, MAX_PAGE_LIMIT]` (`SEC-INPUT-003`), `MAX_PAGE_LIMIT` when absent or not a number. */
122
+ const bounded = (value: Option.Option<number>): number =>
123
+ Option.match(
124
+ Option.filter(value, (count) => !Number.isNaN(count)),
125
+ {
126
+ onNone: () => MAX_PAGE_LIMIT,
127
+ onSome: (count) => Math.max(1, Math.min(Math.trunc(count), MAX_PAGE_LIMIT)),
128
+ },
129
+ );
130
+
131
+ /** A branch's usable weight: a finite, non-negative number, else 0. */
132
+ const weightOf = (branch: BranchRanking): number => {
133
+ const weight = branch.weight ?? 1;
134
+ return Number.isFinite(weight) && weight >= 0 ? weight : 0;
135
+ };
136
+
137
+ /** Higher score first, then id in code-point order: a total order, so fusion is deterministic. */
138
+ const fusedOrder: Order.Order<FusedHit> = Order.combine(
139
+ Order.mapInput(Order.flip(Order.Number), (hit: FusedHit) => hit.score),
140
+ Order.mapInput(byCodePoint, (hit: FusedHit) => hit.id),
141
+ );
142
+
143
+ /**
144
+ * Reciprocal rank fusion. Each branch's first `perBranch` distinct hits
145
+ * score `weight / (k + rank)` (rank 1-based); a hit's score is the sum over
146
+ * the branches that ranked it, and its `ranks` record each such rank. The
147
+ * result is ordered by score, then by id in code-point order, and capped at
148
+ * `limit`. Pure and total: a branch named twice keeps both contributions
149
+ * under the later name's rank.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * import { deepStrictEqual } from "node:assert";
154
+ * import { fuseRankings } from "@thomasfosterau/pulpit/search";
155
+ *
156
+ * const fused = fuseRankings([
157
+ * { name: "text", hits: [{ id: "a" }, { id: "b" }] },
158
+ * { name: "vector", hits: [{ id: "b" }, { id: "c" }] },
159
+ * ]);
160
+ * deepStrictEqual(
161
+ * fused.map((hit) => hit.id),
162
+ * ["b", "a", "c"],
163
+ * );
164
+ * deepStrictEqual(fused[0]?.ranks, { text: 2, vector: 1 });
165
+ * ```
166
+ *
167
+ * @since 0.1.0
168
+ */
169
+ export const fuseRankings = (
170
+ branches: ReadonlyArray<BranchRanking>,
171
+ options: FusionOptions = {},
172
+ ): ReadonlyArray<FusedHit> => {
173
+ const k =
174
+ options.k !== undefined && Number.isFinite(options.k) && options.k > 0 ? options.k : RRF_K;
175
+ const perBranch = bounded(Option.fromUndefinedOr(options.perBranch));
176
+ const contributions = Arr.flatMap(branches, (branch) => {
177
+ const weight = weightOf(branch);
178
+ return Arr.map(
179
+ Arr.take(
180
+ Arr.dedupeWith(branch.hits, (a, b) => a.id === b.id),
181
+ perBranch,
182
+ ),
183
+ (hit, index) => ({ id: hit.id, branch: branch.name, rank: index + 1, weight }),
184
+ );
185
+ });
186
+ const fused = Arr.reduce(contributions, HashMap.empty<string, FusedHit>(), (acc, entry) => {
187
+ const before = Option.getOrElse(HashMap.get(acc, entry.id), () => ({
188
+ id: entry.id,
189
+ score: 0,
190
+ ranks: {},
191
+ }));
192
+ return HashMap.set(acc, entry.id, {
193
+ id: entry.id,
194
+ score: before.score + entry.weight / (k + entry.rank),
195
+ ranks: { ...before.ranks, [entry.branch]: entry.rank },
196
+ });
197
+ });
198
+ return Arr.take(
199
+ Arr.sort(HashMap.toValues(fused), fusedOrder),
200
+ bounded(Option.fromUndefinedOr(options.limit)),
201
+ );
202
+ };
203
+
204
+ /**
205
+ * The optional reranker an application fills (gardeneria's
206
+ * `packages/ai/src/reranker.ts`): given the query and the fused candidates,
207
+ * their ids in its preferred order. The kernel only lets it reorder:
208
+ * {@link applyReranking} drops an id it returns that was not a candidate, and
209
+ * keeps a candidate it omits after the ones it ranked, in fused order.
210
+ *
211
+ * @since 0.1.0
212
+ */
213
+ export interface RerankerSeam<E, R> {
214
+ readonly rerank: (
215
+ query: string,
216
+ candidates: ReadonlyArray<FusedHit>,
217
+ ) => Effect.Effect<ReadonlyArray<string>, E, R>;
218
+ }
219
+
220
+ /**
221
+ * Reorder `candidates` by a reranker's answer: its ids first, in its order
222
+ * (an unknown or repeated id dropped), then every candidate it omitted, in
223
+ * their fused order. Pure: a reranker can reorder, never add or remove a
224
+ * hit.
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * import { deepStrictEqual } from "node:assert";
229
+ * import { applyReranking } from "@thomasfosterau/pulpit/search";
230
+ *
231
+ * const candidates = [
232
+ * { id: "a", score: 3, ranks: {} },
233
+ * { id: "b", score: 2, ranks: {} },
234
+ * { id: "c", score: 1, ranks: {} },
235
+ * ];
236
+ * deepStrictEqual(
237
+ * applyReranking(candidates, ["c", "zzz", "a"]).map((hit) => hit.id),
238
+ * ["c", "a", "b"],
239
+ * );
240
+ * ```
241
+ *
242
+ * @since 0.1.0
243
+ */
244
+ export const applyReranking = (
245
+ candidates: ReadonlyArray<FusedHit>,
246
+ order: ReadonlyArray<string>,
247
+ ): ReadonlyArray<FusedHit> => {
248
+ const byId = HashMap.fromIterable(Arr.map(candidates, (hit) => [hit.id, hit] as const));
249
+ const ranked = Arr.getSomes(Arr.map(Arr.dedupe(order), (id) => HashMap.get(byId, id)));
250
+ const named = HashSet.fromIterable(Arr.map(ranked, (hit) => hit.id));
251
+ return [...ranked, ...Arr.filter(candidates, (hit) => !HashSet.has(named, hit.id))];
252
+ };
253
+
254
+ // ── Branches ──────────────────────────────────────────────────────────
255
+
256
+ /**
257
+ * What a {@link SearchBranch} is asked: the query, the leaf's read scope and
258
+ * how many hits it may return.
259
+ *
260
+ * @since 0.1.0
261
+ */
262
+ export interface BranchRequest<Following> {
263
+ readonly query: string;
264
+ /**
265
+ * The leaf's `searchScopeFilter` for the viewer (`./subtype`). The branch
266
+ * must apply it in the same query that ranks (rendered as the leaf's
267
+ * `list` renders its filter), so a row outside it is never ranked and its
268
+ * rank never shifts another hit's (`SEC-AUTH-ENUM-001`).
269
+ */
270
+ readonly scope: ListFilter<Following>;
271
+ /** At most this many hits, best first; never more than `MAX_PAGE_LIMIT`. */
272
+ readonly limit: number;
273
+ }
274
+
275
+ /**
276
+ * One ranked query an application supplies: full text, trigram, a vector
277
+ * nearest-neighbour search or a literal-identifier match, over its own
278
+ * search view. A store seam: it applies the scope it is given and no other
279
+ * policy, and fails only with its `E`. See the package README → Seams.
280
+ *
281
+ * @since 0.1.0
282
+ */
283
+ export interface SearchBranch<Following, E, R> {
284
+ /** The branch's name, as {@link FusedHit.ranks} keys it. */
285
+ readonly name: string;
286
+ /** Its fusion weight; `1` when omitted (see {@link BranchRanking.weight}). */
287
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is weight 1
288
+ readonly weight?: number | undefined;
289
+ /** The hits in scope, best first, at most `request.limit`. */
290
+ readonly search: (
291
+ request: BranchRequest<Following>,
292
+ ) => Effect.Effect<ReadonlyArray<RankedHit>, E, R>;
293
+ }
294
+
295
+ /**
296
+ * A branch returned a hit outside the scope it was handed. Raised by
297
+ * `./testing`'s `scopeCheckedBranch`, which an application's suite wraps
298
+ * around its own branches; the kernel's {@link hybridSearch} never checks
299
+ * a production branch's rows itself (it has none to check against).
300
+ *
301
+ * @since 0.1.0
302
+ */
303
+ export class SearchScopeLeak extends Schema.TaggedError<
304
+ SearchScopeLeak,
305
+ { readonly brand: unique symbol }
306
+ >()("SearchScopeLeak", {
307
+ branch: Schema.String,
308
+ id: Schema.String,
309
+ }) {}
310
+
311
+ /**
312
+ * {@link hybridSearch}'s configuration: the branches, the optional reranker
313
+ * and the fusion options.
314
+ *
315
+ * @since 0.1.0
316
+ */
317
+ export interface HybridSearchDefinition<Following, E, R> {
318
+ readonly branches: ReadonlyArray<SearchBranch<Following, E, R>>;
319
+ /** Reorders the fused candidates; omitted, the fused order stands. */
320
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is "no reranker"
321
+ readonly reranker?: RerankerSeam<E, R> | undefined;
322
+ /** The RRF constant ({@link FusionOptions.k}). */
323
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is RRF_K
324
+ readonly k?: number | undefined;
325
+ /** How many hits each branch is asked for ({@link FusionOptions.perBranch}). */
326
+ // oxlint-disable-next-line effect/prefer-option-over-null -- an optional field is `?: T | undefined` (AGENTS.md → Conventions); the omitted key is MAX_PAGE_LIMIT
327
+ readonly perBranch?: number | undefined;
328
+ }
329
+
330
+ /**
331
+ * Run every branch under the same scope, one after another in the caller's
332
+ * transaction, fuse their rankings ({@link fuseRankings}) and, with a
333
+ * reranker, reorder the fused candidates ({@link applyReranking}). Returns
334
+ * at most `request.limit` hits (clamped to `[1, MAX_PAGE_LIMIT]`), for the
335
+ * caller to re-read through `hydrateSearchHits`. An empty query asks no
336
+ * branch and finds nothing. Fails with a branch's or the reranker's `E`.
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * import { deepStrictEqual } from "node:assert";
341
+ * import * as Effect from "effect/Effect";
342
+ * import { always } from "@thomasfosterau/pulpit/filter";
343
+ * import { hybridSearch } from "@thomasfosterau/pulpit/search";
344
+ *
345
+ * const search = hybridSearch({
346
+ * branches: [
347
+ * { name: "text", search: () => Effect.succeed([{ id: "a" }, { id: "b" }]) },
348
+ * { name: "vector", weight: 2, search: () => Effect.succeed([{ id: "b" }]) },
349
+ * ],
350
+ * });
351
+ * const hits = await Effect.runPromise(search({ query: "tomato", scope: always, limit: 10 }));
352
+ * deepStrictEqual(
353
+ * hits.map((hit) => hit.id),
354
+ * ["b", "a"],
355
+ * );
356
+ * ```
357
+ *
358
+ * @since 0.1.0
359
+ */
360
+ export const hybridSearch =
361
+ <Following, E, R>(definition: HybridSearchDefinition<Following, E, R>) =>
362
+ (request: BranchRequest<Following>): Effect.Effect<ReadonlyArray<FusedHit>, E, R> =>
363
+ Effect.gen(function* () {
364
+ if (request.query.trim() === "") return [];
365
+ const perBranch = bounded(Option.fromUndefinedOr(definition.perBranch));
366
+ const rankings = yield* Effect.forEach(
367
+ definition.branches,
368
+ (branch) =>
369
+ Effect.map(
370
+ branch.search({ query: request.query, scope: request.scope, limit: perBranch }),
371
+ (hits): BranchRanking => ({ name: branch.name, weight: branch.weight, hits }),
372
+ ),
373
+ { concurrency: 1 },
374
+ );
375
+ const fused = fuseRankings(rankings, {
376
+ k: definition.k,
377
+ perBranch,
378
+ limit: MAX_PAGE_LIMIT,
379
+ });
380
+ const limit = bounded(Option.some(request.limit));
381
+ const reranker = definition.reranker;
382
+ if (reranker === undefined || !Arr.isReadonlyArrayNonEmpty(fused)) {
383
+ return Arr.take(fused, limit);
384
+ }
385
+ const order = yield* reranker.rerank(request.query, fused);
386
+ return Arr.take(applyReranking(fused, order), limit);
387
+ }).pipe(Effect.withSpan("search.hybrid"));
388
+
389
+ // ── Embeddings ────────────────────────────────────────────────────────
390
+
391
+ /**
392
+ * The embedding queue and vector column an application supplies (the apps'
393
+ * `embedding` job table and the document table's nullable `embedding`
394
+ * column). A store seam: no policy, never opens a transaction, fails only
395
+ * with its `E`. See the package README → Seams.
396
+ *
397
+ * @since 0.1.0
398
+ */
399
+ export interface EmbeddingStore<E, R> {
400
+ /** Queue a document for embedding (an outbox row the caller's transaction writes); idempotent. */
401
+ readonly enqueue: (hash: DocumentHash) => Effect.Effect<void, E, R>;
402
+ /**
403
+ * Up to `limit` documents with no vector yet, oldest first: the queued
404
+ * ones and, for the sweep, any stored document whose vector is still
405
+ * `NULL`. One bounded read (`LIMIT $limit`).
406
+ */
407
+ readonly pending: (limit: number) => Effect.Effect<ReadonlyArray<DocumentHash>, E, R>;
408
+ /** Write each document's vector and dequeue it, in one statement. */
409
+ readonly write: (
410
+ vectors: ReadonlyArray<{ readonly hash: DocumentHash; readonly vector: ReadonlyArray<number> }>,
411
+ ) => Effect.Effect<void, E, R>;
412
+ /** Dequeue documents that can no longer be embedded (missing, or with no text). */
413
+ readonly discard: (hashes: ReadonlyArray<DocumentHash>) => Effect.Effect<void, E, R>;
414
+ }
415
+
416
+ /**
417
+ * The embedding model an application supplies: one call for a batch of
418
+ * texts, one vector per text, in order.
419
+ *
420
+ * @since 0.1.0
421
+ */
422
+ export interface Embedder<E, R> {
423
+ readonly embed: (
424
+ texts: ReadonlyArray<string>,
425
+ ) => Effect.Effect<ReadonlyArray<ReadonlyArray<number>>, E, R>;
426
+ }
427
+
428
+ /**
429
+ * The embedder answered with a number of vectors other than the number of
430
+ * texts it was given, so no vector can be matched to its document. Nothing
431
+ * of the batch is written.
432
+ *
433
+ * @since 0.1.0
434
+ */
435
+ export class EmbeddingMismatch extends Schema.TaggedError<
436
+ EmbeddingMismatch,
437
+ { readonly brand: unique symbol }
438
+ >()("EmbeddingMismatch", { expected: Schema.Finite, received: Schema.Finite }) {}
439
+
440
+ /**
441
+ * The document store with embedding enqueued on a document's first insert:
442
+ * `onDocumentInsert` with `embeddings.enqueue`, so the queue row lands in
443
+ * the `put`'s own transaction and a rolled-back write queues nothing.
444
+ *
445
+ * @since 0.1.0
446
+ */
447
+ export const enqueueEmbeddings = <Document, E, R>(
448
+ documents: DocumentStore<Document, E, R>,
449
+ embeddings: Pick<EmbeddingStore<E, R>, "enqueue">,
450
+ ): DocumentStore<Document, E, R> =>
451
+ onDocumentInsert(documents, ({ hash }) => embeddings.enqueue(hash));
452
+
453
+ /**
454
+ * What {@link embedPending} and {@link sweepEmbeddings} need: the queue, the
455
+ * documents, the model and the document's text.
456
+ *
457
+ * @since 0.1.0
458
+ */
459
+ export interface EmbeddingPipeline<Document, E, R> {
460
+ readonly embeddings: EmbeddingStore<E, R>;
461
+ readonly documents: Pick<DocumentStore<Document, E, R>, "getMany">;
462
+ readonly embedder: Embedder<E, R>;
463
+ /** The text to embed for a document (its plain-text serialisation), or none to skip it. */
464
+ readonly text: (document: Document) => Option.Option<string>;
465
+ /** Documents per batch, clamped to `[1, MAX_PAGE_LIMIT]`. */
466
+ readonly batchSize: number;
467
+ }
468
+
469
+ /**
470
+ * What one batch did: how many documents it embedded and how many it
471
+ * discarded (missing, or with no text).
472
+ *
473
+ * @since 0.1.0
474
+ */
475
+ export interface EmbeddingBatch {
476
+ readonly embedded: number;
477
+ readonly discarded: number;
478
+ }
479
+
480
+ /**
481
+ * Embed one batch of pending documents: one `pending` read, one `getMany`,
482
+ * one embedder call and one `write` (`PERF-DB-001`). A pending hash whose
483
+ * document is missing, or whose `text` is none, is discarded rather than
484
+ * retried forever. Fails {@link EmbeddingMismatch} (writing nothing) when
485
+ * the embedder answers with the wrong number of vectors, else with the
486
+ * stores' and the embedder's `E`.
487
+ *
488
+ * @since 0.1.0
489
+ */
490
+ export const embedPending = <Document, E, R>(
491
+ pipeline: EmbeddingPipeline<Document, E, R>,
492
+ ): Effect.Effect<EmbeddingBatch, E | EmbeddingMismatch, R> =>
493
+ Effect.gen(function* () {
494
+ const hashes = yield* pipeline.embeddings.pending(bounded(Option.some(pipeline.batchSize)));
495
+ if (!Arr.isReadonlyArrayNonEmpty(hashes)) return { embedded: 0, discarded: 0 };
496
+ const found = yield* pipeline.documents.getMany(hashes);
497
+ const textOf = (hash: DocumentHash) => Option.flatMap(HashMap.get(found, hash), pipeline.text);
498
+ const skipped = Arr.filter(hashes, (hash) => Option.isNone(textOf(hash)));
499
+ const texts = Arr.getSomes(
500
+ Arr.map(hashes, (hash) => Option.map(textOf(hash), (text) => ({ hash, text }))),
501
+ );
502
+ if (Arr.isReadonlyArrayNonEmpty(skipped)) yield* pipeline.embeddings.discard(skipped);
503
+ if (!Arr.isReadonlyArrayNonEmpty(texts)) return { embedded: 0, discarded: skipped.length };
504
+ const vectors = yield* pipeline.embedder.embed(Arr.map(texts, ({ text }) => text));
505
+ if (vectors.length !== texts.length) {
506
+ return yield* new EmbeddingMismatch({ expected: texts.length, received: vectors.length });
507
+ }
508
+ yield* pipeline.embeddings.write(
509
+ Arr.zipWith(texts, vectors, ({ hash }, vector) => ({ hash, vector })),
510
+ );
511
+ return { embedded: texts.length, discarded: skipped.length };
512
+ }).pipe(Effect.withSpan("search.embedPending"));
513
+
514
+ /**
515
+ * Embed batches until the queue is drained or `maxBatches` (clamped to at
516
+ * least 1) have run: the daily sweep of the unembedded. A batch that
517
+ * embeds and discards nothing ends the sweep. Returns the totals.
518
+ *
519
+ * @since 0.1.0
520
+ */
521
+ export const sweepEmbeddings = <Document, E, R>(
522
+ pipeline: EmbeddingPipeline<Document, E, R>,
523
+ maxBatches: number,
524
+ ): Effect.Effect<EmbeddingBatch, E | EmbeddingMismatch, R> => {
525
+ const step = (
526
+ left: number,
527
+ total: EmbeddingBatch,
528
+ ): Effect.Effect<EmbeddingBatch, E | EmbeddingMismatch, R> =>
529
+ Effect.flatMap(embedPending(pipeline), (batch) => {
530
+ const next = {
531
+ embedded: total.embedded + batch.embedded,
532
+ discarded: total.discarded + batch.discarded,
533
+ };
534
+ const done = batch.embedded + batch.discarded === 0 || left <= 1;
535
+ return done ? Effect.succeed(next) : step(left - 1, next);
536
+ });
537
+ return step(Math.max(1, Math.trunc(maxBatches) || 1), { embedded: 0, discarded: 0 }).pipe(
538
+ Effect.withSpan("search.sweepEmbeddings"),
539
+ );
540
+ };