Skip to main content

rucc_verify/
verify.rs

1//! Turning a rule into a question, and the answers into a report.
2
3use std::fmt;
4
5use rucc_rules::{Error, Rule, Term, TermKind};
6
7use crate::model::{MEMORY_CONST, Model, Sort, Widths, rule_width};
8use crate::solver::{Answer, Ask};
9
10/// What became of one rule.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Verdict {
13    /// Nothing makes the claim false.
14    Discharged,
15    /// Something does, and this is what the solver printed of it.
16    Refuted(String),
17    /// The claim holds at every width narrower than the rule's own, and the rule carries a
18    /// written reason for taking that as enough. A pass, and a counted one.
19    Bounded {
20        /// The widths it was proved at, narrowest first.
21        widths: Vec<u32>,
22        /// The reason the rule gives, which is what a reviewer signed for.
23        why: String,
24    },
25    /// The solver gave up. Not a pass.
26    Unknown,
27}
28
29impl Verdict {
30    /// Whether a rule with this verdict may enter the rule set.
31    #[must_use]
32    pub fn accepted(&self) -> bool {
33        matches!(self, Verdict::Discharged | Verdict::Bounded { .. })
34    }
35
36    /// Why it may not, as a sentence, or nothing when it may.
37    #[must_use]
38    pub fn refusal(&self) -> Option<String> {
39        match self {
40            Verdict::Discharged | Verdict::Bounded { .. } => None,
41            Verdict::Refuted(model) => {
42                Some(format!("this rule is not true, and here is what makes it false: {model}"))
43            }
44            Verdict::Unknown => Some(
45                "the solver could not settle this rule, and a rule nobody has proved does not \
46                 enter the rule set"
47                    .to_owned(),
48            ),
49        }
50    }
51}
52
53/// What became of a rule set.
54#[derive(Debug, Default, Clone, PartialEq, Eq)]
55pub struct Report {
56    /// One verdict per rule, in the order the rules were given.
57    pub verdicts: Vec<Verdict>,
58}
59
60impl Report {
61    /// How many rules were discharged at their own width.
62    #[must_use]
63    pub fn discharged(&self) -> usize {
64        self.verdicts.iter().filter(|v| **v == Verdict::Discharged).count()
65    }
66
67    /// How many rules got a bounded proof instead.
68    ///
69    /// `spec/15-testing.md` section 15.5 asks for this number to be reported rather than merely
70    /// known, because it going up is the signal that the rule set is drifting towards claims
71    /// nobody is checking at the width the compiler runs at.
72    #[must_use]
73    pub fn bounded(&self) -> usize {
74        self.verdicts.iter().filter(|v| matches!(v, Verdict::Bounded { .. })).count()
75    }
76
77    /// Whether every rule was discharged at its own width. A bounded proof is not one of these.
78    #[must_use]
79    pub fn all_discharged(&self) -> bool {
80        self.verdicts.iter().all(|v| *v == Verdict::Discharged)
81    }
82
83    /// Whether every rule may enter the rule set, which allows a bounded proof and allows
84    /// nothing else. A solver that gave up is not a pass, because "we could not tell" is not
85    /// "it is correct".
86    #[must_use]
87    pub fn accepted(&self) -> bool {
88        self.verdicts.iter().all(Verdict::accepted)
89    }
90}
91
92impl fmt::Display for Report {
93    /// One line, which is what a build prints and what a person reads in a log.
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        let refused = self.verdicts.len() - self.discharged() - self.bounded();
96        let rules = if self.verdicts.len() == 1 { "rule" } else { "rules" };
97        write!(
98            f,
99            "{} {rules}: {} discharged, {} by bounded proof, {} refused",
100            self.verdicts.len(),
101            self.discharged(),
102            self.bounded(),
103            refused
104        )
105    }
106}
107
108/// The SMT-LIB question one rule asks, at the width the rule works in.
109///
110/// This is separate from asking it so that the query can be read, kept in a test, and handed to
111/// a solver by hand when one is arguing with it.
112///
113/// # Errors
114///
115/// Anything the model cannot write out, which is any term nobody has said the meaning of.
116pub fn query(path: &str, rule: &Rule, model: &Model) -> Result<String, Error> {
117    query_at(path, rule, model, rule_width(&rule.pattern))
118}
119
120/// The same question asked at a width somebody chose.
121///
122/// This is what a bounded proof is made of: the rule's own claim, in narrower bitvectors than
123/// the ones it will run in. Every width in the rule scales by the one ratio, so a rule that
124/// converts between widths still converts between them here.
125///
126/// # Errors
127///
128/// Anything the model cannot write out, which is any term nobody has said the meaning of, and
129/// anything whose widths do not fit together.
130pub fn query_at(path: &str, rule: &Rule, model: &Model, width: u32) -> Result<String, Error> {
131    let widths = Widths::at(&rule.pattern, width);
132
133    // A rule that reaches memory needs the theory of arrays and a constant to stand for the
134    // memory it starts from, and a rule that reaches a float needs the theory of floats. A rule
135    // that does neither gets neither, so every rule written before effects and floats existed
136    // asks exactly the question it asked before.
137    let parts = [&rule.pattern, &rule.replacement, &rule.spec];
138    let memory = parts.iter().any(|term| model.touches_memory(term));
139    let floats = parts.iter().any(|term| model.touches_floats(term));
140    let logic = match (memory, floats) {
141        (false, false) => "QF_BV",
142        (true, false) => "QF_ABV",
143        (false, true) => "QF_FPBV",
144        (true, true) => "QF_ABVFP",
145    };
146    let mut out = format!("(set-logic {logic})\n");
147    if memory {
148        let sort = Sort::Memory.write(&widths);
149        out.push_str(&format!("(declare-const {MEMORY_CONST} {sort})\n"));
150    }
151
152    // Each name as the pattern binds it, which is not one thing for the whole rule: a rule that
153    // lowers a thirty two bit add of two sixty four bit registers has both widths in it and
154    // neither is the other, and a rule that lowers a float has a float and an address in it.
155    for (name, sort) in widths.names() {
156        out.push_str(&format!("(declare-const {name} {})\n", sort.write(&widths)));
157    }
158
159    if let Some(guard) = &rule.guard {
160        // An assumption, not part of the claim. A rule that only holds for some constants is
161        // only being asked about those constants.
162        out.push_str(&format!("(assert {})\n", model.write(path, guard, &widths)?.0));
163    }
164
165    // Two obligations, asked as one question. The first is the one that matters: what the
166    // pattern means and what the replacement means have to be the same thing, both read out of
167    // the model rather than out of anybody's description of them. The second is the rule's own
168    // `spec` clause, which is written by hand and so is worth checking rather than trusting: a
169    // rule whose stated claim is not what its pattern actually means would otherwise verify
170    // against its own mistake.
171    let (matched, over) = model.write(path, &rule.pattern, &widths)?;
172    let (produced, into) = model.write(path, &rule.replacement, &widths)?;
173    let same = agreement(path, &rule.replacement, &matched, &produced, over, into)?;
174    let substituted = substitute(&rule.spec, &produced);
175    let claim = model.write(path, &substituted, &widths.with(&produced, into))?.0;
176    out.push_str(&format!("(assert (not (and {same} {claim})))\n"));
177    out.push_str("(check-sat)\n(get-model)\n");
178    Ok(out)
179}
180
181/// What it takes for a machine term to compute what the IR term it replaces computes.
182///
183/// The same bitvector, when the two are the same width, which is every rule that does not
184/// convert. When the machine term is wider they have to agree on the bits the IR term has, which
185/// is what lowering a value into a register wider than the value means, and what the rest of the
186/// register holds is left to the rule's own `spec` clause to claim: on a target where a thirty
187/// two bit add sign extends into a sixty four bit register, that clause is the only place the
188/// sign extension is stated and so it is the only place it can be checked.
189///
190/// A machine term narrower than the IR term loses bits, and that is a mistake rather than a
191/// claim about anything.
192fn agreement(
193    path: &str,
194    at: &Term,
195    matched: &str,
196    produced: &str,
197    over: Sort,
198    into: Sort,
199) -> Result<String, Error> {
200    if over == into {
201        return Ok(format!("(= {matched} {produced})"));
202    }
203    let fail = |said: String| Error {
204        path: path.to_owned(),
205        line: at.line,
206        column: at.column,
207        message: said,
208    };
209    let (Sort::Bits(over), Sort::Bits(into)) = (over, into) else {
210        // The two are not both bitvectors and are not the same thing either, so there is no
211        // reading of this which is a mistake in the widths. Either one of them is a memory,
212        // which is a rule replacing something with an effect by something without one or the
213        // other way round, or one of them is a float, which is a rule computing a float out of
214        // bits or the other way round without saying which reading of those bits it means.
215        let said = if over == Sort::Memory || into == Sort::Memory {
216            "this replaces something that computes a value with something that computes a \
217             memory, or the other way round"
218                .to_owned()
219        } else {
220            format!(
221                "this replaces something {} with something {}",
222                over.describe(),
223                into.describe()
224            )
225        };
226        return Err(fail(said));
227    };
228    if into < over {
229        let said = format!(
230            "what this replaces is {over} bits wide and this is {into}, so it cannot compute it"
231        );
232        return Err(fail(said));
233    }
234    Ok(format!("(= {matched} ((_ extract {} 0) {produced}))", over - 1))
235}
236
237/// The widths a bounded proof is taken over, narrowest first.
238///
239/// Two of them rather than one, because a claim that holds at a single width can hold for
240/// reasons that are about that width. Both of them small, because the claims that need a
241/// bounded proof at all are the ones mixing multiplication with division, and one of those is
242/// as far out of reach at sixteen bits as it is at sixty four: the rule the tests use is
243/// answered in a second or two at eight bits and not at all at sixteen. Only widths
244/// narrower than the rule's own are used, so a rule that already works in four bits has nothing
245/// to fall back to.
246pub const BOUNDED_WIDTHS: [u32; 2] = [4, 8];
247
248/// How long a rule that already carries a written reason gets at its real width, in seconds.
249///
250/// Ten, against the five minutes every other rule gets. Such a rule is asked at its real width at
251/// all because a solver that has got better since somebody signed the reason ought to take the
252/// rule off the list, and that is worth finding out on every run. It is not worth the whole
253/// budget: the answer is a shrug by construction, and five minutes of waiting for it is five
254/// minutes on every build and on every run a person does by hand before a commit. The whole gate
255/// over the whole tree is seventy two seconds as it stands, where it was a hundred and forty four
256/// when a shrug cost ninety, and it would be six minutes at five minutes a shrug. One rule is the
257/// whole of that difference. What the short look costs is that rule staying on a list it could
258/// have left, which is a list somebody reads rather than anything the compiler is built from.
259const SIGNED_OFF: u32 = 10;
260
261/// Ask about every rule.
262///
263/// A rule that the solver settles at its own width is discharged and that is the end of it. A
264/// rule it gives up on is asked again at [`BOUNDED_WIDTHS`], but only if the rule carries a
265/// written reason for taking narrow widths as enough, because a bounded proof is a judgement
266/// somebody makes and not a fallback a tool takes on its own. A rule carrying such a reason gets
267/// ten seconds at its real width rather than the whole budget, because what it is being asked
268/// there is whether the reason has stopped being true.
269///
270/// # Errors
271///
272/// Anything the model cannot write out, and anything that stops the solver from running.
273pub fn verify(
274    path: &str,
275    rules: &[Rule],
276    model: &Model,
277    solver: &dyn Ask,
278) -> Result<Report, Vec<Error>> {
279    let mut report = Report::default();
280    let mut errors = Vec::new();
281
282    for rule in rules {
283        let width = rule_width(&rule.pattern);
284        // A rule with a reason written on it is expected to come back a shrug here, so it is
285        // given a look rather than the budget. Everything else gets the whole of it.
286        let full = match rule.bounded {
287            None => solver.seconds(),
288            Some(_) => SIGNED_OFF.min(solver.seconds()),
289        };
290        match ask(path, rule, model, solver, width, full) {
291            Err(error) => errors.push(error),
292            Ok(Answer::Unsat) => report.verdicts.push(Verdict::Discharged),
293            Ok(Answer::Sat(found)) => report.verdicts.push(Verdict::Refuted(found)),
294            Ok(Answer::Unknown) => match &rule.bounded {
295                None => report.verdicts.push(Verdict::Unknown),
296                Some(why) => match bounded(path, rule, model, solver, width, why) {
297                    Ok(verdict) => report.verdicts.push(verdict),
298                    Err(error) => errors.push(error),
299                },
300            },
301        }
302    }
303
304    if errors.is_empty() { Ok(report) } else { Err(errors) }
305}
306
307/// Verify a rule set and refuse the whole of it if anything in it cannot enter.
308///
309/// This is the gate `spec/17-milestones.md` asks for. It refuses the file rather than dropping
310/// the rules that failed, because a compiler built from the rules that happened to pass is a
311/// compiler nobody described: what it does with the terms the dropped rules matched is then a
312/// question about the order of the rest.
313///
314/// # Errors
315///
316/// One error per rule that may not enter, at the line the rule starts on, and anything that
317/// stopped the verification from happening at all.
318pub fn admit(
319    path: &str,
320    rules: &[Rule],
321    model: &Model,
322    solver: &dyn Ask,
323) -> Result<Report, Vec<Error>> {
324    let report = verify(path, rules, model, solver)?;
325    let mut errors = Vec::new();
326    for (rule, verdict) in rules.iter().zip(&report.verdicts) {
327        if let Some(said) = verdict.refusal() {
328            errors.push(Error {
329                path: path.to_owned(),
330                line: rule.line,
331                column: rule.column,
332                message: said,
333            });
334        }
335    }
336    if errors.is_empty() { Ok(report) } else { Err(errors) }
337}
338
339/// Put one question to the solver, at a width and within a budget.
340fn ask(
341    path: &str,
342    rule: &Rule,
343    model: &Model,
344    solver: &dyn Ask,
345    width: u32,
346    seconds: u32,
347) -> Result<Answer, Error> {
348    let asked = query_at(path, rule, model, width)?;
349    solver.ask(&asked, seconds).map_err(|problem| Error {
350        path: path.to_owned(),
351        line: rule.line,
352        column: rule.column,
353        message: format!("the solver could not be run: {problem}"),
354    })
355}
356
357/// Ask the rule again at the narrow widths, once the real one has come back a shrug.
358///
359/// Every width has to come back `unsat`. A counterexample at a narrow width is reported as the
360/// refutation it looks like, named with the width it was found at, because the two things it
361/// can be are a rule that is wrong and a rule whose constants do not fit in four bits, and both
362/// are for a person to look at rather than for this to decide.
363fn bounded(
364    path: &str,
365    rule: &Rule,
366    model: &Model,
367    solver: &dyn Ask,
368    width: u32,
369    why: &str,
370) -> Result<Verdict, Error> {
371    let mut proved = Vec::new();
372    for narrow in BOUNDED_WIDTHS.iter().copied().filter(|narrow| *narrow < width) {
373        match ask(path, rule, model, solver, narrow, solver.seconds())? {
374            Answer::Unsat => proved.push(narrow),
375            Answer::Sat(found) => {
376                let said = format!("at {narrow} bits, where the rule works in {width}: {found}");
377                return Ok(Verdict::Refuted(said));
378            }
379            Answer::Unknown => return Ok(Verdict::Unknown),
380        }
381    }
382    if proved.is_empty() {
383        return Ok(Verdict::Unknown);
384    }
385    Ok(Verdict::Bounded { widths: proved, why: why.to_owned() })
386}
387
388/// Put the replacement's meaning where the specification says `(result)`.
389///
390/// This is a substitution on the written form rather than on the term, because what the
391/// replacement means is SMT-LIB text by the time it is known and there is nothing to put back
392/// into a term.
393fn substitute(spec: &Term, produced: &str) -> Term {
394    match &spec.kind {
395        TermKind::App { head, args } if head == "result" && args.is_empty() => {
396            Term { kind: TermKind::Var(produced.to_owned()), line: spec.line, column: spec.column }
397        }
398        TermKind::App { head, args } => Term {
399            kind: TermKind::App {
400                head: head.clone(),
401                args: args.iter().map(|arg| substitute(arg, produced)).collect(),
402            },
403            line: spec.line,
404            column: spec.column,
405        },
406        _ => spec.clone(),
407    }
408}