oficina / memoria Public

Gate 40: a match outside the first passage, and notes across kinds (GPT-6 Astra)

Astra's third pass: previous findings fixed, no implementation defect,
two guard gaps.

- A match in a later document and passage, with the ruled results in a
  fixed order, asserts its exact citation tuple. Copying the first
  candidate's citation or zeroing the ordinal both passed before.
- Three kinds, eircode unnarrowed then phone and email narrowed with
  different counts, asserts each row's own note. Hanging every note on
  the page's first row, or sharing one note across kinds, both passed
  before.

Both fixtures are written from Astra's probes. All four of its
survivors are built and red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 8032175452bc3bd8877071732c5f9b0b1e8be387 parent 15086a5
1 file changed, +81 −0
  • internal/memoria/narrow_test.go +81 −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.361s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	(cached)
?   	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	1.614s
?   	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/internal/memoria/narrow_test.go b/internal/memoria/narrow_test.go
index b1dcf58..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"
@@ -76,6 +77,14 @@ const officePage = "## Office Address\n\n" +
// - 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.
@@ -230,6 +239,78 @@ func TestGate40NarrowingPicksExactlyOneOverTheCap(t *testing.T) {
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")