- Add BUILD.bazel across tidal, tidal-net, tidal-server, tidalctl for bzlmod build - Add tidal/ crate docs (README, CHANGELOG, CONTRIBUTING, AGENTS, CLAUDE, API, ARCHITECTURE) and ai-lookup reference - Add docker standalone/cluster/deploy images, compose, and prometheus config - Harden WAL (batch format, writer, dedup, diagnostics), text syncer/collectors, and vector registry - Expand tidalctl CLI and tests; restructure WAL/visibility integration test suites - Refine tidal-net transport/client/server and tidal-server cluster/scatter-gather
388 lines
12 KiB
Rust
388 lines
12 KiB
Rust
//! Integration tests for M7P4 Operational Visibility — RLHF signal export.
|
|
//!
|
|
//! - Task 08: RLHF signal export (with session context)
|
|
#![allow(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();
|
|
}
|