@pablotech/akesi 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pablo Rodriguez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,432 @@
1
+ # `@pablotech/akesi`
2
+
3
+ *Ἀκεσώ — Akeso, daughter of Asclepius, goddess of the **process** of curing rather than the cure
4
+ itself; that was her sister Panacea's. The name is the disclaimer: this package builds the reasoning
5
+ and checks what comes back. It does not diagnose.*
6
+
7
+ Clinical reasoning over lab and marker data — unit normalization, reference-range lookup, treatment
8
+ bucketing, finding assembly — where **every constraint stated to the model in prose is re-enforced in
9
+ code on the response.**
10
+
11
+ The constraints worth enforcing are the ones a schema cannot state. Shape is largely the provider's
12
+ job now. What is left is the response that is valid JSON, every field the right type, and still files
13
+ clinical content under a body-system heading this patient does not have — where it is invisible in
14
+ every view organized by group. Assembling one finding raises 96 distinct refusals on four grounds —
15
+ shape, name, count and domain bounds — and the taxonomy is the substance of the package:
16
+ [`ARCHITECTURE.md` § *The response contract*](ARCHITECTURE.md#4-the-response-contract).
17
+
18
+ It is [`neuro-pil`](../neuro-pil)'s sibling, not its demonstration. This package does not import that
19
+ one and knows nothing about graphs; it is the workload the engine was extracted alongside, kept
20
+ separate to prove the engine is separable. A host is what joins them, hanging a derived node off each
21
+ finding so that changing a source — a marker's raw value, a reference range, a prompt template —
22
+ marks exactly the findings downstream of it as stale
23
+ ([`../ARCHITECTURE.md`](../ARCHITECTURE.md#two-packages-no-edge-between-them)).
24
+
25
+ **Not medical advice.** Nothing in this package diagnoses, treats, or gives clinical guidance. It
26
+ assembles prompts and normalizes structured data for an LLM call an adopter supplies themselves;
27
+ what that call returns, and any decision made from it, is the adopter's responsibility to review
28
+ with a qualified clinician before acting on it.
29
+
30
+ ## One patient, end to end
31
+
32
+ Everything below is quoted from this package's own checked-in fixtures — a synthetic patient named
33
+ `fullyPopulated` in [`tests/fixtures/canonical-variants.ts`](tests/fixtures/canonical-variants.ts),
34
+ and the prompt text it produces in
35
+ [`tests/fixtures/prompt-golden/`](tests/fixtures/prompt-golden/). The golden files are asserted
36
+ byte-for-byte by [`tests/prompt-golden.test.ts`](tests/prompt-golden.test.ts), so the rendered
37
+ output shown here cannot quietly drift away from what the code actually emits: if it changed, the
38
+ suite would be red. One exception, flagged where it appears: the grouping prompt in step 5 is
39
+ rendered from the same fixture but has no golden file, so it carries no such guarantee.
40
+
41
+ ### 1. What arrives
42
+
43
+ Structured rows. Two lab results and three treatments, entered at different times by different
44
+ people, with no shared convention beyond the field names:
45
+
46
+ ```ts
47
+ results: [
48
+ { marker: "ApoB", group: "Lipids", source: "lab", date: "2026-01-01", value: 80, unit: "mg/dL" },
49
+ { marker: "LDL", group: "Lipids", source: "lab", date: "2026-01-01", value: 120, unit: "mg/dL" },
50
+ ],
51
+ treatments: [
52
+ { id: "t1", name: "Ezetimibe", dose: "10 mg", kind: "drug", start: "2025-01" },
53
+ { id: "t2", name: "Statin", kind: "drug", start: "2024-01", end: "2024-12" },
54
+ { id: "t3", name: "Start rosuvastatin", kind: "drug", start: "2099-02" },
55
+ ],
56
+ ```
57
+
58
+ Nothing here says which of those three the patient is currently taking, or what `80` means next to
59
+ `120`. That is the work.
60
+
61
+ ### 2. Normalize the units, where a bug is a clinical error
62
+
63
+ `unit-systems.ts` converts a reading from its stored unit into the other measurement system on
64
+ read — never on write, because a stored value stays faithful to its source. Conversion is
65
+ **analyte-specific**, so the table is keyed by marker name:
66
+
67
+ ```ts
68
+ "Apolipoprotein B": { us: "mg/dL", si: "g/L", k: 0.01 },
69
+ "LDL-C": { us: "mg/dL", si: "mmol/L", k: 1 / 38.67 },
70
+ ```
71
+
72
+ An adopter reading this table is entitled to expect our patient's ApoB to render as `0.8 g/L` under
73
+ a metric selector. It does not, and the reason is the rule that gives this package its character
74
+ ([`unit-systems.ts:12-14`](unit-systems.ts)):
75
+
76
+ > **CLINICAL SAFETY:** only analytes with a VERIFIED factor are converted; anything else passes
77
+ > through in its stored unit, untouched, and is surfaced by `unmappedConvertible` + the coverage-gate
78
+ > test. **A missing conversion is safe; a wrong one is a clinical error.**
79
+
80
+ The patient's rows say `ApoB` and `LDL`. The table's keys are `Apolipoprotein B` and `LDL-C`. Those
81
+ are not the same string, so no factor is verified for these two readings and nothing is converted:
82
+
83
+ ```ts
84
+ unmappedConvertible([
85
+ { marker: "ApoB", unit: "mg/dL" },
86
+ { marker: "LDL", unit: "mg/dL" },
87
+ ])
88
+ // → [ { marker: 'ApoB', unit: 'mg/dL' }, { marker: 'LDL', unit: 'mg/dL' } ]
89
+
90
+ unmappedConvertible([
91
+ { marker: "Apolipoprotein B", unit: "mg/dL" },
92
+ { marker: "LDL-C", unit: "mg/dL" },
93
+ ])
94
+ // → []
95
+ ```
96
+
97
+ This is the design working, not failing. `unmappedConvertible` returns every `(marker, unit)` pair
98
+ whose *unit* is of a convertible class but whose *marker* has no rule — the gap made countable, so a
99
+ host can gate on it and a developer sees a console warning, rather than the gap being invisible until
100
+ someone reads a number in the wrong scale. A synonym table mapping `ApoB` → `Apolipoprotein B` is a
101
+ reasonable thing for a host to add; guessing that they mean the same analyte, inside the converter,
102
+ is not.
103
+
104
+ Downstream, the reading is rendered in the unit it was stored in, and says so:
105
+
106
+ ```
107
+ ApoB (mg/dL)
108
+ Last year (1 reading):
109
+ 2026-01-01: 80.0 mg/dL
110
+ Prior: (no earlier data)
111
+ ```
112
+
113
+ The one place conversion is *not* per-reading is `normalizeSeries`: a single marker's history may
114
+ mix units across dates, and a chart or an aggregate over mixed units is meaningless, so a series is
115
+ reconciled to a canonical SI unit before it is charted.
116
+
117
+ ### 3. Bucket by time
118
+
119
+ `bucketOf` (`treatment-bucket.ts:38-42`) is nine lines and settles what the three treatment rows
120
+ mean, relative to a `today` the caller passes in rather than reads from the clock:
121
+
122
+ ```ts
123
+ export function bucketOf(t: Pick<TreatmentItem, "start" | "end">, today: string): Bucket {
124
+ if (t.end && cmp(t.end, today) < 0) return "past";
125
+ if (t.start && cmp(t.start, today) > 0) return "planned";
126
+ return "ongoing";
127
+ }
128
+ ```
129
+
130
+ Against `today = "2026-06-28"`:
131
+
132
+ | Row | `start` | `end` | Bucket |
133
+ |---|---|---|---|
134
+ | Ezetimibe 10 mg | `2025-01` | — | `ongoing` |
135
+ | Statin | `2024-01` | `2024-12` | `past` |
136
+ | Start rosuvastatin | `2099-02` | — | `planned` |
137
+
138
+ `today` is a parameter because a prompt built from the same patient data must be reproducible: a
139
+ function that reads `Date.now()` produces a different prompt tomorrow for reasons that have nothing
140
+ to do with the patient, and a golden test over it could only ever be flaky.
141
+
142
+ ### 4. What the model actually sees
143
+
144
+ `buildUserMessage` (`finding-generate.ts:259`) renders the bucketed rows into the prompt. This block
145
+ is verbatim from
146
+ [`tests/fixtures/prompt-golden/user--finding--fullyPopulated.txt`](tests/fixtures/prompt-golden/user--finding--fullyPopulated.txt):
147
+
148
+ ```
149
+ Treatment History:
150
+ Ongoing regimen (currently being taken — assess each in `treatment`):
151
+ - Ezetimibe 10 mg [since 2025-01]
152
+ Past / discontinued treatments (historical context only — do NOT assess these in `treatment`):
153
+ - Statin [2024-01–2024-12]
154
+ ```
155
+
156
+ The planned one is not in that section at all. It appears under its own `Patient Plan` heading,
157
+ which instructs the model to *"Assess the plan AS A WHOLE in `planAssessment`; do NOT let it
158
+ influence progression / disease / treatment analysis"*:
159
+
160
+ ```
161
+ - Action: "Start rosuvastatin" (timing: 2099-02)
162
+ ```
163
+
164
+ Three undifferentiated rows went in. What comes out is three *different kinds of fact*, each fenced
165
+ off from the others with a stated reason. That fencing is the package's actual output: not a
166
+ cleverer query, but one in which the model cannot mistake a drug the patient stopped eighteen
167
+ months ago for one they are on.
168
+
169
+ ### 5. The round trip that has to converge
170
+
171
+ The finding above is one prompt and one answer. The marker-grouping surface is the other shape this
172
+ package uses, and it is the more instructive one: a bounded loop that keeps re-asking until every
173
+ marker has a home.
174
+
175
+ `runMarkerGroupingPasses` (`marker-groups-prompt.ts:174`) asks the model to place every marker under
176
+ one of the patient's body systems, listed in the prompt under the heading *"BODY SYSTEMS (assign
177
+ every marker to one of these, **verbatim**)"*. For our patient, `contextBlock` renders:
178
+
179
+ ```
180
+ MARKERS TO ASSIGN (place every one of these exactly once):
181
+ - ApoB [watchlist] (latest 80 mg/dL)
182
+ - LDL [watchlist] (latest 120 mg/dL)
183
+
184
+ PURSUED STUDIES (assignment hints):
185
+ - Suspicion: CVD
186
+ ```
187
+
188
+ The answer is not trusted. `reconcileGroups` drops any group name the model did not take verbatim —
189
+ dropped, not corrected, because an invented heading is a mis-filing rather than a typo — keeps the
190
+ first placement of each marker, and sweeps anything still unplaced into an explicit
191
+ `"Not yet categorized"` bucket. Coverage is exactly-once by construction: no marker can be
192
+ duplicated across two systems, and none can silently vanish.
193
+
194
+ Then the loop, and the part worth stealing. Markers left over are re-asked — at most three more
195
+ times, over **only** the still-unplaced residue — and on those passes the escape hatch is taken away:
196
+
197
+ > These markers were NOT assigned on the first pass. EVERY marker below belongs to one of the
198
+ > systems above […] Do NOT output a `"Not yet categorized"` group — **it is not permitted in this
199
+ > response**; if a marker seems to fit no system, choose the one it relates to most.
200
+
201
+ The bucket still exists as a safety net in code — but it is no longer offered to the model as an
202
+ option, because an escape hatch left available becomes the answer. And the loop stops on *two*
203
+ conditions, not one: the residue reaching zero, or a pass placing nothing new. The second is the one
204
+ people forget. A model that has failed to place a marker twice will keep failing, and the third
205
+ attempt costs exactly as much as the first.
206
+
207
+ ### 6. What comes back, and what is checked
208
+
209
+ `assembleFinding` (`finding-assemble.ts:669`) takes the model's parsed response and builds the
210
+ stored `ClientFinding`. Before it does, `validate` refuses a response that is internally
211
+ inconsistent with the prompt that produced it — not merely malformed JSON, but well-formed JSON
212
+ that does not line up with what was asked.
213
+
214
+ Our patient has two note entries, one of which is blank; `populatedNoteEntries` drops the blank, so
215
+ the prompt presents exactly one note. A note has no short label the model could echo back, so
216
+ results are zipped back **positionally**, which only works if the count matches:
217
+
218
+ ```
219
+ noteResults must have exactly one entry per populated Note, in order (expected 1, got 2)
220
+ ```
221
+
222
+ The same discipline runs through the rest: a `studyResults` entry whose `group` is not one of the
223
+ disease groups is rejected by name, a `disease` array with fewer than four areas is rejected, a
224
+ `treatment` array over thirty entries is rejected. A structurally inconsistent answer throws
225
+ instead of being stored — because a wrong association between a note and its result is not a
226
+ display bug, it is a clinical record that says something nobody said.
227
+
228
+ *Demonstrated by:* [`tests/finding-validate.test.ts`](tests/finding-validate.test.ts) §
229
+ *noteResults pairs to notes by position, so the count is the contract* — one case per direction
230
+ (a result too many, a result too few), each red when the count check is removed. The count of
231
+ refusals this section describes is not maintained by hand either:
232
+ [`tests/refusal-count.test.ts`](tests/refusal-count.test.ts) derives it from `finding-assemble.ts`
233
+ and fails every document that states a different one.
234
+
235
+ ## Seven prompts, one discipline
236
+
237
+ The walk above follows one of them. The package has **seven** schema-constrained prompt surfaces, and
238
+ they all work the same way: the prompt states a constraint in prose, and code re-enforces it on the
239
+ response without consulting the prompt.
240
+
241
+ | Surface | What it asks for | What enforces the answer |
242
+ |---|---|---|
243
+ | Finding | The whole clinical reasoning unit — disease areas, treatments, studies, notes | `finding-assemble.ts` — 96 distinct refusals |
244
+ | Marker grouping | Every marker filed under one body system | `GROUPS_SCHEMA` + `reconcileGroups`, then the loop in step 5 |
245
+ | Reference ranges | Age/sex-appropriate ranges for the patient's markers | `RANGE_SCHEMA` |
246
+ | Report extraction | Structured markers out of a lab report | `REPORT_SCHEMA` |
247
+ | Document read | Structured JSON out of an arbitrary document | `DOCUMENT_READ_SCHEMA` |
248
+ | Treatment inference | A product label read as a label, not as a dose | `TREATMENT_INFER_SCHEMA` + the dose rule below |
249
+ | Treatment regrouping | Existing treatments re-filed under changed groups | `TREATMENT_GROUPS_TOOL` + `validateRegroup` |
250
+
251
+ The first row is the one that says the most. It is the largest surface and the only one with **no**
252
+ schema: it asks for strict JSON in prose. Structured output can guarantee that a field is a string —
253
+ it cannot guarantee that the string is a group name the model was given rather than one it made up,
254
+ that answers came back in the order the queries were put, or that a dose was read off a label
255
+ instead of inferred. Where the constraint is semantic, the only place to enforce it is code. That is
256
+ why the surface with no schema is also the surface with ninety-six refusals.
257
+
258
+ Four of the seven have golden fixtures. The gap and the reasoning are in
259
+ [`ARCHITECTURE.md`](./ARCHITECTURE.md) §3.
260
+
261
+ ## Measured
262
+
263
+ One benchmark in this package has been run on real model calls: two retry-correction strategies
264
+ over twelve synthetic cases, scored by the package's own `validate()`. Every attempt passed on the
265
+ first try, which says the Ranges prompt is strong and says nothing about the strategies.
266
+ [`BENCHMARKS.md`](../BENCHMARKS.md#sampled--akesi-pil) has the numbers — the only place this repo
267
+ keeps one.
268
+
269
+ ## When the model gets it wrong
270
+
271
+ `generateFindingResponse` (`finding-generate.ts:1363`) retries a rejected finding with a correction
272
+ appended. Three details of how it does that were each paid for by a real run.
273
+
274
+ **Every prior rejection is accumulated, not just the latest.** One run burned all six attempts it was
275
+ then allowed: attempt 4 failed on a duplicate marker group, 5 on a bad data-requisition group, 6 on a
276
+ `doctorConversation` label. Each correction named exactly one problem; the model fixed that problem
277
+ and broke a different one, and never once saw the accumulated list. A correction that replaces its
278
+ predecessor teaches the model to oscillate between two failures forever.
279
+
280
+ *Measured:* not this claim. Both strategies were run against twelve synthetic cases on two models
281
+ and **not one of the 72 attempts was rejected** — good news about the Ranges prompt, and no evidence
282
+ either way about accumulating corrections, because with nothing to correct both strategies sent
283
+ identical prompts. The paragraph above still rests on the single run that produced it
284
+ ([`BENCHMARKS.md`](../BENCHMARKS.md#sampled--akesi-pil)).
285
+
286
+ **The ceiling is three attempts, and the number is cost-derived.** Each attempt is a whole Opus
287
+ generation — roughly $5 and several minutes. Six of them bought nothing on the run above, so the
288
+ bound came down rather than the corrections getting cleverer. A retry budget that is not priced is
289
+ not a budget.
290
+
291
+ **`onAttemptFailed` reports each rejection to the caller**, for two reasons that belong together. A
292
+ silent retry is indistinguishable from a hang — a live check once ran 32 minutes with nothing on
293
+ screen to say whether it was working or wedged. And the rejection message can name a treatment or a
294
+ study, which makes it PHI-adjacent: the package hands it to the caller rather than logging it, and
295
+ takes no view on where it may go. Observability and disclosure are the same decision here, and it is
296
+ not this package's to make.
297
+
298
+ ## The dose rule
299
+
300
+ The most important logic in this package is not code. `treatment-infer.ts:121-135` is a prompt
301
+ paragraph, and it exists because a model reading a supplement label will otherwise report the
302
+ label's contents as the patient's intake:
303
+
304
+ > **THE DOSE RULE — the one that matters most:**
305
+ > An ingredient amount is a LABEL FACT about the product: what ONE capsule, tablet or serving
306
+ > contains. It is NOT how much the patient takes. […] A product containing 100mcg of selenium, or a
307
+ > label suggesting 1 capsule daily, tells you nothing about what THIS patient takes — the patient's
308
+ > own quantity, frequency and time of day are entered separately, after this step, and may differ
309
+ > from what the label suggests. **Your job stops at the label.**
310
+
311
+ An extraction step that quietly promotes "100mcg per capsule" into "patient takes 100mcg" produces
312
+ a record that reads as a clinical history and is a guess. The rule is stated at length, in the
313
+ prompt, rather than left to be inferred — the domain knowledge is the deliverable, and here it
314
+ happens to be English rather than TypeScript.
315
+
316
+ ## Where `neuro-pil` comes in
317
+
318
+ None of this package's own code decides *when* a finding regenerates — that policy (which source
319
+ change invalidates which derived output, and what triggers a rerun) belongs to the host's
320
+ `neuro-pil` `Dag`. Declare each finding as a `derived` node whose `inputs` are its source markers,
321
+ ranges and prompt template, and `driftedKeys` tells you exactly which findings a given source edit
322
+ stales. Correcting our patient's ApoB from `80` to `85` stales every finding that read it, and
323
+ nothing else.
324
+
325
+ Between the two packages the patient's finding acquires two independent stamps, and the pair is the
326
+ point. `neuro-pil` stamps what the finding was derived *from*; the [brain](../README.md#what-this-is-for)
327
+ that produced it — its prompt, its schema, and the model that answered — is stamped by its own version
328
+ key. A stored finding is still trustworthy only when **both** hold. It can be fresh on one and stale on
329
+ the other: nobody has touched the patient's markers, but the reasoning that read them was replaced last
330
+ Tuesday.
331
+
332
+ See [`ARCHITECTURE.md`](./ARCHITECTURE.md#8-the-hosts-job-a-brain-registry) for how a host layers a
333
+ brain registry and an event log on top of that staleness signal, and
334
+ [`../neuro-pil/compare.ts`](../neuro-pil/compare.ts)'s `compareBrains` for scoring competing prompt
335
+ versions against a fixed case set once you have more than one.
336
+
337
+ ## Module map
338
+
339
+ - **Normalization** — `unit-systems.ts`, `dates.ts`, `ranges.ts` / `ranges-prompt.ts`: converting
340
+ raw lab/marker values and dates into a comparable, unit-consistent shape, and rendering reference
341
+ ranges into prompt-ready text.
342
+ - **Domain registries** — `item-registry.ts`, `system-groups.ts`, `imaging-catalog.ts`,
343
+ `section-labels.ts`: static tables mapping raw report vocabulary (marker names, body systems,
344
+ imaging modalities) onto the labels a prompt or a UI needs.
345
+ - **Marker grouping** — `marker-groups-prompt.ts`: the prompt and JSON schema for assigning every
346
+ marker to a body system, plus `reconcileGroups`, which drops any group name the model did not take
347
+ verbatim from the allowed systems and guarantees exactly-once coverage, and
348
+ `runMarkerGroupingPasses`, which re-prompts only the still-unplaced markers (at most three times,
349
+ stopping early when a pass places nothing new). The model call itself is injected by the caller.
350
+ - **Treatment logic** — `treatment-normalize.ts`, `treatment-bucket.ts`, `treatment-product.ts`,
351
+ `treatment-infer.ts`, `treatment-timing-rules.ts`: turning a free-text treatment/medication entry
352
+ into a normalized, bucketed, timing-aware structure.
353
+ - **Document ingestion** — `document-model.ts`, `document-read.ts`, `report-extract.ts`,
354
+ `report-merge.ts`, `report-title.ts`, `ingest-core.ts`, `parsers-report.ts` (Node-only, `./pdf-node`):
355
+ turning a source document (a lab report PDF, in the reference implementation) into the structured
356
+ form the rest of the package operates on.
357
+ - **Finding construction** — `finding-generate.ts`, `finding-assemble.ts`, `finding-regroup.ts`,
358
+ `marker-deltas.ts`, `pinned-queries.ts`, `factors-edit.ts`: building the actual LLM prompt for one
359
+ reasoning unit ("finding") from its source data, and assembling/regrouping the model's structured
360
+ response.
361
+ - **Benchmarks** — `benchmarks/retry-corrections.ts`: a synthetic case set, two correction
362
+ strategies and the retry loop that runs them, scored by this package's own shipped `validate()`
363
+ rather than by a rubric written to be passed. Not exported from the package root, and never part
364
+ of `npm test` — running it costs real model calls. It constructs no client either: the cases and
365
+ the scorer are data, and a host supplies both the SDK instance and the comparison loop. See
366
+ [`BENCHMARKS.md`](../BENCHMARKS.md#sampled--akesi-pil).
367
+
368
+ ## Importing
369
+
370
+ **The package root exports nothing.** `index.ts` is a comment and `export {};` — there is no barrel
371
+ file, deliberately: a barrel over twenty-nine modules would make every consumer's bundle depend on
372
+ all of them. Every import names its module directly, and the subpath is the module's own filename:
373
+
374
+ ```ts
375
+ import { bucketOf, groupByName } from "@pablotech/akesi/treatment-bucket";
376
+ import { unmappedConvertible, normalizeSeries } from "@pablotech/akesi/unit-systems";
377
+ import { buildUserMessage, SYSTEM_PROMPT } from "@pablotech/akesi/finding-generate";
378
+ import { assembleFinding } from "@pablotech/akesi/finding-assemble";
379
+ import type { Client, TreatmentItem } from "@pablotech/akesi/types";
380
+ ```
381
+
382
+ Every subpath in [`package.json`](package.json)'s `exports` is isomorphic — safe in a browser
383
+ bundle, a Cloudflare Pages Function or a Node CLI alike — with **one** exception: `./pdf-node`
384
+ (`parsers-report.ts`) pulls in `pdfjs-dist`. It is the only Node-only entry point, and it is named
385
+ that way so importing it is a deliberate act rather than something a bundler discovers for you.
386
+
387
+ The package has **no runtime dependencies**. Its two external couplings — `@anthropic-ai/sdk` and
388
+ `pdfjs-dist` — are declared as *optional* peer dependencies, so installing `akesi-pil` pulls in
389
+ neither. Bring the SDK if you call one of the three model-issuing functions, and `pdfjs-dist` if you
390
+ import `./pdf-node`; importing `unit-systems` or `treatment-bucket` should not cost you a PDF parser
391
+ and an HTTP client, and it doesn't.
392
+
393
+ ## Tests
394
+
395
+ `npm test` runs standalone — no credentials, no network, no vault access, no model call. The suite
396
+ asserts eighteen golden prompt fixtures byte-for-byte, so any change to prompt construction shows up
397
+ as a fixture diff rather than a silent pass. Regenerate them with `npm run prompt:golden` and review
398
+ the diff before committing.
399
+
400
+ `benchmarks/` is the one directory `npm test` does not run, because a benchmark issues real model
401
+ calls. Everything in it except the calls is still covered offline against scripted responses — the
402
+ retry loop, both strategies, the scorer and the statistics — because an instrument nobody has tested
403
+ is not a measurement. Those tests assert the accumulation itself, by recording every user message
404
+ the loop sends and requiring the third attempt to carry both earlier rejections under one strategy
405
+ and only the latest under the other; one asserts the module cannot reach a provider on its own.
406
+
407
+ One test in that file is not about prompts at all:
408
+
409
+ ```ts
410
+ it("is generated from a synthetic roster and nothing else", () => {
411
+ const NAMES = ["Bare", "Full", "Empty", "CLI"];
412
+ const clients = [...Object.values(CANONICAL_VARIANTS).map((f) => f()), /* … */];
413
+ expect(clients.length).toBeGreaterThan(NAMES.length);
414
+ for (const c of clients) expect(NAMES).toContain(c.displayName);
415
+ });
416
+ ```
417
+
418
+ Every fixture in this package is synthetic, and this package is published; a real person reaching a
419
+ golden file would be a PHI leak into an open-source artefact. The guard makes that a red build
420
+ rather than a matter of remembering.
421
+
422
+ It checks the generator's *input*, and that is the interesting part. The obvious guard is a denylist
423
+ — scan each fixture for the names that must never appear — and it was written that way first. But a
424
+ denylist has to spell out the names it is keeping out, which in a public repo discloses exactly what
425
+ it exists to protect, and it still passes for a person nobody thought to list. Checking the roster
426
+ instead names only synthetic values and catches every real one, because the two ends are already
427
+ tied: the goldens are pinned byte-for-byte to what this roster produces, so nothing reaches a fixture
428
+ without entering here first. An allowlist over a controlled input beats a denylist over its output.
429
+
430
+ ## License
431
+
432
+ MIT — see [`LICENSE`](./LICENSE).