- Add k8s/ manifests (StatefulSet, kustomize, PDB, ServiceMonitor) + docs/runbooks/kubernetes.md - Add tidal-server/src/openapi.rs (utoipa OpenAPI spec) and wire into router - Add docs/guides/ (build-a-feed-app, embeddings, server-deployment) + foryou_feed example - Consolidate tidal/docker/ into root docker/ (single canonical home) - Update API.md, QUICKSTART.md, README.md, CLAUDE.md, check-docs.sh accordingly
473 lines
18 KiB
Rust
473 lines
18 KiB
Rust
#![allow(clippy::unwrap_used)]
|
||
//! tidalDB short-video "For You" feed: the TikTok/Reels-shaped use case.
|
||
//!
|
||
//! Demonstrates the full swipe-loop on the embedded engine — the same shape a
|
||
//! short-video app's home feed would drive:
|
||
//!
|
||
//! 1. Define a schema modeling short-video signals: `view`, `completion`,
|
||
//! `like`, `share`, `skip` (the swipe-away negative), and `replay`.
|
||
//! 2. Open an ephemeral database and ingest ~24 short videos across 5 creators.
|
||
//! 3. Simulate one user's swipe session: completions + likes on music videos,
|
||
//! skips on the rest, and a couple of shares — fed back through
|
||
//! `signal_with_context` so the engine updates *that user's* taste vector.
|
||
//! 4. Retrieve the personalized `for_you` feed for the user (diversity-capped,
|
||
//! with a 10% exploration budget), then the global `trending` feed for
|
||
//! contrast.
|
||
//!
|
||
//! The point: the ranking outcome is driven by the *signals*, not by the random
|
||
//! embeddings. The user who completes and likes music videos and swipes past
|
||
//! everything else gets a music-leaning personalized feed — deterministically,
|
||
//! because the feedback loop is real.
|
||
//!
|
||
//! # Running
|
||
//!
|
||
//! ```bash
|
||
//! cargo run -p tidaldb --example foryou_feed
|
||
//! ```
|
||
|
||
use std::{collections::HashMap, time::Duration};
|
||
|
||
use rand::Rng;
|
||
use tidaldb::{
|
||
TidalDb,
|
||
schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
|
||
};
|
||
|
||
/// Generate a random unit-normalized embedding of the given dimensionality.
|
||
///
|
||
/// A real short-video app does NOT compute embeddings this way — it runs the
|
||
/// video (frames + audio + caption) through a multimodal model and stores the
|
||
/// resulting vector here. tidalDB *retrieves and ranks over* embeddings; it
|
||
/// does not generate them. See `docs/guides/embeddings.md`.
|
||
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()
|
||
}
|
||
|
||
#[allow(clippy::too_many_lines)] // Linear walkthrough script; splitting would hurt readability.
|
||
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 short-video schema ────────────────────────────────
|
||
//
|
||
// Each signal carries native temporal semantics. The half-lives encode how
|
||
// long each kind of engagement should keep mattering to ranking:
|
||
// - `view` decays over a week; this is the raw impression.
|
||
// - `completion` barely decays — finishing a short video is a strong,
|
||
// durable taste signal. Weight is the fraction watched (0..1), and it is
|
||
// a *positive-engagement* signal: it pulls the user's preference vector
|
||
// toward the video's content embedding.
|
||
// - `like` decays over a month and is also positive-engagement.
|
||
// - `share` decays fast (3d) and tracks velocity — shares are bursty.
|
||
// - `skip` the swipe-away. Fast 1d decay; NOT positive-engagement
|
||
// (a swipe must never pull taste toward the skipped video). Routed
|
||
// through `signal_with_context` it also marks the item as a hard
|
||
// negative / seen for that user.
|
||
// - `replay` medium decay; re-watching is a meaningful positive.
|
||
|
||
let mut schema = SchemaBuilder::new();
|
||
|
||
// view: 7-day half-life, velocity + 1h/24h/AllTime windows.
|
||
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();
|
||
|
||
// completion: very slow decay (1 year), positive-engagement.
|
||
// Weight = fraction of the video watched, in [0.0, 1.0].
|
||
let _ = schema
|
||
.signal(
|
||
"completion",
|
||
EntityKind::Item,
|
||
DecaySpec::Exponential {
|
||
half_life: Duration::from_secs(365 * 24 * 3600),
|
||
},
|
||
)
|
||
.windows(&[Window::AllTime])
|
||
.velocity(false)
|
||
.positive_engagement(true)
|
||
.add();
|
||
|
||
// like: 30-day half-life, positive-engagement.
|
||
let _ = schema
|
||
.signal(
|
||
"like",
|
||
EntityKind::Item,
|
||
DecaySpec::Exponential {
|
||
half_life: Duration::from_secs(30 * 24 * 3600),
|
||
},
|
||
)
|
||
.windows(&[Window::AllTime])
|
||
.velocity(false)
|
||
.positive_engagement(true)
|
||
.add();
|
||
|
||
// share: 3-day half-life, velocity (bursty), 24h + AllTime windows.
|
||
let _ = schema
|
||
.signal(
|
||
"share",
|
||
EntityKind::Item,
|
||
DecaySpec::Exponential {
|
||
half_life: Duration::from_secs(3 * 24 * 3600),
|
||
},
|
||
)
|
||
.windows(&[Window::TwentyFourHours, Window::AllTime])
|
||
.velocity(true)
|
||
.add();
|
||
|
||
// skip: fast 1-day decay — the swipe-away negative. NOT positive-engagement.
|
||
let _ = schema
|
||
.signal(
|
||
"skip",
|
||
EntityKind::Item,
|
||
DecaySpec::Exponential {
|
||
half_life: Duration::from_secs(24 * 3600),
|
||
},
|
||
)
|
||
.windows(&[Window::OneHour, Window::TwentyFourHours])
|
||
.velocity(false)
|
||
.add();
|
||
|
||
// replay: medium decay (14 days), positive-engagement.
|
||
let _ = schema
|
||
.signal(
|
||
"replay",
|
||
EntityKind::Item,
|
||
DecaySpec::Exponential {
|
||
half_life: Duration::from_secs(14 * 24 * 3600),
|
||
},
|
||
)
|
||
.windows(&[Window::AllTime])
|
||
.velocity(false)
|
||
.positive_engagement(true)
|
||
.add();
|
||
|
||
// Embedding slot: 128-dim content vectors for items (the "content" model).
|
||
let _ = schema.embedding_slot("content", EntityKind::Item, 128);
|
||
|
||
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: {}) — short-video For You demo",
|
||
tidaldb::BUILD_HASH
|
||
);
|
||
println!();
|
||
|
||
// ── 3. Ingest ~24 short videos across 5 creators ────────────────────
|
||
//
|
||
// NOTE: embeddings here are random unit vectors purely so the example is
|
||
// self-contained. A production app computes them from the actual video
|
||
// (frames/audio/caption) with a multimodal model — tidalDB only retrieves
|
||
// and ranks over them. See `docs/guides/embeddings.md`.
|
||
|
||
let mut rng = rand::rng();
|
||
let dim = 128;
|
||
|
||
// Five creators, each leaning into one category so the feedback loop has a
|
||
// clear "taste" to discover. Creator 1 == music, 3 == music; the rest are
|
||
// tech / cooking / comedy.
|
||
let categories = ["music", "tech", "cooking", "comedy", "music"];
|
||
let now = Timestamp::now();
|
||
|
||
let mut music_items: Vec<u64> = Vec::new();
|
||
let mut nonmusic_items: Vec<u64> = Vec::new();
|
||
|
||
for i in 1..=24u64 {
|
||
// Round-robin items across the 5 creators / categories.
|
||
let slot = (i as usize - 1) % categories.len();
|
||
let category = categories[slot];
|
||
let creator_id = (slot + 1) as u64; // creator ids 1..=5
|
||
|
||
let mut metadata = HashMap::new();
|
||
metadata.insert("title".to_string(), format!("Short #{i} — {category}"));
|
||
metadata.insert("category".to_string(), category.to_string());
|
||
metadata.insert("format".to_string(), "short".to_string());
|
||
metadata.insert("creator_id".to_string(), creator_id.to_string());
|
||
// Durations typical of short-form video: 15–45s.
|
||
metadata.insert("duration".to_string(), format!("{}", 15 + (i % 4) * 10));
|
||
metadata.insert("created_at".to_string(), now.as_nanos().to_string());
|
||
|
||
db.write_item_with_metadata(EntityId::new(i), &metadata)?;
|
||
|
||
let embedding = random_unit_vector(dim, &mut rng);
|
||
db.write_item_embedding(EntityId::new(i), &embedding)?;
|
||
|
||
if category == "music" {
|
||
music_items.push(i);
|
||
} else {
|
||
nonmusic_items.push(i);
|
||
}
|
||
}
|
||
|
||
println!(
|
||
"Ingested {} short videos across 5 creators ({} music, {} other), {dim}D embeddings.",
|
||
db.item_count(),
|
||
music_items.len(),
|
||
nonmusic_items.len()
|
||
);
|
||
println!(" music videos: {music_items:?}\n non-music videos: {nonmusic_items:?}");
|
||
println!();
|
||
|
||
// ── 4. Simulate one user's swipe session ────────────────────────────
|
||
//
|
||
// We route every engagement through `signal_with_context` with the user id
|
||
// (1001) and the video's creator id. That is what makes this a *feedback
|
||
// loop*: positive-engagement signals (completion, like, replay) fold the
|
||
// video's content embedding into user 1001's preference vector, while skips
|
||
// mark videos as hard negatives for that user.
|
||
//
|
||
// IMPORTANT — a session is a *slice* of the catalog, not the whole thing.
|
||
// Any item the user engages with (positively OR negatively) is marked SEEN
|
||
// for that user, and the personalized `for_you` feed deliberately excludes
|
||
// seen + hard-negative items: a feed must surface FRESH content, never
|
||
// re-show what was just watched. So we touch only part of the catalog and
|
||
// leave fresh videos behind. The taste the user reveals on the watched slice
|
||
// (music) is what ranks those fresh videos.
|
||
|
||
let user_id: u64 = 1001;
|
||
|
||
// Helper to recover a video's creator id from its round-robin slot.
|
||
let creator_of = |item: u64| -> u64 { ((item - 1) % categories.len() as u64) + 1 };
|
||
|
||
// Split each category into a "watched this session" slice and a "fresh"
|
||
// remainder. The watched slice trains taste; the fresh remainder is what the
|
||
// For You feed gets to rank.
|
||
let watched_music: Vec<u64> = music_items.iter().take(5).copied().collect();
|
||
let fresh_music: Vec<u64> = music_items.iter().skip(5).copied().collect();
|
||
let skipped_nonmusic: Vec<u64> = nonmusic_items.iter().take(6).copied().collect();
|
||
let fresh_nonmusic: Vec<u64> = nonmusic_items.iter().skip(6).copied().collect();
|
||
|
||
let mut completions = 0u32;
|
||
let mut likes = 0u32;
|
||
let mut skips = 0u32;
|
||
let mut shares = 0u32;
|
||
let mut replays = 0u32;
|
||
|
||
// The user LOVES music: watches the session's music videos to completion and
|
||
// likes them. (Positive-engagement → folds each video's embedding into the
|
||
// user's preference vector, and strengthens the (user, music-creator) edge.)
|
||
for &item in &watched_music {
|
||
// completion weight is the fraction watched — these get watched fully.
|
||
db.signal_with_context(
|
||
"completion",
|
||
EntityId::new(item),
|
||
1.0,
|
||
now,
|
||
Some(user_id),
|
||
Some(creator_of(item)),
|
||
)?;
|
||
completions += 1;
|
||
|
||
db.signal_with_context(
|
||
"like",
|
||
EntityId::new(item),
|
||
1.0,
|
||
now,
|
||
Some(user_id),
|
||
Some(creator_of(item)),
|
||
)?;
|
||
likes += 1;
|
||
}
|
||
|
||
// Two shares + one replay on the first couple of watched music videos.
|
||
for &item in watched_music.iter().take(2) {
|
||
db.signal_with_context(
|
||
"share",
|
||
EntityId::new(item),
|
||
1.0,
|
||
now,
|
||
Some(user_id),
|
||
Some(creator_of(item)),
|
||
)?;
|
||
shares += 1;
|
||
}
|
||
if let Some(&item) = watched_music.first() {
|
||
db.signal_with_context(
|
||
"replay",
|
||
EntityId::new(item),
|
||
1.0,
|
||
now,
|
||
Some(user_id),
|
||
Some(creator_of(item)),
|
||
)?;
|
||
replays += 1;
|
||
}
|
||
|
||
// The user SWIPES PAST the session's non-music videos: a brief view, then a
|
||
// skip. The skip is the swipe-away negative — it must NOT pull taste toward
|
||
// the skipped content (skip is not positive-engagement), and it marks the
|
||
// video as a hard negative so the feed never re-surfaces it.
|
||
for &item in &skipped_nonmusic {
|
||
// A partial view (watched ~10% — a glance) before swiping.
|
||
db.signal_with_context(
|
||
"view",
|
||
EntityId::new(item),
|
||
0.1,
|
||
now,
|
||
Some(user_id),
|
||
Some(creator_of(item)),
|
||
)?;
|
||
db.signal_with_context(
|
||
"skip",
|
||
EntityId::new(item),
|
||
1.0,
|
||
now,
|
||
Some(user_id),
|
||
Some(creator_of(item)),
|
||
)?;
|
||
skips += 1;
|
||
}
|
||
|
||
println!(
|
||
"User {user_id} swipe session: {completions} completions, {likes} likes, \
|
||
{shares} shares, {replays} replay, {skips} skips."
|
||
);
|
||
println!(
|
||
" watched (now seen): music {watched_music:?}, skipped non-music {skipped_nonmusic:?}"
|
||
);
|
||
println!(" FRESH (feed pool): music {fresh_music:?}, non-music {fresh_nonmusic:?}");
|
||
|
||
// Show that the feedback loop landed: the most-completed music video has a
|
||
// live completion decay score.
|
||
if let Some(&top_music) = watched_music.first() {
|
||
let score = db.read_decay_score(EntityId::new(top_music), "completion", 0)?;
|
||
println!(
|
||
" completion decay score for music video #{top_music}: {:.4}",
|
||
score.unwrap_or(0.0)
|
||
);
|
||
}
|
||
println!();
|
||
|
||
// ── 5. Retrieve the personalized For You feed ───────────────────────
|
||
//
|
||
// `for_you` blends interaction-weighted decay scores (view + 2*like +
|
||
// 1.5*share velocity) with the user's preference vector, then enforces
|
||
// diversity: `max_per_creator = 2` so a single creator cannot dominate the
|
||
// feed, plus a 10% exploration budget (`FOR_YOU_EXPLORATION`) that injects
|
||
// unseen/random items to avoid a filter bubble. Skipped videos are filtered
|
||
// out as hard negatives for this user.
|
||
|
||
let for_you = tidaldb::query::retrieve::Retrieve::builder()
|
||
.for_user(user_id)
|
||
.profile("for_you")
|
||
.limit(10)
|
||
.build()?;
|
||
|
||
let results = db.retrieve(&for_you)?;
|
||
|
||
println!(
|
||
"RETRIEVE profile=for_you (user={user_id}): {} results from {} candidates",
|
||
results.items.len(),
|
||
results.total_candidates
|
||
);
|
||
print_feed(&results);
|
||
|
||
// The payoff: the candidate pool is the FRESH videos only (the 11 watched /
|
||
// skipped videos are excluded), and the top of the feed is the fresh MUSIC
|
||
// videos — surfaced because the user's session built a strong interaction
|
||
// edge with the music creators. The taste was learned from signals, not from
|
||
// the (random) embeddings, so this ordering is stable across runs.
|
||
let top_ids: Vec<u64> = results
|
||
.items
|
||
.iter()
|
||
.take(fresh_music.len())
|
||
.map(|i| i.entity_id.as_u64())
|
||
.collect();
|
||
println!(
|
||
" top {} For You slots are the fresh music videos {top_ids:?} (taste leaned music)",
|
||
fresh_music.len()
|
||
);
|
||
println!();
|
||
|
||
// ── 6. Retrieve the global Trending feed for contrast ───────────────
|
||
//
|
||
// `trending` is NOT personalized: it ranks every item globally by
|
||
// velocity (view_vel + 2*share_vel over the 24h window), also capped per
|
||
// creator. This is what a brand-new visitor with no taste history sees.
|
||
|
||
let trending = tidaldb::query::retrieve::Retrieve::builder()
|
||
.profile("trending")
|
||
.limit(10)
|
||
.build()?;
|
||
|
||
let trending_results = db.retrieve(&trending)?;
|
||
|
||
println!(
|
||
"RETRIEVE profile=trending (global, no user): {} results from {} candidates",
|
||
trending_results.items.len(),
|
||
trending_results.total_candidates
|
||
);
|
||
print_feed(&trending_results);
|
||
println!();
|
||
|
||
// ── 7. Clean up ─────────────────────────────────────────────────────
|
||
|
||
db.close()?;
|
||
println!("tidalDB closed. For You demo complete.");
|
||
|
||
// ── What tidalDB owns vs what the app owns ──────────────────────────
|
||
//
|
||
// tidalDB owns: RANKING. Signal ledgers (decay/velocity/windows), per-user
|
||
// preference vectors, candidate retrieval, filtering, diversity, and the
|
||
// For You / Trending ordering — one process, one query.
|
||
//
|
||
// The app owns: everything else. Video storage + CDN + transcoding,
|
||
// embedding generation (run the video through your multimodal model),
|
||
// authentication/identity, and the player UI. The app writes signals as
|
||
// users swipe and reads back ranked feeds.
|
||
//
|
||
// To build the real thing end-to-end, see `docs/guides/build-a-feed-app.md`.
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Print a ranked feed: rank, entity id, score, and per-item signal summary.
|
||
fn print_feed(results: &tidaldb::query::Results) {
|
||
println!("{:<6} {:<12} {:<10} Signals", "Rank", "Video ID", "Score");
|
||
println!("{}", "-".repeat(64));
|
||
|
||
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} {:<10.4} {}",
|
||
item.rank,
|
||
item.entity_id.as_u64(),
|
||
item.score,
|
||
if signal_summary.is_empty() {
|
||
"-".to_string()
|
||
} else {
|
||
signal_summary
|
||
}
|
||
);
|
||
}
|
||
}
|