From a580e8ffdb7edf6b4fe6776f75387d76f031b742 Mon Sep 17 00:00:00 2001 From: Bubbs Date: Thu, 16 Jul 2026 14:08:52 -0700 Subject: [PATCH 1/2] fix(memory): coerce legacy float salience on row hydration Early builds stored salience as a 0-1 float; the Reflection model now requires an int in [1, 5], so a single legacy row raised a pydantic ValidationError and poisoned every recall()/memory_query that touched it (observed live on bubbs: rows with salience 0.7/0.85/0.9 made recall() unusable). Map 0-1 floats onto the 1-5 scale, round stray numerics, clamp to bounds, and fall back to the default (3) for garbage values. Co-Authored-By: Claude Fable 5 --- src/pinky_memory/store.py | 20 +++++++++++++++++++- tests/test_memory_store.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/pinky_memory/store.py b/src/pinky_memory/store.py index 7f329aec..cc9a72f3 100644 --- a/src/pinky_memory/store.py +++ b/src/pinky_memory/store.py @@ -1512,6 +1512,24 @@ def _timeframe_cutoff(timeframe: str) -> datetime: # ── Helpers ── + @staticmethod + def _coerce_salience(raw: object) -> int: + """Coerce a stored salience value onto the 1-5 integer scale. + + Early builds stored salience as a 0-1 float; the Reflection model + now requires an int in [1, 5], so a single legacy row raises a + pydantic ValidationError and poisons every recall()/query that + touches it. Map 0-1 floats onto 1-5, round anything else numeric, + clamp to bounds, and fall back to the default (3) for garbage. + """ + try: + val = float(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return 3 + if 0 <= val <= 1: + val = val * 5 + return max(1, min(5, round(val))) + @staticmethod def _row_to_reflection(row: sqlite3.Row) -> Reflection: # Handle optional columns that may not exist in older schemas @@ -1533,7 +1551,7 @@ def _row_to_reflection(row: sqlite3.Row) -> Reflection: content=row["content"], context=row["context"], project=row["project"], - salience=row["salience"], + salience=ReflectionStore._coerce_salience(row["salience"]), active=bool(row["active"]), no_recall=no_recall, supersedes=row["supersedes"], diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index dda4c5dd..c4ddadc7 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -1229,3 +1229,39 @@ def test_consolidate_deactivated_ref_cannot_win(self, tmp_path): assert store.get(ref.id).superseded_by == winner.id # ...and must not have gone on to archive the weaker active memory assert store.get(weak.id).active is True + + +class TestLegacySalienceCoercion: + """Rows written by early builds carry 0-1 float salience; hydration must + coerce them onto the 1-5 int scale instead of raising ValidationError + and poisoning every recall()/query that touches the row (#834).""" + + @pytest.mark.parametrize( + "raw,expected", + [ + (0.7, 4), # legacy 0-1 scale -> x5, rounded + (0.9, 4), # round-half-even: 4.5 -> 4 + (1.0, 5), # boundary of legacy scale maps high, not to 1 + (0.0, 1), # clamped to lower bound + (3, 3), # modern ints pass through + (4.4, 4), # stray float on modern scale just rounds + (99, 5), # clamped to upper bound + (None, 3), # garbage falls back to default + ("high", 3), # garbage falls back to default + ], + ) + def test_coerce_salience(self, raw, expected): + assert ReflectionStore._coerce_salience(raw) == expected + + def test_recall_survives_legacy_float_salience_row(self, tmp_path): + store = _store(tmp_path) + r = store.insert(_fact("legacy row", salience=3)) + # Corrupt the row the way early builds wrote it: 0-1 float scale + with store._lock: + store._conn.execute( + "UPDATE reflections SET salience = 0.7 WHERE id = ?", (r.id,) + ) + store._conn.commit() + got = store.get(r.id) + assert got is not None + assert got.salience == 4 From 9fb54c355f82141e5afca3383bf291ac20c8a603 Mon Sep 17 00:00:00 2001 From: Bubbs Date: Thu, 16 Jul 2026 21:03:06 -0700 Subject: [PATCH 2/2] fix: only apply legacy 0-1 salience mapping to genuine floats SQLite preserves per-value type, so legacy rows hydrate as floats and modern rows as ints. Keying the x5 legacy mapping on isinstance(float) prevents a valid modern salience of int 1 from being inflated to 5 on every read. Also treat bools as garbage (default 3) and add regression tests. Co-Authored-By: Claude Fable 5 --- src/pinky_memory/store.py | 14 +++++++++++--- tests/test_memory_store.py | 19 +++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/pinky_memory/store.py b/src/pinky_memory/store.py index cc9a72f3..15e95f5d 100644 --- a/src/pinky_memory/store.py +++ b/src/pinky_memory/store.py @@ -1519,15 +1519,23 @@ def _coerce_salience(raw: object) -> int: Early builds stored salience as a 0-1 float; the Reflection model now requires an int in [1, 5], so a single legacy row raises a pydantic ValidationError and poisons every recall()/query that - touches it. Map 0-1 floats onto 1-5, round anything else numeric, + touches it. Map 0-1 FLOATS onto 1-5, round anything else numeric, clamp to bounds, and fall back to the default (3) for garbage. + + The legacy x5 mapping applies only to genuine floats: SQLite + preserves column affinity per value, so legacy rows hydrate as + Python floats while modern rows hydrate as ints. A modern, + perfectly valid salience of int 1 must stay 1 — not be mistaken + for legacy 1.0 and inflated to 5. """ + if isinstance(raw, bool): # bool is an int subclass; treat as garbage + return 3 + if isinstance(raw, float) and 0 <= raw <= 1: + raw = raw * 5 try: val = float(raw) # type: ignore[arg-type] except (TypeError, ValueError): return 3 - if 0 <= val <= 1: - val = val * 5 return max(1, min(5, round(val))) @staticmethod diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index c4ddadc7..31266a4d 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -1241,12 +1241,15 @@ class TestLegacySalienceCoercion: [ (0.7, 4), # legacy 0-1 scale -> x5, rounded (0.9, 4), # round-half-even: 4.5 -> 4 - (1.0, 5), # boundary of legacy scale maps high, not to 1 - (0.0, 1), # clamped to lower bound + (1.0, 5), # FLOAT 1.0 is legacy-scale boundary -> maps high + (1, 1), # INT 1 is a valid modern salience -> passes through + (0.0, 1), # legacy float 0.0 clamps to lower bound + (0, 1), # int 0 is not legacy-mapped; just clamps to 1 (3, 3), # modern ints pass through (4.4, 4), # stray float on modern scale just rounds (99, 5), # clamped to upper bound (None, 3), # garbage falls back to default + (True, 3), # bool is an int subclass but is garbage here ("high", 3), # garbage falls back to default ], ) @@ -1265,3 +1268,15 @@ def test_recall_survives_legacy_float_salience_row(self, tmp_path): got = store.get(r.id) assert got is not None assert got.salience == 4 + + def test_modern_salience_one_is_not_inflated(self, tmp_path): + """A valid modern salience of 1 (stored as INTEGER) must hydrate as 1. + + Regression guard: the legacy 0-1 x5 mapping must key off the value + being a float, or int 1 gets mistaken for legacy 1.0 and read back + as 5 on every hydration.""" + store = _store(tmp_path) + r = store.insert(_fact("low-salience row", salience=1)) + got = store.get(r.id) + assert got is not None + assert got.salience == 1