oficina / memoria Public
Implementation plan — §8.8's fact box
Against docs/superpowers/specs/2026-09-03-memoria-design.md §8.8 draft 3, decision 15, gates 34–37 (38 struck). Branch feat/lookup-box.
Draft 3 is what this plan is written against, and §0 of the previous version of this file is why it exists. Writing the retrieval step out concretely is what found that a corpus-wide facts table cannot coexist with a cap of three. That finding is now in the spec; what remains here is operational.
---
1. internal/memoria/fact — new package
Recognisers, validators, the kind lexicon and the label rule. Imports regexp, strings, unicode and nothing else: 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, not in query. query is about one decision and its doc comment says so. More to the point, the words that ask for a kind and the validator that admits one are two columns of one table and must not drift apart.
type Kind string // "phone", "eircode", "iban", "vat", "email"
// Wanted returns the kinds a query names. Empty means no box.
func Wanted(q string) []Kind
// Found is one validated fact, located.
type Found struct {
Kind Kind
Span string // the document's bytes. The only thing rendered.
Canonical string // match-only; never rendered, never an operand.
Label string // the document's, per §8.8's five rules.
NoBox bool // an over-cap source-1 label: this kind renders nothing.
}
// 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) []FoundBuild order, each step's test before the next:
Wanted— a fixed map, longest phrase first soemail addressbeatsaddress(which maps to nothing). Tests: the five kinds;address,numberandmobile apppriming nothing, nothing andphonerespectively.- Recognise and validate, per kind. Each kind is
{re, canon, valid}. 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.
Verified 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 checkable 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 | | IE29AIBK93115212345679 | invalid | invalid | | A65 F4E2 (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 load-bearing: the invented VAT and the one-digit IBAN edit are what tell a checksum from a regex, and D6W is the case a pattern written [A-Z][0-9][0-9] gets wrong.
Old-style Irish VAT — a letter or + in second position — is refused deliberately. A refused VAT is a box that does not appear; an admitted wrong one is a fact on the page.
phone gets §8.8's structural rule: a bare run of digits is never a phone number; the span must carry + or grouping separators. rank_test.go's DUNS/D-U-N-S number is 985632364 is the fixture that proves it — nine digits, a plausible Irish mobile without the rule. This is a validator test in fact, and a rejection fixture in gate 35 — not gate 37, which is the label gate.
Separators include U+00A0, since Go's \s does not match it and Word and Notion paste it. chunk.go does not normalise interior ones, so the bytes reach the sealed row.
label, the part drafts 1 and 2 both got wrong. Given the span's byte offset in the chunk text: - walk back to the start of the enclosing sentence or line —.,!,?,\n, or the start — honouring a fixed abbreviation list (Dr. Mr. Mrs. Ms. St. No. ext. Ltd. Co.), which is stated as incomplete and whose failures compose; - if the run contains an earlier recognised span, start after it; - if what remains ends in:,—,–,-,=or tab: - empty after removing it and trimming → fall through to the trail; - longer than 60 →NoBoxfor this kind; - else → the label; - else the heading trail; else the document title, never"".
Every span is taken by byte offset, so Span is a slice of the input and cannot be a reconstruction. Gate 36's byte-identical property is then a property of the type rather than a discipline.
---
2. internal/memoria/store.go — candidates, after the ruling
One unexported method called by Search after rule:
want := fact.Wanted(q); empty → return. No database work for a query naming no kind, which is the common case.- Read the sealed chunk rows for
p.Results— the ruled slice, neverall— in oneINquery; open underseal.PurposeChunk. At most 25 rows. - A chunk row that is gone is skipped silently, as
livePassageskips a stale hit. It does not fail the search. fact.Scanper result, carrying that result'sTitleandHeadingas the label fallbacks and itsDocumentID/Ordinalas the citation.- Group by
Kind. Every rule below is per kind. - Dedupe on
Canonicalinto an ordered slice plus a seen-set — not a bare map, whose range order is randomised and whose first-seen order gate 35 asserts. First occurrence wins the citation, and becausep.Resultsis in rank order that is the best-ranked passage. - Any
NoBoxin the kind → drop the kind. - Two candidates sharing the whole line
(Title, Heading, Label)→ drop the kind. - More than three → drop the kind.
Page gains Facts []Fact; Fact carries Kind, Title, Trail, 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.
---
3. internal/memoria/handlers.go
runSearch copies found.Facts onto the page beside found.Notices. That is the whole handler change, for both routes, because index and searchResults share runSearch — which is why the page and the fragment cannot disagree about the box any more than they can about priming.
---
4. templates/results.html — inside the define
Between the notices block and <ol rst-results>: below the withholding callout, above the list, inside {{define "results"}} so the fragment swap replaces box and list together.
Each row carries title and trail as well as label and value (§8.8 — two pages called "Contact" are the same row to a reader who cannot see which is which), and the citation link's text is the document title, not a bare "source".
The box must never reuse rst-result-title. search.js targets that attribute to decide where Enter goes; a link inside the box wearing it would make Enter land on the box's citation, which §8.8 rules against. The template comment says so.
No safeSnippet — that helper exists to let FTS5's <mark> through and nothing here has one. Span gets Go's ordinary contextual escaping. No anchor built from a document's bytes.
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, which is why the stylesheet is written before the template rather than after.
---
5. tools/drive — the section nobody seeds is the section nobody sees
AGENTS.md, and it has found five bugs this way. Seed three shapes, not one:
- a labelled office number and an eircode in an address block — the happy case;
- the motivating fixture's paragraph, two numbers with distinct sentence labels — the enumerated case, where the reader should see "unused" and understand the page;
- a bulleted contact pair — the collision case, where the box should be absent. The previous version of this plan called the two-sentence paragraph the collision case; it is the opposite, and a seed that demonstrates the wrong thing is worse than no seed.
---
6. Tests, in the order they get written
Unit, in fact, before any wiring. Wanted; each validator's accept and reject rows from §1.2; the label's eight cases from gate 37; spans with an extension and with U+00A0.
Gates, each carrying its mutation in the comment:
- 35, 36, 37 in
internal/memoriatest/gates_test.go— slice 0. - 34 in
internal/memoria, at store level, against a custody fake that rules per item id.fixedCustodyreturnssibling.Decide(f.give)for every candidate from one field and cannot express fixtures 2–6. Extending it is part of this branch. - 38 is struck, not written. The measurement it was going to assert is prose in §8.8; a test that injects a term into every row and asserts
bm25()collapses tests SQLite and no mutation of this repo turns it red.
And the mutations are not mine to pick. §1.9: the gate review picks its own, and this branch is not done until somebody who did not write it has tried them.
---
7. What this does not touch
index/ (no column, no table, no weight, no MatchQuery change), chunk.go (no change to Indexable), models.go, migrations/ (nothing new is stored), seal/ (no new purpose), the sibling contract, the connectors, Delete, Rebuild.
That list is the strongest evidence for draft 3. Draft 2's design touched five of them.
---
8. What building it changed (2026-09-15)
Kept as the record of what the plan did not foresee.
The reachability limit is the ordinary case, not an edge. Writing the gates showed that bare phone number does not match a document reading Office: +353 (0)1 547 7854 — the words are not there, nothing ranks, and there is nothing to extract from. Every fixture had to be given a word the query could find. Pinned in both directions by TestTheBoxOnlySeesWhatRetrievalReturned, and §12 carries the fix.
Three recognisers were wrong on ordinary text, all found by the implementation review and none by the fixtures: Take the N11 then exit at Bray boxed N11 then; The deadline is 01-02-2024 10:30 boxed 01-02-2024 10; Growth of +10 000 000 users boxed +10 000 000. And the grouped IBAN pattern made that kind inert on its commonest written shape — IE29 AIBK … 78 and our BIC is … found nothing at all. §8.8 now carries the three structural rules that fix them.
Label derivation was quadratic in the facts a chunk holds. chunk.Split bounds a chunk by WORDS, so a 2 MiB upload with no whitespace is one chunk and one line: 48.5s to scan 1 MB, which at the upload cap is minutes of search latency bought by one person's file. Deduping before labelling, binary-searching the span list and bounding the backward walk took 1 MB to 152 ms.
Four gates had holes the author's own mutations could not reach, which is §1.9's whole point. silenced counted before the ruling, a citation cross-wired field by field, a collision check comparing only neighbours, and rows sorted rather than first-seen — each passed every shipped fixture and each now has one written for it.
And addFacts turned out to be structurally unable to see unruled data, because it takes only *Page. The leak mutations all have to be made in Search, one level up. That is a stronger statement of the design than the comment claiming it, and it is why the gate-34 comment names the mutation at the level where it is reachable.