oficina / memoria Public

Fact box: narrow by the reader's own words over the cap (§8.8, Paul 2026-09-29)

Paul searched "what is the office phone number?" and got no box: the
top-25 held four or more distinct numbers and the cap of three fired.

When a kind is over the cap, the box now shows the one candidate whose
own label (rule 2, never the heading/title fallback) contains every
word of the query that isn't a kind word, generic word or stopword.
Exactly one, or nothing, as Paul approved. A line under it says what
matched and how many other candidates there were, counted after the
§4.1 ruling. List markers are no longer part of a label.

Spec §8.8 amended, gates 35/37 amended, gate 40 added, §13 item closed.
Spec reviewed by GPT-6 Astra (REQUEST CHANGES, all four must-fixes
answered); its second pass over the answers and the code was still
running when Paul asked for the merge, and its findings land as a
follow-up. 18 mutations, 17 red, the 18th's guard unreachable and
deleted. Deployed as v0.1.0-17-g6593119 before landing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/fact-box-narrowing into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 45675e2b1cc9d739b0e5356075776fec82c58c02 parent cd4856a
10 files changed, +794 −49
  • docs/superpowers/specs/2026-09-03-memoria-design.md +239 −20
  • internal/memoria/fact/fact.go +110 −11
  • internal/memoria/fact/fact_test.go +85 −0
  • internal/memoria/handlers.go +3 −0
  • internal/memoria/narrow_test.go +192 −0
  • internal/memoria/static/memoria.css +7 −0
  • internal/memoria/store.go +114 −18
  • internal/memoria/templates/results.html +5 −0
  • internal/memoriatest/factbox_test.go +32 −0
  • tools/drive/main.go +7 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.927s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.630s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/notion	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/provider	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/query	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/seal	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	(cached)
ok  	amadan.net/oficina/memoria/internal/memoriatest	2.031s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index fb9a7fc..6ef0493 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -3074,12 +3074,18 @@ not wrong — the box describes what this search found — but it means the
box is a function of the page, not of the corpus, and nobody should read
it as the latter.
-**Whether the cap makes the box inert on the corpus that motivated it is
-unmeasured.** Decision 15's principle is settled; this measurement is
-about whether the cap of three is the right number, and it is still owed.
-A real top-25 for "phone number" on Paul's live corpus may well carry more
-than three distinct valid numbers. §13 records it. The remedy if it does
-is a narrower kind lexicon or a per-kind cap, **not** a rule that picks.
+**Whether the cap made the box inert on the corpus that motivated it was
+unmeasured until Paul hit it, 2026-09-29.** He searched *"what is the
+office phone number?"* on the live instance and got 25 results and no
+box. The Office page alone carries the office number and a list of
+unused ones, so the ruled top-25 held four or more distinct valid numbers
+and the cap fired — on the very query this section was written for. This
+section used to say the remedy was *"a narrower kind lexicon or a
+per-kind cap, **not** a rule that picks"*. Neither of those would have
+helped: the lexicon was already right (the query asked for a phone
+number), and any cap large enough to pass would show a list of unused
+numbers as the answer. **Paul ruled the remedy instead: narrow by the
+reader's own words** — see *"Narrowing by the reader's own words"* below.
**§4.1 is unaffected, in the direction that matters.** A reader for whom
passages are withheld draws candidates from a smaller set, never a larger
@@ -3324,6 +3330,29 @@ span's position in the chunk text:
upload with no headings, which is the common pasted-file shape — **the
document title**, never the empty string.
+**A leading list marker is markup, not part of the label (2026-09-29).**
+Rule 1 anchors the run at the start of the line, and a list item's line
+starts with its marker. So `- Office: 01 547 7854` was labelled `- Office`,
+and a to-do item `- [x] Office: …` was labelled `- [x] Office`. For a
+Notion page, memoria's own renderer wrote that marker
+(`notion/markdown.go` renders bulleted items and toggles as `- `, to-do
+items as `- [ ] `/`- [x] `, quotes as `> `), so the label carried memoria's
+syntax as though the document had written it. (A numbered item's `3. `
+was already dropped, because the sentence split treats a full stop after
+a digit as a sentence end. An earlier draft of this paragraph said
+otherwise.)
+
+**The rule:** before rule 2 runs, the run — from where rules 1 and 1's
+earlier-span clause start it, up to the value — loses its leading
+indentation and one leading marker: `-`, `*`, `+` or `•`, each followed
+by a space; `N.` or `N)` followed by a space; either of those followed by
+`[ ]` or `[x]`; or `>` followed by a space. A bare bullet (`- +353 1 547
+7854`) is untouched: its run is trimmed on the right to `-`, which has no
+space after the marker and so is not one, and rule 3 still sends it to
+the trail, exactly as before. The
+cost is stated beside narrowing's other limitations: a literal prefix
+shaped like a marker cannot be told apart from markup.
+
**Draft 1 required the value to start its own line and capped the label at
40, and the repo's own fixture is what breaks that.**
`index/rank_test.go:56` — built from the live instance where Paul hit this
@@ -3380,7 +3409,11 @@ underspecification, honestly served by showing both, labelled and cited.
**Never by picking.** Not highest BM25, not first, not longest. Rank order
sorts a result list and carries no claim about which number is the office
-one.
+one. **The one narrowing allowed is a match between the reader's words
+and the document's own label, and only once the cap has fired** (Paul,
+2026-09-29, below). That is not picking in this paragraph's sense: no
+score and no order are involved, every candidate is treated the same,
+and when two labels match, nothing renders.
**A box row carries the document title and the heading trail as well as
the label**, exactly as a result row carries both and for the reason
@@ -3399,6 +3432,132 @@ results list is the honest surface for that.
**Capped at three inclusive: three renders, four renders nothing.**
+#### Narrowing by the reader's own words (Paul, 2026-09-29)
+
+**The ruling.** Paul asked why *"what is the office phone number?"* showed
+no box, on a page whose second result says *"The office phone number is:
++353 (0)1 547 7854"*. The proposal he approved (*"Sure."*), in the words
+he was shown:
+
+1. *"When there are too many candidates, use the other words in the
+ question ("office") to narrow them down. If exactly one candidate's
+ label contains them, show that one."*
+2. *"Treat " - " as a break when working out labels."*
+3. *"Keep the current rule as the fallback: if narrowing doesn't leave
+ exactly one candidate, still show nothing rather than guess."*
+
+**This section implements 1 and 3 as he approved them, and 2
+differently, for a reason he has not yet been told.** Part 2's premise
+was wrong. The ` - ` he saw was the search snippet flattening Notion's
+list lines. The stored text has real line breaks, which the label rule
+already treats as a boundary. The actual defect is the list marker
+those lines start with, and the list-marker rule above fixes that. **An
+earlier draft of this section went further than the ruling**: it
+narrowed below the cap, and it showed every match rather than exactly
+one. The first review caught both, and this text is back to the ruling.
+
+**When it applies: only when the cap has fired.** If a kind has more
+than three candidates after the §4.1 ruling, it would render nothing,
+and narrowing is attempted. Otherwise nothing changes: a kind with three
+or fewer candidates renders or is suppressed by collision or rule 4
+exactly as before, and the reader's words play no part.
+
+**The qualifiers** are the query's tokens (`fact.tokens`: lowercased, split
+on anything that is not a letter or digit) with these removed:
+
+- every token of every lexicon phrase, for every kind, so `phone`,
+ `mobile`, `email`, `address`, `vat` and the rest are never qualifiers;
+- the generic `number`, `numbers`, `no` and `nr`;
+- a fixed list of question and function words: interrogatives, articles,
+ pronouns, auxiliaries, common prepositions, and request verbs such as
+ `tell`, `give`, `find` and `show`. The list is in the code, it is
+ stated as incomplete, and like the abbreviation list it must not grow
+ into a parser;
+- single-character tokens, which disposes of the `s` in `what's` and
+ `Tito's`.
+
+Pinned examples: *"what is the office phone number?"* → `{office}`;
+*"office phone"* → `{office}`; *"office mobile"* → `{office}`, because
+`mobile` is how Phone is asked for; *"what's Tito's phone number"* →
+`{tito}`; *"phone number for the Dublin office"* → `{dublin, office}`;
+*"support email"* → `{support}`; *"phone number"* → none.
+
+**A candidate matches when its label is the document's own and contains
+every qualifier as a whole token.**
+
+- **"The document's own" means rule 2**, the sentence-anchored label
+ ending in a separator. A rule 5 label (the heading trail, or the
+ title) never matches. This is what makes narrowing work on the
+ motivating page. The unused numbers there are listed under the heading
+ `Office Address`, so a rule that matched fallback labels would match
+ every number on the Office page, unused ones included, and could never
+ leave exactly one. **Provenance is recorded when the label is made
+ (`fact.Found.Own`), never inferred** by comparing the label's text
+ with the heading. A document can write a label identical to its own
+ heading.
+- **Whole tokens, compared on `fact.tokens`**, so `office` does not
+ match `offices` or `backoffice`. There is no stemming.
+- **Every qualifier, not any.** `Dublin office` does not match a label
+ saying `Office phone`.
+
+**The outcome, for a kind over the cap:**
+
+- **Exactly one candidate matches:** that candidate renders, alone. If
+ its label is over-long (rule 4), the kind renders nothing: the
+ document's own words are the answer, and they cannot be shown. Rule 4
+ is kept for matching only. `fact.Scan` returns the over-long text with
+ `NoBox` set, and it is never rendered.
+- **None, or two or more:** nothing renders, as before. Narrowing never
+ picks between two matches.
+
+**The box says it narrowed.** Under the row, one line:
+
+> Matched “office”. 3 other phone numbers are in these results.
+
+It names the matched qualifiers and the number of candidates left out,
+which is always at least three because the cap had fired. The count is
+taken after the §4.1 ruling, like every other count in this section, so
+a withheld document neither adds to it nor is revealed by it. It carries
+no values and no links: the results list below is where the others are.
+
+**Why this line is required: narrowing weakens this section's injection
+defence, and the line is what keeps that visible.** *"Injection"* below
+says the ambiguity rule fails safe against poisoning: a planted second
+number makes the box show both, or nothing. Narrowing changes that in
+one case. On a page already over the cap, a planted number whose label
+alone contains the reader's word becomes the only row: a single,
+confident, cited answer. The count line is the mitigation. It tells the
+reader that other numbers exist and that the box chose among them on
+the reader's own word. It is not a fix, and this spec does not claim
+one.
+
+**Stated limitations, so that they are known rather than discovered:**
+
+- **Only the value right after the separator gets the label.** In
+ `Office: 01 547 7854, 01 598 1234`, only the first number is labelled
+ `Office`. The second follows another recognised span, not a
+ separator, so it falls to the trail and can never match. The same
+ applies to `Office:` followed by bare bullets.
+- **Identity comes before matching.** A value's first occurrence is its
+ candidate. If that occurrence fell to the trail, a later occurrence of
+ the same value with a matching label does not rescue it.
+- **Multi-kind queries share one set of qualifiers.** *"office phone and
+ support email"* applies `{office, support}` to both kinds. Usually no
+ label has both, and neither kind is narrowed.
+- **The lexicon limits which kinds are recognised at all.** *"main office
+ landline"* primes no kind (`landline` is not in the lexicon), so no box
+ is attempted.
+- **A literal prefix shaped like a list marker is indistinguishable from
+ markup.** A document whose label genuinely begins `2. ` loses it.
+
+
+**Model kinds are not narrowed.** §8.9's candidates come from a model
+reading passages, so there is no document-authored separator label to
+match. They keep §8.9's own rules.
+
+**Nothing here is logged**, as in the rest of this section: the
+qualifiers are words from the query, and the query is content.
+
#### Where it sits on the page
Inside the `results` template definition, not above it. `results.html` is
@@ -3447,6 +3606,11 @@ Two things bound it and neither is a fix:
or vanish past the cap. The attack that works against a single-answer
box degrades into a visibly contradictory page, which is a property
worth keeping rather than an accident to optimise away.
+ **Narrowing (above, 2026-09-29) weakens this in one case.** On a page
+ already over the cap, a planted label that alone carries the reader's
+ word becomes the only row. The "Matched … N other …" line is what keeps
+ the other candidates visible. That section states this rather than
+ leaving it to be found.
#### Sequencing
@@ -3609,7 +3773,10 @@ of a model row has to be told:
An omitted fourth address means a box of three where §8.8 would have
shown none.
- **§8.8's poisoning argument weakens.** A planted second phone number
- makes a deterministic box show both or vanish. A planted instruction in
+ makes a deterministic box show both or vanish — **except on a page over
+ the cap where the planted label alone carries the reader's word**, which
+ §8.8's narrowing (2026-09-29) turns into a single row with a line
+ counting the others. A planted instruction in
a passage can make the model omit the real address instead — the attack
§8.8 says degrades into a visible contradiction degrades here into a
silent omission.
@@ -4410,7 +4577,9 @@ The gates:
higher-scoring one not presented as the answer, **and in first-seen
order** — order asserted, since a map's range is not an order;
- **three → still a box. Four → none.** Both, because a cap written
- `>= 3` passes a suite testing only one, two and four;
+ `>= 3` passes a suite testing only one, two and four. **Queries
+ with no qualifiers**, since narrowing (gate 40) can turn four
+ candidates into one row;
- **two spellings of one number** — `+353 1 547 7854` and
`01 547 7854` → **one** candidate. A dedupe keyed on the span
rather than the canonical form passes a same-spelling fixture;
@@ -4476,6 +4645,10 @@ The gates:
written `< 60` passes a suite that only tests "one over";
- an upload with **no headings at all** → the document title, never
`""`. Every fixture carrying a heading hides this.
+ - **List markers (2026-09-29).** `- Office: …`, `3. Office: …` and
+ `- [x] Office: …` → `Office`. A bare `- +353 1 547 7854` still falls
+ through to the trail: the marker is removed only when something
+ follows it.
38. ~~**The facts table does not disturb the main ranking.**~~ **Struck
before it was built, 2026-09-14.** §8.8 draft 3 removed the facts
table, so there is nothing to prove non-interference for — and the
@@ -4489,6 +4662,51 @@ The gates:
reachability fix is not "index the kind words". The number kept its
place in this list rather than being reused, per §13's convention for
struck items.
+39. *§8.9's gate, defined in that section.*
+40. **Narrowing picks exactly one, only over the cap, only on the
+ reader's words against the document's own label** (§8.8, Paul,
+ 2026-09-29). Store level, asserting the candidate tuples and the
+ note's fields, never rendered copy. Every fixture below has four or
+ more candidates unless it says otherwise. Fixtures:
+
+ - **Presence, the motivating page.** The office number labelled *"The
+ office phone number is"*, plus three unused numbers: one labelled
+ *"We also have the following unused phone numbers"* and two as bare
+ bullets under the heading `Office Address`. The query *"what is the
+ office phone number?"* → **one** row, the office number, labelled
+ without its bullet, and a note with `{office}` and **3** others.
+ The same page queried with *"phone number"* → **no box**. The two
+ together are the gate: either alone passes an implementation that
+ always narrows, or one that never does.
+ - **A fallback label never matches.** On the page above, the bare
+ bullets' label is the trail `Office Address`, which contains
+ `office`. If they matched, there would be three matches and no box.
+ - **Under the cap, nothing changes.** Three candidates, one labelled
+ `Office phone`, queried *"office phone"* → **all three** render,
+ and there is no note. Narrowing below the cap is the broadening the
+ first review found in this gate's own section.
+ - **Whole tokens.** Labels `Office phone`, `Offices phone`,
+ `Backoffice line`, `Reception` → one row. Substring matching makes
+ three matches, and no box.
+ - **Every qualifier, both directions.** Query *"Dublin office phone"*.
+ Labels `Dublin office phone`, `Office phone`, `Dublin reception`,
+ `Sales` → one row, `Dublin office phone`: any-of would find three
+ matches and render nothing. Labels `Office phone`, `Reception`,
+ `Sales`, `Fax` → **no box**: any-of finds exactly one and renders it.
+ - **Two matches → nothing.** Labels `Office phone`, `Office line`,
+ `Reception`, `Sales` → no box, no note. An implementation showing
+ every match renders two.
+ - **An over-long matching label silences the kind**, and an over-long
+ label that does not match leaves the single match rendered.
+ - **Counts after the ruling, both halves** (gate 34's rule). One
+ visible match, one withheld match, one withheld non-match, three
+ visible non-matches → four ruled candidates, **one** row and **3**
+ others. Counting matches over the unruled set gives two matches and
+ no box. Counting the others over it gives 5, which also reveals a
+ withheld document. And: one visible match, two visible non-matches,
+ one withheld non-match → **three** ruled candidates, under the cap,
+ so **all three** render with no note. Testing the cap over the
+ unruled set narrows a page that should not be narrowed.
## 11. Sequencing
@@ -4589,17 +4807,18 @@ unfiled), and **§8.9's constraints and kind list** (what replaced
decision 15's scope when Paul ruled on it). **Nothing else here is a
ruling; the rest is work, measurement or somebody else's repo.**
- - **§8.8's cap is unmeasured against the corpus that motivated it.**
- Decision 15's principle is settled; whether three is the right cap is
- not. The box shows
- nothing when a search's ruled top-25 carries four or more distinct
- validated values of one kind. Nobody has counted how many distinct
- phone numbers a real top-25 for "phone number" carries on Paul's live
- corpus, and if the answer is "more than three" the feature is inert on
- the very corpus it was asked for. **The remedy if so is a narrower
- lexicon or a per-kind cap — never a rule that picks**, which is the
- one thing §8.8 cannot trade away. Run it against the live data or
- through `tools/bench`.
+ - ~~**§8.8's cap is unmeasured against the corpus that motivated it.**~~
+ **CLOSED 2026-09-29, by Paul hitting it and ruling the remedy.**
+ *"What is the office phone number?"* on the live corpus showed no
+ box: the ruled top-25 carried four or more distinct numbers. This
+ item's earlier text said the remedy was *"a narrower lexicon or a
+ per-kind cap — never a rule that picks"*. That was the spec author's
+ framing, not a ruling, and it would not have worked: the lexicon was
+ already right, and a larger cap would show unused numbers as the
+ answer. Paul approved narrowing by the reader's own words against
+ the document's own label, which §8.8 now specifies with gate 40. The
+ cap of three itself is unchanged and is still unmeasured on queries
+ with no qualifiers.
- **Decision 10 is narrowed by §8.8 and the narrowing is the spec
author's, not Paul's.** "memoria never does arithmetic" now reads as
"never reports a number it computed"; a mod-97 check digit is
diff --git a/internal/memoria/fact/fact.go b/internal/memoria/fact/fact.go
index 82ffd1a..3f4f82b 100644
--- a/internal/memoria/fact/fact.go
+++ b/internal/memoria/fact/fact.go
@@ -84,7 +84,15 @@ type Found struct {
// distinction is what keeps this inside §8.7 and decision 10.
Canonical string
// Label is what the document called this value. Never composed.
+ // When NoBox is set it holds the over-long label, which is never
+ // rendered: it is kept so §8.8's narrowing can tell whether the
+ // reader's words are in it.
Label string
+ // Own reports that Label is the document's own words for this value —
+ // the sentence-anchored run ending in a separator (§8.8 rule 2) —
+ // rather than the heading trail or title it falls back to. Only an
+ // own label can match the reader's words (§8.8, narrowing).
+ Own bool
// NoBox means this kind renders nothing at all, because a value
// carried a document-authored label too long to show. Substituting
// the heading trail would replace the document's words with vaguer
@@ -175,6 +183,76 @@ func Wanted(q string) []Kind {
return out
}
+// generic are words that name no kind and qualify nothing: "number" is
+// how every kind is asked for.
+var generic = map[string]bool{"number": true, "numbers": true, "no": true, "nr": true}
+
+// stopwords are the question and function words a query carries around
+// its qualifiers. **Stated as incomplete**, like the abbreviation list:
+// a word missing from here becomes a qualifier that no label contains,
+// narrowing matches nothing, and the kind is ruled exactly as it would
+// have been without narrowing. That is the safe direction. This must not
+// grow into a parser.
+var stopwords = map[string]bool{}
+
+func init() {
+ for _, w := range strings.Fields(`
+ what whats which who whose where when why how
+ is are was were be been am do does did has have had can could
+ will would should shall may might must
+ a an the this that these those there here
+ i me my mine we us our ours you your yours he him his she her hers
+ it its they them their theirs
+ of for to at in on by with from about as into onto and or but not
+ please tell give find show get need want know let see look
+ any some one all`) {
+ stopwords[w] = true
+ }
+}
+
+// Qualifiers returns the words in a query that are neither how a kind is
+// asked for nor how a question is asked — "office" in "what is the office
+// phone number?". §8.8 narrows a kind to the candidates whose own label
+// contains every one of them. In query order, without repeats.
+func Qualifiers(q string) []string {
+ lex := map[string]bool{}
+ for _, p := range lexicon {
+ for _, t := range p.toks {
+ lex[t] = true
+ }
+ }
+ var out []string
+ seen := map[string]bool{}
+ for _, t := range tokens(q) {
+ if utf8.RuneCountInString(t) < 2 || lex[t] || generic[t] || stopwords[t] || seen[t] {
+ continue
+ }
+ seen[t] = true
+ out = append(out, t)
+ }
+ return out
+}
+
+// LabelMatches reports whether label contains every qualifier as a whole
+// token. Tokens, not substrings, so "office" does not match "offices" or
+// "backoffice". No stemming: a missed match leaves the kind ruled as it
+// would have been anyway.
+func LabelMatches(label string, qualifiers []string) bool {
+ if len(qualifiers) == 0 {
+ return false
+ }
+ have := map[string]bool{}
+ for _, t := range tokens(label) {
+ have[t] = true
+ }
+ for _, q := range qualifiers {
+ if !have[q] {
+ return false
+ }
+ }
+ return true
+}
+
func matchAt(toks []string, i int, want []string) bool {
for j, w := range want {
if toks[i+j] != w {
@@ -705,12 +783,20 @@ func isAbbreviation(tok string) bool {
return size > 0 && len(tok) == size+1 && tok[size] == '.' && unicode.IsLetter(r)
}
-// labelFor implements §8.8's five rules. It returns the label and whether
-// this kind must render no box at all.
+// listMarker is one leading list marker: a bullet, a number, a to-do box
+// after either, or a quote. memoria's own Notion renderer writes these
+// (notion/markdown.go), and rule 1 anchors the run at the start of the
+// line, so without this the label carried memoria's syntax: "- Office".
+var listMarker = regexp.MustCompile(`^[ \t\x{00A0}]*(?:(?:[-*+•]|[0-9]{1,3}[.)])[ \x{00A0}]+(?:\[[ xX]\][ \x{00A0}]+)?|>[ \x{00A0}]+)`)
+
+// labelOf applies §8.8's label rules. It returns the label, whether the
+// document wrote it (rule 2) rather than it being the fallback (rule 5),
+// and whether the kind renders no box because that label is too long
+// (rule 4) — in which case the label is returned too, for matching only.
//
// The order of the rules is the whole of it, and each one is here because
// a fixture broke without it — see gate 37, which carries one per case.
-func labelFor(text string, start int, spans [][2]int, heading, title string) (string, bool) {
+func labelOf(text string, start int, spans [][2]int, heading, title string) (string, bool, bool) {
b := sentenceStart(text, start)
// **An earlier recognised span inside the run is not part of the
// label.** Without this, "Phone: 01 547 7854 - 01 598 1234" labels
@@ -724,6 +810,15 @@ func labelFor(text string, start int, spans [][2]int, heading, title string) (st
b = e
}
run := strings.TrimRight(text[b:start], "  ")
+ // A leading list marker is markup. The pattern needs a space after
+ // the marker and run is already trimmed on the right, so whatever it
+ // matches has something after it: a bare bullet — "- +353 1 547
+ // 7854" — trims to "-", does not match, and rule 3 still sends it to
+ // the trail. (A guard for "nothing follows" was written and removed:
+ // no input could reach it.)
+ if m := listMarker.FindStringIndex(run); m != nil {
+ run = run[m[1]:]
+ }
if r, size := utf8.DecodeLastRuneInString(run); size > 0 && isSeparator(r) {
lbl := strings.TrimSpace(run[:len(run)-size])
switch {
@@ -734,15 +829,15 @@ func labelFor(text string, start int, spans [][2]int, heading, title string) (st
// contact list boxable; returning "" would make two bullets
// collide and suppress it.
case utf8.RuneCountInString(lbl) > MaxLabel:
- return "", true
+ return lbl, true, true
default:
- return lbl, false
+ return lbl, true, false
}
}
if heading != "" {
- return heading, false
+ return heading, false, false
}
- return title, false
+ return title, false, false
}
// lastSpanEndBefore returns the end of the latest span finishing at or
@@ -787,7 +882,7 @@ func Scan(text, heading, title string, want []Kind) []Found {
// Every span of EVERY kind is located first, and every label is
// derived afterwards. Two separate reasons, both found by review:
//
- // - labelFor needs to know about the spans to its left, and cannot
+ // - labelOf needs to know about the spans to its left, and cannot
// if it is called as they are found.
// - **the spans it needs are of every kind, not just the wanted
// ones.** Locating only what was asked for made a fact's label
@@ -848,12 +943,13 @@ func Scan(text, heading, title string, want []Kind) []Found {
continue
}
seen[k][h.canonical] = true
- lbl, noBox := labelFor(text, h.at[0], spans, heading, title)
+ lbl, own, noBox := labelOf(text, h.at[0], spans, heading, title)
out = append(out, Found{
Kind: h.kind,
Span: h.span,
Canonical: h.canonical,
Label: lbl,
+ Own: own,
NoBox: noBox,
})
}
@@ -897,7 +993,9 @@ func RecognisedSpans(text string) [][2]int {
// LabelAtSpans applies §8.8's five label rules to a value at byte offset
// start. It returns the label, and whether the kind must render no box
-// because the document labelled the value too long to show.
+// because the document labelled the value too long to show — in which
+// case the label is the over-long one, exactly as Scan reports it, and
+// must not be rendered.
//
// spans are the passage's RecognisedSpans, computed once by the caller.
// **There is deliberately no variant that computes them itself.** One
@@ -907,7 +1005,8 @@ func RecognisedSpans(text string) [][2]int {
// that mistake a compile error instead of a timing regression nothing
// could see.
func LabelAtSpans(text string, start int, spans [][2]int, heading, title string) (string, bool) {
- return labelFor(text, start, spans, heading, title)
+ l, _, noBox := labelOf(text, start, spans, heading, title)
+ return l, noBox
}
// WordBounded reports whether text[start:end] begins and ends on a word
diff --git a/internal/memoria/fact/fact_test.go b/internal/memoria/fact/fact_test.go
index efa61b3..815d581 100644
--- a/internal/memoria/fact/fact_test.go
+++ b/internal/memoria/fact/fact_test.go
@@ -681,3 +681,88 @@ func TestWordBounded(t *testing.T) {
t.Error("a span ending before a digit was treated as bounded")
}
}
+
+// Qualifiers on the queries §8.8 pins, which are the realistic shapes a
+// reader types. The first review asked for these by name.
+//
+// MUTATION-TESTED 2026-09-29, each reverted:
+// - lexicon tokens kept → `Qualifiers("support email") = ["support"
+// "email"]`
+// - single-character tokens kept → `["s" "tito"]`
+// - "for" dropped from the stopwords → `["for" "dublin" "office"]`
+// - repeats kept → `["office" "office"]`
+func TestQualifiersOnRealisticQueries(t *testing.T) {
+ for q, want := range map[string][]string{
+ "what is the office phone number?": {"office"},
+ "office phone": {"office"},
+ "office mobile": {"office"},
+ "what's Tito's phone number": {"tito"},
+ "what’s Tito’s phone number": {"tito"},
+ "phone number for the Dublin office": {"dublin", "office"},
+ "support email": {"support"},
+ "phone number": nil,
+ "office office phone": {"office"},
+ } {
+ if got := Qualifiers(q); !reflect.DeepEqual(got, want) {
+ t.Errorf("Qualifiers(%q) = %q, want %q", q, got, want)
+ }
+ }
+}
+
+func TestLabelMatchesWholeTokensAndEveryQualifier(t *testing.T) {
+ for _, c := range []struct {
+ label string
+ quals []string
+ want bool
+ }{
+ {"The office phone number is", []string{"office"}, true},
+ {"Office phone", []string{"office"}, true},
+ {"Offices phone", []string{"office"}, false},
+ {"Backoffice line", []string{"office"}, false},
+ {"Dublin office phone", []string{"dublin", "office"}, true},
+ {"Office phone", []string{"dublin", "office"}, false},
+ {"Office phone", nil, false},
+ } {
+ if got := LabelMatches(c.label, c.quals); got != c.want {
+ t.Errorf("LabelMatches(%q, %q) = %v, want %v", c.label, c.quals, got, c.want)
+ }
+ }
+}
+
+// A leading list marker is markup (§8.8, 2026-09-29), and Own records
+// whether the label is the document's rather than the fallback.
+//
+// MUTATION-TESTED 2026-09-29, reverted: the marker strip disabled →
+// `Scan("- Office: …") label = "- Office"`. A guard for "only when
+// something follows the marker" was also mutated away and stayed green:
+// no input can reach it, so it was deleted rather than kept.
+func TestListMarkersAreNotPartOfTheLabel(t *testing.T) {
+ for _, c := range []struct {
+ text, label string
+ own bool
+ }{
+ {"- Office: +353 (0)1 547 7854", "Office", true},
+ {" - Office: +353 (0)1 547 7854", "Office", true},
+ {"* Office: +353 (0)1 547 7854", "Office", true},
+ {"• Office: +353 (0)1 547 7854", "Office", true},
+ {"- [x] Office: +353 (0)1 547 7854", "Office", true},
+ {"- [ ] Office: +353 (0)1 547 7854", "Office", true},
+ {"3) Office: +353 (0)1 547 7854", "Office", true},
+ {"3. Office: +353 (0)1 547 7854", "Office", true},
+ {"> Office: +353 (0)1 547 7854", "Office", true},
+ // A bare bullet: nothing follows the marker, so rule 3 sends it to
+ // the trail, which is not the document's own label for the value.
+ {"- +353 (0)1 547 7854", "Contact", false},
+ {"Office: +353 (0)1 547 7854", "Office", true},
+ {"Ring us on +353 (0)1 547 7854 any weekday.", "Contact", false},
+ } {
+ got := Scan(c.text, "Contact", "Tito", []Kind{Phone})
+ if len(got) != 1 {
+ t.Fatalf("Scan(%q) found %d, want 1", c.text, len(got))
+ }
+ if got[0].Label != c.label || got[0].Own != c.own {
+ t.Errorf("Scan(%q) label = %q own = %v, want %q own = %v",
+ c.text, got[0].Label, got[0].Own, c.label, c.own)
+ }
+ }
+}
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index bc9e64f..8d82ea0 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -170,6 +170,8 @@ type page struct {
// never "no phone number found". The corpus is not the world, and a
// confident absence is this codebase's worst failure shape.
Facts []Fact
+ // FactNotes say a kind was narrowed by the reader's words (§8.8).
+ FactNotes []FactNote
// SearchFailed is not "no results". A search that errored renders
// its own callout in the results region, because "nothing matched"
@@ -663,6 +665,7 @@ func (a *app) runSearch(r *http.Request, p *page) {
// Primed is decided here: the page and the fragment must not be able
// to disagree about whether there is a box.
p.Facts = found.Facts
+ p.FactNotes = found.FactNotes
p.Primed = query.PrimesFirstResult(p.Query, len(found.Results))
}
diff --git a/internal/memoria/narrow_test.go b/internal/memoria/narrow_test.go
new file mode 100644
index 0000000..3ccfb13
--- /dev/null
+++ b/internal/memoria/narrow_test.go
@@ -0,0 +1,192 @@
+package memoria
+
+import (
+ "context"
+ "reflect"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/suite"
+)
+
+// factCorpus stores one sibling document per {key, title, body}, each
+// under its own item id so perItemCustody can rule them apart.
+func factCorpus(t *testing.T, cus Custody, docs [][3]string) *Store {
+ t.Helper()
+ s := storeForTest(t)
+ s.custody = cus
+ for _, d := range docs {
+ if _, err := s.Ingest(context.Background(), IngestSpec{
+ Source: "Docs", Key: d[0], Title: d[1], Body: d[2],
+ ItemID: d[0], SiblingRevision: 1,
+ }); err != nil {
+ t.Fatalf("ingest %s: %v", d[0], err)
+ }
+ }
+ return s
+}
+
+// officePage is the shape of the page that motivated narrowing (Paul,
+// 2026-09-29): the office number, then unused ones — one labelled by its
+// own sentence, two as bare bullets that fall to the heading trail.
+const officePage = "## Office Address\n\n" +
+ "- Turn the knob\n" +
+ "- The office phone number is: +353 (0)1 547 7854\n" +
+ "- We also have the following unused phone numbers: +353 (0)1 598 1234\n" +
+ " - +353 (0)1 598 1235\n" +
+ " - +353 (0)1 598 1236\n"
+
+// GATE 40 — narrowing picks exactly one, only over the cap, only on the
+// reader's words against the document's own label (§8.8, Paul
+// 2026-09-29).
+//
+// Asserts the struct: the rows' spans and labels and the note's fields.
+// The rendered line is page-level (memoriatest) and is not the evidence.
+// Every fixture has four or more candidates unless it says otherwise, so
+// the cap has fired and narrowing is the only way anything renders.
+//
+// MUTATION-TESTED 2026-09-29, each built and seen red, each reverted:
+// - the own-label check dropped, so fallback labels match → the
+// motivating page renders nothing (three matches)
+// - rule 5 reporting Own=true → the same
+// - narrowing below the cap (len > 0) and at it (len >= MaxFacts) →
+// "under the cap" and "the cap that triggers narrowing counts after
+// the ruling" both narrow three candidates to one
+// - any qualifier instead of every → both "every qualifier" cases
+// - substring instead of token matching → "whole tokens"
+// - the first of several matches instead of exactly one → "two
+// matches render nothing"
+// - no qualifiers treated as matching everything → "no qualifier
+// never narrows"
+// - a matching over-long label not silencing the kind → its case
+// - Others counted as len(cs) → "want one with 3 others"
+//
+// Not mutated, and why: the counts' after-the-ruling property is
+// structural — found is built from p.Results and nothing else — and the
+// two fixtures for it are the ones that would see a change of source.
+func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) {
+ roster := docsRoster()
+ visible := func() Custody { return &perItemCustody{apps: roster} }
+ one := func(t *testing.T, p Page, want string, others int) {
+ t.Helper()
+ if got := spans(p); !reflect.DeepEqual(got, []string{want}) {
+ t.Fatalf("spans = %q, want [%s]", got, want)
+ }
+ if len(p.FactNotes) != 1 || p.FactNotes[0].Others != others {
+ t.Errorf("notes = %+v, want one with %d others", p.FactNotes, others)
+ }
+ }
+ none := func(t *testing.T, p Page) {
+ t.Helper()
+ if len(p.Facts) != 0 || len(p.FactNotes) != 0 {
+ t.Errorf("facts = %q, notes = %+v; want no box", spans(p), p.FactNotes)
+ }
+ }
+ page := func(t *testing.T, body string) *Store {
+ return factCorpus(t, visible(), [][3]string{{"a", "Contacts", body}})
+ }
+
+ // PRESENCE, and the pair that makes it a gate. Either half alone
+ // passes an implementation that always narrows, or one that never
+ // does. The bare bullets fall to the trail "Office Address", which
+ // contains "office": matching a fallback label makes three matches.
+ t.Run("the motivating page", func(t *testing.T) {
+ s := factCorpus(t, visible(), [][3]string{{"o", "Office", officePage}})
+ p := searchFacts(t, s, "what is the office phone number?")
+ one(t, p, "+353 (0)1 547 7854", 3)
+ if p.Facts[0].Label != "The office phone number is" {
+ t.Errorf("label = %q, want the document's own, without the bullet", p.Facts[0].Label)
+ }
+ if got := p.FactNotes[0].Matched; !reflect.DeepEqual(got, []string{"office"}) {
+ t.Errorf("matched = %q, want [office]", got)
+ }
+ none(t, searchFacts(t, s, "phone number"))
+ })
+
+ // One own label among four, and a query with no qualifier: an
+ // implementation treating "no qualifiers" as "everything matches"
+ // finds exactly one and renders it. The motivating page cannot see
+ // that, because it has two own labels.
+ t.Run("no qualifier never narrows", func(t *testing.T) {
+ s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\n- +353 (0)1 547 0002\n- +353 (0)1 547 0003\n- +353 (0)1 547 0004")
+ none(t, searchFacts(t, s, "phone number"))
+ })
+
+ t.Run("under the cap nothing changes", func(t *testing.T) {
+ s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\nSales: +353 (0)1 547 0003")
+ p := searchFacts(t, s, "office phone")
+ if len(p.Facts) != 3 || len(p.FactNotes) != 0 {
+ t.Errorf("facts = %q, notes = %+v; want all three and no note", spans(p), p.FactNotes)
+ }
+ })
+
+ t.Run("whole tokens, not substrings", func(t *testing.T) {
+ s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nOffices phone: +353 (0)1 547 0002\n\n"+
+ "Backoffice line: +353 (0)1 547 0003\n\nReception: +353 (0)1 547 0004")
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", 3)
+ })
+
+ t.Run("every qualifier: the one label with both", func(t *testing.T) {
+ s := page(t, "## Dublin office\n\nDublin office phone: +353 (0)1 547 0001\n\nOffice phone: +353 (0)1 547 0002\n\n"+
+ "Dublin reception: +353 (0)1 547 0003\n\nSales: +353 (0)1 547 0004")
+ one(t, searchFacts(t, s, "Dublin office phone"), "+353 (0)1 547 0001", 3)
+ })
+ t.Run("every qualifier: no label with both", func(t *testing.T) {
+ s := page(t, "## Dublin office\n\nOffice phone: +353 (0)1 547 0001\n\nReception: +353 (0)1 547 0002\n\n"+
+ "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004")
+ none(t, searchFacts(t, s, "Dublin office phone"))
+ })
+
+ t.Run("two matches render nothing", func(t *testing.T) {
+ s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nOffice line: +353 (0)1 547 0002\n\n"+
+ "Reception: +353 (0)1 547 0003\n\nSales: +353 (0)1 547 0004")
+ none(t, searchFacts(t, s, "office phone"))
+ })
+
+ long := strings.Repeat("x", 40)
+ t.Run("an over-long matching label silences the kind", func(t *testing.T) {
+ s := page(t, "## Office\n\nThe office number you must never give to anyone "+long+": +353 (0)1 547 0001\n\n"+
+ "Reception: +353 (0)1 547 0002\n\nSales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004")
+ none(t, searchFacts(t, s, "office phone"))
+ })
+ t.Run("an over-long label that does not match leaves the match", func(t *testing.T) {
+ s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nThe number you must never give to anyone at all "+long+": +353 (0)1 547 0002\n\n"+
+ "Sales: +353 (0)1 547 0003\n\nFax: +353 (0)1 547 0004")
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", 3)
+ })
+
+ // Gate 34's rule, both halves. Counting matches over the unruled set
+ // finds two and renders nothing; counting the others over it says 5
+ // and reveals a withheld document.
+ t.Run("counts after the ruling", func(t *testing.T) {
+ s := factCorpus(t, &perItemCustody{
+ apps: roster,
+ give: map[string]suite.Authorization{"b": suite.NOT_VISIBLE, "c": suite.NOT_VISIBLE},
+ }, [][3]string{
+ {"a", "Dublin", "## Dublin office\n\nOffice phone: +353 (0)1 547 0001"},
+ {"b", "Cork", "## Cork office\n\nOffice phone: +353 (0)21 547 0002"},
+ {"c", "Sligo", "## Sligo office\n\nReception phone: +353 (0)71 547 0003"},
+ {"d", "Galway", "## Galway office\n\nReception phone: +353 (0)91 547 0004"},
+ {"e", "Limerick", "## Limerick office\n\nSales phone: +353 (0)61 547 0005"},
+ {"f", "Waterford", "## Waterford office\n\nFax phone: +353 (0)51 547 0006"},
+ })
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", 3)
+ })
+ // And the cap itself is tested over the ruled set: three visible, a
+ // fourth withheld, is under the cap — all three, no narrowing.
+ t.Run("the cap that triggers narrowing counts after the ruling", func(t *testing.T) {
+ s := factCorpus(t, &perItemCustody{
+ apps: roster,
+ give: map[string]suite.Authorization{"f": suite.NOT_VISIBLE},
+ }, [][3]string{
+ {"a", "Dublin", "## Dublin office\n\nOffice phone: +353 (0)1 547 0001"},
+ {"d", "Galway", "## Galway office\n\nReception phone: +353 (0)91 547 0004"},
+ {"e", "Limerick", "## Limerick office\n\nSales phone: +353 (0)61 547 0005"},
+ {"f", "Waterford", "## Waterford office\n\nFax phone: +353 (0)51 547 0006"},
+ })
+ p := searchFacts(t, s, "office phone")
+ if len(p.Facts) != 3 || len(p.FactNotes) != 0 {
+ t.Errorf("facts = %q, notes = %+v; want all three and no note", spans(p), p.FactNotes)
+ }
+ })
+}
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css
index 35d6235..6d37e63 100644
--- a/internal/memoria/static/memoria.css
+++ b/internal/memoria/static/memoria.css
@@ -328,6 +328,13 @@
font-size: 0.8125rem;
margin-left: auto;
}
+/* §8.8's narrowing line: which of the reader's words chose the rows
+ above, and how many candidates that left out. Quiet, under the rows. */
+[rst-fact-note] {
+ color: var(--rst-text-muted);
+ font-size: 0.8125rem;
+ margin: 0.5rem 0 0;
+}
/* §8.9: a row a model pointed at, rather than one a checksum validated.
Distinct at a glance, and quieter — the value is still the document's
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 40b1e92..01d2c84 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -117,6 +117,45 @@ type Page struct {
// **Derived from Results and from nothing else**, which is what makes
// it safe by construction rather than by care. See addFacts.
Facts []Fact
+ // FactNotes says, per kind, that the box was narrowed by the
+ // reader's own words and how many candidates that left out (§8.8,
+ // Paul 2026-09-29). Counted over Results, like everything above.
+ FactNotes []FactNote
+}
+
+// FactNote is the line under a narrowed kind's rows.
+type FactNote struct {
+ Kind string
+ // Matched are the qualifiers every shown row's label contains.
+ Matched []string
+ // Others is how many of this kind's candidates narrowing left out,
+ // counted after the §4.1 ruling. At least MaxFacts, because
+ // narrowing only runs once the cap has fired.
+ Others int
+}
+
+// factNouns names a kind in the note. Plural only: the note is written
+// when the cap has fired, so at least MaxFacts others are counted.
+var factNouns = map[string]string{
+ string(fact.Phone): "phone numbers",
+ string(fact.Email): "email addresses",
+ string(fact.Eircode): "eircodes",
+ string(fact.IBAN): "IBANs",
+ string(fact.VAT): "VAT numbers",
+}
+
+// Text is the line as the reader sees it:
+// "Matched “office”. 3 other phone numbers are in these results."
+func (n FactNote) Text() string {
+ quoted := make([]string, len(n.Matched))
+ for i, m := range n.Matched {
+ quoted[i] = "“" + m + "”"
+ }
+ words := strings.Join(quoted, ", ")
+ if len(quoted) > 1 {
+ words = strings.Join(quoted[:len(quoted)-1], ", ") + " and " + quoted[len(quoted)-1]
+ }
+ return fmt.Sprintf("Matched %s. %d other %s are in these results.", words, n.Others, factNouns[n.Kind])
}
// Summary is a document as the list shows it.
@@ -1074,9 +1113,8 @@ func (s *Store) addFacts(ctx context.Context, p *Page, query string) {
// counted across kinds lets two phone numbers and two addresses
// suppress each other, and a collision check across kinds suppresses
// a phone and an email that happen to share a label.
- found := map[fact.Kind][]Fact{}
+ found := map[fact.Kind][]factCandidate{}
seen := map[fact.Kind]map[string]bool{}
- silenced := map[fact.Kind]bool{}
for _, r := range p.Results {
text, ok := texts[r.ChunkID]
@@ -1104,24 +1142,26 @@ func (s *Store) addFacts(ctx context.Context, p *Page, query string) {
if seen[f.Kind][f.Canonical] {
continue
}
- if f.NoBox {
- silenced[f.Kind] = true
- continue
- }
// **Identity is the canonical value**, so one number written
// two ways is one candidate and the same number in five
// forwarded signatures is one too. First occurrence wins the
// citation, and because p.Results is in rank order that is
- // the best-ranked passage carrying it.
+ // the best-ranked passage carrying it. A NoBox occurrence is
+ // a candidate like any other: it silences the kind when the
+ // kind is ruled with it in the set (see narrow).
seen[f.Kind][f.Canonical] = true
- found[f.Kind] = append(found[f.Kind], Fact{
- Kind: string(f.Kind),
- Title: r.Title,
- Trail: r.Heading,
- Label: f.Label,
- Span: f.Span,
- DocumentID: r.DocumentID,
- Ordinal: r.Ordinal,
+ found[f.Kind] = append(found[f.Kind], factCandidate{
+ fact: Fact{
+ Kind: string(f.Kind),
+ Title: r.Title,
+ Trail: r.Heading,
+ Label: f.Label,
+ Span: f.Span,
+ DocumentID: r.DocumentID,
+ Ordinal: r.Ordinal,
+ },
+ own: f.Own,
+ noBox: f.NoBox,
})
}
}
@@ -1129,13 +1169,36 @@ func (s *Store) addFacts(ctx context.Context, p *Page, query string) {
// A slice ranged in a fixed order, never a map: Go randomises map
// iteration precisely so that an order nobody chose shows up, and it
// showed up once already in this file's notice aggregation.
+ quals := fact.Qualifiers(query)
for _, k := range fact.Kinds() {
cs := found[k]
- switch {
- case len(cs) == 0, silenced[k], len(cs) > MaxFacts, collides(cs):
+ var note *FactNote
+ if len(cs) > MaxFacts {
+ // The cap has fired. §8.8's narrowing is the only way this
+ // kind renders now, and it renders exactly one row or none.
+ one, ok := narrow(cs, quals)
+ if !ok {
+ continue
+ }
+ note = &FactNote{Kind: string(k), Matched: quals, Others: len(cs) - 1}
+ cs = []factCandidate{one}
+ }
+ if len(cs) == 0 {
continue
}
- p.Facts = append(p.Facts, cs...)
+ rows := make([]Fact, 0, len(cs))
+ silenced := false
+ for _, c := range cs {
+ silenced = silenced || c.noBox
+ rows = append(rows, c.fact)
+ }
+ if silenced || collides(rows) {
+ continue
+ }
+ p.Facts = append(p.Facts, rows...)
+ if note != nil {
+ p.FactNotes = append(p.FactNotes, *note)
+ }
}
// §8.9, strictly after everything above. **Eligibility is the kind,
@@ -1152,6 +1215,39 @@ func (s *Store) addFacts(ctx context.Context, p *Page, query string) {
}
}
+// factCandidate is one canonical value of one kind, before §8.8's rules
+// decide whether its kind renders.
+type factCandidate struct {
+ fact Fact
+ // own is §8.8 rule 2: the document wrote this label for this value.
+ own bool
+ // noBox is rule 4: that label is too long to show, and fact.Label
+ // holds it for matching only. It is never rendered.
+ noBox bool
+}
+
+// narrow is §8.8's narrowing by the reader's own words (Paul,
+// 2026-09-29), for a kind whose candidates are over the cap. It returns
+// the one candidate whose OWN label contains every qualifier, and false
+// when there are no qualifiers, no such candidate, or more than one.
+// Never picks between two: "if narrowing doesn't leave exactly one
+// candidate, still show nothing rather than guess."
+//
+// A fallback label — the trail or the title — never matches. On the page
+// that motivated this, the unused numbers fall to the heading "Office
+// Address", and matching it would leave three candidates, not one.
+func narrow(cs []factCandidate, quals []string) (factCandidate, bool) {
+ var one factCandidate
+ n := 0
+ for _, c := range cs {
+ if c.own && fact.LabelMatches(c.fact.Label, quals) {
+ one = c
+ n++
+ }
+ }
+ return one, n == 1
+}
+
// ModelPassages is how many ruled results §8.9 sends: eight, matching
// §8.6's answer-time budget, so the payload is bounded the same way the
// answer path's is. An address in result eleven is a stated recall limit.
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html
index b45763c..e9ffa98 100644
--- a/internal/memoria/templates/results.html
+++ b/internal/memoria/templates/results.html
@@ -60,6 +60,11 @@
<a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-fact-cite>{{.Title}}{{if .Trail}} › {{.Trail}}{{end}}</a>
</p>
{{end}}
+ {{/* §8.8's narrowing line: the box chose among candidates on the
+ reader's own words, and how many it left out. It keeps the
+ others visible, which is the one thing standing between a
+ planted label and a single confident answer. */}}
+ {{range .FactNotes}}<p rst-fact-note>{{.Text}}</p>{{end}}
</div>
{{end}}
{{if not .Results}}
diff --git a/internal/memoriatest/factbox_test.go b/internal/memoriatest/factbox_test.go
index 73f16e1..0df1fa9 100644
--- a/internal/memoriatest/factbox_test.go
+++ b/internal/memoriatest/factbox_test.go
@@ -389,3 +389,35 @@ func TestTheBoxOnlySeesWhatRetrievalReturned(t *testing.T) {
t.Fatalf("values = %q, want the number: the query shares a word with the document", got)
}
}
+
+var factNoteRe = regexp.MustCompile(`<p rst-fact-note>([^<]*)</p>`)
+
+// The page that motivated §8.8's narrowing (Paul, 2026-09-29), through
+// the real handler and template, on the page and in the fragment
+// search.js swaps. Gate 40 holds the rule at store level; this holds that
+// the row and its line reach the reader, and that the bullet is gone.
+func TestTheNarrowedBoxReachesThePageWithItsLine(t *testing.T) {
+ h := newApp(t)
+ store(t, h, "Office", "## Office Address\n\n"+
+ "- Turn the knob\n"+
+ "- The office phone number is: +353 (0)1 547 7854\n"+
+ "- We also have the following unused phone numbers: +353 (0)1 598 1234\n"+
+ " - +353 (0)1 598 1235\n"+
+ " - +353 (0)1 598 1236\n")
+ q := "what is the office phone number?"
+ for name, body := range map[string]string{
+ "page": search(t, h, q),
+ "fragment": get(t, h, "/search?q="+url.QueryEscape(q)).Body.String(),
+ } {
+ if got := factValues(body); len(got) != 1 || got[0] != "+353 (0)1 547 7854" {
+ t.Fatalf("%s: values = %q, want the office number alone", name, got)
+ }
+ if got := factLabels(body); len(got) != 1 || got[0] != "The office phone number is" {
+ t.Errorf("%s: labels = %q, want the document's own without its bullet", name, got)
+ }
+ want := "Matched “office”. 3 other phone numbers are in these results."
+ if got := capture(factNoteRe, body); len(got) != 1 || got[0] != want {
+ t.Errorf("%s: note = %q, want %q", name, got, want)
+ }
+ }
+}
diff --git a/tools/drive/main.go b/tools/drive/main.go
index 974e107..0eccbbc 100644
--- a/tools/drive/main.go
+++ b/tools/drive/main.go
@@ -132,6 +132,13 @@ func seedViaHTTP(h http.Handler, addr string) {
// the opposite, and a seed that demonstrates the wrong thing is
// worse than no seed at all.
{"Suppliers", "## Contact\n\n- +353 (0)1 555 0101\n- +353 (0)1 555 0202"},
+ // §8.8's NARROWED case (Paul, 2026-09-29), in the shape a Notion
+ // page arrives in: list items, the office number, and unused ones
+ // — two of them bare bullets that fall to the heading trail.
+ // Search "what is the office phone number?": one row, labelled
+ // without its bullet, and the "Matched “office”" line under it.
+ // Search "phone number" for the cap's silence beside it.
+ {"Office", "## Office Address\n\n- Turn the knob\n- The office phone number is: +353 (0)1 547 7854\n- We also have the following unused phone numbers: +353 (0)1 598 4321\n - +353 (0)1 598 4322\n - +353 (0)1 598 4323"},
{"Meeting, 12 August", "## Present\n\nEveryone.\n\n## Decisions\n\nWe will not build a suite index app. It answers a different question from the one anyone asked, and per-item access control is not expressible by the app that would have to publish it.\n\n## Actions\n\nPaul to decide whether the central host is a directory or an authenticator."},
}
for _, doc := range docs {