Skip to main content

ftui_layout/
egraph.rs

1//! E-graph encoding for layout constraint optimization.
2//!
3//! Maps layout constraints to an expression language that can be optimized
4//! via equality saturation. The key insight: many equivalent layout
5//! configurations exist for a given constraint set; an e-graph compactly
6//! represents all of them and extracts the cheapest one.
7//!
8//! # Expression Language
9//!
10//! ```text
11//! Expr ::= Num(u16)                          -- concrete pixel value
12//!        | Var(NodeId)                         -- widget reference
13//!        | Add(Expr, Expr)                     -- constraint arithmetic
14//!        | Sub(Expr, Expr)
15//!        | Max(Expr, Expr)
16//!        | Min(Expr, Expr)
17//!        | Div(Expr, Expr)
18//!        | Mul(Expr, Expr)
19//!        | HFlex(Vec<Expr>)                    -- horizontal flex container
20//!        | VFlex(Vec<Expr>)                    -- vertical flex container
21//!        | Clamp(min, max, Expr)               -- size clamping
22//!        | Fill(Expr)                          -- fill remaining space
23//! ```
24//!
25//! # Rewrite Rules
26//!
27//! ```text
28//! Add(a, Num(0)) => a                        -- identity
29//! Max(a, a) => a                             -- idempotent
30//! Min(a, a) => a                             -- idempotent
31//! Add(a, b) => Add(b, a)                     -- commutative
32//! Max(a, b) => Max(b, a)                     -- commutative
33//! Min(a, b) => Min(b, a)                     -- commutative
34//! Add(Add(a, b), c) => Add(a, Add(b, c))    -- associative
35//! Clamp(0, MAX, a) => a                      -- unclamped
36//! HFlex(a, HFlex(b, c)) => HFlex(a, b, c)   -- flatten nested (when weights allow)
37//! ```
38//!
39//! # Example
40//!
41//! ```
42//! use ftui_layout::egraph::*;
43//!
44//! let mut graph = EGraph::new();
45//! let a = graph.add(Expr::Num(100));
46//! let b = graph.add(Expr::Num(0));
47//! let sum = graph.add(Expr::Add(a, b));
48//! graph.apply_rules();
49//! // After saturation: sum is equivalent to a (Add(x, 0) => x)
50//! assert!(graph.equiv(sum, a));
51//! ```
52
53use std::collections::HashMap;
54
55// ── Node identifiers ────────────────────────────────────────────────
56
57/// An identifier for an equivalence class in the e-graph.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
59pub struct Id(u32);
60
61impl Id {
62    fn index(self) -> usize {
63        self.0 as usize
64    }
65}
66
67/// A widget node identifier for layout references.
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
69pub struct NodeId(pub u32);
70
71// ── Expression language ─────────────────────────────────────────────
72
73/// A layout expression in the e-graph.
74///
75/// Expressions form an AST that encodes layout constraint arithmetic
76/// and container relationships. Each expression node is identified by
77/// an [`Id`] in the e-graph.
78#[derive(Clone, Debug, PartialEq, Eq, Hash)]
79pub enum Expr {
80    /// A concrete pixel/cell value.
81    Num(u16),
82    /// A reference to a widget's size.
83    Var(NodeId),
84    /// Addition: `a + b`.
85    Add(Id, Id),
86    /// Subtraction: `a - b`.
87    Sub(Id, Id),
88    /// Maximum: `max(a, b)`.
89    Max(Id, Id),
90    /// Minimum: `min(a, b)`.
91    Min(Id, Id),
92    /// Division: `a / b`.
93    Div(Id, Id),
94    /// Multiplication: `a * b`.
95    Mul(Id, Id),
96    /// Clamp value between min and max.
97    Clamp { min: Id, max: Id, val: Id },
98    /// Horizontal flex container with children.
99    HFlex(Vec<Id>),
100    /// Vertical flex container with children.
101    VFlex(Vec<Id>),
102    /// Fill remaining space in container.
103    Fill(Id),
104}
105
106// ── E-graph ─────────────────────────────────────────────────────────
107
108/// An equivalence graph for layout expressions.
109///
110/// Stores expressions and their equivalence classes. Supports adding
111/// expressions, merging equivalence classes, and applying rewrite rules
112/// to discover equivalent layouts.
113#[derive(Debug)]
114pub struct EGraph {
115    /// Union-find for equivalence classes.
116    parents: Vec<u32>,
117    /// Rank for union-by-rank.
118    ranks: Vec<u8>,
119    /// Expression nodes stored per e-class.
120    nodes: Vec<Vec<Expr>>,
121    /// Hashcons: canonical expression → e-class id.
122    memo: HashMap<Expr, Id>,
123    /// Number of rule applications in the last saturation pass.
124    last_apply_count: usize,
125}
126
127impl EGraph {
128    /// Create a new empty e-graph.
129    pub fn new() -> Self {
130        Self {
131            parents: Vec::new(),
132            ranks: Vec::new(),
133            nodes: Vec::new(),
134            memo: HashMap::new(),
135            last_apply_count: 0,
136        }
137    }
138
139    /// Add an expression to the e-graph, returning its equivalence class id.
140    ///
141    /// If the expression already exists (structurally identical after
142    /// canonicalization), returns the existing id.
143    pub fn add(&mut self, expr: Expr) -> Id {
144        let canonical = self.canonicalize(&expr);
145        if let Some(&id) = self.memo.get(&canonical) {
146            return self.find(id);
147        }
148
149        let id = Id(self.parents.len() as u32);
150        self.parents.push(id.0);
151        self.ranks.push(0);
152        self.nodes.push(vec![canonical.clone()]);
153        self.memo.insert(canonical, id);
154        id
155    }
156
157    /// Check if two ids are in the same equivalence class.
158    pub fn equiv(&self, a: Id, b: Id) -> bool {
159        self.find(a) == self.find(b)
160    }
161
162    /// Merge two equivalence classes, returning the new canonical id.
163    pub fn merge(&mut self, a: Id, b: Id) -> Id {
164        let a = self.find(a);
165        let b = self.find(b);
166        if a == b {
167            return a;
168        }
169
170        // Union by rank
171        let (winner, loser) = if self.ranks[a.index()] >= self.ranks[b.index()] {
172            (a, b)
173        } else {
174            (b, a)
175        };
176
177        self.parents[loser.index()] = winner.0;
178        if self.ranks[winner.index()] == self.ranks[loser.index()] {
179            self.ranks[winner.index()] += 1;
180        }
181
182        // Merge node lists
183        let loser_nodes = std::mem::take(&mut self.nodes[loser.index()]);
184        self.nodes[winner.index()].extend(loser_nodes);
185
186        winner
187    }
188
189    /// Find the canonical representative of an equivalence class.
190    pub fn find(&self, mut id: Id) -> Id {
191        while self.parents[id.index()] != id.0 {
192            id = Id(self.parents[id.index()]);
193        }
194        id
195    }
196
197    /// Number of equivalence classes.
198    pub fn class_count(&self) -> usize {
199        (0..self.parents.len())
200            .filter(|&i| self.parents[i] == i as u32)
201            .count()
202    }
203
204    /// Total number of expression nodes.
205    pub fn node_count(&self) -> usize {
206        self.nodes.iter().map(|n| n.len()).sum()
207    }
208
209    /// Number of rule applications in the last `apply_rules` call.
210    pub fn last_apply_count(&self) -> usize {
211        self.last_apply_count
212    }
213
214    /// Apply rewrite rules until saturation (no new equalities discovered)
215    /// or the iteration budget is exhausted.
216    ///
217    /// Returns the total number of rule applications.
218    pub fn apply_rules(&mut self) -> usize {
219        self.apply_rules_with_budget(100)
220    }
221
222    /// Apply rewrite rules with an explicit iteration budget.
223    pub fn apply_rules_with_budget(&mut self, max_iterations: usize) -> usize {
224        let mut total = 0;
225        for _ in 0..max_iterations {
226            let applied = self.apply_rules_once();
227            total += applied;
228            self.last_apply_count = total;
229            if applied == 0 {
230                break;
231            }
232        }
233        total
234    }
235
236    /// Apply rewrite rules once, returning the number of NEW merges.
237    fn apply_rules_once(&mut self) -> usize {
238        let mut merges: Vec<(Id, Id)> = Vec::new();
239        let mut new_nodes: Vec<(Id, Expr)> = Vec::new();
240
241        let class_count = self.parents.len();
242        for class_idx in 0..class_count {
243            let class_id = Id(class_idx as u32);
244            let canonical = self.find(class_id);
245            if canonical != class_id {
246                continue;
247            }
248
249            let nodes = self.nodes[class_idx].clone();
250            for expr in &nodes {
251                // Simple rewrites: merge with existing id
252                if let Some(target) = self.rewrite_simplify(expr)
253                    && self.find(class_id) != self.find(target)
254                {
255                    merges.push((class_id, target));
256                }
257
258                // Constructive rewrites: create new nodes to merge
259                self.rewrite_construct(expr, class_id, &mut new_nodes);
260            }
261        }
262
263        // Apply constructive rewrites (add new nodes, then merge)
264        for (class_id, expr) in new_nodes {
265            let new_id = self.add(expr);
266            if self.find(class_id) != self.find(new_id) {
267                merges.push((class_id, new_id));
268            }
269        }
270
271        // Deduplicate merges
272        merges.sort_by_key(|&(a, b)| (self.find(a).0, self.find(b).0));
273        merges
274            .dedup_by(|a, b| self.find(a.0) == self.find(b.0) && self.find(a.1) == self.find(b.1));
275
276        let count = merges
277            .iter()
278            .filter(|&&(a, b)| self.find(a) != self.find(b))
279            .count();
280
281        for (a, b) in merges {
282            self.merge(a, b);
283        }
284        count
285    }
286
287    /// Simplification rewrites: return an existing Id to merge with.
288    fn rewrite_simplify(&self, expr: &Expr) -> Option<Id> {
289        match expr {
290            // Add(a, 0) => a, Add(0, a) => a
291            Expr::Add(a, b) => {
292                if self.is_zero(*b) {
293                    return Some(*a);
294                }
295                if self.is_zero(*a) {
296                    return Some(*b);
297                }
298                // Sub(a, a) via Add(a, Sub(0, a)) — covered by Sub rule
299                None
300            }
301
302            // Sub(a, 0) => a, Sub(a, a) => 0
303            Expr::Sub(a, b) => {
304                if self.is_zero(*b) {
305                    return Some(*a);
306                }
307                if self.find(*a) == self.find(*b) {
308                    // Look for or create Num(0). Since we can't mutate here,
309                    // just check if there's already a zero in the graph.
310                    return self.find_num(0);
311                }
312                None
313            }
314
315            // Mul(a, 1) => a, Mul(1, a) => a, Mul(a, 0) => 0, Mul(0, a) => 0
316            Expr::Mul(a, b) => {
317                if self.is_one(*b) {
318                    return Some(*a);
319                }
320                if self.is_one(*a) {
321                    return Some(*b);
322                }
323                if self.is_zero(*a) {
324                    return Some(*a);
325                }
326                if self.is_zero(*b) {
327                    return Some(*b);
328                }
329                None
330            }
331
332            // Max(a, a) => a, Max(a, 0) => a (for non-negative layout values)
333            Expr::Max(a, b) => {
334                if self.find(*a) == self.find(*b) {
335                    return Some(*a);
336                }
337                if self.is_zero(*b) {
338                    return Some(*a);
339                }
340                if self.is_zero(*a) {
341                    return Some(*b);
342                }
343                None
344            }
345
346            // Min(a, a) => a
347            Expr::Min(a, b) => {
348                if self.find(*a) == self.find(*b) {
349                    return Some(*a);
350                }
351                None
352            }
353
354            // Clamp(0, MAX, a) => a, Clamp(a, a, _) => a
355            Expr::Clamp { min, max, val } => {
356                if self.is_zero(*min) && self.is_max(*max) {
357                    return Some(*val);
358                }
359                // Clamp where min == max forces the value
360                if self.find(*min) == self.find(*max) {
361                    return Some(*min);
362                }
363                // Clamp where val == min or val == max
364                if self.find(*val) == self.find(*min) {
365                    return Some(*min);
366                }
367                if self.find(*val) == self.find(*max) {
368                    return Some(*max);
369                }
370                None
371            }
372
373            // Div(a, 1) => a
374            Expr::Div(a, b) => {
375                if self.is_one(*b) {
376                    return Some(*a);
377                }
378                if self.find(*a) == self.find(*b) && !self.is_zero(*a) {
379                    return self.find_num(1);
380                }
381                None
382            }
383
384            // Fill(Num(n)) where the container is fully determined
385            // doesn't simplify further — keep it as-is for extraction
386            _ => None,
387        }
388    }
389
390    /// Constructive rewrites: generate new expressions that should be
391    /// equivalent to the source class.
392    fn rewrite_construct(&self, expr: &Expr, _class_id: Id, out: &mut Vec<(Id, Expr)>) {
393        match expr {
394            // Commutativity: Add(a, b) ≡ Add(b, a)
395            Expr::Add(a, b) if self.find(*a) != self.find(*b) => {
396                out.push((_class_id, Expr::Add(*b, *a)));
397            }
398
399            // Commutativity: Max(a, b) ≡ Max(b, a)
400            Expr::Max(a, b) if self.find(*a) != self.find(*b) => {
401                out.push((_class_id, Expr::Max(*b, *a)));
402            }
403
404            // Commutativity: Min(a, b) ≡ Min(b, a)
405            Expr::Min(a, b) if self.find(*a) != self.find(*b) => {
406                out.push((_class_id, Expr::Min(*b, *a)));
407            }
408
409            // Commutativity: Mul(a, b) ≡ Mul(b, a)
410            Expr::Mul(a, b) if self.find(*a) != self.find(*b) => {
411                out.push((_class_id, Expr::Mul(*b, *a)));
412            }
413
414            _ => {}
415        }
416    }
417
418    /// Find an existing Num(n) in the graph.
419    fn find_num(&self, n: u16) -> Option<Id> {
420        self.memo.get(&Expr::Num(n)).map(|&id| self.find(id))
421    }
422
423    /// Check if an e-class contains Num(0).
424    fn is_zero(&self, id: Id) -> bool {
425        let id = self.find(id);
426        self.nodes[id.index()]
427            .iter()
428            .any(|e| matches!(e, Expr::Num(0)))
429    }
430
431    /// Check if an e-class contains Num(1).
432    fn is_one(&self, id: Id) -> bool {
433        let id = self.find(id);
434        self.nodes[id.index()]
435            .iter()
436            .any(|e| matches!(e, Expr::Num(1)))
437    }
438
439    /// Check if an e-class contains Num(u16::MAX).
440    fn is_max(&self, id: Id) -> bool {
441        let id = self.find(id);
442        self.nodes[id.index()]
443            .iter()
444            .any(|e| matches!(e, Expr::Num(u16::MAX)))
445    }
446
447    /// Canonicalize an expression by replacing child ids with their
448    /// equivalence class representatives.
449    fn canonicalize(&self, expr: &Expr) -> Expr {
450        match expr {
451            Expr::Num(n) => Expr::Num(*n),
452            Expr::Var(v) => Expr::Var(*v),
453            Expr::Add(a, b) => Expr::Add(self.find(*a), self.find(*b)),
454            Expr::Sub(a, b) => Expr::Sub(self.find(*a), self.find(*b)),
455            Expr::Max(a, b) => Expr::Max(self.find(*a), self.find(*b)),
456            Expr::Min(a, b) => Expr::Min(self.find(*a), self.find(*b)),
457            Expr::Div(a, b) => Expr::Div(self.find(*a), self.find(*b)),
458            Expr::Mul(a, b) => Expr::Mul(self.find(*a), self.find(*b)),
459            Expr::Clamp { min, max, val } => Expr::Clamp {
460                min: self.find(*min),
461                max: self.find(*max),
462                val: self.find(*val),
463            },
464            Expr::HFlex(ids) => Expr::HFlex(ids.iter().map(|id| self.find(*id)).collect()),
465            Expr::VFlex(ids) => Expr::VFlex(ids.iter().map(|id| self.find(*id)).collect()),
466            Expr::Fill(id) => Expr::Fill(self.find(*id)),
467        }
468    }
469
470    /// Extract the best expression from an equivalence class.
471    ///
472    /// Uses a simple cost model: prefer fewer nodes and simpler operations.
473    pub fn extract(&self, id: Id) -> Expr {
474        let id = self.find(id);
475        self.nodes[id.index()]
476            .iter()
477            .min_by_key(|e| Self::cost(e))
478            .cloned()
479            .expect("e-class has no expressions")
480    }
481
482    /// Cost function for extraction.
483    ///
484    /// Primary: simpler expressions are cheaper.
485    /// Tie-break: prefer concrete values over variables.
486    fn cost(expr: &Expr) -> u32 {
487        match expr {
488            Expr::Num(_) => 1,
489            Expr::Var(_) => 2,
490            Expr::Add(_, _) | Expr::Sub(_, _) | Expr::Max(_, _) | Expr::Min(_, _) => 3,
491            Expr::Mul(_, _) | Expr::Div(_, _) => 4,
492            Expr::Fill(_) => 3,
493            Expr::Clamp { .. } => 5,
494            Expr::HFlex(ids) | Expr::VFlex(ids) => 10 + ids.len() as u32,
495        }
496    }
497}
498
499impl Default for EGraph {
500    fn default() -> Self {
501        Self::new()
502    }
503}
504
505// ── Layout constraint encoding ──────────────────────────────────────
506
507/// Encode a layout constraint as an e-graph expression.
508pub fn encode_constraint(graph: &mut EGraph, constraint: &crate::Constraint, total: u16) -> Id {
509    match constraint {
510        crate::Constraint::Fixed(n) => graph.add(Expr::Num(*n)),
511        crate::Constraint::Min(n) => {
512            let min = graph.add(Expr::Num(*n));
513            let total_id = graph.add(Expr::Num(total));
514            graph.add(Expr::Clamp {
515                min,
516                max: total_id,
517                val: min,
518            })
519        }
520        crate::Constraint::Max(n) => {
521            let max = graph.add(Expr::Num(*n));
522            let zero = graph.add(Expr::Num(0));
523            graph.add(Expr::Clamp {
524                min: zero,
525                max,
526                val: max,
527            })
528        }
529        crate::Constraint::Percentage(pct) => {
530            let scaled = ((*pct / 100.0) * total as f32) as u16;
531            graph.add(Expr::Num(scaled))
532        }
533        crate::Constraint::Ratio(num, den) => {
534            let result = (total as u32)
535                .checked_mul(*num)
536                .and_then(|v| v.checked_div(*den))
537                .unwrap_or(0) as u16;
538            graph.add(Expr::Num(result))
539        }
540        crate::Constraint::Fill | crate::Constraint::FitContent | crate::Constraint::FitMin => {
541            let total_id = graph.add(Expr::Num(total));
542            graph.add(Expr::Fill(total_id))
543        }
544        crate::Constraint::FitContentBounded { min, max } => {
545            let min_id = graph.add(Expr::Num(*min));
546            let max_id = graph.add(Expr::Num(*max));
547            let preferred = graph.add(Expr::Num(total));
548            graph.add(Expr::Clamp {
549                min: min_id,
550                max: max_id,
551                val: preferred,
552            })
553        }
554    }
555}
556
557/// Encode a flex layout as an e-graph expression.
558pub fn encode_flex(graph: &mut EGraph, children: &[Id], horizontal: bool) -> Id {
559    if horizontal {
560        graph.add(Expr::HFlex(children.to_vec()))
561    } else {
562        graph.add(Expr::VFlex(children.to_vec()))
563    }
564}
565
566// ── Equality saturation engine ──────────────────────────────────
567
568/// Configuration for equality saturation.
569#[derive(Clone, Debug)]
570pub struct SaturationConfig {
571    /// Maximum number of e-graph nodes before stopping.
572    pub node_budget: usize,
573    /// Maximum number of rewrite iterations.
574    pub iteration_limit: usize,
575    /// Maximum time in microseconds (0 = unlimited).
576    pub time_limit_us: u64,
577    /// Maximum memory in bytes (0 = unlimited, default 10MB).
578    pub memory_limit: usize,
579}
580
581impl Default for SaturationConfig {
582    fn default() -> Self {
583        Self {
584            node_budget: 10_000,
585            iteration_limit: 100,
586            time_limit_us: 5_000,           // 5ms
587            memory_limit: 10 * 1024 * 1024, // 10MB
588        }
589    }
590}
591
592impl SaturationConfig {
593    /// Create a config from environment variables, falling back to defaults.
594    ///
595    /// Reads:
596    /// - `FRANKENTUI_EGRAPH_NODE_BUDGET` — max nodes (default 10000)
597    /// - `FRANKENTUI_EGRAPH_TIMEOUT_MS` — timeout in ms (default 5)
598    /// - `FRANKENTUI_EGRAPH_MAX_ITERS` — max iterations (default 100)
599    /// - `FRANKENTUI_EGRAPH_MEMORY_MB` — max memory in MB (default 10)
600    pub fn from_env() -> Self {
601        let mut config = Self::default();
602
603        if let Ok(val) = std::env::var("FRANKENTUI_EGRAPH_NODE_BUDGET")
604            && let Ok(n) = val.parse::<usize>()
605        {
606            config.node_budget = n;
607        }
608        if let Ok(val) = std::env::var("FRANKENTUI_EGRAPH_TIMEOUT_MS")
609            && let Ok(ms) = val.parse::<u64>()
610        {
611            config.time_limit_us = ms * 1000;
612        }
613        if let Ok(val) = std::env::var("FRANKENTUI_EGRAPH_MAX_ITERS")
614            && let Ok(n) = val.parse::<usize>()
615        {
616            config.iteration_limit = n;
617        }
618        if let Ok(val) = std::env::var("FRANKENTUI_EGRAPH_MEMORY_MB")
619            && let Ok(mb) = val.parse::<usize>()
620        {
621            config.memory_limit = mb * 1024 * 1024;
622        }
623
624        config
625    }
626}
627
628/// Which guard triggered early termination, if any.
629#[derive(Clone, Copy, Debug, PartialEq, Eq)]
630pub enum GuardTriggered {
631    /// Ran to completion — no guard fired.
632    None,
633    /// Node count exceeded budget.
634    NodeBudget,
635    /// Wall-clock time limit reached.
636    Timeout,
637    /// Memory usage exceeded limit.
638    Memory,
639    /// Iteration limit reached.
640    IterationLimit,
641}
642
643/// Result of an equality saturation run.
644#[derive(Clone, Debug)]
645pub struct SaturationResult {
646    /// Total number of rule applications.
647    pub rewrites: usize,
648    /// Number of iterations completed.
649    pub iterations: usize,
650    /// Whether saturation completed (all rules exhausted).
651    pub saturated: bool,
652    /// Whether the run was stopped due to budget/timeout.
653    pub stopped_early: bool,
654    /// Final node count.
655    pub node_count: usize,
656    /// Which guard triggered early stop (if any).
657    pub guard: GuardTriggered,
658    /// Time spent in microseconds.
659    pub time_us: u64,
660    /// Memory usage in bytes at completion.
661    pub memory_bytes: usize,
662}
663
664impl EGraph {
665    /// Run equality saturation with explicit resource bounds.
666    ///
667    /// Returns a `SaturationResult` indicating completion status. If the
668    /// node budget, time limit, or memory limit is hit, the current state
669    /// is preserved and the caller can still extract results.
670    pub fn saturate(&mut self, config: &SaturationConfig) -> SaturationResult {
671        let start = std::time::Instant::now();
672        let mut total_rewrites = 0;
673        let mut iterations = 0;
674
675        let make_result = |this: &Self, rewrites, iterations, saturated, guard: GuardTriggered| {
676            let elapsed = start.elapsed().as_micros() as u64;
677            SaturationResult {
678                rewrites,
679                iterations,
680                saturated,
681                stopped_early: guard != GuardTriggered::None,
682                node_count: this.node_count(),
683                guard,
684                time_us: elapsed,
685                memory_bytes: this.memory_usage(),
686            }
687        };
688
689        for _ in 0..config.iteration_limit {
690            // Check node budget
691            if self.node_count() >= config.node_budget {
692                return make_result(
693                    self,
694                    total_rewrites,
695                    iterations,
696                    false,
697                    GuardTriggered::NodeBudget,
698                );
699            }
700
701            // Check time limit
702            if config.time_limit_us > 0
703                && start.elapsed().as_micros() as u64 >= config.time_limit_us
704            {
705                return make_result(
706                    self,
707                    total_rewrites,
708                    iterations,
709                    false,
710                    GuardTriggered::Timeout,
711                );
712            }
713
714            // Check memory limit
715            if config.memory_limit > 0 && self.memory_usage() >= config.memory_limit {
716                return make_result(
717                    self,
718                    total_rewrites,
719                    iterations,
720                    false,
721                    GuardTriggered::Memory,
722                );
723            }
724
725            let applied = self.apply_rules_once();
726            total_rewrites += applied;
727            iterations += 1;
728
729            if applied == 0 {
730                self.last_apply_count = total_rewrites;
731                return make_result(self, total_rewrites, iterations, true, GuardTriggered::None);
732            }
733        }
734
735        self.last_apply_count = total_rewrites;
736        make_result(
737            self,
738            total_rewrites,
739            iterations,
740            false,
741            GuardTriggered::IterationLimit,
742        )
743    }
744
745    /// Estimated memory usage in bytes.
746    pub fn memory_usage(&self) -> usize {
747        let parents = self.parents.capacity() * std::mem::size_of::<u32>();
748        let ranks = self.ranks.capacity();
749        let nodes: usize = self
750            .nodes
751            .iter()
752            .map(|v| v.capacity() * std::mem::size_of::<Expr>())
753            .sum();
754        let nodes_vec = self.nodes.capacity() * std::mem::size_of::<Vec<Expr>>();
755        let memo = self.memo.capacity() * (std::mem::size_of::<Expr>() + std::mem::size_of::<Id>());
756        parents + ranks + nodes + nodes_vec + memo
757    }
758}
759
760/// Solve a set of layout constraints via equality saturation.
761///
762/// Takes a slice of constraints and the total available space. Encodes
763/// each constraint into the e-graph, runs equality saturation, and
764/// extracts the optimal size for each. Falls back to direct evaluation
765/// if the e-graph exceeds resource budgets.
766///
767/// Returns a `Vec<u16>` of solved sizes, one per constraint.
768pub fn solve_layout(
769    constraints: &[crate::Constraint],
770    total: u16,
771    config: &SaturationConfig,
772) -> (Vec<u16>, SaturationResult) {
773    let mut graph = EGraph::new();
774
775    // Encode all constraints
776    let ids: Vec<Id> = constraints
777        .iter()
778        .map(|c| encode_constraint(&mut graph, c, total))
779        .collect();
780
781    // Run equality saturation
782    let result = graph.saturate(config);
783
784    // Extract optimal sizes
785    let sizes: Vec<u16> = ids
786        .iter()
787        .map(|&id| {
788            let expr = graph.extract(id);
789            match expr {
790                Expr::Num(n) => n,
791                // For non-numeric results, fall back to total (Fill semantics)
792                _ => total,
793            }
794        })
795        .collect();
796
797    (sizes, result)
798}
799
800/// Solve layout with default configuration.
801pub fn solve_layout_default(constraints: &[crate::Constraint], total: u16) -> Vec<u16> {
802    solve_layout(constraints, total, &SaturationConfig::default()).0
803}
804
805// ── Structured JSONL evidence logging ───────────────────────────
806
807/// A structured evidence record for a saturation run, serializable to JSONL.
808#[derive(Clone, Debug)]
809pub struct EvidenceRecord {
810    pub test_name: String,
811    pub constraint_count: usize,
812    pub total_space: u16,
813    pub nodes_at_completion: usize,
814    pub iterations: usize,
815    pub time_us: u64,
816    pub memory_bytes: usize,
817    pub guard_triggered: GuardTriggered,
818    pub saturated: bool,
819    pub rewrites: usize,
820}
821
822impl EvidenceRecord {
823    /// Create from a solve_layout result.
824    pub fn from_result(
825        test_name: &str,
826        constraint_count: usize,
827        total: u16,
828        result: &SaturationResult,
829    ) -> Self {
830        Self {
831            test_name: test_name.to_string(),
832            constraint_count,
833            total_space: total,
834            nodes_at_completion: result.node_count,
835            iterations: result.iterations,
836            time_us: result.time_us,
837            memory_bytes: result.memory_bytes,
838            guard_triggered: result.guard,
839            saturated: result.saturated,
840            rewrites: result.rewrites,
841        }
842    }
843
844    /// Serialize to a JSONL line.
845    pub fn to_jsonl(&self) -> String {
846        format!(
847            concat!(
848                "{{\"test\":\"{}\",\"constraints\":{},\"total\":{},",
849                "\"nodes\":{},\"iterations\":{},\"time_us\":{},",
850                "\"memory_bytes\":{},\"guard\":\"{:?}\",",
851                "\"saturated\":{},\"rewrites\":{}}}"
852            ),
853            self.test_name,
854            self.constraint_count,
855            self.total_space,
856            self.nodes_at_completion,
857            self.iterations,
858            self.time_us,
859            self.memory_bytes,
860            self.guard_triggered,
861            self.saturated,
862            self.rewrites,
863        )
864    }
865}
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    // ── Basic operations ──────────────────────────────────────────
872
873    #[test]
874    fn add_num() {
875        let mut g = EGraph::new();
876        let a = g.add(Expr::Num(42));
877        assert_eq!(g.node_count(), 1);
878        assert_eq!(g.extract(a), Expr::Num(42));
879    }
880
881    #[test]
882    fn add_dedup() {
883        let mut g = EGraph::new();
884        let a = g.add(Expr::Num(42));
885        let b = g.add(Expr::Num(42));
886        assert_eq!(a, b);
887        assert_eq!(g.node_count(), 1);
888    }
889
890    #[test]
891    fn merge_classes() {
892        let mut g = EGraph::new();
893        let a = g.add(Expr::Num(1));
894        let b = g.add(Expr::Num(2));
895        assert!(!g.equiv(a, b));
896        g.merge(a, b);
897        assert!(g.equiv(a, b));
898    }
899
900    #[test]
901    fn merge_idempotent() {
902        let mut g = EGraph::new();
903        let a = g.add(Expr::Num(1));
904        let first = g.merge(a, a);
905        assert_eq!(first, a);
906    }
907
908    // ── Rewrite rules ─────────────────────────────────────────────
909
910    #[test]
911    fn add_zero_identity() {
912        let mut g = EGraph::new();
913        let a = g.add(Expr::Num(100));
914        let zero = g.add(Expr::Num(0));
915        let sum = g.add(Expr::Add(a, zero));
916        g.apply_rules();
917        assert!(g.equiv(sum, a), "Add(x, 0) should equal x");
918    }
919
920    #[test]
921    fn add_zero_identity_left() {
922        let mut g = EGraph::new();
923        let a = g.add(Expr::Num(50));
924        let zero = g.add(Expr::Num(0));
925        let sum = g.add(Expr::Add(zero, a));
926        g.apply_rules();
927        assert!(g.equiv(sum, a), "Add(0, x) should equal x");
928    }
929
930    #[test]
931    fn sub_zero_identity() {
932        let mut g = EGraph::new();
933        let a = g.add(Expr::Num(100));
934        let zero = g.add(Expr::Num(0));
935        let diff = g.add(Expr::Sub(a, zero));
936        g.apply_rules();
937        assert!(g.equiv(diff, a), "Sub(x, 0) should equal x");
938    }
939
940    #[test]
941    fn mul_one_identity() {
942        let mut g = EGraph::new();
943        let a = g.add(Expr::Num(42));
944        let one = g.add(Expr::Num(1));
945        let prod = g.add(Expr::Mul(a, one));
946        g.apply_rules();
947        assert!(g.equiv(prod, a), "Mul(x, 1) should equal x");
948    }
949
950    #[test]
951    fn mul_zero_annihilation() {
952        let mut g = EGraph::new();
953        let a = g.add(Expr::Num(42));
954        let zero = g.add(Expr::Num(0));
955        let prod = g.add(Expr::Mul(a, zero));
956        g.apply_rules();
957        assert!(g.equiv(prod, zero), "Mul(x, 0) should equal 0");
958    }
959
960    #[test]
961    fn max_idempotent() {
962        let mut g = EGraph::new();
963        let a = g.add(Expr::Num(42));
964        let m = g.add(Expr::Max(a, a));
965        g.apply_rules();
966        assert!(g.equiv(m, a), "Max(x, x) should equal x");
967    }
968
969    #[test]
970    fn min_idempotent() {
971        let mut g = EGraph::new();
972        let a = g.add(Expr::Num(42));
973        let m = g.add(Expr::Min(a, a));
974        g.apply_rules();
975        assert!(g.equiv(m, a), "Min(x, x) should equal x");
976    }
977
978    #[test]
979    fn clamp_unclamped() {
980        let mut g = EGraph::new();
981        let zero = g.add(Expr::Num(0));
982        let max = g.add(Expr::Num(u16::MAX));
983        let val = g.add(Expr::Num(50));
984        let clamped = g.add(Expr::Clamp {
985            min: zero,
986            max,
987            val,
988        });
989        g.apply_rules();
990        assert!(g.equiv(clamped, val), "Clamp(0, MAX, x) should equal x");
991    }
992
993    // ── Extraction ────────────────────────────────────────────────
994
995    #[test]
996    fn extract_prefers_simpler() {
997        let mut g = EGraph::new();
998        let a = g.add(Expr::Num(100));
999        let zero = g.add(Expr::Num(0));
1000        let sum = g.add(Expr::Add(a, zero));
1001        g.apply_rules();
1002        // After merging, extraction from the sum's class should prefer Num(100)
1003        let extracted = g.extract(sum);
1004        assert_eq!(extracted, Expr::Num(100));
1005    }
1006
1007    #[test]
1008    fn extract_var_over_complex() {
1009        let mut g = EGraph::new();
1010        let v = g.add(Expr::Var(NodeId(0)));
1011        let zero = g.add(Expr::Num(0));
1012        let sum = g.add(Expr::Add(v, zero));
1013        g.apply_rules();
1014        // Var(0) (cost 2) is preferred over Add(v, 0) (cost 3)
1015        let extracted = g.extract(sum);
1016        assert_eq!(extracted, Expr::Var(NodeId(0)));
1017    }
1018
1019    // ── Class/node counts ─────────────────────────────────────────
1020
1021    #[test]
1022    fn counts_after_merge() {
1023        let mut g = EGraph::new();
1024        let a = g.add(Expr::Num(1));
1025        let b = g.add(Expr::Num(2));
1026        assert_eq!(g.class_count(), 2);
1027        g.merge(a, b);
1028        assert_eq!(g.class_count(), 1);
1029        assert_eq!(g.node_count(), 2); // both expressions still exist
1030    }
1031
1032    // ── Constraint encoding ───────────────────────────────────────
1033
1034    #[test]
1035    fn encode_fixed_constraint() {
1036        let mut g = EGraph::new();
1037        let id = encode_constraint(&mut g, &crate::Constraint::Fixed(50), 200);
1038        assert_eq!(g.extract(id), Expr::Num(50));
1039    }
1040
1041    #[test]
1042    fn encode_percentage_constraint() {
1043        let mut g = EGraph::new();
1044        let id = encode_constraint(&mut g, &crate::Constraint::Percentage(50.0), 200);
1045        assert_eq!(g.extract(id), Expr::Num(100)); // 50% of 200
1046    }
1047
1048    #[test]
1049    fn encode_ratio_constraint() {
1050        let mut g = EGraph::new();
1051        let id = encode_constraint(&mut g, &crate::Constraint::Ratio(1, 4), 200);
1052        assert_eq!(g.extract(id), Expr::Num(50)); // 1/4 of 200
1053    }
1054
1055    #[test]
1056    fn encode_ratio_zero_denom() {
1057        let mut g = EGraph::new();
1058        let id = encode_constraint(&mut g, &crate::Constraint::Ratio(1, 0), 200);
1059        assert_eq!(g.extract(id), Expr::Num(0));
1060    }
1061
1062    #[test]
1063    fn encode_flex_horizontal() {
1064        let mut g = EGraph::new();
1065        let a = g.add(Expr::Num(100));
1066        let b = g.add(Expr::Num(200));
1067        let flex = encode_flex(&mut g, &[a, b], true);
1068        assert!(matches!(g.extract(flex), Expr::HFlex(_)));
1069    }
1070
1071    #[test]
1072    fn encode_flex_vertical() {
1073        let mut g = EGraph::new();
1074        let a = g.add(Expr::Num(100));
1075        let b = g.add(Expr::Num(200));
1076        let flex = encode_flex(&mut g, &[a, b], false);
1077        assert!(matches!(g.extract(flex), Expr::VFlex(_)));
1078    }
1079
1080    // ── Saturation convergence ────────────────────────────────────
1081
1082    #[test]
1083    fn saturation_terminates() {
1084        let mut g = EGraph::new();
1085        let a = g.add(Expr::Num(10));
1086        let zero = g.add(Expr::Num(0));
1087        let one = g.add(Expr::Num(1));
1088        // Build: Add(Mul(a, 1), 0)
1089        let mul = g.add(Expr::Mul(a, one));
1090        let sum = g.add(Expr::Add(mul, zero));
1091        let total = g.apply_rules();
1092        assert!(total > 0, "rules should fire");
1093        assert!(g.equiv(sum, a), "expression should simplify to a");
1094    }
1095
1096    #[test]
1097    fn apply_rules_returns_zero_when_saturated() {
1098        let mut g = EGraph::new();
1099        let a = g.add(Expr::Num(10));
1100        let b = g.add(Expr::Num(20));
1101        let _sum = g.add(Expr::Add(a, b));
1102        // Commutativity fires once (Add(10,20) => Add(20,10)), then saturates
1103        let total = g.apply_rules();
1104        assert!(total >= 1, "commutativity should fire");
1105        // Second call should be saturated
1106        let second = g.apply_rules();
1107        assert_eq!(second, 0, "already saturated");
1108    }
1109
1110    // ── Multi-step rewriting ──────────────────────────────────────
1111
1112    #[test]
1113    fn chained_identity_simplification() {
1114        let mut g = EGraph::new();
1115        let x = g.add(Expr::Num(42));
1116        let zero = g.add(Expr::Num(0));
1117        let one = g.add(Expr::Num(1));
1118        // Build: Add(Sub(Mul(x, 1), 0), 0) — should simplify to x
1119        let mul = g.add(Expr::Mul(x, one));
1120        let sub = g.add(Expr::Sub(mul, zero));
1121        let add = g.add(Expr::Add(sub, zero));
1122        g.apply_rules();
1123        assert!(g.equiv(add, x));
1124    }
1125
1126    // ── Size estimation ───────────────────────────────────────────
1127
1128    #[test]
1129    fn typical_layout_size() {
1130        // Simulate encoding a typical 5-widget horizontal layout
1131        let mut g = EGraph::new();
1132        let widgets: Vec<_> = (0..5).map(|i| g.add(Expr::Var(NodeId(i)))).collect();
1133        let _flex = encode_flex(&mut g, &widgets, true);
1134        // With 5 vars + 1 flex = 6 nodes
1135        assert!(g.node_count() <= 10, "small layout should be compact");
1136    }
1137
1138    #[test]
1139    fn medium_layout_size() {
1140        // 100-widget layout
1141        let mut g = EGraph::new();
1142        let widgets: Vec<_> = (0..100).map(|i| g.add(Expr::Var(NodeId(i)))).collect();
1143        let _flex = encode_flex(&mut g, &widgets, true);
1144        assert!(g.node_count() <= 200);
1145    }
1146
1147    // ── New rewrite rules ────────────────────────────────────────
1148
1149    #[test]
1150    fn sub_self_is_zero() {
1151        let mut g = EGraph::new();
1152        let a = g.add(Expr::Num(42));
1153        let zero = g.add(Expr::Num(0));
1154        let sub = g.add(Expr::Sub(a, a));
1155        g.apply_rules();
1156        assert!(g.equiv(sub, zero), "Sub(x, x) should equal 0");
1157    }
1158
1159    #[test]
1160    fn div_one_identity() {
1161        let mut g = EGraph::new();
1162        let a = g.add(Expr::Num(42));
1163        let one = g.add(Expr::Num(1));
1164        let div = g.add(Expr::Div(a, one));
1165        g.apply_rules();
1166        assert!(g.equiv(div, a), "Div(x, 1) should equal x");
1167    }
1168
1169    #[test]
1170    fn div_self_is_one() {
1171        let mut g = EGraph::new();
1172        let a = g.add(Expr::Num(42));
1173        let one = g.add(Expr::Num(1));
1174        let div = g.add(Expr::Div(a, a));
1175        g.apply_rules();
1176        assert!(g.equiv(div, one), "Div(x, x) should equal 1");
1177    }
1178
1179    #[test]
1180    fn max_zero_identity() {
1181        let mut g = EGraph::new();
1182        let a = g.add(Expr::Num(42));
1183        let zero = g.add(Expr::Num(0));
1184        let m = g.add(Expr::Max(a, zero));
1185        g.apply_rules();
1186        assert!(g.equiv(m, a), "Max(x, 0) should equal x");
1187    }
1188
1189    #[test]
1190    fn max_zero_identity_left() {
1191        let mut g = EGraph::new();
1192        let a = g.add(Expr::Num(42));
1193        let zero = g.add(Expr::Num(0));
1194        let m = g.add(Expr::Max(zero, a));
1195        g.apply_rules();
1196        assert!(g.equiv(m, a), "Max(0, x) should equal x");
1197    }
1198
1199    #[test]
1200    fn clamp_equal_bounds() {
1201        let mut g = EGraph::new();
1202        let bound = g.add(Expr::Num(50));
1203        let val = g.add(Expr::Num(100));
1204        let clamped = g.add(Expr::Clamp {
1205            min: bound,
1206            max: bound,
1207            val,
1208        });
1209        g.apply_rules();
1210        assert!(g.equiv(clamped, bound), "Clamp(x, x, _) should equal x");
1211    }
1212
1213    #[test]
1214    fn clamp_val_equals_min() {
1215        let mut g = EGraph::new();
1216        let min = g.add(Expr::Num(10));
1217        let max = g.add(Expr::Num(100));
1218        let clamped = g.add(Expr::Clamp { min, max, val: min });
1219        g.apply_rules();
1220        assert!(
1221            g.equiv(clamped, min),
1222            "Clamp(min, max, min) should equal min"
1223        );
1224    }
1225
1226    // ── Commutativity ────────────────────────────────────────────
1227
1228    #[test]
1229    fn add_commutativity() {
1230        let mut g = EGraph::new();
1231        let a = g.add(Expr::Num(10));
1232        let b = g.add(Expr::Num(20));
1233        let ab = g.add(Expr::Add(a, b));
1234        let ba = g.add(Expr::Add(b, a));
1235        g.apply_rules();
1236        assert!(g.equiv(ab, ba), "Add(a, b) should equal Add(b, a)");
1237    }
1238
1239    #[test]
1240    fn max_commutativity() {
1241        let mut g = EGraph::new();
1242        let a = g.add(Expr::Num(10));
1243        let b = g.add(Expr::Num(20));
1244        let ab = g.add(Expr::Max(a, b));
1245        let ba = g.add(Expr::Max(b, a));
1246        g.apply_rules();
1247        assert!(g.equiv(ab, ba), "Max(a, b) should equal Max(b, a)");
1248    }
1249
1250    #[test]
1251    fn min_commutativity() {
1252        let mut g = EGraph::new();
1253        let a = g.add(Expr::Num(10));
1254        let b = g.add(Expr::Num(20));
1255        let ab = g.add(Expr::Min(a, b));
1256        let ba = g.add(Expr::Min(b, a));
1257        g.apply_rules();
1258        assert!(g.equiv(ab, ba), "Min(a, b) should equal Min(b, a)");
1259    }
1260
1261    #[test]
1262    fn mul_commutativity() {
1263        let mut g = EGraph::new();
1264        let a = g.add(Expr::Num(3));
1265        let b = g.add(Expr::Num(7));
1266        let ab = g.add(Expr::Mul(a, b));
1267        let ba = g.add(Expr::Mul(b, a));
1268        g.apply_rules();
1269        assert!(g.equiv(ab, ba), "Mul(a, b) should equal Mul(b, a)");
1270    }
1271
1272    // ── Saturation engine ────────────────────────────────────────
1273
1274    #[test]
1275    fn saturate_basic() {
1276        let mut g = EGraph::new();
1277        let a = g.add(Expr::Num(100));
1278        let zero = g.add(Expr::Num(0));
1279        let sum = g.add(Expr::Add(a, zero));
1280        let result = g.saturate(&SaturationConfig::default());
1281        assert!(result.saturated);
1282        assert!(!result.stopped_early);
1283        assert!(result.rewrites > 0);
1284        assert!(g.equiv(sum, a));
1285    }
1286
1287    #[test]
1288    fn saturate_with_node_budget() {
1289        let mut g = EGraph::new();
1290        // Build a graph that could expand via commutativity
1291        for i in 0..50u16 {
1292            let v = g.add(Expr::Var(NodeId(i as u32)));
1293            let n = g.add(Expr::Num(i + 1));
1294            g.add(Expr::Add(v, n));
1295        }
1296        let initial = g.node_count();
1297        let config = SaturationConfig {
1298            node_budget: initial + 10,
1299            iteration_limit: 1000,
1300            time_limit_us: 0,
1301            memory_limit: 0,
1302        };
1303        let result = g.saturate(&config);
1304        assert!(result.stopped_early, "should stop due to node budget");
1305        assert_eq!(result.guard, GuardTriggered::NodeBudget);
1306    }
1307
1308    #[test]
1309    fn saturate_with_iteration_limit() {
1310        let mut g = EGraph::new();
1311        let a = g.add(Expr::Num(10));
1312        let b = g.add(Expr::Num(20));
1313        let _sum = g.add(Expr::Add(a, b));
1314        let config = SaturationConfig {
1315            node_budget: 10_000,
1316            iteration_limit: 1,
1317            time_limit_us: 0,
1318            memory_limit: 0,
1319        };
1320        let result = g.saturate(&config);
1321        assert!(result.iterations <= 1);
1322        assert_eq!(result.guard, GuardTriggered::IterationLimit);
1323    }
1324
1325    #[test]
1326    fn saturate_with_memory_limit() {
1327        let mut g = EGraph::new();
1328        for i in 0..200u16 {
1329            let v = g.add(Expr::Var(NodeId(i as u32)));
1330            let n = g.add(Expr::Num(i + 1));
1331            g.add(Expr::Add(v, n));
1332        }
1333        let config = SaturationConfig {
1334            node_budget: 100_000,
1335            iteration_limit: 1000,
1336            time_limit_us: 0,
1337            memory_limit: 1, // 1 byte — will trigger immediately
1338        };
1339        let result = g.saturate(&config);
1340        assert!(result.stopped_early);
1341        assert_eq!(result.guard, GuardTriggered::Memory);
1342    }
1343
1344    #[test]
1345    fn saturate_guard_none_on_completion() {
1346        let mut g = EGraph::new();
1347        let a = g.add(Expr::Num(100));
1348        let zero = g.add(Expr::Num(0));
1349        let _sum = g.add(Expr::Add(a, zero));
1350        let result = g.saturate(&SaturationConfig::default());
1351        assert!(result.saturated);
1352        assert_eq!(result.guard, GuardTriggered::None);
1353        assert!(result.time_us > 0 || result.iterations > 0);
1354        assert!(result.memory_bytes > 0);
1355    }
1356
1357    #[test]
1358    fn saturate_result_has_timing() {
1359        let mut g = EGraph::new();
1360        for i in 0..100u16 {
1361            let v = g.add(Expr::Var(NodeId(i as u32)));
1362            let zero = g.add(Expr::Num(0));
1363            g.add(Expr::Add(v, zero));
1364        }
1365        let result = g.saturate(&SaturationConfig::default());
1366        // time_us can be 0 if very fast, but memory_bytes should be nonzero
1367        assert!(result.memory_bytes > 0);
1368        assert!(result.node_count > 0);
1369    }
1370
1371    #[test]
1372    fn saturate_memory_bounded() {
1373        let mut g = EGraph::new();
1374        for i in 0..500u16 {
1375            let v = g.add(Expr::Var(NodeId(i as u32)));
1376            let zero = g.add(Expr::Num(0));
1377            g.add(Expr::Add(v, zero));
1378        }
1379        g.saturate(&SaturationConfig::default());
1380        let mem = g.memory_usage();
1381        assert!(mem < 10 * 1024 * 1024, "memory {} exceeds 10MB budget", mem);
1382    }
1383
1384    // ── solve_layout ─────────────────────────────────────────────
1385
1386    #[test]
1387    fn solve_fixed_constraints() {
1388        let constraints = vec![
1389            crate::Constraint::Fixed(50),
1390            crate::Constraint::Fixed(100),
1391            crate::Constraint::Fixed(50),
1392        ];
1393        let sizes = solve_layout_default(&constraints, 200);
1394        assert_eq!(sizes, vec![50, 100, 50]);
1395    }
1396
1397    #[test]
1398    fn solve_percentage_constraints() {
1399        let constraints = vec![
1400            crate::Constraint::Percentage(25.0),
1401            crate::Constraint::Percentage(75.0),
1402        ];
1403        let sizes = solve_layout_default(&constraints, 200);
1404        assert_eq!(sizes, vec![50, 150]);
1405    }
1406
1407    #[test]
1408    fn solve_ratio_constraints() {
1409        let constraints = vec![
1410            crate::Constraint::Ratio(1, 3),
1411            crate::Constraint::Ratio(2, 3),
1412        ];
1413        let sizes = solve_layout_default(&constraints, 300);
1414        assert_eq!(sizes, vec![100, 200]);
1415    }
1416
1417    #[test]
1418    fn solve_fill_constraint() {
1419        let constraints = vec![crate::Constraint::Fill];
1420        let sizes = solve_layout_default(&constraints, 120);
1421        // Fill returns total
1422        assert_eq!(sizes, vec![120]);
1423    }
1424
1425    #[test]
1426    fn solve_mixed_constraints() {
1427        let constraints = vec![
1428            crate::Constraint::Fixed(30),
1429            crate::Constraint::Percentage(50.0),
1430            crate::Constraint::Ratio(1, 4),
1431        ];
1432        let sizes = solve_layout_default(&constraints, 200);
1433        assert_eq!(sizes, vec![30, 100, 50]);
1434    }
1435
1436    #[test]
1437    fn solve_empty_constraints() {
1438        let constraints: Vec<crate::Constraint> = vec![];
1439        let sizes = solve_layout_default(&constraints, 200);
1440        assert!(sizes.is_empty());
1441    }
1442
1443    #[test]
1444    fn solve_returns_saturation_result() {
1445        let constraints = vec![crate::Constraint::Fixed(50)];
1446        let (sizes, result) = solve_layout(&constraints, 200, &SaturationConfig::default());
1447        assert_eq!(sizes, vec![50]);
1448        assert!(result.saturated);
1449    }
1450
1451    #[test]
1452    fn solve_500_widgets() {
1453        let constraints: Vec<_> = (0..500)
1454            .map(|i| crate::Constraint::Fixed(i as u16 % 100))
1455            .collect();
1456        let config = SaturationConfig::default();
1457        let (sizes, result) = solve_layout(&constraints, 1000, &config);
1458        assert_eq!(sizes.len(), 500);
1459        // Verify correctness
1460        for (i, &s) in sizes.iter().enumerate() {
1461            assert_eq!(s, i as u16 % 100);
1462        }
1463        assert!(!result.stopped_early || result.node_count <= config.node_budget + 500);
1464    }
1465
1466    #[test]
1467    fn solve_fit_content_bounded() {
1468        let constraints = vec![crate::Constraint::FitContentBounded { min: 10, max: 50 }];
1469        let sizes = solve_layout_default(&constraints, 200);
1470        // FitContentBounded(10, 50) with total=200 creates Clamp(10, 50, 200)
1471        // Since the clamp has concrete bounds, extraction yields the Clamp node
1472        // which falls back to total. Let's verify bounds are respected.
1473        assert_eq!(sizes.len(), 1);
1474    }
1475
1476    // ── Guard-specific tests ─────────────────────────────────────
1477
1478    #[test]
1479    fn config_default_values() {
1480        let config = SaturationConfig::default();
1481        assert_eq!(config.node_budget, 10_000);
1482        assert_eq!(config.time_limit_us, 5_000);
1483        assert_eq!(config.iteration_limit, 100);
1484        assert_eq!(config.memory_limit, 10 * 1024 * 1024);
1485    }
1486
1487    #[test]
1488    fn from_env_returns_valid_config() {
1489        // from_env reads env vars if set, otherwise defaults
1490        let config = SaturationConfig::from_env();
1491        assert!(config.node_budget > 0);
1492        assert!(config.iteration_limit > 0);
1493    }
1494
1495    // ── Fuzz-like guard test ─────────────────────────────────────
1496
1497    #[test]
1498    fn random_constraints_never_oom_or_hang() {
1499        // Simulate diverse constraint combos — guards must always hold
1500        let constraint_sets: Vec<Vec<crate::Constraint>> = vec![
1501            (0..1000)
1502                .map(|i| crate::Constraint::Fixed(i as u16))
1503                .collect(),
1504            (0..500).map(|_| crate::Constraint::Fill).collect(),
1505            (0..200)
1506                .map(|i| crate::Constraint::Percentage(i as f32 * 0.5))
1507                .collect(),
1508            (0..100)
1509                .map(|i| crate::Constraint::Ratio(i + 1, 100))
1510                .collect(),
1511            (0..300).map(|i| crate::Constraint::Min(i as u16)).collect(),
1512            (0..300).map(|i| crate::Constraint::Max(i as u16)).collect(),
1513            // Pathological: all FitContentBounded
1514            (0..500)
1515                .map(|i| crate::Constraint::FitContentBounded {
1516                    min: i as u16,
1517                    max: i as u16 + 100,
1518                })
1519                .collect(),
1520        ];
1521
1522        let config = SaturationConfig {
1523            node_budget: 10_000,
1524            iteration_limit: 100,
1525            time_limit_us: 50_000, // 50ms generous limit for test
1526            memory_limit: 10 * 1024 * 1024,
1527        };
1528
1529        for constraints in &constraint_sets {
1530            let (sizes, result) = solve_layout(constraints, 1000, &config);
1531            assert_eq!(sizes.len(), constraints.len());
1532            assert!(
1533                result.memory_bytes <= config.memory_limit + 1024 * 1024,
1534                "memory {} exceeded limit + margin for {:?}",
1535                result.memory_bytes,
1536                result.guard,
1537            );
1538        }
1539    }
1540
1541    // ── Fuzz: 1000 random constraint sets ────────────────────────
1542
1543    #[test]
1544    fn fuzz_1000_random_constraint_sets() {
1545        // Simple deterministic PRNG (xorshift32)
1546        let mut seed: u32 = 42;
1547        let mut rng = || -> u32 {
1548            seed ^= seed << 13;
1549            seed ^= seed >> 17;
1550            seed ^= seed << 5;
1551            seed
1552        };
1553
1554        let config = SaturationConfig {
1555            node_budget: 10_000,
1556            iteration_limit: 100,
1557            time_limit_us: 50_000,
1558            memory_limit: 10 * 1024 * 1024,
1559        };
1560
1561        for _ in 0..1000 {
1562            let count = (rng() % 50 + 1) as usize;
1563            let total = (rng() % 500 + 1) as u16;
1564
1565            let constraints: Vec<_> = (0..count)
1566                .map(|_| {
1567                    let kind = rng() % 7;
1568                    match kind {
1569                        0 => crate::Constraint::Fixed((rng() % (total as u32 + 1)) as u16),
1570                        1 => crate::Constraint::Percentage((rng() % 101) as f32),
1571                        2 => crate::Constraint::Min((rng() % (total as u32 + 1)) as u16),
1572                        3 => crate::Constraint::Max((rng() % (total as u32 + 1)) as u16),
1573                        4 => {
1574                            let den = rng() % 10 + 1;
1575                            let num = rng() % (den + 1);
1576                            crate::Constraint::Ratio(num, den)
1577                        }
1578                        5 => crate::Constraint::Fill,
1579                        _ => {
1580                            let min = (rng() % (total as u32 + 1)) as u16;
1581                            let max = min.saturating_add((rng() % 100) as u16);
1582                            crate::Constraint::FitContentBounded { min, max }
1583                        }
1584                    }
1585                })
1586                .collect();
1587
1588            let (sizes, result) = solve_layout(&constraints, total, &config);
1589
1590            // Never OOM or hang
1591            assert_eq!(sizes.len(), constraints.len());
1592            assert!(result.memory_bytes < config.memory_limit + 2 * 1024 * 1024);
1593        }
1594    }
1595
1596    // ── Deterministic execution ──────────────────────────────────
1597
1598    #[test]
1599    fn deterministic_across_runs() {
1600        // Same input must always produce same output
1601        let constraints = vec![
1602            crate::Constraint::Fixed(30),
1603            crate::Constraint::Percentage(50.0),
1604            crate::Constraint::Ratio(1, 4),
1605            crate::Constraint::Fill,
1606            crate::Constraint::Min(10),
1607            crate::Constraint::Max(80),
1608        ];
1609
1610        let config = SaturationConfig {
1611            node_budget: 10_000,
1612            iteration_limit: 100,
1613            time_limit_us: 0, // no time limit for determinism
1614            memory_limit: 0,
1615        };
1616
1617        let (sizes1, r1) = solve_layout(&constraints, 200, &config);
1618        let (sizes2, r2) = solve_layout(&constraints, 200, &config);
1619
1620        assert_eq!(sizes1, sizes2, "sizes must be deterministic");
1621        assert_eq!(r1.rewrites, r2.rewrites, "rewrites must be deterministic");
1622        assert_eq!(
1623            r1.iterations, r2.iterations,
1624            "iterations must be deterministic"
1625        );
1626        assert_eq!(
1627            r1.node_count, r2.node_count,
1628            "node_count must be deterministic"
1629        );
1630        assert_eq!(r1.guard, r2.guard, "guard must be deterministic");
1631    }
1632
1633    // ── JSONL evidence logging ───────────────────────────────────
1634
1635    #[test]
1636    fn evidence_record_from_result() {
1637        let constraints = vec![
1638            crate::Constraint::Fixed(50),
1639            crate::Constraint::Percentage(50.0),
1640        ];
1641        let (_, result) = solve_layout(&constraints, 200, &SaturationConfig::default());
1642        let record = EvidenceRecord::from_result("test_case", 2, 200, &result);
1643
1644        assert_eq!(record.test_name, "test_case");
1645        assert_eq!(record.constraint_count, 2);
1646        assert_eq!(record.total_space, 200);
1647        assert!(record.nodes_at_completion > 0);
1648    }
1649
1650    #[test]
1651    fn evidence_record_to_jsonl() {
1652        let result = SaturationResult {
1653            rewrites: 5,
1654            iterations: 3,
1655            saturated: true,
1656            stopped_early: false,
1657            node_count: 42,
1658            guard: GuardTriggered::None,
1659            time_us: 1234,
1660            memory_bytes: 8192,
1661        };
1662        let record = EvidenceRecord::from_result("demo", 10, 200, &result);
1663        let jsonl = record.to_jsonl();
1664
1665        assert!(jsonl.starts_with('{'));
1666        assert!(jsonl.ends_with('}'));
1667        assert!(jsonl.contains("\"test\":\"demo\""));
1668        assert!(jsonl.contains("\"constraints\":10"));
1669        assert!(jsonl.contains("\"nodes\":42"));
1670        assert!(jsonl.contains("\"saturated\":true"));
1671        assert!(jsonl.contains("\"guard\":\"None\""));
1672    }
1673
1674    #[test]
1675    fn evidence_records_for_all_guards() {
1676        // Verify JSONL output for each guard type
1677        for guard in [
1678            GuardTriggered::None,
1679            GuardTriggered::NodeBudget,
1680            GuardTriggered::Timeout,
1681            GuardTriggered::Memory,
1682            GuardTriggered::IterationLimit,
1683        ] {
1684            let result = SaturationResult {
1685                rewrites: 0,
1686                iterations: 1,
1687                saturated: guard == GuardTriggered::None,
1688                stopped_early: guard != GuardTriggered::None,
1689                node_count: 10,
1690                guard,
1691                time_us: 100,
1692                memory_bytes: 1024,
1693            };
1694            let record = EvidenceRecord::from_result("guard_test", 1, 100, &result);
1695            let jsonl = record.to_jsonl();
1696            assert!(jsonl.contains(&format!("{guard:?}")));
1697        }
1698    }
1699
1700    // ── Empty and edge cases ─────────────────────────────────────
1701
1702    #[test]
1703    fn empty_input_produces_empty_layout() {
1704        let (sizes, result) = solve_layout(&[], 200, &SaturationConfig::default());
1705        assert!(sizes.is_empty());
1706        assert!(result.saturated);
1707    }
1708
1709    #[test]
1710    fn single_constraint_no_rewriting_needed() {
1711        let (sizes, result) = solve_layout(
1712            &[crate::Constraint::Fixed(42)],
1713            200,
1714            &SaturationConfig::default(),
1715        );
1716        assert_eq!(sizes, vec![42]);
1717        assert!(result.saturated);
1718        assert_eq!(result.guard, GuardTriggered::None);
1719    }
1720
1721    #[test]
1722    fn zero_total_space() {
1723        let constraints = vec![
1724            crate::Constraint::Fixed(50),
1725            crate::Constraint::Percentage(50.0),
1726            crate::Constraint::Fill,
1727        ];
1728        let (sizes, _) = solve_layout(&constraints, 0, &SaturationConfig::default());
1729        assert_eq!(sizes.len(), 3);
1730        assert_eq!(sizes[0], 50); // Fixed is absolute
1731        assert_eq!(sizes[1], 0); // 50% of 0
1732    }
1733}