@opum-ai/lore 0.1.0 → 0.2.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 (90) hide show
  1. package/README.md +33 -22
  2. package/bin/lore.cjs +35 -7
  3. package/package.json +17 -17
  4. package/src/adapters/backlog.ts +0 -1084
  5. package/src/adapters/git.ts +0 -221
  6. package/src/cli.ts +0 -667
  7. package/src/commands/agent.ts +0 -301
  8. package/src/commands/agents.ts +0 -302
  9. package/src/commands/args.ts +0 -209
  10. package/src/commands/changed.ts +0 -70
  11. package/src/commands/check.ts +0 -1031
  12. package/src/commands/codex-bridge.ts +0 -49
  13. package/src/commands/concurrency.ts +0 -48
  14. package/src/commands/context.ts +0 -292
  15. package/src/commands/discover.ts +0 -89
  16. package/src/commands/explorer.ts +0 -253
  17. package/src/commands/export.ts +0 -93
  18. package/src/commands/fswrite.ts +0 -928
  19. package/src/commands/graph.ts +0 -291
  20. package/src/commands/help.ts +0 -151
  21. package/src/commands/impact.ts +0 -59
  22. package/src/commands/init.ts +0 -583
  23. package/src/commands/instructions.ts +0 -91
  24. package/src/commands/link.ts +0 -929
  25. package/src/commands/new.ts +0 -476
  26. package/src/commands/orphans.ts +0 -457
  27. package/src/commands/path.ts +0 -67
  28. package/src/commands/provenance.ts +0 -68
  29. package/src/commands/query.ts +0 -312
  30. package/src/commands/reconcile-shared.ts +0 -280
  31. package/src/commands/rename.ts +0 -585
  32. package/src/commands/replace.ts +0 -320
  33. package/src/commands/scaffold.ts +0 -346
  34. package/src/commands/schema.ts +0 -293
  35. package/src/commands/snapshot.ts +0 -130
  36. package/src/commands/supersede.ts +0 -400
  37. package/src/commands/sync.ts +0 -371
  38. package/src/commands/tasks.ts +0 -271
  39. package/src/commands/traversal.ts +0 -151
  40. package/src/commands/validate.ts +0 -226
  41. package/src/config.ts +0 -598
  42. package/src/core/agent-bridge.ts +0 -287
  43. package/src/core/agent-context.ts +0 -498
  44. package/src/core/agent-profile.ts +0 -447
  45. package/src/core/bundle.ts +0 -893
  46. package/src/core/check.ts +0 -853
  47. package/src/core/codex-bridge.ts +0 -100
  48. package/src/core/concept.ts +0 -597
  49. package/src/core/consumer-scaffold.ts +0 -433
  50. package/src/core/context.ts +0 -271
  51. package/src/core/explorer-contract.ts +0 -441
  52. package/src/core/explorer-qualification.ts +0 -58
  53. package/src/core/explorer.ts +0 -518
  54. package/src/core/finding.ts +0 -31
  55. package/src/core/graph.ts +0 -201
  56. package/src/core/indexes.ts +0 -436
  57. package/src/core/instructions.ts +0 -209
  58. package/src/core/ladybug-driver.ts +0 -1795
  59. package/src/core/ladybug-lifecycle.ts +0 -1178
  60. package/src/core/ladybug-native.ts +0 -95
  61. package/src/core/ladybug-source.ts +0 -667
  62. package/src/core/links.ts +0 -681
  63. package/src/core/log.ts +0 -253
  64. package/src/core/managed-block.ts +0 -540
  65. package/src/core/manifest.ts +0 -718
  66. package/src/core/order.ts +0 -13
  67. package/src/core/profile.ts +0 -1007
  68. package/src/core/projection.ts +0 -195
  69. package/src/core/query.ts +0 -542
  70. package/src/core/reconcile.ts +0 -236
  71. package/src/core/replace.ts +0 -419
  72. package/src/core/retrieval.ts +0 -213
  73. package/src/core/rewrite.ts +0 -940
  74. package/src/core/scaffold.ts +0 -255
  75. package/src/core/schema.ts +0 -366
  76. package/src/core/snapshot-runtime.ts +0 -52
  77. package/src/core/snapshot-store.ts +0 -287
  78. package/src/core/snapshot.ts +0 -711
  79. package/src/core/template.ts +0 -429
  80. package/src/core/traversal.ts +0 -487
  81. package/src/core/validate.ts +0 -517
  82. package/src/core/workspace-contract.ts +0 -473
  83. package/src/core/workspace-projection.ts +0 -365
  84. package/src/core/workspace-retrieval.ts +0 -196
  85. package/src/core/workspace-source.ts +0 -174
  86. package/src/errors.ts +0 -697
  87. package/src/meta.ts +0 -7
  88. package/src/output.ts +0 -589
  89. package/src/scripts/upstream-backlog-watch.ts +0 -288
  90. package/src/state.ts +0 -390
package/src/core/query.ts DELETED
@@ -1,542 +0,0 @@
1
- /**
2
- * query.ts — in-memory navigation over the bundle graph.
3
- *
4
- * This is the shared navigation core every read-only "where does this connect"
5
- * command sits on. It hosts two primitives over the same {@link BundleGraph}
6
- * substrate:
7
- *
8
- * - {@link subgraph} — the depth-bounded neighborhood traversal `lore graph` uses
9
- * to root its export at one concept and `lore context` (LORE-34) reuses to gather
10
- * a target's neighbors before compacting them to a token budget.
11
- * - {@link query} — the BM25-style in-memory full-text search + frontmatter-field
12
- * filters behind `lore query` (LORE-33). No vectors, RAG, or chunking (ADR-0015):
13
- * a lightweight lexical index built fresh from the loaded graph each call.
14
- *
15
- * Keeping both in one module is deliberate — they are the two halves of "navigate
16
- * the bundle" (structural reach and lexical relevance), and a consumer that has the
17
- * graph already has both.
18
- *
19
- * Like the rest of `core/` (design §2.1) this module is pure: it reads an
20
- * already-loaded {@link BundleGraph} and returns plain data or throws a
21
- * {@link LoreError}; it never touches the filesystem, prints, or reads flags.
22
- */
23
-
24
- import { singleLine } from "../errors";
25
- import { type BundleGraph, conceptNotInBundle, type Edge, frontmatterScalar } from "./bundle";
26
- import type { Concept } from "./concept";
27
- import { compareCodeUnits } from "./order";
28
- import type { WorkspaceRecordProvenance, WorkspaceResultScope } from "./workspace-contract";
29
-
30
- /**
31
- * Collect the ids of every concept within `maxDepth` link-hops of `rootId`,
32
- * including the root itself — the concept's *neighborhood*.
33
- *
34
- * Traversal is **undirected**: an edge `A → B` makes `A` and `B` mutual
35
- * neighbors, because orientation is what a reader wants from "show me around this
36
- * concept" — both what it points at (its `specs`/links) and what points back at
37
- * it (who supersedes or cites it). Only **resolved** edges connect: a dangling
38
- * edge (`to: null`, a broken reference) has no target node to step to, so it
39
- * never extends reach — surfacing the break is `lore check`'s job, and a node's
40
- * own dangling edges are still reported by the export layer once the node is in.
41
- *
42
- * `maxDepth` bounds the radius in hops from the root:
43
- *
44
- * - `0` → just the root (no neighbors).
45
- * - `n` → the root plus everything reachable in ≤ `n` hops.
46
- * - `Infinity` → the entire connected component the root sits in (how `lore
47
- * graph <id>` behaves with no `--depth`).
48
- *
49
- * The walk is a breadth-first level expansion over a deduplicated adjacency
50
- * built from {@link BundleGraph.edges} (itself deterministic), and the returned
51
- * set iterates in **discovery order** (root first, then each level) — so the
52
- * same bundle and root always yield the same set in the same order, with no
53
- * dependence on edge multiplicity or input order. A link cycle (`A→B→A`) or a
54
- * self-link is harmless: a visited node is never re-enqueued.
55
- *
56
- * @throws LoreError `not_found` (exit 3) via {@link conceptNotInBundle} when
57
- * `rootId` names no concept in the bundle.
58
- */
59
- export function subgraph(graph: BundleGraph, rootId: string, maxDepth: number): Set<string> {
60
- if (!graph.concepts.has(rootId)) {
61
- throw conceptNotInBundle(rootId);
62
- }
63
- // A root-only radius never reads the adjacency, so don't pay to build the whole
64
- // O(E) index for it.
65
- if (maxDepth <= 0) {
66
- return new Set([rootId]);
67
- }
68
- const adjacency = graph.neighbors === undefined ? buildAdjacency(graph.edges) : undefined;
69
- const neighbors = graph.neighbors ?? ((id: string) => adjacency?.get(id) ?? EMPTY);
70
- const visited = new Set<string>([rootId]);
71
- let frontier: string[] = [rootId];
72
- // Level-by-level BFS: each iteration expands one hop. `maxDepth` is an upper
73
- // bound on iterations, so `Infinity` simply runs until the frontier drains.
74
- for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
75
- const next: string[] = [];
76
- for (const id of frontier) {
77
- for (const neighbor of neighbors(id)) {
78
- if (!visited.has(neighbor)) {
79
- visited.add(neighbor);
80
- next.push(neighbor);
81
- }
82
- }
83
- }
84
- frontier = next;
85
- }
86
- return visited;
87
- }
88
-
89
- /** Shared empty neighbor list for nodes with no resolved edges (avoids a per-lookup allocation). */
90
- const EMPTY: readonly string[] = [];
91
-
92
- /**
93
- * Build the undirected adjacency map from the bundle's edge list. Each resolved
94
- * edge contributes a symmetric neighbor pair; a dangling edge (`to: null`) and a
95
- * self-link (`from === to`) contribute nothing (the former has no target, the
96
- * latter no new reach). A {@link Set} per node deduplicates parallel edges (two
97
- * concepts linked both by body and by a frontmatter ref are one neighbor), and
98
- * because {@link BundleGraph.edges} is already in deterministic order, insertion
99
- * order — hence later iteration order — is deterministic too.
100
- */
101
- function buildAdjacency(edges: readonly Edge[]): Map<string, Set<string>> {
102
- const adjacency = new Map<string, Set<string>>();
103
- const connect = (a: string, b: string): void => {
104
- let neighbors = adjacency.get(a);
105
- if (neighbors === undefined) {
106
- neighbors = new Set<string>();
107
- adjacency.set(a, neighbors);
108
- }
109
- neighbors.add(b);
110
- };
111
- for (const edge of edges) {
112
- if (edge.to === null || edge.to === edge.from) {
113
- continue;
114
- }
115
- connect(edge.from, edge.to);
116
- connect(edge.to, edge.from);
117
- }
118
- return adjacency;
119
- }
120
-
121
- // ── Full-text query (lore query, LORE-33) ───────────────────────────────────────
122
-
123
- /** A `key=value` frontmatter-field filter (`lore query --field <key>=<value>`). */
124
- export interface FieldFilter {
125
- /** The frontmatter key to test (trimmed; an empty key is rejected at the command boundary). */
126
- readonly key: string;
127
- /** The value the field must equal (case-insensitively), or — for a list field — contain. */
128
- readonly value: string;
129
- }
130
-
131
- /** The inputs to a {@link query}: an optional search text plus the frontmatter filters and the result cap. */
132
- export interface QueryOptions {
133
- /**
134
- * The free-text search. Trimmed; an empty/whitespace-only or absent value means
135
- * **no text query** (the result is the filtered set, unranked). A non-empty text
136
- * also acts as a relevance filter — only concepts containing at least one of its
137
- * terms are returned.
138
- */
139
- readonly text?: string;
140
- /** `--type`: keep only concepts whose `type` equals this (case-insensitively). */
141
- readonly type?: string;
142
- /** `--tag` (repeatable): keep only concepts whose `tags` contain **every** listed tag (case-insensitively). */
143
- readonly tags?: readonly string[];
144
- /** `--status`: keep only concepts whose `status` frontmatter equals this (case-insensitively). */
145
- readonly status?: string;
146
- /** `--field` (repeatable): keep only concepts matching **every** `key=value` field test. */
147
- readonly fields?: readonly FieldFilter[];
148
- /** `--limit`: the maximum number of hits to return. Defaults to {@link DEFAULT_QUERY_LIMIT}. */
149
- readonly limit?: number;
150
- }
151
-
152
- /** One ranked search hit (cli-surface §query `query.results`). */
153
- export interface QueryHit {
154
- /** The concept id. */
155
- readonly id: string;
156
- /** The concept's resolved `type`. */
157
- readonly type: string;
158
- /** The concept's `title` frontmatter, when present and a non-empty scalar; omitted otherwise. */
159
- readonly title?: string;
160
- /**
161
- * A one-line snippet: the concept's `summary` frontmatter, falling back to its
162
- * `title`, collapsed to a single trimmed line. Omitted when the concept carries
163
- * neither (the id/type are still the structural signal).
164
- */
165
- readonly snippet?: string;
166
- /**
167
- * The BM25 relevance score (higher is more relevant). `0` for every hit on a
168
- * **filters-only** query (no text to rank by) — the hits are then ordered by id.
169
- */
170
- readonly score: number;
171
- /** Complete locator-free provenance in explicit workspace mode. */
172
- readonly provenance?: WorkspaceRecordProvenance;
173
- }
174
-
175
- /** The `query.results` payload: the ranked hits (already capped) plus the bounded-output accounting. */
176
- export interface QueryResult {
177
- /** The normalized text query, when one drove the ranking; omitted on a filters-only query. */
178
- readonly query?: string;
179
- /** The hits, capped to `limit`: score-descending then id-ascending under a text query, else id-ascending. */
180
- readonly hits: readonly QueryHit[];
181
- /** The total number of concepts that matched (filters ∧ text relevance) before the `limit` cap. */
182
- readonly total: number;
183
- /** The number actually returned (`shown <= total`). */
184
- readonly shown: number;
185
- /** `true` when the cap dropped matches (`shown < total`) — an honest bounded-output signal, never a silent cut. */
186
- readonly truncated: boolean;
187
- /** Explicit selected workspace scope; absent for repository-local output. */
188
- readonly workspace?: WorkspaceResultScope;
189
- }
190
-
191
- /** The default `--limit` when none is given — a bounded result so a broad query never floods stdout (cli-surface §query). */
192
- export const DEFAULT_QUERY_LIMIT = 20;
193
-
194
- /** BM25 term-frequency saturation parameter `k1` (the standard 1.5). */
195
- const BM25_K1 = 1.5;
196
- /** BM25 length-normalization parameter `b` (the standard 0.75). */
197
- const BM25_B = 0.75;
198
-
199
- /**
200
- * Search the bundle: keep the concepts matching every frontmatter filter, rank them
201
- * by BM25 relevance to `options.text` (when given), and return the top `limit` hits
202
- * with a bounded-output signal.
203
- *
204
- * **Filtering** (AC#1) is case-insensitive across `--type`/`--tag`/`--status`/`--field`;
205
- * a concept must satisfy *all* provided filters. **Ranking**: with a text query, a
206
- * lexical BM25 index is built fresh from the whole bundle (id, `title`, `summary`,
207
- * `description`, `tags`, and body tokens) and each filtered concept is scored;
208
- * concepts containing **no** query term score 0 and are dropped, so the text behaves
209
- * as a relevance filter as well as a sort key. Ties (and a filters-only query, where
210
- * every score is 0) break by ascending id, so the order is fully deterministic.
211
- *
212
- * **Bounded output** (AC#2): `total` is the full match count and `hits` is its first
213
- * `limit`, with `truncated` set when the cap dropped matches — the command renders the
214
- * §3 truncation line with a narrow-it hint. No vectors, RAG, or chunking (ADR-0015):
215
- * this is a deterministic lexical index, not a semantic one. Pure — reads the graph,
216
- * returns plain data, never touches the filesystem or flags.
217
- */
218
- export function query(graph: BundleGraph, options: QueryOptions = {}): QueryResult {
219
- return queryWithBm25Index(graph, options);
220
- }
221
-
222
- /**
223
- * Search with a caller-supplied deterministic BM25 index.
224
- *
225
- * The persistent Ladybug reader uses this boundary after fetching only postings
226
- * for the requested terms. Reference callers omit `index` and retain the exact
227
- * in-memory behavior.
228
- */
229
- export function queryWithBm25Index(graph: BundleGraph, options: QueryOptions = {}, index?: Bm25Index): QueryResult {
230
- const limit = options.limit ?? DEFAULT_QUERY_LIMIT;
231
- const normalizedText = (options.text ?? "").trim();
232
- const queryTerms = [...new Set(tokenizeQueryText(normalizedText))];
233
- // `hasText` keys off the *tokenized* terms, not the raw string: a non-empty text
234
- // that yields no searchable term (punctuation/separators only, e.g. `"%%%"`) is
235
- // treated as a filters-only query rather than a ranked one that would score every
236
- // concept 0 and silently drop the frontmatter filters passed alongside it.
237
- const hasText = queryTerms.length > 0;
238
-
239
- // Iterating the id-ordered `concepts` map keeps the filtered list ascending by id,
240
- // which is already the filters-only order and the tie-break order under ranking.
241
- const matched: Array<{ concept: Concept; score: number }> = [];
242
- // IDF is a per-term, per-corpus constant — compute it once here, not once per scored
243
- // document, so a query over a large filtered set does not recompute the same logs.
244
- const activeIndex = hasText ? (index ?? buildBm25Index(graph)) : undefined;
245
- const idf = activeIndex !== undefined ? idfForTerms(activeIndex, queryTerms) : undefined;
246
- for (const concept of graph.concepts.values()) {
247
- if (!matchesFilters(concept, options)) {
248
- continue;
249
- }
250
- if (activeIndex === undefined || idf === undefined) {
251
- matched.push({ concept, score: 0 });
252
- continue;
253
- }
254
- // A text query also filters: a concept with none of its terms scores 0 and is dropped.
255
- const score = scoreBm25(activeIndex, idf, concept.id, queryTerms);
256
- if (score > 0) {
257
- matched.push({ concept, score });
258
- }
259
- }
260
-
261
- if (hasText) {
262
- matched.sort((a, b) => b.score - a.score || compareCodeUnits(a.concept.id, b.concept.id));
263
- }
264
-
265
- const total = matched.length;
266
- const shown = Math.min(total, limit);
267
- const hits = matched.slice(0, shown).map(({ concept, score }) => toHit(concept, score));
268
- return {
269
- ...(hasText ? { query: normalizedText } : {}),
270
- hits,
271
- total,
272
- shown,
273
- truncated: shown < total,
274
- };
275
- }
276
-
277
- /** Shape one matched concept into its {@link QueryHit} (title verbatim; snippet = `summary` → `title` → none). */
278
- function toHit(concept: Concept, score: number): QueryHit {
279
- const title = frontmatterScalar(concept.frontmatter.title);
280
- const snippet = oneLine(frontmatterScalar(concept.frontmatter.summary) ?? title);
281
- return {
282
- id: concept.id,
283
- type: concept.type,
284
- ...(title !== undefined ? { title } : {}),
285
- ...(snippet !== undefined ? { snippet } : {}),
286
- score,
287
- };
288
- }
289
-
290
- /**
291
- * Whether `concept` satisfies every provided filter — the AC#1 type/tag/status/field
292
- * gate. `--type` compares the resolved `type` mirror; `--status` and each `--tag` are
293
- * just the general `--field` test against the `status`/`tags` keys (one matcher, so
294
- * the scalar-vs-list and case rules can never diverge between them and an arbitrary
295
- * `--field`). All comparisons fold case.
296
- */
297
- function matchesFilters(concept: Concept, options: QueryOptions): boolean {
298
- if (options.type !== undefined && !equalsFold(concept.type, options.type)) {
299
- return false;
300
- }
301
- if (options.status !== undefined && !matchesField(concept, { key: "status", value: options.status })) {
302
- return false;
303
- }
304
- if (options.tags !== undefined) {
305
- for (const want of options.tags) {
306
- if (!matchesField(concept, { key: "tags", value: want })) {
307
- return false;
308
- }
309
- }
310
- }
311
- if (options.fields !== undefined) {
312
- for (const filter of options.fields) {
313
- if (!matchesField(concept, filter)) {
314
- return false;
315
- }
316
- }
317
- }
318
- return true;
319
- }
320
-
321
- /**
322
- * Whether `concept`'s `filter.key` frontmatter matches `filter.value`: a list field
323
- * matches when **any** element equals the value (case-insensitively), a scalar field
324
- * when it equals the value.
325
- *
326
- * The **key** is resolved case-insensitively too — a `--field Status=…` finds a
327
- * `status:` key — so the key and the value fold consistently (and consistently with
328
- * `--type`/`--tag`/`--status`), instead of a verbatim-case key lookup silently
329
- * missing. Only **own** enumerable keys are scanned (`Object.keys`), so an inherited
330
- * `constructor`/`toString` can never satisfy a filter. A field with no scalar/list
331
- * value, or no matching key, never matches.
332
- *
333
- * YAML keys are case-sensitive, so a concept can carry more than one case-variant of
334
- * the same logical key (`Status: draft` alongside `status: done` — both survive
335
- * frontmatter parsing as distinct own keys). Every such candidate key is checked, not
336
- * only the first one enumeration happens to reach, so a match under *any*
337
- * case-variant key succeeds regardless of author insertion order.
338
- */
339
- function matchesField(concept: Concept, filter: FieldFilter): boolean {
340
- const keys = Object.keys(concept.frontmatter).filter((candidate) => equalsFold(candidate, filter.key));
341
- return keys.some((key) => {
342
- const raw = concept.frontmatter[key];
343
- if (Array.isArray(raw)) {
344
- return raw.some((item) => {
345
- const value = frontmatterScalar(item);
346
- return value !== undefined && equalsFold(value, filter.value);
347
- });
348
- }
349
- const value = frontmatterScalar(raw);
350
- return value !== undefined && equalsFold(value, filter.value);
351
- });
352
- }
353
-
354
- /** A concept's `tags` as a list of scalar strings (a bare string tag is treated as a one-element list; anything else is empty). */
355
- function tagsOf(concept: Concept): string[] {
356
- const raw = concept.frontmatter.tags;
357
- const items = Array.isArray(raw) ? raw : [raw];
358
- const tags: string[] = [];
359
- for (const item of items) {
360
- const value = frontmatterScalar(item);
361
- if (value !== undefined) {
362
- tags.push(value);
363
- }
364
- }
365
- return tags;
366
- }
367
-
368
- /** Case-insensitive string equality — the single rule for every `lore query` filter comparison. */
369
- function equalsFold(a: string, b: string): boolean {
370
- return a.toLowerCase() === b.toLowerCase();
371
- }
372
-
373
- /**
374
- * Collapse an already-coerced scalar ({@link frontmatterScalar}) to a single trimmed,
375
- * non-empty line for a hit's snippet, or `undefined` — the same one-line compaction
376
- * `lore context` applies to a neighbor's summary, so a concept's snippet reads
377
- * identically from both commands.
378
- */
379
- function oneLine(value: string | undefined): string | undefined {
380
- if (value === undefined) {
381
- return undefined;
382
- }
383
- const line = singleLine(value).trim();
384
- return line === "" ? undefined : line;
385
- }
386
-
387
- // ── BM25 lexical index ───────────────────────────────────────────────────────────
388
-
389
- /** One document's term frequencies and length (in tokens) within the BM25 index. */
390
- export interface IndexedDoc {
391
- /** Term → occurrence count in this document. */
392
- readonly tf: ReadonlyMap<string, number>;
393
- /** The document's length in tokens (the BM25 length-normalization input). */
394
- readonly length: number;
395
- }
396
-
397
- /** A whole-bundle BM25 index: per-document term frequencies, document frequencies, the doc count, and the average length. */
398
- export interface Bm25Index {
399
- /** Indexed documents keyed by concept id. */
400
- readonly docs: ReadonlyMap<string, IndexedDoc>;
401
- /** Term → number of documents that contain it (for IDF). */
402
- readonly df: ReadonlyMap<string, number>;
403
- /** The number of indexed documents (`N`). */
404
- readonly n: number;
405
- /** The mean document length in tokens (`avgdl`); `0` only when there are no documents. */
406
- readonly avgdl: number;
407
- }
408
-
409
- /** One arbitrary lexical record for consumers that share Lore's BM25 contract. */
410
- export interface Bm25Record {
411
- readonly id: string;
412
- readonly text: string;
413
- }
414
-
415
- /** One arbitrary record's deterministic relevance score. */
416
- export interface Bm25RecordScore {
417
- readonly id: string;
418
- readonly score: number;
419
- }
420
-
421
- /**
422
- * Tokenize a string into lower-cased lexical terms: maximal runs of Unicode letters
423
- * or digits, with everything else (punctuation, whitespace, path separators) a
424
- * boundary. So `stories/bulk-archive` yields `["stories", "bulk", "archive"]`. This
425
- * is a deterministic lexical split, not a real tokenizer — adequate for the
426
- * lightweight in-memory search ADR-0015 calls for.
427
- */
428
- export function tokenizeQueryText(text: string): string[] {
429
- return text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
430
- }
431
-
432
- /** The searchable text of a concept: its id, the `title`/`summary`/`description`/`tags` scalars, and the body. */
433
- export function searchableConceptText(concept: Concept): string {
434
- return searchableConceptFields(concept).join(" ");
435
- }
436
-
437
- /** Searchable fields kept separate so persistent builders do not copy a large body merely to add separators. */
438
- export function searchableConceptFields(concept: Concept): readonly string[] {
439
- const fm = concept.frontmatter;
440
- return [
441
- concept.id,
442
- frontmatterScalar(fm.title) ?? "",
443
- frontmatterScalar(fm.summary) ?? "",
444
- frontmatterScalar(fm.description) ?? "",
445
- tagsOf(concept).join(" "),
446
- concept.body,
447
- ];
448
- }
449
-
450
- /**
451
- * Build the BM25 index over **every** concept in the bundle (so IDF reflects true
452
- * corpus rarity regardless of which filters a query applies). Each concept's
453
- * {@link searchableText} is tokenized once into its term frequencies; document
454
- * frequencies and the average document length accumulate across the pass.
455
- */
456
- export function buildBm25Index(graph: BundleGraph): Bm25Index {
457
- return buildBm25RecordIndex(
458
- [...graph.concepts.values()].map((concept) => ({ id: concept.id, text: searchableConceptText(concept) })),
459
- );
460
- }
461
-
462
- /**
463
- * Build the same deterministic BM25 index over caller-owned records. This is the
464
- * reusable lexical seam profile section ranking needs; `lore query` delegates to
465
- * it, so the two consumers cannot drift in tokenization, IDF, or saturation.
466
- */
467
- export function buildBm25RecordIndex(records: readonly Bm25Record[]): Bm25Index {
468
- const docs = new Map<string, IndexedDoc>();
469
- const df = new Map<string, number>();
470
- let totalLength = 0;
471
- for (const record of records) {
472
- if (docs.has(record.id)) {
473
- throw new Error(`duplicate BM25 record id: ${record.id}`);
474
- }
475
- const tokens = tokenizeQueryText(record.text);
476
- const tf = new Map<string, number>();
477
- for (const token of tokens) {
478
- tf.set(token, (tf.get(token) ?? 0) + 1);
479
- }
480
- for (const term of tf.keys()) {
481
- df.set(term, (df.get(term) ?? 0) + 1);
482
- }
483
- docs.set(record.id, { tf, length: tokens.length });
484
- totalLength += tokens.length;
485
- }
486
- const n = docs.size;
487
- return { docs, df, n, avgdl: n === 0 ? 0 : totalLength / n };
488
- }
489
-
490
- /**
491
- * Score arbitrary records against task text. Input order is retained in the
492
- * returned array; callers apply their own stable domain tie-breaks. Punctuation-
493
- * only tasks and all-zero corpora return zero scores so declaration-order
494
- * fallback remains explicit at the caller.
495
- */
496
- export function scoreBm25Records(records: readonly Bm25Record[], text: string): readonly Bm25RecordScore[] {
497
- const terms = [...new Set(tokenizeQueryText(text.trim()))];
498
- if (terms.length === 0) return records.map((record) => ({ id: record.id, score: 0 }));
499
- const index = buildBm25RecordIndex(records);
500
- const idf = idfForTerms(index, terms);
501
- return records.map((record) => ({ id: record.id, score: scoreBm25(index, idf, record.id, terms) }));
502
- }
503
-
504
- /**
505
- * The inverse-document-frequency of each query term — computed once per query, since
506
- * IDF depends only on the corpus, not the document being scored. The always-non-negative
507
- * variant `ln(1 + (N − n + 0.5)/(n + 0.5))`, so a term in most documents can never push
508
- * a score negative.
509
- */
510
- function idfForTerms(index: Bm25Index, terms: readonly string[]): ReadonlyMap<string, number> {
511
- const idf = new Map<string, number>();
512
- for (const term of terms) {
513
- const df = index.df.get(term) ?? 0;
514
- idf.set(term, Math.log(1 + (index.n - df + 0.5) / (df + 0.5)));
515
- }
516
- return idf;
517
- }
518
-
519
- /**
520
- * The BM25 relevance of document `id` to `terms` — the sum over the query terms the
521
- * document contains of `IDF(term) · saturated-tf`, using the precomputed {@link idfForTerms}.
522
- * `avgdl` is safely positive wherever it is read: a term with a non-zero frequency means
523
- * the document has tokens, which means the corpus does too.
524
- *
525
- * `id` is always an indexed document — the index is built from the same bundle
526
- * {@link query} iterates, so every concept it scores has an entry (asserting that
527
- * keeps the scorer straight-line, with no dead "missing doc" branch). Likewise every
528
- * scored term has an `idf` entry (both come from the same query-term list).
529
- */
530
- function scoreBm25(index: Bm25Index, idf: ReadonlyMap<string, number>, id: string, terms: readonly string[]): number {
531
- const doc = index.docs.get(id) as IndexedDoc;
532
- let score = 0;
533
- for (const term of terms) {
534
- const freq = doc.tf.get(term);
535
- if (freq === undefined) {
536
- continue;
537
- }
538
- const denominator = freq + BM25_K1 * (1 - BM25_B + (BM25_B * doc.length) / index.avgdl);
539
- score += ((idf.get(term) as number) * (freq * (BM25_K1 + 1))) / denominator;
540
- }
541
- return score;
542
- }