Three real defects, plus a retracted fourth that was a probe artifact. P3 (fixed) - query/stored normalization asymmetry. The write path L2-normalized every stored vector; the read path passed the caller's raw query straight to the index, so the two sides lived in different spaces. With unit v, d = |q|^2 - 2q.v + 1, so a non-unit query shifted and scaled every distance by |q|^2. Measured live: 591-1174 against a documented [0,4], and an exact match scoring |q|^2 - 1 instead of ~0. vector_search_items now normalizes with the canonical l2_normalize; a zero-norm query (no direction, so nearest-by-cosine is undefined) is rejected with 400. Ranking is unchanged - |q|^2 and 1 are constant across candidates - which is why it went unnoticed; what broke was every absolute use of the number. WIRE-VISIBLE, recorded in CHANGELOG. P2 (fixed) - the blob path had zero instrumentation. Added per-kind tidaldb_cluster_blobs_originated/applied/apply_failed totals. Label cardinality is fixed at 4 by construction via a new BlobKind enum, and BlobRecord::blob_kind is now the ONE exhaustive match over the variants (kind() derives from it), so a new variant is a compile error in one place instead of a silent zero in three. Only the live apply path is counted - boot replay would inflate applied past originated on every restart. Coverage gap (fixed) - tidaldb_usearch_vector_count rendered only the metrics owner's shard group, so on a 3-group node two thirds of the corpus had no vector-count series at all. Co-located groups now render shard="N"; the owner stays unlabeled for wire compatibility, so an alert grouped by (shard) buckets each replica set separately without double-counting. P1 (RETRACTED) - the "replica-divergent vector index" does not exist. Every probe wrote through the /sharded/ surface, which hash-partitions and applies to the owning region's local store with no WAL append, and therefore does not replicate BY DESIGN (cluster/node.rs:8828-8829). A controlled A/B settled it: on /items plus /embeddings all 6 entities reach all 3 replicas; on the sharded surface four of six reach exactly one node. Both are now pinned by tests. See tmp/vector-search-correctness/diagnosis.md and the k3s-fleet cluster-state.yaml entry RETRACTED_blob_replication_rf1_2026_08_30. Pre-work: usearch_index.rs 872 to 503 lines by extracting its tests to a sibling (the project's existing path-attribute convention), and the three hand-rolled l2_normalize copies collapsed to one. The two entity copies used a zero threshold about 2900x looser than the canonical one; normalize_centroid now names the centroid zero-tolerance policy once, and a test pins the tightened behavior. Tests: 2107 lib (+5), 8 vector_search e2e (+4, three of which fail without the P3 fix), 4 cluster_sharding e2e (+2). The heavy multiproc tests in cluster_sharding are now serialized - four concurrent 3-node clusters made the pre-existing failover test miss its 10s budget. |
||
|---|---|---|
| .claude | ||
| .codex/agents | ||
| .sdlc | ||
| ai-lookup | ||
| applications | ||
| demo | ||
| docker | ||
| docs | ||
| hooks | ||
| k8s | ||
| scripts | ||
| site | ||
| tests/e2e | ||
| tidal | ||
| tidal-net | ||
| tidal-server | ||
| tidal-stress | ||
| tidalctl | ||
| .dockerignore | ||
| .gitignore | ||
| .woodpecker.yaml | ||
| AGENTS.md | ||
| API.md | ||
| ARCHITECTURE.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| CODING_GUIDELINES.md | ||
| CONTRIBUTING.md | ||
| forage-discover.sh | ||
| package-lock.json | ||
| package.json | ||
| playwright.config.ts | ||
| playwright.demo.config.ts | ||
| playwright.semantics.config.ts | ||
| QUICKSTART.md | ||
| README.md | ||
| remotion.config.ts | ||
| rust-toolchain.toml | ||
| SEQUENCE.md | ||
| thoughts.md | ||
| tsconfig.json | ||
| USE_CASES.md | ||
| VISION.md | ||
tidalDB
An embeddable Rust database for the personalized content ranking problem.
Production-ready. M0–M12 shipped: crash-safe storage, ranked retrieval, hybrid search, ANN vector retrieval, and a quorum-acked HA cluster running in production on k3s. The API surface is stable for shipped features.
Every content platform eventually builds the same distributed system from scratch: Elasticsearch for retrieval, Redis for hot signals, Kafka for event ingestion, a feature store for user profiles, a vector database for semantic search, and a ranking service that stitches them together. The seams between those systems are where correctness dies — stale signals, inconsistent ranking, cache invalidation bugs, ETL lag.
The root cause: existing databases treat ranking as an afterthought. They have no native concept of signals that evolve over time, no understanding of user context, no diversity as a query constraint.
Ranking is not a feature. It is a primitive.
tidalDB is a single-node, embeddable Rust library built for one question: given a user and a context, what content should they see, and in what order? No server, no network protocol, no client SDK. Link it into your process.
What it looks like
use std::collections::HashMap;
use std::time::Duration;
use tidaldb::{TidalDb, query::retrieve::Retrieve, schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window}};
// Declare signals with native decay — no application formulas.
let mut schema = SchemaBuilder::new();
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();
let _ = schema.signal("like", EntityKind::Item, DecaySpec::Exponential {
half_life: Duration::from_secs(30 * 24 * 3600),
}).windows(&[Window::AllTime]).velocity(false).add();
let schema = schema.build()?;
// Open — ephemeral for tests, persistent for production.
let db = TidalDb::builder().ephemeral().with_schema(schema).open()?;
// Ingest content with metadata.
let mut meta = HashMap::new();
meta.insert("title".to_string(), "Introduction to Jazz Piano".to_string());
meta.insert("category".to_string(), "music".to_string());
db.write_item_with_metadata(EntityId::new(1), &meta)?;
// Write an embedding (you generate it, tidalDB indexes and ranks over it).
db.write_item_embedding(EntityId::new(1), &your_model.embed("Introduction to Jazz Piano"))?;
// Record engagement — the feedback loop closes here, no ETL required.
db.signal("view", EntityId::new(1), 1.0, Timestamp::now())?;
db.signal_with_context("like", EntityId::new(1), 1.0, Timestamp::now(), Some(user_id), Some(creator_id))?;
// Retrieve a ranked feed. Name the profile. tidalDB executes the pipeline.
let results = db.retrieve(&Retrieve::builder().for_user(user_id).profile("for_you").limit(50).build()?)?;
// Search: BM25 + semantic similarity fused via RRF.
let results = db.search(&Search::builder().query("jazz piano tutorial").for_user(user_id).limit(20).build()?)?;
db.close()?;
What it replaces
| System | tidalDB equivalent |
|---|---|
| Elasticsearch | Tantivy BM25 text index (derived, crash-recoverable) |
| Redis | Lock-free in-memory signal ledger — decay scores, windowed counters |
| Kafka | Write-ahead log — durable, ordered, replayable |
| Feature store | Signal aggregates + user preference vectors (updated at write time) |
| Vector DB | USearch HNSW — embedded, f16 quantized, predicate-filtered ANN |
| Ranking service | 25 named profiles, scored at query time, swappable by name |
Key capabilities
- Signals with native decay — declare
viewwith a 7-day half-life; the database applies it at query time. Notrending_score_7dfield to maintain. - 25 built-in ranking profiles —
trending,hot,for_you,following,related,hidden_gems,top_week,shuffle,controversial, and more. Name the profile; the database executes the full pipeline. - Hybrid search — BM25 full-text + ANN semantic similarity, fused via Reciprocal Rank Fusion, personalized by user preference vector.
- Composable filters — filter by category, format, duration, language, engagement threshold, location, collection membership, and more — any combination, all composable.
- Diversity as a query constraint —
max_per_creator: 2belongs in the query, not your API layer. - Feedback loop in the write path — a signal write atomically updates the item's ledger, the user's preference vector, and relationship weights. The next ranking query — 100ms later — reflects it.
- Cold start handled — new content gets an exploration budget; new users get sensible defaults. No application logic required.
- Cohort-scoped trending — "trending among US users aged 18-24 who engage with jazz" is one query, not a pipeline.
- Embeddable first — runs in your process.
Arc<TidalDb>isSend + Sync. No operational overhead.
Getting started
Pick the path that matches how you plan to use tidalDB today. Every option below is self-contained and ships in this repo.
1. Embed tidalDB inside your Rust service (library mode)
Setup
- Add the dependency (the
tidaldbcrate is attidal/in this repository):[dependencies] tidaldb = { git = "https://github.com/orchard9/tidaldb", rev = "..." } # or, for a local checkout: tidaldb = { path = "path/to/tidaldb/tidal" } - Define your schema before opening the database (decay, windows, text fields, embeddings). The snippet in Quickstart, Step 2 is a ready-to-copy template.
- Choose storage mode when building:
let db = tidaldb::TidalDb::builder() .with_schema(schema) .ephemeral() // in-memory for tests // .with_data_dir("/var/lib/tidaldb") // persistent deployment .open()?; - Run the end-to-end sample:
cargo run --manifest-path tidal/Cargo.toml --example quickstart
Usage
- Call
db.signal(...),db.signal_with_context(...), anddb.retrieve(...)/db.search(...)from the same process; no network stack required. - Wrap the instance in
Arc<TidalDb>to share it across threads or tasks. - Persisted deployments can be inspected with the CLI tool:
cargo run -p tidalctl -- status --path /var/lib/tidaldb. - Full walkthrough: QUICKSTART.md and API.md.
2. Run the standalone HTTP server (tidal-server)
Why: you want a ready-to-run HTTP facade without writing Axum/Actix glue.
cargo run -p tidal-server -- \
standalone \
--listen 127.0.0.1:9400 \
--schema tidal-server/config/default-schema.yaml
Options:
--data-dir /var/lib/tidaldbswitches to persistent storage.- Provide your own schema file (YAML) to match your signal mix.
Usage:
# register metadata + embedding
curl -X POST http://127.0.0.1:9400/items \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1, "metadata": { "title": "Jazz Piano", "category": "music" } }'
curl -X POST http://127.0.0.1:9400/embeddings \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1, "values": [0.1, 0.2, 0.3] }'
# write engagement (supports user/creator context)
curl -X POST http://127.0.0.1:9400/signals \
-H 'Content-Type: application/json' \
-d '{ "entity_id": 1, "signal": "view", "weight": 1.0, "user_id": 42 }'
# query
curl "http://127.0.0.1:9400/feed?user_id=42&profile=for_you&limit=20"
curl "http://127.0.0.1:9400/search?query=jazz%20piano&user_id=42&limit=5"
curl http://127.0.0.1:9400/health
The default schema lives at tidal-server/config/default-schema.yaml. Edit
it (or provide your own path) to align with your application’s signals,
text fields, and embedding slots.
3. Wrap it in an HTTP service you control
Expose tidalDB through your favorite web framework; the repo ships runnable templates.
-
Axum sample (
tidal/examples/axum_embedding.rs)cargo run --example axum_embedding --manifest-path tidal/Cargo.tomlUsage:
curl -X POST http://127.0.0.1:3000/signal \ -H 'Content-Type: application/json' \ -d '{ "entity_id": 1, "signal": "view", "weight": 1.0 }' curl "http://127.0.0.1:3000/feed?user_id=42" curl http://127.0.0.1:3000/healthThe example handles schema setup, wraps
Arc<TidalDb>in AxumState, and mapsTidalErrorto HTTP responses. -
Actix sample (
tidal/examples/actix_embedding.rs)cargo run --example actix_embedding --manifest-path tidal/Cargo.toml # curl http://127.0.0.1:3001/healthDemonstrates sharing
Arc<TidalDb>throughweb::Dataand using Actix’s shutdown hooks.
Use either sample as a starting point for microservices that prefer a client/server boundary.
4. Run the Forage demo server (Axum + UI)
Want to see tidalDB powering a live personalization surface? Forage is a thin Axum server + feed UI that talks to a tidalDB instance embedded in-process.
cargo run -p forage-server --manifest-path applications/forage/server/Cargo.toml
open http://localhost:4242
Flags:
--ephemeralto keep everything in-memory.--data-dir ~/.forage/datato point at a custom persistent directory.
Usage:
curl -X POST http://localhost:4242/signal \
-H "Content-Type: application/json" \
-d '{ "user_id": 1, "item_id": 42, "signal_type": "view" }'
curl "http://localhost:4242/feed?user=1&limit=7"
The UI shows seeded users, exploration labels, and real-time adaptation; see applications/forage/README.md for the full loop.
5. Run the cluster server + Docker image
Need a real high-availability endpoint? Run tidal-server in cluster
mode. This is a genuine HA cluster — quorum-acked writes, automatic
leader election + failover, elastic seed-join membership, inter-node mTLS,
and per-node Prometheus metrics — deployed in production on k3s as one
StatefulSet (3 pods = 3 regions = 3 voters, full-placement RF3 so every
pod hosts all shard groups, HTTPS + mTLS on :9500). It exposes /signals,
/feed, /search plus cluster-management routes.
Because a standalone node is the right answer for most deployments, cluster
mode requires an explicit opt-in flag (--experimental-cluster, or
TIDAL_ALLOW_EXPERIMENTAL_CLUSTER=1) so nobody starts a multi-node fabric by
accident. Reach for it deliberately when you need multi-node availability or
read-scale.
cargo run -p tidal-server -- \
cluster \
--listen 0.0.0.0:9500 \
--schema tidal-server/config/default-schema.yaml \
--topology tidal-server/config/default-cluster.yaml \
--experimental-cluster
Key endpoints:
curl https://127.0.0.1:9500/health
curl -X POST https://127.0.0.1:9500/signals -d '{ "entity_id": 1, "signal": "view", "weight": 1.0 }'
curl "https://127.0.0.1:9500/feed?profile=trending®ion=eu-west"
curl https://127.0.0.1:9500/cluster/status
# /cluster/promote is a fenced MAINTENANCE verb: a graceful, voluntary
# leadership handoff. It is NOT the failover path — kill the leader and the
# survivors elect a successor automatically, with zero operator action.
curl -X POST https://127.0.0.1:9500/cluster/promote -d '{ "region": "eu-west" }'
Cluster mode replicates global signals only (no user_id /
creator_id contexts) so that followers stay in sync with the leader's
replicated log. For Kubernetes deployment, scaling, failover drills, and the
operational API see docs/runbooks/kubernetes.md
and docs/runbooks/cluster.md.
Prefer containers? Build the provided image and run it anywhere:
docker build -f docker/cluster/Dockerfile -t tidal-cluster .
docker run --rm -p 9500:9500 tidal-cluster
Mount your own schema/topology files with -v if you want different regions
or signal definitions.
6. Simulate a multi-region cluster in tests
The raw SimulatedCluster harness (no HTTP) remains available for property
tests and fuzzing.
cargo test --test m8_uat
cargo test --test m8_uat uat_step3 -- --nocapture # run a single scenario
Tweak tidal/tests/m8_uat.rs to script specific replication, failover, and
migration scenarios inside your own test suites.
MSRV: Rust 1.91
Documentation
| Document | Contents |
|---|---|
| QUICKSTART.md | Step-by-step guide: schema, ingest, signals, ranking, search |
| API.md | Full API reference with code examples |
| Build a feed app | End-to-end TikTok/Reels-style "For You" feed tutorial |
| Embedding integration | Wiring a real embedding model into the write + query paths |
| Server deployment | Running tidal-server: config, auth, OpenAPI, Docker |
| Kubernetes runbook | Deploying on k8s (manifests in k8s/) |
| VISION.md | Problem statement and design thesis |
| ARCHITECTURE.md | Storage, signal system, vector index, query pipeline |
| USE_CASES.md | 14 content discovery surfaces, filter and sort references |
Status
Milestones completed:
- Storage engine, WAL, entity store, signal ledger
- RETRIEVE query: candidate retrieval, filtering, scoring, diversity, pagination
- Vector index (USearch HNSW) with adaptive filtered search; ANN candidate generation in RETRIEVE with honored per-query
ef_search - Multi-vector user preference modeling (per-user interest clusters with decayed importance)
- 25 built-in ranking profiles
- BM25 full-text search (Tantivy) + hybrid RRF fusion
- Creator search and creator profiles
- Cohort-scoped signal aggregation and trending
- Social graph (follows, blocks, following feed)
- Collections, saved searches, autocomplete suggestions
- Session and agent context (short-lived signals, preference decay)
- Crash recovery, graceful degradation, rate limiting, diagnostics
- Scale: tested to 1M items; scale benchmarks passing
- High-availability cluster: quorum-acked writes, automatic election + failover, elastic seed-join membership, inter-node mTLS, per-node Prometheus — running in production on k3s
tidalDB is production-ready. The API surface is stable for the implemented features, and every shipped guarantee is covered by the chaos and soak suites in docs/planning/ROADMAP.md. Semantic versioning applies from here: additive changes ship in minor releases, and any breaking change gets a documented migration path in CHANGELOG.md.