@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.
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,248 @@
1
+ // instrument-validation.mjs — Gate B v2, Task T2. THE HARD PREREQUISITE GATE.
2
+ //
3
+ // Before any cloud spend, prove the metric (T1 fullStyleDistance) actually discriminates
4
+ // INDIVIDUALS — not just registers. We compute same-author vs different-author distances
5
+ // over a labeled corpus and score the discriminator with AUC. The PRIMARY gate is the
6
+ // SAME-REGISTER AUC: can the metric tell apart two authors who write in the same register
7
+ // (the only thing that makes the wrong-target control meaningful). If the same-register
8
+ // discriminator is near chance, the whole behavioral question is unanswerable → report a
9
+ // NULL and CUT; do NOT loosen the thresholds to proceed.
10
+ //
11
+ // Anti-bias discipline (audit must-fix): authors and pairs are selected by IDENTITY and
12
+ // by REGISTER distance only — NEVER by the fullStyleDistance being scored. Selecting
13
+ // "well-separated" authors by the scored metric would inflate AUC. The same-register
14
+ // membership uses the 9-axis register metric (styleDistance), which is independent of the
15
+ // authorship sub-vectors under test.
16
+
17
+ import { fullStyleVector, fullStyleDistance, styleVector, styleDistance } from './stylometry.js';
18
+
19
+ export const DEFAULT_VALIDATION_CFG = Object.freeze({
20
+ registerDelta: 0.15, // same-register band on the 9-axis register metric
21
+ aucFloor: 0.80, // sanity floor on register-diverse AUC
22
+ sameRegisterCILower: 0.75, // PRIMARY gate: bootstrap lower bound on same-register AUC
23
+ minSameRegister: 150, // min same-register between-author pairs (else underpowered)
24
+ bootstrapB: 1000,
25
+ ciAlpha: 0.05, // one-sided lower bound at this alpha
26
+ seed: 1,
27
+ });
28
+
29
+ // ---- deterministic RNG (mulberry32) so bootstrap is reproducible ----
30
+ function mulberry32(seed) {
31
+ let a = seed >>> 0;
32
+ return function rng() {
33
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
34
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
35
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
36
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
37
+ };
38
+ }
39
+
40
+ // Mann-Whitney AUC with higher score = positive class, average-rank tie handling.
41
+ function aucHigherPos(posScores, negScores) {
42
+ const nP = posScores.length;
43
+ const nN = negScores.length;
44
+ if (!nP || !nN) return NaN;
45
+ const all = [];
46
+ for (const s of posScores) all.push({ s, p: 1 });
47
+ for (const s of negScores) all.push({ s, p: 0 });
48
+ all.sort((a, b) => a.s - b.s);
49
+ const N = all.length;
50
+ let rankSumPos = 0;
51
+ let i = 0;
52
+ while (i < N) {
53
+ let j = i;
54
+ while (j < N && all[j].s === all[i].s) j += 1;
55
+ const avgRank = (i + 1 + j) / 2; // ranks are 1-based; average over the tie block
56
+ for (let k = i; k < j; k += 1) if (all[k].p === 1) rankSumPos += avgRank;
57
+ i = j;
58
+ }
59
+ return (rankSumPos - (nP * (nP + 1)) / 2) / (nP * nN);
60
+ }
61
+
62
+ // Positive class = same-author pairs (should have LOW distance), so score = -distance
63
+ // (higher = more "same"). AUC = P(same-author pair ranks closer than diff-author pair).
64
+ function aucFromDistances(sameDists, diffDists) {
65
+ return aucHigherPos(sameDists.map((d) => -d), diffDists.map((d) => -d));
66
+ }
67
+
68
+ // Equal Error Rate over the distance threshold sweep.
69
+ function equalErrorRate(sameDists, diffDists) {
70
+ if (!sameDists.length || !diffDists.length) return NaN;
71
+ const ts = [...new Set([...sameDists, ...diffDists])].sort((a, b) => a - b);
72
+ let best = 1;
73
+ for (const t of ts) {
74
+ const fnr = sameDists.filter((d) => d > t).length / sameDists.length; // same called diff
75
+ const fpr = diffDists.filter((d) => d <= t).length / diffDists.length; // diff called same
76
+ best = Math.min(best, Math.max(fnr, fpr));
77
+ }
78
+ return best;
79
+ }
80
+
81
+ function mean(xs) { return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN; }
82
+
83
+ // Build flat sample list: one sample per document. Each carries authorId + precomputed
84
+ // register (9-axis) and full (authorship) vectors so pairwise distance is cheap.
85
+ //
86
+ // OPT-IN (Gate B v3 trained verifier): if `scorer` is supplied it carries a custom
87
+ // authorship representation + distance. `scorer.vectorize(text)` returns the opaque
88
+ // authorship object stored on `sample.full`; `scorer.distance(a.full, b.full)` returns a
89
+ // distance in [0,1] (higher = more different). The 9-axis REGISTER vector is ALWAYS the
90
+ // shipped styleVector — the same-register tag must stay independent of the authorship
91
+ // representation under test. When `scorer` is null, behavior is identical to before
92
+ // (fullStyleVector + fullStyleDistance).
93
+ function buildSamples(corpus, scorer = null) {
94
+ const samples = [];
95
+ for (const author of corpus) {
96
+ const docs = author.docs || [];
97
+ for (const text of docs) {
98
+ samples.push({
99
+ authorId: author.id,
100
+ register: styleVector(text),
101
+ full: scorer ? scorer.vectorize(text) : fullStyleVector(text),
102
+ });
103
+ }
104
+ }
105
+ return samples;
106
+ }
107
+
108
+ // Authorship distance: the injected scorer's distance, or the shipped composite.
109
+ function authDistance(aFull, bFull, scorer) {
110
+ return scorer ? scorer.distance(aFull, bFull) : fullStyleDistance(aFull, bFull);
111
+ }
112
+
113
+ // Enumerate same-author and diff-author pair distances. Diff pairs are tagged with
114
+ // whether they are SAME-REGISTER (register distance <= delta) — that tag depends ONLY on
115
+ // the register metric, never on the authorship distance under test.
116
+ function enumeratePairs(samples, delta, scorer = null) {
117
+ const same = [];
118
+ const diff = [];
119
+ const diffSameRegister = [];
120
+ for (let i = 0; i < samples.length; i += 1) {
121
+ for (let j = i + 1; j < samples.length; j += 1) {
122
+ const a = samples[i];
123
+ const b = samples[j];
124
+ const d = authDistance(a.full, b.full, scorer);
125
+ if (a.authorId === b.authorId) {
126
+ same.push(d);
127
+ } else {
128
+ diff.push(d);
129
+ if (styleDistance(a.register, b.register) <= delta) diffSameRegister.push(d);
130
+ }
131
+ }
132
+ }
133
+ return { same, diff, diffSameRegister };
134
+ }
135
+
136
+ // Author-level (cluster) bootstrap on the same-register AUC. Resamples AUTHORS with
137
+ // replacement; same-author distances come from each resampled author's own docs, and the
138
+ // same-register NEG pairs are drawn across DISTINCT resampled authors (a real author is
139
+ // never compared to a duplicate of themselves — that would inject distance~0 foreigners).
140
+ function bootstrapSameRegisterAucLower(corpus, delta, B, alpha, seed, scorer = null) {
141
+ const rng = mulberry32(seed);
142
+ const ids = corpus.map((a) => a.id);
143
+ const byId = new Map(corpus.map((a) => [a.id, a]));
144
+ const reps = [];
145
+ for (let b = 0; b < B; b += 1) {
146
+ const pick = [];
147
+ for (let k = 0; k < ids.length; k += 1) pick.push(ids[Math.floor(rng() * ids.length)]);
148
+ const distinct = [...new Set(pick)];
149
+ if (distinct.length < 2) continue;
150
+ // same-author distances: each occurrence contributes its within-author doc pairs
151
+ const sameD = [];
152
+ for (const id of pick) {
153
+ const s = buildSamples([byId.get(id)], scorer);
154
+ for (let i = 0; i < s.length; i += 1) {
155
+ for (let j = i + 1; j < s.length; j += 1) sameD.push(authDistance(s[i].full, s[j].full, scorer));
156
+ }
157
+ }
158
+ // same-register diff distances: across DISTINCT authors only
159
+ const distinctSamples = buildSamples(distinct.map((id) => byId.get(id)), scorer);
160
+ const negSR = [];
161
+ for (let i = 0; i < distinctSamples.length; i += 1) {
162
+ for (let j = i + 1; j < distinctSamples.length; j += 1) {
163
+ const a = distinctSamples[i];
164
+ const c = distinctSamples[j];
165
+ if (a.authorId !== c.authorId && styleDistance(a.register, c.register) <= delta) {
166
+ negSR.push(authDistance(a.full, c.full, scorer));
167
+ }
168
+ }
169
+ }
170
+ if (sameD.length && negSR.length) reps.push(aucFromDistances(sameD, negSR));
171
+ }
172
+ if (!reps.length) return { lower: NaN, reps: 0 };
173
+ reps.sort((a, b) => a - b);
174
+ const idx = Math.max(0, Math.floor(alpha * reps.length) - 1);
175
+ return { lower: reps[idx], reps: reps.length };
176
+ }
177
+
178
+ function fmt(x) { return Number.isFinite(x) ? x.toFixed(3) : 'NaN'; }
179
+
180
+ // Pure gate decision over the computed metrics. Extracted so the threshold logic is
181
+ // unit-testable independent of any fixture's AUC. NEVER loosens: every threshold is a
182
+ // hard floor; a failed check is named for honest reporting at the decision gate.
183
+ export function gateDecision(m, cfg) {
184
+ const checks = [];
185
+ if (!(Number.isFinite(m.auc) && m.auc >= cfg.aucFloor)) {
186
+ checks.push(`diverse AUC ${fmt(m.auc)} < ${cfg.aucFloor}`);
187
+ }
188
+ if (!(Number.isFinite(m.sameRegisterAucCILower) && m.sameRegisterAucCILower >= cfg.sameRegisterCILower)) {
189
+ checks.push(`same-register AUC CI-lower ${fmt(m.sameRegisterAucCILower)} < ${cfg.sameRegisterCILower}`);
190
+ }
191
+ if (!(m.nSameRegister >= cfg.minSameRegister)) {
192
+ checks.push(`nSameRegister ${m.nSameRegister} < ${cfg.minSameRegister}`);
193
+ }
194
+ if (!(Number.isFinite(m.withinMean) && Number.isFinite(m.betweenMean) && m.betweenMean > m.withinMean)) {
195
+ checks.push('betweenMean not > withinMean');
196
+ }
197
+ return { passes: checks.length === 0, failedChecks: checks };
198
+ }
199
+
200
+ // validateInstrument(corpus, preReg) → structured validation result + pass/fail gate.
201
+ // corpus: [{ id, docs: [text, ...] }, ...]. preReg.validation overrides DEFAULT_VALIDATION_CFG.
202
+ export function validateInstrument(corpus, preReg = {}) {
203
+ const cfg = { ...DEFAULT_VALIDATION_CFG, ...preReg.validation };
204
+ // OPT-IN scorer (Gate B v3 trained verifier). preReg.scorer = { vectorize, distance }.
205
+ // vectorize(text) -> opaque authorship object; distance(aFull, bFull) -> [0,1]. The
206
+ // corpus "docs" are the unit of pairing (for the verifier these are author CHUNKS, and
207
+ // distance is the trained-model same/diff distance for that chunk pair). When absent,
208
+ // behavior is byte-identical to the shipped frozen-composite path.
209
+ const scorer = preReg.scorer || null;
210
+ const samples = buildSamples(corpus, scorer);
211
+ const { same, diff, diffSameRegister } = enumeratePairs(samples, cfg.registerDelta, scorer);
212
+
213
+ const withinMean = mean(same);
214
+ const betweenMean = mean(diff);
215
+ const auc = aucFromDistances(same, diff);
216
+ const eer = equalErrorRate(same, diff);
217
+ const nSameRegister = diffSameRegister.length;
218
+ const sameRegisterAuc = nSameRegister ? aucFromDistances(same, diffSameRegister) : NaN;
219
+
220
+ const boot = nSameRegister
221
+ ? bootstrapSameRegisterAucLower(corpus, cfg.registerDelta, cfg.bootstrapB, cfg.ciAlpha, cfg.seed, scorer)
222
+ : { lower: NaN, reps: 0 };
223
+
224
+ const metrics = {
225
+ auc, sameRegisterAucCILower: boot.lower, nSameRegister, withinMean, betweenMean,
226
+ };
227
+ const { passes, failedChecks } = gateDecision(metrics, cfg);
228
+
229
+ return {
230
+ auc,
231
+ eer,
232
+ sameRegisterAuc,
233
+ sameRegisterAucCILower: boot.lower,
234
+ bootstrapReps: boot.reps,
235
+ withinMean,
236
+ betweenMean,
237
+ nWithin: same.length,
238
+ nBetween: diff.length,
239
+ nSameRegister,
240
+ cfg,
241
+ passes,
242
+ failedChecks,
243
+ };
244
+ }
245
+
246
+ export const __test = {
247
+ aucHigherPos, aucFromDistances, equalErrorRate, enumeratePairs, buildSamples, gateDecision,
248
+ };
@@ -0,0 +1,125 @@
1
+ // Gate B v2 — Task T2: instrument-validation gate. Tests the GATE LOGIC and the
2
+ // anti-bias selection rule on small fixtures. (The real >=150-pair / CI>=0.75 thresholds
3
+ // are exercised at run time on the Reddit corpus, not here — here we prove the gate
4
+ // REFUSES to pass when underpowered or below threshold, and never self-loosens.)
5
+
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { validateInstrument, __test } from './instrument-validation.mjs';
9
+
10
+ const {
11
+ aucFromDistances, equalErrorRate, enumeratePairs, buildSamples, gateDecision,
12
+ } = __test;
13
+
14
+ const GATE_CFG = {
15
+ aucFloor: 0.80, sameRegisterCILower: 0.75, minSameRegister: 150,
16
+ };
17
+ const PASSING = {
18
+ auc: 0.88, sameRegisterAucCILower: 0.78, nSameRegister: 200, withinMean: 0.3, betweenMean: 0.5,
19
+ };
20
+
21
+ // Three stylistically distinct authors, 4 docs each.
22
+ const FORMAL = [
23
+ 'The proposal, however, must be examined with care, for the implications of the decision extend well beyond the present matter.',
24
+ 'It is therefore evident that the committee, which has reviewed the whole of the evidence, will arrive at the conclusion in due course.',
25
+ 'The argument rests upon the assumption that the conditions of the experiment were, in the main, properly controlled and recorded.',
26
+ 'One must consider, moreover, the degree to which the results of the study may be generalized beyond the original population.',
27
+ ];
28
+ const CASUAL = [
29
+ "yeah i just think you're gonna like this one, it's kinda fun and you don't really have to do much honestly.",
30
+ 'ok so i tried it and it is like, totally fine? you just click the thing and it kinda works, no big deal.',
31
+ "i dunno, you could do it that way i guess, but honestly i'd just keep it simple, it is way easier that way.",
32
+ "lol yeah that's basically what i did too, you just gotta be patient and it'll sort itself out eventually.",
33
+ ];
34
+ const TERSE_TECH = [
35
+ 'Run the migration. Check the logs. If the index is missing, rebuild it and retry the job.',
36
+ 'Patch the handler. Add a guard for the null case. Ship it behind the flag, then enable ten percent.',
37
+ 'Cache is stale. Bump the version key. Invalidate on write. Measure the hit rate after deploy.',
38
+ 'Lock contention on the queue. Shard by tenant. Add backpressure. Alert if depth exceeds the threshold.',
39
+ ];
40
+ const CORPUS3 = [
41
+ { id: 'formal', docs: FORMAL },
42
+ { id: 'casual', docs: CASUAL },
43
+ { id: 'tech', docs: TERSE_TECH },
44
+ ];
45
+
46
+ test('AUC helper: perfect separation = 1, reversed = 0, identical = 0.5', () => {
47
+ assert.equal(aucFromDistances([0.1, 0.2], [0.8, 0.9]), 1);
48
+ assert.equal(aucFromDistances([0.8, 0.9], [0.1, 0.2]), 0);
49
+ assert.equal(aucFromDistances([0.5, 0.5], [0.5, 0.5]), 0.5);
50
+ });
51
+
52
+ test('EER is 0 for perfectly separated distance distributions', () => {
53
+ assert.equal(equalErrorRate([0.1, 0.15, 0.2], [0.7, 0.8, 0.9]), 0);
54
+ });
55
+
56
+ test('betweenMean > withinMean on a 3-author x 4-doc fixture (metric discriminates)', () => {
57
+ const r = validateInstrument(CORPUS3);
58
+ assert.ok(r.betweenMean > r.withinMean, `between ${r.betweenMean.toFixed(3)} > within ${r.withinMean.toFixed(3)}`);
59
+ assert.ok(r.auc > 0.5, `auc ${r.auc.toFixed(3)} above chance`);
60
+ });
61
+
62
+ test('same-register slice returns nSameRegister>0 + finite AUC + bootstrap CI', () => {
63
+ // Two DIFFERENT formal authors (same register band, different func-word fingerprint).
64
+ const P = [
65
+ 'The proposal, however, must be examined with the greatest care, for the consequences extend beyond the matter at hand.',
66
+ 'It is therefore the case that the observer, having weighed the evidence, will tend toward the cautious conclusion.',
67
+ ];
68
+ const Q = [
69
+ 'The findings are, consequently, of considerable importance; moreover, they bear directly upon the questions raised at the outset.',
70
+ 'Whereas the earlier account emphasized the structural causes, the present analysis attends, hence, to the procedural ones.',
71
+ ];
72
+ const corpus2 = [{ id: 'P', docs: P }, { id: 'Q', docs: Q }];
73
+ const r = validateInstrument(corpus2, { validation: { bootstrapB: 300, minSameRegister: 1 } });
74
+ assert.ok(r.nSameRegister > 0, `nSameRegister ${r.nSameRegister} > 0`);
75
+ assert.ok(Number.isFinite(r.sameRegisterAuc), 'sameRegisterAuc finite');
76
+ assert.ok(Number.isFinite(r.sameRegisterAucCILower), 'CI lower finite');
77
+ assert.ok(r.bootstrapReps > 0, 'bootstrap produced replicates');
78
+ });
79
+
80
+ test('GATE refuses to pass when nSameRegister < 150 (underpowered), reported honestly', () => {
81
+ const r = validateInstrument(CORPUS3); // tiny fixture, far below 150
82
+ assert.equal(r.passes, false);
83
+ assert.ok(r.failedChecks.some((c) => /nSameRegister/.test(c)), `failedChecks names the gap: ${r.failedChecks}`);
84
+ });
85
+
86
+ test('gateDecision PASSES only when ALL thresholds are met (not vacuously false)', () => {
87
+ assert.equal(gateDecision(PASSING, GATE_CFG).passes, true);
88
+ });
89
+
90
+ test('gateDecision FAILS on diverse AUC below floor — threshold not loosened', () => {
91
+ const g = gateDecision({ ...PASSING, auc: 0.79 }, GATE_CFG);
92
+ assert.equal(g.passes, false);
93
+ assert.ok(g.failedChecks.some((c) => /diverse AUC/.test(c)), g.failedChecks.join('; '));
94
+ });
95
+
96
+ test('gateDecision FAILS on same-register CI-lower below floor — the PRIMARY gate', () => {
97
+ const g = gateDecision({ ...PASSING, sameRegisterAucCILower: 0.74 }, GATE_CFG);
98
+ assert.equal(g.passes, false);
99
+ assert.ok(g.failedChecks.some((c) => /same-register AUC CI-lower/.test(c)), g.failedChecks.join('; '));
100
+ });
101
+
102
+ test('gateDecision FAILS on NaN same-register CI-lower (no same-register pairs found)', () => {
103
+ const g = gateDecision({ ...PASSING, sameRegisterAucCILower: NaN }, GATE_CFG);
104
+ assert.equal(g.passes, false);
105
+ assert.ok(g.failedChecks.some((c) => /same-register AUC CI-lower/.test(c)));
106
+ });
107
+
108
+ test('gateDecision FAILS on underpowered nSameRegister (<150), never auto-loosened', () => {
109
+ const g = gateDecision({ ...PASSING, nSameRegister: 149 }, GATE_CFG);
110
+ assert.equal(g.passes, false);
111
+ assert.ok(g.failedChecks.some((c) => /nSameRegister/.test(c)));
112
+ });
113
+
114
+ test('SELECTION-BIAS GUARD: same-register membership tracks REGISTER only, not authorship distance', () => {
115
+ // X and Y: near-identical register (both terse imperative), different authorship content.
116
+ const xText = 'Run it. Check it. Ship it.';
117
+ const yText = 'Stop it. Fix it. Test it.';
118
+ // Z: very different register (long formal).
119
+ const zText = 'The committee, however, having deliberated at considerable length upon the whole of the available evidence, ultimately resolved to defer the decision until a later and more convenient occasion.';
120
+ const samples = buildSamples([{ id: 'X', docs: [xText] }, { id: 'Y', docs: [yText] }, { id: 'Z', docs: [zText] }]);
121
+ const { diffSameRegister, diff } = enumeratePairs(samples, 0.15);
122
+ // X-Y (same register) is included; pairs involving Z (different register) are not all included.
123
+ assert.ok(diffSameRegister.length >= 1, 'at least the same-register X-Y pair is included');
124
+ assert.ok(diffSameRegister.length < diff.length, 'the register-distant pairs (with Z) are excluded');
125
+ });
@@ -0,0 +1,106 @@
1
+ // multi-subject-harness.mjs — Gate B v2, Task T4. Runs the arms for every subject and
2
+ // scores each arm's output against the subject's HELD-OUT TEST fingerprint.
3
+ //
4
+ // Arms:
5
+ // baseline — no style brief ('') → anchor
6
+ // derived — STYLE-AXIS-BAND-ONLY brief built from TRAIN register (no numbers, no
7
+ // raw user prose). Verdict-bearing.
8
+ // fewShotOracle— raw OWN_TRAIN exemplars injected as "match this voice" (public corpus
9
+ // only — see operator decision 1). The TRUE instrument ceiling. Verdict-bearing.
10
+ //
11
+ // Circularity kills enforced here:
12
+ // * Briefs are built from persona.trainDocs ONLY. The harness NEVER touches testDocs in a
13
+ // brief builder (AST guard test) and NEVER imports styleTargetFromAxes (AST guard test).
14
+ // * assertBriefNonLeaky throws if a non-baseline brief contains a verbatim OWN_test
15
+ // document or sits inside the func+trigram leak floor of OWN_test (prose-leak guard C1').
16
+ // * Scoring is PER-SUBJECT aggregated: all of a subject's probe outputs for an arm are
17
+ // concatenated into ONE authorship vector, so the high-dim func/trigram vector is stable
18
+ // (not per-probe noise).
19
+
20
+ import { styleVector, fullStyleVector, fullStyleDistance } from './stylometry.js';
21
+
22
+ export const ARMS = ['baseline', 'derived', 'fewShotOracle'];
23
+ export const VERDICT_ARMS = ['derived', 'fewShotOracle']; // baseline is the anchor, not a verdict
24
+ export const DEFAULT_PROBES = [
25
+ 'Write a short note about your plans for the coming week.',
26
+ 'Describe, in your own words, how you would approach a new project.',
27
+ 'Give a brief reaction to a change a teammate just proposed.',
28
+ ];
29
+
30
+ // Map the 9-axis register vector to neutral BAND words. No numbers; no raw user prose.
31
+ function describeBands(reg) {
32
+ const band = (v, lo, hi, low, mid, high) => (v < lo ? low : v > hi ? high : mid);
33
+ return [
34
+ `length ${band(reg.terseness, 0.4, 0.6, 'expansive', 'moderate', 'very terse')}`,
35
+ `tone ${band(reg.formality, 0.15, 0.4, 'casual', 'neutral', 'formal')}`,
36
+ reg.emojiRate > 0.08 ? 'uses emoji' : 'no emoji',
37
+ `punctuation ${band(reg.punctProfile, 0.25, 0.5, 'sparse', 'moderate', 'heavy')}`,
38
+ `hedging ${band(reg.hedgeRate, 0.1, 0.3, 'direct', 'some', 'frequent')}`,
39
+ ].join('; ');
40
+ }
41
+
42
+ // Deterministic exemplar selection from TRAIN docs (longest-first for signal density).
43
+ function pickExemplars(trainDocs, n) {
44
+ return [...trainDocs].sort((a, b) => b.length - a.length).slice(0, n);
45
+ }
46
+
47
+ // buildBriefs(persona, cfg) → { baseline, derived, fewShotOracle }. TRAIN-ONLY by
48
+ // construction — references persona.trainDocs and never persona.testDocs.
49
+ export function buildBriefs(persona, cfg = {}) {
50
+ const trainText = persona.trainDocs.join('\n');
51
+ const reg = styleVector(trainText);
52
+ const derived = `Write in this style — ${describeBands(reg)}.`;
53
+ const exemplars = pickExemplars(persona.trainDocs, cfg.nExemplars || 2);
54
+ const fewShotOracle = `Match the voice of these writing samples:\n${exemplars.map((e) => `"""${e}"""`).join('\n')}`;
55
+ return { baseline: '', derived, fewShotOracle };
56
+ }
57
+
58
+ const FUNC_TRI_WEIGHTS = { register: 0, func: 0.67, tri: 0.33, punct: 0 };
59
+
60
+ // assertBriefNonLeaky(brief, persona, cfg): a non-baseline brief must not contain OWN_test
61
+ // content. (1) no verbatim OWN_test document; (2) not inside the func+trigram leak floor of
62
+ // OWN_test (catches near-verbatim test prose). Train exemplars (disjoint docs) clear this.
63
+ export function assertBriefNonLeaky(brief, persona, cfg = {}) {
64
+ if (!brief) return; // baseline
65
+ const leakFloor = cfg.leakFloor ?? 0.02;
66
+ for (const td of persona.testDocs) {
67
+ if (td.length > 20 && brief.includes(td)) {
68
+ throw new Error(`brief leaks a verbatim OWN_test document (persona ${persona.id})`);
69
+ }
70
+ }
71
+ const d = fullStyleDistance(brief, persona.testDocs.join('\n'), FUNC_TRI_WEIGHTS);
72
+ if (d < leakFloor) {
73
+ throw new Error(`brief too close to OWN_test func+tri signature (${d.toFixed(3)} < ${leakFloor}, persona ${persona.id})`);
74
+ }
75
+ }
76
+
77
+ function buildPrompt(brief, task) {
78
+ return (brief ? `${brief}\n\n` : '') + `Task: ${task}`;
79
+ }
80
+
81
+ // runHarness(personas, { transport, probes, cfg }) → per-(subject, arm) aggregated result.
82
+ // transport: async (prompt) => string. Real cloud transport at run time; a style-faithful
83
+ // fake in tests.
84
+ export async function runHarness(personas, opts) {
85
+ const { transport, probes = DEFAULT_PROBES, cfg = {} } = opts;
86
+ if (typeof transport !== 'function') throw new Error('runHarness requires a transport(prompt)=>text');
87
+ const results = {};
88
+ for (const p of personas) {
89
+ const briefs = buildBriefs(p, cfg);
90
+ for (const arm of ARMS) if (arm !== 'baseline') assertBriefNonLeaky(briefs[arm], p, cfg);
91
+ results[p.id] = {};
92
+ for (const arm of ARMS) {
93
+ const outputs = [];
94
+ for (const task of probes) {
95
+ // eslint-disable-next-line no-await-in-loop
96
+ outputs.push(String(await transport(buildPrompt(briefs[arm], task))));
97
+ }
98
+ const aggregated = outputs.join('\n'); // PER-SUBJECT aggregation → one vector/arm
99
+ const vector = fullStyleVector(aggregated);
100
+ results[p.id][arm] = { vector, distOwn: fullStyleDistance(vector, p.fingerprint), outputs };
101
+ }
102
+ }
103
+ return { personaIds: personas.map((p) => p.id), arms: ARMS, results };
104
+ }
105
+
106
+ export const __test = { describeBands, pickExemplars, buildPrompt };
@@ -0,0 +1,99 @@
1
+ // Gate B v2 — Task T4: multi-subject harness. The load-bearing guards: briefs are
2
+ // TRAIN-only (dataflow + AST), the harness never imports styleTargetFromAxes, the leak
3
+ // guard catches OWN_test prose in a brief, scoring is per-subject aggregated, and a
4
+ // faithful agent makes the oracle beat baseline while a constant agent fabricates NO
5
+ // advantage (the harness can't manufacture an arm difference on its own).
6
+
7
+ import { test } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import {
12
+ buildBriefs, assertBriefNonLeaky, runHarness, ARMS,
13
+ } from './multi-subject-harness.mjs';
14
+ import { generatePersonaText } from './synthetic-personas.js';
15
+ import { fullStyleVector } from './stylometry.js';
16
+
17
+ // Formal personas (archetype 0), distinct content per persona.
18
+ function formalPersona(id, seed) {
19
+ const trainDocs = [generatePersonaText(0, seed + 1, 18), generatePersonaText(0, seed + 2, 18)];
20
+ const testDocs = [generatePersonaText(0, seed + 9001, 14)];
21
+ return {
22
+ id, synthetic: false, headlineEligible: true, trainDocs, testDocs,
23
+ fingerprint: fullStyleVector(testDocs.join('\n')),
24
+ };
25
+ }
26
+ const PERSONAS = [formalPersona('p1', 100), formalPersona('p2', 200), formalPersona('p3', 300)];
27
+ const RELAX = { cfg: { leakFloor: 0.001 } }; // templated synthetic train≈test; relax 2nd-tier floor
28
+
29
+ // Faithful agent: echoes injected exemplars (→ author style), else a fixed CASUAL default.
30
+ function faithfulAgent(prompt) {
31
+ const ex = prompt.match(/"""([\s\S]*?)"""/g);
32
+ if (ex) return ex.map((s) => s.replace(/"""/g, '')).join(' ');
33
+ return 'yeah it just kinda works i think, pretty simple honestly, no big deal at all and you can just go with it.';
34
+ }
35
+ // Constant agent: same output for every prompt — no arm can differ.
36
+ function constantAgent() { return 'the result, however, meets the stated goal; therefore the process follows the plan.'; }
37
+
38
+ test('buildBriefs: baseline is empty, arms differ only by brief, TRAIN-derived', () => {
39
+ const b = buildBriefs(PERSONAS[0]);
40
+ assert.equal(b.baseline, '');
41
+ assert.ok(b.derived.length > 0 && b.fewShotOracle.length > 0);
42
+ // fewShotOracle contains a TRAIN doc, never a TEST doc
43
+ assert.ok(PERSONAS[0].trainDocs.some((d) => b.fewShotOracle.includes(d)), 'oracle injects a train exemplar');
44
+ assert.ok(!PERSONAS[0].testDocs.some((d) => b.fewShotOracle.includes(d)), 'oracle never injects a test doc');
45
+ });
46
+
47
+ test('AST GUARD: buildBriefs references trainDocs and never testDocs; no styleTargetFromAxes', () => {
48
+ const src = fs.readFileSync(fileURLToPath(new URL('./multi-subject-harness.mjs', import.meta.url)), 'utf8');
49
+ // strip comments so the guard checks CODE, not its own documentation
50
+ const code = src.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
51
+ assert.ok(!/styleTargetFromAxes/.test(code), 'harness code must not import/use styleTargetFromAxes');
52
+ // extract the buildBriefs body by stable markers (next top-level statement)
53
+ const start = src.indexOf('export function buildBriefs');
54
+ const end = src.indexOf('const FUNC_TRI_WEIGHTS', start);
55
+ assert.ok(start !== -1 && end !== -1 && end > start, 'located buildBriefs body');
56
+ const body = src.slice(start, end);
57
+ assert.ok(/trainDocs/.test(body), 'buildBriefs uses trainDocs');
58
+ assert.ok(!/testDocs/.test(body), 'buildBriefs never touches testDocs');
59
+ });
60
+
61
+ test('LEAK GUARD: verbatim OWN_test prose in a brief throws; a generic brief passes', () => {
62
+ const p = PERSONAS[0];
63
+ // positive control: brief IS the test text → leaky
64
+ assert.throws(() => assertBriefNonLeaky(p.testDocs[0], p), /leak|too close/i);
65
+ // the derived band brief (generic English) is func/tri-far from the persona's test → ok
66
+ const briefs = buildBriefs(p);
67
+ assert.doesNotThrow(() => assertBriefNonLeaky(briefs.derived, p));
68
+ });
69
+
70
+ test('per-subject AGGREGATION: one authorship vector per (subject, arm)', async () => {
71
+ const out = await runHarness(PERSONAS, { transport: faithfulAgent, ...RELAX });
72
+ assert.deepEqual(out.arms, ARMS);
73
+ for (const id of out.personaIds) {
74
+ for (const arm of ARMS) {
75
+ const r = out.results[id][arm];
76
+ assert.equal(r.vector.__full, true, 'a single full vector per arm');
77
+ assert.equal(r.outputs.length, 3, 'three probe outputs aggregated into the one vector');
78
+ assert.ok(Number.isFinite(r.distOwn));
79
+ }
80
+ }
81
+ });
82
+
83
+ test('FAITHFUL agent: fewShotOracle beats baseline (mean distance to OWN test)', async () => {
84
+ const out = await runHarness(PERSONAS, { transport: faithfulAgent, ...RELAX });
85
+ const mean = (arm) => out.personaIds.reduce((s, id) => s + out.results[id][arm].distOwn, 0) / out.personaIds.length;
86
+ assert.ok(mean('fewShotOracle') < mean('baseline'), `oracle ${mean('fewShotOracle').toFixed(3)} < baseline ${mean('baseline').toFixed(3)}`);
87
+ });
88
+
89
+ test('CONSTANT agent: harness fabricates NO arm advantage (all arms equal distance)', async () => {
90
+ const out = await runHarness(PERSONAS, { transport: constantAgent, ...RELAX });
91
+ for (const id of out.personaIds) {
92
+ assert.equal(out.results[id].fewShotOracle.distOwn, out.results[id].baseline.distOwn);
93
+ assert.equal(out.results[id].derived.distOwn, out.results[id].baseline.distOwn);
94
+ }
95
+ });
96
+
97
+ test('runHarness requires a transport function', async () => {
98
+ await assert.rejects(() => runHarness(PERSONAS, {}), /transport/);
99
+ });
@@ -0,0 +1,83 @@
1
+ // Gate B v2 — Task T3: persona loaders. Real authors (document-disjoint train/test,
2
+ // seed-only selection) + synthetic (downgrade-only). The selection-bias guard is the
3
+ // load-bearing test: persona order must NOT depend on the authorship distance under test.
4
+
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import {
8
+ splitAuthorDocs, makePersona, loadRealPersonas,
9
+ } from './real-personas.mjs';
10
+ import { makePersonas, generatePersonaText } from './synthetic-personas.js';
11
+ import { fullStyleVector, fullStyleDistance } from './stylometry.js';
12
+
13
+ const SMALL = { minTrainTokens: 15, minTestTokens: 8, seed: 1 };
14
+
15
+ function makeAuthor(id, variant) {
16
+ // 4 unique docs, ~13 tokens each — clears the small floors with a disjoint split.
17
+ return {
18
+ id,
19
+ docs: Array.from({ length: 4 }, (_, i) => `${variant} document number ${i} with several extra filler words placed here to clear the token floor.`),
20
+ };
21
+ }
22
+
23
+ test('splitAuthorDocs produces DISJOINT train/test slices that clear the floors', () => {
24
+ const a = makeAuthor('a', 'alpha');
25
+ const s = splitAuthorDocs(a, SMALL);
26
+ assert.ok(s.trainTokens >= SMALL.minTrainTokens);
27
+ assert.ok(s.testTokens >= SMALL.minTestTokens);
28
+ const inter = s.trainDocs.filter((d) => s.testDocs.includes(d));
29
+ assert.equal(inter.length, 0, 'no document appears in both slices');
30
+ assert.ok(s.trainDocs.length + s.testDocs.length === 4, 'every doc allocated exactly once');
31
+ });
32
+
33
+ test('makePersona THROWS on an author too short to split with power', () => {
34
+ const tiny = { id: 'tiny', docs: ['hi there', 'ok then'] };
35
+ assert.throws(() => makePersona(tiny, SMALL), /too short|< \d+ tokens/);
36
+ });
37
+
38
+ test('persona.fingerprint is the held-out TEST fingerprint (not train)', () => {
39
+ const p = makePersona(makeAuthor('a', 'alpha'), SMALL);
40
+ assert.equal(p.fingerprint.__full, true);
41
+ const fromTest = fullStyleVector(p.testDocs.join('\n'));
42
+ assert.equal(fullStyleDistance(p.fingerprint, fromTest), 0, 'fingerprint == fullStyleVector(testDocs)');
43
+ assert.equal(p.synthetic, false);
44
+ assert.equal(p.headlineEligible, true);
45
+ });
46
+
47
+ test('SELECTION-BIAS GUARD: persona order is a pure function of seed, not of content/distance', () => {
48
+ const ids = ['zeta', 'alpha', 'mike', 'delta'];
49
+ const corpusA = ids.map((id) => makeAuthor(id, 'alpha-content'));
50
+ // SAME ids, DIFFERENT text → different mutual fullStyleDistances
51
+ const corpusB = ids.map((id) => makeAuthor(id, 'totally different wording entirely'));
52
+ const orderA = loadRealPersonas(corpusA, SMALL).map((p) => p.id);
53
+ const orderB = loadRealPersonas(corpusB, SMALL).map((p) => p.id);
54
+ assert.deepEqual(orderA, orderB, 'identical ids ⇒ identical order regardless of content');
55
+ // and changing the seed changes the order (it really is seed-driven)
56
+ const orderSeed2 = loadRealPersonas(corpusA, { ...SMALL, seed: 99 }).map((p) => p.id);
57
+ assert.notDeepEqual(orderA, orderSeed2);
58
+ });
59
+
60
+ test('loadRealPersonas THROWS (abort-and-ingest) when too few authors qualify', () => {
61
+ const corpus = [makeAuthor('a', 'x'), { id: 'short', docs: ['too', 'short'] }];
62
+ assert.throws(() => loadRealPersonas(corpus, { ...SMALL, nAuthors: 2 }), /ingest more authors/);
63
+ });
64
+
65
+ test('synthetic personas are stamped downgrade-only and deterministic', () => {
66
+ const p1 = makePersonas(4, 7);
67
+ const p2 = makePersonas(4, 7);
68
+ assert.equal(p1.length, 4);
69
+ for (const p of p1) {
70
+ assert.equal(p.synthetic, true);
71
+ assert.equal(p.headlineEligible, false);
72
+ assert.equal(p.fingerprint.__full, true);
73
+ }
74
+ // deterministic by seed
75
+ assert.deepEqual(p1.map((p) => p.testDocs), p2.map((p) => p.testDocs));
76
+ // mutually distinct fingerprints (different archetypes/content)
77
+ assert.ok(fullStyleDistance(p1[0].fingerprint, p1[1].fingerprint) > 0);
78
+ });
79
+
80
+ test('generatePersonaText: same style+content seed is deterministic; train/test differ', () => {
81
+ assert.equal(generatePersonaText(0, 123, 5), generatePersonaText(0, 123, 5));
82
+ assert.notEqual(generatePersonaText(0, 1, 5), generatePersonaText(0, 9001, 5));
83
+ });