Formal Silk
Formal Silk is Silk’s compile-time formal verification language. It lets you write machine-checked specifications next to ordinary code, and have the compiler prove those specifications using the Z3 SMT solver.
Two properties make this practical:
- Zero runtime cost. Verification directives do not exist at runtime; they don’t slow down your program.
- Opt-in by syntax. Normal code stays normal. Proofs are required only where you write verification syntax.
The key design choice is opt-in by syntax:
- normal code stays normal, and
- proofs are required only when verification syntax is present.
Formal Silk is meant to be used the way you actually write systems code: small, local assertions around the parts that are easy to get subtly wrong (boundary checks, invariants, protocol rules, and “this must never happen” assumptions).
If you want a step-by-step walkthrough, start with
Tutorial 7: Formal Silk in real code.
Where directives live#
Formal Silk directives attach to specific syntactic sites:
- Before
fn:#require,#assure,#theory - Before
struct:#require(struct requirements) - Before
while:#invariant,#variant,#monovariant - Inside blocks:
#const,#assert,#theory(inline declaration + use) - At top level:
theorydeclarations (optionallyexport)
The basic pieces#
Formal Silk uses a small vocabulary of directives:
#require— preconditions (what must be true before a function runs)#assure— postconditions (what must be true when a function returns)#assert— a proof obligation at a specific point in a block#invariant— a property that must hold before/after loop iterations#variant— a measure used for termination reasoning (it must decrease)#monovariant— a measure that must be monotonic (non-decreasing or non-increasing)#const— a compile-time-only binding used inside specificationstheory/#theory— reusable proof obligations
You’ll see these used in three places: function boundaries, inside blocks, and around loops.
Syntax (one screen)#
This shows the full Formal Silk syntax surface in one place:
// Function contracts:
#require <bool-expr>;
#assure <bool-expr>; // may reference `result`
#theory SomeTheory(args);
fn f (params) -> T { return <expr>; }
// Struct requirements:
#require <bool-expr>; // may reference fields by name
struct S { field: int }
// Loop specifications:
#invariant <bool-expr>;
#variant <int-expr>;
#monovariant <int-expr>;
while <bool-expr> { ... }
// Block-local proofs + declarations:
#const name = <expr>;
#assert <bool-expr>;
#theory local_name(params) { ... } // inline theory declaration
#theory local_name(args); // theory use (asserts obligations here)
// Reusable proof bundles:
export theory SomeTheory (params) { ... }
Details and semantics: Formal Silk
Examples (copy/paste)#
Function contracts#
Function contracts define what must be true at the boundary of a function:
#requireis proved at call sites.#assureis proved for the function body.
#require x >= 0;
#assure result == x + 1;
fn inc (x: int) -> int {
return x + 1;
}
#require x >= 0;
#assure result == x + 2;
fn inc2 (x: int) -> int {
// Contracted calls are part of the “verified code” subset.
return inc(inc(x));
}
fn main () -> int {
// `inc2(40) == 42`, so exit code is 0.
return inc2(40) - 42;
}
Notes:
resultis a built-in name available only in#assureexpressions.- Formal arithmetic uses fixed-width bitvectors (modular 2^N semantics). See the reference for details.
Loop specifications#
Loop specifications express facts that span iterations:
#invariant— must hold at entry and after each iteration.#variant— must be non-negative at the loop head and decrease each iteration (termination reasoning).#monovariant— must be monotonic (either non-decreasing or non-increasing) each iteration.
Counting up (increasing monovariant, decreasing variant):
fn main () -> int {
let limit: int = 3;
#const original_limit = limit;
let mut i: int = 0;
#invariant i >= 0;
#invariant i <= original_limit;
#variant original_limit - i;
#monovariant i;
while i < limit {
i += 1;
}
return 0;
}
Counting down (decreasing monovariant and variant):
fn main () -> int {
let mut remaining: int = 3;
#invariant remaining >= 0;
#variant remaining;
#monovariant remaining;
while remaining > 0 {
remaining = remaining - 1;
}
return 0;
}
Block-local proofs (#assert) and declarations (#const)#
Use #assert to state a fact that must be provable right here, and #const to name intermediate values for specs:
fn main () -> int {
let x: int = 3;
#const x0 = x;
let y: int = x + 1;
#assert y > x0;
return 0;
}
After a #assert succeeds, the verifier assumes it for the remainder of the block.
Struct requirements#
Struct requirements let you enforce shape invariants at construction sites:
#require len >= 0;
struct SliceU8 {
ptr: u64,
len: int,
}
#require cap >= len;
struct BufferU8 {
ptr: u64,
len: int,
cap: int,
}
fn main () -> int {
let s: SliceU8 = SliceU8{ ptr: 0, len: 0 };
let b: BufferU8 = BufferU8{ ptr: 0, len: 1, cap: 1 };
return (s.len + b.len) - 1;
}
Reference: Struct requirements
Theories (theory / #theory)#
Theories are reusable proof bundles. You can:
- define them at top level (
export theory ...), - apply them as part of a function contract (prefix
#theory ...;), and - apply them inside blocks (as a proof obligation at that point).
Top-level theory + contract attachment:
export theory add_commutes (x: int, y: int) {
#assure (x + y) == (y + x);
}
#theory add_commutes(a, b);
#assure result == a + b;
fn add (a: int, b: int) -> int {
return a + b;
}
Inline (block-local) theory declaration + use:
fn main () -> int {
let x: int = 2;
let y: int = 1;
#theory local_sum_not_zero (x: int, y: int) {
#const z = x + y;
#assure z != 0;
}
#theory local_sum_not_zero(x, y);
return 0;
}
Theory composition (a theory that applies other theories):
export theory add_commutes (x: int, y: int) {
#assure (x + y) == (y + x);
}
export theory add_associates (x: int, y: int, z: int) {
#assure (x + (y + z)) == ((x + y) + z);
}
export theory add_laws (x: int, y: int, z: int) {
#theory add_commutes(x, y);
#theory add_associates(x, y, z);
}
Reference: Formal Silk
Values and operators (what you can write in specs)#
Formal Silk expressions are normal Silk expressions, but the verifier accepts a restricted subset. The Supported forms includes:
boolexpressions (!,&&,||, comparisons, equality),stringequality/inequality (==,!=),- integer arithmetic, comparisons, and bitwise ops,
- layout queries:
sizeof,alignof,offsetof.
struct Pair { a: int, b: int }
#require mode == "safe" || mode == "fast";
fn run (mode: string) -> int { return 0; }
fn main () -> int {
#assert (1 + 2 * 3) == 7;
#assert ((7 << 1) | 1) == 15;
#assert (~0) == -1;
#assert (10 % 3) == 1;
// Layout queries (current subset).
#assert offsetof(Pair, a) < offsetof(Pair, b);
#assert sizeof(Pair) >= 16;
#assert alignof(Pair) >= 8;
return run("safe");
}
See the reference for the exact accepted subset and the Z3 mapping: Formal Silk
Build metadata in proofs#
Formal Silk can reason about build metadata, which is useful when you want to state “this helper is only valid in test builds” or “this proof assumes a package version floor”.
#require BUILD_MODE == "test";
#assure result == 0;
fn test_only_status () -> int {
return 0;
}
#require BUILD_VERSION_MAJOR >= 1;
#assure result >= 0;
fn stable_api_floor () -> int {
return 0;
}
The current built-in metadata names are:
BUILD_KIND,BUILD_MODE,BUILD_VERSIONBUILD_VERSION_MAJOR,BUILD_VERSION_MINOR,BUILD_VERSION_PATCH
Opaque contracts for precompiled helpers#
Formal Silk is still useful when the implementation body lives somewhere else. If a declaration has a visible contract but no visible body, the verifier treats the call as opaque: it proves the preconditions, then assumes the postconditions.
#require bytes >= 0;
#assure result >= bytes;
fn align_up_page (bytes: int) -> int;
fn main () -> int {
let size = align_up_page(4096);
#assert size >= 4096;
return 0;
}
This is a practical way to document and verify assumptions around:
- allocator shims,
- precompiled libraries,
- host calls reached through a prototype surface.
Real-world example: packet layout and constructor safety#
#require on a struct is the right tool when the invariant belongs to the
type itself rather than to one helper function.
#require header_len == 8 || header_len == 12;
#require payload_len >= 0;
#require total_len == header_len + payload_len;
struct FrameLayout {
header_len: int,
payload_len: int,
total_len: int,
}
fn main () -> int {
let layout = FrameLayout{
header_len: 8,
payload_len: 24,
total_len: 32,
};
return layout.total_len - 32;
}
This style works well for:
- wire headers,
- on-disk record layouts,
- buffer descriptors,
- length/capacity pairs.
Real-world example: progress guarantees in a bounded loop#
#variant and #monovariant are most valuable when a loop is easy to get
almost-right but expensive to debug after the fact.
fn main () -> int {
let budget: int = 16;
#const original_budget = budget;
let mut used: int = 0;
#invariant used >= 0;
#invariant used <= original_budget;
#variant original_budget - used;
#monovariant used;
while used < budget {
used += 1;
}
#assert used == original_budget;
return 0;
}
This is a good fit for:
- retry budgets,
- scan cursors,
- parser offsets,
- bounded work queues.
Reusing std::formal theories#
When the same proof shape appears repeatedly, prefer a shared theory rather than restating the same bounds boilerplate in every function:
import { nonnegative_i64, bounds_i64 } from "std/formal";
#theory nonnegative_i64(len);
#theory bounds_i64(index, len);
#assure result == index;
fn checked_offset (index: i64, len: i64) -> i64 {
return index;
}
This is the right pattern for parsers, pointer/length APIs, and any codebase that wants a consistent verified vocabulary.
Choosing the right directive#
- Use
#requirewhen a caller must establish a fact before entering a function. - Use
#assurewhen a callee guarantees something about its return value. - Use
#assertwhen a fact matters only at one point inside a block. - Use
#requireon astructwhen the invariant belongs to the data type itself. - Use
#invariant/#variant/#monovariantwhen a property spans loop iterations. - Use
theory/#theorywhen the same proof shape appears in more than one place or more than one module.
Why it’s valuable#
Formal verification is most useful where bugs are expensive:
- memory safety boundaries
- cryptographic and security-sensitive logic
- protocol parsers and encoders
- concurrency invariants
Silk’s approach keeps verification lightweight and local: you opt in where it buys you confidence.
A practical workflow#
For most downstream code, the loop is:
silk check verified_logic.slk
silk build verified_logic.slk --debug -o build/verified_logic
z3 -smt2 .silk/z3/silk_z3_m0_0.smt2
Suggested habit:
- start with a single
#assertor#require, - introduce
#constnames when an expression becomes hard to read, - extract a
theoryonly after the proof shape repeats, - use
--debugonly when the normal diagnostic is not enough.
Debugging failed proofs#
When a proof fails, the compiler reports a normal diagnostic at the annotation site.
For deeper debugging, run with --debug so the verifier can emit additional information and (when available) write an
SMT‑LIB reproduction script you can replay with an external Z3 binary.
The workflow is intentionally pragmatic: when a proof fails, you should be able to iterate the same way you iterate on type errors — with good diagnostics and small edits.
The most common Formal Silk diagnostics in practice are:
E3001/E3002/E3003for loop invariants and variants,E3006for#assertand theory obligations,E3007for contracted calls whose preconditions are not provable,E3008for non-monotonic#monovariantexpressions.
Source repository · Edit this page · View Markdown