@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.
- package/bin/ijfw-dashboard +20 -1
- package/package.json +4 -3
- package/src/audit-roster.js +89 -12
- package/src/brain/tiered-llm.js +57 -7
- package/src/cross-orchestrator-cli.js +344 -4
- package/src/cross-project-search.js +39 -1
- package/src/dashboard-server.js +7 -1
- package/src/dream/runner.mjs +560 -8
- package/src/handlers/brain-handler.js +101 -1
- package/src/importers/discover.js +1 -1
- package/src/memory/bench-metrics.js +289 -0
- package/src/memory/benchmark.js +1 -1
- package/src/memory/search.js +53 -1
- package/src/orchestrator/plan-checker.js +1 -1
- package/src/profile/audit.js +671 -0
- package/src/profile/capture.js +871 -0
- package/src/profile/derive-dialectic.js +242 -0
- package/src/profile/derive-heuristic.js +733 -0
- package/src/profile/derive.js +156 -0
- package/src/profile/egress.js +306 -0
- package/src/profile/eval/build-real-probes.mjs +197 -0
- package/src/profile/eval/corpus-from-reddit.mjs +166 -0
- package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
- package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
- package/src/profile/eval/gate-b-behavior.mjs +420 -0
- package/src/profile/eval/gate-b-decision-run.mjs +171 -0
- package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
- package/src/profile/eval/gate-b-run.mjs +417 -0
- package/src/profile/eval/gate-b-run.test.mjs +204 -0
- package/src/profile/eval/gate-c-capture.mjs +323 -0
- package/src/profile/eval/harness.mjs +551 -0
- package/src/profile/eval/instrument-validation.mjs +248 -0
- package/src/profile/eval/instrument-validation.test.mjs +125 -0
- package/src/profile/eval/multi-subject-harness.mjs +106 -0
- package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
- package/src/profile/eval/personas.test.mjs +83 -0
- package/src/profile/eval/plumbing.test.mjs +69 -0
- package/src/profile/eval/prereg.mjs +130 -0
- package/src/profile/eval/prereg.test.mjs +78 -0
- package/src/profile/eval/real-corpus.test.mjs +103 -0
- package/src/profile/eval/real-personas.mjs +109 -0
- package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
- package/src/profile/eval/run-real-corpus.mjs +358 -0
- package/src/profile/eval/slug-quality.mjs +464 -0
- package/src/profile/eval/stylometry-features.js +85 -0
- package/src/profile/eval/stylometry-reference.js +16 -0
- package/src/profile/eval/stylometry.js +224 -0
- package/src/profile/eval/stylometry.test.mjs +103 -0
- package/src/profile/eval/synthetic-personas.js +91 -0
- package/src/profile/eval/verifier-features.mjs +170 -0
- package/src/profile/eval/verifier-logreg.mjs +74 -0
- package/src/profile/eval/verifier-pair.mjs +122 -0
- package/src/profile/eval/verifier-reference.mjs +68 -0
- package/src/profile/eval/verifier-scorer.mjs +30 -0
- package/src/profile/eval/wrong-target-control.mjs +168 -0
- package/src/profile/eval/wrong-target-control.test.mjs +124 -0
- package/src/profile/exemplar-capture.js +232 -0
- package/src/profile/exemplar-retrieve.js +138 -0
- package/src/profile/exemplar-store.js +314 -0
- package/src/profile/lock.js +64 -0
- package/src/profile/merge.js +624 -0
- package/src/profile/path-policy.js +213 -0
- package/src/profile/precision-stamp.mjs +151 -0
- package/src/profile/render-brief.js +717 -0
- package/src/profile/schema.js +244 -0
- package/src/profile/sensitivity.js +249 -0
- package/src/profile/serve.js +345 -0
- package/src/profile/store.js +261 -0
- package/src/profile/telemetry.js +289 -0
- package/src/recovery/checkpoint.js +7 -1
- package/src/server.js +185 -14
- 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
|
+
}
|