@ijfw/memory-server 1.5.6 → 1.6.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 (72) hide show
  1. package/bin/ijfw-dashboard +20 -1
  2. package/package.json +4 -3
  3. package/src/audit-roster.js +89 -12
  4. package/src/brain/tiered-llm.js +57 -7
  5. package/src/cross-orchestrator-cli.js +344 -4
  6. package/src/cross-project-search.js +39 -1
  7. package/src/dashboard-server.js +7 -1
  8. package/src/dream/runner.mjs +560 -8
  9. package/src/handlers/brain-handler.js +101 -1
  10. package/src/importers/discover.js +1 -1
  11. package/src/memory/bench-metrics.js +289 -0
  12. package/src/memory/benchmark.js +1 -1
  13. package/src/memory/search.js +53 -1
  14. package/src/orchestrator/plan-checker.js +1 -1
  15. package/src/profile/audit.js +671 -0
  16. package/src/profile/capture.js +871 -0
  17. package/src/profile/derive-dialectic.js +242 -0
  18. package/src/profile/derive-heuristic.js +733 -0
  19. package/src/profile/derive.js +156 -0
  20. package/src/profile/egress.js +306 -0
  21. package/src/profile/eval/build-real-probes.mjs +197 -0
  22. package/src/profile/eval/corpus-from-reddit.mjs +166 -0
  23. package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
  24. package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
  25. package/src/profile/eval/gate-b-behavior.mjs +420 -0
  26. package/src/profile/eval/gate-b-decision-run.mjs +171 -0
  27. package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
  28. package/src/profile/eval/gate-b-run.mjs +417 -0
  29. package/src/profile/eval/gate-b-run.test.mjs +204 -0
  30. package/src/profile/eval/gate-c-capture.mjs +323 -0
  31. package/src/profile/eval/harness.mjs +551 -0
  32. package/src/profile/eval/instrument-validation.mjs +248 -0
  33. package/src/profile/eval/instrument-validation.test.mjs +125 -0
  34. package/src/profile/eval/multi-subject-harness.mjs +106 -0
  35. package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
  36. package/src/profile/eval/personas.test.mjs +83 -0
  37. package/src/profile/eval/plumbing.test.mjs +69 -0
  38. package/src/profile/eval/prereg.mjs +130 -0
  39. package/src/profile/eval/prereg.test.mjs +78 -0
  40. package/src/profile/eval/real-corpus.test.mjs +103 -0
  41. package/src/profile/eval/real-personas.mjs +109 -0
  42. package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
  43. package/src/profile/eval/run-real-corpus.mjs +358 -0
  44. package/src/profile/eval/slug-quality.mjs +464 -0
  45. package/src/profile/eval/stylometry-features.js +85 -0
  46. package/src/profile/eval/stylometry-reference.js +16 -0
  47. package/src/profile/eval/stylometry.js +224 -0
  48. package/src/profile/eval/stylometry.test.mjs +103 -0
  49. package/src/profile/eval/synthetic-personas.js +91 -0
  50. package/src/profile/eval/verifier-features.mjs +170 -0
  51. package/src/profile/eval/verifier-logreg.mjs +74 -0
  52. package/src/profile/eval/verifier-pair.mjs +122 -0
  53. package/src/profile/eval/verifier-reference.mjs +68 -0
  54. package/src/profile/eval/verifier-scorer.mjs +30 -0
  55. package/src/profile/eval/wrong-target-control.mjs +168 -0
  56. package/src/profile/eval/wrong-target-control.test.mjs +124 -0
  57. package/src/profile/exemplar-capture.js +232 -0
  58. package/src/profile/exemplar-retrieve.js +138 -0
  59. package/src/profile/exemplar-store.js +314 -0
  60. package/src/profile/lock.js +64 -0
  61. package/src/profile/merge.js +624 -0
  62. package/src/profile/path-policy.js +213 -0
  63. package/src/profile/precision-stamp.mjs +151 -0
  64. package/src/profile/render-brief.js +717 -0
  65. package/src/profile/schema.js +244 -0
  66. package/src/profile/sensitivity.js +249 -0
  67. package/src/profile/serve.js +345 -0
  68. package/src/profile/store.js +261 -0
  69. package/src/profile/telemetry.js +289 -0
  70. package/src/recovery/checkpoint.js +7 -1
  71. package/src/server.js +185 -14
  72. package/src/.registry-meta-key.pem +0 -3
@@ -0,0 +1,64 @@
1
+ /**
2
+ * profile/lock.js — Cross-system profile bus, P0.4.
3
+ *
4
+ * The GLOBAL profile lock. Concurrency is a first-class threat (design-v2 §7
5
+ * "Concurrency", audit HIGH): N per-repo SessionEnd cycles all write ONE global
6
+ * file. The per-repo dream lock does NOT cover that contention, so this is a
7
+ * SEPARATE, user-global lock at `~/.ijfw/state/.profile.lock`.
8
+ *
9
+ * It reuses the proven `withFsLock` directory-lock primitive (atomic mkdir,
10
+ * heartbeat-refreshed, stale-recovering). All profile writes (P0.5 merge) MUST
11
+ * route through `withProfileLock`, and the read-merge-write + backup all happen
12
+ * INSIDE the held lock so two converging processes can never lose an update.
13
+ *
14
+ * Zero deps beyond the existing fs-lock. NO LLM calls.
15
+ */
16
+
17
+ import { join } from 'node:path';
18
+
19
+ import { withFsLock } from '../fs-lock.js';
20
+ import { resolveOverrideDir, homedirProfileDefault } from './path-policy.js';
21
+
22
+ /**
23
+ * The user-global state directory. Override via IJFW_PROFILE_STATE_DIR (tests).
24
+ * Default `~/.ijfw/state`. Homedir-rooted, NOT REPO_ROOT — the lock is global,
25
+ * matching the global file it protects.
26
+ *
27
+ * SECURITY (audit HIGH-4): a verbatim override lets a parent point the lock at a
28
+ * pre-created/attacker-owned dir to STARVE all writers (fleet-wide DoS on the
29
+ * global write path). `resolveOverrideDir` honors the override only under a test
30
+ * runner OR when it resolves under os.homedir() and is uid-owned; otherwise we
31
+ * fall back to the default homedir path.
32
+ *
33
+ * TEST-ISOLATION: with no safe override, the default is resolved by
34
+ * `homedirProfileDefault`, which under a test context returns a process-unique
35
+ * `os.tmpdir()` scratch dir (never the real homedir) — so a profile test that
36
+ * forgot to set IJFW_PROFILE_STATE_DIR is auto-isolated and can never create the
37
+ * real `~/.ijfw/state/.profile.lock`. Production behavior is unchanged.
38
+ */
39
+ export function profileStateDir() {
40
+ const safe = resolveOverrideDir(process.env.IJFW_PROFILE_STATE_DIR);
41
+ if (safe) return safe;
42
+ return homedirProfileDefault(['.ijfw', 'state']);
43
+ }
44
+
45
+ /** `~/.ijfw/state/.profile.lock` — distinct from any per-repo dream/wave lock. */
46
+ export function profileLockPath() {
47
+ return join(profileStateDir(), '.profile.lock');
48
+ }
49
+
50
+ /**
51
+ * withProfileLock(fn, opts?) — run `fn` under the global profile lock.
52
+ * Forwards to withFsLock; callers may tune staleMs/acquireTimeoutMs but the
53
+ * default heartbeat keeps a live long-running merge from being stale-reclaimed.
54
+ *
55
+ * `opts.lockPath` overrides the lock location (tests inject a per-case path so
56
+ * concurrent test cases don't fight over one shared env-var-derived path).
57
+ *
58
+ * @param {() => Promise<T>|T} fn
59
+ * @returns {Promise<T>}
60
+ */
61
+ export function withProfileLock(fn, opts = {}) {
62
+ const { lockPath, ...rest } = opts;
63
+ return withFsLock(lockPath || profileLockPath(), fn, rest);
64
+ }
@@ -0,0 +1,624 @@
1
+ /**
2
+ * profile/merge.js — Cross-system profile bus, P0.5 (+ P0.6 bounds helpers).
3
+ *
4
+ * CRDT-ish read-merge-write. The convergence mechanism: every per-host/per-repo
5
+ * SessionEnd derives a `ProfileDelta` (P2, not this phase) and folds it into the
6
+ * one global file under the global profile lock. The merge MUST be:
7
+ * - non-clobbering (read current, fold delta, write back),
8
+ * - evidence-accumulating (two sessions about the same trait reinforce it),
9
+ * - commutative on disjoint fields (order of two independent merges can't
10
+ * change the result — required because N processes interleave arbitrarily).
11
+ *
12
+ * Merge rules (design-v2 §4, plan P0.5):
13
+ * - style axes: EMA fold of `sample` (α from weight) + Beta(α,β) mass update.
14
+ * - inferences: dedupe by id; SUM evidence_count; UNION source_sessions /
15
+ * source_hosts; MAX-recency last_confirmed; MAX confidence.
16
+ * - expertise: accumulate accepts/n; recompute Wilson lower-bound.
17
+ * - overlays: same style fold, per overlay key; global layer untouched.
18
+ *
19
+ * `applyDelta` is PURE (no I/O, no mutation of its input). `mergeAndWrite`
20
+ * performs the read→merge→bound→write INSIDE the global lock, taking the backup
21
+ * inside the lock (design-v2 §7 Concurrency).
22
+ *
23
+ * Zero deps. NO LLM calls.
24
+ */
25
+
26
+ import { existsSync, mkdirSync, appendFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ import { withProfileLock } from './lock.js';
30
+ import {
31
+ readProfile, writeProfile, archiveDir,
32
+ } from './store.js';
33
+ import {
34
+ makeProfile,
35
+ makeInference,
36
+ inferenceId,
37
+ } from './schema.js';
38
+ // FIX 2 (CRITICAL-1 / M1 / H4): the per-host trust weighting, single-session
39
+ // influence cap, and asymmetric decay are DEFINED in capture.js but were never
40
+ // applied in the live fold. We REUSE those constants here (import, do NOT
41
+ // duplicate-and-diverge the numbers) so the merge actually enforces the
42
+ // documented anti-poison levers.
43
+ import {
44
+ STYLE_DELTA_CAP,
45
+ CONFIRM_ALPHA,
46
+ CONTRADICT_ALPHA,
47
+ } from './capture.js';
48
+
49
+ function clamp01(x) {
50
+ if (!Number.isFinite(x)) return 0;
51
+ if (x < 0) return 0;
52
+ if (x > 1) return 1;
53
+ return x;
54
+ }
55
+
56
+ /**
57
+ * Wilson score lower bound (95%, z=1.96) on a proportion — the conservative
58
+ * expertise estimate from accept-without-edit ratios (design-v2 §4). N=0 → 0.
59
+ */
60
+ export function wilsonLowerBound(accepts, n, z = 1.96) {
61
+ if (!n || n <= 0) return 0;
62
+ const phat = accepts / n;
63
+ const z2 = z * z;
64
+ const denom = 1 + z2 / n;
65
+ const center = phat + z2 / (2 * n);
66
+ const margin = z * Math.sqrt((phat * (1 - phat) + z2 / (4 * n)) / n);
67
+ const lb = (center - margin) / denom;
68
+ return clamp01(lb);
69
+ }
70
+
71
+ /** Deep-ish clone good enough for our plain-data profile (no functions/dates). */
72
+ function clone(obj) {
73
+ return JSON.parse(JSON.stringify(obj));
74
+ }
75
+
76
+ /**
77
+ * Fold one style sample into an axis object {ema,alpha,beta,evidence_count}.
78
+ *
79
+ * FIX 2 (CRITICAL-1 / M1 / H4) — the three documented anti-poison levers, now
80
+ * ACTUALLY ENFORCED here (they were inert before: `weight` was only guarded `>0`,
81
+ * so weight:1000 -> step=min(1, 0.15*1000)=1 -> a single delta overwrote the
82
+ * axis; trust was dropped entirely; the per-session δ cap was never applied):
83
+ *
84
+ * (a) WEIGHT CLAMP — the incoming sample weight is clamped to [0,1]. A weight
85
+ * above 1 (forged or buggy) behaves as 1, never as a step multiplier.
86
+ * (b) TRUST SCALING — the effective weight is multiplied by the row's
87
+ * per-host `trust` (also clamped to [0,1]); a low-trust host moves the
88
+ * axis strictly less than a full-trust one.
89
+ * (c) ASYMMETRIC STEP — a CONTRADICTING observation (sample on the opposite
90
+ * side of the 0.5 midpoint from the current EMA) uses the FASTER
91
+ * CONTRADICT_ALPHA; a confirming one uses the slower CONFIRM_ALPHA
92
+ * ("trust slowly, distrust quickly"). Same semantics as capture.js
93
+ * `asymmetricStep` — constants imported, not re-derived.
94
+ * (d) HARD δ CAP — the NET EMA move per merge is hard-clamped to
95
+ * ±STYLE_DELTA_CAP (capture.js `cappedDelta` semantics). No combination of
96
+ * weight*trust/extremity can move an axis past the cap in ONE merge. This
97
+ * is the structural single-session anti-drift guarantee.
98
+ *
99
+ * The Beta mass uses the SAME effective (clamped*trust) weight so a forged
100
+ * weight cannot inflate α+β either.
101
+ */
102
+ function foldStyleAxis(axis, sample, weight, trust) {
103
+ const s = clamp01(Number(sample));
104
+ // (a) clamp weight to [0,1] — a >1 weight is NOT a multiplier. Default to 1
105
+ // when absent/non-finite (a present observation), 0 only when explicitly 0/neg.
106
+ const wRaw = Number(weight);
107
+ const w = Number.isFinite(wRaw) ? clamp01(wRaw) : 1;
108
+ // (b) trust scaling — clamp to [0,1], default full trust when absent.
109
+ const tRaw = Number(trust);
110
+ const t = Number.isFinite(tRaw) ? clamp01(tRaw) : 1;
111
+ const effW = w * t;
112
+
113
+ // (c) asymmetric α — contradiction (opposite side of the 0.5 midpoint) adapts
114
+ // faster. Mirrors capture.js asymmetricStep's side comparison.
115
+ const currentSide = axis.ema >= 0.5 ? 1 : -1;
116
+ const sampleSide = s >= 0.5 ? 1 : -1;
117
+ const contradicting = sampleSide !== currentSide;
118
+ const alpha = contradicting ? CONTRADICT_ALPHA : CONFIRM_ALPHA;
119
+
120
+ // EMA step scaled by the effective weight, then (d) HARD-clamped to ±cap.
121
+ const rawStep = (s - axis.ema) * alpha * effW;
122
+ const cappedStep = Math.max(-STYLE_DELTA_CAP, Math.min(STYLE_DELTA_CAP, rawStep));
123
+ const ema = clamp01(axis.ema + cappedStep);
124
+
125
+ // Beta mass: treat the sample as a soft success/failure split, using the SAME
126
+ // effective weight so a forged weight cannot inflate the mass either.
127
+ return {
128
+ ...axis,
129
+ ema,
130
+ alpha: axis.alpha + s * effW,
131
+ beta: axis.beta + (1 - s) * effW,
132
+ evidence_count: (axis.evidence_count || 0) + 1,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Fold a `{axis:{sample,weight,trust}}` style delta into a style map. `trust`
138
+ * is the per-host trust weight threaded from capture (toDeriveMeta carries
139
+ * trust_weight; deriveStyle emits it per observation). Absent trust -> full.
140
+ */
141
+ function mergeStyle(styleMap, styleDelta) {
142
+ const out = clone(styleMap || {});
143
+ for (const [axis, obs] of Object.entries(styleDelta || {})) {
144
+ if (!obs || typeof obs !== 'object' || obs.sample === undefined) continue;
145
+ const current = out[axis] || { ema: 0.5, alpha: 1, beta: 1, evidence_count: 0 };
146
+ out[axis] = foldStyleAxis(current, obs.sample, obs.weight, obs.trust);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ // ===========================================================================
152
+ // S4 — ADMISSION GATE: cite-or-drop, corroborate-or-stay-unconfirmed.
153
+ //
154
+ // Nothing becomes "you" without enough INDEPENDENT evidence. A derived
155
+ // preference / correction stays UNCONFIRMED until it has been corroborated by
156
+ // >= EVIDENCE_CONFIRM_MIN edit-deltas across NON-ADJACENT sessions. The bar is
157
+ // deliberately high: prefer UNDER-learning (a real preference takes a few
158
+ // sessions to confirm) to MIS-learning (a single accidental edit minting a
159
+ // confirmed "you"). Three levers:
160
+ //
161
+ // (1) NON-ADJACENT corroboration — we count DISTINCT sessions, and require
162
+ // that they are not all back-to-back (a real recurring preference shows up
163
+ // across spread-out work, not three edits in one sitting). The session
164
+ // ORDINAL threaded from derive (source_ordinals) drives this.
165
+ // (2) DECAY / HALF-LIFE — a confirmed preference that is not re-validated
166
+ // within CONFIRM_HALF_LIFE_MS drops back to UNCONFIRMED (its confidence
167
+ // decays). A stale "you" must re-earn its standing.
168
+ // (3) CONTRADICTION FLIPS THE SIGN, with HISTORY — a later edit-delta that
169
+ // contradicts the current direction (different committed_hash for the same
170
+ // scope-subject) does NOT silently overwrite: it pushes the prior belief
171
+ // into `history[]` (invalidate-with-history, forensic + reversible) and
172
+ // resets corroboration so the NEW direction must itself re-earn confirmed.
173
+ // ===========================================================================
174
+
175
+ /** Distinct non-adjacent sessions required before a derived preference confirms. */
176
+ export const EVIDENCE_CONFIRM_MIN = 3;
177
+ /** A confirmed preference not re-validated within this window decays to unconfirmed. */
178
+ export const CONFIRM_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 60; // 60 days
179
+ /** Confidence multiplier applied when a confirmed preference goes stale. */
180
+ export const CONFIRM_DECAY_FACTOR = 0.5;
181
+ /** Max retained prior-belief history entries per inference (bounded forensics). */
182
+ export const MAX_INFERENCE_HISTORY = 8;
183
+
184
+ /** True when the corroborating session ordinals are NOT all consecutive. */
185
+ function hasNonAdjacentSpread(ordinals) {
186
+ const uniq = [...new Set(ordinals.filter((n) => Number.isFinite(n)))].sort((a, b) => a - b);
187
+ if (uniq.length < EVIDENCE_CONFIRM_MIN) return false;
188
+ // At least one gap > 1 between consecutive corroborations => spread-out work,
189
+ // not a single back-to-back burst. If we have no ordinal info at all, fall
190
+ // back to distinct-session count only (handled by the caller).
191
+ for (let i = 1; i < uniq.length; i += 1) {
192
+ if (uniq[i] - uniq[i - 1] > 1) return true;
193
+ }
194
+ return false;
195
+ }
196
+
197
+ /**
198
+ * confirmationState(inference, { now }) -> 'unconfirmed' | 'confirmed'.
199
+ *
200
+ * Pure. A derived preference/correction is CONFIRMED only when it has been
201
+ * corroborated by >= EVIDENCE_CONFIRM_MIN distinct sessions AND (when ordinals
202
+ * are present) those sessions are non-adjacent AND it has been re-validated
203
+ * within CONFIRM_HALF_LIFE_MS. Otherwise UNCONFIRMED — the brief must not
204
+ * surface it as an established preference.
205
+ */
206
+ export function confirmationState(inference, { now = Date.now() } = {}) {
207
+ if (!inference || typeof inference !== 'object') return 'unconfirmed';
208
+ const distinct = new Set(inference.source_sessions || []).size;
209
+ if (distinct < EVIDENCE_CONFIRM_MIN) return 'unconfirmed';
210
+
211
+ const ordinals = Array.isArray(inference.source_ordinals) ? inference.source_ordinals : [];
212
+ // When ordinal evidence exists, demand non-adjacency. When it is absent
213
+ // entirely (legacy rows / feedback path), distinct-session count alone gates.
214
+ if (ordinals.length >= EVIDENCE_CONFIRM_MIN && !hasNonAdjacentSpread(ordinals)) {
215
+ return 'unconfirmed';
216
+ }
217
+
218
+ // Half-life: a confirmed preference must have been re-validated recently.
219
+ const last = Date.parse(inference.last_confirmed) || 0;
220
+ if (last > 0 && (now - last) > CONFIRM_HALF_LIFE_MS) return 'unconfirmed';
221
+ return 'confirmed';
222
+ }
223
+
224
+ /** Direction key for a derived preference: which committed span it points at. */
225
+ function directionKey(inf) {
226
+ const v = inf && inf.value;
227
+ if (v && typeof v === 'object' && v.cited && typeof v.cited === 'object') {
228
+ return String(v.cited.committed_hash || '');
229
+ }
230
+ return '';
231
+ }
232
+
233
+ /**
234
+ * Evidence-accumulate one incoming inference into a dialectic[] list. Applies
235
+ * the S4 admission gate: distinct + non-adjacent session corroboration, the
236
+ * confirmed/unconfirmed flag, half-life recompute, and contradiction-flips-with-
237
+ * history (a later opposite-direction edit invalidates-with-history rather than
238
+ * overwriting).
239
+ */
240
+ function mergeInference(list, incoming, now = Date.now()) {
241
+ const id = incoming.id || inferenceId(incoming.kind, incoming.subject);
242
+ const idx = list.findIndex((x) => x.id === id);
243
+ if (idx === -1) {
244
+ const seeded = makeInference(incoming);
245
+ // Carry the S4 non-adjacency ordinals + the cold confirmation flag.
246
+ if (Array.isArray(incoming.source_ordinals)) {
247
+ seeded.source_ordinals = [...new Set(incoming.source_ordinals.filter((n) => Number.isFinite(n)))];
248
+ }
249
+ // S2 — carry the precision-gate verdict (stamped by precision-stamp.mjs at
250
+ // derive time). makeInference drops unknown fields, so without this the flag
251
+ // never reaches the stored atom and the snapshot gate (render-brief.js) stays
252
+ // dead. Fail-closed: absent stamp => false (held back), never silently true.
253
+ seeded.precision_eligible = incoming.precision_eligible === true;
254
+ seeded.confirmed = confirmationState(seeded, { now }) === 'confirmed';
255
+ list.push(seeded);
256
+ return;
257
+ }
258
+ const cur = list[idx];
259
+ const curDir = directionKey(cur);
260
+ const incDir = directionKey(incoming);
261
+ const curTs = Date.parse(cur.last_confirmed) || 0;
262
+ const incTs = Date.parse(incoming.last_confirmed) || 0;
263
+
264
+ // (3) CONTRADICTION — a later edit-delta points at a DIFFERENT committed span
265
+ // for the SAME scope-subject. Do NOT overwrite: push the prior belief to
266
+ // history (invalidate-with-history) and RESET corroboration so the new
267
+ // direction must itself re-earn confirmed from scratch.
268
+ const contradicts = curDir && incDir && curDir !== incDir && incTs >= curTs;
269
+ if (contradicts) {
270
+ const history = Array.isArray(cur.history) ? cur.history.slice() : [];
271
+ history.push({
272
+ value: cur.value,
273
+ confidence: cur.confidence,
274
+ evidence_count: cur.evidence_count,
275
+ last_confirmed: cur.last_confirmed,
276
+ invalidated_at: new Date(now).toISOString(),
277
+ reason: 'contradicted-by-later-edit',
278
+ });
279
+ const flipped = makeInference({
280
+ ...cur,
281
+ value: incoming.value,
282
+ confidence: Number(incoming.confidence) || 0,
283
+ evidence_count: incoming.evidence_count || 1,
284
+ last_confirmed: incoming.last_confirmed,
285
+ source_sessions: [...(incoming.source_sessions || [])],
286
+ source_hosts: [...(incoming.source_hosts || [])],
287
+ sensitivity: cur.sensitivity, // sticky
288
+ });
289
+ flipped.source_ordinals = Array.isArray(incoming.source_ordinals)
290
+ ? [...new Set(incoming.source_ordinals.filter((n) => Number.isFinite(n)))]
291
+ : [];
292
+ // S2 — the FLIPPED (new-direction) atom must re-earn precision eligibility:
293
+ // take the incoming stamp (fail-closed to false). The old direction's verdict
294
+ // does not carry over to a contradicting belief.
295
+ flipped.precision_eligible = incoming.precision_eligible === true;
296
+ flipped.history = history.slice(-MAX_INFERENCE_HISTORY);
297
+ flipped.confirmed = confirmationState(flipped, { now }) === 'confirmed';
298
+ list[idx] = flipped;
299
+ return;
300
+ }
301
+
302
+ // CONFIRMING corroboration — accumulate evidence, union sources/ordinals.
303
+ const sessions = new Set([...(cur.source_sessions || []), ...(incoming.source_sessions || [])]);
304
+ const hosts = new Set([...(cur.source_hosts || []), ...(incoming.source_hosts || [])]);
305
+ const ordinals = new Set([
306
+ ...(Array.isArray(cur.source_ordinals) ? cur.source_ordinals : []),
307
+ ...(Array.isArray(incoming.source_ordinals) ? incoming.source_ordinals : []),
308
+ ].filter((n) => Number.isFinite(n)));
309
+ const merged = makeInference({
310
+ ...cur,
311
+ // value follows the most recent confirmation
312
+ value: incTs >= curTs ? incoming.value : cur.value,
313
+ confidence: Math.max(Number(cur.confidence) || 0, Number(incoming.confidence) || 0),
314
+ evidence_count: (cur.evidence_count || 0) + (incoming.evidence_count || 0),
315
+ last_confirmed: incTs >= curTs ? incoming.last_confirmed : cur.last_confirmed,
316
+ source_sessions: [...sessions],
317
+ source_hosts: [...hosts],
318
+ sensitivity: cur.sensitivity, // sensitivity is sticky once set
319
+ });
320
+ merged.source_ordinals = [...ordinals];
321
+ // S2 — the precision verdict follows the most-recent stamp: a re-stamped
322
+ // incoming (precision_eligible present) wins; otherwise the current verdict is
323
+ // preserved (corroboration alone never UN-stamps a previously-cleared atom, and
324
+ // never silently promotes one — fail-closed default false).
325
+ merged.precision_eligible = Object.prototype.hasOwnProperty.call(incoming, 'precision_eligible')
326
+ ? incoming.precision_eligible === true
327
+ : cur.precision_eligible === true;
328
+ if (Array.isArray(cur.history)) merged.history = cur.history.slice(-MAX_INFERENCE_HISTORY);
329
+ // (1)+(2) recompute the admission flag: distinct + non-adjacent + not-stale.
330
+ merged.confirmed = confirmationState(merged, { now }) === 'confirmed';
331
+ list[idx] = merged;
332
+ }
333
+
334
+ /** Accumulate expertise counts and recompute the Wilson lower bound. */
335
+ function mergeExpertise(map, expertiseDelta) {
336
+ const out = clone(map || {});
337
+ for (const [domain, obs] of Object.entries(expertiseDelta || {})) {
338
+ if (!obs || typeof obs !== 'object') continue;
339
+ const cur = out[domain] || { accepts: 0, n: 0, wilsonLB: 0 };
340
+ const accepts = (cur.accepts || 0) + (Number(obs.accepts) || 0);
341
+ const n = (cur.n || 0) + (Number(obs.n) || 0);
342
+ out[domain] = { accepts, n, wilsonLB: wilsonLowerBound(accepts, n) };
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /**
348
+ * applyDelta(profile, delta) -> NEW profile. Pure: never mutates `profile`.
349
+ *
350
+ * ProfileDelta = {
351
+ * style?: { axis: { sample:0..1, weight?:number } },
352
+ * inferences?: Inference[],
353
+ * expertise?: { domain: { accepts:number, n:number } },
354
+ * overlays?: { key: { style?: {...} } },
355
+ * provenance?: { ...scalars to merge },
356
+ * }
357
+ */
358
+ export function applyDelta(profile, delta = {}, opts = {}) {
359
+ const next = clone(profile || makeProfile());
360
+ if (!next.global) next.global = { style: {}, dialectic: [] };
361
+ if (!next.global.style) next.global.style = {};
362
+ if (!Array.isArray(next.global.dialectic)) next.global.dialectic = [];
363
+ if (!next.overlays) next.overlays = {};
364
+ if (!next.expertise) next.expertise = {};
365
+ if (!next.provenance) next.provenance = {};
366
+
367
+ // A single `now` for the whole fold so the S4 admission gate's half-life /
368
+ // confirmation flag is evaluated consistently (overridable for tests). NOT
369
+ // persisted into any content field — provenance.updated stays content-derived
370
+ // (commutative MAX, below), so applyDelta remains deterministic on its inputs.
371
+ const now = Number.isFinite(Number(opts.now)) ? Number(opts.now) : Date.now();
372
+
373
+ if (delta.style) {
374
+ next.global.style = mergeStyle(next.global.style, delta.style);
375
+ }
376
+
377
+ if (Array.isArray(delta.inferences)) {
378
+ for (const inc of delta.inferences) {
379
+ if (!inc || typeof inc !== 'object') continue;
380
+ mergeInference(next.global.dialectic, inc, now);
381
+ }
382
+ }
383
+
384
+ if (delta.expertise) {
385
+ next.expertise = mergeExpertise(next.expertise, delta.expertise);
386
+ }
387
+
388
+ if (delta.overlays) {
389
+ for (const [key, ov] of Object.entries(delta.overlays)) {
390
+ if (!ov || typeof ov !== 'object') continue;
391
+ const cur = next.overlays[key] || { style: {} };
392
+ const merged = { ...cur };
393
+ if (ov.style) merged.style = mergeStyle(cur.style || {}, ov.style);
394
+ if (Array.isArray(ov.inferences)) {
395
+ if (!Array.isArray(merged.dialectic)) merged.dialectic = [];
396
+ for (const inc of ov.inferences) mergeInference(merged.dialectic, inc, now);
397
+ }
398
+ next.overlays[key] = merged;
399
+ }
400
+ }
401
+
402
+ // Capture the prior `updated` BEFORE folding the delta's provenance scalars,
403
+ // so the delta cannot clobber the existing recency we are about to MAX over.
404
+ const priorUpdated = next.provenance.updated;
405
+ if (delta.provenance && typeof delta.provenance === 'object') {
406
+ next.provenance = { ...next.provenance, ...delta.provenance };
407
+ }
408
+ // `provenance.updated` must be COMMUTATIVE on disjoint merges: two independent
409
+ // deltas folded in either order must yield the same value (N processes
410
+ // interleave arbitrarily). A wall-clock `new Date()` stamp breaks that — two
411
+ // orderings stamp different instants. Instead derive `updated` from the
412
+ // CONTENT: the MAX (most-recent) of the existing value, the delta's
413
+ // provenance.updated, and the incoming inferences' last_confirmed (the
414
+ // natural recency signal). MAX is order-independent, so commutativity holds,
415
+ // and `updated` never moves backward (monotonic).
416
+ const recencyCandidates = [
417
+ priorUpdated,
418
+ delta.provenance && delta.provenance.updated,
419
+ ];
420
+ if (Array.isArray(delta.inferences)) {
421
+ for (const inc of delta.inferences) {
422
+ if (inc && typeof inc === 'object') recencyCandidates.push(inc.last_confirmed);
423
+ }
424
+ }
425
+ if (delta.overlays) {
426
+ for (const ov of Object.values(delta.overlays)) {
427
+ if (ov && Array.isArray(ov.inferences)) {
428
+ for (const inc of ov.inferences) {
429
+ if (inc && typeof inc === 'object') recencyCandidates.push(inc.last_confirmed);
430
+ }
431
+ }
432
+ }
433
+ }
434
+ let maxTs = -Infinity;
435
+ let maxIso = next.provenance.updated;
436
+ for (const cand of recencyCandidates) {
437
+ const t = Date.parse(cand);
438
+ if (Number.isFinite(t) && t > maxTs) {
439
+ maxTs = t;
440
+ maxIso = new Date(t).toISOString();
441
+ }
442
+ }
443
+ next.provenance.updated = maxIso;
444
+
445
+ return next;
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // P0.6 — Bounded store + eviction + decay-to-archive.
450
+ //
451
+ // The global profile must not grow without bound as N sessions accumulate.
452
+ // Hard caps on dialectic inference + per-overlay inference counts; eviction
453
+ // drops the LOWEST-confidence / OLDEST entries; decay reduces confidence of
454
+ // stale low-evidence inferences and ARCHIVES them (never hard-delete — the
455
+ // archive is forensic + recoverable, design-v2 §4 "decay-to-archive (not just
456
+ // decay)").
457
+ // ---------------------------------------------------------------------------
458
+
459
+ export const DEFAULT_BOUNDS = Object.freeze({
460
+ maxDialectic: 200, // global trait inferences cap
461
+ maxOverlayInferences: 100, // per-overlay inference cap
462
+ maxExpertiseDomains: 200,
463
+ // decay: an inference not confirmed within this window AND below the evidence
464
+ // floor decays; once its confidence falls under archiveBelow it is archived.
465
+ staleMs: 1000 * 60 * 60 * 24 * 90, // 90 days
466
+ decayEvidenceFloor: 3, // < this many confirmations = decay-eligible
467
+ decayFactor: 0.5, // confidence multiplier on decay
468
+ archiveBelow: 0.1, // archive once confidence drops under this
469
+ });
470
+
471
+ function archiveInferences(entries) {
472
+ if (!entries || entries.length === 0) return;
473
+ try {
474
+ const dir = archiveDir();
475
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
476
+ // Snapshot the current profile alongside the archived entries for forensics,
477
+ // then append a JSONL record. Best-effort — archiving must never break a
478
+ // merge. We append rather than rewrite so concurrent (lock-held) merges
479
+ // accrete history.
480
+ const rec = { ts: new Date().toISOString(), archived: entries };
481
+ const line = `${JSON.stringify(rec)}\n`;
482
+ const file = join(dir, 'archived-inferences.jsonl');
483
+ // appendFileSync is fine here — merges run inside the global lock, so the
484
+ // JSONL accretes history without interleaving.
485
+ appendFileSync(file, line, 'utf8');
486
+ } catch {
487
+ // forensic-only; swallow.
488
+ }
489
+ }
490
+
491
+ /** Sort key for eviction: lowest confidence first, then oldest confirmation. */
492
+ function evictionRank(a, b) {
493
+ const ca = Number(a.confidence) || 0;
494
+ const cb = Number(b.confidence) || 0;
495
+ if (ca !== cb) return ca - cb;
496
+ const ta = Date.parse(a.last_confirmed) || 0;
497
+ const tb = Date.parse(b.last_confirmed) || 0;
498
+ return ta - tb;
499
+ }
500
+
501
+ /**
502
+ * decayAndArchive(list, bounds, now) -> { kept, archived }.
503
+ * Stale, low-evidence inferences have their confidence reduced; any that fall
504
+ * under archiveBelow are pulled out (archived, not hard-deleted).
505
+ */
506
+ function decayAndArchive(list, bounds, now) {
507
+ const kept = [];
508
+ const archived = [];
509
+ for (const inf of list) {
510
+ // `last_confirmed` defaults to epoch (Date(0)) — a deliberate "never
511
+ // confirmed" sentinel from makeInference. A never-confirmed inference is
512
+ // brand-new (e.g. a freshly-derived P2 trait), NOT ~56-years-stale, so it
513
+ // must not be treated as stale. Only inferences with a real confirmation
514
+ // timestamp (ts > 0) can age into staleness.
515
+ const ts = Date.parse(inf.last_confirmed) || 0;
516
+ const isStale = ts > 0 && (now - ts) > bounds.staleMs;
517
+ const lowEvidence = (inf.evidence_count || 0) < bounds.decayEvidenceFloor;
518
+ let decayed = inf;
519
+ if (isStale && lowEvidence) {
520
+ // S4 lever (2): a stale low-evidence preference decays AND drops back to
521
+ // UNCONFIRMED — a stale "you" must re-earn its standing before a brief
522
+ // surfaces it again. (A high-evidence inference is exempt: it cleared the
523
+ // corroboration bar and decayEvidenceFloor keeps it out of this branch.)
524
+ decayed = {
525
+ ...inf,
526
+ confidence: clamp01((Number(inf.confidence) || 0) * bounds.decayFactor),
527
+ confirmed: false,
528
+ };
529
+ }
530
+ // Archive ONLY when genuinely stale (mirrors the decay gate). Without the
531
+ // staleness requirement, a fresh low-confidence inference is silently
532
+ // archived on its first enforceBounds — data loss for derived traits.
533
+ if (isStale && (Number(decayed.confidence) || 0) < bounds.archiveBelow && lowEvidence) {
534
+ archived.push(decayed);
535
+ } else {
536
+ kept.push(decayed);
537
+ }
538
+ }
539
+ return { kept, archived };
540
+ }
541
+
542
+ /**
543
+ * enforceBounds(profile, bounds) -> NEW profile with caps applied. Decays +
544
+ * archives stale low-evidence inferences first, then evicts the lowest-ranked
545
+ * entries if still over cap. Archived entries are written to the archive dir.
546
+ * Pure w.r.t. its input (returns a clone); the archive write is a side effect.
547
+ */
548
+ export function enforceBounds(profile, bounds = DEFAULT_BOUNDS) {
549
+ const b = { ...DEFAULT_BOUNDS, ...bounds };
550
+ const next = clone(profile || makeProfile());
551
+ const now = Date.now();
552
+ const allArchived = [];
553
+
554
+ // Global dialectic: decay-to-archive, then cap by eviction.
555
+ if (Array.isArray(next.global?.dialectic)) {
556
+ const { kept, archived } = decayAndArchive(next.global.dialectic, b, now);
557
+ allArchived.push(...archived);
558
+ let keep = kept;
559
+ if (keep.length > b.maxDialectic) {
560
+ const ranked = [...keep].sort(evictionRank);
561
+ const evicted = ranked.slice(0, keep.length - b.maxDialectic);
562
+ allArchived.push(...evicted);
563
+ const evictedIds = new Set(evicted.map((x) => x.id));
564
+ keep = keep.filter((x) => !evictedIds.has(x.id));
565
+ }
566
+ next.global.dialectic = keep;
567
+ }
568
+
569
+ // Per-overlay inference caps (decay + evict, same policy).
570
+ for (const key of Object.keys(next.overlays || {})) {
571
+ const ov = next.overlays[key];
572
+ if (Array.isArray(ov?.dialectic)) {
573
+ const { kept, archived } = decayAndArchive(ov.dialectic, b, now);
574
+ allArchived.push(...archived);
575
+ let keep = kept;
576
+ if (keep.length > b.maxOverlayInferences) {
577
+ const ranked = [...keep].sort(evictionRank);
578
+ const evicted = ranked.slice(0, keep.length - b.maxOverlayInferences);
579
+ allArchived.push(...evicted);
580
+ const evictedIds = new Set(evicted.map((x) => x.id));
581
+ keep = keep.filter((x) => !evictedIds.has(x.id));
582
+ }
583
+ ov.dialectic = keep;
584
+ }
585
+ }
586
+
587
+ // Expertise: cap domains by lowest Wilson LB (oldest signal first via n).
588
+ const domains = Object.keys(next.expertise || {});
589
+ if (domains.length > b.maxExpertiseDomains) {
590
+ const ranked = domains
591
+ .map((d) => ({ d, lb: Number(next.expertise[d].wilsonLB) || 0, n: Number(next.expertise[d].n) || 0 }))
592
+ .sort((x, y) => (x.lb - y.lb) || (x.n - y.n));
593
+ const drop = ranked.slice(0, domains.length - b.maxExpertiseDomains);
594
+ for (const { d } of drop) delete next.expertise[d];
595
+ }
596
+
597
+ if (allArchived.length) archiveInferences(allArchived);
598
+ return next;
599
+ }
600
+
601
+ /**
602
+ * mergeAndWrite(delta, opts?) — read → merge → bound → write, INSIDE the global
603
+ * profile lock. The backup is taken by writeProfile (which copies the prior good
604
+ * content before overwrite) — and because the whole read-merge-write is under
605
+ * the lock, two converging processes serialize and neither update is lost.
606
+ *
607
+ * @param {object} delta a ProfileDelta
608
+ * @param {object} [opts] { lockPath, bounds } — lockPath for test isolation
609
+ * @returns {Promise<{ok:boolean, code?:string, message?:string}>}
610
+ */
611
+ export function mergeAndWrite(delta, opts = {}) {
612
+ const { lockPath, bounds = DEFAULT_BOUNDS, ...lockOpts } = opts;
613
+ return withProfileLock(async () => {
614
+ const r = readProfile();
615
+ if (!r.ok) {
616
+ // Corrupt + unrecoverable: refuse rather than clobber. Caller surfaces.
617
+ return { ok: false, code: r.code || 'EREAD', message: r.message };
618
+ }
619
+ let merged = applyDelta(r.profile, delta);
620
+ merged = enforceBounds(merged, bounds);
621
+ const w = writeProfile(merged);
622
+ return w;
623
+ }, { lockPath, ...lockOpts });
624
+ }