| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "testing" |
| 6 | |
| 7 | "amadan.net/oficina/suite" |
| 8 | |
| 9 | "amadan.net/oficina/memoria/internal/memoria/sibling" |
| 10 | ) |
| 11 | |
| 12 | // perItemCustody rules each candidate on its own item id. |
| 13 | // |
| 14 | // **fixedCustody cannot do this**, and that is why this type exists |
| 15 | // rather than a field being added to it: it returns sibling.Decide(give) |
| 16 | // for every candidate from ONE field, so every fixture it builds has all |
| 17 | // its passages visible or none of them. Gate 34 is entirely about pages |
| 18 | // where some are and some are not, and drafts 1 and 2 of §8.8 both said |
| 19 | // the gate could run against "memoriatest's sibling stub, which exists". |
| 20 | // No such stub exists. §10 opens by naming that failure — a gate naming a |
| 21 | // test that was renamed away — and the section citing it committed it |
| 22 | // twice. |
| 23 | type perItemCustody struct { |
| 24 | apps map[string]sibling.Sibling |
| 25 | // give is keyed by item id. A candidate whose item id is absent is |
| 26 | // VISIBLE, so a fixture names only the exceptions it cares about. |
| 27 | give map[string]suite.Authorization |
| 28 | } |
| 29 | |
| 30 | func (p *perItemCustody) Sibling(source string) (sibling.Sibling, bool) { |
| 31 | s, ok := p.apps[source] |
| 32 | return s, ok |
| 33 | } |
| 34 | |
| 35 | func (p *perItemCustody) Rule(_ context.Context, _ suite.Subject, cands []sibling.Candidate) []sibling.Disposition { |
| 36 | out := make([]sibling.Disposition, len(cands)) |
| 37 | for i, c := range cands { |
| 38 | a, ok := p.give[c.Item.ItemID] |
| 39 | if !ok { |
| 40 | a = suite.VISIBLE |
| 41 | } |
| 42 | out[i] = sibling.Decide(a) |
| 43 | } |
| 44 | return out |
| 45 | } |
| 46 | |
| 47 | // eircodeCorpus stores one sibling document per (title, eircode) pair, |
| 48 | // each under its own item id so the fake above can rule them apart. |
| 49 | func eircodeCorpus(t *testing.T, cus Custody, docs [][3]string) *Store { |
| 50 | t.Helper() |
| 51 | s := storeForTest(t) |
| 52 | s.custody = cus |
| 53 | for _, d := range docs { |
| 54 | if _, err := s.Ingest(context.Background(), IngestSpec{ |
| 55 | Source: "Docs", Key: d[0], Title: d[1], |
| 56 | Body: "## Contact\n\nEircode: " + d[2], |
| 57 | ItemID: d[0], |
| 58 | SiblingRevision: 1, |
| 59 | }); err != nil { |
| 60 | t.Fatalf("ingest %s: %v", d[0], err) |
| 61 | } |
| 62 | } |
| 63 | return s |
| 64 | } |
| 65 | |
| 66 | func searchFacts(t *testing.T, s *Store, q string) Page { |
| 67 | t.Helper() |
| 68 | p, err := s.Search(context.Background(), |
| 69 | suite.Subject{Kind: suite.SubjectAddress, Value: "paul@tito.io"}, q, 25) |
| 70 | if err != nil { |
| 71 | t.Fatalf("search: %v", err) |
| 72 | } |
| 73 | return p |
| 74 | } |
| 75 | |
| 76 | func spans(p Page) []string { |
| 77 | var out []string |
| 78 | for _, f := range p.Facts { |
| 79 | out = append(out, f.Span) |
| 80 | } |
| 81 | return out |
| 82 | } |
| 83 | |
| 84 | // GATE 34 — the box never speaks a withheld passage, and every count |
| 85 | // happens after the ruling (§8.8, §10). |
| 86 | // |
| 87 | // **Asserts the struct, never the rendered copy.** Two dispositions that |
| 88 | // render an identical page is how Outcome.Record filed silent rulings |
| 89 | // undetected (§1.9); a page assertion here could not tell case 2 from |
| 90 | // case 3 at all. |
| 91 | // |
| 92 | // Mutations run, each verified red and reverted, 2026-09-14: |
| 93 | // - addFacts ranging the pre-ruling hits instead of p.Results — cases |
| 94 | // 2, 4, 5 and 6 go red. |
| 95 | // - the cap counted before the ruling — case 4 goes red alone. |
| 96 | // - the collision check computed over the unruled set — case 6 alone. |
| 97 | // - citing r.DocumentID of the first hit rather than the first RESULT — |
| 98 | // case 5 alone, and no other fixture can see it because every other |
| 99 | // one uses distinct values per document. |
| 100 | func TestGate34TheFactBoxNeverSpeaksAWithheldPassage(t *testing.T) { |
| 101 | roster := docsRoster() |
| 102 | |
| 103 | // 1. PRESENCE. Both visible, two distinct eircodes, two candidates. |
| 104 | // Without this every absence below is an assertion about an empty |
| 105 | // world — §10's second rule, and gate 4's lesson. |
| 106 | t.Run("presence: two visible passages give two candidates", func(t *testing.T) { |
| 107 | s := eircodeCorpus(t, &perItemCustody{apps: roster}, [][3]string{ |
| 108 | {"a", "Office A", "D02 X285"}, |
| 109 | {"b", "Office B", "A65 F4E2"}, |
| 110 | }) |
| 111 | if got := spans(searchFacts(t, s, "eircode")); len(got) != 2 { |
| 112 | t.Fatalf("spans = %q, want two", got) |
| 113 | } |
| 114 | }) |
| 115 | |
| 116 | // 2. NOT_VISIBLE IS SILENT. §4.1's table makes OmitSilently the only |
| 117 | // disposition that may be silent, and Outcome.Record implements |
| 118 | // exactly that. Draft 1 of §8.8 demanded a notice here, which is |
| 119 | // either unreachable against correct code or an instruction to build |
| 120 | // the existence oracle §4.1 exists to deny. **The empty-notice |
| 121 | // assertion is the load-bearing half of this case.** |
| 122 | t.Run("not_visible: the survivor is boxed and nothing is said", func(t *testing.T) { |
| 123 | s := eircodeCorpus(t, &perItemCustody{ |
| 124 | apps: roster, |
| 125 | give: map[string]suite.Authorization{"b": suite.NOT_VISIBLE}, |
| 126 | }, [][3]string{ |
| 127 | {"a", "Office A", "D02 X285"}, |
| 128 | {"b", "Office B", "A65 F4E2"}, |
| 129 | }) |
| 130 | p := searchFacts(t, s, "eircode") |
| 131 | if got := spans(p); len(got) != 1 || got[0] != "D02 X285" { |
| 132 | t.Fatalf("spans = %q, want just the visible one", got) |
| 133 | } |
| 134 | if len(p.Notices) != 0 { |
| 135 | t.Errorf("notices = %q, want none: a NOT_VISIBLE is the one value that may be silent", p.Notices) |
| 136 | } |
| 137 | }) |
| 138 | |
| 139 | // 3. UNKNOWN IS LOUD. The same fixture, the other disposition. This |
| 140 | // is what draft 1 meant and attached to the wrong one. |
| 141 | t.Run("unknown: the survivor is boxed and the page says so", func(t *testing.T) { |
| 142 | s := eircodeCorpus(t, &perItemCustody{ |
| 143 | apps: roster, |
| 144 | give: map[string]suite.Authorization{"b": suite.UNKNOWN}, |
| 145 | }, [][3]string{ |
| 146 | {"a", "Office A", "D02 X285"}, |
| 147 | {"b", "Office B", "A65 F4E2"}, |
| 148 | }) |
| 149 | p := searchFacts(t, s, "eircode") |
| 150 | if got := spans(p); len(got) != 1 || got[0] != "D02 X285" { |
| 151 | t.Fatalf("spans = %q, want just the visible one", got) |
| 152 | } |
| 153 | if len(p.Notices) == 0 { |
| 154 | t.Error("want a notice: an UNKNOWN is withheld loudly (§6.5)") |
| 155 | } |
| 156 | }) |
| 157 | |
| 158 | // 4. THE CAP COUNTS AFTER THE RULING. Four values, one withheld, |
| 159 | // three left — a box. Counted before the ruling there are four, the |
| 160 | // cap fires, and the reader loses the box because of a document they |
| 161 | // may not see. **No two-document fixture can tell those apart**, |
| 162 | // which is why this one has four. |
| 163 | t.Run("the cap counts the ruled set", func(t *testing.T) { |
| 164 | s := eircodeCorpus(t, &perItemCustody{ |
| 165 | apps: roster, |
| 166 | give: map[string]suite.Authorization{"d": suite.NOT_VISIBLE}, |
| 167 | }, [][3]string{ |
| 168 | {"a", "Office A", "D02 X285"}, |
| 169 | {"b", "Office B", "A65 F4E2"}, |
| 170 | {"c", "Office C", "D6W 1234"}, |
| 171 | {"d", "Office D", "T12 XY45"}, |
| 172 | }) |
| 173 | if got := spans(searchFacts(t, s, "eircode")); len(got) != 3 { |
| 174 | t.Fatalf("spans = %q, want three", got) |
| 175 | } |
| 176 | }) |
| 177 | |
| 178 | // 5. EVERY FIELD OF THE CITATION IS THE SURVIVOR'S. The SAME eircode |
| 179 | // in a visible and a withheld document: one candidate, and all five |
| 180 | // of its fields from the visible one. |
| 181 | // |
| 182 | // **Asserting Title alone was not enough**, and a review mutation |
| 183 | // proved it: taking Title from the ruled result and DocumentID, |
| 184 | // Ordinal, Label and Trail from the first UNRULED occurrence passed |
| 185 | // this case, and would have rendered an href to a document the |
| 186 | // reader may not open, under the withheld document's own heading and |
| 187 | // label text. The two bodies were identical, so nothing could tell |
| 188 | // the fields apart. They differ now, and §10 says assert the tuple. |
| 189 | t.Run("every field of the citation is the visible passage's", func(t *testing.T) { |
| 190 | cus := &perItemCustody{ |
| 191 | apps: roster, |
| 192 | give: map[string]suite.Authorization{"hidden": suite.NOT_VISIBLE}, |
| 193 | } |
| 194 | s := storeForTest(t) |
| 195 | s.custody = cus |
| 196 | ctx := context.Background() |
| 197 | // The withheld one is ingested first AND carries the query's |
| 198 | // words three times, so it ranks above the visible one: if any |
| 199 | // field came from the hits rather than the results, it would be |
| 200 | // this document's. |
| 201 | hidden, err := s.Ingest(ctx, IngestSpec{ |
| 202 | Source: "Docs", Key: "hidden", Title: "Hidden office", |
| 203 | Body: "## Hidden trail\n\nHidden eircode eircode eircode label: D02 X285", |
| 204 | ItemID: "hidden", SiblingRevision: 1, |
| 205 | }) |
| 206 | if err != nil { |
| 207 | t.Fatalf("ingest hidden: %v", err) |
| 208 | } |
| 209 | shown, err := s.Ingest(ctx, IngestSpec{ |
| 210 | Source: "Docs", Key: "shown", Title: "Visible office", |
| 211 | Body: "## Contact\n\nEircode: D02 X285", |
| 212 | ItemID: "shown", SiblingRevision: 1, |
| 213 | }) |
| 214 | if err != nil { |
| 215 | t.Fatalf("ingest shown: %v", err) |
| 216 | } |
| 217 | if hidden == shown { |
| 218 | t.Fatal("the two documents share an id; this fixture cannot be wrong about anything") |
| 219 | } |
| 220 | p := searchFacts(t, s, "eircode") |
| 221 | if len(p.Facts) != 1 { |
| 222 | t.Fatalf("facts = %+v, want one", p.Facts) |
| 223 | } |
| 224 | got := p.Facts[0] |
| 225 | for _, c := range []struct{ name, got, want string }{ |
| 226 | {"title", got.Title, "Visible office"}, |
| 227 | {"trail", got.Trail, "Contact"}, |
| 228 | {"label", got.Label, "Eircode"}, |
| 229 | } { |
| 230 | if c.got != c.want { |
| 231 | t.Errorf("%s = %q, want %q: the box took a field from the passage this reader may not read", c.name, c.got, c.want) |
| 232 | } |
| 233 | } |
| 234 | if got.DocumentID != shown { |
| 235 | t.Errorf("citation links to document %d, want %d: the href points at the withheld document", got.DocumentID, shown) |
| 236 | } |
| 237 | // The fifth field, which the comment above claimed and the |
| 238 | // assertions did not make. Both documents here are single-chunk |
| 239 | // so it cannot currently differ — asserted anyway, because the |
| 240 | // claim should be true and the next fixture may have two. |
| 241 | if got.Ordinal != 0 { |
| 242 | t.Errorf("ordinal = %d, want 0", got.Ordinal) |
| 243 | } |
| 244 | }) |
| 245 | |
| 246 | // 7. A WITHHELD PASSAGE CANNOT SILENCE A KIND. §8.8's rule 4 — a |
| 247 | // document-authored label too long to show renders no box for that |
| 248 | // kind — is a count like the cap and the collision check, and it has |
| 249 | // to happen after the ruling for the same reason. Computed over the |
| 250 | // unruled set, a document the reader may not see takes the box away |
| 251 | // from them, which reports that document's existence. |
| 252 | // |
| 253 | // **No other fixture here has a NoBox candidate at all**, so a |
| 254 | // mutation OR-ing `silenced` from the unruled set passed all six of |
| 255 | // them. Found by review, 2026-09-14. |
| 256 | t.Run("a withheld over-long label does not silence the kind", func(t *testing.T) { |
| 257 | cus := &perItemCustody{ |
| 258 | apps: roster, |
| 259 | give: map[string]suite.Authorization{"hidden": suite.NOT_VISIBLE}, |
| 260 | } |
| 261 | s := storeForTest(t) |
| 262 | s.custody = cus |
| 263 | ctx := context.Background() |
| 264 | for _, d := range [][3]string{ |
| 265 | // Over sixty characters, and carrying the query's word so it |
| 266 | // is actually retrieved — the first version of this fixture |
| 267 | // was green because nothing ever reached the box. |
| 268 | {"hidden", "Hidden office", "The eircode you must never give to any customer or supplier is: T12 XY45"}, |
| 269 | {"shown", "Visible office", "Eircode: D02 X285"}, |
| 270 | } { |
| 271 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 272 | Source: "Docs", Key: d[0], Title: d[1], |
| 273 | Body: "## Contact\n\n" + d[2], |
| 274 | ItemID: d[0], SiblingRevision: 1, |
| 275 | }); err != nil { |
| 276 | t.Fatalf("ingest %s: %v", d[0], err) |
| 277 | } |
| 278 | } |
| 279 | if got := spans(searchFacts(t, s, "eircode")); len(got) != 1 || got[0] != "D02 X285" { |
| 280 | t.Fatalf("spans = %q, want the visible one boxed", got) |
| 281 | } |
| 282 | }) |
| 283 | |
| 284 | // 6. COLLISION COUNTS AFTER THE RULING TOO. Two documents sharing a |
| 285 | // title and a trail, both falling to the trail for a label: over the |
| 286 | // unruled set they collide and the box vanishes, which is the same |
| 287 | // leak as case 4 wearing the collision rule's coat. |
| 288 | t.Run("collision counts the ruled set", func(t *testing.T) { |
| 289 | cus := &perItemCustody{ |
| 290 | apps: roster, |
| 291 | give: map[string]suite.Authorization{"twin-b": suite.NOT_VISIBLE}, |
| 292 | } |
| 293 | s := storeForTest(t) |
| 294 | s.custody = cus |
| 295 | ctx := context.Background() |
| 296 | for i, d := range [][2]string{{"twin-a", "D02 X285"}, {"twin-b", "A65 F4E2"}} { |
| 297 | if _, err := s.Ingest(ctx, IngestSpec{ |
| 298 | Source: "Docs", Key: d[0], Title: "Offices", |
| 299 | // Mid-sentence and with no separator before it, so the |
| 300 | // label falls to the heading trail and the two rows |
| 301 | // would be identical but for their values. |
| 302 | Body: "## Contact\n\nThe eircode " + d[1] + " reaches us.", |
| 303 | ItemID: d[0], |
| 304 | SiblingRevision: int64(i) + 1, |
| 305 | }); err != nil { |
| 306 | t.Fatalf("ingest %s: %v", d[0], err) |
| 307 | } |
| 308 | } |
| 309 | if got := spans(searchFacts(t, s, "eircode")); len(got) != 1 { |
| 310 | t.Fatalf("spans = %q, want the one survivor boxed", got) |
| 311 | } |
| 312 | }) |
| 313 | } |
| 314 | |