@@ -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" ) ;
0 commit comments