@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
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)).
|