A .theorem file is a UTF-8 text file containing one or more YAML (YAML Ain't
Markup Language) documents. Multiple documents within a single file are
separated by ---. Each document describes one theorem.
Loading theorem documents
Use theoremc::schema::load_theorem_docs to parse a .theorem file's contents
into a vector of TheoremDoc structs:
use theoremc::schema::load_theorem_docs;
let yaml = std::fs::read_to_string("theorems/my_theorem.theorem")?;
let docs = load_theorem_docs(&yaml)?;
The function:
- Deserializes one or more YAML documents from the input string.
- Rejects unknown keys (any key not defined in the schema causes an error).
- Validates theorem identifiers and
Forallkeys against the identifier rules (see below). - Validates
ForallandActionstype strings as Rust types, rejecting free named lifetimes such as&'a T. - Enforces non-empty constraints on string fields (see below).
- Returns
Err(SchemaError)with an actionable message on failure.
When a concrete source path is available (for example, a fixture path or
project file path), prefer load_theorem_docs_with_source so diagnostics
include that source identifier:
use theoremc::schema::{SourceId, load_theorem_docs_with_source};
let source = "theorems/my_theorem.theorem";
let yaml = std::fs::read_to_string(source)?;
let docs = load_theorem_docs_with_source(&SourceId::new(source), &yaml)?;
Top-level fields
Every theorem document is a YAML mapping with the following fields. Keys use
TitleCase canonically, but lowercase aliases are also accepted (e.g.,
Theorem or theorem).
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
Schema |
integer | no | None when omitted |
Forwards compatibility; omitted values are represented as None, and the loader preserves the distinction between omitted and explicitly declared Schema values. |
Theorem |
string | yes | — | Must be a valid identifier (see below). |
About |
string | yes | — | Human-readable description of intent. Must be non-empty after trimming. |
Tags |
list of strings | no | [] |
Metadata for filtering and reporting. |
Given |
list of strings | no | [] |
Narrative context (no codegen impact). |
Forall |
map (identifier → type) | no | {} |
Symbolic quantified variables. |
Assume |
list of Assumption |
no | [] |
Constraints on symbolic inputs. |
Witness |
list of WitnessCheck |
no | [] |
Non-vacuity witnesses. |
Let |
map (identifier → LetBinding) |
no | {} |
Named fixtures. |
Do |
list of Step |
no | [] |
Theorem step sequence. |
Actions |
map (canonical action → signature) | required when Let/Do reference actions |
{} |
Maps canonical action names to Rust signatures used by Let/Do probes. See Declaring action signatures. |
Prove |
list of Assertion |
yes | — | Proof obligations. |
Evidence |
Evidence |
yes | — | Backend configuration. |
Identifier rules
Theorem names and Forall map keys must satisfy:
- Match the ASCII pattern
^[A-Za-z_][A-Za-z0-9_]*$. - Must not be a Rust reserved keyword (
fn,let,match,type,self,Self,async,yield, etc.).
Invalid identifiers produce an InvalidIdentifier error with a message
explaining why the identifier was rejected.
Non-empty constraints
All string fields that carry semantic content must be non-empty after trimming
(leading and trailing whitespace removed using Unicode-aware str::trim()).
The loader rejects documents where any of the following fields are empty or
contain only whitespace:
AboutAssumption.exprandAssumption.becauseAssertion.assertandAssertion.becauseWitnessCheck.coverandWitnessCheck.becauseKaniEvidence.vacuity_because(when present)
The loader also enforces these structural constraints:
- The
Provesection requires at least one assertion. Evidence.kani.unwindaccepts only positive integers (> 0).- At least one
Witnessentry is required whenallow_vacuousis omitted or explicitlyfalse. allow_vacuous: trueis accepted only with a non-emptyvacuity_becauserationale.
Expression syntax validation
The expression fields Assumption.expr, Assertion.assert, and
WitnessCheck.cover must contain syntactically valid Rust expressions. The
loader parses each expression using syn::Expr and rejects any expression that
is not a single, value-producing form.
Accepted forms include comparisons, function and method calls, boolean
literals, identifiers, arithmetic, if expressions, match expressions,
closures, field access, and other standard Rust expressions:
Assume:
- expr: "amount <= (u64::MAX - balance)"
because: prevent overflow
Prove:
- assert: "result.is_valid()"
because: account invariants hold
Witness:
- cover: "if x > 0 { x } else { 1 }"
because: positive branch is exercised
Rejected forms include statement blocks, loops (for, while, loop), let
bindings, unsafe/async/const blocks, assignments, and flow-control
statements (return, break, continue):
# These will be rejected:
Assume:
- expr: "{ let x = 1; x > 0 }" # block expression
- expr: "for i in 0..10 { }" # for loop
- expr: "x = 5" # assignment
Step and Let binding validation
The loader validates the structural constraints of Let bindings and Do
steps:
- Every
ActionCall.actionfield (in bothLetbindings andDosteps) must be non-empty after trimming. Blank action names are rejected. - Every non-blank
ActionCall.actionmust follow canonical action-name grammar:Segment ("." Segment)+(at least one.separator). - Each canonical action-name segment must match
^[A-Za-z_][A-Za-z0-9_]*$and must not be a Rust reserved keyword. - Every
MaybeBlock.becausefield must be non-empty after trimming. - Every
MaybeBlock.dolist must contain at least one step (an emptymaybeblock is meaningless). - Validation recurses into nested
maybeblocks. Amaybecontaining anothermaybewith a blankbecauseis caught with a full path context (e.g.,"Do step 2: maybe.do step 1: maybe.because must be non-empty"). Letbindings accept onlycallormustvariants. Amaybeblock insideLetis rejected at the deserialization level.
Subordinate types
Assumption: a constraint on symbolic inputs. Both expr and because are
required and must be non-empty after trimming.
Assume:
- expr: "amount <= u64::MAX"
because: "prevent overflow"
Assertion: a proof obligation. Both assert and because are required and
must be non-empty after trimming.
Prove:
- assert: "balance == expected"
because: "deposit adds to balance"
WitnessCheck: a non-vacuity witness. Both cover and because are
required and must be non-empty after trimming.
Witness:
- cover: "amount == 50"
because: "mid-range deposit is exercised"
LetBinding: a named value binding. Must be one of call or must.
Let:
params:
must:
action: account.params
args: { max_balance: 1000 }
result:
call:
action: account.deposit
args: { account: { ref: a }, amount: { ref: amount } }
Step: an element of the Do sequence. Must be one of call, must, or
maybe.
Do:
- call:
action: account.deposit
args: { account: { ref: a }, amount: 100 }
- must:
action: account.validate
args: { account: { ref: result } }
- maybe:
because: "optional second deposit"
do:
- call:
action: account.deposit
args: { account: { ref: result }, amount: 10 }
ActionCall: an invocation of a theorem action.
action(required): dot-separated action name (e.g.,account.deposit).actionmust use canonical grammar (Segment ("." Segment)+), where each segment is an ASCII identifier and not a Rust reserved keyword.args(required): mapping of parameter name to value.as(optional): binding name for the return value.
Evidence: backend configuration. Currently, supports kani, with verus
and stateright as placeholders. The Evidence section is required for every
theorem document, and theorem_file! requires an Evidence.kani entry so it
can generate the Kani proof harness. Omitting Evidence.kani causes macro
expansion to fail with MissingKaniEvidence.
Evidence:
kani:
unwind: 10
expect: SUCCESS
KaniEvidence fields:
unwind(required): positive integer, must be > 0 (loop unwinding bound).expect(required): one ofSUCCESS,FAILURE,UNREACHABLE, orUNDETERMINED.allow_vacuous(optional, defaultfalse): whether vacuous success is permitted. When omitted, behaviour is identical toallow_vacuous: false.vacuity_because(required whenallow_vacuousistrue): human-readable justification. Must be non-empty after trimming.
Value forms in arguments
After YAML deserialization, each action argument value is decoded into an
ArgValue that distinguishes literals from variable references. This encoding
ensures that plain YAML strings are unconditionally treated as string literals
and variable references require the explicit { ref: <name> } wrapper.
Decoded argument types (`ArgValue`):
ArgValue::Literal(LiteralValue::Bool(b))— a YAML boolean (true/false).ArgValue::Literal(LiteralValue::Integer(n))— a YAML integer.ArgValue::Literal(LiteralValue::Float(f))— a YAML float.ArgValue::Literal(LiteralValue::String(s))— a plain YAML string. Plain strings are always string literals, regardless of whether aLetbinding with the same name exists in the same theorem.ArgValue::Reference(name)— an explicit variable reference via{ ref: <name> }. Thenamemust be a valid ASCII identifier (^[A-Za-z_][A-Za-z0-9_]*$) and must not be a Rust reserved keyword.ArgValue::RawSequence(values)— a YAML sequence. During proof harness generation (Phase 3), sequences are recursively lowered tovec![...]macro expressions. Nested sequences, scalars, and references are supported.ArgValue::RawMap(map)— any YAML map that is not a single-key sentinel wrapper. During proof harness generation (Phase 3), maps are lowered to struct literals using the expected parameter type name. Field values are lowered recursively. Multi-key maps are never treated as wrappers, even when one of their keys isreforliteral.
Semantic stability invariant: adding a new Let binding can never silently
change the meaning of an existing argument that was previously a plain string.
A plain string "x" always decodes as ArgValue::Literal(String("x")), even
if a binding named x exists. To reference a binding, use { ref: x }.
Examples:
args:
name: "hello" # → ArgValue::Literal(String("hello"))
count: 42 # → ArgValue::Literal(Integer(42))
enabled: true # → ArgValue::Literal(Bool(true))
graph_ref: { ref: graph } # → ArgValue::Reference("graph")
label: { literal: "graph" } # → ArgValue::Literal(String("graph"))
opts: { timeout: 30 } # → ArgValue::RawMap (future: struct literal)
Invalid reference targets produce actionable error messages:
{ ref: "" }— "ref value must not be empty"{ ref: fn }— "ref value 'fn' is a Rust reserved keyword"{ ref: 123bad }— "ref value '123bad' is not a valid identifier"{ ref: 42 }— "ref value must be a string identifier, not an integer"
Explicit literal wrappers produce string literals when the value is a string. Non-string values are rejected:
{ literal: "graph" }—ArgValue::Literal(LiteralValue::String("graph")).{ literal: "" }—ArgValue::Literal(LiteralValue::String(""))(empty string is valid).{ literal: 42 }— "literal value must be a string, not an integer".{ literal: true }— "literal value must be a string, not a boolean".
Lowering limitations (current implementation):
- Nested maps within composite values (maps inside lists, or maps as field values within other maps) are not yet supported because field type information requires Phase 3 compile-time type probes. Attempts to lower nested maps produce a clear error directing users to use explicit let-bindings for nested struct construction. Top-level map arguments and lists of scalars/references are fully supported.
- Type shape restrictions: Only simple type paths (
MyStruct,module::Type) are supported as expected parameter types. Generic types, references, and tuple types require explicit handling and may produce unsupported type errors during lowering.
Supported YAML value forms (summary):
- YAML booleans → Rust boolean literals (
true,false). - YAML integers → Rust unsuffixed integer literals (
42, not42i64). - YAML floats → Rust unsuffixed float literals (
99.5, not99.5f64). - YAML strings → Rust string literals (
"hello"). Plain strings are always literals. - YAML lists →
vec![...]macro expressions (lowered recursively during Phase 3 harness generation). Nested lists, scalars, and references are supported. Empty lists are allowed (vec![]). - YAML maps → Rust struct literals (lowered during Phase 3 harness generation using the expected parameter type name). Field values are lowered recursively. Unknown fields, missing fields, and type mismatches surface as Rust compilation errors, not theoremc validation errors.
- Single-key sentinel wrappers:
{ ref: name }→ArgValue::Reference,{ literal: "text" }→ArgValue::Literal. All other YAML maps (including multi-key maps such as{ literal: "x", other: 1 }) pass through asArgValue::RawMapfor struct-literal lowering.
Error handling
load_theorem_docs and load_theorem_docs_with_source return
Result<Vec<TheoremDoc>, SchemaError>, where SchemaError has six variants:
Deserialize { message, diagnostic }— YAML parsing or schema mismatch error.InvalidIdentifier { identifier, reason }— identifier validation failure.InvalidActionName { action, reason }— action name grammar or keyword validation failure.ValidationFailed { theorem, reason, diagnostic, source }— structural constraint violation (e.g., emptyProvesection or no Evidence backend), or a raw-to-public decode failure with its original source error preserved.MangledIdentifierCollision { message }— two or more different canonical action names produce the same mangled Rust identifier.DuplicateTheoremKey { theorem_key, collisions, diagnostic }— two theorem documents loaded from the same source produce the same literal theorem key{P}#{T}, with structured collision diagnostics for each duplicate key.
For parse failures, validation failures, and duplicate theorem-key failures,
diagnostic includes structured location metadata when available:
- stable code (
schema.parse_failureorschema.validation_failure), - source identifier,
- line and column,
- deterministic fallback message.
Use SchemaError::diagnostic() to access this payload for custom rendering,
snapshot assertions, or editor integration. For duplicate theorem-key errors,
callers can also inspect
SchemaError::DuplicateTheoremKey { theorem_key, collisions, diagnostic }
directly to enumerate every colliding theorem key in stable order.
All variants produce actionable error messages suitable for display to theorem authors.
Minimal example
Theorem: DepositInvariant
About: Depositing into an account preserves the balance invariant.
Forall:
amount: u64
Assume:
- expr: "amount <= u64::MAX - balance"
because: "prevent overflow"
Witness:
- cover: "amount == 50"
because: "mid-range deposit is exercised"
Prove:
- assert: "new_balance == balance + amount"
because: "deposit adds exactly the deposited amount"
Evidence:
kani:
unwind: 10
expect: SUCCESS