@loomwork/knowledge 0.0.0-stage → 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.
@@ -0,0 +1,489 @@
1
+ import { tainted } from "@loomwork/classify";
2
+ import { canonicalJson, fnv1a64 } from "@loomwork/core";
3
+ import { TieredMemory } from "@loomwork/memory";
4
+ import type { MemoryEntry, MemoryStore } from "@loomwork/memory";
5
+ import { declareReach } from "@loomwork/verify";
6
+ import { packParagraphs } from "./chunk";
7
+ import type { Chunker, DocRecord, KnowledgeDoc, Passage } from "./types";
8
+
9
+ /** The `decide` a retrieval is recorded through — structurally `ctx.decide`. */
10
+ export type DecideFn = <T>(key: string, compute: () => Promise<T> | T) => Promise<T>;
11
+
12
+ export interface KnowledgeBaseConfig {
13
+ /** The memory the corpus lives in. Its tier must not evict — see {@link KnowledgeBase}. */
14
+ memory: TieredMemory;
15
+ /** Names this corpus within the tier. One knowledge base, one scope. */
16
+ scope: string;
17
+ /** Defaults to `"semantic"` — the tier that never evicts. */
18
+ tier?: string;
19
+ /** Defaults to {@link packParagraphs}. */
20
+ chunker?: Chunker;
21
+ /**
22
+ * The provenance label stamped on every passage's taint, shown to the model when
23
+ * `guardRequest` fences the content. Defaults to `knowledge:<scope>:<docId>`.
24
+ */
25
+ origin?: (doc: KnowledgeDoc) => string;
26
+ }
27
+
28
+ export interface RetrieveOptions {
29
+ /** REQUIRED — `ctx.decide`. See {@link KnowledgeBase.retrieve}. */
30
+ decide: DecideFn;
31
+ limit?: number;
32
+ /** Overrides the recorded decision key. Defaults to the tier/scope/query triple. */
33
+ decisionKey?: string;
34
+ }
35
+
36
+ export interface SyncResult {
37
+ /** Documents written because they were new or their fingerprint changed. */
38
+ written: string[];
39
+ /** Documents already present at the same fingerprint — not rewritten. */
40
+ unchanged: string[];
41
+ /** Documents removed because the source no longer lists them. */
42
+ removed: string[];
43
+ /**
44
+ * Documents left alone because another writer owns them (see {@link DocRecord.source}).
45
+ *
46
+ * Reported rather than silent: "your sync deleted nothing" and "your sync protected four
47
+ * documents it does not own" describe very different corpora, and only one of them is
48
+ * something an operator should stop noticing in a nightly job's output.
49
+ */
50
+ retained: string[];
51
+ }
52
+
53
+ /** Names the writer a call speaks for. Omitted means the corpus's default writer. */
54
+ export interface SourceOptions {
55
+ source?: string;
56
+ }
57
+
58
+ /**
59
+ * A document corpus over a memory tier: ingest, revise, delete, retrieve — with the
60
+ * provenance and the recorded retrieval that make the result usable in a prompt.
61
+ *
62
+ * ## What this is not
63
+ *
64
+ * It is not a construct, an SPI, or a retrieval engine. Ranking is the `MemoryStore`'s
65
+ * (FTS5, `tsvector`, cosine — whichever backend is configured), reproducibility is
66
+ * `ctx.decide`'s, safety is ADR 0085's, and the corpus is an ordinary `semantic` tier
67
+ * anyone can read with `recall`. What lives here is the one thing none of those provide:
68
+ * the mapping from a DOCUMENT — the unit a corpus is edited in — to the PASSAGES it is
69
+ * searched in, so that revising a document supersedes its old passages instead of
70
+ * duplicating them.
71
+ *
72
+ * ## The tier must not evict
73
+ *
74
+ * A corpus is kept until someone deletes it, so the tier it lives in must have no `ttlMs`
75
+ * and no `maxEntries`. Under eviction, the store would drop passages the manifest still
76
+ * counts, and a document would silently retrieve as a fragment of itself. `semantic` and
77
+ * `procedural` are unbounded by default, which is exactly why they are the tiers a corpus
78
+ * belongs in — and the constructor refuses a bounded one rather than letting it rot.
79
+ *
80
+ * ## Two scopes
81
+ *
82
+ * Passages live in `scope`; the manifest lives in `scope::docs`, one small row per
83
+ * document. They are separate because they are searched differently: a manifest row in
84
+ * the corpus would be an entry that matches queries and answers nothing.
85
+ */
86
+ export class KnowledgeBase {
87
+ private readonly memory: TieredMemory;
88
+ private readonly tier: string;
89
+ private readonly scope: string;
90
+ private readonly manifestScope: string;
91
+ private readonly chunker: Chunker;
92
+ private readonly origin: (doc: KnowledgeDoc) => string;
93
+
94
+ constructor(config: KnowledgeBaseConfig) {
95
+ this.memory = config.memory;
96
+ this.tier = config.tier ?? "semantic";
97
+ this.scope = config.scope;
98
+ this.manifestScope = `${config.scope}::docs`;
99
+ this.chunker = config.chunker ?? packParagraphs();
100
+ this.origin = config.origin ?? ((doc) => `knowledge:${config.scope}:${doc.id}`);
101
+
102
+ const policy = this.memory.tierPolicy(this.tier);
103
+ if (policy === undefined) {
104
+ throw new Error(
105
+ `KnowledgeBase: unknown memory tier "${this.tier}" (have: ${this.memory.tierNames.join(", ")})`
106
+ );
107
+ }
108
+ if (policy.ttlMs !== undefined || policy.maxEntries !== undefined) {
109
+ throw new Error(
110
+ `KnowledgeBase: tier "${this.tier}" evicts (ttlMs/maxEntries), and a corpus must not. Eviction would drop passages the document manifest still counts, so a document would retrieve as a fragment of itself with nothing to detect it. Use an unbounded tier ("semantic"/"procedural" are unbounded by default).`
111
+ );
112
+ }
113
+ }
114
+
115
+ /** `docId#index` — derived, never stored, so a document's passage ids are its count. */
116
+ private passageId(docId: string, index: number): string {
117
+ return `${docId}#${index}`;
118
+ }
119
+
120
+ private fingerprint(doc: KnowledgeDoc): string {
121
+ // canonicalJson + fnv1a64 are core's one canonicalization and one hash (ADR 0063).
122
+ // Over the WHOLE document, not just its text: a changed title or a changed `meta`
123
+ // reaches the prompt through every passage, so it is a revision like any other.
124
+ return fnv1a64(
125
+ canonicalJson({
126
+ title: doc.title,
127
+ text: doc.text,
128
+ meta: doc.meta,
129
+ })
130
+ );
131
+ }
132
+
133
+ private toPassages(doc: KnowledgeDoc): Passage[] {
134
+ const origin = this.origin(doc);
135
+ return this.chunker(doc).map((text, index) => ({
136
+ docId: doc.id,
137
+ ...(doc.title !== undefined && { title: doc.title }),
138
+ index,
139
+ text: tainted(text, origin),
140
+ ...(doc.meta !== undefined && { meta: doc.meta }),
141
+ }));
142
+ }
143
+
144
+ /**
145
+ * What is actually WRITTEN for a passage: its `text`, and its `title`/`meta` if any.
146
+ *
147
+ * `docId` and `index` are deliberately NOT stored, because a store indexes the string
148
+ * and number leaves of `content` — so storing them would make a document's own id a term
149
+ * matching all of its passages, and the chunk ordinal a term matching the first passage
150
+ * of every document in the corpus. That is the same widening that made a tainted value's
151
+ * `origin` unindexable (ADR 0090), and the same rule the SPI states for `MemoryEntry.id`.
152
+ *
153
+ * They are not lost: the entry id IS `docId#index`, ids are required to round-trip
154
+ * (SPI 2.0.0), and ids are not indexed. The requirement earns its keep twice.
155
+ */
156
+ private storedPassage(passage: Passage): Record<string, unknown> {
157
+ return {
158
+ ...(passage.title !== undefined && { title: passage.title }),
159
+ text: passage.text,
160
+ ...(passage.meta !== undefined && { meta: passage.meta }),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The tiered memory the corpus lives in, for a caller that also wants to `recall` it,
166
+ * point a second corpus at the same store, or inspect it from ops code.
167
+ */
168
+ get tieredMemory(): TieredMemory {
169
+ return this.memory;
170
+ }
171
+
172
+ /** `docId#revision` — a manifest row's id. Rows are appended; the highest revision wins. */
173
+ private rowId(docId: string, revision: number): string {
174
+ return `${docId}#${revision}`;
175
+ }
176
+
177
+ /**
178
+ * The manifest: the live record per document, plus every row id currently standing for
179
+ * it so superseded rows can be pruned.
180
+ *
181
+ * One read of a small scope — rows are ids, counts and hashes, never passage text — which
182
+ * is what keeps `sync` proportional to the number of DOCUMENTS rather than to the size of
183
+ * the corpus. Read order is append order (the SPI guarantees it), so the last row for a
184
+ * document is the live one.
185
+ */
186
+ private async manifest(): Promise<Map<string, ManifestSlot>> {
187
+ const entries = await this.memory.recall(this.tier, this.manifestScope);
188
+ const slots = new Map<string, ManifestSlot>();
189
+ for (const entry of entries) {
190
+ const record = entry.content as DocRecord;
191
+ const slot = slots.get(record.docId);
192
+ const rowId = this.rowId(record.docId, record.revision);
193
+ if (slot === undefined) slots.set(record.docId, { record, rowIds: [rowId] });
194
+ else {
195
+ slot.rowIds.push(rowId);
196
+ slot.record = record;
197
+ }
198
+ }
199
+ return slots;
200
+ }
201
+
202
+ private async appendRecord(record: DocRecord): Promise<void> {
203
+ await this.memory.remember(this.tier, this.manifestScope, {
204
+ id: this.rowId(record.docId, record.revision),
205
+ content: record,
206
+ });
207
+ }
208
+
209
+ /**
210
+ * Remove a document's passages, by the count the manifest holds. Idempotent — a document
211
+ * that is absent, or whose passages were already deleted, is a no-op.
212
+ */
213
+ async removeDoc(docId: string): Promise<void> {
214
+ const slot = (await this.manifest()).get(docId);
215
+ if (slot === undefined) return;
216
+ await this.removeRecordedPassages(slot.record);
217
+ await this.memory.forget(this.tier, this.manifestScope, slot.rowIds);
218
+ }
219
+
220
+ private async removeRecordedPassages(record: DocRecord): Promise<void> {
221
+ const ids = Array.from({ length: record.chunkCount }, (_, i) =>
222
+ this.passageId(record.docId, i)
223
+ );
224
+ if (ids.length > 0) await this.memory.forget(this.tier, this.scope, ids);
225
+ }
226
+
227
+ /**
228
+ * Ingest a document, superseding any previous revision of it. Returns the passages now
229
+ * stored for it (empty if the document chunked to nothing).
230
+ *
231
+ * UPSERT, not append. Ingesting the same document twice must not double it in the index
232
+ * — retrieval would then return the stale passage beside the current one, and a citation
233
+ * could not say which is live. That is the operation a `MemoryStore` alone cannot
234
+ * express, and the reason this class exists.
235
+ *
236
+ * The write order is the crash contract, and it is deliberate:
237
+ *
238
+ * 1. APPEND an uncommitted manifest row at the new count and fingerprint;
239
+ * 2. delete the previous revision's passages (by the count the old row holds);
240
+ * 3. append the new passages;
241
+ * 4. APPEND the committed manifest row, then prune the superseded rows.
242
+ *
243
+ * A crash before (4) leaves an uncommitted row, which {@link sync} and every later
244
+ * `putDoc` treat as changed no matter what the fingerprint says — so the document is
245
+ * rewritten rather than skipped.
246
+ *
247
+ * Two orderings look more natural and neither survives. Passages first: passages nothing
248
+ * counts are passages nothing can remove, and they stay retrievable forever. Manifest
249
+ * updated in place (delete the old row, write the new one): the crash window between
250
+ * them leaves the document with NO row, which is the same orphaning by a shorter path —
251
+ * which is why rows are appended and superseded ones are pruned only afterwards.
252
+ */
253
+ async putDoc(doc: KnowledgeDoc, opts: SourceOptions = {}): Promise<Passage[]> {
254
+ const slot = (await this.manifest()).get(doc.id);
255
+ // Only an EXISTING document has an owner. An absent one is unclaimed, so any writer
256
+ // may create it — the check is about not taking someone else's, not about permission.
257
+ if (slot !== undefined) assertOwned(doc.id, slot.record.source, opts.source);
258
+ return this.write(doc, slot, opts.source);
259
+ }
260
+
261
+ private async write(
262
+ doc: KnowledgeDoc,
263
+ slot: ManifestSlot | undefined,
264
+ source: string | undefined
265
+ ): Promise<Passage[]> {
266
+ const passages = this.toPassages(doc);
267
+ const pending: DocRecord = {
268
+ docId: doc.id,
269
+ chunkCount: passages.length,
270
+ fingerprint: this.fingerprint(doc),
271
+ committed: false,
272
+ revision: (slot?.record.revision ?? -1) + 1,
273
+ ...(source !== undefined && { source }),
274
+ };
275
+
276
+ await this.appendRecord(pending);
277
+ if (slot !== undefined) await this.removeRecordedPassages(slot.record);
278
+ for (const passage of passages) {
279
+ await this.memory.remember(this.tier, this.scope, {
280
+ id: this.passageId(doc.id, passage.index),
281
+ content: this.storedPassage(passage),
282
+ });
283
+ }
284
+ const committed: DocRecord = { ...pending, committed: true, revision: pending.revision + 1 };
285
+ await this.appendRecord(committed);
286
+ // Prune only after the committed row is durable, and only rows this call has seen: a
287
+ // concurrent writer's row is not ours to delete.
288
+ const superseded = [...(slot?.rowIds ?? []), this.rowId(doc.id, pending.revision)];
289
+ await this.memory.forget(this.tier, this.manifestScope, superseded);
290
+ return passages;
291
+ }
292
+
293
+ /**
294
+ * Reconcile the corpus against the documents the source currently has: write what is new
295
+ * or changed, leave what is identical, remove what the source dropped.
296
+ *
297
+ * This is the operation a knowledge base is actually run with — a wiki export, a docs
298
+ * directory, a table of policies — and it is what makes deletion complete rather than
299
+ * best-effort. Without it, a document deleted upstream keeps answering questions.
300
+ *
301
+ * Cost is one manifest read (rows are ids and counts) plus writes for the documents that
302
+ * genuinely changed — NOT a rewrite of the corpus, which is the whole point of the
303
+ * fingerprint. Re-running it on an unchanged source writes nothing.
304
+ *
305
+ * `docs` is the COMPLETE set. Passing a subset removes everything not in it, which is
306
+ * the correct reading of "reconcile" and the wrong thing to do with a page of results.
307
+ */
308
+ async sync(docs: readonly KnowledgeDoc[], opts: SourceOptions = {}): Promise<SyncResult> {
309
+ const manifest = await this.manifest();
310
+ const plan = this.classify(docs, manifest, opts.source);
311
+ // Indexed once: `written` is O(docs) and a scan per entry would make a sync of a
312
+ // 10,000-document corpus quadratic in the size of the source, not of the change.
313
+ const byId = new Map(docs.map((doc) => [doc.id, doc]));
314
+
315
+ for (const docId of plan.written) {
316
+ // An UNCOMMITTED row is rewritten even when the fingerprint matches. That case is
317
+ // exactly the crash this design exists for, and treating it as unchanged is how a
318
+ // document disappears from a corpus that still lists it.
319
+ await this.write(byId.get(docId) as KnowledgeDoc, manifest.get(docId), opts.source);
320
+ }
321
+ for (const docId of plan.removed) {
322
+ const slot = manifest.get(docId) as ManifestSlot;
323
+ await this.removeRecordedPassages(slot.record);
324
+ await this.memory.forget(this.tier, this.manifestScope, slot.rowIds);
325
+ }
326
+ return plan;
327
+ }
328
+
329
+ /**
330
+ * What {@link sync} WOULD do, without doing it. Read-only.
331
+ *
332
+ * Exists because a corpus sync is a destructive reconcile — `docs` is the complete set,
333
+ * so anything missing from it is deleted — and the operator command that runs it
334
+ * (`loom knowledge sync`) defaults to a dry run for that reason. A preview computed by a
335
+ * separate code path would be a preview of a different operation, so both go through the
336
+ * same classification and only `sync` writes.
337
+ */
338
+ async planSync(docs: readonly KnowledgeDoc[], opts: SourceOptions = {}): Promise<SyncResult> {
339
+ return this.classify(docs, await this.manifest(), opts.source);
340
+ }
341
+
342
+ /** Pure: which documents are new/changed, unchanged, or no longer in the source. */
343
+ private classify(
344
+ docs: readonly KnowledgeDoc[],
345
+ manifest: Map<string, ManifestSlot>,
346
+ source: string | undefined
347
+ ): SyncResult {
348
+ const result: SyncResult = { written: [], unchanged: [], removed: [], retained: [] };
349
+ const seen = new Set<string>();
350
+ for (const doc of docs) {
351
+ if (seen.has(doc.id)) {
352
+ throw new Error(
353
+ `KnowledgeBase.sync: duplicate document id "${doc.id}". Ids identify a document across revisions, so two documents sharing one would each supersede the other on every sync.`
354
+ );
355
+ }
356
+ seen.add(doc.id);
357
+ const slot = manifest.get(doc.id);
358
+ if (slot !== undefined) assertOwned(doc.id, slot.record.source, source);
359
+ if (slot?.record.committed === true && slot.record.fingerprint === this.fingerprint(doc)) {
360
+ result.unchanged.push(doc.id);
361
+ } else {
362
+ result.written.push(doc.id);
363
+ }
364
+ }
365
+ for (const [docId, slot] of manifest) {
366
+ if (seen.has(docId)) continue;
367
+ // The whole point: a reconcile deletes only what it OWNS. Another writer's documents
368
+ // are not missing from this source — they were never this source's to list.
369
+ if (slot.record.source !== source) result.retained.push(docId);
370
+ else result.removed.push(docId);
371
+ }
372
+ return result;
373
+ }
374
+
375
+ /** The documents this corpus holds, in manifest order. Ids and counts — no passage text. */
376
+ async listDocs(): Promise<DocRecord[]> {
377
+ return [...(await this.manifest()).values()].map((slot) => slot.record);
378
+ }
379
+
380
+ /**
381
+ * Retrieve the passages most relevant to a question, best first.
382
+ *
383
+ * `decide` is REQUIRED, and it is a type requirement rather than advice for the reason
384
+ * ADR 0090 gives: a corpus grows and is edited, so the passages a question retrieves
385
+ * genuinely differ between a run and its replay. Recorded, the retrieval is a fact and
386
+ * the replay rebuilds the prompt that actually ran; unrecorded, "reproducing" an
387
+ * incident quietly means asking today's corpus yesterday's question.
388
+ *
389
+ * What is recorded is the passage CONTENT, not passage ids — ids do not reconstruct a
390
+ * prompt once the document behind them has been revised.
391
+ */
392
+ async retrieve(query: string, opts: RetrieveOptions): Promise<Passage[]> {
393
+ const hits = await this.memory.recallRelevant(this.tier, this.scope, query, {
394
+ decide: opts.decide,
395
+ ...(opts.limit !== undefined && { limit: opts.limit }),
396
+ ...(opts.decisionKey !== undefined && { decisionKey: opts.decisionKey }),
397
+ });
398
+ return hits.map(toPassage);
399
+ }
400
+ }
401
+
402
+ // L34 - declared reach (ADR 0086). `kb.retrieve(q, { decide: ctx.decide })` is one `ctx.decide`
403
+ // whose decider reads the memory store; it runs no tool (embedding, if any, is the store's own, not
404
+ // a ctx effect). Declared on the PROTOTYPE so both `verify.reach["kb.retrieve"]` and `verify.reach.kb`
405
+ // (the instance, which inherits it) and a method reference (`kb.retrieve`) resolve. Only `retrieve` is
406
+ // ever handed a ctx seam.
407
+ declareReach(KnowledgeBase.prototype, { run: [], transient: [] });
408
+ declareReach(KnowledgeBase.prototype.retrieve, { run: [], transient: [] });
409
+
410
+ /**
411
+ * Open a corpus over a `MemoryStore` directly — the two-line form of the four every
412
+ * consumer was writing by hand.
413
+ *
414
+ * A corpus needs exactly one memory tier and that tier must not evict, which is a fact
415
+ * about corpora and not a decision each application should be re-deriving. Both first
416
+ * consumers built the identical `new TieredMemory({ tiers: { semantic: {} }, store })`
417
+ * before they could construct anything, and an application wiring `defineConfig` cannot
418
+ * pass `app.memory` here at all — that is a {@link MemoryRuntime}, one layer below
419
+ * `TieredMemory` — so the boilerplate was mandatory rather than incidental.
420
+ *
421
+ * Reach for the constructor instead when the corpus shares a memory with something else
422
+ * (a conversation in `episodic`, say), which is exactly when the tier set is a decision.
423
+ */
424
+ export function openKnowledgeBase(
425
+ store: MemoryStore,
426
+ opts: Omit<KnowledgeBaseConfig, "memory">
427
+ ): KnowledgeBase {
428
+ const tier = opts.tier ?? "semantic";
429
+ return new KnowledgeBase({
430
+ ...opts,
431
+ memory: new TieredMemory({ tiers: { [tier]: {} }, store }),
432
+ });
433
+ }
434
+
435
+ /**
436
+ * Refuse a write to a document another writer owns.
437
+ *
438
+ * Loud rather than re-stamping, because silently taking ownership makes the two writers
439
+ * fight: each `sync` would claim the document and the other's next `sync` would delete it,
440
+ * so the corpus would flap between two revisions forever while every individual run looked
441
+ * correct. Two writers claiming one document is the ambiguity this field exists to remove.
442
+ */
443
+ function assertOwned(docId: string, owner: string | undefined, writer: string | undefined): void {
444
+ if (owner === writer) return;
445
+ const name = (s: string | undefined): string =>
446
+ s === undefined ? "the default writer" : `source "${s}"`;
447
+ throw new Error(
448
+ `KnowledgeBase: document "${docId}" is managed by ${name(owner)}, and this call speaks for ${name(writer)}. One document, one writer — otherwise the two delete each other's revisions on alternate syncs. Use a different document id, or the owning source.`
449
+ );
450
+ }
451
+
452
+ /** `docId#index` split at the LAST separator, so a document id may itself contain one. */
453
+ function splitPassageId(id: string): { docId: string; index: number } {
454
+ const cut = id.lastIndexOf("#");
455
+ const index = Number(id.slice(cut + 1));
456
+ if (cut < 1 || !Number.isInteger(index)) {
457
+ throw new Error(
458
+ `KnowledgeBase: entry id "${id}" is not a passage id (expected "docId#index"). The corpus scope holds passages this KnowledgeBase wrote; something else appended to it.`
459
+ );
460
+ }
461
+ return { docId: id.slice(0, cut), index };
462
+ }
463
+
464
+ /**
465
+ * A stored entry, as a passage: its identity from the entry id, the rest from `content`.
466
+ *
467
+ * The content half is a cast and not a parse, deliberately: on replay this value comes back
468
+ * out of a `DECISION_RECORDED` payload as plain JSON, and re-validating it would only be
469
+ * able to reject what the log already contains — the value IS what ran. The taint wrapper
470
+ * survives that round trip because it is plain JSON too, which is the property ADR 0085 was
471
+ * built on. The identity half is checked, because an unparseable id means the entry was not
472
+ * written by a KnowledgeBase and a `docId: undefined` would flow straight into a citation.
473
+ */
474
+ function toPassage(entry: MemoryEntry): Passage {
475
+ if (entry.id === undefined) {
476
+ throw new Error(
477
+ "KnowledgeBase: a corpus entry has no id, so the document it belongs to cannot be named. Either the backend does not round-trip `MemoryEntry.id` (REQUIRED since MemoryStore SPI 2.0.0 — run `runMemoryStoreConformance` against it), or something other than this KnowledgeBase appended to the corpus scope."
478
+ );
479
+ }
480
+ const { docId, index } = splitPassageId(entry.id);
481
+ const stored = entry.content as Omit<Passage, "docId" | "index">;
482
+ return { docId, index, ...stored };
483
+ }
484
+
485
+ /** A document's live manifest record, plus every row id standing for it right now. */
486
+ interface ManifestSlot {
487
+ record: DocRecord;
488
+ rowIds: string[];
489
+ }
package/src/chunk.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { Chunker, KnowledgeDoc } from "./types";
2
+
3
+ /**
4
+ * The default {@link Chunker}: split on blank lines, then greedily pack paragraphs up to
5
+ * `maxChars` so a passage is a coherent unit of prose rather than a fixed window that cuts
6
+ * mid-sentence.
7
+ *
8
+ * It is a DEFAULT and not a recommendation. Chunking decides what retrieval can return —
9
+ * too small and a passage loses the context that makes it answer the question, too large
10
+ * and one irrelevant paragraph drags a whole page into the prompt — and the right split is
11
+ * a property of the corpus, not of the framework. Pass your own for anything structured.
12
+ *
13
+ * A paragraph longer than `maxChars` is emitted whole rather than cut: splitting mid-word
14
+ * to satisfy a soft bound produces two passages that each retrieve badly, and the bound is
15
+ * a packing target, not a storage limit. Oversized content belongs behind
16
+ * `claimCheckMiddleware` at the effect boundary, which is where size actually bites.
17
+ */
18
+ export function packParagraphs(maxChars = 1_000): Chunker {
19
+ if (!Number.isFinite(maxChars) || maxChars < 1) {
20
+ throw new TypeError(`packParagraphs: maxChars must be a positive number (got ${maxChars})`);
21
+ }
22
+ return (doc: KnowledgeDoc): string[] => {
23
+ const paragraphs = doc.text
24
+ .split(/\n\s*\n/)
25
+ .map((p) => p.replace(/\s+/g, " ").trim())
26
+ .filter((p) => p.length > 0);
27
+
28
+ const chunks: string[] = [];
29
+ let current = "";
30
+ for (const paragraph of paragraphs) {
31
+ if (current.length > 0 && current.length + paragraph.length + 1 > maxChars) {
32
+ chunks.push(current);
33
+ current = paragraph;
34
+ } else {
35
+ current = current.length > 0 ? `${current} ${paragraph}` : paragraph;
36
+ }
37
+ }
38
+ if (current.length > 0) chunks.push(current);
39
+ return chunks;
40
+ };
41
+ }
@@ -0,0 +1,88 @@
1
+ import type { Passage } from "./types";
2
+
3
+ export interface CitationCheck {
4
+ /** Claimed ids that were actually retrieved, deduplicated, in retrieval order. */
5
+ grounded: string[];
6
+ /**
7
+ * Claimed ids that were NOT retrieved — the model named a source it was never shown.
8
+ * Non-empty is a fabricated citation, not a near miss.
9
+ */
10
+ ungrounded: string[];
11
+ /** Retrieved documents the answer did not cite. Not an error — evidence for tuning `limit`. */
12
+ unused: string[];
13
+ }
14
+
15
+ export class UngroundedCitationError extends Error {
16
+ constructor(
17
+ readonly ungrounded: readonly string[],
18
+ readonly retrieved: readonly string[]
19
+ ) {
20
+ super(
21
+ `answer cites ${ungrounded.length} document(s) that were not retrieved: ${ungrounded.join(", ")} (retrieved: ${retrieved.join(", ") || "none"})`
22
+ );
23
+ this.name = "UngroundedCitationError";
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Compare the documents an answer CLAIMS to cite against the ones actually retrieved.
29
+ *
30
+ * The check is nearly free and almost never written, because the number it needs is
31
+ * already in hand: the retrieval was recorded, so the set of documents the model could
32
+ * possibly have read is known exactly. A citation outside that set was not misread from a
33
+ * passage — there was no passage. It is the one hallucination in a RAG answer that can be
34
+ * detected with certainty rather than judged, which is what makes it mechanism and not a
35
+ * quality heuristic.
36
+ *
37
+ * What it does NOT check is whether the answer's CLAIMS follow from the cited passage.
38
+ * That needs a judge, it belongs to `@loomwork/learning`'s evaluation surface, and conflating
39
+ * the two would turn a decidable check into an approximate one.
40
+ *
41
+ * Ids are compared exactly: a corpus's document ids are its own keys, and normalizing
42
+ * case or punctuation here would be guessing at someone else's namespace.
43
+ */
44
+ export function checkCitations(
45
+ claimed: readonly string[],
46
+ passages: readonly Passage[]
47
+ ): CitationCheck {
48
+ const retrieved: string[] = [];
49
+ const retrievedSet = new Set<string>();
50
+ for (const passage of passages) {
51
+ if (retrievedSet.has(passage.docId)) continue;
52
+ retrievedSet.add(passage.docId);
53
+ retrieved.push(passage.docId);
54
+ }
55
+
56
+ const claimedSet = new Set(claimed);
57
+ const ungrounded: string[] = [];
58
+ const seen = new Set<string>();
59
+ for (const id of claimed) {
60
+ if (seen.has(id)) continue;
61
+ seen.add(id);
62
+ if (!retrievedSet.has(id)) ungrounded.push(id);
63
+ }
64
+
65
+ return {
66
+ // Retrieval order, not claim order: it is the order the app can explain (best first),
67
+ // and the model's ordering carries no meaning worth preserving.
68
+ grounded: retrieved.filter((id) => claimedSet.has(id)),
69
+ ungrounded,
70
+ unused: retrieved.filter((id) => !claimedSet.has(id)),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * {@link checkCitations}, but a fabricated citation throws.
76
+ *
77
+ * Returns the grounded ids, so the caller's happy path is `cited: assertGrounded(...)`.
78
+ * Use it where an answer is about to be shown as sourced: the failure mode this prevents
79
+ * is an answer that reads as attributed, names a plausible document, and cannot be traced
80
+ * to anything — the citation making it MORE trusted than an uncited answer would be.
81
+ */
82
+ export function assertGrounded(claimed: readonly string[], passages: readonly Passage[]): string[] {
83
+ const check = checkCitations(claimed, passages);
84
+ if (check.ungrounded.length > 0) {
85
+ throw new UngroundedCitationError(check.ungrounded, [...new Set(passages.map((p) => p.docId))]);
86
+ }
87
+ return check.grounded;
88
+ }
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { KnowledgeBase, openKnowledgeBase } from "./KnowledgeBase";
2
+ // Note: `DecideFn` is deliberately NOT re-exported — it is the same structural type as
3
+ // `@loomwork/memory`'s and `@loomwork/reasoning`'s (all three mirror `ctx.decide`), and exporting
4
+ // more than one of them collides in the `@loomwork/sdk` barrel. Pass `ctx.decide` directly.
5
+ export type {
6
+ KnowledgeBaseConfig,
7
+ RetrieveOptions,
8
+ SourceOptions,
9
+ SyncResult,
10
+ } from "./KnowledgeBase";
11
+ export { packParagraphs } from "./chunk";
12
+ export { checkCitations, assertGrounded, UngroundedCitationError } from "./citations";
13
+ export type { CitationCheck } from "./citations";
14
+ export { groundedRequest, passageMessages } from "./prompt";
15
+ export type { GroundedRequestOptions } from "./prompt";
16
+ export type { Chunker, DocRecord, KnowledgeDoc, Passage } from "./types";