@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.
@@ -21,17 +21,56 @@ export declare function targetIdFor(verse: ScriptureVerse): string;
21
21
  /** Chapter-level grouping — the unit diversification thins by. */
22
22
  export declare function groupIdFor(verse: ScriptureVerse): string;
23
23
  export declare function referenceLabel(verse: ScriptureVerse): string;
24
+ /**
25
+ * Significant-word count at which a verbatim phrase carries FULL exact-phrase
26
+ * authority (0.10.0 stage 3 taper). Below two significant words a "phrase" is
27
+ * one unit of meaning wearing a 60-point badge and files as token_overlap; at
28
+ * two it earns 2/3 authority (40 points, exactly the concept_anchor ceiling);
29
+ * at three or more it is a real quotation and earns the full 60.
30
+ *
31
+ * This constant is FIXED reviewed data, not a tuning knob: raising it to 4
32
+ * would push 2-word phrases to 30 points, colliding with weakAggregateCap 30
33
+ * and endangering correct remembered-phrase verbatim #1s. It is mirrored into
34
+ * eval/budgets.json `signalBudgets` for the G6 reviewed-constants check;
35
+ * changing it changes ordering, so covenant #2 requires an ENGINE_VERSION
36
+ * bump in the same commit.
37
+ */
38
+ export declare const EXACT_PHRASE_FULL_AUTHORITY_WORDS = 3;
24
39
  /**
25
40
  * Exact phrase evidence.
26
41
  *
27
- * Strength is BINARY: a verse either contains the phrase or it does not, and
28
- * pretending otherwise would put a confidence gradient on a yes/no fact.
42
+ * Strength carries no per-verse confidence gradient: a verse either contains
43
+ * the phrase or it does not, and every verse containing the same phrase gets
44
+ * the same strength. What strength DOES encode is how much the phrase itself
45
+ * means — coverage of the query times the significant-width taper below.
29
46
  * bm25 is used upstream to choose WHICH matches survive the candidate cap
30
47
  * when there are more than the limit, but it never modulates strength — so
31
48
  * equal-strength matches fall through to the canonical-order tie-break,
32
49
  * which is what a reader expects from a concordance-style result.
33
50
  */
34
51
  export declare function phraseEvidence(fragment: string, fragmentWords: number, queryWords: number): Evidence;
52
+ /**
53
+ * Complete-match subsumption (0.10.0 stage 3).
54
+ *
55
+ * When a candidate's evidence includes a COMPLETE whole-query exact_phrase
56
+ * match, its token_overlap and proximity evidence restates a fact the verbatim
57
+ * match already fully asserts: both are computed from the same query tokens
58
+ * the phrase accounts for in their entirety. Counting the restatement let a
59
+ * verbatim rebuke carry 11 extra points of "corroboration" that was really one
60
+ * fact shown three times. This is the correlation principle the budgets
61
+ * already commit to (G7: correlated families share one budget; identical
62
+ * edges collapse rather than sum), applied across the exact_phrase/
63
+ * token_overlap boundary — and covenant #5 improves with it, because the
64
+ * result no longer shows three chips asserting one fact.
65
+ *
66
+ * Fragment matches do NOT subsume: a partial quotation leaves room for the
67
+ * rest of the query to earn honest token credit. The caller marks only the
68
+ * targets whose whole-query match was emitted as exact_phrase; this function
69
+ * is pure and deterministic, and with an empty mark set it is the identity.
70
+ */
71
+ export declare function subsumeCompletePhraseRestatements(candidates: readonly Candidate[], completePhraseTargets: ReadonlySet<string>): readonly Candidate[];
72
+ /** A fallback phrase must carry at least two meaningful words. */
73
+ export declare function isMeaningfulPhraseFragment(fragment: string, query: string): boolean;
35
74
  /**
36
75
  * Token-overlap and proximity evidence.
37
76
  *
@@ -40,7 +79,7 @@ export declare function phraseEvidence(fragment: string, fragmentWords: number,
40
79
  * by inverse document frequency expresses that without anyone having to
41
80
  * hand-maintain a list of which words are important.
42
81
  */
43
- export declare function tokenEvidence(match: TokenMatch, queryIdfTotal: number): Evidence[];
82
+ export declare function tokenEvidence(match: TokenMatch, queryIdfTotal: number, correctionCitations?: ReadonlyMap<string, string>): Evidence[];
44
83
  /** Total IDF of the query's tokens — the denominator for coverage. */
45
84
  export declare function queryIdfTotal(tokens: readonly string[], documentFrequencies: ReadonlyMap<string, number>, documentCount: number): number;
46
85
  /** Merge per-verse evidence from every lexical intent into ranked candidates. */
@@ -6,6 +6,7 @@
6
6
  * decides ordering; the ranker does, under the signal budgets. That split is
7
7
  * what keeps "add a new intent" from being able to destabilize ranking.
8
8
  */
9
+ import { correctionCitation } from '../reasons/display.js';
9
10
  import { significantWords } from '../tokenizer/index.js';
10
11
  /**
11
12
  * Canonical, sortable target id: zero-padded so lexicographic order IS
@@ -23,11 +24,28 @@ export function groupIdFor(verse) {
23
24
  export function referenceLabel(verse) {
24
25
  return `${verse.bookName} ${verse.chapter}:${verse.verse}`;
25
26
  }
27
+ /**
28
+ * Significant-word count at which a verbatim phrase carries FULL exact-phrase
29
+ * authority (0.10.0 stage 3 taper). Below two significant words a "phrase" is
30
+ * one unit of meaning wearing a 60-point badge and files as token_overlap; at
31
+ * two it earns 2/3 authority (40 points, exactly the concept_anchor ceiling);
32
+ * at three or more it is a real quotation and earns the full 60.
33
+ *
34
+ * This constant is FIXED reviewed data, not a tuning knob: raising it to 4
35
+ * would push 2-word phrases to 30 points, colliding with weakAggregateCap 30
36
+ * and endangering correct remembered-phrase verbatim #1s. It is mirrored into
37
+ * eval/budgets.json `signalBudgets` for the G6 reviewed-constants check;
38
+ * changing it changes ordering, so covenant #2 requires an ENGINE_VERSION
39
+ * bump in the same commit.
40
+ */
41
+ export const EXACT_PHRASE_FULL_AUTHORITY_WORDS = 3;
26
42
  /**
27
43
  * Exact phrase evidence.
28
44
  *
29
- * Strength is BINARY: a verse either contains the phrase or it does not, and
30
- * pretending otherwise would put a confidence gradient on a yes/no fact.
45
+ * Strength carries no per-verse confidence gradient: a verse either contains
46
+ * the phrase or it does not, and every verse containing the same phrase gets
47
+ * the same strength. What strength DOES encode is how much the phrase itself
48
+ * means — coverage of the query times the significant-width taper below.
31
49
  * bm25 is used upstream to choose WHICH matches survive the candidate cap
32
50
  * when there are more than the limit, but it never modulates strength — so
33
51
  * equal-strength matches fall through to the canonical-order tie-break,
@@ -35,17 +53,90 @@ export function referenceLabel(verse) {
35
53
  */
36
54
  export function phraseEvidence(fragment, fragmentWords, queryWords) {
37
55
  const complete = fragmentWords >= queryWords;
56
+ const coverage = Math.max(0, Math.min(1, fragmentWords / Math.max(1, queryWords)));
57
+ // Taper, part 1 (0.10.0 stage 3): a verbatim match of fewer than two
58
+ // significant words is one unit of meaning, not a quotation — "the cross"
59
+ // occurring verbatim says no more than the token `cross` matching, and the
60
+ // 60-point badge it used to wear is how crucifixion-mockery verses outranked
61
+ // the curated atonement anchors. It files under token_overlap at full
62
+ // strength (10 points), keeping its truthful label: the phrase genuinely
63
+ // occurs; the points now say how much that means.
64
+ if (fragmentWords < 2) {
65
+ return {
66
+ family: 'token_overlap',
67
+ label: complete ? 'Exact phrase' : `Contains "${fragment}"`,
68
+ strength: complete ? 1 : coverage,
69
+ };
70
+ }
71
+ // Taper, part 2: authority grows with significant width up to
72
+ // EXACT_PHRASE_FULL_AUTHORITY_WORDS. Two significant words earn 2/3 of the
73
+ // family budget (40 points — level with the concept_anchor ceiling, above
74
+ // weakAggregateCap 30); three or more earn the full 60.
75
+ const authority = Math.min(1, fragmentWords / EXACT_PHRASE_FULL_AUTHORITY_WORDS);
38
76
  return {
39
- family: 'exact_phrase',
77
+ // Authoritative only while the verbatim text covers the MAJORITY of the
78
+ // query. G6's definition of the exact_phrase family is "the query text
79
+ // occurs verbatim in the verse" — half a query occurring verbatim is not
80
+ // that, it is partial lexical overlap, which is precisely what the weak
81
+ // tier exists to hold. Filed under token_overlap, a minority fragment
82
+ // keeps its honest label but competes under weak-family caps instead of
83
+ // outranking curated anchors.
84
+ family: complete || coverage >= 0.5 ? 'exact_phrase' : 'token_overlap',
40
85
  label: complete ? 'Exact phrase' : `Contains "${fragment}"`,
41
- // Proportional to how much of the question this verbatim text answers.
42
- // A whole-query match earns full authority; a four-word fragment of an
43
- // eight-word paraphrase earns half. This is what lets a paraphrase like
44
- // "be doers of the word not hearers only" still resolve decisively to
86
+ // Coverage is proportional to how much of the question this verbatim text
87
+ // answers. A whole-query match earns full coverage; a four-word fragment
88
+ // of an eight-word paraphrase earns half. This is what lets a paraphrase
89
+ // like "be doers of the word not hearers only" still resolve decisively to
45
90
  // James 1:22, without pretending a fragment is the whole quotation.
46
- strength: Math.max(0, Math.min(1, fragmentWords / Math.max(1, queryWords))),
91
+ //
92
+ // Since 0.8.0 the caller passes SIGNIFICANT word counts, not raw ones.
93
+ // Raw counts let a verbatim run of function words wear authority it had
94
+ // not earned: "God is close to the brokenhearted" contains the fragment
95
+ // "is close to" — one significant word — which under raw counting scored
96
+ // 3/6 of full phrase authority and put Zechariah 13:7 ("the man who is
97
+ // close to me": strike the shepherd) above Psalm 34:18 for a grieving
98
+ // searcher. Coverage of MEANING, not of words, is what this signal
99
+ // claims to measure.
100
+ strength: coverage * authority,
47
101
  };
48
102
  }
103
+ /**
104
+ * Complete-match subsumption (0.10.0 stage 3).
105
+ *
106
+ * When a candidate's evidence includes a COMPLETE whole-query exact_phrase
107
+ * match, its token_overlap and proximity evidence restates a fact the verbatim
108
+ * match already fully asserts: both are computed from the same query tokens
109
+ * the phrase accounts for in their entirety. Counting the restatement let a
110
+ * verbatim rebuke carry 11 extra points of "corroboration" that was really one
111
+ * fact shown three times. This is the correlation principle the budgets
112
+ * already commit to (G7: correlated families share one budget; identical
113
+ * edges collapse rather than sum), applied across the exact_phrase/
114
+ * token_overlap boundary — and covenant #5 improves with it, because the
115
+ * result no longer shows three chips asserting one fact.
116
+ *
117
+ * Fragment matches do NOT subsume: a partial quotation leaves room for the
118
+ * rest of the query to earn honest token credit. The caller marks only the
119
+ * targets whose whole-query match was emitted as exact_phrase; this function
120
+ * is pure and deterministic, and with an empty mark set it is the identity.
121
+ */
122
+ export function subsumeCompletePhraseRestatements(candidates, completePhraseTargets) {
123
+ if (completePhraseTargets.size === 0)
124
+ return candidates;
125
+ return candidates.map((candidate) => completePhraseTargets.has(candidate.targetId)
126
+ ? {
127
+ ...candidate,
128
+ evidence: candidate.evidence.filter((item) => item.family !== 'token_overlap' && item.family !== 'proximity'),
129
+ }
130
+ : candidate);
131
+ }
132
+ /** A fallback phrase must carry at least two meaningful words. */
133
+ export function isMeaningfulPhraseFragment(fragment, query) {
134
+ const querySignificant = significantWords(query);
135
+ if (querySignificant.length === 0) {
136
+ return fragment.trim().split(/\s+/).filter(Boolean).length >= 2;
137
+ }
138
+ return significantWords(fragment).length >= 2;
139
+ }
49
140
  const PROXIMITY_WINDOW = 20;
50
141
  /**
51
142
  * Additive smoothing for the precision term. Keeps a very short verse from
@@ -60,11 +151,20 @@ const PRECISION_SMOOTHING = 2;
60
151
  * by inverse document frequency expresses that without anyone having to
61
152
  * hand-maintain a list of which words are important.
62
153
  */
63
- export function tokenEvidence(match, queryIdfTotal) {
154
+ export function tokenEvidence(match, queryIdfTotal, correctionCitations) {
64
155
  const evidence = [];
65
156
  const coverage = queryIdfTotal > 0 ? Math.min(1, match.idfSum / queryIdfTotal) : 0;
66
157
  if (coverage <= 0)
67
158
  return evidence;
159
+ // Cited corrections (0.12.0/QR-5): a token that reached this verse only
160
+ // because an out-of-vocabulary typed word was corrected must SAY so, on the
161
+ // chip, citing the surface form the user typed — never the stem. Silence
162
+ // here would be a corrected query pretending to be an exact one, the
163
+ // theological failure mode the feature's design forbids.
164
+ const display = (token) => {
165
+ const typed = correctionCitations?.get(token);
166
+ return typed === undefined ? token : `${token} (${correctionCitation(typed)})`;
167
+ };
68
168
  // Coverage alone is RECALL — "did the verse contain what I asked for?" —
69
169
  // and it saturates at 1.0 for every verse containing all the query's
70
170
  // terms. That is how Luke 6:47 tied with (and then beat) James 1:22 for
@@ -84,8 +184,8 @@ export function tokenEvidence(match, queryIdfTotal) {
84
184
  evidence.push({
85
185
  family: 'token_overlap',
86
186
  label: match.matchedTokens.length === 1
87
- ? `Shared word: ${match.matchedTokens[0]}`
88
- : `Shared words: ${match.matchedTokens.join(', ')}`,
187
+ ? `Shared word: ${display(match.matchedTokens[0])}`
188
+ : `Shared words: ${match.matchedTokens.map(display).join(', ')}`,
89
189
  strength: coverage * precision,
90
190
  });
91
191
  // Proximity only means something with two or more matched tokens. The
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Deterministic cited spelling correction (0.12.0/QR-5) — the pure half.
3
+ *
4
+ * The pipeline precomputes a SymSpell-style delete-variant index over the
5
+ * artifact's whole vocabulary (corpus tokens ∪ book aliases ∪ lexicon tokens
6
+ * ∪ translation tokens ∪ Layer B verse terms); at query time an out-of-vocabulary token's own
7
+ * delete variants are looked up through the port and every candidate is
8
+ * verified with the bounded integer Damerau DP before one may substitute.
9
+ * Everything in this module is a pure function — no I/O, no floats in
10
+ * decisions, no locale, no randomness — so the winning correction is a
11
+ * function of (typed token, candidate SET) alone.
12
+ *
13
+ * ONE edit-policy table (Phase 5 design invariant): the budgets here are the
14
+ * SAME constants the reference did-you-mean uses (imported, not copied —
15
+ * there is exactly one table to review, J31/J35 review it once). The pipeline
16
+ * imports these same exports when it builds the delete index, and eval
17
+ * cross-checks that the shipped table matches a recomputation from them.
18
+ *
19
+ * Correction is a query-time-only rung: the corpus is sacred input, the query
20
+ * is fallible input, and only the fallible side gets the fallback. The
21
+ * pipeline never "corrects" scripture text, and the tokenizer is untouched
22
+ * (TOKENIZER_VERSION stays 1.0.0) — correction lives HERE, in the intent
23
+ * layer.
24
+ */
25
+ /**
26
+ * The ONE policy table, re-exported under the spelling surface so QR-5's
27
+ * reviewers see the same numbers QR-4's did (J31 = J35's edit bounds):
28
+ * token length <5 → never correct; 5–8 → edit distance 1; ≥9 → edit
29
+ * distance 2; a transposition counts as 1 (Damerau).
30
+ *
31
+ * Keyed on the NORMALIZED (post-stem) token's length — the measured truth of
32
+ * the shipped rule, stated plainly for the J31 review (round-2 fix: earlier
33
+ * docs said "typed", which the code never did). It is the coherent key: the
34
+ * distance is computed between the normalized token and normalized dictionary
35
+ * terms, so budgeting by a different string's length would mix units. The
36
+ * practical difference is one-sided and SUPPRESSIVE — a typed word whose stem
37
+ * falls under 5 characters never corrects even when the surface is ≥5
38
+ * ("angles" stems to "angl", so angles→angels, the canonical typo, is out of
39
+ * policy; "sines" likewise) — i.e. strictly fewer corrections than the typed
40
+ * reading, never more.
41
+ */
42
+ export declare const SPELLING_MIN_TOKEN_LENGTH = 5;
43
+ export declare const SPELLING_EDIT1_MAX_TOKEN_LENGTH = 8;
44
+ /** Edit budget for a normalized token — the same function the book did-you-mean uses. */
45
+ export declare function spellingEditBudget(tokenLength: number): number;
46
+ /**
47
+ * How many delete variants the PIPELINE must precompute for a dictionary
48
+ * term, derived from the typed-side policy table rather than guessed:
49
+ * a term of length m can be within budgeted distance of some typed token
50
+ * only when a typed length t exists with |t − m| ≤ budget(t), and the depth
51
+ * needed is the largest such budget.
52
+ *
53
+ * budget 2 requires t ≥ 9 and |t − m| ≤ 2 → m ≥ 7
54
+ * budget 1 requires 5 ≤ t ≤ 8 and |t − m| ≤ 1 → 4 ≤ m ≤ 9
55
+ *
56
+ * So: length ≥ 7 → depth 2; length 4–6 → depth 1; below 4 → 0 (no typed
57
+ * token in policy can ever reach it — such terms ship in spelling_terms for
58
+ * the vocabulary gate but contribute no delete rows). Exported for the
59
+ * pipeline and for the eval cross-check.
60
+ */
61
+ export declare function dictionaryDeleteDepth(termLength: number): number;
62
+ /**
63
+ * All strings reachable from `word` by deleting up to `depth` characters —
64
+ * INCLUDING the word itself (0 deletions), so a candidate reachable purely by
65
+ * deleting from the other side matches on the identity row. Returned sorted
66
+ * (code-unit order) so pipeline insertion order is byte-deterministic.
67
+ */
68
+ export declare function deleteVariants(word: string, depth: number): readonly string[];
69
+ /** One dictionary term surfaced by the delete-variant lookup. */
70
+ export interface SpellingCandidate {
71
+ readonly term: string;
72
+ /** Corpus document frequency; 0 for vocabulary-only origins (books, lexicon, translations, verse terms). */
73
+ readonly documentCount: number;
74
+ }
75
+ export interface PickedCorrection {
76
+ readonly term: string;
77
+ /** Verified integer Damerau distance from the typed token — the citation. */
78
+ readonly distance: number;
79
+ readonly documentCount: number;
80
+ }
81
+ /**
82
+ * Chooses the winning correction for an out-of-vocabulary typed token.
83
+ *
84
+ * Every candidate is re-verified with the bounded Damerau DP (the delete
85
+ * index only proposes; it never decides), then the winner is selected under
86
+ * a TOTAL order: distance ascending → document_count descending → term
87
+ * lexicographic ascending. Total means row-order independent: the outcome is
88
+ * a pure function of the candidate SET. Distance 0 is skipped by
89
+ * construction — a token equal to a vocabulary term is in-vocabulary and the
90
+ * caller never asks about it — and an empty or fully out-of-bound candidate
91
+ * list yields null: no correction beats a wrong correction.
92
+ */
93
+ export declare function pickCorrection(typed: string, candidates: readonly SpellingCandidate[], bound: number): PickedCorrection | null;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Deterministic cited spelling correction (0.12.0/QR-5) — the pure half.
3
+ *
4
+ * The pipeline precomputes a SymSpell-style delete-variant index over the
5
+ * artifact's whole vocabulary (corpus tokens ∪ book aliases ∪ lexicon tokens
6
+ * ∪ translation tokens ∪ Layer B verse terms); at query time an out-of-vocabulary token's own
7
+ * delete variants are looked up through the port and every candidate is
8
+ * verified with the bounded integer Damerau DP before one may substitute.
9
+ * Everything in this module is a pure function — no I/O, no floats in
10
+ * decisions, no locale, no randomness — so the winning correction is a
11
+ * function of (typed token, candidate SET) alone.
12
+ *
13
+ * ONE edit-policy table (Phase 5 design invariant): the budgets here are the
14
+ * SAME constants the reference did-you-mean uses (imported, not copied —
15
+ * there is exactly one table to review, J31/J35 review it once). The pipeline
16
+ * imports these same exports when it builds the delete index, and eval
17
+ * cross-checks that the shipped table matches a recomputation from them.
18
+ *
19
+ * Correction is a query-time-only rung: the corpus is sacred input, the query
20
+ * is fallible input, and only the fallible side gets the fallback. The
21
+ * pipeline never "corrects" scripture text, and the tokenizer is untouched
22
+ * (TOKENIZER_VERSION stays 1.0.0) — correction lives HERE, in the intent
23
+ * layer.
24
+ */
25
+ import { damerauLevenshtein, editDistanceBudget, SUGGESTION_EDIT1_MAX_KEY_LENGTH, SUGGESTION_MIN_KEY_LENGTH, } from '../reference/reference.js';
26
+ /**
27
+ * The ONE policy table, re-exported under the spelling surface so QR-5's
28
+ * reviewers see the same numbers QR-4's did (J31 = J35's edit bounds):
29
+ * token length <5 → never correct; 5–8 → edit distance 1; ≥9 → edit
30
+ * distance 2; a transposition counts as 1 (Damerau).
31
+ *
32
+ * Keyed on the NORMALIZED (post-stem) token's length — the measured truth of
33
+ * the shipped rule, stated plainly for the J31 review (round-2 fix: earlier
34
+ * docs said "typed", which the code never did). It is the coherent key: the
35
+ * distance is computed between the normalized token and normalized dictionary
36
+ * terms, so budgeting by a different string's length would mix units. The
37
+ * practical difference is one-sided and SUPPRESSIVE — a typed word whose stem
38
+ * falls under 5 characters never corrects even when the surface is ≥5
39
+ * ("angles" stems to "angl", so angles→angels, the canonical typo, is out of
40
+ * policy; "sines" likewise) — i.e. strictly fewer corrections than the typed
41
+ * reading, never more.
42
+ */
43
+ export const SPELLING_MIN_TOKEN_LENGTH = SUGGESTION_MIN_KEY_LENGTH;
44
+ export const SPELLING_EDIT1_MAX_TOKEN_LENGTH = SUGGESTION_EDIT1_MAX_KEY_LENGTH;
45
+ /** Edit budget for a normalized token — the same function the book did-you-mean uses. */
46
+ export function spellingEditBudget(tokenLength) {
47
+ return editDistanceBudget(tokenLength);
48
+ }
49
+ /**
50
+ * How many delete variants the PIPELINE must precompute for a dictionary
51
+ * term, derived from the typed-side policy table rather than guessed:
52
+ * a term of length m can be within budgeted distance of some typed token
53
+ * only when a typed length t exists with |t − m| ≤ budget(t), and the depth
54
+ * needed is the largest such budget.
55
+ *
56
+ * budget 2 requires t ≥ 9 and |t − m| ≤ 2 → m ≥ 7
57
+ * budget 1 requires 5 ≤ t ≤ 8 and |t − m| ≤ 1 → 4 ≤ m ≤ 9
58
+ *
59
+ * So: length ≥ 7 → depth 2; length 4–6 → depth 1; below 4 → 0 (no typed
60
+ * token in policy can ever reach it — such terms ship in spelling_terms for
61
+ * the vocabulary gate but contribute no delete rows). Exported for the
62
+ * pipeline and for the eval cross-check.
63
+ */
64
+ export function dictionaryDeleteDepth(termLength) {
65
+ if (termLength >= 7)
66
+ return 2;
67
+ return termLength >= 4 ? 1 : 0;
68
+ }
69
+ /**
70
+ * All strings reachable from `word` by deleting up to `depth` characters —
71
+ * INCLUDING the word itself (0 deletions), so a candidate reachable purely by
72
+ * deleting from the other side matches on the identity row. Returned sorted
73
+ * (code-unit order) so pipeline insertion order is byte-deterministic.
74
+ */
75
+ export function deleteVariants(word, depth) {
76
+ const seen = new Set([word]);
77
+ let frontier = [word];
78
+ for (let round = 0; round < depth; round += 1) {
79
+ const next = [];
80
+ for (const value of frontier) {
81
+ if (value.length <= 1)
82
+ continue;
83
+ for (let index = 0; index < value.length; index += 1) {
84
+ const variant = value.slice(0, index) + value.slice(index + 1);
85
+ if (!seen.has(variant)) {
86
+ seen.add(variant);
87
+ next.push(variant);
88
+ }
89
+ }
90
+ }
91
+ frontier = next;
92
+ }
93
+ return [...seen].sort();
94
+ }
95
+ /**
96
+ * Chooses the winning correction for an out-of-vocabulary typed token.
97
+ *
98
+ * Every candidate is re-verified with the bounded Damerau DP (the delete
99
+ * index only proposes; it never decides), then the winner is selected under
100
+ * a TOTAL order: distance ascending → document_count descending → term
101
+ * lexicographic ascending. Total means row-order independent: the outcome is
102
+ * a pure function of the candidate SET. Distance 0 is skipped by
103
+ * construction — a token equal to a vocabulary term is in-vocabulary and the
104
+ * caller never asks about it — and an empty or fully out-of-bound candidate
105
+ * list yields null: no correction beats a wrong correction.
106
+ */
107
+ export function pickCorrection(typed, candidates, bound) {
108
+ if (bound <= 0)
109
+ return null;
110
+ let best = null;
111
+ for (const candidate of candidates) {
112
+ const distance = damerauLevenshtein(typed, candidate.term, bound);
113
+ if (distance === null || distance === 0)
114
+ continue;
115
+ if (best === null ||
116
+ distance < best.distance ||
117
+ (distance === best.distance &&
118
+ (candidate.documentCount > best.documentCount ||
119
+ (candidate.documentCount === best.documentCount && candidate.term < best.term)))) {
120
+ best = { term: candidate.term, distance, documentCount: candidate.documentCount };
121
+ }
122
+ }
123
+ return best;
124
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * INTERNAL surface of @jestek-dev/scripture-engine (plan P7.2 / CO-5).
3
+ *
4
+ * Everything exported here is implementation detail: the repo's own pipeline,
5
+ * eval harness and workbench import it (one tokenizer, shared constants, the
6
+ * G6 reviewed-constants mirror), and it is published so those tools and
7
+ * white-box diagnostics can reach it — but it carries NO stability promise.
8
+ * Names here may move, change shape, or disappear in any version without a
9
+ * major bump and without a §5 consumer-contract review. Consumers pin the
10
+ * PUBLIC entry (`@jestek-dev/scripture-engine`): the five methods, the result
11
+ * types, and the `corrections`/`suggestion` fields — that surface is the
12
+ * contract (docs/COMPATIBILITY.md), this one is not.
13
+ *
14
+ * The internal entry is a strict superset: it re-exports the public tier too,
15
+ * so a tool that needs one internal symbol does not need two import
16
+ * specifiers. The public entry stays the compatibility boundary; the exact
17
+ * public surface is pinned by eval/test/public-surface.test.ts.
18
+ */
19
+ export * from './index.js';
20
+ export { collapseAnchorRuns, collapseRuns, type GroupingSpanInfo, } from './createEngine.js';
21
+ export { CorpusRepository, MAX_CANDIDATES, MAX_PHRASE_LENGTH, type CorpusMeta, type CrossReferencePhraseRow, type CuratedAliasRow, type PericopeRow, type PhraseMatch, type TokenMatch, } from './corpus/repository.js';
22
+ export { EXACT_PHRASE_FULL_AUTHORITY_WORDS, groupIdFor, mergeCandidates, phraseEvidence, queryIdfTotal, referenceLabel, subsumeCompletePhraseRestatements, targetIdFor, tokenEvidence, } from './intents/lexical.js';
23
+ export { aliasConceptEvidence, aliasPassageEvidence, dedupeConceptAnchors, PASSAGE_TERM_PMI_HALF_SATURATION, sourceLabel, } from './intents/concept.js';
24
+ export { deleteVariants, dictionaryDeleteDepth, pickCorrection, SPELLING_EDIT1_MAX_TOKEN_LENGTH, SPELLING_MIN_TOKEN_LENGTH, spellingEditBudget, type PickedCorrection, type SpellingCandidate, } from './intents/spelling.js';
25
+ export { normalizedPhrase, normalizeToken, significantWords, significantWordsWithSurface, tokenStream, TOKENIZER_ARCHAIC_FORM_COUNT, TOKENIZER_STOPWORD_COUNT, } from './tokenizer/index.js';
26
+ export { damerauLevenshtein, editDistanceBudget, normalizeBookAlias, resolveReference, resolveReferenceAttempt, SUGGESTION_EDIT1_MAX_KEY_LENGTH, SUGGESTION_MIN_KEY_LENGTH, type BookAliasEntry, type ReferenceResolutionAttempt, type ReferenceResolver, type ResolvedBook, type ResolvedReference, } from './reference/reference.js';
27
+ export { makeVerseId, parseVerseId, type VerseLocation } from './reference/verseId.js';
28
+ export { AUTHORITATIVE_FAMILIES, isAuthoritative } from './reasons/types.js';
29
+ export { CHIP_DISPLAY_MIN_POINTS, PASSAGE_TERM_CHIP_DISPLAY_FLOOR, correctionCitation, pinCorrectionCitations, polishChipsForDisplay, } from './reasons/display.js';
30
+ export { applyBudgets, DEFAULT_BUDGETS, type BudgetedScore, } from './ranking/budgets.js';
31
+ export { DEFAULT_LIMIT, DEFAULT_MAX_PER_GROUP, rank, type Candidate, type RankedResult, } from './ranking/rank.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * INTERNAL surface of @jestek-dev/scripture-engine (plan P7.2 / CO-5).
3
+ *
4
+ * Everything exported here is implementation detail: the repo's own pipeline,
5
+ * eval harness and workbench import it (one tokenizer, shared constants, the
6
+ * G6 reviewed-constants mirror), and it is published so those tools and
7
+ * white-box diagnostics can reach it — but it carries NO stability promise.
8
+ * Names here may move, change shape, or disappear in any version without a
9
+ * major bump and without a §5 consumer-contract review. Consumers pin the
10
+ * PUBLIC entry (`@jestek-dev/scripture-engine`): the five methods, the result
11
+ * types, and the `corrections`/`suggestion` fields — that surface is the
12
+ * contract (docs/COMPATIBILITY.md), this one is not.
13
+ *
14
+ * The internal entry is a strict superset: it re-exports the public tier too,
15
+ * so a tool that needs one internal symbol does not need two import
16
+ * specifiers. The public entry stays the compatibility boundary; the exact
17
+ * public surface is pinned by eval/test/public-surface.test.ts.
18
+ */
19
+ export * from './index.js';
20
+ export { collapseAnchorRuns, collapseRuns, } from './createEngine.js';
21
+ export { CorpusRepository, MAX_CANDIDATES, MAX_PHRASE_LENGTH, } from './corpus/repository.js';
22
+ export { EXACT_PHRASE_FULL_AUTHORITY_WORDS, groupIdFor, mergeCandidates, phraseEvidence, queryIdfTotal, referenceLabel, subsumeCompletePhraseRestatements, targetIdFor, tokenEvidence, } from './intents/lexical.js';
23
+ export { aliasConceptEvidence, aliasPassageEvidence, dedupeConceptAnchors, PASSAGE_TERM_PMI_HALF_SATURATION, sourceLabel, } from './intents/concept.js';
24
+ export { deleteVariants, dictionaryDeleteDepth, pickCorrection, SPELLING_EDIT1_MAX_TOKEN_LENGTH, SPELLING_MIN_TOKEN_LENGTH, spellingEditBudget, } from './intents/spelling.js';
25
+ export { normalizedPhrase, normalizeToken, significantWords, significantWordsWithSurface, tokenStream, TOKENIZER_ARCHAIC_FORM_COUNT, TOKENIZER_STOPWORD_COUNT, } from './tokenizer/index.js';
26
+ export { damerauLevenshtein, editDistanceBudget, normalizeBookAlias, resolveReference, resolveReferenceAttempt, SUGGESTION_EDIT1_MAX_KEY_LENGTH, SUGGESTION_MIN_KEY_LENGTH, } from './reference/reference.js';
27
+ export { makeVerseId, parseVerseId } from './reference/verseId.js';
28
+ export { AUTHORITATIVE_FAMILIES, isAuthoritative } from './reasons/types.js';
29
+ export { CHIP_DISPLAY_MIN_POINTS, PASSAGE_TERM_CHIP_DISPLAY_FLOOR, correctionCitation, pinCorrectionCitations, polishChipsForDisplay, } from './reasons/display.js';
30
+ export { applyBudgets, DEFAULT_BUDGETS, } from './ranking/budgets.js';
31
+ export { DEFAULT_LIMIT, DEFAULT_MAX_PER_GROUP, rank, } from './ranking/rank.js';
@@ -41,6 +41,16 @@ export interface SignalBudgets {
41
41
  * independent would inflate confidence for what is substantially one fact.
42
42
  */
43
43
  readonly correlationGroups: readonly (readonly SignalFamily[])[];
44
+ /**
45
+ * Sole-evidence floor: a ceiling applied ONLY when EVERY surviving reason's
46
+ * family is named in this map (when several are named, the smallest ceiling
47
+ * wins). A family listed here is a hint that may accompany an honest text
48
+ * match but must never overrule one on its own — in practice a lone
49
+ * translation_variant, which says "this could be the verse you mean", not
50
+ * "this is it". A single reason from any unlisted family disables the step,
51
+ * so corroborated results are untouched by construction.
52
+ */
53
+ readonly soleEvidenceMaxPoints?: Readonly<Partial<Record<SignalFamily, number>>>;
44
54
  }
45
55
  export declare const DEFAULT_BUDGETS: SignalBudgets;
46
56
  export interface BudgetedScore {
@@ -57,6 +67,8 @@ export interface BudgetedScore {
57
67
  * 2. per-family point cap (via maxPoints scaling)
58
68
  * 3. correlation-group cap (correlated families share the larger budget)
59
69
  * 4. weak aggregate cap (scale all weak reasons proportionally)
70
+ * 5. sole-evidence floor (cap the total when every surviving reason is a
71
+ * hint-only family; see SignalBudgets.soleEvidenceMaxPoints)
60
72
  *
61
73
  * Scaling proportionally rather than truncating keeps the relative ordering
62
74
  * of weak reasons intact, so the displayed explanation still ranks the same
@@ -26,6 +26,11 @@ export const DEFAULT_BUDGETS = {
26
26
  // Weak — individually modest, collectively capped below.
27
27
  concept_lexicon: { maxPoints: 12, maxReasons: 2 },
28
28
  token_overlap: { maxPoints: 10, maxReasons: 1 },
29
+ // Above token_overlap because it is not an inference: the verse genuinely
30
+ // reads this way somewhere. Still weak, and still inside the aggregate
31
+ // cap, because the match is to a bag of stems rather than to the shipped
32
+ // wording — it says "this could be the verse you mean", not "this is it".
33
+ translation_variant: { maxPoints: 14, maxReasons: 1 },
29
34
  proximity: { maxPoints: 6, maxReasons: 1 },
30
35
  passage_terms: { maxPoints: 8, maxReasons: 2 },
31
36
  cross_reference: { maxPoints: 6, maxReasons: 2 },
@@ -35,6 +40,12 @@ export const DEFAULT_BUDGETS = {
35
40
  // system, all firing at full strength, still loses to one verbatim match.
36
41
  weakAggregateCap: 30,
37
42
  correlationGroups: [['cross_reference', 'co_citation']],
43
+ // A lone translation_variant caps at 6 — below token_overlap's 10, level
44
+ // with proximity/cross_reference. Uncapped, its 14 points outranked every
45
+ // honest lexical signal with zero corroboration (the 2026-08-20 battery's
46
+ // sense-inverted harmful #1s). The value is reviewed data (J20) and is
47
+ // mirrored into eval/budgets.json signalBudgets for the G6 equality check.
48
+ soleEvidenceMaxPoints: { translation_variant: 6 },
38
49
  };
39
50
  function toReason(evidence, budget) {
40
51
  const clampedStrength = Math.min(1, Math.max(0, evidence.strength));
@@ -74,6 +85,8 @@ function compareEvidence(a, b) {
74
85
  * 2. per-family point cap (via maxPoints scaling)
75
86
  * 3. correlation-group cap (correlated families share the larger budget)
76
87
  * 4. weak aggregate cap (scale all weak reasons proportionally)
88
+ * 5. sole-evidence floor (cap the total when every surviving reason is a
89
+ * hint-only family; see SignalBudgets.soleEvidenceMaxPoints)
77
90
  *
78
91
  * Scaling proportionally rather than truncating keeps the relative ordering
79
92
  * of weak reasons intact, so the displayed explanation still ranks the same
@@ -136,6 +149,32 @@ export function applyBudgets(evidence, budgets = DEFAULT_BUDGETS) {
136
149
  };
137
150
  }
138
151
  }
152
+ // 5: sole-evidence floor. When EVERY surviving reason's family is named in
153
+ // soleEvidenceMaxPoints, the whole result rests on evidence that only hints
154
+ // (in practice: a lone translation_variant), so its total is capped below
155
+ // any honest text match. Labels are untouched — the chip stays truthful;
156
+ // only the points now admit the evidence is a hint. One reason from any
157
+ // unlisted family disables the step, so corroborated results never enter it.
158
+ const soleCaps = budgets.soleEvidenceMaxPoints;
159
+ if (soleCaps && reasons.length > 0) {
160
+ const memberCaps = reasons.map((reason) => soleCaps[reason.family]);
161
+ if (memberCaps.every((cap) => cap !== undefined)) {
162
+ const soleCap = Math.min(...memberCaps);
163
+ const total = reasons.reduce((sum, reason) => sum + reason.points, 0);
164
+ if (total > soleCap && total > 0) {
165
+ capped = true;
166
+ const scale = soleCap / total;
167
+ for (let index = 0; index < reasons.length; index += 1) {
168
+ const member = reasons[index];
169
+ reasons[index] = {
170
+ ...member,
171
+ points: member.points * scale,
172
+ uncappedPoints: member.uncappedPoints ?? member.points,
173
+ };
174
+ }
175
+ }
176
+ }
177
+ }
139
178
  // Stable presentation order: strongest first, ties broken by family then label.
140
179
  const ordered = [...reasons].sort((a, b) => {
141
180
  if (b.points !== a.points)
@@ -15,9 +15,11 @@ export const DEFAULT_LIMIT = 25;
15
15
  export const DEFAULT_MAX_PER_GROUP = 3;
16
16
  /**
17
17
  * Total order over scored results: score desc, then authoritative-first (so a
18
- * direct match outranks an equal-scoring pile of hints), then targetId asc.
19
- * targetId is the documented final tie-break and is unique, so this comparator
20
- * never returns 0 for distinct results no reliance on sort stability.
18
+ * direct match outranks an equal-scoring pile of hints), then at an exact
19
+ * tie between two authoritative results — curated-anchor-first, then targetId
20
+ * asc. targetId is the documented final tie-break and is unique, so this
21
+ * comparator never returns 0 for distinct results — no reliance on sort
22
+ * stability.
21
23
  */
22
24
  function compareResults(a, b) {
23
25
  if (b.score !== a.score)
@@ -26,6 +28,21 @@ function compareResults(a, b) {
26
28
  const bAuth = b.reasons.some((reason) => isAuthoritative(reason.family));
27
29
  if (aAuth !== bAuth)
28
30
  return aAuth ? -1 : 1;
31
+ // Equal-score curated tie-break (0.10.0 stage 3): at an EXACT score tie
32
+ // between two authoritative results, the one a curated source anchors
33
+ // orders before one whose authority is a verbatim string hit alone. At
34
+ // identical evidence weight, preferring the passage a named human recorded
35
+ // for this concept over "the string occurs" is the product's stated reason
36
+ // to exist — and it adjudicates no theology: it prefers recorded human
37
+ // curation, not a doctrine. It fires ONLY on exact ties between
38
+ // authoritative results; unequal scores are never reordered, and the
39
+ // comparator remains a total order.
40
+ if (aAuth && bAuth) {
41
+ const aAnchor = a.reasons.some((reason) => reason.family === 'concept_anchor');
42
+ const bAnchor = b.reasons.some((reason) => reason.family === 'concept_anchor');
43
+ if (aAnchor !== bAnchor)
44
+ return aAnchor ? -1 : 1;
45
+ }
29
46
  return a.targetId < b.targetId ? -1 : a.targetId > b.targetId ? 1 : 0;
30
47
  }
31
48
  export function rank(candidates, options = {}) {