1/** 2 * Hybrid search ranking and the sort key that presents it (#279). 3 * 4 * App.js owns the state; the decisions live here, as pure functions, so they 5 * can be tested without a browser. Everything below takes plain values and 6 * returns plain values: no React, no DOM, no fetch. 7 * 8 * Three jobs: 9 * 1. fuse the lexical and semantic hit orders into one ranking (RRF); 10 * 2. order a filtered record list by that ranking; 11 * 3. decide which sort key may be selected right now. 12 * 13 * Job 3 exists because the sort control only lists "Relevance" while a query is 14 * active. Selecting relevance with an empty search box leaves the select with a 15 * value no option carries, and the browser then renders it blank. 16 */ 17import { 18 chipFor, 19 reciprocalRankFusion, 20 LABEL_LEXICAL, 21 LABEL_SEMANTIC, 22} from './rrf.js?v=3.8.36'; 23 24/** Sort key for the fused hybrid order. Offered only while a query is active. */ 25export const RELEVANCE_SORT = 'relevance'; 26 27/** The sort the archive falls back to whenever relevance cannot be offered. */ 28export const DEFAULT_SORT = 'date-desc'; 29 30/** 31 * Chip for a record the ranked legs never reached. 32 * 33 * The archive also matches records by plain substring, which no ranked leg 34 * covers, so a card can be a real keyword match with no fused rank. It gets the 35 * keyword chip: an unlabelled card beside labelled ones reads as a bug. 36 */ 37export const LEXICAL_SIGNAL = chipFor([LABEL_LEXICAL]); 38 39const SEARCH_SIGNAL_PRESENTATION = Object.freeze({ 40 [LEXICAL_SIGNAL]: Object.freeze({ 41 label: 'Matching words', 42 description: 'This record uses words from your search.', 43 }), 44 [chipFor([LABEL_SEMANTIC])]: Object.freeze({ 45 label: 'Related meaning', 46 description: 'This record discusses the idea in your search, even when it uses different words.', 47 }), 48 [chipFor([LABEL_LEXICAL, LABEL_SEMANTIC])]: Object.freeze({ 49 label: 'Words and meaning', 50 description: 'This record matches both the words and the idea in your search.', 51 }), 52}); 53 54/** Convert internal ranking source codes into reader-facing language. */ 55export function presentSearchSignal(signal) { 56 return SEARCH_SIGNAL_PRESENTATION[signal] || SEARCH_SIGNAL_PRESENTATION[LEXICAL_SIGNAL]; 57} 58 59/** 60 * Fuse the two hit orders into one ranking plus its provenance chips. 61 * 62 * @param {{ lexicalOrder?: string[], semanticOrder?: string[] }} legs - ranked 63 * record ids, best first. Either may be empty. 64 * @returns {{ 65 * fusedRanks: Map<string, number>, 66 * searchSignals: Map<string, string>|null, 67 * semanticIds: Set<string>, 68 * lexicalIds: Set<string>|null, 69 * }} `fusedRanks` maps a record id to its 0-indexed fused position. 70 * `searchSignals` maps a record id to its chip, and is null while the 71 * semantic leg is empty, because a chip on every card says nothing when only 72 * one leg can contribute. `semanticIds` and `lexicalIds` are membership sets 73 * for the filter step; `lexicalIds` is null when the lexical leg is empty so 74 * a caller can skip the lookup. 75 */ 76export function buildSearchRanking({ lexicalOrder = [], semanticOrder = [] } = {}) { 77 const fused = reciprocalRankFusion({ 78 [LABEL_LEXICAL]: lexicalOrder, 79 [LABEL_SEMANTIC]: semanticOrder, 80 }); 81 return { 82 fusedRanks: new Map(fused.map((hit, index) => [hit.id, index])), 83 searchSignals: semanticOrder.length > 0 84 ? new Map(fused.map(hit => [hit.id, chipFor(hit.sources)])) 85 : null, 86 semanticIds: new Set(semanticOrder), 87 lexicalIds: lexicalOrder.length > 0 ? new Set(lexicalOrder) : null, 88 }; 89} 90 91/** 92 * Order records by fused rank, keeping the input order for everything the 93 * ranked legs did not reach. 94 * 95 * Pass a list already sorted the way unranked records should fall (newest 96 * first, in the archive). A record with no fused rank sorts after every ranked 97 * one and keeps its incoming position, so the tail stays stable. 98 */ 99export function orderByFusedRank(records, fusedRanks) { 100 if (!fusedRanks || fusedRanks.size === 0) return records; 101 return records 102 .map((record, index) => ({ record, index })) 103 .sort((a, b) => ( 104 (fusedRanks.get(a.record.id) ?? Infinity) - (fusedRanks.get(b.record.id) ?? Infinity) 105 || a.index - b.index 106 )) 107 .map(entry => entry.record); 108} 109 110/** 111 * Sort key after the semantic toggle is switched. 112 * 113 * On, with a query: fused relevance is the point of the toggle, so ranking 114 * follows it. On, with an empty box: relevance is not on offer, so it is 115 * dropped. Off: give back the default sort, since the reader never picked 116 * relevance themselves. 117 */ 118export function sortForSemanticToggle(current, { enabled, hasQuery } = {}) { 119 if (!enabled || !hasQuery) return current === RELEVANCE_SORT ? DEFAULT_SORT : current; 120 return RELEVANCE_SORT; 121} 122 123/** 124 * Sort key after the search box changes. 125 * 126 * Clearing the box drops relevance, which no longer has a query to rank 127 * against. Starting a new query while semantic search is on picks relevance up 128 * again. Editing an active query changes nothing, so a reader who chose a date 129 * order keeps it while they type. 130 */ 131export function sortForQueryChange(current, { hasQuery, hadQuery = false, semanticEnabled = false } = {}) { 132 if (!hasQuery) return current === RELEVANCE_SORT ? DEFAULT_SORT : current; 133 if (semanticEnabled && !hadQuery) return RELEVANCE_SORT; 134 return current; 135} 136 137export default { 138 RELEVANCE_SORT, 139 DEFAULT_SORT, 140 LEXICAL_SIGNAL, 141 presentSearchSignal, 142 buildSearchRanking, 143 orderByFusedRank, 144 sortForSemanticToggle, 145 sortForQueryChange, 146};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.