# Recorded measurements

A review of draft 2 observed that every performance figure in §7 was an
assertion with no benchmark, command, machine or sample behind it, and
that the FTS size could not be reconciled with the stated schema. Both
were fair. This file is the answer: `tools/bench` produces it, and §7
cites it rather than restating numbers.

## Machine and command

```
linux/amd64, 16 cpu, go1.26.5
go build -o bench ./tools/bench
./bench --corpus /home/paulca/amadan.net/oficina --chunks 90000
```

Run 2026-09-03. The corpus is every `.md` in the oficina tree, chunked by
**memoria's own chunker** (`internal/memoria/chunk`), then cycled to reach
the target count. Cycling repeats vocabulary, so the FTS figures are
**optimistic**: genuinely varied English produces a larger postings list.

## What a memoria chunk actually is

650 chunks from the real corpus, **mean 202 words / 1325 bytes**.

This corrects draft 2, which assumed 400 words and ~1.6 KB. The chunker
targets 400 words but folds runts and never straddles a heading, and real
documents have headings every few paragraphs — so the mean lands at about
half the target. Every figure below follows from the measured size, not
the intended one.

At 90,000 chunks that is **113.8 MB of text**.

## FTS5

| schema | index size | bytes/chunk | build | one BM25 query |
|---|---|---|---|---|
| `fts5(body, tokenize='porter unicode61')` | **200.0 MB** | 2329 | 6.8 s | 1.09 ms |
| `fts5(body, content='', tokenize=…)` | **49.3 MB** | 574 | 6.2 s | 1.09 ms |

**Draft 2 claimed 104 MB. That was wrong by about half.** A default FTS5
table keeps its own copy of the inserted text, which the earlier
measurement did not account for; the review caught this before the
arithmetic downstream of it was trusted.

The contentless table is **4× smaller** and just as fast to query, but it
cannot return column values, so `snippet()` does not work on it and
memoria would have to build its own highlight from the sealed chunk. That
is a real trade and §7 records it as a decision rather than an accident.

## Brute-force cosine, one goroutine, pure Go, no SIMD

Best of three runs. Draft 2 asserted 3.2 GFLOP/s; measured, it is 3.2
GFLOP/s, and every latency in draft 2's table was right to within a
millisecond.

| chunks × dims | measured | GFLOP/s | vectors f32 | vectors int8 |
|---|---|---|---|---|
| 10,000 × 512 | 3.18 ms | 3.2 | 20 MB | 5 MB |
| 90,000 × 512 | 28.9 ms | 3.2 | 176 MB | 44 MB |
| 100,000 × 768 | 49.2 ms | 3.1 | 293 MB | 73 MB |
| 250,000 × 512 | 80.0 ms | 3.2 | 488 MB | 122 MB |

## The wake footprint, recomputed

At 90,000 chunks:

| design | FTS | vectors | separate text | total |
|---|---|---|---|---|
| default FTS (text lives in the index) | 200 MB | 44 MB | — | **244 MB** |
| contentless FTS + text for snippets | 49 MB | 44 MB | 114 MB | **207 MB** |

So the ceiling under a 250 MB / 5-second budget is **about 90,000–110,000
chunks**, not the 150,000 draft 2 derived. Draft 1's ~100,000 was closer,
and draft 2's revision moved the number the wrong way while claiming to
have fixed the arithmetic.

## Still not measured

- Bucket read bandwidth and AES-GCM throughput on the platform. The
  50–100 MB/s and ~1 GB/s in §7.3 remain assumptions, and they are the
  inputs the ceiling is most sensitive to.
- The rest of a cold start: opening SQLite, allocating the vector matrix,
  parsing the snapshot, building id maps. The 5-second budget is spent on
  transfer and decryption alone, which flatters it.
- A corpus of genuinely varied English rather than a cycled one.
