Verified static analysis in Isabelle/HOL

A static analyzer reads your program and establishes properties of its executions, without running any. Once it says a check always holds, people stop looking at that line. An error in the analyzer can invalidate that conclusion.

Voblint addresses this problem through a soundness proof in Isabelle/HOL, from source semantics to analyzer verdicts. The result holds whenever the solver finishes. The analyzer in the playground runs the code Isabelle generated from those same definitions. This page explains the analyzer, its results, and what the proofs do and do not cover.

Analyzer output

Analysis results and verdicts

Take the program below. It sets k to 10 and divides 100 by it, then divides 100 by an arbitrary integer d, and finally asks whether q is positive. It also has a branch that no run can enter. Voblint analyzes it without running it. It checks every division and remainder for a zero divisor on its own, without being asked. Any other condition you want checked, you write as a __voblint_check.

Your program

fun main() {
  k = 10;
  q = 100 / k;
  d = __voblint_nondet_int();
  y = 100 / d;
  __voblint_check(0 < q);
  if (k < 0) {
    __voblint_check(q == 0);
  }
}

Settings

  • analyses interval
  • contexts none
  • globals warrow

Voblint

never runs
your program

WARNING possible division by zero: 100 / d

automatic

PROVED 0 < q, where q = [10, 10]

your check

DEAD q == 0 sits where no run arrives

your check

At the proved check, the whole state: k = [10, 10] · q = [10, 10] · d = ⊤ · y = ⊤

reported state

Three of these rows are claims about every run, and each has a theorem behind it. PROVED means that whenever a run reaches the check, the condition holds there (run_voblint_check_sound). It does not say that any run gets there. DEAD says that no run reaches the line (run_voblint_dead_check_unreached). The warning works the other way round. A warning means the analysis could not rule out a zero divisor. The theorem concerns program points with no warning. No run reaching such a point divides by zero in an expression on an outgoing graph edge (run_voblint_arithmetic_safe). All three theorems assume that the analyzer's solver finished on this program. The last row, the whole state at the check, has no theorem. It is the state the analysis computed, printed as text, and nothing proves that the text says what the state means.

A real run with --analysis interval and the default settings. The divisor warning needs no annotation; the two checks are the program's own. Inside the branch under k < 0 nothing else would be reported either. Even a division by zero written there would stay silent, because no run performs it. Run it ↗

Soundness

Soundness and analyzer defects

Analyzers are programs, and programs have bugs. Many of them make the analyzer too cautious. An affected analyzer answers UNKNOWN where it could have said more. That costs precision, but the answer is still right. An unsound analyzer can report PROVED for a check violated by real executions, or DEAD for code that does run, and nobody looks at that line again.

Goblint, a mature analyzer for C, files these bugs under their own label. Below is one of them, reported in issue #1156 and fixed by pull request #1161, replayed with the verdict it got wrong. The notes in the margins are the analyzer's state at each line; the next two sections explain them, and the one after says what a machine-checked proof rules out.

In C, integer division truncates toward zero, so a remainder takes the sign of its dividend. For a negative odd c the result of c % 2 is −1. Goblint's congruence domain describes c as 1+2ℤ, the set of all odd integers, and it evaluated c % 2 to the constant 1 for every such c, regardless of sign. In the program below, c is −5 or −7, so Goblint reported that c % 2 == 1 succeeds, although every run violates it. The program involves no wrap-around, only signed arithmetic, so it means the same thing in VIMP. The dot follows one real run, with top ≠ 0.

Goblint, before the fix

main() {
  int top;
  int c = -5;c = −5
  if (top) {
    c = -7;−5 ⊔ −7 = 1+2ℤ
  }
  ↳ 1+2ℤ % 2 = 1  ✗ every run gives −1
  __goblint_check(c % 2 == 1);✗ will succeed
  __goblint_check(c % 2 == -1);not documented
}

The first verdict is wrong. The check fails in every run. Issue #1156 does not record Goblint's verdict on the second check.

Voblint, congruence only

fun main() {
  top = __voblint_nondet_int();
  c = -5;c = −5
  if (top) {
    c = -7;−5 ⊔ −7 = 1+2ℤ
  }
  ↳ 1+2ℤ % 2 = 1+2ℤ  ✓ holds −1 and 1 both
  __voblint_check(c % 2 == 1);? UNKNOWN
  __voblint_check(c % 2 == -1);? UNKNOWN
}

Neither check is decided, and neither verdict is wrong. With the same domain and the same program, Voblint cannot produce the answers shown on the left.

  1. c = −5
  2. top ≠ 0, so c = −7
  3. c % 2 = −1
  4. c % 2 == 1 is false
  5. c % 2 == -1 is true
The left pane shows Goblint before that pull request, on the regression test that pull request added, 37-congruence/14-negative.c, with that test's flags --enable ana.int.congruence --set sem.int.signed_overflow assume_none. The fix returns the constant result of % only when the remainder is zero, when the dividend is already a single value, or when the type is unsigned. A signed c that is only known to be odd now yields the class 1+2ℤ. Both analyzers run the congruence domain and arrive at this class. The difference is that Voblint's operation is proved sound (congruence_mod_sound), so the wrong answers on the left are not available to it. Neither analyzer decides the checks with congruences alone, and Goblint's test records the missing information. It marks the second check TODO (Track information that c is negative). The missing information is the sign of c; machine integers play no role. Int supplies the sign and decides both checks. Run the congruence side ↗ Goblint's soundness issues ↗

The rest of the page builds toward one argument. At every program point, the stores real runs can have there lie inside the set of stores the analyzer's answer stands for. The proof chain states this as a chain of set inclusions, from source executions down to the verdicts, and each section before it explains one piece the chain needs.

Foundations

Abstract interpretation

A test runs a program on a few inputs and watches what happens. It says nothing about the inputs nobody tried. Abstract interpretation computes with descriptions of values instead, such as “an integer between 0 and 5”. At every program point it keeps a description large enough to include every value a real run could have there, and it repeats the computation until the descriptions stop changing. If every value a description allows satisfies a check, then every run does.

Testing bad states input nobody tried: ? time → Four tests pass. Runs you did not try stay unknown. Abstract interpretation bad states analysis result never touches the bad states time → One region contains every run, tried or not.
The shaded region is what the analysis reports. It may contain values no real run has, which costs precision. It may never leave out a value a real run has.

Concrete executions and interval states

The figure applies this to a real program. It shows part of the program's control-flow graph, starting at the first branch. It has one node per program point and one edge per step. Each run appears as a dot at every node it reaches, placed at the value x has there. The abstract view replaces the dots with the one interval the analysis keeps per node. Soundness means that no interval leaves out a dot.

The intervals are not exact, though. The runs only ever reach multiples of three, and an interval cannot express that, so every band also covers integers no run has. On the left this makes a node look reachable although no run reaches it. On the right the guard rules out every value in the interval, and the analysis marks the check DEAD.

Each check gets one of four verdicts, and each states what the analysis established. PROVED: the check holds in every run that reaches it. REFUTED: it fails in every run that reaches it. DEAD: no run reaches it. UNKNOWN: the intervals are too coarse to decide. A check that no run reaches can still come out UNKNOWN, as pp4 does here, because DEAD means the analysis proved the point unreachable, not merely that it happens to be.

fun main() {
  n = __voblint_nondet_int();
  x = 3 * n;

  if (x > 0) {
    if (x < 3) {
      __voblint_check(x == 1);
    }
  } else {
    if (x > 5) {
      __voblint_check(x == 6);
    }
  }
}
[x > 0] ![x > 0] [x < 3] [x > 5] check(x == 1) check(x == 6) ![x < 3] ![x > 5]
  • a run's value
  • inside the interval, reached by no run

No run ever reaches pp4, but the interval at pp3, [1, +∞], admits 1 and 2, so the guard x < 3 leaves [1, 2] and the check there is UNKNOWN instead of DEAD. At pp7 the guard x > 5 contradicts [−∞, 0] outright. The value is ⊥ and the check is DEAD, which means no run gets there. Int, whose congruence component tracks x ≡ 0 (mod 3), marks both checks DEAD.

The illustration uses nine runs with x from −12 to 12 to represent the set of executions; every unbounded interval continues past the drawn range. The intervals and verdicts are the interval analyzer's result for this program (no context); with --analysis int both checks are DEAD. The collecting semantics node_collect is the set of stores a node could ever receive; each dot is one of them, read at x. Run it in the playground ↗

Abstract domains

Abstract values and concretization

Every abstract value names a set of concrete integers, its concretization γ. [−2, 5] names eight of them, even names infinitely many, ⊤ names every integer. Soundness relates these sets. The set the analyzer gives a program point must contain every value a real run has there.

The analyzer computes with these abstract values instead of with single integers; it never handles the sets themselves, only their descriptions. Where one run holds x = 7 and executes x = x + 1, the analyzer holds the interval [0, 9] for x and computes [1, 10]. This single step of interval arithmetic covers every run in which x lies between 0 and 9.

Computing with descriptions costs precision. A domain can name only some sets, so the answer can be wider than the exact set of values. That exact set may not be nameable at all, and even when it is, the analyzer may not find it. A different domain would not avoid this. For general programs, many semantic questions we would like to decide exactly are undecidable, so a sound analyzer that always terminates has to lose information somewhere. The rest of this page asks whether a wide answer still settles the questions you care about.

  • in the set
  • not in the set

Pick a value and see which integers it stands for. Only −12 to 12 are drawn; a set marked with … continues the same way beyond them. The domains divide the same integers in different ways. even and 1 (mod 3) name sets no interval can name, and no single one of them names {1, 7}, which is why the Int-product preset combines these domains to pin it down.

Numeric domains

⊤ ≥0 ≤0 <0 0 >0 ⊥

Sign

The domain contains seven values, from “nothing” to “anything”. Joining 0 and >0 gives ≥0.

−∞ +∞ [−2, 5]

Interval

A lower and an upper bound, either of which may be infinite. Among the four base domains, the only one that needs real widening; Int inherits it through this component.

⊤ even odd ⊥

Parity

Even, odd, or either. Joining even and odd loses everything.

0 x ≡ 2 (mod 3)

Congruence

A remainder c modulo m. Modulus 0 means exactly the value c.

sign>0 interval[1, 9] parityodd congruence1 mod 4

Int

The product of all four domains. The components sharpen one another after every arithmetic operation and every guard, so together they can decide a check that none of them decides alone.

Reduction in the Int product

An Int value describes the integers that all four of its components allow. Each component on its own can miss what another one knows, so the product lets them sharpen one another. A sharpening step never changes the set of integers the value describes. It only finds a smaller description of the same set, and a smaller description can decide a check that the larger one could not. Even a component that starts out knowing nothing can contribute, once the others have told it something.

Program

fun main() {
  n = __voblint_nondet_int();
  x = 4 * n + 1;
  if (x >= -2) {
    if (x <= 10) {
      __voblint_check(x >= 1 && x <= 9);
    }
  }
}

What each component says about x at the check

sign
interval
parity
congruence
allowed by all four{1, 5, 9}
  • x >= 1 && x <= 9 UNKNOWN

The bottom row never moves. Reduction leaves the set of integers alone (refine_exact) and only shrinks the description (refine_reductive), so a reduced value is sound whenever the unreduced one was. One round is refine_round, and Int repeats rounds after every arithmetic operation and every guard until nothing changes. Here that takes two rounds; a third finds nothing new. The thesis argues that a chain of refinements is at most two rounds long, but no bound is proved: refine_fix iterates until the tuple stops changing, and in the logic returns the value it started from if that never happens; the generated code would keep iterating. Compare the domains in the playground ↗
Remainder bounds for negative operands

Remainder bounds for negative operands

Take an unconstrained n, set x = 6n + 5, and ask for x % 6. You would expect 5, but Voblint reports the interval [−1, 5]. Negative operands explain the lower bound.

fun main() {
  n = __voblint_nondet_int();interval: [−∞,+∞]
  x = 6 * n + 5;interval: [−∞,+∞] · congruence: 5+6ℤ
  a = x % 6;interval: [−1,5] · congruence: 5+6ℤ · together: {−1, 5}
  __voblint_check(a >= 0);? UNKNOWN
  if (a < 0) {a narrows to −1
    __voblint_check(a == -1);✓ PROVED
  } else {a narrows to 5
    __voblint_check(a == 5);✓ PROVED
  }
}
In C, a remainder takes the sign of its dividend, so n = −1 gives x = −1 and x % 6 = −1. That run is real, and a bound without −1 would be unsound; this is why a >= 0 comes back UNKNOWN. The bound also comes from reduction; no single component reaches it. Interval alone stops at [−5, 5], because it never learns x ≡ 5 (mod 6). Congruence keeps that class but knows nothing about magnitude, so the sign guard cannot refine it. Under interval, congruence, parity or sign alone, both arms come back UNKNOWN. Together, the class and the bounds leave {−1, 5}, which the guard splits into one value per arm. Run it in the playground ↗

Cooperating analyses and queries

Int combines four numeric domains inside one value. Separate analyses can also exchange information while the fixpoint is being computed. In the first program below, both guards hold at the assignment, so x = y and z is 1. Interval keeps one range per variable, and after the guards both x and y are still unbounded. The order analysis keeps a set of pairs, each meaning that one variable is at most another. It knows x = y, but it tracks no value for z. Neither can prove the check alone. Run together, Interval's assignment asks all active analyses what x == y evaluates to, and the order analysis answers 1.

Program

fun main() {
  x = __voblint_nondet_int();
  y = __voblint_nondet_int();
  if (x <= y) {
    if (y <= x) {
      z = (x == y);
      __voblint_check(z == 1);
    }
  }
}

State at the check, per analysis

intervalz=[1,1]
order{x≤y ∧ y≤x}
  1. At z = (x == y), Interval queries the value of x == y.
  2. Interval itself answers [0,1]; the order analysis holds both pairs and answers 1.
  3. The answers are met: [0,1] ⊓ 1 = 1, so Interval assigns z = [1,1].

PROVED z == 1

The states and verdicts are analyzer output for both programs under each selection of analyses; the numbered steps explain how the combined run gets there. In the first program Interval asks and the order analysis answers. In the second the direction reverses. At each assignment, the order analysis asks how the new value compares with the other variables, and Interval answers. Both runs are also checked in Isabelle, by evaluating run_voblint with eval on variants of these programs (coop_demo_needs_both, order_asks_needs_both). Run the first program ↗ Run the second ↗
Framework representation

Each analysis supplies a local_spec: its transfers, which may ask questions, and a handler that answers questions from its own state. An answer to “what does e evaluate to?” is sound at a store if the value of e there lies in the answer. The answer interface provides laws for combining responses. The top answer holds everywhere, so an analysis that cannot answer says ⊤ and claims nothing. Answers that both hold can be met, so a question asked of all analyses gets the meet of their answers (mcp_answer does the same for checks).

The questions travel through a channel built from the state before the edge (ls_channel). A question asked again while it is being answered gets ⊤, as in Goblint's MCP.query', and a depth bound (query_depth) stops the recursion. The generated program aborts there, while in the logic the aborted branch answers ⊤. No analysis has to be ordered before another. ls_channel_sound proves the channel's answers hold.

An analysis proves its own laws once, as sound_local_spec, and the laws mention no other analysis. dg_spec_of turns a local specification into the record the equations consume, and dg_spec_of_contract carries the laws to the analysis contract. mcp_comp_sound gives that contract for every nonempty selection of distinct analyses, so the rest of the pipeline treats a combination like a single analysis.

Trace semantics

Activation traces

To say that an answer covers every run, we first need a precise object that a run is. The soundness theorems on this page are statements about traces. A trace is the record a single run leaves behind. It contains the visited nodes and the store at each node. A trace records one concrete run. It approximates nothing and quantifies over nothing.

If you have stepped through a program in a debugger, you have watched one being made. Every press of step moves control one node on and leaves the store at whatever the last statement made of it; the line the debugger highlights and the variables panel beside it are one (node, store) pair. A trace is that whole sequence, recorded instead of watched. The one panel a plain sequence throws away is the call stack, and the proofs need that panel.

For a program that is one long sequence of statements, a list of (node, store) pairs records the execution, and the run below would be exactly that list. Procedures make it insufficient.

The program

fun dec(x) {
  return x - 1;
}

fun sum(n) {
  if (n < 1) {
    return 0;
  }
  m = dec(n);
  r = sum(m);
  return r + n;
}

fun main() {
  x = sum(2);
  __voblint_check(x == 3);
}

What the compiler makes of it

main sum dec body cont check return call sum(2) resume body n < 1 ¬ n < 1 return 0 cont return r + n call dec(n) resume call sum(m) resume body return x - 1 entry_main pp9 pp10 pp11 exit_main entry_sum pp2 pp3 pp5 pp6 pp7 exit_sum entry_dec pp0 exit_dec
Hover a source line to see the nodes it compiled to. A node is a point between two statements, and the names are the playground's: ppN is Statement N, and entry_sum and exit_sum are FunctionEntry sum and FunctionResult sum. The labels identify the traces below are written in. Notice that each procedure appears once, whatever the run does. It contains one entry_sum, one exit_sum, one pp5.

sum(2) calls dec once and then calls itself, and the second activation does the same, so the run visits three activations of sum and two of dec. These activations follow the same single copy of the graph. Written as one flat list, the run therefore contains the same node again and again, and nothing in the list says which activation each occurrence belonged to.

The same run as one flat list

entry_main pp9 entry_sum pp2 pp5 entry_dec pp0 exit_dec pp6 entry_sum pp2 pp5 entry_dec pp0 exit_dec pp6 entry_sum pp2 pp3 exit_sum pp7 exit_sum pp7 exit_sum pp10 pp11 exit_main

Twenty-seven steps in the order they happened, coloured by the activation each belongs to. entry_sum occurs three times and entry_dec twice. The list itself does not record which occurrence belongs to which activation; only the colouring added for this figure does.

The list has every step, in the right order. A proof about one activation would first have to recover, occurrence by occurrence, which entries of this list belong to it.

Trace structure and validity

Voblint uses the second shape below. An activation trace is one activation's view of the run. It holds one path, the activation's own, in execution order, and no step of any other activation appears on it. The activations around it are kept beside the path, and they are activation traces themselves. They include the caller that started this activation, stopped at its call node, and one trace for each callee that has already returned into it. The definition is recursive, just as calls are.

The shape is borrowed from the local traces of Schwarz et al. Those belong to a semantics of multithreaded programs, where the operations of different threads are only partially ordered. A sequential run has no such concurrency, so Voblint does not reuse the name.

The same run, nested by activation

main

entry_main pp9 pp10 pp11 exit_main

sum(2)

entry_sum pp2 pp5 pp6 pp7 exit_sum

dec(2)

entry_dec pp0 exit_dec

sum(1)

entry_sum pp2 pp5 pp6 pp7 exit_sum

dec(1)

entry_dec pp0 exit_dec

sum(0)

entry_sum pp2 pp3 exit_sum

The same twenty-seven steps, each on the path of the activation that took it. Now the three entry_sums are in three different boxes, a returned callee stays where it was called from, and every question the proofs ask about one activation is answered by one box. A coloured bar in a path marks where that activation handed control down. It carries the colour of the box below that ran next, and no steps of that box appear on this path.

The steps are those of the list above, in the same order. The nested form stores the grouping into activations in the value itself, where the flat list leaves it for the reader to reconstruct.

The debugger has a name for that cut. Press step into at every call and you get the flat list. The callee's lines scroll past in the same stream as your own. Press step over instead and you get one activation's path, exactly as an activation trace records it. The callee still runs and its effect is still in the store when you land, but none of its steps appear on your line. The coloured bars in the trace diagram are the points where you pressed step over, and the box beneath each bar is the run you stepped over, which the trace keeps.

Trace interface

Trace observers

A soundness statement uses specific properties of a run: which node it has reached and what memory holds there, the store it started with, and who called it. The trace datatype has six observers for these properties. Each has a short definition, and the soundness statements access a run only through these observers. The most used pair is sink_node and sink_store, the node and the store at the end of an activation's path.

Isabelle datatype and trace observers

Start from the type. It is three lines long, and once activation_path is named, it reads like the description of the trace. An activation trace is one path plus the activations around it.

type_synonym activation_path = "(cfg_node × store) list"

datatype activation_trace =
    Root                                                                   activation_path
  | Call   (activation_trace_caller: activation_trace)                     activation_path
  | Resume (activation_trace_current: activation_trace)
           (activation_trace_callee: activation_trace)                     activation_path

There is one constructor per situation an activation can be in. Root is main, which nobody called, so it carries a path and nothing else. Call is an activation that is running and will return to its caller, activation_trace_caller, which it holds. Resume is the same activation after a callee has returned. activation_trace_current is the activation stopped at its call node, and activation_trace_callee is the finished callee. The callee is kept whole instead of being spliced into the path, so a returned call remains one box you can ask questions about.

Observers, at

The same run, nested by activation pick one for t

main

entry_main pp9 pp10 pp11 exit_main

sum(2)

entry_sum pp2 pp5 pp6 pp7 exit_sum

dec(2)

entry_dec pp0 exit_dec

sum(1)

entry_sum pp2 pp5 pp6 pp7 exit_sum

dec(1)

entry_dec pp0 exit_dec

sum(0)

entry_sum pp2 pp3 exit_sum

Hover an observer to see what it reads off t, and click to keep it; the answer carries that observer's defining equation and links into the theory. Click an activation on the right to select it as t. The trace for main has no caller because caller_of descends through its Resume to a Root and returns None. An activation nothing has returned into yet is a Call with no finished callee to read.

The nesting simplifies the question every soundness proof has to ask: “Which runs can be standing at node v, and what does memory hold there?” With activation traces, the answer is the end of one path. With the flat list, you would have to scan an interleaved sequence and decide, for every occurrence of v, whether it belongs to the activation you are reasoning about. The analysis answers per program point, and per point and context once a policy keeps copies of a procedure apart. Activation traces are shaped so that this per-point question can be obtained directly instead of being reconstructed.

Grouping by activation instead of by procedure has one more consequence. In this run, sum is one procedure with three activations, and the trace keeps all three apart because each is its own box. The analysis cannot keep every activation apart. It has to decide in advance how many copies of sum to reason about separately, and a context is the result of that decision. Because the concrete side distinguishes every activation, the proofs can check that whatever the abstract side merges is still covered.

Traces in the soundness proof

It could not usefully do so. Every trace is finite, but nothing bounds its length or how deeply it nests. A recursive call creates one activation per level. When the program reads an unconstrained input, the set of all traces is infinite.

An activation trace exists only as a value in the logic. It gives soundness something to be a statement about, and the proofs quantify over the set of traces without anything ever enumerating it. In the generated code, none of the 13,109 lines of OCaml exported from these theories mentions activation_trace, valid_activation_trace, sink_store, caller_of or node_collect. The specification is not part of the analyzer.

Operational semantics

Source and graph semantics

A semantics is a rule for the transitions a program can make from one state to another. Writing it down turns “what the program really does” from an appeal to intuition into a set you can quantify over.

Voblint relates two small-step semantics to a third, trace-based description of executions, because the three objects it has to relate have different shapes. A source step (pstep) rewrites a program that is still program text, carrying the store and a stack of caller frames. A CFG step (cstep) moves a position through a control-flow graph, carrying a stack of return points. The third does not take steps. activation_trace is the activation trace from the previous section, and valid_activation_trace is a rule that says which of those values the graph admits. It judges one activation with its whole history at once, instead of moving from one state to the next.

Soundness is stated against the third, because of what the analysis answers. The analysis assigns a value to each program point. Soundness requires that “at node v, this value covers every store a run can hold there”. pstep has no notion of a node, so the claim cannot even be stated against it. cstep records nodes and a call stack. It retains the current store but does not identify the activation whose history determines its context. Activation traces retain that history. A trace ending at v represents an activation at that node and contains its current store.

Source pstep

applicable inductive rule

fun dec(x) {
  return x - 1;
}

fun sum(n) {
  if (n < 1) {
    return 0;
  }
  m = dec(n);
  r = sum(m);
  return r + n;
}

fun main() {
  x = sum(2);
  __voblint_check(x == 3);
}

Rest of the program

CFG cstep

applicable inductive rule

main sum dec body cont check return call sum(2) resume body n < 1 ¬ n < 1 return 0 cont return r + n call dec(n) resume call sum(m) resume body return x - 1 entry_main pp9 pp10 pp11 exit_main entry_sum pp2 pp3 pp5 pp6 pp7 exit_sum entry_dec pp0 exit_dec

Activation trace activation_trace in valid_activation_trace

applicable inductive rule

Hover an activation for what the observers answer about it.

store, the same on every side

    Source frames Frame caller dst

      CFG return points (cont, dst, caller)

        Trace callers caller_of

        The same program and graph as above, now with one run walking them. A transition between two displayed states can group consecutive source steps; its rule box names every rule it crosses. Stores list only the variables the current activation uses; a callee's locals start at 0 except its formals, which take the actuals, and x in main starts at any value. The source rules are pstep, the graph rules cstep, and the activation-trace type activation_trace, whose three constructors are Root, Call and Resume. valid_activation_trace is the predicate that says one of them is a run the graph really admits. caller_of descends the frozen caller a Resume keeps, which is why a returned callee composes the same way whatever constructor it came from. The source rules shown are the ones that do the work; the congruence rules Seq1 and Seq2 that reach into a sequence are left out, and stores omit the result slot ret_var. The boxes on the right show who called whom, and a box holding two finished callees stands for two nested Resumes. In the value itself, a live Call holds its caller, so the nesting runs the other way until the callee returns and a Resume takes it in.

        The semantics differ in the three stacks below the figure. At an open call, the source keeps a Frame: the caller's store and the variable to assign. The graph keeps the same and the node to return to. An activation trace keeps the caller itself, whole, as a field. Soundness must identify a program point and the stores that can occur there, separately for each activation. The table compares the semantics on these requirements. The table adds a fourth candidate: activation_path is a list of pairs, and one such list for the whole run, instead of one per activation, is the flat list from the trace section.

        asked ofpath_of tentry_store tsink_node tcaller_of t
        a source configuration, pstep no history is keptnot in the stateno notion of a CFG node at alla frame, not a run
        a graph configuration, cstep no history is keptnot in the statethe node, directlya frame, not a run
        one flat list of the whole run scan it, matching calls to returnsscan back to its callthe run's last step, not this activation'sscan back for the unmatched call
        an activation trace, activation_trace a fieldthe first pairthe last paira field

        The repeated answers follow from what the stacks keep. A configuration is a snapshot, so there is no path to recover from it. The frames hold the callers' stores, so an activation's own entered store is lost when it assigns a variable. A frame names a return point, not a run. The flat list has all the steps but not the grouping, so every answer requires a scan.

        The last row is the reason the proofs use activation traces. When a call returns, the proof has to say that this callee returned into that caller. With the caller inside the value, the return rule states caller_of callee = Some caller. Against a stack there is no term for the caller's run that a rule could refer to, and this matters for recursion. Contexts later read the same two fields: caller_of for the activation that made the call, and entry_store for the store it was entered with.

        The choice follows a general rule. A soundness claim can only distinguish what the semantics it quantifies over distinguishes. An analyzer that keeps activations apart therefore needs a semantics that keeps them apart, or its theorem cannot say what it covers. The coarser views are projections of the finer semantics. node_collect drops the activation, and activation_collect keeps its context. A coarser base, in contrast, cannot be refined afterwards.

        Node collecting semantics

        The execution diagram follows a single run, but the analyzer never looks at runs. What it produces is a table keyed by program point, with nothing in it about paths, calls or activations (contexts split the keys further, as the section on procedures shows). To compare the two, we bring the concrete side into the same shape. Take every activation trace the graph admits, which is the set valid_activation_trace builds up by starting at the entry node, following an edge, entering a call and returning from one. For each trace, note the node where it ends and the store it ends with.

        The theories write this with a little notation. g is the compiled graph, S the set of stores a run may start in, and 𝒢 the classifier that says which variables are declared globals. 𝒯𝒢,g,S is the set of valid activation traces of g starting from S (valid_activation_trace). For a trace t, sink_node t and sink_store t are the node and the store at the end of its path. The stores collected at a node v, 𝒞𝒢,g,S v (node_collect), are then:

        𝒞𝒢,g,S v =
          {sink_store t | t. t ∈ 𝒯𝒢,g,S ∧ sink_node t = v}

        The result is one set per node, holding the stores a real run can have there. Neither of the other two semantics yields this directly. Over pstep there is no node v to index by. Over cstep it can be written, but the only finer index available there is the call stack, not the activation.

        Node collection deliberately discards the grouping by activation. A trace has to belong to one activation for “ends at v” to mean anything; the collection then forgets the activation and puts all stores at v into one set. activation_collect is the variant that keeps a key per activation, and a context decides how much of that key to keep.

        return 0 return r + n pp3 pp7 exit_sum

        A node collects stores across incoming edges and activations. These include the edges that can reach it and the activations that can be on them. exit_sum has two incoming edges and three activations arrive over them, so its bucket holds three stores.

        activations that reached the node

        node_collect there

        what the analyzer will answer

        sum(2){n = 2, m = 1, r = 1}
        sum(1){n = 1, m = 0, r = 0}
        sum(0){n = 0, m = 0, r = 0}

        exit_sum

        {n = 2, m = 1, r = 1}

        {n = 1, m = 0, r = 0}

        {n = 0, m = 0, r = 0}

        n = [-∞, 2]
        m = ⊤
        r = ⊤
        sum(2){n = 2, m = 0, r = 0}
        sum(1){n = 1, m = 0, r = 0}
        sum(0){n = 0, m = 0, r = 0}

        pp2

        {n = 2, m = 0, r = 0}

        {n = 1, m = 0, r = 0}

        {n = 0, m = 0, r = 0}

        n = [-∞, 2]
        m = ⊤
        r = ⊤
        dec(2){x = 2}
        dec(1){x = 1}

        pp0

        {x = 2}

        {x = 1}

        x = [-∞, 2]
        main{x = 3}

        pp10

        {x = 3}

        x = [0, +∞]
        The diagram shows four nodes from the same run and retains the activation colours. The middle column is node_collect at that node for this one program, on the variables shown. It contains the stores a real run holds there, without activation identities. The right column is what voblint --analysis interval --context none will answer at those nodes once the next sections have built it. Every answer is wider than the set beside it, and none of them misses a store. Soundness is this containment at every node.

        Soundness asks only for containment. The answer at v has to cover the set beside it, and may cover much more.

        Here it covers much more. Runs only ever reach pp2 with n equal to 2, 1 or 0, but the answer is [-∞, 2]. The loss of precision has two causes. First, the three activations of sum were merged into a single entry before anything was computed. Second, the lower bound of that merged entry kept moving down, so the solver stopped following it instead of iterating forever. As a result, the check at pp10 comes back UNKNOWN, on a program in which every run makes it true. The rest of the page is about the computation of sound answers and the cost of improving their precision.

        Equation construction

        Analysis equations and solving

        The compiler turns a program into a control-flow graph, and the analysis turns that graph into equations. In this single-procedure example, there is one unknown per node and one term per incoming edge. Each term applies the edge's action to its source value. With procedures, unknowns will be keyed by node and context. Starting from the requested result, the solver computes values for the unknowns that result depends on. To the rest of the pipeline the solver has an abstract interface. It accepts equations and a query, then returns an abstract value for each unknown it reached.

        Compilation to control-flow graphs

        The compiler reads a procedure one statement at a time and turns each statement into a numbered node with outgoing edges. Each statement is compiled with the number of the statement that follows it, so its target is known before it is compiled. The figure replays this on the counting loop, with the node numbers and edge labels the real compiler produces.

        Source

        fun main() {
          i = 0;
          while (i < 5) {
            i = i + 1;
          }
          __voblint_check(i == 5);
        }
        Show the Isabelle clause that fires

        compile_proc

        compile_proc Π p decl n =
              (let r = n + csize (body decl); …
               in … insert (FunctionEntry p, EA_Body p, ben)
                      (if falls_through (body decl)
                       then insert (Statement r, EA_Ret None p, FunctionResult p) E
                       else E) …)

        Compiled graph

        body(main) i := 0 [i < 5] ![i < 5] i := i + 1 check(i == 5) return entry_main pp0Statement 0 pp1Statement 1 pp2Statement 2 pp3Statement 3 pp4Statement 4 exit_main

        Every statement has become a node, and every node's outgoing edges point where the source would go next.

        Every source execution has a matching route through this graph: csim_star

        Node names and edge labels are the playground's for this program (ppN is Statement N); the clauses are compile and compile_proc, shortened. Dashed nodes are numbers the compiler has already handed out as a continuation but not yet compiled. The Seq clause that strings the statements together adds no node of its own. See this graph in the playground ↗

        The graph

        i := 0 i < 5 i := i + 1 i ≥ 5 check i == 5 E H B X

        The equations it becomes

        E = ⊤the entry: any store

        H = [i := 0] E ⊔ [i := i + 1] Btwo incoming edges, joined

        B = assume(i < 5) Hthe guard keeps only stores where it holds

        X = assume(i ≥ 5) Hthe loop exit

        Hover a term or an edge to see which belongs to which.

        Four of the system's unknowns. The real system has one unknown per node, seven here, plus a seed key at the procedure entry; E stands in for the entry, where the initial store comes from. Nothing is solved yet. The solver receives these equations. The solver itself starts at the other end. In this sketch that is X, the node after the loop; the real query is the procedure's exit, one edge further on. From there it asks for the values it needs.

        Inside the solver, a right-hand side is a strategy tree. This program reads unknowns one at a time, may publish values to global unknowns, and returns an answer. Each read passes its value to the remaining tree. The solver can therefore evaluate an equation incrementally and follow the dependencies needed by the current query. The details below show the tree type and how the generator builds one tree per node.

        Equation representation with strategy trees

        Strategy trees for equation evaluation

        At this level, the solver does not interpret ⊔ or assume. Each right-hand side is represented as a strategy tree, which specifies the evaluation steps. QueryL reads a local unknown. QueryG reads a global one. Side contributes a value to a global. Answer returns the result. A query does not name its next step directly. It holds a function from the value it reads to the rest of the tree, so the solver learns which unknowns an equation depends on by evaluating it. Trees and the systems built from them are the vendored solver's input language. An equation system is one function from an unknown to its tree.

        datatype ('x, 'g, 'd) strategy_tree =
            Answer 'd
          | QueryL 'x ('d ⇒ ('x, 'g, 'd) strategy_tree)
          | QueryG 'g ('d ⇒ ('x, 'g, 'd) strategy_tree)
          | Side 'g 'd (('x, 'g, 'd) strategy_tree)
        
        type_synonym ('x, 'g, 'd) eqsT = 'x ⇒ ('x, 'g, 'd) strategy_tree

        Building such trees directly is awkward. A query holds the rest of the tree as a function. Each generated read must occur inside the preceding continuation, adding another level of nesting. Voblint therefore generates each right-hand side as a strategy program. This function accepts the continuation that consumes its result and returns a tree. Programs compose by passing the second to the first as its continuation. The do-notation expresses this composition with reads listed in execution order. sp_compile then closes the last continuation with Answer, which yields a tree.

        The notation distinguishes the representations. A program uses do-notation; a tree uses its constructors. Everything the generator builds is a program, and the names say so (dg_spec_edge_program, fold_rhs_program); sp_compile is the one step that turns a program into a tree.

        type_synonym ('x, 'g, 'd, 'a) strategy_program =
          ('a ⇒ ('x, 'g, 'd) strategy_tree) ⇒ ('x, 'g, 'd) strategy_tree

        Only the dashed stage is Voblint's own, and it is finished before the solver runs. The solver receives an ordinary tree, so the vendored correctness proof applies unchanged.

        The generator works one edge at a time, then one node at a time. dg_spec_edge_program turns a single CFG edge into a small program. It reads the unknown at the edge's source, applies the edge's transfer, and returns an answer. A node with multiple incoming edges gets one such program per edge, and fold_rhs_program folds that list into one program that joins their answers; this is where the ⊔ of a join node goes. Compiling the program gives the node's tree.

        Writes to globals are buffered (routed_node_rhs_buffered). The buffer collects the writes per key and emits each key once per flush, as a single Side with the collected contribution. At a node into which at most one call returns, the buffer flushes before the next local read. The call therefore publishes its seed before querying the callee's result. At a node where multiple calls return, it emits at the end, after the last read. A write of bot is dropped (flush_sides): joining bot into a global changes nothing, but publishing it would still trigger an update and destabilize that global's readers. Doing this for every node builds the equation system eqsT, one tree per unknown. The equation-construction diagram steps through it for the loop head.

        The graph

        i := 0 i < 5 i := i + 1 i ≥ 5 E H B X

        Hover a node to see its equation built.

        Equation construction

        Every node is built the same way, whether it sits on a straight edge, a join, or a call. The generator makes one program per contribution, folds them into one, and compiles that. The contributions are a compiled edge for an intra edge, a whole call for a call site, and a seed read at a procedure entry.

        Compiling every node of the counting loop, not only the four shown, gives its eqsT, and this one function is the entire equation system the solver receives. Every equation is now a chain of reads ending in an answer. The ⊔ and the assume shown in the equations above are no longer visible at the top level. They sit inside the answer, as arithmetic on a domain the solver knows nothing about.

        Solver input

        eqs E H B X

        The TD solver is demand-driven. It is asked for one unknown and evaluates that unknown's tree, and every QueryL on the way asks for another unknown, recursively. It computes exactly what the first question turns out to need. Voblint always queries the exit of the entry procedure under the root context.

        root_query p = (cfg_exit (prog_cfg p), root_ctx)

        A terminating run ends at that point, so asking for it pulls in, one query at a time, every point its value depends on. The vendored solver therefore promises a partial post-solution with three properties. It covers the question. The covered set is closed under the dependencies the trees take. And for every covered unknown, the tree's answer is below σ's value there, with its contributions below σ at the globals they write. Read as a pair, the answer together with the partial map of its contributions, that last property is the usual condition on a side-effecting right-hand side (part_post_solution_iff_rhs_pair). The middle property speaks only of direct dependencies; part_post_solution_implies_trans_dep_subsumed extends it to transitive dependencies, so everything transitively reachable from the question lies inside the covered set.

        Every node a run can reach has a path in the graph to its procedure's exit, so the solver's backward queries reach it (live_unknowns_cover). A key outside the solved set is still defined to read as bot:

        solved_local_reader vars σ k =
          (case k of Inl vc ⇒ (if vc ∈ vars then locals (σ (Inl vc)) else bot)
                   | Inr _ ⇒ bot)

        Leaving the key unsolved is sound because no run reaches it, and the empty set of stores covers every run that does (solved_local_reader_uncovered). Code inside a reached procedure that the analysis proves unreachable is different. It is solved to bot, yielding a DEAD verdict.

        Solver results

        The steps so far built the system and computed no value. Handing the finished system to the solver is a single call, and what comes back is one abstract value per unknown the solver reached.

        eqsTthe counting loop's four TD solververified in Isabelle
        E⊤
        H[0,5]
        B[0,4]
        X[5,5]

        What the box guarantees

        The values form a post-solution: each one is at least what its equation computes from the others. The displayed equations let you check the post-solution condition.

        • H = [0,5] ⊒ [i := 0] ⊤ ⊔ [i := i + 1] [0,4] = [0,0] ⊔ [1,5] = [0,5] ✓
        • B = [0,4] ⊒ assume(i < 5) [0,5] = [0,4] ✓
        • X = [5,5] ⊒ assume(i ≥ 5) [0,5] = [5,5] ✓, so the check i == 5 is proved.
        The counting loop's four unknowns, solved.
        Widening and solver steps

        Widening for loops

        A loop can run any number of times, so following it pass by pass may never end. The analysis instead widens by replacing a moving bound with infinity. This replacement is sound and stops the ascending interval sequence. The analysis then narrows, using the loop's own guard to pull the bound back. Warrowing combines these operations. It widens while a value grows and narrows once it shrinks.

        Nothing in the equations marks a loop head. The solver discovers a widening point when it encounters an unknown already being computed. It warrows local unknowns at these points under every rule for updating globals. A domain takes part through executable_domain, which includes the solver's warrowing class. Step through it below.

        Enter the loop: i ∈ [0, 0]

        After one pass through the body, the head's equation yields [0, 1] (dashed); the stored value is still [0, 0]

        Plain iteration would continue: [0, 2], [0, 3], and so on. The solver does not take this step.

        Widen: the upper bound moved, so it jumps to +∞

        Narrow: the body only runs when i < 5, so the head is [0, 5]

        Leave the loop, where i ≥ 5: i ∈ [5, 5]

        i == 5 holds for every run: PROVED

        0 1 2 3 4 5 6 7 +∞
        A sketch of an interval analysis on a counting loop: the value of i at the loop head, and after the loop in the last two steps. In every configuration the solver applies warrowing at the widening point it discovers at this loop head, so it jumps from [0,0] straight to [0,+∞] when the equation yields [0,1]. The dashed [0,1] bar shows the equation's result before storage. The dashed [0,2] frame shows plain iteration; autoplay skips this frame. The solver's own trace is in the black box below. Run it in the playground ↗

        Program graph

        i := 0 i < 5 i := i + 1 i ≥ 5 check i == 5 E H B X

        Equations

        E = ⊤ entry: any store

        H = [i := 0] E ⊔ [i := i + 1] B loop head

        B = assume(i < 5) H loop body

        X = assume(i ≥ 5) H after the loop

        Values σ

        E
        H
        B
        X

        Being computed c

        Stable

        Widening points

        The trace follows the definitions of query and iterate in the vendored solver, simplified to one variable and without side effects. The round that discovers a widening point still updates plainly, and an unknown that is already being computed is marked a widening point and answered from its current value rather than recomputed. Run the loop ↗

        Replaying the real solver. The playground can record the generated analyzer's solver trace and replay it on the graph. The replay shows queries and answers, stored values, and side effects into seeds. It also shows updates, destabilized readers, and the context selected for each call. The trace is an unverified observation of the verified computation, not part of any theorem. The section on watching the solver says how it is recorded.

        Procedures

        Context-sensitive procedure analysis

        A procedure can be analysed once for all its callers, or separately for different calling situations, and that choice trades precision for cost. Everything so far had one procedure, and a call breaks two assumptions at once. The callee's entry state now arrives from somewhere else in the equation system, and multiple call sites can reach the same copy of the callee. The solver's side effects handle both, and two settings tune them. The first decides how many separate copies of a procedure the analysis keeps apart. The second decides how the values that different call sites send into one copy are combined. This section covers the first; the second follows once the system those copies live in is clear. Both settings affect precision and whether a run finishes, while preserving soundness.

        Side effects on equation unknowns

        Every right-hand side so far only read. It queried other unknowns and answered with a value for its own, which is why the walkthrough above could be drawn as one variable changing. The solver allows one more operation. A right-hand side can publish a value into an unknown it does not own. The solver joins the value in, records who published it, and re-evaluates every right-hand side that had read that unknown. In a strategy tree this is a Side node, the part the walkthrough left out.

        Voblint publishes for exactly two reasons. A flow-insensitive program global has its own unknown. Only assigning edges publish to it, and only points mentioning the variable read it. Calls also publish entry states. A caller does not write the callee's entry point directly. It publishes the entry state under a key (Activation_Seed), which the callee's entry reads back. The vendored solver can only side-effect global unknowns, so this key is a global stand-in for the local unknown that Goblint writes with sidel. The alignment register records this deliberate difference from Goblint.

        Both settings act on this one publication. Contexts decide which key a call publishes under, and therefore how many copies of a procedure exist. Globals decides how multiple publications to the same key are merged.

        Call equations and the enter/combine protocol

        Call behavior is determined by the analysis and context policy. enter builds the callee's entry state from the caller's state. A context policy picks which copy of the callee receives that state. combine merges the callee's exit state back into the caller. enter and combine come from the analysis; the policy is a run setting. The framework wires all three into equations in the same way for every domain, and each connection carries its own soundness obligation.

        This wiring stays within one procedure. Each node gets one equation, built from that node's incoming edges, and a call site is a node whose contribution publishes and queries. Generating the equations never looks into the callee, which is why a recursive call needs no special case. The generator does not follow the recursion; the solver determines how far it proceeds. A real run does go into f, and the step-through of a run follows one through a program with calls.

        One equation per node

        fun main() {  a = f(2);  __voblint_check(a == 2);}
        main f never opened Seed entry_f exit_f entry_main pp5 pp6 pp7 exit_main

        The generated equations

        At main's entry, the sole contribution is a read of the published seed. Nothing calls main, so the seed stays bot and the answer is cinit.

        An ordinary edge: one predecessor, one transfer, one query.

        The call-site equation does not look into f. It publishes the entry state under f's seed key and then queries f's exit; both are ordinary unknowns. All it needs from f is its name, which static_resolve reads off the graph's calls relation.

        An ordinary edge again. Whatever a holds here came out of combine, with no record of how f computed it.

        The return edge, into main's last node.

        Five equations, and none of them looks into f. f's own six equations are built the same way from its own nodes, and only the solver connects the two systems. A recursive call needs nothing special: f has a call site too, which publishes under a key of the same kind.

        eqs entry_main =
          QG (Seed entry_main) (λs.
            Answer (cinit ⊔ locals s))
        eqs pp5 =
          QL entry_main (λs.
            Answer (body(main) s))
        eqs pp6 =
          QL pp5 (λcaller.
            Side (Seed entry_f) (enter f(2) caller)
              (QL exit_f (λcallee.
                Answer (combine caller callee))))
        eqs pp7 =
          QL pp6 (λs.
            Answer (check(a == 2) s))
        eqs exit_main =
          QL pp7 (λs.
            Answer (return s))
        The diagram shows equation generation. Each node's right-hand side is built from its own incoming contributions (routed_node_rhs), independently of the other nodes, so the figure shows all five at once; select a node to see its equation. A call site contributes one program that publishes and then reads (routed_call_program). The solver later starts from the other end, at exit_main, as the solve replay shows. enter and combine belong to the analysis, as fields of dg_spec; as in Goblint, combining is split into two fields, combine_env and then combine_assign. Goblint writes the two connections with sidel and a query of the callee's exit. The keys above are written without their context, which the next subsection adds. Run this program ↗

        Calling-context policies

        The equations above simplified one thing. Their unknowns were bare program points, so any two calls to f would land in the same copy of it, and the entry states they produce would be merged. An analysis can be set up to keep those call sites apart. A context is the key a procedure is analyzed under. Call sites that produce the same key share one copy of f, and call sites that produce different keys each get their own. This setting picks how that key is computed, using one of the available policies.

        Seed (entry_f, c₁) (exit_f, c₁) Seed (entry_f, c₂) (exit_f, c₂) Seed (entry_g, c₃) (exit_g, c₃) Seed (entry_g, c₄) (exit_g, c₄) Seed (entry_g, c₅) (exit_g, c₅) context c context c₁ context c₂ context c₃ context c₄ context c₅ f f g g g entry_main pp6 pp8 exit_main
        Every box is a context, and every unknown a call touches is a pair of a node and the context it belongs to. main has one context, because nothing calls it. Both call sites in main publish the entry of a copy of f and query that copy's exit, and each copy of f reaches g in the same way. The first copy calls g from two sites of its own, so it alone accounts for c₃ and c₄. Copies therefore multiply down the call tree, and one procedure can have multiple contexts. Which call sites end up sharing a key depends on the strategy, which the next subsection compares.

        c is main's own context. c₁ and c₂ are the keys the strategy returned at the two call sites, and each appears in exactly two places: the key the call publishes under, and the exit it queries. The rest of the equation stays as before.

        Precision of context policies

        The available context policies differ only in the key they use. The call string also takes a depth, so the context comparison shows four settings. It concerns a small program in which wrap passes its argument straight to scale, which doubles it, so a is 2 and b is 8.

        Whether the analysis can establish a == 2 depends on whether the two calls are still kept apart when they reach scale. If they meet there, scale receives one value covering both arguments and returns one result covering both, so the check is UNKNOWN. The strategy alone decides this. Each mode's Try link opens the playground with that setting.

        fun scale(v) {
          return v * 2;
        }
        
        fun wrap(w) {
          r = scale(w);            // s3
          return r;
        }
        
        fun main() {
          a = wrap(1);             // s1
          b = wrap(4);             // s2
          __voblint_check(a == 2);
        }
        context scale wrap main a == 2 · UNKNOWN · a == 2 · PROVED () (w=1) (w=4) (v=1) (v=4) a == 2 · UNKNOWN [] [s1] [s2] [s3] a == 2 · PROVED [] [s1] [s2] [s3 s1] [s3 s2]
        None
        One context per procedure. Every call shares it, which is the same partition a call string of depth 0 gives. Try ↗
        Entry state
        The key is the abstract value of each formal parameter on entry, so calls with different arguments stay apart. Try ↗
        Call string, k = 1
        The key is the most recent call site. scale has only one, s3, so both calls still meet. Try ↗
        Call string, k = 2
        The key is the two most recent call sites, s3 plus s1 or s2, so the calls separate. Try ↗
        A joined value is at least [1, 4]; a solver may widen it further. More contexts mean more work and can make the result more precise.

        Precision and cost of calling contexts

        Every context is another copy of a procedure to solve. Of the two measurements below, the first counts the nodes of the solved graph and the second counts the contexts themselves. Both grow with the call-string depth until it is long enough to tell every call path apart. The numbers are measured with the analyzer on this site (Interval, Warrow). Additional contexts can improve precision, but the improvement is not monotone.

        Graph size and verdicts by configuration

        Fan-out: main → f6 → … → f0, each level twice

        Solved graph nodes per setting, and whether x <= 22 is proved.

        fun f0(x) { __voblint_check(x <= 22); }
        fun f1(x) { f0(x); f0(x + 1); }
        fun f2(x) { f1(x); f1(x + 2); }
        fun f3(x) { f2(x); f2(x + 3); }
        fun f4(x) { f3(x); f3(x + 4); }
        fun f5(x) { f4(x); f4(x + 5); }
        fun f6(x) { f5(x); f5(x + 6); }
        
        fun main() {
          f6(1);
        }
        600 400 200 0 90 60 30 0 nodes ms 38?NoneNo context: 38 nodes, 8 contexts, 4.7 ms, UNKNOWN: copies merge and widening opens the bound 67?k = 1Call string k = 1: 67 nodes, 14 contexts, 7.3 ms, UNKNOWN: copies merge and widening opens the bound 115?k = 2Call string k = 2: 115 nodes, 24 contexts, 12.7 ms, UNKNOWN: copies merge and widening opens the bound 191?k = 3Call string k = 3: 191 nodes, 40 contexts, 22.1 ms, UNKNOWN: copies merge and widening opens the bound 303?k = 4Call string k = 4: 303 nodes, 64 contexts, 41.1 ms, UNKNOWN: copies merge and widening opens the bound 447?k = 5Call string k = 5: 447 nodes, 96 contexts, 77.5 ms, UNKNOWN: copies merge and widening opens the bound 575✓k = 6Call string k = 6: 575 nodes, 128 contexts, 79.0 ms, PROVED: every value in f0 exact 337✓EntryEntry state: 337 nodes, 72 contexts, 37.1 ms, PROVED: every value in f0 exact None: 4.7 ms k = 1: 7.3 ms k = 2: 12.7 ms k = 3: 22.1 ms k = 4: 41.1 ms k = 5: 77.5 ms k = 6: 79.0 ms Entry: 37.1 ms

        Six levels, each calling the one below it twice, so x reaches f0 along 64 paths as one of 1 to 22. A call string shorter than that depth merges copies and widening opens the bound, so the check only lands at k = 6. The first bar represents a call string of depth 0, which retains no context. See it miss at k = 5 ↗

        Times are wall clock, best of 60 runs on one laptop. Entry-state contexts prove the same check with 337 nodes in 37.1 ms. The full-depth call string requires 575 nodes and 79.0 ms. Entry-state keys record arguments rather than call paths. The line stops before it because its key is not a call string at all.

        Recursion: down(n) calls down(n - 1) from down(100)

        Contexts per setting, and whether n >= 0 is proved.

        fun down(n) {
          __voblint_check(n >= 0);
        
          if (n > 0) {
            down(n - 1);
          }
        }
        
        fun main() {
          down(100);
        }
        120 80 40 0 240 160 80 0 contexts ms ✓None2No context: 2 contexts, 3.9 ms, PROVED ✓k = 13Call string k = 1: 3 contexts, 4.1 ms, PROVED ✓k = 24Call string k = 2: 4 contexts, 4.9 ms, PROVED ✓k = 1012Call string k = 10: 12 contexts, 6.8 ms, PROVED ✓k = 5052Call string k = 50: 52 contexts, 41.8 ms, PROVED ?k = 99101Call string k = 99: 101 contexts, 184.2 ms, UNKNOWN: the context standing for the rest is widened ✓k = 100102Call string k = 100: 102 contexts, 184.7 ms, PROVED ✓Entry102Entry state: 102 contexts, 79.8 ms, PROVED No context: 3.9 ms Call string k = 1: 4.1 ms Call string k = 2: 4.9 ms Call string k = 10: 6.8 ms Call string k = 50: 41.8 ms Call string k = 99: 184.2 ms Call string k = 100: 184.7 ms Entry state: 79.8 ms

        Precision does not climb with k. At k = 99 the analysis keeps 101 contexts and proves less than k = 50 does with 52: the one context standing for the calls it cannot separate is widened, and narrowing never brings the lower bound back. It also spends 184.2 ms to answer UNKNOWN, against the 184.7 ms that k = 100 spends to prove the check. See the dip at k = 99 ↗

        Entry state sits off the series again, and reaches the same 102 contexts as k = 100 in 79.8 ms rather than 184.7.

        Equation system

        Demand-driven equation solving

        The preceding diagrams showed only a small selection of equations. The generator maps each program-point/context pair to a right-hand side. It is defined over the entire type, so the equations do not form a finite table. A fixed program has only finitely many nodes that matter, but the set of contexts its calls create can be infinite. A run produces only the part that its query reached.

        Equation system for a five-procedure program

        A program point is a cfg_node: a numbered statement, or a procedure's entry or result, named by an arbitrary string. A context is whatever the policy keys copies by. The context key is a call_string, a list of call sites, or, under entry states, the list of abstract values the call binds to the callee's formals, obtained from the entered abs_state. Neither type is finite, and compiled_routed_eqs_for gives an equation for every pair of them. No step builds the equations, no table is allocated and no enumeration needs a bound. A solve evaluates the part of the system its query reaches.

        The example contains five procedures. The arrows trace exactly the program's call graph: main calls w and f, f calls itself twice and calls g, and g calls h. w calls itself with a value nothing constrains, so the analysis cannot tell its copies apart and keeps one.

        The source does not say how many copies of each procedure the analysis will keep, or which keys they get. The context policy decides that, so the filled cells below only take shape during a run.

        fun h(z) {
          return z + 1;
        }
        
        fun g(u, v) {
          r = h(u);
          return r + v;
        }
        
        fun w(x) {
          if (x > 0) {
            y = __voblint_nondet_int();
            r = w(y);
            return r;
          }
          return 0;
        }
        
        fun f(n) {
          if (n < 2) {
            r = g(n, 0);
            return r;
          }
          a = f(n - 1);
          b = f(n - 2);
          return a + b;
        }
        
        fun main() {
          q = __voblint_nondet_int();
          s = w(q);
          r = f(3);
        }
        contexts · a few of the infinitely many the system is defined at program points · one per cfg_node context ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ ∞ () (17) (9) (3) (2) (1) (0) (⊤) (1,0) (0,0) (42,42) (1,1,1) (5,5,5) read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context read, and it answers bottom: the guard in front of it is not taken in this context aaaaaa: a procedure the type allows and this program does not have hh: 2 program points drawn, reached in 2 contexts pppp: a procedure the type allows and this program does not have gg: 3 program points drawn, reached in 2 contexts ff: 7 program points drawn, reached in 4 contexts zzzz: a procedure the type allows and this program does not have ww: 3 program points drawn, reached in 1 context mainmain: 4 program points drawn, reached in 1 context bbbb: a procedure the type allows and this program does not have root_query: the one unknown the solver is asked for ring = asked for · dashed = seed published · solid = exit read back · hollow = answers bottom
        One cell is one equation, keyed here by the entry state a call arrives with. The ringed cell is the only one the solver is asked for (root_query: the program's exit in the root context). Every other filled cell is there because a cell the solver had already reached read it. Each call appears as a loop. The dashed arrow is the seed the call publishes at the callee's entry; it creates that context, and the entry equation reads it back. The solid arrow is the callee's exit returning to the call site that queried it. Hollow cells are read too, and answer bottom. Inside f, the guard n < 2 holds under (1) and (0) but not under (3) and (2), so only two of f's copies ever enter g. w's two arrows return to its own column. It calls itself with a value nothing constrains, so caller and callee land in the same context and the unknown depends on itself. The solver iterates such an unknown, widening if needed, until it stops changing; whether that happens is the termination premise.

        The drawing includes one column per displayed context and one row per displayed program point. The marks are axis breaks; across one, the spacing means nothing, and no drawing could list what they leave out. Both index types are infinite. A context is the list of abstract values a call binds to the callee's formals, projected out of an abs_state. A program point is a cfg_node whose procedure name can be any string, which is why aaa, pp, zz and bb are rows of this system although the program has no such procedures, and why most columns are contexts in which nothing was ever entered. The filled cells are everything the run reached; the system is the whole grid.

        Nothing builds this system. An equation system is an eqsT, and eqsT is a function type, 'x ⇒ strategy_tree, from an unknown to its right-hand side. compiled_routed_eqs_for defines one such function, and a function in Isabelle/HOL is total. It has a value at every argument of its type. The right-hand side for every pair of a program point and a context is already defined. Nobody constructs it. The solver evaluates it when it needs it, so the infinite size of the system has no run-time cost.

        Only a query produces something finite. The solver receives the system and one unknown, and returns a partial post-solution (part_post_solution): every unknown it stabilised is at least what that unknown's equation computes, over the set of keys it reached. That set is finite because the run terminated; the system itself is infinite.

        A key the solver never touched reads as bottom. The theorem shows that this loses nothing. The solved keys contain the root query and are closed under every step a run can take (live_unknowns_cover), so every key a run reaches is among them, and every answer in the result table is covered, including bottom at points no run reaches.

        Shared state

        Shared callee-entry states

        Context sensitivity and update rules affect different parts of the equation system. Context sensitivity determines which unknowns exist and how many copies of a procedure there are, represented by columns. This setting decides what a cell holds when multiple writers publish to the same key. It never adds or removes a key. It can therefore stop a value from growing forever, but it cannot stop the number of copies from growing.

        Contributions to global unknowns

        In the playground this is the Globals setting. “Globals” there means the solver's global unknowns, not VIMP's global declarations. Under the default placement, a program's globals are part of the state a call publishes. They therefore cross a call through a solver global. Under the other placement each program global has a solver global of its own, which the end of this section describes. The dashed arrow in the equation diagram represents entry-state publication. A call site writes the callee's entry key, and the entry equation reads it. The rest of this section is about those entry keys, and this setting decides what happens when multiple writes reach one of them.

        Three of the four programs the guide table further down measures, in one file. The fourth is a recursion whose argument never stops climbing, and it cannot sit beside them. This setting can determine whether that solve finishes. Each call site is lettered, because a call site is what publishes to a key: A and B both write p's entry, C is g's only writer but publishes more than once, and D and E both write s's.

        fun p(x) {
          __voblint_check(x <= 2);
        }
        
        fun g(y) {
          __voblint_check(y < 10);
        }
        
        fun s(a) {
          if (a < 4) {
            s(9 / (a + 2));              // E
          }
          __voblint_check(a <= 9);
        }
        
        fun main() {
          p(1);                          // A
          p(2);                          // B
          i = 0;
          while (i < 10) {               // C, ten times
            g(i);
            i = i + 1;
          }
          s(-1);                         // D
        }
        contexts · one, because this run keeps none program points · one per cfg_node context · p g s main p's entry: the one key both of main's p calls write root_query: the one unknown the solver is asked for ring = asked for · accent ring = p's entry key · dashed = seed · solid = read back
        The same four procedures with context sensitivity switched off. There is one context, so one column, and exactly one copy of each. Every call loops back into that column, so multiple calls write the same keys, and this setting decides how their values are combined. main calls p twice, with 1 and with 2, and both publish into the one entry key p has. The setting has to combine those two values, and they are the reason x <= 2 comes out differently under Warrow than under the other four.

        Every key here takes contributions from multiple origins, so all five settings have something to do, and they do not agree. Warrow widens p's and g's entry away and loses both checks. Warrow per origin keeps those two and loses s, and so does Bounded narrowing. Join and Join per origin keep all three, and neither of them finishes on a recursion whose argument climbs, which is why neither is the default. The four-program table further down is those runs.

        Widening only helps where a key is shared. Run f(x) { f(x + 1); } from f(0) with --context none, and Warrow finishes with x = [0, +∞]: one key receives every contribution, and widening stops the climb. Run the same program with entry-state contexts and no setting here finishes. Each new argument is a new context, hence a new key with a single writer, and none of these rules has anything to widen against. Limiting the number of copies is the job of the context setting.

        Update rules for global unknowns

        A site sends the state its call binds the callee's parameters to, so [0, 3] means one site says the parameter arrives somewhere in that range. The rule combines these contributions into a single value, the callee's entry state, from which the callee is analysed and seeds its own calls.

        One site can publish repeatedly. A right-hand side is not run once; the solver re-evaluates it whenever an unknown it reads has moved, and each re-evaluation publishes again at the same key from the same origin. Site C above does that each time the loop head's value moves, which is a few rounds however many turns the loop runs for. A contribution comes back smaller when narrowing has pulled a widened value down and the site is recomputed from it.

        Below, A and B are two writers at one key. The numbers are not taken from the program. They are chosen so that A sends three times, first smaller and then larger, and this sequence distinguishes the first four rules. Step 2 separates Warrow from the rest, step 3 separates Join from Join per origin, and step 4 separates Join from Warrow per origin. Telling Bounded narrowing apart from Warrow per origin takes more steps. The rules apply to any domain with join, widening, and narrowing. Intervals illustrate the difference between widening and join. Sign and Parity are short enough lattices that their widening is defined as the join.

        Globals Rule after 1A sends [0, 3] after 2B sends [4, 4] after 3A sends [1, 1] after 4A sends [0, 8]
        Join old ⊔ new [0, 3] [0, 4] [0, 4] [0, 8]
        Join per origin one value per call site; report their join [0, 3] [0, 4] [1, 4] [0, 8]
        Warrow old ∇Δ (join over call sites) [0, 3] [0, +∞] [0, 4] [0, +∞]
        Warrow per origin ∇Δ inside each call site's value; report their join [0, 3] [0, 4] [0, 4] [0, +∞]
        Bounded narrowing ∇ or Δ inside each call site's value, narrowing bounded per call site; report their join [0, 3] [0, 4] [0, 4] [0, +∞]
        Values follow from the vendored update rules with Voblint's interval widening and narrowing, where narrowing only replaces an infinite bound. An origin identifies the publishing program point and context. A call site inside a context-sensitive procedure therefore has one origin per context. Warrow widens at step 2 because [0, 4] outgrows [0, 3]. Join never forgets A's first contribution; Join per origin replaces it. At step 4 A's own contributions climb, which is the one thing per-origin widening reacts to, so Warrow per origin jumps to [0, +∞] where Join keeps [0, 8]. Bounded narrowing uses the same per-origin operations. It also counts each origin's switches from widening to narrowing. The bound defaults to 5 and can be set with --narrow-bound. Once the count reaches the bound, each switch performs only its first narrowing step. A bound of 0 therefore behaves like 1. Here A switches once, at step 3, and the row matches Warrow per origin; the bound changes a result only where an origin needs successive narrowing steps. The setting governs these procedure entries; widening points always use warrowing.

        Termination with join updates

        The cost of joining grows with the length of the chain. A joining rule never widens. It follows a growing value one step at a time, requiring one solver round per step. Widening can cover that growth in one step. On f(x) { if (x < N) f(x + 1); }, started from f(0), every rule proves x <= N. But as N grows from 64 to 4096, the two joining rules go from 5 ms to about 240 ms, while the three widening rules stay at 4 ms. Without the bound, the growth never ends, which is the no answer in the update-rule comparison.

        Choice of update rule

        No setting is best everywhere. They trade how quickly a growing value is cut off against how much a merged value forgets. Nothing proves any of them terminates on every program: Voblint assumes termination per program, so a bad choice shows up as a run that never finishes, not as a wrong answer.

        Warrow as the default

        The playground's starting point. With widening, the analysis of a recursive call whose argument keeps growing still finishes. It cannot stop entry-state contexts from multiplying, though: those need context None or a call string.

        Precision loss across callers

        Try Warrow per origin. It widens each call site's contribution on its own, so one caller's growth is not blurred into a jump to infinity for every caller. It can also lose precision, as the final comparison row shows. Bounded narrowing widens per call site too, and bounds how long each one keeps narrowing.

        Join and Join per origin on intervals

        Precise when a procedure receives finitely many values, but they never widen. A call chain that keeps growing an argument gives no answer within the time limit, and we expect, without a proof, that the run never finishes.

        Solver nontermination

        The playground has no timeout; the command-line analyzer takes one with --timeout. Press Cancel, or change any setting, then pick Warrow, Warrow per origin or Bounded narrowing, and switch entry-state contexts to None or a call string.

        Program Join Join per origin Warrow Warrow per origin Bounded narrowing
        Two call sites p(1); p(2); checks x <= 2 in p PROVED [1,2] PROVED [1,2] UNKNOWN [1,+∞] PROVED [1,2] PROVED [1,2]
        Call inside a loop while (x < 10) { g(x); … } checks x < 10 in g PROVED [0,9] PROVED [0,9] UNKNOWN [0,+∞] PROVED [0,9] PROVED [0,9]
        Growing recursion f(x) { f(x + 1); } called as f(0) no answer no answer PROVED x >= 0 PROVED x >= 0 PROVED x >= 0
        Recursion that shrinks f(a) { if (a < 4) { f(9 / (a + 2)); } } from f(-1), checks a <= 9 PROVED [-1,9] PROVED [-1,9] PROVED [-1,9] UNKNOWN [-∞,+∞] UNKNOWN [-∞,+∞]
        Measured with the analyzer on this site: Interval analysis, no context sensitivity. “No answer” means no result within 12 seconds. Click a cell to run it in the playground. In the last row the recursive call site's own contribution changes from 9 to [1, 9]. Warrow per origin detects growth at that call site and widens it to −∞; after that a + 2 may be zero and the division loses everything. Warrow joins in main's −1 first, so the lower bound stays at −1; only the upper bound is widened, and narrowing brings it back to 9. Per origin, the recursive site's own lower bound drops from 9 to 1 and is widened to −∞, which narrowing cannot undo once the division has lost everything. Bounded narrowing widens per origin as well and gives the same answer.

        Context policies and update rules

        Run every combination of the two settings on the program above, and their effects separate. Cost changes only between rows. The solved graph has 24 nodes with no contexts, 34 at either call-string depth, and 38 under entry states. Update rules do not change the unknowns, so each row retains its node count. Precision changes along both axes, and the coarser the context, the more a widening loses. Warrow proves one of the three checks with no contexts and all three once entry states keep the writers apart. Warrow per origin and Bounded narrowing lose only s's check, and only with no contexts. Both joining rules prove all three everywhere. The cheapest fully precise cells are None with either joining rule. Join is also the rule that gives no answer within the time limit on a recursion whose argument keeps growing.

        Recursion with a growing argument

        Both settings meet in recursion. f(x) calls f(x + 1) forever, so real runs wind outward on an endless spiral: f(0), f(1), f(2), each turn wider than the last. The analysis must describe these activations with finitely many contexts. Join and entry-state contexts follow the growing arguments and time out. Warrowing covers the growth. Switch to the same function stopped at x = 10 and every setting finishes.

        fun f(x) {
          __voblint_check(x >= 0);
        
          f(x + 1);
        }
        
        fun main() {
          f(0);
        }

        What the analyzer reports

        PROVED x ∈ [0, +∞]

        ∇ widen

        One context covers every depth. The entry value keeps growing, so warrowing jumps to [0, +∞] instead of following the spiral outward, and x >= 0 is proved for every turn.

        Measured result for x >= 0

        Globals \ contextnonecall string k=1k=2k=3entry state
        Joinno answerno answerno answerno answerno answer
        Join per originno answerno answerno answerno answerno answer
        Warrow[0,+∞][0,0] | [1,+∞][0,0] | [1,1] | [2,+∞][0,0] | [1,1] | [2,2] | [3,+∞]no answer
        Warrow per origin[0,+∞][0,0] | [1,+∞][0,0] | [1,1] | [2,+∞][0,0] | [1,1] | [2,2] | [3,+∞]no answer
        Bounded narrowing[0,+∞][0,0] | [1,+∞][0,0] | [1,1] | [2,+∞][0,0] | [1,1] | [2,2] | [3,+∞]no answer
        Measured with cli/voblint --analysis interval --timeout 60: every value is PROVED, with one interval per context; “no answer” means the run was killed after 60 s. Settings that finish do so in under a second. On the endless program every finishing setting also reports f's exit and the code after the call as unreachable, which is right. That recursion never returns. The turns are depths of real runs; how many solver rounds pass before warrowing jumps is not drawn. Run this setting ↗

        Representation of program globals

        So far a VIMP global has behaved like a local that every procedure can see. It travels inside each point's state, entering a callee with the caller's state and returning through combine. The playground's Program globals setting (--program-globals on the command line, program_globals in the theories) offers a second placement, the one the FM 2026 tutorial calls flow-insensitive.

        Under Flow-insensitive, each declared global gets a global unknown of its own, and the points stop carrying it. An edge that assigns g publishes the new value of g to that unknown, an edge whose expressions mention g reads it, and a call reads only the globals its arguments mention. A point that touches no global depends on none, and a write to g re-runs only the points that read g (read_g_depends_on_g_only and write_h_publishes_h_only check this on one reading and one writing edge). The initial value 0 is published by the program's entry like any other write.

        This placement costs precision, because one value has to cover everything the global ever holds, at every point. Take set() writing Gx = 1 and get() returning Gx, called in that order from main. Flow-sensitively, Sign carries Gx = + from set's exit into get and proves 0 < y. Flow-insensitively, the unknown of Gx joins the initial 0 with the written 1, Sign can only say Gx ≥ 0, and 0 < y stays UNKNOWN. Run it flow-sensitively and flow-insensitively to compare.

        For a sequential VIMP program, the additional placement can lose precision, as this example shows. It provides infrastructure for a future language extension. The FM 2026 tutorial names multi-threaded code as a main source of mixed flow-sensitivity. A thread-modular analysis collects values exchanged through shared globals flow-insensitively. This separation allows flow-sensitive tracking of each thread's state without enumerating all concurrent thread states. Goblint analyzes concurrent C this way. VIMP has no threads, so the current result concerns the framework and its end-to-end theorem, which already support a mixed flow-sensitive analysis. An extension with threads would reuse this machinery and add a thread semantics and synchronization.

        Flow-sensitive main entry, x = 1 set(): Gx = 1 get(): return Gx main: after y = get() Gx = 0 Gx = + Gx = +, y = + 0 < y PROVED Flow-insensitive main entry, x = 1 set(): Gx = 1 get(): return Gx main: after y = get() no Gx no Gx y = ≥0 Gx ≥0 publishes 0 (initial) publishes 1 reads Gx 0 < y UNKNOWN
        The program set() then y = get() under Sign, drawn as the four points the value of Gx passes. Left, Gx rides in each point's state (orange), so the write of 1 reaches get as +. Right, the points carry only locals; the program entry and set publish to one unknown for Gx (purple), which must cover both 0 and 1, and get reads it back (dashed). Values as the analyzer reports them; callee entry and exit points are omitted. Run the flow-insensitive side ↗

        Both placements carry the same end-to-end theorem, run_voblint_source_sound, because the setting is an argument of the analysis function it is stated about. The flow-insensitive one comes from a lifter, keyed_split_spec, that wraps the active analyses. The lifter's soundness proof (keyed_split_contract) rests on a fact about the concrete semantics. A step leaves every global it does not assign unchanged (edge_step_frame), so skipping the publication of an unwritten global loses nothing. Only per-variable state splits this way. The order analysis relates variables to each other and keeps its whole state at the points.

        The global unknowns are side-effected, so the Globals setting above decides how their writes merge. Choosing Flow-insensitive selects Bounded narrowing unless you chose a rule explicitly. Under plain warrowing, a global unknown that alternates between widening and narrowing can fail to stabilize.

        Verification

        Verification scope and trust boundary

        The first section showed what a verdict promises a user. This one says where that guarantee starts and where it stops. The congruence bug at the start of this page is the kind of mistake a proof rules out. VIMP's % is c_mod, and the congruence domain has to prove that c_mod i j lies inside γ of whatever it answers. An operation that rounds the wrong way cannot satisfy that obligation. Voblint's analysis is written in Isabelle/HOL, and theorems there state that, once the solve finishes, its answer covers every store a real run can have. Isabelle checks every step of those proofs down to the axioms of its logic.

        Prior work and contributions

        The soundness proof builds on existing formalizations and analysis techniques. Much of the machinery rests on prior work, some is adapted here, and the contribution is the formalization that connects it end to end with a machine-checked proof.

        Prior foundations

        Adapted here

        • Local traces of threads (Schwarz et al., 2021) become activation traces, one per procedure activation
        • From one record of sound value operations, Voblint derives both the abstract transfer and the executable one and proves them to agree on every live state
        • Goblint's queries between analyses become a query channel each analysis is proved sound against, for one query kind and analyses without globals

        Contributions of this work

        • End-to-end soundness. The definite verdicts of run_voblint are correct for every source execution from an initial store with zeroed globals that reaches the check, in every configuration, whenever it answers (run_voblint_source_sound).
        • A semantic account of calling contexts. A context policy is a relation between calls and callee contexts. Under the coverage contract, whose totality condition gives every covered call a context, the per-context collections together equal the context-free one (node_collect_eq_Union_activation_collect).
        • Compositional soundness. Domain, context policy and solver are verified separately (activation_collect_dg_sound), and so are analyses that exchange queries (mcp_combine_sound).
        • Necessity and non-vacuity as theorems. Counterexample theorems show that dropping or weakening the identified obligations in the exhibited ways admits unsound results (total_dropped_unsound), and evaluated theorems give non-vacuous verdicts (nv_check_proved_sound).
        Where the pieces come from, following the thesis's contribution statement and its comparison with related work. The “Contributions of this work” box follows the thesis's stated contribution scope. It describes what this formalization contributes, without claiming that no related result exists elsewhere. The model covers Goblint's architecture; the comparison with Goblint lists the differences.

        Adequacy of the formal definitions

        Every theorem here depends on the formal semantics of program execution. Those semantics are pstep and the operators it runs on, down to c_mod taking its sign from the dividend, consistent with C. Isabelle checks that the analyzer's answers cover every run those definitions admit. It cannot check that these are the intended definitions. Whether pstep adequately models the intended C-like language requires review beyond the proof.

        The graph and activation-trace semantics are not on that list. They are reached from pstep by proof (source_reaches_node_collect).

        What remains to be read is 6 theories, 998 lines out of the 59,477 in src/, including pstep's 14 rules: VIMP_Syntax, VIMP_Expr, VIMP_Special, VIMP_Globals, VIMP_Program and VIMP_Proc. Everything else, from the compiler and the equation generator to the solver, the six analyses and the result table, Isabelle's kernel has already checked against them. The result covers PROVED, REFUTED and check reachability (run_voblint_check_sound); a reviewer also reads the theorem statements themselves. The DEAD and arithmetic theorems are stated over the compiled graph's collecting semantics, so reading them at source level also takes the compiler and the graph semantics.

        Verified components and trusted infrastructure

        A proof covers a precise statement about precise definitions. Whatever the statement does not mention is trusted. It has to be correct for the executable result to reach you, but nothing here proves it.

        Unverified input Source semantics: pstep and the operators the one assumption no proof covers · read it ↗ Proved in Isabelle/HOL Unverified output Trusted foundation Isabelle kernel · code generator and its target mappings OCaml and wasm toolchain · Zarith · your browser Source .vimp text Lexer + parser source ↗ Compiler AST → CFG Equations per point and context TD solver post-solution Result verdicts Adapter, editor, graph source ↗ theorem: if the solver finishes, the result covers every real store
        The theorem covers the components inside the solid box. The lexer and parser lie outside it; they are generated from the grammar manifest and tested. The handwritten OCaml that renders badges, graphs, and value text also lies outside the proof. The displayed value strings are computed in HOL but no theorem says they read back what the states mean; the verdicts beside them are covered. Above the box sits the other thing no proof covers: the source semantics the theorems assume a run of, which is the one part a reader has to check for themselves. For the check theorem, the graph and activation-trace semantics are not up there, because they are reached from it by proof.

        Code generation and the browser artifact

        The analyzer you run in the browser is not a reimplementation of any of this. Isabelle exports the proved definitions as OCaml, the OCaml is compiled to WebAssembly, and a background worker started by the playground calls it. The analysis core in the browser is exactly the code in the solid box above. The parser and adapters around it are compiled into the same bundle, unproved.

        This page is checked against the theories too. isar-tools generates the notation table from the Isabelle sources. A separate check fails when a link into the rendered theories no longer names a declaration.

        Build, once Isabelle theories definitions + proofs Voblint_Generated.ml generated · source ↗ OCaml adapters review on GitHub ↗ dune wasm_of_ocaml wasm .bc.wasm.js export_code linked compile .thy .ml .wasm Every run, in your browser importScripts The playground review on GitHub ↗ voblint-worker.js web worker · source ↗ Voblint_run(…) parse, run_voblint, render JSON postMessage call source + settings JSON answer proved generated or compiled by a trusted tool handwritten, unverified
        export_code writes the whole reachable program into one module, Generated, in the file Voblint_Generated.ml, next to two of HOL's own serializer preludes. This file is the analyzer that runs, in the command-line tool and, compiled to WebAssembly, in the playground. The handwritten OCaml reaches it through a thin facade, the module Voblint, which re-exports it unchanged. The lexer and parser are generated from the grammar manifest by an unverified generator and built with Menhir and ocamllex. No theorem covers them. The same manifest generates both the Menhir front end and the Isabelle syntax, and they are tested through a drift check that regenerates both on every commit, the .vimp regression corpus, and parser fuzzing. The proved chain starts at an already-built syntax tree, so how that tree was produced lies outside every theorem here. The adapter that calls run_voblint and the JSON rendering are handwritten. render_report, which turns the returned report's states into text, is generated too, but no theorem speaks about the text.

        wasm_of_ocaml compiles the generated code and adapters into the bundle the worker loads, and the worker keeps a long analysis from freezing the page. The site build compiles the checked-in export instead of running export_code again, and a separate CI job fails when the two differ. The unverified parts are small enough to read: the OCaml adapters and the page and worker.

        Solver tracing and replay

        The solver trace in the playground's run_voblint: call and answer panel, like voblint --trace --verbose on the command line, lists what the solver did: each unknown it solves, each query and the value it returns, each side effect into a seed, each update and the readers it destabilizes, and the context each call is routed to. The trace writes one line per step, using Goblint-aligned terms and indentation for query depth. The panel colors lines by operation and folds queries with their resulting steps.

        The solver result is the exported computation of proved equations; its trace calls come from code equations proved equal to the untraced ones. The trace is an unverified observation of that computation, recorded by a hook that returns nothing, swallows exceptions and never touches solver state. The replay is an unverified visualization of the trace.

        The playground's Solve replay section draws the same steps on the graph, with the trace beside it following along. The solver starts at the exit of main and queries backward; each call is routed to a context of its own and publishes its entry state into that context's seed, the circle at the top of the box, and every node fills in with the value the solver holds for it at that step.

        The solve replay on the context example: the solver starts at the exit of main, queries backward to its entry, routes each call of bump to its own context, and fills in every node's interval step by step, with the indented trace beside the graph.
        The solve replay on the context example, one frame per solver step. Dashed nodes are not reached yet, orange ones are being solved, the thick one is solved now, and dark ones are stable; a seed fills once a call has published into it. At a node into which only one call returns, the call publishes its seed before it reads the callee's exit, so the callee context is solved from its published entry state (#251). The replay folds the run's trace into these states; it visualizes the trace and is outside the proof. Run it ↗

        Proof architecture

        The soundness proof chain

        Inside the boundary drawn in the previous section, soundness is a chain of set inclusions. Start with the stores reached by source executions at one program point. The stores are covered by activation traces, then by context-indexed collecting sets, and finally by the solved abstract states. Each link is a theorem of its own. The analysis may lose precision at every link, but it may not lose a store. One last step turns the final set into the verdict you see. Below, the chain is drawn for n at pp0, the if (n < 2) of this program:

        fun f(n) {
          if (n < 2) {
            return 1;
          }
          r = f(n - 1);
          return n * r;
        }
        
        fun main() {
          x = f(2);
        }

        main calls f(2), which calls f(1), so two runs reach pp0: one with n = 2 and one with n = 1.

        1. 1

          Source runs

          Stores a run of pstep has when it is about to test n < 2.

        2. 2

          Collecting semantics node_collect pp0

          The sink_store of every local trace the graph admits whose sink_node is pp0, with the activation each belonged to forgotten.

        3. 3

          Per context activation_collect pp0 c

          The same activation traces, grouped by the contexts their activation was analyzed under; the key is obtained from the trace, which may belong to multiple contexts.

        4. 4

          The solver's answer γ(σ (pp0, c))

          A post-solution whenever the solve terminates (the vendored partial_post_solution, with termination discharged by solve_dom_of_solve_c), read back as the result the analyzer reports (gamma_reader_eq_lookup).

        • a store some run has
        • admitted by the abstraction, reached by no run
        • one context's bucket

        Values at rung 4 are the analyzer's interval results for the factorial program, with the default Warrow. Links 2 to 4 compose into fun_route_result_node_sound without context, and into entry_state_lookup_sound_of_terminates with entry-state contexts; run_voblint_source_sound, the first flagship theorem below, adds link 1. On the executable side, one more step transports the solver's association-list states to the function-valued states these sets are stated over.

        Proof obligations for the soundness chain

        The chain connects real runs to the verdicts, and the displayed argument uses two theorems and three obligations the analyzer has to discharge. Remove one to see which answers stop being covered.

        every real run the verdicts pstep* PROVED · DEAD · SAFE 1 2 3 4 5 unsound: a run the verdict does not cover i = 5

        All five pillars stand, so every store a real run has reaches a verdict that covers it. Click a pillar to remove it.

        Pillar 1, csim_step, is proved once, for the compiler. It also needs the other half of the simulation, which carries a source run into a valid activation trace. Pillar 2, sound_local_spec, is what every selectable analysis must prove about its transfers, and pillar 5, classify_proved, is what its check classifier must satisfy. A numeric domain proves neither by hand. It certifies one bundle of value operations once, in sound_nonrelational_ops, and both pillars follow from that certificate. The order analysis proves pillar 2 directly (order_spec_sound). Pillar 3, routing_adequate, is discharged by each context policy. Pillar 4 connects to the vendored solver's own theorem, which promises a post-solution only for a solve that terminates. The analyzer runs the executable solver, which answers only where the solve terminates, so an answer discharges that condition (solve_c_run) and no flagship theorem below takes it as a premise.

        The diagram omits initial-state coverage and the correspondence of executable states to the transfers and routes used in the theorems. For a numeric domain, the transfer half of the second also follows from the bundle. The executable transfer and the one the proofs use are built from the same operations, and tf_st_for_commute shows that they agree on every live state. The failures shown are what the obligations rule out; they are not bugs anyone has found in Voblint.

        Design constraints exposed by the proof

        Stating the soundness theorem precisely exposed design constraints on the analyzer. The first two below are necessity results. Weakening the condition while keeping the others admits an unsound claim. These results establish necessity for the exhibited conditions. They do not establish necessity for every premise in the development. The third is an executable witness that the implemented equation system obeys another constraint the proof exposed.

        Read a callee's result at the callee's context

        Tempting When a call returns, read the callee's exit at the caller's own context, since that is the context the caller is in.

        Fails on The program fun f(n) { return n; } with a = f(1); b = f(5);, where the context of f is the entry value of n. A claim that reads the result that way meets every other obligation, yet covers no store after the first call, although a run gets there. The analysis would call b = f(5) unreachable.

        So The return obligation reads the callee's exit at the context the call entered.

        return_at_caller_context_unsound

        Every call needs a context

        Tempting Let a context policy decline some calls, for instance ones it cannot classify. Every call it does classify is still handled soundly.

        Fails on The same program, with a policy that admits no context at all. The claim meets the initial, edge, call, and return obligations. After the call, the caller resumes in its own context, where the claim is empty. The reached store therefore lies outside the claim.

        So The coverage contract requires totality. Every call the claim covers is admitted at some context.

        total_dropped_unsound

        Choose the context and publish the seed from the same state

        Tempting Pick the callee's context from the caller's state, which is at hand, and publish the entered state the analysis computes for the callee.

        Fails on A call whose entered state differs from the caller's. Here the caller holds [1,1] and the entered state is [7,7]. The seed must land at the key of [7,7]; under the caller's key it would describe an activation it does not belong to.

        So The call program routes and publishes with the same entered value, and reads the callee's exit at that same key.

        w0_seed_at_entered_frame
        The first two are theorems about a concrete program, with the facts about that program established by evaluation. The third is evaluated for one hand-built call (w0_seed_at_entered_frame and w0_no_seed_at_caller_frame), so it shows the implementation follows the rule, not that every alternative fails.
        Further constraints from the formalization
        • An analyzer that decides nothing is sound. An analyzer that answers UNKNOWN at every check satisfies the soundness statement (unknown_everywhere_sound), while one that answers PROVED everywhere violates it (proved_everywhere_unsound). The non-vacuity witnesses establish that concrete theorem instances produce definite verdicts (nv_check_proved_sound).
        • A domain obligation rules out the Goblint bug for every operand. Any abstract remainder that returns the constant 1 for the class 1+2ℤ modulo 2 violates the statement that the shipped congruence remainder satisfies, at −5 (prefix_congruence_mod_unsound). Goblint's regression test samples one program; the obligation covers every operand. The obligation is machine-checked. The upstream behavior is documented here without rerunning Goblint.
        • Entry states must be covered in pairs. When enter returns multiple alternatives, each pair of caller continuation and callee entry has to cover the run together. Covering the caller store with one pair and the entered store with another lets the combined result exclude the run's real value (unpaired_entry_cover_unsound).
        • Publish to a global unknown only complete contributions. The solver's update rules record one contribution per origin, so the equations buffer their writes (see the buffering above). Where the buffer flushes only at the answer, each key is published once per evaluation; a flush before a local read gives that up for keys written again afterwards. The buffered system terminates on a regression whose equation would otherwise publish twice in one evaluation (keyed_multiwrite_buffered_terminates). That the unbuffered system does not terminate is expected, but neither proved nor evaluated.
        • A verdict is about VIMP. VIMP's integers are unbounded and division by zero is defined, so a verdict about a VIMP program does not automatically transfer to a corresponding C program. The relevance of the theorem depends on the adequacy of its semantics; the limits section returns to this.

        Formal results

        Source-level soundness theorems

        The following theorems concern run_voblint, the function called by the browser analyzer. They establish source coverage and report properties. The proof chain is the argument; this section gives the exact statements it ends in, for readers who want to audit them. Select one to see what it assumes and what it proves.

        How to read the theorem statements
        SymbolReads asIn the theories
        γ agamma of agamma
        ⟦e⟧e sthe value of e in saval
        ⟦e⟧♯ dthe abstract value of e in daval_abs
        ⟦a⟧♯cthe abstract transfer of action a in component cabs_step
        γ dthe stores the abstract state d representsgamma_S
        𝒢,Π ⊢ c →p c' / 𝒢,Π ⊢ c →p* c'c steps to c′pstep psteps
        𝒢,g ⊢ c →c c' / 𝒢,g ⊢ c →c* c'c steps to c′ in gcstep csteps
        Π,g,p ⊢ c ≈ c'c is matched with c′csim
        p,s0 ⊢ c ⊳ vfrom s₀, c is observed at node v in pat_node
        skip♯ S / assign♯ S / special♯ S / branch♯ S / body♯ S / return♯ S / event♯ Sthe op transfer of analysis Sdgs_skip dgs_assign dgs_special dgs_branch dgs_body dgs_return dgs_event
        enter♯ S / combine_env♯ S / combine_assign♯ Sthe call-boundary transfers of analysis Sdgs_enter dgs_combine_env dgs_combine_assign
        combine♯ 𝒢 dst q rthe abstract return of r into qcombine_collect_abs
        context♯the computed callee contextroute
        a ∇ b / a Δ ba widened by b, a narrowed by bwiden narrow
        𝒢the global-variable classifierdeclared_global
        𝒯𝒢,g,SWithin activation_coverage, the fixed parameters are omitted: 𝒯the valid activation traces of g from Svalid_activation_trace
        𝒞𝒢,g,S vWithin activation_coverage, the fixed parameters are omitted: 𝒞 vthe stores collected at vnode_collect
        𝒜𝒢,adm,c₀,g,S v cWithin activation_coverage, the fixed parameters are omitted: 𝒜 v cthe stores collected at v in context cactivation_collect
        ℛres vthe stores report res describes at vreport_sem
        𝒱res vthe stores the verdicts of res at v hold inverdict_stores

        Each name links to its declaration. The README's notation table also gives the meaning of every symbol and the proof-local shorthands.

        run_voblint_source_sound in the formalization ↗

        a run reaches store srun_voblint … = Analysed res
        source_sound
        observed at some v: (residual, s, frs) ⊳ vat every such v: s ∈ node_collect vs ∈ ℛres vs ∈ 𝒱res v

        Hover or focus a premise or conclusion for what it says exactly.

        Run the program from any initial store whose globals start at zero, and stop anywhere. Suppose the analyzer returns Analysed res. At every observation node, the reached store belongs to the collecting semantics and is described by a reported state. Every definite verdict at that node holds for the store. Termination is not a premise. The analyzer runs the executable solver, and an answer means that solve finished.

        The theorem also says where the run is observed, written p, s0 ⊢ (residual, s, frs) ⊳ v, and its guarantees hold at every such node. The residual command alone does not determine the node, because in if (b) { x = 1; } else { x = 1; } both branches leave the same command behind at different nodes. The relation therefore reads the run through the program's annotated syntax, which labels every statement with the nodes the compiler emits for it and compiles to the same graph. The annotated command at which the run stopped names v, and the compiler's simulation relates the source state to it. A configuration reachable along two paths can be observed at two nodes. The annotated syntax is used only in the proof, and the analyzer runs the program as written.

        Isabelle statement
        theorem run_voblint_source_sound:
          assumes "s0 ∈ cinit_stores (declared_global p)"
              and "declared_global p, prog_table p
                     ⊢ (main_body (prog_table p), s0, []) →p* (residual, s, frs)"
              and "run_voblint config p = Analysed res"
          shows "(∃v. p, s0 ⊢ (residual, s, frs) ⊳ v)
               ∧ (∀v. p, s0 ⊢ (residual, s, frs) ⊳ v ⟶
                        s ∈ 𝒞declared_global p,prog_cfg p,cinit_stores (declared_global p) v
                      ∧ s ∈ ℛres v
                      ∧ s ∈ 𝒱res v)"

        run_voblint_check_sound in the formalization ↗

        a run reaches store snext: __voblint_check(e)run_voblint … = Analysed res
        check_sound
        a check for e is listed where s isit is not DEADPROVED ⇒ e true in s; REFUTED ⇒ false

        Hover or focus a premise or conclusion for what it says exactly.

        A run about to execute __voblint_check(e) finds a check for e in the result, listed at a node its store reaches. That check is not DEAD; if it says PROVED, e is true in the store, and if it says REFUTED, e is false. The theorem locates the node. Two identical procedure bodies list their checks separately.

        Isabelle statement
        theorem run_voblint_check_sound:
          assumes "s0 ∈ cinit_stores (declared_global p)"
              and "declared_global p, prog_table p
                     ⊢ (main_body (prog_table p), s0, []) →p* (residual, s, frs)"
              and "next_check residual = Some (l, e)"
              and "run_voblint config p = Analysed res"
          shows "∃c ∈ set (report_checks res). check_label c = l ∧ check_exp c = e
                   ∧ s ∈ 𝒞declared_global p,prog_cfg p,cinit_stores (declared_global p) (check_point c)
                   ∧ check_verdict c ≠ Dead
                   ∧ (check_verdict c = Decided Check_Proved ⟶ truthy (⟦e⟧e s))
                   ∧ (check_verdict c = Decided Check_Refuted ⟶ ¬ truthy (⟦e⟧e s))"

        run_voblint_dead_check_unreached in the formalization ↗

        run_voblint … = Analysed rescheck chk is listedits verdict is DEAD
        dead_check_unreached
        node_collect at its node = ∅

        Hover or focus a premise or conclusion for what it says exactly.

        If a listed check is DEAD, the collecting semantics at its node is empty. No run of the program, starting with its globals at zero, ever reaches that node of the compiled graph. A DEAD check means every state the report holds at its node is ⊥, so the report's semantics there is empty, and the collecting semantics lies inside it.

        Isabelle statement
        corollary run_voblint_dead_check_unreached:
          assumes "run_voblint config p = Analysed res"
              and "chk ∈ set (report_checks res)"
              and "check_verdict chk = Dead"
          shows "𝒞declared_global p,prog_cfg p,cinit_stores (declared_global p) (check_point chk) = {}"

        run_voblint_arithmetic_safe in the formalization ↗

        run_voblint … = Analysed ress ∈ node_collect vno diagnostic listed at v
        arithmetic_safe
        no divisor at v is 0 in s

        Hover or focus a premise or conclusion for what it says exactly.

        At a node with no arithmetic diagnostic, every store the program reaches is arithmetically safe. This property covers the expressions on the edges leaving it, including a call's arguments. The corollary run_voblint_arithmetic_intra_safe states it for intra edges. Every divisor in an expression on such an edge evaluates to a non-zero value.

        Isabelle statement
        theorem run_voblint_arithmetic_safe:
          assumes "run_voblint config p = Analysed res"
              and "s ∈ 𝒞declared_global p,prog_cfg p,cinit_stores (declared_global p) v"
              and "∀d ∈ set (report_diagnostics res). diagnostic_point d ≠ v"
          shows "arithmetic_safe_at (prog_cfg p) v s"

        run_voblint_check_sites in the formalization ↗

        run_voblint … = Analysed res
        check_sites
        one listed check per compiled check, at its node, in graph order

        Hover or focus a premise or conclusion for what it says exactly.

        Whatever the configuration, the result lists exactly one check per check in the compiled graph, at that check's node, with its label and condition, in graph order. No check is silently dropped. The label is the check's source position. Writing it in the parser and printing it in the renderer happen outside the proof.

        Isabelle statement
        corollary run_voblint_check_sites:
          assumes "run_voblint config p = Analysed res"
          shows "map (λchk. (check_point chk, check_label chk, check_exp chk)) (report_checks res)
                   = check_sites (prog_cfg p)"
        fun main() {
          n = __voblint_nondet_int();
          if (n > 0) {
            q = 10 / n;
            __voblint_check(n >= 1);
          } else {
            if (n > 5) { __voblint_check(n == 0); }
          }
        }
        solve finished ✓ n = nondet() n > 0 ¬(n > 0) q = 10 / n check n >= 1 n > 5 ¬(n > 5) check n == 0 return entry 0 1 2 4 3 5 7 exit n ∈ [1,+∞] n ∈ [1,+∞] n ∈ [−∞,0] ⊥ ✓ PROVED ∅ DEAD s ∈ node_collect(3) n = 2 ∈ [1,+∞] 1 n = 2: 2 ≥ 1 holds 2 no store, ever no diagnostic here n = 2 ≠ 0 n = 2 n = 2 n = 7 n = −3

        Hover a premise or conclusion to see where it lives. Open this program ↗

        No theorem assumes termination. run_voblint calls the executable solver, a partial function that answers only where the solve terminates, and builds the report from that answer (solve_c_run). Termination is still not proved in general. Where the solve does not terminate, the analyzer does not answer and no theorem applies. The guarantees are partial correctness.

        Proof dependencies of a PROVED verdict

        A verdict on the screen rests on a stack of claims. The zoom below starts at the verdict for the counting loop's check and follows one real run, the one that leaves the loop with i = 5, down to the arithmetic lemma it depends on and the proof that Isabelle checks.

        1. What you see

          i = 0;
          while (i < 5) {
            i = i + 1;
          }
          __voblint_check(i == 5);   ✓ PROVED

          Read as: every run that reaches this line has i = 5.

        2. The flagship theorem

          check_verdict c = Decided Check_Proved ⟶ truthy (⟦e⟧e s)

          For every store s a source run has when it is about to run the check, a listed check for i == 5 sits at a node s reaches, and PROVED there makes i == 5 true in s.

          run_voblint_check_sound ↗
        3. Why the verdict may say PROVED

          classify c d = Check_Proved ⟹ s ∈ γ d ⟹ truthy (⟦c⟧e s)

          The analysis says PROVED only when every store its state d describes satisfies the check. At the check, d is i ∈ [5, 5]: one value, and it is 5. The classifier is shared across domains. It is built from the two comparison queries of Interval's operation bundle, and this lemma is proved once for every domain that supplies such a bundle.

          classify_check_proved ↗
        4. Run coverage by d

          𝒞𝒢,prog_cfg p,cinit_stores 𝒢 v ⊆ γ (state_at 𝒢 p v)

          Every store the collecting semantics reaches at the check's node lies in the concretization of the result there. Our run's store, i = 5, is in γ([5, 5]).

          fun_route_result_node_sound ↗
        5. Coverage of graph nodes

          activation_collect 𝒢 adm c₀ g S v c ⊆ cover v c

          By induction along the run's trace E → H → B → H → … → X: if one store is covered by the solver's value at its node, the next store is covered at the next node. The solver's post-solution supplies the value, each edge's transfer supplies the step.

          activation_collect_sound ↗
        6. One step: the edge i := i + 1

          s ∈ γ σ ⟹ s(x := ⟦a⟧e s) ∈ γ (assign x a σ)

          On its last pass the run is at B with i = 4, inside σ(B) = [0, 4]. The transfer contract requires that the assignment must cover the store after the step, i = 5. No numeric domain writes its assignment transfer by hand. The transfer is built from the domain's evaluator, and this lemma is proved once for every certified bundle.

          sound_nonrelational_ops.assign_sound ↗
        7. The right-hand side i + 1

          s ∈ γ σ ⟹ ⟦e⟧e s ∈ γ (⟦e⟧♯ σ)

          If every variable's value is covered by the abstract state, evaluating the expression abstractly covers evaluating it concretely: 4 + 1 = 5 lies in γ([0, 4] + [1, 1]).

          ivl_arith.aval_abs_sound ↗
        8. One operator: interval addition

          i ∈ γ a ⟹ j ∈ γ b ⟹ i + j ∈ γ (a + b)

          With i = 4 ∈ γ [0, 4] and j = 1 ∈ γ [1, 1], the lemma gives 5 ∈ γ [1, 5]. Everything above rests on lemmas of this size.

          ivl_plus_sound ↗
        9. What γ is

          gamma_ivl (Ivl l u) = {n. l ≤ Fin n ∧ Fin n ≤ u}

          Every rung above is an ∈ γ claim, and this is where γ stops being a symbol. An interval is a pair of eint bounds, each MinInf, Fin of an integer, or PlusInf. So [0, 4] is Ivl (Fin 0) (Fin 4), and the set it stands for is the comprehension above: {0, 1, 2, 3, 4}. The chain of definitions ends here. The integers are HOL's, and the order on eint has three cases.

          gamma_ivl ↗
        10. The bottom: a proof

          lemma ivl_plus_sound:
            assumes "i ∈ gamma_ivl a" "j ∈ gamma_ivl b"
            shows "i + j ∈ gamma_ivl (a + b)"
          proof (cases a; cases b)
            …
            show "i + j ∈ gamma_ivl (a + b)"
              unfolding ‹a = Ivl l1 u1› ‹b = Ivl l2 u2›
              using bnds normalize_ivl_def
              by (cases l1; cases l2; cases u1; cases u2) auto
          qed

          A three-way case split on each of the four bounds (−∞, a number, +∞), every case closed by auto. Isabelle's kernel replays every inference.

        The levels are the actual path through the proof for the interval analysis without contexts, with the counting loop's values. Int proves this check too, along a path whose last levels depend on the example. The edge is i = i + 1, so the operator lemma there is the one for addition, such as congruence_plus_sound in the congruence domain. Congruence, Sign and Parity answer UNKNOWN on this check, so the zoom has no PROVED to start from in those.

        Proof dependency graph

        The zoom followed one path. The development has layers for semantics, compilation, abstract domains, equations, and solving. Stations on one line belong to the same layer and are listed in reading order. Only the interchanges claim that one result uses another. All five lines reach the result line, which ends at the verdicts you see. Hover over a station to see its theory, and click it to open the theory.

        SEMANTICS COMPILER DOMAIN FRAMEWORK SOLVER RESULT pstep cstep valid_activation_trace node_collect compile_prog csim csim_step source_run_has_activation_trace source_reaches_node_collect nonrelational_ops sound_nonrelational_ops tf_st_for_commute order_spec_sound exec_local_spec_sound sound_local_spec dg_spec analysis_contract routed_node_rhs routing_adequate dg_spec_of_contract strategy_tree sp_bind TD solve partial_post_solution pp_st activation_collect_sound activation_collect_dg_sound fun_route_result_node_sound entry_state_policy_of_terminates run_voblint_contexts run_voblint_covers run_voblint_source_sound SPINE PROVED DEAD SAFE

        activation_collect_dg_sound Voblint_Framework.Routed_Context

        This theorem connects semantics, framework, and solver. It assumes a post-solution of the routed equations, a sound specification, and the context-policy obligations. Every trace-reached store lies in γ of the solved value.

        Stations are real definitions, theorems and the obligations an analysis discharges; wide markers are interchanges, where one result consumes the others that meet there. Being next to each other on a line says only that two stations belong to the same layer. The map follows the session structure. The semantics line lives in Voblint_VIMP and Voblint_CFG, the compiler in Voblint_Compile, the domain line in Voblint_Nonrelational, for its executable refinement Voblint_Exec, and, for its branch, Voblint_Analysis_Relational, the framework line in Voblint_Framework, the solver in the vendored TD session, Voblint_Solver and Voblint_Exec, and the result line in Voblint_Routing, Voblint_Result and Voblint_CLI.

        All three terminals are proved from one hub, run_voblint_covers, which says that the report's semantics at a node of the graph contains every store the collecting semantics has there. The semantics line therefore continues into the central interchange. DEAD and SAFE are stated over node_collect too. The theorem run_voblint_source_sound adds the source run. PROVED is the only terminal stated at a source run. DEAD skips it on purpose. The bridge runs forwards, placing each run at the nodes it is observed at (p, s0 ⊢ … ⊳ v), so it cannot be read backwards into “no run reaches this node”. That claim is proved forwards from the hub, at every collected store.

        The result line splits once per kind of context policy. Policies that compute a context from the call alone go through fun_route_result_node_sound; entry-state contexts, obtained from the solved result, go through entry_state_policy_of_terminates. A second endpoint, run_voblint_spine, keeps the contexts that run_voblint_source_sound erases. Per policy, it places a run's store in the collecting semantics of one context its activation trace carries. It is an interchange of the compiler and result lines like the headline, and both branch from run_voblint_contexts, where the two routes rejoin.

        Inside the development

        Explore the formalization

        The proof chain follows the main soundness results. The interactive proof map lets you explore the declarations around them, from the source semantics to the domain instances and the executable analyzer.

        Treemap of exported Isabelle entities, grouped into session blocks and theory tiles. Colors follow the session dependency layers.

        Each small mark is an exported fact, constant, type or locale, including automatically generated declarations. The treemap groups them by session and theory; block area follows the number of entities. Colors match the dependency layers in the strata figure below. Gold diamonds mark facts cited in the thesis, and red stars mark selected source-level soundness results.

        Switch to the corpus wheel to follow each theory’s declarations in radial streaks. Pinch to reveal more detail, use two-finger scrolling to pan, or search for a name. Click a mark to open its Isabelle documentation. The connecting lines show references in statements and specifications; they do not record which lemmas a proof uses.

        Explore the interactive proof map ↗

        Goblint correspondence

        Comparison with Goblint

        Voblint follows the architecture of Goblint, a static analyzer for C programs. Voblint analyzes VIMP instead, a small imperative language with integer variables, globals and recursive procedures (see what it leaves out). What we claim is a simplified, machine-checked semantic model of Goblint's interprocedural, context-sensitive architecture, not a model of its implementation. The vocabulary of locals, globals and side effects follows the FM 2026 tutorial on mixed flow-sensitivity by Goblint's authors.

        • Spec modeled dg_spec An analysis is a record with one transfer per edge action plus the call and return operations, matching Goblint's method-per-construct shape. Spec's startstate, exitstate and morphstate have no counterpart.
        • D / G lattices simplified 'dl / 'dg carriers The local/global split exists. The shipped domains keep their whole state local unless program globals are flow-insensitive; then a lifter publishes each global's part of the combined state.
        • Globals keyed by V simplified One unknown per program global With flow-insensitive program globals, each declared global is a global unknown of its own, read only where an edge mentions it and published only where an edge may assign it. The names are the program's variables, and the combined analysis has no globals per component.
        • TD3 solver simplified Top-down solver with side effects Vendored from td-verification with a partial-correctness proof; it has neither Goblint's sidel nor its demandl.
        • sidel into a function entry simplified Side effect into an entry proxy A callee entry is published to a global proxy key and read back by the entry's local unknown. Widening occurs one hop later. Equivalence to Goblint's fixpoint is not claimed.
        • enter returning alternatives modeled dgs_enter A list of alternatives, each a pair of caller continuation and callee entry state; the call-site equation routes each alternative and joins their contributions.
        • context from the callee state modeled Context selector on the entered state The context is chosen from the state after enter, as in Goblint, though the selector does not see which callee it is choosing for.
        • combine_env / combine_assign simplified dgs_combine_env / dgs_combine_assign Both hooks exist. The executable specifications separate environment merging from the return write, following Goblint. Only the proof-side whole-state specifications use the identity for environment merging.
        • Widening and narrowing simplified Five global update rules Local unknowns are warrowed at widening points under every rule. The global update rule is chosen once per run and applies to all side-effected globals.
        • Context-sensitivity options simplified None, entry state, call strings Bounded call strings give a finite context space over a compiled program; nothing certifies that entry-state contexts stay finite, and lifters such as context gas are absent.
        • IntDomTuple simplified Int: sign × interval × parity × congruence Goblint combines six optional integer domains; only interval and congruence have counterparts here, and sign and parity have none upstream. A DefExc-style exclusion component cannot form the required lattice over unbounded integers; that argument is recorded in the alignment register, not machine-checked.
        • Machine integers (ikind) absent Mathematical integers No soundness theorem here covers wrap-around arithmetic.
        • Queries (ask) simplified man_ask A transfer asks through its manager; every active analysis answers and the answers are met. Pointwise analyses query the value of an assignment's right-hand side. The order analysis queries comparisons between the assigned value and other variables. Cooperation therefore runs both ways. The order analysis can hand Interval an exact result, and Interval can hand the order analysis a comparison. A handler may ask in turn, and a query already being asked is answered with top. A check is decided by the met answer to EvalInt of its condition. Only EvalInt exists, answers are not cached, and a combined analysis reads and publishes no globals of its own.
        • sync absent none The analysis record has no sync hook; publication happens only in an edge transfer's result.
        • MCP, the analysis combinator simplified local_spec The analyses a run activates share one combined state, one field each, and run in one solve. The state is unreachable when any component is unreachable. Every operation, entry and return included, receives the query channel, and a return also receives the callee's. Activation has no dependency order.
        • Threads absent none Thread creation and privatization are not modeled.
        • Solver termination simplified solve_c_run No general termination theorem exists here. The analyzer answers only after the executable solve finished, so the end-to-end theorems need no termination premise; a solve that does not terminate yields no answer.
        • Any program CIL accepts simplified Admissible input A well-formedness gate rejects inputs outside the compiler proof, and the soundness theorem excludes those inputs. Goblint has no corresponding admissibility predicate.
        • C through CIL absent VIMP A small language of integer variables and procedures: no memory, pointers, arrays or floating point.
        Selected rows from our Goblint alignment register, which records each difference, why it exists and what would close it. Goblint names link to its source at a pinned commit; Voblint names link to the theory in the formalization.

        One row of the table deserves more than a line. Five of the six analyses keep a pointwise state, one abstract value per variable, so it is fair to ask whether the framework works only for that shape. The sixth, the order analysis, shows that it does not. The order domain's carrier is a set of known pairs x ≤ y, and it contains no vname ⇒ 'a anywhere. Rel_Order_Domain interprets analysis_contract with this carrier without any change to the framework, and Rel_Order_Local runs the same carrier inside the combined state, next to the other analyses. There it uses the same interface as every selectable analysis. It supplies a local_spec and proves it sound (order_spec_sound), the interface the five pointwise analyses reach through their certified operation bundles. It answers comparisons from the pairs it records, and at an assignment it asks the other analyses how the new value compares with each variable. The analysis computes no transitive closure, and calls enter and return with no facts. Together with Interval, it proves checks that neither analysis proves alone (an example).

        Cost

        Running time

        Most programs in the regression corpus are quick to analyze. The median one solves in a few milliseconds natively. A proof says nothing about speed, though, and some settings are expensive. The numbers here come from the recorded measurement, on one machine.

        Contexts are where the cost goes. With entry-state contexts a recursive procedure gets one context for every abstract argument it is entered with. Under the interval domain, which distinguishes individual numbers, h(n) counting down from n therefore keeps n + 1 copies of h apart. That copy count drives the run time. A CPU profile of such a run spends much of its own time in the generated code's list lookups, which compare nodes and contexts one at a time.

        The playground runs the same generated code compiled to WebAssembly. It is slower than the native command line.

        The profiling page documents the measurements and methods. It includes results for each domain and context policy across the corpus, scaling curves, and an interactive CPU profile. These measurements do not establish bounds for arbitrary programs. Whether the solver finishes at all is a premise checked per program, as the next section explains.

        Limitations

        Scope and limitations

        Voblint's soundness theorems hold for a small language under a fixed set of assumptions. This section describes what lies outside them.

        VIMP and C

        Goblint analyzes C. Voblint analyzes VIMP, a language small enough that its semantics could be written down and every analysis step proved against it. Hover a region to see what VIMP leaves out.

        C featuresanalyzed by Goblint pointers & heap arrays & structs threads machineintegers floats indirect calls goto, break, for library& I/O VIMP integers · globals procedures · recursion if · while · checks nondet input

        Hover or focus a region.

        Limits of the soundness guarantee

        Solver termination

        Every soundness guarantee assumes the solve terminates. Proving that in general is out of scope; for intervals, the join-based rules give no answer within a time limit on some programs, and so do entry-state contexts. We expect these solves to diverge, but a timeout does not prove it.

        Rejected programs

        A program failing wf_program_compile_input_exec receives Malformed_Program and is never analysed. The Analysed res premise excludes rejected programs from the theorem.

        Precision

        There is no completeness claim. A correct result may still say UNKNOWN for a check that holds.

        Counterexample generation

        REFUTED says the check fails in every run that reaches it, not that any run does. An error diagnostic likewise does not prove the point is reachable.

        Unverified executable components

        The parser is generated from one grammar and tested. Isabelle's code generator, the OCaml compiler, wasm and your browser are trusted. Source positions, badges and graph drawings (OCaml rendering, this page) are unverified.

        Goblint implementation correctness

        Voblint models Goblint's design, and no theorem here speaks about Goblint's OCaml implementation.

        Speed

        The generated OCaml is slow. The purely functional implementation uses association lists for most maps and sets, with red-black trees in a few components. These representations simplify the proofs.

        Exclusion sets such as x != 5

        Goblint's exclusion sets are absent. Over unbounded integers, joining 5 and 7 has no least upper bound among them, so exclusion sets cannot form the lattice these domains require.

        Formalization effort

        The analyzer that runs is a small part of the project. Most of the work went into definitions of what a program means and into proofs that every step of the analysis respects them.

        imported by Voblint_Solver 59,477 lines of Isabelle 213 theories · 2,695 lemmas · 1,431 definitions 9,533 lines of the vendored TD solver proof that Voblint imports 7 of 26 theories, 30,560 lines in all 4,922 lines of handwritten OCaml parser glue, renderers, CLI, browser glue outside the proof 386 .vimp regression programs 164 by eval witnesses tested, not proved 13,109 lines generated OCaml: the analyzer that runs
        The areas above and below the waterline have the same ratio as the line counts. Theory counts come from pixi run theory-stats over src. The solver berg counts only the vendored theories Voblint's sessions import, directly or through each other; the rest of that development verifies solver variants Voblint does not use. The exported OCaml module and the handwritten OCaml under cli are plain line counts. Counted when the site was built.

        Structure of the formal development

        The iceberg shows the size of the development; this figure shows its structure. Every Isabelle session is a rock layer, placed just above the highest session it builds on, from the logic at the bottom to the examples at the surface. Hover over a session, and the layers it rests on light up. A layer's thickness grows with its line count, so the core sample also shows where most of the material is.

        The CLI and browser expose all six analyses: Sign, Interval, Parity, Congruence, Int and relational Order. Order sits lower because it supplies its local specification directly, without the non-relational builder. Hover over CLI to see the dependencies of their shared entry point.

        Examples and regressions Example sessions Code export Codegen Entry point CLI · five numeric analyses + Order Int Int Value domains (operation bundles) Congruence Interval Sign Parity Non-relational analysis builder Nonrelational Result Result Routing and order analysis Routing Order (relational) Executable carrier Exec Framework and compiler Framework Compile Values, graph and solver interface Domain CFG Solver Solver and language TD VIMP Bedrock Isabelle/HOL · HOL-IMP · Deriving

        Core sample

        Voblint_CLI

        —

        Includes Sign, Interval, Parity, Congruence, Int and Relational.

        run_voblint exposes Sign, Interval, Parity, Congruence, Int and relational Order to the CLI and browser. This session assembles their cooperating local specifications and proves the source-level soundness results.

        Hover a layer to highlight its session dependencies. Click to open the session.

        Each session from ROOTS lies one layer above the highest session it rests on, where resting on means its ROOT parent, a session its ROOT lists, or a session one of its theories imports. The site build reads all three from the sources (scripts/session_graph.py), so the layers follow the development. A layer shows order, rather than every contact. Hovering a session outlines what it rests on directly in bold and everything below that, reached through those, in a lighter dotted line. The example sessions are drawn together at the surface. Lines are counted per ROOT directory with the scanner behind pixi run theory-stats: 59,477 in src plus 30,560 across the 26 theory files in the vendored solver's directory, of which Voblint's imports reach 7 theories and 9,533 lines. A layer is 22 px plus a height proportional to its lines; sessions sharing a layer are as wide as their share of its lines, but at least 104 px so every name fits. The bedrock is not to scale, and lemmas count the proofs the scanner finds.

        Related work

        Further reading

        Goblint is developed at TUM and the University of Tartu. The groups below run from the oldest ideas this page builds on to the solver proofs it imports. If you read one, read the FM 2026 tutorial on mixed flow-sensitivity, whose vocabulary this page uses. The full list, theses included, is on Goblint's publication page, and the analyzer itself is described in its documentation.

        Hover or focus a dot to see the paper; click it to jump to its entry.

          A paper sits at the year it first appeared, so Seidl and Vogler's solver is placed at PPDP 2018 although it links to the journal version. The axis skips the years 1991 to 2007.

          Abstract interpretation

          Verified analyzers and Isabelle

          • Code Generation via Higher-Order Rewrite Systems Florian Haftmann, Tobias Nipkow · FLOPS 2010 The code generator behind export_code, which turns the proved definitions into the OCaml you run.
          • Abstract Interpretation of Annotated Commands Tobias Nipkow · ITP 2012 A verified abstract interpreter that follows the program text instead of a solver.
          • Concrete Semantics with Isabelle/HOL Tobias Nipkow, Gerwin Klein · Springer, 2014 · free online IMP, its semantics and an abstract interpreter in Isabelle; VIMP builds on its HOL-IMP theories.
          • A Formally-Verified C Static Analyzer Jacques-Henri Jourdan, Vincent Laporte, Sandrine Blazy, Xavier Leroy, David Pichardie · POPL 2015 Abstract interpretation of C, verified in Coq against CompCert's semantics.
          • Verified Functional Programming of an Abstract Interpreter Lucas Franceschino, David Pichardie, Jean-Pierre Talpin · SAS 2021 A close neighbour of Voblint: a verified interval analysis of an IMP-like language, with backward expression analysis and widening. It is written in F*, where an SMT prover discharges most obligations, and it iterates loops along the program text rather than through a CFG and a solver. Its analyzer also runs in a browser.

          Goblint's foundations

          Verified top-down solvers

          Mixed flow-sensitivity

          The inherited solver

          Documentation and tools

          Project resources

          Start from the certified pipeline theory for the compact theorem-level view, or browse the generated Isabelle documentation for the complete development.