docs← Back to article

Markdown for LLMs

Batch: many cases, one loop

The source Markdown for this article. Copy it into your assistant or download it as a text file.

Download this articlePlain text ↗
# Batch: many cases, one loop

The engine answers one case per call: there is no native batch call that
takes many cases at once. To evaluate many cases, the application loops:
one case in, one answer out, errors kept per case. This page shows that
loop on the deadline example.

```text
deadline-app-0.1.0.zip: src/batch.ts, test/fixtures/batch-cases.json
```

## When to use it

Use the loop when cases arrive together — a file of forms, a queue of
submissions, a backlog to clear. Each case still travels the full data
path (validate, facts, query, engine, read, view, capture); the loop
adds no new layer and changes no module.

## The procedure

Run the shipped fixture — three good rows and one bad row:

```bash
node --import tsx src/cli.ts batch test/fixtures/batch-cases.json /tmp/report.ndjson --captures /tmp/captures
```

```text
rows: 4, ok: 3, failed: 1
report: /tmp/report.ndjson
```

One NDJSON line per row, in input order. The bad row records its
validation error and the run continues past it:

```json
{"id": "calc-1", "ok": true, "headline": "The period ends on 2026-03-20", "capture": "case-0001.json"}
{"id": "verify-1", "ok": true, "headline": "Yes — the proposed date is established", "capture": "case-0002.json"}
{"id": "bad-1", "ok": false, "errors": ["eventDate must be a calendar date in YYYY-MM-DD form"]}
{"id": "verify-2", "ok": true, "headline": "Not established either way", "capture": "case-0004.json"}
```

(The `detail` field ships on every kept line; it is trimmed here for
width. The full lines are in the report file the command writes.)

For each case, in order:

1. Validate the raw fields with `validateFormInput`. A bad form records
   an error for that case and the loop moves on; no engine call runs.
2. Build a fresh case input with `toCaseInput(form)`. Fresh per
   case: nothing carries over from the previous iteration.
3. Evaluate with `evaluateCollect(form)` — or the truth call when the
   row proposes a candidate — against the shared model handle
   (`de.bgb.fristen@0.1.0`, `offline: true`).
4. Read the answer into `value|claim|not-computed|unknown` and shape
   it with `toViewModel`.
5. Store a capture per kept answer under `--captures/case-NNNN.json`
   with `CAPTURE_FORMAT deadline-app.capture/1`.

```ts
// src/batch.ts
export async function runBatch(
  rows: BatchRow[],
  opts: { model?: LawPackage; capturesDir: string; onLine: (line: BatchLine) => void },
): Promise<BatchCounts> {
```

The `try/catch` sits inside the loop, around one case: a failure records
itself next to its case id and the run continues — including an
engine-level throw, which a dedicated test pins with a failing model
stub. The shared model handle is read-only across iterations; all
per-case state is built fresh inside the iteration. Capture files are
index-named (`case-0004.json`), never id-named: row ids are untrusted
input and must not become path segments. The command exits 1 when any
row failed, 0 when all passed.

## Sequential execution

The loop awaits each case before starting the next. Results are
written as each case completes, so finished cases never accumulate —
but the input array is held whole: `runBatch(rows: BatchRow[], …)`
takes every row up front, and that array plus the working data of
the active computation is what lives in memory. What sequential
execution does not bound is the report itself either — the file
grows with the number of cases, and that is expected. Order is the
other reason: failures stay attributable, and the report reads in
input order.

## The report

The fixture run ends with kept rows and error rows side by side:

| Row | Call | Outcome | Capture |
|---|---|---|---|
| `calc-1` | collect | `COMPUTED 2026-03-20` | `case-0001.json` |
| `verify-1` | truth on `2026-03-20` | `TRUE_ONLY` | `case-0002.json` |
| `bad-1` | — | validation error; the engine never runs | — |
| `verify-2` | truth on `2026-03-21` | `NEITHER` (the period ends on the 23rd) | `case-0004.json` |

Each row makes one call — collect when no candidate is proposed,
truth when one is. A row that needs both answers is two rows, or a
loop of your own over `runBatch` lines.

Every kept row reports `via` as `local` and carries the `program`,
`semantic`, and `result` hashes; `sources` is empty in this canon
build. To reproduce any row later, replay its capture with
`replayCapture` under the same pins and the same SDK.

## What batch is not

- Not a native batch call: the engine exposes no multi-case entry point,
  and the loop does not emulate one — it calls the single-case functions
  once per case.
- Not parallel: iterations run in order against one shared handle.
- Not a merge: cases never combine into one case input, and answers
  never combine into one document. Each kept answer gets its own capture
  file, replayed by the SDK that saved it.
- Not constant total memory: the input array is held whole and the
  report file grows with the run; only the per-case working set is
  bounded to the active computation. Streaming the input
  (`AsyncIterable`, chunked reads, backpressure on the sink) is a
  separate extension for large runs, not part of this recipe.

## Check your understanding

```bash
node --import tsx --test test/batch.test.ts
```

```text
ok 1 - batch runs the fixture: two keeps, one validation error, one NEITHER
ok 2 - batch continues after an engine-level failure
# tests 2
# pass 2
# fail 0
```

Predict before you open each answer.

<details>
<summary>Row `bad-1` has a malformed `eventDate`. Does the engine run for it, and does the batch stop?</summary>

Neither. Validation rejects the row before any engine call, the error
is recorded next to `bad-1`, and the loop moves on to `verify-2`. The
command exits 1 at the end because one row failed.

</details>

<details>
<summary>Why is the capture for `verify-2` named `case-0004.json`, not `verify-2.json`?</summary>

Capture files are index-named: `verify-2` is the fourth row. Row ids
are untrusted input and must not become path segments.

</details>

<details>
<summary>A row proposes a candidate date. How many engine calls does it make?</summary>

One: a truth call on the candidate. Each row makes one call — collect
when no candidate is proposed, truth when one is. A row that needs both
answers is two rows.

</details>

## Next

- [Offline: install once, run with no network](/build/patterns/offline/)