@heixui/dsh-session-suite 0.0.0-stage → 0.3.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 +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +640 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +75 -4
package/lib/memory.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session memory ledger.
|
|
3
|
+
*
|
|
4
|
+
* DSH's durable log is the only source of truth for the conversation, so the
|
|
5
|
+
* suite keeps *derived* memory here: short, human-readable facts a session (or
|
|
6
|
+
* its agent) decided to remember. Because every entry records the session
|
|
7
|
+
* sequence it was observed at, a rollback can trim the ledger back to exactly
|
|
8
|
+
* the state the conversation had at that point 鈥?the piece the product design
|
|
9
|
+
* called out as the real differentiator.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs/promises'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { ensureDir, readJson, sha256Text, shortId, writeJson } from './util.js'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export class MemoryLedger {
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} options ledger options
|
|
20
|
+
* @param {string} options.root suite data directory
|
|
21
|
+
*/
|
|
22
|
+
constructor({ root }) {
|
|
23
|
+
this.root = path.join(root, 'memory')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {string} scope session id or `_global` */
|
|
27
|
+
#file(scope) {
|
|
28
|
+
return path.join(this.root, `${scope}.json`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} scope session id or `_global`
|
|
33
|
+
* @returns {Promise<{ version: number, scope: string, entries: any[] }>} ledger
|
|
34
|
+
*/
|
|
35
|
+
async read(scope) {
|
|
36
|
+
const data = await readJson(this.#file(scope))
|
|
37
|
+
if (!data || !Array.isArray(data.entries)) return { version: 1, scope, entries: [] }
|
|
38
|
+
return data
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {string} scope session id or `_global` @param {any} data ledger */
|
|
42
|
+
async write(scope, data) {
|
|
43
|
+
await ensureDir(this.root)
|
|
44
|
+
await writeJson(this.#file(scope), data)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} sessionId session
|
|
49
|
+
* @param {{ text: string, tags?: string[], seq?: number, turn?: number, source?: string }} input entry
|
|
50
|
+
* @returns {Promise<any>} stored entry
|
|
51
|
+
*/
|
|
52
|
+
async add(sessionId, input) {
|
|
53
|
+
const text = String(input.text ?? '').trim()
|
|
54
|
+
if (!text) throw new Error('memory text is empty')
|
|
55
|
+
const data = await this.read(sessionId)
|
|
56
|
+
const entry = {
|
|
57
|
+
id: `mem_${shortId(text)}`,
|
|
58
|
+
text,
|
|
59
|
+
tags: Array.isArray(input.tags) ? input.tags.filter(tag => typeof tag === 'string') : [],
|
|
60
|
+
seq: typeof input.seq === 'number' ? input.seq : null,
|
|
61
|
+
turn: typeof input.turn === 'number' ? input.turn : null,
|
|
62
|
+
source: input.source ?? 'manual',
|
|
63
|
+
pinned: false,
|
|
64
|
+
createdAt: Date.now(),
|
|
65
|
+
digest: sha256Text(text).slice(0, 16),
|
|
66
|
+
}
|
|
67
|
+
data.entries.push(entry)
|
|
68
|
+
await this.write(sessionId, data)
|
|
69
|
+
return entry
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} sessionId session
|
|
74
|
+
* @param {string} id entry id
|
|
75
|
+
* @param {{ text?: string, tags?: string[], pinned?: boolean }} patch changes
|
|
76
|
+
* @returns {Promise<any | undefined>} updated entry
|
|
77
|
+
*/
|
|
78
|
+
async update(sessionId, id, patch) {
|
|
79
|
+
const data = await this.read(sessionId)
|
|
80
|
+
const entry = data.entries.find(item => item.id === id)
|
|
81
|
+
if (!entry) return undefined
|
|
82
|
+
if (typeof patch.text === 'string' && patch.text.trim()) {
|
|
83
|
+
entry.text = patch.text.trim()
|
|
84
|
+
entry.digest = sha256Text(entry.text).slice(0, 16)
|
|
85
|
+
entry.editedAt = Date.now()
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(patch.tags)) entry.tags = patch.tags.filter(tag => typeof tag === 'string')
|
|
88
|
+
if (typeof patch.pinned === 'boolean') entry.pinned = patch.pinned
|
|
89
|
+
await this.write(sessionId, data)
|
|
90
|
+
return entry
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} sessionId session
|
|
95
|
+
* @param {string} id entry id
|
|
96
|
+
* @returns {Promise<boolean>} removed
|
|
97
|
+
*/
|
|
98
|
+
async remove(sessionId, id) {
|
|
99
|
+
const data = await this.read(sessionId)
|
|
100
|
+
const before = data.entries.length
|
|
101
|
+
data.entries = data.entries.filter(item => item.id !== id)
|
|
102
|
+
await this.write(sessionId, data)
|
|
103
|
+
return data.entries.length !== before
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Trim entries observed after a session sequence 鈥?the memory half of a
|
|
108
|
+
* checkpoint rollback.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} sessionId session
|
|
111
|
+
* @param {number} seq inclusive bound to keep
|
|
112
|
+
* @returns {Promise<{ removed: number, kept: number }>} outcome
|
|
113
|
+
*/
|
|
114
|
+
async trimTo(sessionId, seq) {
|
|
115
|
+
const data = await this.read(sessionId)
|
|
116
|
+
const kept = []
|
|
117
|
+
let removed = 0
|
|
118
|
+
for (const entry of data.entries) {
|
|
119
|
+
if (typeof entry.seq === 'number' && entry.seq > seq && !entry.pinned) removed += 1
|
|
120
|
+
else kept.push(entry)
|
|
121
|
+
}
|
|
122
|
+
if (removed > 0) {
|
|
123
|
+
data.entries = kept
|
|
124
|
+
await this.write(sessionId, data)
|
|
125
|
+
}
|
|
126
|
+
return { removed, kept: kept.length }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Copy a ledger into another session, optionally only the entries observed up
|
|
131
|
+
* to a sequence. Used when a rewind or a segment delete forks a child: the
|
|
132
|
+
* conversation continues in a new session, so its memory has to follow.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} fromId source session
|
|
135
|
+
* @param {string} toId destination session
|
|
136
|
+
* @param {number} [maxSeq] inclusive upper bound on `seq`
|
|
137
|
+
* @returns {Promise<{ copied: number }>} outcome
|
|
138
|
+
*/
|
|
139
|
+
async clone(fromId, toId, maxSeq) {
|
|
140
|
+
if (fromId === toId) return { copied: 0 }
|
|
141
|
+
const data = await this.read(fromId)
|
|
142
|
+
const entries = data.entries
|
|
143
|
+
.filter(entry => typeof maxSeq !== 'number' || typeof entry.seq !== 'number' || entry.seq <= maxSeq)
|
|
144
|
+
.map(entry => ({ ...entry }))
|
|
145
|
+
await this.write(toId, { version: 1, scope: toId, entries })
|
|
146
|
+
return { copied: entries.length }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {string} sessionId session
|
|
151
|
+
* @returns {Promise<any[]>} entries in insertion order
|
|
152
|
+
*/
|
|
153
|
+
async list(sessionId) {
|
|
154
|
+
const data = await this.read(sessionId)
|
|
155
|
+
return data.entries
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Everything this session contributed to the global long-term store.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} sessionId session
|
|
162
|
+
* @param {{ text: string }} input entry
|
|
163
|
+
* @returns {Promise<any>} stored global entry
|
|
164
|
+
*/
|
|
165
|
+
async addGlobal(sessionId, input) {
|
|
166
|
+
const text = String(input.text ?? '').trim()
|
|
167
|
+
if (!text) throw new Error('memory text is empty')
|
|
168
|
+
const data = await this.read('_global')
|
|
169
|
+
const entry = {
|
|
170
|
+
id: `gmem_${shortId(text)}`,
|
|
171
|
+
text,
|
|
172
|
+
sessionId,
|
|
173
|
+
tags: [],
|
|
174
|
+
pinned: false,
|
|
175
|
+
createdAt: Date.now(),
|
|
176
|
+
}
|
|
177
|
+
data.entries.push(entry)
|
|
178
|
+
await this.write('_global', data)
|
|
179
|
+
return entry
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {string} sessionId session
|
|
184
|
+
* @returns {Promise<any[]>} global entries that mention this session
|
|
185
|
+
*/
|
|
186
|
+
async listGlobal(sessionId) {
|
|
187
|
+
const data = await this.read('_global')
|
|
188
|
+
return data.entries.filter(entry => entry.sessionId === sessionId)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {string} sessionId session
|
|
193
|
+
*/
|
|
194
|
+
async purge(sessionId) {
|
|
195
|
+
await fs.rm(this.#file(sessionId), { force: true }).catch(() => {})
|
|
196
|
+
const data = await this.read('_global')
|
|
197
|
+
const kept = data.entries.filter(entry => entry.sessionId !== sessionId)
|
|
198
|
+
if (kept.length !== data.entries.length) {
|
|
199
|
+
data.entries = kept
|
|
200
|
+
await this.write('_global', data)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Whole-ledger snapshot used by the undo bundle.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} sessionId session
|
|
208
|
+
* @returns {Promise<any>} ledger content
|
|
209
|
+
*/
|
|
210
|
+
async snapshot(sessionId) {
|
|
211
|
+
return this.read(sessionId)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @param {string} sessionId session
|
|
216
|
+
* @param {any} data ledger content captured earlier
|
|
217
|
+
*/
|
|
218
|
+
async restore(sessionId, data) {
|
|
219
|
+
if (!data || !Array.isArray(data.entries)) return
|
|
220
|
+
await this.write(sessionId, data)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const LEDGER_GLOBAL = '_global'
|
package/lib/retrieval.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval primitives, deliberately free of I/O and of the memory engine.
|
|
3
|
+
*
|
|
4
|
+
* The engine used to rank with a single lexical signal: bigram coverage weighted
|
|
5
|
+
* by inverse document frequency. That works for precision but has one error
|
|
6
|
+
* profile, and a single route cannot outvote itself. Chapter 3 of *深入理解
|
|
7
|
+
* AI Agent* describes the standard remedy as hybrid retrieval: run several
|
|
8
|
+
* routes with *different* blind spots, then fuse them by rank rather than by
|
|
9
|
+
* score, because the scores live on incomparable scales (cosine 0..1 versus
|
|
10
|
+
* BM25 0..50).
|
|
11
|
+
*
|
|
12
|
+
* The book's example fuses a dense route with a sparse one. We have no embedding
|
|
13
|
+
* model on the recall path — recall runs offline and must stay offline — so this
|
|
14
|
+
* module fuses two *lexical* routes chosen to fail differently:
|
|
15
|
+
*
|
|
16
|
+
* - bigrams (two-character sequences): precise, but blind to any paraphrase
|
|
17
|
+
* that does not reproduce the exact pair, and blind to single characters.
|
|
18
|
+
* - unigrams (single characters plus ASCII words): loose, noisy, but it still
|
|
19
|
+
* finds a memory when only part of the query vocabulary overlaps.
|
|
20
|
+
*
|
|
21
|
+
* Fusing them is not a substitute for a dense route. It recovers ranking order
|
|
22
|
+
* among memories that are already reachable, and it cannot invent a match where
|
|
23
|
+
* no character is shared at all. That limit is documented rather than papered
|
|
24
|
+
* over.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Tokenise into the bigram-plus-word vocabulary used for precise matching.
|
|
29
|
+
*
|
|
30
|
+
* Returns an array *with multiplicity*, because BM25 needs term frequency. The
|
|
31
|
+
* set form is the same vocabulary deduplicated, so `new Set(tokens(text))` is
|
|
32
|
+
* exactly the shingle set the rest of the engine already uses.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} text input
|
|
35
|
+
* @returns {string[]} tokens, duplicates preserved
|
|
36
|
+
*/
|
|
37
|
+
export function tokens(text) {
|
|
38
|
+
const normalized = String(text ?? '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ')
|
|
39
|
+
const out = []
|
|
40
|
+
for (const word of normalized.split(' ')) {
|
|
41
|
+
if (!word) continue
|
|
42
|
+
if (/^[\x00-\x7f]+$/.test(word)) {
|
|
43
|
+
out.push(word)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
const chars = [...word]
|
|
47
|
+
if (chars.length === 1) out.push(chars[0])
|
|
48
|
+
for (let index = 0; index + 1 < chars.length; index += 1) out.push(`${chars[index]}${chars[index + 1]}`)
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tokenise into the loose vocabulary: every single character, plus ASCII words.
|
|
55
|
+
*
|
|
56
|
+
* A CJK character carries far less meaning than a bigram, which is exactly the
|
|
57
|
+
* point — this route tolerates a query that shares only part of a word, which
|
|
58
|
+
* the bigram route cannot.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} text input
|
|
61
|
+
* @returns {string[]} tokens, duplicates preserved
|
|
62
|
+
*/
|
|
63
|
+
export function unigrams(text) {
|
|
64
|
+
const normalized = String(text ?? '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ')
|
|
65
|
+
const out = []
|
|
66
|
+
for (const word of normalized.split(' ')) {
|
|
67
|
+
if (!word) continue
|
|
68
|
+
if (/^[\x00-\x7f]+$/.test(word)) {
|
|
69
|
+
out.push(word)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
for (const char of word) out.push(char)
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {string[]} list @returns {Map<string, number>} term frequencies */
|
|
78
|
+
function termFrequency(list) {
|
|
79
|
+
const out = new Map()
|
|
80
|
+
for (const token of list) out.set(token, (out.get(token) ?? 0) + 1)
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Inverse document frequency, always positive.
|
|
86
|
+
*
|
|
87
|
+
* The textbook form `ln((N - df + 0.5) / (df + 0.5))` goes negative once a term
|
|
88
|
+
* appears in more than half the documents, which would make a common word
|
|
89
|
+
* *subtract* from relevance. Chapter 3 notes the same problem and recommends a
|
|
90
|
+
* floor; wrapping the ratio in `ln(1 + ...)` is the floor, and it is what
|
|
91
|
+
* production search engines actually ship.
|
|
92
|
+
*
|
|
93
|
+
* @param {number} total document count
|
|
94
|
+
* @param {number} seen document frequency
|
|
95
|
+
* @returns {number} idf, > 0
|
|
96
|
+
*/
|
|
97
|
+
function idf(total, seen) {
|
|
98
|
+
return Math.log(1 + (total - seen + 0.5) / (seen + 0.5))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* BM25 relevance of every document to one query.
|
|
103
|
+
*
|
|
104
|
+
* BM25 is the correction the book describes for TF-IDF's two weaknesses: raw
|
|
105
|
+
* term frequency grows without bound, and long documents win merely by being
|
|
106
|
+
* long. `k1` makes repeated occurrences saturate; `b` normalises by length.
|
|
107
|
+
*
|
|
108
|
+
* @param {string[]} queryTokens query, with multiplicity
|
|
109
|
+
* @param {string[][]} documentTokens one token list per document
|
|
110
|
+
* @param {object} [options] tuning
|
|
111
|
+
* @param {number} [options.k1] term-frequency saturation, default 1.5
|
|
112
|
+
* @param {number} [options.b] length normalisation strength, default 0.75
|
|
113
|
+
* @returns {number[]} one score per document, in input order
|
|
114
|
+
*/
|
|
115
|
+
export function bm25(queryTokens, documentTokens, options = {}) {
|
|
116
|
+
const k1 = options.k1 ?? 1.5
|
|
117
|
+
const b = options.b ?? 0.75
|
|
118
|
+
const total = documentTokens.length
|
|
119
|
+
if (total === 0) return []
|
|
120
|
+
|
|
121
|
+
const frequencies = documentTokens.map(termFrequency)
|
|
122
|
+
const lengths = documentTokens.map(list => list.length)
|
|
123
|
+
const averageLength = lengths.reduce((sum, value) => sum + value, 0) / total || 1
|
|
124
|
+
|
|
125
|
+
const seen = new Map()
|
|
126
|
+
for (const frequency of frequencies) {
|
|
127
|
+
for (const token of frequency.keys()) seen.set(token, (seen.get(token) ?? 0) + 1)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const queryFrequency = termFrequency(queryTokens)
|
|
131
|
+
const scores = new Array(total).fill(0)
|
|
132
|
+
|
|
133
|
+
for (let index = 0; index < total; index += 1) {
|
|
134
|
+
const length = lengths[index]
|
|
135
|
+
let score = 0
|
|
136
|
+
for (const [token, queryCount] of queryFrequency) {
|
|
137
|
+
const frequency = frequencies[index].get(token) ?? 0
|
|
138
|
+
if (frequency === 0) continue
|
|
139
|
+
const weight = idf(total, seen.get(token) ?? 0)
|
|
140
|
+
const saturation = (frequency * (k1 + 1)) / (frequency + k1 * (1 - b + (b * length) / averageLength))
|
|
141
|
+
score += weight * saturation * Math.min(queryCount, 3)
|
|
142
|
+
}
|
|
143
|
+
scores[index] = score
|
|
144
|
+
}
|
|
145
|
+
return scores
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* IDF-weighted fraction of the query that a document accounts for.
|
|
150
|
+
*
|
|
151
|
+
* Coverage, not Jaccard: a memory may be far longer than the query, and being
|
|
152
|
+
* long must not be a penalty when the question is "did it contain what I asked".
|
|
153
|
+
* This is the loose route's scoring function; the precise route uses BM25.
|
|
154
|
+
*
|
|
155
|
+
* @param {string[]} queryTokens query, with multiplicity
|
|
156
|
+
* @param {string[]} documentTokens document, with multiplicity
|
|
157
|
+
* @param {string[][]} allDocumentTokens corpus, for document frequency
|
|
158
|
+
* @returns {number} coverage 0..1
|
|
159
|
+
*/
|
|
160
|
+
export function weightedCoverage(queryTokens, documentTokens, allDocumentTokens) {
|
|
161
|
+
const querySet = new Set(queryTokens)
|
|
162
|
+
if (querySet.size === 0) return 0
|
|
163
|
+
const documentSet = new Set(documentTokens)
|
|
164
|
+
const total = allDocumentTokens.length || 1
|
|
165
|
+
const seen = new Map()
|
|
166
|
+
for (const list of allDocumentTokens) {
|
|
167
|
+
for (const token of new Set(list)) seen.set(token, (seen.get(token) ?? 0) + 1)
|
|
168
|
+
}
|
|
169
|
+
let matched = 0
|
|
170
|
+
let available = 0
|
|
171
|
+
for (const token of querySet) {
|
|
172
|
+
const weight = idf(total, seen.get(token) ?? 0)
|
|
173
|
+
available += weight
|
|
174
|
+
if (documentSet.has(token)) matched += weight
|
|
175
|
+
}
|
|
176
|
+
return available > 0 ? matched / available : 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reciprocal Rank Fusion.
|
|
181
|
+
*
|
|
182
|
+
* Fusing by rank rather than by score is the whole point: BM25 returns values
|
|
183
|
+
* spanning tens, coverage returns 0..1, and adding them would let whichever
|
|
184
|
+
* route has the larger scale decide the outcome. RRF ignores the magnitudes and
|
|
185
|
+
* keeps only the ordering, which is the part both routes agree is meaningful.
|
|
186
|
+
*
|
|
187
|
+
* @param {Array<Array<string | number>>} rankings each route's ids, best first
|
|
188
|
+
* @param {number} [k] smoothing constant; 60 is the customary default
|
|
189
|
+
* @returns {Map<string | number, number>} fused score, normalised to 0..1
|
|
190
|
+
*/
|
|
191
|
+
export function reciprocalRankFusion(rankings, k = 60) {
|
|
192
|
+
const fused = new Map()
|
|
193
|
+
const routes = rankings.filter(ranking => ranking.length > 0)
|
|
194
|
+
for (const ranking of routes) {
|
|
195
|
+
ranking.forEach((id, index) => {
|
|
196
|
+
fused.set(id, (fused.get(id) ?? 0) + 1 / (k + index + 1))
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
// Normalise so a document ranked first by every route scores 1, keeping the
|
|
200
|
+
// fused signal comparable to the 0..1 importance score it is combined with.
|
|
201
|
+
if (routes.length > 0) {
|
|
202
|
+
const ceiling = routes.length / (k + 1)
|
|
203
|
+
for (const [id, score] of fused) fused.set(id, score / ceiling)
|
|
204
|
+
}
|
|
205
|
+
return fused
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Order ids by a score map, best first, with a stable tiebreak.
|
|
210
|
+
*
|
|
211
|
+
* @param {Array<string | number>} ids candidates
|
|
212
|
+
* @param {Map<string | number, number>} scores score per id
|
|
213
|
+
* @returns {Array<string | number>} sorted ids
|
|
214
|
+
*/
|
|
215
|
+
export function rankIds(ids, scores) {
|
|
216
|
+
return [...ids].sort((a, b) => (scores.get(b) ?? 0) - (scores.get(a) ?? 0) || String(a).localeCompare(String(b)))
|
|
217
|
+
}
|