@jestek-dev/scripture-engine 0.7.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/dist/config/engineVersion.d.ts +19 -0
- package/dist/config/engineVersion.js +19 -0
- package/dist/corpus/repository.d.ts +193 -0
- package/dist/corpus/repository.js +466 -0
- package/dist/createEngine.d.ts +58 -0
- package/dist/createEngine.js +338 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +18 -0
- package/dist/intents/concept.d.ts +69 -0
- package/dist/intents/concept.js +143 -0
- package/dist/intents/lexical.d.ts +51 -0
- package/dist/intents/lexical.js +136 -0
- package/dist/ranking/budgets.d.ts +65 -0
- package/dist/ranking/budgets.js +149 -0
- package/dist/ranking/rank.d.ts +42 -0
- package/dist/ranking/rank.js +71 -0
- package/dist/reasons/types.d.ts +55 -0
- package/dist/reasons/types.js +20 -0
- package/dist/reference/reference.d.ts +31 -0
- package/dist/reference/reference.js +130 -0
- package/dist/reference/verseId.d.ts +12 -0
- package/dist/reference/verseId.js +36 -0
- package/dist/tokenizer/index.d.ts +39 -0
- package/dist/tokenizer/index.js +207 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +11 -0
- package/package.json +27 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexical intents — steps 2, 3 and 4 of the ladder (exact phrase, distinctive
|
|
3
|
+
* tokens with proximity, conservative normalization).
|
|
4
|
+
*
|
|
5
|
+
* Each intent's job is to turn corpus rows into typed Evidence. It never
|
|
6
|
+
* decides ordering; the ranker does, under the signal budgets. That split is
|
|
7
|
+
* what keeps "add a new intent" from being able to destabilize ranking.
|
|
8
|
+
*/
|
|
9
|
+
import { significantWords } from '../tokenizer/index.js';
|
|
10
|
+
/**
|
|
11
|
+
* Canonical, sortable target id: zero-padded so lexicographic order IS
|
|
12
|
+
* scripture order. The ranker uses targetId as its final tie-break, so this
|
|
13
|
+
* padding is what makes equal-scoring results present in Genesis-to-
|
|
14
|
+
* Revelation order rather than string-sorted nonsense ("10" before "9").
|
|
15
|
+
*/
|
|
16
|
+
export function targetIdFor(verse) {
|
|
17
|
+
return `${verse.translationCode}:${String(verse.verseId).padStart(8, '0')}`;
|
|
18
|
+
}
|
|
19
|
+
/** Chapter-level grouping — the unit diversification thins by. */
|
|
20
|
+
export function groupIdFor(verse) {
|
|
21
|
+
return `${verse.translationCode}:${verse.bookId}:${verse.chapter}`;
|
|
22
|
+
}
|
|
23
|
+
export function referenceLabel(verse) {
|
|
24
|
+
return `${verse.bookName} ${verse.chapter}:${verse.verse}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Exact phrase evidence.
|
|
28
|
+
*
|
|
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.
|
|
31
|
+
* bm25 is used upstream to choose WHICH matches survive the candidate cap
|
|
32
|
+
* when there are more than the limit, but it never modulates strength — so
|
|
33
|
+
* equal-strength matches fall through to the canonical-order tie-break,
|
|
34
|
+
* which is what a reader expects from a concordance-style result.
|
|
35
|
+
*/
|
|
36
|
+
export function phraseEvidence(fragment, fragmentWords, queryWords) {
|
|
37
|
+
const complete = fragmentWords >= queryWords;
|
|
38
|
+
return {
|
|
39
|
+
family: 'exact_phrase',
|
|
40
|
+
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
|
|
45
|
+
// James 1:22, without pretending a fragment is the whole quotation.
|
|
46
|
+
strength: Math.max(0, Math.min(1, fragmentWords / Math.max(1, queryWords))),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const PROXIMITY_WINDOW = 20;
|
|
50
|
+
/**
|
|
51
|
+
* Additive smoothing for the precision term. Keeps a very short verse from
|
|
52
|
+
* scoring high precision on one incidental shared word.
|
|
53
|
+
*/
|
|
54
|
+
const PRECISION_SMOOTHING = 2;
|
|
55
|
+
/**
|
|
56
|
+
* Token-overlap and proximity evidence.
|
|
57
|
+
*
|
|
58
|
+
* Coverage is IDF-weighted rather than a raw count: matching "refuge" and
|
|
59
|
+
* "strength" is worth far more than matching "do" and "one", and weighting
|
|
60
|
+
* by inverse document frequency expresses that without anyone having to
|
|
61
|
+
* hand-maintain a list of which words are important.
|
|
62
|
+
*/
|
|
63
|
+
export function tokenEvidence(match, queryIdfTotal) {
|
|
64
|
+
const evidence = [];
|
|
65
|
+
const coverage = queryIdfTotal > 0 ? Math.min(1, match.idfSum / queryIdfTotal) : 0;
|
|
66
|
+
if (coverage <= 0)
|
|
67
|
+
return evidence;
|
|
68
|
+
// Coverage alone is RECALL — "did the verse contain what I asked for?" —
|
|
69
|
+
// and it saturates at 1.0 for every verse containing all the query's
|
|
70
|
+
// terms. That is how Luke 6:47 tied with (and then beat) James 1:22 for
|
|
71
|
+
// "be doers of the word not hearers only": both contain hear + do + word,
|
|
72
|
+
// so both scored a perfect 1.0.
|
|
73
|
+
//
|
|
74
|
+
// Precision is the missing half: what fraction of the VERSE is what you
|
|
75
|
+
// asked about? James 1:22 is five significant words, three of them yours;
|
|
76
|
+
// Luke 6:47 is seven, three of them yours. The first is more ABOUT the
|
|
77
|
+
// query even though both contain it.
|
|
78
|
+
//
|
|
79
|
+
// Smoothed by PRECISION_SMOOTHING so that very short verses do not win by
|
|
80
|
+
// arithmetic accident — without it a two-word verse sharing one word would
|
|
81
|
+
// score 0.5 precision on almost no evidence.
|
|
82
|
+
const precision = (match.matchedTokens.length + PRECISION_SMOOTHING) /
|
|
83
|
+
(Math.max(match.matchedTokens.length, match.distinctTokenCount) + PRECISION_SMOOTHING);
|
|
84
|
+
evidence.push({
|
|
85
|
+
family: 'token_overlap',
|
|
86
|
+
label: match.matchedTokens.length === 1
|
|
87
|
+
? `Shared word: ${match.matchedTokens[0]}`
|
|
88
|
+
: `Shared words: ${match.matchedTokens.join(', ')}`,
|
|
89
|
+
strength: coverage * precision,
|
|
90
|
+
});
|
|
91
|
+
// Proximity only means something with two or more matched tokens. The
|
|
92
|
+
// ideal span for n tokens is n-1 (adjacent words); anything wider decays
|
|
93
|
+
// linearly to zero at PROXIMITY_WINDOW words apart.
|
|
94
|
+
if (match.minSpan !== null && match.matchedTokens.length > 1) {
|
|
95
|
+
const ideal = match.matchedTokens.length - 1;
|
|
96
|
+
const excess = Math.max(0, match.minSpan - ideal);
|
|
97
|
+
const strength = Math.max(0, 1 - excess / PROXIMITY_WINDOW);
|
|
98
|
+
if (strength > 0) {
|
|
99
|
+
evidence.push({
|
|
100
|
+
family: 'proximity',
|
|
101
|
+
label: 'Matched words appear close together',
|
|
102
|
+
strength,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return evidence;
|
|
107
|
+
}
|
|
108
|
+
/** Total IDF of the query's tokens — the denominator for coverage. */
|
|
109
|
+
export function queryIdfTotal(tokens, documentFrequencies, documentCount) {
|
|
110
|
+
return [...new Set(tokens)].reduce((sum, token) => {
|
|
111
|
+
const df = documentFrequencies.get(token) ?? 0;
|
|
112
|
+
return sum + Math.log(1 + documentCount / Math.max(1, df));
|
|
113
|
+
}, 0);
|
|
114
|
+
}
|
|
115
|
+
/** Merge per-verse evidence from every lexical intent into ranked candidates. */
|
|
116
|
+
export function mergeCandidates(contributions) {
|
|
117
|
+
const byTarget = new Map();
|
|
118
|
+
for (const contribution of contributions) {
|
|
119
|
+
const key = targetIdFor(contribution.verse);
|
|
120
|
+
const existing = byTarget.get(key);
|
|
121
|
+
if (existing)
|
|
122
|
+
existing.evidence.push(...contribution.evidence);
|
|
123
|
+
else
|
|
124
|
+
byTarget.set(key, { verse: contribution.verse, evidence: [...contribution.evidence] });
|
|
125
|
+
}
|
|
126
|
+
// Sorted by key so candidate construction order is deterministic regardless
|
|
127
|
+
// of which intent happened to see a verse first.
|
|
128
|
+
return [...byTarget.entries()]
|
|
129
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
130
|
+
.map(([targetId, entry]) => ({
|
|
131
|
+
targetId,
|
|
132
|
+
groupId: groupIdFor(entry.verse),
|
|
133
|
+
evidence: entry.evidence,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
export { significantWords };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SIGNAL BUDGETS — guardrail G6, the deepest guardrail in the system.
|
|
3
|
+
*
|
|
4
|
+
* Every other gate measures whether a data addition made results worse.
|
|
5
|
+
* This one makes the worst case BOUNDED BY CONSTRUCTION: because these caps
|
|
6
|
+
* are enforced inside the scoring core rather than assumed by convention, an
|
|
7
|
+
* admitted dataset cannot shout down an exact match or a curated anchor no
|
|
8
|
+
* matter how large it is. Adding data changes WHICH candidates surface; it
|
|
9
|
+
* can never change HOW LOUD a signal class is permitted to be.
|
|
10
|
+
*
|
|
11
|
+
* That bound is what makes it safe to keep feeding the system over time: a
|
|
12
|
+
* bad addition can waste space and add mediocre candidates (which G4, G5, G8
|
|
13
|
+
* and G9 catch), but it cannot invert the evidence hierarchy.
|
|
14
|
+
*
|
|
15
|
+
* These numbers are reviewed data. Changing one changes ordering, so gate G2
|
|
16
|
+
* requires an ENGINE_VERSION bump in the same commit, and the golden corpus
|
|
17
|
+
* (G3) plus noise probes (G8) must still pass.
|
|
18
|
+
*/
|
|
19
|
+
import type { Evidence, Reason, SignalFamily } from '../reasons/types.js';
|
|
20
|
+
export interface FamilyBudget {
|
|
21
|
+
/** Points awarded at strength 1.0. */
|
|
22
|
+
readonly maxPoints: number;
|
|
23
|
+
/**
|
|
24
|
+
* Most reasons of this family that may contribute to a single result.
|
|
25
|
+
* Ten sermons agreeing is not ten independent facts.
|
|
26
|
+
*/
|
|
27
|
+
readonly maxReasons: number;
|
|
28
|
+
}
|
|
29
|
+
export interface SignalBudgets {
|
|
30
|
+
readonly families: Readonly<Record<SignalFamily, FamilyBudget>>;
|
|
31
|
+
/**
|
|
32
|
+
* Ceiling on the SUM of all weak-family points for one result. This is the
|
|
33
|
+
* rule that keeps accumulated weak evidence from ever equalling direct
|
|
34
|
+
* evidence: no pile of thematic hints outranks a verbatim phrase match.
|
|
35
|
+
*/
|
|
36
|
+
readonly weakAggregateCap: number;
|
|
37
|
+
/**
|
|
38
|
+
* Families whose evidence derives from overlapping upstream data share one
|
|
39
|
+
* budget (guardrail G7). OpenBible's cross-references derive largely from
|
|
40
|
+
* TSK, and homiletical co-citations overlap both; counting them as
|
|
41
|
+
* independent would inflate confidence for what is substantially one fact.
|
|
42
|
+
*/
|
|
43
|
+
readonly correlationGroups: readonly (readonly SignalFamily[])[];
|
|
44
|
+
}
|
|
45
|
+
export declare const DEFAULT_BUDGETS: SignalBudgets;
|
|
46
|
+
export interface BudgetedScore {
|
|
47
|
+
readonly score: number;
|
|
48
|
+
readonly reasons: readonly Reason[];
|
|
49
|
+
/** True when any cap actually reduced a contribution — surfaced in gate reports. */
|
|
50
|
+
readonly capped: boolean;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Apply the budgets to one candidate's raw evidence.
|
|
54
|
+
*
|
|
55
|
+
* Order of operations matters and is fixed:
|
|
56
|
+
* 1. per-family reason count cap (drop the weakest duplicates)
|
|
57
|
+
* 2. per-family point cap (via maxPoints scaling)
|
|
58
|
+
* 3. correlation-group cap (correlated families share the larger budget)
|
|
59
|
+
* 4. weak aggregate cap (scale all weak reasons proportionally)
|
|
60
|
+
*
|
|
61
|
+
* Scaling proportionally rather than truncating keeps the relative ordering
|
|
62
|
+
* of weak reasons intact, so the displayed explanation still ranks the same
|
|
63
|
+
* way the evidence does.
|
|
64
|
+
*/
|
|
65
|
+
export declare function applyBudgets(evidence: readonly Evidence[], budgets?: SignalBudgets): BudgetedScore;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SIGNAL BUDGETS — guardrail G6, the deepest guardrail in the system.
|
|
3
|
+
*
|
|
4
|
+
* Every other gate measures whether a data addition made results worse.
|
|
5
|
+
* This one makes the worst case BOUNDED BY CONSTRUCTION: because these caps
|
|
6
|
+
* are enforced inside the scoring core rather than assumed by convention, an
|
|
7
|
+
* admitted dataset cannot shout down an exact match or a curated anchor no
|
|
8
|
+
* matter how large it is. Adding data changes WHICH candidates surface; it
|
|
9
|
+
* can never change HOW LOUD a signal class is permitted to be.
|
|
10
|
+
*
|
|
11
|
+
* That bound is what makes it safe to keep feeding the system over time: a
|
|
12
|
+
* bad addition can waste space and add mediocre candidates (which G4, G5, G8
|
|
13
|
+
* and G9 catch), but it cannot invert the evidence hierarchy.
|
|
14
|
+
*
|
|
15
|
+
* These numbers are reviewed data. Changing one changes ordering, so gate G2
|
|
16
|
+
* requires an ENGINE_VERSION bump in the same commit, and the golden corpus
|
|
17
|
+
* (G3) plus noise probes (G8) must still pass.
|
|
18
|
+
*/
|
|
19
|
+
import { isAuthoritative } from '../reasons/types.js';
|
|
20
|
+
export const DEFAULT_BUDGETS = {
|
|
21
|
+
families: {
|
|
22
|
+
// Authoritative — these dominate by design.
|
|
23
|
+
reference: { maxPoints: 100, maxReasons: 1 },
|
|
24
|
+
exact_phrase: { maxPoints: 60, maxReasons: 1 },
|
|
25
|
+
concept_anchor: { maxPoints: 40, maxReasons: 3 },
|
|
26
|
+
// Weak — individually modest, collectively capped below.
|
|
27
|
+
concept_lexicon: { maxPoints: 12, maxReasons: 2 },
|
|
28
|
+
token_overlap: { maxPoints: 10, maxReasons: 1 },
|
|
29
|
+
proximity: { maxPoints: 6, maxReasons: 1 },
|
|
30
|
+
passage_terms: { maxPoints: 8, maxReasons: 2 },
|
|
31
|
+
cross_reference: { maxPoints: 6, maxReasons: 2 },
|
|
32
|
+
co_citation: { maxPoints: 5, maxReasons: 2 },
|
|
33
|
+
},
|
|
34
|
+
// Deliberately below exact_phrase.maxPoints: every weak signal in the
|
|
35
|
+
// system, all firing at full strength, still loses to one verbatim match.
|
|
36
|
+
weakAggregateCap: 30,
|
|
37
|
+
correlationGroups: [['cross_reference', 'co_citation']],
|
|
38
|
+
};
|
|
39
|
+
function toReason(evidence, budget) {
|
|
40
|
+
const clampedStrength = Math.min(1, Math.max(0, evidence.strength));
|
|
41
|
+
return {
|
|
42
|
+
family: evidence.family,
|
|
43
|
+
label: evidence.label,
|
|
44
|
+
points: clampedStrength * budget.maxPoints,
|
|
45
|
+
...(evidence.provenance ? { provenance: evidence.provenance } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Deterministic evidence ordering. Sorting by strength alone would let two
|
|
50
|
+
* equal-strength items from different sources swap places between builds,
|
|
51
|
+
* which would break the reproducibility contract; family name then label
|
|
52
|
+
* then locator give a total order over otherwise-tied evidence.
|
|
53
|
+
*/
|
|
54
|
+
function compareEvidence(a, b) {
|
|
55
|
+
if (b.strength !== a.strength)
|
|
56
|
+
return b.strength - a.strength;
|
|
57
|
+
if (a.family !== b.family)
|
|
58
|
+
return a.family < b.family ? -1 : 1;
|
|
59
|
+
if (a.label !== b.label)
|
|
60
|
+
return a.label < b.label ? -1 : 1;
|
|
61
|
+
const aLoc = a.provenance?.locator ?? '';
|
|
62
|
+
const bLoc = b.provenance?.locator ?? '';
|
|
63
|
+
if (aLoc !== bLoc)
|
|
64
|
+
return aLoc < bLoc ? -1 : 1;
|
|
65
|
+
const aSrc = a.provenance?.sourceId ?? '';
|
|
66
|
+
const bSrc = b.provenance?.sourceId ?? '';
|
|
67
|
+
return aSrc < bSrc ? -1 : aSrc > bSrc ? 1 : 0;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Apply the budgets to one candidate's raw evidence.
|
|
71
|
+
*
|
|
72
|
+
* Order of operations matters and is fixed:
|
|
73
|
+
* 1. per-family reason count cap (drop the weakest duplicates)
|
|
74
|
+
* 2. per-family point cap (via maxPoints scaling)
|
|
75
|
+
* 3. correlation-group cap (correlated families share the larger budget)
|
|
76
|
+
* 4. weak aggregate cap (scale all weak reasons proportionally)
|
|
77
|
+
*
|
|
78
|
+
* Scaling proportionally rather than truncating keeps the relative ordering
|
|
79
|
+
* of weak reasons intact, so the displayed explanation still ranks the same
|
|
80
|
+
* way the evidence does.
|
|
81
|
+
*/
|
|
82
|
+
export function applyBudgets(evidence, budgets = DEFAULT_BUDGETS) {
|
|
83
|
+
let capped = false;
|
|
84
|
+
// 1 + 2: per-family caps.
|
|
85
|
+
const byFamily = new Map();
|
|
86
|
+
for (const item of evidence) {
|
|
87
|
+
const bucket = byFamily.get(item.family);
|
|
88
|
+
if (bucket)
|
|
89
|
+
bucket.push(item);
|
|
90
|
+
else
|
|
91
|
+
byFamily.set(item.family, [item]);
|
|
92
|
+
}
|
|
93
|
+
const reasons = [];
|
|
94
|
+
for (const [family, items] of byFamily) {
|
|
95
|
+
const budget = budgets.families[family];
|
|
96
|
+
const ordered = [...items].sort(compareEvidence);
|
|
97
|
+
if (ordered.length > budget.maxReasons)
|
|
98
|
+
capped = true;
|
|
99
|
+
for (const item of ordered.slice(0, budget.maxReasons)) {
|
|
100
|
+
reasons.push(toReason(item, budget));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// 3: correlation groups — the group's total may not exceed the largest
|
|
104
|
+
// single member budget, so overlapping sources cannot stack.
|
|
105
|
+
for (const group of budgets.correlationGroups) {
|
|
106
|
+
const members = reasons.filter((reason) => group.includes(reason.family));
|
|
107
|
+
if (members.length < 2)
|
|
108
|
+
continue;
|
|
109
|
+
const groupCap = Math.max(...group.map((family) => budgets.families[family].maxPoints));
|
|
110
|
+
const total = members.reduce((sum, reason) => sum + reason.points, 0);
|
|
111
|
+
if (total <= groupCap || total === 0)
|
|
112
|
+
continue;
|
|
113
|
+
capped = true;
|
|
114
|
+
const scale = groupCap / total;
|
|
115
|
+
for (const member of members) {
|
|
116
|
+
const index = reasons.indexOf(member);
|
|
117
|
+
reasons[index] = {
|
|
118
|
+
...member,
|
|
119
|
+
points: member.points * scale,
|
|
120
|
+
uncappedPoints: member.points,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// 4: weak aggregate cap.
|
|
125
|
+
const weak = reasons.filter((reason) => !isAuthoritative(reason.family));
|
|
126
|
+
const weakTotal = weak.reduce((sum, reason) => sum + reason.points, 0);
|
|
127
|
+
if (weakTotal > budgets.weakAggregateCap && weakTotal > 0) {
|
|
128
|
+
capped = true;
|
|
129
|
+
const scale = budgets.weakAggregateCap / weakTotal;
|
|
130
|
+
for (const member of weak) {
|
|
131
|
+
const index = reasons.indexOf(member);
|
|
132
|
+
reasons[index] = {
|
|
133
|
+
...member,
|
|
134
|
+
points: member.points * scale,
|
|
135
|
+
uncappedPoints: member.uncappedPoints ?? member.points,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Stable presentation order: strongest first, ties broken by family then label.
|
|
140
|
+
const ordered = [...reasons].sort((a, b) => {
|
|
141
|
+
if (b.points !== a.points)
|
|
142
|
+
return b.points - a.points;
|
|
143
|
+
if (a.family !== b.family)
|
|
144
|
+
return a.family < b.family ? -1 : 1;
|
|
145
|
+
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
|
|
146
|
+
});
|
|
147
|
+
const score = ordered.reduce((sum, reason) => sum + reason.points, 0);
|
|
148
|
+
return { score, reasons: ordered, capped };
|
|
149
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure scoring core. Pattern ported from LH Worship Setlist's
|
|
3
|
+
* `src/lib/reco/engine.ts`: this module imports no database and no corpus
|
|
4
|
+
* module, takes fully pre-extracted evidence, and is therefore unit-testable
|
|
5
|
+
* in isolation. Only the orchestrator (`engine/src/index.ts`) touches I/O.
|
|
6
|
+
*
|
|
7
|
+
* Determinism is the product here. Same engine version + same corpus
|
|
8
|
+
* fingerprint + same query MUST yield byte-identical ordering on every
|
|
9
|
+
* platform, which is why every comparison below ends in a total-order
|
|
10
|
+
* tie-break and no step depends on input order or Map iteration order.
|
|
11
|
+
*/
|
|
12
|
+
import type { Evidence, Reason } from '../reasons/types.js';
|
|
13
|
+
import { type SignalBudgets } from './budgets.js';
|
|
14
|
+
export interface Candidate {
|
|
15
|
+
/** Stable target id — the final tie-break, so ordering never wobbles. */
|
|
16
|
+
readonly targetId: string;
|
|
17
|
+
/** Pericope or passage grouping key, used for diversification. */
|
|
18
|
+
readonly groupId: string;
|
|
19
|
+
readonly evidence: readonly Evidence[];
|
|
20
|
+
}
|
|
21
|
+
export interface RankedResult {
|
|
22
|
+
readonly targetId: string;
|
|
23
|
+
readonly groupId: string;
|
|
24
|
+
readonly score: number;
|
|
25
|
+
readonly reasons: readonly Reason[];
|
|
26
|
+
readonly capped: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface RankOptions {
|
|
29
|
+
readonly budgets?: SignalBudgets;
|
|
30
|
+
readonly limit?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Most results allowed from one passage group, applied ONLY to results
|
|
33
|
+
* carrying no authoritative evidence. Per the 2026-07-20 rule, results
|
|
34
|
+
* "diversify by passage context only after high-confidence direct matches
|
|
35
|
+
* are protected" — a genuine 6-verse exact-phrase hit in one chapter must
|
|
36
|
+
* never be thinned for the sake of variety.
|
|
37
|
+
*/
|
|
38
|
+
readonly maxPerGroup?: number;
|
|
39
|
+
}
|
|
40
|
+
export declare const DEFAULT_LIMIT = 25;
|
|
41
|
+
export declare const DEFAULT_MAX_PER_GROUP = 3;
|
|
42
|
+
export declare function rank(candidates: readonly Candidate[], options?: RankOptions): readonly RankedResult[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure scoring core. Pattern ported from LH Worship Setlist's
|
|
3
|
+
* `src/lib/reco/engine.ts`: this module imports no database and no corpus
|
|
4
|
+
* module, takes fully pre-extracted evidence, and is therefore unit-testable
|
|
5
|
+
* in isolation. Only the orchestrator (`engine/src/index.ts`) touches I/O.
|
|
6
|
+
*
|
|
7
|
+
* Determinism is the product here. Same engine version + same corpus
|
|
8
|
+
* fingerprint + same query MUST yield byte-identical ordering on every
|
|
9
|
+
* platform, which is why every comparison below ends in a total-order
|
|
10
|
+
* tie-break and no step depends on input order or Map iteration order.
|
|
11
|
+
*/
|
|
12
|
+
import { isAuthoritative } from '../reasons/types.js';
|
|
13
|
+
import { applyBudgets, DEFAULT_BUDGETS } from './budgets.js';
|
|
14
|
+
export const DEFAULT_LIMIT = 25;
|
|
15
|
+
export const DEFAULT_MAX_PER_GROUP = 3;
|
|
16
|
+
/**
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
function compareResults(a, b) {
|
|
23
|
+
if (b.score !== a.score)
|
|
24
|
+
return b.score - a.score;
|
|
25
|
+
const aAuth = a.reasons.some((reason) => isAuthoritative(reason.family));
|
|
26
|
+
const bAuth = b.reasons.some((reason) => isAuthoritative(reason.family));
|
|
27
|
+
if (aAuth !== bAuth)
|
|
28
|
+
return aAuth ? -1 : 1;
|
|
29
|
+
return a.targetId < b.targetId ? -1 : a.targetId > b.targetId ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
export function rank(candidates, options = {}) {
|
|
32
|
+
const budgets = options.budgets ?? DEFAULT_BUDGETS;
|
|
33
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
34
|
+
const maxPerGroup = options.maxPerGroup ?? DEFAULT_MAX_PER_GROUP;
|
|
35
|
+
const scored = candidates.map((candidate) => {
|
|
36
|
+
const { score, reasons, capped } = applyBudgets(candidate.evidence, budgets);
|
|
37
|
+
return {
|
|
38
|
+
targetId: candidate.targetId,
|
|
39
|
+
groupId: candidate.groupId,
|
|
40
|
+
score,
|
|
41
|
+
reasons,
|
|
42
|
+
capped,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
// Drop zero-evidence candidates rather than ranking them arbitrarily.
|
|
46
|
+
const surviving = scored.filter((result) => result.reasons.length > 0);
|
|
47
|
+
surviving.sort(compareResults);
|
|
48
|
+
const groupCounts = new Map();
|
|
49
|
+
const output = [];
|
|
50
|
+
const deferred = [];
|
|
51
|
+
for (const result of surviving) {
|
|
52
|
+
if (output.length >= limit)
|
|
53
|
+
break;
|
|
54
|
+
const authoritative = result.reasons.some((reason) => isAuthoritative(reason.family));
|
|
55
|
+
const used = groupCounts.get(result.groupId) ?? 0;
|
|
56
|
+
if (!authoritative && used >= maxPerGroup) {
|
|
57
|
+
deferred.push(result);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
groupCounts.set(result.groupId, used + 1);
|
|
61
|
+
output.push(result);
|
|
62
|
+
}
|
|
63
|
+
// Diversification thins, it never discards: if the capped groups left room
|
|
64
|
+
// under the limit, deferred results return in their original total order.
|
|
65
|
+
for (const result of deferred) {
|
|
66
|
+
if (output.length >= limit)
|
|
67
|
+
break;
|
|
68
|
+
output.push(result);
|
|
69
|
+
}
|
|
70
|
+
return output;
|
|
71
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed reason objects. Shape ported from LH Worship Setlist's
|
|
3
|
+
* `RecoReason { kind, label, tone, points }` and extended with the two things
|
|
4
|
+
* Maskil's plan requires: machine-verifiable evidence and provenance.
|
|
5
|
+
*
|
|
6
|
+
* Contract (from the 2026-07-20 ranking rules): every result carries at least
|
|
7
|
+
* one reason, and every reason must correspond to actual scoring evidence.
|
|
8
|
+
* A result whose displayed reason does not match the evidence that produced
|
|
9
|
+
* its score is a gate G3 failure, not a cosmetic bug — the explanation IS the
|
|
10
|
+
* product. "No result claims the application understood or interpreted the
|
|
11
|
+
* lyric": reasons state what matched, never what it means.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Signal families, in evidence-strength order. The split into authoritative
|
|
15
|
+
* and weak is not cosmetic — `ranking/budgets.ts` caps the weak families both
|
|
16
|
+
* individually and in aggregate, which is what structurally prevents a large
|
|
17
|
+
* new dataset from outranking an exact match (guardrail G6).
|
|
18
|
+
*/
|
|
19
|
+
export type SignalFamily = 'reference' | 'exact_phrase' | 'concept_anchor' | 'concept_lexicon' | 'token_overlap' | 'proximity' | 'passage_terms' | 'cross_reference' | 'co_citation';
|
|
20
|
+
export declare const AUTHORITATIVE_FAMILIES: readonly SignalFamily[];
|
|
21
|
+
export declare function isAuthoritative(family: SignalFamily): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Where a piece of evidence came from. Every weak-signal reason must carry
|
|
24
|
+
* one: it is what lets the UI print "Nave 'Obedience'" or "Spurgeon, MTP
|
|
25
|
+
* #1467" instead of an unfalsifiable claim of relevance, and what lets gate
|
|
26
|
+
* G1 prove every shipped row traces to an admitted source.
|
|
27
|
+
*/
|
|
28
|
+
export interface Provenance {
|
|
29
|
+
/** Manifest source id, e.g. 'openbible-topics', 'nave', 'editorial'. */
|
|
30
|
+
readonly sourceId: string;
|
|
31
|
+
/** Human-facing attribution, e.g. "Nave's Topical Bible". */
|
|
32
|
+
readonly label: string;
|
|
33
|
+
/** Optional locator within the source: topic name, sermon id, work section. */
|
|
34
|
+
readonly locator?: string;
|
|
35
|
+
/** Optional source-defined weight (e.g. OpenBible votes) — a prior, never truth. */
|
|
36
|
+
readonly weight?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface Reason {
|
|
39
|
+
readonly family: SignalFamily;
|
|
40
|
+
/** Short display label, e.g. "Exact phrase", "Theme: hearing and doing". */
|
|
41
|
+
readonly label: string;
|
|
42
|
+
/** Points this reason actually contributed AFTER caps were applied. */
|
|
43
|
+
readonly points: number;
|
|
44
|
+
/** Points before caps; present only when a cap reduced the contribution. */
|
|
45
|
+
readonly uncappedPoints?: number;
|
|
46
|
+
readonly provenance?: Provenance;
|
|
47
|
+
}
|
|
48
|
+
/** Raw evidence produced by an intent, before budgets are applied. */
|
|
49
|
+
export interface Evidence {
|
|
50
|
+
readonly family: SignalFamily;
|
|
51
|
+
readonly label: string;
|
|
52
|
+
/** Raw strength, typically 0..1 within the family; budgets convert to points. */
|
|
53
|
+
readonly strength: number;
|
|
54
|
+
readonly provenance?: Provenance;
|
|
55
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed reason objects. Shape ported from LH Worship Setlist's
|
|
3
|
+
* `RecoReason { kind, label, tone, points }` and extended with the two things
|
|
4
|
+
* Maskil's plan requires: machine-verifiable evidence and provenance.
|
|
5
|
+
*
|
|
6
|
+
* Contract (from the 2026-07-20 ranking rules): every result carries at least
|
|
7
|
+
* one reason, and every reason must correspond to actual scoring evidence.
|
|
8
|
+
* A result whose displayed reason does not match the evidence that produced
|
|
9
|
+
* its score is a gate G3 failure, not a cosmetic bug — the explanation IS the
|
|
10
|
+
* product. "No result claims the application understood or interpreted the
|
|
11
|
+
* lyric": reasons state what matched, never what it means.
|
|
12
|
+
*/
|
|
13
|
+
export const AUTHORITATIVE_FAMILIES = [
|
|
14
|
+
'reference',
|
|
15
|
+
'exact_phrase',
|
|
16
|
+
'concept_anchor',
|
|
17
|
+
];
|
|
18
|
+
export function isAuthoritative(family) {
|
|
19
|
+
return AUTHORITATIVE_FAMILIES.includes(family);
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface ResolvedBook {
|
|
2
|
+
readonly id: number;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly chapterCount: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ResolvedReference {
|
|
7
|
+
readonly book: ResolvedBook;
|
|
8
|
+
readonly startChapter: number;
|
|
9
|
+
readonly startVerse: number;
|
|
10
|
+
readonly endChapter: number;
|
|
11
|
+
readonly endVerse: number;
|
|
12
|
+
readonly startId: number;
|
|
13
|
+
readonly endId: number;
|
|
14
|
+
readonly label: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ReferenceResolver {
|
|
17
|
+
resolveBookAlias(aliasKey: string): Promise<ResolvedBook | null>;
|
|
18
|
+
getChapterVerseCount(bookId: number, chapter: number): Promise<number | null>;
|
|
19
|
+
verseExists(bookId: number, chapter: number, verse: number): Promise<boolean>;
|
|
20
|
+
}
|
|
21
|
+
export type ReferenceResolutionAttempt = {
|
|
22
|
+
readonly kind: 'not-reference';
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: 'invalid-reference';
|
|
25
|
+
} | {
|
|
26
|
+
readonly kind: 'resolved';
|
|
27
|
+
readonly reference: ResolvedReference;
|
|
28
|
+
};
|
|
29
|
+
export declare function normalizeBookAlias(input: string): string;
|
|
30
|
+
export declare function resolveReferenceAttempt(input: string, resolver: ReferenceResolver): Promise<ReferenceResolutionAttempt>;
|
|
31
|
+
export declare function resolveReference(input: string, resolver: ReferenceResolver): Promise<ResolvedReference | null>;
|