Repository navigation
fix(memory): keep a zero confidence from scoring as the default - #5586
WillemJiang merged 1 commit into
Conversation
or 0.5 cannot distinguish an explicit 0.0 from an unset field, so a discounted fact got the default confidence weight in both the FTS5 document and the substring fallback, while the same row indexed through FTS5Retrieval.index_fact kept its zero weight.
|
Filed from #5585. This opened as a draft because the repository force-drafts pull requests from contributors without write access; nothing here is still in progress — the fix and both tests are complete and the validation numbers are in the description. A maintainer marking it ready is all that is needed to get it into review. |
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed at f8d31e4 — no issues found; the fix is correct and minimal.
Verified: (1) 0.5 if confidence is None else float(confidence) at both read sites matches the storage contract — _normalize_fact (storage.py:211-214) persists 0.0 as legal and rejects non-numeric values at the write boundary, so only unset/null can reach the default here, and the explicit-null branch preserves the old or 0.5 tolerance. (2) A sweep of the deermem package found no remaining falsy-collapse confidence defaulting — FTS5Retrieval.index_fact / rebuild_from_facts already used the key-missing .get(k, 0.5) form, and _compute_final_score substitutes 0.5 only for non-finite/non-numeric values. (3) Rows persisted by the old code carry the inflated 0.5, but the index is derived data: the Gateway warm-up / lazy per-scope rebuild re-derives documents from canonical Markdown, so the correction takes effect on restart without a migration. (4) Ran tests/test_memory_retrieval_adapter.py at this head against the PR source: 20 passed, 1 skipped, matching the PR's reported results; both new tests pin the fix (zero < half) and the unchanged missing-key defaulting.
…dance#5586) or 0.5 cannot distinguish an explicit 0.0 from an unset field, so a discounted fact got the default confidence weight in both the FTS5 document and the substring fallback, while the same row indexed through FTS5Retrieval.index_fact kept its zero weight.
Fixes #5585
Why
A
confidenceof0.0is a value the storage layer validates, persists and considers legal(
0 <= confidence <= 1,deermem/core/storage.py:211-214), but two read sites rescued theirdefault with
or 0.5, which cannot tell "explicitly zero" from "unset"(
deermem/core/retrieval.py:585,deermem/core/storage.py:1738). Reproduced as #5585:BM25 × time_decay + confidence × 0.2therefore added the default weight to a zero-confidencefact, and the same stored fact scored
0.100001when indexed throughFTS5RetrievalAdapter.upsertagainst
1e-06when the identical row was indexed throughFTS5Retrieval.index_fact— whose owndefaulting (
fact.get("confidence", 0.5),retrieval.py:167and:296) distinguishes the twocases correctly. So the ranking of a discounted memory depended on which code path happened to
write its row, and the returned payload still reported
"confidence": 0.0(
fact.setdefaultatretrieval.py:459never overwritesfact_json), which is why the inflationis invisible to the caller.
_compute_final_scoreitself is already strict in the right way: itsubstitutes
0.5only for a non-numeric or non-finite value (retrieval.py:503-507), so itexpects
0.0to be a real weight.What changed
confidence: 0.0now ranks strictly below one with0.5, through boththe FTS5 index and the substring fallback, and matches what the same fact already scores via
FTS5Retrieval.index_fact. Callers see one consistent ranking regardless of which write pathproduced the row.
confidencestill means "unset" and still defaults to0.5; the explicitnullcase keeps its existing tolerance instead of becoming aTypeError.confidence: 0in the substring fallback now returns a score of0.0rather than0.5, so itcan no longer outrank a mid-confidence fact when the index is unavailable or a scope is dirty.
backend/tests/test_memory_retrieval_adapter.py, one per read path, eachwith an in-file control that pins the unchanged defaulting.
Surface area
FTS5RetrievalAdapter._documentandFileMemoryStorage._search_substring. No schema change, no newdependency, no I/O, and the stored Markdown payload is untouched — only the score derived from it.
search_factsconfidenceis falsy but legal (0,0.0), the retrieval score drops by0.1. Every other value, including a missing key, produces byte-identical scores to before, so no currently-ranking fact changes except the ones this report calls a bug.Bug fix verification
tests/test_memory_retrieval_adapter.pymain@2ff006b0+ new tests (source reverted)2 failed, 18 passed, 1 skipped20 passed, 1 skippedBoth failures are the falsy collapse, verbatim:
The controls pass on both sides, which is the point:
test_indexed_zero_confidence_keeps_its_retrieval_weightalso asserts that a fact with no
confidencekey still scores exactly like an explicit0.5, and the18 pre-existing tests in the file are unaffected.
Validation
PYTHONPATH=. pytest -m "not live" --ignore=tests/blocking_io tests/(themake testsuite) — the run on this branch was still in progress when this PR was written, so onlythe targeted 11-file set above is reported.
(
test_memory_storage*.py,test_memory_retrieval_adapter.py,test_deermem_self_contained.py,test_memory_eviction.py,test_memory_fact_dedup.py,test_memory_scope_gate.py,test_memory_updater.py,test_updater_watermark.py,test_custom_agent.py):384 passed, 1 skippedonmain→386 passed, 1 skippedhere (the +2 are the new tests; noexisting test changes outcome).
make lintsteps, run directly:ruff check .→All checks passed!, andruff format --check .→1545 files already formatted(ruff0.15.12,backend/ruff.toml).makeitself (nomakeon this Windows host, so the targets were executed as theirunderlying commands with the project venv), the
liveandblocking_iopytest marks, the frontendsuites, and Docker e2e. Reported from a local source checkout, not CI.
AI assistance
Tool(s) used: Qoder (agentic CLI).
How you used it: reproduction scripting and the first draft of the two tests; the root-cause
call, the choice of
None-only defaulting over the repo's.get(k, 0.5)idiom (so an explicitnullkeeps its current tolerance instead of raising), and the line-by-line review of the finaldiff were done here by the agent against
main; no human has read the diff yet, which is why the box below is unticked.