1use std::collections::HashMap;
4use std::sync::Mutex;
5
6#[cfg(test)]
22pub(crate) struct LoadHook {
23 pub reached_tx: tokio::sync::oneshot::Sender<()>,
24 pub proceed_rx: tokio::sync::oneshot::Receiver<()>,
25}
26
27#[cfg(test)]
28pub(crate) static POST_LOAD_HOOK: std::sync::Mutex<Option<LoadHook>> = std::sync::Mutex::new(None);
29
30#[cfg(test)]
31pub(crate) fn set_post_load_hook(hook: LoadHook) {
32 *POST_LOAD_HOOK.lock().unwrap() = Some(hook);
33}
34
35#[cfg(test)]
36pub(crate) fn clear_post_load_hook() {
37 *POST_LOAD_HOOK.lock().unwrap() = None;
38}
39
40use serde_json::Value;
41
42use khive_runtime::{KhiveRuntime, NamespaceToken, RuntimeError};
43use khive_storage::types::{SqlStatement, SqlValue};
44use khive_storage::{SqlAccess, SqlWriter};
45
46use khive_brain_core::{
47 validate_brain_state_snapshot_with_capacity, BrainSignal, BrainState, BrainStateSnapshot,
48};
49
50use crate::event::interpret;
51
52const SNAPSHOT_PROFILE_ID: &str = "__brain__";
53const DEFAULT_SNAPSHOT_BATCH_SIZE: u64 = 5;
54
55pub struct PersistenceTracker {
57 pub(crate) active_namespace: Option<String>,
59 saved_states: HashMap<String, BrainState>,
62 pub(crate) loaded_namespaces: HashMap<String, ()>,
64 dirty_counts: HashMap<String, u64>,
65 snapshot_batch_size: u64,
66 pending_hook_signals: HashMap<String, Vec<BrainSignal>>,
78}
79
80impl Default for PersistenceTracker {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl PersistenceTracker {
87 pub fn new() -> Self {
89 Self {
90 active_namespace: None,
91 saved_states: HashMap::new(),
92 loaded_namespaces: HashMap::new(),
93 dirty_counts: HashMap::new(),
94 snapshot_batch_size: DEFAULT_SNAPSHOT_BATCH_SIZE,
95 pending_hook_signals: HashMap::new(),
96 }
97 }
98
99 pub fn is_loaded(&self, namespace: &str) -> bool {
101 self.loaded_namespaces.contains_key(namespace)
102 }
103
104 pub fn is_active(&self, namespace: &str) -> bool {
106 self.active_namespace.as_deref() == Some(namespace)
107 }
108
109 pub fn mark_loaded(&mut self, namespace: String) {
111 self.loaded_namespaces.insert(namespace.clone(), ());
112 self.active_namespace = Some(namespace);
113 }
114
115 pub fn swap_namespace(
117 &mut self,
118 from_namespace: &str,
119 from_state: BrainState,
120 to_namespace: String,
121 ) -> Option<BrainState> {
122 self.saved_states
123 .insert(from_namespace.to_string(), from_state);
124 let saved = self.saved_states.remove(&to_namespace);
125 self.active_namespace = Some(to_namespace);
126 saved
127 }
128
129 pub fn increment_dirty(&mut self, namespace: &str) -> bool {
131 let count = self.dirty_counts.entry(namespace.to_string()).or_insert(0);
132 *count += 1;
133 *count >= self.snapshot_batch_size
134 }
135
136 pub fn reset_dirty(&mut self, namespace: &str) {
137 self.dirty_counts.insert(namespace.to_string(), 0);
138 }
139
140 #[cfg(test)]
147 pub(crate) fn total_events_for(&self, namespace: &str) -> Option<u64> {
148 if let Some(s) = self.saved_states.get(namespace) {
149 return Some(s.balanced_recall.total_events);
150 }
151 self.pending_hook_signals
152 .get(namespace)
153 .map(|signals| signals.len() as u64)
154 }
155
156 pub(crate) fn route_signal(
170 &mut self,
171 namespace: &str,
172 signal: &khive_brain_core::BrainSignal,
173 entity_capacity: usize,
174 ) -> ApplyTarget {
175 if self.is_active(namespace) {
176 return ApplyTarget::ActiveSlot;
177 }
178
179 if self.is_loaded(namespace) {
180 if let Some(state) = self.saved_states.get_mut(namespace) {
183 state.balanced_recall.apply_signal(signal);
184 crate::sync_balanced_recall_record(state);
185 }
186 return ApplyTarget::Done;
187 }
188
189 let _ = entity_capacity;
193 self.pending_hook_signals
194 .entry(namespace.to_string())
195 .or_default()
196 .push(signal.clone());
197 ApplyTarget::Done
198 }
199
200 pub(crate) fn drain_pending_signals(&mut self, namespace: &str) -> Vec<BrainSignal> {
205 self.pending_hook_signals
206 .remove(namespace)
207 .unwrap_or_default()
208 }
209}
210
211pub(crate) enum ApplyTarget {
213 ActiveSlot,
216 Done,
218}
219
220fn sql_err(context: &str, e: impl std::fmt::Display) -> RuntimeError {
221 RuntimeError::Internal(format!("brain persistence {context}: {e}"))
222}
223
224async fn append_brain_event_on_writer(
227 writer: &mut dyn SqlWriter,
228 namespace: &str,
229 profile_id: &str,
230 event_kind: &str,
231 payload: &Value,
232 created_at_us: i64,
233) -> Result<(), RuntimeError> {
234 let payload_str = serde_json::to_string(payload).map_err(|e| sql_err("serialize event", e))?;
235
236 writer
237 .execute(SqlStatement {
238 sql: "INSERT INTO brain_event_log (profile_id, namespace, event_kind, payload, created_at) VALUES (?1, ?2, ?3, ?4, ?5)".into(),
239 params: vec![
240 SqlValue::Text(profile_id.to_string()),
241 SqlValue::Text(namespace.to_string()),
242 SqlValue::Text(event_kind.to_string()),
243 SqlValue::Text(payload_str),
244 SqlValue::Integer(created_at_us),
245 ],
246 label: Some("brain_event_log_append".into()),
247 })
248 .await
249 .map_err(|e| sql_err("append event", e))?;
250
251 Ok(())
252}
253
254async fn upsert_snapshot_on_writer(
257 writer: &mut dyn SqlWriter,
258 namespace: &str,
259 snapshot: &BrainStateSnapshot,
260 updated_at_us: i64,
261) -> Result<(), RuntimeError> {
262 let snapshot_json =
263 serde_json::to_string(snapshot).map_err(|e| sql_err("serialize snapshot", e))?;
264
265 writer
266 .execute(SqlStatement {
267 sql: "INSERT INTO brain_profile_snapshots (profile_id, namespace, snapshot_json, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(profile_id, namespace) DO UPDATE SET snapshot_json = excluded.snapshot_json, updated_at = excluded.updated_at".into(),
268 params: vec![
269 SqlValue::Text(SNAPSHOT_PROFILE_ID.to_string()),
270 SqlValue::Text(namespace.to_string()),
271 SqlValue::Text(snapshot_json),
272 SqlValue::Integer(updated_at_us),
273 ],
274 label: Some("brain_snapshot_upsert".into()),
275 })
276 .await
277 .map_err(|e| sql_err("upsert snapshot", e))?;
278
279 Ok(())
280}
281
282pub async fn append_brain_event(
283 sql: &dyn SqlAccess,
284 namespace: &str,
285 profile_id: &str,
286 event_kind: &str,
287 payload: &Value,
288 created_at_us: i64,
289) -> Result<(), RuntimeError> {
290 let mut writer = sql.writer().await.map_err(|e| sql_err("writer", e))?;
291 append_brain_event_on_writer(
292 writer.as_mut(),
293 namespace,
294 profile_id,
295 event_kind,
296 payload,
297 created_at_us,
298 )
299 .await
300}
301
302pub async fn upsert_snapshot(
303 sql: &dyn SqlAccess,
304 namespace: &str,
305 snapshot: &BrainStateSnapshot,
306 updated_at_us: i64,
307) -> Result<(), RuntimeError> {
308 let mut writer = sql.writer().await.map_err(|e| sql_err("writer", e))?;
309 upsert_snapshot_on_writer(writer.as_mut(), namespace, snapshot, updated_at_us).await
310}
311
312pub struct BrainMutationEvent {
315 pub profile_id: String,
316 pub event_kind: String,
317 pub payload: Value,
318}
319
320pub async fn persist_brain_state_mutation<R>(
336 sql: &dyn SqlAccess,
337 token: &NamespaceToken,
338 tracker: &Mutex<PersistenceTracker>,
339 state: &Mutex<BrainState>,
340 event: BrainMutationEvent,
341 entity_capacity: usize,
342 mutate: impl FnOnce(&mut BrainState) -> Result<R, RuntimeError>,
343) -> Result<R, RuntimeError> {
344 let namespace = token.namespace().as_str().to_string();
345 let now_us = chrono::Utc::now().timestamp_micros();
346
347 let mut proposed = {
348 let current = state.lock().unwrap();
349 BrainState::from_snapshot(current.to_snapshot(), entity_capacity)
350 };
351
352 let result = mutate(&mut proposed)?;
353 let snapshot = proposed.to_snapshot();
354
355 let namespace_for_op = namespace.clone();
356 let profile_id = event.profile_id;
357 let event_kind = event.event_kind;
358 let payload = event.payload;
359 let op: khive_storage::AtomicUnitOp = Box::new(move |writer| {
360 Box::pin(async move {
361 append_brain_event_on_writer(
362 writer,
363 &namespace_for_op,
364 &profile_id,
365 &event_kind,
366 &payload,
367 now_us,
368 )
369 .await
370 .map_err(|e| {
371 khive_storage::StorageError::driver(
372 khive_storage::StorageCapability::Sql,
373 "brain_persist_append_event",
374 e,
375 )
376 })?;
377 upsert_snapshot_on_writer(writer, &namespace_for_op, &snapshot, now_us)
378 .await
379 .map_err(|e| {
380 khive_storage::StorageError::driver(
381 khive_storage::StorageCapability::Sql,
382 "brain_persist_upsert_snapshot",
383 e,
384 )
385 })?;
386 Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
387 })
388 });
389
390 sql.atomic_unit(op).await?;
391
392 {
393 let mut live = state.lock().unwrap();
394 *live = proposed;
395 }
396 {
397 let mut t = tracker.lock().unwrap();
398 t.reset_dirty(&namespace);
399 t.mark_loaded(namespace);
400 }
401
402 Ok(result)
403}
404
405pub async fn load_latest_snapshot(
406 sql: &dyn SqlAccess,
407 namespace: &str,
408 entity_capacity: usize,
409) -> Result<Option<(BrainStateSnapshot, i64)>, RuntimeError> {
410 let mut reader = sql.reader().await.map_err(|e| sql_err("reader", e))?;
411 let row = reader
412 .query_row(SqlStatement {
413 sql: "SELECT snapshot_json, updated_at FROM brain_profile_snapshots WHERE profile_id = ?1 AND namespace = ?2 ORDER BY updated_at DESC LIMIT 1".into(),
414 params: vec![
415 SqlValue::Text(SNAPSHOT_PROFILE_ID.to_string()),
416 SqlValue::Text(namespace.to_string()),
417 ],
418 label: Some("brain_snapshot_load".into()),
419 })
420 .await
421 .map_err(|e| sql_err("load snapshot", e))?;
422
423 match row {
424 None => Ok(None),
425 Some(row) => {
426 let json_str = match row.get("snapshot_json") {
427 Some(SqlValue::Text(s)) => s.clone(),
428 _ => return Err(sql_err("load snapshot", "missing snapshot_json column")),
429 };
430 let updated_at = match row.get("updated_at") {
431 Some(SqlValue::Integer(n)) => *n,
432 _ => return Err(sql_err("load snapshot", "missing updated_at column")),
433 };
434 let snapshot: BrainStateSnapshot =
435 serde_json::from_str(&json_str).map_err(|e| sql_err("deserialize snapshot", e))?;
436 validate_brain_state_snapshot_with_capacity(&snapshot, entity_capacity)
437 .map_err(|e| sql_err("snapshot invariant violation", e))?;
438 Ok(Some((snapshot, updated_at)))
439 }
440 }
441}
442
443pub struct QuarantinedRow {
446 pub id: i64,
448 pub profile_id: String,
450 pub created_at: i64,
452 pub reason: String,
454 pub payload_snippet: String,
456}
457
458pub struct LoadEventsResult {
460 pub events: Vec<khive_storage::event::Event>,
461 pub quarantined: Vec<QuarantinedRow>,
464}
465
466impl LoadEventsResult {
467 pub fn quarantine_count(&self) -> usize {
469 self.quarantined.len()
470 }
471}
472
473pub async fn load_events_since(
474 sql: &dyn SqlAccess,
475 namespace: &str,
476 since_us: i64,
477) -> Result<LoadEventsResult, RuntimeError> {
478 let mut reader = sql.reader().await.map_err(|e| sql_err("reader", e))?;
479 let rows = reader
480 .query_all(SqlStatement {
481 sql: "SELECT id, profile_id, event_kind, payload, created_at \
482 FROM brain_event_log \
483 WHERE namespace = ?1 AND created_at > ?2 \
484 ORDER BY created_at ASC, id ASC"
485 .into(),
486 params: vec![
487 SqlValue::Text(namespace.to_string()),
488 SqlValue::Integer(since_us),
489 ],
490 label: Some("brain_events_replay".into()),
491 })
492 .await
493 .map_err(|e| sql_err("load events", e))?;
494
495 let mut events = Vec::with_capacity(rows.len());
496 let mut quarantined: Vec<QuarantinedRow> = Vec::new();
497
498 for row in &rows {
499 let row_id = match row.get("id") {
500 Some(SqlValue::Integer(i)) => *i,
501 _ => 0,
502 };
503 let profile_id = match row.get("profile_id") {
504 Some(SqlValue::Text(s)) => s.clone(),
505 _ => String::new(),
506 };
507 let created_at = match row.get("created_at") {
508 Some(SqlValue::Integer(i)) => *i,
509 _ => 0,
510 };
511
512 let mut push_quarantine = |reason: String, payload_raw: &str| {
513 let snippet = if payload_raw.len() > 200 {
514 let end = payload_raw.floor_char_boundary(200);
515 format!("{}…", &payload_raw[..end])
516 } else {
517 payload_raw.to_string()
518 };
519 eprintln!(
520 "[brain] event-log replay: quarantined row id={row_id} profile={profile_id:?}: {reason}"
521 );
522 quarantined.push(QuarantinedRow {
523 id: row_id,
524 profile_id: profile_id.clone(),
525 created_at,
526 reason,
527 payload_snippet: snippet,
528 });
529 };
530
531 let payload_str = match row.get("payload") {
532 Some(SqlValue::Text(s)) => s,
533 _ => {
534 push_quarantine("missing or non-text payload column".into(), "");
535 continue;
536 }
537 };
538 let event = match serde_json::from_str::<khive_storage::event::Event>(payload_str) {
539 Ok(ev) => ev,
540 Err(e) => {
541 push_quarantine(format!("malformed event JSON: {e}"), payload_str);
542 continue;
543 }
544 };
545 if event.verb == "brain.feedback" {
549 if let Some(ss) = event.payload.get("section_signals") {
550 if let Err(e) = crate::validate_section_signals(ss) {
551 push_quarantine(
552 format!("semantically invalid section_signals: {e}"),
553 payload_str,
554 );
555 continue;
556 }
557 }
558 }
559 events.push(event);
560 }
561 if !quarantined.is_empty() {
562 eprintln!(
563 "[brain] event-log replay: {} row(s) quarantined out of {} total; \
564 replayed {} clean event(s)",
565 quarantined.len(),
566 rows.len(),
567 events.len()
568 );
569 }
570 Ok(LoadEventsResult {
571 events,
572 quarantined,
573 })
574}
575
576pub async fn ensure_loaded(
577 runtime: &KhiveRuntime,
578 token: &NamespaceToken,
579 tracker: &Mutex<PersistenceTracker>,
580 state: &Mutex<BrainState>,
581 entity_capacity: usize,
582) -> Result<(), RuntimeError> {
583 let namespace = token.namespace().as_str().to_string();
584
585 {
586 let t = tracker.lock().unwrap();
587 if t.is_active(&namespace) {
588 return Ok(());
589 }
590 }
591
592 let already_loaded = {
593 let t = tracker.lock().unwrap();
594 t.is_loaded(&namespace)
595 };
596
597 let brain_state: Option<BrainState> = if already_loaded {
598 None
599 } else {
600 let sql = runtime.sql();
601 let snapshot_result =
602 load_latest_snapshot(sql.as_ref(), &namespace, entity_capacity).await?;
603
604 let bs = if let Some((snapshot, updated_at)) = snapshot_result {
605 let replay_result = load_events_since(sql.as_ref(), &namespace, updated_at).await?;
606
607 let mut bs = BrainState::from_snapshot(snapshot, entity_capacity);
608
609 for event in &replay_result.events {
610 let signal = interpret(event);
611 bs.balanced_recall.apply_signal(&signal);
612
613 let serving_profile = event
614 .payload
615 .get("served_by_profile_id")
616 .and_then(|v| v.as_str())
617 .unwrap_or("balanced-recall-v1");
618
619 {
620 let section_state =
621 crate::ensure_section_state_seeded(&mut bs.section_states, serving_profile);
622 section_state.apply_signal(&signal);
623 }
624 }
625
626 crate::sync_balanced_recall_record(&mut bs);
627 bs
628 } else {
629 BrainState::new(entity_capacity)
630 };
631 Some(bs)
632 };
633
634 #[cfg(test)]
638 if brain_state.is_some() {
639 let hook = POST_LOAD_HOOK.lock().unwrap().take();
640 if let Some(h) = hook {
641 let _ = h.reached_tx.send(());
643 let _ = h.proceed_rx.await;
645 }
646 }
647
648 {
658 let mut t = tracker.lock().unwrap();
659
660 if t.is_active(&namespace) {
664 return Ok(());
665 }
666
667 let fresh_brain_state = if t.is_loaded(&namespace) {
673 None
674 } else {
675 brain_state
676 };
677
678 let current_ns = t.active_namespace.clone();
679
680 let new_state = {
684 let current_state = state.lock().unwrap();
685
686 if let Some(ref from_ns) = current_ns {
687 let saved_current = BrainState {
688 profiles: current_state.profiles.clone(),
689 balanced_recall: khive_brain_core::BalancedRecallState::from_snapshot(
690 current_state.balanced_recall.to_snapshot(),
691 entity_capacity,
692 ),
693 profile_states: current_state
694 .profile_states
695 .iter()
696 .map(|(k, v)| {
697 (
698 k.clone(),
699 khive_brain_core::BalancedRecallState::from_snapshot(
700 v.to_snapshot(),
701 entity_capacity,
702 ),
703 )
704 })
705 .collect(),
706 bindings: current_state.bindings.clone(),
707 section_states: current_state
708 .section_states
709 .iter()
710 .map(|(k, v)| {
711 (
712 k.clone(),
713 khive_brain_core::SectionPosteriorState::from_snapshot(
714 v.to_snapshot(),
715 ),
716 )
717 })
718 .collect(),
719 router_state: current_state.router_state.clone(),
720 adapter_set: current_state.adapter_set.clone(),
721 };
722 drop(current_state);
725
726 let restored = t.swap_namespace(from_ns, saved_current, namespace.clone());
727 fresh_brain_state
728 .or(restored)
729 .unwrap_or_else(|| BrainState::new(entity_capacity))
730 } else {
731 drop(current_state);
732 fresh_brain_state.unwrap_or_else(|| BrainState::new(entity_capacity))
735 }
736 };
737
738 let pending = t.drain_pending_signals(&namespace);
744 let mut final_state = new_state;
745 for sig in &pending {
746 final_state.balanced_recall.apply_signal(sig);
747 }
748 if !pending.is_empty() {
749 crate::sync_balanced_recall_record(&mut final_state);
750 }
751
752 *state.lock().unwrap() = final_state;
756
757 if current_ns.is_none() {
760 t.active_namespace = Some(namespace.clone());
761 }
762
763 t.loaded_namespaces.insert(namespace, ());
764 }
765
766 Ok(())
767}
768
769#[cfg(test)]
772mod brain_007_replay_quarantine {
773 use super::*;
774 use khive_brain_core::BrainState;
775 use khive_runtime::{KhiveRuntime, Namespace};
776 use khive_storage::event::Event;
777 use khive_types::{EventKind, SubstrateKind};
778 use uuid::Uuid;
779
780 async fn insert_raw_payload_at(
781 rt: &KhiveRuntime,
782 namespace: &str,
783 payload: &str,
784 created_at: i64,
785 ) {
786 let sql = rt.sql();
787 let mut writer = sql.writer().await.expect("writer");
788 writer
789 .execute(SqlStatement {
790 sql: "INSERT INTO brain_event_log (profile_id, namespace, event_kind, payload, created_at) VALUES (?1, ?2, ?3, ?4, ?5)".into(),
791 params: vec![
792 SqlValue::Text("test-profile".to_string()),
793 SqlValue::Text(namespace.to_string()),
794 SqlValue::Text("brain.feedback".to_string()),
795 SqlValue::Text(payload.to_string()),
796 SqlValue::Integer(created_at),
797 ],
798 label: None,
799 })
800 .await
801 .expect("insert raw row");
802 }
803
804 async fn insert_raw_payload(rt: &KhiveRuntime, namespace: &str, payload: &str) {
805 insert_raw_payload_at(rt, namespace, payload, 1_000_000).await;
806 }
807
808 fn make_valid_event_json(namespace: &str) -> String {
809 let ev = Event::new(
810 namespace,
811 "recall",
812 EventKind::Audit,
813 SubstrateKind::Note,
814 "brain",
815 );
816 serde_json::to_string(&ev).expect("serialize event")
817 }
818
819 fn make_feedback_event_json(
821 namespace: &str,
822 section_signals: Option<serde_json::Value>,
823 ) -> String {
824 let mut ev = Event::new(
825 namespace,
826 "brain.feedback",
827 EventKind::Audit,
828 SubstrateKind::Event,
829 "brain",
830 );
831 ev.target_id = Some(Uuid::new_v4());
832 let mut payload = serde_json::json!({"signal": "useful"});
833 if let Some(ss) = section_signals {
834 payload["section_signals"] = ss;
835 }
836 ev.payload = payload;
837 serde_json::to_string(&ev).expect("serialize feedback event")
838 }
839
840 #[tokio::test]
843 async fn malformed_json_rows_are_quarantined_not_panicked() {
844 let rt = KhiveRuntime::memory().expect("memory runtime");
845 let token = rt.authorize(Namespace::local()).expect("token");
846 let ns = token.namespace().as_str();
847 let sql = rt.sql();
848
849 insert_raw_payload(&rt, ns, "this is not valid json {{").await;
851 insert_raw_payload(&rt, ns, &make_valid_event_json(ns)).await;
852
853 let result = load_events_since(sql.as_ref(), ns, 0)
855 .await
856 .expect("load must not fail on malformed rows");
857
858 assert_eq!(
860 result.events.len(),
861 1,
862 "one valid event expected; malformed row must be quarantined, not panic"
863 );
864 assert_eq!(result.quarantine_count(), 1, "quarantine_count must be 1");
865 }
866
867 #[tokio::test]
868 async fn all_malformed_rows_quarantined_returns_empty_vec() {
869 let rt = KhiveRuntime::memory().expect("memory runtime");
870 let token = rt.authorize(Namespace::local()).expect("token");
871 let ns = token.namespace().as_str();
872 let sql = rt.sql();
873
874 insert_raw_payload(&rt, ns, "bad json").await;
875 insert_raw_payload(&rt, ns, "{invalid}").await;
876
877 let result = load_events_since(sql.as_ref(), ns, 0)
878 .await
879 .expect("load must succeed even when all rows are malformed");
880
881 assert!(
882 result.events.is_empty(),
883 "all malformed rows must be quarantined"
884 );
885 assert_eq!(result.quarantine_count(), 2, "quarantine_count must be 2");
886 }
887
888 #[tokio::test]
889 async fn clean_rows_replay_without_quarantine() {
890 let rt = KhiveRuntime::memory().expect("memory runtime");
891 let token = rt.authorize(Namespace::local()).expect("token");
892 let ns = token.namespace().as_str();
893 let sql = rt.sql();
894
895 for _ in 0..3 {
896 insert_raw_payload(&rt, ns, &make_valid_event_json(ns)).await;
897 }
898
899 let result = load_events_since(sql.as_ref(), ns, 0)
900 .await
901 .expect("clean rows must replay without error");
902
903 assert_eq!(result.events.len(), 3, "all 3 clean rows must be returned");
904 assert_eq!(result.quarantine_count(), 0, "quarantine_count must be 0");
905 }
906
907 #[tokio::test]
910 async fn empty_section_signals_quarantined() {
911 let rt = KhiveRuntime::memory().expect("memory runtime");
912 let token = rt.authorize(Namespace::local()).expect("token");
913 let ns = token.namespace().as_str();
914 let sql = rt.sql();
915
916 let poison = make_feedback_event_json(ns, Some(serde_json::json!({})));
918 insert_raw_payload(&rt, ns, &poison).await;
919
920 let result = load_events_since(sql.as_ref(), ns, 0)
921 .await
922 .expect("load must not fail");
923
924 assert!(
925 result.events.is_empty(),
926 "empty section_signals must be quarantined"
927 );
928 assert_eq!(result.quarantine_count(), 1);
929 }
930
931 #[tokio::test]
932 async fn unknown_section_signals_quarantined() {
933 let rt = KhiveRuntime::memory().expect("memory runtime");
934 let token = rt.authorize(Namespace::local()).expect("token");
935 let ns = token.namespace().as_str();
936 let sql = rt.sql();
937
938 let poison = make_feedback_event_json(
940 ns,
941 Some(serde_json::json!({"not_a_real_section": "useful"})),
942 );
943 insert_raw_payload(&rt, ns, &poison).await;
944
945 let result = load_events_since(sql.as_ref(), ns, 0)
946 .await
947 .expect("load must not fail");
948
949 assert!(
950 result.events.is_empty(),
951 "unknown section must be quarantined"
952 );
953 assert_eq!(result.quarantine_count(), 1);
954 }
955
956 #[tokio::test]
957 async fn semantic_signal_in_section_signals_quarantined() {
958 let rt = KhiveRuntime::memory().expect("memory runtime");
959 let token = rt.authorize(Namespace::local()).expect("token");
960 let ns = token.namespace().as_str();
961 let sql = rt.sql();
962
963 let poison = make_feedback_event_json(
966 ns,
967 Some(serde_json::json!({"overview": "explicit_positive"})),
968 );
969 insert_raw_payload(&rt, ns, &poison).await;
970
971 let result = load_events_since(sql.as_ref(), ns, 0)
972 .await
973 .expect("load must not fail");
974
975 assert!(
976 result.events.is_empty(),
977 "semantic signal in section_signals must be quarantined"
978 );
979 assert_eq!(result.quarantine_count(), 1);
980 }
981
982 async fn seed_snapshot(rt: &KhiveRuntime, namespace: &str) -> BrainStateSnapshot {
987 let state = BrainState::new(16);
988 let snapshot = state.to_snapshot();
989 let sql = rt.sql();
990 upsert_snapshot(sql.as_ref(), namespace, &snapshot, 500_000)
991 .await
992 .expect("seed snapshot");
993 snapshot
994 }
995
996 fn assert_section_posteriors_at_baseline(state: &BrainState, baseline: &BrainState) {
1001 for key in state.section_states.keys() {
1002 let s = &state.section_states[key];
1003 let b = &baseline.section_states[key];
1004 assert_eq!(
1005 s.total_events, b.total_events,
1006 "section_states[{key}].total_events changed; bad row must not advance section state"
1007 );
1008 assert_eq!(
1009 s.exploration_epoch, b.exploration_epoch,
1010 "section_states[{key}].exploration_epoch changed; bad row must not advance section state"
1011 );
1012 for (st, p) in &s.posteriors {
1013 let bp = &b.posteriors[st];
1014 assert!(
1015 (p.alpha() - bp.alpha()).abs() < 1e-12
1016 && (p.beta() - bp.beta()).abs() < 1e-12,
1017 "section posterior for {:?} changed: got alpha={} beta={}, expected alpha={} beta={}; \
1018 bad row must not mutate posteriors",
1019 st, p.alpha(), p.beta(), bp.alpha(), bp.beta()
1020 );
1021 }
1022 }
1023 }
1024
1025 #[tokio::test]
1026 async fn bad_row_first_does_not_mutate_posterior_state() {
1027 let rt = KhiveRuntime::memory().expect("memory runtime");
1028 let token = rt.authorize(Namespace::local()).expect("token");
1029 let ns = token.namespace().as_str();
1030
1031 seed_snapshot(&rt, ns).await;
1032
1033 let poison =
1035 make_feedback_event_json(ns, Some(serde_json::json!({"overview": "correction"})));
1036 insert_raw_payload_at(&rt, ns, &poison, 600_001).await;
1037
1038 insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_002).await;
1040
1041 let sql = rt.sql();
1043 let result = load_events_since(sql.as_ref(), ns, 500_000)
1044 .await
1045 .expect("load must not fail");
1046
1047 assert_eq!(
1048 result.quarantine_count(),
1049 1,
1050 "bad first row must be quarantined"
1051 );
1052 assert_eq!(result.events.len(), 1, "one clean event must pass through");
1053
1054 let baseline = BrainState::new(16);
1056 let mut state = BrainState::new(16);
1057 for event in &result.events {
1058 let signal = crate::event::interpret(event);
1059 state.balanced_recall.apply_signal(&signal);
1060 for section_state in state.section_states.values_mut() {
1061 section_state.apply_signal(&signal);
1062 }
1063 }
1064 assert_section_posteriors_at_baseline(&state, &baseline);
1066 }
1067
1068 #[tokio::test]
1069 async fn bad_row_last_does_not_mutate_posterior_state() {
1070 let rt = KhiveRuntime::memory().expect("memory runtime");
1071 let token = rt.authorize(Namespace::local()).expect("token");
1072 let ns = token.namespace().as_str();
1073
1074 seed_snapshot(&rt, ns).await;
1075
1076 insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_001).await;
1078 let poison = make_feedback_event_json(ns, Some(serde_json::json!({})));
1080 insert_raw_payload_at(&rt, ns, &poison, 600_002).await;
1081
1082 let sql = rt.sql();
1083 let result = load_events_since(sql.as_ref(), ns, 500_000)
1084 .await
1085 .expect("load must not fail");
1086
1087 assert_eq!(
1088 result.quarantine_count(),
1089 1,
1090 "bad last row must be quarantined"
1091 );
1092 assert_eq!(result.events.len(), 1, "one clean event must pass through");
1093
1094 let baseline = BrainState::new(16);
1095 let mut state = BrainState::new(16);
1096 for event in &result.events {
1097 let signal = crate::event::interpret(event);
1098 state.balanced_recall.apply_signal(&signal);
1099 for section_state in state.section_states.values_mut() {
1100 section_state.apply_signal(&signal);
1101 }
1102 }
1103 assert_section_posteriors_at_baseline(&state, &baseline);
1104 }
1105
1106 #[tokio::test]
1107 async fn bad_rows_interleaved_do_not_mutate_posterior_state() {
1108 let rt = KhiveRuntime::memory().expect("memory runtime");
1109 let token = rt.authorize(Namespace::local()).expect("token");
1110 let ns = token.namespace().as_str();
1111
1112 seed_snapshot(&rt, ns).await;
1113
1114 insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_001).await;
1116 let p1 = make_feedback_event_json(
1117 ns,
1118 Some(serde_json::json!({"overview": "explicit_negative"})),
1119 );
1120 insert_raw_payload_at(&rt, ns, &p1, 600_002).await;
1121 insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_003).await;
1122 let p2 = make_feedback_event_json(ns, Some(serde_json::json!({})));
1123 insert_raw_payload_at(&rt, ns, &p2, 600_004).await;
1124 insert_raw_payload_at(&rt, ns, &make_valid_event_json(ns), 600_005).await;
1125
1126 let sql = rt.sql();
1127 let result = load_events_since(sql.as_ref(), ns, 500_000)
1128 .await
1129 .expect("load must not fail");
1130
1131 assert_eq!(
1132 result.quarantine_count(),
1133 2,
1134 "2 bad rows must be quarantined"
1135 );
1136 assert_eq!(result.events.len(), 3, "3 clean rows must pass through");
1137
1138 let baseline = BrainState::new(16);
1140 let mut state = BrainState::new(16);
1141 for event in &result.events {
1142 let signal = crate::event::interpret(event);
1143 state.balanced_recall.apply_signal(&signal);
1144 for section_state in state.section_states.values_mut() {
1145 section_state.apply_signal(&signal);
1146 }
1147 }
1148 assert_section_posteriors_at_baseline(&state, &baseline);
1149 }
1150
1151 #[tokio::test]
1154 async fn quarantined_rows_return_id_and_reason() {
1155 let rt = KhiveRuntime::memory().expect("memory runtime");
1156 let token = rt.authorize(Namespace::local()).expect("token");
1157 let ns = token.namespace().as_str();
1158 let sql = rt.sql();
1159
1160 insert_raw_payload(&rt, ns, "not json at all").await;
1162 let poison = make_feedback_event_json(ns, Some(serde_json::json!({})));
1163 insert_raw_payload(&rt, ns, &poison).await;
1164
1165 let result = load_events_since(sql.as_ref(), ns, 0)
1166 .await
1167 .expect("load must not fail");
1168
1169 assert_eq!(
1170 result.quarantine_count(),
1171 2,
1172 "both bad rows must be quarantined"
1173 );
1174 assert!(result.events.is_empty(), "no clean events expected");
1175
1176 for qr in &result.quarantined {
1179 assert!(
1180 qr.id > 0,
1181 "quarantined row id must be a real autoincrement id; got {}",
1182 qr.id
1183 );
1184 assert!(
1185 !qr.reason.is_empty(),
1186 "quarantined row must carry a non-empty reason string"
1187 );
1188 assert_eq!(
1189 qr.profile_id, "test-profile",
1190 "quarantined row profile_id must match inserted value; got {:?}",
1191 qr.profile_id
1192 );
1193 assert!(
1194 qr.created_at > 0,
1195 "quarantined row created_at must be non-zero; got {}",
1196 qr.created_at
1197 );
1198 assert!(
1199 !qr.payload_snippet.is_empty(),
1200 "quarantined row payload_snippet must be non-empty"
1201 );
1202 }
1203
1204 assert!(
1206 result.quarantined[0].reason.contains("malformed")
1207 || result.quarantined[0].reason.contains("JSON")
1208 || result.quarantined[0].reason.contains("json"),
1209 "first quarantine reason must describe malformed JSON; got: {:?}",
1210 result.quarantined[0].reason
1211 );
1212 assert!(
1214 result.quarantined[0].payload_snippet.contains("not json"),
1215 "first row snippet must contain 'not json'; got: {:?}",
1216 result.quarantined[0].payload_snippet
1217 );
1218
1219 assert!(
1221 result.quarantined[1].reason.contains("section_signals"),
1222 "second quarantine reason must mention section_signals; got: {:?}",
1223 result.quarantined[1].reason
1224 );
1225 assert!(
1227 !result.quarantined[1].payload_snippet.is_empty(),
1228 "second row payload_snippet must be non-empty; got: {:?}",
1229 result.quarantined[1].payload_snippet
1230 );
1231 }
1232
1233 #[tokio::test]
1236 async fn payload_snippet_truncation_safe_on_multibyte_chars() {
1237 let rt = KhiveRuntime::memory().expect("memory runtime");
1238 let token = rt.authorize(Namespace::local()).expect("token");
1239 let ns = token.namespace().as_str();
1240 let sql = rt.sql();
1241
1242 let long_multibyte: String = "日".repeat(250);
1245 insert_raw_payload(&rt, ns, &long_multibyte).await;
1246
1247 let result = load_events_since(sql.as_ref(), ns, 0)
1249 .await
1250 .expect("load must not fail");
1251
1252 assert_eq!(result.quarantine_count(), 1);
1253 let qr = &result.quarantined[0];
1254
1255 assert!(
1258 !qr.payload_snippet.is_empty(),
1259 "snippet must be non-empty for a non-empty payload"
1260 );
1261 let snippet_body = qr.payload_snippet.trim_end_matches('…');
1263 assert!(
1264 snippet_body.len() <= 200,
1265 "snippet body (sans ellipsis) must be ≤200 bytes; got {} bytes",
1266 snippet_body.len()
1267 );
1268 assert!(
1271 snippet_body.chars().all(|c| c == '日'),
1272 "snippet body must contain only '日' chars; got: {:?}",
1273 snippet_body
1274 );
1275 }
1276}
1277
1278#[cfg(test)]
1281mod braincore_aud_001_capacity {
1282 use uuid::Uuid;
1283
1284 use super::*;
1285 use khive_brain_core::BetaPosterior;
1286 use khive_runtime::{KhiveRuntime, Namespace};
1287
1288 #[tokio::test]
1293 async fn oversized_brain_snapshot_load_returns_runtime_error() {
1294 let rt = KhiveRuntime::memory().expect("memory runtime");
1295 let token = rt.authorize(Namespace::local()).expect("token");
1296 let ns = token.namespace().as_str();
1297 let sql = rt.sql();
1298
1299 let capacity = 2;
1300 let mut state = BrainState::new(capacity);
1301 for _ in 0..capacity {
1302 state
1303 .balanced_recall
1304 .entity_posteriors
1305 .get_or_insert(Uuid::new_v4(), BetaPosterior::default);
1306 }
1307 let mut snapshot = state.to_snapshot();
1308 snapshot
1311 .balanced_recall
1312 .entity_posteriors
1313 .insert(Uuid::new_v4(), BetaPosterior::default());
1314
1315 upsert_snapshot(sql.as_ref(), ns, &snapshot, 500_000)
1316 .await
1317 .expect("seed oversized snapshot");
1318
1319 let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1320 assert!(
1321 result.is_err(),
1322 "snapshot with entity_posteriors.len() > capacity must be rejected at load"
1323 );
1324 let err = result.unwrap_err().to_string();
1325 assert!(
1326 err.contains("snapshot invariant violation"),
1327 "error must name the invariant-violation load boundary, got: {err}"
1328 );
1329 }
1330
1331 #[tokio::test]
1333 async fn in_capacity_brain_snapshot_load_succeeds() {
1334 let rt = KhiveRuntime::memory().expect("memory runtime");
1335 let token = rt.authorize(Namespace::local()).expect("token");
1336 let ns = token.namespace().as_str();
1337 let sql = rt.sql();
1338
1339 let capacity = 2;
1340 let state = BrainState::new(capacity);
1341 let snapshot = state.to_snapshot();
1342 upsert_snapshot(sql.as_ref(), ns, &snapshot, 500_000)
1343 .await
1344 .expect("seed snapshot");
1345
1346 let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1347 assert!(result.is_ok(), "in-capacity snapshot must load cleanly");
1348 }
1349
1350 #[tokio::test]
1357 async fn version_zero_snapshot_with_explicit_partial_order_rejected_at_load() {
1358 let rt = KhiveRuntime::memory().expect("memory runtime");
1359 let token = rt.authorize(Namespace::local()).expect("token");
1360 let ns = token.namespace().as_str();
1361 let sql = rt.sql();
1362
1363 let capacity = 10;
1364 let mut state = BrainState::new(capacity);
1365 let a = Uuid::new_v4();
1366 let b = Uuid::new_v4();
1367 state
1368 .balanced_recall
1369 .entity_posteriors
1370 .get_or_insert(a, BetaPosterior::default);
1371 state
1372 .balanced_recall
1373 .entity_posteriors
1374 .get_or_insert(b, BetaPosterior::default);
1375
1376 let mut snapshot = state.to_snapshot();
1377 snapshot.balanced_recall.entity_posteriors_version = 0;
1378 snapshot.balanced_recall.entity_posterior_order = vec![a];
1379
1380 upsert_snapshot(sql.as_ref(), ns, &snapshot, 500_000)
1381 .await
1382 .expect("seed corrupt version-0 snapshot");
1383
1384 let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1385 assert!(
1386 result.is_err(),
1387 "version-0 snapshot with non-empty partial order must be rejected at load, not normalized"
1388 );
1389 let err = result.unwrap_err().to_string();
1390 assert!(
1391 err.contains("snapshot invariant violation"),
1392 "error must name the load-boundary invariant violation, got: {err}"
1393 );
1394 }
1395
1396 #[tokio::test]
1403 async fn version_omitted_snapshot_with_partial_order_rejected_at_load() {
1404 let rt = KhiveRuntime::memory().expect("memory runtime");
1405 let token = rt.authorize(Namespace::local()).expect("token");
1406 let ns = token.namespace().as_str();
1407 let sql = rt.sql();
1408
1409 let capacity = 10;
1410 let mut state = BrainState::new(capacity);
1411 let a = Uuid::new_v4();
1412 let b = Uuid::new_v4();
1413 state
1414 .balanced_recall
1415 .entity_posteriors
1416 .get_or_insert(a, BetaPosterior::default);
1417 state
1418 .balanced_recall
1419 .entity_posteriors
1420 .get_or_insert(b, BetaPosterior::default);
1421
1422 let mut snapshot = state.to_snapshot();
1423 snapshot.balanced_recall.entity_posterior_order = vec![a];
1424
1425 let mut json = serde_json::to_value(&snapshot).expect("serialize snapshot");
1426 json["balanced_recall"]
1427 .as_object_mut()
1428 .expect("balanced_recall is an object")
1429 .remove("entity_posteriors_version");
1430 let json_str = serde_json::to_string(&json).expect("re-serialize snapshot");
1431
1432 let reparsed: BrainStateSnapshot =
1436 serde_json::from_str(&json_str).expect("deserialize snapshot with omitted version");
1437 assert_eq!(
1438 reparsed.balanced_recall.entity_posteriors_version, 0,
1439 "omitted entity_posteriors_version must serde-default to 0"
1440 );
1441
1442 let mut writer = sql.writer().await.expect("writer");
1443 writer
1444 .execute(SqlStatement {
1445 sql: "INSERT INTO brain_profile_snapshots (profile_id, namespace, snapshot_json, updated_at) VALUES (?1, ?2, ?3, ?4)".into(),
1446 params: vec![
1447 SqlValue::Text(SNAPSHOT_PROFILE_ID.to_string()),
1448 SqlValue::Text(ns.to_string()),
1449 SqlValue::Text(json_str),
1450 SqlValue::Integer(500_000),
1451 ],
1452 label: None,
1453 })
1454 .await
1455 .expect("insert raw snapshot row with omitted version");
1456 drop(writer);
1457
1458 let result = load_latest_snapshot(sql.as_ref(), ns, capacity).await;
1459 assert!(
1460 result.is_err(),
1461 "snapshot with omitted entity_posteriors_version and non-empty partial order must be rejected at load"
1462 );
1463 let err = result.unwrap_err().to_string();
1464 assert!(
1465 err.contains("snapshot invariant violation"),
1466 "error must name the load-boundary invariant violation, got: {err}"
1467 );
1468 }
1469}
1470
1471#[cfg(test)]
1475mod persist_write_queue_routing {
1476 use super::*;
1477 use khive_brain_core::BrainState;
1478 use khive_runtime::{KhiveRuntime, Namespace};
1479
1480 #[tokio::test]
1486 async fn persist_brain_state_mutation_routes_through_writer_task_when_flag_enabled() {
1487 let token_rt = KhiveRuntime::memory().expect("memory runtime for token minting");
1490 let token = token_rt
1491 .authorize(Namespace::local())
1492 .expect("authorize local namespace");
1493
1494 let dir = tempfile::tempdir().expect("tempdir");
1497 let db_path = dir.path().join("persist-write-queue-routing.db");
1498 let pool_cfg = khive_db::PoolConfig {
1499 path: Some(db_path),
1500 write_queue_enabled: true,
1501 ..khive_db::PoolConfig::default()
1502 };
1503 let pool = std::sync::Arc::new(khive_db::ConnectionPool::new(pool_cfg).expect("pool"));
1504 {
1505 let mut writer = pool.writer().expect("writer");
1506 khive_db::run_migrations(writer.conn_mut()).expect("migrations");
1507 }
1508 let sql: std::sync::Arc<dyn SqlAccess> =
1509 std::sync::Arc::new(khive_db::SqlBridge::new(std::sync::Arc::clone(&pool), true));
1510
1511 let writer_task = pool
1512 .writer_task_handle()
1513 .unwrap()
1514 .expect("writer task must be spawned with the flag on for a file-backed pool");
1515
1516 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
1517 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
1518 let occupier = {
1519 let writer_task = writer_task.clone();
1520 tokio::spawn(async move {
1521 writer_task
1522 .send(move |_conn| {
1523 let _ = started_tx.send(());
1524 let _ = release_rx.blocking_recv();
1525 Ok::<(), khive_storage::StorageError>(())
1526 })
1527 .await
1528 })
1529 };
1530
1531 started_rx
1532 .await
1533 .expect("occupier must signal it has started running inside the writer task");
1534 assert_eq!(
1535 writer_task.queue_depth(),
1536 0,
1537 "channel must start empty once the occupier has been dequeued and is running"
1538 );
1539
1540 let tracker: Mutex<PersistenceTracker> = Mutex::new(PersistenceTracker::new());
1541 let state: Mutex<BrainState> = Mutex::new(BrainState::new(16));
1542
1543 let persist_task = tokio::spawn(async move {
1544 persist_brain_state_mutation(
1545 sql.as_ref(),
1546 &token,
1547 &tracker,
1548 &state,
1549 BrainMutationEvent {
1550 profile_id: "write-queue-routing-profile".to_string(),
1551 event_kind: "brain.test_mutation".to_string(),
1552 payload: serde_json::json!({"probe": true}),
1553 },
1554 16,
1555 |_state: &mut BrainState| -> Result<(), RuntimeError> { Ok(()) },
1556 )
1557 .await
1558 });
1559
1560 let mut saw_enqueued = false;
1561 for _ in 0..100 {
1562 if writer_task.queue_depth() >= 1 {
1563 saw_enqueued = true;
1564 break;
1565 }
1566 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1567 }
1568 assert!(
1569 saw_enqueued,
1570 "persist_brain_state_mutation's atomic_unit request never appeared in the \
1571 writer task's channel while the occupier held the single drain slot — \
1572 atomic_unit is not routing this call through the shared writer task"
1573 );
1574
1575 release_tx
1576 .send(())
1577 .expect("occupier must still be waiting on the release signal");
1578 occupier
1579 .await
1580 .expect("occupier task must not panic")
1581 .expect("occupier write must succeed");
1582
1583 persist_task
1584 .await
1585 .expect("persist_task must not panic")
1586 .expect("persist_brain_state_mutation must succeed once unblocked");
1587 }
1588}