tidaldb/tidal/src/signals/ledger/hot_top_k.rs
jx12n bb21e69ae6 feat(m12): vector retrieval G1/G2 — recall harness, ANN in RETRIEVE, index tuning
m12p1 (measurement truth): TidalDb::vector_search_items pure k-NN probe +
POST /vector_search (standalone + region node, merge-by-distance) +
tidal-stress --verify-recall (deterministic id-keyed corpus, in-RAM brute-force
cosine oracle, open-loop ramp → recall@k + true p99 + read-knee + JSON/gate exit).
Repaired fabricated p99 columns (mean-as-p99) in social-scale.md / scale.rs.
Verified real: recall@10=0.9997 at 20k/1536-D vs brute-force.

m12p2 (G1 unblock): ANN candidate-gen wired into RETRIEVE — for_you=preference
vector, related=seed embedding (similar_to), graceful scan-fallback. Cached
per-signal-type top-K (signals/ledger/hot_top_k.rs, decay-order-invariant) so
trending serves O(K). related over HTTP (FeedQuery.similar_to). Harness gains
--feed-profile / --seed-preferences. Verified: trending retrieve p99 3.5-7.7ms.

m12p3 (G2): per-query ef_search now honored (RwLock epoch-guard with_expansion,
shared guard for same-ef concurrency) + dimension-aware brute→HNSW crossover
usearch_min_vectors(dim) + memory_usage() + examples/ann_grid_search.rs.
Measured 1536-D/100k clustered: default M=16/ef_c=400/F16/ef_s=200 clears
G1+G2 (recall 0.997, p99 1.4ms); F16 -0.25% vs F32; Int8 rejected (-28%).
Recall corpus is now clustered (Gaussian mixture) in grid + harness.
2026-06-14 11:07:09 -06:00

161 lines
6.5 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.

//! Cached per-signal-type top-K (m12p2): bound `SignalRanked` candidate
//! generation to O(K) per query instead of an O(N) ledger scan on every call.
//!
//! # Why a cache is correct here
//!
//! A `SignalRanked` strategy ranks candidates by a signal's decayed score. The
//! score of an entity at query time is `accumulated * exp(-lambda * dt)`, and the
//! SAME `lambda` applies to every entity of a given signal type, so time decay
//! multiplies every entity's score by the same factor — it **never changes the
//! relative order**. The membership of the top-K therefore only changes when a
//! NEW signal write bumps some entity's accumulated score. That makes a cached
//! top-K valid until the next write, not until the next clock tick.
//!
//! # Freshness policy
//!
//! - **Small ledgers** (`< SMALL_LEDGER_ENTRIES`): the O(N) rebuild is
//! sub-millisecond, so we rebuild whenever a write has landed since the cache
//! was built — always fresh, which is what in-process tests and small apps
//! expect.
//! - **Large ledgers** (`>= SMALL_LEDGER_ENTRIES`): a continuous-write workload
//! would otherwise land an O(N) scan on the read hot path and blow the p99, so
//! rebuilds are throttled to at most one per [`REFRESH_INTERVAL_NS`]. Trending
//! tolerates a second of staleness; the p99 SLO does not tolerate a 50ms scan.
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::DashMap;
use super::super::SignalTypeId;
use super::types::EntitySignalEntry;
use crate::schema::EntityId;
/// Ledgers smaller than this rebuild the cache on every post-write query (always
/// fresh; the scan is cheap at this size). At or above it, rebuilds throttle to
/// [`REFRESH_INTERVAL_NS`] to keep the O(N) scan off the read hot path.
const SMALL_LEDGER_ENTRIES: usize = 50_000;
/// Minimum wall-clock between rebuilds for a large ledger (1 second).
const REFRESH_INTERVAL_NS: u64 = 1_000_000_000;
/// How many entities to materialize per signal type. Bounds cache memory (a few
/// thousand `EntityId`s per signal type) and caps the candidate pool any single
/// `SignalRanked` query draws from. Comfortably above `MAX_LIMIT × 4`.
const REBUILD_K: usize = 4096;
/// One signal type's materialized top-K plus the bookkeeping that decides
/// freshness.
struct CachedTopK {
/// Top entities by decayed score, best-first (length ≤ [`REBUILD_K`]).
items: Vec<EntityId>,
/// Wall-clock (ns) the cache was built — drives the large-ledger throttle.
built_at_ns: u64,
/// The global write counter at build time — if it still matches, no signal
/// has been written since, so the cache is exact regardless of elapsed time.
built_dirty: u64,
}
/// Per-signal-type cached top-K, with a global write counter for invalidation.
pub struct HotTopKCache {
per_type: DashMap<SignalTypeId, CachedTopK>,
/// Bumped once per signal apply (one relaxed atomic add — negligible on the
/// write path). A cache built at an earlier value knows writes have landed.
dirty: AtomicU64,
}
impl HotTopKCache {
pub fn new() -> Self {
Self {
per_type: DashMap::new(),
dirty: AtomicU64::new(0),
}
}
/// Record that a signal was written (invalidates caches on their next read).
pub fn note_write(&self) {
self.dirty.fetch_add(1, Ordering::Relaxed);
}
/// Top entities for `type_id` by decayed score, best-first, capped to the
/// caller's `needed` count. Serves a fresh-enough cache in O(needed); rebuilds
/// (one bounded O(N) scan) only when stale per the freshness policy.
pub fn candidates(
&self,
entries: &DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
type_id: SignalTypeId,
needed: usize,
now_ns: u64,
) -> Vec<EntityId> {
let needed = needed.min(REBUILD_K);
let dirty_now = self.dirty.load(Ordering::Relaxed);
if let Some(cached) = self.per_type.get(&type_id) {
let fresh = if cached.built_dirty == dirty_now {
// No writes at all since the build ⇒ exact, regardless of clock.
true
} else if entries.len() < SMALL_LEDGER_ENTRIES {
// Small ledger + new writes ⇒ rebuild (cheap, always fresh).
false
} else {
// Large ledger ⇒ throttle the O(N) rebuild off the hot path.
now_ns.saturating_sub(cached.built_at_ns) < REFRESH_INTERVAL_NS
};
if fresh {
return cached.items.iter().take(needed).copied().collect();
}
}
let items = rebuild(entries, type_id, now_ns);
let out = items.iter().take(needed).copied().collect();
self.per_type.insert(
type_id,
CachedTopK {
items,
built_at_ns: now_ns,
built_dirty: dirty_now,
},
);
out
}
}
/// The bounded O(N) ledger scan: the top [`REBUILD_K`] entities of `type_id` by
/// decayed score, best-first. Memory stays O(K): a buffer capped at `2·K` is
/// partition-truncated to K whenever it fills, so peak allocation is bounded
/// regardless of how many entities carry the signal.
fn rebuild(
entries: &DashMap<(EntityId, SignalTypeId), EntitySignalEntry>,
type_id: SignalTypeId,
now_ns: u64,
) -> Vec<EntityId> {
let cap = REBUILD_K;
// Descending by score; NaN sinks via a deterministic id tiebreak.
let cmp = |a: &(EntityId, f64), b: &(EntityId, f64)| {
b.1.partial_cmp(&a.1)
.unwrap_or_else(|| b.0.as_u64().cmp(&a.0.as_u64()))
};
let buffer_cap = cap.saturating_mul(2).max(cap + 1);
let mut scored: Vec<(EntityId, f64)> = Vec::with_capacity(buffer_cap);
for entry in entries {
let (entity_id, signal_type_id) = entry.key();
if *signal_type_id == type_id {
// Decay score with lambda=0 (no extra decay beyond what was applied at
// write time) — the same simplified candidate-gen ranking the prior
// O(N) scan used. Stage 3 does the full scoring.
let score = entry.value().hot.current_score(0, now_ns, 0.0);
scored.push((*entity_id, score));
if scored.len() >= buffer_cap {
scored.select_nth_unstable_by(cap - 1, cmp);
scored.truncate(cap);
}
}
}
if scored.len() > cap {
scored.select_nth_unstable_by(cap - 1, cmp);
scored.truncate(cap);
}
scored.sort_unstable_by(cmp);
scored.into_iter().map(|(id, _)| id).collect()
}