@pablotech/neuro 0.1.22
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/README.md +622 -0
- package/benchmarks/staleness-corpus.ts +603 -0
- package/canonical.ts +54 -0
- package/cli.ts +201 -0
- package/compare.ts +41 -0
- package/dag.ts +81 -0
- package/hash-node.ts +8 -0
- package/hash-web.ts +8 -0
- package/index.ts +11 -0
- package/markdown.ts +120 -0
- package/mermaid.ts +37 -0
- package/package.json +32 -0
- package/validate.ts +98 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// What this measures, and why it is built the way it is.
|
|
2
|
+
//
|
|
3
|
+
// README.md's claims about staleness are claims about OUTPUT: "a key whose stamp moved is reported
|
|
4
|
+
// stale even when regenerating it would have produced the same answer" (canonical.ts:46-48). A
|
|
5
|
+
// corpus that compares the engine's drift set against a graph walk cannot measure that — both sides
|
|
6
|
+
// are reachability, so the comparison is a graph identity that holds for any correct closure
|
|
7
|
+
// implementation and reports the same number whatever the engine does.
|
|
8
|
+
//
|
|
9
|
+
// So every derived node here carries a REAL derivation: a pure function of its inputs' values.
|
|
10
|
+
// Ground truth is an evaluation, not a walk — compute every node, perturb, compute again, and take
|
|
11
|
+
// the nodes whose value actually changed. That makes two quantities measurable:
|
|
12
|
+
//
|
|
13
|
+
// false negatives a node whose output changed and was NOT reported stale -> soundness
|
|
14
|
+
// false positives a node reported stale whose output is identical -> over-approximation
|
|
15
|
+
//
|
|
16
|
+
// The second is the number README.md § "What this trades away" is about and the reason the mix of
|
|
17
|
+
// derivations below matters: information-LOSSY derivations (a max, a threshold, a rounding) absorb
|
|
18
|
+
// upstream changes, and every node whose derivation absorbed one is a regeneration that would have
|
|
19
|
+
// been paid for nothing. A corpus of only information-preserving derivations cannot produce a single
|
|
20
|
+
// false positive and would report a precision of 1.000 no matter how coarse the engine was.
|
|
21
|
+
//
|
|
22
|
+
// The truth path never consults canonicalMap, driftedKeys, sourceClosureOf or downstreamOf. It reads
|
|
23
|
+
// `spec.inputs` one hop at a time because computing a value requires knowing what feeds it — that is
|
|
24
|
+
// evaluation, not reachability, and it is the one thing a ground truth for this claim cannot avoid.
|
|
25
|
+
//
|
|
26
|
+
// npx tsx benchmarks/staleness-corpus.ts [--seed N] [--graphs N] [--json] [--no-scaling]
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { defineDag, type Dag, type DagNode } from "../dag";
|
|
29
|
+
import { canonicalMap, driftedKeys, type Slices } from "../canonical";
|
|
30
|
+
import { validate, sliceParity } from "../validate";
|
|
31
|
+
import { sha256hex12 } from "../hash-node";
|
|
32
|
+
|
|
33
|
+
/** Source key -> the raw text that source owns. Slices normalize it; derivations read it. */
|
|
34
|
+
export type Subject = Record<string, string>;
|
|
35
|
+
|
|
36
|
+
/** `sum` is information-preserving: change any input and the output moves. The other four discard
|
|
37
|
+
* information, which is what lets a real upstream change leave a downstream value untouched. A
|
|
38
|
+
* clinical "elevated / normal" verdict is a threshold over a number, so this is the ordinary case
|
|
39
|
+
* rather than an adversarial one. */
|
|
40
|
+
export type DerivationKind = "sum" | "max" | "threshold" | "round" | "parity";
|
|
41
|
+
|
|
42
|
+
export const PRESERVING: DerivationKind[] = ["sum"];
|
|
43
|
+
export const LOSSY: DerivationKind[] = ["max", "threshold", "round", "parity"];
|
|
44
|
+
|
|
45
|
+
export interface Derivation {
|
|
46
|
+
kind: DerivationKind;
|
|
47
|
+
k: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type Derivations = Record<string, Derivation>;
|
|
51
|
+
|
|
52
|
+
export interface Corpus {
|
|
53
|
+
specs: DagNode[];
|
|
54
|
+
dag: Dag;
|
|
55
|
+
subject: Subject;
|
|
56
|
+
slices: Slices<Subject>;
|
|
57
|
+
derivations: Derivations;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface GraphShape {
|
|
61
|
+
sources: number;
|
|
62
|
+
derived: number;
|
|
63
|
+
leaves: number;
|
|
64
|
+
maxFanIn: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const DEFAULT_SHAPE: GraphShape = { sources: 8, derived: 12, leaves: 4, maxFanIn: 3 };
|
|
68
|
+
export const DEFAULT_GRAPHS = 200;
|
|
69
|
+
export const DEFAULT_SEED = 20260830;
|
|
70
|
+
|
|
71
|
+
/** Prefix for the source nodes that the "declare the derivation" remedy introduces. Excluded from
|
|
72
|
+
* evaluation inputs — see evaluate(). */
|
|
73
|
+
export const PROMPT_PREFIX = "prompt/";
|
|
74
|
+
|
|
75
|
+
/** mulberry32 — small, seeded, identical across platforms, so a run reproduces from its seed. */
|
|
76
|
+
function rng(seed: number): () => number {
|
|
77
|
+
let a = seed >>> 0;
|
|
78
|
+
return () => {
|
|
79
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
80
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
81
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
82
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The host-side normalization README.md names as the lever against over-approximation: formatting
|
|
87
|
+
* collapsed here never reaches the hash. Derivations normalize too, so a cosmetic edit changes no
|
|
88
|
+
* output either — which is what makes the cosmetic row a real precision measurement rather than an
|
|
89
|
+
* assertion about string handling. */
|
|
90
|
+
export function normalize(raw: string): string {
|
|
91
|
+
return raw.trim().replace(/\s+/g, " ").toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A source node's value: the number its raw text carries, read through the same normalization. */
|
|
95
|
+
export function sourceValue(raw: string): number {
|
|
96
|
+
const m = normalize(raw).match(/-?\d+/);
|
|
97
|
+
return m ? Number(m[0]) : 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function describeDerivation(d: Derivation): string {
|
|
101
|
+
return `${d.kind} over inputs, k=${d.k}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function applyDerivation(d: Derivation, inputs: number[]): number {
|
|
105
|
+
const sum = inputs.reduce((a, b) => a + b, 0);
|
|
106
|
+
switch (d.kind) {
|
|
107
|
+
case "sum":
|
|
108
|
+
return sum + d.k;
|
|
109
|
+
case "max":
|
|
110
|
+
return (inputs.length === 0 ? 0 : Math.max(...inputs)) + d.k;
|
|
111
|
+
case "threshold":
|
|
112
|
+
return sum > d.k ? 1 : 0;
|
|
113
|
+
case "round":
|
|
114
|
+
return Math.round(sum / d.k) * d.k;
|
|
115
|
+
case "parity":
|
|
116
|
+
return (sum + d.k) % 2;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function generateCorpus(seed: number, shape: GraphShape = DEFAULT_SHAPE): Corpus {
|
|
121
|
+
const rand = rng(seed);
|
|
122
|
+
const pick = <T,>(xs: T[]): T => xs[Math.floor(rand() * xs.length)];
|
|
123
|
+
|
|
124
|
+
const specs: DagNode[] = [];
|
|
125
|
+
const derivations: Derivations = {};
|
|
126
|
+
const sourceKeys: string[] = [];
|
|
127
|
+
for (let i = 0; i < shape.sources; i++) {
|
|
128
|
+
const key = `src/${i}`;
|
|
129
|
+
sourceKeys.push(key);
|
|
130
|
+
specs.push({ key, label: `source ${i}`, kind: "source", inputs: [], basis: `raw reading ${i}` });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Every source gets a consumer, so a generated graph lints clean — an `orphan` finding would mean
|
|
134
|
+
// the generator, not the engine, is what the corpus is measuring.
|
|
135
|
+
const unconsumed = [...sourceKeys];
|
|
136
|
+
const built: string[] = [...sourceKeys];
|
|
137
|
+
|
|
138
|
+
const addNode = (key: string, kind: "derived" | "leaf", label: string) => {
|
|
139
|
+
const inputs = new Set<string>();
|
|
140
|
+
if (unconsumed.length > 0) inputs.add(unconsumed.shift() as string);
|
|
141
|
+
const fanIn = 1 + Math.floor(rand() * shape.maxFanIn);
|
|
142
|
+
while (inputs.size < fanIn) inputs.add(pick(built));
|
|
143
|
+
// Roughly two lossy derivations for every preserving one. Both families have to be present: with
|
|
144
|
+
// only preserving ones there are no false positives to find, with only lossy ones a false
|
|
145
|
+
// negative could hide behind an absorbed change.
|
|
146
|
+
const kind_ = rand() < 0.34 ? pick(PRESERVING) : pick(LOSSY);
|
|
147
|
+
const d: Derivation = { kind: kind_, k: 2 + Math.floor(rand() * 40) };
|
|
148
|
+
derivations[key] = d;
|
|
149
|
+
specs.push({ key, label, kind, inputs: [...inputs], basis: describeDerivation(d) });
|
|
150
|
+
built.push(key);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < shape.derived; i++) addNode(`der/${i}`, "derived", `derived ${i}`);
|
|
154
|
+
for (let i = 0; i < shape.leaves; i++) addNode(`leaf/${i}`, "leaf", `leaf ${i}`);
|
|
155
|
+
|
|
156
|
+
// Any source still unconsumed is wired into the last node rather than left to trip validate()'s
|
|
157
|
+
// orphan rule.
|
|
158
|
+
if (unconsumed.length > 0) {
|
|
159
|
+
const last = specs[specs.length - 1];
|
|
160
|
+
last.inputs = [...new Set([...last.inputs, ...unconsumed])];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const subject: Subject = {};
|
|
164
|
+
const slices: Slices<Subject> = {};
|
|
165
|
+
for (const key of sourceKeys) {
|
|
166
|
+
subject[key] = ` Reading ${Math.floor(rand() * 1000)} `;
|
|
167
|
+
slices[key] = (s) => normalize(s[key] ?? "");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { specs, dag: defineDag(specs), subject, slices, derivations };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Ground truth. Every node's actual value, computed by running the derivations — never by asking
|
|
174
|
+
* the engine what it thinks moved. `specs` is in topological order by construction (a node's inputs
|
|
175
|
+
* are only ever drawn from nodes already built), so one forward pass suffices.
|
|
176
|
+
*
|
|
177
|
+
* `prompt/*` inputs are skipped: the remedy's source node carries the derivation's own text, and
|
|
178
|
+
* that dependence is already expressed by `derivations[key]`. Feeding it in as a number too would
|
|
179
|
+
* count it twice and make the declared corpus compute different values from the plain one, which
|
|
180
|
+
* would leave the two conditions incomparable. */
|
|
181
|
+
export function evaluate(
|
|
182
|
+
corpus: Corpus,
|
|
183
|
+
subject: Subject = corpus.subject,
|
|
184
|
+
derivations: Derivations = corpus.derivations,
|
|
185
|
+
): Record<string, number> {
|
|
186
|
+
const values: Record<string, number> = {};
|
|
187
|
+
for (const spec of corpus.specs) {
|
|
188
|
+
if (spec.kind === "source") {
|
|
189
|
+
values[spec.key] = sourceValue(subject[spec.key] ?? "");
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const inputs = spec.inputs.filter((i) => !i.startsWith(PROMPT_PREFIX)).map((i) => values[i] ?? 0);
|
|
193
|
+
values[spec.key] = applyDerivation(derivations[spec.key], inputs);
|
|
194
|
+
}
|
|
195
|
+
return values;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The keys whose computed value actually differs — the only definition of "stale" that does not
|
|
199
|
+
* presuppose the engine's answer. */
|
|
200
|
+
export function changedKeys(before: Record<string, number>, after: Record<string, number>): Set<string> {
|
|
201
|
+
const out = new Set<string>();
|
|
202
|
+
for (const k of Object.keys(after)) if (before[k] !== after[k]) out.add(k);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Every derivation rewritten: same graph, same sources, different function. `basis` moves with it,
|
|
207
|
+
* so the declared-derivation remedy has real content to hash. */
|
|
208
|
+
export function rewriteDerivations(derivations: Derivations): Derivations {
|
|
209
|
+
const out: Derivations = {};
|
|
210
|
+
for (const [key, d] of Object.entries(derivations)) out[key] = { kind: d.kind, k: d.k + 1 };
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The buy-back README.md § "What this does not catch" prescribes: re-declare every derivation as a
|
|
215
|
+
* source node, so the derivation text enters the closure like any other input. */
|
|
216
|
+
export function withDeclaredDerivations(corpus: Corpus): Corpus {
|
|
217
|
+
const specs: DagNode[] = [];
|
|
218
|
+
const subject: Subject = { ...corpus.subject };
|
|
219
|
+
const slices: Slices<Subject> = { ...corpus.slices };
|
|
220
|
+
|
|
221
|
+
for (const n of corpus.specs) {
|
|
222
|
+
if (n.kind === "source") {
|
|
223
|
+
specs.push(n);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const promptKey = `${PROMPT_PREFIX}${n.key}`;
|
|
227
|
+
specs.push({ key: promptKey, label: `derivation of ${n.key}`, kind: "source", inputs: [], basis: "the derivation itself" });
|
|
228
|
+
specs.push({ ...n, inputs: [...n.inputs, promptKey] });
|
|
229
|
+
subject[promptKey] = n.basis;
|
|
230
|
+
slices[promptKey] = (s) => normalize(s[promptKey] ?? "");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { specs, dag: defineDag(specs), subject, slices, derivations: corpus.derivations };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The subject for a declared corpus after its derivations are rewritten. */
|
|
237
|
+
export function declaredSubjectFor(corpus: Corpus, declared: Corpus, rewritten: Derivations): Subject {
|
|
238
|
+
const out: Subject = { ...declared.subject };
|
|
239
|
+
for (const n of corpus.specs) {
|
|
240
|
+
if (n.kind === "source") continue;
|
|
241
|
+
out[`${PROMPT_PREFIX}${n.key}`] = describeDerivation(rewritten[n.key]);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The universe every rate below is taken over: nodes something has to *produce*. A source is not
|
|
247
|
+
* regenerated, it is edited — so counting the source you just changed as a correctly-reported stale
|
|
248
|
+
* node pads the hit column with a triviality and flatters precision. The claim being measured is
|
|
249
|
+
* about what regeneration costs, so the denominator is what can be regenerated. */
|
|
250
|
+
const regenerableKeys = (specs: DagNode[]) =>
|
|
251
|
+
new Set(specs.filter((n) => n.kind === "derived" || n.kind === "leaf").map((n) => n.key));
|
|
252
|
+
|
|
253
|
+
export interface Confusion {
|
|
254
|
+
/** Output changed, engine stayed silent. The soundness failure. */
|
|
255
|
+
missed: number;
|
|
256
|
+
/** Engine reported stale, output identical. The over-approximation — unnecessary regeneration. */
|
|
257
|
+
unnecessary: number;
|
|
258
|
+
/** Reported and genuinely changed. */
|
|
259
|
+
hit: number;
|
|
260
|
+
/** Nodes whose output actually changed. */
|
|
261
|
+
truth: number;
|
|
262
|
+
/** Nodes the engine reported. */
|
|
263
|
+
reported: number;
|
|
264
|
+
/** Every node something has to produce — the denominator the no-tracking baseline regenerates. */
|
|
265
|
+
regenerable: number;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const emptyConfusion = (): Confusion => ({ missed: 0, unnecessary: 0, hit: 0, truth: 0, reported: 0, regenerable: 0 });
|
|
269
|
+
|
|
270
|
+
/** What you do without any staleness tracking: regenerate everything. It is *also* sound — it misses
|
|
271
|
+
* nothing — which is what makes it the fair comparator for over-approximation. The engine is not
|
|
272
|
+
* competing against perfection here; it is competing against regenerating the lot. */
|
|
273
|
+
export function regenerateEverything(c: Confusion): Confusion {
|
|
274
|
+
const { regenerable, truth } = c;
|
|
275
|
+
return { missed: 0, unnecessary: regenerable - truth, hit: truth, truth, reported: regenerable, regenerable };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function accumulate(into: Confusion, truth: Set<string>, reported: string[], universe: Set<string>): void {
|
|
279
|
+
const engine = new Set(reported.filter((k) => universe.has(k)));
|
|
280
|
+
const real = new Set([...truth].filter((k) => universe.has(k)));
|
|
281
|
+
for (const k of real) if (engine.has(k)) into.hit++;
|
|
282
|
+
else into.missed++;
|
|
283
|
+
for (const k of engine) if (!real.has(k)) into.unnecessary++;
|
|
284
|
+
into.truth += real.size;
|
|
285
|
+
into.reported += engine.size;
|
|
286
|
+
into.regenerable += universe.size;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type ConditionKey = "sourceEdit" | "cosmeticEdit" | "derivationEdit" | "derivationDeclared" | "sliceDropped";
|
|
290
|
+
|
|
291
|
+
export interface CorpusResult {
|
|
292
|
+
graphs: number;
|
|
293
|
+
seed: number;
|
|
294
|
+
conditions: Record<ConditionKey, Confusion>;
|
|
295
|
+
/** A deleted node's stamp is never revisited: driftedKeys iterates the fresh map. */
|
|
296
|
+
deletedNode: { reported: number; total: number };
|
|
297
|
+
/** sliceParity is the lint rule that names the gap hashing cannot see. */
|
|
298
|
+
sliceParityCaught: { caught: number; total: number };
|
|
299
|
+
lintClean: { clean: number; total: number };
|
|
300
|
+
/** Distinct canonical strings seen, and distinct 12-hex-char hashes they produced. */
|
|
301
|
+
collisions: { strings: number; hashes: number };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** How a stamp is computed. Injectable for one reason: a row that scores the same for two different
|
|
305
|
+
* correct-looking engines is not measuring the engine. tests/staleness-corpus.test.ts runs a
|
|
306
|
+
* direct-input variant through this same corpus and asserts the soundness row separates them —
|
|
307
|
+
* and, in the other direction, that ground truth does NOT move when the engine is swapped. */
|
|
308
|
+
export type Engine = (corpus: Corpus, subject: Subject) => Record<string, string>;
|
|
309
|
+
|
|
310
|
+
export const sourceClosureEngine: Engine = (corpus, subject) => canonicalMap(corpus.dag, subject, corpus.slices);
|
|
311
|
+
|
|
312
|
+
export function runCorpus(
|
|
313
|
+
seed = DEFAULT_SEED,
|
|
314
|
+
graphs = DEFAULT_GRAPHS,
|
|
315
|
+
shape: GraphShape = DEFAULT_SHAPE,
|
|
316
|
+
engine: Engine = sourceClosureEngine,
|
|
317
|
+
): CorpusResult {
|
|
318
|
+
const conditions = {
|
|
319
|
+
sourceEdit: emptyConfusion(),
|
|
320
|
+
cosmeticEdit: emptyConfusion(),
|
|
321
|
+
derivationEdit: emptyConfusion(),
|
|
322
|
+
derivationDeclared: emptyConfusion(),
|
|
323
|
+
sliceDropped: emptyConfusion(),
|
|
324
|
+
} as Record<ConditionKey, Confusion>;
|
|
325
|
+
|
|
326
|
+
const r: CorpusResult = {
|
|
327
|
+
graphs,
|
|
328
|
+
seed,
|
|
329
|
+
conditions,
|
|
330
|
+
deletedNode: { reported: 0, total: 0 },
|
|
331
|
+
sliceParityCaught: { caught: 0, total: 0 },
|
|
332
|
+
lintClean: { clean: 0, total: 0 },
|
|
333
|
+
collisions: { strings: 0, hashes: 0 },
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const canonicalStrings = new Set<string>();
|
|
337
|
+
|
|
338
|
+
for (let g = 0; g < graphs; g++) {
|
|
339
|
+
const corpus = generateCorpus(seed + g, shape);
|
|
340
|
+
const { specs, dag, subject, slices } = corpus;
|
|
341
|
+
const regenerable = regenerableKeys(specs);
|
|
342
|
+
const sources = specs.filter((n) => n.kind === "source");
|
|
343
|
+
const target = sources[g % sources.length].key;
|
|
344
|
+
|
|
345
|
+
r.lintClean.total++;
|
|
346
|
+
if (validate(dag).length === 0) r.lintClean.clean++;
|
|
347
|
+
|
|
348
|
+
const before = engine(corpus, subject);
|
|
349
|
+
for (const s of Object.values(before)) canonicalStrings.add(s);
|
|
350
|
+
const valuesBefore = evaluate(corpus);
|
|
351
|
+
|
|
352
|
+
// A — a source edit. FN is the soundness claim; FP is the over-approximation claim.
|
|
353
|
+
const moved: Subject = { ...subject, [target]: ` Reading ${sourceValue(subject[target]) + 7} ` };
|
|
354
|
+
const movedTruth = changedKeys(valuesBefore, evaluate(corpus, moved));
|
|
355
|
+
accumulate(conditions.sourceEdit, movedTruth, driftedKeys(engine(corpus, moved), before), regenerable);
|
|
356
|
+
|
|
357
|
+
// B — an edit the slice normalizes away. Nothing moved and nothing should be reported.
|
|
358
|
+
const cosmetic: Subject = { ...subject, [target]: `\n ${subject[target].toUpperCase()} ` };
|
|
359
|
+
const cosmeticTruth = changedKeys(valuesBefore, evaluate(corpus, cosmetic));
|
|
360
|
+
accumulate(conditions.cosmeticEdit, cosmeticTruth, driftedKeys(engine(corpus, cosmetic), before), regenerable);
|
|
361
|
+
|
|
362
|
+
// C — the documented blind spot. Every derivation rewritten, every source untouched.
|
|
363
|
+
const rewritten = rewriteDerivations(corpus.derivations);
|
|
364
|
+
const rewrittenSpecs = specs.map((n) => (n.kind === "source" ? n : { ...n, basis: describeDerivation(rewritten[n.key]) }));
|
|
365
|
+
const rewrittenCorpus: Corpus = { ...corpus, specs: rewrittenSpecs, dag: defineDag(rewrittenSpecs), derivations: rewritten };
|
|
366
|
+
const rewrittenTruth = changedKeys(valuesBefore, evaluate(corpus, subject, rewritten));
|
|
367
|
+
accumulate(conditions.derivationEdit, rewrittenTruth, driftedKeys(engine(rewrittenCorpus, subject), before), regenerable);
|
|
368
|
+
|
|
369
|
+
// D — the remedy: the same rewrite with the derivation declared as a source, same graph.
|
|
370
|
+
const declared = withDeclaredDerivations(corpus);
|
|
371
|
+
const declaredRegenerable = regenerableKeys(declared.specs);
|
|
372
|
+
const declaredBefore = engine(declared, declared.subject);
|
|
373
|
+
const declaredValuesBefore = evaluate(declared);
|
|
374
|
+
const declaredAfter = declaredSubjectFor(corpus, declared, rewritten);
|
|
375
|
+
const declaredTruth = changedKeys(declaredValuesBefore, evaluate(declared, declaredAfter, rewritten));
|
|
376
|
+
accumulate(conditions.derivationDeclared, declaredTruth, driftedKeys(engine(declared, declaredAfter), declaredBefore), declaredRegenerable);
|
|
377
|
+
|
|
378
|
+
// E — a source with no slice function. The same real edit as A, now invisible to the hash.
|
|
379
|
+
const { [target]: _dropped, ...gapped } = slices;
|
|
380
|
+
const gappedCorpus: Corpus = { ...corpus, slices: gapped };
|
|
381
|
+
const gappedBefore = engine(gappedCorpus, subject);
|
|
382
|
+
accumulate(conditions.sliceDropped, movedTruth, driftedKeys(engine(gappedCorpus, moved), gappedBefore), regenerable);
|
|
383
|
+
r.sliceParityCaught.total++;
|
|
384
|
+
if (sliceParity(dag, gapped).some((f) => f.rule === "missing-slice" && f.node === target)) r.sliceParityCaught.caught++;
|
|
385
|
+
|
|
386
|
+
// F — a deleted node. ARCHITECTURE.md is explicit that driftedKeys iterates the fresh map, so a
|
|
387
|
+
// key that has left the graph is never reported. The last spec is safe to remove: nothing was
|
|
388
|
+
// built after it, so nothing consumes it.
|
|
389
|
+
const victim = specs[specs.length - 1].key;
|
|
390
|
+
const reducedSpecs = specs.filter((n) => n.key !== victim);
|
|
391
|
+
const reduced: Corpus = { ...corpus, specs: reducedSpecs, dag: defineDag(reducedSpecs) };
|
|
392
|
+
r.deletedNode.total++;
|
|
393
|
+
if (driftedKeys(engine(reduced, subject), before).includes(victim)) r.deletedNode.reported++;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
r.collisions.strings = canonicalStrings.size;
|
|
397
|
+
r.collisions.hashes = new Set([...canonicalStrings].map(sha256hex12)).size;
|
|
398
|
+
return r;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- reported alongside the corpus ------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/** Probability that some pair among `n` random 48-bit hashes collides — the birthday bound for the
|
|
404
|
+
* truncation ARCHITECTURE.md calls a deliberate tradeoff without giving it a number. */
|
|
405
|
+
export function collisionProbability(n: number, bits = 48): number {
|
|
406
|
+
return -Math.expm1((-n * (n - 1)) / (2 * Math.pow(2, bits)));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export interface ScalingPoint {
|
|
410
|
+
nodes: number;
|
|
411
|
+
ms: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A chain: the worst case for depth, and the shape README.md's "cheap by construction at any
|
|
415
|
+
* depth" is about. Each node's source closure is walked from scratch with no memoization between
|
|
416
|
+
* nodes (canonical.ts:33,41), so depth d costs a walk of length d. */
|
|
417
|
+
function chainSpecs(nodes: number): DagNode[] {
|
|
418
|
+
const specs: DagNode[] = [{ key: "src/0", label: "source", kind: "source", inputs: [], basis: "raw" }];
|
|
419
|
+
for (let i = 1; i < nodes; i++) {
|
|
420
|
+
specs.push({ key: `der/${i}`, label: `derived ${i}`, kind: "derived", inputs: [i === 1 ? "src/0" : `der/${i - 1}`], basis: "chain" });
|
|
421
|
+
}
|
|
422
|
+
return specs;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A wide, shallow random graph at the same node counts — reported alongside the chain so the
|
|
426
|
+
* exponent below cannot be dismissed as an artefact of one pathological shape. */
|
|
427
|
+
function wideSpecs(nodes: number, seed: number): DagNode[] {
|
|
428
|
+
const sources = Math.max(1, Math.floor(nodes / 4));
|
|
429
|
+
const corpus = generateCorpus(seed, { sources, derived: nodes - sources - 1, leaves: 1, maxFanIn: 3 });
|
|
430
|
+
return corpus.specs;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function timeCanonicalMap(specs: DagNode[]): number {
|
|
434
|
+
const dag = defineDag(specs);
|
|
435
|
+
const subject: Subject = {};
|
|
436
|
+
const slices: Slices<Subject> = {};
|
|
437
|
+
for (const n of specs) {
|
|
438
|
+
if (n.kind !== "source") continue;
|
|
439
|
+
subject[n.key] = `Reading ${n.key.length}`;
|
|
440
|
+
slices[n.key] = (s) => normalize(s[n.key] ?? "");
|
|
441
|
+
}
|
|
442
|
+
const started = performance.now();
|
|
443
|
+
canonicalMap(dag, subject, slices);
|
|
444
|
+
return performance.now() - started;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function scalingCurve(
|
|
448
|
+
shape: "chain" | "wide" = "chain",
|
|
449
|
+
sizes: number[] = [250, 500, 1000, 2000],
|
|
450
|
+
seed = DEFAULT_SEED,
|
|
451
|
+
): ScalingPoint[] {
|
|
452
|
+
return sizes.map((nodes) => ({
|
|
453
|
+
nodes,
|
|
454
|
+
ms: timeCanonicalMap(shape === "chain" ? chainSpecs(nodes) : wideSpecs(nodes, seed)),
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Least-squares slope of log(ms) against log(nodes) — the empirical growth exponent. 1 would mean
|
|
459
|
+
* linear; 2 means the cost is quadratic in graph size. */
|
|
460
|
+
export function fitExponent(points: ScalingPoint[]): number {
|
|
461
|
+
const usable = points.filter((p) => p.ms > 0);
|
|
462
|
+
const n = usable.length;
|
|
463
|
+
if (n < 2) return NaN;
|
|
464
|
+
const xs = usable.map((p) => Math.log(p.nodes));
|
|
465
|
+
const ys = usable.map((p) => Math.log(p.ms));
|
|
466
|
+
const mx = xs.reduce((a, b) => a + b, 0) / n;
|
|
467
|
+
const my = ys.reduce((a, b) => a + b, 0) / n;
|
|
468
|
+
const num = xs.reduce((acc, x, i) => acc + (x - mx) * (ys[i] - my), 0);
|
|
469
|
+
const den = xs.reduce((acc, x) => acc + (x - mx) ** 2, 0);
|
|
470
|
+
return num / den;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --- rendering ---------------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
/** A count with its rate, or the bare count when there is no denominator to take a rate against —
|
|
476
|
+
* never "n/a", because 0 out of 0 reported is itself the result the cosmetic row exists to show. */
|
|
477
|
+
const share = (n: number, d: number) => (d === 0 ? String(n) : `${n} (${((n / d) * 100).toFixed(1)}%)`);
|
|
478
|
+
|
|
479
|
+
export interface ConditionRow {
|
|
480
|
+
condition: string;
|
|
481
|
+
changed: string;
|
|
482
|
+
missed: string;
|
|
483
|
+
reported: string;
|
|
484
|
+
unnecessary: string;
|
|
485
|
+
note: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const CONDITION_NOTES: Record<ConditionKey, [string, string]> = {
|
|
489
|
+
sourceEdit: ["Source edit", "one source value changed"],
|
|
490
|
+
cosmeticEdit: ["Cosmetic edit", "a change the slice normalizes away"],
|
|
491
|
+
derivationEdit: ["Derivation edit", "every derivation rewritten, sources untouched"],
|
|
492
|
+
derivationDeclared: ["Derivation declared", "the same rewrite, derivation declared as a source"],
|
|
493
|
+
sliceDropped: ["Slice dropped", "the source edit again, with that source's slice removed"],
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
export function conditionRows(r: CorpusResult): ConditionRow[] {
|
|
497
|
+
return (Object.keys(CONDITION_NOTES) as ConditionKey[]).map((key) => {
|
|
498
|
+
const c = r.conditions[key];
|
|
499
|
+
const [label, note] = CONDITION_NOTES[key];
|
|
500
|
+
return {
|
|
501
|
+
condition: label,
|
|
502
|
+
changed: String(c.truth),
|
|
503
|
+
missed: share(c.missed, c.truth),
|
|
504
|
+
reported: String(c.reported),
|
|
505
|
+
unnecessary: share(c.unnecessary, c.reported),
|
|
506
|
+
note,
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function renderTable(r: CorpusResult): string {
|
|
512
|
+
const head =
|
|
513
|
+
"| Condition | Output changed | Missed | Reported stale | Unnecessary | |\n|---|---|---|---|---|---|";
|
|
514
|
+
const body = conditionRows(r)
|
|
515
|
+
.map((w) => `| ${w.condition} | ${w.changed} | **${w.missed}** | ${w.reported} | **${w.unnecessary}** | ${w.note} |`)
|
|
516
|
+
.join("\n");
|
|
517
|
+
return `${head}\n${body}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export interface PriceRow {
|
|
521
|
+
policy: string;
|
|
522
|
+
regenerated: number;
|
|
523
|
+
wasted: string;
|
|
524
|
+
missed: number;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The three anchors that make the over-approximation rate mean anything. All three are sound, so
|
|
528
|
+
* the comparison is purely about waste. The floor is unreachable rather than merely unbuilt:
|
|
529
|
+
* knowing which regenerations to skip means computing them, and a derivation that cannot be
|
|
530
|
+
* re-run is the premise of the whole package. */
|
|
531
|
+
export function priceRows(r: CorpusResult): PriceRow[] {
|
|
532
|
+
const engine = r.conditions.sourceEdit;
|
|
533
|
+
const all = regenerateEverything(engine);
|
|
534
|
+
return [
|
|
535
|
+
{ policy: "No tracking — regenerate everything", regenerated: all.reported, wasted: share(all.unnecessary, all.reported), missed: all.missed },
|
|
536
|
+
{ policy: "neuro-pil", regenerated: engine.reported, wasted: share(engine.unnecessary, engine.reported), missed: engine.missed },
|
|
537
|
+
{ policy: "An oracle — unreachable", regenerated: engine.truth, wasted: share(0, engine.truth), missed: 0 },
|
|
538
|
+
];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** The single figure the price table exists to produce: how much of the waste you would pay without
|
|
542
|
+
* any tracking the engine removes. Printed rather than left for a reader to divide, because a
|
|
543
|
+
* figure derived by hand is a figure nothing re-checks when the corpus moves. */
|
|
544
|
+
export function wasteRemoved(r: CorpusResult): number {
|
|
545
|
+
const engine = r.conditions.sourceEdit;
|
|
546
|
+
const all = regenerateEverything(engine);
|
|
547
|
+
return (all.unnecessary - engine.unnecessary) / all.unnecessary;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function renderPrice(r: CorpusResult): string {
|
|
551
|
+
const head = "| Policy | Regenerated | Wasted | Missed |\n|---|---|---|---|";
|
|
552
|
+
const body = priceRows(r)
|
|
553
|
+
.map((p) => `| ${p.policy} | ${p.regenerated} | **${p.wasted}** | ${p.missed} |`)
|
|
554
|
+
.join("\n");
|
|
555
|
+
const removed = `\nTracking removes ${(wasteRemoved(r) * 100).toFixed(1)}% of the waste you pay without it.`;
|
|
556
|
+
return `${head}\n${body}\n${removed}`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function main(): void {
|
|
560
|
+
const arg = (flag: string, fallback: number) => {
|
|
561
|
+
const i = process.argv.indexOf(flag);
|
|
562
|
+
return i === -1 ? fallback : Number(process.argv[i + 1]);
|
|
563
|
+
};
|
|
564
|
+
const seed = arg("--seed", DEFAULT_SEED);
|
|
565
|
+
const graphs = arg("--graphs", DEFAULT_GRAPHS);
|
|
566
|
+
|
|
567
|
+
const started = Date.now();
|
|
568
|
+
const result = runCorpus(seed, graphs);
|
|
569
|
+
const elapsed = Date.now() - started;
|
|
570
|
+
|
|
571
|
+
if (process.argv.includes("--json")) {
|
|
572
|
+
console.log(JSON.stringify(result, null, 2));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
console.log(`neuro-pil staleness corpus — ${graphs} graphs, seed ${seed}, ${elapsed}ms`);
|
|
577
|
+
console.log(`Ground truth is the set of nodes whose recomputed value differs.\n`);
|
|
578
|
+
console.log(renderTable(result));
|
|
579
|
+
console.log(`\nThe price of a source edit, against the alternatives — all three miss nothing:`);
|
|
580
|
+
console.log(renderPrice(result));
|
|
581
|
+
console.log(
|
|
582
|
+
`\nDeleted node reported stale: ${result.deletedNode.reported}/${result.deletedNode.total}` +
|
|
583
|
+
`\nsliceParity names the missing slice: ${result.sliceParityCaught.caught}/${result.sliceParityCaught.total}` +
|
|
584
|
+
`\nGenerated graphs linting clean: ${result.lintClean.clean}/${result.lintClean.total}` +
|
|
585
|
+
`\nCanonical strings seen: ${result.collisions.strings}, distinct 48-bit hashes: ${result.collisions.hashes}`,
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
console.log(`\nCollision probability at 48 bits (birthday bound):`);
|
|
589
|
+
for (const n of [1e3, 1e4, 1e5, 1e6]) {
|
|
590
|
+
console.log(` ${n.toExponential(0).padStart(6)} stamps: ${(collisionProbability(n) * 100).toPrecision(3)}%`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!process.argv.includes("--no-scaling")) {
|
|
594
|
+
for (const shape of ["chain", "wide"] as const) {
|
|
595
|
+
const curve = scalingCurve(shape);
|
|
596
|
+
console.log(`\ncanonicalMap over a ${shape} graph:`);
|
|
597
|
+
for (const p of curve) console.log(` ${String(p.nodes).padStart(5)} nodes: ${p.ms.toFixed(1)}ms`);
|
|
598
|
+
console.log(` fitted growth exponent: ${fitExponent(curve).toFixed(2)} (1.0 = linear, 2.0 = quadratic)`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
|
package/canonical.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Dag } from "./dag";
|
|
2
|
+
import { isStamped } from "./dag";
|
|
3
|
+
|
|
4
|
+
// Deterministic (sorted-key) JSON stringification, so the same logical value always canonicalizes to
|
|
5
|
+
// the same string regardless of property insertion order. `undefined` values are dropped rather than
|
|
6
|
+
// serialized (matching JSON.stringify's own behavior for object values) — do not "clean up" the
|
|
7
|
+
// `?.`/`.filter(Boolean)` here without checking every canonicalizer that relies on an unpopulated
|
|
8
|
+
// field disappearing rather than becoming `null` (that would change the string, and therefore the
|
|
9
|
+
// hash, for every existing record).
|
|
10
|
+
export function stableStringify(value: unknown): string {
|
|
11
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
12
|
+
if (Array.isArray(value)) return "[" + value.map(stableStringify).join(",") + "]";
|
|
13
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
14
|
+
return "{" + keys.map((k) => {
|
|
15
|
+
const v = (value as Record<string, unknown>)[k];
|
|
16
|
+
return v === undefined ? "" : JSON.stringify(k) + ":" + stableStringify(v);
|
|
17
|
+
}).filter(Boolean).join(",") + "}";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One slice function per `source` node key: given the subject, return the exact raw datum that
|
|
21
|
+
// source node owns. The host defines what gets normalized (e.g. a patient's allergy list); the
|
|
22
|
+
// library only defines how normalized values combine into one canonical string per DAG node.
|
|
23
|
+
export type Slices<T> = Record<string, (subject: T) => unknown>;
|
|
24
|
+
|
|
25
|
+
// Canonical string of the source nodes a single DAG node depends on (its source closure) — a
|
|
26
|
+
// content-addressed cache key in the Nix/Bazel sense (README.md "Lineage"), just hashing declared
|
|
27
|
+
// clinical inputs instead of source files. An unknown key silently contributes no value
|
|
28
|
+
// (`slices[k]?.(subject)` -> `undefined` -> dropped by stableStringify) rather than throwing — see
|
|
29
|
+
// validate.ts's missing-slice rule for how that gap is caught instead, at lint time rather than at
|
|
30
|
+
// hash time.
|
|
31
|
+
export function canonicalFor<T>(dag: Dag, subject: T, slices: Slices<T>, nodeKey: string): string {
|
|
32
|
+
const slice: Record<string, unknown> = {};
|
|
33
|
+
for (const k of dag.sourceClosureOf(nodeKey)) slice[k] = slices[k]?.(subject);
|
|
34
|
+
return stableStringify(slice);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Canonical strings for every stamped node (kind !== "projection") — projections are self-hashed
|
|
38
|
+
// out-of-band and never appear in a stamp this function's caller would compare against.
|
|
39
|
+
export function canonicalMap<T>(dag: Dag, subject: T, slices: Slices<T>): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
for (const n of dag.nodes) if (isStamped(n)) out[n.key] = canonicalFor(dag, subject, slices, n.key);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Which stamped keys differ between a freshly-computed canonical map and a previously-stored one.
|
|
46
|
+
// Not change propagation: nothing is recomputed here, so a key whose stamp moved is reported stale
|
|
47
|
+
// even when regenerating it would have produced the same answer. That over-approximation is the
|
|
48
|
+
// deliberate trade — there is no re-execution to cut off on (see ARCHITECTURE.md § Lineage).
|
|
49
|
+
// Missing keys in `stamped` (a pre-existing stamp predating a new node) count as drifted.
|
|
50
|
+
export function driftedKeys(now: Record<string, string>, stamped: Record<string, string>): string[] {
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
for (const k of Object.keys(now)) if (now[k] !== stamped[k]) out.push(k);
|
|
53
|
+
return out;
|
|
54
|
+
}
|