Skip to content

Commit 9f2ea38

Browse files
authored
Merge branch 'main' into feat/persistent-importance-column
2 parents 464d7c8 + ed67339 commit 9f2ea38

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
@@ -104,13 +104,22 @@ Deletion is explicit and two-step:
104104
- **`purge`** — permanently delete entities that are **already archived**.
105105
Supports `dry_run`. This is the only way memory leaves the database.
106106

107-
## Known limitation
107+
## Semantic recall and reinforcement
108108

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

src/db.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,6 +1820,7 @@ impl Database {
18201820
let dense_results = self.dense_search(query_vec, params.limit as usize)?;
18211821
let mut out: Vec<Entity> = dense_results.into_iter().map(|(e, _)| e).collect();
18221822
Self::retain_layer(&mut out, params);
1823+
self.reinforce_if_requested(params, &out)?;
18231824
return Ok(out);
18241825
}
18251826

@@ -1907,6 +1908,7 @@ impl Database {
19071908
};
19081909
let mut out: Vec<Entity> = fused.into_iter().map(|(e, _)| e).collect();
19091910
Self::retain_layer(&mut out, params);
1911+
self.reinforce_if_requested(params, &out)?;
19101912
return Ok(out);
19111913
}
19121914
// Empty query: nothing to embed, fall through to FTS5
@@ -1917,6 +1919,26 @@ impl Database {
19171919
Ok(results)
19181920
}
19191921

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

8631+
#[test]
8632+
fn hybrid_recall_reinforce_flag_bumps_returned_hits_opt_in_only() {
8633+
// Opt-in counterpart to hybrid_recall_is_read_only_and_idempotent:
8634+
// reinforce=true applies the standard side-effects to the returned
8635+
// hits; skip_side_effects still wins over it.
8636+
let (db, path) = temp_db();
8637+
let blob = |v: &[f32]| -> Vec<u8> { v.iter().flat_map(|f| f.to_le_bytes()).collect() };
8638+
db.conn()
8639+
.unwrap()
8640+
.execute(
8641+
"INSERT INTO entities (id, category, key, body_json, type, status,
8642+
retrieval_count, last_accessed_unix_ms, created_at_unix_ms,
8643+
decay_score, layer, embedding, archived)
8644+
VALUES ('e-r', 'insight', 'reinf', '{\"note\":\"espresso ritual\"}',
8645+
'insight', 'active', 0, 0, 0, 0.5, 'buffer', ?1, 0)",
8646+
params![blob(&[1.0, 0.0, 0.0])],
8647+
)
8648+
.unwrap();
8649+
db.conn()
8650+
.unwrap()
8651+
.execute(
8652+
"INSERT INTO entities_fts (rowid, body_json)
8653+
VALUES ((SELECT rowid FROM entities WHERE id = 'e-r'), '{\"note\":\"espresso ritual\"}')",
8654+
[],
8655+
)
8656+
.unwrap();
8657+
8658+
// skip_side_effects wins over reinforce: still a pure read.
8659+
let pure = RecallParams {
8660+
query: "espresso".to_string(),
8661+
mode: crate::models::SearchMode::Hybrid,
8662+
embedding: Some(vec![1.0, 0.0, 0.0]),
8663+
limit: 10,
8664+
reinforce: true,
8665+
skip_side_effects: true,
8666+
..RecallParams::default()
8667+
};
8668+
db.recall(&pure).unwrap();
8669+
let rc: i64 = db
8670+
.conn()
8671+
.unwrap()
8672+
.query_row("SELECT retrieval_count FROM entities WHERE id = 'e-r'", [], |r| r.get(0))
8673+
.unwrap();
8674+
assert_eq!(rc, 0, "skip_side_effects must override reinforce");
8675+
8676+
// reinforce alone: returned hit gets the standard side-effects.
8677+
let reinforcing = RecallParams {
8678+
skip_side_effects: false,
8679+
..pure
8680+
};
8681+
let hits = db.recall(&reinforcing).unwrap();
8682+
assert!(hits.iter().any(|e| e.id == "e-r"), "hit expected");
8683+
let (rc2, la2, ds2): (i64, i64, f64) = db
8684+
.conn()
8685+
.unwrap()
8686+
.query_row(
8687+
"SELECT retrieval_count, last_accessed_unix_ms, decay_score FROM entities WHERE id = 'e-r'",
8688+
[],
8689+
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
8690+
)
8691+
.unwrap();
8692+
assert_eq!(rc2, 1, "reinforce must bump retrieval_count");
8693+
assert!(la2 > 0, "reinforce must touch last_accessed");
8694+
assert!(ds2 > 0.5, "reinforce must boost decay_score, got {ds2}");
8695+
8696+
let _ = fs::remove_file(&path);
8697+
}
8698+
86098699
#[test]
86108700
fn hybrid_over_fetches_arms_before_fusion() {
86118701
// Each hybrid arm is fetched at a candidate pool LARGER than `limit`, then
@@ -8840,6 +8930,7 @@ mod tests {
88408930
agent_id: None,
88418931
visibility: None,
88428932
layer: None,
8933+
reinforce: false,
88438934
},
88448935
)
88458936
.unwrap();
@@ -8973,6 +9064,7 @@ mod tests {
89739064
agent_id: None,
89749065
visibility: None,
89759066
layer: None,
9067+
reinforce: false,
89769068
}) {
89779069
Ok(_) => {},
89789070
Err(e) => {
@@ -9036,6 +9128,7 @@ mod tests {
90369128
agent_id: None,
90379129
visibility: None,
90389130
layer: None,
9131+
reinforce: false,
90399132
};
90409133

90419134
// Scope to "alpha" — should only see ent_a
@@ -9081,6 +9174,7 @@ mod tests {
90819174
agent_id: None,
90829175
visibility: None,
90839176
layer: None,
9177+
reinforce: false,
90849178
};
90859179
let results = db.recall(&params).unwrap();
90869180
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)