tidaldb/tidal/examples/foryou_feed.rs
jx12n 1092d34c39 feat: kubernetes deployment, OpenAPI spec, guides, and docker consolidation
- 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
2026-06-09 17:06:34 -06:00

473 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![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: 1545s.
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
}
);
}
}