dsh-memu-memory 1.1.2 → 1.2.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.
Files changed (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -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
+ }