oficina / memoria Public

Plan for §8.8's fact box, and the contradiction it found

Writing the retrieval step out concretely showed that draft 2's facts
table cannot work: the section caps candidates at three, the table is
queried corpus-wide by kind, and any real corpus holds dozens of phone
numbers — so the cap fires on the first query, on every kind, forever.
Bounding it requires ranking candidates and keeping the best three,
which is what "never by picking" forbids.

Scoping candidates to the ruled top-25 fixes it and leaves the facts
table with no query-time job. Its only other job was the ingest-time
ranking lift, which is the job the IDF problem attacks. So §0 proposes
one extraction, at query time, over the ruled results, and the
reachability limit goes back to §12 where draft 1 had it.

The IDF claim draft 2 asserted is now measured rather than argued:
best bm25() for "phone" is -2.7169 with the term in 1 of 20 rows and
-0.0000 injected into all 20. A collapse, not a degradation — worth
keeping as the reason §12's eventual fix is not "index the kind words".

The three checksum validators are verified against known-good values —
Google Ireland's VAT, two standard IBANs, the published eircode example
and Dublin 6W's irregular routing key — with reject cases, because a
validator that admits everything is a guard written to pass. Phone has
no checksum and the plan says so rather than implying one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 828385e8f707ced60851a50a48f62b790e954ffa parent 08f4408
1 file changed, +307 −0
  • FACT-BOX-PLAN.md +307 −0
diff --git a/FACT-BOX-PLAN.md b/FACT-BOX-PLAN.md
new file mode 100644
index 0000000..0605dbf
--- /dev/null
+++ b/FACT-BOX-PLAN.md
@@ -0,0 +1,307 @@
+# Implementation plan — §8.8's fact box
+
+Against `docs/superpowers/specs/2026-09-03-memoria-design.md` §8.8,
+decision 15, gates 34–38. Branch `feat/lookup-box`.
+
+---
+
+## 0. The design change this plan proposes, before anything is built
+
+**Draft 2's facts table should not be built, and the plan is what found
+it.** Writing the retrieval step out concretely produced a contradiction
+the spec does not survive:
+
+- §8.8 caps candidates at three, *"three renders, four renders nothing"*.
+- Draft 2's facts table is queried by kind: `WHERE kind = 'phone'` over
+ the whole corpus.
+- A real team corpus holds dozens of phone numbers. `rank_test.go`'s
+ twelve-chunk fixture already holds two, plus four account numbers and a
+ VAT number.
+
+So a corpus-wide fact query passes the cap on the first query anyone
+types, on every kind, forever. The box would never appear. The only way
+to bound it is to rank the candidates and keep the best three — which is
+**precisely what "never by picking" forbids**, and forbids for a good
+reason: BM25 order carries no claim about which phone number is the
+office one.
+
+**The candidate set must therefore be scoped by the query, and the ruled
+top-25 is already exactly that scope.** Once candidates come from the
+ruled results, the facts table has no query-time job left. Its only other
+job was the ingest-time ranking lift, and that is the job the IDF problem
+attacks.
+
+**So: one extraction, at query time, over the ruled results. No facts
+table, no ingest-time half, no union, no second IDF space, no appending
+to the results list, no `FactWeight`.** Six of draft 2's moving parts
+turn out to be load carried for a part that should not exist.
+
+**What this costs, stated rather than hidden:** the reachability limit
+draft 1 had and draft 2 claimed to remove. A fact in a document that does
+not reach the ruled top-25 is a fact the box does not have — an eircode
+written `D02 XY45` in a document that never says "eircode" is findable
+only if something else about it ranks. Draft 1 named this and deferred the
+fix to §12; that is where it goes back.
+
+**And the IDF measurement stays, because it is why the deferred fix is
+not "index the kind words".** Measured on `modernc.org/sqlite`'s FTS5,
+20 rows, one carrying `phone`:
+
+| Table | best `bm25()` for `"phone"` |
+|---|---|
+| `phone` in 1 of 20 rows | **-2.7169** |
+| `phone` injected into all 20 | **-0.0000** |
+
+Not a degradation: a total collapse. Whatever §12 eventually builds, it
+is not an extra term in the main index.
+
+**§8.8 needs a draft 3 to say this before any code is written.** The
+plan below assumes it.
+
+---
+
+## 1. `internal/memoria/fact` — new package
+
+The recognisers, the validators, the kind lexicon and the label rule. No
+imports beyond `regexp`, `strings`, `unicode`. Pure, table-driven and
+unit-testable without a database, a browser or an app — the same reason
+`query` and `chunk` are their own packages.
+
+**The lexicon lives here, beside the validators, and not in `query`.**
+`query` is about one decision — is this a question or a lookup — and its
+own doc comment says so. More to the point, the words that ask for a kind
+and the validator that admits one must not drift apart: they are two
+columns of one table and they belong in one file.
+
+```go
+type Kind string // "phone", "eircode", "iban", "vat", "email"
+
+// Wanted returns the kinds a query names, in no particular order.
+// Empty means no box.
+func Wanted(q string) []Kind
+
+// Found is one validated fact, located.
+type Found struct {
+ Kind Kind
+ Span string // byte-identical to the document. The only thing rendered.
+ Canonical string // match-only; never rendered, never an operand.
+ Label string // the document's, per §8.8's three sources.
+}
+
+// Scan returns every validated fact of the wanted kinds in text.
+// heading and title are the label fallbacks, in that order.
+func Scan(text, heading, title string, want []Kind) []Found
+```
+
+Build order inside the package, each step with its test before the next:
+
+1. **`Wanted`** — a fixed map, longest-phrase-first so `email address`
+ beats `address` (which maps to nothing). Tests: the five kinds; that
+ `address`, `number` and `mobile app` prime nothing, one kind and
+ nothing respectively.
+2. **Recognise + validate, per kind.** Each kind is
+ `{re *regexp.Regexp, canon func(string) string, valid func(string) bool}`.
+ The regex is deliberately loose and the validator does the work — that
+ is §8.8's *"validation is not pattern-spotting"* made structural, and
+ it is what stops the next person tightening a regex into a validator.
+ - `eircode`: routing key from `ACDEFHKNPRTVWXY` + 3 more, then 4 chars.
+ - `phone`: canonicalise by stripping non-digits, honouring a leading
+ `+` and dropping a `(0)` after a country code; valid if it lands on
+ E.164 or an Irish national number.
+ - `iban`: country + length-by-country + mod-97.
+ - `vat`: Irish format + checksum.
+ - `email`: `local@domain`, domain with a dot, no whitespace. **Its
+ weakness is §8.8's, stated there; the code comment says so and does
+ not pretend otherwise.**
+
+ **Three of the four algorithms were run against known-good values
+ before this plan was written**, because a validator that admits
+ everything is the guard-written-to-pass failure in its purest form
+ and a checksum is the one thing here that can be checked against the
+ world rather than against a fixture somebody wrote:
+
+ | Input | Expect | Got |
+ |---|---|---|
+ | `IE6388047V` (Google Ireland) | valid | valid |
+ | `IE3628739L` | valid | valid |
+ | `IE1234567X` (invented) | **invalid** | invalid |
+ | `IE29AIBK93115212345678` | valid | valid |
+ | `GB82WEST12345698765432` | valid | valid |
+ | `IE29AIBK9311521234567`**`9`** (one digit changed) | **invalid** | invalid |
+ | `A65 F4E2` (the published example) | valid | valid |
+ | `D6W 1234` (Dublin 6W's irregular key) | valid | valid |
+ | `B12 3456` (`B` is not a routing-key letter) | **invalid** | invalid |
+
+ The reject rows are the load-bearing ones: `IE1234567X` and the
+ one-digit IBAN edit are what tell a checksum from a regex, and `D6W`
+ is the case a routing-key pattern written as `[A-Z][0-9][0-9]` gets
+ wrong.
+
+ **Old-style Irish VAT — a letter or `+` in the second position — is
+ refused**, deliberately. It is the safe direction: a refused VAT is a
+ box that does not appear, an admitted wrong one is a fact on the page.
+
+ **`phone` has no checksum and this plan will not pretend it does.**
+ §8.8 calls its validation "length and prefix bounds", which is
+ structural, so its false-positive mode is a number that is not a phone
+ number. The structural rule that bounds it: **a bare run of digits is
+ never a phone number.** The span must carry `+`, or separators
+ grouping it. That is not a hack bolted on — it is how documents
+ actually write phone numbers, and it is what keeps `rank_test.go`'s
+ own corpus clean. That fixture holds `account number 80854641`,
+ `company number is 566334` and `DUNS/D-U-N-S number is 985632364`;
+ the last is nine digits and would be a plausible Irish mobile without
+ this rule. **Gate 37's corpus is that fixture, for exactly that
+ reason.**
+3. **`label`**, the part draft 1 got wrong and the part most likely to go
+ wrong again. Given the text and the span's byte offset:
+ - walk back to the start of the enclosing sentence or line — `. `,
+ `! `, `? `, `\n`, or the start of the text;
+ - if that run ends in `:`, `—`, `-`, `=` or a tab **and** is ≤ 60
+ characters once trimmed, it is the label;
+ - else the heading trail; else the document title.
+
+ The sentence split is naive and must stay naive — the comment says
+ which direction it fails in and why that is the safe one.
+
+**Every regex is anchored on a word boundary and every span is taken by
+byte offset**, so `Span` is a slice of the input and cannot be a
+reconstruction. The gate-36 property — byte-identical display — is then a
+property of the type rather than a discipline.
+
+---
+
+## 2. `internal/memoria/store.go` — candidates, after the ruling
+
+One new method, deliberately not a new parameter on `Search`: `Search`
+already returns `Page` and the box is part of the page.
+
+```go
+// Facts fills p.Facts for the kinds this query names. Called by Search,
+// after rule(), over nothing but the results rule() kept.
+func (s *Store) facts(ctx context.Context, p *Page, q string)
+```
+
+Steps, and the order is the spec's ruling:
+
+1. `want := fact.Wanted(q)`; if empty, return. **No database work for a
+ query that names no kind** — this is the common case and it must cost
+ nothing.
+2. For `p.Results` — the **ruled** slice, never `all` — read the sealed
+ chunk rows by `ChunkID` in one query and open them under
+ `seal.PurposeChunk`. At most 25 rows, one `IN` query, 25 AEAD opens.
+3. **A chunk row that is gone is skipped silently**, exactly as
+ `livePassage` skips a stale hit and for the reason recorded there.
+ It does not fail the search: the box is a garnish and trading the page
+ for it is the wrong direction.
+4. `fact.Scan` per chunk. Collect into a map keyed by `Canonical` —
+ **that is the candidate identity** (§8.8, gate 35). The same number in
+ five results is one candidate, cited to the first result carrying it,
+ which is the best-ranked because `p.Results` is in rank order.
+5. If two candidates render the same `Label`, **no box** — return with
+ `p.Facts` nil.
+6. If `len(candidates) > 3`, **no box**.
+7. Otherwise `p.Facts` in first-seen order.
+
+`Page` gains `Facts []Fact`, and `Fact` carries `Kind, Label, Span,
+DocumentID, Ordinal` — **not `ChunkID`, and not the chunk text**. A field
+a template can reach is a field a template eventually prints, which is
+why `Result.item` is unexported; the box needs no more than its citation.
+
+---
+
+## 3. `internal/memoria/handlers.go` — one line, and a shape question
+
+`runSearch` already has everything: it calls `store.Search` and copies
+`found.Results` and `found.Notices` onto the page. It copies `found.Facts`
+too. That is the whole handler change, and it is the whole handler change
+for both routes — `index` and `searchResults` share `runSearch`, which is
+why the page and the fragment cannot disagree.
+
+`page` gains `Facts []Fact`, beside `Notices` and for the same reasons,
+with the same kind of comment.
+
+---
+
+## 4. `internal/memoria/templates/results.html` — inside the define
+
+Between the notices block and the `<ol rst-results>`, per §8.8: below the
+withholding callout, above the list, **inside** `{{define "results"}}` so
+the fragment swap replaces box and list together.
+
+```
+{{if .Facts}}
+ <div rst-fact-box>
+ {{range .Facts}}
+ <p rst-fact>
+ <span rst-fact-label rst-cell-mut>{{.Label}}</span>
+ <span rst-fact-value>{{.Span}}</span>
+ <a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-fact-cite>source</a>
+ </p>
+ {{end}}
+ </div>
+{{end}}
+```
+
+**No `safeSnippet`.** `Span` is a document's bytes and gets Go's ordinary
+contextual escaping; the snippet helper exists to let FTS5's `<mark>`
+through and nothing here has one. **No anchor built from a document's
+bytes** — §8.8 and §8.5; the only link is the citation.
+
+`memoria.css` gets `[rst-fact-box]`, `[rst-fact]`, `[rst-fact-label]`,
+`[rst-fact-value]`, `[rst-fact-cite]`, **tokens only, no colour
+literals** — `TestNoColourLiteralsInAppCSS` and
+`TestEveryTemplateAttributeIsInTheDesignSystem` both bite here, and the
+second one is why the attributes are declared in the stylesheet before
+the template is written rather than after.
+
+---
+
+## 5. `tools/drive` — the section that is not seeded is the section nobody sees
+
+AGENTS.md, and it has found five bugs this way. `seedViaHTTP` gains a
+document carrying a labelled phone number, an eircode in an address
+block, and — deliberately — a second phone number in the same paragraph
+as the first, so the running app shows the ambiguity case and the label
+collision rather than only the happy one.
+
+---
+
+## 6. Tests, in the order they get written
+
+Unit, in `fact` — before any wiring:
+`Wanted`; each validator's accept and reject; the label's five cases;
+byte-identical spans including an extension and a non-breaking space.
+
+Gates, in `internal/memoriatest/gates_test.go`, numbered as §10 numbers
+them, each carrying its mutation in the comment:
+
+- **35, 36, 37, 38** — slice 0, no sibling needed. Note that **gate 38
+ changes shape** with §0's design change: there is no facts table to
+ prove non-interference for, so what survives is the half that matters —
+ **the IDF measurement recorded as a regression test on the main index**,
+ so that the next person who reaches for "just index the kind words"
+ finds the number rather than the idea. `TestOrRecallStillRanksBestMatchFirst`
+ and `rank_test.go`'s NEAR measurement must be unchanged by this branch,
+ which is now assertable by simply running them.
+- **34** — needs slice 1's S2 stub, which `memoriatest` has. Four
+ fixtures: presence (two VISIBLE → two candidates), `NOT_VISIBLE` → one
+ candidate **and empty notices**, `UNKNOWN` → one candidate **and a
+ notice**, and four-with-one-withheld → three candidates. Asserting the
+ struct, not the page.
+
+**And the mutations are not mine to pick.** §1.9: the gate review picks
+them, and this branch is not done until somebody who did not write it has
+tried its own.
+
+---
+
+## 7. What this plan does not touch
+
+`index.go` (no new column, no new table, no weight), `chunk.go` (no
+change to `Indexable`), `models.go` (no new model), `migrations/` (no
+migration — nothing new is stored), `seal` (no new purpose), the sibling
+contract, the connectors, `Delete`, `Rebuild`, `MatchQuery`.
+
+That list is the strongest evidence for §0. Draft 2's design touched
+five of them.