//! Milestone 7 Task 07: Crash Fencing for M6 State. //! //! Property tests and integration tests verifying that all M6 state surfaces //! survive a clean close + reopen cycle. Each test writes state through the //! public `TidalDb` API, cleanly shuts down (triggering checkpoint), reopens //! from the same data directory, and asserts all state is recoverable. //! //! Surfaces under test: //! 1. `CohortSignalLedger` checkpoint/restore roundtrip. //! 2. `CollectionIndex` persistence across restart. //! 3. `CoEngagement` index checkpoint/restore across restart. //! 4. Active session signal recovery via WAL replay. //! 5. Mixed workload (items + signals + collections + cohorts) restart. #![allow( clippy::unwrap_used, clippy::cast_precision_loss, clippy::too_many_lines )] use std::collections::HashMap; use std::time::Duration; use tidaldb::TidalDb; use tidaldb::cohort::{CohortDef, Predicate}; use tidaldb::entities::Visibility; use tidaldb::schema::{ AgentPolicy, DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window, }; // ── Schema helpers ────────────────────────────────────────────────────────── /// Standard M6 schema with view, like, share signals and all windows enabled. fn m6_schema() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); for &(name, half_life_days) in &[ ("view", 7), ("like", 14), ("share", 7), ("skip", 1), ("completion", 14), ] { let _ = builder .signal( name, EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(half_life_days * 24 * 3600), }, ) .windows(&[ Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::AllTime, ]) .velocity(true) .add(); } builder.build().expect("m6 schema must be valid") } /// Schema with session policy for session recovery tests. fn m6_session_schema() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); for &(name, half_life_days) in &[ ("view", 7), ("like", 14), ("share", 7), ("skip", 1), ("completion", 14), ] { let _ = builder .signal( name, EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(half_life_days * 24 * 3600), }, ) .windows(&[ Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::AllTime, ]) .velocity(true) .add(); } builder.session_policy( "default", AgentPolicy { allowed_signals: vec!["view".to_string(), "like".to_string(), "share".to_string()], denied_signals: vec![], max_session_duration: Duration::from_secs(3600), max_signals_per_session: 1000, }, ); builder.build().expect("m6 session schema must be valid") } fn item_metadata(category: &str, creator_id: u64) -> HashMap { let mut meta = HashMap::new(); meta.insert("category".to_string(), category.to_string()); meta.insert("format".to_string(), "video".to_string()); meta.insert("creator_id".to_string(), creator_id.to_string()); meta.insert( "created_at".to_string(), Timestamp::now().as_nanos().to_string(), ); meta } fn user_metadata(locale: &str, primary_category: &str) -> HashMap { let mut m = HashMap::new(); m.insert("locale".to_string(), locale.to_string()); m.insert("primary_category".to_string(), primary_category.to_string()); m } // ── Test 1: Cohort signal ledger checkpoint/restore roundtrip ─────────────── // // Defines cohorts, writes users and signals with user context, cleanly shuts // down, reopens, and verifies that per-cohort windowed counts and decay scores // are preserved. #[test] fn cohort_ledger_checkpoint_restore_roundtrip() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); // Phase 1: Define cohorts, write signals, checkpoint via close(). { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); // Define two cohorts. db.define_cohort(CohortDef { name: "tech_en".to_string(), predicate: Predicate::And(vec![ Predicate::Eq { field: "locale".into(), value: "en".into(), }, Predicate::Eq { field: "primary_category".into(), value: "tech".into(), }, ]), }) .unwrap(); db.define_cohort(CohortDef { name: "sports".to_string(), predicate: Predicate::Eq { field: "primary_category".into(), value: "sports".into(), }, }) .unwrap(); // Write users. db.write_user(EntityId::new(1001), &user_metadata("en", "tech")) .unwrap(); db.write_user(EntityId::new(1002), &user_metadata("en", "sports")) .unwrap(); // Write items. for i in 1..=5u64 { db.write_item_with_metadata(EntityId::new(i), &item_metadata("tech", 100)) .unwrap(); } let ts = Timestamp::now(); // User 1001 (tech_en) views items 1, 2, 3 (3 signals each attributed to tech_en). for i in 1..=3u64 { db.signal_with_context("view", EntityId::new(i), 1.0, ts, Some(1001), Some(100)) .unwrap(); } // User 1002 (sports) views items 2, 4 (attributed to sports cohort). for &i in &[2u64, 4] { db.signal_with_context("view", EntityId::new(i), 1.0, ts, Some(1002), Some(100)) .unwrap(); } // Verify pre-close state. let ledger = db.cohort_ledger(); let tech_1 = ledger .read_windowed_count("tech_en", EntityId::new(1), "view", Window::AllTime) .unwrap(); assert_eq!(tech_1, 1, "pre-close: tech_en item 1 should have 1 view"); let sports_4 = ledger .read_windowed_count("sports", EntityId::new(4), "view", Window::AllTime) .unwrap(); assert_eq!(sports_4, 1, "pre-close: sports item 4 should have 1 view"); db.close().unwrap(); } // Phase 2: Reopen and verify cohort ledger state survived. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); // Cohort definitions should be restored (define_cohort with same name should fail). let dup_result = db.define_cohort(CohortDef { name: "tech_en".to_string(), predicate: Predicate::Eq { field: "x".into(), value: "y".into(), }, }); assert!( dup_result.is_err(), "tech_en cohort definition should survive restart" ); let dup_result = db.define_cohort(CohortDef { name: "sports".to_string(), predicate: Predicate::Eq { field: "x".into(), value: "y".into(), }, }); assert!( dup_result.is_err(), "sports cohort definition should survive restart" ); // Verify cohort signal state was restored. let ledger = db.cohort_ledger(); // tech_en: items 1, 2, 3 should each have 1 view. for i in 1..=3u64 { let count = ledger .read_windowed_count("tech_en", EntityId::new(i), "view", Window::AllTime) .unwrap(); assert_eq!( count, 1, "post-restart: tech_en item {i} should have 1 view, got {count}" ); } // tech_en: item 4 should have 0 views (only sports user viewed it). let tech_4 = ledger .read_windowed_count("tech_en", EntityId::new(4), "view", Window::AllTime) .unwrap(); assert_eq!( tech_4, 0, "post-restart: tech_en item 4 should have 0 views" ); // sports: items 2, 4 should each have 1 view. for &i in &[2u64, 4] { let count = ledger .read_windowed_count("sports", EntityId::new(i), "view", Window::AllTime) .unwrap(); assert_eq!( count, 1, "post-restart: sports item {i} should have 1 view, got {count}" ); } // sports: item 1 should have 0 views. let sports_1 = ledger .read_windowed_count("sports", EntityId::new(1), "view", Window::AllTime) .unwrap(); assert_eq!( sports_1, 0, "post-restart: sports item 1 should have 0 views" ); // Decay scores should be present and positive. let tech_score = ledger .read_decay_score("tech_en", EntityId::new(1), "view", 0) .unwrap(); assert!( tech_score.is_some(), "post-restart: tech_en item 1 decay score should exist" ); assert!( tech_score.unwrap() > 0.0, "post-restart: tech_en item 1 decay score should be positive" ); db.close().unwrap(); } } // ── Test 2: Collection index persistence across restart ───────────────────── // // Creates collections, adds items, cleanly shuts down, reopens, and verifies // collection membership, item counts, and metadata survive. #[test] fn collection_persistence_across_restart() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); let owner = EntityId::new(42); // Phase 1: Create collections and add items. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); // Create two collections. let cid_1 = db .create_collection(owner, "favorites", Visibility::Private) .unwrap(); let cid_2 = db .create_collection(owner, "watchlist", Visibility::Public) .unwrap(); // Add items to collections. db.add_to_collection(cid_1, EntityId::new(10)).unwrap(); db.add_to_collection(cid_1, EntityId::new(20)).unwrap(); db.add_to_collection(cid_1, EntityId::new(30)).unwrap(); db.add_to_collection(cid_2, EntityId::new(100)).unwrap(); db.add_to_collection(cid_2, EntityId::new(200)).unwrap(); // Verify before close. let collections = db.list_collections(owner).unwrap(); assert_eq!(collections.len(), 2); db.close().unwrap(); } // Phase 2: Reopen and verify collections survived. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let collections = db.list_collections(owner).unwrap(); assert_eq!( collections.len(), 2, "both collections should survive restart" ); // Find favorites and watchlist by name. let fav = collections.iter().find(|c| c.name == "favorites").unwrap(); let watch = collections.iter().find(|c| c.name == "watchlist").unwrap(); assert_eq!(fav.item_count, 3, "favorites should have 3 items"); assert_eq!(fav.visibility, Visibility::Private); assert_eq!(watch.item_count, 2, "watchlist should have 2 items"); assert_eq!(watch.visibility, Visibility::Public); // Verify bitmap membership through the collection index. let index = db.collection_index(); assert!( index.contains(fav.id, 10), "favorites should contain item 10" ); assert!( index.contains(fav.id, 20), "favorites should contain item 20" ); assert!( index.contains(fav.id, 30), "favorites should contain item 30" ); assert!( !index.contains(fav.id, 100), "favorites should NOT contain item 100" ); assert!( index.contains(watch.id, 100), "watchlist should contain item 100" ); assert!( index.contains(watch.id, 200), "watchlist should contain item 200" ); db.close().unwrap(); } } // ── Test 3: Co-engagement index checkpoint/restore ────────────────────────── // // Inserts co-engagement edges, cleanly shuts down (triggering checkpoint), // reopens, and verifies all edge weights are preserved. #[test] fn co_engagement_checkpoint_restore_roundtrip() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); // Phase 1: Build co-engagement edges and checkpoint via close(). { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); // Write items (required for co-engagement to make semantic sense). for id in [10u64, 20, 30, 40] { db.write_item_with_metadata(EntityId::new(id), &item_metadata("jazz", 100)) .unwrap(); } // Build co-engagement edges via direct insert. let co_eng = db.co_engagement(); co_eng.insert_edge(10, 20, 5.0); co_eng.insert_edge(10, 30, 2.5); co_eng.insert_edge(20, 30, 1.0); co_eng.insert_edge(30, 40, 7.5); assert_eq!(co_eng.edge_count(), 4); db.close().unwrap(); } // Phase 2: Reopen and verify co-engagement edges survived. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let co_eng = db.co_engagement(); assert!( co_eng.edge_count() >= 4, "co-engagement edges should survive restart; got {}", co_eng.edge_count() ); // Verify specific edge weights. let score_10_20 = co_eng.score(EntityId::new(10), EntityId::new(20)); assert!( (score_10_20 - 5.0).abs() < f32::EPSILON, "edge (10, 20) should be 5.0 after restore; got {score_10_20}" ); let score_10_30 = co_eng.score(EntityId::new(10), EntityId::new(30)); assert!( (score_10_30 - 2.5).abs() < f32::EPSILON, "edge (10, 30) should be 2.5 after restore; got {score_10_30}" ); let score_20_30 = co_eng.score(EntityId::new(20), EntityId::new(30)); assert!( (score_20_30 - 1.0).abs() < f32::EPSILON, "edge (20, 30) should be 1.0 after restore; got {score_20_30}" ); let score_30_40 = co_eng.score(EntityId::new(30), EntityId::new(40)); assert!( (score_30_40 - 7.5).abs() < f32::EPSILON, "edge (30, 40) should be 7.5 after restore; got {score_30_40}" ); // Reverse direction should still be zero (asymmetric). let reverse = co_eng.score(EntityId::new(20), EntityId::new(10)); assert!( (reverse - 0.0).abs() < f32::EPSILON, "reverse edge (20, 10) should be 0.0; got {reverse}" ); db.close().unwrap(); } } // ── Test 4: Active session signal recovery via WAL ────────────────────────── // // Opens a session, writes session signals, then drops the database WITHOUT // calling close_session (simulating an unclean shutdown). Reopens and verifies // the active session is restored from WAL replay with correct signal counts. #[test] fn active_session_signal_recovery() { let dir = tempfile::tempdir().unwrap(); let schema = m6_session_schema(); let user_id = 99u64; let session_id_raw: u64; // Phase 1: Start session, write signals, close db without closing session. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); // Write some items so signals have targets. for id in 1..=3u64 { db.write_item_with_metadata(EntityId::new(id), &item_metadata("jazz", 100)) .unwrap(); } let handle = db .start_session(user_id, "test-agent", "default", HashMap::new()) .unwrap(); session_id_raw = handle.id.as_u64(); // Write session signals. let ts = Timestamp::now(); db.session_signal(&handle, "view", EntityId::new(1), 1.0, ts, None) .unwrap(); db.session_signal(&handle, "view", EntityId::new(2), 1.0, ts, None) .unwrap(); db.session_signal( &handle, "like", EntityId::new(1), 1.0, ts, Some("great content".to_string()), ) .unwrap(); // Verify the session is active. let active = db.active_sessions(); assert_eq!(active.len(), 1, "one active session before close"); assert_eq!(active[0].signals_written, 3); // Close DB without closing session -- simulates unclean shutdown. // The session WAL events (start + 3 signals) should be written. db.close().unwrap(); } // Phase 2: Reopen and verify session was restored from WAL. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let active = db.active_sessions(); // The session should be restored as active (no Close event in WAL). assert_eq!( active.len(), 1, "session should be restored as active after restart" ); let restored = &active[0]; assert_eq!( restored.id.as_u64(), session_id_raw, "restored session should have the same ID" ); assert_eq!(restored.user_id, user_id, "restored session user_id"); assert_eq!( restored.signals_written, 3, "restored session should have 3 signals replayed" ); // The snapshot should reflect the replayed signals. let snapshot = db .session_snapshot(tidaldb::SessionId::from_raw(session_id_raw)) .unwrap(); assert_eq!( snapshot.signaled_entities.len(), 2, "two distinct entities were signaled (items 1 and 2)" ); assert!( snapshot.signaled_entities.contains(&1), "entity 1 should be in signaled set" ); assert!( snapshot.signaled_entities.contains(&2), "entity 2 should be in signaled set" ); // Check that annotations were replayed. assert!( !snapshot.annotations.is_empty(), "annotation 'great content' should be replayed" ); db.close().unwrap(); } } // ── Test 5: Mixed workload crash recovery ─────────────────────────────────── // // Writes items, signals, collections, cohorts, and co-engagement edges in a // single session. Cleanly closes and reopens. Verifies all state surfaces are // consistent after restart. #[test] fn mixed_workload_crash_recovery() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); let owner = EntityId::new(1); // Phase 1: Write a mixed workload. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); // Define a cohort. db.define_cohort(CohortDef { name: "tech_en".to_string(), predicate: Predicate::And(vec![ Predicate::Eq { field: "locale".into(), value: "en".into(), }, Predicate::Eq { field: "primary_category".into(), value: "tech".into(), }, ]), }) .unwrap(); // Write user for cohort membership. db.write_user(EntityId::new(1001), &user_metadata("en", "tech")) .unwrap(); // Write 10 items. for id in 1..=10u64 { db.write_item_with_metadata(EntityId::new(id), &item_metadata("tech", 100)) .unwrap(); } // Create a collection with some items. let cid = db .create_collection(owner, "best-of", Visibility::Public) .unwrap(); db.add_to_collection(cid, EntityId::new(1)).unwrap(); db.add_to_collection(cid, EntityId::new(5)).unwrap(); db.add_to_collection(cid, EntityId::new(10)).unwrap(); // Write global signals. let ts = Timestamp::now(); for id in 1..=10u64 { db.signal("view", EntityId::new(id), 1.0, ts).unwrap(); } // Extra views for item 1. for _ in 0..4 { db.signal("view", EntityId::new(1), 1.0, ts).unwrap(); } // Write signals with user context (triggers cohort attribution). for i in 1..=3u64 { db.signal_with_context("view", EntityId::new(i), 1.0, ts, Some(1001), Some(100)) .unwrap(); } // Build co-engagement edges. let co_eng = db.co_engagement(); co_eng.insert_edge(1, 2, 3.0); co_eng.insert_edge(1, 5, 1.5); // Save a search. db.save_search(EntityId::new(1001), "my-query", "jazz music", None) .unwrap(); db.close().unwrap(); } // Phase 2: Reopen and verify all state surfaces. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); // 1. Item metadata survives in durable storage. // Note: item_count() reads from the in-memory universe bitmap which // is NOT rebuilt on restart -- it is an ephemeral acceleration index. // Items are durable in fjall; verify via get_item_metadata(). let meta = db.get_item_metadata(EntityId::new(1)).unwrap(); assert!(meta.is_some(), "item 1 metadata should survive restart"); let meta = meta.unwrap(); assert_eq!(meta.get("category").map(String::as_str), Some("tech")); // 3. Global signal state survives (via checkpoint + WAL replay). let count_1 = db .read_windowed_count(EntityId::new(1), "view", Window::AllTime) .unwrap(); // Item 1 had 1 global signal + 4 extra + 1 context signal = 6 total. assert!( count_1 >= 5, "item 1 should have at least 5 views after restart; got {count_1}" ); let score = db.read_decay_score(EntityId::new(1), "view", 0).unwrap(); assert!( score.is_some(), "item 1 decay score should exist after restart" ); assert!( score.unwrap() > 0.0, "item 1 decay score should be positive" ); // 4. Collections survive. let collections = db.list_collections(owner).unwrap(); assert_eq!(collections.len(), 1, "collection should survive restart"); assert_eq!(collections[0].name, "best-of"); assert_eq!(collections[0].item_count, 3); let index = db.collection_index(); assert!(index.contains(collections[0].id, 1)); assert!(index.contains(collections[0].id, 5)); assert!(index.contains(collections[0].id, 10)); assert!(!index.contains(collections[0].id, 2)); // 5. Cohort definitions survive. let dup = db.define_cohort(CohortDef { name: "tech_en".to_string(), predicate: Predicate::Eq { field: "x".into(), value: "y".into(), }, }); assert!(dup.is_err(), "tech_en cohort should survive restart"); // 6. Cohort signal state survives. let cohort_ledger = db.cohort_ledger(); for i in 1..=3u64 { let count = cohort_ledger .read_windowed_count("tech_en", EntityId::new(i), "view", Window::AllTime) .unwrap(); assert_eq!( count, 1, "cohort tech_en item {i} should have 1 view after restart; got {count}" ); } // 7. Co-engagement edges survive. let co_eng = db.co_engagement(); assert!( co_eng.edge_count() >= 2, "co-engagement edges should survive restart; got {}", co_eng.edge_count() ); let score_1_2 = co_eng.score(EntityId::new(1), EntityId::new(2)); assert!( (score_1_2 - 3.0).abs() < f32::EPSILON, "edge (1, 2) should be 3.0; got {score_1_2}" ); let score_1_5 = co_eng.score(EntityId::new(1), EntityId::new(5)); assert!( (score_1_5 - 1.5).abs() < f32::EPSILON, "edge (1, 5) should be 1.5; got {score_1_5}" ); // 8. Saved search survives. let searches = db.list_saved_searches(EntityId::new(1001)).unwrap(); assert_eq!(searches.len(), 1, "saved search should survive restart"); assert_eq!(searches[0].name, "my-query"); assert_eq!(searches[0].query_text, "jazz music"); db.close().unwrap(); } } // ── Test 6: Collection add/remove survives restart ────────────────────────── // // Tests that add_to_collection and remove_from_collection operations are // durable: items added are present after restart, items removed are absent. #[test] fn collection_add_remove_survives_restart() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); let owner = EntityId::new(7); // Phase 1: Create, add, remove. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); let cid = db .create_collection(owner, "playlist", Visibility::Shared) .unwrap(); db.add_to_collection(cid, EntityId::new(1)).unwrap(); db.add_to_collection(cid, EntityId::new(2)).unwrap(); db.add_to_collection(cid, EntityId::new(3)).unwrap(); db.add_to_collection(cid, EntityId::new(4)).unwrap(); // Remove item 2 and 4. db.remove_from_collection(cid, EntityId::new(2)).unwrap(); db.remove_from_collection(cid, EntityId::new(4)).unwrap(); // Before close: should have items 1 and 3. let collections = db.list_collections(owner).unwrap(); assert_eq!(collections[0].item_count, 2); db.close().unwrap(); } // Phase 2: Reopen and verify removal was persisted. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let collections = db.list_collections(owner).unwrap(); assert_eq!(collections.len(), 1); assert_eq!(collections[0].name, "playlist"); assert_eq!( collections[0].item_count, 2, "should have 2 items after restart (items 1 and 3)" ); assert_eq!(collections[0].visibility, Visibility::Shared); let index = db.collection_index(); assert!( index.contains(collections[0].id, 1), "item 1 should be in collection" ); assert!( !index.contains(collections[0].id, 2), "item 2 should NOT be in collection (removed)" ); assert!( index.contains(collections[0].id, 3), "item 3 should be in collection" ); assert!( !index.contains(collections[0].id, 4), "item 4 should NOT be in collection (removed)" ); db.close().unwrap(); } } // ── Test 7: Cohort ledger with multiple cohorts and signal types ──────────── // // Tests that cohort signal state for multiple cohorts and multiple signal // types survives restart independently. #[test] fn cohort_multi_signal_type_checkpoint_restore() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); // Phase 1: Record different signal types into different cohorts. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); db.define_cohort(CohortDef { name: "english".to_string(), predicate: Predicate::Eq { field: "locale".into(), value: "en".into(), }, }) .unwrap(); db.define_cohort(CohortDef { name: "tech".to_string(), predicate: Predicate::Eq { field: "primary_category".into(), value: "tech".into(), }, }) .unwrap(); // User belongs to BOTH cohorts. db.write_user(EntityId::new(3001), &user_metadata("en", "tech")) .unwrap(); db.write_item_with_metadata(EntityId::new(1), &item_metadata("tech", 100)) .unwrap(); let ts = Timestamp::now(); // Signal "view" -- attributed to both english and tech cohorts. db.signal_with_context("view", EntityId::new(1), 1.0, ts, Some(3001), Some(100)) .unwrap(); // Signal "like" -- also attributed to both. db.signal_with_context("like", EntityId::new(1), 1.0, ts, Some(3001), Some(100)) .unwrap(); let ledger = db.cohort_ledger(); assert_eq!( ledger .read_windowed_count("english", EntityId::new(1), "view", Window::AllTime) .unwrap(), 1 ); assert_eq!( ledger .read_windowed_count("english", EntityId::new(1), "like", Window::AllTime) .unwrap(), 1 ); assert_eq!( ledger .read_windowed_count("tech", EntityId::new(1), "view", Window::AllTime) .unwrap(), 1 ); assert_eq!( ledger .read_windowed_count("tech", EntityId::new(1), "like", Window::AllTime) .unwrap(), 1 ); db.close().unwrap(); } // Phase 2: Reopen and verify all cohort x signal_type combinations. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let ledger = db.cohort_ledger(); let english_view = ledger .read_windowed_count("english", EntityId::new(1), "view", Window::AllTime) .unwrap(); assert_eq!( english_view, 1, "english cohort view count should be 1 after restart" ); let english_like = ledger .read_windowed_count("english", EntityId::new(1), "like", Window::AllTime) .unwrap(); assert_eq!( english_like, 1, "english cohort like count should be 1 after restart" ); let tech_view = ledger .read_windowed_count("tech", EntityId::new(1), "view", Window::AllTime) .unwrap(); assert_eq!( tech_view, 1, "tech cohort view count should be 1 after restart" ); let tech_like = ledger .read_windowed_count("tech", EntityId::new(1), "like", Window::AllTime) .unwrap(); assert_eq!( tech_like, 1, "tech cohort like count should be 1 after restart" ); db.close().unwrap(); } } // ── Test 8: Co-engagement via signal_with_context survives restart ─────────── // // Tests that co-engagement edges created through the natural signal_with_context // path (not just insert_edge) survive restart. #[test] fn co_engagement_via_signal_survives_restart() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); // Phase 1: Generate co-engagement via like signals. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); for id in [10u64, 20, 30] { db.write_item_with_metadata(EntityId::new(id), &item_metadata("jazz", 100)) .unwrap(); } let ts = Timestamp::now(); // User 1 likes items 10, 20, 30 in sequence. // This should create co-engagement edges: (20,10), (30,10), (30,20). db.signal_with_context("like", EntityId::new(10), 1.0, ts, Some(1), Some(100)) .unwrap(); db.signal_with_context("like", EntityId::new(20), 1.0, ts, Some(1), Some(100)) .unwrap(); db.signal_with_context("like", EntityId::new(30), 1.0, ts, Some(1), Some(100)) .unwrap(); // Verify edges exist before close. let co_eng = db.co_engagement(); assert!( co_eng.score(EntityId::new(20), EntityId::new(10)) > 0.0, "pre-close: (20, 10) edge should exist" ); assert!( co_eng.score(EntityId::new(30), EntityId::new(10)) > 0.0, "pre-close: (30, 10) edge should exist" ); assert!( co_eng.score(EntityId::new(30), EntityId::new(20)) > 0.0, "pre-close: (30, 20) edge should exist" ); db.close().unwrap(); } // Phase 2: Reopen and verify edges survived. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let co_eng = db.co_engagement(); let s_20_10 = co_eng.score(EntityId::new(20), EntityId::new(10)); assert!( s_20_10 > 0.0, "post-restart: (20, 10) edge should survive; got {s_20_10}" ); let s_30_10 = co_eng.score(EntityId::new(30), EntityId::new(10)); assert!( s_30_10 > 0.0, "post-restart: (30, 10) edge should survive; got {s_30_10}" ); let s_30_20 = co_eng.score(EntityId::new(30), EntityId::new(20)); assert!( s_30_20 > 0.0, "post-restart: (30, 20) edge should survive; got {s_30_20}" ); // Asymmetric: reverse should still be 0. let reverse = co_eng.score(EntityId::new(10), EntityId::new(20)); assert!( (reverse - 0.0).abs() < f32::EPSILON, "post-restart: reverse (10, 20) should be 0; got {reverse}" ); db.close().unwrap(); } } // ── Test 9: Saved search persistence roundtrip ────────────────────────────── // // Tests that saved searches survive restart and can be listed correctly. #[test] fn saved_search_persistence_roundtrip() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); let user = EntityId::new(777); // Phase 1: Save multiple searches. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); db.save_search(user, "jazz-favorites", "jazz music favorites", None) .unwrap(); db.save_search(user, "rust-databases", "rust embedded database", None) .unwrap(); db.save_search(user, "ml-papers", "machine learning papers", None) .unwrap(); // Delete one. db.delete_saved_search(user, "ml-papers").unwrap(); let searches = db.list_saved_searches(user).unwrap(); assert_eq!(searches.len(), 2, "should have 2 searches before close"); db.close().unwrap(); } // Phase 2: Reopen and verify. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); let searches = db.list_saved_searches(user).unwrap(); assert_eq!( searches.len(), 2, "should have 2 saved searches after restart" ); let names: Vec<&str> = searches.iter().map(|s| s.name.as_str()).collect(); assert!( names.contains(&"jazz-favorites"), "jazz-favorites should survive restart" ); assert!( names.contains(&"rust-databases"), "rust-databases should survive restart" ); assert!( !names.contains(&"ml-papers"), "deleted ml-papers should NOT survive restart" ); // Verify query text. let jazz = searches .iter() .find(|s| s.name == "jazz-favorites") .unwrap(); assert_eq!(jazz.query_text, "jazz music favorites"); db.close().unwrap(); } } // ── Test 10: Two-phase mixed collection + signal restart ──────────────────── // // Write items and signals in phase 1, close. Reopen in phase 2, add more items // and collections, close. Reopen in phase 3 and verify all state from both // phases is present. Tests incremental persistence across multiple sessions. #[test] fn incremental_persistence_across_multiple_opens() { let dir = tempfile::tempdir().unwrap(); let schema = m6_schema(); // Phase 1: Write items 1-5 and signals. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); for id in 1..=5u64 { db.write_item_with_metadata(EntityId::new(id), &item_metadata("jazz", 100)) .unwrap(); } let ts = Timestamp::now(); for id in 1..=5u64 { db.signal("view", EntityId::new(id), 1.0, ts).unwrap(); } db.close().unwrap(); } // Phase 2: Reopen, add items 6-10, create collection, add more signals. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema.clone()) .open() .unwrap(); // Verify phase 1 state via durable metadata (not in-memory universe). for id in 1..=5u64 { let meta = db.get_item_metadata(EntityId::new(id)).unwrap(); assert!(meta.is_some(), "phase 1 item {id} metadata should survive"); } for id in 6..=10u64 { db.write_item_with_metadata(EntityId::new(id), &item_metadata("blues", 200)) .unwrap(); } let cid = db .create_collection(EntityId::new(1), "phase2-collection", Visibility::Private) .unwrap(); db.add_to_collection(cid, EntityId::new(3)).unwrap(); db.add_to_collection(cid, EntityId::new(7)).unwrap(); let ts = Timestamp::now(); for id in 6..=10u64 { db.signal("view", EntityId::new(id), 1.0, ts).unwrap(); } db.close().unwrap(); } // Phase 3: Verify all state from both phases. { let db = TidalDb::builder() .with_data_dir(dir.path()) .with_schema(schema) .open() .unwrap(); // All 10 items durable in fjall (metadata readable). for id in 1..=10u64 { let meta = db.get_item_metadata(EntityId::new(id)).unwrap(); assert!( meta.is_some(), "item {id} metadata should survive two phases" ); } // Signal state from both phases. for id in 1..=10u64 { let count = db .read_windowed_count(EntityId::new(id), "view", Window::AllTime) .unwrap(); assert!( count >= 1, "item {id} should have at least 1 view after two phases; got {count}" ); } // Collection from phase 2 survives. let collections = db.list_collections(EntityId::new(1)).unwrap(); assert_eq!(collections.len(), 1); assert_eq!(collections[0].name, "phase2-collection"); assert_eq!(collections[0].item_count, 2); let index = db.collection_index(); assert!(index.contains(collections[0].id, 3)); assert!(index.contains(collections[0].id, 7)); db.close().unwrap(); } }