deserialize_start_record now returns the full (session_id, user_id,
started_at_ns, metadata) tuple — the metadata bytes were already written
by serialize_start_record but silently discarded on read.
restore_session_wal_events looks up the persisted start record in storage
for each open session and uses the deserialized metadata instead of
HashMap::new(), so fields like {"tool":"planner"} survive a crash.
The signal replay loop no longer discards _annotation — annotations are
now pushed into state.annotations during WAL replay, restoring preference
hints like "more jazz today" so FOR SESSION ranking works post-restart.
Two new integration tests in session_durability.rs verify both fixes
against a real persistent store with simulated crash (drop without
close_session). session/serde.rs split into serde/mod.rs + serde/start_record.rs
to satisfy the 600-line limit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
703 lines
21 KiB
Rust
703 lines
21 KiB
Rust
#![allow(clippy::unwrap_used)]
|
||
//! Session durability tests: persistent archive, hint-keyword ranking,
|
||
//! per-signal windowed counts, and audit truncation.
|
||
|
||
use std::collections::HashMap;
|
||
use std::time::Duration;
|
||
|
||
use tidaldb::TidalDb;
|
||
use tidaldb::schema::{
|
||
AgentPolicy, DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window,
|
||
};
|
||
use tidaldb::session::MAX_AUDIT_ENTRIES;
|
||
|
||
fn test_schema() -> tidaldb::schema::Schema {
|
||
let mut builder = SchemaBuilder::new();
|
||
|
||
for sig in &["view", "like", "reward", "skip"] {
|
||
let _ = builder
|
||
.signal(
|
||
sig,
|
||
EntityKind::Item,
|
||
DecaySpec::Exponential {
|
||
half_life: Duration::from_secs(7 * 24 * 3600),
|
||
},
|
||
)
|
||
.windows(&[Window::OneHour, Window::TwentyFourHours])
|
||
.velocity(false)
|
||
.add();
|
||
}
|
||
|
||
let _ = builder.session_policy(
|
||
"default_policy",
|
||
AgentPolicy {
|
||
allowed_signals: vec!["reward".to_string(), "view".to_string()],
|
||
denied_signals: vec!["skip".to_string()],
|
||
max_session_duration: Duration::from_secs(3600),
|
||
max_signals_per_session: 10_000,
|
||
},
|
||
);
|
||
|
||
builder.build().unwrap()
|
||
}
|
||
|
||
// ── Test 1: Archived session readable after close and reopen ────────────────
|
||
|
||
#[test]
|
||
fn archived_session_readable_after_close_and_reopen() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let schema = test_schema();
|
||
|
||
let session_id;
|
||
let written;
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema.clone())
|
||
.open()
|
||
.unwrap();
|
||
|
||
let handle = db
|
||
.start_session(1, "agent-a", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
session_id = handle.id;
|
||
let ts = Timestamp::now();
|
||
|
||
for i in 1u64..=3 {
|
||
let mut meta = HashMap::new();
|
||
meta.insert("title".to_string(), format!("item-{i}"));
|
||
db.write_item_with_metadata(EntityId::new(i), &meta)
|
||
.unwrap();
|
||
}
|
||
|
||
db.session_signal(&handle, "reward", EntityId::new(1), 1.0, ts, None)
|
||
.unwrap();
|
||
db.session_signal(&handle, "view", EntityId::new(2), 0.5, ts, None)
|
||
.unwrap();
|
||
|
||
let summary = db.close_session(handle).unwrap();
|
||
written = summary.signals_written;
|
||
|
||
db.close().unwrap();
|
||
}
|
||
|
||
// Reopen — snapshot must be readable from storage.
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema)
|
||
.open()
|
||
.unwrap();
|
||
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
assert_eq!(
|
||
snap.signals_written, written,
|
||
"signals_written survives reopen"
|
||
);
|
||
assert_eq!(snap.signals_written, 2);
|
||
assert_eq!(snap.signals_rejected, 0);
|
||
|
||
db.close().unwrap();
|
||
}
|
||
}
|
||
|
||
// ── Test 2: Hint keywords boost matching items ───────────────────────────────
|
||
|
||
#[test]
|
||
fn hint_keywords_boost_matching_items() {
|
||
let db = TidalDb::builder()
|
||
.ephemeral()
|
||
.with_schema(test_schema())
|
||
.open()
|
||
.unwrap();
|
||
|
||
// Write 5 jazz items and 5 rock items.
|
||
for i in 1u64..=5 {
|
||
let mut meta = HashMap::new();
|
||
meta.insert("genre".to_string(), "jazz".to_string());
|
||
meta.insert("title".to_string(), format!("jazz-track-{i}"));
|
||
db.write_item_with_metadata(EntityId::new(i), &meta)
|
||
.unwrap();
|
||
}
|
||
for i in 6u64..=10 {
|
||
let mut meta = HashMap::new();
|
||
meta.insert("genre".to_string(), "rock".to_string());
|
||
meta.insert("title".to_string(), format!("rock-track-{i}"));
|
||
db.write_item_with_metadata(EntityId::new(i), &meta)
|
||
.unwrap();
|
||
}
|
||
|
||
let handle = db
|
||
.start_session(1, "agent-a", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
let session_id = handle.id;
|
||
let ts = Timestamp::now();
|
||
|
||
// Signal with annotation hinting jazz preference.
|
||
db.session_signal(
|
||
&handle,
|
||
"reward",
|
||
EntityId::new(1),
|
||
1.0,
|
||
ts,
|
||
Some("jazz fusion acoustic".to_string()),
|
||
)
|
||
.unwrap();
|
||
|
||
// Query FOR SESSION.
|
||
let query = tidaldb::query::retrieve::RetrieveBuilder::new(
|
||
EntityKind::Item,
|
||
tidaldb::query::retrieve::ProfileRef::new("hot"),
|
||
)
|
||
.limit(10)
|
||
.for_session(session_id)
|
||
.build()
|
||
.unwrap();
|
||
let results = db.retrieve(&query).unwrap();
|
||
|
||
assert!(!results.items.is_empty(), "should return results");
|
||
assert!(
|
||
results.session_snapshot.is_some(),
|
||
"FOR SESSION query must attach session snapshot"
|
||
);
|
||
|
||
// Jazz items (1–5) should appear in results (session hint matched metadata).
|
||
let jazz_count = results
|
||
.items
|
||
.iter()
|
||
.filter(|r| r.entity_id.as_u64() <= 5)
|
||
.count();
|
||
assert!(
|
||
jazz_count > 0,
|
||
"at least one jazz item should appear in FOR SESSION results"
|
||
);
|
||
|
||
db.close_session(handle).unwrap();
|
||
db.close().unwrap();
|
||
}
|
||
|
||
// ── Test 3: Per-signal windowed counts in snapshot ───────────────────────────
|
||
|
||
#[test]
|
||
fn per_signal_snapshot_shows_windowed_counts() {
|
||
let db = TidalDb::builder()
|
||
.ephemeral()
|
||
.with_schema(test_schema())
|
||
.open()
|
||
.unwrap();
|
||
|
||
let mut meta = HashMap::new();
|
||
meta.insert("title".to_string(), "item-1".to_string());
|
||
db.write_item_with_metadata(EntityId::new(1), &meta)
|
||
.unwrap();
|
||
|
||
let handle = db
|
||
.start_session(2, "agent-b", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
let session_id = handle.id;
|
||
let ts = Timestamp::now();
|
||
|
||
// Write 5 "reward" signals.
|
||
for _ in 0..5 {
|
||
db.session_signal(&handle, "reward", EntityId::new(1), 1.0, ts, None)
|
||
.unwrap();
|
||
}
|
||
|
||
// Write 3 "view" signals.
|
||
for _ in 0..3 {
|
||
db.session_signal(&handle, "view", EntityId::new(1), 0.5, ts, None)
|
||
.unwrap();
|
||
}
|
||
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
|
||
assert!(
|
||
snap.signals.contains_key("reward"),
|
||
"reward should appear in signals map"
|
||
);
|
||
assert!(
|
||
snap.signals.contains_key("view"),
|
||
"view should appear in signals map"
|
||
);
|
||
|
||
let reward = &snap.signals["reward"];
|
||
assert_eq!(
|
||
reward.window_1h, 5,
|
||
"reward window_1h should count 5 signals"
|
||
);
|
||
assert!(
|
||
reward.decay_score > 0.0,
|
||
"reward decay_score should be positive"
|
||
);
|
||
|
||
let view = &snap.signals["view"];
|
||
assert_eq!(view.window_1h, 3, "view window_1h should count 3 signals");
|
||
|
||
db.close_session(handle).unwrap();
|
||
db.close().unwrap();
|
||
}
|
||
|
||
// ── Test 4: Audit truncation marker ─────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn audit_truncation_marker_set_when_cap_exceeded() {
|
||
let db = TidalDb::builder()
|
||
.ephemeral()
|
||
.with_schema(test_schema())
|
||
.open()
|
||
.unwrap();
|
||
|
||
let mut meta = HashMap::new();
|
||
meta.insert("title".to_string(), "item-1".to_string());
|
||
db.write_item_with_metadata(EntityId::new(1), &meta)
|
||
.unwrap();
|
||
|
||
// Use a policy with a very large signal cap so it doesn't interfere.
|
||
let handle = db
|
||
.start_session(3, "agent-c", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
let session_id = handle.id;
|
||
let ts = Timestamp::now();
|
||
|
||
// Write MAX_AUDIT_ENTRIES + 1 signals (all "reward" which is allowed).
|
||
for _ in 0..=MAX_AUDIT_ENTRIES {
|
||
let _ = db.session_signal(&handle, "reward", EntityId::new(1), 1.0, ts, None);
|
||
}
|
||
|
||
// audit_truncated flag is visible in the live snapshot.
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
assert!(
|
||
snap.audit_truncated,
|
||
"audit_truncated should be true after exceeding MAX_AUDIT_ENTRIES"
|
||
);
|
||
|
||
// session_audit() returns the capped entries (MAX_AUDIT_ENTRIES).
|
||
let entries = db.session_audit(session_id).unwrap();
|
||
assert_eq!(
|
||
entries.len(),
|
||
MAX_AUDIT_ENTRIES,
|
||
"audit log capped at MAX_AUDIT_ENTRIES"
|
||
);
|
||
|
||
db.close_session(handle).unwrap();
|
||
db.close().unwrap();
|
||
}
|
||
|
||
// ── Test 5: Annotation timestamps preserved in snapshot ─────────────────────
|
||
|
||
#[test]
|
||
fn annotation_timestamps_preserved() {
|
||
let db = TidalDb::builder()
|
||
.ephemeral()
|
||
.with_schema(test_schema())
|
||
.open()
|
||
.unwrap();
|
||
|
||
let mut meta = HashMap::new();
|
||
meta.insert("title".to_string(), "item-1".to_string());
|
||
db.write_item_with_metadata(EntityId::new(1), &meta)
|
||
.unwrap();
|
||
|
||
let handle = db
|
||
.start_session(4, "agent-d", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
let session_id = handle.id;
|
||
let ts = Timestamp::now();
|
||
|
||
db.session_signal(
|
||
&handle,
|
||
"reward",
|
||
EntityId::new(1),
|
||
1.0,
|
||
ts,
|
||
Some("piano solo".to_string()),
|
||
)
|
||
.unwrap();
|
||
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
assert_eq!(snap.annotations.len(), 1);
|
||
let (ann_ts, ann_text) = &snap.annotations[0];
|
||
assert!(*ann_ts > 0, "annotation timestamp should be non-zero");
|
||
assert_eq!(ann_text, "piano solo");
|
||
|
||
db.close_session(handle).unwrap();
|
||
db.close().unwrap();
|
||
}
|
||
|
||
// ── Test 6: Active session state restored after crash ────────────────────────
|
||
|
||
/// Proves that an active (never-closed) session is restored from the WAL
|
||
/// journal after a simulated crash. The "crash" is simulated by dropping
|
||
/// the `TidalDb` without calling `close_session()` — the WAL has a
|
||
/// `SessionStart` and N `SessionSignal` records but no `SessionClose`,
|
||
/// so on reopen the session must appear as active with all signals intact.
|
||
#[test]
|
||
fn active_session_state_restored_after_crash() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let schema = test_schema();
|
||
|
||
let session_id;
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema.clone())
|
||
.open()
|
||
.unwrap();
|
||
|
||
// Write an item so session signals have a valid target.
|
||
let mut meta = HashMap::new();
|
||
meta.insert("title".to_string(), "item-1".to_string());
|
||
db.write_item_with_metadata(EntityId::new(1), &meta)
|
||
.unwrap();
|
||
|
||
let handle = db
|
||
.start_session(42, "agent-crash", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
session_id = handle.id;
|
||
let ts = Timestamp::now();
|
||
|
||
// Write 4 "reward" signals and 3 "view" signals (7 total).
|
||
for _ in 0..4 {
|
||
db.session_signal(&handle, "reward", EntityId::new(1), 1.0, ts, None)
|
||
.unwrap();
|
||
}
|
||
for _ in 0..3 {
|
||
db.session_signal(&handle, "view", EntityId::new(1), 0.5, ts, None)
|
||
.unwrap();
|
||
}
|
||
|
||
// Verify signals are live before "crash".
|
||
let snap_before = db.session_snapshot(session_id).unwrap();
|
||
assert_eq!(snap_before.signals_written, 7);
|
||
|
||
// Simulate crash: drop the db without calling close_session().
|
||
// The Drop impl flushes the WAL, but the session was never archived.
|
||
drop(db);
|
||
}
|
||
|
||
// Reopen — the session should be restored as active from the WAL journal.
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema)
|
||
.open()
|
||
.unwrap();
|
||
|
||
// The session must appear in active_sessions().
|
||
let active = db.active_sessions();
|
||
let restored = active
|
||
.iter()
|
||
.find(|info| info.id == session_id)
|
||
.expect("session should be restored as active after crash");
|
||
assert_eq!(
|
||
restored.user_id, 42,
|
||
"restored session should have the original user_id"
|
||
);
|
||
assert_eq!(
|
||
restored.signals_written, 7,
|
||
"all 7 signals should be replayed from WAL"
|
||
);
|
||
|
||
// Snapshot must contain per-signal-type data.
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
assert_eq!(
|
||
snap.signals_written, 7,
|
||
"snapshot signals_written must match total replayed signals"
|
||
);
|
||
|
||
let reward = snap
|
||
.signals
|
||
.get("reward")
|
||
.expect("reward signal type should exist in restored snapshot");
|
||
assert_eq!(
|
||
reward.window_1h, 4,
|
||
"reward window_1h should count 4 replayed signals"
|
||
);
|
||
assert!(
|
||
reward.decay_score > 0.0,
|
||
"reward decay_score should be positive after replay"
|
||
);
|
||
|
||
let view = snap
|
||
.signals
|
||
.get("view")
|
||
.expect("view signal type should exist in restored snapshot");
|
||
assert_eq!(
|
||
view.window_1h, 3,
|
||
"view window_1h should count 3 replayed signals"
|
||
);
|
||
assert!(
|
||
view.decay_score > 0.0,
|
||
"view decay_score should be positive after replay"
|
||
);
|
||
|
||
// Entity 1 should appear in signaled_entities.
|
||
assert!(
|
||
snap.signaled_entities.contains(&1),
|
||
"entity 1 should be in signaled_entities after replay"
|
||
);
|
||
|
||
db.close().unwrap();
|
||
}
|
||
}
|
||
|
||
// ── Test 6: Session metadata survives crash recovery ─────────────────────────
|
||
|
||
/// Verifies that `start_session` metadata (e.g. `{"tool": "planner"}`) is
|
||
/// rehydrated from the storage start record during WAL replay, so that
|
||
/// `session_snapshot().metadata` is populated after a crash.
|
||
#[test]
|
||
fn metadata_survives_crash() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let schema = test_schema();
|
||
|
||
let session_id;
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema.clone())
|
||
.open()
|
||
.unwrap();
|
||
|
||
let mut meta = HashMap::new();
|
||
meta.insert("tool".to_string(), "planner".to_string());
|
||
meta.insert("context".to_string(), "daily-feed".to_string());
|
||
|
||
let handle = db
|
||
.start_session(10, "agent-meta", "default_policy", meta)
|
||
.unwrap();
|
||
session_id = handle.id;
|
||
|
||
// Write a signal so the WAL has a SessionSignal record too.
|
||
let mut item_meta = HashMap::new();
|
||
item_meta.insert("title".to_string(), "item-1".to_string());
|
||
db.write_item_with_metadata(EntityId::new(1), &item_meta)
|
||
.unwrap();
|
||
let ts = Timestamp::now();
|
||
db.session_signal(&handle, "reward", EntityId::new(1), 1.0, ts, None)
|
||
.unwrap();
|
||
|
||
// Simulate crash: drop without close_session().
|
||
drop(db);
|
||
}
|
||
|
||
// Reopen — metadata must be present in the restored active session.
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema)
|
||
.open()
|
||
.unwrap();
|
||
|
||
let active = db.active_sessions();
|
||
assert!(
|
||
active.iter().any(|info| info.id == session_id),
|
||
"session must be restored as active after crash"
|
||
);
|
||
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
assert_eq!(
|
||
snap.metadata.get("tool").map(String::as_str),
|
||
Some("planner"),
|
||
"tool metadata must survive crash recovery"
|
||
);
|
||
assert_eq!(
|
||
snap.metadata.get("context").map(String::as_str),
|
||
Some("daily-feed"),
|
||
"context metadata must survive crash recovery"
|
||
);
|
||
|
||
db.close().unwrap();
|
||
}
|
||
}
|
||
|
||
// ── Test 7: Preference annotations survive crash recovery ────────────────────
|
||
|
||
/// Verifies that session signal annotations ("more jazz today") are replayed
|
||
/// from the WAL during crash recovery, so that `SessionContext::keywords` is
|
||
/// populated for FOR SESSION ranking after a restart.
|
||
#[test]
|
||
fn annotations_survive_crash() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let schema = test_schema();
|
||
|
||
let session_id;
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema.clone())
|
||
.open()
|
||
.unwrap();
|
||
|
||
let handle = db
|
||
.start_session(11, "agent-ann", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
session_id = handle.id;
|
||
|
||
let mut item_meta = HashMap::new();
|
||
item_meta.insert("genre".to_string(), "jazz".to_string());
|
||
db.write_item_with_metadata(EntityId::new(1), &item_meta)
|
||
.unwrap();
|
||
let ts = Timestamp::now();
|
||
|
||
// Write two annotated signals.
|
||
db.session_signal(
|
||
&handle,
|
||
"reward",
|
||
EntityId::new(1),
|
||
1.0,
|
||
ts,
|
||
Some("more jazz today".to_string()),
|
||
)
|
||
.unwrap();
|
||
db.session_signal(
|
||
&handle,
|
||
"view",
|
||
EntityId::new(1),
|
||
0.5,
|
||
ts,
|
||
Some("acoustic vibes".to_string()),
|
||
)
|
||
.unwrap();
|
||
|
||
// Simulate crash.
|
||
drop(db);
|
||
}
|
||
|
||
// Reopen — annotations must be present so FOR SESSION ranking can use them.
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema)
|
||
.open()
|
||
.unwrap();
|
||
|
||
let active = db.active_sessions();
|
||
assert!(
|
||
active.iter().any(|info| info.id == session_id),
|
||
"session must be restored as active after crash"
|
||
);
|
||
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
assert_eq!(
|
||
snap.annotations.len(),
|
||
2,
|
||
"both annotations must be replayed from WAL"
|
||
);
|
||
|
||
let texts: Vec<&str> = snap.annotations.iter().map(|(_, s)| s.as_str()).collect();
|
||
assert!(
|
||
texts.contains(&"more jazz today"),
|
||
"jazz annotation must survive crash recovery"
|
||
);
|
||
assert!(
|
||
texts.contains(&"acoustic vibes"),
|
||
"acoustic annotation must survive crash recovery"
|
||
);
|
||
|
||
db.close().unwrap();
|
||
}
|
||
}
|
||
|
||
// ── Test 8: WAL replay preserves signal counts exactly ───────────────────────
|
||
|
||
/// Property-like correctness test: write exactly K signals of one type into
|
||
/// an active session, "crash" (drop without close_session), reopen, and
|
||
/// verify the replayed count is exactly K — not K-1, not K+1.
|
||
///
|
||
/// This directly tests the acceptance criterion from the roadmap:
|
||
/// "WAL replay of session signals restores SessionSignalState accumulators
|
||
/// correctly."
|
||
#[test]
|
||
fn wal_replay_restores_signal_counts_exactly() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let schema = test_schema();
|
||
|
||
const K: u64 = 5;
|
||
let session_id;
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema.clone())
|
||
.open()
|
||
.unwrap();
|
||
|
||
let mut meta = HashMap::new();
|
||
meta.insert("title".to_string(), "target".to_string());
|
||
db.write_item_with_metadata(EntityId::new(99), &meta)
|
||
.unwrap();
|
||
|
||
let handle = db
|
||
.start_session(7, "agent-replay", "default_policy", HashMap::new())
|
||
.unwrap();
|
||
session_id = handle.id;
|
||
let ts = Timestamp::now();
|
||
|
||
// Write exactly K "reward" signals, all targeting the same entity.
|
||
for _ in 0..K {
|
||
db.session_signal(&handle, "reward", EntityId::new(99), 1.0, ts, None)
|
||
.unwrap();
|
||
}
|
||
|
||
// Sanity check before "crash".
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
assert_eq!(snap.signals_written, K);
|
||
assert_eq!(snap.signals["reward"].window_1h, K);
|
||
|
||
// Drop without close_session — session left active in WAL.
|
||
drop(db);
|
||
}
|
||
|
||
// Reopen and verify exact replay.
|
||
{
|
||
let db = TidalDb::builder()
|
||
.with_data_dir(dir.path())
|
||
.with_schema(schema)
|
||
.open()
|
||
.unwrap();
|
||
|
||
// Session must be active (not closed — no SessionClose in WAL).
|
||
let active = db.active_sessions();
|
||
assert!(
|
||
active.iter().any(|info| info.id == session_id),
|
||
"session must be restored as active"
|
||
);
|
||
|
||
let snap = db.session_snapshot(session_id).unwrap();
|
||
|
||
// The critical assertion: exact signal count after WAL replay.
|
||
assert_eq!(
|
||
snap.signals_written, K,
|
||
"total signals_written must be exactly {K} after replay"
|
||
);
|
||
|
||
let reward = snap
|
||
.signals
|
||
.get("reward")
|
||
.expect("reward signal type must exist after replay");
|
||
assert_eq!(
|
||
reward.window_1h, K,
|
||
"reward window_1h must be exactly {K} after replay"
|
||
);
|
||
|
||
// Only one entity was signaled.
|
||
assert_eq!(
|
||
snap.signaled_entities.len(),
|
||
1,
|
||
"exactly one entity should appear in signaled_entities"
|
||
);
|
||
assert_eq!(
|
||
snap.signaled_entities[0], 99,
|
||
"signaled entity should be 99"
|
||
);
|
||
|
||
// Decay score should be positive (signals were recent).
|
||
assert!(
|
||
reward.decay_score > 0.0,
|
||
"decay_score must be positive for recently replayed signals"
|
||
);
|
||
|
||
db.close().unwrap();
|
||
}
|
||
}
|