Skip to content

feat(state): give recall a relevance score, kind filter, time window and floor - #439

Merged
ion-alpha-dev merged 3 commits into
mainfrom
memory-recall-selectivity
Jul 31, 2026
Merged

feat(state): give recall a relevance score, kind filter, time window and floor#439
ion-alpha-dev merged 3 commits into
mainfrom
memory-recall-selectivity

Conversation

@ion-alpha-dev

@ion-alpha-dev ion-alpha-dev commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

RecallQuery gains Kinds, a half-open [Since, Until) window, MinScore, and Order; MemoryItem gains Score. SQLite surfaces bm25 instead of discarding it. All of it is opt-in, so the zero query behaves exactly as before.

Stacked on the scope-resolution branch; review that one first.

Why

The recall contract was {Query, Scope, Limit}, which cannot express the one thing that decides what a per-turn recall costs in context: the most relevant K items above a relevance floor. Limit caps how many rows come back and says nothing about how good they are, so the strongest match gets truncated away by a row that merely arrived later.

Three consequences, all closed here:

  • No score. A ranking backend was contractually forced to throw its rank away. storage/sqlite joins memory_fts on a MATCH, so FTS5 computes bm25 for that join whether we want it or not, and then it ordered by created_at. With no score there is no floor, so the only lever on context cost was row count.
  • No kind filter. Injecting the user's preferences meant dragging every observation along, or over-fetching and post-filtering in the host, which pays the retrieval cost for rows it then discards.
  • No time window. Retention and recency-weighting were entirely the host's problem and every backend scanned all of history.

Score is a read-side annotation, not part of the record: Recall sets it, Write clears it, no backend persists it. Clearing happens in the shared state.Stamper, so every backend that stamps through it is covered rather than each remembering. A backend that cannot rank reports 1, not 0 ("it matched, no opinion how well"), so a floor excludes nothing on a store with no ranking to offer instead of silently emptying every recall against it. OrderRelevance therefore degrades to plain recency on those backends: the ordering rule stays identical across backends even though their scores do not.

How to verify

go test -race ./state/... ./memory/... ./storage/sqlite/...
golangci-lint run ./state/... ./memory/... ./storage/sqlite/...
  • memorytest gains Selectors and Relevance. They assert the contract's rules rather than any backend's numbers (scores are non-increasing under OrderRelevance, the window partitions cleanly at its boundary, Score never survives a write), so they hold whether or not the backend ranks.
  • state/recall_order_test.go drives SortRecall directly with items differing on every axis at once, pinning the key precedence: relevance, then scope, then recency, then ID. No conformance run can reach this, because against both bundled backends every match scores 1 and SQLite orders in SQL.
  • Patch coverage 92.2%.

Notes for reviewers

  • The time window is deliberately not pushed into SQL. created_at is stored as RFC3339Nano, which strips trailing zeros from the fractional second, so the column is not fixed-width and does not compare lexicographically: ...T00:00:00.000000001Z sorts before ...T00:00:00Z, though it is later. Comparing parsed times is the only correct answer available. The kind filter does push down, being an exact match on an indexed column that cuts rows before the sort.
  • A query that filters after the rows return must also cap there. Otherwise a SQL LIMIT truncates rows the Go stage was going to drop anyway and the recall comes back short. The suite pins this case.
  • This exposes a pre-existing bug, not fixed here. The same variable-width created_at means SQLite's ORDER BY m.created_at DESC is wrong whenever two rows differ in stored precision, which is roughly one timestamp in ten (any reading that lands on a zero nanosecond). Fixing it means a fixed-width encoding plus a migration rewriting every timestamp column, which is its own change with its own risk; tracked separately.
  • bm25 magnitudes look tiny on a small corpus. That is FTS5 flooring near-zero IDF for a term that appears in most documents, and it is correct. The scale is the backend's own, is not comparable across backends, and shifts as the corpus grows. MemoryItem.Score says so, because a caller tuning MinScore is tuning it for one backend and one corpus, not setting a portable constant.

…and floor

RecallQuery was {Query, Scope, Limit}, which cannot express the one thing that
decides what a per-turn recall costs in context: the most relevant K items above
a relevance floor. Limit caps how many rows come back but says nothing about how
good they are, so the strongest match is truncated away by a row that merely
arrived later.

- MemoryItem.Score grades the match, in [0,1]. It is a read-side annotation:
  Recall sets it, Write clears it (in the shared Stamper, so every backend that
  stamps through it is covered), and no backend persists it. A store that cannot
  rank reports 1 rather than 0 - "it matched, no opinion how well" - so a floor
  never silently empties a recall against a backend with no ranking to offer.
- RecallQuery gains Kinds, the half-open [Since, Until) window, MinScore, and
  Order. All are opt-in: the zero query behaves exactly as before.
- OrderRelevance ranks by score, recency breaking ties, and degrades to plain
  recency against a backend where every match scores 1 - so the ordering rule
  stays identical across backends even though their scores differ.

SQLite surfaces bm25 instead of discarding it. FTS5 computed the rank for the
MATCH already and the contract threw it away, ordering by created_at. bm25 is
<= 0 with a more negative value meaning a better match, so -b/(1-b) maps it onto
[0,1) increasing in match quality.

The kind filter pushes into SQL, where it cuts rows before the sort. The time
window deliberately does not: created_at is stored as RFC3339Nano, which strips
trailing zeros from the fractional second, so the column is not fixed-width and
does not compare lexicographically - "...T00:00:00.000000001Z" sorts before
"...T00:00:00Z". Comparing parsed times is the only correct answer, and a query
that filters after the rows come back must also cap there, or a SQL LIMIT
truncates rows the Go stage was going to drop anyway and returns short.

memorytest gains Selectors and Relevance cases. They assert the contract's rules
rather than any backend's numbers - scores are non-increasing under
OrderRelevance, the window partitions at its boundary, Score never persists -
so they hold whether or not the backend has real ranking. The window case
separates its writes past the coarsest platform clock granularity and asserts
the stamps are strictly increasing, so it cannot quietly degenerate into
"everything landed on one side" on a fast runner.

Signed-off-by: Ion Alpha <contact@ionalpha.io>
SortRecall is where the ordering contract lives and every backend sorts through
it, but no test could separate two items by score: against both bundled backends
every match scores 1, and SQLite orders in SQL rather than here. So the key
precedence - relevance, then scope, then recency, then ID - went unexercised.
state/recall_order_test.go drives SortRecall directly with items that differ on
each axis at once, so which key wins is asserted rather than assumed.

The conformance suite's new cases move onto the helpers introduced with the
scope fix, and the two older cases follow, so the whole suite now reads as one
assertion per line and the failure branches are not duplicated at every check.
Two helpers join them: wantSet for questions about which items came back (so a
set answer cannot accidentally pin an ordering the contract leaves open) and
wantIncreasingStamps, which fails outright if two writes land on one clock tick
instead of letting the window assertions quietly weaken.

One real gap closed while measuring: a limit alongside a time window was never
asserted. That is the case a backend gets wrong by pushing the limit into its
query language while applying the window afterwards - it caps first and returns
short. The suite now pins it.

Signed-off-by: Ion Alpha <contact@ionalpha.io>
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.84530% with 22 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
memory/memorytest/memorytest.go 84.44% 8 Missing and 6 partials ⚠️
storage/sqlite/sqlite.go 87.30% 4 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

Base automatically changed from memory-scope-resolution to main July 31, 2026 18:37
@ion-alpha-dev
ion-alpha-dev merged commit 0a1b7b3 into main Jul 31, 2026
26 checks passed
@ion-alpha-dev
ion-alpha-dev deleted the memory-recall-selectivity branch July 31, 2026 18:47
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant