Skip to main content

conjure_cp_core/rule_engine/
rewrite.rs

1use super::{
2    AtomKind, RewriteError, RuleFailureInvalidation, RulePrefilter, RuleSet,
3    resolve_rules::RuleData,
4};
5use crate::{
6    Model,
7    ast::{
8        Atom, Expression as Expr, ExpressionArena, ExpressionNodeId, Metadata, Name,
9        discriminant_from_value, finish_root_evaluator_normalisation, normalise_evaluator_local,
10        normalise_root_constraint_deep,
11    },
12    bug,
13    domain_tightening::tighten_domains_from_constraints,
14    objective::introduce_objective_auxiliary,
15    rule_engine::{
16        get_rules_grouped,
17        rewriter_common::{
18            NoopObserver, RuleAttemptObserver, RuleAttemptStatus, RuleResult,
19            VariableDeclarationSnapshot, choose_rule_result_index, log_rule_application,
20            root_variable_snapshot_for_default_trace, snapshot_symbols_after_effect,
21            try_rewrite_value_letting_once,
22        },
23    },
24    settings::{
25        RewriteConfig, Rewriter, default_rule_trace_enabled, rule_attempt_trace_enabled,
26        rule_trace_enabled, set_current_rewriter,
27    },
28    stats::RewriterStats,
29};
30
31use itertools::Itertools;
32use std::{
33    cmp::Ordering,
34    collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet},
35    fmt::Write as FmtWrite,
36    fs::{self, OpenOptions},
37    io::Write as IoWrite,
38    path::PathBuf,
39    time::Instant,
40};
41use tracing::trace;
42
43// Rewriter selection invariant:
44//
45// 1. Higher rule priority wins.
46// 2. Within one priority, an expression is tried before its descendants.
47//
48// Consequently, if A contains B and both are rewriteable at the same priority, A must be selected
49// first. If A rewrites, old descendants such as B are not considered; descendants are only
50// scheduled when no enclosing expression rewrites first. The rewriter does not require old full-scan
51// preorder between unrelated sibling subtrees.
52// This is a semantic requirement of the rewriter, not merely a scheduling optimisation.
53//
54// Rule side effects:
55//
56// A selected rule always replaces the focused expression. It may also append top-level constraints,
57// append CNF clauses, add symbol declarations, or change existing symbol declarations (for example
58// by tightening a domain). Rewriting a value-letting surface writes the replacement expression back
59// to that symbol declaration. Symbol changes invalidate rule-application context caches; changed
60// declarations and CNF effects may require rebuilding rewrite surfaces from the model.
61
62#[cfg(debug_assertions)]
63use crate::ast::assertions::debug_assert_model_well_formed;
64
65type ApplicableRule<'a, CtxFnType> = (
66    RuleResult<'a>,
67    usize,
68    Expr,
69    CtxFnType,
70    Option<VariableDeclarationSnapshot>,
71);
72
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
74enum ScheduledMode {
75    /// Check only this node at the scheduled rule level.
76    CheckNode,
77    /// Traverse this subtree at the scheduled level and carry it forward if it survives.
78    TraverseSubtreeRoot,
79    /// Traverse this descendant at the current level only; the root carries future levels.
80    TraverseSubtreeDescendant,
81}
82
83impl ScheduledMode {
84    fn includes(self, other: Self) -> bool {
85        matches!(
86            (self, other),
87            (ScheduledMode::TraverseSubtreeRoot, ScheduledMode::CheckNode)
88                | (
89                    ScheduledMode::TraverseSubtreeRoot,
90                    ScheduledMode::TraverseSubtreeRoot
91                )
92                | (
93                    ScheduledMode::TraverseSubtreeRoot,
94                    ScheduledMode::TraverseSubtreeDescendant
95                )
96                | (
97                    ScheduledMode::TraverseSubtreeDescendant,
98                    ScheduledMode::CheckNode
99                )
100                | (
101                    ScheduledMode::TraverseSubtreeDescendant,
102                    ScheduledMode::TraverseSubtreeDescendant
103                )
104                | (ScheduledMode::CheckNode, ScheduledMode::CheckNode)
105        )
106    }
107
108    fn descends_on_failure(self) -> bool {
109        matches!(
110            self,
111            ScheduledMode::TraverseSubtreeRoot | ScheduledMode::TraverseSubtreeDescendant
112        )
113    }
114
115    fn next_self_mode(self) -> Option<Self> {
116        match self {
117            ScheduledMode::CheckNode => Some(ScheduledMode::CheckNode),
118            ScheduledMode::TraverseSubtreeRoot => Some(ScheduledMode::TraverseSubtreeRoot),
119            ScheduledMode::TraverseSubtreeDescendant => None,
120        }
121    }
122
123    fn advances_as_subtree(self) -> bool {
124        matches!(self, ScheduledMode::TraverseSubtreeRoot)
125    }
126}
127
128#[derive(Default, Debug)]
129struct WorklistModeCounts {
130    check_node: usize,
131    traverse_subtree: usize,
132}
133
134impl WorklistModeCounts {
135    fn increment(&mut self, mode: ScheduledMode) {
136        self.add(mode, 1);
137    }
138
139    fn add(&mut self, mode: ScheduledMode, value: usize) {
140        match mode {
141            ScheduledMode::CheckNode => self.check_node += value,
142            ScheduledMode::TraverseSubtreeRoot | ScheduledMode::TraverseSubtreeDescendant => {
143                self.traverse_subtree += value
144            }
145        }
146    }
147}
148
149#[derive(Clone, Copy, Debug)]
150enum WorklistStaleReason {
151    ModeMismatch,
152    MissingSurface,
153    InactiveSurface,
154    UnreachableNode,
155    GenerationMismatch,
156}
157
158#[derive(Default, Debug)]
159struct WorklistStaleReasonCounts {
160    mode_mismatch: WorklistModeCounts,
161    missing_surface: WorklistModeCounts,
162    inactive_surface: WorklistModeCounts,
163    unreachable_node: WorklistModeCounts,
164    generation_mismatch: WorklistModeCounts,
165}
166
167impl WorklistStaleReasonCounts {
168    fn increment(&mut self, reason: WorklistStaleReason, mode: ScheduledMode) {
169        match reason {
170            WorklistStaleReason::ModeMismatch => self.mode_mismatch.increment(mode),
171            WorklistStaleReason::MissingSurface => self.missing_surface.increment(mode),
172            WorklistStaleReason::InactiveSurface => self.inactive_surface.increment(mode),
173            WorklistStaleReason::UnreachableNode => self.unreachable_node.increment(mode),
174            WorklistStaleReason::GenerationMismatch => self.generation_mismatch.increment(mode),
175        }
176    }
177}
178
179#[derive(Default)]
180struct DirtyTrace {
181    enabled: bool,
182    destination: DirtyTraceDestination,
183    passes: usize,
184    priority_scans: usize,
185    expression_visits: usize,
186    attempted_expressions: usize,
187    rule_attempts: usize,
188    rewrites: usize,
189    value_letting_rewrites: usize,
190    side_effects_kept_in_arena: usize,
191    replacement_subtree_clears: usize,
192    worklist_enqueues: usize,
193    worklist_pops: usize,
194    worklist_stale_pops: usize,
195    worklist_enqueues_by_mode: WorklistModeCounts,
196    worklist_pops_by_mode: WorklistModeCounts,
197    worklist_stale_pops_by_mode: WorklistModeCounts,
198    worklist_stale_pops_by_reason: WorklistStaleReasonCounts,
199    worklist_no_candidate_pops_by_mode: WorklistModeCounts,
200    worklist_rule_attempt_pops_by_mode: WorklistModeCounts,
201    worklist_child_descents_by_mode: WorklistModeCounts,
202    rule_attempts_by_priority: BTreeMap<u16, usize>,
203    rule_attempts_by_rule: BTreeMap<String, usize>,
204    rewrites_by_rule: BTreeMap<String, usize>,
205    side_effect_rewrites_by_rule: BTreeMap<String, usize>,
206}
207
208#[derive(Default, Debug, PartialEq, Eq)]
209enum DirtyTraceDestination {
210    #[default]
211    Stderr,
212    File(PathBuf),
213    Directory(PathBuf),
214}
215
216impl DirtyTrace {
217    fn from_env() -> Self {
218        let Some(destination) = std::env::var_os("CONJURE_DIRTY_TRACE") else {
219            return Self::default();
220        };
221
222        Self {
223            enabled: true,
224            destination: dirty_trace_destination_from_env_value(destination),
225            ..Self::default()
226        }
227    }
228
229    fn record_rewrite(&mut self, rule_name: &str, side_effects: bool) {
230        if !self.enabled {
231            return;
232        }
233        self.rewrites += 1;
234        *self
235            .rewrites_by_rule
236            .entry(rule_name.to_owned())
237            .or_default() += 1;
238        if side_effects {
239            *self
240                .side_effect_rewrites_by_rule
241                .entry(rule_name.to_owned())
242                .or_default() += 1;
243        }
244    }
245
246    fn record_side_effect_kept_in_arena(&mut self) {
247        if !self.enabled {
248            return;
249        }
250        self.side_effects_kept_in_arena += 1;
251    }
252
253    fn record_rule_attempt(&mut self, priority: u16, rule_name: &str) {
254        self.rule_attempts += 1;
255        if !self.enabled {
256            return;
257        }
258        *self.rule_attempts_by_priority.entry(priority).or_default() += 1;
259        *self
260            .rule_attempts_by_rule
261            .entry(rule_name.to_owned())
262            .or_default() += 1;
263    }
264
265    fn record_worklist_enqueue(&mut self, mode: ScheduledMode) {
266        if !self.enabled {
267            return;
268        }
269        self.worklist_enqueues += 1;
270        self.worklist_enqueues_by_mode.increment(mode);
271    }
272
273    fn record_worklist_pop(&mut self, mode: ScheduledMode) {
274        if !self.enabled {
275            return;
276        }
277        self.worklist_pops += 1;
278        self.worklist_pops_by_mode.increment(mode);
279    }
280
281    fn record_worklist_stale_pop(&mut self, mode: ScheduledMode, reason: WorklistStaleReason) {
282        if !self.enabled {
283            return;
284        }
285        self.worklist_stale_pops += 1;
286        self.worklist_stale_pops_by_mode.increment(mode);
287        self.worklist_stale_pops_by_reason.increment(reason, mode);
288    }
289
290    fn record_worklist_no_candidate_pop(&mut self, mode: ScheduledMode) {
291        if !self.enabled {
292            return;
293        }
294        self.worklist_no_candidate_pops_by_mode.increment(mode);
295    }
296
297    fn record_worklist_rule_attempt_pop(&mut self, mode: ScheduledMode) {
298        if !self.enabled {
299            return;
300        }
301        self.worklist_rule_attempt_pops_by_mode.increment(mode);
302    }
303
304    fn record_worklist_child_descent(&mut self, mode: ScheduledMode, children: usize) {
305        if !self.enabled {
306            return;
307        }
308        self.worklist_child_descents_by_mode.add(mode, children);
309    }
310
311    fn finish(&self, stats: &RewriterStats) {
312        if !self.enabled {
313            return;
314        }
315
316        let mut output = String::new();
317        writeln!(output, "[dirty-trace] passes={}", self.passes).unwrap();
318        writeln!(
319            output,
320            "[dirty-trace] priority_scans={}",
321            self.priority_scans
322        )
323        .unwrap();
324        writeln!(
325            output,
326            "[dirty-trace] expression_visits={}",
327            self.expression_visits
328        )
329        .unwrap();
330        writeln!(
331            output,
332            "[dirty-trace] attempted_expressions={}",
333            self.attempted_expressions
334        )
335        .unwrap();
336        writeln!(
337            output,
338            "[dirty-trace] rule_attempts_counted={}",
339            self.rule_attempts
340        )
341        .unwrap();
342        writeln!(
343            output,
344            "[dirty-trace] stats_rule_attempts={}",
345            stats.rewriter_rule_application_attempts.unwrap_or(0)
346        )
347        .unwrap();
348        writeln!(output, "[dirty-trace] rewrites={}", self.rewrites).unwrap();
349        writeln!(
350            output,
351            "[dirty-trace] value_letting_rewrites={}",
352            self.value_letting_rewrites
353        )
354        .unwrap();
355        writeln!(
356            output,
357            "[dirty-trace] side_effects_kept_in_arena={}",
358            self.side_effects_kept_in_arena
359        )
360        .unwrap();
361        writeln!(
362            output,
363            "[dirty-trace] replacement_subtree_clears={}",
364            self.replacement_subtree_clears
365        )
366        .unwrap();
367        writeln!(
368            output,
369            "[dirty-trace] rule_attempts_by_priority={:?}",
370            self.rule_attempts_by_priority
371        )
372        .unwrap();
373        writeln!(
374            output,
375            "[dirty-trace] rule_attempts_by_rule={:?}",
376            self.rule_attempts_by_rule
377        )
378        .unwrap();
379        writeln!(
380            output,
381            "[dirty-trace] rewrites_by_rule={:?}",
382            self.rewrites_by_rule
383        )
384        .unwrap();
385        writeln!(
386            output,
387            "[dirty-trace] side_effect_rewrites_by_rule={:?}",
388            self.side_effect_rewrites_by_rule
389        )
390        .unwrap();
391        writeln!(
392            output,
393            "[dirty-trace] worklist_enqueues={}",
394            self.worklist_enqueues
395        )
396        .unwrap();
397        writeln!(
398            output,
399            "[dirty-trace] worklist_enqueues_by_mode={:?}",
400            self.worklist_enqueues_by_mode
401        )
402        .unwrap();
403        writeln!(output, "[dirty-trace] worklist_pops={}", self.worklist_pops).unwrap();
404        writeln!(
405            output,
406            "[dirty-trace] worklist_pops_by_mode={:?}",
407            self.worklist_pops_by_mode
408        )
409        .unwrap();
410        writeln!(
411            output,
412            "[dirty-trace] worklist_stale_pops={}",
413            self.worklist_stale_pops
414        )
415        .unwrap();
416        writeln!(
417            output,
418            "[dirty-trace] worklist_stale_pops_by_mode={:?}",
419            self.worklist_stale_pops_by_mode
420        )
421        .unwrap();
422        writeln!(
423            output,
424            "[dirty-trace] worklist_stale_pops_by_reason={:?}",
425            self.worklist_stale_pops_by_reason
426        )
427        .unwrap();
428        writeln!(
429            output,
430            "[dirty-trace] worklist_no_candidate_pops_by_mode={:?}",
431            self.worklist_no_candidate_pops_by_mode
432        )
433        .unwrap();
434        writeln!(
435            output,
436            "[dirty-trace] worklist_rule_attempt_pops_by_mode={:?}",
437            self.worklist_rule_attempt_pops_by_mode
438        )
439        .unwrap();
440        writeln!(
441            output,
442            "[dirty-trace] worklist_child_descents_by_mode={:?}",
443            self.worklist_child_descents_by_mode
444        )
445        .unwrap();
446
447        self.write_output(&output);
448    }
449
450    fn write_output(&self, output: &str) {
451        let path = match &self.destination {
452            DirtyTraceDestination::Stderr => {
453                eprint!("{output}");
454                return;
455            }
456            DirtyTraceDestination::File(path) => path.clone(),
457            DirtyTraceDestination::Directory(directory) => directory.join(format!(
458                "dirty-trace-{}.txt",
459                current_test_name_for_dirty_trace()
460            )),
461        };
462
463        if let Some(parent) = path
464            .parent()
465            .filter(|parent| !parent.as_os_str().is_empty())
466            && let Err(error) = fs::create_dir_all(parent)
467        {
468            eprintln!(
469                "[dirty-trace] failed to create trace directory {}: {error}",
470                parent.display()
471            );
472            eprint!("{output}");
473            return;
474        }
475
476        match OpenOptions::new().create(true).append(true).open(&path) {
477            Ok(mut file) => {
478                if let Err(error) = file.write_all(output.as_bytes()) {
479                    eprintln!(
480                        "[dirty-trace] failed to write trace file {}: {error}",
481                        path.display()
482                    );
483                    eprint!("{output}");
484                }
485            }
486            Err(error) => {
487                eprintln!(
488                    "[dirty-trace] failed to open trace file {}: {error}",
489                    path.display()
490                );
491                eprint!("{output}");
492            }
493        }
494    }
495}
496
497fn dirty_trace_destination_from_env_value(
498    destination: std::ffi::OsString,
499) -> DirtyTraceDestination {
500    if destination.is_empty()
501        || destination == "1"
502        || destination
503            .to_str()
504            .is_some_and(|value| value.eq_ignore_ascii_case("true"))
505    {
506        return DirtyTraceDestination::Stderr;
507    }
508
509    let path = PathBuf::from(destination);
510    if path.is_file() {
511        return DirtyTraceDestination::File(path);
512    }
513
514    // Treat bare paths as directories to support a single `nextest` run with
515    // `CONJURE_DIRTY_TRACE=/abs/trace-dir`; each test then writes its own file.
516    // Explicit file output remains available for paths that look like filenames.
517    if path.is_dir() || path.extension().is_none() {
518        DirtyTraceDestination::Directory(path)
519    } else {
520        DirtyTraceDestination::File(path)
521    }
522}
523
524fn current_test_name_for_dirty_trace() -> String {
525    let current_thread = std::thread::current();
526    let name = current_thread
527        .name()
528        .map(ToOwned::to_owned)
529        .unwrap_or_else(|| format!("pid-{}", std::process::id()));
530    sanitize_dirty_trace_filename(&name)
531}
532
533fn sanitize_dirty_trace_filename(name: &str) -> String {
534    let sanitized = name
535        .chars()
536        .map(|character| {
537            if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') {
538                character
539            } else {
540                '-'
541            }
542        })
543        .collect::<String>();
544
545    let sanitized = sanitized.trim_matches('-');
546    if sanitized.is_empty() {
547        "unknown".to_string()
548    } else {
549        sanitized.to_string()
550    }
551}
552
553#[derive(Clone)]
554struct RuleGroup<'a> {
555    priority: u16,
556    rules: Vec<RuleData<'a>>,
557    /// Indexed by discriminant id for O(1) lookup of simple variant prefilters.
558    rules_by_discriminant: Vec<Option<Vec<RuleData<'a>>>>,
559    universal_rules: Vec<RuleData<'a>>,
560    has_non_discriminant_filters: bool,
561}
562
563enum CandidateRules<'group, 'rules> {
564    Slice(std::slice::Iter<'group, RuleData<'rules>>),
565    Filtered {
566        iter: std::slice::Iter<'group, RuleData<'rules>>,
567        expr: &'group Expr,
568        arena_node: Option<(&'group ExpressionArena, ExpressionNodeId)>,
569        include_universal: bool,
570    },
571}
572
573impl<'group, 'rules> Iterator for CandidateRules<'group, 'rules> {
574    type Item = &'group RuleData<'rules>;
575
576    fn next(&mut self) -> Option<Self::Item> {
577        match self {
578            CandidateRules::Slice(iter) => iter.next(),
579            CandidateRules::Filtered {
580                iter,
581                expr,
582                arena_node,
583                include_universal,
584            } => loop {
585                let rule_data = iter.next()?;
586                if rule_matches_specific_prefilter(rule_data, expr, *arena_node)
587                    || (*include_universal && rule_is_universal(rule_data))
588                {
589                    return Some(rule_data);
590                }
591            },
592        }
593    }
594}
595
596impl<'a> RuleGroup<'a> {
597    fn new(priority: u16, rules: Vec<RuleData<'a>>) -> Self {
598        let discriminants = rules
599            .iter()
600            .filter_map(|rd| rd.rule.prefilters)
601            .flatten()
602            .filter_map(|prefilter| match prefilter {
603                RulePrefilter::Variant(discriminant) => Some(*discriminant),
604                RulePrefilter::Child { .. }
605                | RulePrefilter::VariantChild { .. }
606                | RulePrefilter::Atom(_)
607                | RulePrefilter::ChildAtom(_) => None,
608            })
609            .collect_vec();
610
611        let mut rules_by_discriminant = Vec::new();
612        if let Some(max_discriminant) = discriminants.iter().copied().max() {
613            rules_by_discriminant.resize_with(max_discriminant + 1, || None);
614        }
615
616        let target_discriminants = discriminants.into_iter().unique().collect_vec();
617
618        for &discriminant in &target_discriminants {
619            rules_by_discriminant[discriminant] = Some(
620                rules
621                    .iter()
622                    .filter(|rd| {
623                        rule_is_universal(rd) || rule_matches_self_discriminant(rd, discriminant)
624                    })
625                    .cloned()
626                    .collect(),
627            );
628        }
629
630        let universal_rules: Vec<RuleData<'a>> = rules
631            .iter()
632            .filter(|rd| rule_is_universal(rd))
633            .cloned()
634            .collect();
635        let has_non_discriminant_filters = rules.iter().any(|rd| {
636            rd.rule.prefilters.is_some_and(|prefilters| {
637                prefilters
638                    .iter()
639                    .any(|prefilter| !matches!(prefilter, RulePrefilter::Variant(_)))
640            })
641        });
642        Self {
643            priority,
644            rules,
645            rules_by_discriminant,
646            universal_rules,
647            has_non_discriminant_filters,
648        }
649    }
650
651    fn candidates<'group>(
652        &'group self,
653        config: RewriteConfig,
654        expr: &'group Expr,
655    ) -> CandidateRules<'group, 'a> {
656        self.candidates_with_arena(config, expr, None)
657    }
658
659    fn candidates_at_node<'group>(
660        &'group self,
661        config: RewriteConfig,
662        arena: &'group ExpressionArena,
663        node_id: ExpressionNodeId,
664    ) -> CandidateRules<'group, 'a> {
665        self.candidates_with_arena(config, arena.expression(node_id), Some((arena, node_id)))
666    }
667
668    fn candidates_with_arena<'group>(
669        &'group self,
670        config: RewriteConfig,
671        expr: &'group Expr,
672        arena_node: Option<(&'group ExpressionArena, ExpressionNodeId)>,
673    ) -> CandidateRules<'group, 'a> {
674        if !config.prefilter {
675            return CandidateRules::Slice(self.rules.iter());
676        }
677
678        if self.has_non_discriminant_filters {
679            // Always keep universal rules eligible. A matching child/atom
680            // prefilter on a sibling rule does not mean universals are
681            // inapplicable — suppressing them here previously skipped
682            // `matrix_to_list` on `SafeIndex` when `matrix_ref_to_atom` matched
683            // `* / Atomic` (permMultElementId).
684            return CandidateRules::Filtered {
685                iter: self.rules.iter(),
686                expr,
687                arena_node,
688                include_universal: true,
689            };
690        }
691
692        let discriminant = discriminant_from_value(expr);
693        CandidateRules::Slice(
694            self.rules_by_discriminant
695                .get(discriminant)
696                .and_then(Option::as_deref)
697                .unwrap_or(&self.universal_rules)
698                .iter(),
699        )
700    }
701
702    #[cfg(test)]
703    fn has_candidates(&self, config: RewriteConfig, expr: &Expr) -> bool {
704        self.has_candidates_with_arena(config, expr, None)
705    }
706
707    fn has_candidates_at_node(
708        &self,
709        config: RewriteConfig,
710        arena: &ExpressionArena,
711        node_id: ExpressionNodeId,
712    ) -> bool {
713        self.has_candidates_with_arena(config, arena.expression(node_id), Some((arena, node_id)))
714    }
715
716    fn has_candidates_with_arena(
717        &self,
718        config: RewriteConfig,
719        expr: &Expr,
720        arena_node: Option<(&ExpressionArena, ExpressionNodeId)>,
721    ) -> bool {
722        if !config.prefilter {
723            return !self.rules.is_empty();
724        }
725
726        if self.has_non_discriminant_filters {
727            return !self.universal_rules.is_empty()
728                || self
729                    .rules
730                    .iter()
731                    .any(|rule_data| rule_matches_specific_prefilter(rule_data, expr, arena_node));
732        }
733
734        let discriminant = discriminant_from_value(expr);
735        !self
736            .rules_by_discriminant
737            .get(discriminant)
738            .and_then(Option::as_deref)
739            .unwrap_or(&self.universal_rules)
740            .is_empty()
741    }
742}
743
744#[derive(Clone, Debug)]
745enum RewriteSurfaceKind {
746    Root,
747    ValueLetting { name: Name },
748}
749
750struct RewriteSurface {
751    kind: RewriteSurfaceKind,
752    arena: ExpressionArena,
753    active: bool,
754}
755
756impl RewriteSurface {
757    fn root(arena: ExpressionArena) -> Self {
758        Self {
759            kind: RewriteSurfaceKind::Root,
760            arena,
761            active: true,
762        }
763    }
764
765    fn value_letting(name: Name, expr: Expr) -> Self {
766        Self {
767            kind: RewriteSurfaceKind::ValueLetting { name },
768            arena: ExpressionArena::from_root(expr),
769            active: true,
770        }
771    }
772}
773
774#[derive(Clone, Copy, Debug)]
775struct ScheduledNode {
776    surface: usize,
777    node_id: ExpressionNodeId,
778    generation: u32,
779    mode: ScheduledMode,
780    depth: usize,
781    sequence: u64,
782}
783
784impl PartialEq for ScheduledNode {
785    fn eq(&self, other: &Self) -> bool {
786        self.depth == other.depth && self.sequence == other.sequence
787    }
788}
789
790impl Eq for ScheduledNode {}
791
792impl PartialOrd for ScheduledNode {
793    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
794        Some(self.cmp(other))
795    }
796}
797
798impl Ord for ScheduledNode {
799    fn cmp(&self, other: &Self) -> Ordering {
800        other
801            .depth
802            .cmp(&self.depth)
803            .then_with(|| other.sequence.cmp(&self.sequence))
804    }
805}
806
807#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
808struct ScheduledKey {
809    level: usize,
810    surface: usize,
811    node_id: ExpressionNodeId,
812    generation: u32,
813}
814
815/// Which rule levels can match the subtree rooted at one arena node.
816///
817/// `known_levels` marks the levels already decided for this node's current generation;
818/// `candidate_levels` holds the answer for those. A stale generation resets both.
819#[derive(Clone, Copy, Debug, Default)]
820struct NodeSubtreeCandidateCache {
821    generation: u32,
822    generation_valid: bool,
823    known_levels: u128,
824    candidate_levels: u128,
825}
826
827/// Identifies one failed application of a rule whose applicability depends only on symbols.
828///
829/// `rule` is the address of the `&'static Rule` that failed, which identifies it exactly.
830#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
831struct FailedSymbolRuleKey {
832    surface: usize,
833    node_id: ExpressionNodeId,
834    rule: usize,
835}
836
837#[derive(Clone, Copy)]
838struct WorklistSchedulingContext<'arena, 'groups, 'rules> {
839    arena: &'arena ExpressionArena,
840    surface: usize,
841    rule_groups: &'groups [RuleGroup<'rules>],
842    config: RewriteConfig,
843}
844
845impl<'arena, 'groups, 'rules> WorklistSchedulingContext<'arena, 'groups, 'rules> {
846    fn new(
847        arena: &'arena ExpressionArena,
848        surface: usize,
849        rule_groups: &'groups [RuleGroup<'rules>],
850        config: RewriteConfig,
851    ) -> Self {
852        Self {
853            arena,
854            surface,
855            rule_groups,
856            config,
857        }
858    }
859}
860
861struct WorklistScheduler {
862    // Candidate-level skipping can enqueue a descendant into a future rule level before an
863    // ancestor reaches that same level. Each level is therefore ordered by depth, then insertion
864    // sequence, so the semantic invariant "try an expression before its descendants at the same
865    // priority" does not depend on enqueue timing.
866    queues_by_level: Vec<BinaryHeap<ScheduledNode>>,
867    scheduled: HashMap<ScheduledKey, ScheduledMode>,
868    /// Per-surface, per-arena-slot cache of the rule levels that can match a subtree.
869    ///
870    /// Arena node ids are dense indices, so a flat cache avoids hashing a `(surface, node,
871    /// level, generation)` tuple for every worklist descent. A `u128` covers the current 58 rule
872    /// levels; levels beyond that remain correct and simply run uncached.
873    subtree_candidate_cache: Vec<Vec<NodeSubtreeCandidateCache>>,
874    /// Failed applications whose applicability depends only on the symbol table, keyed by the
875    /// symbol-table revision at which they were attempted.
876    failed_symbol_rules: HashMap<FailedSymbolRuleKey, u64>,
877    /// Arena nodes containing direct references, indexed by declaration name.
878    ///
879    /// Replacement appends fresh arena slots, so old entries can remain and are validated lazily
880    /// at invalidation time. This avoids rescanning every expression surface after each
881    /// representation effect.
882    reference_nodes_by_name: HashMap<Name, HashSet<(usize, ExpressionNodeId)>>,
883    symbol_revision: u64,
884    next_sequence: u64,
885}
886
887/// Ancestors waiting for an evaluator pass, held until the worklist runs out of rule work.
888///
889/// A rewrite normalises its own replacement subtree straight away and records its ancestors here.
890/// Because the same ancestor is only recorded once, a wide `and`/`or`/arithmetic node gets one
891/// evaluator pass after all its changed children are done, instead of one per child.
892#[derive(Default)]
893struct DeferredEvaluatorWork {
894    nodes: HashSet<(usize, ExpressionNodeId)>,
895}
896
897impl DeferredEvaluatorWork {
898    fn defer_ancestors(
899        &mut self,
900        arena: &ExpressionArena,
901        surface: usize,
902        node_id: ExpressionNodeId,
903    ) {
904        let mut current = arena.parent(node_id);
905        while let Some(current_id) = current {
906            self.nodes.insert((surface, current_id));
907            current = arena.parent(current_id);
908        }
909    }
910
911    fn is_empty(&self) -> bool {
912        self.nodes.is_empty()
913    }
914}
915
916/// Ancestors waiting to have their rules re-checked, held until the worklist is about to move on
917/// to a lower priority (or emptied straight away after a change to the model).
918///
919/// Rewriting a child can make a higher-priority rule apply to an expression enclosing it, so those
920/// ancestors have to be re-checked before any lower-priority work runs. Collecting them up while
921/// the current priority finishes means a shared wide root is visited once per priority instead of
922/// once per child -- the shape unrolling a comprehension produces.
923#[derive(Default)]
924struct DeferredAncestorRuleWork {
925    nodes: HashSet<(usize, ExpressionNodeId)>,
926    postponed: Vec<(usize, usize, ExpressionNodeId, ScheduledMode)>,
927    flush_after_level: Option<usize>,
928}
929
930impl DeferredAncestorRuleWork {
931    fn defer_ancestors(
932        &mut self,
933        arena: &ExpressionArena,
934        surface: usize,
935        node_id: ExpressionNodeId,
936        rewrite_level: usize,
937    ) {
938        self.flush_after_level = Some(
939            self.flush_after_level
940                .map_or(rewrite_level, |level| level.min(rewrite_level)),
941        );
942
943        let mut current = arena.parent(node_id);
944        while let Some(current_id) = current {
945            self.nodes.insert((surface, current_id));
946            current = arena.parent(current_id);
947        }
948    }
949
950    fn should_flush_before(&self, next_level: Option<usize>) -> bool {
951        let Some(flush_after_level) = self.flush_after_level else {
952            return false;
953        };
954        next_level.is_none_or(|level| level > flush_after_level)
955    }
956
957    fn contains(&self, surface: usize, node_id: ExpressionNodeId) -> bool {
958        self.nodes.contains(&(surface, node_id))
959    }
960
961    fn postpone(&mut self, item: (usize, usize, ExpressionNodeId, ScheduledMode)) {
962        self.postponed.push(item);
963    }
964
965    /// Brings just the enclosing expressions of a rewrite up to date, stopping short of the root.
966    ///
967    /// Choosing a representation can change which rule should win for a sibling in the same
968    /// comparison, so the expressions around it cannot wait. The surface root is left in the
969    /// batch on purpose: rebuilding its long list of constraints every time an auxiliary appears
970    /// costs time proportional to the whole model, once per auxiliary.
971    fn flush_effect_path_before_root(
972        &mut self,
973        scheduler: &mut WorklistScheduler,
974        surfaces: &mut [RewriteSurface],
975        deferred_evaluators: &mut DeferredEvaluatorWork,
976        surface: usize,
977        node_id: ExpressionNodeId,
978        dirty_trace: &mut DirtyTrace,
979    ) {
980        let Some(rewrite_surface) = surfaces.get(surface) else {
981            return;
982        };
983        let root = rewrite_surface.arena.root();
984        let mut path = Vec::new();
985        let mut current = rewrite_surface.arena.parent(node_id);
986        while let Some(current_id) = current {
987            if current_id == root {
988                break;
989            }
990            path.push(current_id);
991            current = rewrite_surface.arena.parent(current_id);
992        }
993
994        for ancestor in path {
995            self.nodes.remove(&(surface, ancestor));
996            let Some(rewrite_surface) = surfaces.get_mut(surface) else {
997                continue;
998            };
999            if !rewrite_surface.active || !rewrite_surface.arena.is_reachable(ancestor) {
1000                continue;
1001            }
1002
1003            rewrite_surface
1004                .arena
1005                .rebuild_payload_from_children(ancestor);
1006            let evaluator_changed = if matches!(rewrite_surface.kind, RewriteSurfaceKind::Root) {
1007                deferred_evaluators.nodes.remove(&(surface, ancestor));
1008                normalise_evaluator_node_to_fixpoint_without_ancestor_sync(
1009                    &mut rewrite_surface.arena,
1010                    ancestor,
1011                    dirty_trace,
1012                )
1013            } else {
1014                false
1015            };
1016
1017            if evaluator_changed {
1018                scheduler.index_subtree_references(&rewrite_surface.arena, surface, ancestor);
1019                scheduler.enqueue_subtree(
1020                    &rewrite_surface.arena,
1021                    surface,
1022                    ancestor,
1023                    Some(dirty_trace),
1024                );
1025            } else {
1026                scheduler.enqueue_node_at_level(
1027                    &rewrite_surface.arena,
1028                    surface,
1029                    ancestor,
1030                    0,
1031                    ScheduledMode::CheckNode,
1032                    Some(dirty_trace),
1033                );
1034            }
1035        }
1036    }
1037
1038    fn flush(
1039        &mut self,
1040        scheduler: &mut WorklistScheduler,
1041        surfaces: &mut [RewriteSurface],
1042        deferred_evaluators: &mut DeferredEvaluatorWork,
1043        dirty_trace: &mut DirtyTrace,
1044    ) {
1045        let nodes = deferred_nodes_in_bottom_up_order(std::mem::take(&mut self.nodes), surfaces);
1046        let postponed = std::mem::take(&mut self.postponed);
1047        self.flush_after_level = None;
1048
1049        // Expressions above a changed child are left alone while the priority finishes, so
1050        // anything already queued against them stays valid. Rebuild deepest first, once each, so
1051        // every ancestor sees up-to-date children. Ancestors on the root surface also get the
1052        // evaluator here, so a parent sees an already-normalised child and the path above a
1053        // rewrite is copied once per batch instead of once per rewrite.
1054        let mut evaluator_changed = HashSet::new();
1055        for &(surface, node_id, _) in &nodes {
1056            let Some(rewrite_surface) = surfaces.get_mut(surface) else {
1057                continue;
1058            };
1059            if !rewrite_surface.active || !rewrite_surface.arena.is_reachable(node_id) {
1060                continue;
1061            }
1062            rewrite_surface.arena.rebuild_payload_from_children(node_id);
1063
1064            if matches!(rewrite_surface.kind, RewriteSurfaceKind::Root) {
1065                deferred_evaluators.nodes.remove(&(surface, node_id));
1066                if node_id != rewrite_surface.arena.root()
1067                    && normalise_evaluator_node_to_fixpoint_without_ancestor_sync(
1068                        &mut rewrite_surface.arena,
1069                        node_id,
1070                        dirty_trace,
1071                    )
1072                {
1073                    evaluator_changed.insert((surface, node_id));
1074                }
1075            }
1076        }
1077
1078        for (surface, node_id, _) in nodes {
1079            let Some(rewrite_surface) = surfaces.get(surface) else {
1080                continue;
1081            };
1082            if !rewrite_surface.active || !rewrite_surface.arena.is_reachable(node_id) {
1083                continue;
1084            }
1085            if evaluator_changed.contains(&(surface, node_id)) {
1086                scheduler.index_subtree_references(&rewrite_surface.arena, surface, node_id);
1087                scheduler.enqueue_subtree(
1088                    &rewrite_surface.arena,
1089                    surface,
1090                    node_id,
1091                    Some(dirty_trace),
1092                );
1093            } else {
1094                scheduler.enqueue_node_at_level(
1095                    &rewrite_surface.arena,
1096                    surface,
1097                    node_id,
1098                    0,
1099                    ScheduledMode::CheckNode,
1100                    Some(dirty_trace),
1101                );
1102            }
1103        }
1104
1105        // An item taken off the queue while its ancestors were still out of date must not be
1106        // dropped. Put it back now that they are rebuilt, so descendants it had not reached yet
1107        // -- new top-level constraints in particular -- still get visited.
1108        for (level, surface, node_id, mode) in postponed {
1109            let Some(rewrite_surface) = surfaces.get(surface) else {
1110                continue;
1111            };
1112            if !rewrite_surface.active || !rewrite_surface.arena.is_reachable(node_id) {
1113                continue;
1114            }
1115            scheduler.enqueue_node_at_level(
1116                &rewrite_surface.arena,
1117                surface,
1118                node_id,
1119                level,
1120                mode,
1121                Some(dirty_trace),
1122            );
1123        }
1124    }
1125}
1126
1127impl WorklistScheduler {
1128    fn empty(rule_groups: &[RuleGroup<'_>]) -> Self {
1129        Self {
1130            queues_by_level: vec![BinaryHeap::new(); rule_groups.len()],
1131            scheduled: HashMap::new(),
1132            subtree_candidate_cache: Vec::new(),
1133            failed_symbol_rules: HashMap::new(),
1134            reference_nodes_by_name: HashMap::new(),
1135            symbol_revision: 0,
1136            next_sequence: 0,
1137        }
1138    }
1139
1140    fn new(
1141        surfaces: &[RewriteSurface],
1142        rule_groups: &[RuleGroup<'_>],
1143        config: RewriteConfig,
1144    ) -> Self {
1145        let mut scheduler = Self::empty(rule_groups);
1146        for surface in 0..surfaces.len() {
1147            scheduler.enqueue_surface(surfaces, surface, rule_groups, config, None);
1148        }
1149        scheduler
1150    }
1151
1152    fn enqueue_surface(
1153        &mut self,
1154        surfaces: &[RewriteSurface],
1155        surface: usize,
1156        _rule_groups: &[RuleGroup<'_>],
1157        _config: RewriteConfig,
1158        dirty_trace: Option<&mut DirtyTrace>,
1159    ) {
1160        let Some(rewrite_surface) = surfaces.get(surface) else {
1161            return;
1162        };
1163        if !rewrite_surface.active {
1164            return;
1165        }
1166
1167        self.index_subtree_references(
1168            &rewrite_surface.arena,
1169            surface,
1170            rewrite_surface.arena.root(),
1171        );
1172        self.enqueue_subtree(
1173            &rewrite_surface.arena,
1174            surface,
1175            rewrite_surface.arena.root(),
1176            dirty_trace,
1177        );
1178    }
1179
1180    fn index_subtree_references(
1181        &mut self,
1182        arena: &ExpressionArena,
1183        surface: usize,
1184        subtree_root: ExpressionNodeId,
1185    ) {
1186        for node_id in rewriter_reachable_subtree_ids(arena, subtree_root) {
1187            let Expr::Atomic(_, Atom::Reference(reference)) = arena.expression(node_id) else {
1188                continue;
1189            };
1190            self.reference_nodes_by_name
1191                .entry(reference.name().clone())
1192                .or_default()
1193                .insert((surface, node_id));
1194        }
1195    }
1196
1197    fn should_attempt_rule(
1198        &self,
1199        surface: usize,
1200        node_id: ExpressionNodeId,
1201        rule_data: &RuleData<'_>,
1202    ) -> bool {
1203        if rule_data.rule.failure_invalidation != RuleFailureInvalidation::SymbolsOnly {
1204            return true;
1205        }
1206
1207        self.failed_symbol_rules.get(&FailedSymbolRuleKey {
1208            surface,
1209            node_id,
1210            rule: rule_data.rule as *const _ as usize,
1211        }) != Some(&self.symbol_revision)
1212    }
1213
1214    fn record_rule_failure(
1215        &mut self,
1216        surface: usize,
1217        node_id: ExpressionNodeId,
1218        rule_data: &RuleData<'_>,
1219    ) {
1220        if rule_data.rule.failure_invalidation == RuleFailureInvalidation::SymbolsOnly {
1221            self.failed_symbol_rules.insert(
1222                FailedSymbolRuleKey {
1223                    surface,
1224                    node_id,
1225                    rule: rule_data.rule as *const _ as usize,
1226                },
1227                self.symbol_revision,
1228            );
1229        }
1230    }
1231
1232    fn invalidate_symbol_rule_failures(&mut self) {
1233        let (next_revision, overflowed) = self.symbol_revision.overflowing_add(1);
1234        self.symbol_revision = next_revision;
1235        if overflowed {
1236            self.failed_symbol_rules.clear();
1237        }
1238    }
1239
1240    fn enqueue_subtree(
1241        &mut self,
1242        arena: &ExpressionArena,
1243        surface: usize,
1244        node_id: ExpressionNodeId,
1245        dirty_trace: Option<&mut DirtyTrace>,
1246    ) {
1247        self.enqueue_node_at_level(
1248            arena,
1249            surface,
1250            node_id,
1251            0,
1252            ScheduledMode::TraverseSubtreeRoot,
1253            dirty_trace,
1254        );
1255    }
1256
1257    #[cfg(test)]
1258    fn enqueue_node_and_ancestors(
1259        &mut self,
1260        arena: &ExpressionArena,
1261        surface: usize,
1262        node_id: ExpressionNodeId,
1263        dirty_trace: &mut DirtyTrace,
1264    ) {
1265        let mut chain = Vec::new();
1266        let mut current = Some(node_id);
1267        while let Some(current_id) = current {
1268            chain.push(current_id);
1269            current = arena.parent(current_id);
1270        }
1271
1272        for current_id in chain.into_iter().rev() {
1273            self.enqueue_node_at_level(
1274                arena,
1275                surface,
1276                current_id,
1277                0,
1278                ScheduledMode::CheckNode,
1279                Some(dirty_trace),
1280            );
1281        }
1282    }
1283
1284    fn enqueue_children_at_level(
1285        &mut self,
1286        context: WorklistSchedulingContext<'_, '_, '_>,
1287        node_id: ExpressionNodeId,
1288        level: usize,
1289        mut dirty_trace: Option<&mut DirtyTrace>,
1290    ) -> usize {
1291        if matches!(context.arena.expression(node_id), Expr::Comprehension(_, _)) {
1292            return 0;
1293        }
1294
1295        let mut child_count = 0;
1296        for &child_id in context.arena.children(node_id) {
1297            if !self.subtree_has_candidates_at_level(
1298                context.arena,
1299                context.surface,
1300                child_id,
1301                level,
1302                context.rule_groups,
1303                context.config,
1304            ) {
1305                continue;
1306            }
1307            child_count += 1;
1308            self.enqueue_node_at_level(
1309                context.arena,
1310                context.surface,
1311                child_id,
1312                level,
1313                ScheduledMode::TraverseSubtreeDescendant,
1314                dirty_trace.as_deref_mut(),
1315            );
1316        }
1317        child_count
1318    }
1319
1320    fn enqueue_node_at_level(
1321        &mut self,
1322        arena: &ExpressionArena,
1323        surface: usize,
1324        node_id: ExpressionNodeId,
1325        level: usize,
1326        mode: ScheduledMode,
1327        mut dirty_trace: Option<&mut DirtyTrace>,
1328    ) {
1329        if level >= self.queues_by_level.len() {
1330            return;
1331        }
1332        if !arena.is_reachable(node_id) {
1333            return;
1334        }
1335
1336        let scheduled = ScheduledNode {
1337            surface,
1338            node_id,
1339            generation: arena.generation(node_id),
1340            mode,
1341            depth: arena.depth(node_id),
1342            sequence: self.next_sequence,
1343        };
1344        self.next_sequence += 1;
1345        let key = ScheduledKey {
1346            level,
1347            surface,
1348            node_id,
1349            generation: scheduled.generation,
1350        };
1351        match self.scheduled.entry(key) {
1352            std::collections::hash_map::Entry::Vacant(entry) => {
1353                entry.insert(mode);
1354                self.queues_by_level[level].push(scheduled);
1355                if let Some(trace) = dirty_trace.as_deref_mut() {
1356                    trace.record_worklist_enqueue(mode);
1357                }
1358            }
1359            std::collections::hash_map::Entry::Occupied(mut entry) => {
1360                if !entry.get().includes(mode) {
1361                    entry.insert(mode);
1362                    self.queues_by_level[level].push(scheduled);
1363                    if let Some(trace) = dirty_trace {
1364                        trace.record_worklist_enqueue(mode);
1365                    }
1366                }
1367            }
1368        }
1369    }
1370
1371    fn enqueue_after_no_rewrite(
1372        &mut self,
1373        context: WorklistSchedulingContext<'_, '_, '_>,
1374        node_id: ExpressionNodeId,
1375        level: usize,
1376        next_self_level: usize,
1377        mode: ScheduledMode,
1378        mut dirty_trace: Option<&mut DirtyTrace>,
1379    ) {
1380        if mode.descends_on_failure() {
1381            let child_count =
1382                self.enqueue_children_at_level(context, node_id, level, dirty_trace.as_deref_mut());
1383            if let Some(trace) = dirty_trace.as_deref_mut() {
1384                trace.record_worklist_child_descent(mode, child_count);
1385            }
1386        }
1387
1388        let Some(next_self_mode) = mode.next_self_mode() else {
1389            return;
1390        };
1391        // Descendants reached while traversing the current level do not eagerly advance to
1392        // future levels. The traversal root advances and rediscovers descendants only after it
1393        // survives that future level, avoiding stale descendant work when an ancestor rewrites.
1394        let next_self_level = if mode.advances_as_subtree() {
1395            self.next_subtree_candidate_level(
1396                context.arena,
1397                context.surface,
1398                node_id,
1399                next_self_level,
1400                context.rule_groups,
1401                context.config,
1402            )
1403        } else {
1404            next_worklist_candidate_level(
1405                context.arena,
1406                node_id,
1407                next_self_level,
1408                context.rule_groups,
1409                context.config,
1410            )
1411        };
1412        self.enqueue_node_at_level(
1413            context.arena,
1414            context.surface,
1415            node_id,
1416            next_self_level,
1417            next_self_mode,
1418            dirty_trace,
1419        );
1420    }
1421
1422    fn pop_next(
1423        &mut self,
1424        surfaces: &[RewriteSurface],
1425        rule_groups: &[RuleGroup<'_>],
1426        config: RewriteConfig,
1427        dirty_trace: &mut DirtyTrace,
1428    ) -> Option<(usize, usize, ExpressionNodeId, ScheduledMode)> {
1429        for level in 0..self.queues_by_level.len() {
1430            while let Some(scheduled) = self.queues_by_level[level].pop() {
1431                let key = ScheduledKey {
1432                    level,
1433                    surface: scheduled.surface,
1434                    node_id: scheduled.node_id,
1435                    generation: scheduled.generation,
1436                };
1437                if self.scheduled.get(&key).copied() != Some(scheduled.mode) {
1438                    dirty_trace.record_worklist_stale_pop(
1439                        scheduled.mode,
1440                        WorklistStaleReason::ModeMismatch,
1441                    );
1442                    continue;
1443                }
1444                self.scheduled.remove(&key);
1445
1446                let Some(surface) = surfaces.get(scheduled.surface) else {
1447                    dirty_trace.record_worklist_stale_pop(
1448                        scheduled.mode,
1449                        WorklistStaleReason::MissingSurface,
1450                    );
1451                    continue;
1452                };
1453                if !surface.active {
1454                    dirty_trace.record_worklist_stale_pop(
1455                        scheduled.mode,
1456                        WorklistStaleReason::InactiveSurface,
1457                    );
1458                    continue;
1459                }
1460                let arena = &surface.arena;
1461                if !arena.is_reachable(scheduled.node_id) {
1462                    dirty_trace.record_worklist_stale_pop(
1463                        scheduled.mode,
1464                        WorklistStaleReason::UnreachableNode,
1465                    );
1466                    continue;
1467                }
1468                if arena.generation(scheduled.node_id) != scheduled.generation {
1469                    dirty_trace.record_worklist_stale_pop(
1470                        scheduled.mode,
1471                        WorklistStaleReason::GenerationMismatch,
1472                    );
1473                    if scheduled.mode.advances_as_subtree() {
1474                        // A descendant rewrite updates ancestor generations. Refreshing the
1475                        // traversal root preserves its job of carrying still-unseen sibling
1476                        // descendants to later rule levels.
1477                        let refresh_level = self.next_subtree_candidate_level(
1478                            arena,
1479                            scheduled.surface,
1480                            scheduled.node_id,
1481                            level,
1482                            rule_groups,
1483                            config,
1484                        );
1485                        self.enqueue_node_at_level(
1486                            arena,
1487                            scheduled.surface,
1488                            scheduled.node_id,
1489                            refresh_level,
1490                            scheduled.mode,
1491                            Some(dirty_trace),
1492                        );
1493                    }
1494                    continue;
1495                }
1496
1497                dirty_trace.record_worklist_pop(scheduled.mode);
1498                return Some((level, scheduled.surface, scheduled.node_id, scheduled.mode));
1499            }
1500        }
1501
1502        None
1503    }
1504
1505    fn next_subtree_candidate_level(
1506        &mut self,
1507        arena: &ExpressionArena,
1508        surface: usize,
1509        node_id: ExpressionNodeId,
1510        start_level: usize,
1511        rule_groups: &[RuleGroup<'_>],
1512        config: RewriteConfig,
1513    ) -> usize {
1514        if start_level >= rule_groups.len() || !arena.is_reachable(node_id) {
1515            return rule_groups.len();
1516        }
1517
1518        (start_level..rule_groups.len())
1519            .find(|&level| {
1520                self.subtree_has_candidates_at_level(
1521                    arena,
1522                    surface,
1523                    node_id,
1524                    level,
1525                    rule_groups,
1526                    config,
1527                )
1528            })
1529            .unwrap_or(rule_groups.len())
1530    }
1531
1532    fn subtree_has_candidates_at_level(
1533        &mut self,
1534        arena: &ExpressionArena,
1535        surface: usize,
1536        node_id: ExpressionNodeId,
1537        level: usize,
1538        rule_groups: &[RuleGroup<'_>],
1539        config: RewriteConfig,
1540    ) -> bool {
1541        if level >= rule_groups.len() || !arena.is_reachable(node_id) {
1542            return false;
1543        }
1544
1545        let cache_bit = 1u128.checked_shl(level as u32);
1546        if let Some(bit) = cache_bit {
1547            if self.subtree_candidate_cache.len() <= surface {
1548                self.subtree_candidate_cache
1549                    .resize_with(surface + 1, Vec::new);
1550            }
1551            let surface_cache = &mut self.subtree_candidate_cache[surface];
1552            if surface_cache.len() <= node_id.index() {
1553                surface_cache.resize(node_id.index() + 1, NodeSubtreeCandidateCache::default());
1554            }
1555            let entry = &mut surface_cache[node_id.index()];
1556            let generation = arena.generation(node_id);
1557            if !entry.generation_valid || entry.generation != generation {
1558                *entry = NodeSubtreeCandidateCache {
1559                    generation,
1560                    generation_valid: true,
1561                    ..NodeSubtreeCandidateCache::default()
1562                };
1563            }
1564            if entry.known_levels & bit != 0 {
1565                return entry.candidate_levels & bit != 0;
1566            }
1567        }
1568
1569        let rule_group = &rule_groups[level];
1570        let has_candidates = rule_group.has_candidates_at_node(config, arena, node_id)
1571            || (!matches!(arena.expression(node_id), Expr::Comprehension(_, _))
1572                && arena.children(node_id).iter().any(|&child_id| {
1573                    self.subtree_has_candidates_at_level(
1574                        arena,
1575                        surface,
1576                        child_id,
1577                        level,
1578                        rule_groups,
1579                        config,
1580                    )
1581                }));
1582
1583        if let Some(bit) = cache_bit {
1584            let entry = &mut self.subtree_candidate_cache[surface][node_id.index()];
1585            entry.known_levels |= bit;
1586            if has_candidates {
1587                entry.candidate_levels |= bit;
1588            }
1589        }
1590        has_candidates
1591    }
1592}
1593
1594fn next_worklist_candidate_level(
1595    arena: &ExpressionArena,
1596    node_id: ExpressionNodeId,
1597    start_level: usize,
1598    rule_groups: &[RuleGroup<'_>],
1599    config: RewriteConfig,
1600) -> usize {
1601    if start_level >= rule_groups.len() || !arena.is_reachable(node_id) {
1602        return rule_groups.len();
1603    }
1604
1605    rule_groups
1606        .iter()
1607        .enumerate()
1608        .skip(start_level)
1609        .find_map(|(level, rule_group)| {
1610            rule_group
1611                .has_candidates_at_node(config, arena, node_id)
1612                .then_some(level)
1613        })
1614        .unwrap_or(rule_groups.len())
1615}
1616
1617struct RuleEffectImpact {
1618    added_names: Vec<Name>,
1619    changed_names: Vec<Name>,
1620    invalidated_reference_names: Vec<Name>,
1621    has_new_top: bool,
1622    has_new_clauses: bool,
1623}
1624
1625impl RuleEffectImpact {
1626    fn new(
1627        effect: &crate::rule_engine::rule::RuleEffect,
1628        symbols: &crate::ast::SymbolTable,
1629    ) -> Self {
1630        let mut changed_names: Vec<_> = effect
1631            .changed_symbols(symbols)
1632            .into_iter()
1633            .map(|(name, _, _)| name)
1634            .collect();
1635        for name in effect.updated_declaration_names() {
1636            if !changed_names.contains(&name) {
1637                changed_names.push(name);
1638            }
1639        }
1640        let added_names: Vec<_> = effect.added_symbols(symbols).into_iter().collect();
1641        // A new auxiliary stands in for the declaration it was derived from, so expressions
1642        // referencing that source have to be reconsidered alongside the ones that changed.
1643        let mut invalidated_reference_names = changed_names.clone();
1644        for added_name in &added_names {
1645            let Some(declaration) = effect.symbols.lookup_local(added_name) else {
1646                continue;
1647            };
1648            let mut seen_sources = HashSet::new();
1649            let mut source = declaration.source().clone();
1650            while let Some(source_declaration) = source.take() {
1651                let source_name = source_declaration.name().clone();
1652                if !seen_sources.insert(source_name.clone()) {
1653                    break;
1654                }
1655                if !invalidated_reference_names.contains(&source_name) {
1656                    invalidated_reference_names.push(source_name);
1657                }
1658                source.clone_from(&source_declaration.source());
1659            }
1660        }
1661
1662        Self {
1663            added_names,
1664            changed_names,
1665            invalidated_reference_names,
1666            has_new_top: !effect.new_top.is_empty(),
1667            has_new_clauses: !effect.new_clauses.is_empty(),
1668        }
1669    }
1670
1671    fn has_model_side_effects(&self) -> bool {
1672        self.has_new_top || self.has_new_clauses || self.has_symbol_changes()
1673    }
1674
1675    fn has_symbol_changes(&self) -> bool {
1676        !self.added_names.is_empty() || !self.changed_names.is_empty()
1677    }
1678}
1679
1680struct RewritePassContext<'ctx, 'rules, O> {
1681    rules_grouped: &'ctx Vec<(u16, Vec<RuleData<'rules>>)>,
1682    bucketed_rules: &'ctx Vec<RuleGroup<'rules>>,
1683    stats: &'ctx mut RewriterStats,
1684    dirty_trace: &'ctx mut DirtyTrace,
1685    config: RewriteConfig,
1686    attempt_observer: &'ctx mut O,
1687}
1688
1689struct TracingObserver<'run> {
1690    run_start: &'run Instant,
1691    general_enabled: bool,
1692    csv_enabled: bool,
1693}
1694
1695impl RuleAttemptObserver for TracingObserver<'_> {
1696    fn attempted(
1697        &mut self,
1698        priority: u16,
1699        rule: &RuleData<'_>,
1700        expression: &Expr,
1701        status: RuleAttemptStatus,
1702    ) {
1703        let elapsed_s = self.run_start.elapsed().as_secs_f64();
1704
1705        if self.general_enabled {
1706            trace!(
1707                target: "conjure::rule_attempt",
1708                elapsed_s,
1709                rule_level = priority,
1710                rule_name = rule.rule.name,
1711                rule_set = rule.rule_set.name,
1712                status = status.as_str(),
1713                expression = %expression,
1714                "attempted rule"
1715            );
1716        }
1717
1718        if self.csv_enabled {
1719            let expression = expression.to_string();
1720            trace!(
1721                target: "rule_engine_rule_attempt_trace",
1722                "{elapsed_s:.3},{priority},{},{},{},{}",
1723                csv_escape(rule.rule.name),
1724                csv_escape(rule.rule_set.name),
1725                status.as_str(),
1726                csv_escape(&expression),
1727            );
1728        }
1729    }
1730}
1731
1732/// True when a domain still needs Essence→Essence' abstract representation
1733/// (set/tuple/record/…); concrete bool/int and matrices of those do not.
1734fn domain_needs_abstract_repr(domain: &crate::ast::DomainPtr) -> bool {
1735    domain_needs_abstract_repr_at(domain, true)
1736}
1737
1738/// `is_value` is false for a matrix's index domains, which describe which entries exist rather
1739/// than a value the solver assigns, and so are never encoded.
1740fn domain_needs_abstract_repr_at(domain: &crate::ast::DomainPtr, is_value: bool) -> bool {
1741    use crate::ast::{Domain, GroundDomain, UnresolvedDomain};
1742    let int_is_abstract = is_value && crate::settings::ints_need_representation();
1743    match domain.as_ref() {
1744        Domain::Ground(gd) => match gd.as_ref() {
1745            GroundDomain::Empty(..) | GroundDomain::Bool => false,
1746            GroundDomain::Int(_) => int_is_abstract,
1747            // Every matrix has a layout to choose between, so representation selection has work
1748            // to do whatever the elements are.
1749            GroundDomain::Matrix(..) => true,
1750            _ => true,
1751        },
1752        Domain::Unresolved(ud) => match ud.as_ref() {
1753            UnresolvedDomain::Int(..) => int_is_abstract,
1754            UnresolvedDomain::Matrix(..) => true,
1755            UnresolvedDomain::Reference(re) => re
1756                .domain()
1757                .is_some_and(|d| domain_needs_abstract_repr_at(&d, is_value)),
1758            _ => true,
1759        },
1760    }
1761}
1762
1763/// Whether `ReprGeneral` / `ReprTuplePacked` can do useful work on this model.
1764///
1765/// Models that only use bools, ints, and matrices of those (e.g. solitaire_battleship)
1766/// still paid hundreds of thousands of failed attempts on record/tuple/set rules.
1767fn model_needs_abstract_repr_rules(model: &Model) -> bool {
1768    use crate::ast::{AbstractLiteral, Atom, Expression as Expr, Literal};
1769
1770    for (_, decl) in model.symbols().iter_local() {
1771        if let Some(domain) = decl.domain()
1772            && domain_needs_abstract_repr(&domain)
1773        {
1774            return true;
1775        }
1776    }
1777
1778    // Record/tuple/set *literals* also need ReprGeneral even without abstract finds.
1779    // Matrix literals are handled by `ReprMatrixComponents`, not these rule sets.
1780    model.root().any_expression(|expr| match expr {
1781        Expr::AbstractLiteral(_, abs) => !matches!(abs, AbstractLiteral::Matrix(..)),
1782        Expr::Atomic(_, Atom::Literal(Literal::AbstractLiteral(abs))) => {
1783            !matches!(abs, AbstractLiteral::Matrix(..))
1784        }
1785        _ => false,
1786    })
1787}
1788
1789/// Rewrites a model by applying rules in priority order, trying enclosing expressions before their
1790/// descendants at each priority.
1791pub fn rewrite_model<'a>(
1792    model: &Model,
1793    rule_sets: &Vec<&'a RuleSet<'a>>,
1794    config: RewriteConfig,
1795) -> Result<Model, RewriteError> {
1796    set_current_rewriter(Rewriter::Rewrite(config));
1797
1798    let needs_abstract_repr = model_needs_abstract_repr_rules(model);
1799    let filtered_rule_sets: Vec<&'a RuleSet<'a>> = if needs_abstract_repr {
1800        rule_sets.clone()
1801    } else {
1802        rule_sets
1803            .iter()
1804            .copied()
1805            .filter(|rs| rs.name != "ReprGeneral" && rs.name != "ReprTuplePacked")
1806            .collect()
1807    };
1808
1809    let rules_grouped = get_rules_grouped(&filtered_rule_sets)
1810        .unwrap_or_else(|_| bug!("get_rule_priorities() failed!"))
1811        .into_iter()
1812        .collect_vec();
1813    let bucketed_rules = rules_grouped
1814        .iter()
1815        .map(|(priority, rules)| RuleGroup::new(*priority, rules.clone()))
1816        .collect_vec();
1817
1818    let mut model = introduce_objective_auxiliary(model.clone());
1819    tighten_domains_from_constraints(&mut model);
1820    let mut rewriter_stats = RewriterStats::new();
1821    rewriter_stats.is_optimisation_enabled = Some(!config.is_baseline());
1822    let mut dirty_trace = DirtyTrace::from_env();
1823    let run_start = Instant::now();
1824
1825    if rule_trace_enabled() && default_rule_trace_enabled() {
1826        trace!(
1827            target: "rule_engine_rule_trace",
1828            "Model before rewriting:\n\n{}\n--\n",
1829            model
1830        );
1831    }
1832    if rule_trace_enabled() && rule_attempt_trace_enabled() {
1833        trace!(
1834            target: "rule_engine_rule_attempt_trace",
1835            "elapsed_s,rule_level,rule_name,rule_set,status,expression"
1836        );
1837    }
1838
1839    let general_attempt_trace_enabled = tracing::enabled!(
1840        target: "conjure::rule_attempt",
1841        tracing::Level::TRACE
1842    );
1843    let csv_attempt_trace_enabled = rule_trace_enabled() && rule_attempt_trace_enabled();
1844
1845    if general_attempt_trace_enabled || csv_attempt_trace_enabled {
1846        let mut observer = TracingObserver {
1847            run_start: &run_start,
1848            general_enabled: general_attempt_trace_enabled,
1849            csv_enabled: csv_attempt_trace_enabled,
1850        };
1851        rewrite_to_fixpoint(
1852            &mut model,
1853            &rules_grouped,
1854            &bucketed_rules,
1855            &mut rewriter_stats,
1856            &mut dirty_trace,
1857            config,
1858            &mut observer,
1859        );
1860    } else {
1861        let mut observer = NoopObserver;
1862        rewrite_to_fixpoint(
1863            &mut model,
1864            &rules_grouped,
1865            &bucketed_rules,
1866            &mut rewriter_stats,
1867            &mut dirty_trace,
1868            config,
1869            &mut observer,
1870        );
1871    }
1872
1873    let run_end = Instant::now();
1874    rewriter_stats.rewriter_run_time = Some(run_end - run_start);
1875    model
1876        .context
1877        .write()
1878        .unwrap()
1879        .stats
1880        .add_rewriter_run(rewriter_stats);
1881    dirty_trace.finish(
1882        model
1883            .context
1884            .read()
1885            .unwrap()
1886            .stats
1887            .rewriter_runs
1888            .last()
1889            .expect("rewriter stats were just added"),
1890    );
1891
1892    if rule_trace_enabled() && default_rule_trace_enabled() {
1893        trace!(
1894            target: "rule_engine_rule_trace",
1895            "Final model:\n\n{}",
1896            model
1897        );
1898    }
1899
1900    // Flatten top-level `and` into the root constraint list (and strip `true` / propagate
1901    // `false`) after rewriting. Must not run mid-loop: early flattening of large expanded
1902    // conjunctions explodes worklist rule attempts. Solver adaptors expect one flat constraint
1903    // per root entry.
1904    if let Some(normalised_root) = finish_root_evaluator_normalisation(model.root()) {
1905        model.replace_root(normalised_root);
1906    }
1907
1908    Ok(model)
1909}
1910
1911#[allow(clippy::too_many_arguments)]
1912fn rewrite_to_fixpoint<'rules, O: RuleAttemptObserver>(
1913    model: &mut Model,
1914    rules_grouped: &Vec<(u16, Vec<RuleData<'rules>>)>,
1915    bucketed_rules: &Vec<RuleGroup<'rules>>,
1916    stats: &mut RewriterStats,
1917    dirty_trace: &mut DirtyTrace,
1918    config: RewriteConfig,
1919    attempt_observer: &mut O,
1920) {
1921    let mut pass_ctx = RewritePassContext {
1922        rules_grouped,
1923        bucketed_rules,
1924        stats,
1925        dirty_trace,
1926        config,
1927        attempt_observer,
1928    };
1929    if config.worklist {
1930        let _ = try_rewrite_model(model, &mut pass_ctx);
1931    } else {
1932        let mut done_something = true;
1933        while done_something {
1934            done_something = try_rewrite_model(model, &mut pass_ctx).is_some();
1935        }
1936    }
1937}
1938
1939// Tries to rewrite the model until a full scan finds no applicable rules.
1940//
1941// Returns None if no change was made.
1942fn try_rewrite_model<'ctx, 'rules, O: RuleAttemptObserver>(
1943    submodel: &mut Model,
1944    ctx: &mut RewritePassContext<'ctx, 'rules, O>,
1945) -> Option<()> {
1946    ctx.dirty_trace.passes += 1;
1947    if !ctx.config.worklist
1948        && try_rewrite_value_letting_once(submodel, ctx.rules_grouped, ctx.attempt_observer)
1949            .is_some()
1950    {
1951        ctx.dirty_trace.value_letting_rewrites += 1;
1952        increment_counter(&mut ctx.stats.rewriter_value_letting_rewrites);
1953        return Some(());
1954    }
1955
1956    let mut did_rewrite = false;
1957    let mut arena = ExpressionArena::from_root(take_model_root(submodel));
1958    normalise_evaluators_bottom_up(&mut arena, ctx.dirty_trace);
1959
1960    if ctx.config.worklist {
1961        return try_rewrite_model_with_worklist(submodel, ctx, arena);
1962    }
1963
1964    'rewrite_loop: loop {
1965        let mut results: Vec<ApplicableRule<'_, ExpressionNodeId>> = vec![];
1966        let preorder_ids = rewriter_preorder_ids(&arena);
1967        // Iterate over rules by priority in descending order.
1968        'top: for (level, rule_group) in ctx.bucketed_rules.iter().enumerate() {
1969            ctx.dirty_trace.priority_scans += 1;
1970            for &node_id in &preorder_ids {
1971                ctx.dirty_trace.expression_visits += 1;
1972                let mut attempted_rule = false;
1973                {
1974                    let expr = arena.expression(node_id);
1975                    for rd in rule_group.candidates(ctx.config, expr) {
1976                        attempted_rule = true;
1977                        ctx.dirty_trace
1978                            .record_rule_attempt(rule_group.priority, rd.rule.name);
1979                        // Count rule application attempts
1980                        ctx.stats.rewriter_rule_application_attempts =
1981                            Some(ctx.stats.rewriter_rule_application_attempts.unwrap_or(0) + 1);
1982
1983                        match (rd.rule.application)(expr, &submodel.symbols()) {
1984                            Ok(red) => {
1985                                ctx.attempt_observer.attempted(
1986                                    rule_group.priority,
1987                                    rd,
1988                                    expr,
1989                                    RuleAttemptStatus::Success,
1990                                );
1991
1992                                // Count successful rule applications
1993                                ctx.stats.rewriter_rule_applications =
1994                                    Some(ctx.stats.rewriter_rule_applications.unwrap_or(0) + 1);
1995
1996                                // Collect applicable rules
1997                                results.push((
1998                                    RuleResult {
1999                                        rule_data: rd.clone(),
2000                                        effect: red,
2001                                    },
2002                                    level,
2003                                    expr.clone(),
2004                                    node_id,
2005                                    root_variable_snapshot_for_default_trace(
2006                                        expr,
2007                                        &submodel.symbols(),
2008                                    ),
2009                                ));
2010                            }
2011                            Err(_) => {
2012                                ctx.attempt_observer.attempted(
2013                                    rule_group.priority,
2014                                    rd,
2015                                    expr,
2016                                    RuleAttemptStatus::Failure,
2017                                );
2018                            }
2019                        }
2020                    }
2021                }
2022                if attempted_rule {
2023                    ctx.dirty_trace.attempted_expressions += 1;
2024                }
2025                // This expression has the highest rule priority so far, so this is what we want to
2026                // rewrite.
2027                if !results.is_empty() {
2028                    break 'top;
2029                }
2030            }
2031        }
2032
2033        if !results.is_empty() {
2034            let selected =
2035                choose_rule_result_index(results.iter().map(|(result, _, _, _, _)| result));
2036            results.swap(0, selected);
2037        }
2038
2039        match results.into_iter().next() {
2040            None => {
2041                submodel.replace_root(arena.into_synced_root_expression());
2042                break;
2043            }
2044            Some((result, _level, expr, node_id, variable_snapshot_before)) => {
2045                let effect = result.effect.materialise(&submodel.symbols());
2046                let variable_snapshots = variable_snapshot_before.map(|before| {
2047                    let after = snapshot_symbols_after_effect(&submodel.symbols(), &effect);
2048                    (before, after)
2049                });
2050                let result = RuleResult {
2051                    rule_data: result.rule_data,
2052                    effect,
2053                };
2054
2055                // Extract the single applicable rule and apply it
2056                log_rule_application(
2057                    &result,
2058                    &expr,
2059                    &submodel.symbols(),
2060                    variable_snapshots
2061                        .as_ref()
2062                        .map(|(before, after)| (before, after)),
2063                );
2064
2065                let effect_impact = RuleEffectImpact::new(&result.effect, &submodel.symbols());
2066                let has_model_side_effects = effect_impact.has_model_side_effects();
2067                let rule_name = result.rule_data.rule.name;
2068                let RuleResult { effect, .. } = result;
2069                let crate::rule_engine::rule::RuleEffect {
2070                    new_expression,
2071                    new_top,
2072                    symbols,
2073                    new_clauses,
2074                    declaration_updates,
2075                    ..
2076                } = effect;
2077                // Replace expr with new_expression
2078                replace_focus_and_sync_ancestors(&mut arena, node_id, new_expression);
2079
2080                // Apply new symbols and top level
2081                ctx.dirty_trace
2082                    .record_rewrite(rule_name, has_model_side_effects);
2083                for update in declaration_updates {
2084                    update.apply();
2085                }
2086                submodel.symbols_mut().extend(symbols);
2087                if effect_impact.has_new_top {
2088                    arena.add_root_children(new_top);
2089                }
2090                submodel.add_clauses(new_clauses);
2091                let _ =
2092                    normalise_evaluators_from_node_to_root(&mut arena, node_id, ctx.dirty_trace);
2093                if has_model_side_effects {
2094                    ctx.dirty_trace.record_side_effect_kept_in_arena();
2095                }
2096
2097                #[cfg(debug_assertions)]
2098                {
2099                    // Check well-formedness without rebuilding the live arena: a rebuild would
2100                    // renumber nodes and can change subsequent full-scan order vs release builds.
2101                    submodel.replace_root(arena.expression(arena.root()).clone());
2102                    let assertion_context = format!("rewriter after applying rule '{rule_name}'");
2103                    debug_assert_model_well_formed(submodel, &assertion_context);
2104                }
2105
2106                did_rewrite = true;
2107                continue 'rewrite_loop;
2108            }
2109        }
2110    }
2111
2112    did_rewrite.then_some(())
2113}
2114
2115fn try_rewrite_model_with_worklist<'ctx, 'rules, O: RuleAttemptObserver>(
2116    submodel: &mut Model,
2117    ctx: &mut RewritePassContext<'ctx, 'rules, O>,
2118    arena: ExpressionArena,
2119) -> Option<()> {
2120    let mut did_rewrite = false;
2121    let root_surface = 0usize;
2122    let (mut surfaces, mut value_letting_surfaces) = build_worklist_surfaces(submodel, arena);
2123    for surface in &mut surfaces {
2124        if surface.active {
2125            normalise_evaluators_bottom_up(&mut surface.arena, ctx.dirty_trace);
2126        }
2127    }
2128    write_worklist_surfaces_to_model(submodel, &surfaces);
2129    let mut scheduler = WorklistScheduler::new(&surfaces, ctx.bucketed_rules, ctx.config);
2130    let mut deferred_evaluators = DeferredEvaluatorWork::default();
2131    let mut deferred_ancestor_rules = DeferredAncestorRuleWork::default();
2132
2133    loop {
2134        let next = loop {
2135            let candidate =
2136                scheduler.pop_next(&surfaces, ctx.bucketed_rules, ctx.config, ctx.dirty_trace);
2137            if let Some((_, surface, node_id, _)) = candidate
2138                && deferred_ancestor_rules.contains(surface, node_id)
2139            {
2140                deferred_ancestor_rules.postpone(candidate.unwrap());
2141                continue;
2142            }
2143            break candidate;
2144        };
2145        let crosses_priority_boundary =
2146            deferred_ancestor_rules.should_flush_before(next.as_ref().map(|item| item.0));
2147        if crosses_priority_boundary {
2148            // `pop_next` removes a live item from the queue. Put it back before injecting the
2149            // higher-priority ancestor checks that must precede it.
2150            if let Some((level, surface, node_id, mode)) = next {
2151                scheduler.enqueue_node_at_level(
2152                    &surfaces[surface].arena,
2153                    surface,
2154                    node_id,
2155                    level,
2156                    mode,
2157                    Some(ctx.dirty_trace),
2158                );
2159            }
2160            deferred_ancestor_rules.flush(
2161                &mut scheduler,
2162                &mut surfaces,
2163                &mut deferred_evaluators,
2164                ctx.dirty_trace,
2165            );
2166            continue;
2167        }
2168
2169        let Some((level, surface_index, node_id, scheduled_mode)) = next else {
2170            if normalise_deferred_evaluators(
2171                &mut deferred_evaluators,
2172                submodel,
2173                &mut surfaces,
2174                &mut scheduler,
2175                ctx.dirty_trace,
2176            ) {
2177                did_rewrite = true;
2178                continue;
2179            }
2180            break;
2181        };
2182        ctx.dirty_trace.priority_scans += 1;
2183        ctx.dirty_trace.expression_visits += 1;
2184
2185        let rule_group = &ctx.bucketed_rules[level];
2186        if !rule_group.has_candidates_at_node(ctx.config, &surfaces[surface_index].arena, node_id) {
2187            ctx.dirty_trace
2188                .record_worklist_no_candidate_pop(scheduled_mode);
2189            scheduler.enqueue_after_no_rewrite(
2190                WorklistSchedulingContext::new(
2191                    &surfaces[surface_index].arena,
2192                    surface_index,
2193                    ctx.bucketed_rules,
2194                    ctx.config,
2195                ),
2196                node_id,
2197                level,
2198                level + 1,
2199                scheduled_mode,
2200                Some(ctx.dirty_trace),
2201            );
2202            continue;
2203        }
2204
2205        let mut results: Vec<ApplicableRule<'_, ExpressionNodeId>> = vec![];
2206        let mut attempted_rule = false;
2207        {
2208            let arena = &surfaces[surface_index].arena;
2209            let expr = arena.expression(node_id);
2210            for rd in rule_group.candidates_at_node(ctx.config, arena, node_id) {
2211                if !scheduler.should_attempt_rule(surface_index, node_id, rd) {
2212                    continue;
2213                }
2214                attempted_rule = true;
2215                ctx.dirty_trace
2216                    .record_rule_attempt(rule_group.priority, rd.rule.name);
2217                ctx.stats.rewriter_rule_application_attempts =
2218                    Some(ctx.stats.rewriter_rule_application_attempts.unwrap_or(0) + 1);
2219
2220                match (rd.rule.application)(expr, &submodel.symbols()) {
2221                    Ok(red) => {
2222                        ctx.attempt_observer.attempted(
2223                            rule_group.priority,
2224                            rd,
2225                            expr,
2226                            RuleAttemptStatus::Success,
2227                        );
2228
2229                        ctx.stats.rewriter_rule_applications =
2230                            Some(ctx.stats.rewriter_rule_applications.unwrap_or(0) + 1);
2231
2232                        results.push((
2233                            RuleResult {
2234                                rule_data: rd.clone(),
2235                                effect: red,
2236                            },
2237                            level,
2238                            expr.clone(),
2239                            node_id,
2240                            root_variable_snapshot_for_default_trace(expr, &submodel.symbols()),
2241                        ));
2242                    }
2243                    Err(_) => {
2244                        scheduler.record_rule_failure(surface_index, node_id, rd);
2245                        ctx.attempt_observer.attempted(
2246                            rule_group.priority,
2247                            rd,
2248                            expr,
2249                            RuleAttemptStatus::Failure,
2250                        );
2251                    }
2252                }
2253            }
2254        }
2255
2256        if attempted_rule {
2257            ctx.dirty_trace.attempted_expressions += 1;
2258            ctx.dirty_trace
2259                .record_worklist_rule_attempt_pop(scheduled_mode);
2260        }
2261
2262        if results.is_empty() {
2263            scheduler.enqueue_after_no_rewrite(
2264                WorklistSchedulingContext::new(
2265                    &surfaces[surface_index].arena,
2266                    surface_index,
2267                    ctx.bucketed_rules,
2268                    ctx.config,
2269                ),
2270                node_id,
2271                level,
2272                level + 1,
2273                scheduled_mode,
2274                Some(ctx.dirty_trace),
2275            );
2276            continue;
2277        }
2278
2279        let selected = choose_rule_result_index(results.iter().map(|(result, _, _, _, _)| result));
2280        let (result, _level, expr, node_id, variable_snapshot_before) =
2281            results.swap_remove(selected);
2282
2283        let effect = result.effect.materialise(&submodel.symbols());
2284        let variable_snapshots = variable_snapshot_before.map(|before| {
2285            let after = snapshot_symbols_after_effect(&submodel.symbols(), &effect);
2286            (before, after)
2287        });
2288        let result = RuleResult {
2289            rule_data: result.rule_data,
2290            effect,
2291        };
2292
2293        log_rule_application(
2294            &result,
2295            &expr,
2296            &submodel.symbols(),
2297            variable_snapshots
2298                .as_ref()
2299                .map(|(before, after)| (before, after)),
2300        );
2301
2302        let effect_impact = RuleEffectImpact::new(&result.effect, &submodel.symbols());
2303        let has_model_side_effects = effect_impact.has_model_side_effects();
2304        let rewritten_value_letting_name =
2305            value_letting_surface_name(&surfaces[surface_index].kind).cloned();
2306        let invalidates_symbol_rules =
2307            effect_impact.has_symbol_changes() || rewritten_value_letting_name.is_some();
2308        let rule_name = result.rule_data.rule.name;
2309        let RuleResult { effect, .. } = result;
2310        let crate::rule_engine::rule::RuleEffect {
2311            new_expression,
2312            new_top,
2313            symbols,
2314            new_clauses,
2315            declaration_updates,
2316            ..
2317        } = effect;
2318        {
2319            let arena = &mut surfaces[surface_index].arena;
2320            arena.replace_subtree(node_id, new_expression);
2321        }
2322
2323        ctx.dirty_trace
2324            .record_rewrite(rule_name, has_model_side_effects);
2325        for update in declaration_updates {
2326            update.apply();
2327        }
2328        submodel.symbols_mut().extend(symbols);
2329        let new_top_node_ids = if effect_impact.has_new_top {
2330            surfaces[root_surface].arena.add_root_children(new_top)
2331        } else {
2332            Vec::new()
2333        };
2334        submodel.add_clauses(new_clauses);
2335        let rewrite_impact_node_id = {
2336            let arena = &mut surfaces[surface_index].arena;
2337            normalise_evaluators_subtree_bottom_up(arena, node_id, ctx.dirty_trace);
2338            deferred_evaluators.defer_ancestors(arena, surface_index, node_id);
2339            deferred_ancestor_rules.defer_ancestors(arena, surface_index, node_id, level);
2340            node_id
2341        };
2342        scheduler.index_subtree_references(
2343            &surfaces[surface_index].arena,
2344            surface_index,
2345            rewrite_impact_node_id,
2346        );
2347        for &new_top_node_id in &new_top_node_ids {
2348            if surfaces[root_surface].arena.is_reachable(new_top_node_id) {
2349                normalise_evaluators_subtree_bottom_up(
2350                    &mut surfaces[root_surface].arena,
2351                    new_top_node_id,
2352                    ctx.dirty_trace,
2353                );
2354                deferred_evaluators.defer_ancestors(
2355                    &surfaces[root_surface].arena,
2356                    root_surface,
2357                    new_top_node_id,
2358                );
2359                deferred_ancestor_rules.defer_ancestors(
2360                    &surfaces[root_surface].arena,
2361                    root_surface,
2362                    new_top_node_id,
2363                    level,
2364                );
2365                scheduler.index_subtree_references(
2366                    &surfaces[root_surface].arena,
2367                    root_surface,
2368                    new_top_node_id,
2369                );
2370            }
2371        }
2372        if let Some(name) = rewritten_value_letting_name.as_ref() {
2373            write_value_letting_surface_to_model(submodel, name, &surfaces[surface_index].arena);
2374            ctx.dirty_trace.value_letting_rewrites += 1;
2375            increment_counter(&mut ctx.stats.rewriter_value_letting_rewrites);
2376        }
2377        let mut affected_names = effect_impact.invalidated_reference_names.clone();
2378        if let Some(name) = rewritten_value_letting_name.as_ref()
2379            && !affected_names.contains(name)
2380        {
2381            affected_names.push(name.clone());
2382        }
2383        let mut symbol_surface_names = effect_impact.added_names.clone();
2384        symbol_surface_names.extend(effect_impact.changed_names.iter().cloned());
2385        if let Some(name) = rewritten_value_letting_name.as_ref() {
2386            symbol_surface_names.retain(|candidate| candidate != name);
2387        }
2388        let synced_surfaces = sync_value_letting_surfaces(
2389            submodel,
2390            &mut surfaces,
2391            &mut value_letting_surfaces,
2392            &symbol_surface_names,
2393        );
2394
2395        // Choosing a representation, or any other change to the model, can change which rule
2396        // should win for a sibling at the same priority. Do not batch across that: bring the
2397        // enclosing expressions up to date and re-check their rules before moving to another
2398        // sibling. Rewrites that only change an expression still get batched, and those are the
2399        // common case when unrolling.
2400        if has_model_side_effects {
2401            deferred_ancestor_rules.flush_effect_path_before_root(
2402                &mut scheduler,
2403                &mut surfaces,
2404                &mut deferred_evaluators,
2405                surface_index,
2406                rewrite_impact_node_id,
2407                ctx.dirty_trace,
2408            );
2409        }
2410        {
2411            if has_model_side_effects {
2412                ctx.dirty_trace.record_side_effect_kept_in_arena();
2413            }
2414            if invalidates_symbol_rules {
2415                scheduler.invalidate_symbol_rule_failures();
2416                scheduler.enqueue_node_at_level(
2417                    &surfaces[root_surface].arena,
2418                    root_surface,
2419                    surfaces[root_surface].arena.root(),
2420                    0,
2421                    ScheduledMode::CheckNode,
2422                    Some(ctx.dirty_trace),
2423                );
2424            }
2425            scheduler.enqueue_subtree(
2426                &surfaces[surface_index].arena,
2427                surface_index,
2428                rewrite_impact_node_id,
2429                Some(ctx.dirty_trace),
2430            );
2431            for new_top_node_id in new_top_node_ids {
2432                scheduler.enqueue_subtree(
2433                    &surfaces[root_surface].arena,
2434                    root_surface,
2435                    new_top_node_id,
2436                    Some(ctx.dirty_trace),
2437                );
2438            }
2439            for synced_surface in synced_surfaces {
2440                scheduler.enqueue_surface(
2441                    &surfaces,
2442                    synced_surface,
2443                    ctx.bucketed_rules,
2444                    ctx.config,
2445                    Some(ctx.dirty_trace),
2446                );
2447            }
2448            enqueue_worklist_nodes_referencing_names(
2449                &mut scheduler,
2450                &surfaces,
2451                &affected_names,
2452                ctx.dirty_trace,
2453            );
2454        }
2455
2456        did_rewrite = true;
2457    }
2458
2459    move_worklist_surfaces_to_model(submodel, surfaces);
2460    #[cfg(debug_assertions)]
2461    debug_assert_model_well_formed(submodel, "rewriter after a settled worklist pass");
2462    did_rewrite.then_some(())
2463}
2464
2465#[cfg(test)]
2466fn enqueue_worklist_rewrite_impact(
2467    scheduler: &mut WorklistScheduler,
2468    arena: &ExpressionArena,
2469    surface: usize,
2470    node_id: ExpressionNodeId,
2471    dirty_trace: &mut DirtyTrace,
2472) {
2473    scheduler.enqueue_node_and_ancestors(arena, surface, node_id, dirty_trace);
2474    scheduler.enqueue_subtree(arena, surface, node_id, Some(dirty_trace));
2475}
2476
2477/// Rebuilds and evaluates the waiting ancestors, once the worklist has run out of rule work.
2478///
2479/// Nodes are handled deepest first, so each parent is rebuilt once from children that are already
2480/// up to date. Doing it here rather than after every rewrite is what stops each rewrite from
2481/// copying the whole matrix/`and`/root chain above it. The surfaces that changed go back on the
2482/// worklist, and later rewrites fill the next batch.
2483fn normalise_deferred_evaluators(
2484    work: &mut DeferredEvaluatorWork,
2485    submodel: &mut Model,
2486    surfaces: &mut [RewriteSurface],
2487    scheduler: &mut WorklistScheduler,
2488    dirty_trace: &mut DirtyTrace,
2489) -> bool {
2490    if work.is_empty() {
2491        return false;
2492    }
2493
2494    let nodes = deferred_nodes_in_bottom_up_order(std::mem::take(&mut work.nodes), surfaces);
2495
2496    let mut affected_surfaces = HashSet::new();
2497    let mut changed_value_lettings = HashSet::new();
2498    for (surface, node_id, _) in nodes {
2499        let Some(rewrite_surface) = surfaces.get_mut(surface) else {
2500            continue;
2501        };
2502        if !rewrite_surface.active || !rewrite_surface.arena.is_reachable(node_id) {
2503            continue;
2504        }
2505        affected_surfaces.insert(surface);
2506        rewrite_surface.arena.rebuild_payload_from_children(node_id);
2507
2508        // Root-list reshaping is deliberately left to the final evaluator pass.
2509        if node_id != rewrite_surface.arena.root()
2510            && normalise_evaluator_node_to_fixpoint_without_ancestor_sync(
2511                &mut rewrite_surface.arena,
2512                node_id,
2513                dirty_trace,
2514            )
2515        {
2516            scheduler.index_subtree_references(&rewrite_surface.arena, surface, node_id);
2517            if let Some(name) = value_letting_surface_name(&rewrite_surface.kind) {
2518                changed_value_lettings.insert(name.clone());
2519            }
2520        }
2521    }
2522
2523    let mut wrote_value_letting = false;
2524    let mut changed_value_lettings: Vec<_> = changed_value_lettings.into_iter().collect();
2525    changed_value_lettings.sort();
2526    for name in &changed_value_lettings {
2527        let Some(surface) = surfaces.iter().find(|surface| {
2528            surface.active && value_letting_surface_name(&surface.kind) == Some(name)
2529        }) else {
2530            continue;
2531        };
2532        wrote_value_letting |=
2533            write_value_letting_surface_to_model_without_refresh(submodel, name, &surface.arena);
2534    }
2535    if wrote_value_letting {
2536        submodel.symbols_mut().refresh_local_binding_hashes();
2537        scheduler.invalidate_symbol_rule_failures();
2538        scheduler.enqueue_node_at_level(
2539            &surfaces[0].arena,
2540            0,
2541            surfaces[0].arena.root(),
2542            0,
2543            ScheduledMode::CheckNode,
2544            Some(dirty_trace),
2545        );
2546        enqueue_worklist_nodes_referencing_names(
2547            scheduler,
2548            surfaces,
2549            &changed_value_lettings,
2550            dirty_trace,
2551        );
2552    }
2553
2554    let mut affected_surfaces: Vec<_> = affected_surfaces.into_iter().collect();
2555    affected_surfaces.sort_unstable();
2556    for surface in affected_surfaces.iter().copied() {
2557        let arena = &surfaces[surface].arena;
2558        scheduler.enqueue_subtree(arena, surface, arena.root(), Some(dirty_trace));
2559    }
2560
2561    !affected_surfaces.is_empty()
2562}
2563
2564fn deferred_nodes_in_bottom_up_order(
2565    nodes: impl IntoIterator<Item = (usize, ExpressionNodeId)>,
2566    surfaces: &[RewriteSurface],
2567) -> Vec<(usize, ExpressionNodeId, Option<Vec<usize>>)> {
2568    // Cache each rarely needed path once. Reconstructing paths inside the comparison closure would
2569    // turn sorting into O(n log n) parent-chain walks.
2570    let mut nodes: Vec<_> = nodes
2571        .into_iter()
2572        .map(|(surface, node_id)| {
2573            let path = surfaces
2574                .get(surface)
2575                .filter(|rewrite_surface| rewrite_surface.active)
2576                .filter(|rewrite_surface| rewrite_surface.arena.is_reachable(node_id))
2577                .map(|rewrite_surface| rewrite_surface.arena.preorder_path(node_id));
2578            (surface, node_id, path)
2579        })
2580        .collect();
2581    nodes.sort_by(
2582        |(left_surface, left_node, left_path), (right_surface, right_node, right_path)| {
2583            right_path
2584                .as_ref()
2585                .map_or(0, Vec::len)
2586                .cmp(&left_path.as_ref().map_or(0, Vec::len))
2587                .then_with(|| left_surface.cmp(right_surface))
2588                .then_with(|| match (left_path, right_path) {
2589                    (Some(left_path), Some(right_path)) => left_path.cmp(right_path),
2590                    _ => left_node.cmp(right_node),
2591                })
2592        },
2593    );
2594    nodes
2595}
2596
2597/// Applies evaluator normalisation throughout an existing arena surface.
2598///
2599/// This is the initial half of the evaluator normalisation hook. Evaluation is a privileged pure
2600/// simplification rather than an ordinary rule: before normal rule scheduling starts, each surface
2601/// is normalised bottom-up so parent evaluators can assume already-normal children. Comprehensions
2602/// remain atomic here for the same scoped-rewrite reason as normal scheduler traversal.
2603fn normalise_evaluators_bottom_up(arena: &mut ExpressionArena, dirty_trace: &mut DirtyTrace) {
2604    normalise_evaluators_subtree_bottom_up(arena, arena.root(), dirty_trace);
2605}
2606
2607fn normalise_evaluators_subtree_bottom_up(
2608    arena: &mut ExpressionArena,
2609    subtree_root: ExpressionNodeId,
2610    dirty_trace: &mut DirtyTrace,
2611) -> bool {
2612    let nodes = rewriter_reachable_subtree_ids(arena, subtree_root);
2613    let mut changed = false;
2614
2615    for node_id in nodes.into_iter().rev() {
2616        if !arena.is_reachable(node_id) {
2617            continue;
2618        }
2619        changed |=
2620            normalise_evaluator_node_to_fixpoint_without_ancestor_sync(arena, node_id, dirty_trace);
2621
2622        // Copy the child's new value one level up. Going deepest first means each parent is up
2623        // to date before its own evaluator runs, without touching wider ancestors every time
2624        // something folds. Anything above the subtree root is the caller's job: the worklist
2625        // defers it, the non-worklist path updates it directly.
2626        if node_id != subtree_root
2627            && let Some(parent_id) = arena.parent(node_id)
2628            && arena.is_reachable(parent_id)
2629        {
2630            arena.sync_payload_for_changed_child(parent_id, node_id);
2631        }
2632    }
2633
2634    changed
2635}
2636
2637/// Applies the post-rewrite evaluator hook from `node_id` up to the root.
2638///
2639/// Ordinary rules still obey the rewriter invariant that higher priority wins and, within a
2640/// priority, enclosing expressions are tried before descendants. Evaluators are the explicit
2641/// exception: after an ordinary rule changes a subtree, local full/partial evaluation is allowed to
2642/// fire immediately at that node and then at each ancestor because such simplification is always
2643/// preferable to scheduling another ordinary rule on the unevaluated expression.
2644fn normalise_evaluators_from_node_to_root(
2645    arena: &mut ExpressionArena,
2646    node_id: ExpressionNodeId,
2647    dirty_trace: &mut DirtyTrace,
2648) -> (ExpressionNodeId, bool) {
2649    // Ordinary rewrites can introduce fresh nested arithmetic whose children were not previously
2650    // scheduled. Normalise the replacement subtree bottom-up first, then walk upward so evaluator
2651    // simplification remains privileged without running ordinary rules to a fixpoint.
2652    let subtree_changed = normalise_evaluators_subtree_bottom_up(arena, node_id, dirty_trace);
2653    let mut came_from = node_id;
2654    let mut current = arena.parent(node_id);
2655    let mut highest_rewritten = node_id;
2656    let mut changed = subtree_changed;
2657
2658    while let Some(current_id) = current {
2659        if !arena.is_reachable(current_id) {
2660            break;
2661        }
2662
2663        let rewritten = if current_id == arena.root() {
2664            // Only deep-normalise the root child that contains the rewrite. Full-root selective
2665            // deep on every upward walk re-traverses every non-flat sibling and dominates CPU on
2666            // large models (lee-distance profile).
2667            normalise_root_evaluator_for_child(arena, came_from, dirty_trace)
2668        } else {
2669            normalise_evaluator_node_to_fixpoint(arena, current_id, dirty_trace)
2670        };
2671        if rewritten {
2672            highest_rewritten = current_id;
2673            changed = true;
2674        }
2675        came_from = current_id;
2676        current = arena.parent(current_id);
2677    }
2678
2679    (highest_rewritten, changed)
2680}
2681
2682/// Deep-normalises only the root constraint that `child_id` belongs to.
2683///
2684/// `child_id` must be a direct child of the arena root. Sibling constraints are left untouched.
2685///
2686/// The replacement is written to `child_id` rather than to the root. Rebuilding the root here
2687/// would clone every sibling constraint and re-import the whole model into the arena, which this
2688/// hook cannot afford: it runs after every rewrite.
2689fn normalise_root_evaluator_for_child(
2690    arena: &mut ExpressionArena,
2691    child_id: ExpressionNodeId,
2692    dirty_trace: &mut DirtyTrace,
2693) -> bool {
2694    let root_id = arena.root();
2695    if !arena.children(root_id).contains(&child_id) {
2696        return normalise_evaluator_node_to_fixpoint(arena, root_id, dirty_trace);
2697    }
2698
2699    let Some(replacement) = normalise_root_constraint_deep(arena.expression(child_id)) else {
2700        return false;
2701    };
2702
2703    dirty_trace.replacement_subtree_clears += 1;
2704    arena.replace_subtree(child_id, replacement);
2705    dirty_trace.record_rewrite("evaluator_normalisation_hook", false);
2706    sync_ancestor_payloads(arena, child_id);
2707    true
2708}
2709
2710fn normalise_evaluator_node_to_fixpoint(
2711    arena: &mut ExpressionArena,
2712    node_id: ExpressionNodeId,
2713    dirty_trace: &mut DirtyTrace,
2714) -> bool {
2715    let changed =
2716        normalise_evaluator_node_to_fixpoint_without_ancestor_sync(arena, node_id, dirty_trace);
2717
2718    if changed {
2719        sync_ancestor_payloads(arena, node_id);
2720    }
2721
2722    changed
2723}
2724
2725fn normalise_evaluator_node_to_fixpoint_without_ancestor_sync(
2726    arena: &mut ExpressionArena,
2727    node_id: ExpressionNodeId,
2728    dirty_trace: &mut DirtyTrace,
2729) -> bool {
2730    let mut changed = false;
2731
2732    while arena.is_reachable(node_id) {
2733        let expr = arena.expression(node_id);
2734        let Some(replacement) = normalise_evaluator_local(expr) else {
2735            break;
2736        };
2737
2738        dirty_trace.replacement_subtree_clears += 1;
2739        arena.replace_subtree(node_id, replacement);
2740        dirty_trace.record_rewrite("evaluator_normalisation_hook", false);
2741        changed = true;
2742    }
2743
2744    changed
2745}
2746
2747fn build_worklist_surfaces(
2748    submodel: &Model,
2749    root_arena: ExpressionArena,
2750) -> (Vec<RewriteSurface>, HashMap<Name, usize>) {
2751    let mut surfaces = vec![RewriteSurface::root(root_arena)];
2752    let mut value_letting_surfaces = HashMap::new();
2753
2754    let symbols = submodel.symbols();
2755    for (name, decl) in symbols.iter_local() {
2756        let letting_expr = decl.as_value_letting().map(|expr| expr.clone());
2757        if let Some(expr) = letting_expr {
2758            let surface = surfaces.len();
2759            surfaces.push(RewriteSurface::value_letting(name.clone(), expr));
2760            value_letting_surfaces.insert(name.clone(), surface);
2761        }
2762    }
2763
2764    (surfaces, value_letting_surfaces)
2765}
2766
2767fn value_letting_surface_name(kind: &RewriteSurfaceKind) -> Option<&Name> {
2768    match kind {
2769        RewriteSurfaceKind::Root => None,
2770        RewriteSurfaceKind::ValueLetting { name } => Some(name),
2771    }
2772}
2773
2774fn current_value_letting_expression(submodel: &Model, name: &Name) -> Option<Expr> {
2775    let declaration = {
2776        let symbols = submodel.symbols();
2777        symbols.lookup_local(name)
2778    }?;
2779    declaration.as_value_letting().map(|expr| expr.clone())
2780}
2781
2782fn sync_value_letting_surfaces(
2783    submodel: &Model,
2784    surfaces: &mut Vec<RewriteSurface>,
2785    value_letting_surfaces: &mut HashMap<Name, usize>,
2786    names: &[Name],
2787) -> Vec<usize> {
2788    let mut synced_surfaces = Vec::new();
2789    let mut seen = HashSet::new();
2790
2791    for name in names {
2792        if !seen.insert(name.clone()) {
2793            continue;
2794        }
2795
2796        if let Some(expr) = current_value_letting_expression(submodel, name) {
2797            if let Some(old_surface) = value_letting_surfaces.get(name).copied()
2798                && let Some(surface) = surfaces.get_mut(old_surface)
2799            {
2800                surface.active = false;
2801            }
2802            let new_surface = surfaces.len();
2803            surfaces.push(RewriteSurface::value_letting(name.clone(), expr));
2804            value_letting_surfaces.insert(name.clone(), new_surface);
2805            synced_surfaces.push(new_surface);
2806        } else if let Some(old_surface) = value_letting_surfaces.remove(name)
2807            && let Some(surface) = surfaces.get_mut(old_surface)
2808        {
2809            surface.active = false;
2810        }
2811    }
2812
2813    synced_surfaces
2814}
2815
2816fn write_worklist_surfaces_to_model(submodel: &mut Model, surfaces: &[RewriteSurface]) {
2817    let root = surfaces[0]
2818        .arena
2819        .expression(surfaces[0].arena.root())
2820        .clone();
2821    submodel.replace_root(root);
2822
2823    let mut wrote_value_letting = false;
2824    for surface in surfaces.iter().skip(1) {
2825        if !surface.active {
2826            continue;
2827        }
2828        let Some(name) = value_letting_surface_name(&surface.kind) else {
2829            continue;
2830        };
2831        let expression = surface.arena.expression(surface.arena.root()).clone();
2832        wrote_value_letting |=
2833            write_value_letting_expression_to_model_without_refresh(submodel, name, expression);
2834    }
2835    if wrote_value_letting {
2836        submodel.symbols_mut().refresh_local_binding_hashes();
2837    }
2838}
2839
2840fn move_worklist_surfaces_to_model(submodel: &mut Model, surfaces: Vec<RewriteSurface>) {
2841    let mut surfaces = surfaces.into_iter();
2842    let root = surfaces.next().expect("worklist must have a root surface");
2843    submodel.replace_root(root.arena.into_synced_root_expression());
2844
2845    let mut wrote_value_letting = false;
2846    for surface in surfaces {
2847        if !surface.active {
2848            continue;
2849        }
2850        let RewriteSurfaceKind::ValueLetting { name } = surface.kind else {
2851            continue;
2852        };
2853        wrote_value_letting |= write_value_letting_expression_to_model_without_refresh(
2854            submodel,
2855            &name,
2856            surface.arena.into_synced_root_expression(),
2857        );
2858    }
2859    if wrote_value_letting {
2860        submodel.symbols_mut().refresh_local_binding_hashes();
2861    }
2862}
2863
2864fn write_value_letting_surface_to_model(
2865    submodel: &mut Model,
2866    name: &Name,
2867    arena: &ExpressionArena,
2868) -> bool {
2869    let written = write_value_letting_surface_to_model_without_refresh(submodel, name, arena);
2870    if written {
2871        submodel.symbols_mut().refresh_local_binding_hashes();
2872    }
2873    written
2874}
2875
2876fn write_value_letting_surface_to_model_without_refresh(
2877    submodel: &mut Model,
2878    name: &Name,
2879    arena: &ExpressionArena,
2880) -> bool {
2881    write_value_letting_expression_to_model_without_refresh(
2882        submodel,
2883        name,
2884        arena.expression_from(arena.root()),
2885    )
2886}
2887
2888fn write_value_letting_expression_to_model_without_refresh(
2889    submodel: &mut Model,
2890    name: &Name,
2891    expression: Expr,
2892) -> bool {
2893    let declaration = {
2894        let symbols = submodel.symbols();
2895        symbols.lookup_local(name)
2896    };
2897    let Some(mut declaration) = declaration else {
2898        return false;
2899    };
2900    {
2901        let Some(mut letting) = declaration.as_value_letting_mut() else {
2902            return false;
2903        };
2904
2905        *letting = expression;
2906    }
2907    true
2908}
2909
2910fn enqueue_worklist_nodes_referencing_names(
2911    scheduler: &mut WorklistScheduler,
2912    surfaces: &[RewriteSurface],
2913    names: &[Name],
2914    dirty_trace: &mut DirtyTrace,
2915) {
2916    if names.is_empty() {
2917        return;
2918    }
2919
2920    // A `BTreeSet` rather than a hash set: the scheduler breaks depth ties by enqueue order, so
2921    // the order these are inserted in has to be the same on every run.
2922    let mut affected_nodes = BTreeSet::new();
2923    for name in names {
2924        let Some(reference_nodes) = scheduler.reference_nodes_by_name.get(name) else {
2925            continue;
2926        };
2927        for &(surface_index, reference_node) in reference_nodes {
2928            let Some(surface) = surfaces.get(surface_index) else {
2929                continue;
2930            };
2931            if !surface.active
2932                || !surface.arena.is_reachable(reference_node)
2933                || !expression_directly_references_any(
2934                    surface.arena.expression(reference_node),
2935                    std::slice::from_ref(name),
2936                )
2937            {
2938                continue;
2939            }
2940
2941            let mut current = Some(reference_node);
2942            while let Some(node_id) = current {
2943                affected_nodes.insert((surface_index, node_id));
2944                current = surface.arena.parent(node_id);
2945            }
2946        }
2947    }
2948
2949    for (surface_index, node_id) in affected_nodes {
2950        let surface = &surfaces[surface_index];
2951        scheduler.enqueue_node_at_level(
2952            &surface.arena,
2953            surface_index,
2954            node_id,
2955            0,
2956            ScheduledMode::CheckNode,
2957            Some(dirty_trace),
2958        );
2959    }
2960}
2961
2962#[cfg(test)]
2963fn collect_worklist_nodes_referencing_names(
2964    arena: &ExpressionArena,
2965    node_id: ExpressionNodeId,
2966    names: &[Name],
2967    affected_nodes: &mut Vec<ExpressionNodeId>,
2968) -> bool {
2969    if !arena.is_reachable(node_id) {
2970        return false;
2971    }
2972
2973    let mut references_changed_name =
2974        expression_directly_references_any(arena.expression(node_id), names);
2975    if !matches!(arena.expression(node_id), Expr::Comprehension(_, _)) {
2976        for &child_id in arena.children(node_id) {
2977            references_changed_name |=
2978                collect_worklist_nodes_referencing_names(arena, child_id, names, affected_nodes);
2979        }
2980    }
2981
2982    if references_changed_name {
2983        affected_nodes.push(node_id);
2984    }
2985    references_changed_name
2986}
2987
2988fn expression_directly_references_any(expr: &Expr, names: &[Name]) -> bool {
2989    let Expr::Atomic(_, Atom::Reference(reference)) = expr else {
2990        return false;
2991    };
2992    names.iter().any(|name| &*reference.name() == name)
2993}
2994
2995fn increment_counter(counter: &mut Option<usize>) {
2996    *counter = Some(counter.unwrap_or(0) + 1);
2997}
2998
2999/// Returns expression node ids in rewriter preorder, without entering comprehensions.
3000fn rewriter_preorder_ids(arena: &ExpressionArena) -> Vec<ExpressionNodeId> {
3001    rewriter_reachable_subtree_ids(arena, arena.root())
3002}
3003
3004/// Returns reachable expression node ids under `node_id` in rewriter preorder.
3005///
3006/// The rewrite pass treats comprehensions as atomic here; comprehension expansion is responsible
3007/// for rewriting their bodies in the right scoped context.
3008fn rewriter_reachable_subtree_ids(
3009    arena: &ExpressionArena,
3010    node_id: ExpressionNodeId,
3011) -> Vec<ExpressionNodeId> {
3012    fn collect(
3013        arena: &ExpressionArena,
3014        node_id: ExpressionNodeId,
3015        nodes: &mut Vec<ExpressionNodeId>,
3016    ) {
3017        if !arena.is_reachable(node_id) {
3018            return;
3019        }
3020        nodes.push(node_id);
3021        if matches!(arena.expression(node_id), Expr::Comprehension(_, _)) {
3022            return;
3023        }
3024
3025        for child in arena.children(node_id) {
3026            collect(arena, *child, nodes);
3027        }
3028    }
3029
3030    let mut nodes = Vec::new();
3031    collect(arena, node_id, &mut nodes);
3032    nodes
3033}
3034
3035/// Replaces the focused expression and updates ancestor payloads to match it.
3036fn replace_focus_and_sync_ancestors(
3037    arena: &mut ExpressionArena,
3038    node_id: ExpressionNodeId,
3039    new_focus: Expr,
3040) {
3041    arena.replace_subtree(node_id, new_focus);
3042    sync_ancestor_payloads(arena, node_id);
3043}
3044
3045fn sync_ancestor_payloads(arena: &mut ExpressionArena, node_id: ExpressionNodeId) {
3046    let mut child_id = node_id;
3047    let mut ancestor = arena.parent(node_id);
3048    while let Some(ancestor_id) = ancestor {
3049        // Same-arity parent update: clone only the changed child into the parent payload.
3050        arena.sync_payload_for_changed_child(ancestor_id, child_id);
3051        child_id = ancestor_id;
3052        ancestor = arena.parent(ancestor_id);
3053    }
3054}
3055
3056fn take_model_root(model: &mut Model) -> Expr {
3057    model.replace_root(Expr::Root(Metadata::new(), Vec::new()))
3058}
3059
3060fn rule_is_universal(rule_data: &RuleData<'_>) -> bool {
3061    rule_data.rule.prefilters.is_none()
3062}
3063
3064fn rule_matches_self_discriminant(rule_data: &RuleData<'_>, expr_discriminant: usize) -> bool {
3065    rule_data
3066        .rule
3067        .prefilters
3068        .is_some_and(|prefilters| {
3069            prefilters.iter().any(|prefilter| {
3070                matches!(prefilter, RulePrefilter::Variant(discriminant) if *discriminant == expr_discriminant)
3071            })
3072        })
3073}
3074
3075fn rule_matches_specific_prefilter(
3076    rule_data: &RuleData<'_>,
3077    expr: &Expr,
3078    arena_node: Option<(&ExpressionArena, ExpressionNodeId)>,
3079) -> bool {
3080    if rule_is_universal(rule_data) {
3081        return false;
3082    }
3083
3084    let expr_discriminant = discriminant_from_value(expr);
3085    rule_data.rule.prefilters.is_some_and(|prefilters| {
3086        prefilters.iter().any(|prefilter| match prefilter {
3087            RulePrefilter::Variant(discriminant) => *discriminant == expr_discriminant,
3088            RulePrefilter::Child { child } => {
3089                has_direct_child_discriminant(expr, arena_node, *child)
3090            }
3091            RulePrefilter::VariantChild { variant, child } => {
3092                *variant == expr_discriminant
3093                    && has_direct_child_discriminant(expr, arena_node, *child)
3094            }
3095            RulePrefilter::Atom(atom_kind) => expr_atom_kind(expr) == Some(*atom_kind),
3096            RulePrefilter::ChildAtom(atom_kind) => {
3097                expr_has_direct_child_atom_kind(expr, *atom_kind)
3098            }
3099        })
3100    })
3101}
3102
3103fn has_direct_child_discriminant(
3104    expr: &Expr,
3105    arena_node: Option<(&ExpressionArena, ExpressionNodeId)>,
3106    target_discriminant: usize,
3107) -> bool {
3108    if let Some((arena, node_id)) = arena_node {
3109        arena.has_direct_child_discriminant(node_id, target_discriminant)
3110    } else {
3111        expr_has_direct_child_discriminant(expr, &[target_discriminant])
3112    }
3113}
3114
3115fn expr_atom_kind(expr: &Expr) -> Option<AtomKind> {
3116    match expr {
3117        Expr::Atomic(_, Atom::Literal(_)) => Some(AtomKind::Literal),
3118        Expr::Atomic(_, Atom::Reference(_)) => Some(AtomKind::Reference),
3119        _ => None,
3120    }
3121}
3122
3123/// Returns whether `expr` has an immediate `Atomic` child of this kind.
3124///
3125/// Direct children are walked rather than read off the arena's cached child discriminants, which
3126/// record `Expression` variants only and so cannot tell a reference from a literal.
3127fn expr_has_direct_child_atom_kind(expr: &Expr, target: AtomKind) -> bool {
3128    let mut found = false;
3129    expr.for_each_expr_child(&mut |child| {
3130        if !found && expr_atom_kind(child) == Some(target) {
3131            found = true;
3132        }
3133    });
3134    found
3135}
3136
3137fn expr_has_direct_child_discriminant(expr: &Expr, target_discriminants: &[usize]) -> bool {
3138    if target_discriminants.is_empty() {
3139        return false;
3140    }
3141
3142    let mut found = false;
3143    expr.for_each_expr_child(&mut |child| {
3144        if !found && target_discriminants.contains(&discriminant_from_value(child)) {
3145            found = true;
3146        }
3147    });
3148    found
3149}
3150
3151fn csv_escape(field: &str) -> String {
3152    if field.contains([',', '"', '\n', '\r']) {
3153        format!("\"{}\"", field.replace('"', "\"\""))
3154    } else {
3155        field.to_string()
3156    }
3157}
3158
3159#[cfg(test)]
3160mod tests {
3161    use crate::ast::comprehension::ComprehensionBuilder;
3162    use crate::ast::{Atom, DeclarationPtr, ExpressionArena, Literal, Moo, SymbolTablePtr};
3163    use crate::matrix_expr;
3164
3165    use super::*;
3166
3167    fn int_lit(value: i32) -> Expr {
3168        Expr::Atomic(Metadata::new(), Atom::Literal(Literal::Int(value)))
3169    }
3170
3171    fn bool_lit(value: bool) -> Expr {
3172        Expr::Atomic(Metadata::new(), Atom::Literal(Literal::Bool(value)))
3173    }
3174
3175    fn root(exprs: Vec<Expr>) -> Expr {
3176        Expr::Root(Metadata::new(), exprs)
3177    }
3178
3179    fn comprehension(return_expression: Expr, guards: Vec<Expr>) -> Expr {
3180        let mut builder = ComprehensionBuilder::new(SymbolTablePtr::new());
3181        for guard in guards {
3182            builder = builder.guard(guard);
3183        }
3184        Expr::Comprehension(
3185            Metadata::new(),
3186            Moo::new(builder.with_return_value(return_expression)),
3187        )
3188    }
3189
3190    #[test]
3191    fn dirty_trace_filename_replaces_path_separators_and_module_delimiters() {
3192        assert_eq!(
3193            sanitize_dirty_trace_filename("generated_tests::savilerow/quasiGroup6"),
3194            "generated_tests--savilerow-quasiGroup6"
3195        );
3196    }
3197
3198    #[test]
3199    fn dirty_trace_bare_path_is_directory_destination() {
3200        assert_eq!(
3201            dirty_trace_destination_from_env_value("trace-dir".into()),
3202            DirtyTraceDestination::Directory(PathBuf::from("trace-dir"))
3203        );
3204    }
3205
3206    #[test]
3207    fn dirty_trace_txt_path_is_file_destination() {
3208        assert_eq!(
3209            dirty_trace_destination_from_env_value("trace.txt".into()),
3210            DirtyTraceDestination::File(PathBuf::from("trace.txt"))
3211        );
3212    }
3213
3214    fn reference_expr(name: &Name) -> Expr {
3215        use crate::ast::{Domain, Range, Reference};
3216
3217        Expr::Atomic(
3218            Metadata::new(),
3219            Atom::Reference(Reference::new(DeclarationPtr::new_find(
3220                name.clone(),
3221                Domain::int(vec![Range::Bounded(1, 3)]),
3222            ))),
3223        )
3224    }
3225
3226    fn test_rule_set_applies(_: &crate::settings::SolverFamily) -> bool {
3227        true
3228    }
3229
3230    fn never_apply_test_rule(
3231        _: &Expr,
3232        _: &crate::ast::SymbolTable,
3233    ) -> crate::rule_engine::ApplicationResult {
3234        Err(crate::rule_engine::ApplicationError::RuleNotApplicable)
3235    }
3236
3237    static TEST_RULE_SET: RuleSet<'static> =
3238        RuleSet::new("test-rule-set", &[], test_rule_set_applies);
3239    static TEST_RULE: crate::rule_engine::Rule<'static> = crate::rule_engine::Rule::new(
3240        "never-apply-test-rule",
3241        never_apply_test_rule,
3242        &[("test-rule-set", 1)],
3243    );
3244    static TEST_NO_TARGET_RULE: crate::rule_engine::Rule<'static> = crate::rule_engine::Rule {
3245        name: "no-target-test-rule",
3246        application: never_apply_test_rule,
3247        rule_sets: &[("test-rule-set", 1)],
3248        prefilters: Some(&[]),
3249        failure_invalidation: crate::rule_engine::RuleFailureInvalidation::ExpressionOrSymbols,
3250    };
3251    static TEST_SYMBOLS_ONLY_RULE: crate::rule_engine::Rule<'static> = crate::rule_engine::Rule {
3252        name: "symbols-only-test-rule",
3253        application: never_apply_test_rule,
3254        rule_sets: &[("test-rule-set", 1)],
3255        prefilters: None,
3256        failure_invalidation: crate::rule_engine::RuleFailureInvalidation::SymbolsOnly,
3257    };
3258    fn test_rule_groups_at_priorities(priorities: &[u16]) -> Vec<RuleGroup<'static>> {
3259        priorities
3260            .iter()
3261            .map(|&priority| {
3262                RuleGroup::new(
3263                    priority,
3264                    vec![crate::rule_engine::RuleData {
3265                        rule: &TEST_RULE,
3266                        priority,
3267                        rule_set: &TEST_RULE_SET,
3268                    }],
3269                )
3270            })
3271            .collect()
3272    }
3273
3274    fn test_rule_groups() -> Vec<RuleGroup<'static>> {
3275        test_rule_groups_at_priorities(&[1])
3276    }
3277
3278    fn test_rule_groups_with_two_levels() -> Vec<RuleGroup<'static>> {
3279        test_rule_groups_at_priorities(&[1, 2])
3280    }
3281
3282    fn test_rule_groups_with_no_candidate_middle_level() -> Vec<RuleGroup<'static>> {
3283        vec![
3284            RuleGroup::new(
3285                1,
3286                vec![crate::rule_engine::RuleData {
3287                    rule: &TEST_RULE,
3288                    priority: 1,
3289                    rule_set: &TEST_RULE_SET,
3290                }],
3291            ),
3292            RuleGroup::new(
3293                2,
3294                vec![crate::rule_engine::RuleData {
3295                    rule: &TEST_NO_TARGET_RULE,
3296                    priority: 2,
3297                    rule_set: &TEST_RULE_SET,
3298                }],
3299            ),
3300            RuleGroup::new(
3301                3,
3302                vec![crate::rule_engine::RuleData {
3303                    rule: &TEST_RULE,
3304                    priority: 3,
3305                    rule_set: &TEST_RULE_SET,
3306                }],
3307            ),
3308        ]
3309    }
3310
3311    fn test_rule_groups_targeting_expr(expr: &Expr) -> Vec<RuleGroup<'static>> {
3312        let discriminant = discriminant_from_value(expr);
3313        let mut rules_by_discriminant = Vec::new();
3314        rules_by_discriminant.resize_with(discriminant + 1, || None);
3315        rules_by_discriminant[discriminant] = Some(vec![crate::rule_engine::RuleData {
3316            rule: &TEST_RULE,
3317            priority: 1,
3318            rule_set: &TEST_RULE_SET,
3319        }]);
3320
3321        vec![RuleGroup {
3322            priority: 1,
3323            rules: vec![crate::rule_engine::RuleData {
3324                rule: &TEST_RULE,
3325                priority: 1,
3326                rule_set: &TEST_RULE_SET,
3327            }],
3328            rules_by_discriminant,
3329            universal_rules: Vec::new(),
3330            has_non_discriminant_filters: false,
3331        }]
3332    }
3333
3334    #[test]
3335    fn rule_group_child_filter_matches_immediate_child_kind() {
3336        let bubble_discriminant = discriminant_from_value(&Expr::Bubble(
3337            Metadata::new(),
3338            Moo::new(int_lit(0)),
3339            Moo::new(bool_lit(true)),
3340        ));
3341        let child_prefilters: &'static [RulePrefilter] = Box::leak(
3342            vec![RulePrefilter::Child {
3343                child: bubble_discriminant,
3344            }]
3345            .into_boxed_slice(),
3346        );
3347        let child_bubble_rule: &'static crate::rule_engine::Rule<'static> =
3348            Box::leak(Box::new(crate::rule_engine::Rule {
3349                name: "child-bubble-test-rule",
3350                application: never_apply_test_rule,
3351                rule_sets: &[("test-rule-set", 1)],
3352                prefilters: Some(child_prefilters),
3353                failure_invalidation:
3354                    crate::rule_engine::RuleFailureInvalidation::ExpressionOrSymbols,
3355            }));
3356        let rule_group = RuleGroup::new(
3357            1,
3358            vec![crate::rule_engine::RuleData {
3359                rule: child_bubble_rule,
3360                priority: 1,
3361                rule_set: &TEST_RULE_SET,
3362            }],
3363        );
3364        let config = RewriteConfig::optimised();
3365
3366        let expr_with_bubble_child = Expr::Eq(
3367            Metadata::new(),
3368            Moo::new(Expr::Bubble(
3369                Metadata::new(),
3370                Moo::new(int_lit(1)),
3371                Moo::new(bool_lit(true)),
3372            )),
3373            Moo::new(int_lit(2)),
3374        );
3375        let expr_without_bubble_child =
3376            Expr::Eq(Metadata::new(), Moo::new(int_lit(1)), Moo::new(int_lit(2)));
3377
3378        assert!(rule_group.has_candidates(config, &expr_with_bubble_child));
3379        assert_eq!(
3380            rule_group
3381                .candidates(config, &expr_with_bubble_child)
3382                .map(|rule_data| rule_data.rule.name)
3383                .collect_vec(),
3384            vec!["child-bubble-test-rule"]
3385        );
3386        assert!(!rule_group.has_candidates(config, &expr_without_bubble_child));
3387    }
3388
3389    #[test]
3390    fn rule_group_atom_filter_matches_atomic_reference() {
3391        let atom_reference_rule: &'static crate::rule_engine::Rule<'static> =
3392            Box::leak(Box::new(crate::rule_engine::Rule {
3393                name: "atom-reference-test-rule",
3394                application: never_apply_test_rule,
3395                rule_sets: &[("test-rule-set", 1)],
3396                prefilters: Some(&[RulePrefilter::Atom(AtomKind::Reference)]),
3397                failure_invalidation:
3398                    crate::rule_engine::RuleFailureInvalidation::ExpressionOrSymbols,
3399            }));
3400        let rule_group = RuleGroup::new(
3401            1,
3402            vec![crate::rule_engine::RuleData {
3403                rule: atom_reference_rule,
3404                priority: 1,
3405                rule_set: &TEST_RULE_SET,
3406            }],
3407        );
3408        let config = RewriteConfig::optimised();
3409        let reference = reference_expr(&Name::user("x"));
3410        let literal = int_lit(1);
3411        let composite = Expr::Eq(Metadata::new(), Moo::new(int_lit(1)), Moo::new(int_lit(2)));
3412
3413        assert!(rule_group.has_candidates(config, &reference));
3414        assert_eq!(
3415            rule_group
3416                .candidates(config, &reference)
3417                .map(|rule_data| rule_data.rule.name)
3418                .collect_vec(),
3419            vec!["atom-reference-test-rule"]
3420        );
3421        assert!(!rule_group.has_candidates(config, &literal));
3422        assert!(!rule_group.has_candidates(config, &composite));
3423    }
3424
3425    #[test]
3426    fn rule_group_child_atom_filter_matches_direct_atomic_reference_child() {
3427        let child_atom_rule: &'static crate::rule_engine::Rule<'static> =
3428            Box::leak(Box::new(crate::rule_engine::Rule {
3429                name: "child-atom-reference-test-rule",
3430                application: never_apply_test_rule,
3431                rule_sets: &[("test-rule-set", 1)],
3432                prefilters: Some(&[RulePrefilter::ChildAtom(AtomKind::Reference)]),
3433                failure_invalidation:
3434                    crate::rule_engine::RuleFailureInvalidation::ExpressionOrSymbols,
3435            }));
3436        let rule_group = RuleGroup::new(
3437            1,
3438            vec![crate::rule_engine::RuleData {
3439                rule: child_atom_rule,
3440                priority: 1,
3441                rule_set: &TEST_RULE_SET,
3442            }],
3443        );
3444        let config = RewriteConfig::optimised();
3445        let with_reference_child = Expr::Eq(
3446            Metadata::new(),
3447            Moo::new(reference_expr(&Name::user("x"))),
3448            Moo::new(int_lit(2)),
3449        );
3450        // Literal children have the same `Atomic` variant, so only the atom kind separates them.
3451        let with_literal_children =
3452            Expr::Eq(Metadata::new(), Moo::new(int_lit(1)), Moo::new(int_lit(2)));
3453        // The reference itself is not a match: the filter is about children.
3454        let bare_reference = reference_expr(&Name::user("x"));
3455
3456        assert!(rule_group.has_candidates(config, &with_reference_child));
3457        assert_eq!(
3458            rule_group
3459                .candidates(config, &with_reference_child)
3460                .map(|rule_data| rule_data.rule.name)
3461                .collect_vec(),
3462            vec!["child-atom-reference-test-rule"]
3463        );
3464        assert!(!rule_group.has_candidates(config, &with_literal_children));
3465        assert!(!rule_group.has_candidates(config, &bare_reference));
3466    }
3467
3468    #[test]
3469    fn rule_group_includes_universal_rules_in_variant_buckets() {
3470        let lex_discriminant = discriminant_from_value(&Expr::LexLt(
3471            Metadata::new(),
3472            Moo::new(int_lit(1)),
3473            Moo::new(int_lit(2)),
3474        ));
3475        let lex_prefilters: &'static [RulePrefilter] =
3476            Box::leak(Box::new([RulePrefilter::Variant(lex_discriminant)]));
3477        let variant_rule: &'static crate::rule_engine::Rule<'static> =
3478            Box::leak(Box::new(crate::rule_engine::Rule {
3479                name: "variant-specific-test-rule",
3480                application: never_apply_test_rule,
3481                rule_sets: &[("test-rule-set", 1)],
3482                prefilters: Some(lex_prefilters),
3483                failure_invalidation:
3484                    crate::rule_engine::RuleFailureInvalidation::ExpressionOrSymbols,
3485            }));
3486        let universal_rule: &'static crate::rule_engine::Rule<'static> =
3487            Box::leak(Box::new(crate::rule_engine::Rule {
3488                name: "universal-test-rule",
3489                application: never_apply_test_rule,
3490                rule_sets: &[("test-rule-set", 1)],
3491                prefilters: None,
3492                failure_invalidation:
3493                    crate::rule_engine::RuleFailureInvalidation::ExpressionOrSymbols,
3494            }));
3495        let rule_group = RuleGroup::new(
3496            1,
3497            vec![
3498                crate::rule_engine::RuleData {
3499                    rule: variant_rule,
3500                    priority: 1,
3501                    rule_set: &TEST_RULE_SET,
3502                },
3503                crate::rule_engine::RuleData {
3504                    rule: universal_rule,
3505                    priority: 1,
3506                    rule_set: &TEST_RULE_SET,
3507                },
3508            ],
3509        );
3510        let config = RewriteConfig::optimised();
3511        let lex = Expr::LexLt(Metadata::new(), Moo::new(int_lit(1)), Moo::new(int_lit(2)));
3512
3513        assert!(rule_group.has_candidates(config, &lex));
3514        assert_eq!(
3515            rule_group
3516                .candidates(config, &lex)
3517                .map(|rule_data| rule_data.rule.name)
3518                .collect_vec(),
3519            vec!["variant-specific-test-rule", "universal-test-rule"]
3520        );
3521    }
3522
3523    #[test]
3524    fn rule_group_variant_child_filter_does_not_cross_product_alternatives() {
3525        let and_discriminant = discriminant_from_value(&Expr::And(
3526            Metadata::new(),
3527            Moo::new(matrix_expr![bool_lit(true)]),
3528        ));
3529        let or_discriminant = discriminant_from_value(&Expr::Or(
3530            Metadata::new(),
3531            Moo::new(matrix_expr![bool_lit(true)]),
3532        ));
3533        let comprehension_discriminant =
3534            discriminant_from_value(&comprehension(bool_lit(true), vec![]));
3535        let atomic_discriminant = discriminant_from_value(&bool_lit(true));
3536        let paired_prefilters: &'static [RulePrefilter] = Box::leak(
3537            vec![
3538                RulePrefilter::VariantChild {
3539                    variant: and_discriminant,
3540                    child: comprehension_discriminant,
3541                },
3542                RulePrefilter::VariantChild {
3543                    variant: or_discriminant,
3544                    child: atomic_discriminant,
3545                },
3546            ]
3547            .into_boxed_slice(),
3548        );
3549        let paired_rule: &'static crate::rule_engine::Rule<'static> =
3550            Box::leak(Box::new(crate::rule_engine::Rule {
3551                name: "paired-prefilter-test-rule",
3552                application: never_apply_test_rule,
3553                rule_sets: &[("test-rule-set", 1)],
3554                prefilters: Some(paired_prefilters),
3555                failure_invalidation:
3556                    crate::rule_engine::RuleFailureInvalidation::ExpressionOrSymbols,
3557            }));
3558        let rule_group = RuleGroup::new(
3559            1,
3560            vec![crate::rule_engine::RuleData {
3561                rule: paired_rule,
3562                priority: 1,
3563                rule_set: &TEST_RULE_SET,
3564            }],
3565        );
3566        let config = RewriteConfig::optimised();
3567        let and_with_comprehension = Expr::And(
3568            Metadata::new(),
3569            Moo::new(comprehension(bool_lit(true), vec![])),
3570        );
3571        let or_with_atomic = Expr::Or(Metadata::new(), Moo::new(bool_lit(true)));
3572        let and_with_atomic = Expr::And(Metadata::new(), Moo::new(bool_lit(true)));
3573        let or_with_comprehension = Expr::Or(
3574            Metadata::new(),
3575            Moo::new(comprehension(bool_lit(true), vec![])),
3576        );
3577
3578        assert!(rule_group.has_candidates(config, &and_with_comprehension));
3579        assert!(rule_group.has_candidates(config, &or_with_atomic));
3580        assert!(!rule_group.has_candidates(config, &and_with_atomic));
3581        assert!(!rule_group.has_candidates(config, &or_with_comprehension));
3582    }
3583
3584    #[test]
3585    fn rewriter_subtree_preorder_does_not_enter_comprehensions() {
3586        let tree = root(vec![
3587            comprehension(int_lit(1), vec![int_lit(2)]),
3588            int_lit(3),
3589        ]);
3590        let arena = ExpressionArena::from_root(tree);
3591        let root_ids = rewriter_preorder_ids(&arena);
3592        let comp_id = arena.children(arena.root())[0];
3593
3594        assert_eq!(root_ids.len(), 3);
3595        assert_eq!(
3596            rewriter_reachable_subtree_ids(&arena, comp_id),
3597            vec![comp_id]
3598        );
3599    }
3600
3601    #[test]
3602    fn worklist_ancestor_enqueue_checks_enclosing_nodes_first() {
3603        let tree = root(vec![Expr::Eq(
3604            Metadata::new(),
3605            Moo::new(int_lit(1)),
3606            Moo::new(int_lit(2)),
3607        )]);
3608        let surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(tree))];
3609        let arena = &surfaces[0].arena;
3610        let ids = rewriter_preorder_ids(arena);
3611        let root_id = ids[0];
3612        let eq_id = ids[1];
3613        let left_leaf_id = ids[2];
3614
3615        let rule_groups = test_rule_groups();
3616        let mut scheduler = WorklistScheduler::empty(&rule_groups);
3617        let mut dirty_trace = DirtyTrace::default();
3618
3619        scheduler.enqueue_node_and_ancestors(arena, 0, left_leaf_id, &mut dirty_trace);
3620
3621        assert_eq!(
3622            scheduler.pop_next(
3623                &surfaces,
3624                &rule_groups,
3625                RewriteConfig::optimised(),
3626                &mut dirty_trace
3627            ),
3628            Some((0, 0, root_id, ScheduledMode::CheckNode))
3629        );
3630        assert_eq!(
3631            scheduler.pop_next(
3632                &surfaces,
3633                &rule_groups,
3634                RewriteConfig::optimised(),
3635                &mut dirty_trace
3636            ),
3637            Some((0, 0, eq_id, ScheduledMode::CheckNode))
3638        );
3639        assert_eq!(
3640            scheduler.pop_next(
3641                &surfaces,
3642                &rule_groups,
3643                RewriteConfig::optimised(),
3644                &mut dirty_trace
3645            ),
3646            Some((0, 0, left_leaf_id, ScheduledMode::CheckNode))
3647        );
3648        assert_eq!(
3649            scheduler.pop_next(
3650                &surfaces,
3651                &rule_groups,
3652                RewriteConfig::optimised(),
3653                &mut dirty_trace
3654            ),
3655            None
3656        );
3657    }
3658
3659    #[test]
3660    fn worklist_level_order_prefers_ancestors_over_earlier_descendants() {
3661        let tree = root(vec![Expr::Eq(
3662            Metadata::new(),
3663            Moo::new(int_lit(1)),
3664            Moo::new(int_lit(2)),
3665        )]);
3666        let surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(tree))];
3667        let arena = &surfaces[0].arena;
3668        let ids = rewriter_preorder_ids(arena);
3669        let eq_id = ids[1];
3670        let left_leaf_id = ids[2];
3671
3672        let rule_groups = test_rule_groups();
3673        let mut scheduler = WorklistScheduler::empty(&rule_groups);
3674        let mut dirty_trace = DirtyTrace::default();
3675
3676        scheduler.enqueue_node_at_level(
3677            arena,
3678            0,
3679            left_leaf_id,
3680            0,
3681            ScheduledMode::CheckNode,
3682            Some(&mut dirty_trace),
3683        );
3684        scheduler.enqueue_node_at_level(
3685            arena,
3686            0,
3687            eq_id,
3688            0,
3689            ScheduledMode::CheckNode,
3690            Some(&mut dirty_trace),
3691        );
3692
3693        assert_eq!(
3694            scheduler.pop_next(
3695                &surfaces,
3696                &rule_groups,
3697                RewriteConfig::optimised(),
3698                &mut dirty_trace
3699            ),
3700            Some((0, 0, eq_id, ScheduledMode::CheckNode))
3701        );
3702        assert_eq!(
3703            scheduler.pop_next(
3704                &surfaces,
3705                &rule_groups,
3706                RewriteConfig::optimised(),
3707                &mut dirty_trace
3708            ),
3709            Some((0, 0, left_leaf_id, ScheduledMode::CheckNode))
3710        );
3711    }
3712
3713    #[test]
3714    fn worklist_subtree_descends_lazily_in_breadth_first_order() {
3715        let tree = root(vec![
3716            Expr::Eq(Metadata::new(), Moo::new(int_lit(1)), Moo::new(int_lit(2))),
3717            int_lit(3),
3718        ]);
3719        let surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(tree))];
3720        let arena = &surfaces[0].arena;
3721        let ids = rewriter_preorder_ids(arena);
3722        let root_id = ids[0];
3723        let eq_id = ids[1];
3724        let left_leaf_id = ids[2];
3725        let right_leaf_id = ids[3];
3726        let root_sibling_id = ids[4];
3727
3728        let rule_groups = test_rule_groups_with_two_levels();
3729        let mut scheduler =
3730            WorklistScheduler::new(&surfaces, &rule_groups, RewriteConfig::optimised());
3731        let mut dirty_trace = DirtyTrace::default();
3732
3733        let root_work = scheduler.pop_next(
3734            &surfaces,
3735            &rule_groups,
3736            RewriteConfig::optimised(),
3737            &mut dirty_trace,
3738        );
3739        assert_eq!(
3740            root_work,
3741            Some((0, 0, root_id, ScheduledMode::TraverseSubtreeRoot))
3742        );
3743        assert_eq!(
3744            scheduler.pop_next(
3745                &surfaces,
3746                &rule_groups,
3747                RewriteConfig::optimised(),
3748                &mut dirty_trace
3749            ),
3750            None
3751        );
3752
3753        scheduler.enqueue_after_no_rewrite(
3754            WorklistSchedulingContext::new(arena, 0, &rule_groups, RewriteConfig::optimised()),
3755            root_id,
3756            0,
3757            1,
3758            ScheduledMode::TraverseSubtreeRoot,
3759            Some(&mut dirty_trace),
3760        );
3761        let eq_work = scheduler.pop_next(
3762            &surfaces,
3763            &rule_groups,
3764            RewriteConfig::optimised(),
3765            &mut dirty_trace,
3766        );
3767        assert_eq!(
3768            eq_work,
3769            Some((0, 0, eq_id, ScheduledMode::TraverseSubtreeDescendant))
3770        );
3771        scheduler.enqueue_after_no_rewrite(
3772            WorklistSchedulingContext::new(arena, 0, &rule_groups, RewriteConfig::optimised()),
3773            eq_id,
3774            0,
3775            1,
3776            ScheduledMode::TraverseSubtreeDescendant,
3777            Some(&mut dirty_trace),
3778        );
3779        assert_eq!(
3780            scheduler.pop_next(
3781                &surfaces,
3782                &rule_groups,
3783                RewriteConfig::optimised(),
3784                &mut dirty_trace
3785            ),
3786            Some((
3787                0,
3788                0,
3789                root_sibling_id,
3790                ScheduledMode::TraverseSubtreeDescendant
3791            ))
3792        );
3793        assert_eq!(
3794            scheduler.pop_next(
3795                &surfaces,
3796                &rule_groups,
3797                RewriteConfig::optimised(),
3798                &mut dirty_trace
3799            ),
3800            Some((0, 0, left_leaf_id, ScheduledMode::TraverseSubtreeDescendant))
3801        );
3802        assert_eq!(
3803            scheduler.pop_next(
3804                &surfaces,
3805                &rule_groups,
3806                RewriteConfig::optimised(),
3807                &mut dirty_trace
3808            ),
3809            Some((
3810                0,
3811                0,
3812                right_leaf_id,
3813                ScheduledMode::TraverseSubtreeDescendant
3814            ))
3815        );
3816        assert_eq!(
3817            scheduler.pop_next(
3818                &surfaces,
3819                &rule_groups,
3820                RewriteConfig::optimised(),
3821                &mut dirty_trace
3822            ),
3823            Some((1, 0, root_id, ScheduledMode::TraverseSubtreeRoot))
3824        );
3825        assert_eq!(
3826            scheduler.pop_next(
3827                &surfaces,
3828                &rule_groups,
3829                RewriteConfig::optimised(),
3830                &mut dirty_trace
3831            ),
3832            None
3833        );
3834
3835        scheduler.enqueue_after_no_rewrite(
3836            WorklistSchedulingContext::new(arena, 0, &rule_groups, RewriteConfig::optimised()),
3837            root_id,
3838            1,
3839            2,
3840            ScheduledMode::TraverseSubtreeRoot,
3841            Some(&mut dirty_trace),
3842        );
3843        assert_eq!(
3844            scheduler.pop_next(
3845                &surfaces,
3846                &rule_groups,
3847                RewriteConfig::optimised(),
3848                &mut dirty_trace
3849            ),
3850            Some((1, 0, eq_id, ScheduledMode::TraverseSubtreeDescendant))
3851        );
3852    }
3853
3854    #[test]
3855    fn worklist_refreshes_stale_subtree_carrier_after_descendant_rewrite() {
3856        let tree = root(vec![
3857            Expr::Eq(Metadata::new(), Moo::new(int_lit(1)), Moo::new(int_lit(2))),
3858            int_lit(3),
3859        ]);
3860        let mut surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(tree))];
3861        let ids = rewriter_preorder_ids(&surfaces[0].arena);
3862        let root_id = ids[0];
3863        let eq_id = ids[1];
3864        let root_sibling_id = ids[4];
3865
3866        let rule_groups = test_rule_groups_with_two_levels();
3867        let mut scheduler =
3868            WorklistScheduler::new(&surfaces, &rule_groups, RewriteConfig::optimised());
3869        let mut dirty_trace = DirtyTrace::default();
3870
3871        assert_eq!(
3872            scheduler.pop_next(
3873                &surfaces,
3874                &rule_groups,
3875                RewriteConfig::optimised(),
3876                &mut dirty_trace
3877            ),
3878            Some((0, 0, root_id, ScheduledMode::TraverseSubtreeRoot))
3879        );
3880        scheduler.enqueue_after_no_rewrite(
3881            WorklistSchedulingContext::new(
3882                &surfaces[0].arena,
3883                0,
3884                &rule_groups,
3885                RewriteConfig::optimised(),
3886            ),
3887            root_id,
3888            0,
3889            1,
3890            ScheduledMode::TraverseSubtreeRoot,
3891            Some(&mut dirty_trace),
3892        );
3893
3894        assert_eq!(
3895            scheduler.pop_next(
3896                &surfaces,
3897                &rule_groups,
3898                RewriteConfig::optimised(),
3899                &mut dirty_trace
3900            ),
3901            Some((0, 0, eq_id, ScheduledMode::TraverseSubtreeDescendant))
3902        );
3903        replace_focus_and_sync_ancestors(&mut surfaces[0].arena, eq_id, int_lit(10));
3904        enqueue_worklist_rewrite_impact(
3905            &mut scheduler,
3906            &surfaces[0].arena,
3907            0,
3908            eq_id,
3909            &mut dirty_trace,
3910        );
3911
3912        let mut found_refreshed_root = false;
3913        for _ in 0..32 {
3914            let Some((level, surface, node_id, mode)) = scheduler.pop_next(
3915                &surfaces,
3916                &rule_groups,
3917                RewriteConfig::optimised(),
3918                &mut dirty_trace,
3919            ) else {
3920                break;
3921            };
3922            if (level, surface, node_id, mode)
3923                == (1, 0, root_id, ScheduledMode::TraverseSubtreeRoot)
3924            {
3925                found_refreshed_root = true;
3926                scheduler.enqueue_after_no_rewrite(
3927                    WorklistSchedulingContext::new(
3928                        &surfaces[0].arena,
3929                        0,
3930                        &rule_groups,
3931                        RewriteConfig::optimised(),
3932                    ),
3933                    root_id,
3934                    1,
3935                    2,
3936                    ScheduledMode::TraverseSubtreeRoot,
3937                    Some(&mut dirty_trace),
3938                );
3939                break;
3940            }
3941            scheduler.enqueue_after_no_rewrite(
3942                WorklistSchedulingContext::new(
3943                    &surfaces[surface].arena,
3944                    surface,
3945                    &rule_groups,
3946                    RewriteConfig::optimised(),
3947                ),
3948                node_id,
3949                level,
3950                level + 1,
3951                mode,
3952                Some(&mut dirty_trace),
3953            );
3954        }
3955        assert!(found_refreshed_root);
3956
3957        let mut found_sibling_at_next_level = false;
3958        for _ in 0..32 {
3959            let Some((level, _surface, node_id, mode)) = scheduler.pop_next(
3960                &surfaces,
3961                &rule_groups,
3962                RewriteConfig::optimised(),
3963                &mut dirty_trace,
3964            ) else {
3965                break;
3966            };
3967            if (level, node_id, mode)
3968                == (1, root_sibling_id, ScheduledMode::TraverseSubtreeDescendant)
3969            {
3970                found_sibling_at_next_level = true;
3971                break;
3972            }
3973        }
3974        assert!(found_sibling_at_next_level);
3975    }
3976
3977    #[test]
3978    fn worklist_prunes_child_subtrees_without_candidates_at_level() {
3979        let eq = Expr::Eq(Metadata::new(), Moo::new(int_lit(1)), Moo::new(int_lit(2)));
3980        let tree = root(vec![eq.clone(), int_lit(3)]);
3981        let surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(tree))];
3982        let arena = &surfaces[0].arena;
3983        let ids = rewriter_preorder_ids(arena);
3984        let root_id = ids[0];
3985        let eq_id = ids[1];
3986
3987        let rule_groups = test_rule_groups_targeting_expr(&eq);
3988        let mut scheduler =
3989            WorklistScheduler::new(&surfaces, &rule_groups, RewriteConfig::optimised());
3990        let mut dirty_trace = DirtyTrace::default();
3991
3992        assert_eq!(
3993            scheduler.pop_next(
3994                &surfaces,
3995                &rule_groups,
3996                RewriteConfig::optimised(),
3997                &mut dirty_trace
3998            ),
3999            Some((0, 0, root_id, ScheduledMode::TraverseSubtreeRoot))
4000        );
4001        scheduler.enqueue_after_no_rewrite(
4002            WorklistSchedulingContext::new(arena, 0, &rule_groups, RewriteConfig::optimised()),
4003            root_id,
4004            0,
4005            1,
4006            ScheduledMode::TraverseSubtreeRoot,
4007            Some(&mut dirty_trace),
4008        );
4009
4010        assert_eq!(
4011            scheduler.pop_next(
4012                &surfaces,
4013                &rule_groups,
4014                RewriteConfig::optimised(),
4015                &mut dirty_trace
4016            ),
4017            Some((0, 0, eq_id, ScheduledMode::TraverseSubtreeDescendant))
4018        );
4019        assert_eq!(
4020            scheduler.pop_next(
4021                &surfaces,
4022                &rule_groups,
4023                RewriteConfig::optimised(),
4024                &mut dirty_trace
4025            ),
4026            None
4027        );
4028    }
4029
4030    #[test]
4031    fn worklist_no_rewrite_skips_levels_without_candidates_for_subtree() {
4032        let tree = root(vec![int_lit(1)]);
4033        let surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(tree))];
4034        let arena = &surfaces[0].arena;
4035        let ids = rewriter_preorder_ids(arena);
4036        let root_id = ids[0];
4037        let child_id = ids[1];
4038
4039        let rule_groups = test_rule_groups_with_no_candidate_middle_level();
4040        let mut scheduler =
4041            WorklistScheduler::new(&surfaces, &rule_groups, RewriteConfig::optimised());
4042        let mut dirty_trace = DirtyTrace::default();
4043
4044        assert_eq!(
4045            scheduler.pop_next(
4046                &surfaces,
4047                &rule_groups,
4048                RewriteConfig::optimised(),
4049                &mut dirty_trace
4050            ),
4051            Some((0, 0, root_id, ScheduledMode::TraverseSubtreeRoot))
4052        );
4053
4054        scheduler.enqueue_after_no_rewrite(
4055            WorklistSchedulingContext::new(arena, 0, &rule_groups, RewriteConfig::optimised()),
4056            root_id,
4057            0,
4058            1,
4059            ScheduledMode::TraverseSubtreeRoot,
4060            Some(&mut dirty_trace),
4061        );
4062
4063        assert_eq!(
4064            scheduler.pop_next(
4065                &surfaces,
4066                &rule_groups,
4067                RewriteConfig::optimised(),
4068                &mut dirty_trace
4069            ),
4070            Some((0, 0, child_id, ScheduledMode::TraverseSubtreeDescendant))
4071        );
4072        assert_eq!(
4073            scheduler.pop_next(
4074                &surfaces,
4075                &rule_groups,
4076                RewriteConfig::optimised(),
4077                &mut dirty_trace
4078            ),
4079            Some((2, 0, root_id, ScheduledMode::TraverseSubtreeRoot))
4080        );
4081    }
4082
4083    #[test]
4084    fn worklist_reference_invalidation_schedules_affected_path_once() {
4085        let x = Name::user("x");
4086        let tree = root(vec![
4087            Expr::Eq(
4088                Metadata::new(),
4089                Moo::new(reference_expr(&x)),
4090                Moo::new(int_lit(1)),
4091            ),
4092            int_lit(2),
4093        ]);
4094        let arena = ExpressionArena::from_root(tree);
4095        let ids = rewriter_preorder_ids(&arena);
4096        let root_id = ids[0];
4097        let eq_id = ids[1];
4098        let reference_id = ids[2];
4099
4100        let mut affected_nodes = Vec::new();
4101        collect_worklist_nodes_referencing_names(
4102            &arena,
4103            arena.root(),
4104            std::slice::from_ref(&x),
4105            &mut affected_nodes,
4106        );
4107
4108        assert_eq!(
4109            affected_nodes.into_iter().rev().collect_vec(),
4110            vec![root_id, eq_id, reference_id]
4111        );
4112    }
4113
4114    #[test]
4115    fn worklist_caches_symbols_only_failures_until_symbols_change() {
4116        let tree = root(vec![int_lit(1)]);
4117        let surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(tree))];
4118        let node_id = surfaces[0].arena.root();
4119        let rule_groups = vec![RuleGroup::new(
4120            1,
4121            vec![crate::rule_engine::RuleData {
4122                rule: &TEST_SYMBOLS_ONLY_RULE,
4123                priority: 1,
4124                rule_set: &TEST_RULE_SET,
4125            }],
4126        )];
4127        let rule_data = &rule_groups[0].rules[0];
4128        let mut scheduler = WorklistScheduler::empty(&rule_groups);
4129
4130        assert!(scheduler.should_attempt_rule(0, node_id, rule_data));
4131        scheduler.record_rule_failure(0, node_id, rule_data);
4132        assert!(!scheduler.should_attempt_rule(0, node_id, rule_data));
4133
4134        scheduler.invalidate_symbol_rule_failures();
4135        assert!(scheduler.should_attempt_rule(0, node_id, rule_data));
4136    }
4137
4138    #[test]
4139    fn evaluator_batches_ancestor_work_until_the_worklist_is_empty() {
4140        let x = Name::user("x");
4141        let and = Expr::And(
4142            Metadata::new(),
4143            Moo::new(matrix_expr![bool_lit(true), reference_expr(&x)]),
4144        );
4145        let mut surfaces = vec![RewriteSurface::root(ExpressionArena::from_root(root(
4146            vec![and],
4147        )))];
4148        let ids = rewriter_preorder_ids(&surfaces[0].arena);
4149        let and_id = ids
4150            .iter()
4151            .copied()
4152            .find(|&node_id| matches!(surfaces[0].arena.expression(node_id), Expr::And(..)))
4153            .expect("and node");
4154        let true_id = ids
4155            .iter()
4156            .copied()
4157            .find(|&node_id| surfaces[0].arena.expression(node_id) == &bool_lit(true))
4158            .expect("true literal");
4159
4160        replace_focus_and_sync_ancestors(&mut surfaces[0].arena, true_id, bool_lit(false));
4161        let mut work = DeferredEvaluatorWork::default();
4162        work.defer_ancestors(&mut surfaces[0].arena, 0, true_id);
4163        work.defer_ancestors(&mut surfaces[0].arena, 0, true_id);
4164        assert_eq!(
4165            work.nodes.len(),
4166            3,
4167            "matrix, `and`, and root ancestors are unique"
4168        );
4169        assert!(matches!(
4170            surfaces[0].arena.expression(and_id),
4171            Expr::And(..)
4172        ));
4173
4174        let rule_groups = Vec::new();
4175        let mut scheduler = WorklistScheduler::empty(&rule_groups);
4176        let mut dirty_trace = DirtyTrace::default();
4177        assert!(normalise_deferred_evaluators(
4178            &mut work,
4179            &mut Model::new(Default::default()),
4180            &mut surfaces,
4181            &mut scheduler,
4182            &mut dirty_trace,
4183        ));
4184        assert_eq!(surfaces[0].arena.expression(and_id), &bool_lit(false));
4185        assert!(work.is_empty());
4186    }
4187}