Skip to main content

velesdb_core/collection/streaming/
deferred.rs

1//! Deferred indexer for high-throughput sequential vector inserts.
2//!
3//! The [`DeferredIndexer`] buffers incoming vectors in a single write buffer
4//! and exposes them to search via brute-force scan while they await insertion
5//! into the HNSW graph. This decouples the write path (fast, O(1) per point)
6//! from the index path (slower, O(log n) per point) and enables
7//! threshold-triggered merge.
8//!
9//! # Single-buffer with threshold-triggered merge
10//!
11//! The indexer holds one buffer that accepts writes. When the buffer reaches
12//! `merge_threshold`, [`swap_and_drain`](DeferredIndexer::swap_and_drain)
13//! drains it and returns the vectors for the caller to batch-insert into HNSW.
14//!
15//! # Deleted IDs
16//!
17//! When a point is deleted while buffered, its ID is recorded in a
18//! `deleted_ids` set. Search results are filtered against this set so that
19//! deleted vectors never surface. The set is cleared on drain (the HNSW
20//! tombstone system takes over after merge).
21//!
22//! # Lock ordering
23//!
24//! `DeferredIndexer` is above `DeltaBuffer` (position 10) in the lock order.
25//! The `swap_lock` (position 10.1) must never be held while acquiring any
26//! lower-numbered lock.
27
28use super::delta::DeltaBuffer;
29use crate::distance::DistanceMetric;
30use parking_lot::{Mutex, RwLock};
31use rustc_hash::FxHashSet;
32use serde::{Deserialize, Serialize};
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::Arc;
35use std::time::Instant;
36
37// ── Constants ────────────────────────────────────────────────────────────────
38
39/// Default number of buffered vectors before a merge is triggered.
40const DEFAULT_MERGE_THRESHOLD: usize = 1024;
41
42/// Default maximum age of buffered data before a time-based merge (ms).
43const DEFAULT_MAX_BUFFER_AGE_MS: u64 = 5000;
44
45// ── Configuration ────────────────────────────────────────────────────────────
46
47/// Configuration for the [`DeferredIndexer`].
48///
49/// Controls whether deferred indexing is enabled, how many vectors to
50/// buffer before triggering a merge, and the maximum age of buffered data.
51///
52/// # Examples
53///
54/// ```
55/// use velesdb_core::collection::streaming::DeferredIndexerConfig;
56///
57/// let config = DeferredIndexerConfig::default();
58/// assert!(!config.enabled);
59/// assert_eq!(config.merge_threshold, 1024);
60/// ```
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct DeferredIndexerConfig {
63    /// Whether deferred indexing is enabled (default: `false`).
64    #[serde(default)]
65    pub enabled: bool,
66
67    /// Number of buffered vectors that triggers a merge into HNSW.
68    #[serde(default = "default_merge_threshold")]
69    pub merge_threshold: usize,
70
71    /// Maximum age (milliseconds) of the oldest buffered vector before a
72    /// time-based merge is triggered.
73    ///
74    /// The age is checked at write time (`push`/`extend`) and by
75    /// [`DeferredIndexer::should_merge`] — there is no background timer, so
76    /// an expired buffer merges on the next write or explicit check, not
77    /// spontaneously. `0` makes every write signal a merge.
78    #[serde(default = "default_max_buffer_age_ms")]
79    pub max_buffer_age_ms: u64,
80}
81
82fn default_merge_threshold() -> usize {
83    DEFAULT_MERGE_THRESHOLD
84}
85
86fn default_max_buffer_age_ms() -> u64 {
87    DEFAULT_MAX_BUFFER_AGE_MS
88}
89
90impl Default for DeferredIndexerConfig {
91    fn default() -> Self {
92        Self {
93            enabled: false,
94            merge_threshold: DEFAULT_MERGE_THRESHOLD,
95            max_buffer_age_ms: DEFAULT_MAX_BUFFER_AGE_MS,
96        }
97    }
98}
99
100// ── DeferredIndexer ──────────────────────────────────────────────────────────
101
102/// Buffers vectors for deferred HNSW insertion with brute-force searchability.
103///
104/// See the [module-level docs](self) for design details.
105pub struct DeferredIndexer {
106    /// Write buffer — accepts pushes and is drained on merge.
107    buffer: Arc<DeltaBuffer>,
108
109    /// Serializes swap-and-drain operations so only one drain runs at a time.
110    swap_lock: Mutex<()>,
111
112    /// IDs deleted while in the buffer. Filtered out of search results.
113    /// Uses `FxHashSet` for faster integer hashing on the hot search path.
114    deleted_ids: RwLock<FxHashSet<u64>>,
115
116    /// Configuration (immutable after construction).
117    config: DeferredIndexerConfig,
118
119    /// Millisecond timestamp (relative to `epoch`) of the first push since
120    /// the last drain; [`AGE_EMPTY`] when the buffer holds no epoch-tracked
121    /// entries. Lock-free: the common push path is one relaxed load.
122    first_push_millis: AtomicU64,
123
124    /// Time origin for `first_push_millis`.
125    epoch: Instant,
126}
127
128/// Sentinel for `first_push_millis`: no first-push timestamp recorded.
129const AGE_EMPTY: u64 = u64::MAX;
130
131impl DeferredIndexer {
132    /// Creates a new `DeferredIndexer` with the given configuration.
133    ///
134    /// The buffer starts inactive. If `config.enabled` is `false`, all
135    /// write operations are no-ops.
136    #[must_use]
137    pub fn new(config: DeferredIndexerConfig) -> Self {
138        Self {
139            buffer: Arc::new(DeltaBuffer::new()),
140            swap_lock: Mutex::new(()),
141            deleted_ids: RwLock::new(FxHashSet::default()),
142            config,
143            first_push_millis: AtomicU64::new(AGE_EMPTY),
144            epoch: Instant::now(),
145        }
146    }
147
148    /// Records the first-push timestamp if none is recorded yet.
149    ///
150    /// One relaxed load in the steady state (timestamp already set); the CAS
151    /// runs only on the first push after a drain. Losing the race to another
152    /// first pusher is fine — either timestamp is a valid buffer birth time.
153    #[inline]
154    fn note_push_time(&self) {
155        if self.first_push_millis.load(Ordering::Relaxed) == AGE_EMPTY {
156            #[allow(clippy::cast_possible_truncation)]
157            // Reason: millis since indexer creation; u64 covers 584M years.
158            let now = self.epoch.elapsed().as_millis() as u64;
159            let _ = self.first_push_millis.compare_exchange(
160                AGE_EMPTY,
161                now,
162                Ordering::Relaxed,
163                Ordering::Relaxed,
164            );
165        }
166    }
167
168    /// Returns `true` if the oldest buffered entry has exceeded
169    /// `max_buffer_age_ms`. `false` when the buffer is age-empty.
170    #[inline]
171    fn buffer_expired(&self) -> bool {
172        let first = self.first_push_millis.load(Ordering::Relaxed);
173        if first == AGE_EMPTY {
174            return false;
175        }
176        #[allow(clippy::cast_possible_truncation)]
177        // Reason: same clock domain as note_push_time.
178        let now = self.epoch.elapsed().as_millis() as u64;
179        now.saturating_sub(first) >= self.config.max_buffer_age_ms
180    }
181
182    /// Whether deferred indexing is enabled.
183    #[must_use]
184    pub fn is_enabled(&self) -> bool {
185        self.config.enabled
186    }
187
188    /// Pushes a vector into the write buffer.
189    ///
190    /// Activates the buffer lazily on first write. Returns `true` if
191    /// the buffer has reached `merge_threshold`, signaling the caller
192    /// to trigger a merge.
193    ///
194    /// No-op if deferred indexing is disabled.
195    ///
196    /// # TOCTOU note
197    ///
198    /// The `enabled` check and `len() >= threshold` read are not atomic with
199    /// the push. This is benign: a concurrent drain may reset the count
200    /// between push and the threshold check, causing a missed merge signal.
201    /// The next push will re-trigger.
202    ///
203    /// A previous TOCTOU window existed where `swap_and_drain` could
204    /// deactivate the buffer between `ensure_buffer_active` and the
205    /// underlying `buffer.push`, causing the vector to be silently dropped.
206    /// This is fixed: `swap_and_drain` now re-activates the buffer after
207    /// draining so pushes between drain and the next merge succeed.
208    pub fn push(&self, id: u64, vector: Vec<f32>) -> bool {
209        if !self.config.enabled {
210            return false;
211        }
212        self.ensure_buffer_active();
213        self.buffer.push(id, vector);
214        self.note_push_time();
215        self.buffer.len() >= self.config.merge_threshold || self.buffer_expired()
216    }
217
218    /// Batch-pushes vectors into the write buffer.
219    ///
220    /// Returns `true` if the buffer has reached `merge_threshold`.
221    /// No-op if deferred indexing is disabled.
222    pub fn extend(&self, entries: impl IntoIterator<Item = (u64, Vec<f32>)>) -> bool {
223        if !self.config.enabled {
224            return false;
225        }
226        self.ensure_buffer_active();
227        self.buffer.extend(entries);
228        self.note_push_time();
229        self.buffer.len() >= self.config.merge_threshold || self.buffer_expired()
230    }
231
232    /// Marks `id` as deleted, removing it from the buffer.
233    ///
234    /// The ID is added to `deleted_ids` so that search results are filtered
235    /// even if the vector was already snapshot for a concurrent search.
236    pub fn remove(&self, id: u64) {
237        self.buffer.remove(id);
238        self.deleted_ids.write().insert(id);
239    }
240
241    /// Brute-force searches the buffer, filtering deleted IDs.
242    ///
243    /// Results are sorted by the metric ordering and truncated to `k`.
244    ///
245    /// To compensate for post-filter attrition, the buffer is queried with
246    /// `k + deleted_ids.len()` candidates. This is bounded: `deleted_ids`
247    /// never exceeds `merge_threshold` entries (cleared on every drain).
248    ///
249    /// # TOCTOU note
250    ///
251    /// The `deleted_ids` snapshot is read under a separate lock from the
252    /// buffer search. A concurrent delete between the buffer snapshot and the
253    /// `deleted_ids` read is benign: the ID will be filtered on the next
254    /// search after the delete completes.
255    #[must_use]
256    pub fn search(&self, query: &[f32], k: usize, metric: DistanceMetric) -> Vec<(u64, f32)> {
257        let deleted = self.deleted_ids.read();
258        let overfetch = k.saturating_add(deleted.len());
259        let buffer_results = self.buffer.search(query, overfetch, metric);
260        let mut filtered = filter_deleted(buffer_results, &deleted);
261        drop(deleted);
262        metric.sort_results(&mut filtered);
263        filtered.truncate(k);
264        filtered
265    }
266
267    /// Merges HNSW results with deferred buffer results.
268    ///
269    /// Buffer is authoritative on duplicate IDs (more recent data): when a
270    /// point is upserted while deferred indexing is active, the new vector
271    /// goes to the buffer while HNSW still holds the stale vector. On ID
272    /// conflict the buffer score is kept, mirroring `merge_with_delta` in
273    /// `delta.rs`.
274    ///
275    /// Deleted IDs are filtered from buffer results but not from HNSW
276    /// results (HNSW has its own tombstone system).
277    #[must_use]
278    pub fn merge_with_hnsw(
279        &self,
280        hnsw_results: Vec<(u64, f32)>,
281        query: &[f32],
282        k: usize,
283        metric: DistanceMetric,
284    ) -> Vec<(u64, f32)> {
285        let buffer_results = self.search(query, k, metric);
286        if buffer_results.is_empty() {
287            return hnsw_results;
288        }
289        // Buffer holds more-recent data (upserts route through buffer, not HNSW).
290        // On ID conflict, keep the buffer score.
291        let buffer_ids: FxHashSet<u64> = buffer_results.iter().map(|(id, _)| *id).collect();
292        let mut combined: Vec<(u64, f32)> = hnsw_results
293            .into_iter()
294            .filter(|(id, _)| !buffer_ids.contains(id))
295            .collect();
296        combined.extend(buffer_results);
297        metric.sort_results(&mut combined);
298        combined.truncate(k);
299        combined
300    }
301
302    /// Drains the buffer and returns vectors for HNSW insertion.
303    ///
304    /// After this call the buffer is empty but **re-activated** so that
305    /// pushes arriving between drain and the next merge are not silently
306    /// dropped. The `deleted_ids` set is cleared because the caller is
307    /// expected to apply deletions to HNSW after merge.
308    ///
309    /// Serialized by an internal mutex so concurrent calls are safe (the
310    /// second caller gets an empty drain).
311    pub fn swap_and_drain(&self) -> Vec<(u64, Vec<f32>)> {
312        let _guard = self.swap_lock.lock();
313        let drained = self.buffer.deactivate_and_drain();
314        self.first_push_millis.store(AGE_EMPTY, Ordering::Relaxed);
315        self.deleted_ids.write().clear();
316        // Re-activate the buffer so pushes between drain and next merge
317        // are not silently dropped (fixes TOCTOU race with concurrent push).
318        self.buffer.activate();
319        drained
320    }
321
322    /// Total number of pending (not yet indexed) vectors in the buffer.
323    #[must_use]
324    pub fn pending_count(&self) -> usize {
325        self.buffer.len()
326    }
327
328    /// Returns `true` if the buffer has reached `merge_threshold`.
329    #[must_use]
330    pub fn should_merge(&self) -> bool {
331        self.buffer.len() >= self.config.merge_threshold || self.buffer_expired()
332    }
333
334    /// Returns `true` if deferred indexing is enabled and the buffer has
335    /// searchable data.
336    #[must_use]
337    pub fn is_searchable(&self) -> bool {
338        self.config.enabled && self.buffer.is_searchable()
339    }
340
341    /// Drains all vectors from the buffer (for shutdown / flush).
342    ///
343    /// Clears `deleted_ids`. After this call the buffer is empty and
344    /// inactive.
345    pub fn drain_all(&self) -> Vec<(u64, Vec<f32>)> {
346        let _guard = self.swap_lock.lock();
347        let all = self.buffer.deactivate_and_drain();
348        self.first_push_millis.store(AGE_EMPTY, Ordering::Relaxed);
349        self.deleted_ids.write().clear();
350        all
351    }
352
353    /// Lazily activates the buffer if it is not already active.
354    ///
355    /// Uses the compare-and-swap [`try_activate`](super::delta::DeltaBuffer::try_activate)
356    /// instead of a `is_active()` + `activate()` pair: the atomic transition
357    /// closes the TOCTOU window between the check and the store. An
358    /// `AlreadyActive` result is the expected idempotent case on the hot push
359    /// path (the buffer stays active across many pushes) and is ignored here.
360    fn ensure_buffer_active(&self) {
361        let _ = self.buffer.try_activate();
362    }
363}
364
365// ── Helpers ──────────────────────────────────────────────────────────────────
366
367/// Filters out deleted IDs from a result set.
368fn filter_deleted(results: Vec<(u64, f32)>, deleted: &FxHashSet<u64>) -> Vec<(u64, f32)> {
369    if deleted.is_empty() {
370        return results;
371    }
372    results
373        .into_iter()
374        .filter(|(id, _)| !deleted.contains(id))
375        .collect()
376}
377
378// ── Tests ────────────────────────────────────────────────────────────────────
379
380#[cfg(test)]
381// Reason: clippy 1.90 similar_names flags idiomatic test bindings (ids/idx).
382#[allow(clippy::similar_names)]
383mod tests {
384    use super::*;
385    use std::collections::HashSet;
386
387    /// Helper: builds an enabled config with a custom threshold.
388    fn enabled_config(threshold: usize) -> DeferredIndexerConfig {
389        DeferredIndexerConfig {
390            enabled: true,
391            merge_threshold: threshold,
392            ..DeferredIndexerConfig::default()
393        }
394    }
395
396    // ── Push tests ───────────────────────────────────────────────────────
397
398    #[test]
399    fn test_deferred_push_when_enabled() {
400        let idx = DeferredIndexer::new(enabled_config(1024));
401        idx.push(1, vec![1.0, 0.0, 0.0]);
402        idx.push(2, vec![0.0, 1.0, 0.0]);
403        assert_eq!(idx.pending_count(), 2);
404    }
405
406    #[test]
407    fn age_zero_makes_every_write_signal_a_merge() {
408        let indexer = DeferredIndexer::new(DeferredIndexerConfig {
409            enabled: true,
410            merge_threshold: 1_000,
411            max_buffer_age_ms: 0,
412        });
413        // Far below the count threshold: only the age trigger can fire.
414        assert!(indexer.push(1, vec![0.0; 4]));
415        assert!(indexer.should_merge());
416        // Draining clears the age stamp; an empty buffer never reports
417        // expiry on its own.
418        let drained = indexer.swap_and_drain();
419        assert_eq!(drained.len(), 1);
420        assert!(!indexer.should_merge());
421    }
422
423    #[test]
424    fn unexpired_buffer_below_threshold_does_not_merge() {
425        let indexer = DeferredIndexer::new(DeferredIndexerConfig {
426            enabled: true,
427            merge_threshold: 1_000,
428            // One hour: cannot expire within the test.
429            max_buffer_age_ms: 3_600_000,
430        });
431        assert!(!indexer.push(1, vec![0.0; 4]));
432        assert!(!indexer.extend(vec![(2, vec![0.0; 4])]));
433        assert!(!indexer.should_merge());
434    }
435
436    #[test]
437    fn test_deferred_push_returns_true_at_threshold() {
438        let idx = DeferredIndexer::new(enabled_config(3));
439        assert!(!idx.push(1, vec![1.0]));
440        assert!(!idx.push(2, vec![2.0]));
441        assert!(idx.push(3, vec![3.0]), "third push should hit threshold");
442    }
443
444    #[test]
445    fn test_deferred_push_noop_when_disabled() {
446        let config = DeferredIndexerConfig::default(); // enabled=false
447        let idx = DeferredIndexer::new(config);
448        let triggered = idx.push(1, vec![1.0, 2.0]);
449        assert!(!triggered);
450        assert_eq!(idx.pending_count(), 0);
451    }
452
453    #[test]
454    fn test_deferred_extend_returns_true_at_threshold() {
455        let idx = DeferredIndexer::new(enabled_config(3));
456        let entries = vec![(1, vec![1.0]), (2, vec![2.0]), (3, vec![3.0])];
457        assert!(idx.extend(entries), "batch should hit threshold");
458    }
459
460    // ── Search tests ─────────────────────────────────────────────────────
461
462    #[test]
463    fn test_deferred_search_finds_buffered_vectors() {
464        let idx = DeferredIndexer::new(enabled_config(1024));
465        idx.push(1, vec![1.0, 0.0]);
466        idx.push(2, vec![0.0, 1.0]);
467
468        let results = idx.search(&[1.0, 0.0], 2, DistanceMetric::Cosine);
469        assert_eq!(results.len(), 2);
470        // Cosine: id=1 (identical to query) should be first
471        assert_eq!(results[0].0, 1);
472    }
473
474    #[test]
475    fn test_deferred_search_filters_deleted_ids() {
476        let idx = DeferredIndexer::new(enabled_config(1024));
477        idx.push(1, vec![1.0, 0.0, 0.0]);
478        idx.push(2, vec![0.0, 1.0, 0.0]);
479        idx.push(3, vec![0.0, 0.0, 1.0]);
480        idx.remove(2);
481
482        let results = idx.search(&[1.0, 0.0, 0.0], 10, DistanceMetric::Euclidean);
483        let ids: Vec<u64> = results.iter().map(|(id, _)| *id).collect();
484        assert!(!ids.contains(&2), "deleted ID 2 must not appear in results");
485        assert_eq!(ids.len(), 2);
486    }
487
488    // ── Swap and drain tests ─────────────────────────────────────────────
489
490    #[test]
491    fn test_deferred_swap_and_drain() {
492        let idx = DeferredIndexer::new(enabled_config(1024));
493        idx.push(1, vec![1.0]);
494        idx.push(2, vec![2.0]);
495
496        let drained = idx.swap_and_drain();
497        assert_eq!(drained.len(), 2);
498        assert_eq!(idx.pending_count(), 0, "buffer should be empty after drain");
499    }
500
501    #[test]
502    fn test_deferred_swap_and_drain_clears_deleted_ids() {
503        let idx = DeferredIndexer::new(enabled_config(1024));
504        idx.push(1, vec![1.0]);
505        idx.remove(1);
506        let _drained = idx.swap_and_drain();
507        // After drain, deleted_ids should be cleared
508        assert!(idx.deleted_ids.read().is_empty());
509    }
510
511    #[test]
512    fn test_deferred_swap_and_drain_reactivates_buffer() {
513        // Regression: swap_and_drain must re-activate the buffer so that
514        // pushes between drain and the next merge are not silently dropped.
515        let idx = DeferredIndexer::new(enabled_config(1024));
516        idx.push(1, vec![1.0]);
517        let _ = idx.swap_and_drain();
518
519        // After drain, the buffer should be re-activated and accept pushes.
520        idx.push(2, vec![2.0]);
521        assert_eq!(idx.pending_count(), 1, "push after drain must succeed");
522        assert!(
523            idx.is_searchable(),
524            "buffer should be searchable after push"
525        );
526    }
527
528    #[test]
529    fn test_deferred_drain_all_leaves_buffer_inactive() {
530        // drain_all is for shutdown — buffer is left inactive (not
531        // re-activated like swap_and_drain). A subsequent push *will*
532        // re-activate via ensure_buffer_active, but there is a window
533        // where the buffer is inactive immediately after drain_all.
534        let idx = DeferredIndexer::new(enabled_config(1024));
535        idx.push(1, vec![1.0]);
536        let _ = idx.drain_all();
537
538        // Immediately after drain_all the buffer is inactive.
539        assert!(
540            !idx.is_searchable(),
541            "buffer must not be searchable immediately after drain_all"
542        );
543        assert_eq!(
544            idx.pending_count(),
545            0,
546            "buffer should be empty after drain_all"
547        );
548    }
549
550    // ── Merge with HNSW tests ────────────────────────────────────────────
551
552    #[test]
553    fn test_deferred_merge_with_hnsw() {
554        let idx = DeferredIndexer::new(enabled_config(1024));
555        idx.push(10, vec![0.9, 0.1]);
556        idx.push(30, vec![0.5, 0.5]);
557
558        // HNSW results: id=10 (also in buffer) and id=20 (only in HNSW)
559        let hnsw = vec![(10, 0.95_f32), (20, 0.80_f32)];
560        let merged = idx.merge_with_hnsw(hnsw, &[1.0, 0.0], 3, DistanceMetric::Cosine);
561
562        // No duplicate IDs
563        let ids: Vec<u64> = merged.iter().map(|(id, _)| *id).collect();
564        let unique: HashSet<u64> = ids.iter().copied().collect();
565        assert_eq!(ids.len(), unique.len(), "no duplicate IDs");
566
567        // All three IDs should be present (10 from buffer, 20 from HNSW, 30 from buffer)
568        assert_eq!(merged.len(), 3);
569        assert!(ids.contains(&10));
570        assert!(ids.contains(&20));
571        assert!(ids.contains(&30));
572
573        // Buffer score for id=10 should be kept (not the HNSW score of 0.95),
574        // because the buffer holds more-recent data (upserts route there).
575        let id10_score = merged.iter().find(|(id, _)| *id == 10).map(|(_, s)| *s);
576        assert!(
577            (id10_score.unwrap_or(0.0) - 0.95).abs() > f32::EPSILON,
578            "buffer score should be authoritative for id=10, not HNSW"
579        );
580    }
581
582    #[test]
583    fn test_deferred_merge_with_hnsw_empty_buffer() {
584        let idx = DeferredIndexer::new(enabled_config(1024));
585        // Buffer is empty — merge should return HNSW results unchanged
586        let hnsw = vec![(1, 0.9_f32), (2, 0.8_f32)];
587        let merged = idx.merge_with_hnsw(hnsw.clone(), &[1.0, 0.0], 5, DistanceMetric::Cosine);
588        assert_eq!(merged, hnsw);
589    }
590
591    // ── Drain-all test ───────────────────────────────────────────────────
592
593    #[test]
594    fn test_deferred_drain_all() {
595        let idx = DeferredIndexer::new(enabled_config(1024));
596        idx.push(1, vec![1.0]);
597        idx.push(2, vec![2.0]);
598
599        let all = idx.drain_all();
600        assert_eq!(all.len(), 2);
601        assert_eq!(idx.pending_count(), 0);
602        assert!(!idx.is_searchable(), "not searchable after drain_all");
603    }
604
605    // ── Config serde test ────────────────────────────────────────────────
606
607    #[test]
608    fn test_deferred_config_serde() {
609        let config = DeferredIndexerConfig {
610            enabled: true,
611            merge_threshold: 512,
612            max_buffer_age_ms: 3000,
613        };
614        let json = serde_json::to_string(&config).expect("serialize");
615        let restored: DeferredIndexerConfig = serde_json::from_str(&json).expect("deserialize");
616        assert!(restored.enabled);
617        assert_eq!(restored.merge_threshold, 512);
618        assert_eq!(restored.max_buffer_age_ms, 3000);
619    }
620
621    #[test]
622    fn test_deferred_config_serde_defaults() {
623        let json = "{}";
624        let config: DeferredIndexerConfig = serde_json::from_str(json).expect("deserialize empty");
625        assert!(!config.enabled);
626        assert_eq!(config.merge_threshold, DEFAULT_MERGE_THRESHOLD);
627        assert_eq!(config.max_buffer_age_ms, DEFAULT_MAX_BUFFER_AGE_MS);
628    }
629
630    // ── Edge cases ───────────────────────────────────────────────────────
631
632    #[test]
633    fn test_deferred_should_merge_reflects_threshold() {
634        let idx = DeferredIndexer::new(enabled_config(2));
635        assert!(!idx.should_merge());
636        idx.push(1, vec![1.0]);
637        assert!(!idx.should_merge());
638        idx.push(2, vec![2.0]);
639        assert!(idx.should_merge());
640    }
641
642    #[test]
643    fn test_deferred_is_enabled_reflects_config() {
644        let enabled = DeferredIndexer::new(enabled_config(1024));
645        assert!(enabled.is_enabled());
646        let disabled = DeferredIndexer::new(DeferredIndexerConfig::default());
647        assert!(!disabled.is_enabled());
648    }
649}