Skip to content

Commit ed67339

Browse files
tcconnallytcconnallyclaude
authored
feat: opt-in reinforce flag for dense/hybrid recall (#343)
The semantic recall paths are side-effect-free by default so repeated recalls over a frozen DB stay byte-deterministic (#247) — the cost being that memories only ever found semantically decay as if unused (the gap documented in docs/retention.md). reinforce=true on mimir_recall (mode dense/hybrid) now applies the same side-effects the fts5 path uses — retrieval-count bump, recency reset, +0.25 decay boost, layer promotion — to the returned hits, via the existing apply_recall_side_effects batch. Rules: - default false: nothing changes, determinism preserved - skip_side_effects always wins over reinforce (a pure read never mutates) - no effect on the fts5 path, which already reinforces - side-effects apply after ranking, so the flagged call itself returns the same result it would have without the flag Tests: hybrid_recall_reinforce_flag_bumps_returned_hits_opt_in_only (both the skip-wins and the bump paths). Suite: 163 passed / 0 failed. Co-authored-by: tcconnally <hermes@perseus.observer> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent f28bb24 commit ed67339

5 files changed

Lines changed: 135 additions & 8 deletions

File tree

docs/retention.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,22 @@ Deletion is explicit and two-step:
103103
- **`purge`** — permanently delete entities that are **already archived**.
104104
Supports `dry_run`. This is the only way memory leaves the database.
105105

106-
## Known limitation
106+
## Semantic recall and reinforcement
107107

108-
Retrieval reinforcement currently fires only on the keyword (`fts5`) recall
109-
path; the default hybrid/dense path is side-effect-free to keep recall
110-
byte-deterministic over a frozen DB (#247, see
108+
By default, retrieval reinforcement fires only on the keyword (`fts5`) recall
109+
path; the hybrid/dense paths are side-effect-free so recall over a frozen DB
110+
stays byte-deterministic (#247, see
111111
`deterministic-recall-and-provenance.md`). A memory that is only ever found
112-
via hybrid recall therefore still decays as if unused. Whether to trade that
113-
determinism for reinforcement-on-use is an open product decision; until it is
114-
made, mark load-bearing memories `verified` (floor) or `always_on`
115-
(unconditional injection) rather than relying on usage to keep them alive.
112+
semantically therefore decays as if unused — unless you opt in:
113+
114+
- **`reinforce: true`** on `mimir_recall` with `mode: 'dense'`/`'hybrid'`
115+
applies the standard side-effects (retrieval-count bump, recency reset,
116+
+0.25 decay boost, layer promotion) to the returned hits. This trades
117+
byte-determinism of *subsequent* recalls for "used memories resist decay" —
118+
the recall that carries the flag still returns the same ranking it would
119+
have without it.
120+
- Alternatively, mark load-bearing memories `verified` (decay floor) or
121+
`always_on` (unconditional injection) and keep semantic recall pure.
122+
123+
`skip_side_effects` always wins over `reinforce`: a caller that asked for a
124+
pure read never mutates.

src/db.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1812,6 +1812,7 @@ impl Database {
18121812
let dense_results = self.dense_search(query_vec, params.limit as usize)?;
18131813
let mut out: Vec<Entity> = dense_results.into_iter().map(|(e, _)| e).collect();
18141814
Self::retain_layer(&mut out, params);
1815+
self.reinforce_if_requested(params, &out)?;
18151816
return Ok(out);
18161817
}
18171818

@@ -1899,6 +1900,7 @@ impl Database {
18991900
};
19001901
let mut out: Vec<Entity> = fused.into_iter().map(|(e, _)| e).collect();
19011902
Self::retain_layer(&mut out, params);
1903+
self.reinforce_if_requested(params, &out)?;
19021904
return Ok(out);
19031905
}
19041906
// Empty query: nothing to embed, fall through to FTS5
@@ -1909,6 +1911,26 @@ impl Database {
19091911
Ok(results)
19101912
}
19111913

1914+
/// Opt-in reinforcement for the semantic (Dense/Hybrid) recall paths.
1915+
/// Applies the standard recall side-effects (retrieval-count bump,
1916+
/// recency, decay boost, layer promotion) to the returned hits when the
1917+
/// caller set `reinforce` — and only then. The default stays
1918+
/// side-effect-free so repeated semantic recalls over a frozen DB remain
1919+
/// byte-deterministic (#247). `skip_side_effects` always wins: a caller
1920+
/// that asked for a pure read never mutates, whatever else is set.
1921+
fn reinforce_if_requested(
1922+
&self,
1923+
params: &RecallParams,
1924+
hits: &[Entity],
1925+
) -> Result<(), Box<dyn std::error::Error>> {
1926+
if !params.reinforce || params.skip_side_effects || hits.is_empty() {
1927+
return Ok(());
1928+
}
1929+
let ids: Vec<String> = hits.iter().map(|e| e.id.clone()).collect();
1930+
self.apply_recall_side_effects(&ids)?;
1931+
Ok(())
1932+
}
1933+
19121934
/// Core FTS5 + LIKE keyword search (extracted for reuse by recall and hybrid).
19131935
fn fts5_search(
19141936
&self,
@@ -8525,6 +8547,74 @@ mod tests {
85258547
let _ = fs::remove_file(&path);
85268548
}
85278549

8550+
#[test]
8551+
fn hybrid_recall_reinforce_flag_bumps_returned_hits_opt_in_only() {
8552+
// Opt-in counterpart to hybrid_recall_is_read_only_and_idempotent:
8553+
// reinforce=true applies the standard side-effects to the returned
8554+
// hits; skip_side_effects still wins over it.
8555+
let (db, path) = temp_db();
8556+
let blob = |v: &[f32]| -> Vec<u8> { v.iter().flat_map(|f| f.to_le_bytes()).collect() };
8557+
db.conn()
8558+
.unwrap()
8559+
.execute(
8560+
"INSERT INTO entities (id, category, key, body_json, type, status,
8561+
retrieval_count, last_accessed_unix_ms, created_at_unix_ms,
8562+
decay_score, layer, embedding, archived)
8563+
VALUES ('e-r', 'insight', 'reinf', '{\"note\":\"espresso ritual\"}',
8564+
'insight', 'active', 0, 0, 0, 0.5, 'buffer', ?1, 0)",
8565+
params![blob(&[1.0, 0.0, 0.0])],
8566+
)
8567+
.unwrap();
8568+
db.conn()
8569+
.unwrap()
8570+
.execute(
8571+
"INSERT INTO entities_fts (rowid, body_json)
8572+
VALUES ((SELECT rowid FROM entities WHERE id = 'e-r'), '{\"note\":\"espresso ritual\"}')",
8573+
[],
8574+
)
8575+
.unwrap();
8576+
8577+
// skip_side_effects wins over reinforce: still a pure read.
8578+
let pure = RecallParams {
8579+
query: "espresso".to_string(),
8580+
mode: crate::models::SearchMode::Hybrid,
8581+
embedding: Some(vec![1.0, 0.0, 0.0]),
8582+
limit: 10,
8583+
reinforce: true,
8584+
skip_side_effects: true,
8585+
..RecallParams::default()
8586+
};
8587+
db.recall(&pure).unwrap();
8588+
let rc: i64 = db
8589+
.conn()
8590+
.unwrap()
8591+
.query_row("SELECT retrieval_count FROM entities WHERE id = 'e-r'", [], |r| r.get(0))
8592+
.unwrap();
8593+
assert_eq!(rc, 0, "skip_side_effects must override reinforce");
8594+
8595+
// reinforce alone: returned hit gets the standard side-effects.
8596+
let reinforcing = RecallParams {
8597+
skip_side_effects: false,
8598+
..pure
8599+
};
8600+
let hits = db.recall(&reinforcing).unwrap();
8601+
assert!(hits.iter().any(|e| e.id == "e-r"), "hit expected");
8602+
let (rc2, la2, ds2): (i64, i64, f64) = db
8603+
.conn()
8604+
.unwrap()
8605+
.query_row(
8606+
"SELECT retrieval_count, last_accessed_unix_ms, decay_score FROM entities WHERE id = 'e-r'",
8607+
[],
8608+
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
8609+
)
8610+
.unwrap();
8611+
assert_eq!(rc2, 1, "reinforce must bump retrieval_count");
8612+
assert!(la2 > 0, "reinforce must touch last_accessed");
8613+
assert!(ds2 > 0.5, "reinforce must boost decay_score, got {ds2}");
8614+
8615+
let _ = fs::remove_file(&path);
8616+
}
8617+
85288618
#[test]
85298619
fn hybrid_over_fetches_arms_before_fusion() {
85308620
// Each hybrid arm is fetched at a candidate pool LARGER than `limit`, then
@@ -8759,6 +8849,7 @@ mod tests {
87598849
agent_id: None,
87608850
visibility: None,
87618851
layer: None,
8852+
reinforce: false,
87628853
},
87638854
)
87648855
.unwrap();
@@ -8892,6 +8983,7 @@ mod tests {
88928983
agent_id: None,
88938984
visibility: None,
88948985
layer: None,
8986+
reinforce: false,
88958987
}) {
88968988
Ok(_) => {},
88978989
Err(e) => {
@@ -8955,6 +9047,7 @@ mod tests {
89559047
agent_id: None,
89569048
visibility: None,
89579049
layer: None,
9050+
reinforce: false,
89589051
};
89599052

89609053
// Scope to "alpha" — should only see ent_a
@@ -9000,6 +9093,7 @@ mod tests {
90009093
agent_id: None,
90019094
visibility: None,
90029095
layer: None,
9096+
reinforce: false,
90039097
};
90049098
let results = db.recall(&params).unwrap();
90059099
let found = results.iter().find(|e| e.key == "key1").expect("entity recalled");

src/mcp.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,11 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
392392
"default": false,
393393
"description": "Add a normalized confidence score (0.0-1.0) to each result, rolled up from rank, trust (verified/certainty), and decay. Presentation-only; does not change ranking."
394394
},
395+
"reinforce": {
396+
"type": "boolean",
397+
"default": false,
398+
"description": "Opt-in reinforcement for mode='dense'/'hybrid': bump retrieval_count/last_accessed/decay on the returned hits so semantically-used memories resist decay and promote through layers. Default false keeps semantic recall side-effect-free and byte-deterministic over a frozen DB. No effect on mode='fts5', which already reinforces."
399+
},
395400
"expansion": {
396401
"type": "object",
397402
"properties": {

src/models.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,15 @@ pub struct RecallParams {
235235
#[allow(dead_code)]
236236
pub visibility: Option<String>,
237237
pub layer: Option<String>,
238+
/// Opt-in reinforcement for Dense/Hybrid recall. The semantic paths are
239+
/// side-effect-free by default so repeated recalls over a frozen DB stay
240+
/// byte-deterministic (#247) — the cost is that memories only ever found
241+
/// semantically decay as if unused. When true, the returned hits receive
242+
/// the same retrieval_count/last_accessed/decay-boost side-effects the
243+
/// fts5 path applies, trading determinism for "used memories resist
244+
/// decay". Ignored when skip_side_effects is set. No effect on the fts5
245+
/// path, which already reinforces unless skip_side_effects.
246+
pub reinforce: bool,
238247
}
239248

240249
/// Search mode for recall: FTS5 keyword, dense vector, or hybrid fusion.
@@ -299,6 +308,7 @@ impl Default for RecallParams {
299308
agent_id: None,
300309
visibility: None,
301310
layer: None,
311+
reinforce: false,
302312
}
303313
}
304314
}

src/tools.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ pub struct RecallArgs {
172172
/// existing callers and snapshot tests are unaffected; ranking is unchanged.
173173
#[serde(default, deserialize_with = "null_as_default")]
174174
pub include_confidence: bool,
175+
/// Opt-in reinforcement for dense/hybrid recall: bump retrieval stats on
176+
/// the returned hits so semantically-used memories resist decay. Default
177+
/// false — the semantic paths stay byte-deterministic (#247).
178+
#[serde(default, deserialize_with = "null_as_default")]
179+
pub reinforce: bool,
175180
}
176181

177182
/// #287: presentation-layer confidence rollup over signals Mneme already has.
@@ -516,6 +521,7 @@ pub fn handle_recall(db: &Database, args: Value) -> Result<String, String> {
516521
agent_id: a.agent_id.clone(),
517522
visibility: None,
518523
layer: a.layer.as_deref().filter(|s| !s.is_empty()).map(canonical_layer),
524+
reinforce: a.reinforce,
519525
};
520526

521527
let entities = db
@@ -646,6 +652,9 @@ fn handle_recall_with_expansion(db: &Database, a: &RecallArgs) -> Result<String,
646652
agent_id: a.agent_id.clone(),
647653
visibility: None,
648654
layer: a.layer.as_deref().filter(|s| !s.is_empty()).map(canonical_layer),
655+
// Fts5-only path: reinforcement is handled by the batched
656+
// side-effect below, not the per-variant recalls.
657+
reinforce: false,
649658
};
650659

651660
if let Ok(entities) = db.recall(&params) {

0 commit comments

Comments
 (0)