tidaldb/tidal/tests/m1p1_schema_uat.rs
jordan 192c473f55 feat: complete Milestone 5 — full-text search, RRF fusion, and creator search
- M5p1: BM25 text indexing via Tantivy with background syncer (0.26ms @ 10K docs)
- M5p2: RRF fusion layer combining BM25 + ANN scores (46µs @ 1K candidates)
- M5p3: unified Search query API (8-stage pipeline, BM25 + vector + ranking)
- M5p4: creator text + vector indexing and creator search executor (< 20ms @ 200 creators)
- Refactor db/mod.rs into focused sub-modules (creators, items, sessions, signals, etc.)
- Decompose monolithic files into directory modules (query/executor, ranking/diversity, etc.)
- Split brute.rs → brute/mod.rs + brute/tests.rs; extract search executor helpers
- Add benches: fusion, search, session, text_index
- Add M5 UAT test suites (m5_uat, m5_search, m5p4_creator_search, text_index)
- Update blog posts, roadmap, content strategy, and M5 planning docs
- Add tmp/ and .claude/worktrees/ to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 23:53:16 -07:00

576 lines
16 KiB
Rust

//! UAT: Milestone 1, Phase 1 — Core Type System and Schema
//!
//! Exercises every m1p1 acceptance criterion from the public API.
//! Does NOT duplicate the unit tests in `schema/entity.rs`, `schema/signal.rs`,
//! `schema/error.rs`, `schema/score.rs`, or `schema/validation.rs`.
//! Instead it verifies from the *user* perspective: import `tidaldb::schema::*`,
//! construct types, and assert the documented guarantees hold.
#![allow(clippy::unwrap_used, unused_must_use)]
use std::collections::HashSet;
use std::time::Duration;
use tidaldb::schema::{
DecaySpec, EntityId, EntityKind, SchemaBuilder, SchemaError, Score, Window, WindowSet,
};
// ── UAT-01: EntityId — u64 newtype with Display, Hash, Eq, Ord, to_be_bytes ──
#[test]
fn uat01_entity_id_display() {
let id = EntityId::new(42);
assert_eq!(id.to_string(), "42");
}
#[test]
fn uat01_entity_id_hash_eq() {
let a = EntityId::new(7);
let b = EntityId::new(7);
let c = EntityId::new(8);
// Eq
assert_eq!(a, b);
assert_ne!(a, c);
// Hash: equal entities produce equal hashes (insert into HashSet, verify dedup)
let mut set = HashSet::new();
set.insert(a);
set.insert(b);
set.insert(c);
assert_eq!(set.len(), 2, "HashSet should deduplicate equal EntityIds");
}
#[test]
fn uat01_entity_id_ord() {
let ids: Vec<EntityId> = vec![
EntityId::new(100),
EntityId::new(1),
EntityId::new(50),
EntityId::new(0),
EntityId::new(u64::MAX),
];
let mut sorted = ids.clone();
sorted.sort();
let vals: Vec<u64> = sorted.iter().map(|id| id.as_u64()).collect();
assert_eq!(vals, vec![0, 1, 50, 100, u64::MAX]);
}
#[test]
fn uat01_entity_id_be_bytes_preserves_ordering() {
// The acceptance criterion: big-endian encoding preserves numeric ordering.
let pairs = [
(0_u64, 1_u64),
(1, 2),
(255, 256),
(u64::MAX - 1, u64::MAX),
(0, u64::MAX),
];
for (a, b) in pairs {
let bytes_a = EntityId::new(a).to_be_bytes();
let bytes_b = EntityId::new(b).to_be_bytes();
assert!(
bytes_a < bytes_b,
"be_bytes ordering violated: {a} < {b} but bytes {bytes_a:?} >= {bytes_b:?}"
);
}
}
// ── UAT-02: EntityKind — Item, User, Creator ─────────────────────────────────
#[test]
fn uat02_entity_kind_variants_exist() {
let _item = EntityKind::Item;
let _user = EntityKind::User;
let _creator = EntityKind::Creator;
}
#[test]
fn uat02_entity_kind_display() {
assert_eq!(EntityKind::Item.to_string(), "item");
assert_eq!(EntityKind::User.to_string(), "user");
assert_eq!(EntityKind::Creator.to_string(), "creator");
}
// ── UAT-03: SignalTypeDef via SchemaBuilder ──────────────────────────────────
#[test]
fn uat03_signal_type_def_captures_all_fields() {
let mut builder = SchemaBuilder::new();
builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(604_800),
},
)
.windows(&[Window::OneHour, Window::TwentyFourHours])
.velocity(true)
.add();
let schema = builder.build().unwrap();
let view = schema.signal("view").unwrap();
assert_eq!(view.name(), "view");
assert_eq!(view.target(), EntityKind::Item);
assert!(view.velocity_enabled());
assert_eq!(view.windows().len(), 2);
assert!(view.windows().contains(&Window::OneHour));
assert!(view.windows().contains(&Window::TwentyFourHours));
assert!(view.decay().lambda().is_some());
assert!(view.decay().half_life().is_some());
}
#[test]
fn uat03_signal_type_def_user_target() {
let mut builder = SchemaBuilder::new();
builder
.signal("follow", EntityKind::User, DecaySpec::Permanent)
.add();
let schema = builder.build().unwrap();
let follow = schema.signal("follow").unwrap();
assert_eq!(follow.target(), EntityKind::User);
}
#[test]
fn uat03_signal_type_def_creator_target() {
let mut builder = SchemaBuilder::new();
builder
.signal("subscribe", EntityKind::Creator, DecaySpec::Permanent)
.add();
let schema = builder.build().unwrap();
let sub = schema.signal("subscribe").unwrap();
assert_eq!(sub.target(), EntityKind::Creator);
}
// ── UAT-04: DecayModel — pre-computed lambda, no division on hot path ────────
#[test]
fn uat04_exponential_decay_precomputes_lambda() {
let half_life_secs = 7.0 * 24.0 * 3600.0; // 7 days
let expected_lambda = std::f64::consts::LN_2 / half_life_secs;
let mut builder = SchemaBuilder::new();
builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(604_800),
},
)
.windows(&[Window::AllTime])
.add();
let schema = builder.build().unwrap();
let view = schema.signal("view").unwrap();
let lambda = view.decay().lambda().unwrap();
assert!(
(lambda - expected_lambda).abs() < 1e-15,
"lambda should be precomputed as ln(2)/half_life: got {lambda}, expected {expected_lambda}"
);
}
#[test]
fn uat04_linear_decay_has_no_lambda() {
let mut builder = SchemaBuilder::new();
builder
.signal(
"impression",
EntityKind::Item,
DecaySpec::Linear {
lifetime: Duration::from_secs(86_400),
},
)
.windows(&[Window::TwentyFourHours])
.add();
let schema = builder.build().unwrap();
let sig = schema.signal("impression").unwrap();
assert!(sig.decay().lambda().is_none());
assert!(sig.decay().half_life().is_none());
}
#[test]
fn uat04_permanent_decay_has_no_lambda() {
let mut builder = SchemaBuilder::new();
builder
.signal("block", EntityKind::User, DecaySpec::Permanent)
.add();
let schema = builder.build().unwrap();
let sig = schema.signal("block").unwrap();
assert!(sig.decay().lambda().is_none());
assert!(sig.decay().half_life().is_none());
}
// ── UAT-05: Window enum — all variants, duration(), label(), duration_secs_f64() ─
#[test]
fn uat05_window_variants_and_methods() {
let windows = [
(Window::OneHour, Duration::from_secs(3_600), "1h", 3_600.0),
(
Window::TwentyFourHours,
Duration::from_secs(86_400),
"24h",
86_400.0,
),
(
Window::SevenDays,
Duration::from_secs(604_800),
"7d",
604_800.0,
),
(
Window::ThirtyDays,
Duration::from_secs(2_592_000),
"30d",
2_592_000.0,
),
];
for (window, expected_dur, expected_label, expected_secs) in windows {
assert_eq!(
window.duration(),
expected_dur,
"duration mismatch for {expected_label}"
);
assert_eq!(window.label(), expected_label);
assert!(
(window.duration_secs_f64() - expected_secs).abs() < 1e-10,
"duration_secs_f64 mismatch for {expected_label}"
);
}
// AllTime is special
assert_eq!(Window::AllTime.duration(), Duration::MAX);
assert_eq!(Window::AllTime.label(), "all");
assert!(Window::AllTime.duration_secs_f64().is_infinite());
}
// ── UAT-06: WindowSet — deduplicates and sorts; empty() for permanent ────────
#[test]
fn uat06_window_set_deduplicates_and_sorts() {
let ws = WindowSet::new(&[
Window::AllTime,
Window::OneHour,
Window::SevenDays,
Window::OneHour, // duplicate
Window::AllTime, // duplicate
]);
assert_eq!(ws.len(), 3);
let windows: Vec<Window> = ws.iter().copied().collect();
assert_eq!(
windows,
vec![Window::OneHour, Window::SevenDays, Window::AllTime]
);
}
#[test]
fn uat06_window_set_empty() {
let ws = WindowSet::empty();
assert!(ws.is_empty());
assert_eq!(ws.len(), 0);
}
// ── UAT-07: Error types — all TidalError variants ───────────────────────────
#[test]
fn uat07_tidal_error_variants_exist() {
use tidaldb::TidalError;
// Verify the variants exist and implement Display (via thiserror).
let errors: Vec<Box<dyn std::error::Error>> = vec![
Box::new(TidalError::Internal("test".into())),
Box::new(TidalError::NotFound {
kind: EntityKind::Item,
id: EntityId::new(1),
}),
Box::new(TidalError::Schema(SchemaError::NoSignalsDefined)),
];
for e in &errors {
// Display works
let msg = e.to_string();
assert!(!msg.is_empty());
}
}
#[test]
fn uat07_schema_error_converts_to_tidal_error() {
use tidaldb::TidalError;
let schema_err = SchemaError::DuplicateSignalName("view".into());
let tidal_err: TidalError = schema_err.into();
assert!(matches!(
tidal_err,
TidalError::Schema(SchemaError::DuplicateSignalName(_))
));
}
// ── UAT-08: SchemaError — all validation error conditions ───────────────────
#[test]
fn uat08_rejects_duplicate_signal_names() {
let mut builder = SchemaBuilder::new();
builder
.signal("view", EntityKind::Item, DecaySpec::Permanent)
.add();
builder
.signal("view", EntityKind::Item, DecaySpec::Permanent)
.add();
let err = builder.build().unwrap_err();
assert!(
matches!(err, SchemaError::DuplicateSignalName(ref name) if name == "view"),
"expected DuplicateSignalName, got: {err}"
);
}
#[test]
fn uat08_rejects_invalid_identifiers() {
let invalid_names = [
"",
"View",
"1view",
"view count",
"view-count",
"_view",
"view!",
];
for name in invalid_names {
let mut builder = SchemaBuilder::new();
builder
.signal(name, EntityKind::Item, DecaySpec::Permanent)
.add();
let err = builder.build().unwrap_err();
assert!(
matches!(err, SchemaError::InvalidSignalName(_)),
"expected InvalidSignalName for '{name}', got: {err}"
);
}
}
#[test]
fn uat08_rejects_zero_half_life() {
let mut builder = SchemaBuilder::new();
builder
.signal(
"bad",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::ZERO,
},
)
.windows(&[Window::AllTime])
.add();
let err = builder.build().unwrap_err();
assert!(
matches!(err, SchemaError::InvalidHalfLife { .. }),
"expected InvalidHalfLife, got: {err}"
);
}
#[test]
fn uat08_rejects_zero_lifetime() {
let mut builder = SchemaBuilder::new();
builder
.signal(
"bad",
EntityKind::Item,
DecaySpec::Linear {
lifetime: Duration::ZERO,
},
)
.windows(&[Window::AllTime])
.add();
let err = builder.build().unwrap_err();
assert!(
matches!(err, SchemaError::InvalidLifetime { .. }),
"expected InvalidLifetime, got: {err}"
);
}
#[test]
fn uat08_rejects_empty_windows_for_non_permanent() {
// Exponential without windows
let mut builder = SchemaBuilder::new();
builder
.signal(
"bad",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(3600),
},
)
.add();
let err = builder.build().unwrap_err();
assert!(
matches!(err, SchemaError::EmptyWindows { .. }),
"expected EmptyWindows for exponential, got: {err}"
);
// Linear without windows
let mut builder = SchemaBuilder::new();
builder
.signal(
"bad",
EntityKind::Item,
DecaySpec::Linear {
lifetime: Duration::from_secs(3600),
},
)
.add();
let err = builder.build().unwrap_err();
assert!(
matches!(err, SchemaError::EmptyWindows { .. }),
"expected EmptyWindows for linear, got: {err}"
);
}
#[test]
fn uat08_accepts_empty_windows_for_permanent() {
let mut builder = SchemaBuilder::new();
builder
.signal("hide", EntityKind::Item, DecaySpec::Permanent)
.add();
assert!(builder.build().is_ok());
}
#[test]
fn uat08_rejects_velocity_without_windows() {
let mut builder = SchemaBuilder::new();
builder
.signal("bad", EntityKind::Item, DecaySpec::Permanent)
.velocity(true)
.add();
let err = builder.build().unwrap_err();
assert!(
matches!(err, SchemaError::VelocityWithoutWindows { .. }),
"expected VelocityWithoutWindows, got: {err}"
);
}
#[test]
fn uat08_rejects_empty_schema() {
let err = SchemaBuilder::new().build().unwrap_err();
assert!(
matches!(err, SchemaError::NoSignalsDefined),
"expected NoSignalsDefined, got: {err}"
);
}
// ── UAT-09: SchemaBuilder — valid multi-signal schema ───────────────────────
#[test]
fn uat09_valid_multi_signal_schema() {
let mut builder = SchemaBuilder::new();
// Exponential decay with velocity
builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[
Window::OneHour,
Window::TwentyFourHours,
Window::SevenDays,
Window::ThirtyDays,
Window::AllTime,
])
.velocity(true)
.add();
// Linear decay
builder
.signal(
"impression",
EntityKind::Item,
DecaySpec::Linear {
lifetime: Duration::from_secs(86_400),
},
)
.windows(&[Window::TwentyFourHours])
.velocity(false)
.add();
// Permanent
builder
.signal("hide", EntityKind::Item, DecaySpec::Permanent)
.add();
// User signal
builder
.signal("follow", EntityKind::User, DecaySpec::Permanent)
.add();
// Creator signal
builder
.signal("subscribe", EntityKind::Creator, DecaySpec::Permanent)
.add();
let schema = builder.build().unwrap();
assert_eq!(schema.signal_count(), 5);
// Verify all signals are retrievable
assert!(schema.signal("view").is_some());
assert!(schema.signal("impression").is_some());
assert!(schema.signal("hide").is_some());
assert!(schema.signal("follow").is_some());
assert!(schema.signal("subscribe").is_some());
assert!(schema.signal("nonexistent").is_none());
// Verify iterate signals returns all
let names: HashSet<&str> = schema.signals().map(|s| s.name()).collect();
assert_eq!(names.len(), 5);
assert!(names.contains("view"));
assert!(names.contains("impression"));
assert!(names.contains("hide"));
assert!(names.contains("follow"));
assert!(names.contains("subscribe"));
}
// ── UAT-10: Score — rejects NaN/Inf, accepts finite, total ordering ─────────
#[test]
fn uat10_score_rejects_nan_and_inf() {
assert!(Score::new(f64::NAN).is_none());
assert!(Score::new(f64::INFINITY).is_none());
assert!(Score::new(f64::NEG_INFINITY).is_none());
}
#[test]
fn uat10_score_accepts_finite_values() {
let s = Score::new(0.75).unwrap();
assert!((s.as_f64() - 0.75).abs() < 1e-15);
let neg = Score::new(-10.0).unwrap();
assert!((neg.as_f64() - (-10.0)).abs() < 1e-15);
assert_eq!(Score::ZERO.as_f64(), 0.0);
}
#[test]
fn uat10_score_total_ordering() {
let a = Score::new(-1.0).unwrap();
let b = Score::ZERO;
let c = Score::new(0.5).unwrap();
let d = Score::new(1.0).unwrap();
assert!(a < b);
assert!(b < c);
assert!(c < d);
assert_eq!(b, Score::new(0.0).unwrap());
// Sort works
let mut scores = vec![d, a, c, b];
scores.sort();
let vals: Vec<f64> = scores.iter().map(|s| s.as_f64()).collect();
assert_eq!(vals, vec![-1.0, 0.0, 0.5, 1.0]);
}