oficina / memoria Public

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

Spec: the first Astra review's four must-fixes. The draft broadened the
ruling (narrowed below the cap, showed every match). It is now faithful
to what Paul approved: only once a kind is over the cap, and only when
exactly one candidate's own (rule 2) label contains every qualifier.
Otherwise nothing, as before. The contradictory "office mobile" example
is replaced with "Dublin office" cases in both directions. Gate 40 now
has the counterexamples the review named: a withheld non-match, the cap
tested over the ruled set, and under-cap unchanged. §8.9's poisoning
sentence is qualified. The list-marker paragraph is corrected (numbered
items already lost "3."). Stated limitations are recorded.

Code:
- fact: Found.Own records label provenance when the label is made; an
  over-long label is kept for matching only; list markers are stripped;
  Qualifiers and LabelMatches.
- store: narrow() and FactNote. The note renders under the row: "Matched
  “office”. N other phone numbers are in these results."
- drive: seeds the Notion-shaped Office page.

Gate 40 and the unit tests: 18 mutations, each built. 17 red; the 18th
removed a guard no input could reach, so the guard is deleted. Looked
at in headless Chromium through tools/drive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 659311982ed33b74e8d40bb60b4ea679f9324e94 parent 0d63f67
10 files changed, +714 −141
  • docs/superpowers/specs/2026-09-03-memoria-design.md +159 −112
  • 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	5.296s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.974s
?   	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.566s
?   	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 a297598..6ef0493 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -3333,17 +3333,25 @@ span's position in the chunk text:
**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 `3. The office phone number is: …` was labelled `3. The office phone
-number is`. For a Notion page, memoria's own renderer wrote that marker
-(`notion/markdown.go` renders bulleted, numbered and to-do items as
-`- `, `N. `, `- [ ] ` and `- [x] `), so the label carried memoria's
-syntax as though the document had written it. Before rule 2 runs, one
-leading marker is removed from the run: `-`, `*`, `+` 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. **Only when
-something follows it.** A bare bullet (`- +353 1 547 7854`) has nothing
-after its marker, so rule 3 still applies and it falls through to the
-trail, exactly as before.
+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.**
@@ -3402,10 +3410,10 @@ 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. **The one narrowing allowed is a match between the reader's words
-and the document's own label** (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, both
-render.
+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
@@ -3428,32 +3436,51 @@ results list is the honest surface for that.
**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."*) has three parts:
-
-1. When a kind has too many candidates, use the query's other words to
- narrow them.
-2. If a candidate's label contains those words, show it.
-3. If narrowing leaves nothing, the rules above still decide, and the
- usual result is to show nothing rather than guess.
-
-This section is the spec author's precise form of that proposal. Where
-it is stricter than those three parts, it says so.
++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 three sets removed:
+on anything that is not a letter or digit) with these removed:
- every token of every lexicon phrase, for every kind, so `phone`,
- `email`, `address`, `vat` and the rest are never qualifiers;
+ `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.
+ into a parser;
+- single-character tokens, which disposes of the `s` in `what's` and
+ `Tito's`.
-Single-character tokens are removed too, which disposes of the `s` in
-`what's`. For *"what is the office phone number?"* the qualifiers are
-`{office}`. For *"phone number"* there are none.
+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.**
@@ -3463,52 +3490,66 @@ every qualifier as a whole token.**
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 would
- narrow nothing.
+ 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, and that is a
- stated cost in the safe direction: a missed match shows what the rules
- above would have shown anyway.
-- **Every qualifier, not any.** `office mobile` does not match a label
- saying `office phone`.
-
-**The outcome, per kind, after the §4.1 ruling and after identity:**
-
-- **No qualifiers, or no candidate matches:** the kind is ruled exactly
- as before, over all its candidates. Narrowing never makes a box appear
- that the rules above would refuse, except by leaving candidates out.
-- **One or more match:** collision and the cap of three are applied to
- the matching candidates only, in first-seen order. If two labels
- match, both render. Narrowing never picks between matches.
-- **A `NoBox` candidate (rule 4, an over-long label) silences the kind
- only if it is in the set being ruled.** With no narrowing, that is
- always, as before. With narrowing, it silences the kind only when its
- own over-long label matches. A non-matching candidate is left out
- whether its label is short or long, and silencing the answer because
- of a candidate the reader's words already excluded would bring back the
- very problem this rule fixes. So `fact.Scan` keeps the text of an
- over-long label for matching purposes. It is never rendered.
-
-**The box says it narrowed.** Under a narrowed kind's rows, one line:
+ 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 count of candidates left out.
-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. The line appears only when at least one candidate was left out. It
-carries no values and no links: the results list below is where the
-others are.
+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. If a planted number's label contains the reader's word and the
-real number's label does not, the box shows only the planted one — a
-single, confident, cited answer. The count line is the mitigation. It
-tells the reader other numbers exist and that the box chose between them
-on their own word. It is not a fix, and this spec does not claim one.
+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
@@ -3565,11 +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.** A planted
- label carrying the reader's word, when the real label does not, 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.
+ **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
@@ -3732,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.
@@ -4534,8 +4578,8 @@ The gates:
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. **Queries
- with no qualifiers**, since narrowing (gate 40) can shrink the set
- the cap is applied to;
+ 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;
@@ -4619,47 +4663,50 @@ The gates:
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 matches the reader's words to the document's own label,
- and nothing else** (§8.8, Paul, 2026-09-29). Store level, asserting
- the candidate tuples and the narrowing line's fields, never rendered
- copy. Fixtures:
+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, and the
- line reports `{office}` and **3** others. The same page queried
- with *"phone number"* → **no box** (four candidates, no
- qualifiers). The two together are the gate: either alone passes an
- implementation that always narrows, or one that never does.
- - **A fallback label never matches.** The bare bullets' label is the
- trail `Office Address`, which contains `office`. If they matched,
- the presence fixture would render three rows or none, never one.
- - **Whole tokens.** A label saying `Offices` or `Backoffice line`
- does not match `office`.
- - **Every qualifier.** `office mobile` against a label saying
- `Office phone` → no match, and the kind is ruled over all
- candidates.
- - **No match → unchanged.** `fax phone` matching nothing, on four
- candidates → no box, and no narrowing line.
- - **Two matches → both**, in first-seen order, on a fixture where
- first-seen order and value order disagree.
- - **Collision and the cap still apply to the matches:** four matching
- candidates → no box, and two matching labels with the same title
- and trail → no box.
- - **`NoBox` inside and outside the matches.** An over-long label
- containing the qualifier silences the kind. An over-long label
- without it does not, and the short matching label renders.
- - **Counts after the ruling** (gate 34's rule, applied here). A
- matching candidate in a `NOT_VISIBLE` document is neither a row
- nor counted in the line. So a fixture with one visible match, one
- withheld match and two visible non-matches gives one row and **2**
- others, not two rows, and not 3 others.
- - **The line is absent when nothing was left out.** Two candidates,
- both matching → two rows, no line. A line saying "0 others" is
- noise, and one that is always present cannot tell the reader
- anything.
+ 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
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 {