Skip to main content

logicaffeine_compile/tensor_graph/
smt.rs

1//! The IR's meaning as an SMT-LIB obligation: two graphs compute the same values for every input, and for every meaning
2//! of the ops neither pins down.
3//!
4//! [`eval`](super::eval) runs a graph on numbers; this module runs it on terms. Every tensor a step starts with is an
5//! array of unknown values. A primitive that only moves values — a head slice, a row taken, rows gathered, a transpose,
6//! a join, rows written into a table — is the index map it is. Every other primitive is a function nothing is known of
7//! but that it is one: of its operands' values, the same for every op of the same shape. An operand is read whole or
8//! as a part of every unit, a result lands in its own buffer or in rows or units of a table, and a fused op runs its
9//! parts and keeps what it writes outside itself, exactly as the evaluator does. Every array is zero past its length,
10//! so two arrays are equal exactly when their values are.
11//!
12//! Terms are shared: two graphs that compute a value by the same ops in the same way compute the same term, and that
13//! equality needs no solver. Two terms that are one open function of operands that pair up are equal when the operands
14//! are; what is left are the pairs that differ in more — a part read in place where a copy was read, a result stored
15//! into a table's rows where a row write copied it — and each is a lemma asking that the two be equal arrays, every
16//! subterm both share standing for any values. The indices a step reads rows by are assumed in range where the program
17//! it was lifted from reads them, and so is every interval the program proves of an integer it starts with; the
18//! rewritten graph's own reads must then be in range too.
19//!
20//! An [`Obligation`] is SMT-LIB 2 text, printed the same way every time from the same two graphs: the tensors compared,
21//! and lemmas whose premises a solver finds satisfiable and whose goals make them unsatisfiable exactly when the rewrite
22//! keeps the meaning. A proof checked once is pinned by the text's digest and re-checked without a solver until the
23//! graphs change.
24
25use std::collections::{BTreeMap, BTreeSet, HashMap};
26use std::fmt::{self, Write as _};
27
28use super::front::TensorOpSpec;
29use super::graph::{Access, Dst, Elem, Op, Role, RowSel, Sym, TensorGraph};
30use crate::tensor_recognize::qwen::RowIndex;
31
32/// An array's element sort.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
34enum Sort {
35    Real,
36    Int,
37}
38
39impl Sort {
40    fn of(elem: Elem) -> Sort {
41        match elem {
42            Elem::Float => Sort::Real,
43            Elem::Int => Sort::Int,
44        }
45    }
46
47    fn smt(self) -> &'static str {
48        match self {
49            Sort::Real => "Real",
50            Sort::Int => "Int",
51        }
52    }
53
54    fn zero(self) -> &'static str {
55        match self {
56            Sort::Real => "0.0",
57            Sort::Int => "0",
58        }
59    }
60
61    fn tag(self) -> char {
62        match self {
63            Sort::Real => 'R',
64            Sort::Int => 'I',
65        }
66    }
67}
68
69/// Where a store's first value lands: a fixed offset, or the start of the row a parameter's first value names (1-based)
70/// in a table of rows `pitch` wide.
71#[derive(Clone, Debug, PartialEq, Eq, Hash)]
72enum At {
73    Fixed(usize),
74    Row { param: usize, pitch: usize },
75}
76
77/// Which value of its source each value of a picked array is.
78#[derive(Clone, Debug, PartialEq, Eq, Hash)]
79enum Map {
80    /// Value `j` is `(j div width)·pitch + offset + j mod width`: a part of every unit. One unit is `pitch` 0.
81    Units { pitch: usize, offset: usize, width: usize },
82    /// Value `j` of a `rows × cols` matrix's transpose.
83    Transpose { rows: usize, cols: usize },
84    /// Value `j` of the row a parameter's first value names (1-based), rows `cols` wide.
85    Row { param: usize, cols: usize },
86    /// Value `j` of rows gathered by an index array: row `indices[j div cols]` (1-based), rows `cols` wide.
87    Rows { indices: usize, cols: usize },
88}
89
90/// An op computed exactly, over the reals or the integers: a matrix product, an elementwise sum or product (its second
91/// operand one row of `row` values combined with every row, where it is one), a scale by a factor's exact value.
92#[derive(Clone, Debug, PartialEq, Eq, Hash)]
93enum Arith {
94    Matmul { m: usize, k: usize, n: usize },
95    Binary { mul: bool, row: Option<usize> },
96    Scale { factor: u64 },
97}
98
99/// One term: an array of values, `len` of them meaningful and zero past them.
100#[derive(Clone, Debug, PartialEq, Eq, Hash)]
101enum Node {
102    /// A tensor's values when the step starts.
103    Start { name: String, sort: Sort, len: usize },
104    /// A table nothing has written yet.
105    Zeros { sort: Sort, len: usize },
106    /// An op's result left open: function `f` of its operands.
107    Apply { f: usize, args: Vec<usize>, len: usize },
108    /// Values of `src` an index map picks.
109    Pick { src: usize, map: Map, len: usize },
110    /// `table` with the first `n` values of `src` stored from `at` — one after another, or each unit's share in its part
111    /// of every unit of the table (`(pitch, offset, width)`).
112    Store { table: usize, src: usize, at: At, unit: Option<(usize, usize, usize)>, n: usize },
113    /// An integer array's values as reals.
114    Real { src: usize },
115    /// An op's result computed exactly from its operands.
116    Arith { op: Arith, args: Vec<usize>, sort: Sort, len: usize },
117}
118
119/// A function the meaning leaves open: its name and its signature.
120#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
121struct Fun {
122    name: String,
123    args: Vec<Sort>,
124    sort: Sort,
125}
126
127/// A fact a run needs: value `index` of array `node` lies in `lo..=hi`, and why.
128#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
129struct Bound {
130    node: usize,
131    index: usize,
132    lo: i64,
133    hi: i64,
134    why: String,
135}
136
137/// The terms of both graphs, shared.
138#[derive(Default)]
139struct Terms {
140    nodes: Vec<Node>,
141    sorts: Vec<Sort>,
142    lens: Vec<usize>,
143    ids: HashMap<Node, usize>,
144    funs: Vec<Fun>,
145    fun_ids: HashMap<Fun, usize>,
146}
147
148impl Terms {
149    fn node(&mut self, node: Node) -> usize {
150        if let Some(&id) = self.ids.get(&node) {
151            return id;
152        }
153        let (sort, len) = match &node {
154            Node::Start { sort, len, .. } | Node::Zeros { sort, len } => (*sort, *len),
155            Node::Apply { f, len, .. } => (self.funs[*f].sort, *len),
156            Node::Pick { src, len, .. } => (self.sorts[*src], *len),
157            Node::Store { table, .. } => (self.sorts[*table], self.lens[*table]),
158            Node::Real { src } => (Sort::Real, self.lens[*src]),
159            Node::Arith { sort, len, .. } => (*sort, *len),
160        };
161        let id = self.nodes.len();
162        self.nodes.push(node.clone());
163        self.sorts.push(sort);
164        self.lens.push(len);
165        self.ids.insert(node, id);
166        id
167    }
168
169    fn fun(&mut self, fun: Fun) -> usize {
170        if let Some(&id) = self.fun_ids.get(&fun) {
171            return id;
172        }
173        self.funs.push(fun.clone());
174        self.fun_ids.insert(fun, self.funs.len() - 1);
175        self.funs.len() - 1
176    }
177
178    /// `src`'s values from `offset`, `width` of every `pitch`, `len` in all; `src` itself where that is all of it.
179    fn units(&mut self, src: usize, pitch: usize, offset: usize, width: usize, len: usize) -> usize {
180        let contiguous = len <= width || pitch == width;
181        if contiguous && offset == 0 && len == self.lens[src] {
182            return src;
183        }
184        let pitch = if contiguous { 0 } else { pitch };
185        let width = if contiguous { len } else { width };
186        self.node(Node::Pick { src, map: Map::Units { pitch, offset, width }, len })
187    }
188
189    /// `src` as an array of `sort`: an integer array's values as reals where reals are read.
190    fn as_sort(&mut self, src: usize, sort: Sort) -> usize {
191        match (self.sorts[src], sort) {
192            (Sort::Int, Sort::Real) => self.node(Node::Real { src }),
193            _ => src,
194        }
195    }
196}
197
198/// Why two graphs give no obligation.
199#[derive(Clone, Debug, PartialEq, Eq)]
200pub enum SmtError {
201    /// An op of one side reads a tensor that holds no value when it runs.
202    Missing { side: &'static str, op: usize, tensor: String },
203    /// An op of one side does what no run of the evaluator does.
204    Malformed { side: &'static str, op: usize, why: String },
205    /// A tensor the step carries or a statement observes, which the rewritten graph does not keep.
206    Unkept { tensor: String },
207}
208
209impl fmt::Display for SmtError {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        match self {
212            SmtError::Missing { side, op, tensor } => write!(f, "{side} op {op} reads `{tensor}`, which holds no value when it runs"),
213            SmtError::Malformed { side, op, why } => write!(f, "{side} op {op}: {why}"),
214            SmtError::Unkept { tensor } => write!(f, "`{tensor}` is carried or observed, and the rewritten graph does not keep it"),
215        }
216    }
217}
218
219/// One side's run: every tensor's term as it stands, and the facts its reads need.
220struct Run<'g> {
221    side: &'static str,
222    /// Whether products, elementwise sums and products and scales are computed exactly rather than left open.
223    exact: bool,
224    g: &'g TensorGraph,
225    env: BTreeMap<String, usize>,
226    bounds: Vec<Bound>,
227}
228
229impl Run<'_> {
230    fn missing(&self, op: usize, tensor: &str) -> SmtError {
231        SmtError::Missing { side: self.side, op, tensor: tensor.to_string() }
232    }
233
234    fn malformed(&self, op: usize, why: impl Into<String>) -> SmtError {
235        SmtError::Malformed { side: self.side, op, why: why.into() }
236    }
237
238    fn len_of(&self, at: usize, t: &str) -> Result<usize, SmtError> {
239        self.g.tensors.get(t).map(|t| t.len()).ok_or_else(|| self.missing(at, t))
240    }
241
242    fn sort_of(&self, at: usize, t: &str) -> Result<Sort, SmtError> {
243        self.g.tensors.get(t).map(|t| Sort::of(t.elem)).ok_or_else(|| self.missing(at, t))
244    }
245
246    fn value(&self, at: usize, t: &str) -> Result<usize, SmtError> {
247        self.env.get(t).copied().ok_or_else(|| self.missing(at, t))
248    }
249
250    /// The row a selector names, as where its row's values start in a table of rows `pitch` wide; the bounds it needs
251    /// for a run's `n` values landing there to stay inside the table's `len`.
252    fn row(&mut self, terms: &Terms, at: usize, sel: &RowSel, pitch: usize, last: usize, len: usize, what: &str) -> Result<At, SmtError> {
253        let hi = if pitch == 0 || last >= len { 0 } else { ((len - 1 - last) / pitch) as i64 + 1 };
254        if hi < 1 {
255            return Err(self.malformed(at, format!("no row of {what} holds {} values", last + 1)));
256        }
257        match sel {
258            RowSel::Fixed(r) if *r >= 1 && (*r as i64) <= hi => Ok(At::Fixed((r - 1) * pitch)),
259            RowSel::Fixed(r) => Err(self.malformed(at, format!("row {r} of {what}"))),
260            RowSel::Param(p) => {
261                let param = self.value(at, p)?;
262                if terms.lens[param] != 1 || terms.sorts[param] != Sort::Int {
263                    return Err(self.malformed(at, format!("`{p}` names the row of {what} and is not one Int")));
264                }
265                self.bounds.push(Bound { node: param, index: 0, lo: 1, hi, why: format!("`{p}` names a row of {what}") });
266                Ok(At::Row { param, pitch })
267            }
268        }
269    }
270
271    /// Operand `k` of `op`, read as its access says.
272    fn read(&self, terms: &mut Terms, at: usize, op: &Op, k: usize) -> Result<usize, SmtError> {
273        let whole = self.value(at, &op.ins[k])?;
274        match op.access.get(k).copied().unwrap_or(Access::Whole) {
275            Access::Whole => Ok(whole),
276            Access::Strided(s) => {
277                let len = terms.lens[whole];
278                if s.pitch == 0 || s.offset + s.width > s.pitch || len % s.pitch != 0 {
279                    return Err(self.malformed(at, format!("{len} values of `{}` are no whole units of {} to read {} from {} of", op.ins[k], s.pitch, s.width, s.offset)));
280                }
281                Ok(terms.units(whole, s.pitch, s.offset, s.width, len / s.pitch * s.width))
282            }
283        }
284    }
285
286    /// Store `result` where `dst` says.
287    fn land(&mut self, terms: &mut Terms, at: usize, dst: &Dst, result: usize) -> Result<(), SmtError> {
288        if let Some(name) = dst.own() {
289            self.env.insert(name.to_string(), result);
290            return Ok(());
291        }
292        let table = match self.env.get(&dst.buf) {
293            Some(&t) => t,
294            None => {
295                let (sort, len) = (self.sort_of(at, &dst.buf)?, self.len_of(at, &dst.buf)?);
296                terms.node(Node::Zeros { sort, len })
297            }
298        };
299        if terms.sorts[table] != terms.sorts[result] {
300            return Err(self.malformed(at, format!("a result of one element type landing in `{}` of another", dst.buf)));
301        }
302        let (n, len) = (terms.lens[result], terms.lens[table]);
303        let last = match dst.unit {
304            None => n.saturating_sub(1),
305            Some(u) => {
306                if u.width == 0 || n % u.width != 0 || u.offset + u.width > u.pitch {
307                    return Err(self.malformed(at, format!("{n} values in shares of {} at {} of units of {}", u.width, u.offset, u.pitch)));
308                }
309                (n / u.width).saturating_sub(1) * u.pitch + u.offset + u.width - 1
310            }
311        };
312        let place = match &dst.row {
313            Some((sel, pitch)) => self.row(terms, at, sel, *pitch, last, len, &format!("`{}`", dst.buf))?,
314            None if last < len || n == 0 => At::Fixed(0),
315            None => return Err(self.malformed(at, format!("{n} values past the {len} of `{}`", dst.buf))),
316        };
317        let unit = dst.unit.map(|u| (u.pitch, u.offset, u.width));
318        let stored = terms.node(Node::Store { table, src: result, at: place, unit, n });
319        self.env.insert(dst.buf.clone(), stored);
320        Ok(())
321    }
322
323    fn op(&mut self, terms: &mut Terms, at: usize, op: &Op) -> Result<(), SmtError> {
324        match &op.sym {
325            Sym::Fused(_) => {
326                let before: BTreeSet<String> = self.env.keys().cloned().collect();
327                self.parts(terms, at, op)?;
328                let outlive: BTreeSet<&str> = op.writes().into_iter().collect();
329                self.env.retain(|k, _| before.contains(k) || outlive.contains(k.as_str()));
330                Ok(())
331            }
332            Sym::View { offset } => {
333                let src = self.read(terms, at, op, 0)?;
334                let len = self.len_of(at, &op.out)?;
335                if offset + len > terms.lens[src] {
336                    return Err(self.malformed(at, format!("a view of {len} values from {offset} into {} values", terms.lens[src])));
337                }
338                let v = terms.units(src, 0, *offset, len, len);
339                self.land(terms, at, &op.dst, v)
340            }
341            Sym::Quantize(_) => {
342                let x = self.read(terms, at, op, 0)?;
343                self.land(terms, at, &op.dst, x)
344            }
345            Sym::Prim(spec) => {
346                let ins: Vec<usize> = (0..op.ins.len()).map(|k| self.read(terms, at, op, k)).collect::<Result<_, _>>()?;
347                let (result, state) = self.prim(terms, at, op, spec, &ins)?;
348                for (k, v) in state {
349                    self.env.insert(op.ins[k].clone(), v);
350                }
351                match result {
352                    Some(r) => self.land(terms, at, &op.dst, r),
353                    None => Ok(()),
354                }
355            }
356        }
357    }
358
359    /// A fused op's parts in order, a member a rule built running its own parts in turn.
360    fn parts(&mut self, terms: &mut Terms, at: usize, op: &Op) -> Result<(), SmtError> {
361        let Sym::Fused(f) = &op.sym else { return self.op(terms, at, op) };
362        for part in &f.parts {
363            match part.sym {
364                Sym::Fused(_) => self.parts(terms, at, part)?,
365                _ => self.op(terms, at, part)?,
366            }
367        }
368        Ok(())
369    }
370
371    /// The row a row index names: a literal, or the one Int operand `k` names.
372    fn index(&mut self, terms: &Terms, at: usize, i: &RowIndex, op: &Op, k: usize, pitch: usize, last: usize, len: usize, what: &str) -> Result<At, SmtError> {
373        match i {
374            RowIndex::Literal(n) if *n >= 1 => self.row(terms, at, &RowSel::Fixed(*n as usize), pitch, last, len, what),
375            RowIndex::Literal(n) => Err(self.malformed(at, format!("row {n} of {what}"))),
376            RowIndex::Named(_) => {
377                let name = op.ins.get(k).ok_or_else(|| self.malformed(at, "a named row with no operand naming it"))?.clone();
378                self.row(terms, at, &RowSel::Param(name), pitch, last, len, what)
379            }
380        }
381    }
382
383    /// A primitive's result and the operands it writes in place: the index map of an op that moves values, a function
384    /// left open of every other.
385    #[allow(clippy::type_complexity)]
386    fn prim(&mut self, terms: &mut Terms, at: usize, op: &Op, spec: &TensorOpSpec, ins: &[usize]) -> Result<(Option<usize>, Vec<(usize, usize)>), SmtError> {
387        let name = |k: usize| format!("`{}`", op.ins[k]);
388        match spec {
389            TensorOpSpec::HeadSlice(s) => {
390                let len = s.n / s.every.max(1) * s.width;
391                Ok((Some(terms.units(ins[0], s.every, s.from, s.width, len)), Vec::new()))
392            }
393            TensorOpSpec::Transpose(t) => {
394                let picked = terms.node(Node::Pick { src: ins[0], map: Map::Transpose { rows: t.rows, cols: t.cols }, len: t.rows * t.cols });
395                Ok((Some(picked), Vec::new()))
396            }
397            TensorOpSpec::RowGather(r) => {
398                let what = format!("{}'s {} rows", name(0), r.rows);
399                let place = self.index(terms, at, &r.index, op, 1, r.cols, r.cols - 1, r.rows * r.cols, &what)?;
400                let picked = match place {
401                    At::Fixed(from) => terms.units(ins[0], 0, from, r.cols, r.cols),
402                    At::Row { param, .. } => terms.node(Node::Pick { src: ins[0], map: Map::Row { param, cols: r.cols }, len: r.cols }),
403                };
404                Ok((Some(picked), Vec::new()))
405            }
406            TensorOpSpec::RowsGather(r) => {
407                let indices = ins[1];
408                if terms.sorts[indices] != Sort::Int || terms.lens[indices] < r.count {
409                    return Err(self.malformed(at, format!("{} holds no {} Int row indices", name(1), r.count)));
410                }
411                for index in 0..r.count {
412                    self.bounds.push(Bound { node: indices, index, lo: 1, hi: r.rows as i64, why: format!("{} names rows of {}", name(1), name(0)) });
413                }
414                let picked = terms.node(Node::Pick { src: ins[0], map: Map::Rows { indices, cols: r.cols }, len: r.count * r.cols });
415                Ok((Some(picked), Vec::new()))
416            }
417            TensorOpSpec::Join(j) => {
418                let (ha, hb) = (j.wa / j.heads.max(1), j.wb / j.heads.max(1));
419                let units = j.rows * j.heads;
420                let sort = if terms.sorts[ins[0]] == Sort::Int && terms.sorts[ins[1]] == Sort::Int { Sort::Int } else { Sort::Real };
421                let (a, b) = (terms.as_sort(ins[0], sort), terms.as_sort(ins[1], sort));
422                let zeros = terms.node(Node::Zeros { sort, len: units * (ha + hb) });
423                let with_a = terms.node(Node::Store { table: zeros, src: a, at: At::Fixed(0), unit: Some((ha + hb, 0, ha)), n: units * ha });
424                let joined = terms.node(Node::Store { table: with_a, src: b, at: At::Fixed(0), unit: Some((ha + hb, ha, hb)), n: units * hb });
425                Ok((Some(joined), Vec::new()))
426            }
427            TensorOpSpec::SetRow(r) => {
428                let n = r.cols * r.count;
429                let what = format!("{}'s {} rows", name(0), r.rows);
430                let place = self.index(terms, at, &r.index, op, 2, r.cols, n.saturating_sub(1), r.rows * r.cols, &what)?;
431                let table = ins[0];
432                if terms.sorts[table] == Sort::Int && terms.sorts[ins[1]] == Sort::Real {
433                    return Err(self.malformed(at, format!("Reals written into {}, a table of Ints", name(0))));
434                }
435                let src = terms.as_sort(ins[1], terms.sorts[table]);
436                let stored = terms.node(Node::Store { table, src, at: place, unit: None, n });
437                Ok((None, vec![(0, stored)]))
438            }
439            TensorOpSpec::Matmul(_) | TensorOpSpec::Elementwise(_) | TensorOpSpec::Scale(_) if self.exact => {
440                let op_ = match spec {
441                    TensorOpSpec::Matmul(m) => Arith::Matmul { m: m.m, k: m.k, n: m.n },
442                    TensorOpSpec::Elementwise(e) => Arith::Binary { mul: e.op == crate::tensor_recognize::ElementwiseOp::Mul, row: e.row },
443                    TensorOpSpec::Scale(s) => Arith::Scale { factor: s.factor.get().to_bits() },
444                    _ => unreachable!("the arms above"),
445                };
446                let (sort, len) = (self.sort_of(at, &op.out)?, self.len_of(at, &op.out)?);
447                let args: Vec<usize> = ins.iter().map(|&i| terms.as_sort(i, sort)).collect();
448                Ok((Some(terms.node(Node::Arith { op: op_, args, sort, len })), Vec::new()))
449            }
450            _ => {
451                let args: Vec<Sort> = ins.iter().map(|&i| terms.sorts[i]).collect();
452                let shape = shape_of(spec);
453                let open = |terms: &mut Terms, out: usize, sort: Sort, len: usize| {
454                    let f = terms.fun(Fun { name: format!("{}!{}!{}", spec.kind(), shape, out), args: args.clone(), sort });
455                    terms.node(Node::Apply { f, args: ins.to_vec(), len })
456                };
457                let mut state = Vec::new();
458                for (n, k) in spec.state_inputs().into_iter().enumerate() {
459                    let (sort, len) = (terms.sorts[ins[k]], terms.lens[ins[k]]);
460                    state.push((k, open(terms, n + 1, sort, len)));
461                }
462                let (sort, len) = (self.sort_of(at, &op.out)?, self.len_of(at, &op.out)?);
463                Ok((Some(open(terms, 0, sort, len)), state))
464            }
465        }
466    }
467}
468
469/// A primitive's shape: the first 8 bytes of the sha256 of its spec without the symbols it names, so two ops of one
470/// shape are one function of their operands.
471fn shape_of(spec: &TensorOpSpec) -> String {
472    let text = format!("{spec:?}");
473    let mut bare = String::new();
474    let mut rest = text.as_str();
475    while let Some(i) = rest.find("Symbol(") {
476        bare.push_str(&rest[..i]);
477        rest = &rest[i..];
478        let end = rest.find(')').map_or(rest.len(), |e| e + 1);
479        rest = &rest[end..];
480    }
481    bare.push_str(rest);
482    hex(&crate::crypto_accel::sha256(bare.as_bytes())[..8])
483}
484
485fn hex(bytes: &[u8]) -> String {
486    bytes.iter().map(|b| format!("{b:02x}")).collect()
487}
488
489/// One step of a proof a solver checks: SMT-LIB 2 premises — the terms it reads and the facts it assumes — and a goal
490/// asserting that two terms differ, or that a read strays out of range. The premises are satisfiable, so nothing follows
491/// from them vacuously; with the goal they are not, exactly when the step holds.
492#[derive(Clone, Debug, PartialEq, Eq)]
493pub struct Lemma {
494    /// Declarations, definitions and the asserted facts.
495    pub premises: String,
496    /// The one assert of the goal.
497    pub goal: String,
498}
499
500impl Lemma {
501    /// The premises and the goal, as one SMT-LIB 2 script without commands.
502    pub fn text(&self) -> String {
503        format!("{}{}", self.premises, self.goal)
504    }
505}
506
507/// What two graphs give a solver. A tensor both keep that the two graphs compute by one term needs no proof; one they
508/// compute by different terms is equal when the open functions are applied to equal operands, down to the pairs of terms
509/// that differ in more than their operands — an index map, a store, a value only one side computes. Each such pair is a
510/// lemma, its terms defined down to the subterms both share, which stand for any values; so is every read of the
511/// rewritten graph that its facts must keep in range.
512#[derive(Clone, Debug, PartialEq, Eq)]
513pub struct Obligation {
514    /// What the proof checks beyond the terms both graphs share.
515    pub lemmas: Vec<Lemma>,
516    /// The tensors compared: every one both graphs keep that an op of either writes.
517    pub compared: usize,
518    /// Of those, the ones both graphs compute by the same term.
519    pub identical: usize,
520    /// The facts the lemmas assume, each saying what states it.
521    pub assumed: Vec<String>,
522    /// The tensors compared, by name, each marked computed alike or not.
523    header: String,
524}
525
526impl Obligation {
527    /// The tensors compared and every lemma, as one text: what a proof of it is a proof of.
528    pub fn text(&self) -> String {
529        let mut t = self.header.clone();
530        for l in &self.lemmas {
531            t.push_str(&l.text());
532        }
533        t
534    }
535
536    /// The text's sha256, as 64 lowercase hex digits.
537    pub fn digest(&self) -> String {
538        hex(&crate::crypto_accel::sha256(self.text().as_bytes()))
539    }
540}
541
542fn run<'g>(terms: &mut Terms, side: &'static str, exact: bool, g: &'g TensorGraph) -> Result<Run<'g>, SmtError> {
543    let mut r = Run { side, exact, g, env: BTreeMap::new(), bounds: Vec::new() };
544    for t in g.tensors.values().filter(|t| t.role != Role::Activation || t.fill.is_some()) {
545        let start = terms.node(Node::Start { name: t.name.clone(), sort: Sort::of(t.elem), len: t.len() });
546        r.env.insert(t.name.clone(), start);
547    }
548    for (at, op) in g.ops.iter().enumerate() {
549        r.op(terms, at, op)?;
550    }
551    Ok(r)
552}
553
554/// The obligation that `rewritten` means what `reference` means: for every input and every meaning of the ops the
555/// meaning leaves open, wherever the reference's reads are in range, the rewritten graph's are too and every tensor both
556/// keep holds the same values. A tensor of one name is the same tensor in both.
557pub fn obligation(reference: &TensorGraph, rewritten: &TensorGraph) -> Result<Obligation, SmtError> {
558    obligation_under(reference, rewritten, false)
559}
560
561/// The obligation that two programs compute every tensor both keep alike when their products, elementwise sums and
562/// products and scales are computed exactly — an algebraic identity at the shapes the programs declare. Every other op
563/// is left open as [`obligation`] leaves it.
564pub fn identity(lhs: &TensorGraph, rhs: &TensorGraph) -> Result<Obligation, SmtError> {
565    obligation_under(lhs, rhs, true)
566}
567
568fn obligation_under(reference: &TensorGraph, rewritten: &TensorGraph, exact: bool) -> Result<Obligation, SmtError> {
569    let mut terms = Terms::default();
570    let a = run(&mut terms, "reference", exact, reference)?;
571    let b = run(&mut terms, "rewritten", exact, rewritten)?;
572    for t in reference.tensors.values().filter(|t| t.role == Role::State || t.observed) {
573        if a.env.contains_key(&t.name) && !b.env.contains_key(&t.name) {
574            return Err(SmtError::Unkept { tensor: t.name.clone() });
575        }
576    }
577    let starts: BTreeMap<&str, usize> = reference
578        .tensors
579        .values()
580        .filter_map(|t| terms.ids.get(&Node::Start { name: t.name.clone(), sort: Sort::of(t.elem), len: t.len() }).map(|&id| (t.name.as_str(), id)))
581        .collect();
582    let goals: Vec<(&str, usize, usize)> = b
583        .env
584        .iter()
585        .filter_map(|(name, &y)| a.env.get(name).map(|&x| (name.as_str(), x, y)))
586        .filter(|(name, x, y)| starts.get(name) != Some(x) || starts.get(name) != Some(y))
587        .collect();
588    let key = |b: &Bound| (b.node, b.index, b.lo, b.hi);
589    let assumed_bounds: BTreeSet<(usize, usize, i64, i64)> = a.bounds.iter().map(key).collect();
590    let mut owed: Vec<&Bound> = b.bounds.iter().filter(|x| !assumed_bounds.contains(&key(x))).collect();
591    owed.sort();
592    owed.dedup_by_key(|b| key(b));
593    let (alike, differ): (Vec<_>, Vec<_>) = goals.iter().partition(|(_, x, y)| x == y);
594
595    let mut header = String::from("; tensor_graph::smt — the rewritten graph means what the reference means\n");
596    for (name, x, y) in &goals {
597        let _ = writeln!(header, "; {} `{name}`", if x == y { "one term on both sides:" } else { "compared:" });
598    }
599
600    // The facts the step may assume: the range of every integer it starts with that the program proves one of, and every
601    // read the reference makes in range.
602    let mut facts: Vec<(usize, usize, i64, i64, String)> = Vec::new();
603    for t in reference.tensors.values() {
604        let (Some((lo, hi)), Some(&node)) = (t.range, starts.get(t.name.as_str())) else { continue };
605        if t.elem != Elem::Int || (lo == i64::MIN && hi == i64::MAX) {
606            continue;
607        }
608        for index in 0..t.len() {
609            facts.push((node, index, lo, hi, format!("`{}` holds [{lo}, {hi}], the interval the program proves", t.name)));
610        }
611    }
612    let mut known = BTreeSet::new();
613    for bound in &a.bounds {
614        if known.insert(key(bound)) {
615            facts.push((bound.node, bound.index, bound.lo, bound.hi, format!("{}, as the reference reads it", bound.why)));
616        }
617    }
618
619    // The lemmas, lowest first: each pair of terms the goals still come down to through the open functions, once every
620    // pair proven before it is one term. A lemma assumes only what the lemmas before it prove.
621    let mut lemmas = Vec::new();
622    let mut assumed: Vec<String> = Vec::new();
623    let mut proven: Vec<(usize, usize)> = Vec::new();
624    loop {
625        let class = classes(&terms, &proven);
626        let mut pairs: Vec<(usize, usize, &str)> = Vec::new();
627        let mut seen: BTreeSet<(usize, usize)> = BTreeSet::new();
628        let mut work: Vec<(usize, usize, &str)> = differ.iter().rev().map(|(name, x, y)| (*x, *y, *name)).collect();
629        while let Some((x, y, from)) = work.pop() {
630            if class[x] == class[y] || !seen.insert((x, y)) {
631                continue;
632            }
633            match congruent(&terms, x, y) {
634                Some(kids) => work.extend(kids.into_iter().rev().map(|(cx, cy)| (cx, cy, from))),
635                None => pairs.push((x, y, from)),
636            }
637        }
638        let Some(&(x, y, from)) = pairs.iter().min_by_key(|(x, y, _)| (*x.max(y), *x.min(y))) else { break };
639        assert!(!proven.contains(&(x.min(y), x.max(y))), "a proven pair is one class: `{from}` chosen twice");
640        let (premises, cited) = premises_of(&terms, &class, &[x, y], &[x, y], &facts, &format!("`{from}` is one term on both sides"));
641        let (lx, ly) = (cited.local[&x], cited.local[&y]);
642        // Terms computed by exact arithmetic are compared value by value at each index, each a polynomial in values the
643        // step starts with; any other two at one index the solver chooses, which index maps over a symbolic row need.
644        let goal = if cited.arithmetic {
645            let len = terms.lens[x].max(terms.lens[y]);
646            let each: Vec<String> = (0..len).map(|i| format!("(= (select |n{lx}| {i}) (select |n{ly}| {i}))")).collect();
647            format!("(assert (not (and true {})))\n", each.join(" "))
648        } else {
649            format!("(assert (not (= |n{lx}| |n{ly}|)))\n")
650        };
651        lemmas.push(Lemma { premises, goal });
652        assumed.extend(cited.assumed);
653        proven.push((x.min(y), x.max(y)));
654    }
655    if !owed.is_empty() {
656        let nodes: Vec<usize> = owed.iter().map(|b| b.node).collect();
657        let class = classes(&terms, &proven);
658        let (mut premises, cited) = premises_of(&terms, &class, &nodes, &[], &facts, "the rewritten graph reads in range");
659        for bound in &owed {
660            let _ = writeln!(premises, "; owed: {}, as the rewritten graph reads it", bound.why);
661        }
662        let reads: Vec<String> = owed.iter().map(|b| within(cited.local[&b.node], b.index, b.lo, b.hi)).collect();
663        lemmas.push(Lemma { premises, goal: format!("(assert (not (and true {})))\n", reads.join(" ")) });
664        assumed.extend(cited.assumed);
665    }
666    let mut once = BTreeSet::new();
667    assumed.retain(|a| once.insert(a.clone()));
668    Ok(Obligation { lemmas, compared: goals.len(), identical: alike.len(), assumed, header })
669}
670
671/// Whether `x` and `y` are one open function, or one coercion, applied to operands that pair up: the pairs whose equality
672/// makes them equal. Any other two terms are compared whole.
673fn congruent(terms: &Terms, x: usize, y: usize) -> Option<Vec<(usize, usize)>> {
674    match (&terms.nodes[x], &terms.nodes[y]) {
675        (Node::Apply { f: f1, args: a1, len: l1 }, Node::Apply { f: f2, args: a2, len: l2 }) if f1 == f2 && l1 == l2 && a1.len() == a2.len() => {
676            Some(a1.iter().copied().zip(a2.iter().copied()).collect())
677        }
678        (Node::Real { src: s1 }, Node::Real { src: s2 }) => Some(vec![(*s1, *s2)]),
679        _ => None,
680    }
681}
682
683/// `node` with each term it reads renamed by `f`.
684fn renamed(node: &Node, f: &dyn Fn(usize) -> usize) -> Node {
685    let at = |a: &At| match a {
686        At::Fixed(s) => At::Fixed(*s),
687        At::Row { param, pitch } => At::Row { param: f(*param), pitch: *pitch },
688    };
689    match node {
690        Node::Start { .. } | Node::Zeros { .. } => node.clone(),
691        Node::Apply { f: fun, args, len } => Node::Apply { f: *fun, args: args.iter().map(|&a| f(a)).collect(), len: *len },
692        Node::Pick { src, map, len } => {
693            let map = match map {
694                Map::Row { param, cols } => Map::Row { param: f(*param), cols: *cols },
695                Map::Rows { indices, cols } => Map::Rows { indices: f(*indices), cols: *cols },
696                Map::Units { .. } | Map::Transpose { .. } => map.clone(),
697            };
698            Node::Pick { src: f(*src), map, len: *len }
699        }
700        Node::Store { table, src, at: place, unit, n } => Node::Store { table: f(*table), src: f(*src), at: at(place), unit: *unit, n: *n },
701        Node::Real { src } => Node::Real { src: f(*src) },
702        Node::Arith { op, args, sort, len } => Node::Arith { op: op.clone(), args: args.iter().map(|&a| f(a)).collect(), sort: *sort, len: *len },
703    }
704}
705
706/// Each term's class once every pair of `proven` is one term: the least term of the class, where two terms are one
707/// class when a proven pair joins them, when one constructor reads terms of the same classes, or when a chain of those
708/// leads from one to the other. Terms are taken in the order they were made, so a term's operands have their classes
709/// before it does.
710fn classes(terms: &Terms, proven: &[(usize, usize)]) -> Vec<usize> {
711    fn root(parent: &[usize], mut x: usize) -> usize {
712        while parent[x] != x {
713            x = parent[x];
714        }
715        x
716    }
717    fn join(parent: &mut [usize], a: usize, b: usize) {
718        let (ra, rb) = (root(parent, a), root(parent, b));
719        parent[ra.max(rb)] = ra.min(rb);
720    }
721    let mut parent: Vec<usize> = (0..terms.nodes.len()).collect();
722    let mut partners: HashMap<usize, Vec<usize>> = HashMap::new();
723    for &(a, b) in proven {
724        partners.entry(a.max(b)).or_default().push(a.min(b));
725    }
726    let mut seen: HashMap<Node, usize> = HashMap::new();
727    for (n, node) in terms.nodes.iter().enumerate() {
728        let signature = renamed(node, &|k| root(&parent, k));
729        match seen.get(&signature) {
730            Some(&m) => join(&mut parent, n, m),
731            None => {
732                seen.insert(signature, n);
733            }
734        }
735        for &p in partners.get(&n).into_iter().flatten() {
736            join(&mut parent, n, p);
737        }
738    }
739    (0..terms.nodes.len()).map(|n| root(&parent, n)).collect()
740}
741
742/// Every class the terms `from` reaches belong to, its own included.
743fn reach(terms: &Terms, class: &[usize], from: usize) -> BTreeSet<usize> {
744    let mut out = BTreeSet::new();
745    let mut seen = BTreeSet::new();
746    let mut stack = vec![from];
747    while let Some(n) = stack.pop() {
748        if seen.insert(n) {
749            out.insert(class[n]);
750            stack.extend(children(&terms.nodes[n]));
751        }
752    }
753    out
754}
755
756/// A lemma's terms as it numbers them, the facts it assumes, and whether it defines a term by exact arithmetic.
757struct Cited {
758    local: BTreeMap<usize, usize>,
759    assumed: Vec<String>,
760    arithmetic: bool,
761}
762
763/// The premises of a lemma about `roots`: each root defined down to the terms of a class more than one of `split` reaches
764/// — each such class one constant for any values, zero past its length, as the lemmas before it prove its terms one —
765/// and every fact about a term it reads.
766fn premises_of(terms: &Terms, class: &[usize], roots: &[usize], split: &[usize], facts: &[(usize, usize, i64, i64, String)], what: &str) -> (String, Cited) {
767    let reaches: Vec<BTreeSet<usize>> = split.iter().map(|&r| reach(terms, class, r)).collect();
768    let shared = |n: usize| reaches.iter().filter(|r| r.contains(&class[n])).count() > 1;
769    let mut defined = BTreeSet::new();
770    let mut opaque: BTreeMap<usize, BTreeSet<usize>> = BTreeMap::new();
771    let mut stack: Vec<usize> = roots.to_vec();
772    let mut visited = BTreeSet::new();
773    while let Some(n) = stack.pop() {
774        if !visited.insert(n) {
775            continue;
776        }
777        let leaf = matches!(terms.nodes[n], Node::Start { .. } | Node::Zeros { .. });
778        if !roots.contains(&n) && !leaf && (shared(n) || split.is_empty()) {
779            opaque.entry(class[n]).or_default().insert(n);
780        } else {
781            defined.insert(n);
782            stack.extend(children(&terms.nodes[n]));
783        }
784    }
785    // One name a defined term, one a class of opaque terms: the first of each in the order the terms were made.
786    let mut names: BTreeMap<usize, usize> = BTreeMap::new();
787    for &n in &defined {
788        names.insert(n, n);
789    }
790    for members in opaque.values() {
791        let first = *members.iter().next().expect("a class has a member");
792        for &m in members {
793            names.insert(m, first);
794        }
795    }
796    let shown: BTreeSet<usize> = names.values().copied().collect();
797    let order: BTreeMap<usize, usize> = shown.iter().enumerate().map(|(k, &n)| (n, k)).collect();
798    let local: BTreeMap<usize, usize> = names.iter().map(|(&n, first)| (n, order[first])).collect();
799    let opaque_names: BTreeSet<usize> = opaque.values().map(|m| *m.iter().next().expect("a member")).collect();
800    let mut text = format!("; lemma: {what}\n(set-logic ALL)\n");
801    let mut declared: BTreeMap<&str, Sort> = BTreeMap::new();
802    let mut funs: BTreeSet<usize> = BTreeSet::new();
803    for &n in &defined {
804        match &terms.nodes[n] {
805            Node::Start { name, sort, .. } => {
806                declared.insert(name, *sort);
807            }
808            Node::Apply { f, .. } => {
809                funs.insert(*f);
810            }
811            _ => {}
812        }
813    }
814    for (name, sort) in &declared {
815        let _ = writeln!(text, "(declare-const |in!{name}| (Array Int {}))", sort.smt());
816    }
817    for &n in &opaque_names {
818        let _ = writeln!(text, "(declare-const |o!{}| (Array Int {}))", local[&n], terms.sorts[n].smt());
819    }
820    let mut fun_lines: Vec<String> = funs
821        .iter()
822        .map(|&f| {
823            let fun = &terms.funs[f];
824            let args: Vec<String> = fun.args.iter().map(|s| format!("(Array Int {})", s.smt())).collect();
825            let sig: String = fun.args.iter().map(|s| s.tag()).collect();
826            format!("(declare-fun |f!{}!{sig}| ({}) (Array Int {}))", fun.name, args.join(" "), fun.sort.smt())
827        })
828        .collect();
829    fun_lines.sort();
830    for line in fun_lines {
831        text.push_str(&line);
832        text.push('\n');
833    }
834    for &n in &shown {
835        let body = if opaque_names.contains(&n) {
836            let (sort, len) = (terms.sorts[n], terms.lens[n]);
837            format!("(lambda ((j Int)) (ite (and (<= 0 j) (< j {len})) (select |o!{}| j) {}))", local[&n], sort.zero())
838        } else {
839            expr(terms, n, &local)
840        };
841        let _ = writeln!(text, "(define-fun |n{}| () (Array Int {}) {body})", local[&n], terms.sorts[n].smt());
842    }
843    let mut assumed = Vec::new();
844    for (node, index, lo, hi, why) in facts.iter().filter(|f| local.contains_key(&f.0)) {
845        let _ = writeln!(text, "; assumed: {why}\n(assert {})", within(local[node], *index, *lo, *hi));
846        assumed.push(why.clone());
847    }
848    let arithmetic = defined.iter().any(|&n| matches!(terms.nodes[n], Node::Arith { .. }));
849    (text, Cited { local, assumed, arithmetic })
850}
851
852fn children(node: &Node) -> Vec<usize> {
853    match node {
854        Node::Start { .. } | Node::Zeros { .. } => Vec::new(),
855        Node::Apply { args, .. } => args.clone(),
856        Node::Pick { src, map, .. } => {
857            let mut c = vec![*src];
858            match map {
859                Map::Row { param, .. } => c.push(*param),
860                Map::Rows { indices, .. } => c.push(*indices),
861                Map::Units { .. } | Map::Transpose { .. } => {}
862            }
863            c
864        }
865        Node::Store { table, src, at, .. } => {
866            let mut c = vec![*table, *src];
867            if let At::Row { param, .. } = at {
868                c.push(*param);
869            }
870            c
871        }
872        Node::Real { src } => vec![*src],
873        Node::Arith { args, .. } => args.clone(),
874    }
875}
876
877fn within(node: usize, index: usize, lo: i64, hi: i64) -> String {
878    format!("(and (<= {} (select |n{node}| {index})) (<= (select |n{node}| {index}) {}))", int(lo), int(hi))
879}
880
881fn int(v: i64) -> String {
882    if v < 0 {
883        format!("(- {})", v.unsigned_abs())
884    } else {
885        v.to_string()
886    }
887}
888
889/// Term `n` as an SMT-LIB array expression, zero past its length.
890fn expr(terms: &Terms, n: usize, local: &BTreeMap<usize, usize>) -> String {
891    let (sort, len) = (terms.sorts[n], terms.lens[n]);
892    let id = |x: &usize| local[x];
893    let clamp = |body: String| format!("(lambda ((j Int)) (ite (and (<= 0 j) (< j {len})) {body} {}))", sort.zero());
894    match &terms.nodes[n] {
895        Node::Start { name, .. } => clamp(format!("(select |in!{name}| j)")),
896        Node::Zeros { .. } => format!("((as const (Array Int {})) {})", sort.smt(), sort.zero()),
897        Node::Apply { f, args, .. } => {
898            let fun = &terms.funs[*f];
899            let sig: String = fun.args.iter().map(|s| s.tag()).collect();
900            let call = if args.is_empty() {
901                format!("|f!{}!{sig}|", fun.name)
902            } else {
903                format!("(|f!{}!{sig}| {})", fun.name, args.iter().map(|a| format!("|n{}|", id(a))).collect::<Vec<_>>().join(" "))
904            };
905            clamp(format!("(select {call} j)"))
906        }
907        Node::Pick { src, map, .. } => {
908            let index = match map {
909                Map::Units { pitch: 0, offset, .. } => format!("(+ j {offset})"),
910                Map::Units { pitch, offset, width } => format!("(+ (* (div j {width}) {pitch}) {offset} (mod j {width}))"),
911                Map::Transpose { rows, cols } => format!("(+ (* (mod j {rows}) {cols}) (div j {rows}))"),
912                Map::Row { param, cols } => format!("(+ (* (- (select |n{}| 0) 1) {cols}) j)", id(param)),
913                Map::Rows { indices, cols } => format!("(+ (* (- (select |n{}| (div j {cols})) 1) {cols}) (mod j {cols}))", id(indices)),
914            };
915            clamp(format!("(select |n{}| {index})", id(src)))
916        }
917        Node::Store { table, src, at, unit, n: count } => {
918            let start = match at {
919                At::Fixed(s) => s.to_string(),
920                At::Row { param, pitch } => format!("(* (- (select |n{}| 0) 1) {pitch})", id(param)),
921            };
922            let d = format!("(- j {start})");
923            let (inside, from) = match unit {
924                None => (format!("(and (<= 0 {d}) (< {d} {count}))"), d.clone()),
925                Some((pitch, offset, width)) => (
926                    format!("(and (<= 0 {d}) (< (div {d} {pitch}) {}) (<= {offset} (mod {d} {pitch})) (< (mod {d} {pitch}) {}))", count / width, offset + width),
927                    format!("(+ (* (div {d} {pitch}) {width}) (- (mod {d} {pitch}) {offset}))"),
928                ),
929            };
930            clamp(format!("(ite {inside} (select |n{}| {from}) (select |n{}| j))", id(src), id(table)))
931        }
932        Node::Real { src } => clamp(format!("(to_real (select |n{}| j))", id(src))),
933        Node::Arith { op, args, .. } => {
934            let at = |k: usize, index: String| format!("(select |n{}| {index})", id(&args[k]));
935            clamp(match op {
936                Arith::Matmul { k: 0, .. } => sort.zero().to_string(),
937                Arith::Matmul { k, n: cols, .. } => {
938                    let terms: Vec<String> = (0..*k)
939                        .map(|kk| format!("(* {} {})", at(0, format!("(+ (* (div j {cols}) {k}) {kk})")), at(1, format!("(+ {} (mod j {cols}))", kk * cols))))
940                        .collect();
941                    if terms.len() == 1 {
942                        terms[0].clone()
943                    } else {
944                        format!("(+ {})", terms.join(" "))
945                    }
946                }
947                Arith::Binary { mul, row } => {
948                    let b = row.map_or_else(|| at(1, "j".to_string()), |w| at(1, format!("(mod j {w})")));
949                    format!("({} {} {b})", if *mul { "*" } else { "+" }, at(0, "j".to_string()))
950                }
951                Arith::Scale { factor } => format!("(* {} {})", exact_real(f64::from_bits(*factor)), at(0, "j".to_string())),
952            })
953        }
954    }
955}
956
957/// `v`'s exact value as an SMT-LIB real: a binary64 is an integer times a power of two.
958fn exact_real(v: f64) -> String {
959    let bits = v.to_bits();
960    let negative = bits >> 63 == 1;
961    let exponent = ((bits >> 52) & 0x7ff) as i64;
962    let fraction = bits & ((1u64 << 52) - 1);
963    let (mut mantissa, mut power) = if exponent == 0 { (fraction, -1074) } else { (fraction | (1u64 << 52), exponent - 1075) };
964    if mantissa == 0 {
965        return "0.0".to_string();
966    }
967    while mantissa % 2 == 0 && power < 0 {
968        mantissa /= 2;
969        power += 1;
970    }
971    let magnitude = if power == 0 {
972        format!("{mantissa}.0")
973    } else if power > 0 {
974        format!("(* {mantissa}.0 {}.0)", num_pow2(power as u32))
975    } else {
976        format!("(/ {mantissa}.0 {}.0)", num_pow2(-power as u32))
977    };
978    if negative {
979        format!("(- {magnitude})")
980    } else {
981        magnitude
982    }
983}
984
985/// `2^e` in decimal.
986fn num_pow2(e: u32) -> String {
987    let mut digits = vec![1u8];
988    for _ in 0..e {
989        let mut carry = 0u8;
990        for d in digits.iter_mut() {
991            let v = *d * 2 + carry;
992            *d = v % 10;
993            carry = v / 10;
994        }
995        if carry > 0 {
996            digits.push(carry);
997        }
998    }
999    digits.iter().rev().map(|d| (b'0' + d) as char).collect()
1000}