@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 ADDED
@@ -0,0 +1,622 @@
1
+ # `@pablotech/neuro`
2
+
3
+ *Greek νεῦρον, sinew or cord, plus πῖλος, felted wool — **nerve felt**, the tangled mesh between
4
+ nerve cell bodies where most synaptic connection happens. It is the connections, not the cell
5
+ bodies, that do the computing. That is the whole design position: this package models the edges and
6
+ never looks inside a node.*
7
+
8
+ **Staleness is a property you compute from the graph, not one anyone remembers to assert.** Declare
9
+ what a value was derived from, and this package tells you when the evidence under it has moved — by
10
+ hashing each node's transitive sources and comparing against a recorded stamp.
11
+
12
+ The trade that buys is deliberate and worth knowing up front: a node's stamp is a function of its
13
+ sources' raw content and **nothing else** — never its own text, never the reasoning that produced
14
+ it. So this engine works identically whether the derivation was a build step, a spreadsheet formula
15
+ or a model call it could never re-run. What it cannot tell you is that a conclusion went bad on its
16
+ own. Evidence moving is the only thing it detects.
17
+
18
+ Concretely, in the shape the tutorial below builds from an empty directory in ten minutes: two weather
19
+ stations feed one weekend forecast. Change a station's readings and the forecast goes stale. Change
20
+ the forecast's own prose and **nothing** does — it is downstream, not evidence. Staleness has a
21
+ direction, and that is the idea worth having in your hands rather than in your head.
22
+
23
+ Nothing here is clinical, and nothing here knows what a prompt is. A graph over weather stations
24
+ (the tutorial below), invoices or build artifacts is as valid a consumer as the lab-marker graph
25
+ that motivated it. The [repo README](../README.md) states the position in two sentences and this page argues it
26
+ at length below;
27
+ [`ARCHITECTURE.md`](ARCHITECTURE.md) is the byte-level contract — schema, canonical-hashing
28
+ algorithm, CLI, and academic lineage.
29
+
30
+ ## Tutorial — a vault from scratch
31
+
32
+ Ten minutes, no TypeScript. This walks the **vault** front-end: a directory of plain markdown files
33
+ that declare a graph in their frontmatter, which is the path a consumer with no toolchain takes.
34
+ ([The other front-end, a compiled TypeScript manifest, is worked below](#example--the-typescript-manifest-front-end).)
35
+
36
+ The worked graph: two weather stations feed one weekend forecast. Edit a station's readings and the
37
+ forecast should go stale. Edit the forecast's own prose and nothing should — it is downstream, not
38
+ evidence.
39
+
40
+ ### 1. Declare three nodes
41
+
42
+ Anywhere on disk, make a directory and three files. Each node is a `.md` file whose frontmatter
43
+ carries a `node` key and a `kind`; the prose below the frontmatter is yours.
44
+
45
+ ```
46
+ vault/
47
+ station/COASTAL.md
48
+ station/INLAND.md
49
+ forecast/WEEKEND.md
50
+ ```
51
+
52
+ ```markdown
53
+ ---
54
+ node: station/COASTAL
55
+ kind: source
56
+ basis: "Raw hourly readings from the coastal station."
57
+ ---
58
+
59
+ # Coastal station
60
+
61
+ wind 18kt, gusting 30
62
+ ```
63
+
64
+ `station/INLAND.md` is the same shape (`wind 6kt, steady`). The forecast declares what it reads:
65
+
66
+ ```markdown
67
+ ---
68
+ node: forecast/WEEKEND
69
+ kind: derived
70
+ inputs: [station/INLAND, station/COASTAL]
71
+ basis: "Weekend forecast written across the inland and coastal readings."
72
+ ---
73
+
74
+ # Weekend forecast
75
+
76
+ Small-craft advisory on the coast; inland stays calm.
77
+ ```
78
+
79
+ `kind: source` means raw evidence — a leaf with no inputs of its own. `kind: derived` means written
80
+ from other nodes, named in `inputs`. `basis` is the human sentence saying *why* those inputs; it is
81
+ never parsed for meaning, only carried.
82
+
83
+ Any file without both `node` and `kind` is silently skipped rather than rejected — a `README.md`
84
+ next to your nodes is not an error, because a real vault is mostly files that were never meant to be
85
+ graph nodes.
86
+
87
+ ### 2. Check the graph holds together — `lint`
88
+
89
+ ```console
90
+ $ npx tsx cli.ts lint vault
91
+ neuro-pil: no findings across 3 nodes. # exit 0
92
+ ```
93
+
94
+ Break it on purpose. Repoint the forecast from `station/COASTAL` to a `station/BUOY` you never
95
+ wrote:
96
+
97
+ ```console
98
+ $ npx tsx cli.ts lint vault
99
+ [unknown-input] forecast/WEEKEND: "forecast/WEEKEND" lists unknown input "station/BUOY"
100
+ [orphan] station/COASTAL: "station/COASTAL" (source) is consumed by nothing — collected but never read by anything downstream
101
+ # exit 1
102
+ ```
103
+
104
+ Two findings from one edit, and the second is the interesting one: `station/COASTAL` is still a
105
+ perfectly valid node, but nothing reads it any more, so the evidence you are still collecting no
106
+ longer reaches any conclusion. `orphan` fires only for `source` nodes — a `derived` node with no
107
+ consumers is legitimately terminal.
108
+
109
+ Those are two of seven rules; the others cover cycles, duplicate keys, a `source` that declares
110
+ inputs, and a note used as evidence. The full catalogue with exact message strings is
111
+ [`ARCHITECTURE.md`](ARCHITECTURE.md) §3a *The rule catalogue*.
112
+
113
+ ### 3. See it — `mermaid`
114
+
115
+ ```console
116
+ $ npx tsx cli.ts mermaid vault
117
+ graph LR
118
+ forecast/WEEKEND["forecast/WEEKEND"]
119
+ station/COASTAL["station/COASTAL"]
120
+ station/INLAND["station/INLAND"]
121
+ station/INLAND --> forecast/WEEKEND
122
+ station/COASTAL --> forecast/WEEKEND
123
+ ```
124
+
125
+ `--write <path>` instead rewrites the fenced block between `<!-- DAG:START -->` and
126
+ `<!-- DAG:END -->` markers in that file, so a diagram checked into a doc is regenerated rather than
127
+ hand-maintained, and can never drift from the graph.
128
+
129
+ ### 4. Record a baseline — `stale --update`
130
+
131
+ `stale` is read-only by default. A first run has nothing to compare against and says so, without
132
+ writing anything:
133
+
134
+ ```console
135
+ $ npx tsx cli.ts stale vault
136
+ no prior stamp — nothing to compare. # exit 0
137
+ ```
138
+
139
+ Pass `--update` to record the current state:
140
+
141
+ ```console
142
+ $ npx tsx cli.ts stale vault --update
143
+ no prior stamp — nothing to compare.
144
+ Wrote stamp for 3 nodes into vault/.neuro-pil/stamp.json
145
+ # exit 0
146
+ ```
147
+
148
+ ```json
149
+ {
150
+ "forecast/WEEKEND": "cd7f0a884f4c",
151
+ "station/COASTAL": "c7e4bf80da45",
152
+ "station/INLAND": "bf777bb2d513"
153
+ }
154
+ ```
155
+
156
+ ### 5. Change the evidence
157
+
158
+ The wind picks up. Edit `station/COASTAL.md`'s `18kt` to `22kt`, and ask again:
159
+
160
+ ```console
161
+ $ npx tsx cli.ts stale vault
162
+ [stale] forecast/WEEKEND
163
+ [stale] station/COASTAL # exit 1
164
+ ```
165
+
166
+ Two nodes, and both are right. `station/COASTAL` changed. `forecast/WEEKEND` did not change — but it
167
+ was *written from* something that did, so whatever it says about the weekend was reasoned from
168
+ readings that no longer hold. `station/INLAND` is untouched and stays silent.
169
+
170
+ The exit code is the point: wire `stale` into CI and a commit that edits evidence without revisiting
171
+ what was derived from it fails the build.
172
+
173
+ ### 6. Now change the forecast's prose
174
+
175
+ Put the coastal reading back, re-stamp, then rewrite `forecast/WEEKEND.md`'s body — not its
176
+ frontmatter — into something completely different:
177
+
178
+ ```console
179
+ $ npx tsx cli.ts stale vault
180
+ neuro-pil: no drift across 3 stamped nodes. # exit 0
181
+ ```
182
+
183
+ Nothing moved. A derived node's stamp is a function of its transitive *sources'* raw content and nothing else:
184
+ never its own text, never the reasoning that produced it. That is the whole trade this engine makes,
185
+ and [`../README.md`](../README.md) argues it at length. Rewording a conclusion is not new evidence,
186
+ so it does not mark anything stale — and equally, this engine cannot tell you that a *conclusion* went
187
+ bad on its own.
188
+
189
+ ### 7. Script it — `--json`
190
+
191
+ Every subcommand takes `--json` and prints one line of machine-readable result instead of the prose
192
+ above:
193
+
194
+ ```console
195
+ $ npx tsx cli.ts stale vault --json
196
+ {"baseline":false,"drifted":["forecast/WEEKEND","station/COASTAL"],"nodeCount":3,"updated":false}
197
+ ```
198
+
199
+ Exit codes are the contract: `0` clean, `1` findings or drift present, `2` usage error.
200
+
201
+ ## Two front-ends over the same `Dag` type
202
+
203
+ - **TypeScript manifest** — `defineDag` (`dag.ts`), consumed via the package root `index.ts`. A
204
+ compiled, statically-typed graph, for a consumer that shares the same toolchain.
205
+ - **Plain-markdown vault** — `dagFromFiles` (`markdown.ts`), for a consumer with no toolchain at
206
+ all. Nodes are declared as YAML frontmatter on `.md` files, or a bare `.neuro-pil.yml` folder
207
+ manifest. `cli.ts` is the example front-end, and the tutorial above.
208
+
209
+ Both produce the same `Dag`, so `validate`, `renderMermaid`, `canonicalFor`/`driftedKeys`, etc.
210
+ work identically regardless of which front-end built the graph.
211
+
212
+ ## Example — the TypeScript manifest front-end
213
+
214
+ ### Scenario
215
+
216
+ Say you have a source document and an LLM-generated summary of it. The summary is expensive — every
217
+ regeneration is a real API call, real seconds, real money — and every time the source document
218
+ changes, the summary might now be wrong.
219
+
220
+ ### Before: two options, both fail
221
+
222
+ - **Always regenerate.** Every time anything touches the document, call the model again, just in
223
+ case. Correct, but wasteful — you're paying the expensive cost on every edit, including the ones
224
+ that didn't touch anything the summary actually depends on.
225
+ - **Cache it, and trust something to invalidate the cache.** A TTL. A "regenerate" button someone has
226
+ to remember to click. A code comment that says *remember to rerun this if the document changes*.
227
+ Cheap, but now correctness depends on a human (or a timer) remembering — and the failure mode is
228
+ silent: a stale summary sits right next to a changed document with nothing distinguishing it from a
229
+ fresh one.
230
+
231
+ Both options fail at the same job: detecting staleness without either paying to regenerate on every
232
+ edit or trusting a human to notice. You shouldn't have to choose between paying the expensive cost
233
+ unnecessarily and trusting someone's memory.
234
+
235
+ ### After: ask the graph
236
+
237
+ ```ts
238
+ import { defineDag, canonicalFor, driftedKeys } from "@pablotech/neuro";
239
+
240
+ // The expensive derivation. In production this is an LLM call — money and
241
+ // seconds per run. It is never called just to check whether it's still valid.
242
+ function expensiveSummarize(text: string): string {
243
+ console.log(" calling the model to summarize... (this costs money)");
244
+ return `Summary: ${text.split(" ").length} words.`;
245
+ }
246
+
247
+ const dag = defineDag([
248
+ { key: "article", label: "Source article", kind: "source", inputs: [],
249
+ basis: "The raw document text." },
250
+ { key: "summary", label: "LLM-generated summary", kind: "derived", inputs: ["article"],
251
+ basis: "expensiveSummarize(article)" },
252
+ { key: "alert", label: "Slack alert", kind: "derived", inputs: ["summary"],
253
+ basis: "buildAlert(summary)" },
254
+ ]);
255
+
256
+ let articleText = "Storms are expected across the coast this weekend.";
257
+ const slices = { article: () => articleText };
258
+ ```
259
+
260
+ The graph, rendered by `renderMermaid(dag)`:
261
+
262
+ ```mermaid
263
+ graph LR
264
+ article["Source article"]
265
+ summary["LLM-generated summary"]
266
+ alert["Slack alert"]
267
+ article --> summary
268
+ summary --> alert
269
+ ```
270
+
271
+ **Produce the summary once, and keep only its stamp** — not the summary itself, just enough to detect
272
+ staleness later. Stamp `alert` too:
273
+
274
+ ```ts
275
+ const summary = expensiveSummarize(articleText); // -> "calling the model..." (paid once)
276
+ const stamp = {
277
+ summary: canonicalFor(dag, {}, slices, "summary"),
278
+ alert: canonicalFor(dag, {}, slices, "alert"),
279
+ };
280
+ ```
281
+
282
+ Time passes. Someone edits the article:
283
+
284
+ ```ts
285
+ articleText = "Storms are expected across the coast this weekend and into Monday.";
286
+ ```
287
+
288
+ **Before regenerating anything, ask the graph which nodes are stale** — no model call involved:
289
+
290
+ ```ts
291
+ const now = {
292
+ summary: canonicalFor(dag, {}, slices, "summary"),
293
+ alert: canonicalFor(dag, {}, slices, "alert"),
294
+ };
295
+ driftedKeys(now, stamp); // -> ["summary", "alert"]
296
+ ```
297
+
298
+ **Both `summary` and `alert` come back stale — not just `summary`.**
299
+
300
+ `alert`'s own definition (`buildAlert(summary)`) never mentions `article`. But `canonicalFor` doesn't
301
+ hash a node's direct input — it hashes a node's **source closure**: every `source` node it transitively
302
+ depends on, no matter how many derived nodes sit in between. `alert`'s source closure is `{article}`,
303
+ the same closure `summary` has, because `alert` → `summary` → `article` is one connected chain. That's
304
+ how the graph found `article` from `alert`, which never named it.
305
+
306
+ - **The saving.** Nobody wrote that chain down by hand. `alert`'s owner added no check for `article`.
307
+ If `summary` ever gains a second source, `alert` doesn't need a matching update either — the graph
308
+ walks the chain itself, at any depth.
309
+ - **Why `summary` and `alert` hash identically above.** Same reason: they share the same source
310
+ closure, `{article}`. A node's hash is a function of its source closure alone, never its own identity
311
+ or logic — give `alert` a second source of its own and it gets its own, different hash.
312
+
313
+ Only now, knowing for certain both are stale, would you pay to regenerate them — the same graph, with
314
+ the two keys `driftedKeys` reported marked in red:
315
+
316
+ ```mermaid
317
+ graph LR
318
+ article["Source article"]
319
+ summary["LLM-generated summary"]
320
+ alert["Slack alert"]
321
+ article --> summary
322
+ summary --> alert
323
+ style summary fill:#e05252,stroke:#7a1f1f,color:#fff
324
+ style alert fill:#e05252,stroke:#7a1f1f,color:#fff
325
+ ```
326
+
327
+ Now run the same check *without* editing the article first. `driftedKeys` returns `[]` — nothing is
328
+ stale, and you know it without spending a single call to find out.
329
+
330
+ ### The point
331
+
332
+ - Staleness is computed from the graph, not remembered by a person. It stays correct no matter how
333
+ many hops a node sits from the source it depends on.
334
+ - The expensive step (`expensiveSummarize`) never runs in order to *check* staleness. Only
335
+ `canonicalFor` does, over each node's source closure — no model call at any depth.
336
+ *Measured:* the check itself grows **quadratically with depth** — `sourceClosureOf` walks upstream
337
+ once per node without memoizing. It stays orders of magnitude cheaper than a model call, but a
338
+ deep chain is where it stops being cheap against itself
339
+ ([`BENCHMARKS.md`](../BENCHMARKS.md#deterministic--neuro-pil)).
340
+ - Both answers are worth having. "Stale, regenerate it" and "not stale, reuse it" each come from the
341
+ graph, structurally, every time.
342
+
343
+ ## Declaring the prompt as a source node
344
+
345
+ The engine's blind spot is that it never looks inside a derivation. If a value was produced by a
346
+ model call, editing the prompt changes the value — and nothing in the graph moved, so nothing goes
347
+ stale. The fix is not to teach the engine about prompts. It is to declare the prompt as evidence,
348
+ because that is exactly what it is.
349
+
350
+ This is also how a [brain](../README.md#what-this-is-for)'s version key gets into the graph: a prompt
351
+ plus the model that answered it *is* the version, and declaring it as a source is what buys back the
352
+ one blind spot this engine otherwise accepts by design.
353
+
354
+ ```markdown
355
+ ---
356
+ node: prompt/FORECAST-TEMPLATE
357
+ kind: source
358
+ basis: "The template the forecast is written from. Edit it and every forecast is out of date."
359
+ ---
360
+
361
+ Summarize the weekend outlook from the station readings below. Lead with any advisory.
362
+ ```
363
+
364
+ Then list it alongside the readings:
365
+
366
+ ```yaml
367
+ node: forecast/WEEKEND
368
+ kind: derived
369
+ inputs: [station/INLAND, station/COASTAL, prompt/FORECAST-TEMPLATE]
370
+ ```
371
+
372
+ Now `stale` fires on a prompt edit exactly as it does on a reading change, and it fires on precisely
373
+ the derived nodes that named that template — not on every node in the vault. Add the model
374
+ identifier as a second source node (`model/OPUS-5`, whose body is the version string) and a model
375
+ upgrade stales its outputs the same way.
376
+
377
+ Nothing about this is a special case in the code. The engine still knows nothing about prompts; it
378
+ hashes a source's bytes and reports what moved. Declaring the prompt is just being honest about what
379
+ the conclusion was derived from — which is the only thing this engine ever asks of you.
380
+
381
+ ## Scoring competing versions — `compareBrains`
382
+
383
+ A **brain**, in the sense the [repo README defines](../README.md#what-this-is-for), is a named,
384
+ versioned unit of reasoning — a prompt, a model, a schema and an audience. This is the function that
385
+ lets you replace one with a better one on evidence, and it is the only place this package admits a
386
+ derivation might be a model call at all.
387
+
388
+ Staleness says a value should be revisited. It cannot say whether the new one is *better* — for a
389
+ model call, nothing structural can. `compare.ts` is the one concession to that: run competing
390
+ versions over a fixed case set, score each result, compare the means.
391
+
392
+ ```ts
393
+ import { compareBrains } from "@pablotech/neuro/compare";
394
+
395
+ const { perVersion } = await compareBrains(
396
+ cases, // a fixed set — the point is that it does not move between runs
397
+ ["v3-terse", "v4-explicit"],
398
+ (testCase, version) => runMyPrompt(version, testCase), // your model call
399
+ (result, testCase) => gradeIt(result, testCase), // your rubric: any number
400
+ );
401
+
402
+ for (const { version, mean, scores } of perVersion) {
403
+ console.log(version, mean.toFixed(2), scores);
404
+ }
405
+ ```
406
+
407
+ `Case`, `Version` and `Result` are opaque type parameters — the package never inspects any of them —
408
+ and `run` and `score` are the entire extension surface. A version is a value you choose: a prompt
409
+ string, a model identifier, a config object, a function.
410
+
411
+ What it deliberately is **not**: it does not call a provider, store runs, track cost, or version your
412
+ prompts. It is roughly forty lines, and it is not trying to be PromptLayer, Langfuse, Portkey,
413
+ Helicone or MLflow — if you already run one of those, keep it. This exists so that promoting a
414
+ prompt version is an act with evidence behind it even in a repo that runs none of them, and so that
415
+ the same fixed-case discipline the golden fixtures apply to prompt *text* can be applied to prompt
416
+ *quality*.
417
+
418
+ The example above is hypothetical, because this package is domain-free and acquiring a clinical case
419
+ set to demonstrate its own API would defeat the point. A real run of it exists a directory away:
420
+ `akesi-pil/benchmarks/` holds twelve cases and two competing retry strategies, and
421
+ [`BENCHMARKS.md`](../BENCHMARKS.md#sampled--akesi-pil) reports what came back: every attempt
422
+ passed on the first try, so the two strategies sent identical prompts and scored identically. Note
423
+ where the four lines that call `compareBrains` live: in neither package, because neither imports
424
+ the other.
425
+
426
+ ## Importing
427
+
428
+ The package root is the **isomorphic** subset — safe in a browser bundle, a Cloudflare Pages
429
+ Function, or a `node:crypto` CLI alike. Anything runtime-specific is behind its own subpath, so
430
+ importing it is a deliberate act rather than something a bundler discovers for you:
431
+
432
+ | Import | From |
433
+ |--------|------|
434
+ | `defineDag`, `isStamped`, types `Dag` / `DagNode` / `NodeKind` | `@pablotech/neuro` |
435
+ | `stableStringify`, `canonicalFor`, `canonicalMap`, `driftedKeys` | `@pablotech/neuro` |
436
+ | `validate`, `sliceParity`, types `Finding` / `ValidateOptions` | `@pablotech/neuro` |
437
+ | `renderMermaid`, `extractDagBlock`, `writeDagBlock`, `DEFAULT_MERMAID_MARKERS` | `@pablotech/neuro` |
438
+ | `sha256hex12` → `string` | `@pablotech/neuro/hash-node` |
439
+ | `sha256hex12` → `Promise<string>` | `@pablotech/neuro/hash-web` |
440
+ | `dagFromFiles`, `parseVaultNode`, `extractFrontmatter`, `parseFrontmatterBlock` | `@pablotech/neuro/markdown` |
441
+ | `compareBrains` (async), types `Comparison` / `VersionScore` | `@pablotech/neuro/compare` |
442
+
443
+ Two things that catch people. `sha256hex12` is **synchronous** in `hash-node` and **asynchronous**
444
+ in `hash-web` — `SubtleCrypto` has no sync digest — so the two are not drop-in substitutes even
445
+ though they produce identical bytes. And `sourceClosureOf`, `upstreamOf` and `downstreamOf` are
446
+ **methods on the `Dag` object** returned by `defineDag`/`dagFromFiles`, not importable functions:
447
+ call `dag.sourceClosureOf(key)`.
448
+
449
+ ## Module map
450
+
451
+ - `dag.ts` — the `Dag`/`DagNode` shape, `defineDag`, `isStamped`.
452
+ - `canonical.ts` — deterministic (sorted-key) JSON stringification and the per-node canonical
453
+ hashing input (`canonicalFor`, `canonicalMap`, `driftedKeys`).
454
+ - `validate.ts` — structural graph checks (cycles, unknown inputs, orphan sources, note-sink
455
+ rules) plus `sliceParity`, the one check that needs a host's slice map.
456
+ - `mermaid.ts` — renders a `Dag` to a mermaid diagram, and reads/writes a fenced block between
457
+ markers in a doc.
458
+ - `markdown.ts` — the vault-mode front-end: frontmatter parsing and `dagFromFiles`.
459
+ - `hash-node.ts` / `hash-web.ts` — the same truncated-SHA-256 hash, `node:crypto` and
460
+ `SubtleCrypto` variants, kept separate so nothing that runs in a browser or a Cloudflare Pages
461
+ Function pulls in `node:crypto`.
462
+ - `cli.ts` — subcommands over a vault directory (see CLI usage below), the CLI a non-code
463
+ consumer, or an external invoker, runs directly via `tsx`.
464
+
465
+ ## CLI usage
466
+
467
+ ```
468
+ tsx cli.ts lint <dir> [--json]
469
+ tsx cli.ts mermaid <dir> [--write <path>]
470
+ tsx cli.ts stale <dir> [--update] [--json]
471
+ ```
472
+
473
+ - `lint` — `validate(dagFromFiles(walkVault(dir)))`. No `sliceParity` — that check needs a host's
474
+ slice map, and vault mode has no analogue for one. Clean: `neuro-pil: no findings across ${n}
475
+ nodes.\n`, exit 0. Else: one line per finding, `[${rule}] ${node}: ${message}\n`, exit 1.
476
+ - `mermaid` — prints `renderMermaid(dagFromFiles(walkVault(dir)))` to stdout. `--write <path>`
477
+ instead reads that file and rewrites its fenced `<!-- DAG:START -->`/`<!-- DAG:END -->` block in
478
+ place, printing `Wrote ${n}-node mermaid into ${path}\n`.
479
+ - `stale` — hashes every stamped node (`canonicalFor` + `sha256hex12`, one slice per source node
480
+ returning that node's own raw file text) and compares against `<dir>/.neuro-pil/stamp.json` via
481
+ `driftedKeys`. **Read-only by default** — the stamp is written only when `--update` is passed.
482
+ No prior stamp: `no prior stamp — nothing to compare.\n`, exit 0. Clean: `neuro-pil: no drift
483
+ across ${n} stamped nodes.\n`, exit 0. Drift: one line per drifted key, `[stale] ${key}\n`, exit
484
+ 1. `--update` always (re-)writes the stamp to the freshly-computed hashes, after reporting
485
+ whatever drift (if any) was found against the old one.
486
+ - `--json` (where supported) prints the subcommand's result object as one line of JSON instead of
487
+ the human-readable text above.
488
+ - Exit codes: `0` clean, `1` findings/drift present, `2` usage error (bad dir, unknown subcommand,
489
+ missing required arg).
490
+
491
+ ## Lineage
492
+
493
+ Every system below answers one question: **what do you compare, when recomputing is too expensive to
494
+ do on every edit?** Fifty years of answers, differing mainly in how much of the *derivation* they fold
495
+ into the comparison. That axis is where `neuro-pil` sits.
496
+
497
+ **Codd** set the precondition in 1970. §2.2 *Redundancy* of *A Relational Model of Data for Large
498
+ Shared Data Banks* defines **derivability** — a relation obtainable from a set by operations that,
499
+ "for all time," yield it from members of that set. §2.3 *Consistency* then delivers the sentence
500
+ this library is built on: a system
501
+ lacking detailed semantic information about each named relation "cannot deduce the redundancies
502
+ applicable to the named set." **The graph must be declared; it cannot be inferred.** Codd's own
503
+ consistency check, though, re-evaluates the derivation and compares — he can afford to, because
504
+ relational algebra is cheap.
505
+
506
+ **Feldman's Make** (1979) is the first refusal to re-evaluate: compare *evidence* instead —
507
+ prerequisite modification times, pairwise along each edge. And Make does not look at the recipe.
508
+ Change `CFLAGS=-O` to `CFLAGS=-g`, type `make`, and nothing happens. **D. J. Bernstein** diagnosed it
509
+ in one line — "make has no idea that the target files depend on the Makefile" — then refused to call
510
+ it a bug: *"This isn't make's fault. The author simply has to be honest to make."* And he declined the
511
+ fix the next generation would adopt, because depending on the makefile rebuilds the documentation too,
512
+ as if someone "had fired a `make clean; make` bazooka." **The man who diagnosed the blind spot chose
513
+ to keep it.**
514
+
515
+ **Vesta** (Heydon, Levin and Yu, 2000) took the fix Bernstein declined. Its cache key is "the
516
+ fingerprint of the `compile` function's *body* combined with the fingerprint of the literal
517
+ `test.c`" — instruction and inputs, one hash. **Bazel** carries the idea into daily practice.
518
+
519
+ **Dolstra's Nix** (2006) sharpens it to the limit. A derivation is serialized to a canonical form
520
+ whose fields include `builder` and `args`, and *those bytes are what gets hashed* — builder, arguments
521
+ and the full transitive input closure, all inside one store path. Change anything upstream and the
522
+ path differs by construction, not by a traversal someone had to get right. **This is the closest
523
+ structural ancestor of `sourceClosureOf`.**
524
+
525
+ **Acar's self-adjusting computation** (2005) — with his *Adaptive Functional Programming*, joint with
526
+ Blelloch and Harper, three years before it, and **Hammer's Adapton** and **Salsa** after — takes the
527
+ other branch entirely:
528
+ don't declare the graph, *discover* it by watching an execution. That buys the one thing no hashing
529
+ scheme can: **early cutoff**. Re-run the query, notice the output didn't actually change, and stop.
530
+ Salsa calls its implementation **backdating** — stamp the value with the revision it last *really*
531
+ changed, and nothing downstream is disturbed.
532
+
533
+ **Mokhov, Mitchell and Peyton Jones** sorted the field onto two axes in *Build Systems à la Carte*
534
+ (ICFP 2018): a **scheduler** that orders the work, and a **rebuilder** that decides whether a key is
535
+ dirty. Make keeps a dirty bit; Bazel keeps constructive traces; Nix keeps *deep* constructive traces.
536
+ Every cell in their table is occupied by a system that can run its own build step.
537
+
538
+ Full citations, with DOIs, in [`ARCHITECTURE.md`](ARCHITECTURE.md#lineage).
539
+
540
+ ## What this trades away
541
+
542
+ Each of the three claims below is measured on a generated corpus rather than left as prose —
543
+ including the one that counts against this library. The rates, and the mutation testing that shows
544
+ they would move if the engine stopped behaving, are in
545
+ [`BENCHMARKS.md`](../BENCHMARKS.md#deterministic--neuro-pil).
546
+
547
+ Read that history as one argument and this library is easy to place — and it is not an invention.
548
+ Three claims, and only the third is ours — it is the one the [repo README](../README.md) opens on, and the reason the
549
+ library exists rather than a concession it makes:
550
+
551
+ 1. **The mechanism is Nix's, borrowed.** Hash the transitive closure, compare stamps, difference means
552
+ stale. In Mokhov et al.'s vocabulary `canonicalFor` is a *verifying trace*: small hashes of what a
553
+ key depended on. Nothing here is new.
554
+ 2. **The exclusion is Make's, and it makes the system weaker.** `neuro-pil` hashes only *source*
555
+ values; the derivation is not in the key. That is a subtraction from Vesta, Bazel and Nix, and it
556
+ lands on precisely Bernstein's blind spot — a changed prompt marks nothing stale, exactly as a
557
+ changed `CFLAGS` marked nothing stale.
558
+ 3. **Only the reason is new, and it is a design position, not a result.** Every ancestor can fold the
559
+ instruction into the key because the instruction is a function it owns and can *call again*. An LLM
560
+ call is not. The same prompt and the same model produce different output, so re-running proves
561
+ nothing about whether the cached answer is still good.
562
+
563
+ That third point is sharper in *Build Systems à la Carte*'s own terms than it sounds. Their `Task` is
564
+ a runnable function, and §3.6 *Correctness of a Build System* states the condition **by re-running
565
+ it**: "if we recompute the value of the key (using the task description, and the final store), we
566
+ should get exactly the same value as we see in the final store." A derivation that can never be
567
+ recomputed cannot be checked against that definition at all. So `neuro-pil` is not a worse cell in
568
+ their table — it is off the grid, and it forfeits what the grid buys. §6.5 *Self-tracking* arrives at
569
+ the same escape hatch from the other side: a build system in a full programming language faces "the
570
+ challenge of implementing equality on arbitrary task functions," so "the pessimistic assumption that
571
+ any change to the build system potentially changes any build task can often be used." For
572
+ `neuro-pil` that pessimism is not a fallback — it is the permanent condition.
573
+
574
+ **What it forfeits is early cutoff.** Acar, Adapton and Salsa re-run and discover nothing changed;
575
+ `neuro-pil` never re-runs, so it can only over-approximate — every stamp that moved is reported stale,
576
+ whether or not the answer would have differed. It keeps self-adjusting computation's *motivation*
577
+ (declare the dependencies once; let mechanics, not human memory, decide what is stale) while refusing
578
+ its *mechanism*. Put plainly: **`neuro-pil` is Nix's closure hash with Make's blind spot, and the
579
+ blind spot is deliberate.**
580
+
581
+ **Validity is not reproducibility.** That is the entire claim. `neuro-pil` never asserts that
582
+ regenerating a node yields the same bytes — only that the evidence it was derived from has not moved.
583
+ What that costs you in practice, and the levers you have:
584
+ [What this does not catch](#what-this-does-not-catch).
585
+
586
+ If you have run `dbt build --select state:modified+`, you have run this algorithm under other nouns: a
587
+ manifest holding a checksum per node, compared against a baseline, selecting what differs plus
588
+ everything downstream. Note dbt is **stronger** here — its checksum is taken over the model's own file
589
+ contents, so editing the SQL does mark the model modified. Its blind spot sits elsewhere, and it is
590
+ the familiar one: dbt's docs warn that a model reading a `var` or `env_var` may not be caught by
591
+ `state:modified`, though a variable change that alters the rendered config often will be. Undeclared
592
+ inputs are invisible to every system on this page, this one included.
593
+
594
+ ## What this does not catch
595
+
596
+ Three consequences of the trade above, and what to do about each:
597
+
598
+ - **Changing the derivation marks nothing stale.** Edit the prompt, switch the model, rewrite the
599
+ summarizer — every stamp is unchanged and every cached value still reads fresh. **The fix: declare
600
+ the prompt text or model version as a source node**, so it enters the closure like any other input.
601
+ That is the blind spot [described above](#what-this-trades-away), and declaring it is how you
602
+ buy back the part you need. The complementary half lives in `akesi-pil`: its prompts are asserted
603
+ byte-for-byte, so a prompt change is a diff a reviewer sees rather than an invisible edit, and
604
+ `compareBrains` scores the new version against a fixed case set before it is promoted.
605
+ *Measured:* rewrite every derivation and **2419 node outputs** change; the engine reports none of
606
+ them. Declare the derivation as a source and the same rewrite reports all **2419**
607
+ ([`BENCHMARKS.md`](../BENCHMARKS.md#deterministic--neuro-pil)).
608
+ - **A source with no slice function contributes nothing, silently.** It serializes to `undefined`,
609
+ which is dropped rather than emitted, so the node hashes as though that input did not exist.
610
+ `validate`'s slice-parity check exists to catch exactly this — run it in CI.
611
+ *Measured:* with that source's slice removed, editing it changes **565 node outputs** and the
612
+ engine reports **zero of the 565** — no error, no warning, just stale values that read fresh.
613
+ `sliceParity` names the missing slice in **200/200** graphs
614
+ ([`BENCHMARKS.md`](../BENCHMARKS.md#deterministic--neuro-pil)).
615
+ - **Every stamp that moved is reported stale**, including cosmetic edits that would not have changed
616
+ the output. There is no early cutoff to save you, for the reason given above. **The lever you have
617
+ is normalization**: whitespace, ordering and formatting collapsed inside your slice functions never
618
+ reach the hash, so they never cost you a regeneration.
619
+ *Measured:* **436 of the 1001** nodes a source edit reports stale would have regenerated
620
+ byte-identical, and you pay for every one of them. Regenerating everything instead — what you do
621
+ without this library — wastes six times as many
622
+ ([`BENCHMARKS.md`](../BENCHMARKS.md#deterministic--neuro-pil)).