Skip to main content

rucc_verify/
report.rs

1//! The list of rules nobody has proved at the width the compiler runs them at.
2//!
3//! Design: `spec/optimizer/41-correctness.md` section 41.8. The count of rules that needed a
4//! bounded proof has been printed since this crate existed, and a number in a build log is a
5//! thing nobody reads twice. What that section asks for instead is a file: every such rule listed
6//! by name with the reason it was let in, checked in beside the rules, so that adding one is a
7//! line in a diff somebody has to approve. The list is the artefact and the count going up is the
8//! alarm.
9//!
10//! Nothing else is on the list, because there is nothing else to put on it. A rule the solver
11//! refutes and a rule it gives up on without a written reason do not enter the rule set at all,
12//! so the only case there is to record is the middle one: a claim no solver settles at sixty four
13//! bits, settled at [`crate::BOUNDED_WIDTHS`] instead, with somebody's reason for taking that as
14//! enough.
15//!
16//! # Naming a rule
17//!
18//! Rules have no names, so the name here is the pattern printed back, with the guard after it
19//! when there is one. That is what a reader recognises the rule by and it is stable under the
20//! edits that are not about this rule, which a line number would not be: a rule added at the top
21//! of a file would otherwise rewrite every entry below it and the diff would stop meaning
22//! anything.
23
24use std::fmt::Write as _;
25
26use rucc_rules::Rule;
27
28use crate::verify::{Report, Verdict};
29
30/// One rule that was let in on a proof at narrower widths than it runs at.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Unverified {
33    /// The rule file it is in, as it was named on the command line.
34    pub file: String,
35    /// The pattern it matches, printed back.
36    pub pattern: String,
37    /// The condition on the match, printed back, when the rule has one.
38    pub guard: Option<String>,
39    /// The widths it was proved at, narrowest first.
40    pub widths: Vec<u32>,
41    /// The reason the rule's `bounded` clause gives, which is what a reviewer signed for.
42    pub why: String,
43}
44
45/// Every rule in one file that was let in on a bounded proof, in the order they are written.
46#[must_use]
47pub fn listed(file: &str, rules: &[Rule], report: &Report) -> Vec<Unverified> {
48    let mut out = Vec::new();
49    for (rule, verdict) in rules.iter().zip(&report.verdicts) {
50        let Verdict::Bounded { widths, why } = verdict else { continue };
51        out.push(Unverified {
52            file: file.to_owned(),
53            pattern: rule.pattern.to_string(),
54            guard: rule.guard.as_ref().map(ToString::to_string),
55            widths: widths.clone(),
56            why: why.clone(),
57        });
58    }
59    out
60}
61
62/// The whole file, as text.
63///
64/// The entries arrive already in the order the files were verified in, which is sorted, so the
65/// grouping below is a run over neighbours rather than a sort of its own. A file that produced no
66/// entries gets no heading, because a heading with nothing under it reads as a claim that
67/// something is wrong there.
68#[must_use]
69pub fn render(entries: &[Unverified]) -> String {
70    let mut out = String::from("# Unverified rules\n\n");
71    out.push_str(
72        "Generated by `cargo run -q -p rucc-verify -- crates/rucc-codegen/rules crates/rucc-opt/rules --report docs/UNVERIFIED.md`. Do not edit this file, edit the rules.\n\n",
73    );
74    out.push_str(
75        "Every rule in the rule set carries a bitvector claim and `rucc-verify` discharges it before the rule is allowed in. Almost all of them are settled at the width the compiler runs them at, and there is nothing to say about those. This file is the rest: a rule no solver settles at its own width, proved instead at four and eight bits because somebody wrote down a reason for taking that as enough. `spec/optimizer/41-correctness.md` section 41.8 asks for that set to be a list rather than a number in a build log, because the list is the thing a reviewer can argue with.\n\n",
76    );
77    out.push_str(
78        "A rule the solver refutes is not here, and neither is a rule it gives up on that carries no reason. Neither of those enters the rule set at all, so there is no list for them to be on.\n\n",
79    );
80    out.push_str(&format!("{}\n\n", counted(entries.len())));
81
82    let mut file = "";
83    for entry in entries {
84        if entry.file != file {
85            file = &entry.file;
86            let _ = writeln!(out, "## `{file}`\n");
87        }
88        out.push_str(&bullet(entry));
89    }
90    out
91}
92
93/// How many rules are on the list, said as a sentence, which is the number the alarm is about.
94fn counted(count: usize) -> String {
95    match count {
96        0 => "No rule is on this list, which is the state to keep it in.".to_owned(),
97        1 => "One rule is on this list.".to_owned(),
98        many => format!("{many} rules are on this list."),
99    }
100}
101
102/// One entry, as the line it occupies in the file.
103fn bullet(entry: &Unverified) -> String {
104    let mut out = format!("- `{}`", entry.pattern);
105    if let Some(guard) = &entry.guard {
106        let _ = write!(out, " when `{guard}`");
107    }
108    let _ = writeln!(out, ", proved at {}: {}", widths(&entry.widths), entry.why);
109    out
110}
111
112/// The widths a bounded proof was taken over, said the way a person would say them.
113fn widths(over: &[u32]) -> String {
114    let mut out = String::new();
115    for (at, width) in over.iter().enumerate() {
116        if at > 0 {
117            out.push_str(if at + 1 == over.len() { " and " } else { ", " });
118        }
119        let _ = write!(out, "{width}");
120    }
121    out.push_str(" bits");
122    out
123}
124
125/// How the list on disk differs from the list the solver just produced.
126///
127/// The two halves are what was added and what was removed, and both are wanted rather than a
128/// count: a rule leaving the list is a rule somebody managed to prove properly and is worth
129/// seeing, and a rule joining it is the thing this whole file exists to make visible. Comparing
130/// the entry lines rather than the whole text is what makes that possible, since the surrounding
131/// prose changing is a different event and gets a different sentence out of the caller.
132#[must_use]
133pub fn difference(found: &str, wanted: &str) -> (Vec<String>, Vec<String>) {
134    let entries = |text: &str| -> Vec<String> {
135        text.lines().filter(|line| line.starts_with("- `")).map(ToOwned::to_owned).collect()
136    };
137    let (before, after) = (entries(found), entries(wanted));
138    let added = after.iter().filter(|line| !before.contains(line)).cloned().collect();
139    let removed = before.iter().filter(|line| !after.contains(line)).cloned().collect();
140    (added, removed)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    fn entry(file: &str, pattern: &str) -> Unverified {
148        Unverified {
149            file: file.to_owned(),
150            pattern: pattern.to_owned(),
151            guard: None,
152            widths: vec![4, 8],
153            why: "the solver does not settle a multiply of two unknowns at this width".to_owned(),
154        }
155    }
156
157    #[test]
158    fn an_empty_list_says_so_rather_than_trailing_off() {
159        let text = render(&[]);
160        assert!(text.contains("No rule is on this list"), "{text}");
161        assert!(!text.contains("- `"), "{text}");
162        assert!(!text.contains("## `"), "{text}");
163    }
164
165    #[test]
166    fn an_entry_carries_the_rule_the_widths_and_the_reason() {
167        let text = render(&[entry("rules/x86-64.rules", "(mul.i64 (value.i64 x) (iconst.i64 k))")]);
168        assert!(text.contains("One rule is on this list."), "{text}");
169        assert!(text.contains("## `rules/x86-64.rules`"), "{text}");
170        assert!(
171            text.contains(
172                "- `(mul.i64 (value.i64 x) (iconst.i64 k))`, proved at 4 and 8 bits: the solver"
173            ),
174            "{text}"
175        );
176    }
177
178    #[test]
179    fn a_guard_is_part_of_what_names_the_rule() {
180        let mut one = entry("rules/x86-64.rules", "(mul.i64 (value.i64 x) (iconst.i64 k))");
181        one.guard = Some("(= k 1)".to_owned());
182        let text = render(&[one]);
183        assert!(text.contains("(iconst.i64 k))` when `(= k 1)`, proved at"), "{text}");
184    }
185
186    #[test]
187    fn each_file_gets_one_heading_and_the_rules_under_it() {
188        let text = render(&[
189            entry("rules/a.rules", "(mul.i8 x y)"),
190            entry("rules/a.rules", "(mul.i16 x y)"),
191            entry("rules/b.rules", "(mul.i32 x y)"),
192        ]);
193        assert_eq!(text.matches("## `rules/a.rules`").count(), 1, "{text}");
194        assert_eq!(text.matches("## `rules/b.rules`").count(), 1, "{text}");
195        assert!(text.contains("3 rules are on this list."), "{text}");
196    }
197
198    #[test]
199    fn one_width_is_not_said_as_a_pair() {
200        assert_eq!(widths(&[4]), "4 bits");
201        assert_eq!(widths(&[4, 8]), "4 and 8 bits");
202        assert_eq!(widths(&[4, 8, 16]), "4, 8 and 16 bits");
203    }
204
205    #[test]
206    fn the_difference_is_the_rules_that_moved_and_not_the_prose_around_them() {
207        let before = render(&[entry("rules/a.rules", "(mul.i8 x y)")]);
208        let after = render(&[entry("rules/a.rules", "(mul.i16 x y)")]);
209        let (added, removed) = difference(&before, &after);
210        assert_eq!(added.len(), 1, "{added:?}");
211        assert_eq!(removed.len(), 1, "{removed:?}");
212        assert!(added[0].contains("(mul.i16 x y)"), "{added:?}");
213        assert!(removed[0].contains("(mul.i8 x y)"), "{removed:?}");
214
215        // The same list twice is no difference at all, which is the case the gate passes on.
216        let (added, removed) = difference(&before, &before);
217        assert!(added.is_empty() && removed.is_empty(), "{added:?} {removed:?}");
218    }
219}