@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.
@@ -48,6 +48,15 @@ export class CorpusRepository {
48
48
  constructor(database) {
49
49
  this.database = database;
50
50
  }
51
+ /**
52
+ * The alias vocabulary for the reference did-you-mean (0.11.0/QR-4),
53
+ * fetched through the port once per repository instance and cached: ~270
54
+ * rows that cannot change under a running engine (the artifact is
55
+ * immutable), so re-reading them per query would be waste, and caching
56
+ * keeps the engine's no-I/O covenant intact — the ONE read still goes
57
+ * through ContentQueryPort.
58
+ */
59
+ bookAliasCache = null;
51
60
  async close() {
52
61
  await this.database.close();
53
62
  }
@@ -92,6 +101,21 @@ export class CorpusRepository {
92
101
  const result = await this.database.execute('SELECT 1 AS present FROM verses WHERE book_id = ? AND chapter = ? AND verse = ? LIMIT 1', [bookId, chapter, verse]);
93
102
  return result.rows.length > 0;
94
103
  }
104
+ async listBookAliases() {
105
+ if (this.bookAliasCache)
106
+ return this.bookAliasCache;
107
+ const result = await this.database.execute(`SELECT a.alias_key AS aliasKey, b.id AS bookId, b.name AS bookName,
108
+ b.chapter_count AS chapterCount
109
+ FROM book_aliases a JOIN books b ON b.id = a.book_id
110
+ ORDER BY a.alias_key`);
111
+ this.bookAliasCache = result.rows.map((row) => ({
112
+ aliasKey: str(row, 'aliasKey'),
113
+ bookId: num(row, 'bookId'),
114
+ bookName: str(row, 'bookName'),
115
+ chapterCount: num(row, 'chapterCount'),
116
+ }));
117
+ return this.bookAliasCache;
118
+ }
95
119
  async resolveReference(input) {
96
120
  return await resolveReferenceAttempt(input, this);
97
121
  }
@@ -222,6 +246,176 @@ export class CorpusRepository {
222
246
  FROM token_stats WHERE token IN (${placeholders}) GROUP BY token`, unique);
223
247
  return new Map(result.rows.map((row) => [str(row, 'token'), num(row, 'df')]));
224
248
  }
249
+ /**
250
+ * Whether this artifact carries the precomputed spelling index
251
+ * (schema v7, 0.12.0/QR-5). Presence-probed like the other optional
252
+ * layers: a v6 artifact simply has no tables, the probe returns false, and
253
+ * the engine gracefully does not correct — behaving exactly as the
254
+ * pre-spelling engine did. That probe IS the rollback story: rebuild the
255
+ * artifact without the tables and behavior reverts with no engine change.
256
+ */
257
+ async hasSpellingIndex() {
258
+ try {
259
+ const result = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'spelling_terms'");
260
+ return result.rows.length > 0;
261
+ }
262
+ catch {
263
+ return false;
264
+ }
265
+ }
266
+ /**
267
+ * Whether this artifact carries the derived pericope tiling (schema v8,
268
+ * CO-3 PR 1). Presence-and-rows probed like the other optional layers: a
269
+ * v7 artifact has no table, an emptied table disables the (future)
270
+ * grouping step silently, and behavior reverts to pre-pericope output
271
+ * with no engine change — the probe IS the rollback story.
272
+ */
273
+ async hasPericopes() {
274
+ try {
275
+ const table = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pericopes'");
276
+ if (table.rows.length === 0)
277
+ return false;
278
+ const rows = await this.database.execute('SELECT 1 AS present FROM pericopes LIMIT 1');
279
+ return rows.rows.length > 0;
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ }
285
+ /**
286
+ * The pericopes containing any of the given verse ids, batched as ONE
287
+ * bounded query over the ranked window (G11): the window's min..max verse
288
+ * span overlaps few pericopes, and the caller maps verses to rows. Rows
289
+ * come back ordered by start verse for platform-stable iteration.
290
+ *
291
+ * NO CALL SITES in discover() yet (CO-3 PR 1 capability): the grouping
292
+ * behavior that consumes this lands with the PR 2 ENGINE_VERSION bump.
293
+ * boundaryVotes is the summed boundary vote at the pericope's start verse
294
+ * — the countable fact the artifact stores, so a future explanation and
295
+ * the shipped data cannot disagree.
296
+ */
297
+ async pericopesContaining(verseIds) {
298
+ const unique = [...new Set(verseIds)];
299
+ if (unique.length === 0)
300
+ return [];
301
+ const result = await this.database.execute(`SELECT start_verse_id AS startVerseId, end_verse_id AS endVerseId,
302
+ boundary_votes AS boundaryVotes, source_id AS sourceId
303
+ FROM pericopes
304
+ WHERE end_verse_id >= ? AND start_verse_id <= ?
305
+ ORDER BY start_verse_id`, [Math.min(...unique), Math.max(...unique)]);
306
+ const spans = result.rows.map((row) => ({
307
+ startVerseId: num(row, 'startVerseId'),
308
+ endVerseId: num(row, 'endVerseId'),
309
+ boundaryVotes: num(row, 'boundaryVotes'),
310
+ sourceId: str(row, 'sourceId'),
311
+ }));
312
+ // The min..max window can overlap pericopes containing none of the
313
+ // asked-for verses; keep only real containers so the caller's mapping
314
+ // stays honest.
315
+ return spans.filter((span) => unique.some((verseId) => verseId >= span.startVerseId && verseId <= span.endVerseId));
316
+ }
317
+ /**
318
+ * Whether this artifact carries the mined TSK cross-reference phrase keys
319
+ * (schema v9, B3 Phase A). Presence-and-rows probed like pericopes: a v8
320
+ * artifact has no table, an emptied table reads false, and (future)
321
+ * phrase-labeled behavior reverts to plain cross_references output with no
322
+ * engine change — the probe IS the rollback story.
323
+ */
324
+ async hasCrossReferencePhrases() {
325
+ try {
326
+ const table = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cross_reference_phrases'");
327
+ if (table.rows.length === 0)
328
+ return false;
329
+ const rows = await this.database.execute('SELECT 1 AS present FROM cross_reference_phrases LIMIT 1');
330
+ return rows.rows.length > 0;
331
+ }
332
+ catch {
333
+ return false;
334
+ }
335
+ }
336
+ /**
337
+ * The cross-reference phrase triples whose FROM verse is one of the given
338
+ * verse ids, batched as ONE bounded query over the window's min..max span
339
+ * (G11) and filtered back to the asked-for verses. Rows come back ordered
340
+ * by (from, phrase, start, end, source) for platform-stable iteration —
341
+ * sorted HERE, in engine code, by UTF-16 code units (the same comparison
342
+ * buildConceptLayer's fingerprint feed uses), never by the port's SQL
343
+ * collation: SQLite's BINARY collation compares UTF-8 bytes, which
344
+ * disagrees with JS on some non-ASCII strings, and the ordering contract
345
+ * must not depend on which side compares.
346
+ *
347
+ * NO CALL SITES in discover() yet (B3 Phase A capability): the labeling
348
+ * and off-phrase-discount behavior that consumes this lands with the
349
+ * Phase B ENGINE_VERSION bump behind J26/J55.
350
+ */
351
+ async crossReferencePhrasesFor(verseIds) {
352
+ const unique = [...new Set(verseIds)];
353
+ if (unique.length === 0)
354
+ return [];
355
+ const asked = new Set(unique);
356
+ const result = await this.database.execute(`SELECT from_verse_id AS fromVerseId, normalized_phrase AS normalizedPhrase,
357
+ to_start_verse_id AS toStartVerseId, to_end_verse_id AS toEndVerseId,
358
+ source_id AS sourceId
359
+ FROM cross_reference_phrases
360
+ WHERE from_verse_id >= ? AND from_verse_id <= ?
361
+ ORDER BY from_verse_id`, [Math.min(...unique), Math.max(...unique)]);
362
+ // The min..max window can include from-verses nobody asked about; keep
363
+ // only the asked-for ones so the caller's mapping stays honest.
364
+ return result.rows
365
+ .map((row) => ({
366
+ fromVerseId: num(row, 'fromVerseId'),
367
+ normalizedPhrase: str(row, 'normalizedPhrase'),
368
+ toStartVerseId: num(row, 'toStartVerseId'),
369
+ toEndVerseId: num(row, 'toEndVerseId'),
370
+ sourceId: str(row, 'sourceId'),
371
+ }))
372
+ .filter((row) => asked.has(row.fromVerseId))
373
+ // source_id joins the tie-break so two sources naming the same
374
+ // (from, phrase, target) triple can never come back in
375
+ // platform-unspecified order once a second phrase source exists.
376
+ .sort((a, b) => a.fromVerseId - b.fromVerseId ||
377
+ (a.normalizedPhrase < b.normalizedPhrase ? -1 : a.normalizedPhrase > b.normalizedPhrase ? 1 : 0) ||
378
+ a.toStartVerseId - b.toStartVerseId ||
379
+ a.toEndVerseId - b.toEndVerseId ||
380
+ (a.sourceId < b.sourceId ? -1 : a.sourceId > b.sourceId ? 1 : 0));
381
+ }
382
+ /**
383
+ * Which of the given tokens exist in the artifact's spelling vocabulary
384
+ * (corpus tokens ∪ book aliases ∪ lexicon tokens ∪ translation tokens ∪
385
+ * Layer B verse terms).
386
+ * This is the OOV gate's second half: a token with corpus df 0 that is
387
+ * still a known name or curated word is IN vocabulary and never corrected.
388
+ */
389
+ async spellingTermsPresent(tokens) {
390
+ const unique = [...new Set(tokens)];
391
+ if (unique.length === 0)
392
+ return new Set();
393
+ const placeholders = unique.map(() => '?').join(', ');
394
+ const result = await this.database.execute(`SELECT term FROM spelling_terms WHERE term IN (${placeholders})`, unique);
395
+ return new Set(result.rows.map((row) => str(row, 'term')));
396
+ }
397
+ /**
398
+ * Dictionary terms whose precomputed delete variants intersect the given
399
+ * keys — the SymSpell candidate lookup (0.12.0/QR-5). Proposes only: every
400
+ * candidate is re-verified with the bounded Damerau DP before it may win
401
+ * (see intents/spelling.ts). ORDER BY term for a platform-stable row order,
402
+ * though the picker is proven row-order independent anyway.
403
+ */
404
+ async spellingCandidates(deleteKeys) {
405
+ const unique = [...new Set(deleteKeys)];
406
+ if (unique.length === 0)
407
+ return [];
408
+ const placeholders = unique.map(() => '?').join(', ');
409
+ const result = await this.database.execute(`SELECT DISTINCT d.term AS term, t.document_count AS documentCount
410
+ FROM spelling_deletes d
411
+ JOIN spelling_terms t ON t.term = d.term
412
+ WHERE d.delete_key IN (${placeholders})
413
+ ORDER BY d.term`, unique);
414
+ return result.rows.map((row) => ({
415
+ term: str(row, 'term'),
416
+ documentCount: num(row, 'documentCount'),
417
+ }));
418
+ }
225
419
  }
226
420
  /** Longest fragment length worth searching; below this, phrases are noise. */
227
421
  const MIN_FRAGMENT_WORDS = 3;
@@ -318,7 +512,8 @@ export class ConceptRepository {
318
512
  v.book_id AS bookId, b.name AS bookName,
319
513
  v.chapter AS chapter, v.verse AS verse, v.text AS text,
320
514
  a.concept_id AS conceptId, c.label AS conceptLabel,
321
- a.source_id AS sourceId, a.weight AS weight, a.locator AS locator
515
+ a.source_id AS sourceId, a.weight AS weight, a.locator AS locator,
516
+ a.start_verse_id AS anchorStartVerseId, a.end_verse_id AS anchorEndVerseId
322
517
  FROM concept_anchors a
323
518
  JOIN concepts c ON c.id = a.concept_id
324
519
  JOIN verses v ON v.verse_id BETWEEN a.start_verse_id AND a.end_verse_id
@@ -333,6 +528,8 @@ export class ConceptRepository {
333
528
  sourceId: str(row, 'sourceId'),
334
529
  weight: num(row, 'weight'),
335
530
  locator: typeof row['locator'] === 'string' ? row['locator'] : null,
531
+ anchorStartVerseId: num(row, 'anchorStartVerseId'),
532
+ anchorEndVerseId: num(row, 'anchorEndVerseId'),
336
533
  }));
337
534
  }
338
535
  /**
@@ -418,6 +615,70 @@ export class ConceptRepository {
418
615
  * it is a long way from the passage saying it. G6 caps it so no volume of
419
616
  * homiletical vocabulary can outrank a curated anchor or a verbatim quote.
420
617
  */
618
+ /**
619
+ * Verses whose CROSS-TRANSLATION vocabulary matches the query.
620
+ *
621
+ * This is what lets someone search in the translation they learned a verse
622
+ * in. The stems here appear in some English translation of the verse but not
623
+ * in the one shipped, so a query using that wording reaches the verse
624
+ * anyway. See pipeline/src/schema.ts for what is and is not stored.
625
+ *
626
+ * Ranked by how MANY query stems a verse accounts for, then by verse id. No
627
+ * IDF weighting: these stems are already the residue after the shipped
628
+ * wording is subtracted, so a stem appearing here is by construction
629
+ * something the shipped text does not say.
630
+ */
631
+ async searchTranslationTokens(tokens, limit = 60) {
632
+ const unique = [...new Set(tokens)];
633
+ if (unique.length === 0)
634
+ return [];
635
+ const placeholders = unique.map(() => '?').join(', ');
636
+ const result = await this.database.execute(`WITH hits AS (
637
+ SELECT vtt.verse_id AS vid,
638
+ group_concat(vtt.token, ' ') AS tokens,
639
+ COUNT(*) AS matched
640
+ FROM verse_translation_tokens vtt
641
+ WHERE vtt.token IN (${placeholders})
642
+ GROUP BY vtt.verse_id
643
+ )
644
+ SELECT v.id AS id, v.verse_id AS verseId,
645
+ v.translation_id AS translationId, t.code AS translationCode,
646
+ v.book_id AS bookId, b.name AS bookName,
647
+ v.chapter AS chapter, v.verse AS verse, v.text AS text,
648
+ h.tokens AS tokens, h.matched AS matched
649
+ FROM hits h
650
+ JOIN verses v ON v.verse_id = h.vid
651
+ JOIN translations t ON t.id = v.translation_id
652
+ JOIN books b ON b.id = v.book_id
653
+ ORDER BY h.matched DESC, v.verse_id, t.code
654
+ LIMIT ?`, [...unique, limit]);
655
+ return result.rows.map((row) => ({
656
+ ...mapVerse(row),
657
+ matchedTokens: [...new Set(str(row, 'tokens').split(' ').filter(Boolean))].sort(),
658
+ }));
659
+ }
660
+ /** How many verses carry each stem — the df for weighting alternate wording. */
661
+ async translationTokenDocumentCounts(tokens) {
662
+ const unique = [...new Set(tokens)];
663
+ if (unique.length === 0)
664
+ return new Map();
665
+ const placeholders = unique.map(() => '?').join(', ');
666
+ const result = await this.database.execute(`SELECT token, COUNT(DISTINCT verse_id) AS n
667
+ FROM verse_translation_tokens
668
+ WHERE token IN (${placeholders})
669
+ GROUP BY token`, unique);
670
+ return new Map(result.rows.map((row) => [str(row, 'token'), num(row, 'n')]));
671
+ }
672
+ /** Whether the artifact carries cross-translation vocabulary at all. */
673
+ async hasTranslationTokens() {
674
+ try {
675
+ const result = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'verse_translation_tokens'");
676
+ return result.rows.length > 0;
677
+ }
678
+ catch {
679
+ return false;
680
+ }
681
+ }
421
682
  async searchPassageTerms(terms, limit = 60) {
422
683
  const unique = [...new Set(terms)];
423
684
  if (unique.length === 0)
@@ -459,6 +720,76 @@ export class ConceptRepository {
459
720
  const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='verse_terms'");
460
721
  return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
461
722
  }
723
+ /**
724
+ * Whether this artifact carries any curated phrase/hymn aliases
725
+ * (0.13.0/QR-6). Presence-AND-ROWS probed, deliberately stricter than the
726
+ * other layer probes: schema v7 ships the table EMPTY (QR-5), and an
727
+ * engine that ran the alias step against an empty table would pay a query
728
+ * per research() call for nothing — and, more importantly, the rollback
729
+ * story is "rebuild without alias rows", which must restore pre-QR-6
730
+ * behavior exactly. No table, or an empty one, and 0.13.0 behaves as
731
+ * 0.12.0 did.
732
+ */
733
+ async hasCuratedAliases() {
734
+ try {
735
+ const table = await this.database.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'curated_aliases'");
736
+ if (table.rows.length === 0)
737
+ return false;
738
+ const rows = await this.database.execute('SELECT 1 AS present FROM curated_aliases LIMIT 1');
739
+ return rows.rows.length > 0;
740
+ }
741
+ catch {
742
+ return false;
743
+ }
744
+ }
745
+ /**
746
+ * The curated aliases whose whole-query key equals the given normalized
747
+ * phrase. EQUALITY, never containment — the line that keeps a curated
748
+ * phrase table from becoming a hidden second ranking system; brittleness
749
+ * to extra words is accepted BY DESIGN. `normalized_raw` is UNIQUE, so
750
+ * this returns at most one row; it is typed as a list so the caller does
751
+ * not encode that schema fact.
752
+ */
753
+ async matchAliases(normalizedQuery) {
754
+ if (!normalizedQuery)
755
+ return [];
756
+ const result = await this.database.execute(`SELECT a.id AS id, a.title AS title, a.concept_id AS conceptId,
757
+ c.label AS conceptLabel,
758
+ a.start_verse_id AS startVerseId, a.end_verse_id AS endVerseId,
759
+ a.source_id AS sourceId, a.weight AS weight, a.locator AS locator
760
+ FROM curated_aliases a
761
+ LEFT JOIN concepts c ON c.id = a.concept_id
762
+ WHERE a.normalized_raw = ?
763
+ ORDER BY a.id`, [normalizedQuery]);
764
+ return result.rows.map((row) => ({
765
+ id: num(row, 'id'),
766
+ title: str(row, 'title'),
767
+ conceptId: typeof row['conceptId'] === 'string' ? row['conceptId'] : null,
768
+ conceptLabel: typeof row['conceptLabel'] === 'string' ? row['conceptLabel'] : null,
769
+ startVerseId: typeof row['startVerseId'] === 'number' ? row['startVerseId'] : null,
770
+ endVerseId: typeof row['endVerseId'] === 'number' ? row['endVerseId'] : null,
771
+ sourceId: str(row, 'sourceId'),
772
+ weight: num(row, 'weight'),
773
+ locator: typeof row['locator'] === 'string' ? row['locator'] : null,
774
+ }));
775
+ }
776
+ /**
777
+ * Verses of an explicit alias verse range (the XOR's other arm). A range
778
+ * absent from this corpus returns no rows — the alias then contributes
779
+ * nothing, honestly, rather than being guessed at.
780
+ */
781
+ async aliasRangeVerses(startVerseId, endVerseId) {
782
+ const result = await this.database.execute(`SELECT v.id AS id, v.verse_id AS verseId,
783
+ v.translation_id AS translationId, t.code AS translationCode,
784
+ v.book_id AS bookId, b.name AS bookName,
785
+ v.chapter AS chapter, v.verse AS verse, v.text AS text
786
+ FROM verses v
787
+ JOIN translations t ON t.id = v.translation_id
788
+ JOIN books b ON b.id = v.book_id
789
+ WHERE v.verse_id BETWEEN ? AND ?
790
+ ORDER BY v.verse_id, t.code`, [startVerseId, endVerseId]);
791
+ return result.rows.map(mapVerse);
792
+ }
462
793
  async hasConceptLayer() {
463
794
  const result = await this.database.execute("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='table' AND name='concepts'");
464
795
  return num(result.rows[0] ?? { n: 0 }, 'n') > 0;
@@ -10,8 +10,9 @@
10
10
  * Curated expansion (concepts, cross-references) attaches at step 5 in Phase
11
11
  * 2 without changing anything above it.
12
12
  */
13
+ import { type PericopeRow } from './corpus/repository.js';
13
14
  import { type RankOptions } from './ranking/rank.js';
14
- import type { ConceptMatch, ContentQueryPort, PassageResult, RelatedResult, ResearchResult, SongInput } from './types.js';
15
+ import type { ConceptMatch, ContentQueryPort, DiscoveryResult, PassageResult, RelatedResult, ResearchResult, ScriptureVerse, SongInput } from './types.js';
15
16
  export interface EngineOptions {
16
17
  /**
17
18
  * Throw if the artifact was tokenized by a different tokenizer version.
@@ -56,3 +57,77 @@ export interface ScriptureEngine {
56
57
  readonly engineVersion: string;
57
58
  }
58
59
  export declare function createEngine(database: ContentQueryPort, options?: EngineOptions): Promise<ScriptureEngine>;
60
+ /** A grouping span's own extent and the source(s) that named it. */
61
+ export interface GroupingSpanInfo {
62
+ readonly startVerseId: number;
63
+ readonly endVerseId: number;
64
+ readonly sourceIds: ReadonlySet<string>;
65
+ }
66
+ /**
67
+ * Collapse the surfaced verses of ONE grouping unit into a single
68
+ * passage-level row. Two mechanisms feed it, in FIXED authority order:
69
+ *
70
+ * 1. Curated anchor spans (0.10.0 stage 7 semantics, unchanged: span
71
+ * MEMBERSHIP, not rank adjacency) — a passage a human named for a theme.
72
+ * Checked first: a verse any anchor span claims belongs to the anchor
73
+ * path and is never considered for pericope runs, so pericope provenance
74
+ * can never usurp anchor provenance (the anchor-grouping-explained
75
+ * fixture pins this).
76
+ * 2. Derived pericopes (0.14.0/CO-3 PR 2) — a structural sectioning fact
77
+ * (OpenBible section counts). Deliberately MORE conservative than the
78
+ * anchor path, because nobody named THIS passage for THIS query: members
79
+ * must be consecutive in rank AND verseId-consecutive AND share one
80
+ * pericope. A boundary is never crossed (the Terah fixture pins this),
81
+ * and ±1 verse-id adjacency structurally cannot cross a chapter — the
82
+ * documented v1 limitation that grouping never crosses a chapter even
83
+ * when a pericope does.
84
+ *
85
+ * Since 0.14.0 a merged row also SAYS why its verses travel together: it
86
+ * carries `verses[]` (each member's own evidence, uncollapsed) and a typed
87
+ * `grouping` naming the section span and the source that drew it — for
88
+ * anchor runs the anchor's own source(s), for pericope runs
89
+ * 'openbible-sections' plus the summed boundary vote at the section's start
90
+ * verse, read from the same artifact row the derivation stored. Grouping
91
+ * contributes ZERO points: the merged score is the max of the members,
92
+ * never a sum — a passage must not outrank by having more mediocre verses —
93
+ * and the row's `reference` spans the HITS, never the whole section.
94
+ *
95
+ * Why this exists: a ranged anchor emits one candidate per verse, and results
96
+ * carrying authoritative evidence are deliberately exempt from group
97
+ * diversification — a genuine multi-verse hit must never be thinned for the
98
+ * sake of variety. Correct for exact-phrase matches; wrong for a curated span,
99
+ * where the results ARE one passage. `communion` returned 1 Corinthians
100
+ * 11:23, :24, :25 and :26 at identical scores, spending the whole top of the
101
+ * list on a passage a human had already grouped.
102
+ *
103
+ * Until 0.10.0 this required RANK adjacency, which made the collapse depend
104
+ * on what happened to rank in between: `praise` filled five slots with
105
+ * individual verses of Psalm 150 because they ranked non-adjacently, and one
106
+ * differently-scored verse of a span broke the whole merge. The span is the
107
+ * unit because a person chose it — whether its verses rank consecutively is
108
+ * an accident of the other evidence. Now every surfaced member of a span
109
+ * merges into one row at the position of its best-ranked member; the members
110
+ * below drop and the results shift up. That is the point: the passage
111
+ * occupies one slot, not N.
112
+ *
113
+ * Determinism: pass 1 assigns each result a governing span — the span, among
114
+ * the spans it belongs to, covering the most surfaced results (ties broken by
115
+ * span key ascending) — and pass 2 emits in rank order, so the output is a
116
+ * pure function of the ranker's total order and data already in the artifact.
117
+ * Nothing is inferred, and equal inputs collapse identically on every
118
+ * platform.
119
+ *
120
+ * The merged row is honest about what surfaced: its reference spans the
121
+ * surfaced members (canonical min..max), the excerpt is their texts in
122
+ * canonical verse order, the score is the best member's (existing policy),
123
+ * and reasons merge strongest-per-label so the chips explain the passage
124
+ * rather than an arbitrary one of its verses.
125
+ */
126
+ export declare function collapseRuns(results: readonly DiscoveryResult[], verses: ReadonlyMap<string, ScriptureVerse>, anchorSpans: ReadonlyMap<string, ReadonlySet<string>>, spanInfo: ReadonlyMap<string, GroupingSpanInfo>, pericopeOf: ReadonlyMap<number, PericopeRow>): readonly DiscoveryResult[];
127
+ /**
128
+ * The 0.13.0 entry point, kept for compatibility: anchor-span collapse only,
129
+ * no pericope runs, merged rows in their exact pre-0.14.0 shape (no
130
+ * `verses`/`grouping` — those need the span provenance the 5-argument
131
+ * `collapseRuns` receives). `discover()` no longer calls this.
132
+ */
133
+ export declare function collapseAnchorRuns(results: readonly DiscoveryResult[], verses: ReadonlyMap<string, ScriptureVerse>, anchorSpans: ReadonlyMap<string, ReadonlySet<string>>): readonly DiscoveryResult[];