@jestek-dev/scripture-engine 0.7.0 → 0.14.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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/config/engineVersion.d.ts +1 -1
- package/dist/config/engineVersion.js +47 -1
- package/dist/corpus/repository.d.ts +189 -2
- package/dist/corpus/repository.js +332 -1
- package/dist/createEngine.d.ts +76 -1
- package/dist/createEngine.js +716 -52
- package/dist/index.d.ts +20 -14
- package/dist/index.js +15 -13
- package/dist/intents/concept.d.ts +115 -2
- package/dist/intents/concept.js +323 -6
- package/dist/intents/lexical.d.ts +42 -3
- package/dist/intents/lexical.js +111 -11
- package/dist/intents/spelling.d.ts +93 -0
- package/dist/intents/spelling.js +124 -0
- package/dist/internal.d.ts +31 -0
- package/dist/internal.js +31 -0
- package/dist/ranking/budgets.d.ts +12 -0
- package/dist/ranking/budgets.js +39 -0
- package/dist/ranking/rank.js +20 -3
- package/dist/reasons/display.d.ts +86 -0
- package/dist/reasons/display.js +105 -0
- package/dist/reasons/types.d.ts +1 -1
- package/dist/reference/reference.d.ts +57 -0
- package/dist/reference/reference.js +210 -48
- package/dist/tokenizer/index.d.ts +35 -0
- package/dist/tokenizer/index.js +37 -1
- package/dist/types.d.ts +104 -0
- package/package.json +16 -1
package/dist/createEngine.js
CHANGED
|
@@ -11,11 +11,34 @@
|
|
|
11
11
|
* 2 without changing anything above it.
|
|
12
12
|
*/
|
|
13
13
|
import { ConceptRepository, CorpusRepository, searchLongestFragment, } from './corpus/repository.js';
|
|
14
|
+
import { parseVerseId } from './reference/verseId.js';
|
|
14
15
|
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 {
|
|
18
|
-
|
|
16
|
+
import { mergeCandidates, isMeaningfulPhraseFragment, phraseEvidence, queryIdfTotal, referenceLabel, significantWords, subsumeCompletePhraseRestatements, targetIdFor, tokenEvidence, } from './intents/lexical.js';
|
|
17
|
+
import { aliasConceptEvidence, aliasPassageEvidence, conceptAnchorEvidence, conceptCueEvidence, crossReferenceEvidence, dedupeConceptAnchors, isThinBareWordConceptCue, passageTermEvidence, relatedConceptEvidence, sourceLabel, translationVariantEvidence, } from './intents/concept.js';
|
|
18
|
+
import { deleteVariants, pickCorrection, spellingEditBudget, } from './intents/spelling.js';
|
|
19
|
+
import { normalizedPhrase, significantWordsWithSurface } from './tokenizer/index.js';
|
|
20
|
+
import { DEFAULT_LIMIT, rank } from './ranking/rank.js';
|
|
21
|
+
import { pinCorrectionCitations, polishChipsForDisplay } from './reasons/display.js';
|
|
22
|
+
/**
|
|
23
|
+
* Extra candidates ranked beyond the caller's limit so that collapsing a run
|
|
24
|
+
* of anchor verses does not shrink the page. Bounded rather than unlimited:
|
|
25
|
+
* ranking is cheap but not free, and a curated anchor spanning more than this
|
|
26
|
+
* many verses is a passage, not a page of results.
|
|
27
|
+
*/
|
|
28
|
+
const COLLAPSE_HEADROOM = 25;
|
|
29
|
+
// '8' (CO-3): the pericopes schema. Since 0.14.0 (CO-3 PR 2) discover()
|
|
30
|
+
// consumes the tiling — verse hits inside one derived section merge into a
|
|
31
|
+
// passage-level result. Over a v7-shaped artifact (no pericopes table, or an
|
|
32
|
+
// emptied one) the presence-and-rows probe reads false and behavior reverts
|
|
33
|
+
// to the 0.13.0 anchor-only collapse: rebuilding without pericope rows IS
|
|
34
|
+
// the rollback.
|
|
35
|
+
// '9' (B3 Phase A): the TSK cross-reference-phrases schema. Capability only —
|
|
36
|
+
// NO discover() code path reads the table yet, so a v9 artifact ranks
|
|
37
|
+
// byte-identically to a v8 one; the behavior that consumes the phrase keys
|
|
38
|
+
// (named-phrase labels, off-phrase discount) lands with the Phase B
|
|
39
|
+
// ENGINE_VERSION bump behind J26/J55. Rebuilding without phrase rows (or
|
|
40
|
+
// dropping the table) IS the rollback, same presence-and-rows probe story.
|
|
41
|
+
const SUPPORTED_SCHEMA_VERSIONS = new Set(['1', '2', '3', '4', '5', '6', '7', '8', '9']);
|
|
19
42
|
/**
|
|
20
43
|
* Lyric tokens admitted to forSong(). A full lyric sheet is hundreds of
|
|
21
44
|
* mostly-common words; past this point they add candidates without adding
|
|
@@ -39,14 +62,51 @@ export async function createEngine(database, options = {}) {
|
|
|
39
62
|
const conceptRepository = new ConceptRepository(database);
|
|
40
63
|
const concepts = (await conceptRepository.hasConceptLayer()) ? conceptRepository : null;
|
|
41
64
|
const hasPassageTerms = await conceptRepository.hasPassageTerms();
|
|
65
|
+
const hasTranslationTokens = await conceptRepository.hasTranslationTokens();
|
|
66
|
+
// Presence-probed like the other optional layers: a v6 artifact has no
|
|
67
|
+
// spelling tables and the engine gracefully does not correct (0.12.0/QR-5).
|
|
68
|
+
const hasSpellingIndex = await repository.hasSpellingIndex();
|
|
69
|
+
// Presence-AND-ROWS probed (0.13.0/QR-6): schema v7 ships curated_aliases
|
|
70
|
+
// EMPTY, and 0.13.0 over such an artifact must behave exactly as 0.12.0
|
|
71
|
+
// did. Rebuilding without alias rows IS the rollback.
|
|
72
|
+
const hasCuratedAliases = await conceptRepository.hasCuratedAliases();
|
|
73
|
+
// Presence-AND-ROWS probed (0.14.0/CO-3 PR 2): a v7-shaped artifact has no
|
|
74
|
+
// pericopes table and 0.14.0 over it must behave exactly as 0.13.0 did —
|
|
75
|
+
// the probe IS the rollback story, mirroring the alias precedent.
|
|
76
|
+
const hasPericopes = await repository.hasPericopes();
|
|
42
77
|
const documentCount = await repository.documentCount();
|
|
43
78
|
const identity = {
|
|
44
79
|
engineVersion: ENGINE_VERSION,
|
|
45
80
|
corpusFingerprint: meta.corpusFingerprint,
|
|
46
81
|
layerFingerprint: meta.layerFingerprint,
|
|
47
82
|
};
|
|
48
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The discovery ladder. `correctSpelling` is true ONLY on the `research()`
|
|
85
|
+
* path (0.12.0/QR-5): themes() stays exact-curated, forSong() lyrics are
|
|
86
|
+
* never corrected, and reference-shaped inputs never get here at all (the
|
|
87
|
+
* reference short-circuit runs first). Corrections are returned alongside
|
|
88
|
+
* the results so the outcome can carry the machine-readable citation.
|
|
89
|
+
*/
|
|
90
|
+
async function discover(query, correctSpelling = false) {
|
|
49
91
|
const verses = new Map();
|
|
92
|
+
// targetId -> the curated anchor spans that produced it.
|
|
93
|
+
const anchorSpans = new Map();
|
|
94
|
+
// span key -> the span's own extent and the source(s) that named it, so
|
|
95
|
+
// a merged row can say WHY its verses travel together (0.14.0/CO-3 PR 2:
|
|
96
|
+
// the grouping explanation is part of the contract). Sources accumulate
|
|
97
|
+
// as a set because stage-6 dedupe may deliver '+'-joined agreement.
|
|
98
|
+
const spanInfo = new Map();
|
|
99
|
+
const recordSpan = (key, startVerseId, endVerseId, sourceId) => {
|
|
100
|
+
const info = spanInfo.get(key) ?? { startVerseId, endVerseId, sourceIds: new Set() };
|
|
101
|
+
for (const id of sourceId.split('+'))
|
|
102
|
+
info.sourceIds.add(id);
|
|
103
|
+
spanInfo.set(key, info);
|
|
104
|
+
};
|
|
105
|
+
// Targets whose evidence carries a COMPLETE whole-query exact_phrase
|
|
106
|
+
// match, marked for the complete-match subsumption at candidate merge
|
|
107
|
+
// (0.10.0 stage 3): their token_overlap/proximity evidence restates what
|
|
108
|
+
// the verbatim match already fully asserts. Fragments never mark.
|
|
109
|
+
const completePhraseTargets = new Set();
|
|
50
110
|
const contributions = [];
|
|
51
111
|
// Step 2 — verbatim text. Tries the whole query first and falls back to
|
|
52
112
|
// its longest matching fragment, so a paraphrase still gets credit for
|
|
@@ -55,40 +115,147 @@ export async function createEngine(database, options = {}) {
|
|
|
55
115
|
// badge it has not earned.
|
|
56
116
|
if (query.trim().includes(' ')) {
|
|
57
117
|
const whole = await repository.searchPhrase(query);
|
|
58
|
-
const queryWords = query.trim().split(/\s+/).filter(Boolean).length;
|
|
59
118
|
if (whole.length > 0) {
|
|
119
|
+
// Whole-match authority is measured in SIGNIFICANT words (0.10.0
|
|
120
|
+
// stage 3), mirroring what the fragment branch has done since 0.8.0:
|
|
121
|
+
// "the cross" is two raw words but one unit of meaning, and granting
|
|
122
|
+
// it full 60-point authority let verbatim occurrences in negative
|
|
123
|
+
// context outrank the curated anchors. Deliberately NO raw-count
|
|
124
|
+
// fallback here (unlike the fragment branch, which needs one to
|
|
125
|
+
// avoid dividing by zero): an all-stopword verbatim match is the
|
|
126
|
+
// token intent wearing an authoritative badge, exactly what the
|
|
127
|
+
// taper demotes — phraseEvidence files anything under two
|
|
128
|
+
// significant words as token_overlap.
|
|
129
|
+
const querySignificant = significantWords(query).length;
|
|
60
130
|
for (const match of whole) {
|
|
131
|
+
const evidence = phraseEvidence(query.trim(), querySignificant, querySignificant);
|
|
61
132
|
verses.set(targetIdFor(match), match);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
});
|
|
133
|
+
if (evidence.family === 'exact_phrase') {
|
|
134
|
+
completePhraseTargets.add(targetIdFor(match));
|
|
135
|
+
}
|
|
136
|
+
contributions.push({ verse: match, evidence: [evidence] });
|
|
66
137
|
}
|
|
67
138
|
}
|
|
68
139
|
else {
|
|
69
140
|
const fragment = await searchLongestFragment(repository, query);
|
|
70
141
|
if (fragment) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
142
|
+
// Fragment authority is measured in SIGNIFICANT words (0.8.0).
|
|
143
|
+
// "is close to" is three raw words but one unit of meaning, and
|
|
144
|
+
// scoring it 3/6 of a full match let stopword runs outrank curated
|
|
145
|
+
// anchors. A query of nothing but function words falls back to raw
|
|
146
|
+
// counts rather than dividing by zero.
|
|
147
|
+
const querySignificant = significantWords(query).length;
|
|
148
|
+
const fragmentSignificant = significantWords(fragment.fragment).length;
|
|
149
|
+
const useSignificant = querySignificant > 0;
|
|
150
|
+
// A fallback containing only one significant word is token overlap,
|
|
151
|
+
// not phrase evidence. Adding both would count the same thin match
|
|
152
|
+
// twice and can lift an irrelevant stopword-heavy fragment into the
|
|
153
|
+
// ranked window ("is close to" was the motivating case).
|
|
154
|
+
const fragmentIsPhrase = isMeaningfulPhraseFragment(fragment.fragment, query);
|
|
155
|
+
if (fragmentIsPhrase) {
|
|
156
|
+
for (const match of fragment.matches) {
|
|
157
|
+
verses.set(targetIdFor(match), match);
|
|
158
|
+
contributions.push({
|
|
159
|
+
verse: match,
|
|
160
|
+
evidence: [
|
|
161
|
+
phraseEvidence(fragment.fragment, useSignificant ? fragmentSignificant : fragment.fragmentWords, useSignificant ? querySignificant : fragment.queryWords),
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
79
165
|
}
|
|
80
166
|
}
|
|
81
167
|
}
|
|
82
168
|
}
|
|
83
169
|
// Steps 3-4 — tokens with proximity. Normalization is inherent: the
|
|
84
170
|
// shared tokenizer folds inflection and archaic forms on both sides.
|
|
85
|
-
|
|
171
|
+
//
|
|
172
|
+
// Cited spelling correction (0.12.0/QR-5) runs FIRST, before any token
|
|
173
|
+
// step, and only on the research() path: a typed token with corpus df 0
|
|
174
|
+
// that exists in NO vocabulary (corpus tokens, book aliases, lexicon
|
|
175
|
+
// tokens, translation tokens, Layer B verse terms — the OOV gate)
|
|
176
|
+
// substitutes the unique
|
|
177
|
+
// in-policy winner of the precomputed SymSpell lookup, verified by the
|
|
178
|
+
// bounded integer Damerau DP under the ONE edit-policy table. Corrected
|
|
179
|
+
// tokens then flow through every step below unchanged, and every
|
|
180
|
+
// substitution is CITED — on the token chips (typed surface form, never
|
|
181
|
+
// the stem) and in the returned corrections list. A word in ANY
|
|
182
|
+
// vocabulary is never rewritten. The whole-query FTS phrase step above
|
|
183
|
+
// stays uncorrected (documented v1 cut).
|
|
184
|
+
let tokens = significantWords(query);
|
|
185
|
+
const corrections = [];
|
|
186
|
+
const correctionCitations = new Map();
|
|
187
|
+
let precomputedFrequencies = null;
|
|
188
|
+
if (correctSpelling && hasSpellingIndex && tokens.length > 0) {
|
|
189
|
+
const typedFrequencies = await repository.tokenDocumentCounts(tokens);
|
|
190
|
+
const zeroDf = tokens.filter((token) => (typedFrequencies.get(token) ?? 0) === 0);
|
|
191
|
+
if (zeroDf.length === 0) {
|
|
192
|
+
precomputedFrequencies = typedFrequencies;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const inVocabulary = await repository.spellingTermsPresent(zeroDf);
|
|
196
|
+
const substitutions = new Map();
|
|
197
|
+
for (const pair of significantWordsWithSurface(query)) {
|
|
198
|
+
if ((typedFrequencies.get(pair.token) ?? 0) > 0)
|
|
199
|
+
continue;
|
|
200
|
+
if (inVocabulary.has(pair.token))
|
|
201
|
+
continue;
|
|
202
|
+
const bound = spellingEditBudget(pair.token.length);
|
|
203
|
+
if (bound === 0)
|
|
204
|
+
continue;
|
|
205
|
+
const candidates = await repository.spellingCandidates(deleteVariants(pair.token, bound));
|
|
206
|
+
const winner = pickCorrection(pair.token, candidates, bound);
|
|
207
|
+
if (!winner)
|
|
208
|
+
continue;
|
|
209
|
+
substitutions.set(pair.token, winner.term);
|
|
210
|
+
if (!correctionCitations.has(winner.term)) {
|
|
211
|
+
correctionCitations.set(winner.term, pair.surface);
|
|
212
|
+
}
|
|
213
|
+
corrections.push({
|
|
214
|
+
typed: pair.surface,
|
|
215
|
+
corrected: winner.term,
|
|
216
|
+
distance: winner.distance,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (substitutions.size > 0) {
|
|
220
|
+
// Substitute in place, then re-deduplicate preserving first
|
|
221
|
+
// occurrence: a correction may land on a term the query already
|
|
222
|
+
// contains, and one term must contribute once.
|
|
223
|
+
const seen = new Set();
|
|
224
|
+
tokens = tokens
|
|
225
|
+
.map((token) => substitutions.get(token) ?? token)
|
|
226
|
+
.filter((token) => (seen.has(token) ? false : (seen.add(token), true)));
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
precomputedFrequencies = typedFrequencies;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
let tokenFrequencies = new Map();
|
|
234
|
+
let tokenIdfTotal = 0;
|
|
86
235
|
if (tokens.length > 0) {
|
|
87
|
-
|
|
88
|
-
|
|
236
|
+
tokenFrequencies = precomputedFrequencies ?? (await repository.tokenDocumentCounts(tokens));
|
|
237
|
+
tokenIdfTotal = queryIdfTotal(tokens, tokenFrequencies, documentCount);
|
|
89
238
|
for (const match of await repository.searchTokens(tokens, documentCount)) {
|
|
90
239
|
verses.set(targetIdFor(match), match);
|
|
91
|
-
contributions.push({
|
|
240
|
+
contributions.push({
|
|
241
|
+
verse: match,
|
|
242
|
+
evidence: tokenEvidence(match, tokenIdfTotal, correctionCitations),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Step 4b — cross-translation vocabulary. Placed AFTER the shipped text
|
|
247
|
+
// has had its chance: if the query matches what this artifact actually
|
|
248
|
+
// says, that is the better evidence and this only adds to it. What this
|
|
249
|
+
// catches is the reader who learned the verse elsewhere.
|
|
250
|
+
if (hasTranslationTokens && tokens.length > 1) {
|
|
251
|
+
const variantFrequencies = await conceptRepository.translationTokenDocumentCounts(tokens);
|
|
252
|
+
const variantIdfTotal = queryIdfTotal(tokens, variantFrequencies, documentCount);
|
|
253
|
+
for (const match of await conceptRepository.searchTranslationTokens(tokens)) {
|
|
254
|
+
const evidence = translationVariantEvidence(match, variantIdfTotal, variantFrequencies, documentCount);
|
|
255
|
+
if (!evidence)
|
|
256
|
+
continue;
|
|
257
|
+
verses.set(targetIdFor(match), match);
|
|
258
|
+
contributions.push({ verse: match, evidence: [evidence] });
|
|
92
259
|
}
|
|
93
260
|
}
|
|
94
261
|
// Step 5a — homiletical vocabulary. Weak by design and weak by budget.
|
|
@@ -104,42 +271,200 @@ export async function createEngine(database, options = {}) {
|
|
|
104
271
|
if (concepts && tokens.length > 0) {
|
|
105
272
|
const matched = await concepts.matchConcepts(tokens);
|
|
106
273
|
if (matched.length > 0) {
|
|
107
|
-
const
|
|
108
|
-
|
|
274
|
+
const bareCueIdfShare = (phrase) => {
|
|
275
|
+
if (tokenIdfTotal <= 0)
|
|
276
|
+
return 1;
|
|
277
|
+
const phraseToken = significantWords(phrase)[0];
|
|
278
|
+
if (!phraseToken)
|
|
279
|
+
return 1;
|
|
280
|
+
const df = tokenFrequencies.get(phraseToken) ?? 0;
|
|
281
|
+
return Math.log(1 + documentCount / Math.max(1, df)) / tokenIdfTotal;
|
|
282
|
+
};
|
|
283
|
+
const matchedByConcept = new Map(matched.map((match) => [match.conceptId, match]));
|
|
284
|
+
const authoritativeConceptIds = matched
|
|
285
|
+
.filter((match) => !isThinBareWordConceptCue(match.matchedPhrase, tokens.length, bareCueIdfShare(match.matchedPhrase)))
|
|
286
|
+
.map((match) => match.conceptId);
|
|
287
|
+
const authoritativeConceptSet = new Set(authoritativeConceptIds);
|
|
288
|
+
// Anchor dedupe (0.10.0 stage 6): one verse, one concept, one scored
|
|
289
|
+
// contribution — the surviving row's chip names every agreeing source.
|
|
290
|
+
// See dedupeConceptAnchors.
|
|
291
|
+
const anchors = dedupeConceptAnchors(await concepts.anchorVerses(matched.map((match) => match.conceptId)));
|
|
109
292
|
for (const anchor of anchors) {
|
|
293
|
+
const match = matchedByConcept.get(anchor.conceptId);
|
|
294
|
+
const matchedTokenCount = match?.matchedTokenCount ?? 1;
|
|
295
|
+
const weakCue = match !== undefined &&
|
|
296
|
+
isThinBareWordConceptCue(match.matchedPhrase, tokens.length, bareCueIdfShare(match.matchedPhrase));
|
|
110
297
|
verses.set(targetIdFor(anchor), anchor);
|
|
298
|
+
// Remember which curated span this verse came from, so contiguous
|
|
299
|
+
// verses of ONE anchor can be presented as the passage a human named.
|
|
300
|
+
const spans = anchorSpans.get(targetIdFor(anchor)) ?? new Set();
|
|
301
|
+
const spanKey = `${anchor.conceptId}:${anchor.anchorStartVerseId}-${anchor.anchorEndVerseId}`;
|
|
302
|
+
spans.add(spanKey);
|
|
303
|
+
anchorSpans.set(targetIdFor(anchor), spans);
|
|
304
|
+
recordSpan(spanKey, anchor.anchorStartVerseId, anchor.anchorEndVerseId, anchor.sourceId);
|
|
111
305
|
contributions.push({
|
|
112
306
|
verse: anchor,
|
|
113
307
|
evidence: [
|
|
114
|
-
|
|
308
|
+
weakCue
|
|
309
|
+
? conceptCueEvidence(anchor, matchedTokenCount, tokens.length)
|
|
310
|
+
: conceptAnchorEvidence(anchor, matchedTokenCount, tokens.length),
|
|
115
311
|
],
|
|
116
312
|
});
|
|
117
313
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
314
|
+
if (authoritativeConceptIds.length > 0) {
|
|
315
|
+
// One hop through the curated graph, filed as weak evidence.
|
|
316
|
+
const relatedIds = await concepts.relatedConcepts(authoritativeConceptIds);
|
|
317
|
+
for (const anchor of dedupeConceptAnchors(await concepts.anchorVerses(relatedIds))) {
|
|
318
|
+
verses.set(targetIdFor(anchor), anchor);
|
|
319
|
+
contributions.push({ verse: anchor, evidence: [relatedConceptEvidence(anchor)] });
|
|
320
|
+
}
|
|
321
|
+
// Cross-reference expansion seeded ONLY from authoritative concept
|
|
322
|
+
// anchors, never from arbitrary lexical hits or bare-word theme cues.
|
|
323
|
+
// Seeding from weak matches is how a curated graph turns into a
|
|
324
|
+
// random walk.
|
|
325
|
+
const authoritativeAnchors = anchors.filter((anchor) => authoritativeConceptSet.has(anchor.conceptId));
|
|
326
|
+
const seeds = [...new Set(authoritativeAnchors.map((anchor) => anchor.verseId))].sort((a, b) => a - b);
|
|
327
|
+
const seedLabels = new Map(authoritativeAnchors.map((anchor) => [anchor.verseId, referenceLabel(anchor)]));
|
|
328
|
+
// Same-concept cross-reference suppression (0.10.0 stage 4). Within
|
|
329
|
+
// one concept's anchor set, an edge between two members restates the
|
|
330
|
+
// curated consensus each member's concept_anchor chip already
|
|
331
|
+
// carries — the same humans naming the same theme, walked one hop
|
|
332
|
+
// and counted again as if independent. That stacking is how a
|
|
333
|
+
// co-anchor's ≤6 "corroboration" points closed a deliberate
|
|
334
|
+
// curated-weight gap and displaced the verse being quoted (ph2,
|
|
335
|
+
// Jer 29:11 vs Rom 15:13). Suppressed, not discounted: a ×0.5 keeps
|
|
336
|
+
// a tunable fraction of double-counting with no principled value
|
|
337
|
+
// (G7 — correlated evidence shares one budget; identical facts
|
|
338
|
+
// collapse rather than sum — applied across the anchor/xref
|
|
339
|
+
// boundary). The map is built only from AUTHORITATIVE anchors of
|
|
340
|
+
// matched concepts, so edges from outside the set, edges whose
|
|
341
|
+
// target is not a co-anchor of the seeding concept, and edges
|
|
342
|
+
// between anchors of two DIFFERENT matched concepts are all
|
|
343
|
+
// untouched, and membership lookup is order-independent by
|
|
344
|
+
// construction. related() is deliberately untouched: there the
|
|
345
|
+
// passage is the input and its edges are exactly what was asked
|
|
346
|
+
// for — no concept consensus is being restated.
|
|
347
|
+
const anchorConceptsByVerse = new Map();
|
|
348
|
+
for (const anchor of authoritativeAnchors) {
|
|
349
|
+
const bucket = anchorConceptsByVerse.get(anchor.verseId);
|
|
350
|
+
if (bucket)
|
|
351
|
+
bucket.add(anchor.conceptId);
|
|
352
|
+
else
|
|
353
|
+
anchorConceptsByVerse.set(anchor.verseId, new Set([anchor.conceptId]));
|
|
354
|
+
}
|
|
355
|
+
const sharesMatchedConcept = (fromVerseId, toVerseId) => {
|
|
356
|
+
const from = anchorConceptsByVerse.get(fromVerseId);
|
|
357
|
+
const to = anchorConceptsByVerse.get(toVerseId);
|
|
358
|
+
if (!from || !to)
|
|
359
|
+
return false;
|
|
360
|
+
for (const conceptId of from)
|
|
361
|
+
if (to.has(conceptId))
|
|
362
|
+
return true;
|
|
363
|
+
return false;
|
|
364
|
+
};
|
|
365
|
+
const maxVotes = await concepts.maxCrossReferenceVotes();
|
|
366
|
+
for (const edge of await concepts.expandCrossReferences(seeds)) {
|
|
367
|
+
// The suppressed target stays in the candidate set through its own
|
|
368
|
+
// anchor evidence (it IS an anchor of the matched concept — that
|
|
369
|
+
// is why the edge is redundant); only the restated edge evidence
|
|
370
|
+
// is dropped, so no result ever disappears from this.
|
|
371
|
+
if (sharesMatchedConcept(edge.fromVerseId, edge.verseId))
|
|
372
|
+
continue;
|
|
373
|
+
verses.set(targetIdFor(edge), edge);
|
|
374
|
+
contributions.push({
|
|
375
|
+
verse: edge,
|
|
376
|
+
evidence: [
|
|
377
|
+
crossReferenceEvidence(edge, maxVotes, seedLabels.get(edge.fromVerseId) ?? 'a matched passage'),
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
}
|
|
123
381
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Step 5b — curated phrase/hymn aliases (0.13.0/QR-6). Whole-query
|
|
385
|
+
// EQUALITY on the TYPED query's normalizedPhrase (stopwords kept, no
|
|
386
|
+
// stemming — see tokenizer), never containment and never the
|
|
387
|
+
// spelling-corrected token stream: the alias key is a curated claim
|
|
388
|
+
// about exactly this string, and brittleness to extra words is accepted
|
|
389
|
+
// BY DESIGN. This is the shape concept_lexicon structurally cannot
|
|
390
|
+
// carry ("it is well with my soul" -> `well soul`; "how great thou art"
|
|
391
|
+
// -> `great`). A concept-target alias surfaces the target's own curated
|
|
392
|
+
// anchors — honestly weighted by the anchors' reviewed weights — under
|
|
393
|
+
// the existing concept_anchor family (no new SignalFamily; the budgets
|
|
394
|
+
// roster is untouched); a verse-range alias surfaces the named passage.
|
|
395
|
+
// Every chip names the hymn and the source; nothing is adjudicated.
|
|
396
|
+
if (hasCuratedAliases) {
|
|
397
|
+
const aliasKey = normalizedPhrase(query);
|
|
398
|
+
if (aliasKey.length > 0) {
|
|
399
|
+
for (const alias of await conceptRepository.matchAliases(aliasKey)) {
|
|
400
|
+
if (alias.conceptId !== null) {
|
|
401
|
+
const anchors = dedupeConceptAnchors(await conceptRepository.anchorVerses([alias.conceptId]));
|
|
402
|
+
for (const anchor of anchors) {
|
|
403
|
+
verses.set(targetIdFor(anchor), anchor);
|
|
404
|
+
// Same span key the concept step uses, so a verse surfaced by
|
|
405
|
+
// both merges into one governing span and the run collapse
|
|
406
|
+
// still presents the passage a human named.
|
|
407
|
+
const spans = anchorSpans.get(targetIdFor(anchor)) ?? new Set();
|
|
408
|
+
const spanKey = `${anchor.conceptId}:${anchor.anchorStartVerseId}-${anchor.anchorEndVerseId}`;
|
|
409
|
+
spans.add(spanKey);
|
|
410
|
+
anchorSpans.set(targetIdFor(anchor), spans);
|
|
411
|
+
recordSpan(spanKey, anchor.anchorStartVerseId, anchor.anchorEndVerseId, anchor.sourceId);
|
|
412
|
+
contributions.push({
|
|
413
|
+
verse: anchor,
|
|
414
|
+
evidence: [aliasConceptEvidence(alias, anchor)],
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else if (alias.startVerseId !== null && alias.endVerseId !== null) {
|
|
419
|
+
for (const verse of await conceptRepository.aliasRangeVerses(alias.startVerseId, alias.endVerseId)) {
|
|
420
|
+
verses.set(targetIdFor(verse), verse);
|
|
421
|
+
const spans = anchorSpans.get(targetIdFor(verse)) ?? new Set();
|
|
422
|
+
const spanKey = `alias:${alias.id}:${alias.startVerseId}-${alias.endVerseId}`;
|
|
423
|
+
spans.add(spanKey);
|
|
424
|
+
anchorSpans.set(targetIdFor(verse), spans);
|
|
425
|
+
recordSpan(spanKey, alias.startVerseId, alias.endVerseId, alias.sourceId);
|
|
426
|
+
contributions.push({
|
|
427
|
+
verse,
|
|
428
|
+
evidence: [aliasPassageEvidence(alias, verse)],
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
138
432
|
}
|
|
139
433
|
}
|
|
140
434
|
}
|
|
141
|
-
|
|
142
|
-
|
|
435
|
+
// Rank with headroom, collapse, THEN cut to the limit. Collapsing after the
|
|
436
|
+
// cut would hand back fewer results than asked for: a four-verse anchor run
|
|
437
|
+
// inside the top 25 becomes one row, and the three freed slots stay empty
|
|
438
|
+
// while genuinely different passages sit just outside the window.
|
|
439
|
+
const limit = options.rankOptions?.limit ?? DEFAULT_LIMIT;
|
|
440
|
+
const ranked = rank(
|
|
441
|
+
// Complete-match subsumption (0.10.0 stage 3): a complete whole-query
|
|
442
|
+
// exact_phrase match drops its same-token token_overlap/proximity
|
|
443
|
+
// restatement before ranking. See subsumeCompletePhraseRestatements.
|
|
444
|
+
subsumeCompletePhraseRestatements(mergeCandidates(contributions), completePhraseTargets), {
|
|
445
|
+
...options.rankOptions,
|
|
446
|
+
limit: limit + COLLAPSE_HEADROOM,
|
|
447
|
+
});
|
|
448
|
+
// Pericope lookup for the ranked window (0.14.0/CO-3 PR 2): ONE batched
|
|
449
|
+
// query (G11), asked only for verses NO curated anchor span governs —
|
|
450
|
+
// authority order is fixed, an anchor-claimed verse never consults the
|
|
451
|
+
// pericope path, so fetching for it would widen the window for nothing.
|
|
452
|
+
const pericopeOf = new Map();
|
|
453
|
+
if (hasPericopes) {
|
|
454
|
+
const ungoverned = ranked
|
|
455
|
+
.filter((result) => !anchorSpans.has(result.targetId))
|
|
456
|
+
.map((result) => verses.get(result.targetId).verseId);
|
|
457
|
+
if (ungoverned.length > 0) {
|
|
458
|
+
for (const row of await repository.pericopesContaining(ungoverned)) {
|
|
459
|
+
for (const verseId of ungoverned) {
|
|
460
|
+
if (verseId >= row.startVerseId && verseId <= row.endVerseId) {
|
|
461
|
+
pericopeOf.set(verseId, row);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const results = (collapseRuns(ranked.map((result) => {
|
|
143
468
|
const verse = verses.get(result.targetId);
|
|
144
469
|
return {
|
|
145
470
|
targetId: result.targetId,
|
|
@@ -148,13 +473,39 @@ export async function createEngine(database, options = {}) {
|
|
|
148
473
|
score: result.score,
|
|
149
474
|
reasons: result.reasons,
|
|
150
475
|
};
|
|
151
|
-
})
|
|
476
|
+
}), verses, anchorSpans, spanInfo, pericopeOf)
|
|
477
|
+
.slice(0, limit)
|
|
478
|
+
// Chip display polish (0.10.0 CO-2/F22), applied LAST — after
|
|
479
|
+
// ranking, collapsing and the cut — so it is display-only by
|
|
480
|
+
// construction: scores, order and the page are already decided.
|
|
481
|
+
// Withheld chips' points still count (a result's score may exceed
|
|
482
|
+
// the sum of its displayed chips). related() is deliberately
|
|
483
|
+
// untouched: its only chip family (cross_reference, votes >= 1
|
|
484
|
+
// against the corpus maximum) cannot produce a chip below the
|
|
485
|
+
// display minimum, and passage_terms never appears there.
|
|
486
|
+
//
|
|
487
|
+
// Then the correction-citation pin (0.12.0/QR-5 round-2): on a
|
|
488
|
+
// corrected query EVERY result must visibly cite every correction —
|
|
489
|
+
// a result surfaced through concept/passage evidence has no
|
|
490
|
+
// decorated token chip, and a citation only the machine-readable
|
|
491
|
+
// corrections field carries is not "shown" (J31, covenant 5). Same
|
|
492
|
+
// display seam, same guarantees: labels only, never points, scores
|
|
493
|
+
// or order.
|
|
494
|
+
.map((result) => {
|
|
495
|
+
const polished = pinCorrectionCitations(polishChipsForDisplay(result.reasons), corrections);
|
|
496
|
+
return polished === result.reasons ? result : { ...result, reasons: polished };
|
|
497
|
+
}));
|
|
498
|
+
return { results, corrections };
|
|
152
499
|
}
|
|
153
500
|
async function relatedFor(reference) {
|
|
154
501
|
const trimmed = reference.trim();
|
|
155
502
|
const attempt = await repository.resolveReference(trimmed);
|
|
156
503
|
if (attempt.kind !== 'resolved') {
|
|
157
|
-
|
|
504
|
+
// Same posture as passage(): lookups never fall through, and the
|
|
505
|
+
// did-you-mean citation travels when one validated.
|
|
506
|
+
return attempt.kind === 'invalid-reference' && attempt.suggestion
|
|
507
|
+
? { kind: 'invalid-reference', query: trimmed, suggestion: attempt.suggestion, ...identity }
|
|
508
|
+
: { kind: 'invalid-reference', query: trimmed, ...identity };
|
|
158
509
|
}
|
|
159
510
|
const resolved = attempt.reference;
|
|
160
511
|
if (!concepts) {
|
|
@@ -239,9 +590,36 @@ export async function createEngine(database, options = {}) {
|
|
|
239
590
|
};
|
|
240
591
|
}
|
|
241
592
|
if (attempt.kind === 'invalid-reference') {
|
|
242
|
-
|
|
593
|
+
// Bare-number shapes with no resolving book and no citable
|
|
594
|
+
// suggestion fall through to discovery (0.11.0/QR-4, J36):
|
|
595
|
+
// "plans 29 11" is a Jeremiah 29:11 memory query, and dead-ending it
|
|
596
|
+
// served nobody. Explicit-separator queries state reference intent
|
|
597
|
+
// and stay typed invalid, carrying the did-you-mean when one
|
|
598
|
+
// validated (suggestion only — never a silently opened guess, J35).
|
|
599
|
+
if (attempt.fallthroughToDiscovery) {
|
|
600
|
+
const discovered = await discover(trimmed, true);
|
|
601
|
+
return {
|
|
602
|
+
kind: 'discovery',
|
|
603
|
+
query: trimmed,
|
|
604
|
+
results: discovered.results,
|
|
605
|
+
...(discovered.corrections.length > 0
|
|
606
|
+
? { corrections: discovered.corrections }
|
|
607
|
+
: {}),
|
|
608
|
+
...identity,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
return attempt.suggestion
|
|
612
|
+
? { kind: 'invalid-reference', query: trimmed, suggestion: attempt.suggestion, ...identity }
|
|
613
|
+
: { kind: 'invalid-reference', query: trimmed, ...identity };
|
|
243
614
|
}
|
|
244
|
-
|
|
615
|
+
const discovered = await discover(trimmed, true);
|
|
616
|
+
return {
|
|
617
|
+
kind: 'discovery',
|
|
618
|
+
query: trimmed,
|
|
619
|
+
results: discovered.results,
|
|
620
|
+
...(discovered.corrections.length > 0 ? { corrections: discovered.corrections } : {}),
|
|
621
|
+
...identity,
|
|
622
|
+
};
|
|
245
623
|
},
|
|
246
624
|
async themes(query) {
|
|
247
625
|
if (!concepts)
|
|
@@ -292,7 +670,11 @@ export async function createEngine(database, options = {}) {
|
|
|
292
670
|
...identity,
|
|
293
671
|
};
|
|
294
672
|
}
|
|
295
|
-
|
|
673
|
+
// A lookup has nothing to fall through to, so every non-resolution is
|
|
674
|
+
// typed invalid here — but the did-you-mean citation still travels.
|
|
675
|
+
return attempt.kind === 'invalid-reference' && attempt.suggestion
|
|
676
|
+
? { kind: 'invalid-reference', query: trimmed, suggestion: attempt.suggestion, ...identity }
|
|
677
|
+
: { kind: 'invalid-reference', query: trimmed, ...identity };
|
|
296
678
|
},
|
|
297
679
|
related: relatedFor,
|
|
298
680
|
async forSong(input) {
|
|
@@ -309,7 +691,10 @@ export async function createEngine(database, options = {}) {
|
|
|
309
691
|
if (input.lyrics)
|
|
310
692
|
parts.push(significantWords(input.lyrics).slice(0, MAX_LYRIC_TOKENS).join(' '));
|
|
311
693
|
const query = parts.join(' ').trim();
|
|
312
|
-
|
|
694
|
+
// forSong() never corrects (0.12.0/QR-5): lyrics are quoted text, not a
|
|
695
|
+
// fallible typed query, and a silent rewrite inside a song's own words
|
|
696
|
+
// is exactly the failure mode the correction feature forbids.
|
|
697
|
+
const results = query === '' ? [] : (await discover(query)).results;
|
|
313
698
|
// A foundational reference is a claim the writer made about the song, so
|
|
314
699
|
// its curated edges are admitted alongside discovery — but never as the
|
|
315
700
|
// only input, and never seeded from lyrics, which are not a claim.
|
|
@@ -336,3 +721,282 @@ export async function createEngine(database, options = {}) {
|
|
|
336
721
|
},
|
|
337
722
|
};
|
|
338
723
|
}
|
|
724
|
+
/**
|
|
725
|
+
* Label a section span. Uses the engine's own bbcccvvv codec (parseVerseId)
|
|
726
|
+
* — legitimate here, unlike inside the hit-label logic below, because the
|
|
727
|
+
* section's endpoints may not be surfaced verses at all: the codec is the
|
|
728
|
+
* only witness to their chapter:verse. Spans never cross books (anchor refs
|
|
729
|
+
* and pericopes are both book-scoped by construction).
|
|
730
|
+
*/
|
|
731
|
+
function sectionLabel(bookName, startVerseId, endVerseId) {
|
|
732
|
+
const start = parseVerseId(startVerseId);
|
|
733
|
+
const end = parseVerseId(endVerseId);
|
|
734
|
+
return startVerseId === endVerseId
|
|
735
|
+
? `${bookName} ${start.chapter}:${start.verse}`
|
|
736
|
+
: start.chapter === end.chapter
|
|
737
|
+
? `${bookName} ${start.chapter}:${start.verse}-${end.verse}`
|
|
738
|
+
: `${bookName} ${start.chapter}:${start.verse}-${end.chapter}:${end.verse}`;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Collapse the surfaced verses of ONE grouping unit into a single
|
|
742
|
+
* passage-level row. Two mechanisms feed it, in FIXED authority order:
|
|
743
|
+
*
|
|
744
|
+
* 1. Curated anchor spans (0.10.0 stage 7 semantics, unchanged: span
|
|
745
|
+
* MEMBERSHIP, not rank adjacency) — a passage a human named for a theme.
|
|
746
|
+
* Checked first: a verse any anchor span claims belongs to the anchor
|
|
747
|
+
* path and is never considered for pericope runs, so pericope provenance
|
|
748
|
+
* can never usurp anchor provenance (the anchor-grouping-explained
|
|
749
|
+
* fixture pins this).
|
|
750
|
+
* 2. Derived pericopes (0.14.0/CO-3 PR 2) — a structural sectioning fact
|
|
751
|
+
* (OpenBible section counts). Deliberately MORE conservative than the
|
|
752
|
+
* anchor path, because nobody named THIS passage for THIS query: members
|
|
753
|
+
* must be consecutive in rank AND verseId-consecutive AND share one
|
|
754
|
+
* pericope. A boundary is never crossed (the Terah fixture pins this),
|
|
755
|
+
* and ±1 verse-id adjacency structurally cannot cross a chapter — the
|
|
756
|
+
* documented v1 limitation that grouping never crosses a chapter even
|
|
757
|
+
* when a pericope does.
|
|
758
|
+
*
|
|
759
|
+
* Since 0.14.0 a merged row also SAYS why its verses travel together: it
|
|
760
|
+
* carries `verses[]` (each member's own evidence, uncollapsed) and a typed
|
|
761
|
+
* `grouping` naming the section span and the source that drew it — for
|
|
762
|
+
* anchor runs the anchor's own source(s), for pericope runs
|
|
763
|
+
* 'openbible-sections' plus the summed boundary vote at the section's start
|
|
764
|
+
* verse, read from the same artifact row the derivation stored. Grouping
|
|
765
|
+
* contributes ZERO points: the merged score is the max of the members,
|
|
766
|
+
* never a sum — a passage must not outrank by having more mediocre verses —
|
|
767
|
+
* and the row's `reference` spans the HITS, never the whole section.
|
|
768
|
+
*
|
|
769
|
+
* Why this exists: a ranged anchor emits one candidate per verse, and results
|
|
770
|
+
* carrying authoritative evidence are deliberately exempt from group
|
|
771
|
+
* diversification — a genuine multi-verse hit must never be thinned for the
|
|
772
|
+
* sake of variety. Correct for exact-phrase matches; wrong for a curated span,
|
|
773
|
+
* where the results ARE one passage. `communion` returned 1 Corinthians
|
|
774
|
+
* 11:23, :24, :25 and :26 at identical scores, spending the whole top of the
|
|
775
|
+
* list on a passage a human had already grouped.
|
|
776
|
+
*
|
|
777
|
+
* Until 0.10.0 this required RANK adjacency, which made the collapse depend
|
|
778
|
+
* on what happened to rank in between: `praise` filled five slots with
|
|
779
|
+
* individual verses of Psalm 150 because they ranked non-adjacently, and one
|
|
780
|
+
* differently-scored verse of a span broke the whole merge. The span is the
|
|
781
|
+
* unit because a person chose it — whether its verses rank consecutively is
|
|
782
|
+
* an accident of the other evidence. Now every surfaced member of a span
|
|
783
|
+
* merges into one row at the position of its best-ranked member; the members
|
|
784
|
+
* below drop and the results shift up. That is the point: the passage
|
|
785
|
+
* occupies one slot, not N.
|
|
786
|
+
*
|
|
787
|
+
* Determinism: pass 1 assigns each result a governing span — the span, among
|
|
788
|
+
* the spans it belongs to, covering the most surfaced results (ties broken by
|
|
789
|
+
* span key ascending) — and pass 2 emits in rank order, so the output is a
|
|
790
|
+
* pure function of the ranker's total order and data already in the artifact.
|
|
791
|
+
* Nothing is inferred, and equal inputs collapse identically on every
|
|
792
|
+
* platform.
|
|
793
|
+
*
|
|
794
|
+
* The merged row is honest about what surfaced: its reference spans the
|
|
795
|
+
* surfaced members (canonical min..max), the excerpt is their texts in
|
|
796
|
+
* canonical verse order, the score is the best member's (existing policy),
|
|
797
|
+
* and reasons merge strongest-per-label so the chips explain the passage
|
|
798
|
+
* rather than an arbitrary one of its verses.
|
|
799
|
+
*/
|
|
800
|
+
export function collapseRuns(results, verses, anchorSpans, spanInfo, pericopeOf) {
|
|
801
|
+
// Pass 1a — how many surfaced results does each span cover? Counted over
|
|
802
|
+
// the results actually present, so a span mostly outside the ranked window
|
|
803
|
+
// does not outvote one that is really here.
|
|
804
|
+
const spanCounts = new Map();
|
|
805
|
+
for (const result of results) {
|
|
806
|
+
const spans = anchorSpans.get(result.targetId);
|
|
807
|
+
if (!spans)
|
|
808
|
+
continue;
|
|
809
|
+
for (const span of spans) {
|
|
810
|
+
spanCounts.set(span, (spanCounts.get(span) ?? 0) + 1);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// Pass 1b — each result's governing span: most surfaced members first,
|
|
814
|
+
// then span key ascending. A verse inside two overlapping curated spans
|
|
815
|
+
// joins the one that gathers more of this result page (deterministic, and
|
|
816
|
+
// the larger passage is the one the page is actually showing).
|
|
817
|
+
const governing = new Map();
|
|
818
|
+
const members = new Map();
|
|
819
|
+
for (const result of results) {
|
|
820
|
+
const spans = anchorSpans.get(result.targetId);
|
|
821
|
+
if (!spans || spans.size === 0 || !verses.has(result.targetId))
|
|
822
|
+
continue;
|
|
823
|
+
let chosen = null;
|
|
824
|
+
for (const span of spans) {
|
|
825
|
+
if (chosen === null ||
|
|
826
|
+
(spanCounts.get(span) ?? 0) > (spanCounts.get(chosen) ?? 0) ||
|
|
827
|
+
((spanCounts.get(span) ?? 0) === (spanCounts.get(chosen) ?? 0) && span < chosen)) {
|
|
828
|
+
chosen = span;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
governing.set(result.targetId, chosen);
|
|
832
|
+
const bucket = members.get(chosen);
|
|
833
|
+
if (bucket)
|
|
834
|
+
bucket.push(result);
|
|
835
|
+
else
|
|
836
|
+
members.set(chosen, [result]);
|
|
837
|
+
}
|
|
838
|
+
// Pass 1c — pericope runs among the UNGOVERNED results (fixed authority
|
|
839
|
+
// order: anchor membership was decided above and is never revisited). A
|
|
840
|
+
// run is a maximal sequence of results that are consecutive in rank,
|
|
841
|
+
// verseId-consecutive (±1 — which cannot cross a chapter under bbcccvvv),
|
|
842
|
+
// and members of ONE pericope. Rank adjacency is measured over the INPUT
|
|
843
|
+
// ranked list, before any merging — the conservative reading: an
|
|
844
|
+
// interloper between two section-mates keeps them apart.
|
|
845
|
+
const runOf = new Map();
|
|
846
|
+
const runs = [];
|
|
847
|
+
{
|
|
848
|
+
let current = [];
|
|
849
|
+
let currentPericope = null;
|
|
850
|
+
const flush = () => {
|
|
851
|
+
if (current.length >= 2) {
|
|
852
|
+
const index = runs.length;
|
|
853
|
+
runs.push(current);
|
|
854
|
+
for (const member of current)
|
|
855
|
+
runOf.set(member.targetId, index);
|
|
856
|
+
}
|
|
857
|
+
current = [];
|
|
858
|
+
currentPericope = null;
|
|
859
|
+
};
|
|
860
|
+
for (const result of results) {
|
|
861
|
+
const verse = verses.get(result.targetId);
|
|
862
|
+
const pericope = governing.has(result.targetId) || verse === undefined
|
|
863
|
+
? undefined
|
|
864
|
+
: pericopeOf.get(verse.verseId);
|
|
865
|
+
if (!verse || !pericope) {
|
|
866
|
+
flush();
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (currentPericope !== null &&
|
|
870
|
+
pericope.startVerseId === currentPericope.startVerseId &&
|
|
871
|
+
Math.abs(verse.verseId - verses.get(current[current.length - 1].targetId).verseId) === 1) {
|
|
872
|
+
current.push(result);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
flush();
|
|
876
|
+
current = [result];
|
|
877
|
+
currentPericope = pericope;
|
|
878
|
+
}
|
|
879
|
+
flush();
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Build the merged passage row for one group. Canonical verse order for
|
|
883
|
+
* the label and the excerpt; rank order decides WHERE the row sits (at the
|
|
884
|
+
* best member) and the targetId (the best member's, so consumers can still
|
|
885
|
+
* address the passage and fixture range-matching keeps working unchanged).
|
|
886
|
+
*/
|
|
887
|
+
const mergeGroup = (head, group, grouping) => {
|
|
888
|
+
const canonical = [...group].sort((a, b) => verses.get(a.targetId).verseId - verses.get(b.targetId).verseId);
|
|
889
|
+
const first = verses.get(canonical[0].targetId);
|
|
890
|
+
const final = verses.get(canonical[canonical.length - 1].targetId);
|
|
891
|
+
// Reasons merged by label, strongest kept, so the chip still explains the
|
|
892
|
+
// passage rather than an arbitrary one of its verses.
|
|
893
|
+
const byLabel = new Map();
|
|
894
|
+
for (const item of group) {
|
|
895
|
+
for (const reason of item.reasons) {
|
|
896
|
+
const existing = byLabel.get(reason.label);
|
|
897
|
+
if (!existing || reason.points > existing.points)
|
|
898
|
+
byLabel.set(reason.label, reason);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const reasons = [...byLabel.values()].sort((a, b) => b.points !== a.points ? b.points - a.points : a.label < b.label ? -1 : 1);
|
|
902
|
+
// Per-verse evidence, uncollapsed (0.14.0): members are plain rows by
|
|
903
|
+
// construction, so their own fields ARE the GroupedVerse shape.
|
|
904
|
+
const memberVerses = canonical.map((item) => ({
|
|
905
|
+
targetId: item.targetId,
|
|
906
|
+
reference: item.reference,
|
|
907
|
+
excerpt: item.excerpt,
|
|
908
|
+
score: item.score,
|
|
909
|
+
reasons: item.reasons,
|
|
910
|
+
}));
|
|
911
|
+
return {
|
|
912
|
+
targetId: head.targetId,
|
|
913
|
+
reference: first.verseId === final.verseId
|
|
914
|
+
? canonical[0].reference
|
|
915
|
+
: first.chapter === final.chapter
|
|
916
|
+
? `${referenceLabel(first)}-${final.verse}`
|
|
917
|
+
: // Reachable only for a span crossing a chapter boundary. Written
|
|
918
|
+
// correctly regardless of the bbcccvvv id encoding: that is not
|
|
919
|
+
// this function's invariant to rely on, and "Psalms 22:31-1" is
|
|
920
|
+
// the kind of wrong that survives review because nobody can
|
|
921
|
+
// produce it on demand.
|
|
922
|
+
`${referenceLabel(first)}-${final.chapter}:${final.verse}`,
|
|
923
|
+
excerpt: canonical.map((item) => item.excerpt).join(' '),
|
|
924
|
+
score: Math.max(...group.map((item) => item.score)),
|
|
925
|
+
reasons,
|
|
926
|
+
...(grouping ? { verses: memberVerses, grouping } : {}),
|
|
927
|
+
};
|
|
928
|
+
};
|
|
929
|
+
// Pass 2 — emit in rank order. The first-encountered member of a group
|
|
930
|
+
// becomes the merged passage row; later members drop and everything below
|
|
931
|
+
// shifts up.
|
|
932
|
+
const emitted = new Set();
|
|
933
|
+
const emittedRuns = new Set();
|
|
934
|
+
const output = [];
|
|
935
|
+
for (const result of results) {
|
|
936
|
+
const span = governing.get(result.targetId);
|
|
937
|
+
if (span === undefined) {
|
|
938
|
+
const runIndex = runOf.get(result.targetId);
|
|
939
|
+
if (runIndex === undefined) {
|
|
940
|
+
output.push(result);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
if (emittedRuns.has(runIndex))
|
|
944
|
+
continue;
|
|
945
|
+
emittedRuns.add(runIndex);
|
|
946
|
+
const group = runs[runIndex];
|
|
947
|
+
const pericope = pericopeOf.get(verses.get(result.targetId).verseId);
|
|
948
|
+
output.push(mergeGroup(result, group, {
|
|
949
|
+
section: {
|
|
950
|
+
reference: sectionLabel(verses.get(result.targetId).bookName, pericope.startVerseId, pericope.endVerseId),
|
|
951
|
+
startVerseId: pericope.startVerseId,
|
|
952
|
+
endVerseId: pericope.endVerseId,
|
|
953
|
+
},
|
|
954
|
+
provenance: {
|
|
955
|
+
sourceId: pericope.sourceId,
|
|
956
|
+
label: sourceLabel(pericope.sourceId),
|
|
957
|
+
boundaryVotes: pericope.boundaryVotes,
|
|
958
|
+
},
|
|
959
|
+
}));
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (emitted.has(span))
|
|
963
|
+
continue;
|
|
964
|
+
emitted.add(span);
|
|
965
|
+
const group = members.get(span);
|
|
966
|
+
if (group.length === 1) {
|
|
967
|
+
output.push(result);
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
// The span's own provenance explains the merge (0.14.0): the anchor's
|
|
971
|
+
// source(s), ascending-joined when several agree — the same convention
|
|
972
|
+
// the stage-6 chips use. Absent spanInfo (the legacy 3-argument entry
|
|
973
|
+
// point) the merged row keeps its exact pre-0.14.0 shape.
|
|
974
|
+
const info = spanInfo.get(span);
|
|
975
|
+
output.push(mergeGroup(result, group, info
|
|
976
|
+
? {
|
|
977
|
+
section: {
|
|
978
|
+
reference: sectionLabel(verses.get(result.targetId).bookName, info.startVerseId, info.endVerseId),
|
|
979
|
+
startVerseId: info.startVerseId,
|
|
980
|
+
endVerseId: info.endVerseId,
|
|
981
|
+
},
|
|
982
|
+
provenance: {
|
|
983
|
+
sourceId: [...info.sourceIds].sort().join('+'),
|
|
984
|
+
label: [...info.sourceIds]
|
|
985
|
+
.sort()
|
|
986
|
+
.map((id) => sourceLabel(id))
|
|
987
|
+
.join(' + '),
|
|
988
|
+
},
|
|
989
|
+
}
|
|
990
|
+
: null));
|
|
991
|
+
}
|
|
992
|
+
return output;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* The 0.13.0 entry point, kept for compatibility: anchor-span collapse only,
|
|
996
|
+
* no pericope runs, merged rows in their exact pre-0.14.0 shape (no
|
|
997
|
+
* `verses`/`grouping` — those need the span provenance the 5-argument
|
|
998
|
+
* `collapseRuns` receives). `discover()` no longer calls this.
|
|
999
|
+
*/
|
|
1000
|
+
export function collapseAnchorRuns(results, verses, anchorSpans) {
|
|
1001
|
+
return collapseRuns(results, verses, anchorSpans, new Map(), new Map());
|
|
1002
|
+
}
|