@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.
- package/dist/config/engineVersion.d.ts +19 -0
- package/dist/config/engineVersion.js +19 -0
- package/dist/corpus/repository.d.ts +193 -0
- package/dist/corpus/repository.js +466 -0
- package/dist/createEngine.d.ts +58 -0
- package/dist/createEngine.js +338 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +18 -0
- package/dist/intents/concept.d.ts +69 -0
- package/dist/intents/concept.js +143 -0
- package/dist/intents/lexical.d.ts +51 -0
- package/dist/intents/lexical.js +136 -0
- package/dist/ranking/budgets.d.ts +65 -0
- package/dist/ranking/budgets.js +149 -0
- package/dist/ranking/rank.d.ts +42 -0
- package/dist/ranking/rank.js +71 -0
- package/dist/reasons/types.d.ts +55 -0
- package/dist/reasons/types.js +20 -0
- package/dist/reference/reference.d.ts +31 -0
- package/dist/reference/reference.js +130 -0
- package/dist/reference/verseId.d.ts +12 -0
- package/dist/reference/verseId.js +36 -0
- package/dist/tokenizer/index.d.ts +39 -0
- package/dist/tokenizer/index.js +207 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +11 -0
- package/package.json +27 -0
|
@@ -0,0 +1,338 @@
|
|
|
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 { ConceptRepository, CorpusRepository, searchLongestFragment, } from './corpus/repository.js';
|
|
14
|
+
import { ENGINE_VERSION, TOKENIZER_VERSION } from './config/engineVersion.js';
|
|
15
|
+
import { mergeCandidates, phraseEvidence, queryIdfTotal, referenceLabel, significantWords, targetIdFor, tokenEvidence, } from './intents/lexical.js';
|
|
16
|
+
import { conceptAnchorEvidence, crossReferenceEvidence, passageTermEvidence, relatedConceptEvidence, } from './intents/concept.js';
|
|
17
|
+
import { rank } from './ranking/rank.js';
|
|
18
|
+
const SUPPORTED_SCHEMA_VERSIONS = new Set(['1', '2', '3', '4', '5']);
|
|
19
|
+
/**
|
|
20
|
+
* Lyric tokens admitted to forSong(). A full lyric sheet is hundreds of
|
|
21
|
+
* mostly-common words; past this point they add candidates without adding
|
|
22
|
+
* evidence, and drown the themes the writer actually stated.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_LYRIC_TOKENS = 40;
|
|
25
|
+
export async function createEngine(database, options = {}) {
|
|
26
|
+
const repository = new CorpusRepository(database);
|
|
27
|
+
const meta = await repository.readMeta();
|
|
28
|
+
if (!SUPPORTED_SCHEMA_VERSIONS.has(meta.schemaVersion)) {
|
|
29
|
+
throw new Error(`createEngine: artifact schema v${meta.schemaVersion} is not supported by ` +
|
|
30
|
+
`engine ${ENGINE_VERSION} (supports v${[...SUPPORTED_SCHEMA_VERSIONS].join(', v')})`);
|
|
31
|
+
}
|
|
32
|
+
if ((options.enforceTokenizerVersion ?? true) && meta.tokenizerVersion !== TOKENIZER_VERSION) {
|
|
33
|
+
throw new Error(`createEngine: artifact was tokenized by tokenizer ${meta.tokenizerVersion} but this ` +
|
|
34
|
+
`engine uses ${TOKENIZER_VERSION}. Precomputed token postings would describe a ` +
|
|
35
|
+
`vocabulary this runtime cannot reproduce. Rebuild the artifact.`);
|
|
36
|
+
}
|
|
37
|
+
// The concept layer is optional: a v1 artifact has no concept tables, and
|
|
38
|
+
// the engine still works as a lexical search rather than refusing to open.
|
|
39
|
+
const conceptRepository = new ConceptRepository(database);
|
|
40
|
+
const concepts = (await conceptRepository.hasConceptLayer()) ? conceptRepository : null;
|
|
41
|
+
const hasPassageTerms = await conceptRepository.hasPassageTerms();
|
|
42
|
+
const documentCount = await repository.documentCount();
|
|
43
|
+
const identity = {
|
|
44
|
+
engineVersion: ENGINE_VERSION,
|
|
45
|
+
corpusFingerprint: meta.corpusFingerprint,
|
|
46
|
+
layerFingerprint: meta.layerFingerprint,
|
|
47
|
+
};
|
|
48
|
+
async function discover(query) {
|
|
49
|
+
const verses = new Map();
|
|
50
|
+
const contributions = [];
|
|
51
|
+
// Step 2 — verbatim text. Tries the whole query first and falls back to
|
|
52
|
+
// its longest matching fragment, so a paraphrase still gets credit for
|
|
53
|
+
// the part the user quoted exactly. Only for multi-word queries: a single
|
|
54
|
+
// word "matching a phrase" is the token intent wearing an authoritative
|
|
55
|
+
// badge it has not earned.
|
|
56
|
+
if (query.trim().includes(' ')) {
|
|
57
|
+
const whole = await repository.searchPhrase(query);
|
|
58
|
+
const queryWords = query.trim().split(/\s+/).filter(Boolean).length;
|
|
59
|
+
if (whole.length > 0) {
|
|
60
|
+
for (const match of whole) {
|
|
61
|
+
verses.set(targetIdFor(match), match);
|
|
62
|
+
contributions.push({
|
|
63
|
+
verse: match,
|
|
64
|
+
evidence: [phraseEvidence(query.trim(), queryWords, queryWords)],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const fragment = await searchLongestFragment(repository, query);
|
|
70
|
+
if (fragment) {
|
|
71
|
+
for (const match of fragment.matches) {
|
|
72
|
+
verses.set(targetIdFor(match), match);
|
|
73
|
+
contributions.push({
|
|
74
|
+
verse: match,
|
|
75
|
+
evidence: [
|
|
76
|
+
phraseEvidence(fragment.fragment, fragment.fragmentWords, fragment.queryWords),
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Steps 3-4 — tokens with proximity. Normalization is inherent: the
|
|
84
|
+
// shared tokenizer folds inflection and archaic forms on both sides.
|
|
85
|
+
const tokens = significantWords(query);
|
|
86
|
+
if (tokens.length > 0) {
|
|
87
|
+
const frequencies = await repository.tokenDocumentCounts(tokens);
|
|
88
|
+
const idfTotal = queryIdfTotal(tokens, frequencies, documentCount);
|
|
89
|
+
for (const match of await repository.searchTokens(tokens, documentCount)) {
|
|
90
|
+
verses.set(targetIdFor(match), match);
|
|
91
|
+
contributions.push({ verse: match, evidence: tokenEvidence(match, idfTotal) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Step 5a — homiletical vocabulary. Weak by design and weak by budget.
|
|
95
|
+
if (hasPassageTerms && tokens.length > 0) {
|
|
96
|
+
for (const match of await conceptRepository.searchPassageTerms(tokens)) {
|
|
97
|
+
verses.set(targetIdFor(match), match);
|
|
98
|
+
contributions.push({ verse: match, evidence: [passageTermEvidence(match)] });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Step 5 — curated concept expansion. This is the step that can find a
|
|
102
|
+
// passage sharing NO vocabulary with the query, because a human recorded
|
|
103
|
+
// that it belongs. Everything above it is untouched by its presence.
|
|
104
|
+
if (concepts && tokens.length > 0) {
|
|
105
|
+
const matched = await concepts.matchConcepts(tokens);
|
|
106
|
+
if (matched.length > 0) {
|
|
107
|
+
const specificity = new Map(matched.map((match) => [match.conceptId, match.matchedTokenCount]));
|
|
108
|
+
const anchors = await concepts.anchorVerses(matched.map((match) => match.conceptId));
|
|
109
|
+
for (const anchor of anchors) {
|
|
110
|
+
verses.set(targetIdFor(anchor), anchor);
|
|
111
|
+
contributions.push({
|
|
112
|
+
verse: anchor,
|
|
113
|
+
evidence: [
|
|
114
|
+
conceptAnchorEvidence(anchor, specificity.get(anchor.conceptId) ?? 1),
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// One hop through the curated graph, filed as weak evidence.
|
|
119
|
+
const relatedIds = await concepts.relatedConcepts(matched.map((match) => match.conceptId));
|
|
120
|
+
for (const anchor of await concepts.anchorVerses(relatedIds)) {
|
|
121
|
+
verses.set(targetIdFor(anchor), anchor);
|
|
122
|
+
contributions.push({ verse: anchor, evidence: [relatedConceptEvidence(anchor)] });
|
|
123
|
+
}
|
|
124
|
+
// Cross-reference expansion seeded ONLY from concept anchors, never
|
|
125
|
+
// from arbitrary lexical hits. Seeding from weak matches is how a
|
|
126
|
+
// curated graph turns into a random walk.
|
|
127
|
+
const seeds = [...new Set(anchors.map((anchor) => anchor.verseId))].sort((a, b) => a - b);
|
|
128
|
+
const seedLabels = new Map(anchors.map((anchor) => [anchor.verseId, referenceLabel(anchor)]));
|
|
129
|
+
const maxVotes = await concepts.maxCrossReferenceVotes();
|
|
130
|
+
for (const edge of await concepts.expandCrossReferences(seeds)) {
|
|
131
|
+
verses.set(targetIdFor(edge), edge);
|
|
132
|
+
contributions.push({
|
|
133
|
+
verse: edge,
|
|
134
|
+
evidence: [
|
|
135
|
+
crossReferenceEvidence(edge, maxVotes, seedLabels.get(edge.fromVerseId) ?? 'a matched passage'),
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const ranked = rank(mergeCandidates(contributions), options.rankOptions);
|
|
142
|
+
return ranked.map((result) => {
|
|
143
|
+
const verse = verses.get(result.targetId);
|
|
144
|
+
return {
|
|
145
|
+
targetId: result.targetId,
|
|
146
|
+
reference: referenceLabel(verse),
|
|
147
|
+
excerpt: verse.text,
|
|
148
|
+
score: result.score,
|
|
149
|
+
reasons: result.reasons,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async function relatedFor(reference) {
|
|
154
|
+
const trimmed = reference.trim();
|
|
155
|
+
const attempt = await repository.resolveReference(trimmed);
|
|
156
|
+
if (attempt.kind !== 'resolved') {
|
|
157
|
+
return { kind: 'invalid-reference', query: trimmed, ...identity };
|
|
158
|
+
}
|
|
159
|
+
const resolved = attempt.reference;
|
|
160
|
+
if (!concepts) {
|
|
161
|
+
return { kind: 'related', reference: resolved.label, concepts: [], results: [], ...identity };
|
|
162
|
+
}
|
|
163
|
+
const anchoring = await concepts.conceptsAnchoring(resolved.startId, resolved.endId);
|
|
164
|
+
// Populate each concept's full anchor list, exactly as themes() does.
|
|
165
|
+
// A consumer showing "this passage belongs to Refuge in trouble" wants
|
|
166
|
+
// the rest of that concept's passages as the obvious next click, and an
|
|
167
|
+
// empty array here reads as "this concept anchors nothing" rather than
|
|
168
|
+
// as data we declined to fetch.
|
|
169
|
+
const anchorsByConcept = new Map();
|
|
170
|
+
for (const anchor of await concepts.anchorVerses(anchoring.map((concept) => concept.conceptId))) {
|
|
171
|
+
const bucket = anchorsByConcept.get(anchor.conceptId);
|
|
172
|
+
const label = referenceLabel(anchor);
|
|
173
|
+
if (bucket) {
|
|
174
|
+
if (!bucket.includes(label))
|
|
175
|
+
bucket.push(label);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
anchorsByConcept.set(anchor.conceptId, [label]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Seed cross-reference expansion from the verses the user actually
|
|
182
|
+
// named. Unlike discovery, there is no query to be wrong about: the
|
|
183
|
+
// passage IS the input, so its edges are exactly what was asked for.
|
|
184
|
+
const seeds = [];
|
|
185
|
+
for (let verseId = resolved.startId; verseId <= resolved.endId; verseId += 1) {
|
|
186
|
+
seeds.push(verseId);
|
|
187
|
+
}
|
|
188
|
+
const maxVotes = await concepts.maxCrossReferenceVotes();
|
|
189
|
+
const verses = new Map();
|
|
190
|
+
const contributions = [];
|
|
191
|
+
for (const edge of await concepts.expandCrossReferences(seeds)) {
|
|
192
|
+
// A passage is not related to itself.
|
|
193
|
+
if (edge.verseId >= resolved.startId && edge.verseId <= resolved.endId)
|
|
194
|
+
continue;
|
|
195
|
+
verses.set(targetIdFor(edge), edge);
|
|
196
|
+
contributions.push({
|
|
197
|
+
verse: edge,
|
|
198
|
+
evidence: [crossReferenceEvidence(edge, maxVotes, resolved.label)],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
const ranked = rank(mergeCandidates(contributions), options.rankOptions);
|
|
202
|
+
return {
|
|
203
|
+
kind: 'related',
|
|
204
|
+
reference: resolved.label,
|
|
205
|
+
concepts: anchoring.map((concept) => ({
|
|
206
|
+
conceptId: concept.conceptId,
|
|
207
|
+
label: concept.label,
|
|
208
|
+
matchedOn: resolved.label,
|
|
209
|
+
anchors: anchorsByConcept.get(concept.conceptId) ?? [],
|
|
210
|
+
})),
|
|
211
|
+
results: ranked.map((result) => {
|
|
212
|
+
const verse = verses.get(result.targetId);
|
|
213
|
+
return {
|
|
214
|
+
targetId: result.targetId,
|
|
215
|
+
reference: referenceLabel(verse),
|
|
216
|
+
excerpt: verse.text,
|
|
217
|
+
score: result.score,
|
|
218
|
+
reasons: result.reasons,
|
|
219
|
+
};
|
|
220
|
+
}),
|
|
221
|
+
...identity,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
engineVersion: ENGINE_VERSION,
|
|
226
|
+
corpusFingerprint: meta.corpusFingerprint,
|
|
227
|
+
layerFingerprint: meta.layerFingerprint,
|
|
228
|
+
async research(query) {
|
|
229
|
+
const trimmed = query.trim();
|
|
230
|
+
// Step 1 — an explicit reference wins outright and short-circuits.
|
|
231
|
+
// Discovery never runs for "Ps 46": the user asked for a passage, not
|
|
232
|
+
// for verses that resemble the string "Ps 46".
|
|
233
|
+
const attempt = await repository.resolveReference(trimmed);
|
|
234
|
+
if (attempt.kind === 'resolved') {
|
|
235
|
+
return {
|
|
236
|
+
kind: 'reference',
|
|
237
|
+
passage: await repository.loadPassage(attempt.reference),
|
|
238
|
+
...identity,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (attempt.kind === 'invalid-reference') {
|
|
242
|
+
return { kind: 'invalid-reference', query: trimmed, ...identity };
|
|
243
|
+
}
|
|
244
|
+
return { kind: 'discovery', query: trimmed, results: await discover(trimmed), ...identity };
|
|
245
|
+
},
|
|
246
|
+
async themes(query) {
|
|
247
|
+
if (!concepts)
|
|
248
|
+
return [];
|
|
249
|
+
const tokens = significantWords(query);
|
|
250
|
+
if (tokens.length === 0)
|
|
251
|
+
return [];
|
|
252
|
+
const matched = await concepts.matchConcepts(tokens);
|
|
253
|
+
if (matched.length === 0)
|
|
254
|
+
return [];
|
|
255
|
+
// One anchor query for all matched concepts rather than one per concept:
|
|
256
|
+
// the cost is the same and the ordering is stable.
|
|
257
|
+
const anchors = await concepts.anchorVerses(matched.map((match) => match.conceptId));
|
|
258
|
+
const byConcept = new Map();
|
|
259
|
+
for (const anchor of anchors) {
|
|
260
|
+
const bucket = byConcept.get(anchor.conceptId);
|
|
261
|
+
const label = referenceLabel(anchor);
|
|
262
|
+
if (bucket) {
|
|
263
|
+
if (!bucket.includes(label))
|
|
264
|
+
bucket.push(label);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
byConcept.set(anchor.conceptId, [label]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Most specific first — a three-word lexicon hit says more about intent
|
|
271
|
+
// than a one-word one — then by id, so ties never depend on row order.
|
|
272
|
+
return [...matched]
|
|
273
|
+
.sort((a, b) => b.matchedTokenCount !== a.matchedTokenCount
|
|
274
|
+
? b.matchedTokenCount - a.matchedTokenCount
|
|
275
|
+
: a.conceptId < b.conceptId
|
|
276
|
+
? -1
|
|
277
|
+
: 1)
|
|
278
|
+
.map((match) => ({
|
|
279
|
+
conceptId: match.conceptId,
|
|
280
|
+
label: match.label,
|
|
281
|
+
matchedOn: match.matchedPhrase,
|
|
282
|
+
anchors: byConcept.get(match.conceptId) ?? [],
|
|
283
|
+
}));
|
|
284
|
+
},
|
|
285
|
+
async passage(reference) {
|
|
286
|
+
const trimmed = reference.trim();
|
|
287
|
+
const attempt = await repository.resolveReference(trimmed);
|
|
288
|
+
if (attempt.kind === 'resolved') {
|
|
289
|
+
return {
|
|
290
|
+
kind: 'passage',
|
|
291
|
+
passage: await repository.loadPassage(attempt.reference),
|
|
292
|
+
...identity,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
return { kind: 'invalid-reference', query: trimmed, ...identity };
|
|
296
|
+
},
|
|
297
|
+
related: relatedFor,
|
|
298
|
+
async forSong(input) {
|
|
299
|
+
// Fields are concatenated in a FIXED order, most-intentional first, and
|
|
300
|
+
// lyrics are truncated. Both choices are about determinism and noise
|
|
301
|
+
// rather than taste: field order must not depend on object key order,
|
|
302
|
+
// and a full lyric sheet contributes hundreds of low-IDF tokens that
|
|
303
|
+
// would swamp a stated theme without adding evidence.
|
|
304
|
+
const parts = [];
|
|
305
|
+
if (input.themes?.length)
|
|
306
|
+
parts.push(input.themes.join(' '));
|
|
307
|
+
if (input.title)
|
|
308
|
+
parts.push(input.title);
|
|
309
|
+
if (input.lyrics)
|
|
310
|
+
parts.push(significantWords(input.lyrics).slice(0, MAX_LYRIC_TOKENS).join(' '));
|
|
311
|
+
const query = parts.join(' ').trim();
|
|
312
|
+
const results = query === '' ? [] : await discover(query);
|
|
313
|
+
// A foundational reference is a claim the writer made about the song, so
|
|
314
|
+
// its curated edges are admitted alongside discovery — but never as the
|
|
315
|
+
// only input, and never seeded from lyrics, which are not a claim.
|
|
316
|
+
if (input.foundationalRef && concepts) {
|
|
317
|
+
const attempt = await repository.resolveReference(input.foundationalRef);
|
|
318
|
+
if (attempt.kind === 'resolved') {
|
|
319
|
+
const related = await relatedFor(input.foundationalRef);
|
|
320
|
+
if (related.kind === 'related') {
|
|
321
|
+
const seen = new Set(results.map((result) => result.targetId));
|
|
322
|
+
const merged = [...results];
|
|
323
|
+
for (const extra of related.results) {
|
|
324
|
+
if (!seen.has(extra.targetId))
|
|
325
|
+
merged.push(extra);
|
|
326
|
+
}
|
|
327
|
+
merged.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.targetId < b.targetId ? -1 : 1));
|
|
328
|
+
return { kind: 'discovery', query, results: merged, ...identity };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { kind: 'discovery', query, results, ...identity };
|
|
333
|
+
},
|
|
334
|
+
async close() {
|
|
335
|
+
await repository.close();
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for @jestek-dev/scripture-engine.
|
|
3
|
+
*
|
|
4
|
+
* Phase 0 exports the pure core that later phases build on: the shared
|
|
5
|
+
* tokenizer, the reference parser, the typed reason vocabulary, the signal
|
|
6
|
+
* budgets, and the deterministic ranker. The intent ladder (Phase 1) and the
|
|
7
|
+
* concept layer (Phase 2) attach to these without changing their contracts.
|
|
8
|
+
*/
|
|
9
|
+
export { ENGINE_VERSION, TOKENIZER_VERSION } from './config/engineVersion.js';
|
|
10
|
+
export { createEngine, type EngineOptions, type ScriptureEngine } from './createEngine.js';
|
|
11
|
+
export { CorpusRepository, MAX_CANDIDATES, MAX_PHRASE_LENGTH, type CorpusMeta, type PhraseMatch, type TokenMatch, } from './corpus/repository.js';
|
|
12
|
+
export { groupIdFor, mergeCandidates, phraseEvidence, queryIdfTotal, referenceLabel, targetIdFor, tokenEvidence, } from './intents/lexical.js';
|
|
13
|
+
export { normalizeToken, significantWords, tokenStream, TOKENIZER_ARCHAIC_FORM_COUNT, TOKENIZER_STOPWORD_COUNT, } from './tokenizer/index.js';
|
|
14
|
+
export { normalizeBookAlias, resolveReference, resolveReferenceAttempt, type ReferenceResolutionAttempt, type ReferenceResolver, type ResolvedBook, type ResolvedReference, } from './reference/reference.js';
|
|
15
|
+
export { makeVerseId, parseVerseId, type VerseLocation } from './reference/verseId.js';
|
|
16
|
+
export { AUTHORITATIVE_FAMILIES, isAuthoritative, type Evidence, type Provenance, type Reason, type SignalFamily, } from './reasons/types.js';
|
|
17
|
+
export { applyBudgets, DEFAULT_BUDGETS, type BudgetedScore, type FamilyBudget, type SignalBudgets, } from './ranking/budgets.js';
|
|
18
|
+
export { DEFAULT_LIMIT, DEFAULT_MAX_PER_GROUP, rank, type Candidate, type RankedResult, type RankOptions, } from './ranking/rank.js';
|
|
19
|
+
export type { ConceptMatch, PassageResult, RelatedResult, SongInput, ContentQueryPort, ContentQueryResult, ContentScalar, DiscoveryResult, ResearchOutcome, ResearchResult, ResultIdentity, ScripturePassage, ScriptureVerse, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for @jestek-dev/scripture-engine.
|
|
3
|
+
*
|
|
4
|
+
* Phase 0 exports the pure core that later phases build on: the shared
|
|
5
|
+
* tokenizer, the reference parser, the typed reason vocabulary, the signal
|
|
6
|
+
* budgets, and the deterministic ranker. The intent ladder (Phase 1) and the
|
|
7
|
+
* concept layer (Phase 2) attach to these without changing their contracts.
|
|
8
|
+
*/
|
|
9
|
+
export { ENGINE_VERSION, TOKENIZER_VERSION } from './config/engineVersion.js';
|
|
10
|
+
export { createEngine } from './createEngine.js';
|
|
11
|
+
export { CorpusRepository, MAX_CANDIDATES, MAX_PHRASE_LENGTH, } from './corpus/repository.js';
|
|
12
|
+
export { groupIdFor, mergeCandidates, phraseEvidence, queryIdfTotal, referenceLabel, targetIdFor, tokenEvidence, } from './intents/lexical.js';
|
|
13
|
+
export { normalizeToken, significantWords, tokenStream, TOKENIZER_ARCHAIC_FORM_COUNT, TOKENIZER_STOPWORD_COUNT, } from './tokenizer/index.js';
|
|
14
|
+
export { normalizeBookAlias, resolveReference, resolveReferenceAttempt, } from './reference/reference.js';
|
|
15
|
+
export { makeVerseId, parseVerseId } from './reference/verseId.js';
|
|
16
|
+
export { AUTHORITATIVE_FAMILIES, isAuthoritative, } from './reasons/types.js';
|
|
17
|
+
export { applyBudgets, DEFAULT_BUDGETS, } from './ranking/budgets.js';
|
|
18
|
+
export { DEFAULT_LIMIT, DEFAULT_MAX_PER_GROUP, rank, } from './ranking/rank.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concept intent — step 5 of the ladder, and the reason this engine exists.
|
|
3
|
+
*
|
|
4
|
+
* The lexical intents can only find verses that share vocabulary with the
|
|
5
|
+
* query. This one finds verses that share MEANING, because a human wrote
|
|
6
|
+
* down that they do and said on what authority.
|
|
7
|
+
*
|
|
8
|
+
* Note what it does NOT do: it renders no theological judgment. It reports
|
|
9
|
+
* that a named source associates this passage with this concept, and names
|
|
10
|
+
* the source. Where that source is us, the chip says "LH editorial" and the
|
|
11
|
+
* reader can weigh it accordingly.
|
|
12
|
+
*/
|
|
13
|
+
import type { ConceptAnchorRow, CrossReferenceRow } from '../corpus/repository.js';
|
|
14
|
+
import type { Evidence } from '../reasons/types.js';
|
|
15
|
+
/**
|
|
16
|
+
* Anchor evidence.
|
|
17
|
+
*
|
|
18
|
+
* Strength is the curated weight, which is a PRIOR: editorial confidence for
|
|
19
|
+
* hand-authored anchors, normalized vote share for OpenBible ones. It is
|
|
20
|
+
* never treated as a probability of correctness, and it enters the ranker
|
|
21
|
+
* under the concept_anchor budget like any other bounded input.
|
|
22
|
+
*
|
|
23
|
+
* `specificity` scales strength by how much of the concept's lexicon phrase
|
|
24
|
+
* the query actually matched. A one-token match ("grace") is real but thin
|
|
25
|
+
* evidence that the user meant the curated concept; a four-token match
|
|
26
|
+
* ("be doers of the word") is close to unambiguous.
|
|
27
|
+
*/
|
|
28
|
+
export declare function conceptAnchorEvidence(anchor: ConceptAnchorRow, matchedTokenCount: number): Evidence;
|
|
29
|
+
/**
|
|
30
|
+
* Evidence for a concept reached one hop away in the curated graph.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately filed under the WEAK `concept_lexicon` family rather than
|
|
33
|
+
* `concept_anchor`: "related to something you asked about" is a genuinely
|
|
34
|
+
* weaker claim than "this is what you asked about", and the budget system
|
|
35
|
+
* only protects us if evidence is filed honestly.
|
|
36
|
+
*/
|
|
37
|
+
export declare function relatedConceptEvidence(anchor: ConceptAnchorRow): Evidence;
|
|
38
|
+
/**
|
|
39
|
+
* Cross-reference evidence.
|
|
40
|
+
*
|
|
41
|
+
* Votes are normalized against the corpus maximum on a log scale: the
|
|
42
|
+
* difference between 3 votes and 30 is meaningful, between 300 and 330 is
|
|
43
|
+
* not, and a linear scale would let a handful of famous verse pairs
|
|
44
|
+
* monopolize the signal.
|
|
45
|
+
*/
|
|
46
|
+
export declare function crossReferenceEvidence(edge: CrossReferenceRow, maxVotes: number, fromReference: string): Evidence;
|
|
47
|
+
/**
|
|
48
|
+
* Homiletical term-profile evidence (Layer B).
|
|
49
|
+
*
|
|
50
|
+
* Strength saturates: matching three distinctive terms is meaningfully more
|
|
51
|
+
* than one, but twenty is not meaningfully more than ten, and a linear scale
|
|
52
|
+
* would let one verbose exposition dominate. Log growth also keeps the signal
|
|
53
|
+
* honest about what it is — a hint that people preaching this passage reach
|
|
54
|
+
* for these words, nothing stronger.
|
|
55
|
+
*/
|
|
56
|
+
export declare function passageTermEvidence(match: {
|
|
57
|
+
matchedTerms: readonly string[];
|
|
58
|
+
pmiSum: number;
|
|
59
|
+
sourceIds: string;
|
|
60
|
+
minSpanVerses: number;
|
|
61
|
+
locator: string;
|
|
62
|
+
}): Evidence;
|
|
63
|
+
/**
|
|
64
|
+
* Human-facing source names. Kept here rather than read from the artifact
|
|
65
|
+
* because these strings appear in result chips, and a chip that says
|
|
66
|
+
* "openbible-topics" leaks a database identifier into the product.
|
|
67
|
+
*/
|
|
68
|
+
declare function sourceLabel(sourceId: string): string;
|
|
69
|
+
export { sourceLabel };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concept intent — step 5 of the ladder, and the reason this engine exists.
|
|
3
|
+
*
|
|
4
|
+
* The lexical intents can only find verses that share vocabulary with the
|
|
5
|
+
* query. This one finds verses that share MEANING, because a human wrote
|
|
6
|
+
* down that they do and said on what authority.
|
|
7
|
+
*
|
|
8
|
+
* Note what it does NOT do: it renders no theological judgment. It reports
|
|
9
|
+
* that a named source associates this passage with this concept, and names
|
|
10
|
+
* the source. Where that source is us, the chip says "LH editorial" and the
|
|
11
|
+
* reader can weigh it accordingly.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Anchor evidence.
|
|
15
|
+
*
|
|
16
|
+
* Strength is the curated weight, which is a PRIOR: editorial confidence for
|
|
17
|
+
* hand-authored anchors, normalized vote share for OpenBible ones. It is
|
|
18
|
+
* never treated as a probability of correctness, and it enters the ranker
|
|
19
|
+
* under the concept_anchor budget like any other bounded input.
|
|
20
|
+
*
|
|
21
|
+
* `specificity` scales strength by how much of the concept's lexicon phrase
|
|
22
|
+
* the query actually matched. A one-token match ("grace") is real but thin
|
|
23
|
+
* evidence that the user meant the curated concept; a four-token match
|
|
24
|
+
* ("be doers of the word") is close to unambiguous.
|
|
25
|
+
*/
|
|
26
|
+
export function conceptAnchorEvidence(anchor, matchedTokenCount) {
|
|
27
|
+
const specificity = Math.min(1, 0.55 + 0.15 * Math.max(0, matchedTokenCount - 1));
|
|
28
|
+
return {
|
|
29
|
+
family: 'concept_anchor',
|
|
30
|
+
label: `Theme: ${anchor.conceptLabel}`,
|
|
31
|
+
strength: Math.max(0, Math.min(1, anchor.weight)) * specificity,
|
|
32
|
+
provenance: {
|
|
33
|
+
sourceId: anchor.sourceId,
|
|
34
|
+
label: sourceLabel(anchor.sourceId),
|
|
35
|
+
...(anchor.locator ? { locator: anchor.locator } : {}),
|
|
36
|
+
weight: anchor.weight,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Evidence for a concept reached one hop away in the curated graph.
|
|
42
|
+
*
|
|
43
|
+
* Deliberately filed under the WEAK `concept_lexicon` family rather than
|
|
44
|
+
* `concept_anchor`: "related to something you asked about" is a genuinely
|
|
45
|
+
* weaker claim than "this is what you asked about", and the budget system
|
|
46
|
+
* only protects us if evidence is filed honestly.
|
|
47
|
+
*/
|
|
48
|
+
export function relatedConceptEvidence(anchor) {
|
|
49
|
+
return {
|
|
50
|
+
family: 'concept_lexicon',
|
|
51
|
+
label: `Related theme: ${anchor.conceptLabel}`,
|
|
52
|
+
strength: Math.max(0, Math.min(1, anchor.weight)) * 0.5,
|
|
53
|
+
provenance: {
|
|
54
|
+
sourceId: anchor.sourceId,
|
|
55
|
+
label: sourceLabel(anchor.sourceId),
|
|
56
|
+
...(anchor.locator ? { locator: anchor.locator } : {}),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Cross-reference evidence.
|
|
62
|
+
*
|
|
63
|
+
* Votes are normalized against the corpus maximum on a log scale: the
|
|
64
|
+
* difference between 3 votes and 30 is meaningful, between 300 and 330 is
|
|
65
|
+
* not, and a linear scale would let a handful of famous verse pairs
|
|
66
|
+
* monopolize the signal.
|
|
67
|
+
*/
|
|
68
|
+
export function crossReferenceEvidence(edge, maxVotes, fromReference) {
|
|
69
|
+
const normalized = maxVotes > 1 ? Math.log1p(Math.max(0, edge.votes)) / Math.log1p(maxVotes) : 0;
|
|
70
|
+
return {
|
|
71
|
+
family: 'cross_reference',
|
|
72
|
+
label: `Cross-referenced from ${fromReference}`,
|
|
73
|
+
strength: Math.max(0, Math.min(1, normalized)),
|
|
74
|
+
provenance: {
|
|
75
|
+
sourceId: edge.sourceId,
|
|
76
|
+
label: sourceLabel(edge.sourceId),
|
|
77
|
+
locator: fromReference,
|
|
78
|
+
weight: edge.votes,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Homiletical term-profile evidence (Layer B).
|
|
84
|
+
*
|
|
85
|
+
* Strength saturates: matching three distinctive terms is meaningfully more
|
|
86
|
+
* than one, but twenty is not meaningfully more than ten, and a linear scale
|
|
87
|
+
* would let one verbose exposition dominate. Log growth also keeps the signal
|
|
88
|
+
* honest about what it is — a hint that people preaching this passage reach
|
|
89
|
+
* for these words, nothing stronger.
|
|
90
|
+
*/
|
|
91
|
+
export function passageTermEvidence(match) {
|
|
92
|
+
const saturating = Math.log1p(match.matchedTerms.length) / Math.log1p(6);
|
|
93
|
+
// Specificity: evidence distilled from a one-verse note is a stronger claim
|
|
94
|
+
// about THIS verse than the same words inherited from a whole-psalm essay.
|
|
95
|
+
// 1 verse -> 1.0, 6 verses -> ~0.61, a whole chapter -> ~0.45. Gentle on
|
|
96
|
+
// purpose: diffuse commentary is discounted, never discarded.
|
|
97
|
+
const span = Math.max(1, match.minSpanVerses);
|
|
98
|
+
const specificity = 1 / (1 + 0.25 * Math.log2(span));
|
|
99
|
+
return {
|
|
100
|
+
family: 'passage_terms',
|
|
101
|
+
label: match.matchedTerms.length === 1
|
|
102
|
+
? `Preached vocabulary: ${match.matchedTerms[0]}`
|
|
103
|
+
: `Preached vocabulary: ${match.matchedTerms.slice(0, 3).join(', ')}`,
|
|
104
|
+
strength: Math.max(0, Math.min(1, saturating * specificity)),
|
|
105
|
+
provenance: {
|
|
106
|
+
sourceId: match.sourceIds,
|
|
107
|
+
label: joinedSourceLabel(match.sourceIds),
|
|
108
|
+
locator: match.locator,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** '+'-joined source ids rendered as human labels. */
|
|
113
|
+
function joinedSourceLabel(sourceIds) {
|
|
114
|
+
return sourceIds
|
|
115
|
+
.split('+')
|
|
116
|
+
.map((id) => sourceLabel(id))
|
|
117
|
+
.join(' + ');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Human-facing source names. Kept here rather than read from the artifact
|
|
121
|
+
* because these strings appear in result chips, and a chip that says
|
|
122
|
+
* "openbible-topics" leaks a database identifier into the product.
|
|
123
|
+
*/
|
|
124
|
+
function sourceLabel(sourceId) {
|
|
125
|
+
switch (sourceId) {
|
|
126
|
+
case 'editorial':
|
|
127
|
+
return 'LH editorial';
|
|
128
|
+
case 'openbible-topics':
|
|
129
|
+
return 'OpenBible topical votes (CC BY)';
|
|
130
|
+
case 'openbible-xrefs':
|
|
131
|
+
return 'OpenBible cross-references (CC BY)';
|
|
132
|
+
case 'maclaren-psalms':
|
|
133
|
+
return 'Maclaren, Expositions (public domain)';
|
|
134
|
+
case 'treasury-of-david-01':
|
|
135
|
+
case 'treasury-of-david-02':
|
|
136
|
+
case 'treasury-of-david-04':
|
|
137
|
+
case 'treasury-of-david-06':
|
|
138
|
+
return 'Spurgeon, Treasury of David (public domain)';
|
|
139
|
+
default:
|
|
140
|
+
return sourceId;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export { sourceLabel };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexical intents — steps 2, 3 and 4 of the ladder (exact phrase, distinctive
|
|
3
|
+
* tokens with proximity, conservative normalization).
|
|
4
|
+
*
|
|
5
|
+
* Each intent's job is to turn corpus rows into typed Evidence. It never
|
|
6
|
+
* decides ordering; the ranker does, under the signal budgets. That split is
|
|
7
|
+
* what keeps "add a new intent" from being able to destabilize ranking.
|
|
8
|
+
*/
|
|
9
|
+
import type { Candidate } from '../ranking/rank.js';
|
|
10
|
+
import type { Evidence } from '../reasons/types.js';
|
|
11
|
+
import { significantWords } from '../tokenizer/index.js';
|
|
12
|
+
import type { TokenMatch } from '../corpus/repository.js';
|
|
13
|
+
import type { ScriptureVerse } from '../types.js';
|
|
14
|
+
/**
|
|
15
|
+
* Canonical, sortable target id: zero-padded so lexicographic order IS
|
|
16
|
+
* scripture order. The ranker uses targetId as its final tie-break, so this
|
|
17
|
+
* padding is what makes equal-scoring results present in Genesis-to-
|
|
18
|
+
* Revelation order rather than string-sorted nonsense ("10" before "9").
|
|
19
|
+
*/
|
|
20
|
+
export declare function targetIdFor(verse: ScriptureVerse): string;
|
|
21
|
+
/** Chapter-level grouping — the unit diversification thins by. */
|
|
22
|
+
export declare function groupIdFor(verse: ScriptureVerse): string;
|
|
23
|
+
export declare function referenceLabel(verse: ScriptureVerse): string;
|
|
24
|
+
/**
|
|
25
|
+
* Exact phrase evidence.
|
|
26
|
+
*
|
|
27
|
+
* Strength is BINARY: a verse either contains the phrase or it does not, and
|
|
28
|
+
* pretending otherwise would put a confidence gradient on a yes/no fact.
|
|
29
|
+
* bm25 is used upstream to choose WHICH matches survive the candidate cap
|
|
30
|
+
* when there are more than the limit, but it never modulates strength — so
|
|
31
|
+
* equal-strength matches fall through to the canonical-order tie-break,
|
|
32
|
+
* which is what a reader expects from a concordance-style result.
|
|
33
|
+
*/
|
|
34
|
+
export declare function phraseEvidence(fragment: string, fragmentWords: number, queryWords: number): Evidence;
|
|
35
|
+
/**
|
|
36
|
+
* Token-overlap and proximity evidence.
|
|
37
|
+
*
|
|
38
|
+
* Coverage is IDF-weighted rather than a raw count: matching "refuge" and
|
|
39
|
+
* "strength" is worth far more than matching "do" and "one", and weighting
|
|
40
|
+
* by inverse document frequency expresses that without anyone having to
|
|
41
|
+
* hand-maintain a list of which words are important.
|
|
42
|
+
*/
|
|
43
|
+
export declare function tokenEvidence(match: TokenMatch, queryIdfTotal: number): Evidence[];
|
|
44
|
+
/** Total IDF of the query's tokens — the denominator for coverage. */
|
|
45
|
+
export declare function queryIdfTotal(tokens: readonly string[], documentFrequencies: ReadonlyMap<string, number>, documentCount: number): number;
|
|
46
|
+
/** Merge per-verse evidence from every lexical intent into ranked candidates. */
|
|
47
|
+
export declare function mergeCandidates(contributions: readonly {
|
|
48
|
+
verse: ScriptureVerse;
|
|
49
|
+
evidence: readonly Evidence[];
|
|
50
|
+
}[]): Candidate[];
|
|
51
|
+
export { significantWords };
|