@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,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
+ }
@@ -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 };