oficina / memoria Public

Review 2 on the store: the ninth result, TensorX named, and gate 39's gaps

Reviewed by Codex GPT-6-Astra (dispatched: codex/gpt-6-astra; self-
reported "GPT-6 (Codex)"). REQUEST CHANGES, four must-fix.

**A deleted chunk let result nine in.** The sent set counted surviving
chunks toward eight, so a chunk deleted between the ruling and the read
widened the payload: the review deleted result one and watched 2-9 go
out, not 2-8. §8.9 excludes the ninth result by name, and it could then
supply a row, fill the cap, collide or silence the kind. The set is now
bounded by rank first and thinned, never refilled; reproduced as a
fixture exactly as the review did it.

**Model rows named the model and not the provider.** "deepseek/…"
names DeepSeek's weights and never TensorX, the party that received the
passages — decision 4 names the provider. Fact carries both now and the
row renders "🤖 TensorX · deepseek/deepseek-v4-flash-0731", asserted in
the rendered fragment and seen in drive.

**Six cases still passed with extraction switched off entirely** — the
five short/empty/mid-word/embedded refusals and the cancellation log
case. I had fixed three vacuous cases in the previous round and missed
these, which were the same failure in a different loop. Each now asserts
the call and the sent passage before asserting refusal or silence;
switching extraction off fails 31 subtests where it used to leave six
green.

**Fifteen mutations of modelFacts survived gate 39**, and each now has
the fixture that sees it, each re-run and red: the last eight sent
instead of the first (a count cannot see which eight — the recorded
passages are now checked against the page's own ranking, in order);
passage priority reversed; dedupe off; the cap at >= (nothing showed
three rendering); ordinals zeroed (every fixture was one passage); text
accepted from a passage the model did not name; the negative-index
guard; the cap applied to raw proposals; an over-long label checked
before dedupe; the end boundary alone; collision by label alone; the
digit/comma/newline requirement; context.Background to the provider;
the accepted span logged on success; and a span exactly equal to a
recognised fact.

**Disclosure.** The README now says "up to eight passages per search
request" and that live searches while typing count — each debounced
pause is its own request, which the review observed as three calls
without a submit. §8.3 now names all three triggers and says only the
third, this one, exists; §8.9 no longer says "not built", says what does
keep content in the instance today (the switch, asserted by gate 39),
and records the rank-first bound and the per-request cost of live
search. The plan no longer lists a mutation that cannot be caught.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 1b01bed0a4ea2bab0ebacf069f1f336156c00582 parent e60af04
6 files changed, +439 −37
  • MODEL-FALLBACK-PLAN.md +7 −2
  • README.md +9 −6
  • docs/superpowers/specs/2026-09-03-memoria-design.md +46 −17
  • internal/memoria/modelfacts_test.go +354 −4
  • internal/memoria/store.go +22 −7
  • internal/memoria/templates/results.html +1 −1

CI failed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
--- FAIL: TestCreditsMatchProductionDependencies (0.03s)
    credits_inventory_test.go:36: production dependency graph unavailable exit status 1
FAIL
FAIL	amadan.net/oficina/memoria/internal/memoria	1.027s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	(cached)
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.510s
?   	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	0.862s
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.568s
?   	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]
FAIL
make: *** [Makefile:62: test] Error 1

amadan: step test failed: exit status 2

amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/MODEL-FALLBACK-PLAN.md b/MODEL-FALLBACK-PLAN.md
index 1c7a145..77c964b 100644
--- a/MODEL-FALLBACK-PLAN.md
+++ b/MODEL-FALLBACK-PLAN.md
@@ -142,11 +142,16 @@ would soften it.
**39**, as §8.9 specifies, against an `httptest` stub provider:
presence (span in the passage → renders, document bytes, document label,
🤖); a span in no passage → no box and not logged; a span that is only in
-a withheld passage → no box; **the model's text rendered instead of the
-sliced span → red**; and **the deterministic-first ordering: a validated
+a withheld passage → no box; the full citation tuple and the recorded
+request contents; and **the deterministic-first ordering: a validated
kind never calls the provider at all**, asserted by counting requests at
the stub, not by reading the page.
+*(An earlier version of this line listed "the model's text rendered
+instead of the sliced span → red" as a mutation to catch. It cannot be
+caught and does not matter: for an exact match the bytes are identical.
+What matters is the coordinate, which the tuple assertions see.)*
+
Plus: AI off → the stub receives zero requests for every query (the
decision-5 floor, asserted as absence-of-traffic with a presence half
where AI on does call it); startup refusals for the config; the response
diff --git a/README.md b/README.md
index 9eb5974..fcbe278 100644
--- a/README.md
+++ b/README.md
@@ -17,12 +17,15 @@ With it off — the default — the trust claim is as strong as it gets:
**With `MEMORIA_AI=on`, one thing changes, and this is all of it.** When
a search asks for a postal address — "office address", "postal address" —
memoria first looks for one with its own checks. Addresses have no
-checksum, so those checks cannot find one. It then sends **the top eight
-passages that search returned** — passages you were already allowed to
-read, in this search — to **TensorX**, the one named provider, which
-points at any addresses in them. memoria shows only text it finds
-byte-for-byte in those passages, marks each such row 🤖 with the model's
-name, and never shows the model's own words. Nothing else is sent: not
+checksum, so those checks cannot find one. It then sends **up to eight
+passages per search request** — the top eight that search returned,
+passages you were already allowed to read — to **TensorX**, the one named
+provider, which points at any addresses in them. **That includes the live
+searches that run while you type**, not only the one you submit: each
+pause in typing a query that asks for an address is its own request.
+memoria shows only text it finds byte-for-byte in those passages, marks
+each such row 🤖 with TensorX and the model's name, and never shows the
+model's own words. Nothing else is sent: not
the rest of your documents, not your query's other results, and nothing
at all for phone numbers, eircodes, IBANs, VAT numbers or email
addresses, which memoria checks itself. TensorX can read what it
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index fa93655..8580bd6 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -2556,6 +2556,15 @@ the subprocessor point cuts both ways since it runs on Azure. It loses on
jurisdiction, on open weights, and on cost. Written that way the choice
stands; written as "TensorX wins on decision 4" it would be false.
+**Three things send content to it, and only the third exists today.**
+Embeddings at ingest — every chunk, once (§7.2, not built). Answers — the
+passages behind a generated answer (§8.2, not built). And **§8.9's
+metadata extraction** — up to eight ruled passages of a search request
+that asks for a kind with no validator, a postal address, whether or not
+an answer would follow and including live searches while typing. The
+third was built first, on 2026-09-27, and the README discloses it alone
+because it is the only one the binary performs.
+
**What it does not change.** This is still whole-corpus egress at ingest,
and §3.2's leak list is unaltered: a provider that sees the corpus sees the
corpus, whatever its jurisdiction. EU jurisdiction and zero retention
@@ -3497,27 +3506,44 @@ regardless; §8.8 says so and this does not change it.
**With AI off, nothing in this section happens** and §8.8 is unchanged.
Decision 5's floor is the search page and the deterministic box.
-**Not built. This is slice 2**, because §11 sequences every provider call
-there. Draft 1 said gate 31 enforced that; it does not — gate 31 forbids
-logging a provider's response body and says nothing about when a provider
-may be called. The sequencing is §11's, and until this section is built
-the claim is enforced only by there being no provider client in the
-binary.
+**Built ahead of the rest of slice 2**, on `feat/model-fallback`
+(2026-09-27): the provider client, the `MEMORIA_AI` switch, this
+section's extraction path and gate 39 — and nothing of the embedding
+half. §11 sequences provider calls into slice 2; this is the first of
+them to land. Draft 1 said gate 31 enforced the sequencing; it did not —
+gate 31 forbids logging a provider's response body and says nothing about
+when a provider may be called. What keeps content in the instance today
+is the switch: with `MEMORIA_AI` unset no extractor is wired and gate 39
+asserts, with its presence half, that no request is made.
#### What is sent
-**The top eight ruled results, by rank** — the passages §4.1 allowed in
-this request, bounded at eight to match §8.6's answer-time budget. Not
-the twenty-five §8.8 scans: an address in result eleven is a recall limit
-this section accepts in exchange for a bounded payload and a response
-inside the deadline. Each passage is sent with an index, and nothing else
-about it — no title, no path, no id.
+**Of the top eight ruled results, those still present, by rank** — the
+passages §4.1 allowed in this request, bounded at eight to match §8.6's
+answer-time budget. **Bounded by rank first and then thinned, never
+refilled**: a chunk deleted between the ruling and the read makes the
+payload smaller; it does not let result nine in. (The first build counted
+survivors instead, and a review deleted result one and watched 2-9 go
+out.) Not the twenty-five §8.8 scans: an address in result eleven is a
+recall limit this section accepts in exchange for a bounded payload and a
+response inside the deadline. Each passage is sent with an index, and
+nothing else about it — no title, no path, no id.
+
+**Per request, and that includes live search.** `GET /search` renders the
+results region on each pause in typing and runs this path like the full
+page does, so a query that asks for an address sends passages on every
+debounced keystroke that reaches the server. The request context bounds
+it — `search.js` aborts a superseded fetch and the call is cancelled with
+it — but it is still a request per pause, and the README says so.
**Egress is the existing category with a new trigger**, and §3.2 now says
both halves: the same recipient and the same kind of transmission as the
answer path, but sent when a query names a model kind whether or not a
-generated answer would follow. The disclosure copy names this trigger
-alongside the answer path.
+generated answer would follow. **The README names it now**, because
+unlike the answer path it exists: it says what is sent, to whom, when,
+and that live searches count. §3.6 is the full assistant's paragraph and
+records why it is not yet the README's; §8.3 names this trigger beside
+embeddings and answers.
**If decision 16 lands** — TensorX as zero-data-retention, treated as
equivalent to local inference, proposed on `design/zdr-treat-as-local` —
@@ -3589,9 +3615,12 @@ of a model row has to be told:
silent omission.
**So a model row never inherits the deterministic layer's claim.** It is
-marked 🤖, names the model that produced it (decision 4: every answer
-names its provider), and is visually distinct from a row a checksum
-vouched for. A reader must be able to tell "validated" from "a model
+marked 🤖 and names **both the provider and the model** — "TensorX ·
+deepseek/deepseek-v4-flash-0731" — and is visually distinct from a row a
+checksum vouched for. Both, because decision 4 names the *provider*: a
+row naming only the model names DeepSeek's weights and never the company
+that received the passages. (Draft 2 said "names the model" while citing
+the requirement to name the provider; a review caught the substitution.) A reader must be able to tell "validated" from "a model
pointed here, and the text really is in the document" at a glance.
**What the checks do guarantee, narrowly:** every accepted value is a
diff --git a/internal/memoria/modelfacts_test.go b/internal/memoria/modelfacts_test.go
index b8112fd..98bb810 100644
--- a/internal/memoria/modelfacts_test.go
+++ b/internal/memoria/modelfacts_test.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "net/http/httptest"
"strings"
"testing"
@@ -92,7 +93,7 @@ func TestGate39TheModelHalf(t *testing.T) {
}
got := rows[0]
want := Fact{Kind: "address", Title: "Tito office", Trail: "Contact", Label: "Office address",
- Span: addr, DocumentID: id, Ordinal: 0, Model: provider.Model}
+ Span: addr, DocumentID: id, Ordinal: 0, Provider: provider.Name, Model: provider.Model}
if got != want {
t.Errorf("row = %+v\nwant %+v", got, want)
}
@@ -119,6 +120,18 @@ func TestGate39TheModelHalf(t *testing.T) {
if len(rec.calls) != 1 || len(rec.calls[0]) != ModelPassages {
t.Fatalf("sent %d calls of %v passages; want one call of %d", len(rec.calls), lens(rec.calls), ModelPassages)
}
+ // **Which eight, and in which order** — against the page's own
+ // ranking, which Search produced before modelFacts ran. A count
+ // alone passed a review's "send the LAST eight" mutation.
+ for i := 0; i < ModelPassages; i++ {
+ want := p.Results[i].Title // "Office N", whose body names "N+1 Main Street"
+ var n int
+ fmt.Sscanf(want, "Office %d", &n)
+ addr := fmt.Sprintf("%d Main Street", n+1)
+ if !strings.Contains(rec.calls[0][i], addr) {
+ t.Errorf("sent passage %d is not rank %d's (%s): %q", i, i, want, rec.calls[0][i])
+ }
+ }
})
t.Run("a span in no passage renders nothing, and the refusal is ours", func(t *testing.T) {
@@ -254,8 +267,17 @@ func TestGate39TheModelHalf(t *testing.T) {
t.Run(c.name, func(t *testing.T) {
s := storeForTest(t)
upload(t, s, "Tito office", "## Contact\n\nOffice address: 12 Main Street, Dublin 2. Mail info.12-main-street@tito.io")
- s.extractor = &recorder{reply: point(0, c.model)}
- if rows := modelRows(searchFacts(t, s, "office address")); len(rows) != 0 {
+ rec := &recorder{reply: point(0, c.model)}
+ s.extractor = rec
+ rows := modelRows(searchFacts(t, s, "office address"))
+ // **Delivered first.** A review switched extraction off
+ // entirely and all five of these stayed green: no call, no
+ // rows, "refused". The refusal has to be memoria's, after
+ // the model answered.
+ if len(rec.calls) != 1 || !sentContains(rec.calls, "12 Main Street") {
+ t.Fatalf("calls = %q: the model was never asked, so this proves nothing", rec.calls)
+ }
+ if len(rows) != 0 {
t.Errorf("accepted %q: %+v", c.model, rows)
}
})
@@ -455,8 +477,12 @@ func TestAModelFailureLogsNoContent(t *testing.T) {
var buf bytes.Buffer
s.logger = slog.New(slog.NewTextHandler(&buf, nil))
upload(t, s, "Tito office", "## Contact\n\nOffice address: 12 Main Street, Dublin 2")
- s.extractor = &recorder{reply: func([]string) ([]provider.Pointer, error) { return nil, context.Canceled }}
+ rec := &recorder{reply: func([]string) ([]provider.Pointer, error) { return nil, context.Canceled }}
+ s.extractor = rec
searchFacts(t, s, "office address")
+ if len(rec.calls) != 1 {
+ t.Fatal("the model was never asked; silence here proves nothing")
+ }
if buf.Len() != 0 {
t.Errorf("logged %q for an abandoned keystroke", buf.String())
}
@@ -472,3 +498,327 @@ func lens(calls [][]string) []int {
}
var _ = errors.Is // keep errors imported for future cases
+
+// GATE 39, continued — what the first draft could not see.
+//
+// Each subtest below exists because a review (Codex GPT-6-Astra,
+// 2026-09-27) found a mutation of modelFacts that every case above let
+// through. The mutation is named in each; each was re-run against this
+// file and goes red.
+func TestGate39WhatTheFirstDraftCouldNotSee(t *testing.T) {
+ two := func(t *testing.T) (*Store, map[int64]string) {
+ // Two single-passage documents whose ranks are fixed by how often
+ // they say the query's words.
+ s := storeForTest(t)
+ ids := map[int64]string{}
+ ids[upload(t, s, "First office", "## Contact\n\nOffice address: 1 First Street, Cork. Our office address is the office address.")] = "1 First Street, Cork"
+ ids[upload(t, s, "Second office", "## Contact\n\nOffice address: 2 Second Street, Cork")] = "2 Second Street, Cork"
+ return s, ids
+ }
+
+ // Mutation: reverse passage priority in the sort. The earlier order
+ // fixture used one passage and could not see it.
+ t.Run("rows follow rank across passages, not the provider's order", func(t *testing.T) {
+ s, _ := two(t)
+ s.extractor = &recorder{reply: func(sent []string) ([]provider.Pointer, error) {
+ var out []provider.Pointer
+ for i := len(sent) - 1; i >= 0; i-- { // deliberately backwards
+ out = append(out, provider.Pointer{Passage: i, Text: sent[i][strings.Index(sent[i], ": ")+2 : strings.Index(sent[i], "Cork")+4]})
+ }
+ return out, nil
+ }}
+ p := searchFacts(t, s, "office address")
+ rows := modelRows(p)
+ if len(rows) != 2 {
+ t.Fatalf("rows = %+v", rows)
+ }
+ if rows[0].DocumentID != p.Results[0].DocumentID || rows[1].DocumentID != p.Results[1].DocumentID {
+ t.Errorf("row order %d,%d does not follow result order %d,%d",
+ rows[0].DocumentID, rows[1].DocumentID, p.Results[0].DocumentID, p.Results[1].DocumentID)
+ }
+ })
+
+ // Mutation: disable span dedupe. Same bytes in two passages are ONE
+ // candidate, cited to the better-ranked.
+ t.Run("the same span in two passages is one row, cited to the better-ranked", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "First office", "## Contact\n\nOffice address: 9 Shared Street, Cork. The office address again: office address.")
+ upload(t, s, "Second office", "## Contact\n\nOffice address: 9 Shared Street, Cork")
+ s.extractor = &recorder{reply: func(sent []string) ([]provider.Pointer, error) {
+ return []provider.Pointer{{Passage: 1, Text: "9 Shared Street, Cork"}, {Passage: 0, Text: "9 Shared Street, Cork"}}, nil
+ }}
+ p := searchFacts(t, s, "office address")
+ rows := modelRows(p)
+ if len(rows) != 1 || rows[0].DocumentID != p.Results[0].DocumentID {
+ t.Errorf("rows = %+v; want one, cited to result 0 (doc %d)", rows, p.Results[0].DocumentID)
+ }
+ })
+
+ // Mutation: cap written >= rather than >. The cap fixtures showed only
+ // that four renders none.
+ t.Run("three accepted addresses render three", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", "## Our addresses\n\nA: 1 First Street, Cork. B: 2 Second Street, Cork. C: 3 Third Street, Cork.")
+ s.extractor = &recorder{reply: func([]string) ([]provider.Pointer, error) {
+ return []provider.Pointer{{Passage: 0, Text: "1 First Street, Cork"}, {Passage: 0, Text: "2 Second Street, Cork"}, {Passage: 0, Text: "3 Third Street, Cork"}}, nil
+ }}
+ if rows := modelRows(searchFacts(t, s, "address")); len(rows) != 3 {
+ t.Errorf("rows = %d, want 3: three is under the cap", len(rows))
+ }
+ })
+
+ // Mutation: apply the cap to RAW proposals, rejected ones included.
+ t.Run("rejected proposals do not count toward the cap", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", "## Our addresses\n\nA: 1 First Street, Cork. B: 2 Second Street, Cork. C: 3 Third Street, Cork.")
+ s.extractor = &recorder{reply: func([]string) ([]provider.Pointer, error) {
+ return []provider.Pointer{
+ {Passage: 0, Text: "1 First Street, Cork"}, {Passage: 0, Text: "2 Second Street, Cork"}, {Passage: 0, Text: "3 Third Street, Cork"},
+ {Passage: 0, Text: "99 Invented Road, Galway"}, {Passage: 5, Text: "1 First Street, Cork"}, // both dropped
+ }, nil
+ }}
+ if rows := modelRows(searchFacts(t, s, "address")); len(rows) != 3 {
+ t.Errorf("rows = %d, want 3: two of five proposals were refused", len(rows))
+ }
+ })
+
+ // Mutation: set every citation's ordinal to zero. Every earlier
+ // fixture was a single-passage document.
+ t.Run("the citation names the passage's own ordinal", func(t *testing.T) {
+ s := storeForTest(t)
+ intro := "## About\n\n" + strings.Repeat("We are a small office team that works on software. ", 12)
+ upload(t, s, "Tito office", intro+"\n\n## Visiting\n\nOffice address: 12 Main Street, Dublin 2")
+ s.extractor = &recorder{reply: func(sent []string) ([]provider.Pointer, error) {
+ for i, p := range sent {
+ if strings.Contains(p, "12 Main Street") {
+ return []provider.Pointer{{Passage: i, Text: "12 Main Street, Dublin 2"}}, nil
+ }
+ }
+ return nil, nil
+ }}
+ p := searchFacts(t, s, "office address")
+ var want int = -1
+ for _, r := range p.Results {
+ if strings.Contains(r.Heading, "Visiting") {
+ want = r.Ordinal
+ }
+ }
+ if want < 1 {
+ t.Fatalf("the address passage is ordinal %d; this fixture needs it past 0", want)
+ }
+ if rows := modelRows(p); len(rows) != 1 || rows[0].Ordinal != want {
+ t.Errorf("rows = %+v; want ordinal %d", rows, want)
+ }
+ })
+
+ // Mutation: when text is absent from its claimed passage, look in the
+ // other sent passages and accept it there.
+ t.Run("a span in a different passage from the one claimed is refused", func(t *testing.T) {
+ s, _ := two(t)
+ rec := &recorder{reply: func(sent []string) ([]provider.Pointer, error) {
+ // Claim passage 0 for the text that is only in passage 1.
+ other := sent[1][strings.Index(sent[1], ": ")+2 : strings.Index(sent[1], "Cork")+4]
+ return []provider.Pointer{{Passage: 0, Text: other}}, nil
+ }}
+ s.extractor = rec
+ rows := modelRows(searchFacts(t, s, "office address"))
+ if len(rec.calls) != 1 || len(rec.calls[0]) < 2 {
+ t.Fatal("fewer than two passages were sent; this proves nothing")
+ }
+ if len(rows) != 0 {
+ t.Errorf("accepted text from a passage the model did not name: %+v", rows)
+ }
+ })
+
+ // Mutation: remove the negative-index guard (it panics without it).
+ t.Run("a negative passage index is refused", func(t *testing.T) {
+ s, _ := two(t)
+ s.extractor = &recorder{reply: point(-1, "1 First Street, Cork")}
+ if rows := modelRows(searchFacts(t, s, "office address")); len(rows) != 0 {
+ t.Errorf("rows = %+v", rows)
+ }
+ })
+
+ // Mutation: check over-long labels BEFORE dedupe. Only the occurrence
+ // actually cited decides whether the kind renders (§8.8's order).
+ t.Run("a later occurrence's over-long label does not silence the cited one", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "First office", "## Contact\n\nOffice address: 7 Quay Street, Galway. The office address again: office address.")
+ upload(t, s, "Second office", "## Contact\n\nThe office address you must never give to any customer or supplier at all: 7 Quay Street, Galway")
+ s.extractor = &recorder{reply: func([]string) ([]provider.Pointer, error) {
+ return []provider.Pointer{{Passage: 0, Text: "7 Quay Street, Galway"}, {Passage: 1, Text: "7 Quay Street, Galway"}}, nil
+ }}
+ p := searchFacts(t, s, "office address")
+ if len(p.Results) < 2 || !strings.Contains(p.Results[0].Title, "First") {
+ t.Fatal("the short-labelled document did not rank first; this fixture proves nothing")
+ }
+ if rows := modelRows(p); len(rows) != 1 || rows[0].Label != "Office address" {
+ t.Errorf("rows = %+v; the cited occurrence's label is fine", rows)
+ }
+ })
+
+ // Mutation: remove only the END word-boundary check.
+ t.Run("a span ending mid-word is refused", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", "## Contact\n\nOffice address: 12 Main Streetside, Dublin 2")
+ rec := &recorder{reply: point(0, "12 Main Street")}
+ s.extractor = rec
+ rows := modelRows(searchFacts(t, s, "office address"))
+ if len(rec.calls) != 1 {
+ t.Fatal("never asked")
+ }
+ if len(rows) != 0 {
+ t.Errorf("accepted a span ending inside a word: %+v", rows)
+ }
+ })
+
+ // Mutation: collide on label alone. Different titles disambiguate.
+ t.Run("the same label under different titles renders both", func(t *testing.T) {
+ s, _ := two(t)
+ s.extractor = &recorder{reply: func(sent []string) ([]provider.Pointer, error) {
+ var out []provider.Pointer
+ for i, p := range sent {
+ out = append(out, provider.Pointer{Passage: i, Text: p[strings.Index(p, ": ")+2 : strings.Index(p, "Cork")+4]})
+ }
+ return out, nil
+ }}
+ if rows := modelRows(searchFacts(t, s, "office address")); len(rows) != 2 {
+ t.Errorf("rows = %d, want 2: the titles tell them apart", len(rows))
+ }
+ })
+
+ // Mutation: keep the 12-byte minimum, drop the digit/comma/newline
+ // requirement.
+ t.Run("a long span with no digit, comma or line break is refused", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", "## Contact\n\nOffice address: Rose Cottage Lane near the harbour")
+ rec := &recorder{reply: point(0, "Rose Cottage Lane")}
+ s.extractor = rec
+ rows := modelRows(searchFacts(t, s, "office address"))
+ if len(rec.calls) != 1 {
+ t.Fatal("never asked")
+ }
+ if len(rows) != 0 {
+ t.Errorf("accepted %+v", rows)
+ }
+ })
+
+ // Mutation: reject only STRICTLY interior recognised spans, letting a
+ // span exactly equal to one through.
+ t.Run("a span exactly equal to a recognised fact is refused", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", "## Contact\n\nOffice address: write to info.12-main@tito.io today")
+ rec := &recorder{reply: point(0, "info.12-main@tito.io")}
+ s.extractor = rec
+ rows := modelRows(searchFacts(t, s, "office address"))
+ if len(rec.calls) != 1 {
+ t.Fatal("never asked")
+ }
+ if len(rows) != 0 {
+ t.Errorf("accepted an email address as a postal one: %+v", rows)
+ }
+ })
+
+ // Mutation: pass context.Background() to the extractor. The request's
+ // context must reach it, or an abandoned keystroke is not cancelled.
+ t.Run("the request's context reaches the provider", func(t *testing.T) {
+ s := storeForTest(t)
+ upload(t, s, "Tito office", "## Contact\n\nOffice address: 12 Main Street, Dublin 2")
+ type key struct{}
+ var saw any
+ s.extractor = ctxRecorder(func(ctx context.Context) { saw = ctx.Value(key{}) })
+ ctx := context.WithValue(context.Background(), key{}, "the request")
+ if _, err := s.Search(ctx, suite.Subject{Kind: suite.SubjectAddress, Value: "paul@tito.io"}, "office address", 25); err != nil {
+ t.Fatal(err)
+ }
+ if saw != "the request" {
+ t.Errorf("the provider saw a different context (value %v)", saw)
+ }
+ })
+
+ // Mutation: log the accepted proposal's text on success. Gate 4: no
+ // content in any log line, and success is a path too.
+ t.Run("a successful extraction logs no content", func(t *testing.T) {
+ s := storeForTest(t)
+ var buf bytes.Buffer
+ s.logger = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ upload(t, s, "Tito office", "## Contact\n\nOffice address: 12 Main Street, Dublin 2")
+ s.extractor = &recorder{reply: point(0, "12 Main Street, Dublin 2")}
+ if rows := modelRows(searchFacts(t, s, "office address")); len(rows) != 1 {
+ t.Fatal("no row; the success path did not run")
+ }
+ if strings.Contains(buf.String(), "Main Street") {
+ t.Errorf("the log carries content: %q", buf.String())
+ }
+ })
+
+ // The sent set is bounded by RANK and then thinned, never refilled.
+ // The first build counted surviving chunks, so a chunk deleted between
+ // the ruling and the read let result nine in. Reproduced as the review
+ // did: rule a page, delete result one's document, then run addFacts on
+ // the already-ruled page.
+ t.Run("a chunk deleted after the ruling does not let result nine in", func(t *testing.T) {
+ s := storeForTest(t)
+ addrOf := map[int64]string{}
+ for i := 0; i < 10; i++ {
+ a := fmt.Sprintf("%d Main Street, Dublin 2", i+1)
+ addrOf[upload(t, s, fmt.Sprintf("Office %d", i), "## Contact\n\nOffice address: "+a)] = a
+ }
+ ruled := searchFacts(t, s, "office address") // extractor nil: no call
+ if len(ruled.Results) < 10 {
+ t.Fatalf("only %d results", len(ruled.Results))
+ }
+ if err := s.Delete(context.Background(), ruled.Results[0].DocumentID); err != nil {
+ t.Fatal(err)
+ }
+ rec := &recorder{}
+ s.extractor = rec
+ page := Page{Results: ruled.Results}
+ s.addFacts(context.Background(), &page, "office address")
+ if len(rec.calls) != 1 {
+ t.Fatalf("calls = %d", len(rec.calls))
+ }
+ if got := len(rec.calls[0]); got != ModelPassages-1 {
+ t.Errorf("sent %d passages; want %d — results 2 to 8", got, ModelPassages-1)
+ }
+ ninth := addrOf[ruled.Results[ModelPassages].DocumentID]
+ if sentContains(rec.calls, ninth) {
+ t.Errorf("result nine (%q) was sent after result one went away", ninth)
+ }
+ })
+}
+
+// ctxRecorder is an Extractor that reports the context it was given.
+type ctxRecorder func(context.Context)
+
+func (f ctxRecorder) ExtractAddresses(ctx context.Context, _ []string) ([]provider.Pointer, error) {
+ f(ctx)
+ return []provider.Pointer{}, nil
+}
+
+// The provider is NAMED on a model row, not only the model — rendered,
+// because the review that found the omission found it in the page.
+// Decision 4 names the provider: "deepseek/…" alone names DeepSeek's
+// weights and never the party that received the passages.
+func TestAModelRowNamesTheProviderAndTheModel(t *testing.T) {
+ rec := httptest.NewRecorder()
+ renderFragment(rec, "results", page{
+ Searched: true, Query: "office address",
+ Results: []Result{{DocumentID: 1, Title: "Tito office", Snippet: "x"}},
+ Facts: []Fact{
+ {Kind: "address", Title: "Tito office", Label: "Office address", Span: "12 Main Street",
+ DocumentID: 1, Provider: provider.Name, Model: provider.Model},
+ {Kind: "eircode", Title: "Tito office", Label: "Eircode", Span: "D02 X285", DocumentID: 1},
+ },
+ })
+ body := rec.Body.String()
+ for _, want := range []string{"TensorX", provider.Model, "rst-fact-model"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the model row does not carry %q", want)
+ }
+ }
+ // And the validated row does not: exactly one row is marked.
+ if n := strings.Count(body, "rst-fact-model"); n != 1 {
+ t.Errorf("%d rows marked as model output, want 1", n)
+ }
+}
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 1759606..c068e2f 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -979,6 +979,12 @@ type Fact struct {
Span string
DocumentID int64
Ordinal int
+ // Provider names the party the passages were SENT to (decision 4),
+ // and Model names the weights that answered. Both, because a row
+ // naming only "deepseek/…" names DeepSeek's model and never the
+ // company that received the team's passages. Empty on a row the
+ // deterministic layer validated.
+ Provider string
// Model names the model that pointed at this span, and is empty for a
// row the deterministic layer validated. **The one field that tells a
// reader which kind of claim they are looking at**: §8.9 is explicit
@@ -1140,16 +1146,24 @@ type sentPassage struct {
// see, or it is dropped — and from the coordinate on, the span, the label
// and the citation all come from the DOCUMENT.
func (s *Store) modelFacts(ctx context.Context, results []Result, texts map[int64]string, k fact.Kind) []Fact {
- // What is sent: the top eight ruled results that are still there, in
- // rank order. A chunk gone since the ruling is not a passage; it is
- // skipped, as addFacts skips it, and the next result takes its place.
+ // What is sent: of the top eight ruled results, those still present,
+ // in rank order.
+ //
+ // **Bounded by RANK first, and then thinned — never refilled.** The
+ // first version counted surviving chunks instead, so a chunk deleted
+ // between the ruling and this read let result nine in: a review
+ // deleted result one and watched 2-9 go out, not 2-8. §8.9 excludes
+ // the ninth result by name, and it could then supply a row, fill the
+ // cap, collide or silence the kind. A gone passage makes the payload
+ // smaller, as it makes §8.8's page shorter; it does not reach further.
+ top := results
+ if len(top) > ModelPassages {
+ top = top[:ModelPassages]
+ }
var sent []sentPassage
- for _, r := range results {
+ for _, r := range top {
if t, ok := texts[r.ChunkID]; ok {
sent = append(sent, sentPassage{result: r, text: t})
- if len(sent) == ModelPassages {
- break
- }
}
}
if len(sent) == 0 {
@@ -1248,6 +1262,7 @@ func (s *Store) modelFacts(ctx context.Context, results []Result, texts map[int6
Span: span,
DocumentID: r.DocumentID,
Ordinal: r.Ordinal,
+ Provider: provider.Name,
Model: provider.Model,
})
}
diff --git a/internal/memoria/templates/results.html b/internal/memoria/templates/results.html
index 7067884..b45763c 100644
--- a/internal/memoria/templates/results.html
+++ b/internal/memoria/templates/results.html
@@ -56,7 +56,7 @@
<p rst-fact{{if .Model}} rst-fact-model{{end}}>
<span rst-fact-label>{{.Label}}</span>
<span rst-fact-value>{{.Span}}</span>
- {{if .Model}}<span rst-fact-by title="Found by a model, not validated">🤖 {{.Model}}</span>{{end}}
+ {{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>
{{end}}