Skip to main content

ftui_runtime/
ivm.rs

1//! Incremental View Maintenance (IVM) — delta-propagation DAG for derived
2//! render state (bd-3akdb.1).
3//!
4//! # Overview
5//!
6//! IVM maintains computed layouts, styled text, and visibility flags as
7//! **materialized views** updated by deltas only. Instead of full recomputation
8//! each frame, changes propagate through a DAG of view operators that transform
9//! input deltas into output deltas.
10//!
11//! # Architecture
12//!
13//! ```text
14//!   Observable<Theme>   Observable<Content>   Observable<Constraint>
15//!          │                    │                      │
16//!          ▼                    │                      │
17//!   ┌──────────────┐           │                      │
18//!   │  StyleView   │◄──────────┘                      │
19//!   │  (resolve)   │                                  │
20//!   └──────┬───────┘                                  │
21//!          │ Δ(style)                                 │
22//!          ▼                                          │
23//!   ┌──────────────┐                                  │
24//!   │  LayoutView  │◄─────────────────────────────────┘
25//!   │  (compute)   │
26//!   └──────┬───────┘
27//!          │ Δ(rects)
28//!          ▼
29//!   ┌──────────────┐
30//!   │  RenderView  │
31//!   │  (cells)     │
32//!   └──────┬───────┘
33//!          │ Δ(cells)
34//!          ▼
35//!     BufferDiff → Presenter → Terminal
36//! ```
37//!
38//! # Delta Representation
39//!
40//! Updates are represented as **signed tuples**: `(key, weight, logical_time)`.
41//!
42//! - `weight = +1`: insertion / update (new value replaces old).
43//! - `weight = -1`: deletion (key removed from the view).
44//! - `logical_time`: monotonic counter for causal ordering.
45//!
46//! This is the standard IVM delta encoding from database theory (Chirkova &
47//! Yang, "Materialized Views", §3.1). It composes: applying Δ₁ then Δ₂ is
48//! equivalent to applying their union (with cancellation of opposite signs).
49//!
50//! # Processing Model
51//!
52//! Deltas are processed in **topological micro-batches**:
53//!
54//! 1. Collect all input changes since last frame (the "epoch").
55//! 2. Sort the DAG topologically (pre-computed at DAG build time).
56//! 3. For each view in topological order:
57//!    a. Receive input deltas from upstream views.
58//!    b. Apply `apply_delta()` to produce output deltas.
59//!    c. Forward output deltas to downstream views.
60//! 4. The final view emits cell-level deltas consumed by the presenter.
61//!
62//! If any view's delta set exceeds a size threshold (heuristic: > 50% of
63//! the materialized view size), the view falls back to full recomputation
64//! via `full_recompute()`. This handles the "big change" case efficiently.
65//!
66//! # Evidence Logging
67//!
68//! Each propagation epoch logs an `ivm.propagate` tracing span with:
69//!
70//! - `epoch`: monotonic epoch counter
71//! - `views_processed`: number of views that received deltas
72//! - `views_recomputed`: number of views that fell back to full recompute
73//! - `total_delta_size`: sum of delta entry counts across all views
74//! - `duration_us`: wall-clock time for the entire propagation
75//!
76//! An evidence JSONL entry is emitted comparing delta size vs full recompute
77//! cost per view, enabling offline analysis of IVM efficiency.
78//!
79//! # Fallback
80//!
81//! Setting `FRANKENTUI_FULL_RECOMPUTE=1` disables incremental processing
82//! and forces full recomputation on every frame. This serves as a baseline
83//! for benchmarking and a safety fallback if IVM introduces bugs.
84//!
85//! # Integration with Existing Infrastructure
86//!
87//! - **DepGraph** (ftui-layout): Used for layout-level dirty tracking.
88//!   IVM wraps DepGraph as the backing store for `LayoutView`.
89//! - **Observable/Computed** (ftui-runtime/reactive): Observable changes
90//!   feed the IVM input layer. Computed values can be replaced by IVM views
91//!   for frequently-updated derivations.
92//! - **Buffer dirty tracking** (ftui-render): RenderView output deltas
93//!   are translated to Buffer dirty_rows/dirty_spans for the presenter.
94//! - **BatchScope** (ftui-runtime/reactive): Batched observable mutations
95//!   naturally align with IVM epochs — one batch = one propagation pass.
96
97use std::fmt;
98use std::hash::{Hash, Hasher};
99
100// ============================================================================
101// DeltaEntry — signed tuple for incremental updates
102// ============================================================================
103
104/// A signed delta entry representing an incremental change.
105///
106/// The fundamental unit of IVM propagation. Changes flow through the DAG
107/// as collections of delta entries, which views transform into output deltas.
108///
109/// # Semantics
110///
111/// - `Insert(key, value)`: New or updated mapping at `key`.
112/// - `Delete(key)`: Key removed from the materialized view.
113///
114/// Both variants carry a `logical_time` for causal ordering within an epoch.
115#[derive(Debug, Clone, PartialEq)]
116pub enum DeltaEntry<K, V> {
117    /// Insert or update: `(key, value, logical_time)`.
118    /// Weight = +1 in signed-tuple notation.
119    Insert { key: K, value: V, logical_time: u64 },
120    /// Delete: `(key, logical_time)`.
121    /// Weight = -1 in signed-tuple notation.
122    Delete { key: K, logical_time: u64 },
123}
124
125impl<K, V> DeltaEntry<K, V> {
126    /// The key affected by this delta.
127    pub fn key(&self) -> &K {
128        match self {
129            DeltaEntry::Insert { key, .. } => key,
130            DeltaEntry::Delete { key, .. } => key,
131        }
132    }
133
134    /// The logical time of this delta.
135    pub fn logical_time(&self) -> u64 {
136        match self {
137            DeltaEntry::Insert { logical_time, .. } => *logical_time,
138            DeltaEntry::Delete { logical_time, .. } => *logical_time,
139        }
140    }
141
142    /// The signed weight: +1 for insert, -1 for delete.
143    pub fn weight(&self) -> i8 {
144        match self {
145            DeltaEntry::Insert { .. } => 1,
146            DeltaEntry::Delete { .. } => -1,
147        }
148    }
149
150    /// Whether this is an insert/update.
151    pub fn is_insert(&self) -> bool {
152        matches!(self, DeltaEntry::Insert { .. })
153    }
154}
155
156// ============================================================================
157// DeltaBatch — collection of deltas for one epoch
158// ============================================================================
159
160/// A batch of delta entries for a single propagation epoch.
161///
162/// Micro-batching amortizes per-delta overhead and enables bulk processing
163/// optimizations (e.g., deduplicating multiple updates to the same key within
164/// one epoch).
165#[derive(Debug, Clone)]
166pub struct DeltaBatch<K, V> {
167    /// The epoch number (monotonically increasing).
168    pub epoch: u64,
169    /// The delta entries in this batch, ordered by logical_time.
170    pub entries: Vec<DeltaEntry<K, V>>,
171}
172
173impl<K: Eq + Hash, V> DeltaBatch<K, V> {
174    /// Create an empty batch for the given epoch.
175    pub fn new(epoch: u64) -> Self {
176        Self {
177            epoch,
178            entries: Vec::new(),
179        }
180    }
181
182    /// Number of entries in this batch.
183    pub fn len(&self) -> usize {
184        self.entries.len()
185    }
186
187    /// Whether this batch is empty.
188    pub fn is_empty(&self) -> bool {
189        self.entries.is_empty()
190    }
191
192    /// Push an insert entry.
193    pub fn insert(&mut self, key: K, value: V, logical_time: u64) {
194        self.entries.push(DeltaEntry::Insert {
195            key,
196            value,
197            logical_time,
198        });
199    }
200
201    /// Push a delete entry.
202    pub fn delete(&mut self, key: K, logical_time: u64) {
203        self.entries.push(DeltaEntry::Delete { key, logical_time });
204    }
205}
206
207// ============================================================================
208// ViewId — handle into the DAG
209// ============================================================================
210
211/// Lightweight handle identifying a view in the IVM DAG.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
213pub struct ViewId(pub u32);
214
215impl fmt::Display for ViewId {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(f, "V{}", self.0)
218    }
219}
220
221// ============================================================================
222// ViewDomain — categorization of view types
223// ============================================================================
224
225/// The domain of a view in the IVM pipeline.
226///
227/// Used for logging, evidence collection, and fallback policy decisions.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
229pub enum ViewDomain {
230    /// Style resolution (theme → resolved style per widget).
231    Style,
232    /// Layout computation (constraints → Rect positions).
233    Layout,
234    /// Render cells (style + content + layout → Cell grid).
235    Render,
236    /// Filtered/sorted list (data → visible subset).
237    FilteredList,
238    /// Custom user-defined view domain.
239    Custom,
240}
241
242impl ViewDomain {
243    /// Human-readable label for logging.
244    pub fn as_str(&self) -> &'static str {
245        match self {
246            ViewDomain::Style => "style",
247            ViewDomain::Layout => "layout",
248            ViewDomain::Render => "render",
249            ViewDomain::FilteredList => "filtered_list",
250            ViewDomain::Custom => "custom",
251        }
252    }
253}
254
255// ============================================================================
256// PropagationResult — outcome of processing one view
257// ============================================================================
258
259/// The outcome of processing a single view during propagation.
260#[derive(Debug, Clone)]
261pub struct PropagationResult {
262    /// Which view was processed.
263    pub view_id: ViewId,
264    /// The domain of the view.
265    pub domain: ViewDomain,
266    /// Number of input delta entries received.
267    pub input_delta_size: usize,
268    /// Number of output delta entries produced.
269    pub output_delta_size: usize,
270    /// Whether the view fell back to full recomputation.
271    pub fell_back_to_full: bool,
272    /// Size of the materialized view (for ratio comparison).
273    pub materialized_size: usize,
274    /// Time taken to process this view (microseconds).
275    pub duration_us: u64,
276}
277
278// ============================================================================
279// EpochEvidence — JSONL evidence for one propagation epoch
280// ============================================================================
281
282/// Evidence record for a single IVM propagation epoch.
283///
284/// Serialized to JSONL for offline analysis of delta efficiency vs full
285/// recompute. Consumed by the evidence sink and SLO breach detection.
286#[derive(Debug, Clone)]
287pub struct EpochEvidence {
288    /// Monotonic epoch counter.
289    pub epoch: u64,
290    /// Number of views that received deltas.
291    pub views_processed: usize,
292    /// Number of views that fell back to full recompute.
293    pub views_recomputed: usize,
294    /// Sum of delta entry counts across all views.
295    pub total_delta_size: usize,
296    /// Sum of materialized view sizes (baseline cost).
297    pub total_materialized_size: usize,
298    /// Wall-clock time for the entire propagation (microseconds).
299    pub duration_us: u64,
300    /// Per-view results.
301    pub per_view: Vec<PropagationResult>,
302}
303
304impl EpochEvidence {
305    /// The delta-to-full ratio. Values < 1.0 mean IVM is winning.
306    ///
307    /// Ratio = total_delta_size / total_materialized_size.
308    /// A ratio of 0.05 means the delta was 5% of a full recompute.
309    pub fn delta_ratio(&self) -> f64 {
310        if self.total_materialized_size == 0 {
311            0.0
312        } else {
313            self.total_delta_size as f64 / self.total_materialized_size as f64
314        }
315    }
316
317    /// Format as a JSONL line for the evidence sink.
318    pub fn to_jsonl(&self) -> String {
319        format!(
320            "{{\"type\":\"ivm_epoch\",\"epoch\":{},\"views_processed\":{},\"views_recomputed\":{},\"total_delta_size\":{},\"total_materialized_size\":{},\"delta_ratio\":{:.4},\"duration_us\":{}}}",
321            self.epoch,
322            self.views_processed,
323            self.views_recomputed,
324            self.total_delta_size,
325            self.total_materialized_size,
326            self.delta_ratio(),
327            self.duration_us,
328        )
329    }
330}
331
332// ============================================================================
333// FallbackPolicy — when to give up on incremental and do full recompute
334// ============================================================================
335
336/// Policy for deciding when a view should fall back to full recomputation.
337///
338/// If the delta set is large relative to the materialized view, incremental
339/// processing may be slower than just recomputing everything. The threshold
340/// is configurable per-domain.
341#[derive(Debug, Clone)]
342pub struct FallbackPolicy {
343    /// If delta_size / materialized_size exceeds this ratio, fall back.
344    /// Default: 0.5 (50% — delta is more than half the full view).
345    pub ratio_threshold: f64,
346    /// Absolute minimum delta size before considering fallback.
347    /// Prevents fallback on tiny views where ratio is noisy.
348    /// Default: 10.
349    pub min_delta_for_fallback: usize,
350}
351
352impl Default for FallbackPolicy {
353    fn default() -> Self {
354        Self {
355            ratio_threshold: 0.5,
356            min_delta_for_fallback: 10,
357        }
358    }
359}
360
361impl FallbackPolicy {
362    /// Whether the view should fall back to full recomputation.
363    pub fn should_fallback(&self, delta_size: usize, materialized_size: usize) -> bool {
364        if delta_size < self.min_delta_for_fallback {
365            return false;
366        }
367        if materialized_size == 0 {
368            return true; // Empty view, just recompute.
369        }
370        (delta_size as f64 / materialized_size as f64) > self.ratio_threshold
371    }
372}
373
374// ============================================================================
375// DAG Edge — connection between views
376// ============================================================================
377
378/// An edge in the IVM DAG, connecting an upstream view to a downstream view.
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub struct DagEdge {
381    /// The upstream view that produces deltas.
382    pub from: ViewId,
383    /// The downstream view that consumes deltas.
384    pub to: ViewId,
385}
386
387// ============================================================================
388// DagTopology — the static structure of the view DAG
389// ============================================================================
390
391/// The static topology of the IVM DAG.
392///
393/// Built once when the view pipeline is constructed. The topological order
394/// is pre-computed and cached for efficient per-epoch propagation.
395///
396/// # Invariants
397///
398/// 1. The DAG is acyclic (enforced on edge insertion).
399/// 2. Topological order includes all views.
400/// 3. For every edge (A, B), A appears before B in the topological order.
401/// 4. Adding or removing views invalidates the cached topological order.
402#[derive(Debug, Clone)]
403pub struct DagTopology {
404    /// All views in the DAG, indexed by ViewId.
405    pub views: Vec<ViewDescriptor>,
406    /// All edges in the DAG.
407    pub edges: Vec<DagEdge>,
408    /// Pre-computed topological order (view indices).
409    pub topo_order: Vec<ViewId>,
410}
411
412/// Descriptor for a view in the DAG.
413#[derive(Debug, Clone)]
414pub struct ViewDescriptor {
415    /// The view's unique identifier.
416    pub id: ViewId,
417    /// Human-readable label for debugging/logging.
418    pub label: String,
419    /// The domain of the view.
420    pub domain: ViewDomain,
421    /// Fallback policy for this view.
422    pub fallback_policy: FallbackPolicy,
423}
424
425impl DagTopology {
426    /// Create an empty DAG.
427    pub fn new() -> Self {
428        Self {
429            views: Vec::new(),
430            edges: Vec::new(),
431            topo_order: Vec::new(),
432        }
433    }
434
435    /// Add a view to the DAG. Returns its ViewId.
436    pub fn add_view(&mut self, label: impl Into<String>, domain: ViewDomain) -> ViewId {
437        let id = ViewId(self.views.len() as u32);
438        self.views.push(ViewDescriptor {
439            id,
440            label: label.into(),
441            domain,
442            fallback_policy: FallbackPolicy::default(),
443        });
444        id
445    }
446
447    /// Add an edge: `from` produces deltas consumed by `to`.
448    ///
449    /// # Panics
450    ///
451    /// Panics if this would create a cycle in the DAG.
452    pub fn add_edge(&mut self, from: ViewId, to: ViewId) {
453        // Cycle check: verify `to` cannot reach `from` via existing edges.
454        assert!(
455            !self.can_reach(to, from),
456            "IVM DAG cycle detected: {} -> {} would create a cycle",
457            from,
458            to
459        );
460        self.edges.push(DagEdge { from, to });
461    }
462
463    /// Check if `from` can reach `to` via existing edges (DFS).
464    fn can_reach(&self, from: ViewId, to: ViewId) -> bool {
465        let mut visited = vec![false; self.views.len()];
466        let mut stack = vec![from];
467        while let Some(current) = stack.pop() {
468            if current == to {
469                return true;
470            }
471            let idx = current.0 as usize;
472            if idx >= visited.len() || visited[idx] {
473                continue;
474            }
475            visited[idx] = true;
476            for edge in &self.edges {
477                if edge.from == current && !visited[edge.to.0 as usize] {
478                    stack.push(edge.to);
479                }
480            }
481        }
482        false
483    }
484
485    /// Compute the topological order via Kahn's algorithm.
486    ///
487    /// Must be called after all views and edges are added. The result is
488    /// cached in `self.topo_order` for efficient per-epoch propagation.
489    ///
490    /// # Panics
491    ///
492    /// Panics if the DAG contains a cycle (should be impossible due to
493    /// `add_edge` validation, but checked defensively).
494    pub fn compute_topo_order(&mut self) {
495        let n = self.views.len();
496        let mut in_degree = vec![0usize; n];
497        let mut adj: Vec<Vec<ViewId>> = vec![Vec::new(); n];
498
499        for edge in &self.edges {
500            in_degree[edge.to.0 as usize] += 1;
501            adj[edge.from.0 as usize].push(edge.to);
502        }
503
504        // Seed with zero-in-degree views (sorted for determinism).
505        let mut queue: Vec<ViewId> = (0..n)
506            .filter(|&i| in_degree[i] == 0)
507            .map(|i| ViewId(i as u32))
508            .collect();
509        queue.sort();
510
511        let mut order = Vec::with_capacity(n);
512        while let Some(v) = queue.pop() {
513            order.push(v);
514            // Sort neighbors for deterministic order.
515            let mut neighbors = adj[v.0 as usize].clone();
516            neighbors.sort();
517            for next in neighbors {
518                in_degree[next.0 as usize] -= 1;
519                if in_degree[next.0 as usize] == 0 {
520                    // Insert in sorted position for determinism.
521                    let pos = queue.partition_point(|&x| x > next);
522                    queue.insert(pos, next);
523                }
524            }
525        }
526
527        assert_eq!(
528            order.len(),
529            n,
530            "IVM DAG has a cycle: only {} of {} views in topo order",
531            order.len(),
532            n
533        );
534
535        self.topo_order = order;
536    }
537
538    /// Get the downstream views that consume deltas from `view_id`.
539    pub fn downstream(&self, view_id: ViewId) -> Vec<ViewId> {
540        self.edges
541            .iter()
542            .filter(|e| e.from == view_id)
543            .map(|e| e.to)
544            .collect()
545    }
546
547    /// Get the upstream views that produce deltas for `view_id`.
548    pub fn upstream(&self, view_id: ViewId) -> Vec<ViewId> {
549        self.edges
550            .iter()
551            .filter(|e| e.to == view_id)
552            .map(|e| e.from)
553            .collect()
554    }
555
556    /// Number of views in the DAG.
557    pub fn view_count(&self) -> usize {
558        self.views.len()
559    }
560
561    /// Number of edges in the DAG.
562    pub fn edge_count(&self) -> usize {
563        self.edges.len()
564    }
565}
566
567impl Default for DagTopology {
568    fn default() -> Self {
569        Self::new()
570    }
571}
572
573// ============================================================================
574// Concrete Key/Value Types for the Three Pipeline Stages
575// ============================================================================
576
577/// Key for style view: identifies a widget's style slot.
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
579pub struct StyleKey(pub u32);
580
581/// Key for layout view: identifies a layout node.
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
583pub struct LayoutKey(pub u32);
584
585/// Key for render view: identifies a cell position (row, col).
586#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
587pub struct CellKey {
588    pub row: u16,
589    pub col: u16,
590}
591
592/// Resolved style value (the output of style resolution).
593///
594/// Matches the existing `Style` type from ftui-style but represented as
595/// a hash for delta comparison. The actual `Style` struct is 32-40 bytes
596/// and uses `Copy` semantics.
597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
598pub struct ResolvedStyleValue {
599    /// Hash of the resolved style for change detection.
600    pub style_hash: u64,
601}
602
603/// Layout result value (the output of layout computation).
604///
605/// Wraps the cached Rect positions from IncrementalLayout.
606#[derive(Debug, Clone, PartialEq, Eq)]
607pub struct LayoutValue {
608    /// Hash of the computed rects for change detection.
609    pub rects_hash: u64,
610    /// Number of sub-regions computed.
611    pub rect_count: u16,
612}
613
614/// Cell value (the output of render computation).
615///
616/// Matches the 16-byte Cell struct from ftui-render. Represented as a
617/// hash for delta comparison.
618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
619pub struct CellValue {
620    /// Hash of the cell's content + fg + bg + attrs.
621    pub cell_hash: u64,
622}
623
624// ============================================================================
625// IvmConfig — runtime configuration
626// ============================================================================
627
628/// Configuration for the IVM system.
629#[derive(Debug, Clone)]
630pub struct IvmConfig {
631    /// Force full recomputation on every frame (disables incremental).
632    /// Env: `FRANKENTUI_FULL_RECOMPUTE=1`
633    pub force_full: bool,
634    /// Default fallback policy for views without custom policies.
635    pub default_fallback: FallbackPolicy,
636    /// Whether to emit evidence JSONL for each epoch.
637    pub emit_evidence: bool,
638}
639
640impl Default for IvmConfig {
641    fn default() -> Self {
642        Self {
643            force_full: false,
644            default_fallback: FallbackPolicy::default(),
645            emit_evidence: true,
646        }
647    }
648}
649
650impl IvmConfig {
651    /// Create from environment variables.
652    pub fn from_env() -> Self {
653        let force = std::env::var("FRANKENTUI_FULL_RECOMPUTE")
654            .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
655            .unwrap_or(false);
656        Self {
657            force_full: force,
658            ..Default::default()
659        }
660    }
661}
662
663// ============================================================================
664// Hash utilities
665// ============================================================================
666
667/// Compute a fast hash of an arbitrary hashable value.
668///
669/// Uses FxHash (same as IncrementalLayout) for consistency with the
670/// existing caching infrastructure.
671pub fn fx_hash<T: Hash>(value: &T) -> u64 {
672    let mut h = std::collections::hash_map::DefaultHasher::new();
673    value.hash(&mut h);
674    h.finish()
675}
676
677// ============================================================================
678// IncrementalView — the core trait (bd-3akdb.2)
679// ============================================================================
680
681/// The core trait for incremental view maintenance.
682///
683/// An `IncrementalView` maintains a materialized view that can be updated
684/// incrementally via deltas, or fully recomputed as a fallback.
685///
686/// # Type Parameters
687///
688/// - `K`: Key type for addressing entries in the materialized view.
689/// - `V`: Value type stored at each key.
690///
691/// # Contract
692///
693/// 1. `apply_delta(batch)` produces output deltas reflecting how the
694///    materialized view changed.
695/// 2. `full_recompute()` returns the complete materialized view.
696/// 3. **Correctness invariant**: After applying any sequence of deltas,
697///    the materialized view must equal what `full_recompute()` would
698///    return given the same inputs.
699/// 4. `materialized_size()` returns the number of entries for fallback
700///    policy decisions.
701pub trait IncrementalView<K: Clone + Eq + Hash, V: Clone + PartialEq> {
702    /// Apply a batch of input deltas and produce output deltas.
703    ///
704    /// The output deltas describe how this view's materialized state changed
705    /// as a result of the input. These are forwarded to downstream views.
706    fn apply_delta(&mut self, batch: &DeltaBatch<K, V>) -> DeltaBatch<K, V>;
707
708    /// Fully recompute the materialized view from scratch.
709    ///
710    /// Returns all current (key, value) pairs. Used as a fallback when
711    /// the delta set is too large, or for correctness validation.
712    fn full_recompute(&self) -> Vec<(K, V)>;
713
714    /// Number of entries in the materialized view.
715    fn materialized_size(&self) -> usize;
716
717    /// Domain of this view (for logging and evidence).
718    fn domain(&self) -> ViewDomain;
719
720    /// Human-readable label for this view.
721    fn label(&self) -> &str;
722}
723
724// ============================================================================
725// StyleResolutionView — concrete view for theme → resolved styles
726// ============================================================================
727
728/// A concrete `IncrementalView` that resolves styles by merging a base
729/// (theme) style with per-widget overrides.
730///
731/// When the theme changes, only widgets whose resolved style actually
732/// differs are emitted as output deltas.
733///
734/// # Materialized View
735///
736/// Maps `StyleKey` (widget ID) → `ResolvedStyleValue` (hash of resolved
737/// style). The resolved style is `base_hash XOR override_hash` — a
738/// simplified model of the real merge(child, parent) operation.
739pub struct StyleResolutionView {
740    label: String,
741    /// Base style hash (from theme). Applies to all widgets.
742    base_hash: u64,
743    /// Per-widget style overrides.
744    overrides: std::collections::HashMap<StyleKey, u64>,
745    /// Materialized resolved styles.
746    resolved: std::collections::HashMap<StyleKey, ResolvedStyleValue>,
747}
748
749impl StyleResolutionView {
750    /// Create a new style resolution view with the given base theme hash.
751    pub fn new(label: impl Into<String>, base_hash: u64) -> Self {
752        Self {
753            label: label.into(),
754            base_hash,
755            overrides: std::collections::HashMap::new(),
756            resolved: std::collections::HashMap::new(),
757        }
758    }
759
760    /// Set the base theme hash. All resolved styles will be recomputed.
761    pub fn set_base(&mut self, base_hash: u64) {
762        self.base_hash = base_hash;
763    }
764
765    /// Get the current base hash.
766    pub fn base_hash(&self) -> u64 {
767        self.base_hash
768    }
769
770    /// Resolve a single style: base XOR override.
771    fn resolve(&self, key: &StyleKey) -> ResolvedStyleValue {
772        let override_hash = self.overrides.get(key).copied().unwrap_or(0);
773        ResolvedStyleValue {
774            style_hash: self.base_hash ^ override_hash,
775        }
776    }
777}
778
779impl IncrementalView<StyleKey, ResolvedStyleValue> for StyleResolutionView {
780    fn apply_delta(
781        &mut self,
782        batch: &DeltaBatch<StyleKey, ResolvedStyleValue>,
783    ) -> DeltaBatch<StyleKey, ResolvedStyleValue> {
784        let mut output = DeltaBatch::new(batch.epoch);
785        let mut time = 0u64;
786
787        for entry in &batch.entries {
788            match entry {
789                DeltaEntry::Insert {
790                    key,
791                    value,
792                    logical_time: _,
793                } => {
794                    // Update override for this widget.
795                    self.overrides.insert(*key, value.style_hash);
796                    let new_resolved = self.resolve(key);
797                    let old = self.resolved.insert(*key, new_resolved);
798                    // Only emit delta if resolved style actually changed.
799                    if old.as_ref() != Some(&new_resolved) {
800                        output.insert(*key, new_resolved, time);
801                        time += 1;
802                    }
803                }
804                DeltaEntry::Delete {
805                    key,
806                    logical_time: _,
807                } => {
808                    self.overrides.remove(key);
809                    if self.resolved.remove(key).is_some() {
810                        output.delete(*key, time);
811                        time += 1;
812                    }
813                }
814            }
815        }
816        output
817    }
818
819    fn full_recompute(&self) -> Vec<(StyleKey, ResolvedStyleValue)> {
820        self.overrides
821            .keys()
822            .map(|key| (*key, self.resolve(key)))
823            .collect()
824    }
825
826    fn materialized_size(&self) -> usize {
827        self.resolved.len()
828    }
829
830    fn domain(&self) -> ViewDomain {
831        ViewDomain::Style
832    }
833
834    fn label(&self) -> &str {
835        &self.label
836    }
837}
838
839// ============================================================================
840// FilteredListView — concrete view for data → visible subset
841// ============================================================================
842
843/// A concrete `IncrementalView` that maintains a filtered subset of items.
844///
845/// Items are included if their value hash passes the filter predicate.
846/// When items are inserted/deleted/updated, only the changes to the
847/// filtered set are emitted as output deltas.
848type FilterPredicate<K, V> = dyn Fn(&K, &V) -> bool;
849
850pub struct FilteredListView<K: Clone + Eq + Hash, V: Clone + PartialEq> {
851    label: String,
852    /// The filter predicate: returns true if the item should be visible.
853    filter: Box<FilterPredicate<K, V>>,
854    /// All items (unfiltered).
855    all_items: std::collections::HashMap<K, V>,
856    /// Materialized filtered subset.
857    visible: std::collections::HashMap<K, V>,
858}
859
860impl<K: Clone + Eq + Hash, V: Clone + PartialEq> FilteredListView<K, V> {
861    /// Create a new filtered list view with the given filter predicate.
862    pub fn new(label: impl Into<String>, filter: impl Fn(&K, &V) -> bool + 'static) -> Self {
863        Self {
864            label: label.into(),
865            filter: Box::new(filter),
866            all_items: std::collections::HashMap::new(),
867            visible: std::collections::HashMap::new(),
868        }
869    }
870
871    /// Number of total items (before filtering).
872    pub fn total_count(&self) -> usize {
873        self.all_items.len()
874    }
875
876    /// Number of visible items (after filtering).
877    pub fn visible_count(&self) -> usize {
878        self.visible.len()
879    }
880}
881
882impl<K: Clone + Eq + Hash, V: Clone + PartialEq> IncrementalView<K, V> for FilteredListView<K, V> {
883    fn apply_delta(&mut self, batch: &DeltaBatch<K, V>) -> DeltaBatch<K, V> {
884        let mut output = DeltaBatch::new(batch.epoch);
885        let mut time = 0u64;
886
887        for entry in &batch.entries {
888            match entry {
889                DeltaEntry::Insert {
890                    key,
891                    value,
892                    logical_time: _,
893                } => {
894                    let was_visible = self.visible.contains_key(key);
895                    self.all_items.insert(key.clone(), value.clone());
896                    let now_visible = (self.filter)(key, value);
897
898                    if now_visible {
899                        let old = self.visible.insert(key.clone(), value.clone());
900                        // Emit if newly visible or value changed.
901                        if !was_visible || old.as_ref() != Some(value) {
902                            output.insert(key.clone(), value.clone(), time);
903                            time += 1;
904                        }
905                    } else if was_visible {
906                        // Was visible, now filtered out → delete.
907                        self.visible.remove(key);
908                        output.delete(key.clone(), time);
909                        time += 1;
910                    }
911                }
912                DeltaEntry::Delete {
913                    key,
914                    logical_time: _,
915                } => {
916                    self.all_items.remove(key);
917                    if self.visible.remove(key).is_some() {
918                        output.delete(key.clone(), time);
919                        time += 1;
920                    }
921                }
922            }
923        }
924        output
925    }
926
927    fn full_recompute(&self) -> Vec<(K, V)> {
928        self.all_items
929            .iter()
930            .filter(|(k, v)| (self.filter)(k, v))
931            .map(|(k, v)| (k.clone(), v.clone()))
932            .collect()
933    }
934
935    fn materialized_size(&self) -> usize {
936        self.visible.len()
937    }
938
939    fn domain(&self) -> ViewDomain {
940        ViewDomain::FilteredList
941    }
942
943    fn label(&self) -> &str {
944        &self.label
945    }
946}
947
948// ============================================================================
949// Tests
950// ============================================================================
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    // ── DeltaEntry ──────────────────────────────────────────────────
957
958    #[test]
959    fn delta_entry_insert_fields() {
960        let entry: DeltaEntry<u32, String> = DeltaEntry::Insert {
961            key: 42,
962            value: "hello".into(),
963            logical_time: 1,
964        };
965        assert_eq!(*entry.key(), 42);
966        assert_eq!(entry.logical_time(), 1);
967        assert_eq!(entry.weight(), 1);
968        assert!(entry.is_insert());
969    }
970
971    #[test]
972    fn delta_entry_delete_fields() {
973        let entry: DeltaEntry<u32, String> = DeltaEntry::Delete {
974            key: 42,
975            logical_time: 2,
976        };
977        assert_eq!(*entry.key(), 42);
978        assert_eq!(entry.logical_time(), 2);
979        assert_eq!(entry.weight(), -1);
980        assert!(!entry.is_insert());
981    }
982
983    // ── DeltaBatch ──────────────────────────────────────────────────
984
985    #[test]
986    fn batch_operations() {
987        let mut batch = DeltaBatch::new(1);
988        assert!(batch.is_empty());
989        assert_eq!(batch.len(), 0);
990        assert_eq!(batch.epoch, 1);
991
992        batch.insert(1u32, "a".to_string(), 1);
993        batch.insert(2, "b".to_string(), 2);
994        batch.delete(1, 3);
995
996        assert_eq!(batch.len(), 3);
997        assert!(!batch.is_empty());
998    }
999
1000    // ── FallbackPolicy ──────────────────────────────────────────────
1001
1002    #[test]
1003    fn fallback_below_min() {
1004        let policy = FallbackPolicy {
1005            ratio_threshold: 0.5,
1006            min_delta_for_fallback: 10,
1007        };
1008        // 5 deltas < min_delta_for_fallback, so no fallback.
1009        assert!(!policy.should_fallback(5, 100));
1010    }
1011
1012    #[test]
1013    fn fallback_above_threshold() {
1014        let policy = FallbackPolicy {
1015            ratio_threshold: 0.5,
1016            min_delta_for_fallback: 10,
1017        };
1018        // 60/100 = 0.6 > 0.5 threshold.
1019        assert!(policy.should_fallback(60, 100));
1020    }
1021
1022    #[test]
1023    fn fallback_below_threshold() {
1024        let policy = FallbackPolicy {
1025            ratio_threshold: 0.5,
1026            min_delta_for_fallback: 10,
1027        };
1028        // 20/100 = 0.2 < 0.5 threshold.
1029        assert!(!policy.should_fallback(20, 100));
1030    }
1031
1032    #[test]
1033    fn fallback_empty_view() {
1034        let policy = FallbackPolicy::default();
1035        // Empty view → always fallback if delta >= min.
1036        assert!(policy.should_fallback(10, 0));
1037    }
1038
1039    // ── DagTopology ─────────────────────────────────────────────────
1040
1041    #[test]
1042    fn empty_dag() {
1043        let dag = DagTopology::new();
1044        assert_eq!(dag.view_count(), 0);
1045        assert_eq!(dag.edge_count(), 0);
1046    }
1047
1048    #[test]
1049    fn add_views_and_edges() {
1050        let mut dag = DagTopology::new();
1051        let style = dag.add_view("style", ViewDomain::Style);
1052        let layout = dag.add_view("layout", ViewDomain::Layout);
1053        let render = dag.add_view("render", ViewDomain::Render);
1054
1055        dag.add_edge(style, layout);
1056        dag.add_edge(layout, render);
1057
1058        assert_eq!(dag.view_count(), 3);
1059        assert_eq!(dag.edge_count(), 2);
1060    }
1061
1062    #[test]
1063    fn topological_order_linear() {
1064        let mut dag = DagTopology::new();
1065        let a = dag.add_view("style", ViewDomain::Style);
1066        let b = dag.add_view("layout", ViewDomain::Layout);
1067        let c = dag.add_view("render", ViewDomain::Render);
1068
1069        dag.add_edge(a, b);
1070        dag.add_edge(b, c);
1071        dag.compute_topo_order();
1072
1073        assert_eq!(dag.topo_order, vec![a, b, c]);
1074    }
1075
1076    #[test]
1077    fn topological_order_diamond() {
1078        // A → B, A → C, B → D, C → D
1079        let mut dag = DagTopology::new();
1080        let a = dag.add_view("source", ViewDomain::Style);
1081        let b = dag.add_view("branch_b", ViewDomain::Layout);
1082        let c = dag.add_view("branch_c", ViewDomain::Layout);
1083        let d = dag.add_view("sink", ViewDomain::Render);
1084
1085        dag.add_edge(a, b);
1086        dag.add_edge(a, c);
1087        dag.add_edge(b, d);
1088        dag.add_edge(c, d);
1089        dag.compute_topo_order();
1090
1091        // A must come first, D must come last.
1092        assert_eq!(dag.topo_order[0], a);
1093        assert_eq!(dag.topo_order[3], d);
1094        // B and C can be in either order, both are valid.
1095        let middle: Vec<ViewId> = dag.topo_order[1..3].to_vec();
1096        assert!(middle.contains(&b));
1097        assert!(middle.contains(&c));
1098    }
1099
1100    #[test]
1101    #[should_panic(expected = "cycle")]
1102    fn cycle_detection() {
1103        let mut dag = DagTopology::new();
1104        let a = dag.add_view("a", ViewDomain::Style);
1105        let b = dag.add_view("b", ViewDomain::Layout);
1106        dag.add_edge(a, b);
1107        dag.add_edge(b, a); // Cycle!
1108    }
1109
1110    #[test]
1111    fn downstream_upstream() {
1112        let mut dag = DagTopology::new();
1113        let a = dag.add_view("a", ViewDomain::Style);
1114        let b = dag.add_view("b", ViewDomain::Layout);
1115        let c = dag.add_view("c", ViewDomain::Render);
1116        dag.add_edge(a, b);
1117        dag.add_edge(a, c);
1118
1119        let down = dag.downstream(a);
1120        assert_eq!(down.len(), 2);
1121        assert!(down.contains(&b));
1122        assert!(down.contains(&c));
1123
1124        let up = dag.upstream(b);
1125        assert_eq!(up, vec![a]);
1126    }
1127
1128    // ── EpochEvidence ───────────────────────────────────────────────
1129
1130    #[test]
1131    fn epoch_evidence_delta_ratio() {
1132        let ev = EpochEvidence {
1133            epoch: 1,
1134            views_processed: 3,
1135            views_recomputed: 0,
1136            total_delta_size: 10,
1137            total_materialized_size: 200,
1138            duration_us: 42,
1139            per_view: vec![],
1140        };
1141        assert!((ev.delta_ratio() - 0.05).abs() < 0.001);
1142    }
1143
1144    #[test]
1145    fn epoch_evidence_jsonl() {
1146        let ev = EpochEvidence {
1147            epoch: 5,
1148            views_processed: 3,
1149            views_recomputed: 1,
1150            total_delta_size: 25,
1151            total_materialized_size: 500,
1152            duration_us: 100,
1153            per_view: vec![],
1154        };
1155        let jsonl = ev.to_jsonl();
1156        assert!(jsonl.contains("\"type\":\"ivm_epoch\""));
1157        assert!(jsonl.contains("\"epoch\":5"));
1158        assert!(jsonl.contains("\"views_recomputed\":1"));
1159        assert!(jsonl.contains("\"delta_ratio\":0.0500"));
1160    }
1161
1162    #[test]
1163    fn epoch_evidence_empty_materialized() {
1164        let ev = EpochEvidence {
1165            epoch: 1,
1166            views_processed: 0,
1167            views_recomputed: 0,
1168            total_delta_size: 0,
1169            total_materialized_size: 0,
1170            duration_us: 0,
1171            per_view: vec![],
1172        };
1173        assert!((ev.delta_ratio() - 0.0).abs() < f64::EPSILON);
1174    }
1175
1176    // ── Key types ───────────────────────────────────────────────────
1177
1178    #[test]
1179    fn cell_key_ordering() {
1180        let a = CellKey { row: 0, col: 5 };
1181        let b = CellKey { row: 0, col: 10 };
1182        let c = CellKey { row: 1, col: 0 };
1183        assert!(a < b);
1184        assert!(b < c);
1185    }
1186
1187    #[test]
1188    fn style_key_hash() {
1189        let a = StyleKey(42);
1190        let b = StyleKey(42);
1191        assert_eq!(fx_hash(&a), fx_hash(&b));
1192    }
1193
1194    // ── IvmConfig ───────────────────────────────────────────────────
1195
1196    #[test]
1197    fn config_defaults() {
1198        let config = IvmConfig::default();
1199        assert!(!config.force_full);
1200        assert!(config.emit_evidence);
1201        assert!((config.default_fallback.ratio_threshold - 0.5).abs() < f64::EPSILON);
1202    }
1203
1204    #[test]
1205    fn config_from_env_default() {
1206        let config = IvmConfig::from_env();
1207        assert_eq!(config.default_fallback.min_delta_for_fallback, 10);
1208    }
1209
1210    // ── ViewDomain ──────────────────────────────────────────────────
1211
1212    #[test]
1213    fn view_domain_labels() {
1214        assert_eq!(ViewDomain::Style.as_str(), "style");
1215        assert_eq!(ViewDomain::Layout.as_str(), "layout");
1216        assert_eq!(ViewDomain::Render.as_str(), "render");
1217        assert_eq!(ViewDomain::FilteredList.as_str(), "filtered_list");
1218        assert_eq!(ViewDomain::Custom.as_str(), "custom");
1219    }
1220
1221    // ── Full pipeline topology ──────────────────────────────────────
1222
1223    #[test]
1224    fn three_stage_pipeline_topology() {
1225        let mut dag = DagTopology::new();
1226
1227        // Build the canonical style → layout → render pipeline.
1228        let style = dag.add_view("StyleView", ViewDomain::Style);
1229        let layout = dag.add_view("LayoutView", ViewDomain::Layout);
1230        let render = dag.add_view("RenderView", ViewDomain::Render);
1231
1232        dag.add_edge(style, layout);
1233        dag.add_edge(layout, render);
1234        dag.compute_topo_order();
1235
1236        assert_eq!(dag.topo_order, vec![style, layout, render]);
1237        assert_eq!(dag.downstream(style), vec![layout]);
1238        assert_eq!(dag.downstream(layout), vec![render]);
1239        assert!(dag.downstream(render).is_empty());
1240        assert!(dag.upstream(style).is_empty());
1241        assert_eq!(dag.upstream(layout), vec![style]);
1242        assert_eq!(dag.upstream(render), vec![layout]);
1243    }
1244
1245    #[test]
1246    fn multi_source_pipeline() {
1247        // Theme and Content both feed into Layout.
1248        let mut dag = DagTopology::new();
1249        let theme_style = dag.add_view("ThemeStyle", ViewDomain::Style);
1250        let content = dag.add_view("Content", ViewDomain::Custom);
1251        let layout = dag.add_view("Layout", ViewDomain::Layout);
1252        let render = dag.add_view("Render", ViewDomain::Render);
1253
1254        dag.add_edge(theme_style, layout);
1255        dag.add_edge(content, layout);
1256        dag.add_edge(layout, render);
1257        dag.compute_topo_order();
1258
1259        // Theme and Content before Layout, Layout before Render.
1260        let layout_pos = dag.topo_order.iter().position(|&v| v == layout).unwrap();
1261        let render_pos = dag.topo_order.iter().position(|&v| v == render).unwrap();
1262        let theme_pos = dag
1263            .topo_order
1264            .iter()
1265            .position(|&v| v == theme_style)
1266            .unwrap();
1267        let content_pos = dag.topo_order.iter().position(|&v| v == content).unwrap();
1268
1269        assert!(theme_pos < layout_pos);
1270        assert!(content_pos < layout_pos);
1271        assert!(layout_pos < render_pos);
1272    }
1273
1274    #[test]
1275    fn filtered_list_side_branch() {
1276        // Main pipeline: Style → Layout → Render
1277        // Side branch: Content → FilteredList → Layout
1278        let mut dag = DagTopology::new();
1279        let style = dag.add_view("Style", ViewDomain::Style);
1280        let content = dag.add_view("Content", ViewDomain::Custom);
1281        let filtered = dag.add_view("FilteredList", ViewDomain::FilteredList);
1282        let layout = dag.add_view("Layout", ViewDomain::Layout);
1283        let render = dag.add_view("Render", ViewDomain::Render);
1284
1285        dag.add_edge(style, layout);
1286        dag.add_edge(content, filtered);
1287        dag.add_edge(filtered, layout);
1288        dag.add_edge(layout, render);
1289        dag.compute_topo_order();
1290
1291        let filtered_pos = dag.topo_order.iter().position(|&v| v == filtered).unwrap();
1292        let layout_pos = dag.topo_order.iter().position(|&v| v == layout).unwrap();
1293        assert!(filtered_pos < layout_pos);
1294    }
1295
1296    // ── PropagationResult ───────────────────────────────────────────
1297
1298    #[test]
1299    fn propagation_result_construction() {
1300        let result = PropagationResult {
1301            view_id: ViewId(0),
1302            domain: ViewDomain::Style,
1303            input_delta_size: 5,
1304            output_delta_size: 3,
1305            fell_back_to_full: false,
1306            materialized_size: 100,
1307            duration_us: 15,
1308        };
1309        assert!(!result.fell_back_to_full);
1310        assert_eq!(result.output_delta_size, 3);
1311    }
1312
1313    #[test]
1314    fn propagation_result_fallback() {
1315        let result = PropagationResult {
1316            view_id: ViewId(1),
1317            domain: ViewDomain::Layout,
1318            input_delta_size: 80,
1319            output_delta_size: 100,
1320            fell_back_to_full: true,
1321            materialized_size: 100,
1322            duration_us: 200,
1323        };
1324        assert!(result.fell_back_to_full);
1325    }
1326
1327    // ── IncrementalView trait ──────────────────────────────────────
1328
1329    #[test]
1330    fn style_view_apply_insert() {
1331        let mut view = StyleResolutionView::new("test_style", 0xABCD);
1332        let mut batch = DeltaBatch::new(1);
1333        batch.insert(StyleKey(0), ResolvedStyleValue { style_hash: 0x1234 }, 0);
1334
1335        let output = view.apply_delta(&batch);
1336        assert_eq!(output.len(), 1);
1337        assert!(output.entries[0].is_insert());
1338        assert_eq!(view.materialized_size(), 1);
1339    }
1340
1341    #[test]
1342    fn style_view_deduplicates_unchanged() {
1343        let mut view = StyleResolutionView::new("test", 0x0);
1344        let mut batch1 = DeltaBatch::new(1);
1345        batch1.insert(StyleKey(0), ResolvedStyleValue { style_hash: 0x42 }, 0);
1346        view.apply_delta(&batch1);
1347
1348        // Same override again → no output delta.
1349        let mut batch2 = DeltaBatch::new(2);
1350        batch2.insert(StyleKey(0), ResolvedStyleValue { style_hash: 0x42 }, 0);
1351        let output = view.apply_delta(&batch2);
1352        assert!(output.is_empty(), "same style should produce no delta");
1353    }
1354
1355    #[test]
1356    fn style_view_delete() {
1357        let mut view = StyleResolutionView::new("test", 0x0);
1358        let mut batch1 = DeltaBatch::new(1);
1359        batch1.insert(StyleKey(0), ResolvedStyleValue { style_hash: 0x42 }, 0);
1360        view.apply_delta(&batch1);
1361        assert_eq!(view.materialized_size(), 1);
1362
1363        let mut batch2 = DeltaBatch::new(2);
1364        batch2.delete(StyleKey(0), 0);
1365        let output = view.apply_delta(&batch2);
1366        assert_eq!(output.len(), 1);
1367        assert!(!output.entries[0].is_insert()); // Delete
1368        assert_eq!(view.materialized_size(), 0);
1369    }
1370
1371    #[test]
1372    fn style_view_full_recompute_matches_incremental() {
1373        let mut view = StyleResolutionView::new("test", 0xFF00);
1374        let mut batch = DeltaBatch::new(1);
1375        batch.insert(StyleKey(0), ResolvedStyleValue { style_hash: 0x00FF }, 0);
1376        batch.insert(StyleKey(1), ResolvedStyleValue { style_hash: 0x0F0F }, 1);
1377        view.apply_delta(&batch);
1378
1379        let full = view.full_recompute();
1380        assert_eq!(full.len(), 2);
1381
1382        // Verify full recompute matches incremental state.
1383        for (key, value) in &full {
1384            assert_eq!(value, view.resolved.get(key).unwrap());
1385        }
1386    }
1387
1388    #[test]
1389    fn style_view_base_change_affects_all() {
1390        let mut view = StyleResolutionView::new("test", 0x0);
1391        let mut batch = DeltaBatch::new(1);
1392        batch.insert(StyleKey(0), ResolvedStyleValue { style_hash: 0x42 }, 0);
1393        batch.insert(StyleKey(1), ResolvedStyleValue { style_hash: 0x99 }, 1);
1394        view.apply_delta(&batch);
1395
1396        // Change base hash and verify full_recompute reflects it.
1397        view.set_base(0xFFFF);
1398        let full = view.full_recompute();
1399        for (_key, value) in &full {
1400            // Resolved = base XOR override, so should differ from before.
1401            assert_ne!(value.style_hash, 0);
1402        }
1403    }
1404
1405    #[test]
1406    fn style_view_domain_and_label() {
1407        let view = StyleResolutionView::new("ThemeResolver", 0);
1408        assert_eq!(view.domain(), ViewDomain::Style);
1409        assert_eq!(view.label(), "ThemeResolver");
1410    }
1411
1412    // ── FilteredListView ────────────────────────────────────────────
1413
1414    #[test]
1415    fn filtered_view_insert_visible() {
1416        // Filter: only even values.
1417        let mut view = FilteredListView::new("evens", |_k: &u32, v: &i32| *v % 2 == 0);
1418        let mut batch = DeltaBatch::new(1);
1419        batch.insert(1u32, 4i32, 0); // Even → visible
1420        batch.insert(2u32, 3i32, 1); // Odd → filtered out
1421
1422        let output = view.apply_delta(&batch);
1423        assert_eq!(output.len(), 1); // Only the even value
1424        assert_eq!(view.visible_count(), 1);
1425        assert_eq!(view.total_count(), 2);
1426    }
1427
1428    #[test]
1429    fn filtered_view_insert_then_filter_out() {
1430        let mut view = FilteredListView::new("test", |_k: &u32, v: &i32| *v > 10);
1431        let mut batch1 = DeltaBatch::new(1);
1432        batch1.insert(1u32, 20i32, 0); // Visible (20 > 10)
1433        view.apply_delta(&batch1);
1434        assert_eq!(view.visible_count(), 1);
1435
1436        // Update to value that fails filter.
1437        let mut batch2 = DeltaBatch::new(2);
1438        batch2.insert(1u32, 5i32, 0); // Now 5 < 10, filtered out
1439        let output = view.apply_delta(&batch2);
1440        assert_eq!(output.len(), 1);
1441        assert!(!output.entries[0].is_insert()); // Delete from visible set
1442        assert_eq!(view.visible_count(), 0);
1443    }
1444
1445    #[test]
1446    fn filtered_view_delete() {
1447        let mut view = FilteredListView::new("test", |_k: &u32, v: &i32| *v > 0);
1448        let mut batch1 = DeltaBatch::new(1);
1449        batch1.insert(1u32, 5i32, 0);
1450        view.apply_delta(&batch1);
1451        assert_eq!(view.visible_count(), 1);
1452
1453        let mut batch2 = DeltaBatch::new(2);
1454        batch2.delete(1u32, 0);
1455        let output = view.apply_delta(&batch2);
1456        assert_eq!(output.len(), 1);
1457        assert!(!output.entries[0].is_insert()); // Delete
1458        assert_eq!(view.visible_count(), 0);
1459        assert_eq!(view.total_count(), 0);
1460    }
1461
1462    #[test]
1463    fn filtered_view_full_recompute() {
1464        let mut view = FilteredListView::new("test", |_k: &u32, v: &i32| *v % 2 == 0);
1465        let mut batch = DeltaBatch::new(1);
1466        batch.insert(1u32, 2i32, 0);
1467        batch.insert(2u32, 3i32, 1);
1468        batch.insert(3u32, 4i32, 2);
1469        batch.insert(4u32, 5i32, 3);
1470        view.apply_delta(&batch);
1471
1472        let full = view.full_recompute();
1473        assert_eq!(full.len(), 2); // Only even values
1474        let keys: Vec<u32> = full.iter().map(|(k, _)| *k).collect();
1475        assert!(keys.contains(&1));
1476        assert!(keys.contains(&3));
1477    }
1478
1479    #[test]
1480    fn filtered_view_deduplicates_unchanged() {
1481        let mut view = FilteredListView::new("test", |_k: &u32, v: &i32| *v > 0);
1482        let mut batch1 = DeltaBatch::new(1);
1483        batch1.insert(1u32, 5i32, 0);
1484        view.apply_delta(&batch1);
1485
1486        // Insert same value → no output delta.
1487        let mut batch2 = DeltaBatch::new(2);
1488        batch2.insert(1u32, 5i32, 0);
1489        let output = view.apply_delta(&batch2);
1490        assert!(output.is_empty(), "same value should produce no delta");
1491    }
1492
1493    #[test]
1494    fn filtered_view_domain_and_label() {
1495        let view: FilteredListView<u32, i32> =
1496            FilteredListView::new("EvenFilter", |_k: &u32, v: &i32| *v % 2 == 0);
1497        assert_eq!(view.domain(), ViewDomain::FilteredList);
1498        assert_eq!(view.label(), "EvenFilter");
1499    }
1500
1501    #[test]
1502    fn filtered_view_correctness_invariant() {
1503        // After a sequence of deltas, materialized view == full_recompute.
1504        let mut view = FilteredListView::new("test", |_k: &u32, v: &i32| *v > 0);
1505
1506        let mut batch1 = DeltaBatch::new(1);
1507        batch1.insert(1u32, 5i32, 0);
1508        batch1.insert(2u32, -3i32, 1);
1509        batch1.insert(3u32, 10i32, 2);
1510        view.apply_delta(&batch1);
1511
1512        let mut batch2 = DeltaBatch::new(2);
1513        batch2.delete(1u32, 0);
1514        batch2.insert(4u32, 7i32, 1);
1515        batch2.insert(2u32, 8i32, 2); // Was filtered, now visible
1516        view.apply_delta(&batch2);
1517
1518        // Verify materialized matches full_recompute.
1519        let full = view.full_recompute();
1520        let mut full_map: std::collections::HashMap<u32, i32> = full.into_iter().collect();
1521        assert_eq!(full_map.len(), view.visible_count());
1522        for (k, v) in &view.visible {
1523            assert_eq!(full_map.remove(k).as_ref(), Some(v));
1524        }
1525        assert!(full_map.is_empty());
1526    }
1527
1528    #[test]
1529    fn filtered_view_delete_nonexistent_is_noop() {
1530        let mut view: FilteredListView<u32, i32> =
1531            FilteredListView::new("test", |_k: &u32, _v: &i32| true);
1532        let mut batch = DeltaBatch::new(1);
1533        batch.delete(999u32, 0);
1534        let output = view.apply_delta(&batch);
1535        assert!(output.is_empty());
1536    }
1537
1538    // ── Combined trait usage ────────────────────────────────────────
1539
1540    #[test]
1541    fn trait_object_dispatch() {
1542        // Verify IncrementalView can be used as a trait object.
1543        let view: Box<dyn IncrementalView<StyleKey, ResolvedStyleValue>> =
1544            Box::new(StyleResolutionView::new("dynamic", 0x42));
1545        assert_eq!(view.materialized_size(), 0);
1546        assert_eq!(view.domain(), ViewDomain::Style);
1547        assert_eq!(view.label(), "dynamic");
1548    }
1549}