| 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) |
| + } |
| + } |
| +} |