Skip to main content

lemma/planning/
ordered_dispatch.rs

1//! Ordered dispatch: the key type, comparison classes and region model behind
2//! [`crate::planning::normalize::NormalFormKind::OrderedDispatch`].
3//!
4//! A `Piecewise` whose arms all compare one scrutinee against constants is decided
5//! by a linear reverse scan. The same decision can be made by a binary search once
6//! the constants are sorted, because every arm predicate is constant over each
7//! region of the domain the constants cut it into.
8//!
9//! For breakpoints `b_0 < b_1 < ... < b_{n-1}` the domain splits into `2n + 1`
10//! regions, alternating open intervals and points:
11//!
12//! - `regions[2i]` is the open interval `(b_{i-1}, b_i)`, with `b_-1 = -inf` and `b_n = +inf`
13//! - `regions[2i+1]` is the point `b_i`
14//!
15//! Point regions are what distinguishes `>` from `>=` at a shared boundary.
16
17use std::cmp::Ordering;
18use std::sync::Arc;
19
20use chrono::NaiveDateTime;
21
22use crate::computation::datetime::{semantic_datetime_to_chrono, semantic_time_to_chrono_datetime};
23use crate::computation::rational::{NumericFailure, RationalInteger};
24use crate::computation::VetoType;
25use crate::parsing::ast::PrimitiveKind;
26use crate::planning::semantics::{ComparisonComputation, LemmaType, LiteralValue, ValueKind};
27
28/// One breakpoint in an ordered dispatch table.
29///
30/// Deliberately implements neither `Ord` nor `PartialOrd`. [`RationalInteger`]'s
31/// `Ord` panics when the cross-multiplication needed to order two rationals runs
32/// out of memory, while evaluation must turn that failure into a veto. Ordering
33/// therefore goes through [`Self::try_compare`], which surfaces the failure.
34///
35/// `Text` stays `Arc<str>`: planning clones dispatch tables through the cons key,
36/// `extract_reachable`, and `PlanView` merges, so a shared string is one refcount
37/// bump per clone where an owned one is an allocation per clone (measured 10%
38/// slower load on the 126000 logistics fixture).
39#[derive(Clone, Debug, PartialEq, Eq, Hash)]
40pub enum DispatchKey {
41    Text(Arc<str>),
42    /// Stored magnitude of a number, ratio or measure. Measure magnitudes are held
43    /// in canonical base-unit space, which is why one key covers all three.
44    Rational(RationalInteger),
45    Date(NaiveDateTime),
46    Time(NaiveDateTime),
47}
48
49/// Borrowed probe used only while searching a dispatch table.
50///
51/// Text and Rational borrow the evaluated scrutinee so evaluation does not clone.
52/// Date and Time own a [`NaiveDateTime`] because calendar conversion is a
53/// computation, not a borrow, and must happen once per evaluation either way.
54#[derive(Clone, Copy, Debug)]
55pub(crate) enum DispatchProbe<'a> {
56    Text(&'a str),
57    Rational(&'a RationalInteger),
58    Date(NaiveDateTime),
59    Time(NaiveDateTime),
60}
61
62impl DispatchKey {
63    pub(crate) fn as_probe(&self) -> DispatchProbe<'_> {
64        match self {
65            DispatchKey::Text(text) => DispatchProbe::Text(text.as_ref()),
66            DispatchKey::Rational(magnitude) => DispatchProbe::Rational(magnitude),
67            DispatchKey::Date(moment) => DispatchProbe::Date(*moment),
68            DispatchKey::Time(moment) => DispatchProbe::Time(*moment),
69        }
70    }
71
72    /// Order this stored key against a probe of the same class.
73    ///
74    /// Panics on a cross-class comparison: every key in one dispatch cell shares a
75    /// class by construction, so a mixed pair means the fold built a broken table.
76    pub(crate) fn try_compare(
77        &self,
78        other: &DispatchProbe<'_>,
79    ) -> Result<Ordering, NumericFailure> {
80        match (self, other) {
81            (DispatchKey::Text(left), DispatchProbe::Text(right)) => Ok((**left).cmp(right)),
82            (DispatchKey::Rational(left), DispatchProbe::Rational(right)) => left.try_cmp(right),
83            (DispatchKey::Date(left), DispatchProbe::Date(right)) => Ok(left.cmp(right)),
84            (DispatchKey::Time(left), DispatchProbe::Time(right)) => Ok(left.cmp(right)),
85            (left, right) => panic!(
86                "BUG: OrderedDispatch compared keys of different classes: {left:?} vs {right:?}"
87            ),
88        }
89    }
90}
91
92/// Failure building an owned plan-time dispatch key from a literal.
93#[derive(Debug)]
94pub(crate) enum DispatchKeyBuildError {
95    /// Calendar conversion failed. Same message surface as
96    /// [`crate::computation::datetime::datetime_comparison`].
97    CalendarFailure(String),
98    /// A value kind ordered dispatch never keys on.
99    Unsupported,
100}
101
102/// Owned key for plan storage, cloned from a literal's value.
103pub(crate) fn dispatch_key_of_literal(
104    value: &ValueKind,
105) -> Result<DispatchKey, DispatchKeyBuildError> {
106    match value {
107        ValueKind::Text(text) => Ok(DispatchKey::Text(Arc::clone(text))),
108        ValueKind::Number(magnitude)
109        | ValueKind::Measure(magnitude)
110        | ValueKind::Ratio(magnitude) => Ok(DispatchKey::Rational(magnitude.clone())),
111        ValueKind::Date(date) => match semantic_datetime_to_chrono(date) {
112            Ok(moment) => Ok(DispatchKey::Date(moment.naive_utc())),
113            Err(message) => Err(DispatchKeyBuildError::CalendarFailure(message)),
114        },
115        ValueKind::Time(time) => match semantic_time_to_chrono_datetime(time) {
116            Ok(moment) => Ok(DispatchKey::Time(moment.naive_utc())),
117            Err(message) => Err(DispatchKeyBuildError::CalendarFailure(message)),
118        },
119        ValueKind::Boolean(_)
120        | ValueKind::Range(_, _)
121        | ValueKind::Spec(_)
122        | ValueKind::List(_)
123        | ValueKind::UnscaledMeasure { .. } => Err(DispatchKeyBuildError::Unsupported),
124    }
125}
126
127/// Result of turning a value into a dispatch probe.
128#[derive(Debug)]
129pub(crate) enum DispatchProbeOutcome<'a> {
130    Probe(DispatchProbe<'a>),
131    /// Calendar conversion failed. Evaluation vetoes with this message, matching
132    /// [`crate::computation::datetime::datetime_comparison`].
133    CalendarFailure(String),
134    /// A value kind ordered dispatch never keys on.
135    Unsupported,
136}
137
138pub(crate) fn dispatch_probe_of(value: &ValueKind) -> DispatchProbeOutcome<'_> {
139    match value {
140        ValueKind::Text(text) => DispatchProbeOutcome::Probe(DispatchProbe::Text(text.as_ref())),
141        ValueKind::Number(magnitude)
142        | ValueKind::Measure(magnitude)
143        | ValueKind::Ratio(magnitude) => {
144            DispatchProbeOutcome::Probe(DispatchProbe::Rational(magnitude))
145        }
146        ValueKind::Date(date) => match semantic_datetime_to_chrono(date) {
147            Ok(moment) => DispatchProbeOutcome::Probe(DispatchProbe::Date(moment.naive_utc())),
148            Err(message) => DispatchProbeOutcome::CalendarFailure(message),
149        },
150        ValueKind::Time(time) => match semantic_time_to_chrono_datetime(time) {
151            Ok(moment) => DispatchProbeOutcome::Probe(DispatchProbe::Time(moment.naive_utc())),
152            Err(message) => DispatchProbeOutcome::CalendarFailure(message),
153        },
154        ValueKind::Boolean(_)
155        | ValueKind::Range(_, _)
156        | ValueKind::Spec(_)
157        | ValueKind::List(_)
158        | ValueKind::UnscaledMeasure { .. } => DispatchProbeOutcome::Unsupported,
159    }
160}
161
162/// The comparison class a dispatch cell keys on.
163///
164/// Each variant names a set of `(scrutinee, key)` pairs that
165/// [`crate::computation::comparison::comparison_operation`] resolves by directly
166/// ordering the two values. Pairs that compute something first (any range) cannot
167/// be reproduced by a table lookup and have no class.
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
169pub(crate) enum DispatchClass {
170    /// Text compares with `is` and `is not` only; every other operator is rejected
171    /// inside `comparison_operation`.
172    Text,
173    Rational,
174    Date,
175    Time,
176}
177
178/// The class shared by a scrutinee type and every key, or `None` when the pattern
179/// does not apply.
180pub(crate) fn classify_dispatch(
181    scrutinee_type: &LemmaType,
182    keys: &[&LiteralValue],
183) -> Option<DispatchClass> {
184    if keys.is_empty() {
185        return None;
186    }
187    if scrutinee_type.is_undetermined() || scrutinee_type.is_range() || scrutinee_type.is_boolean()
188    {
189        return None;
190    }
191    let mut class: Option<DispatchClass> = None;
192    for key in keys {
193        let key_class = class_for_pair(scrutinee_type, key)?;
194        match class {
195            None => class = Some(key_class),
196            Some(established) if established == key_class => {}
197            Some(_) => return None,
198        }
199    }
200    class
201}
202
203/// The class for one `(scrutinee type, key literal)` pair, following the arm order
204/// of `comparison_operation`.
205fn class_for_pair(scrutinee_type: &LemmaType, key: &LiteralValue) -> Option<DispatchClass> {
206    // Range keys always decline: ValueKind::Range has no type on the literal anymore.
207    if matches!(key.value, ValueKind::Range(_, _)) {
208        return None;
209    }
210    match &key.value {
211        ValueKind::Text(_) => scrutinee_type
212            .matches_primitive_kind(PrimitiveKind::Text)
213            .then_some(DispatchClass::Text),
214        ValueKind::Date(_) => scrutinee_type
215            .matches_primitive_kind(PrimitiveKind::Date)
216            .then_some(DispatchClass::Date),
217        ValueKind::Time(_) => scrutinee_type
218            .matches_primitive_kind(PrimitiveKind::Time)
219            .then_some(DispatchClass::Time),
220        ValueKind::Ratio(_) => scrutinee_type
221            .matches_primitive_kind(PrimitiveKind::Ratio)
222            .then_some(DispatchClass::Rational),
223        ValueKind::Number(_) => {
224            if scrutinee_type.matches_primitive_kind(PrimitiveKind::Number) {
225                return Some(DispatchClass::Rational);
226            }
227            // `(Measure, Number)` reaches a stored-magnitude compare only for the two
228            // measure shapes `comparison_operation` guards on.
229            (scrutinee_type.is_duration_like_measure() || scrutinee_type.is_calendar_like())
230                .then_some(DispatchClass::Rational)
231        }
232        ValueKind::Measure(_) => {
233            // Measure key typing needs NormalForm.result_type; without it, decline to Piecewise.
234            if scrutinee_type.matches_primitive_kind(PrimitiveKind::Number) {
235                return None;
236            }
237            if !scrutinee_type.is_measure() {
238                return None;
239            }
240            // Same-family measure keys are accepted when the scrutinee is a measure; family
241            // checks that needed key.lemma_type are deferred to evaluation / planning rejects.
242            Some(DispatchClass::Rational)
243        }
244        ValueKind::Boolean(_)
245        | ValueKind::Range(_, _)
246        | ValueKind::Spec(_)
247        | ValueKind::List(_)
248        | ValueKind::UnscaledMeasure { .. } => None,
249    }
250}
251
252/// Number of regions for `boundary_count` breakpoints.
253pub fn region_count(boundary_count: usize) -> usize {
254    2 * boundary_count + 1
255}
256
257/// Paint shape of an exclusive `scrutinee is K` table: every interval region
258/// holds the default body (`regions[0]`), and no point region is itself a nested
259/// Piecewise / OrderedDispatch (`point_is_nested`).
260pub(crate) fn is_exclusive_point_table<T: Copy + PartialEq>(
261    regions: &[T],
262    point_is_nested: impl Fn(T) -> bool,
263) -> bool {
264    assert!(
265        !regions.is_empty(),
266        "BUG: empty OrderedDispatch region table"
267    );
268    let default = regions[0];
269    for (index, &body) in regions.iter().enumerate() {
270        if index % 2 == 0 {
271            if body != default {
272                return false;
273            }
274        } else if point_is_nested(body) {
275            return false;
276        }
277    }
278    true
279}
280
281/// Inclusive region index ranges in which `operator` against the breakpoint at
282/// `boundary_index` holds.
283///
284/// Every predicate covers one contiguous run except `is not`, which is the
285/// complement of a single point and therefore two.
286pub(crate) fn regions_matching(
287    operator: &ComparisonComputation,
288    boundary_index: usize,
289    boundary_count: usize,
290) -> [Option<(usize, usize)>; 2] {
291    let point = 2 * boundary_index + 1;
292    let last = 2 * boundary_count;
293    match operator {
294        ComparisonComputation::Is => [Some((point, point)), None],
295        ComparisonComputation::IsNot => [Some((0, point - 1)), Some((point + 1, last))],
296        ComparisonComputation::GreaterThan => [Some((point + 1, last)), None],
297        ComparisonComputation::GreaterThanOrEqual => [Some((point, last)), None],
298        ComparisonComputation::LessThan => [Some((0, point - 1)), None],
299        ComparisonComputation::LessThanOrEqual => [Some((0, point)), None],
300    }
301}
302
303/// Index of the region holding `value`.
304///
305/// Hand-rolled rather than `partition_point` so a failed rational comparison
306/// surfaces as an error the caller can turn into a veto instead of a panic.
307pub(crate) fn region_for_value(
308    boundaries: &[DispatchKey],
309    value: &DispatchProbe<'_>,
310) -> Result<usize, NumericFailure> {
311    let mut low = 0usize;
312    let mut high = boundaries.len();
313    while low < high {
314        let middle = low + (high - low) / 2;
315        match boundaries[middle].try_compare(value)? {
316            Ordering::Less => low = middle + 1,
317            Ordering::Equal | Ordering::Greater => high = middle,
318        }
319    }
320    if low < boundaries.len() && boundaries[low].try_compare(value)? == Ordering::Equal {
321        Ok(2 * low + 1)
322    } else {
323        Ok(2 * low)
324    }
325}
326
327/// Region index for an evaluated, non-veto scrutinee value. The one decision
328/// procedure shared by value evaluation, explain's winner check and `missing_data`
329/// liveness. A calendar or rational failure is the veto the dispatch evaluates to.
330/// A value kind the fold excludes is a planning bug.
331pub fn region_of_scrutinee(
332    boundaries: &[DispatchKey],
333    value: &ValueKind,
334) -> Result<usize, VetoType> {
335    let probe = match dispatch_probe_of(value) {
336        DispatchProbeOutcome::Probe(probe) => probe,
337        DispatchProbeOutcome::CalendarFailure(message) => {
338            return Err(VetoType::computation(message));
339        }
340        DispatchProbeOutcome::Unsupported => panic!(
341            "BUG: OrderedDispatch scrutinee evaluated to {value:?}, a kind the fold excludes"
342        ),
343    };
344    region_for_value(boundaries, &probe)
345        .map_err(|failure| VetoType::computation(failure.to_string()))
346}
347
348/// Sorted, deduplicated breakpoints.
349///
350/// Keys are always reduced rationals or plain values, so structural equality is
351/// faithful to ordering equality and `dedup` is exact.
352pub(crate) fn sorted_unique_boundaries(
353    mut keys: Vec<DispatchKey>,
354) -> Result<Vec<DispatchKey>, NumericFailure> {
355    let mut failure: Option<NumericFailure> = None;
356    keys.sort_by(|left, right| match left.try_compare(&right.as_probe()) {
357        Ok(ordering) => ordering,
358        Err(numeric) => {
359            failure.get_or_insert(numeric);
360            Ordering::Equal
361        }
362    });
363    match failure {
364        Some(numeric) => Err(numeric),
365        None => {
366            keys.dedup();
367            Ok(keys)
368        }
369    }
370}
371
372/// Region bodies for last-match-wins arm coverage, in O(n α(n)) region visits.
373///
374/// `arms` is earliest-first: each entry is `(operator, boundary_index, body)`.
375/// Later arms overwrite earlier ones. Uncovered regions keep `default_body`.
376///
377/// Uses a next-unpainted jump array (union-find with path compression) so each
378/// region is painted at most once, even when inequality / `is not` spans cover
379/// O(n) regions per arm.
380pub(crate) fn paint_dispatch_regions<T: Copy>(
381    boundary_count: usize,
382    default_body: T,
383    arms: &[(ComparisonComputation, usize, T)],
384) -> Vec<T> {
385    let n = region_count(boundary_count);
386    let mut regions = vec![default_body; n];
387    // `next[i] == i` means region i is unpainted; otherwise `next[i]` points at
388    // the next unpainted index. `next[n] = n` is the permanent sentinel.
389    let mut next: Vec<usize> = (0..=n).collect();
390
391    fn find(next: &mut [usize], mut index: usize) -> usize {
392        let mut root = index;
393        while next[root] != root {
394            root = next[root];
395        }
396        while index != root {
397            let parent = next[index];
398            next[index] = root;
399            index = parent;
400        }
401        root
402    }
403
404    for (operator, boundary_index, body) in arms.iter().rev() {
405        for (start, end) in regions_matching(operator, *boundary_index, boundary_count)
406            .into_iter()
407            .flatten()
408        {
409            let mut index = find(&mut next, start);
410            while index <= end {
411                regions[index] = *body;
412                let after = find(&mut next, index + 1);
413                next[index] = after;
414                index = after;
415            }
416        }
417    }
418    regions
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::computation::rational::rational_new;
425
426    fn rational_key(numerator: i64) -> DispatchKey {
427        DispatchKey::Rational(rational_new(numerator, 1))
428    }
429
430    fn text_key(text: &str) -> DispatchKey {
431        DispatchKey::Text(Arc::from(text))
432    }
433
434    /// Truth of `scrutinee operator boundary`, computed the slow way.
435    fn predicate_holds(
436        scrutinee: &DispatchKey,
437        operator: &ComparisonComputation,
438        boundary: &DispatchKey,
439    ) -> bool {
440        let ordering = scrutinee
441            .try_compare(&boundary.as_probe())
442            .expect("test keys compare without failure");
443        match operator {
444            ComparisonComputation::Is => ordering == Ordering::Equal,
445            ComparisonComputation::IsNot => ordering != Ordering::Equal,
446            ComparisonComputation::GreaterThan => ordering == Ordering::Greater,
447            ComparisonComputation::GreaterThanOrEqual => ordering != Ordering::Less,
448            ComparisonComputation::LessThan => ordering == Ordering::Less,
449            ComparisonComputation::LessThanOrEqual => ordering != Ordering::Greater,
450        }
451    }
452
453    fn all_operators() -> Vec<ComparisonComputation> {
454        vec![
455            ComparisonComputation::Is,
456            ComparisonComputation::IsNot,
457            ComparisonComputation::GreaterThan,
458            ComparisonComputation::GreaterThanOrEqual,
459            ComparisonComputation::LessThan,
460            ComparisonComputation::LessThanOrEqual,
461        ]
462    }
463
464    #[test]
465    fn region_lookup_places_values_below_at_and_above_each_boundary() {
466        let boundaries = vec![rational_key(0), rational_key(5), rational_key(10)];
467        let cases = [
468            (-1, 0usize),
469            (0, 1),
470            (3, 2),
471            (5, 3),
472            (7, 4),
473            (10, 5),
474            (11, 6),
475        ];
476        for (value, expected) in cases {
477            assert_eq!(
478                region_for_value(&boundaries, &rational_key(value).as_probe()).expect("comparable"),
479                expected,
480                "value {value} must land in region {expected}"
481            );
482        }
483    }
484
485    #[test]
486    fn empty_boundary_list_has_one_region() {
487        assert_eq!(region_count(0), 1);
488        assert_eq!(
489            region_for_value(&[], &rational_key(42).as_probe()).expect("comparable"),
490            0
491        );
492    }
493
494    /// The region ranges an operator claims must match the predicate evaluated
495    /// directly at a representative value of every region.
496    #[test]
497    fn claimed_regions_agree_with_the_predicate_at_every_region() {
498        let boundaries = vec![rational_key(0), rational_key(5), rational_key(10)];
499        // Two units apart so every open interval has an integer representative.
500        let representatives = [-5, 0, 2, 5, 7, 10, 15];
501        assert_eq!(representatives.len(), region_count(boundaries.len()));
502
503        for operator in all_operators() {
504            for (boundary_index, boundary) in boundaries.iter().enumerate() {
505                let mut claimed = vec![false; region_count(boundaries.len())];
506                for span in regions_matching(&operator, boundary_index, boundaries.len())
507                    .into_iter()
508                    .flatten()
509                {
510                    let (start, end) = span;
511                    for region in claimed.iter_mut().take(end + 1).skip(start) {
512                        *region = true;
513                    }
514                }
515                for (region, value) in representatives.iter().enumerate() {
516                    let scrutinee = rational_key(*value);
517                    assert_eq!(
518                        region_for_value(&boundaries, &scrutinee.as_probe()).expect("comparable"),
519                        region,
520                        "representative {value} must sit in region {region}"
521                    );
522                    assert_eq!(
523                        claimed[region],
524                        predicate_holds(&scrutinee, &operator, boundary),
525                        "operator {operator} against boundary index {boundary_index} \
526                         disagrees at region {region} (value {value})"
527                    );
528                }
529            }
530        }
531    }
532
533    #[test]
534    fn greater_than_and_greater_or_equal_differ_only_at_the_point_region() {
535        let strictly = regions_matching(&ComparisonComputation::GreaterThan, 1, 3);
536        let inclusive = regions_matching(&ComparisonComputation::GreaterThanOrEqual, 1, 3);
537        assert_eq!(strictly, [Some((4, 6)), None]);
538        assert_eq!(inclusive, [Some((3, 6)), None]);
539    }
540
541    #[test]
542    fn less_than_and_less_or_equal_differ_only_at_the_point_region() {
543        let strictly = regions_matching(&ComparisonComputation::LessThan, 1, 3);
544        let inclusive = regions_matching(&ComparisonComputation::LessThanOrEqual, 1, 3);
545        assert_eq!(strictly, [Some((0, 2)), None]);
546        assert_eq!(inclusive, [Some((0, 3)), None]);
547    }
548
549    #[test]
550    fn is_not_claims_everything_except_its_point() {
551        assert_eq!(
552            regions_matching(&ComparisonComputation::IsNot, 1, 3),
553            [Some((0, 2)), Some((4, 6))]
554        );
555    }
556
557    #[test]
558    fn boundaries_are_sorted_and_deduplicated() {
559        let sorted = sorted_unique_boundaries(vec![
560            rational_key(5),
561            rational_key(-2),
562            rational_key(5),
563            rational_key(0),
564        ])
565        .expect("comparable");
566        assert_eq!(
567            sorted,
568            vec![rational_key(-2), rational_key(0), rational_key(5)]
569        );
570    }
571
572    #[test]
573    fn text_boundaries_sort_lexicographically() {
574        let sorted = sorted_unique_boundaries(vec![text_key("NL"), text_key("AD"), text_key("ZW")])
575            .expect("comparable");
576        assert_eq!(sorted, vec![text_key("AD"), text_key("NL"), text_key("ZW")]);
577        assert_eq!(
578            region_for_value(&sorted, &text_key("NL").as_probe()).expect("comparable"),
579            3
580        );
581        assert_eq!(
582            region_for_value(&sorted, &text_key("BE").as_probe()).expect("comparable"),
583            2
584        );
585    }
586
587    #[test]
588    fn exclusive_point_table_requires_interval_default_and_flat_points() {
589        // Two breakpoints → regions [default, p0, default, p1, default].
590        assert!(is_exclusive_point_table(&[0, 1, 0, 2, 0], |_| false));
591        assert!(!is_exclusive_point_table(&[0, 1, 3, 2, 0], |_| false));
592        assert!(!is_exclusive_point_table(&[0, 1, 0, 2, 0], |body| body == 2));
593    }
594
595    #[test]
596    #[should_panic(expected = "BUG: OrderedDispatch compared keys of different classes")]
597    fn comparing_keys_of_different_classes_is_a_bug() {
598        text_key("AD")
599            .try_compare(&rational_key(1).as_probe())
600            .expect("BUG: comparison must panic before returning a failure");
601    }
602
603    #[test]
604    fn paint_last_wins_matches_forward_overwrite_on_overlapping_spans() {
605        // Same scenario as the fold test: x > 5 then 1, x > 10 then 2.
606        // Regions: below5 | at5 | (5,10) | at10 | above10
607        let forward = {
608            let mut regions = vec![0u8; 5];
609            for (start, end) in regions_matching(&ComparisonComputation::GreaterThan, 0, 2)
610                .into_iter()
611                .flatten()
612            {
613                regions[start..=end].fill(1);
614            }
615            for (start, end) in regions_matching(&ComparisonComputation::GreaterThan, 1, 2)
616                .into_iter()
617                .flatten()
618            {
619                regions[start..=end].fill(2);
620            }
621            regions
622        };
623        let painted = paint_dispatch_regions(
624            2,
625            0u8,
626            &[
627                (ComparisonComputation::GreaterThan, 0, 1u8),
628                (ComparisonComputation::GreaterThan, 1, 2u8),
629            ],
630        );
631        assert_eq!(painted, forward);
632        assert_eq!(painted, vec![0, 0, 1, 1, 2]);
633    }
634
635    #[test]
636    fn paint_is_not_and_duplicate_keys_agree_with_forward_fill() {
637        let arms = [
638            (ComparisonComputation::IsNot, 0usize, 1u8),
639            (ComparisonComputation::Is, 0, 2u8),
640            (ComparisonComputation::Is, 1, 3u8),
641        ];
642        let boundary_count = 2;
643        let mut forward = vec![0u8; region_count(boundary_count)];
644        for (operator, boundary_index, body) in &arms {
645            for (start, end) in regions_matching(operator, *boundary_index, boundary_count)
646                .into_iter()
647                .flatten()
648            {
649                forward[start..=end].fill(*body);
650            }
651        }
652        assert_eq!(paint_dispatch_regions(boundary_count, 0u8, &arms), forward);
653    }
654
655    #[test]
656    fn probe_of_text_and_rational_borrows_without_clone() {
657        let text = ValueKind::Text(Arc::from("NL"));
658        let number = ValueKind::Number(rational_new(3, 1));
659        match dispatch_probe_of(&text) {
660            DispatchProbeOutcome::Probe(DispatchProbe::Text(borrowed)) => {
661                assert_eq!(borrowed, "NL");
662            }
663            other => panic!("expected text probe, got {other:?}"),
664        }
665        match dispatch_probe_of(&number) {
666            DispatchProbeOutcome::Probe(DispatchProbe::Rational(borrowed)) => {
667                assert_eq!(*borrowed, rational_new(3, 1));
668            }
669            other => panic!("expected rational probe, got {other:?}"),
670        }
671    }
672
673    /// Every pair `classify_dispatch` accepts must be a live `comparison_operation` arm.
674    #[test]
675    fn classify_dispatch_pairs_are_accepted_by_comparison_operation() {
676        use crate::computation::comparison::comparison_operation;
677        use crate::computation::operation_result::OperationResult;
678        use crate::planning::semantics::LiteralValue;
679
680        let cases: Vec<(LiteralValue, LiteralValue, ComparisonComputation)> = vec![
681            (
682                LiteralValue::number(rational_new(1, 1)),
683                LiteralValue::number(rational_new(2, 1)),
684                ComparisonComputation::LessThan,
685            ),
686            (
687                LiteralValue::number(rational_new(3, 1)),
688                LiteralValue::number(rational_new(3, 1)),
689                ComparisonComputation::Is,
690            ),
691            (
692                LiteralValue::text("NL"),
693                LiteralValue::text("NL"),
694                ComparisonComputation::Is,
695            ),
696            (
697                LiteralValue::text("NL"),
698                LiteralValue::text("BE"),
699                ComparisonComputation::IsNot,
700            ),
701            (
702                LiteralValue::ratio(rational_new(1, 2)),
703                LiteralValue::ratio(rational_new(3, 4)),
704                ComparisonComputation::LessThan,
705            ),
706        ];
707
708        for (scrutinee, key, operator) in &cases {
709            let scrutinee_type = match &scrutinee.value {
710                ValueKind::Number(_) => crate::planning::semantics::primitive_number_arc(),
711                ValueKind::Text(_) => crate::planning::semantics::primitive_text_arc(),
712                ValueKind::Ratio(_) => crate::planning::semantics::primitive_ratio_arc(),
713                other => panic!("unexpected scrutinee kind {other:?}"),
714            };
715            let class = classify_dispatch(scrutinee_type.as_ref(), &[key]).unwrap_or_else(|| {
716                panic!(
717                    "classify_dispatch must accept {:?} vs {:?}",
718                    scrutinee.value, key.value
719                )
720            });
721            if class == DispatchClass::Text {
722                assert!(
723                    matches!(
724                        operator,
725                        ComparisonComputation::Is | ComparisonComputation::IsNot
726                    ),
727                    "text class only uses is / is not"
728                );
729            }
730            match comparison_operation(
731                &scrutinee.value,
732                scrutinee_type,
733                operator,
734                &key.value,
735                scrutinee_type,
736            ) {
737                OperationResult::Value(result) => match &result.value {
738                    ValueKind::Boolean(_) => {}
739                    other => panic!("expected boolean comparison result, got {other:?}"),
740                },
741                OperationResult::Veto(veto) => {
742                    panic!("comparison_operation vetoed a classify-accepted pair: {veto:?}");
743                }
744            }
745        }
746
747        let boolean = LiteralValue::from_bool(true);
748        assert!(
749            classify_dispatch(
750                crate::planning::semantics::primitive_boolean_arc().as_ref(),
751                &[&boolean],
752            )
753            .is_none(),
754            "boolean scrutinees are not a dispatch class"
755        );
756    }
757}