@jestek-dev/scripture-engine 0.7.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,466 @@
1
+ /**
2
+ * Corpus access. The only module that knows SQL.
3
+ *
4
+ * Ported from Maskil's `ScriptureRepository` and extended with the token
5
+ * postings this engine adds. Everything here returns plain data; scoring
6
+ * lives in `ranking/`, so the ranker stays pure and unit-testable without a
7
+ * database.
8
+ */
9
+ import { normalizeBookAlias, resolveReferenceAttempt, } from '../reference/reference.js';
10
+ export const MAX_PHRASE_LENGTH = 500;
11
+ export const MAX_CANDIDATES = 200;
12
+ function num(row, key) {
13
+ const value = row[key];
14
+ if (typeof value !== 'number')
15
+ throw new Error(`Corpus returned invalid numeric ${key}`);
16
+ return value;
17
+ }
18
+ function str(row, key) {
19
+ const value = row[key];
20
+ if (typeof value !== 'string')
21
+ throw new Error(`Corpus returned invalid text ${key}`);
22
+ return value;
23
+ }
24
+ function mapVerse(row) {
25
+ return {
26
+ id: num(row, 'id'),
27
+ verseId: num(row, 'verseId'),
28
+ translationId: num(row, 'translationId'),
29
+ translationCode: str(row, 'translationCode'),
30
+ bookId: num(row, 'bookId'),
31
+ bookName: str(row, 'bookName'),
32
+ chapter: num(row, 'chapter'),
33
+ verse: num(row, 'verse'),
34
+ text: str(row, 'text'),
35
+ };
36
+ }
37
+ const VERSE_PROJECTION = `
38
+ SELECT v.id AS id, v.verse_id AS verseId,
39
+ v.translation_id AS translationId, t.code AS translationCode,
40
+ v.book_id AS bookId, b.name AS bookName,
41
+ b.chapter_count AS chapterCount,
42
+ v.chapter AS chapter, v.verse AS verse, v.text AS text
43
+ FROM verses v
44
+ JOIN translations t ON t.id = v.translation_id
45
+ JOIN books b ON b.id = v.book_id`;
46
+ export class CorpusRepository {
47
+ database;
48
+ constructor(database) {
49
+ this.database = database;
50
+ }
51
+ async close() {
52
+ await this.database.close();
53
+ }
54
+ async readMeta() {
55
+ const result = await this.database.execute('SELECT key, value FROM meta');
56
+ const map = new Map(result.rows.map((row) => [str(row, 'key'), str(row, 'value')]));
57
+ const required = (key) => {
58
+ const value = map.get(key);
59
+ if (value === undefined)
60
+ throw new Error(`Corpus meta is missing ${key}`);
61
+ return value;
62
+ };
63
+ return {
64
+ schemaVersion: required('schema_version'),
65
+ tokenizerVersion: required('tokenizer_version'),
66
+ corpusFingerprint: required('corpus_fingerprint'),
67
+ verseCount: Number(required('verse_count')),
68
+ avgVerseTokens: Number(required('avg_verse_tokens')),
69
+ layerFingerprint: map.get('layer_fingerprint') ?? '',
70
+ };
71
+ }
72
+ /** Total indexed verses per translation — the N in IDF. */
73
+ async documentCount() {
74
+ const result = await this.database.execute('SELECT COUNT(*) AS n FROM verses');
75
+ return num(result.rows[0] ?? {}, 'n');
76
+ }
77
+ async resolveBookAlias(aliasKey) {
78
+ const result = await this.database.execute(`SELECT b.id AS id, b.name AS name, b.chapter_count AS chapterCount
79
+ FROM book_aliases a JOIN books b ON b.id = a.book_id
80
+ WHERE a.alias_key = ? LIMIT 1`, [normalizeBookAlias(aliasKey)]);
81
+ const row = result.rows[0];
82
+ return row
83
+ ? { id: num(row, 'id'), name: str(row, 'name'), chapterCount: num(row, 'chapterCount') }
84
+ : null;
85
+ }
86
+ async getChapterVerseCount(bookId, chapter) {
87
+ const result = await this.database.execute('SELECT max(verse) AS verseCount FROM verses WHERE book_id = ? AND chapter = ?', [bookId, chapter]);
88
+ const value = result.rows[0]?.verseCount;
89
+ return typeof value === 'number' && value > 0 ? value : null;
90
+ }
91
+ async verseExists(bookId, chapter, verse) {
92
+ const result = await this.database.execute('SELECT 1 AS present FROM verses WHERE book_id = ? AND chapter = ? AND verse = ? LIMIT 1', [bookId, chapter, verse]);
93
+ return result.rows.length > 0;
94
+ }
95
+ async resolveReference(input) {
96
+ return await resolveReferenceAttempt(input, this);
97
+ }
98
+ async loadPassage(resolved) {
99
+ const result = await this.database.execute(`${VERSE_PROJECTION}
100
+ WHERE v.book_id = ? AND v.verse_id BETWEEN ? AND ?
101
+ ORDER BY v.verse_id, t.code`, [resolved.book.id, resolved.startId, resolved.endId]);
102
+ return {
103
+ reference: resolved.label,
104
+ bookId: resolved.book.id,
105
+ chapterCount: resolved.book.chapterCount,
106
+ startChapter: resolved.startChapter,
107
+ endChapter: resolved.endChapter,
108
+ verses: result.rows.map(mapVerse),
109
+ };
110
+ }
111
+ /**
112
+ * Exact phrase via FTS5, ranked by bm25.
113
+ *
114
+ * Runs against the RAW verse text, not our token stream, because "exact"
115
+ * must mean exact — a user asking for a verbatim phrase does not want
116
+ * stemming or archaic folding applied behind their back. The token intent
117
+ * is where fuzziness is allowed, and it says so in its reason label.
118
+ */
119
+ async searchPhrase(phrase, limit = MAX_CANDIDATES) {
120
+ const normalized = phrase.trim().replace(/\s+/g, ' ');
121
+ if (!normalized || normalized.length > MAX_PHRASE_LENGTH)
122
+ return [];
123
+ // Double-quote escaping makes the whole query one FTS5 string literal, so
124
+ // user input can never be interpreted as FTS operators (NEAR, OR, *).
125
+ const ftsPhrase = `"${normalized.replaceAll('"', '""')}"`;
126
+ const result = await this.database.execute(`SELECT v.id AS id, v.verse_id AS verseId,
127
+ v.translation_id AS translationId, t.code AS translationCode,
128
+ v.book_id AS bookId, b.name AS bookName,
129
+ v.chapter AS chapter, v.verse AS verse, v.text AS text,
130
+ bm25(verses_fts) AS bm25
131
+ FROM verses_fts f
132
+ JOIN verses v ON v.id = f.rowid
133
+ JOIN translations t ON t.id = v.translation_id
134
+ JOIN books b ON b.id = v.book_id
135
+ WHERE verses_fts MATCH ?
136
+ ORDER BY bm25(verses_fts), v.verse_id, t.code
137
+ LIMIT ?`, [ftsPhrase, limit]);
138
+ return result.rows.map((row) => ({ ...mapVerse(row), bm25: num(row, 'bm25') }));
139
+ }
140
+ /**
141
+ * Token search over precomputed postings.
142
+ *
143
+ * This is the intent that makes theme-ish queries work before any concept
144
+ * layer exists: query tokens are folded by the same tokenizer the corpus
145
+ * was indexed with, so "hearing and doing" reaches "heareth ... doeth".
146
+ *
147
+ * IDF is computed from stored document counts, which is why keeping common
148
+ * words as tokens is safe — "do" earns a near-zero weight automatically
149
+ * instead of needing a stopword list to predict its unimportance.
150
+ */
151
+ async searchTokens(tokens, documentCount, limit = MAX_CANDIDATES) {
152
+ const unique = [...new Set(tokens)];
153
+ if (unique.length === 0)
154
+ return [];
155
+ const placeholders = unique.map(() => '?').join(', ');
156
+ const params = [...unique, ...unique, limit];
157
+ const result = await this.database.execute(`WITH matched AS (
158
+ SELECT vt.verse_row_id AS rowId, vt.token AS token,
159
+ MIN(vt.position) AS firstPos, MAX(vt.position) AS lastPos
160
+ FROM verse_tokens vt
161
+ WHERE vt.token IN (${placeholders})
162
+ GROUP BY vt.verse_row_id, vt.token
163
+ ),
164
+ scored AS (
165
+ SELECT m.rowId AS rowId,
166
+ COUNT(*) AS tokenCount,
167
+ MIN(m.firstPos) AS spanStart,
168
+ MAX(m.lastPos) AS spanEnd,
169
+ group_concat(m.token, ' ') AS tokens
170
+ FROM matched m
171
+ GROUP BY m.rowId
172
+ )
173
+ SELECT v.id AS id, v.verse_id AS verseId,
174
+ v.translation_id AS translationId, t.code AS translationCode,
175
+ v.book_id AS bookId, b.name AS bookName,
176
+ v.chapter AS chapter, v.verse AS verse, v.text AS text,
177
+ v.token_count AS verseTokenCount,
178
+ v.distinct_token_count AS verseDistinctTokenCount,
179
+ s.tokenCount AS tokenCount, s.tokens AS tokens,
180
+ s.spanStart AS spanStart, s.spanEnd AS spanEnd,
181
+ (SELECT COALESCE(SUM(ts.document_count), 0)
182
+ FROM token_stats ts
183
+ WHERE ts.translation_id = v.translation_id
184
+ AND ts.token IN (${placeholders})) AS dfSum
185
+ FROM scored s
186
+ JOIN verses v ON v.id = s.rowId
187
+ JOIN translations t ON t.id = v.translation_id
188
+ JOIN books b ON b.id = v.book_id
189
+ ORDER BY s.tokenCount DESC, v.verse_id, t.code
190
+ LIMIT ?`, params);
191
+ // IDF is applied here rather than in SQL: it needs per-token document
192
+ // counts, and doing the arithmetic in TypeScript keeps the weighting
193
+ // formula visible and testable instead of buried in a query.
194
+ const stats = await this.tokenDocumentCounts(unique);
195
+ return result.rows.map((row) => {
196
+ const matchedTokens = str(row, 'tokens').split(' ').filter(Boolean);
197
+ const distinct = [...new Set(matchedTokens)].sort();
198
+ const idfSum = distinct.reduce((sum, token) => {
199
+ const df = stats.get(token) ?? 0;
200
+ // Smoothed IDF; +1 keeps a token appearing in every verse at weight 0
201
+ // rather than negative, so ubiquity is worthless, never harmful.
202
+ return sum + Math.log(1 + documentCount / Math.max(1, df));
203
+ }, 0);
204
+ const spanStart = num(row, 'spanStart');
205
+ const spanEnd = num(row, 'spanEnd');
206
+ return {
207
+ ...mapVerse(row),
208
+ matchedTokens: distinct,
209
+ idfSum,
210
+ minSpan: distinct.length > 1 ? spanEnd - spanStart : null,
211
+ tokenCount: num(row, 'verseTokenCount'),
212
+ distinctTokenCount: num(row, 'verseDistinctTokenCount'),
213
+ };
214
+ });
215
+ }
216
+ async tokenDocumentCounts(tokens) {
217
+ const unique = [...new Set(tokens)];
218
+ if (unique.length === 0)
219
+ return new Map();
220
+ const placeholders = unique.map(() => '?').join(', ');
221
+ const result = await this.database.execute(`SELECT token, SUM(document_count) AS df
222
+ FROM token_stats WHERE token IN (${placeholders}) GROUP BY token`, unique);
223
+ return new Map(result.rows.map((row) => [str(row, 'token'), num(row, 'df')]));
224
+ }
225
+ }
226
+ /** Longest fragment length worth searching; below this, phrases are noise. */
227
+ const MIN_FRAGMENT_WORDS = 3;
228
+ /** Queries longer than this skip fragment search to bound query cost. */
229
+ const MAX_FRAGMENT_QUERY_WORDS = 14;
230
+ /**
231
+ * Finds the LONGEST verbatim fragment of the query present in the corpus.
232
+ *
233
+ * Users paraphrase. "be doers of the word not hearers only" is nobody's
234
+ * translation verbatim, but "doers of the word" is exactly James 1:22 — and
235
+ * a search that only tries the whole query throws that away, leaving the
236
+ * decision to weaker signals.
237
+ *
238
+ * Longest-first with early exit: the first fragment length that matches wins,
239
+ * so we never pay for shorter, vaguer fragments once a strong one is found.
240
+ * Strength is computed by the caller as fragmentWords / queryWords, which
241
+ * makes partial verbatim evidence proportional to how much of the question it
242
+ * actually answers — a full verbatim match still earns full authority.
243
+ */
244
+ export async function searchLongestFragment(repository, query, limit = MAX_CANDIDATES) {
245
+ const words = query.trim().split(/\s+/).filter(Boolean);
246
+ if (words.length < MIN_FRAGMENT_WORDS || words.length > MAX_FRAGMENT_QUERY_WORDS)
247
+ return null;
248
+ for (let size = words.length; size >= MIN_FRAGMENT_WORDS; size -= 1) {
249
+ for (let start = 0; start + size <= words.length; start += 1) {
250
+ const fragment = words.slice(start, start + size).join(' ');
251
+ const matches = await repository.searchPhrase(fragment, limit);
252
+ if (matches.length > 0) {
253
+ return { fragment, fragmentWords: size, queryWords: words.length, matches };
254
+ }
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * Concept lookup and anchor expansion — Layer A at query time.
261
+ *
262
+ * Kept in the repository (not the intents module) because it is SQL; the
263
+ * scoring decisions live in `intents/concept.ts` so they stay pure.
264
+ */
265
+ export class ConceptRepository {
266
+ database;
267
+ constructor(database) {
268
+ this.database = database;
269
+ }
270
+ /**
271
+ * Concepts whose lexicon phrase is fully contained in the query's tokens.
272
+ *
273
+ * Containment, not similarity: "hearing and doing" (tokens hear, do) fires
274
+ * the concept because every token of the lexicon phrase is present. A
275
+ * fuzzy threshold here would be a second, hidden ranking system competing
276
+ * with the real one — the lexicon is curated precisely so matching can be
277
+ * exact and explainable.
278
+ */
279
+ async matchConcepts(queryTokens) {
280
+ const tokens = [...new Set(queryTokens)];
281
+ if (tokens.length === 0)
282
+ return [];
283
+ const result = await this.database.execute(`SELECT cl.concept_id AS conceptId, c.label AS label,
284
+ cl.phrase AS phrase, cl.normalized AS normalized, cl.token_count AS tokenCount
285
+ FROM concept_lexicon cl
286
+ JOIN concepts c ON c.id = cl.concept_id`);
287
+ const present = new Set(tokens);
288
+ const best = new Map();
289
+ for (const row of result.rows) {
290
+ const phraseTokens = str(row, 'normalized').split(' ').filter(Boolean);
291
+ if (phraseTokens.length === 0)
292
+ continue;
293
+ if (!phraseTokens.every((token) => present.has(token)))
294
+ continue;
295
+ const candidate = {
296
+ conceptId: str(row, 'conceptId'),
297
+ label: str(row, 'label'),
298
+ matchedPhrase: str(row, 'phrase'),
299
+ matchedTokenCount: num(row, 'tokenCount'),
300
+ };
301
+ // Keep the most specific matching phrase per concept: a three-token
302
+ // phrase matching is stronger evidence than a one-token one.
303
+ const existing = best.get(candidate.conceptId);
304
+ if (!existing || candidate.matchedTokenCount > existing.matchedTokenCount) {
305
+ best.set(candidate.conceptId, candidate);
306
+ }
307
+ }
308
+ return [...best.values()].sort((a, b) => a.conceptId < b.conceptId ? -1 : a.conceptId > b.conceptId ? 1 : 0);
309
+ }
310
+ /** Verses anchored by the given concepts. */
311
+ async anchorVerses(conceptIds) {
312
+ const unique = [...new Set(conceptIds)];
313
+ if (unique.length === 0)
314
+ return [];
315
+ const placeholders = unique.map(() => '?').join(', ');
316
+ const result = await this.database.execute(`SELECT v.id AS id, v.verse_id AS verseId,
317
+ v.translation_id AS translationId, t.code AS translationCode,
318
+ v.book_id AS bookId, b.name AS bookName,
319
+ v.chapter AS chapter, v.verse AS verse, v.text AS text,
320
+ a.concept_id AS conceptId, c.label AS conceptLabel,
321
+ a.source_id AS sourceId, a.weight AS weight, a.locator AS locator
322
+ FROM concept_anchors a
323
+ JOIN concepts c ON c.id = a.concept_id
324
+ JOIN verses v ON v.verse_id BETWEEN a.start_verse_id AND a.end_verse_id
325
+ JOIN translations t ON t.id = v.translation_id
326
+ JOIN books b ON b.id = v.book_id
327
+ WHERE a.concept_id IN (${placeholders})
328
+ ORDER BY a.concept_id, v.verse_id, t.code`, unique);
329
+ return result.rows.map((row) => ({
330
+ ...mapVerse(row),
331
+ conceptId: str(row, 'conceptId'),
332
+ conceptLabel: str(row, 'conceptLabel'),
333
+ sourceId: str(row, 'sourceId'),
334
+ weight: num(row, 'weight'),
335
+ locator: typeof row['locator'] === 'string' ? row['locator'] : null,
336
+ }));
337
+ }
338
+ /**
339
+ * The reverse of anchorVerses: which curated concepts name THIS passage?
340
+ *
341
+ * Powers `related()`, whose contract is "what did a human connect to this
342
+ * text", not "what resembles it". An anchor overlapping the passage at all
343
+ * counts, because a concept anchored to James 1:22-25 is about James 1:23
344
+ * even though it does not name that verse alone.
345
+ */
346
+ async conceptsAnchoring(startVerseId, endVerseId) {
347
+ const result = await this.database.execute(`SELECT DISTINCT a.concept_id AS conceptId, c.label AS label
348
+ FROM concept_anchors a
349
+ JOIN concepts c ON c.id = a.concept_id
350
+ WHERE a.start_verse_id <= ? AND a.end_verse_id >= ?
351
+ ORDER BY a.concept_id`, [endVerseId, startVerseId]);
352
+ return result.rows.map((row) => ({
353
+ conceptId: str(row, 'conceptId'),
354
+ label: str(row, 'label'),
355
+ }));
356
+ }
357
+ /** Concepts one hop away in the curated graph. */
358
+ async relatedConcepts(conceptIds) {
359
+ const unique = [...new Set(conceptIds)];
360
+ if (unique.length === 0)
361
+ return [];
362
+ const placeholders = unique.map(() => '?').join(', ');
363
+ const result = await this.database.execute(`SELECT DISTINCT related_id AS relatedId FROM concept_related
364
+ WHERE concept_id IN (${placeholders}) ORDER BY related_id`, unique);
365
+ return result.rows
366
+ .map((row) => str(row, 'relatedId'))
367
+ .filter((id) => !unique.includes(id));
368
+ }
369
+ /**
370
+ * Verses reached by cross-reference from the given seed verses.
371
+ *
372
+ * Bounded per seed: an unbounded expansion would let one well-connected
373
+ * verse flood the candidate set, which is precision erosion by another
374
+ * name.
375
+ */
376
+ async expandCrossReferences(fromVerseIds, perSeedLimit = 5) {
377
+ const unique = [...new Set(fromVerseIds)];
378
+ if (unique.length === 0)
379
+ return [];
380
+ const placeholders = unique.map(() => '?').join(', ');
381
+ const result = await this.database.execute(`WITH ranked AS (
382
+ SELECT x.from_verse_id AS fromVerseId, x.to_start_verse_id AS toStart,
383
+ x.to_end_verse_id AS toEnd, x.source_id AS sourceId, x.votes AS votes,
384
+ ROW_NUMBER() OVER (
385
+ PARTITION BY x.from_verse_id ORDER BY x.votes DESC, x.to_start_verse_id
386
+ ) AS rn
387
+ FROM cross_references x
388
+ WHERE x.from_verse_id IN (${placeholders})
389
+ )
390
+ SELECT v.id AS id, v.verse_id AS verseId,
391
+ v.translation_id AS translationId, t.code AS translationCode,
392
+ v.book_id AS bookId, b.name AS bookName,
393
+ v.chapter AS chapter, v.verse AS verse, v.text AS text,
394
+ r.fromVerseId AS fromVerseId, r.sourceId AS sourceId, r.votes AS votes
395
+ FROM ranked r
396
+ JOIN verses v ON v.verse_id BETWEEN r.toStart AND r.toEnd
397
+ JOIN translations t ON t.id = v.translation_id
398
+ JOIN books b ON b.id = v.book_id
399
+ WHERE r.rn <= ?
400
+ ORDER BY r.votes DESC, v.verse_id, t.code`, [...unique, perSeedLimit]);
401
+ return result.rows.map((row) => ({
402
+ ...mapVerse(row),
403
+ fromVerseId: num(row, 'fromVerseId'),
404
+ sourceId: str(row, 'sourceId'),
405
+ votes: num(row, 'votes'),
406
+ }));
407
+ }
408
+ /** Highest observed vote count, used to normalize vote-derived strength. */
409
+ async maxCrossReferenceVotes() {
410
+ const result = await this.database.execute('SELECT COALESCE(MAX(votes), 0) AS maxVotes FROM cross_references');
411
+ return num(result.rows[0] ?? { maxVotes: 0 }, 'maxVotes');
412
+ }
413
+ /**
414
+ * Verses whose pericope profile contains query terms (Layer B).
415
+ *
416
+ * Weak evidence by design and by budget: a preacher using a word while
417
+ * expounding a passage is real signal about what the passage is ABOUT, but
418
+ * it is a long way from the passage saying it. G6 caps it so no volume of
419
+ * homiletical vocabulary can outrank a curated anchor or a verbatim quote.
420
+ */
421
+ async searchPassageTerms(terms, limit = 60) {
422
+ const unique = [...new Set(terms)];
423
+ if (unique.length === 0)
424
+ return [];
425
+ const placeholders = unique.map(() => '?').join(', ');
426
+ const result = await this.database.execute(`WITH hits AS (
427
+ SELECT vt.verse_id AS vid,
428
+ group_concat(vt.term, ' ') AS terms,
429
+ SUM(vt.pmi) AS pmiSum,
430
+ MIN(vt.min_span_verses) AS minSpan,
431
+ MIN(vt.source_ids) AS sourceIds,
432
+ MIN(vt.locator) AS locator
433
+ FROM verse_terms vt
434
+ WHERE vt.term IN (${placeholders})
435
+ GROUP BY vt.verse_id
436
+ )
437
+ SELECT v.id AS id, v.verse_id AS verseId,
438
+ v.translation_id AS translationId, t.code AS translationCode,
439
+ v.book_id AS bookId, b.name AS bookName,
440
+ v.chapter AS chapter, v.verse AS verse, v.text AS text,
441
+ h.terms AS terms, h.pmiSum AS pmiSum, h.minSpan AS minSpan,
442
+ h.sourceIds AS sourceIds, h.locator AS locator
443
+ FROM hits h
444
+ JOIN verses v ON v.verse_id = h.vid
445
+ JOIN translations t ON t.id = v.translation_id
446
+ JOIN books b ON b.id = v.book_id
447
+ ORDER BY h.pmiSum DESC, v.verse_id, t.code
448
+ LIMIT ?`, [...unique, limit]);
449
+ return result.rows.map((row) => ({
450
+ ...mapVerse(row),
451
+ matchedTerms: [...new Set(str(row, 'terms').split(' ').filter(Boolean))].sort(),
452
+ pmiSum: num(row, 'pmiSum'),
453
+ sourceIds: str(row, 'sourceIds'),
454
+ minSpanVerses: num(row, 'minSpan'),
455
+ locator: str(row, 'locator'),
456
+ }));
457
+ }
458
+ async hasPassageTerms() {
459
+ const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='verse_terms'");
460
+ return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
461
+ }
462
+ async hasConceptLayer() {
463
+ const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='concepts'");
464
+ return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
465
+ }
466
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The orchestrator — the only place in the engine that does I/O, and it does
3
+ * it through `ContentQueryPort` alone.
4
+ *
5
+ * Intent order follows the 2026-07-20 plan exactly:
6
+ * 1. explicit reference lookup
7
+ * 2. exact normalized phrase
8
+ * 3. distinctive tokens with proximity preference
9
+ * 4. conservative normalization (inflection + archaic forms)
10
+ * Curated expansion (concepts, cross-references) attaches at step 5 in Phase
11
+ * 2 without changing anything above it.
12
+ */
13
+ import { type RankOptions } from './ranking/rank.js';
14
+ import type { ConceptMatch, ContentQueryPort, PassageResult, RelatedResult, ResearchResult, SongInput } from './types.js';
15
+ export interface EngineOptions {
16
+ /**
17
+ * Throw if the artifact was tokenized by a different tokenizer version.
18
+ * Defaults true, and should stay true outside diagnostics: precomputed
19
+ * postings from another tokenizer describe a vocabulary this runtime
20
+ * cannot reproduce, which yields quietly wrong rankings rather than errors.
21
+ */
22
+ readonly enforceTokenizerVersion?: boolean;
23
+ readonly rankOptions?: RankOptions;
24
+ }
25
+ export interface ScriptureEngine {
26
+ /** The full ladder with auto-detected intent: reference, phrase, tokens, concepts. */
27
+ research(query: string): Promise<ResearchResult>;
28
+ /**
29
+ * Concept resolution only — "what themes does this text name?" — with no
30
+ * ranking and no verse retrieval.
31
+ *
32
+ * Separate from research() because consumers need the concepts themselves,
33
+ * not passages: Versed builds memorization packs per theme, Setlist shows
34
+ * which themes a sermon note resolved to before any song is scored.
35
+ */
36
+ themes(query: string): Promise<readonly ConceptMatch[]>;
37
+ /** Parse and fetch a passage. Invalid references are typed, never thrown. */
38
+ passage(reference: string): Promise<PassageResult>;
39
+ /**
40
+ * What curated sources connect to a passage: cross-reference edges, and the
41
+ * concepts whose anchors include it. Not similarity — every entry exists
42
+ * because a human recorded the link.
43
+ */
44
+ related(reference: string): Promise<RelatedResult>;
45
+ /**
46
+ * Multi-field discovery for song and sermon workflows.
47
+ *
48
+ * Setlist matches a sermon theme to songs; Maskil starts from a song being
49
+ * written. Both have several fields of differing evidential weight, and
50
+ * neither should have to flatten them into one string and lose that.
51
+ */
52
+ forSong(input: SongInput): Promise<ResearchResult>;
53
+ close(): Promise<void>;
54
+ readonly corpusFingerprint: string;
55
+ readonly layerFingerprint: string;
56
+ readonly engineVersion: string;
57
+ }
58
+ export declare function createEngine(database: ContentQueryPort, options?: EngineOptions): Promise<ScriptureEngine>;