dsh-memu-memory 1.1.2 → 1.1.3
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/AGENTS.md +8 -6
- package/README.md +21 -5
- package/README.zh.md +21 -5
- package/docs/contributing.md +5 -3
- package/docs/env-and-commands.md +13 -0
- package/docs/host-api.md +35 -3
- package/docs/memory-semantics.md +32 -5
- package/docs/troubleshooting.md +36 -7
- package/lib/activity-log.js +190 -0
- package/lib/backup.js +1103 -0
- package/lib/binding-diagnostics.js +630 -0
- package/lib/client.js +697 -49
- package/lib/curator.js +81 -37
- package/lib/cycle.js +3 -0
- package/lib/embedding-catalog.js +763 -0
- package/lib/embedding-download.js +407 -0
- package/lib/embedding-runtime.js +710 -0
- package/lib/engine.js +2 -2
- package/lib/index.js +36 -0
- package/lib/inject.js +131 -30
- package/lib/local-embedding-entry.js +145 -0
- package/lib/local-embedding-manager.js +249 -0
- package/lib/local-embedding-protocol.js +173 -0
- package/lib/local-embedding-worker.js +652 -0
- package/lib/local-vector-service.js +283 -0
- package/lib/memory.js +385 -45
- package/lib/memu-config.js +10 -22
- package/lib/memu.js +53 -19
- package/lib/paths.js +24 -0
- package/lib/private.js +139 -13
- package/lib/rpc.js +45 -4
- package/lib/state.js +109 -161
- package/lib/tools.js +9 -5
- package/lib/vector-migration.js +878 -0
- package/lib/vector-profile.js +941 -0
- package/package.json +3 -2
- package/scripts/acceptance-local.mjs +5 -4
- package/scripts/chain-matrix.mjs +527 -0
- package/scripts/check-all.mjs +19 -0
- package/scripts/check-doc-drift.mjs +353 -0
- package/scripts/diagnose-bindings.mjs +176 -0
- package/scripts/local-runtime-install.mjs +142 -0
- package/scripts/local-runtime-verify.mjs +56 -0
- package/scripts/local-vector-verify.mjs +93 -0
- package/scripts/recall-bench.mjs +813 -0
- package/scripts/repetition-ab-scenarios.mjs +93 -0
- package/scripts/repetition-detector-snapshot.mjs +314 -0
- package/scripts/repetition-eval-live.mjs +360 -0
- package/scripts/repetition-eval.mjs +702 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Synthetic assistant-repetition evaluation harness (W8 first step).
|
|
4
|
+
*
|
|
5
|
+
* Reads handwritten synthetic dialogue fixtures and computes rule-checkable
|
|
6
|
+
* baseline metrics. It never calls a model, uses the network, or reads a real
|
|
7
|
+
* user conversation. The output is evidence about the fixtures and metric
|
|
8
|
+
* definitions, not about production model behaviour.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
16
|
+
export const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'repetition-eval')
|
|
17
|
+
export const CATEGORIES = Object.freeze(['zh-rp', 'en', 'code', 'names', 'user-quote', 'agreement'])
|
|
18
|
+
export const CONDITIONS = Object.freeze(['baseline', 'candidate'])
|
|
19
|
+
export const NECESSARY_CATEGORIES = Object.freeze(['name', 'agreement', 'user-quote', 'fact', 'code-identifier'])
|
|
20
|
+
export const CONFIG = Object.freeze({
|
|
21
|
+
schemaVersion: 1,
|
|
22
|
+
window: 4,
|
|
23
|
+
cjkNgram: 4,
|
|
24
|
+
latinNgram: 3,
|
|
25
|
+
maxWindow: 10,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const BACKTICK = String.fromCharCode(96)
|
|
29
|
+
const CODE_FENCE = new RegExp(BACKTICK + BACKTICK + BACKTICK + '[\\s\\S]*?' + BACKTICK + BACKTICK + BACKTICK, 'g')
|
|
30
|
+
const INLINE_CODE = new RegExp(BACKTICK + '[^' + BACKTICK + '\\r\\n]*' + BACKTICK, 'g')
|
|
31
|
+
const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/gu
|
|
32
|
+
const LATIN_WORD_RE = /[A-Za-z][A-Za-z0-9_'\u2019-]*/g
|
|
33
|
+
const STOPWORDS = new Set('a an and are as at be but by for from had has have he her his i if in is it its me my not of on or our she so that the their them there they this to was we were what when where which who why will with you your'.split(' '))
|
|
34
|
+
|
|
35
|
+
function fixtureError(message) {
|
|
36
|
+
throw new Error('repetition-eval fixture: ' + message)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isPlainObject(value) {
|
|
40
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertString(value, field, where, options = {}) {
|
|
44
|
+
const nonEmpty = options.nonEmpty !== false
|
|
45
|
+
if (typeof value !== 'string' || (nonEmpty && !value.trim())) {
|
|
46
|
+
fixtureError(where + ': ' + field + ' must be a non-empty string')
|
|
47
|
+
}
|
|
48
|
+
return value
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertStringArray(value, field, where, options = {}) {
|
|
52
|
+
const allowEmpty = options.allowEmpty !== false
|
|
53
|
+
if (!Array.isArray(value)) fixtureError(where + ': ' + field + ' must be an array')
|
|
54
|
+
if (!allowEmpty && value.length === 0) fixtureError(where + ': ' + field + ' must not be empty')
|
|
55
|
+
if (value.some(item => typeof item !== 'string' || !item.trim())) fixtureError(where + ': ' + field + ' must contain non-empty strings')
|
|
56
|
+
if (new Set(value).size !== value.length) fixtureError(where + ': ' + field + ' contains duplicates')
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assertAssistantTurn(turn, field, where, turns) {
|
|
61
|
+
if (!Number.isInteger(turn) || turn < 0 || turn >= turns.length) fixtureError(where + ': ' + field + ' must be an index into turns')
|
|
62
|
+
if (turns[turn].role !== 'assistant') fixtureError(where + ': ' + field + ' must point at an assistant turn')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Validate one synthetic dialogue. Strict so a typo fails rather than changing a denominator. */
|
|
66
|
+
export function validateDialogueShape(dialogue, where = 'dialogue') {
|
|
67
|
+
if (!isPlainObject(dialogue)) fixtureError(where + ': dialogue must be an object')
|
|
68
|
+
if (dialogue.schemaVersion !== CONFIG.schemaVersion) fixtureError(where + ': schemaVersion must be ' + CONFIG.schemaVersion)
|
|
69
|
+
if (dialogue.kind !== 'repetition-eval-dialogue') fixtureError(where + ': kind must be repetition-eval-dialogue')
|
|
70
|
+
assertString(dialogue.id, 'id', where)
|
|
71
|
+
if (!/^[a-z0-9][a-z0-9-]{2,80}$/.test(dialogue.id)) fixtureError(where + ': invalid id ' + dialogue.id)
|
|
72
|
+
assertString(dialogue.pairId, 'pairId', where)
|
|
73
|
+
if (!CATEGORIES.includes(dialogue.category)) fixtureError(where + ': unknown category ' + dialogue.category)
|
|
74
|
+
if (!CONDITIONS.includes(dialogue.condition)) fixtureError(where + ': unknown condition ' + dialogue.condition)
|
|
75
|
+
if (dialogue.draft !== true) fixtureError(where + ': draft must be true')
|
|
76
|
+
if (dialogue.notGold !== true) fixtureError(where + ': notGold must be true (synthetic samples are not a gold set)')
|
|
77
|
+
if (dialogue.window !== undefined && (!Number.isInteger(dialogue.window) || dialogue.window < 1 || dialogue.window > CONFIG.maxWindow)) {
|
|
78
|
+
fixtureError(where + ': window must be an integer 1..' + CONFIG.maxWindow)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!Array.isArray(dialogue.turns) || dialogue.turns.length < 2) fixtureError(where + ': turns must contain at least two entries')
|
|
82
|
+
let users = 0
|
|
83
|
+
let assistants = 0
|
|
84
|
+
for (let index = 0; index < dialogue.turns.length; index += 1) {
|
|
85
|
+
const turn = dialogue.turns[index]
|
|
86
|
+
const turnWhere = where + ' turns[' + index + ']'
|
|
87
|
+
if (!isPlainObject(turn)) fixtureError(turnWhere + ': turn must be an object')
|
|
88
|
+
if (turn.role !== 'user' && turn.role !== 'assistant') fixtureError(turnWhere + ': role must be user or assistant')
|
|
89
|
+
assertString(turn.text, 'text', turnWhere)
|
|
90
|
+
if (turn.role === 'user') users += 1
|
|
91
|
+
else assistants += 1
|
|
92
|
+
}
|
|
93
|
+
if (users === 0 || assistants === 0) fixtureError(where + ': dialogue needs at least one user and one assistant turn')
|
|
94
|
+
|
|
95
|
+
if (!isPlainObject(dialogue.labels)) fixtureError(where + ': labels must be an object')
|
|
96
|
+
if (!Array.isArray(dialogue.labels.necessaryPhrases)) fixtureError(where + ': labels.necessaryPhrases must be an array')
|
|
97
|
+
if (!Array.isArray(dialogue.labels.facts)) fixtureError(where + ': labels.facts must be an array')
|
|
98
|
+
|
|
99
|
+
const phraseKeys = new Set()
|
|
100
|
+
for (let index = 0; index < dialogue.labels.necessaryPhrases.length; index += 1) {
|
|
101
|
+
const label = dialogue.labels.necessaryPhrases[index]
|
|
102
|
+
const labelWhere = where + ' labels.necessaryPhrases[' + index + ']'
|
|
103
|
+
if (!isPlainObject(label)) fixtureError(labelWhere + ': label must be an object')
|
|
104
|
+
assertString(label.text, 'text', labelWhere)
|
|
105
|
+
if (!NECESSARY_CATEGORIES.includes(label.category)) fixtureError(labelWhere + ': unknown necessary category ' + label.category)
|
|
106
|
+
assertAssistantTurn(label.turn, 'turn', labelWhere, dialogue.turns)
|
|
107
|
+
const key = label.turn + '|' + label.text
|
|
108
|
+
if (phraseKeys.has(key)) fixtureError(labelWhere + ': duplicate necessary phrase for the same turn')
|
|
109
|
+
phraseKeys.add(key)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const factIds = new Set()
|
|
113
|
+
for (let index = 0; index < dialogue.labels.facts.length; index += 1) {
|
|
114
|
+
const fact = dialogue.labels.facts[index]
|
|
115
|
+
const factWhere = where + ' labels.facts[' + index + ']'
|
|
116
|
+
if (!isPlainObject(fact)) fixtureError(factWhere + ': fact must be an object')
|
|
117
|
+
assertString(fact.id, 'id', factWhere)
|
|
118
|
+
if (!/^[a-z0-9][a-z0-9-]{1,80}$/.test(fact.id)) fixtureError(factWhere + ': invalid fact id ' + fact.id)
|
|
119
|
+
if (factIds.has(fact.id)) fixtureError(factWhere + ': duplicate fact id ' + fact.id)
|
|
120
|
+
factIds.add(fact.id)
|
|
121
|
+
assertAssistantTurn(fact.turn, 'turn', factWhere, dialogue.turns)
|
|
122
|
+
assertStringArray(fact.requiredTerms, 'requiredTerms', factWhere, { allowEmpty: false })
|
|
123
|
+
}
|
|
124
|
+
return dialogue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sha256(buffer) {
|
|
128
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Load every .json fixture in sorted filename order and hash the exact input set. */
|
|
132
|
+
export function loadDataset(fixtureDir = FIXTURE_DIR) {
|
|
133
|
+
const dir = path.resolve(fixtureDir)
|
|
134
|
+
let names
|
|
135
|
+
try {
|
|
136
|
+
names = fs.readdirSync(dir).filter(name => name.endsWith('.json')).sort()
|
|
137
|
+
} catch (error) {
|
|
138
|
+
fixtureError('cannot read fixture directory ' + dir + ': ' + (error && error.message ? error.message : error))
|
|
139
|
+
}
|
|
140
|
+
if (names.length === 0) fixtureError('no .json fixtures in ' + dir)
|
|
141
|
+
|
|
142
|
+
const files = []
|
|
143
|
+
const dialogues = []
|
|
144
|
+
const ids = new Set()
|
|
145
|
+
const pairConditions = new Set()
|
|
146
|
+
for (const fileName of names) {
|
|
147
|
+
const filePath = path.join(dir, fileName)
|
|
148
|
+
let raw
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
151
|
+
} catch (error) {
|
|
152
|
+
fixtureError(fileName + ': invalid JSON: ' + (error && error.message ? error.message : error))
|
|
153
|
+
}
|
|
154
|
+
validateDialogueShape(raw, fileName)
|
|
155
|
+
if (ids.has(raw.id)) fixtureError('duplicate dialogue id ' + raw.id)
|
|
156
|
+
ids.add(raw.id)
|
|
157
|
+
const pairKey = raw.pairId + '|' + raw.condition
|
|
158
|
+
if (pairConditions.has(pairKey)) fixtureError('duplicate pairId+condition ' + pairKey)
|
|
159
|
+
pairConditions.add(pairKey)
|
|
160
|
+
dialogues.push(raw)
|
|
161
|
+
files.push({ name: fileName, sha256: sha256(fs.readFileSync(filePath)) })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (dialogues.length < 6) fixtureError('expected at least 6 synthetic dialogues, found ' + dialogues.length)
|
|
165
|
+
for (const category of CATEGORIES) {
|
|
166
|
+
if (!dialogues.some(dialogue => dialogue.category === category)) fixtureError('missing category ' + category)
|
|
167
|
+
}
|
|
168
|
+
if (!dialogues.some(dialogue => dialogue.condition === 'baseline')) fixtureError('at least one baseline dialogue is required')
|
|
169
|
+
|
|
170
|
+
const digest = crypto.createHash('sha256')
|
|
171
|
+
for (const file of files) {
|
|
172
|
+
digest.update(file.name)
|
|
173
|
+
digest.update('\0')
|
|
174
|
+
digest.update(file.sha256)
|
|
175
|
+
digest.update('\0')
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
name: 'repetition-eval-synthetic',
|
|
179
|
+
status: 'synthetic',
|
|
180
|
+
draft: true,
|
|
181
|
+
notGold: true,
|
|
182
|
+
dir,
|
|
183
|
+
files,
|
|
184
|
+
sha256: digest.digest('hex'),
|
|
185
|
+
dialogues,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function round(value, digits = 6) {
|
|
190
|
+
const factor = 10 ** digits
|
|
191
|
+
const rounded = Math.round((Number(value) + Number.EPSILON) * factor) / factor
|
|
192
|
+
return Object.is(rounded, -0) ? 0 : rounded
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ratio(numerator, denominator) {
|
|
196
|
+
return denominator === 0 ? 0 : round(numerator / denominator)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function escapeRegExp(text) {
|
|
200
|
+
return text.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/[{}]/g, '\\$&')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function maskSpan(match) {
|
|
204
|
+
return ' '.repeat(match.length)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Remove code spans and protected phrases before lexical repetition matching.
|
|
209
|
+
* Protected phrases are necessary phrases plus fact terms: repeating a name,
|
|
210
|
+
* a user quote, an agreed convention or a fact is not a repetition defect.
|
|
211
|
+
*/
|
|
212
|
+
export function maskForRepetition(text, protectedPhrases = []) {
|
|
213
|
+
let masked = String(text || '')
|
|
214
|
+
masked = masked.replace(CODE_FENCE, maskSpan)
|
|
215
|
+
masked = masked.replace(INLINE_CODE, maskSpan)
|
|
216
|
+
const phrases = [...new Set(protectedPhrases.filter(phrase => typeof phrase === 'string' && phrase.trim()))]
|
|
217
|
+
.sort((a, b) => b.length - a.length)
|
|
218
|
+
for (const phrase of phrases) {
|
|
219
|
+
masked = masked.replace(new RegExp(escapeRegExp(phrase), 'gi'), maskSpan)
|
|
220
|
+
}
|
|
221
|
+
return masked
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cjkRuns(text) {
|
|
225
|
+
const runs = []
|
|
226
|
+
for (const match of text.matchAll(CJK_RE)) runs.push({ text: match[0], index: match.index })
|
|
227
|
+
return runs
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isCodeLikeToken(raw) {
|
|
231
|
+
return /[a-z][A-Z]/.test(raw) || raw.includes('_') || /\d/.test(raw)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function latinTokens(text) {
|
|
235
|
+
const tokens = []
|
|
236
|
+
for (const match of String(text || '').matchAll(LATIN_WORD_RE)) {
|
|
237
|
+
const raw = match[0]
|
|
238
|
+
if (isCodeLikeToken(raw)) continue
|
|
239
|
+
tokens.push({ raw, norm: raw.toLowerCase(), index: match.index })
|
|
240
|
+
}
|
|
241
|
+
return tokens
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function allStopwords(tokens) {
|
|
245
|
+
return tokens.every(token => STOPWORDS.has(token.norm))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function paddedLatinTokens(text) {
|
|
249
|
+
return ' ' + latinTokens(text).map(token => token.norm).join(' ') + ' '
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function priorContainsPhrase(priorText, phrase, kind) {
|
|
253
|
+
if (kind === 'cjk') return priorText.includes(phrase)
|
|
254
|
+
return paddedLatinTokens(priorText).includes(' ' + latinTokens(phrase).map(token => token.norm).join(' ') + ' ')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Find maximal non-overlapping repeated spans in the current masked text.
|
|
259
|
+
* CJK spans are character n-grams (minimum 4); Latin spans are word n-grams
|
|
260
|
+
* (minimum 3). Stopword-only Latin spans and code-like tokens are ignored.
|
|
261
|
+
*/
|
|
262
|
+
export function findRepeatedPhrases(text, priorTexts, options = {}) {
|
|
263
|
+
const cjkNgram = Number(options.cjkNgram || CONFIG.cjkNgram)
|
|
264
|
+
const latinNgram = Number(options.latinNgram || CONFIG.latinNgram)
|
|
265
|
+
const prior = priorTexts.map(value => String(value || ''))
|
|
266
|
+
const events = []
|
|
267
|
+
|
|
268
|
+
for (const run of cjkRuns(String(text || ''))) {
|
|
269
|
+
let index = 0
|
|
270
|
+
while (index + cjkNgram <= run.text.length) {
|
|
271
|
+
const seed = run.text.slice(index, index + cjkNgram)
|
|
272
|
+
if (!prior.some(value => value.includes(seed))) {
|
|
273
|
+
index += 1
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
let end = index + cjkNgram
|
|
277
|
+
while (end < run.text.length && prior.some(value => value.includes(run.text.slice(index, end + 1)))) {
|
|
278
|
+
end += 1
|
|
279
|
+
}
|
|
280
|
+
events.push({ phrase: run.text.slice(index, end), kind: 'cjk' })
|
|
281
|
+
index = end
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const tokens = latinTokens(text)
|
|
286
|
+
const priorPadded = prior.map(paddedLatinTokens)
|
|
287
|
+
let index = 0
|
|
288
|
+
while (index + latinNgram <= tokens.length) {
|
|
289
|
+
const seedTokens = tokens.slice(index, index + latinNgram)
|
|
290
|
+
if (allStopwords(seedTokens)) {
|
|
291
|
+
index += 1
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
const seed = seedTokens.map(token => token.norm).join(' ')
|
|
295
|
+
if (!priorPadded.some(value => value.includes(' ' + seed + ' '))) {
|
|
296
|
+
index += 1
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
let end = index + latinNgram
|
|
300
|
+
while (end < tokens.length) {
|
|
301
|
+
const candidate = tokens.slice(index, end + 1).map(token => token.norm).join(' ')
|
|
302
|
+
if (!priorPadded.some(value => value.includes(' ' + candidate + ' '))) break
|
|
303
|
+
end += 1
|
|
304
|
+
}
|
|
305
|
+
const trimmed = trimLatinArticleEdges(tokens.slice(index, end), latinNgram)
|
|
306
|
+
if (trimmed) events.push({ phrase: trimmed.map(token => token.raw).join(' '), kind: 'latin' })
|
|
307
|
+
index = end
|
|
308
|
+
}
|
|
309
|
+
return events
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const LATIN_EDGE_ARTICLES = new Set(['a', 'an', 'the'])
|
|
313
|
+
|
|
314
|
+
function trimLatinArticleEdges(tokens, minimum) {
|
|
315
|
+
let start = 0
|
|
316
|
+
let end = tokens.length
|
|
317
|
+
while (start < end && LATIN_EDGE_ARTICLES.has(tokens[start].norm)) start += 1
|
|
318
|
+
while (end > start && LATIN_EDGE_ARTICLES.has(tokens[end - 1].norm)) end -= 1
|
|
319
|
+
if (end - start < minimum) return tokens
|
|
320
|
+
return tokens.slice(start, end)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function findPriorTurn(maskedTexts, priorIndices, phrase, kind) {
|
|
324
|
+
for (let index = priorIndices.length - 1; index >= 0; index -= 1) {
|
|
325
|
+
const turn = priorIndices[index]
|
|
326
|
+
if (priorContainsPhrase(maskedTexts[turn], phrase, kind)) return turn
|
|
327
|
+
}
|
|
328
|
+
return null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeProse(text) {
|
|
332
|
+
return String(text || '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLowerCase()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function normalizeCode(text) {
|
|
336
|
+
return String(text || '').normalize('NFKC').replace(/\s+/g, ' ').trim()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function labelPresent(turnText, phrase, category) {
|
|
340
|
+
const haystack = category === 'code-identifier' ? normalizeCode(turnText) : normalizeProse(turnText)
|
|
341
|
+
const needle = category === 'code-identifier' ? normalizeCode(phrase) : normalizeProse(phrase)
|
|
342
|
+
return needle.length > 0 && haystack.includes(needle)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function termPresent(turnText, term) {
|
|
346
|
+
const haystack = normalizeProse(turnText)
|
|
347
|
+
const needle = normalizeProse(term)
|
|
348
|
+
return needle.length > 0 && haystack.includes(needle)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function effectiveWindow(dialogue, options) {
|
|
352
|
+
const requested = dialogue.window !== undefined ? dialogue.window : options.window
|
|
353
|
+
const value = requested === undefined ? CONFIG.window : Number(requested)
|
|
354
|
+
if (!Number.isInteger(value) || value < 1 || value > CONFIG.maxWindow) return CONFIG.window
|
|
355
|
+
return value
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Evaluate one generated assistant turn against earlier assistant texts.
|
|
360
|
+
* This is for live continuation pilots where the prefix is supplied by the
|
|
361
|
+
* fixture and only the final assistant turn is generated. It applies the same
|
|
362
|
+
* exact-match rules as evaluateDialogue, but does not pretend the prefix is
|
|
363
|
+
* generated output.
|
|
364
|
+
*/
|
|
365
|
+
export function evaluateAssistantTurn({ id = 'assistant-turn', category = '', condition = '', text = '', priorAssistantTexts = [], labels = {}, window = CONFIG.window } = {}) {
|
|
366
|
+
const necessaryPhrases = labels.necessaryPhrases || []
|
|
367
|
+
const facts = labels.facts || []
|
|
368
|
+
const protectedPhrases = necessaryPhrases.map(label => label.text)
|
|
369
|
+
const effectiveWindowValue = Number.isInteger(Number(window)) && Number(window) >= 1 && Number(window) <= CONFIG.maxWindow ? Number(window) : CONFIG.window
|
|
370
|
+
const maskedText = maskForRepetition(text, protectedPhrases)
|
|
371
|
+
const maskedPrior = priorAssistantTexts.slice(-effectiveWindowValue).map(item => maskForRepetition(item, protectedPhrases))
|
|
372
|
+
const events = maskedPrior.length === 0 ? [] : findRepeatedPhrases(maskedText, maskedPrior)
|
|
373
|
+
|
|
374
|
+
const necessaryByCategory = {}
|
|
375
|
+
let necessaryRetained = 0
|
|
376
|
+
for (const label of necessaryPhrases) {
|
|
377
|
+
const bucket = necessaryByCategory[label.category] || { retained: 0, total: 0 }
|
|
378
|
+
bucket.total += 1
|
|
379
|
+
if (labelPresent(text, label.text, label.category)) {
|
|
380
|
+
bucket.retained += 1
|
|
381
|
+
necessaryRetained += 1
|
|
382
|
+
}
|
|
383
|
+
necessaryByCategory[label.category] = bucket
|
|
384
|
+
}
|
|
385
|
+
for (const bucket of Object.values(necessaryByCategory)) bucket.rate = ratio(bucket.retained, bucket.total)
|
|
386
|
+
|
|
387
|
+
const factItems = facts.map(fact => {
|
|
388
|
+
const missingTerms = fact.requiredTerms.filter(term => !termPresent(text, term))
|
|
389
|
+
return { id: fact.id, turn: fact.turn, complete: missingTerms.length === 0, missingTerms }
|
|
390
|
+
})
|
|
391
|
+
const factsComplete = factItems.filter(item => item.complete).length
|
|
392
|
+
const factsTotal = factItems.length
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
id,
|
|
396
|
+
category,
|
|
397
|
+
condition,
|
|
398
|
+
window: effectiveWindowValue,
|
|
399
|
+
turns: 1,
|
|
400
|
+
assistantTurns: 1,
|
|
401
|
+
eligibleTurns: maskedPrior.length === 0 ? 0 : 1,
|
|
402
|
+
repeatedTurns: events.length === 0 ? 0 : 1,
|
|
403
|
+
phraseRepetitionRate: ratio(events.length === 0 ? 0 : 1, maskedPrior.length === 0 ? 0 : 1),
|
|
404
|
+
repeatedPhraseEvents: events.length,
|
|
405
|
+
distinctRepeatedPhrases: new Set(events.map(event => event.phrase)).size,
|
|
406
|
+
repetitionEvents: events,
|
|
407
|
+
necessaryRetentionRate: ratio(necessaryRetained, necessaryPhrases.length),
|
|
408
|
+
necessaryRetained,
|
|
409
|
+
necessaryLabels: necessaryPhrases.length,
|
|
410
|
+
necessaryByCategory,
|
|
411
|
+
factCompleteness: ratio(factsComplete, factsTotal),
|
|
412
|
+
factsComplete,
|
|
413
|
+
factsTotal,
|
|
414
|
+
facts: factItems,
|
|
415
|
+
emptySet: {
|
|
416
|
+
phraseRepetitionRate: maskedPrior.length === 0,
|
|
417
|
+
necessaryRetentionRate: necessaryPhrases.length === 0,
|
|
418
|
+
factCompleteness: factsTotal === 0,
|
|
419
|
+
},
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Evaluate one dialogue. Label turns are absolute indexes into turns[].
|
|
425
|
+
* phraseRepetitionRate denominator = assistant turns with at least one earlier
|
|
426
|
+
* assistant turn in the window; the first assistant turn is not eligible.
|
|
427
|
+
*/
|
|
428
|
+
export function evaluateDialogue(dialogue, options = {}) {
|
|
429
|
+
const turns = dialogue.turns
|
|
430
|
+
const window = effectiveWindow(dialogue, options)
|
|
431
|
+
const necessaryPhrases = dialogue.labels.necessaryPhrases
|
|
432
|
+
const facts = dialogue.labels.facts
|
|
433
|
+
// Only labeled necessary phrases are masked. Fact requiredTerms are
|
|
434
|
+
// completeness anchors, not automatically exempt phrases: masking a single
|
|
435
|
+
// keyword such as "rain" would punch holes in the repetition matcher.
|
|
436
|
+
const protectedPhrases = necessaryPhrases.map(label => label.text)
|
|
437
|
+
const masked = turns.map(turn => maskForRepetition(turn.text, protectedPhrases))
|
|
438
|
+
const assistantIndices = []
|
|
439
|
+
for (let index = 0; index < turns.length; index += 1) {
|
|
440
|
+
if (turns[index].role === 'assistant') assistantIndices.push(index)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const events = []
|
|
444
|
+
let eligibleTurns = 0
|
|
445
|
+
let repeatedTurns = 0
|
|
446
|
+
for (const index of assistantIndices) {
|
|
447
|
+
const prior = assistantIndices.filter(candidate => candidate < index).slice(-window)
|
|
448
|
+
if (prior.length === 0) continue
|
|
449
|
+
eligibleTurns += 1
|
|
450
|
+
const repeated = findRepeatedPhrases(masked[index], prior.map(candidate => masked[candidate]))
|
|
451
|
+
if (repeated.length === 0) continue
|
|
452
|
+
repeatedTurns += 1
|
|
453
|
+
for (const item of repeated) {
|
|
454
|
+
events.push({
|
|
455
|
+
turn: index,
|
|
456
|
+
priorTurn: findPriorTurn(masked, prior, item.phrase, item.kind),
|
|
457
|
+
phrase: item.phrase,
|
|
458
|
+
kind: item.kind,
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const necessaryByCategory = {}
|
|
464
|
+
let necessaryRetained = 0
|
|
465
|
+
for (const label of necessaryPhrases) {
|
|
466
|
+
const bucket = necessaryByCategory[label.category] || { retained: 0, total: 0 }
|
|
467
|
+
bucket.total += 1
|
|
468
|
+
if (labelPresent(turns[label.turn].text, label.text, label.category)) {
|
|
469
|
+
bucket.retained += 1
|
|
470
|
+
necessaryRetained += 1
|
|
471
|
+
}
|
|
472
|
+
necessaryByCategory[label.category] = bucket
|
|
473
|
+
}
|
|
474
|
+
for (const bucket of Object.values(necessaryByCategory)) bucket.rate = ratio(bucket.retained, bucket.total)
|
|
475
|
+
const necessaryTotal = necessaryPhrases.length
|
|
476
|
+
|
|
477
|
+
const factItems = facts.map(fact => {
|
|
478
|
+
const missingTerms = fact.requiredTerms.filter(term => !termPresent(turns[fact.turn].text, term))
|
|
479
|
+
return { id: fact.id, turn: fact.turn, complete: missingTerms.length === 0, missingTerms }
|
|
480
|
+
})
|
|
481
|
+
const factsComplete = factItems.filter(item => item.complete).length
|
|
482
|
+
const factsTotal = factItems.length
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
id: dialogue.id,
|
|
486
|
+
pairId: dialogue.pairId,
|
|
487
|
+
category: dialogue.category,
|
|
488
|
+
condition: dialogue.condition,
|
|
489
|
+
window,
|
|
490
|
+
turns: turns.length,
|
|
491
|
+
assistantTurns: assistantIndices.length,
|
|
492
|
+
eligibleTurns,
|
|
493
|
+
repeatedTurns,
|
|
494
|
+
phraseRepetitionRate: ratio(repeatedTurns, eligibleTurns),
|
|
495
|
+
repeatedPhraseEvents: events.length,
|
|
496
|
+
distinctRepeatedPhrases: new Set(events.map(event => event.phrase)).size,
|
|
497
|
+
repetitionEvents: events,
|
|
498
|
+
necessaryRetentionRate: ratio(necessaryRetained, necessaryTotal),
|
|
499
|
+
necessaryRetained,
|
|
500
|
+
necessaryLabels: necessaryTotal,
|
|
501
|
+
necessaryByCategory,
|
|
502
|
+
factCompleteness: ratio(factsComplete, factsTotal),
|
|
503
|
+
factsComplete,
|
|
504
|
+
factsTotal,
|
|
505
|
+
facts: factItems,
|
|
506
|
+
emptySet: {
|
|
507
|
+
phraseRepetitionRate: eligibleTurns === 0,
|
|
508
|
+
necessaryRetentionRate: necessaryTotal === 0,
|
|
509
|
+
factCompleteness: factsTotal === 0,
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Aggregate already-evaluated dialogues with explicit denominators. */
|
|
515
|
+
export function aggregateMetrics(evaluations) {
|
|
516
|
+
let eligibleTurns = 0
|
|
517
|
+
let repeatedTurns = 0
|
|
518
|
+
let repeatedPhraseEvents = 0
|
|
519
|
+
let necessaryLabels = 0
|
|
520
|
+
let necessaryRetained = 0
|
|
521
|
+
let factsTotal = 0
|
|
522
|
+
let factsComplete = 0
|
|
523
|
+
const distinctPhrases = new Set()
|
|
524
|
+
for (const evaluation of evaluations) {
|
|
525
|
+
eligibleTurns += evaluation.eligibleTurns
|
|
526
|
+
repeatedTurns += evaluation.repeatedTurns
|
|
527
|
+
repeatedPhraseEvents += evaluation.repeatedPhraseEvents
|
|
528
|
+
necessaryLabels += evaluation.necessaryLabels
|
|
529
|
+
necessaryRetained += evaluation.necessaryRetained
|
|
530
|
+
factsTotal += evaluation.factsTotal
|
|
531
|
+
factsComplete += evaluation.factsComplete
|
|
532
|
+
for (const event of evaluation.repetitionEvents) distinctPhrases.add(event.phrase)
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
phraseRepetitionRate: ratio(repeatedTurns, eligibleTurns),
|
|
536
|
+
repeatedTurns,
|
|
537
|
+
eligibleTurns,
|
|
538
|
+
repeatedPhraseEvents,
|
|
539
|
+
distinctRepeatedPhrases: distinctPhrases.size,
|
|
540
|
+
necessaryRetentionRate: ratio(necessaryRetained, necessaryLabels),
|
|
541
|
+
necessaryRetained,
|
|
542
|
+
necessaryLabels,
|
|
543
|
+
factCompleteness: ratio(factsComplete, factsTotal),
|
|
544
|
+
factsComplete,
|
|
545
|
+
factsTotal,
|
|
546
|
+
emptySet: {
|
|
547
|
+
phraseRepetitionRate: eligibleTurns === 0,
|
|
548
|
+
necessaryRetentionRate: necessaryLabels === 0,
|
|
549
|
+
factCompleteness: factsTotal === 0,
|
|
550
|
+
},
|
|
551
|
+
denominators: { eligibleTurns, necessaryLabels, factsTotal },
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function groupEvaluations(evaluations, key, order) {
|
|
556
|
+
const groups = new Map()
|
|
557
|
+
for (const evaluation of evaluations) {
|
|
558
|
+
const value = evaluation[key]
|
|
559
|
+
if (!groups.has(value)) groups.set(value, [])
|
|
560
|
+
groups.get(value).push(evaluation)
|
|
561
|
+
}
|
|
562
|
+
const result = {}
|
|
563
|
+
for (const value of order) {
|
|
564
|
+
if (groups.has(value)) result[value] = aggregateMetrics(groups.get(value))
|
|
565
|
+
}
|
|
566
|
+
return result
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function datasetSummary(dataset) {
|
|
570
|
+
let turnCount = 0
|
|
571
|
+
let assistantTurnCount = 0
|
|
572
|
+
for (const dialogue of dataset.dialogues) {
|
|
573
|
+
turnCount += dialogue.turns.length
|
|
574
|
+
assistantTurnCount += dialogue.turns.filter(turn => turn.role === 'assistant').length
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
dialogueCount: dataset.dialogues.length,
|
|
578
|
+
fileCount: dataset.files.length,
|
|
579
|
+
turnCount,
|
|
580
|
+
assistantTurnCount,
|
|
581
|
+
categories: CATEGORIES.filter(category => dataset.dialogues.some(dialogue => dialogue.category === category)),
|
|
582
|
+
conditions: CONDITIONS.filter(condition => dataset.dialogues.some(dialogue => dialogue.condition === condition)),
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Run the synthetic evaluation. No model, network, clock or random source is used. */
|
|
587
|
+
export function runEvaluation(options = {}) {
|
|
588
|
+
const fixtureDir = path.resolve(options.fixtureDir || FIXTURE_DIR)
|
|
589
|
+
const dataset = loadDataset(fixtureDir)
|
|
590
|
+
const evaluations = dataset.dialogues.map(dialogue => evaluateDialogue(dialogue, options))
|
|
591
|
+
const overall = aggregateMetrics(evaluations)
|
|
592
|
+
const summary = datasetSummary(dataset)
|
|
593
|
+
const status = summary.conditions.includes('candidate')
|
|
594
|
+
? 'synthetic-comparison-not-validated'
|
|
595
|
+
: 'synthetic-baseline-not-validated'
|
|
596
|
+
return {
|
|
597
|
+
schemaVersion: CONFIG.schemaVersion,
|
|
598
|
+
kind: 'repetition-eval',
|
|
599
|
+
ok: true,
|
|
600
|
+
status,
|
|
601
|
+
draft: true,
|
|
602
|
+
notGold: true,
|
|
603
|
+
dataset: {
|
|
604
|
+
name: dataset.name,
|
|
605
|
+
sha256: dataset.sha256,
|
|
606
|
+
files: dataset.files,
|
|
607
|
+
...summary,
|
|
608
|
+
},
|
|
609
|
+
config: {
|
|
610
|
+
window: CONFIG.window,
|
|
611
|
+
maxWindow: CONFIG.maxWindow,
|
|
612
|
+
cjkNgram: CONFIG.cjkNgram,
|
|
613
|
+
latinNgram: CONFIG.latinNgram,
|
|
614
|
+
phraseRule: 'maximal repeated CJK span >= 4 chars or Latin span >= 3 words, within the previous N assistant turns',
|
|
615
|
+
matching: 'NFKC; Latin lowercase; code spans and necessary phrases masked; fact requiredTerms are completeness anchors only; Latin stopword-only spans ignored',
|
|
616
|
+
codeProtection: 'fenced and inline code masked; code-like Latin tokens excluded from prose n-grams',
|
|
617
|
+
},
|
|
618
|
+
metrics: {
|
|
619
|
+
...overall,
|
|
620
|
+
byCategory: groupEvaluations(evaluations, 'category', CATEGORIES),
|
|
621
|
+
byCondition: groupEvaluations(evaluations, 'condition', CONDITIONS),
|
|
622
|
+
humanPreference: {
|
|
623
|
+
status: 'not-collected',
|
|
624
|
+
preferenceRate: null,
|
|
625
|
+
denominator: 0,
|
|
626
|
+
emptySet: true,
|
|
627
|
+
note: 'Blind A/B human ratings are a separate step; this script does not synthesize them.',
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
dialogues: evaluations,
|
|
631
|
+
caveats: [
|
|
632
|
+
'Synthetic handwritten fixtures only; no real model output and no user chat was read.',
|
|
633
|
+
'Baseline metrics describe these fixtures, not production model behaviour.',
|
|
634
|
+
'No A/B or human preference data exists yet; do not claim that repetition was reduced.',
|
|
635
|
+
'Lexical n-gram matching does not detect paraphrase or semantic repetition.',
|
|
636
|
+
],
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
class UsageError extends Error {}
|
|
641
|
+
|
|
642
|
+
function parseArgs(argv) {
|
|
643
|
+
const options = {}
|
|
644
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
645
|
+
const arg = argv[index]
|
|
646
|
+
if (arg === '--fixture-dir') {
|
|
647
|
+
const value = argv[++index]
|
|
648
|
+
if (!value) throw new UsageError('--fixture-dir requires a directory')
|
|
649
|
+
options.fixtureDir = value
|
|
650
|
+
} else if (arg === '--out') {
|
|
651
|
+
const value = argv[++index]
|
|
652
|
+
if (!value) throw new UsageError('--out requires a file path')
|
|
653
|
+
options.out = value
|
|
654
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
655
|
+
options.help = true
|
|
656
|
+
} else {
|
|
657
|
+
throw new UsageError('unknown argument ' + arg)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return options
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function usage() {
|
|
664
|
+
return [
|
|
665
|
+
'Usage: node scripts/repetition-eval.mjs [--fixture-dir DIR] [--out FILE] [--help]',
|
|
666
|
+
'',
|
|
667
|
+
'Reads synthetic repetition-eval fixtures and prints machine-readable JSON.',
|
|
668
|
+
'No real model, network, user chat or production state is used.',
|
|
669
|
+
'',
|
|
670
|
+
'Exit codes: 0 success/help, 1 fixture or evaluation error, 2 usage error.',
|
|
671
|
+
].join('\n')
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function main() {
|
|
675
|
+
const options = parseArgs(process.argv.slice(2))
|
|
676
|
+
if (options.help) {
|
|
677
|
+
console.log(usage())
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
const report = runEvaluation(options)
|
|
681
|
+
const json = JSON.stringify(report, null, 2)
|
|
682
|
+
if (options.out) {
|
|
683
|
+
const out = path.resolve(options.out)
|
|
684
|
+
fs.mkdirSync(path.dirname(out), { recursive: true })
|
|
685
|
+
fs.writeFileSync(out, json.replace(/\r?\n/g, '\r\n'), 'utf8')
|
|
686
|
+
console.log('repetition-eval: wrote ' + out)
|
|
687
|
+
} else {
|
|
688
|
+
console.log(json)
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const invoked = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url : false
|
|
693
|
+
if (invoked) {
|
|
694
|
+
main().catch(error => {
|
|
695
|
+
console.error(JSON.stringify({
|
|
696
|
+
ok: false,
|
|
697
|
+
kind: 'repetition-eval',
|
|
698
|
+
error: error && error.message ? error.message : String(error),
|
|
699
|
+
}, null, 2))
|
|
700
|
+
process.exitCode = error instanceof UsageError ? 2 : 1
|
|
701
|
+
})
|
|
702
|
+
}
|