Skip to main content

ty_python_core/
narrowing_constraints.rs

1//! # Narrowing constraints
2//!
3//! When building a semantic index for a file, we associate each binding with a narrowing
4//! constraint, which constrains the type of the binding's place. A binding can be associated with
5//! a different narrowing constraint at different points in a file. See the `use_def` module for
6//! more details.
7//!
8//! A narrowing constraint is a boolean formula over predicates such as `isinstance(x, A)`.
9//! Internally, we store these formulas in a ternary decision diagram (TDD). Each interior node has
10//! three outgoing edges:
11//!
12//! - `if_true` applies when the predicate is true.
13//! - `if_false` applies when the predicate is false.
14//! - `if_uncertain` applies either way.
15//!
16//! Despite its name, `if_uncertain` does not mean that the predicate's value is unknown. It is a
17//! "don't care" edge: the formula on that edge does not depend on the predicate. A node represents
18//! this formula:
19//!
20//! ```text
21//! if_uncertain OR (predicate AND if_true) OR (NOT predicate AND if_false)
22//! ```
23//!
24//! The extra edge keeps repeated unions small. For example, `A OR B` can store `A` once on `B`'s
25//! `if_uncertain` edge instead of copying `A` into both of `B`'s other edges.
26//!
27//! We also absorb redundant cofactors when constructing TDD nodes. This is especially useful for
28//! the continuation of a large `if`/`elif` chain. Each branch has narrowing constraints of the
29//! form `A`, `NOT A AND B`, `NOT A AND NOT B AND C`, and so on. The continuation combines those
30//! branch constraints with `OR`. The negative part of each branch constraint is redundant in that
31//! union because it is already covered by the earlier positive branches. These negative prefixes
32//! are cofactors of the earlier positive alternatives and can be absorbed. For example,
33//! `A OR (NOT A AND B)` simplifies to `A OR B`.
34
35use std::cmp::Ordering;
36
37use ruff_index::{Idx, IndexVec};
38use rustc_hash::FxHashMap;
39
40use crate::ast_ids::ScopedUseId;
41use crate::predicate::ScopedPredicateId;
42use crate::rank::{RankBitBox, RankBitBoxVec};
43use crate::scope::FileScopeId;
44
45/// The ID of a narrowing formula within one scope.
46///
47/// `ALWAYS_TRUE` means that no narrowing applies. `ALWAYS_FALSE` means that the path is
48/// impossible.
49#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)]
50pub struct ScopedNarrowingConstraint(u32);
51
52impl ScopedNarrowingConstraint {
53    pub const ALWAYS_TRUE: Self = Self(u32::MAX);
54    pub const ALWAYS_FALSE: Self = Self(u32::MAX - 1);
55
56    pub fn is_terminal(self) -> bool {
57        self.0 >= Self::ALWAYS_FALSE.0
58    }
59}
60
61impl Idx for ScopedNarrowingConstraint {
62    fn new(value: usize) -> Self {
63        assert!(value < Self::ALWAYS_FALSE.0 as usize);
64        #[expect(clippy::cast_possible_truncation)]
65        Self(value as u32)
66    }
67
68    fn index(self) -> usize {
69        debug_assert!(!self.is_terminal());
70        self.0 as usize
71    }
72}
73
74const ALWAYS_TRUE: ScopedNarrowingConstraint = ScopedNarrowingConstraint::ALWAYS_TRUE;
75const ALWAYS_FALSE: ScopedNarrowingConstraint = ScopedNarrowingConstraint::ALWAYS_FALSE;
76
77/// Once a scope reaches this limit, operations return `ALWAYS_TRUE`. Dropping narrowing is less
78/// precise, but avoids exponential growth on pathological input.
79const MAX_INTERIOR_NODES: usize = 512 * 1024;
80
81#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, get_size2::GetSize)]
82pub struct InteriorNode {
83    /// The predicate tested by this node.
84    pub atom: ScopedPredicateId,
85    /// The remaining formula when the predicate is true.
86    pub if_true: ScopedNarrowingConstraint,
87    /// The part of the formula that applies regardless of the predicate.
88    pub if_uncertain: ScopedNarrowingConstraint,
89    /// The remaining formula when the predicate is false.
90    pub if_false: ScopedNarrowingConstraint,
91}
92
93#[derive(Debug, PartialEq, Eq, get_size2::GetSize)]
94pub struct NarrowingConstraints {
95    used_interiors: Box<[InteriorNode]>,
96    used_indices: Option<RankBitBox>,
97}
98
99impl NarrowingConstraints {
100    /// Creates a constraint graph from compacted interior nodes for use by downstream tests.
101    ///
102    /// The nodes must satisfy the same ordering and allocation invariants as graphs produced by
103    /// [`NarrowingConstraintsBuilder`].
104    pub fn from_test_nodes(nodes: Vec<InteriorNode>) -> Self {
105        Self {
106            used_interiors: nodes.into_boxed_slice(),
107            used_indices: None,
108        }
109    }
110
111    pub(crate) fn is_empty(&self) -> bool {
112        self.used_interiors.is_empty()
113    }
114
115    pub fn get_interior_node(&self, id: ScopedNarrowingConstraint) -> InteriorNode {
116        debug_assert!(!id.is_terminal());
117        let raw_index = id.0 as usize;
118        if let Some(used_indices) = &self.used_indices {
119            debug_assert!(
120                used_indices.get_bit(raw_index).unwrap_or(false),
121                "all used narrowing constraints should have been marked as used",
122            );
123            self.used_interiors[used_indices.rank(raw_index) as usize]
124        } else {
125            self.used_interiors[raw_index]
126        }
127    }
128}
129
130#[derive(Debug, Default, PartialEq, Eq)]
131pub struct NarrowingConstraintsBuilder {
132    interiors: IndexVec<ScopedNarrowingConstraint, InteriorNode>,
133    interior_used: RankBitBoxVec,
134    interior_cache: FxHashMap<InteriorNode, ScopedNarrowingConstraint>,
135    and_cache: FxHashMap<
136        (ScopedNarrowingConstraint, ScopedNarrowingConstraint),
137        ScopedNarrowingConstraint,
138    >,
139    or_cache: FxHashMap<
140        (ScopedNarrowingConstraint, ScopedNarrowingConstraint),
141        ScopedNarrowingConstraint,
142    >,
143}
144
145impl NarrowingConstraintsBuilder {
146    pub(crate) fn build(self) -> NarrowingConstraints {
147        if self.interior_used.first_zero().is_none() {
148            NarrowingConstraints {
149                used_interiors: self.interiors.raw.into_boxed_slice(),
150                used_indices: None,
151            }
152        } else {
153            let used_interiors = self
154                .interiors
155                .into_iter()
156                .zip(&self.interior_used)
157                .filter_map(|(interior, used)| used.then_some(interior))
158                .collect();
159            let used_indices = RankBitBox::from_bits(self.interior_used);
160            NarrowingConstraints {
161                used_interiors,
162                used_indices: Some(used_indices),
163            }
164        }
165    }
166
167    pub(crate) fn mark_used(&mut self, node: ScopedNarrowingConstraint) {
168        if !node.is_terminal() && !self.interior_used[node.index()] {
169            self.interior_used.set(node.index(), true);
170            let node = self.interiors[node];
171            self.mark_used(node.if_true);
172            self.mark_used(node.if_uncertain);
173            self.mark_used(node.if_false);
174        }
175    }
176
177    fn cmp_atoms(&self, a: ScopedNarrowingConstraint, b: ScopedNarrowingConstraint) -> Ordering {
178        if a == b || (a.is_terminal() && b.is_terminal()) {
179            Ordering::Equal
180        } else if a.is_terminal() {
181            Ordering::Greater
182        } else if b.is_terminal() {
183            Ordering::Less
184        } else {
185            self.interiors[a]
186                .atom
187                .cmp(&self.interiors[b].atom)
188                .reverse()
189        }
190    }
191
192    fn add_interior(&mut self, node: InteriorNode) -> ScopedNarrowingConstraint {
193        if node.if_uncertain == ALWAYS_TRUE {
194            return ALWAYS_TRUE;
195        }
196        if node.if_true == node.if_false && node.if_true == node.if_uncertain {
197            return node.if_true;
198        }
199
200        // Find and absorb cofactors if we can. (See module documentation for more details.)
201        // `if_uncertain` contributes to both cofactors. If either cofactor is already true,
202        // then the remaining cofactor can be lifted into `if_uncertain`, avoiding shapes like
203        // `A or (not A and B)`.
204        let when_true = self.add_or_constraint(node.if_true, node.if_uncertain);
205        let when_false = self.add_or_constraint(node.if_false, node.if_uncertain);
206        if when_true == when_false {
207            return when_true;
208        }
209        if when_true == ALWAYS_TRUE
210            && !(node.if_true == ALWAYS_TRUE && node.if_false == ALWAYS_FALSE)
211        {
212            return self.add_interior(InteriorNode {
213                atom: node.atom,
214                if_true: ALWAYS_TRUE,
215                if_uncertain: when_false,
216                if_false: ALWAYS_FALSE,
217            });
218        }
219        if when_false == ALWAYS_TRUE
220            && !(node.if_true == ALWAYS_FALSE && node.if_false == ALWAYS_TRUE)
221        {
222            return self.add_interior(InteriorNode {
223                atom: node.atom,
224                if_true: ALWAYS_FALSE,
225                if_uncertain: when_true,
226                if_false: ALWAYS_TRUE,
227            });
228        }
229
230        *self.interior_cache.entry(node).or_insert_with(|| {
231            self.interior_used.push(false);
232            self.interiors.push(node)
233        })
234    }
235
236    pub(crate) fn add_atom(&mut self, predicate: ScopedPredicateId) -> ScopedNarrowingConstraint {
237        if predicate == ScopedPredicateId::ALWAYS_FALSE {
238            ALWAYS_FALSE
239        } else if predicate == ScopedPredicateId::ALWAYS_TRUE {
240            ALWAYS_TRUE
241        } else {
242            self.add_interior(InteriorNode {
243                atom: predicate,
244                if_true: ALWAYS_TRUE,
245                if_uncertain: ALWAYS_FALSE,
246                if_false: ALWAYS_FALSE,
247            })
248        }
249    }
250
251    pub(crate) fn add_negated_atom(
252        &mut self,
253        predicate: ScopedPredicateId,
254    ) -> ScopedNarrowingConstraint {
255        if predicate == ScopedPredicateId::ALWAYS_FALSE {
256            ALWAYS_TRUE
257        } else if predicate == ScopedPredicateId::ALWAYS_TRUE {
258            ALWAYS_FALSE
259        } else {
260            self.add_interior(InteriorNode {
261                atom: predicate,
262                if_true: ALWAYS_FALSE,
263                if_uncertain: ALWAYS_FALSE,
264                if_false: ALWAYS_TRUE,
265            })
266        }
267    }
268
269    /// Adds a constraint that selects between two formulas based on `predicate`.
270    pub(crate) fn add_conditional(
271        &mut self,
272        predicate: ScopedPredicateId,
273        if_true: ScopedNarrowingConstraint,
274        if_false: ScopedNarrowingConstraint,
275    ) -> ScopedNarrowingConstraint {
276        let node = InteriorNode {
277            atom: predicate,
278            if_true,
279            if_uncertain: ALWAYS_FALSE,
280            if_false,
281        };
282        if let Some(cached) = self.interior_cache.get(&node) {
283            return *cached;
284        }
285        if self.interiors.len() >= MAX_INTERIOR_NODES {
286            return ALWAYS_TRUE;
287        }
288
289        self.add_interior(node)
290    }
291
292    pub(crate) fn add_or_constraint(
293        &mut self,
294        a: ScopedNarrowingConstraint,
295        b: ScopedNarrowingConstraint,
296    ) -> ScopedNarrowingConstraint {
297        match (a, b) {
298            (ALWAYS_TRUE, _) | (_, ALWAYS_TRUE) => return ALWAYS_TRUE,
299            (ALWAYS_FALSE, other) | (other, ALWAYS_FALSE) => return other,
300            _ if a == b => return a,
301            _ => {}
302        }
303
304        let (a, b) = if b.0 < a.0 { (b, a) } else { (a, b) };
305        if let Some(cached) = self.or_cache.get(&(a, b)) {
306            return *cached;
307        }
308        if self.interiors.len() >= MAX_INTERIOR_NODES {
309            return ALWAYS_TRUE;
310        }
311
312        // See the "BDDs with lazy unions (or ternary decision diagrams)" section for the edge
313        // calculations below:
314        // https://elixir-lang.org/blog/2025/12/02/lazier-bdds-for-set-theoretic-types/#bdds-with-lazy-unions-or-ternary-decision-diagrams
315        let result = match self.cmp_atoms(a, b) {
316            Ordering::Equal => {
317                let a_node = self.interiors[a];
318                let b_node = self.interiors[b];
319                let if_true = self.add_or_constraint(a_node.if_true, b_node.if_true);
320                let if_uncertain = self.add_or_constraint(a_node.if_uncertain, b_node.if_uncertain);
321                let if_false = self.add_or_constraint(a_node.if_false, b_node.if_false);
322                self.add_interior(InteriorNode {
323                    atom: a_node.atom,
324                    if_true,
325                    if_uncertain,
326                    if_false,
327                })
328            }
329            ordering @ (Ordering::Less | Ordering::Greater) => {
330                let (node, other) = if ordering == Ordering::Less {
331                    (self.interiors[a], b)
332                } else {
333                    (self.interiors[b], a)
334                };
335                let if_uncertain = self.add_or_constraint(node.if_uncertain, other);
336                self.add_interior(InteriorNode {
337                    atom: node.atom,
338                    if_true: node.if_true,
339                    if_uncertain,
340                    if_false: node.if_false,
341                })
342            }
343        };
344
345        self.or_cache.insert((a, b), result);
346        result
347    }
348
349    pub(crate) fn add_and_constraint(
350        &mut self,
351        a: ScopedNarrowingConstraint,
352        b: ScopedNarrowingConstraint,
353    ) -> ScopedNarrowingConstraint {
354        match (a, b) {
355            (ALWAYS_FALSE, _) | (_, ALWAYS_FALSE) => return ALWAYS_FALSE,
356            (ALWAYS_TRUE, other) | (other, ALWAYS_TRUE) => return other,
357            _ if a == b => return a,
358            _ => {}
359        }
360
361        let (a, b) = if b.0 < a.0 { (b, a) } else { (a, b) };
362        if let Some(cached) = self.and_cache.get(&(a, b)) {
363            return *cached;
364        }
365        if self.interiors.len() >= MAX_INTERIOR_NODES {
366            return ALWAYS_TRUE;
367        }
368
369        // See the "Lazier BDDs (for intersections)" section for the edge calculations below:
370        // https://elixir-lang.org/blog/2025/12/02/lazier-bdds-for-set-theoretic-types/#lazier-bdds-for-intersections
371        let result = match self.cmp_atoms(a, b) {
372            Ordering::Equal => {
373                let a_node = self.interiors[a];
374                let b_node = self.interiors[b];
375
376                let b_true_or_uncertain =
377                    self.add_or_constraint(b_node.if_true, b_node.if_uncertain);
378                let true_from_a = self.add_and_constraint(a_node.if_true, b_true_or_uncertain);
379                let true_from_uncertain =
380                    self.add_and_constraint(a_node.if_uncertain, b_node.if_true);
381                let if_true = self.add_or_constraint(true_from_a, true_from_uncertain);
382
383                let if_uncertain =
384                    self.add_and_constraint(a_node.if_uncertain, b_node.if_uncertain);
385
386                let b_false_or_uncertain =
387                    self.add_or_constraint(b_node.if_false, b_node.if_uncertain);
388                let false_from_a = self.add_and_constraint(a_node.if_false, b_false_or_uncertain);
389                let false_from_uncertain =
390                    self.add_and_constraint(a_node.if_uncertain, b_node.if_false);
391                let if_false = self.add_or_constraint(false_from_a, false_from_uncertain);
392
393                self.add_interior(InteriorNode {
394                    atom: a_node.atom,
395                    if_true,
396                    if_uncertain,
397                    if_false,
398                })
399            }
400            ordering @ (Ordering::Less | Ordering::Greater) => {
401                let (node, other) = if ordering == Ordering::Less {
402                    (self.interiors[a], b)
403                } else {
404                    (self.interiors[b], a)
405                };
406                let if_true = self.add_and_constraint(node.if_true, other);
407                let if_uncertain = self.add_and_constraint(node.if_uncertain, other);
408                let if_false = self.add_and_constraint(node.if_false, other);
409                self.add_interior(InteriorNode {
410                    atom: node.atom,
411                    if_true,
412                    if_uncertain,
413                    if_false,
414                })
415            }
416        };
417
418        self.and_cache.insert((a, b), result);
419        result
420    }
421}
422
423#[derive(Clone, Copy, Debug, Eq, PartialEq)]
424pub enum ConstraintKey {
425    NarrowingConstraint(ScopedNarrowingConstraint),
426    NestedScope(FileScopeId),
427    UseId(ScopedUseId),
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn predicate(index: usize) -> ScopedPredicateId {
435        ScopedPredicateId::new(index)
436    }
437
438    fn evaluate(
439        constraints: &NarrowingConstraintsBuilder,
440        constraint: ScopedNarrowingConstraint,
441        values: &[bool],
442    ) -> bool {
443        match constraint {
444            ALWAYS_TRUE => true,
445            ALWAYS_FALSE => false,
446            _ => {
447                let node = constraints.interiors[constraint];
448                evaluate(constraints, node.if_uncertain, values)
449                    || if values[node.atom.index()] {
450                        evaluate(constraints, node.if_true, values)
451                    } else {
452                        evaluate(constraints, node.if_false, values)
453                    }
454            }
455        }
456    }
457
458    #[test]
459    fn boolean_operations_match_their_truth_tables() {
460        let mut constraints = NarrowingConstraintsBuilder::default();
461        let a = constraints.add_atom(predicate(0));
462        let b = constraints.add_atom(predicate(1));
463
464        let a_or_b = constraints.add_or_constraint(a, b);
465        let not_c = constraints.add_negated_atom(predicate(2));
466        let formula = constraints.add_and_constraint(a_or_b, not_c);
467
468        for mask in 0_u8..8 {
469            let values = [mask & 0b001 != 0, mask & 0b010 != 0, mask & 0b100 != 0];
470            assert_eq!(
471                evaluate(&constraints, formula, &values),
472                (values[0] || values[1]) && !values[2],
473            );
474        }
475    }
476
477    #[test]
478    fn union_parks_the_other_operand_in_the_uncertain_branch() {
479        let mut constraints = NarrowingConstraintsBuilder::default();
480        let a = constraints.add_atom(predicate(0));
481        let b = constraints.add_atom(predicate(1));
482
483        let union = constraints.add_or_constraint(a, b);
484        let root = constraints.interiors[union];
485
486        assert_eq!(root.atom, predicate(1));
487        assert_eq!(root.if_true, ALWAYS_TRUE);
488        assert_eq!(root.if_uncertain, a);
489        assert_eq!(root.if_false, ALWAYS_FALSE);
490    }
491
492    #[test]
493    fn absorption_drops_failed_check_when_preceding_branch_reaches_merge() {
494        let mut constraints = NarrowingConstraintsBuilder::default();
495        let a = constraints.add_atom(predicate(0));
496        let b = constraints.add_atom(predicate(1));
497        let not_a = constraints.add_negated_atom(predicate(0));
498        let later_branch = constraints.add_and_constraint(not_a, b);
499
500        let merged = constraints.add_or_constraint(a, later_branch);
501        let root = constraints.interiors[merged];
502
503        assert_eq!(root.atom, predicate(1));
504        assert_eq!(root.if_true, ALWAYS_TRUE);
505        assert_eq!(root.if_uncertain, a);
506        assert_eq!(root.if_false, ALWAYS_FALSE);
507
508        for mask in 0_u8..4 {
509            let values = [mask & 0b01 != 0, mask & 0b10 != 0];
510            assert_eq!(
511                evaluate(&constraints, merged, &values),
512                values[0] || values[1]
513            );
514        }
515    }
516
517    #[test]
518    fn absorption_keeps_common_failed_check_from_terminal_branch() {
519        let mut constraints = NarrowingConstraintsBuilder::default();
520        let not_a = constraints.add_negated_atom(predicate(0));
521        let b = constraints.add_atom(predicate(1));
522        let not_b = constraints.add_negated_atom(predicate(1));
523        let c = constraints.add_atom(predicate(2));
524
525        let b_branch = constraints.add_and_constraint(not_a, b);
526        let c_branch = constraints.add_and_constraint(not_a, not_b);
527        let c_branch = constraints.add_and_constraint(c_branch, c);
528        let merged = constraints.add_or_constraint(b_branch, c_branch);
529
530        for mask in 0_u8..8 {
531            let values = [mask & 0b001 != 0, mask & 0b010 != 0, mask & 0b100 != 0];
532            assert_eq!(
533                evaluate(&constraints, merged, &values),
534                !values[0] && (values[1] || values[2]),
535            );
536        }
537    }
538}