rucc-verify 0.10.65

SMT verification of the rucc rewrite and lowering rule set.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Turning a rule into a question, and the answers into a report.

use std::fmt;

use rucc_rules::{Error, Rule, Term, TermKind};

use crate::model::{MEMORY_CONST, Model, Sort, Widths, rule_width};
use crate::solver::{Answer, Ask};

/// What became of one rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
    /// Nothing makes the claim false.
    Discharged,
    /// Something does, and this is what the solver printed of it.
    Refuted(String),
    /// The claim holds at every width narrower than the rule's own, and the rule carries a
    /// written reason for taking that as enough. A pass, and a counted one.
    Bounded {
        /// The widths it was proved at, narrowest first.
        widths: Vec<u32>,
        /// The reason the rule gives, which is what a reviewer signed for.
        why: String,
    },
    /// The solver gave up. Not a pass.
    Unknown,
}

impl Verdict {
    /// Whether a rule with this verdict may enter the rule set.
    #[must_use]
    pub fn accepted(&self) -> bool {
        matches!(self, Verdict::Discharged | Verdict::Bounded { .. })
    }

    /// Why it may not, as a sentence, or nothing when it may.
    #[must_use]
    pub fn refusal(&self) -> Option<String> {
        match self {
            Verdict::Discharged | Verdict::Bounded { .. } => None,
            Verdict::Refuted(model) => {
                Some(format!("this rule is not true, and here is what makes it false: {model}"))
            }
            Verdict::Unknown => Some(
                "the solver could not settle this rule, and a rule nobody has proved does not \
                 enter the rule set"
                    .to_owned(),
            ),
        }
    }
}

/// What became of a rule set.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Report {
    /// One verdict per rule, in the order the rules were given.
    pub verdicts: Vec<Verdict>,
}

impl Report {
    /// How many rules were discharged at their own width.
    #[must_use]
    pub fn discharged(&self) -> usize {
        self.verdicts.iter().filter(|v| **v == Verdict::Discharged).count()
    }

    /// How many rules got a bounded proof instead.
    ///
    /// `spec/15-testing.md` section 15.5 asks for this number to be reported rather than merely
    /// known, because it going up is the signal that the rule set is drifting towards claims
    /// nobody is checking at the width the compiler runs at.
    #[must_use]
    pub fn bounded(&self) -> usize {
        self.verdicts.iter().filter(|v| matches!(v, Verdict::Bounded { .. })).count()
    }

    /// Whether every rule was discharged at its own width. A bounded proof is not one of these.
    #[must_use]
    pub fn all_discharged(&self) -> bool {
        self.verdicts.iter().all(|v| *v == Verdict::Discharged)
    }

    /// Whether every rule may enter the rule set, which allows a bounded proof and allows
    /// nothing else. A solver that gave up is not a pass, because "we could not tell" is not
    /// "it is correct".
    #[must_use]
    pub fn accepted(&self) -> bool {
        self.verdicts.iter().all(Verdict::accepted)
    }
}

impl fmt::Display for Report {
    /// One line, which is what a build prints and what a person reads in a log.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let refused = self.verdicts.len() - self.discharged() - self.bounded();
        let rules = if self.verdicts.len() == 1 { "rule" } else { "rules" };
        write!(
            f,
            "{} {rules}: {} discharged, {} by bounded proof, {} refused",
            self.verdicts.len(),
            self.discharged(),
            self.bounded(),
            refused
        )
    }
}

/// The SMT-LIB question one rule asks, at the width the rule works in.
///
/// This is separate from asking it so that the query can be read, kept in a test, and handed to
/// a solver by hand when one is arguing with it.
///
/// # Errors
///
/// Anything the model cannot write out, which is any term nobody has said the meaning of.
pub fn query(path: &str, rule: &Rule, model: &Model) -> Result<String, Error> {
    query_at(path, rule, model, rule_width(&rule.pattern))
}

/// The same question asked at a width somebody chose.
///
/// This is what a bounded proof is made of: the rule's own claim, in narrower bitvectors than
/// the ones it will run in. Every width in the rule scales by the one ratio, so a rule that
/// converts between widths still converts between them here.
///
/// # Errors
///
/// Anything the model cannot write out, which is any term nobody has said the meaning of, and
/// anything whose widths do not fit together.
pub fn query_at(path: &str, rule: &Rule, model: &Model, width: u32) -> Result<String, Error> {
    let widths = Widths::at(&rule.pattern, width);

    // A rule that reaches memory needs the theory of arrays and a constant to stand for the
    // memory it starts from, and a rule that reaches a float needs the theory of floats. A rule
    // that does neither gets neither, so every rule written before effects and floats existed
    // asks exactly the question it asked before.
    let parts = [&rule.pattern, &rule.replacement, &rule.spec];
    let memory = parts.iter().any(|term| model.touches_memory(term));
    let floats = parts.iter().any(|term| model.touches_floats(term));
    let logic = match (memory, floats) {
        (false, false) => "QF_BV",
        (true, false) => "QF_ABV",
        (false, true) => "QF_FPBV",
        (true, true) => "QF_ABVFP",
    };
    let mut out = format!("(set-logic {logic})\n");
    if memory {
        let sort = Sort::Memory.write(&widths);
        out.push_str(&format!("(declare-const {MEMORY_CONST} {sort})\n"));
    }

    // Each name as the pattern binds it, which is not one thing for the whole rule: a rule that
    // lowers a thirty two bit add of two sixty four bit registers has both widths in it and
    // neither is the other, and a rule that lowers a float has a float and an address in it.
    for (name, sort) in widths.names() {
        out.push_str(&format!("(declare-const {name} {})\n", sort.write(&widths)));
    }

    if let Some(guard) = &rule.guard {
        // An assumption, not part of the claim. A rule that only holds for some constants is
        // only being asked about those constants.
        out.push_str(&format!("(assert {})\n", model.write(path, guard, &widths)?.0));
    }

    // Two obligations, asked as one question. The first is the one that matters: what the
    // pattern means and what the replacement means have to be the same thing, both read out of
    // the model rather than out of anybody's description of them. The second is the rule's own
    // `spec` clause, which is written by hand and so is worth checking rather than trusting: a
    // rule whose stated claim is not what its pattern actually means would otherwise verify
    // against its own mistake.
    let (matched, over) = model.write(path, &rule.pattern, &widths)?;
    let (produced, into) = model.write(path, &rule.replacement, &widths)?;
    let same = agreement(path, &rule.replacement, &matched, &produced, over, into)?;
    let substituted = substitute(&rule.spec, &produced);
    let claim = model.write(path, &substituted, &widths.with(&produced, into))?.0;
    out.push_str(&format!("(assert (not (and {same} {claim})))\n"));
    out.push_str("(check-sat)\n(get-model)\n");
    Ok(out)
}

/// What it takes for a machine term to compute what the IR term it replaces computes.
///
/// The same bitvector, when the two are the same width, which is every rule that does not
/// convert. When the machine term is wider they have to agree on the bits the IR term has, which
/// is what lowering a value into a register wider than the value means, and what the rest of the
/// register holds is left to the rule's own `spec` clause to claim: on a target where a thirty
/// two bit add sign extends into a sixty four bit register, that clause is the only place the
/// sign extension is stated and so it is the only place it can be checked.
///
/// A machine term narrower than the IR term loses bits, and that is a mistake rather than a
/// claim about anything.
fn agreement(
    path: &str,
    at: &Term,
    matched: &str,
    produced: &str,
    over: Sort,
    into: Sort,
) -> Result<String, Error> {
    if over == into {
        return Ok(format!("(= {matched} {produced})"));
    }
    let fail = |said: String| Error {
        path: path.to_owned(),
        line: at.line,
        column: at.column,
        message: said,
    };
    let (Sort::Bits(over), Sort::Bits(into)) = (over, into) else {
        // The two are not both bitvectors and are not the same thing either, so there is no
        // reading of this which is a mistake in the widths. Either one of them is a memory,
        // which is a rule replacing something with an effect by something without one or the
        // other way round, or one of them is a float, which is a rule computing a float out of
        // bits or the other way round without saying which reading of those bits it means.
        let said = if over == Sort::Memory || into == Sort::Memory {
            "this replaces something that computes a value with something that computes a \
             memory, or the other way round"
                .to_owned()
        } else {
            format!(
                "this replaces something {} with something {}",
                over.describe(),
                into.describe()
            )
        };
        return Err(fail(said));
    };
    if into < over {
        let said = format!(
            "what this replaces is {over} bits wide and this is {into}, so it cannot compute it"
        );
        return Err(fail(said));
    }
    Ok(format!("(= {matched} ((_ extract {} 0) {produced}))", over - 1))
}

/// The widths a bounded proof is taken over, narrowest first.
///
/// Two of them rather than one, because a claim that holds at a single width can hold for
/// reasons that are about that width. Both of them small, because the claims that need a
/// bounded proof at all are the ones mixing multiplication with division, and one of those is
/// as far out of reach at sixteen bits as it is at sixty four: the rule the tests use is
/// answered in a second or two at eight bits and not at all at sixteen. Only widths
/// narrower than the rule's own are used, so a rule that already works in four bits has nothing
/// to fall back to.
pub const BOUNDED_WIDTHS: [u32; 2] = [4, 8];

/// How long a rule that already carries a written reason gets at its real width, in seconds.
///
/// Ten, against the five minutes every other rule gets. Such a rule is asked at its real width at
/// all because a solver that has got better since somebody signed the reason ought to take the
/// rule off the list, and that is worth finding out on every run. It is not worth the whole
/// budget: the answer is a shrug by construction, and five minutes of waiting for it is five
/// minutes on every build and on every run a person does by hand before a commit. The whole gate
/// over the whole tree is seventy two seconds as it stands, where it was a hundred and forty four
/// when a shrug cost ninety, and it would be six minutes at five minutes a shrug. One rule is the
/// whole of that difference. What the short look costs is that rule staying on a list it could
/// have left, which is a list somebody reads rather than anything the compiler is built from.
const SIGNED_OFF: u32 = 10;

/// Ask about every rule.
///
/// A rule that the solver settles at its own width is discharged and that is the end of it. A
/// rule it gives up on is asked again at [`BOUNDED_WIDTHS`], but only if the rule carries a
/// written reason for taking narrow widths as enough, because a bounded proof is a judgement
/// somebody makes and not a fallback a tool takes on its own. A rule carrying such a reason gets
/// ten seconds at its real width rather than the whole budget, because what it is being asked
/// there is whether the reason has stopped being true.
///
/// # Errors
///
/// Anything the model cannot write out, and anything that stops the solver from running.
pub fn verify(
    path: &str,
    rules: &[Rule],
    model: &Model,
    solver: &dyn Ask,
) -> Result<Report, Vec<Error>> {
    let mut report = Report::default();
    let mut errors = Vec::new();

    for rule in rules {
        let width = rule_width(&rule.pattern);
        // A rule with a reason written on it is expected to come back a shrug here, so it is
        // given a look rather than the budget. Everything else gets the whole of it.
        let full = match rule.bounded {
            None => solver.seconds(),
            Some(_) => SIGNED_OFF.min(solver.seconds()),
        };
        match ask(path, rule, model, solver, width, full) {
            Err(error) => errors.push(error),
            Ok(Answer::Unsat) => report.verdicts.push(Verdict::Discharged),
            Ok(Answer::Sat(found)) => report.verdicts.push(Verdict::Refuted(found)),
            Ok(Answer::Unknown) => match &rule.bounded {
                None => report.verdicts.push(Verdict::Unknown),
                Some(why) => match bounded(path, rule, model, solver, width, why) {
                    Ok(verdict) => report.verdicts.push(verdict),
                    Err(error) => errors.push(error),
                },
            },
        }
    }

    if errors.is_empty() { Ok(report) } else { Err(errors) }
}

/// Verify a rule set and refuse the whole of it if anything in it cannot enter.
///
/// This is the gate `spec/17-milestones.md` asks for. It refuses the file rather than dropping
/// the rules that failed, because a compiler built from the rules that happened to pass is a
/// compiler nobody described: what it does with the terms the dropped rules matched is then a
/// question about the order of the rest.
///
/// # Errors
///
/// One error per rule that may not enter, at the line the rule starts on, and anything that
/// stopped the verification from happening at all.
pub fn admit(
    path: &str,
    rules: &[Rule],
    model: &Model,
    solver: &dyn Ask,
) -> Result<Report, Vec<Error>> {
    let report = verify(path, rules, model, solver)?;
    let mut errors = Vec::new();
    for (rule, verdict) in rules.iter().zip(&report.verdicts) {
        if let Some(said) = verdict.refusal() {
            errors.push(Error {
                path: path.to_owned(),
                line: rule.line,
                column: rule.column,
                message: said,
            });
        }
    }
    if errors.is_empty() { Ok(report) } else { Err(errors) }
}

/// Put one question to the solver, at a width and within a budget.
fn ask(
    path: &str,
    rule: &Rule,
    model: &Model,
    solver: &dyn Ask,
    width: u32,
    seconds: u32,
) -> Result<Answer, Error> {
    let asked = query_at(path, rule, model, width)?;
    solver.ask(&asked, seconds).map_err(|problem| Error {
        path: path.to_owned(),
        line: rule.line,
        column: rule.column,
        message: format!("the solver could not be run: {problem}"),
    })
}

/// Ask the rule again at the narrow widths, once the real one has come back a shrug.
///
/// Every width has to come back `unsat`. A counterexample at a narrow width is reported as the
/// refutation it looks like, named with the width it was found at, because the two things it
/// can be are a rule that is wrong and a rule whose constants do not fit in four bits, and both
/// are for a person to look at rather than for this to decide.
fn bounded(
    path: &str,
    rule: &Rule,
    model: &Model,
    solver: &dyn Ask,
    width: u32,
    why: &str,
) -> Result<Verdict, Error> {
    let mut proved = Vec::new();
    for narrow in BOUNDED_WIDTHS.iter().copied().filter(|narrow| *narrow < width) {
        match ask(path, rule, model, solver, narrow, solver.seconds())? {
            Answer::Unsat => proved.push(narrow),
            Answer::Sat(found) => {
                let said = format!("at {narrow} bits, where the rule works in {width}: {found}");
                return Ok(Verdict::Refuted(said));
            }
            Answer::Unknown => return Ok(Verdict::Unknown),
        }
    }
    if proved.is_empty() {
        return Ok(Verdict::Unknown);
    }
    Ok(Verdict::Bounded { widths: proved, why: why.to_owned() })
}

/// Put the replacement's meaning where the specification says `(result)`.
///
/// This is a substitution on the written form rather than on the term, because what the
/// replacement means is SMT-LIB text by the time it is known and there is nothing to put back
/// into a term.
fn substitute(spec: &Term, produced: &str) -> Term {
    match &spec.kind {
        TermKind::App { head, args } if head == "result" && args.is_empty() => {
            Term { kind: TermKind::Var(produced.to_owned()), line: spec.line, column: spec.column }
        }
        TermKind::App { head, args } => Term {
            kind: TermKind::App {
                head: head.clone(),
                args: args.iter().map(|arg| substitute(arg, produced)).collect(),
            },
            line: spec.line,
            column: spec.column,
        },
        _ => spec.clone(),
    }
}