@jestek-dev/scripture-engine 0.7.1 → 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.
@@ -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 { rank } from './ranking/rank.js';
18
- const SUPPORTED_SCHEMA_VERSIONS = new Set(['1', '2', '3', '4', '5']);
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
- async function discover(query) {
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
- contributions.push({
63
- verse: match,
64
- evidence: [phraseEvidence(query.trim(), queryWords, queryWords)],
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
- 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
- });
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
- const tokens = significantWords(query);
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
- const frequencies = await repository.tokenDocumentCounts(tokens);
88
- const idfTotal = queryIdfTotal(tokens, frequencies, documentCount);
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({ verse: match, evidence: tokenEvidence(match, idfTotal) });
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 specificity = new Map(matched.map((match) => [match.conceptId, match.matchedTokenCount]));
108
- const anchors = await concepts.anchorVerses(matched.map((match) => match.conceptId));
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
- conceptAnchorEvidence(anchor, specificity.get(anchor.conceptId) ?? 1),
308
+ weakCue
309
+ ? conceptCueEvidence(anchor, matchedTokenCount, tokens.length)
310
+ : conceptAnchorEvidence(anchor, matchedTokenCount, tokens.length),
115
311
  ],
116
312
  });
117
313
  }
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)] });
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
- // 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
- });
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
- const ranked = rank(mergeCandidates(contributions), options.rankOptions);
142
- return ranked.map((result) => {
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
- return { kind: 'invalid-reference', query: trimmed, ...identity };
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
- return { kind: 'invalid-reference', query: trimmed, ...identity };
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
- return { kind: 'discovery', query: trimmed, results: await discover(trimmed), ...identity };
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
- return { kind: 'invalid-reference', query: trimmed, ...identity };
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
- const results = query === '' ? [] : await discover(query);
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
+ }