- Score and boost contributions now captured per candidate in signal_snapshot:
score_by_sort() returns (f64, Vec<(String, f64)>) with named signal values
(e.g. view_velocity, share_velocity, view, like). Boost contributions are
appended as `{signal}_boost`. compute_raw_score() threads the snapshot
through; score_inner() and score_personalized() write it onto ScoredCandidate
instead of always initialising to vec![].
- Quickstart switched from `trending` to `hot` profile. The trending profile
scores by 24h velocity, which requires signals to arrive over real elapsed
time to populate hour-level bucket aggregates — impossible in a self-contained
demo. The hot profile (AllTime view count + age decay) works with any timestamp
and produces clearly differentiated scores. Signals now use different counts
per item to drive meaningful ranking output.
232 lines
7.7 KiB
Rust
232 lines
7.7 KiB
Rust
#![allow(clippy::unwrap_used)]
|
|
//! tidalDB quickstart: schema, items, signals, ranking.
|
|
//!
|
|
//! Demonstrates the full ingestion-to-ranking loop:
|
|
//! 1. Define a schema with `view` and `like` signals
|
|
//! 2. Open an ephemeral database
|
|
//! 3. Write 20 items with metadata and 128D random embeddings
|
|
//! 4. Record engagement: view 5 items, like 3 of those
|
|
//! 5. Retrieve ranked results using the `trending` profile
|
|
//! 6. Print ranked items with scores
|
|
//!
|
|
//! # Running
|
|
//!
|
|
//! ```bash
|
|
//! cargo run --manifest-path tidal/Cargo.toml --example quickstart
|
|
//! ```
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use rand::Rng;
|
|
use tidaldb::TidalDb;
|
|
use tidaldb::schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window};
|
|
|
|
/// Generate a random unit-normalized embedding of the given dimensionality.
|
|
fn random_unit_vector(dim: usize, rng: &mut impl Rng) -> Vec<f32> {
|
|
let v: Vec<f32> = (0..dim).map(|_| rng.random::<f32>() - 0.5).collect();
|
|
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
if norm < f32::EPSILON {
|
|
// Degenerate case: return a unit vector along the first axis.
|
|
let mut unit = vec![0.0_f32; dim];
|
|
unit[0] = 1.0;
|
|
return unit;
|
|
}
|
|
v.iter().map(|x| x / norm).collect()
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
// Initialize tracing so spans emitted by tidalDB are visible.
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter("tidaldb=info")
|
|
.init();
|
|
|
|
// ── 1. Define the schema ────────────────────────────────────────────
|
|
|
|
let mut schema = SchemaBuilder::new();
|
|
|
|
// View signal: 7-day half-life, 1h + 24h windows, velocity enabled.
|
|
let _ = schema
|
|
.signal(
|
|
"view",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(7 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime])
|
|
.velocity(true)
|
|
.add();
|
|
|
|
// Like signal: 30-day half-life, AllTime window.
|
|
let _ = schema
|
|
.signal(
|
|
"like",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(30 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::AllTime])
|
|
.velocity(false)
|
|
.add();
|
|
|
|
// Share signal: needed by the trending profile's boost definitions.
|
|
let _ = schema
|
|
.signal(
|
|
"share",
|
|
EntityKind::Item,
|
|
DecaySpec::Exponential {
|
|
half_life: Duration::from_secs(3 * 24 * 3600),
|
|
},
|
|
)
|
|
.windows(&[Window::TwentyFourHours, Window::AllTime])
|
|
.velocity(true)
|
|
.add();
|
|
|
|
let schema = schema.build()?;
|
|
|
|
// ── 2. Open an ephemeral database ───────────────────────────────────
|
|
|
|
let db = TidalDb::builder().ephemeral().with_schema(schema).open()?;
|
|
|
|
db.health_check()?;
|
|
|
|
println!("tidalDB opened (ephemeral, build: {})", tidaldb::BUILD_HASH);
|
|
println!();
|
|
|
|
// ── 3. Write 20 items with metadata and embeddings ──────────────────
|
|
|
|
let mut rng = rand::rng();
|
|
let categories = ["music", "tech", "cooking", "sports", "art"];
|
|
let dim = 128;
|
|
|
|
for i in 1..=20 {
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert("title".to_string(), format!("Item {i}: Great Content"));
|
|
metadata.insert(
|
|
"category".to_string(),
|
|
categories[i % categories.len()].to_string(),
|
|
);
|
|
metadata.insert("format".to_string(), "video".to_string());
|
|
metadata.insert("duration".to_string(), format!("{}", 60 + i * 30));
|
|
metadata.insert(
|
|
"created_at".to_string(),
|
|
Timestamp::now().as_nanos().to_string(),
|
|
);
|
|
|
|
db.write_item_with_metadata(EntityId::new(i as u64), &metadata)?;
|
|
|
|
let embedding = random_unit_vector(dim, &mut rng);
|
|
db.write_item_embedding(EntityId::new(i as u64), &embedding)?;
|
|
}
|
|
|
|
println!(
|
|
"Wrote {} items with metadata and {dim}D embeddings.",
|
|
db.item_count()
|
|
);
|
|
|
|
// ── 4. Record engagement signals ────────────────────────────────────
|
|
//
|
|
// Different items receive different engagement counts. The `hot` profile
|
|
// ranks by cumulative view count with age decay — items with more views
|
|
// score higher, producing clearly differentiated results.
|
|
//
|
|
// Note: the `trending` profile ranks by *velocity* (events/second over a
|
|
// rolling window), which requires signals arriving over real elapsed time
|
|
// to populate hour-level buckets. For a self-contained demo, `hot` is the
|
|
// right choice.
|
|
|
|
let now = Timestamp::now();
|
|
|
|
// (item_id, signal_type, count) — different counts drive different scores.
|
|
let signals: &[(u64, &str, u32)] = &[
|
|
(7, "view", 8),
|
|
(18, "view", 6),
|
|
(3, "view", 5),
|
|
(12, "view", 4),
|
|
(1, "view", 3),
|
|
(4, "view", 2),
|
|
(8, "view", 1),
|
|
(3, "like", 4),
|
|
(7, "like", 3),
|
|
(18, "like", 2),
|
|
(7, "share", 2),
|
|
(3, "share", 1),
|
|
];
|
|
|
|
let mut view_count = 0u32;
|
|
let mut like_count = 0u32;
|
|
let mut share_count = 0u32;
|
|
|
|
for &(item_id, signal_type, count) in signals {
|
|
for _ in 0..count {
|
|
db.signal(signal_type, EntityId::new(item_id), 1.0, now)?;
|
|
}
|
|
match signal_type {
|
|
"view" => view_count += count,
|
|
"like" => like_count += count,
|
|
"share" => share_count += count,
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
println!("Recorded {view_count} views, {like_count} likes, {share_count} shares.");
|
|
|
|
// Verify signal state is live.
|
|
let score = db.read_decay_score(EntityId::new(3), "view", 0)?;
|
|
println!("Item 3 view decay score: {:.4}", score.unwrap_or(0.0));
|
|
println!();
|
|
|
|
// ── 5. Retrieve ranked results ──────────────────────────────────────
|
|
|
|
// The `hot` builtin profile ranks by cumulative view count with age decay
|
|
// (Reddit/HN-style). Items with more views score higher; the age factor
|
|
// penalises older content. All items are treated as 24 hours old here
|
|
// since metadata-based age lookup is wired in M3+.
|
|
let query = tidaldb::query::retrieve::Retrieve::builder()
|
|
.profile("hot")
|
|
.limit(10)
|
|
.build()?;
|
|
|
|
let results = db.retrieve(&query)?;
|
|
|
|
println!(
|
|
"RETRIEVE profile=hot: {} results from {} candidates",
|
|
results.items.len(),
|
|
results.total_candidates
|
|
);
|
|
println!("{:<6} {:<12} {:<8} Signals", "Rank", "Entity ID", "Score");
|
|
println!("{}", "-".repeat(50));
|
|
|
|
for item in &results.items {
|
|
let signal_summary: String = item
|
|
.signals
|
|
.iter()
|
|
.map(|s| format!("{}={:.3}", s.name, s.value))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
|
|
println!(
|
|
"{:<6} {:<12} {:<8.4} {}",
|
|
item.rank,
|
|
item.entity_id.as_u64(),
|
|
item.score,
|
|
if signal_summary.is_empty() {
|
|
"-".to_string()
|
|
} else {
|
|
signal_summary
|
|
}
|
|
);
|
|
}
|
|
|
|
println!();
|
|
|
|
// ── 6. Clean up ─────────────────────────────────────────────────────
|
|
|
|
db.close()?;
|
|
println!("tidalDB closed. Quickstart complete.");
|
|
|
|
Ok(())
|
|
}
|