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.
10 files changed,
+794
−49
docs/superpowers/specs/2026-09-03-memoria-design.md+239 −20internal/memoria/fact/fact.go+110 −11internal/memoria/fact/fact_test.go+85 −0internal/memoria/handlers.go+3 −0internal/memoria/narrow_test.go+192 −0internal/memoria/static/memoria.css+7 −0internal/memoria/store.go+114 −18internal/memoria/templates/results.html+5 −0internal/memoriatest/factbox_test.go+32 −0tools/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 { |