oficina / memoria Public

Fact box narrowing: note under its own row; stronger gate 40 (GPT-6 Astra)

Answers GPT-6 Astra's second and third passes over §8.8's narrowing.
The spec was found faithful to Paul's ruling, with no regression in
authorization or model labels.

Fix: the narrowing line rendered after every row, so in a multi-kind
box it sat under the wrong kind. It now travels on its row (Fact.Note)
and renders directly beneath it.

Guards: gate 40 asserts whole row tuples and whole notes, and adds
fixtures for:
- a short and an over-long matching label;
- a repeated over-long value;
- an authored label equal to its heading;
- five candidates;
- email as a second kind;
- a match outside the first passage;
- per-kind notes across three kinds.

Page tests pin the citation and the note's position. Fact tests add the
mid-line hyphen, the + marker, and generic words. All eighteen of
Astra's surviving mutations are built and red.

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

Squashed from fix/fact-box-narrowing-guards into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 1a7603a26c3aa8181ecb400fea4c070400241949 parent aab9263
7 files changed, +263 −34
  • internal/memoria/fact/fact_test.go +13 −0
  • internal/memoria/handlers.go +0 −3
  • internal/memoria/narrow_test.go +201 −18
  • internal/memoria/static/memoria.css +2 −1
  • internal/memoria/store.go +10 −7
  • internal/memoria/templates/results.html +6 −5
  • internal/memoriatest/factbox_test.go +31 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
go: downloading modernc.org/sqlite v1.55.0
go: downloading github.com/carlosframework/rastrillo v0.23.0
go: downloading amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22
go: downloading golang.org/x/crypto v0.55.0
go: downloading amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d
go: downloading golang.org/x/net v0.57.0
go: downloading amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f
go: downloading amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
go: downloading amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3
go: downloading github.com/go-chi/chi/v5 v5.3.2
go: downloading github.com/google/uuid v1.6.0
go: downloading gorm.io/gorm v1.31.2
go: downloading amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
go: downloading github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
go: downloading gorm.io/plugin/dbresolver v1.6.2
go: downloading github.com/keymaildev/signin v0.1.1
go: downloading github.com/jinzhu/now v1.1.5
go: downloading github.com/jinzhu/inflection v1.0.0
go: downloading golang.org/x/text v0.41.0
go: downloading modernc.org/libc v1.74.1
go: downloading golang.org/x/sys v0.47.0
go: downloading modernc.org/memory v1.11.0
go: downloading modernc.org/mathutil v1.7.1
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.553s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	0.008s
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.529s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	0.035s
ok  	amadan.net/oficina/memoria/internal/memoria/notion	0.021s
ok  	amadan.net/oficina/memoria/internal/memoria/provider	1.132s
ok  	amadan.net/oficina/memoria/internal/memoria/query	0.003s
ok  	amadan.net/oficina/memoria/internal/memoria/seal	0.003s
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	3.023s
ok  	amadan.net/oficina/memoria/internal/memoriatest	1.720s
?   	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
go: downloading github.com/BurntSushi/toml v1.6.0
rastrillo migration check: models and migrations agree
diff --git a/internal/memoria/fact/fact_test.go b/internal/memoria/fact/fact_test.go
index 815d581..a24a637 100644
--- a/internal/memoria/fact/fact_test.go
+++ b/internal/memoria/fact/fact_test.go
@@ -691,6 +691,7 @@ func TestWordBounded(t *testing.T) {
// - single-character tokens kept → `["s" "tito"]`
// - "for" dropped from the stopwords → `["for" "dublin" "office"]`
// - repeats kept → `["office" "office"]`
+// - generic words kept (Astra's) → `["office" "numbers"]`
func TestQualifiersOnRealisticQueries(t *testing.T) {
for q, want := range map[string][]string{
"what is the office phone number?": {"office"},
@@ -702,6 +703,9 @@ func TestQualifiersOnRealisticQueries(t *testing.T) {
"support email": {"support"},
"phone number": nil,
"office office phone": {"office"},
+ "office phone numbers": {"office"},
+ "office phone no": {"office"},
+ "office phone nr": {"office"},
} {
if got := Qualifiers(q); !reflect.DeepEqual(got, want) {
t.Errorf("Qualifiers(%q) = %q, want %q", q, got, want)
@@ -736,6 +740,10 @@ func TestLabelMatchesWholeTokensAndEveryQualifier(t *testing.T) {
// `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.
+//
+// Astra's, 2026-09-29, both red now: the pattern's ^ anchor removed →
+// "Never use - Office" loses its warning and becomes "Office"; the +
+// marker dropped → "+ Office".
func TestListMarkersAreNotPartOfTheLabel(t *testing.T) {
for _, c := range []struct {
text, label string
@@ -744,6 +752,11 @@ func TestListMarkersAreNotPartOfTheLabel(t *testing.T) {
{"- 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},
+ // A hyphen INSIDE a label is the document's words, not a marker:
+ // an unanchored pattern would delete the warning and leave
+ // "Office".
+ {"Never use - Office: +353 (0)1 547 7854", "Never use - 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},
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 808fec7..164b9d8 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -172,8 +172,6 @@ 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"
@@ -667,7 +665,6 @@ 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
index 3ccfb13..25bb309 100644
--- a/internal/memoria/narrow_test.go
+++ b/internal/memoria/narrow_test.go
@@ -3,6 +3,7 @@ package memoria
import (
"context"
"reflect"
+ "sort"
"strings"
"testing"
@@ -61,25 +62,61 @@ const officePage = "## Office Address\n\n" +
// - a matching over-long label not silencing the kind → its case
// - Others counted as len(cs) → "want one with 3 others"
//
+// SECOND ROUND, 2026-09-29: GPT-6 Astra's own mutations, twelve of
+// which passed the gate above. Each is red now, built and reverted:
+// - the narrowed row rebuilt from {Kind, Span, Label} → "row = …
+// Title: Trail: … DocumentID:0"
+// - an over-long label reported Own=false → "a short and an over-long
+// matching label are two matches"
+// - a NoBox occurrence not marked seen → "a repeated over-long value
+// is one candidate"
+// - Own inferred from the label differing from trail and title →
+// "an authored label identical to its heading matches"
+// - narrowing for phone only → "another kind narrows too"
+// - every note's Kind "phone" → the same case, on the note
+// - only the first qualifier kept → the Dublin case's note
+// - Others hardcoded to 3 → "five candidates leave four others"
+//
+// THIRD ROUND, 2026-09-29: four more of Astra's, green until the two
+// fixtures above written from its probes:
+// - the first candidate's citation copied onto the match, and the
+// match's Ordinal forced to 0 → "a match outside the first passage
+// keeps its own citation"
+// - every note hung on the page's first row, and one note pointer
+// shared across kinds → "each narrowed kind carries its own note"
+//
// 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) {
+ // one asserts a narrowed box: exactly one row, with the value
+ // wanted, and its note in full — kind, every matched word, the count.
+ one := func(t *testing.T, p Page, want string, note FactNote) {
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)
+ if p.Facts[0].Note == nil || !reflect.DeepEqual(*p.Facts[0].Note, note) {
+ t.Errorf("note = %+v, want %+v", p.Facts[0].Note, note)
}
}
+ phone := func(others int, matched ...string) FactNote {
+ return FactNote{Kind: "phone", Matched: matched, Others: 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)
+ if len(p.Facts) != 0 {
+ t.Errorf("facts = %q, want no box", spans(p))
+ }
+ }
+ noNotes := func(t *testing.T, p Page) {
+ t.Helper()
+ for _, f := range p.Facts {
+ if f.Note != nil {
+ t.Errorf("row %q carries a note %+v; nothing was narrowed", f.Span, *f.Note)
+ }
}
}
page := func(t *testing.T, body string) *Store {
@@ -93,12 +130,20 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) {
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)
+ one(t, p, "+353 (0)1 547 7854", phone(3, "office"))
+ // The whole row, citation included: a narrowed row rebuilt from
+ // the value alone would lose where it came from.
+ if len(p.Results) != 1 {
+ t.Fatalf("%d results, want the one passage", len(p.Results))
+ }
+ r := p.Results[0]
+ want := Fact{
+ Kind: "phone", Title: "Office", Trail: "Office Address",
+ Label: "The office phone number is", Span: "+353 (0)1 547 7854",
+ DocumentID: r.DocumentID, Ordinal: r.Ordinal, Note: p.Facts[0].Note,
}
- if got := p.FactNotes[0].Matched; !reflect.DeepEqual(got, []string{"office"}) {
- t.Errorf("matched = %q, want [office]", got)
+ if !reflect.DeepEqual(p.Facts[0], want) {
+ t.Errorf("row = %+v, want %+v", p.Facts[0], want)
}
none(t, searchFacts(t, s, "phone number"))
})
@@ -115,21 +160,22 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) {
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)
+ if len(p.Facts) != 3 {
+ t.Errorf("facts = %q, want all three", spans(p))
}
+ noNotes(t, p)
})
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)
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office"))
})
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)
+ one(t, searchFacts(t, s, "Dublin office phone"), "+353 (0)1 547 0001", phone(3, "dublin", "office"))
})
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"+
@@ -152,7 +198,124 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) {
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)
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office"))
+ })
+
+ // A short matching label and an over-long one that also matches are
+ // TWO matches, so nothing renders. Reporting an over-long label as
+ // not the document's own (Own=false) leaves the short one alone, and
+ // renders it.
+ t.Run("a short and an over-long matching label are two matches", func(t *testing.T) {
+ s := page(t, "## Office\n\nOffice phone: +353 (0)1 547 0001\n\nThe office number you must never give to anyone "+long+": +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, "office phone"))
+ })
+
+ // One value, over-long-labelled in two documents, is ONE candidate:
+ // three in all, under the cap, and that candidate silences the kind.
+ // Counted twice it makes four, narrowing runs, and the match renders.
+ t.Run("a repeated over-long value is one candidate", func(t *testing.T) {
+ s := factCorpus(t, visible(), [][3]string{
+ {"a", "Dublin", "## Dublin office\n\nOffice phone: +353 (0)1 547 0001"},
+ {"b", "Cork", "## Cork office phone\n\nThe number you must never give to anyone at all " + long + ": +353 (0)1 547 0002"},
+ {"c", "Sligo", "## Sligo office phone\n\nThe number you must never give to anyone at all " + long + ": +353 (0)1 547 0002"},
+ {"d", "Galway", "## Galway office\n\nSales phone: +353 (0)1 547 0003"},
+ })
+ none(t, searchFacts(t, s, "office phone"))
+ })
+
+ // A document can write a label identical to its own heading, and it
+ // is still the document's label. Provenance inferred by comparing
+ // the label with the heading gets this wrong.
+ t.Run("an authored label identical to its heading matches", func(t *testing.T) {
+ s := page(t, "## Office phone\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")
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office"))
+ })
+
+ t.Run("five candidates leave four others", func(t *testing.T) {
+ s := page(t, "## 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\n\nSupport: +353 (0)1 547 0005")
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(4, "office"))
+ })
+
+ // The match sits in a LATER document and a later passage, so its
+ // citation differs from the first candidate's in every coordinate.
+ // The motivating page has one passage, where copying the first
+ // candidate's citation, or a zero ordinal, is indistinguishable.
+ // The result order is fixed by hand so the match is not first;
+ // addFacts is then run on those ruled results exactly as Search runs
+ // it. (Fixture from GPT-6 Astra's third pass.)
+ t.Run("a match outside the first passage keeps its own citation", func(t *testing.T) {
+ s := factCorpus(t, visible(), [][3]string{
+ {"a", "Other document", "## Other office section\n\nReception phone: 01 547 0002\n\nSales phone: 01 547 0003\n\nFax phone: 01 547 0004"},
+ {"b", "Target document", "## Introduction\n\nA phone directory introduction.\n\n## Labelled office\n\nOffice phone: 01 547 0001"},
+ })
+ p, err := s.SearchWithoutFacts(context.Background(),
+ suite.Subject{Kind: suite.SubjectAddress, Value: "paul@tito.io"}, "office phone", 25)
+ if err != nil {
+ t.Fatalf("search: %v", err)
+ }
+ sort.Slice(p.Results, func(i, j int) bool {
+ a, b := p.Results[i], p.Results[j]
+ if a.DocumentID != b.DocumentID {
+ return a.DocumentID < b.DocumentID
+ }
+ return a.Ordinal < b.Ordinal
+ })
+ var target Result
+ for _, r := range p.Results {
+ if r.Heading == "Labelled office" {
+ target = r
+ }
+ }
+ if target.DocumentID == 0 || target.Ordinal == 0 || len(p.Results) < 2 || target.DocumentID == p.Results[0].DocumentID {
+ t.Fatalf("fixture lacks distinct coordinates: target %+v, first %+v", target, p.Results[0])
+ }
+ s.addFacts(context.Background(), &p, "office phone")
+ want := []Fact{{
+ Kind: "phone", Title: "Target document", Trail: "Labelled office",
+ Label: "Office phone", Span: "01 547 0001",
+ DocumentID: target.DocumentID, Ordinal: target.Ordinal,
+ Note: &FactNote{Kind: "phone", Matched: []string{"office"}, Others: 3},
+ }}
+ if !reflect.DeepEqual(p.Facts, want) {
+ t.Errorf("facts = %+v, want %+v", p.Facts, want)
+ }
+ })
+
+ // Three kinds: an eircode under the cap, then phone and email each
+ // narrowed with a different count. Each note belongs to its own row.
+ // A note hung on the page's first row, or one note shared across
+ // kinds, passes every single-kind fixture. (Astra's third pass.)
+ t.Run("each narrowed kind carries its own note", func(t *testing.T) {
+ s := page(t, "## Office\n\nOffice eircode: D02 X285\n\n"+
+ "Office phone: 01 547 0001\n\nReception phone: 01 547 0002\n\nSales phone: 01 547 0003\n\nFax phone: 01 547 0004\n\n"+
+ "Office email: office@example.test\n\nSales email: sales@example.test\n\nLegal email: legal@example.test\n\n"+
+ "Billing email: billing@example.test\n\nSupport email: support@example.test")
+ p := searchFacts(t, s, "office eircode phone email")
+ if got := spans(p); !reflect.DeepEqual(got, []string{"D02 X285", "01 547 0001", "office@example.test"}) {
+ t.Fatalf("spans = %q, want the eircode, then the narrowed phone and email", got)
+ }
+ if p.Facts[0].Note != nil {
+ t.Errorf("the eircode row carries a note %+v; it was not narrowed", *p.Facts[0].Note)
+ }
+ for i, want := range []FactNote{
+ {Kind: "phone", Matched: []string{"office"}, Others: 3},
+ {Kind: "email", Matched: []string{"office"}, Others: 4},
+ } {
+ f := p.Facts[i+1]
+ if f.Note == nil || !reflect.DeepEqual(*f.Note, want) {
+ t.Errorf("row %q note = %+v, want %+v", f.Span, f.Note, want)
+ }
+ }
+ })
+
+ t.Run("another kind narrows too", func(t *testing.T) {
+ s := page(t, "## Office email\n\nOffice email: office@tito.io\n\nSupport: support@tito.io\n\n"+
+ "Sales: sales@tito.io\n\nBilling: billing@tito.io")
+ one(t, searchFacts(t, s, "office email"), "office@tito.io",
+ FactNote{Kind: "email", Matched: []string{"office"}, Others: 3})
})
// Gate 34's rule, both halves. Counting matches over the unruled set
@@ -170,7 +333,7 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) {
{"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)
+ one(t, searchFacts(t, s, "office phone"), "+353 (0)1 547 0001", phone(3, "office"))
})
// And the cap itself is tested over the ruled set: three visible, a
// fourth withheld, is under the cap — all three, no narrowing.
@@ -185,8 +348,28 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) {
{"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)
+ if len(p.Facts) != 3 {
+ t.Errorf("facts = %q, want all three", spans(p))
}
+ noNotes(t, p)
})
}
+
+// The note's sentence: every matched word, the kind's own noun.
+func TestFactNoteText(t *testing.T) {
+ for _, c := range []struct {
+ n FactNote
+ want string
+ }{
+ {FactNote{Kind: "phone", Matched: []string{"office"}, Others: 3},
+ "Matched “office”. 3 other phone numbers are in these results."},
+ {FactNote{Kind: "email", Matched: []string{"dublin", "office"}, Others: 4},
+ "Matched “dublin” and “office”. 4 other email addresses are in these results."},
+ {FactNote{Kind: "vat", Matched: []string{"main", "dublin", "office"}, Others: 5},
+ "Matched “main”, “dublin” and “office”. 5 other VAT numbers are in these results."},
+ } {
+ if got := c.n.Text(); got != c.want {
+ t.Errorf("Text(%+v) = %q, want %q", c.n, got, c.want)
+ }
+ }
+}
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css
index 6d37e63..e4f2d4c 100644
--- a/internal/memoria/static/memoria.css
+++ b/internal/memoria/static/memoria.css
@@ -311,7 +311,8 @@
gap: 0.2rem 0.6rem;
margin: 0;
}
-[rst-fact] + [rst-fact] {
+[rst-fact] + [rst-fact],
+[rst-fact-note] + [rst-fact] {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--rst-line);
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 01d2c84..012b8d2 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -117,13 +117,11 @@ 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.
+// FactNote is the line under a narrowed kind's row: the box chose it on
+// the reader's own words, and this many others were left out (§8.8, Paul
+// 2026-09-29). Counted over Results, like everything else in the box.
type FactNote struct {
Kind string
// Matched are the qualifiers every shown row's label contains.
@@ -1057,6 +1055,11 @@ type Fact struct {
// distinguish the two at a glance. Decision 4's naming rule, reaching
// a box that has no answer paragraph.
Model string
+ // Note is set on the one row narrowing left for its kind, and nil on
+ // every other row. It is carried by the row rather than the page so
+ // that it renders under the row it describes, not under whichever
+ // kind happens to render last.
+ Note *FactNote
}
// addFacts fills p.Facts for the kinds this query names.
@@ -1195,10 +1198,10 @@ func (s *Store) addFacts(ctx context.Context, p *Page, query string) {
if silenced || collides(rows) {
continue
}
- p.Facts = append(p.Facts, rows...)
if note != nil {
- p.FactNotes = append(p.FactNotes, *note)
+ rows[0].Note = note
}
+ p.Facts = append(p.Facts, rows...)
}
// §8.9, strictly after everything above. **Eligibility is the kind,
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html
index e9ffa98..046c804 100644
--- a/internal/memoria/templates/results.html
+++ b/internal/memoria/templates/results.html
@@ -59,12 +59,13 @@
{{if .Model}}<span rst-fact-by title="Found by a model, not validated">🤖 {{.Provider}} · {{.Model}}</span>{{end}}
<a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-fact-cite>{{.Title}}{{if .Trail}} › {{.Trail}}{{end}}</a>
</p>
+ {{/* §8.8's narrowing line, under the row it describes: the box
+ chose this row 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. */}}
+ {{with .Note}}<p rst-fact-note>{{.Text}}</p>{{end}}
{{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 0df1fa9..657e1a6 100644
--- a/internal/memoriatest/factbox_test.go
+++ b/internal/memoriatest/factbox_test.go
@@ -419,5 +419,36 @@ func TestTheNarrowedBoxReachesThePageWithItsLine(t *testing.T) {
if got := capture(factNoteRe, body); len(got) != 1 || got[0] != want {
t.Errorf("%s: note = %q, want %q", name, got, want)
}
+ // The citation survives narrowing: title, trail and a link to the
+ // passage, not just the value.
+ if !regexp.MustCompile(`<a href="/documents/\d+#p\d+" rst-fact-cite>Office › Office Address</a>`).MatchString(body) {
+ t.Errorf("%s: the narrowed row's citation is missing or wrong:\n%s", name, body)
+ }
+ }
+}
+
+// The note sits under the row it describes, not under whichever kind
+// renders last. Phone renders before VAT, so a note placed after every
+// row would sit under the VAT number and describe the wrong thing.
+//
+// MUTATION-TESTED 2026-09-29, each reverted: the note rendered after all
+// rows (Astra's finding) → "order is phone 2432, note 2818, vat 2668";
+// the note rendered above its row → "phone 2525, note 2362".
+func TestTheNarrowingLineSitsUnderItsOwnRow(t *testing.T) {
+ h := newApp(t)
+ store(t, h, "Office", "## 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\n\nOffice VAT: IE3384527RH")
+ body := search(t, h, "office phone and vat")
+ if got := factValues(body); len(got) != 2 {
+ t.Fatalf("values = %q, want the narrowed phone and the VAT number", got)
+ }
+ phone := strings.Index(body, "<span rst-fact-value>&#43;353 (0)1 547 0001</span>")
+ note := strings.Index(body, "<p rst-fact-note>")
+ vat := strings.Index(body, "<span rst-fact-value>IE3384527RH</span>")
+ if phone < 0 || note < 0 || vat < 0 {
+ t.Fatalf("phone %d, note %d, vat %d: something is missing:\n%s", phone, note, vat, body)
+ }
+ if !(phone < note && note < vat) {
+ t.Errorf("order is phone %d, note %d, vat %d; the note must sit between its row and the next", phone, note, vat)
}
}