@jestek-dev/scripture-engine 0.7.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/config/engineVersion.d.ts +1 -1
- package/dist/config/engineVersion.js +47 -1
- package/dist/corpus/repository.d.ts +189 -2
- package/dist/corpus/repository.js +332 -1
- package/dist/createEngine.d.ts +76 -1
- package/dist/createEngine.js +716 -52
- package/dist/index.d.ts +20 -14
- package/dist/index.js +15 -13
- package/dist/intents/concept.d.ts +115 -2
- package/dist/intents/concept.js +323 -6
- package/dist/intents/lexical.d.ts +42 -3
- package/dist/intents/lexical.js +111 -11
- package/dist/intents/spelling.d.ts +93 -0
- package/dist/intents/spelling.js +124 -0
- package/dist/internal.d.ts +31 -0
- package/dist/internal.js +31 -0
- package/dist/ranking/budgets.d.ts +12 -0
- package/dist/ranking/budgets.js +39 -0
- package/dist/ranking/rank.js +20 -3
- package/dist/reasons/display.d.ts +86 -0
- package/dist/reasons/display.js +105 -0
- package/dist/reasons/types.d.ts +1 -1
- package/dist/reference/reference.d.ts +57 -0
- package/dist/reference/reference.js +210 -48
- package/dist/tokenizer/index.d.ts +35 -0
- package/dist/tokenizer/index.js +37 -1
- package/dist/types.d.ts +104 -0
- package/package.json +16 -1
|
@@ -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
|
|
28
|
-
*
|
|
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. */
|
package/dist/intents/lexical.js
CHANGED
|
@@ -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
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
42
|
-
// A whole-query match earns full
|
|
43
|
-
// eight-word paraphrase earns half. This is what lets a paraphrase
|
|
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
|
-
|
|
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';
|
package/dist/internal.js
ADDED
|
@@ -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
|
package/dist/ranking/budgets.js
CHANGED
|
@@ -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)
|
package/dist/ranking/rank.js
CHANGED
|
@@ -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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 = {}) {
|