oficina / memoria Public

Fact box narrowing: answer the second review (GPT-6 Astra)

Astra's second pass found the spec faithful to Paul's ruling and no
regression in authorization or model labels. It asked for stronger
guards, and flagged one placement bug.

Placement: the narrowing line rendered after every row, so in a
two-kind box it sat under the wrong kind. The note now travels on its
row (Fact.Note, replacing Page.FactNotes) and renders directly under
it. The row divider still draws after a note.

Guards: gate 40 asserts the narrowed row's whole tuple and the note's
every field, and gains fixtures for:
- a short and an over-long matching label (two matches);
- a value over-long-labelled in two documents (one candidate);
- an authored label identical to its heading;
- five candidates;
- email as a second kind.

The page test checks the narrowed row's citation. A new page test pins
the note between its own row and the next kind's. Fact tests add the
mid-line hyphen ("Never use - Office"), the + marker, and generic
query words.

Astra's twelve surviving mutations and two placement mutations: all
fourteen built and red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 15086a52a0c144959da9ec5f4da2ab80036817e6 parent 45675e2
7 files changed, +182 −34
  • internal/memoria/fact/fact_test.go +13 −0
  • internal/memoria/handlers.go +0 −3
  • internal/memoria/narrow_test.go +120 −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 ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	3.445s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	0.007s
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.533s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	0.036s
ok  	amadan.net/oficina/memoria/internal/memoria/notion	0.022s
ok  	amadan.net/oficina/memoria/internal/memoria/provider	1.132s
ok  	amadan.net/oficina/memoria/internal/memoria/query	0.004s
ok  	amadan.net/oficina/memoria/internal/memoria/seal	0.005s
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	3.021s
ok  	amadan.net/oficina/memoria/internal/memoriatest	1.692s
?   	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/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 8d82ea0..bc9e64f 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -170,8 +170,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"
@@ -665,7 +663,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..b1dcf58 100644
--- a/internal/memoria/narrow_test.go
+++ b/internal/memoria/narrow_test.go
@@ -61,25 +61,53 @@ 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"
+//
// 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 +121,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 +151,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 +189,52 @@ 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"))
+ })
+
+ 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 +252,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 +267,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)
}
}