mcpField
Q
quint-lang

quint-lang

github.com/quint-coDevelopment11485 installsUpdated 17w ago
Repository

About quint-lang

quint-lang is a reference skill for Quint, an executable specification language for complex systems. It documents the language constructs and workflows used to write, simulate, test, and model-check specifications.

Key features

  1. 01Explains modules, types, and definitions
  2. 02Documents actions and state variables
  3. 03Covers invariants and temporal properties
  4. 04Describes CLI validation and simulation
  5. 05Includes guidance on file layout and tests

Use cases

  1. 01Learn Quint syntax and data types
  2. 02Write state-transition specifications
  3. 03Run simulations and tests for a spec

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/quint-co/quint-llm-kit --skill quint-lang

After installing, type/quint-langin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

guidelines/
choreo.md
cli.md
constraints.md
operators.md
patterns.md
simulations.md
tests.md

Details

From the project README

Quint Language Reference

Quint is an executable specification language for complex systems, developed by Informal Systems. It compiles to TLA+ and supports simulation and model checking.

Module structure

module MyProtocol {
  // type aliases, constants, state, actions, properties
}

Modules can import others:

import Voting.*              // all definitions
import Voting(quorum)        // specific definition
import Voting as V           // namespace alias

Types

TypeDescriptionExample
intIntegers-1, 0, 42
boolBooleanstrue, false
strStrings"hello"
Set[T]Finite setSet(1, 2, 3)
List[T]Ordered sequenceList(1, 2, 3)
K -> VKey-value map (type is K -> V, not Map[K, V])value: Map("a" -> 1)
(T1, T2)Tuple(1, "x")
{ f: T, g: U }Record{ x: 1, ok: true }
T | USum (variant)(use type alias)

Type aliases:

type NodeId = int
type Phase = Idle | Propose | Vote | Commit   // enum — prefer over string literals

Definitions

// Module parameter — fixed at instantiation, not a state variable
const N: int
const Nodes: Set[str]

// Pure function — no state access, usable anywhere
pure def max(a: int, b: int): int = if (a > b) a else b

// Stateful operator — can read vars, takes arguments (unlike val)
def isActive(n: str): bool = active.contains(n)

// State-reading value — can read vars, no arguments
val quorum: bool = votes.size() * 2 > nodes.size()

// Compile-time constant
pure val N: int = 4
val threshold: int = N / 2 + 1

const vs pure val: const is a module parameter bound at instantiation (import A(N = 3)); pure val is a fixed expression computed once. def vs val: def takes arguments; val does not.


State variables

type LocalState = {
  leader: int,
  phase: Phase,        // enum (see Type aliases) — prefer over a bare str
  votes: Set[int],
  log: List[str],
}

var localState: LocalState  // cohesive local protocol state
var peers: int -> str       // independent concern (peer metadata)

State variables can only be read in val definitions and actions; they cannot be read in pure def.


Actions

Actions describe state transitions. They return bool — true if the action fires.

action init: bool = all {
  leader' = 0,
  phase' = Idle,
  votes' = Set(),
  log' = List(),
  state' = Map(),
}

action propose(node: int): bool = all {
  phase == Idle,
  node > 0,
  leader' = node,
  phase' = Propose,
  votes' = votes,
  log' = log,
  state' = state,
}

Key rules:

  • Every var must be assigned in every action (use x' = x to leave unchanged).
  • all { ... } — all sub-expressions must hold (conjunction). Guards are plain boolean expressions inside all { }.
  • any { ... } — at least one must hold (disjunction); the REPL picks non-deterministically.

Non-determinism

action step: bool = any {
  propose(1),
  propose(2),
  vote,
  timeout,
}

// Non-deterministic choice from a set
action deliverMessage: bool = {
  nondet msg = pending.oneOf()
  all {
    pending.size() > 0,
    delivered' = delivered.union(Set(msg)),
    pending' = pending.exclude(Set(msg)),
    // ... other vars unchanged
  }
}

Set operators

Set(1, 2, 3).contains(2)          // true
Set(1, 2).union(Set(2, 3))        // Set(1, 2, 3)
Set(1, 2, 3).intersect(Set(2, 3)) // Set(2, 3)
Set(1, 2, 3).exclude(Set(2))      // Set(1, 3)
Set(1, 2, 3).filter(x => x > 1)  // Set(2, 3)
Set(1, 2, 3).map(x => x * 2)     // Set(2, 4, 6)
Set(1, 2, 3).fold(0, (acc, x) => acc + x)  // 6
Set(1, 2, 3).size()               // 3
Set(1, 2, 3).forall(x => x > 0)  // true
Set(1, 2, 3).exists(x => x > 2)  // true
1.to(5)                           // Set(1, 2, 3, 4, 5)
nondet x = Set(1, 2, 3).oneOf()   // non-deterministic pick — only valid in nondet bindings

List operators

List(1, 2, 3).head()              // 1
List(1, 2, 3).tail()              // List(2, 3)
List(1, 2, 3).length()            // 3
List(1, 2, 3).nth(1)              // 2  (0-indexed)
List(1, 2, 3).append(4)           // List(1, 2, 3, 4)
List(1, 2).concat(List(3, 4))     // List(1, 2, 3, 4)
List(1, 2, 3).foldl(0, (acc, x) => acc + x)  // 6
List(1, 2, 3).select(x => x > 1) // List(2, 3)

Map operators

Map("a" -> 1, "b" -> 2).get("a")        // 1
Map("a" -> 1).put("b", 2)               // Map("a" -> 1, "b" -> 2)
Map("a" -> 1, "b" -> 2).keys()          // Set("a", "b")
Set(1, 2, 3).mapBy(k => k * 2)          // Map(1 -> 2, 2 -> 4, 3 -> 6)  — set of keys → map

Records

Records group related fields into a named type. They are the primary tool for modelling structured state in Quint.

Type aliases for records
type NodeState = {
  phase:  Phase,   // enum: Idle | Propose | Vote | Commit
  voted:  bool,
  log:    List[int],
}

type Message = {
  from:    int,
  to:      int,
  round:   int,
  payload: str,
}
Creating and accessing
val n: NodeState = { phase: Idle, voted: false, log: List() }
n.phase                          // Idle
n.voted                          // false
Updating (immutable — returns a new record)
{ ...n, phase: Propose }                            // ✅ preferred — idiomatic, handles multiple fields
{ ...n, voted: true, phase: Vote }                  // ✅ multiple fields at once

n.with("phase", Propose)                            // ⚠️ valid but non-idiomatic — field name is a string literal
Records as state — when to group variables

TLA+ specs typically flatten all state into independent top-level variables. Quint's type system lets you group them. When fields describe one cohesive local state, make a record type and use a single state variable of that type.

Group into a record when:

  • They represent the local state of a single actor (e.g. one node's phase + log + vote)
  • They are always passed together as function arguments
  • An invariant relates multiple fields of the same conceptual entity

Keep flat when:

  • The variables represent distinct concerns that change independently
  • The component is simple and grouping adds no clarity
  • The variables are intentionally in different ownership/lifecycle domains
Example: preferred grouped local state vs. anti-pattern

Preferred (cohesive local state):

type LocalState = {
  id: int,
  phase: Phase,
  est1: int,
  est2: Option[int],   // Option is from basicSpells, not built in — see Basic spells below
  round: int,
  crashed: bool,
  leader: int,
  received_messages: Set[Message],
}

var localState: LocalState

Avoid for cohesive local state:

var id: int
var phase: Phase
var est1: int
var est2: Option[int]
var round: int
var crashed: bool
var leader: int
var received_messages: Set[Message]

For N actors, use a map of grouped records:

type LocalState = { phase: Phase, votedFor: int, log: List[int] }

var nodes: int -> LocalState

action commit(id: int): bool = {
  val node = nodes.get(id)
  all {
    node.phase == Vote,
    nodes' = nodes.put(id, {...node, phase: Commit}),
  }
}
Nested records
type ClusterState = {
  nodes:   int -> NodeState,
  leader:  int,
  epoch:   int,
}

var cluster: ClusterState

// Read nested field:
cluster.nodes.get(1).phase

// Update nested field (must rebuild from the inside out):
val updated = {...cluster.nodes.get(1), phase: Commit}
cluster' = {...cluster, nodes: cluster.nodes.put(1, updated)}
Records in sets (messages, events)
var inFlight: Set[Message]

action send(src: int, dst: int, r: int, p: str): bool = all {
  inFlight' = inFlight.union(Set({ from: src, to: dst, round: r, payload: p })),
  // ...
}

// Filter by field:
inFlight.filter(m => m.to == nodeId)
inFlight.exists(m => m.round == currentRound and m.payload == "vote")

Sum types

Sum types (variants) represent a value that can be one of several distinct cases.

type Action =
  | Propose({ value: int, proposer: int })
  | Vote({ value: int, voter: int })
  | Decide({ value: int })

Each variant has a named constructor and carries one payload. A constructor takes exactly one argument — wrap multiple fields in a record (as above) or a tuple.

Construct a value by calling the constructor:

val a: Action = Propose({ value: 1, proposer: 2 })

Pattern-match with match, binding the payload:

pure def describeAction(a: Action): str =
  match a {
    | Propose(p) => "proposal"
    | Vote(v)    => "vote"
    | Decide(d)  => "decision"
  }

Use _ to ignore the payload when you only care which variant it is:

match a {
  | Propose(_) => "proposal"
  | _          => "other"
}

Use sum types when a message, event, or state can take structurally different forms — not just different values of the same type.


Enum types

Enum types are a special case of sum types where each case has no additional data.

type Phase = Idle | Propose | Vote | Commit
var phase: Phase

if (phase == Propose) { ... }

Variable grouping — decision guide

Before writing var declarations, answer these questions for each candidate group:

QuestionGroup → record if...Keep flat if...
Do these vars always change together?Yes, in most actionsNo, they're independent
Do they describe the same entity?Same node / same message / same roundDifferent concerns
Is there one instance or N instances?Either one or N (group if cohesive; for N use Id -> RecordType)Flat only when concerns are truly independent
Do invariants relate them?Invariant spans multiple fields of one entityInvariant uses vars independently

Boolean operators

not(p)             // negation — Quint has no ! operator
p and q            // conjunction
p or q             // disjunction
p implies q        // p => q  (not(p) or q)
p iff q            // p == q for booleans

and { p1, p2, p3 } // block form — equivalent to p1 and p2 and p3
or  { p1, p2, p3 } // block form — at least one must hold

and { } and or { } are the same operators as all { } and any { } in actions — use whichever reads more naturally in context.


Invariants and temporal properties

// Safety invariant — must hold in every reachable state
// @invariant
val noDuplicateLeader: bool =
  leaders.size() <= 1

// Temporal property — evaluated over traces
// @temporal
temporal eventualProgress: bool =
  eventually(committed.size() > 0)

// Temporal operators
eventually(p)      // p holds in some future state
always(p)          // p holds in all future states
p.implies(q)       // p => q

Assume

assume nodeCountPositive = N > 0
assume quorumMajority = 2 * quorum > N

An assume states a premise about constants, but it is not enforced — a violated assume is silently ignored by quint typecheck, quint run, and quint verify (none of them flags it). It is documentation, not a checked constraint. To actually check a condition on constants, write a run test that asserts it (it executes and fails when the condition is false):

run quorumAssumptionTest = all {
  2 * quorum > N,
  N > 0,
}

Run it with quint test; the test fails (reporting which conjunct broke) if a constant assignment violates the condition.


Conditional and let

if (x > 0) "positive" else "non-positive"

val result = {
  val doubled = x * 2
  doubled + 1
}

REPL usage

Prefer CLI commands (quint typecheck, quint run, quint test, quint verify) for all validation and execution tasks. Open the REPL (quint or quint -r spec.qnt::ModuleName) only when you need expression-level interaction the CLI does not provide.

Type inspection:

>>> :type myExpression

File layout

Split specs across two files:

<protocol-name>.qnt        # main module — step, init, vars, invariants
<protocol-name>_test.qnt   # test module — run tests and scenario witnesses (imports main)
Module responsibilities

Main module (<protocol-name>.qnt):

  • Declares all state variables, init, actions, and safety invariants
  • step must live in the main module — it is the entry point for quint run simulation
  • The module name matches the file stem: module myProtocol in myProtocol.qnt

Test module (<protocol-name>_test.qnt):

  • Imports the main module (import myProtocol.*)
  • Contains run tests and scenario witnesses invoked via quint test or quint run
  • Inherits step from the main module through the import
Which --main to pass for quint run

quint run must receive the module that owns the property being checked:

Property locationCorrect --main
Invariant defined in main modulemain module name
Witness / run test defined in test moduletest module name (it imports step from main)

The primitive's module_name field (set during indexing) always holds the correct value. Use it directly — do not derive from the filename.



Basic spells

Many useful operators are not built into Quint but are available in basicSpells.qnt, a standard library shipped with most Quint projects. Import it with:

import basicSpells.* from "./basicSpells"

Key definitions it provides:

DefinitionWhat it does
type Option[a] = Some(a) | NoneThe option type — Quint has no built-in Option. Any spec field typed Option[T] depends on this import.
unwrap(o)The value inside Some; undefined on None
require(cond)Blocks the action if cond is false (cleaner than bare all { cond, ... })
values(m)Set of all values in map m
transformValues(m, f)New map with f applied to every value
has(m, key)True if key is bound in m
getOrElse(m, key, default)m.get(key) if present, otherwise default
mapRemove(m, key) / mapRemoveAll(m, ks)Copy of m without key (or without the set of keys ks)
setRemove(s, e) / setAdd(s, e)Copy of set s without / with element e
find(s, f) / findFirst(l, f)First element of set / list satisfying f, as Option
max(i, j) / min(i, j) / abs(i)Max / min of two integers; absolute value

When you see a spec using Option, require, values, or transformValues without an import, it is relying on basicSpells — check whether the project includes it. (Less common operators live in a sibling rareSpells.qnt.)


Guidelines

Detailed references — read these when you need more than the quick reference above:

FileContents
guidelines/operators.mdComplete operator reference: extended set/list/map operators, run/then/expect/reps for tests and witnesses, temporal fairness, q::debug
guidelines/simulations.mdWitnesses vs invariants, result interpretation, progressive increase protocol, trace analysis, coverage standard
guidelines/constraints.mdHard language limitations: no string ops, no nested match, no destructuring, no loops, no early returns
guidelines/cli.mdFull CLI reference: quint run, quint test, quint verify flags, verbosity guide, reading output
guidelines/patterns.md14 core patterns: State Type, Pure Functions, Thin Actions, Map Pre-population, Syntax Rules, Undefined Behavior, Witnesses, Nondeterministic Testing, Separate Test Files, REPL-First Debugging, Separate Concerns First, Extract System Model, Types-First Scaffolding, Logic Stubs
guidelines/tests.mdWriting and debugging tests: run/then/expect/reps/fail, nondeterministic tests, error location ≠ failure point, frame counting, REPL-first debugging
guidelines/choreo.mdChoreo framework for distributed protocols: two-file split, choreo::cue pattern, .with_cue().perform() testing, witness-based test discovery

Comments

Markdown supported