# VErification via interaction Nets (VEIN)
# Copyright (C) 2026 Eric Marin
#
# This program is free software: you can redistribute it and/or modify it under the terms of the
# GNU Affero General Public License as published by the Free Software Foundation, either version 3
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
# even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License along with this program.
# If not, see .
import z3
import numpy as np
import subprocess
import onnx
import onnx.shape_inference
from onnx import numpy_helper
from typing import List, Dict, Optional
import os
import tempfile
import hashlib
from itertools import count
import re
import ast
sat = z3.sat
unsat = z3.unsat
rules = """
Linear(x, float q, float r) >< Add(out, b) => b ~ AddCheckLinear(out, x, q, r);
Concrete(float k) >< Add(out, b)
| k == 0 => out ~ b
| _ => b ~ AddCheckConcrete(out, k);
Linear(y, float s, float t) >< AddCheckLinear(out, x, float q, float r)
| (q == 0) && (r == 0) && (s == 0) && (t == 0) => out ~ Concrete(0), x ~ Eraser, y ~ Eraser
| (s == 0) && (t == 0) => out ~ Linear(x, q, r), y ~ Eraser
| (q == 0) && (r == 0) => out ~ (*L)Linear(y, s, t), x ~ Eraser
| _ => Linear(x, q, r) ~ Materialize(out_x), (*L)Linear(y, s, t) ~ Materialize(out_y), out ~ Linear(TermAdd(out_x, out_y), 1, 0);
Concrete(float j) >< AddCheckLinear(out, x, float q, float r) => out ~ Linear(x, q, r + j);
Linear(y, float s, float t) >< AddCheckConcrete(out, float k) => out ~ Linear(y, s, t + k);
Concrete(float j) >< AddCheckConcrete(out, float k)
| j == 0 => out ~ Concrete(k)
| _ => out ~ Concrete(k + j);
Linear(x, float q, float r) >< Mul(out, b) => b ~ MulCheckLinear(out, x, q, r);
Concrete(float k) >< Mul(out, b)
| k == 0 => b ~ Eraser, out ~ (*L)Concrete(0)
| k == 1 => out ~ b
| _ => b ~ MulCheckConcrete(out, k);
Linear(y, float s, float t) >< MulCheckLinear(out, x, float q, float r)
| ((q == 0) && (r == 0)) || ((s == 0) && (t == 0)) => out ~ Concrete(0), x ~ Eraser, y ~ Eraser
| _ => Linear(x, q, r) ~ Materialize(out_x), (*L)Linear(y, s, t) ~ Materialize(out_y), out ~ Linear(TermMul(out_x, out_y), 1, 0);
Concrete(float j) >< MulCheckLinear(out, x, float q, float r) => out ~ Linear(x, q * j, r * j);
Linear(y, float s, float t) >< MulCheckConcrete(out, float k) => out ~ Linear(y, s * k, t * k);
Concrete(float j) >< MulCheckConcrete(out, float k)
| j == 0 => out ~ Concrete(0)
| j == 1 => out ~ Concrete(k)
| _ => out ~ Concrete(k * j);
Linear(x, float q, float r) >< ReLU(out) => (*L)Linear(x, q, r) ~ Materialize(out_x), out ~ Linear(TermReLU(out_x), 1, 0);
Concrete(float k) >< ReLU(out)
| k > 0 => out ~ (*L)Concrete(k)
| _ => out ~ Concrete(0);
Linear(x, float q, float r) >< Materialize(out)
| (q == 0) => out ~ TermConcrete(r), x ~ Eraser
| (q == 1) && (r == 0) => out ~ x
| (q == 1) && (r != 0) => out ~ TermAdd(x, TermConcrete(r))
| (q != 0) && (r == 0) => out ~ TermMul(TermConcrete(q), x)
| _ => out ~ TermAdd(TermMul(TermConcrete(q), x), TermConcrete(r));
Concrete(float k) >< Materialize(out) => out ~ (*L)TermConcrete(k);
"""
_CACHE = {}
def inpla_export(model: onnx.ModelProto, bounds: Optional[Dict[str, List[float]]] = None) -> str:
# TODO: Add Range agent
_ = bounds
def get_initializers(graph) -> Dict[str, np.ndarray]:
initializers = {}
for init in graph.initializer:
initializers[init.name] = numpy_helper.to_array(init)
return initializers
def get_attrs(node) -> Dict:
return {attr.name: onnx.helper.get_attribute_value(attr) for attr in node.attribute}
graph, initializers = model.graph, get_initializers(model.graph)
counter = count()
wire_gen = lambda: f"w{next(counter)}"
interactions: Dict[str, List[List[str]]] = {}
dims = {i.name: i.type.tensor_type.shape.dim[-1].dim_value for i in list(graph.input) + list(graph.output) + list(graph.value_info)}
script = []
def balanced_fanout(agent_name: str, terms: List[str]) -> str:
if not terms: return "Eraser"
if len(terms) == 1: return terms[0]
while len(terms) > 1:
next_level = []
for i in range(0, len(terms), 2):
if i + 1 < len(terms):
in_w = wire_gen()
script.append(f"{in_w} ~ {agent_name}({terms[i]}, {terms[i+1]});")
next_level.append(in_w)
else:
next_level.append(terms[i])
terms = next_level
return terms[0]
def balanced_fanin(agent_name: str, terms: List[str]) -> str:
if not terms: return "Eraser"
if len(terms) == 1: return terms[0]
while len(terms) > 1:
next_level = []
for i in range(0, len(terms), 2):
if i + 1 < len(terms):
res_w = wire_gen()
script.append(f"{terms[i]} ~ {agent_name}({res_w}, {terms[i+1]});")
next_level.append(res_w)
else:
next_level.append(terms[i])
terms = next_level
return terms[0]
def gemm(Y, A, B, C, alpha, beta, _, transB):
weights = initializers[B]
if transB == 0:
weights = weights.T
out_dim, in_dim = weights.shape
biases = initializers[C] if C is not None else None
if A not in interactions:
interactions[A] = [[] for _ in range(in_dim)]
out_terms = interactions.get(Y) or [[f"Materialize(result{j})"] for j in range(out_dim)]
for j in range(out_dim):
sink = balanced_fanout("Dup", out_terms[j])
neuron_terms = []
for i in range(in_dim):
weight = float(alpha * weights[j, i])
if weight != 0:
v = wire_gen()
interactions[A][i].append(f"Mul({v}, Concrete({weight}))")
neuron_terms.append(v)
bias = float(beta * biases[j]) if biases is not None else 0.0
if bias != 0 or len(neuron_terms) == 0:
neuron_terms.append(f"Concrete({bias})")
root = balanced_fanin("Add", neuron_terms)
script.append(f"{root} ~ {sink};")
def op_gemm(node):
attrs = get_attrs(node)
gemm(node.output[0], node.input[0], node.input[1], node.input[2], attrs.get("alpha", 1.0), attrs.get("beta", 1.0), attrs.get("transA", 0), attrs.get("transB", 0))
def op_matmul(node):
gemm(node.output[0], node.input[0], node.input[1], None, 1.0, 0.0, 0, 0)
def op_relu(node):
in_name, out_name = node.input[0], node.output[0]
dim = dims.get(out_name) or 1
if in_name not in interactions:
interactions[in_name] = [[] for _ in range(dim)]
out_terms = interactions.get(node.output[0]) or [[f"Materialize(result{j})"] for j in range(dim)]
for i in range(dim):
sink = balanced_fanout("Dup", out_terms[i])
v = wire_gen()
interactions[in_name][i].append(f"ReLU({v})")
script.append(f"{v} ~ {sink};")
def op_add(node):
in_a, in_b = node.input[0], node.input[1]
out_name = node.output[0]
dim = dims.get(out_name) or dims.get(in_a) or dims.get(in_b) or 1
if in_a not in interactions:
interactions[in_a] = [[] for _ in range(dim)]
if in_b not in interactions:
interactions[in_b] = [[] for _ in range(dim)]
out_terms = interactions.get(out_name) or [[f"Materialize(result{j})"] for j in range(dim)]
b_const = initializers.get(in_b)
a_const = initializers.get(in_a)
for i in range(dim):
sink = balanced_fanout("Dup", out_terms[i])
if b_const is not None:
val = float(b_const.flatten()[i % b_const.size])
interactions[in_a][i].append(f"Add({sink}, Concrete({val}))")
elif a_const is not None:
val = float(a_const.flatten()[i % a_const.size])
interactions[in_b][i].append(f"Add({sink}, Concrete({val}))")
else:
v_b = wire_gen()
interactions[in_a][i].append(f"Add({sink}, {v_b})")
interactions[in_b][i].append(f"{v_b}")
def op_sub(node):
in_a, in_b = node.input[0], node.input[1]
out_name = node.output[0]
dim = dims.get(out_name) or dims.get(in_a) or dims.get(in_b) or 1
if in_a not in interactions:
interactions[in_a] = [[] for _ in range(dim)]
if in_b not in interactions:
interactions[in_b] = [[] for _ in range(dim)]
out_terms = interactions.get(out_name) or [[f"Materialize(result{j})"] for j in range(dim)]
b_const = initializers.get(in_b)
a_const = initializers.get(in_a)
for i in range(dim):
sink = balanced_fanout("Dup", out_terms[i])
if b_const is not None:
val = float(b_const.flatten()[i % b_const.size])
interactions[in_a][i].append(f"Add({sink}, Concrete({-val}))")
elif a_const is not None:
val = float(a_const.flatten()[i % a_const.size])
interactions[in_b][i].append(f"Mul(Add({sink}, Concrete({val})), Concrete(-1.0))")
else:
v_b = wire_gen()
interactions[in_a][i].append(f"Add({sink}, {v_b})")
interactions[in_b][i].append(f"Mul({v_b}, Concrete(-1.0))")
def op_squeeze(node):
op_identity(node)
def op_unsqueeze(node):
op_identity(node)
def op_flatten(node):
op_identity(node)
def op_reshape(node):
op_identity(node)
def op_identity(node):
in_name, out_name = node.input[0], node.output[0]
if out_name in interactions:
interactions[in_name] = interactions[out_name]
ops = {
"Gemm": op_gemm,
"Relu": op_relu,
"Flatten": op_flatten,
"Reshape": op_reshape,
"MatMul": op_matmul,
"Add": op_add,
"Sub": op_sub,
"Squeeze": op_squeeze,
"Unsqueeze": op_unsqueeze,
"Identity": op_identity
}
if graph.output:
out = graph.output[0].name
dim = dims.get(out)
if dim:
interactions[out] = [[f"Materialize(result{i})"] for i in range(dim)]
for node in reversed(graph.node):
if node.op_type in ops:
ops[node.op_type](node)
else:
raise RuntimeError(f"Unsupported ONNX operator: {node.op_type}")
if graph.input:
for input in graph.input:
if input.name in interactions:
for i, terms in enumerate(interactions[input.name]):
sink = balanced_fanout("Dup", terms)
script.append(f"{sink} ~ Linear(TermSymbolic(X_{i}), 1.0, 0.0);")
result_lines = [f"result{i};" for i in range(len(interactions.get(graph.output[0].name, [])))]
return "\n".join(script + result_lines)
def inpla_run(model: str) -> str:
with tempfile.NamedTemporaryFile(mode="w", suffix=".inpla", delete=False) as f:
f.write(f"{rules}\n{model}")
temp_path = f.name
try:
res = subprocess.run(["./inpla", "-f", temp_path, "-foptimise-tail-calls"], capture_output=True, text=True)
if res.stderr:
raise RuntimeError(res.stderr)
return res.stdout
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def z3_evaluate(model: str, X: dict):
def TermSymbolic(id):
if id not in X:
X[id] = z3.Real(id)
return X[id]
def TermConcrete(val): return z3.RealVal(val)
def TermAdd(a, b): return a + b
def TermMul(a, b): return a * b
def TermReLU(x): return z3.If(x > 0, x, 0)
context = {
'TermConcrete': TermConcrete,
'TermSymbolic': TermSymbolic,
'TermAdd': TermAdd,
'TermMul': TermMul,
'TermReLU': TermReLU
}
exprs = []
allowed_calls = set(context.keys())
allowed_nodes = (ast.Expression, ast.Call, ast.Name, ast.Load, ast.Constant, ast.UnaryOp, ast.USub)
model = re.sub(r'X_\d+', lambda m: f'"{m.group(0)}"', model)
for line in model.splitlines():
line = line.strip().rstrip(';')
if not line:
continue
tree = ast.parse(line, mode="eval")
for node in ast.walk(tree):
if not isinstance(node, allowed_nodes):
raise ValueError(f"Disallowed syntax: {type(node).__name__}")
if isinstance(node, ast.Call):
if not isinstance(node.func, ast.Name) or node.func.id not in allowed_calls:
raise ValueError("Disallowed function call")
if isinstance(node, ast.Constant) and not isinstance(node.value, (int, float, str)):
raise ValueError(f"Only numeric constants and string names allowed")
exprs.append(eval(compile(tree, "", "eval"), {"__builtins__": {}}, context))
return exprs
def net(model: onnx.ModelProto, X, bounds: Optional[Dict[str, List[float]]] = None):
model_hash = hashlib.sha256(model.SerializeToString()).hexdigest()
# bounds_key = tuple(sorted((k, tuple(v)) for k, v in bounds.items())) if bounds else None
# cache_key = (model_hash, bounds_key)
cache_key = model_hash
if cache_key not in _CACHE:
exported = inpla_export(model, bounds)
reduced = inpla_run(exported)
evaluated = z3_evaluate(reduced, X)
_CACHE[cache_key] = evaluated
exprs = _CACHE[cache_key]
return exprs if exprs is not None else []
class Solver(z3.Solver):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# self.bounds: Dict[str, List[float]] = {}
self.pending_nets: List[onnx.ModelProto] = []
self.X = {}
def load_smtlib(self, file_path: str):
with open(file_path, "r") as f:
content = f.read()
# for match in re.finditer(r"\(assert\s+\((>=|<=)\s+(X_\d+)\s+([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)\)\)", content):
# op, var, val = match.groups()
# val = float(val)
# if var not in self.bounds: self.bounds[var] = [float('-inf'), float('inf')]
# if op == ">=": self.bounds[var][0] = val
# else: self.bounds[var][1] = val
assertions = z3.parse_smt2_string(content)
self.add(assertions)
def load_onnx(self, file_path: str):
model = onnx.load(file_path)
model = onnx.shape_inference.infer_shapes(model)
self.pending_nets.append(model)
def _process_nets(self):
y_count = 0
for model in self.pending_nets:
z3_outputs = net(model, self.X)
if z3_outputs:
for out_expr in z3_outputs:
y_var = z3.Real(f"Y_{y_count}")
self.add(y_var == out_expr)
y_count += 1
self.pending_nets = []
def check(self, *args):
self._process_nets()
return super().check(*args)