Skip to content

Commit 25a93fd

Browse files
bench: add answer prompt v2 and cmd/replay for offline evaluation
Introduces answer prompt v2, which relaxes the abstention rule and resolves relative dates against source timestamps. This fixes two failures that cost ~0.10 answerable Judge at the oracle. Adds cmd/replay to re-answer questions from existing prediction dumps. Since the answer prompt cannot affect retrieval, replaying against a dump allows measuring prompt and model changes without a full re-run. - Registers v1 and v2 answer prompts for reproducibility. - Centralizes scoring logic in bench.Score to ensure parity between live runs, the oracle, and offline replays. - Updates RESULTS.md with the stage decomposition and v2 findings.
1 parent ec17d6e commit 25a93fd

17 files changed

Lines changed: 4939 additions & 97 deletions

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,32 @@ benchmark validating the retrieval and consolidation levers as a set) is the
1414
gate for cutting it — see `bench/RESULTS.md` and `PLAN-v2.md` §10.
1515

1616
### Added
17+
- **Temporal grounding: `Message.Timestamp` and `Fact.ObservedAt`.** A message
18+
can now carry when it was said, and the fact extracted from it records that
19+
time — distinct from `CreatedAt`, which stays ingestion time. The extractor's
20+
date arithmetic is anchored on the conversation's own timestamp instead of on
21+
today, so "I went yesterday" resolves against when it was said rather than when
22+
it was ingested. Both fields are optional and default to the previous behavior
23+
(fall back to the clock; `ObservedAt` stays zero), so no existing caller
24+
changes. SQLite stores get an `observed_at` column, added by an idempotent
25+
migration on `Open` — existing databases upgrade in place and their rows read
26+
back with a zero `ObservedAt`, which is the truth: their source time was never
27+
recorded.
28+
29+
`ObservedAt` is **say-time, not event-time**: for "I went yesterday" said on 8
30+
May it is 8 May, and the event's own date (7 May) is what the extractor resolves
31+
into the fact text. Do not answer "when did X happen" from the field directly.
32+
33+
Under `Consolidate`, an `UPDATE` takes the reconciling conversation's
34+
observation time; if that conversation carried no timestamp, the fact keeps the
35+
date it already had rather than being blanked — losing a known date is worse
36+
than not learning a new one. `Store.Update` enforces this, so no caller can
37+
erase a date by passing a partially-populated record.
38+
39+
This fixes a silent corruption. Without a grounding date, `gemini-2.5-flash-lite`
40+
wrote *"Caroline attended an LGBTQ support group on 2026-07-11"* — the ingestion
41+
date — for a May 2023 event, and nothing downstream could detect it. See
42+
`bench/RESULTS.md`.
1743
- **Consolidation write strategy** (`WithStrategy(Consolidate)`): a second LLM
1844
call per `Add` reconciles newly extracted facts against existing ones —
1945
ADD / UPDATE / DELETE / NONE — so a changed fact replaces the stale one
@@ -47,6 +73,18 @@ gate for cutting it — see `bench/RESULTS.md` and `PLAN-v2.md` §10.
4773
- **Transient-failure retries** in the OpenAI-compatible client: network errors,
4874
429/5xx, and empty/garbled 2xx bodies are retried with exponential backoff (up
4975
to 5 attempts), so one gateway blip no longer aborts a long ingestion or bench.
76+
- **Benchmark answer prompt v2** (`cmd/bench -answer-version`, now the default).
77+
The source oracle showed the answer stage lost more score than retrieval did:
78+
v1 abstained on questions whose answer sat verbatim in the evidence, and echoed
79+
relative dates ("last week") back instead of resolving them. v2 fixes both,
80+
worth +0.097 answerable Judge at the oracle. v1 stays registered so past runs
81+
remain reproducible, and an unknown version is now an error rather than a
82+
silent fallback to the default.
83+
- **`cmd/replay`**: re-runs the answer step of a finished run against a different
84+
answer prompt or answer model, reading its prediction dump and writing a new one
85+
for `cmd/rescore` to compare. The answer prompt cannot affect retrieval, so
86+
replaying a dump is exact, not approximate — an answer-stage change now costs
87+
one call per question instead of a full re-ingest.
5088

5189
### Changed
5290
- File-backed SQLite stores open in WAL mode for better concurrent-read behavior.
@@ -58,6 +96,11 @@ gate for cutting it — see `bench/RESULTS.md` and `PLAN-v2.md` §10.
5896
README config table, and `examples/basic` updated to match. See
5997
`bench/RESULTS.md`.
6098

99+
**Superseded.** Those figures predate the adversarial-scoring fix, and the
100+
recommendation itself no longer holds: flash's edge was almost entirely
101+
temporal date normalization, which `Message.Timestamp` now supplies to any
102+
extraction model for free. See `bench/RESULTS.md` for the current numbers.
103+
61104
### Fixed
62105
- Consolidation detects no-op `UPDATE`s against a target that was concurrently
63106
deleted, instead of reporting a phantom write.

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ func main() {
6363

6464
A runnable version is in [`examples/basic`](./examples/basic).
6565

66+
### Timestamp your messages
67+
68+
`Message.Timestamp` is optional, and setting it is the cheapest accuracy win
69+
available:
70+
71+
```go
72+
m.Add(ctx, []mneme.Message{
73+
{Role: "user", Content: "I went to the support group yesterday.",
74+
Timestamp: time.Date(2023, 5, 8, 13, 56, 0, 0, time.UTC)},
75+
}, scope)
76+
```
77+
78+
It anchors the extractor's date arithmetic on **when the conversation happened**
79+
rather than on when you ingested it, and it lands on the resulting fact as
80+
`Fact.ObservedAt`. Leave it out and the pipeline falls back to its clock, which
81+
means "yesterday" resolves against today — so a conversation ingested months late
82+
produces facts with confidently wrong dates that nothing downstream can detect.
83+
84+
`Fact.CreatedAt` is when the fact was written; `Fact.ObservedAt` is when it was
85+
said. They are different questions and the store now keeps both.
86+
6687
## Configuration (env)
6788

6889
`mneme.New()` builds providers from the environment (override any of them with

bench/RESULTS.md

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,64 @@ v2's behavior would have changed nothing upstream anyway (it cannot — retrieva
131131
answer prompt). And the adversarial delta was measured on the real pipeline dumps, not the
132132
oracle, because the oracle hands adversarial questions no evidence by construction.
133133

134+
## Temporal grounding: `ObservedAt` (the fix the section above pointed at)
135+
136+
The section above ended by saying facts throw away the source timestamp, and that giving them one
137+
was the highest-value memory change on the board. It was. `Message.Timestamp` now flows into
138+
`Fact.ObservedAt`, the extractor's OBSERVATION DATE is anchored on when the conversation happened
139+
rather than on today, and a dated fact renders as `(said on 8 May, 2023) …` to the answer model.
140+
141+
Full re-run, flash-lite extraction, answer prompt v2:
142+
143+
| run | answerable Judge | paired Δ vs baseline [95% CI] | abstains |
144+
|---|---|---|---|
145+
| additive · 3-small (v1 baseline) | 0.341 || 40.0% |
146+
| rawturns (v2) | 0.446 | +0.048 → see below | 30.6% |
147+
| additive · extract=flash (v1) | 0.398 | +0.057 [+0.024, +0.083] | 39.6% |
148+
| **additive · flash-lite + ObservedAt (v2)** | **0.426** | **+0.085 [+0.062, +0.109]** | **29.2%** |
149+
150+
**The cheap extractor now beats the expensive one.** `flash-lite + ObservedAt` scores 0.426 against
151+
`extract_flash`'s 0.398 — a real regression for flash (−0.028 [−0.052, −0.005] paired) at 5× the
152+
per-fact cost. Almost all of flash's edge was doing relative-date arithmetic correctly at extraction
153+
time. The source timestamp supplies that to *any* extraction model, for free, and more reliably.
154+
155+
The gain is where the mechanism predicts, and nowhere else:
156+
157+
| category | baseline | + ObservedAt |
158+
|---|---|---|
159+
| **temporal** | 0.237 | **0.533** |
160+
| single_hop | 0.426 | 0.476 |
161+
| open_domain | 0.094 | 0.125 |
162+
| multi_hop | 0.291 | 0.259 |
163+
164+
Temporal more than doubles, and clears flash extraction's 0.49 using the model flash extraction was
165+
brought in to replace. Note this is not the answer prompt doing the work: replaying v2 against the
166+
*untimestamped* additive dump was worth −0.004. v2 and `ObservedAt` are worth nothing apart and
167+
+0.085 together — the prompt supplies the rule, the record supplies the date, and neither is any use
168+
without the other.
169+
170+
### The hybrid store is now the measured conclusion, not a hypothesis
171+
172+
Under the same answer prompt, the fact store and raw turns have converged — and they win different
173+
categories:
174+
175+
| | rawturns (v2) | additive + ObservedAt (v2) |
176+
|---|---|---|
177+
| answerable | 0.446 | 0.426 (Δ +0.020 [−0.019, +0.051]**indistinguishable**) |
178+
| **single_hop** | **0.555** | 0.476 |
179+
| **temporal** | 0.414 | **0.533** |
180+
| multi_hop | 0.262 | 0.259 |
181+
| open_domain | 0.125 | 0.125 |
182+
183+
Raw turns keep the wording, so they win single-hop recall. Facts normalize the dates, so they win
184+
temporal. Neither dominates, the aggregate difference is noise, and the per-category split is a
185+
clean statement of what each representation is *for*. That is the hybrid store's case, made with
186+
numbers rather than argument: keep the episodes and the derived facts, retrieve over both, and
187+
expect roughly single-hop-from-episodes plus temporal-from-facts.
188+
189+
Two caveats. The `-0.03` multi-hop dip is within noise but consistent across both v2 runs, and is
190+
worth a look before v2 is called free. And every number here is still ten conversations.
191+
134192
## What this settles (measured, not guessed)
135193

136194
1. **The system abstains correctly: adversarial 0.96.** LoCoMo's adversarial questions are unanswerable traps, and mneme declines them almost every time. The answer prompt's explicit "say I don't know" instruction is doing its job. Worth keeping, but it is not a lever, and averaging it into a headline meant to guide work only obscures the rows that differ.
@@ -149,11 +207,11 @@ oracle, because the oracle hands adversarial questions no evidence by constructi
149207

150208
**Recommendation (v1 answer prompt): use `gemini-2.5-flash` for extraction, not flash-lite.** Roughly 5× the per-fact cost ($0.0017 vs $0.00034) for +0.057 [+0.026, +0.084] answerable Judge, most of it temporal. Boosters stay opt-in and undefaulted, pending a combined run and more conversations.
151209

152-
> **This recommendation does not survive answer prompt v2.** Almost all of flash's edge was
153-
> temporal date normalization, and v2 gets that from the source timestamp instead — for free, and
154-
> more reliably. Under v2, rawturns (0.451, no extraction model at all) beats flash extraction
155-
> (0.387). If you are paying for extraction, pay it for something the raw turn cannot give you.
156-
> See [The answer stage](#the-answer-stage-two-prompt-bugs-worth-010).
210+
> **Superseded — do not follow this.** Almost all of flash's edge was temporal date normalization,
211+
> which `Message.Timestamp`/`Fact.ObservedAt` now supply to any extraction model for free. Measured
212+
> head to head, **flash-lite + ObservedAt (0.426) beats flash extraction (0.398)** flash is now a
213+
> real regression at the per-fact cost. Use flash-lite and timestamp your messages. See
214+
> [Temporal grounding](#temporal-grounding-observedat-the-fix-the-section-above-pointed-at).
157215
158216
## Extraction-model A/B (eval fixtures)
159217

@@ -176,25 +234,23 @@ the first item in.
176234
1. ~~The answer stage, not extraction.~~ **Done: answer prompt v2**, worth +0.097 at the oracle
177235
and +0.063 on rawturns. It also raised the ceiling to 0.642, which *re-opens* memory headroom
178236
rather than closing it.
179-
2. **Persist source timestamps on facts.** This is now the highest-value memory change on the
180-
board, and it is small. A fact is text + hash + scope + ingest time, so the v2 date rule has
181-
nothing to bite on and flash-lite's ingest-date corruption has nothing to correct it. Give
182-
`types.Fact` an `ObservedAt` (the source turn's timestamp) and render it into the memory block
183-
the way rawturns already does. This is the cheap half of structured attribution — do it before
184-
subjects, predicates, and validity intervals.
185-
3. **A hybrid store, not a better extractor.** Raw turns beat extracted facts on aggregate, for
186-
free, and the gap *widened* under v2 (0.451 vs 0.387). Extraction still earns its keep only
187-
where it derives something the turn does not carry. Keep the episodes *and* the derived facts,
188-
and retrieve over both.
189-
4. **A full v2 re-run of the matrix.** Every row above is a v1 run. The rankings between rows are
190-
unlikely to move (v2 does not touch retrieval), but the headline numbers are now stale by
191-
construction and the boosters × flash combination is still unrun.
192-
5. **Extraction recall on implied and causal facts (tk #11)** drops further down. The raw-turn
193-
result keeps suggesting the fact representation itself, not its recall, is the weaker part.
194-
6. **open_domain (0.06–0.17) is the weakest category everywhere,** including the oracle, which
195-
manages 0.17 under v2 (up from 0.06). A category the ceiling itself cannot score is a
196-
question-format or judge problem, not a memory one — and the judge is still unvalidated
197-
against human labels, which is the one piece of the harness nothing else can backstop.
237+
2. ~~Persist source timestamps on facts.~~ **Done: `Fact.ObservedAt`**, worth +0.085 answerable and
238+
temporal 0.24 → 0.53. It also killed the flash-extraction recommendation.
239+
3. **Build the hybrid store.** No longer a hypothesis: under the same answer prompt, raw turns and
240+
dated facts are statistically tied on aggregate and split the categories cleanly — episodes win
241+
single-hop (0.555 vs 0.476), facts win temporal (0.533 vs 0.414). Keep both, retrieve over both.
242+
This is the next real build, and the measurement says what to expect from it.
243+
4. **A full v2 + ObservedAt re-run of the matrix.** The lever matrix is all v1, untimestamped runs.
244+
Consolidation, the bigger embedder, and the boosters have never been measured on a store whose
245+
facts carry dates, and the boosters × extraction combination is still unrun.
246+
5. **Validate the judge against human labels.** Now the largest un-derisked thing in the harness.
247+
Everything above is steered on a binary LLM judge that has never been checked against a person,
248+
on a benchmark where a scoring bug already cost a full paid re-run. A stratified hand-labelled
249+
sample of ~100 questions would put an error bar on every number in this file.
250+
6. **open_domain (0.09–0.17) is the weakest category everywhere,** including the oracle, which
251+
manages only 0.17. A category the ceiling itself cannot score is a question-format or judge
252+
problem, not a memory one — and #5 is how you find out which.
253+
7. **Extraction recall on implied and causal facts (tk #11)** drops further down again.
198254

199255
## Scoring history
200256

bench/observed_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package bench
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"time"
7+
8+
"github.com/AccursedGalaxy/mneme"
9+
)
10+
11+
func TestParseSessionDate(t *testing.T) {
12+
got, ok := parseSessionDate("1:56 pm on 8 May, 2023")
13+
if !ok {
14+
t.Fatal("LoCoMo's session date format must parse; it is every fact's ObservedAt")
15+
}
16+
want := time.Date(2023, 5, 8, 13, 56, 0, 0, time.UTC)
17+
if !got.Equal(want) {
18+
t.Errorf("got %v, want %v", got, want)
19+
}
20+
21+
// A date we cannot parse must stay zero ("unknown"). Defaulting it would
22+
// stamp every fact in the session with a confidently wrong timestamp, which
23+
// is worse than carrying none: nothing downstream could detect it.
24+
if got, ok := parseSessionDate("sometime last spring"); ok || !got.IsZero() {
25+
t.Errorf("an unparseable date must be zero/false, got %v/%v", got, ok)
26+
}
27+
}
28+
29+
func TestIngestMessagesStampsEveryTurn(t *testing.T) {
30+
sess := Session{
31+
Date: "1:56 pm on 8 May, 2023",
32+
Messages: []mneme.Message{
33+
{Role: "user", Name: "Caroline", Content: "I went to a support group yesterday."},
34+
{Role: "user", Name: "Melanie", Content: "That's great!"},
35+
},
36+
}
37+
msgs := ingestMessages(sess)
38+
if len(msgs) != 3 {
39+
t.Fatalf("want the dated note + 2 turns, got %d", len(msgs))
40+
}
41+
want := time.Date(2023, 5, 8, 13, 56, 0, 0, time.UTC)
42+
for i, m := range msgs {
43+
if !m.Timestamp.Equal(want) {
44+
t.Errorf("message %d timestamp = %v, want %v", i, m.Timestamp, want)
45+
}
46+
}
47+
}
48+
49+
// The whole point of ObservedAt is that it reaches the answer model. A fact that
50+
// knows when it was said must say so, or answer prompt v2's relative-date rule
51+
// has nothing to resolve against and the store is no better off than before.
52+
func TestBuildAnswerUserRendersObservedAt(t *testing.T) {
53+
at := time.Date(2023, 5, 8, 13, 56, 0, 0, time.UTC)
54+
got := buildAnswerUser("When?", []mneme.Fact{
55+
{Text: "Caroline attended a support group.", ObservedAt: at},
56+
{Text: "Melanie paints."}, // no timestamp: renders bare, as before
57+
})
58+
if !strings.Contains(got, "(said on 8 May, 2023) Caroline attended a support group.") {
59+
t.Errorf("a dated fact must render its date, got:\n%s", got)
60+
}
61+
if !strings.Contains(got, "- Melanie paints.\n") {
62+
t.Errorf("an undated fact must render bare, got:\n%s", got)
63+
}
64+
}
65+
66+
// A session date the layouts cannot parse must not stamp the zero time over
67+
// timestamps the messages already carry. Overwriting a real timestamp with
68+
// "unknown" is the same silent corruption the mechanism exists to prevent.
69+
func TestIngestMessagesKeepsExistingTimestampsWhenDateUnparseable(t *testing.T) {
70+
own := time.Date(2023, 5, 8, 13, 56, 0, 0, time.UTC)
71+
sess := Session{
72+
Date: "sometime last spring", // matches no layout
73+
Messages: []mneme.Message{
74+
{Role: "user", Content: "I went to a support group yesterday.", Timestamp: own},
75+
},
76+
}
77+
msgs := ingestMessages(sess)
78+
if len(msgs) != 2 {
79+
t.Fatalf("want the prose note + 1 turn, got %d", len(msgs))
80+
}
81+
if !msgs[1].Timestamp.Equal(own) {
82+
t.Errorf("an unparseable session date must leave the message's own timestamp alone, got %v want %v",
83+
msgs[1].Timestamp, own)
84+
}
85+
}
86+
87+
// An unparsed session date is invisible in the score — it just reads as "temporal
88+
// did not improve" — so the harness must count it and be able to say so.
89+
func TestUnparsedSessionDatesIsCounted(t *testing.T) {
90+
before := UnparsedSessionDates()
91+
ingestMessages(Session{
92+
Date: "sometime last spring",
93+
Messages: []mneme.Message{{Role: "user", Content: "hi"}},
94+
})
95+
if got := UnparsedSessionDates(); got != before+1 {
96+
t.Errorf("an unparseable session date must be counted: %d -> %d", before, got)
97+
}
98+
}
99+
100+
// LongMemEval's published haystack_dates carry a weekday and time; the ISO-only
101+
// assumption would have silently dropped every one of them.
102+
func TestParseSessionDateCoversLongMemEvalShapes(t *testing.T) {
103+
for _, tc := range []struct {
104+
in string
105+
want time.Time
106+
}{
107+
{"2023/05/20 (Sat) 02:21", time.Date(2023, 5, 20, 2, 21, 0, 0, time.UTC)},
108+
{"2023-05-08", time.Date(2023, 5, 8, 0, 0, 0, 0, time.UTC)},
109+
} {
110+
got, ok := parseSessionDate(tc.in)
111+
if !ok || !got.Equal(tc.want) {
112+
t.Errorf("parseSessionDate(%q) = %v/%v, want %v/true", tc.in, got, ok, tc.want)
113+
}
114+
}
115+
}

bench/qa.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ func answerSystem(version string) string {
9393
return answerPrompts[DefaultAnswerVersion]
9494
}
9595

96+
// RenderFact is how one memory is presented to the answer model: its text,
97+
// prefixed with the date it was said when the fact knows one.
98+
//
99+
// It is exported and used for the prediction dump as well as the prompt, so the
100+
// dump records the memory string the model actually saw. That equivalence is what
101+
// makes cmd/replay exact rather than approximate — a dump that stored bare
102+
// f.Text would replay without the dates and quietly under-report every run that
103+
// depends on them.
104+
func RenderFact(f mneme.Fact) string {
105+
if f.ObservedAt.IsZero() {
106+
return f.Text
107+
}
108+
return fmt.Sprintf("(said on %s) %s", f.ObservedAt.Format("2 January, 2006"), f.Text)
109+
}
110+
96111
// Answer is the retrieve→answer step's second half: it feeds the retrieved
97112
// facts and the question to the answer LLM under the versioned QA prompt and
98113
// returns the model's answer. With no facts it still asks (the prompt makes the
@@ -108,14 +123,22 @@ func Answer(ctx context.Context, llm provider.LLM, version, question string, fac
108123

109124
// buildAnswerUser renders the MEMORIES block and the QUESTION. Facts are listed
110125
// highest-scored first (Search already returns them in that order).
126+
//
127+
// A fact that knows when it was observed is rendered with that date in front of
128+
// it, which is what gives answer prompt v2's relative-date rule something to
129+
// resolve against. Raw turns have always carried their timestamp inline (that is
130+
// most of why rawturns beat the fact store on temporal questions); a fact carries
131+
// it in ObservedAt, and it is only useful if it reaches the answer model.
132+
//
133+
// Facts with no ObservedAt render bare, exactly as before.
111134
func buildAnswerUser(question string, facts []mneme.Fact) string {
112135
var b strings.Builder
113136
b.WriteString("MEMORIES:\n")
114137
if len(facts) == 0 {
115138
b.WriteString("(none)\n")
116139
} else {
117140
for _, f := range facts {
118-
fmt.Fprintf(&b, "- %s\n", f.Text)
141+
fmt.Fprintf(&b, "- %s\n", RenderFact(f))
119142
}
120143
}
121144
b.WriteString("\nQUESTION: ")

0 commit comments

Comments
 (0)