#![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 { let v: Vec = (0..dim).map(|_| rng.random::() - 0.5).collect(); let norm = v.iter().map(|x| x * x).sum::().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> { // 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 = Vec::new(); let mut nonmusic_items: Vec = 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 = music_items.iter().take(5).copied().collect(); let fresh_music: Vec = music_items.iter().skip(5).copied().collect(); let skipped_nonmusic: Vec = nonmusic_items.iter().take(6).copied().collect(); let fresh_nonmusic: Vec = 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 = 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::>() .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 } ); } }