//! Integration tests for M7P4 Operational Visibility — RLHF signal export. //! //! - Task 08: RLHF signal export (with session context) #![allow(clippy::too_many_lines, clippy::unwrap_used)] #[cfg(feature = "test-utils")] use std::collections::HashMap; use std::time::Duration; use tidaldb::{ ExportFormat, ExportRequest, ExportedSignal, TidalDb, schema::{DecaySpec, EntityId, EntityKind, Timestamp, Window}, }; // ── Shared test schema ─────────────────────────────────────────────────────── fn build_test_schema() -> tidaldb::schema::Schema { use tidaldb::{AgentPolicy, schema::SchemaBuilder}; let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); let _ = builder .signal( "like", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(30 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); builder.session_policy( "default", AgentPolicy { allowed_signals: vec!["view".to_string(), "like".to_string()], denied_signals: vec![], max_session_duration: Duration::from_secs(3600), max_signals_per_session: 1000, }, ); builder.build().unwrap() } // ── Task 08: RLHF Signal Export ────────────────────────────────────────────── #[test] #[cfg(feature = "test-utils")] fn export_signals_ephemeral_returns_empty() { use tidaldb::{TidalDb, schema::SchemaBuilder}; let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); let schema = builder.build().unwrap(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); // Ephemeral mode: no WAL on disk, always returns empty. let req = ExportRequest::time_range(0, u64::MAX); let signals = db.export_signals(&req).unwrap(); assert!(signals.is_empty()); } #[test] #[cfg(feature = "test-utils")] fn export_signals_unknown_type_returns_error() { use tidaldb::{TidalDb, schema::SchemaBuilder}; let mut builder = SchemaBuilder::new(); let _ = builder .signal( "view", EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour]) .add(); let schema = builder.build().unwrap(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); let req = ExportRequest::signals_in_range(vec!["nonexistent".into()], 0, u64::MAX); let result = db.export_signals(&req); assert!( result.is_err(), "requesting an unknown signal type must return an error" ); } #[test] fn export_format_json_lines_value() { assert_eq!(ExportFormat::JsonLines, ExportFormat::JsonLines); } #[test] fn export_request_time_range_fields() { let req = ExportRequest::time_range(1000, 2000); assert_eq!(req.since, Some(1000)); assert_eq!(req.until, Some(2000)); assert!(req.signal_types.is_empty()); assert_eq!(req.format, ExportFormat::JsonLines); assert!(req.limit.is_none()); assert!(req.user_id.is_none()); } #[test] fn exported_signal_to_json_line_valid() { let sig = ExportedSignal { entity_id: 42, signal_type: "view".to_string(), weight: 1.0, timestamp_ns: 1_700_000_000_000_000_000, user_id: None, session_id: None, annotation: None, }; let line = sig.to_json_line(); assert!(line.starts_with('{')); assert!(line.ends_with('}')); assert!(line.contains("\"entity_id\":42")); assert!(line.contains("\"signal_type\":\"view\"")); // Anonymous signals must not include optional keys. assert!(!line.contains("user_id")); assert!(!line.contains("session_id")); assert!(!line.contains("annotation")); } /// `export_signals` reads actual WAL events written to a persistent database. /// /// Export reads live, uncompacted WAL segments — those fsynced to disk but /// not yet removed by a clean shutdown. This test exercises the WAL scanning /// code path (persistent mode) that the ephemeral early-return skips. /// /// Note: a clean `close()` compacts WAL segments, so export is called on the /// same open DB instance — not after reopening. #[test] #[cfg(feature = "test-utils")] fn export_signals_reads_wal_events() { use tidaldb::TempTidalHome; let home = TempTidalHome::new().unwrap(); let schema = build_test_schema(); let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema) .open() .unwrap(); let since = Timestamp::now().as_nanos(); db.signal("view", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); db.signal("view", EntityId::new(2), 1.5, Timestamp::now()) .unwrap(); db.signal("like", EntityId::new(3), 2.0, Timestamp::now()) .unwrap(); // Export BEFORE close — WAL segments are live on disk. // Clean close compacts (deletes) segments, so export must happen first. let req = ExportRequest::time_range(since, u64::MAX); let signals = db.export_signals(&req).unwrap(); assert_eq!(signals.len(), 3, "all 3 WAL events must be exported"); let view_count = signals.iter().filter(|s| s.signal_type == "view").count(); assert_eq!(view_count, 2, "must export 2 view events"); let like_count = signals.iter().filter(|s| s.signal_type == "like").count(); assert_eq!(like_count, 1, "must export 1 like event"); // Entity IDs and weights are preserved end-to-end. assert!( signals .iter() .any(|s| s.entity_id == 1 && (s.weight - 1.0).abs() < 0.001), "entity 1 view event must be present" ); assert!( signals .iter() .any(|s| s.entity_id == 3 && s.signal_type == "like"), "entity 3 like event must be present" ); // Anonymous batch WAL signals must have None for user/session/annotation. for sig in &signals { assert!(sig.user_id.is_none(), "batch WAL signals have no user_id"); assert!( sig.session_id.is_none(), "batch WAL signals have no session_id" ); assert!( sig.annotation.is_none(), "batch WAL signals have no annotation" ); } db.close().unwrap(); } /// `export_signals` respects the `limit` field. /// /// Export is called on the same open DB instance (before close) because /// clean shutdown compacts WAL segments, leaving nothing to scan. #[test] #[cfg(feature = "test-utils")] fn export_signals_respects_limit() { use tidaldb::TempTidalHome; let home = TempTidalHome::new().unwrap(); let schema = build_test_schema(); let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema) .open() .unwrap(); for i in 1u64..=10 { db.signal("view", EntityId::new(i), 1.0, Timestamp::now()) .unwrap(); } let mut req = ExportRequest::time_range(0, u64::MAX); req.limit = Some(3); let signals = db.export_signals(&req).unwrap(); assert_eq!(signals.len(), 3, "limit must cap the result count"); db.close().unwrap(); } /// Anonymous batch WAL signals are excluded when a `user_id` filter is set. /// In ephemeral mode there is no WAL or session journal on disk, so the /// result is empty regardless. This test verifies that anonymous signals /// written via `db.signal()` (no session) do NOT appear in user-filtered /// exports. #[test] fn export_signals_user_id_filter_excludes_anonymous() { let schema = build_test_schema(); let db = TidalDb::builder() .ephemeral() .with_schema(schema) .open() .unwrap(); db.signal("view", EntityId::new(1), 1.0, Timestamp::now()) .unwrap(); let mut req = ExportRequest::time_range(0, u64::MAX); req.user_id = Some(42); let signals = db.export_signals(&req).unwrap(); assert!( signals.is_empty(), "user_id filter must exclude anonymous batch WAL signals" ); } /// Session signals carry `user_id`, `session_id`, and annotation through to export. /// /// This test exercises the full session-journal export path: /// 1. Persistent DB with schema and session policy /// 2. Session signals written with annotations via `session_signal()` /// 3. Export with `user_id` filter returns only that user's session signals /// 4. Export with a different `user_id` returns empty #[test] #[cfg(feature = "test-utils")] fn export_signals_with_session_context() { use tidaldb::TempTidalHome; let home = TempTidalHome::new().unwrap(); let schema = build_test_schema(); let db = TidalDb::builder() .with_data_dir(home.path()) .with_schema(schema) .open() .unwrap(); let user_id = 42u64; let meta = HashMap::new(); // Start a session for user 42. let handle = db .start_session(user_id, "test-agent", "default", meta) .unwrap(); let since = Timestamp::now().as_nanos(); // Write session signals — one with annotation, one without. db.session_signal( &handle, "view", EntityId::new(100), 1.0, Timestamp::now(), Some("good content".to_string()), ) .unwrap(); db.session_signal( &handle, "like", EntityId::new(101), 2.0, Timestamp::now(), None, ) .unwrap(); // Close the session so it is fully flushed to the journal. db.close_session(handle).unwrap(); // Session journal writes are fire-and-forget through the WAL writer thread. // Allow time for the writer to drain and fsync the session commands. std::thread::sleep(Duration::from_millis(100)); // Export with user_id filter = Some(42). let mut req = ExportRequest::time_range(since, u64::MAX); req.user_id = Some(user_id); let signals = db.export_signals(&req).unwrap(); assert!( !signals.is_empty(), "export with user_id filter must return session signals" ); assert_eq!(signals.len(), 2, "expected 2 session signals for user 42"); // All results must have user_id == Some(42). for sig in &signals { assert_eq!( sig.user_id, Some(user_id), "all exported signals must carry the user_id" ); assert!( sig.session_id.is_some(), "all exported signals must carry a session_id" ); } // At least one result has the annotation set. assert!( signals.iter().any(|s| s.annotation.is_some()), "at least one signal must have an annotation" ); let annotated = signals.iter().find(|s| s.annotation.is_some()).unwrap(); assert_eq!( annotated.annotation.as_deref(), Some("good content"), "annotation text must round-trip through the journal" ); // Export with a different user_id returns empty. let mut req2 = ExportRequest::time_range(since, u64::MAX); req2.user_id = Some(999); let signals2 = db.export_signals(&req2).unwrap(); assert!( signals2.is_empty(), "export for non-existent user must return empty" ); db.close().unwrap(); }