PageSourceSearch

https://pressthink.org/j/rosen-archive/frontend/utils/searchRanking.js?v=3.8.36

js pressthink.org collected 2026-10-02 04:26:20 UTC 5,493 bytes, 146 lines download raw bytes

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.