@ijfw/memory-server 1.5.5 → 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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile/path-policy.js — Cross-system profile bus, audit HIGH-4 hardening.
|
|
3
|
+
*
|
|
4
|
+
* The user-global profile is deliberately homedir-rooted and EXEMPT from the
|
|
5
|
+
* per-project namespacing (store.js KEY INVARIANT) — that is what lets two host
|
|
6
|
+
* processes in two repos converge on ONE file. The env overrides
|
|
7
|
+
* `IJFW_PROFILE_DIR` / `IJFW_PROFILE_STATE_DIR` exist for tests and power users,
|
|
8
|
+
* but returned VERBATIM they are a privilege-escalation surface (audit HIGH-4):
|
|
9
|
+
*
|
|
10
|
+
* - a parent process can relocate the ENTIRE global profile out from under the
|
|
11
|
+
* user (defeating identity partitioning / leaking one user's profile to
|
|
12
|
+
* another), or
|
|
13
|
+
* - point the lock dir at a pre-created, attacker-owned location to STARVE all
|
|
14
|
+
* writers (a fleet-wide denial of service on the global write path).
|
|
15
|
+
*
|
|
16
|
+
* POLICY — `resolveOverrideDir(override)` returns the override ONLY when it is
|
|
17
|
+
* safe; otherwise null (caller falls back to the default path):
|
|
18
|
+
*
|
|
19
|
+
* 1. Test context (NODE_ENV==='test' OR the node:test runner marker
|
|
20
|
+
* NODE_TEST_CONTEXT is set) → honored verbatim. The suite points the
|
|
21
|
+
* override at a fresh `os.tmpdir()` scratch dir per case; that is NOT under
|
|
22
|
+
* homedir, so without this carve-out every profile test would break. The
|
|
23
|
+
* marker is set by `node --test` in the worker process, so it cannot be
|
|
24
|
+
* forged by a non-test parent in production.
|
|
25
|
+
* 2. Production → the resolved real path must live UNDER os.homedir() (no `..`
|
|
26
|
+
* escape, no symlinked component that re-points outside homedir — checked
|
|
27
|
+
* via realpathSync on the nearest existing ancestor), AND, where the dir
|
|
28
|
+
* already exists, be owned by the current uid (anti-pre-created-by-attacker).
|
|
29
|
+
* Anything else → null.
|
|
30
|
+
*
|
|
31
|
+
* DEFAULT PATH (the no-safe-override fallback) — `homedirProfileDefault`:
|
|
32
|
+
*
|
|
33
|
+
* - Production → the real homedir path `join(os.homedir(), ...subParts)`,
|
|
34
|
+
* UNCHANGED. Real users get their real `~/.ijfw/profile` (and `~/.ijfw/state`).
|
|
35
|
+
* - Test context → a PROCESS-UNIQUE scratch dir under `os.tmpdir()`, NEVER the
|
|
36
|
+
* real homedir. So a profile test (or a test-context child) that performs a
|
|
37
|
+
* write WITHOUT setting IJFW_PROFILE_DIR/IJFW_PROFILE_STATE_DIR is silently
|
|
38
|
+
* ISOLATED instead of clobbering the user's real profile. This is the
|
|
39
|
+
* test-isolation leak fix: the prior design THROWied here, which closed the
|
|
40
|
+
* leak but was brittle (any forgetful future test hard-fails, and a wrapping
|
|
41
|
+
* try/catch could mask it). The auto-tmpdir default is self-healing — there is
|
|
42
|
+
* no code path under a test context that returns the real homedir profile.
|
|
43
|
+
*
|
|
44
|
+
* Zero deps, Node built-ins only. NO LLM calls.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { realpathSync, lstatSync, existsSync, mkdirSync } from 'node:fs';
|
|
48
|
+
import { homedir, userInfo, tmpdir } from 'node:os';
|
|
49
|
+
import { resolve, sep, dirname, join } from 'node:path';
|
|
50
|
+
import { randomBytes } from 'node:crypto';
|
|
51
|
+
|
|
52
|
+
/** True iff the process is running under a test runner. */
|
|
53
|
+
export function inTestContext(env = process.env) {
|
|
54
|
+
if (env && String(env.NODE_ENV).toLowerCase() === 'test') return true;
|
|
55
|
+
// node:test sets NODE_TEST_CONTEXT in the worker process; it is the canonical,
|
|
56
|
+
// runner-set marker present for every `node --test` invocation.
|
|
57
|
+
if (env && typeof env.NODE_TEST_CONTEXT === 'string' && env.NODE_TEST_CONTEXT) return true;
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `isTestContext` — canonical name used by the fail-closed default guard
|
|
63
|
+
* (`homedirProfileDefault`). Alias of `inTestContext`; both are exported so
|
|
64
|
+
* older call sites and the audit HIGH-4 documentation keep resolving.
|
|
65
|
+
*/
|
|
66
|
+
export const isTestContext = inTestContext;
|
|
67
|
+
|
|
68
|
+
/** True iff `child` (already resolved) is `base` or strictly under it. */
|
|
69
|
+
function isUnder(base, child) {
|
|
70
|
+
const b = resolve(base);
|
|
71
|
+
const c = resolve(child);
|
|
72
|
+
if (c === b) return true;
|
|
73
|
+
return c.startsWith(b.endsWith(sep) ? b : b + sep);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the override to a REAL path, following symlinks on whatever portion of
|
|
78
|
+
* the path already exists. A path whose existing ancestors realpath OUTSIDE
|
|
79
|
+
* homedir (a symlinked component pointing away) is rejected by the caller via
|
|
80
|
+
* the isUnder check on the returned real path. Returns the realpath of the
|
|
81
|
+
* nearest existing ancestor joined with the remaining (non-existent) tail.
|
|
82
|
+
*/
|
|
83
|
+
function realResolved(p) {
|
|
84
|
+
const abs = resolve(p);
|
|
85
|
+
// Walk up to the nearest existing ancestor and realpath THAT (so a symlinked
|
|
86
|
+
// existing component is dereferenced); re-append the not-yet-created tail.
|
|
87
|
+
let cur = abs;
|
|
88
|
+
const tail = [];
|
|
89
|
+
// Guard against an unbounded loop on a pathological path.
|
|
90
|
+
for (let i = 0; i < 4096; i += 1) {
|
|
91
|
+
if (existsSync(cur)) {
|
|
92
|
+
let real;
|
|
93
|
+
try {
|
|
94
|
+
real = realpathSync(cur);
|
|
95
|
+
} catch {
|
|
96
|
+
real = cur;
|
|
97
|
+
}
|
|
98
|
+
return tail.length ? resolve(real, ...tail.reverse()) : real;
|
|
99
|
+
}
|
|
100
|
+
const parent = dirname(cur);
|
|
101
|
+
if (parent === cur) break; // reached filesystem root
|
|
102
|
+
tail.push(cur.slice(parent.length + 1));
|
|
103
|
+
cur = parent;
|
|
104
|
+
}
|
|
105
|
+
return abs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Current process uid, or null on platforms without uids (Windows). */
|
|
109
|
+
function currentUid() {
|
|
110
|
+
try {
|
|
111
|
+
const info = userInfo();
|
|
112
|
+
return typeof info.uid === 'number' && info.uid >= 0 ? info.uid : null;
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* resolveOverrideDir(override, env?) -> string|null.
|
|
120
|
+
*
|
|
121
|
+
* Returns the override to use, or null when the caller must fall back to the
|
|
122
|
+
* default homedir path. See the module header for the full policy.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveOverrideDir(override, env = process.env) {
|
|
125
|
+
if (!override || !String(override).trim()) return null;
|
|
126
|
+
|
|
127
|
+
// (1) Test context — honored verbatim so the suite's tmpdir scratch dirs work.
|
|
128
|
+
if (inTestContext(env)) return override;
|
|
129
|
+
|
|
130
|
+
// (2) Production — must resolve under homedir, no symlink escape.
|
|
131
|
+
const home = realResolved(homedir());
|
|
132
|
+
const real = realResolved(override);
|
|
133
|
+
if (!isUnder(home, real)) return null;
|
|
134
|
+
|
|
135
|
+
// Ownership: if the dir already exists it must be owned by the current uid
|
|
136
|
+
// (an attacker-pre-created dir under a shared-home path is rejected). A
|
|
137
|
+
// not-yet-existent dir is fine — we create it ourselves. uid-less platforms
|
|
138
|
+
// (Windows) skip this leg; the homedir-containment check is the guard there.
|
|
139
|
+
const uid = currentUid();
|
|
140
|
+
if (uid != null) {
|
|
141
|
+
const abs = resolve(override);
|
|
142
|
+
if (existsSync(abs)) {
|
|
143
|
+
try {
|
|
144
|
+
const st = lstatSync(abs);
|
|
145
|
+
if (st.isSymbolicLink()) return null; // never honor a symlinked override dir
|
|
146
|
+
if (typeof st.uid === 'number' && st.uid !== uid) return null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return override;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Per-process root for the auto-isolated test-context profile scratch dirs.
|
|
158
|
+
* Lazily created once per process, unique per pid+random so two test processes
|
|
159
|
+
* (or a test parent and its spawned child) never collide on disk. Memoized so
|
|
160
|
+
* every `homedirProfileDefault` call in one process agrees on the SAME path —
|
|
161
|
+
* `profileDir()` and `profilePath()` and `profileStateDir()` must be stable
|
|
162
|
+
* within a process or a read wouldn't find what a prior write put down.
|
|
163
|
+
*
|
|
164
|
+
* @type {string|null}
|
|
165
|
+
*/
|
|
166
|
+
let testScratchRoot = null;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve (creating once) the process-unique scratch root under os.tmpdir().
|
|
170
|
+
* Kept out of homedir on purpose: an isolated tmpdir can NEVER be the user's
|
|
171
|
+
* real profile, which is the whole point of the leak fix.
|
|
172
|
+
*/
|
|
173
|
+
function testScratchRootDir() {
|
|
174
|
+
if (testScratchRoot) return testScratchRoot;
|
|
175
|
+
const unique = `ijfw-test-profile-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
176
|
+
testScratchRoot = join(tmpdir(), unique);
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(testScratchRoot, { recursive: true });
|
|
179
|
+
} catch {
|
|
180
|
+
// Best-effort: the consuming store/lock layer also mkdirs before writing.
|
|
181
|
+
// If creation here races/fails, the path is still a tmpdir (never homedir),
|
|
182
|
+
// so the isolation guarantee holds regardless.
|
|
183
|
+
}
|
|
184
|
+
return testScratchRoot;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* homedirProfileDefault(subParts, env?) -> string.
|
|
189
|
+
*
|
|
190
|
+
* The default profile path for a helper. Callers reach here ONLY when there is
|
|
191
|
+
* NO safe override (resolveOverrideDir returned null).
|
|
192
|
+
*
|
|
193
|
+
* - Production (non-test) → `join(os.homedir(), ...subParts)`, UNCHANGED.
|
|
194
|
+
* - Test context (NODE_ENV==='test' OR NODE_TEST_CONTEXT set) → a
|
|
195
|
+
* process-unique scratch dir under os.tmpdir(), so a test (or test-context
|
|
196
|
+
* child) that forgot to set IJFW_PROFILE_DIR/IJFW_PROFILE_STATE_DIR is
|
|
197
|
+
* ISOLATED to tmpdir and can never write into the user's real
|
|
198
|
+
* `~/.ijfw/profile`. See the module header for the rationale (auto-tmpdir is
|
|
199
|
+
* the robust successor to the prior fail-closed THROW).
|
|
200
|
+
*
|
|
201
|
+
* The same `subParts` always maps to the same path within one process (the
|
|
202
|
+
* scratch root is memoized), so read-after-write inside a test works.
|
|
203
|
+
*
|
|
204
|
+
* @param {string[]} subParts path segments, e.g. ['.ijfw','profile']
|
|
205
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
206
|
+
* @returns {string} the real homedir default (prod) or an isolated tmpdir (test)
|
|
207
|
+
*/
|
|
208
|
+
export function homedirProfileDefault(subParts, env = process.env) {
|
|
209
|
+
if (isTestContext(env)) {
|
|
210
|
+
return join(testScratchRootDir(), ...subParts);
|
|
211
|
+
}
|
|
212
|
+
return join(homedir(), ...subParts);
|
|
213
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile/precision-stamp.mjs — SLICE S2 RUNTIME WIRE (the gate goes live).
|
|
3
|
+
*
|
|
4
|
+
* THE BUG THIS CLOSES: render-brief.js reads `inf.precision_eligible` (the S5
|
|
5
|
+
* snapshot gate, render-brief.js:417) but NOTHING in src/ ever WROTE it, and the
|
|
6
|
+
* slug-quality precision gate (eval/slug-quality.mjs `eligibleSlugsForInjection`)
|
|
7
|
+
* had ZERO runtime callers. The 0.8 precision bar was DEAD CODE: every derived
|
|
8
|
+
* preference slug shipped without the flag, so the snapshot path's
|
|
9
|
+
* `if (inf.precision_eligible !== true) continue;` held EVERYTHING back —
|
|
10
|
+
* fail-closed-but-dark. This module is the runtime caller that runs the gate and
|
|
11
|
+
* STAMPS the verdict, so a cleared slug can finally inject and a noise slug never
|
|
12
|
+
* can.
|
|
13
|
+
*
|
|
14
|
+
* WHY THIS LIVES OUTSIDE THE SERVE MOAT: the serve/read path (serve.js,
|
|
15
|
+
* render-brief.js) must NEVER import eval/ or the LLM tier — the P4.5 moat-guard
|
|
16
|
+
* test statically proves it. This module is called from the DREAM/DERIVE path
|
|
17
|
+
* (dream/runner.mjs), which is already LLM-capable (it imports derive.js). So the
|
|
18
|
+
* gate runs at DERIVE time, stamps a plain boolean onto the atom, and the
|
|
19
|
+
* zero-LLM serve path only ever READS that boolean. The moat stays intact: no
|
|
20
|
+
* serve module imports this file.
|
|
21
|
+
*
|
|
22
|
+
* THE ANTI-CIRCULARITY (the ea15479 lesson): the precision gate scores a surfaced
|
|
23
|
+
* slug against a HELD-OUT gold of the user's REAL preferences — never the train
|
|
24
|
+
* target a brief injected. At derive time on a user's machine there is no labeled
|
|
25
|
+
* external gold, so we build the gold from the strongest GROUNDED evidence the
|
|
26
|
+
* user produced THIS cycle: the EDIT-DELTA corrections. An edit-delta correction
|
|
27
|
+
* carries a real cited diff span (the agent proposed X, the user committed Y —
|
|
28
|
+
* the diff IS the citation). That is the cleanest "this is genuinely a preference
|
|
29
|
+
* the user expressed" signal in the whole system. Feedback-only slugs (regex
|
|
30
|
+
* triggers in a prompt) are CANDIDATES scored AGAINST that grounded gold:
|
|
31
|
+
* - a feedback slug that semantically matches a grounded edit correction is
|
|
32
|
+
* corroborated -> CORRECT -> precision_eligible.
|
|
33
|
+
* - a noise/meaningless feedback slug ("not to deal with this garbage") that
|
|
34
|
+
* matches NO grounded correction is WRONG -> NOT precision_eligible (closes
|
|
35
|
+
* the "meaningless-but-real slug mints" finding — it is stored but can never
|
|
36
|
+
* reach the brief).
|
|
37
|
+
* This is non-circular by construction: feedback can NEVER bootstrap its own
|
|
38
|
+
* eligibility; only diff-grounded evidence seeds the gold. No grounded gold ->
|
|
39
|
+
* empty corpus -> NOTHING is precision-eligible (fail-closed).
|
|
40
|
+
*
|
|
41
|
+
* Zero deps. ESM. Pure (no I/O). Never throws — a stamp failure degrades to
|
|
42
|
+
* "stamped false" (fail-closed), never a thrown derive cycle.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { labelSlugs, SLUG_LABELS, SLUG_SEMANTIC_THRESHOLD } from './eval/slug-quality.mjs';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The same slug-normalization the heuristic derive + slug-quality gate use, so a
|
|
49
|
+
* gold phrase and a surfaced subject compare on ONE scale. Re-stated (derive does
|
|
50
|
+
* not export it). Pure.
|
|
51
|
+
*/
|
|
52
|
+
function toSubject(phrase) {
|
|
53
|
+
return String(phrase || '')
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9\s]+/g, ' ')
|
|
56
|
+
.replace(/\s+/g, ' ')
|
|
57
|
+
.trim()
|
|
58
|
+
.slice(0, 80);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Is this inference an actionable preference/correction (vs a dialectic belief)? */
|
|
62
|
+
function isActionablePreference(inf) {
|
|
63
|
+
return inf && (inf.kind === 'preference' || inf.kind === 'correction');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True iff the atom is grounded in an actual edit-delta citation (the diff). */
|
|
67
|
+
function isEditGrounded(inf) {
|
|
68
|
+
const v = inf && inf.value;
|
|
69
|
+
return !!(v && typeof v === 'object' && v.cited && typeof v.cited === 'object'
|
|
70
|
+
&& (v.cited.committed_hash || v.cited.proposed_hash));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The human-readable preference PHRASE an atom asserts (what a brief would
|
|
75
|
+
* render): the value.phrase when present, else the subject. For an edit-grounded
|
|
76
|
+
* correction the subject is `scopeKey::citedSlug` — we strip the scope prefix so
|
|
77
|
+
* the gold/candidate compare on the CITED preference content, not the scope tag.
|
|
78
|
+
*/
|
|
79
|
+
function preferencePhrase(inf) {
|
|
80
|
+
const v = inf && inf.value;
|
|
81
|
+
if (v && typeof v === 'object' && typeof v.phrase === 'string' && v.phrase.trim()) {
|
|
82
|
+
return v.phrase.trim();
|
|
83
|
+
}
|
|
84
|
+
const subject = String((inf && inf.subject) || '');
|
|
85
|
+
const ci = subject.indexOf('::');
|
|
86
|
+
return ci >= 0 ? subject.slice(ci + 2) : subject;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* stampPrecisionEligible(delta, opts) -> delta (same object, inferences mutated).
|
|
91
|
+
*
|
|
92
|
+
* For every actionable preference/correction inference in the delta, run the S2
|
|
93
|
+
* slug-quality gate (labelSlugs) against a held-out gold built from THIS delta's
|
|
94
|
+
* EDIT-DELTA-grounded corrections, and stamp `precision_eligible` = (the gate
|
|
95
|
+
* labels the slug CORRECT). Dialectic beliefs and non-actionable atoms are left
|
|
96
|
+
* untouched (no precision_eligible) — they are not preference slugs.
|
|
97
|
+
*
|
|
98
|
+
* FAIL-CLOSED everywhere:
|
|
99
|
+
* - no grounded gold this cycle -> corpus empty -> every slug labels WRONG ->
|
|
100
|
+
* precision_eligible = false on all of them.
|
|
101
|
+
* - any error in labeling -> precision_eligible = false (never thrown).
|
|
102
|
+
*
|
|
103
|
+
* @param {object} delta a ProfileDelta ({ inferences?: Inference[] , ... })
|
|
104
|
+
* @param {object} [opts]
|
|
105
|
+
* @param {number} [opts.semanticThreshold] match threshold (default gate's 0.5)
|
|
106
|
+
* @param {Array<string>} [opts.goldPhrases] EXTRA grounded gold (e.g. confirmed
|
|
107
|
+
* edit-corrections already on the stored profile), unioned with this cycle's.
|
|
108
|
+
* @returns {object} the same delta (inferences stamped in place + returned).
|
|
109
|
+
*/
|
|
110
|
+
export function stampPrecisionEligible(delta, opts = {}) {
|
|
111
|
+
if (!delta || typeof delta !== 'object' || !Array.isArray(delta.inferences)) {
|
|
112
|
+
return delta;
|
|
113
|
+
}
|
|
114
|
+
const threshold = Number.isFinite(opts.semanticThreshold)
|
|
115
|
+
? opts.semanticThreshold : SLUG_SEMANTIC_THRESHOLD;
|
|
116
|
+
|
|
117
|
+
// GOLD = the user's GROUNDED preferences: every edit-delta-grounded correction
|
|
118
|
+
// in this delta (the diff is the citation), PLUS any caller-supplied grounded
|
|
119
|
+
// phrases (already-confirmed corrections on the stored profile). Feedback-only
|
|
120
|
+
// atoms are NEVER gold — they cannot vouch for themselves.
|
|
121
|
+
const goldSet = new Set();
|
|
122
|
+
for (const inf of delta.inferences) {
|
|
123
|
+
if (isActionablePreference(inf) && isEditGrounded(inf)) {
|
|
124
|
+
const g = toSubject(preferencePhrase(inf));
|
|
125
|
+
if (g) goldSet.add(g);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const extra of (Array.isArray(opts.goldPhrases) ? opts.goldPhrases : [])) {
|
|
129
|
+
const g = toSubject(extra);
|
|
130
|
+
if (g) goldSet.add(g);
|
|
131
|
+
}
|
|
132
|
+
const corpus = { testPreferences: [...goldSet] };
|
|
133
|
+
|
|
134
|
+
for (const inf of delta.inferences) {
|
|
135
|
+
if (!isActionablePreference(inf)) continue; // dialectic/other -> not a pref slug
|
|
136
|
+
let eligible = false;
|
|
137
|
+
try {
|
|
138
|
+
const slug = toSubject(preferencePhrase(inf));
|
|
139
|
+
if (slug && goldSet.size > 0) {
|
|
140
|
+
const { labels } = labelSlugs([slug], corpus, { semanticThreshold: threshold });
|
|
141
|
+
eligible = labels.length === 1 && labels[0].label === SLUG_LABELS.CORRECT;
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
eligible = false; // fail-closed: an un-scorable slug never auto-injects
|
|
145
|
+
}
|
|
146
|
+
inf.precision_eligible = eligible;
|
|
147
|
+
}
|
|
148
|
+
return delta;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default { stampPrecisionEligible };
|