quint-modeling

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Quint Modelling

Quint建模

Produce a Quint specification from whatever the user starts with. This skill owns the shared modelling discipline (below) and routes to a flow-specific guideline for the intake — the part that turns a particular kind of input into the understanding the shared steps build on.
For language syntax, operators, the CLI, and
basicSpells
, defer to the quint-lang reference. This skill is about how to model, not Quint syntax.

根据用户提供的初始内容生成Quint规格说明。本技能涵盖通用建模规范(如下所述),并会根据输入类型导向特定的流程指南——即从特定输入中提取信息,为后续通用步骤构建基础认知的环节。
关于语言语法、运算符、CLI及
basicSpells
,请参考quint-lang参考文档。本技能聚焦于建模方法,而非Quint语法。

Pick the flow

选择流程

Identify what the user is starting from, then read the matching guideline. The guideline handles intake — extracting the system's state, operations, and assumptions from that source. After intake, everyone converges on the Shared modelling spine below.
Starting pointFlowGuideline
Only an idea / informal proposal, built interactively with the userfrom nothing
guidelines/from-nothing.md
A written requirements / functional-spec documentfrom requirements
guidelines/from-requirements.md
Source code (Rust, Go, TypeScript, …)from code
guidelines/from-code.md
An existing TLA+ specificationfrom TLA+
guidelines/from-tlaplus.md
A finished
.qnt
spec to audit
, not build
review
guidelines/review.md
The first four are build flows: intake → the Shared modelling spine below. The review flow is different — there is no new model to produce; you audit an existing spec against a checklist and report findings. It does not use the spine; read
guidelines/review.md
and follow it directly.
If the starting point is ambiguous (e.g. "a design doc with some pseudocode"), ask the user which source is authoritative before picking — the intake differs materially.

确定用户的初始输入类型,然后阅读对应的指南。指南负责信息提取——从输入源中提取系统的状态、操作及假设。完成信息提取后,所有流程都会收敛到下方的通用建模主干流程
初始输入类型流程指南
仅为交互式构思的想法/非正式提案从零构建
guidelines/from-nothing.md
书面需求/功能规格文档基于需求构建
guidelines/from-requirements.md
源代码(Rust、Go、TypeScript等)基于代码构建
guidelines/from-code.md
现有TLA+规格说明基于TLA+构建
guidelines/from-tlaplus.md
待审计的已完成
.qnt
规格(非构建)
审查流程
guidelines/review.md
前四种属于构建流程:信息提取 → 下方的通用建模主干流程。审查流程有所不同——无需生成新模型,只需对照清单审计现有规格并报告结果,不使用主干流程,请直接阅读并遵循
guidelines/review.md
若初始输入类型模糊(例如“包含伪代码的设计文档”),请先询问用户以确定权威输入源,再选择流程——不同输入源的信息提取方式差异显著。

Shared modelling spine

通用建模主干流程

Every flow lands here. Intake (flow-specific) produces an understanding of the system: its entities, the operations they perform, the state each operation reads and writes, and the system-model assumptions (communication, failures, time, participants). The spine turns that understanding into a verified Quint spec.
Two principles hold throughout:
  • Executable from the first line. Quint specs run immediately — you validate design decisions as you make them, not at the end. Never build more than one step without verifying the previous one:
    quint typecheck
    , then
    quint run
    . Typecheck is not enough — a spec can typecheck and still fail to execute, which defeats the whole point of an executable spec. The trap to watch for: a
    const
    not given a value
    typechecks but cannot run —
    quint run
    fails with
    Uninitialized const
    . To make the spec runnable, instantiate the const in a concrete instance module
    module spec_3 { import spec(C = Set(1,2,3)).* }
    — and run that module. (
    assume
    documents a premise; it does not supply a value — see Step 1 and quint-lang's
    guidelines/simulations.md
    .) After each addition, actually run it — if you can't
    quint run
    the module, it isn't done. Use
    quint run
    only — never
    quint verify
    .
    This holds for the whole skill: building, iterating, reviewing, sanity-checking invariants — every step uses sampled
    quint run
    , not once
    quint verify
    , not even as a final pass.
    verify
    is exhaustive bounded model checking (Apalache): far slower (minutes to hours) and not part of the modeling workflow. It is a model-checking tool, documented in quint-lang (
    guidelines/cli.md
    ,
    guidelines/simulations.md
    ) — reach for it only when the user explicitly asks to model-check the spec, never on your own.
  • What, not how. Model observable state transitions and their effects. Abstract away implementation detail (serialization, memory management, retry plumbing). If a detail doesn't affect an invariant you care about, it doesn't belong in the spec.
所有流程最终都会进入此环节。流程特定的信息提取会生成对系统的认知:系统实体、实体执行的操作、每个操作读写的状态,以及系统模型假设(通信、故障、时间、参与者)。主干流程会将这些认知转化为经过验证的Quint规格。
全程遵循两大原则:
  • 从第一行代码即可执行。Quint规格可立即运行——在做出设计决策时即可验证,无需等到全部完成。每完成一步都要验证前一步:先执行
    quint typecheck
    再执行
    quint run
    。仅类型检查是不够的——规格可能通过类型检查但无法运行,这违背了可执行规格的核心意义。需要注意的陷阱:未赋值的
    const
    可通过类型检查但无法运行——
    quint run
    会因
    Uninitialized const
    失败。要使规格可运行,需在具体实例模块中实例化const——例如
    module spec_3 { import spec(C = Set(1,2,3)).* }
    ——并运行该模块。(
    assume
    用于记录前提,但不提供值——请参阅步骤1及quint-lang的
    guidelines/simulations.md
    。)每次添加内容后都要实际运行——若无法用
    quint run
    运行模块,则说明未完成。仅使用
    quint run
    ——绝不使用
    quint verify
    。本技能的所有环节均遵循此规则:构建、迭代、审查、不变量 sanity检查——每一步都使用抽样的
    quint run
    ,绝不使用
    quint verify
    ,即使是最终环节也不例外。
    verify
    是 exhaustive有界模型检查工具(基于Apalache):速度慢得多(耗时数分钟至数小时),不属于建模工作流。它是模型检查工具,在quint-lang中有相关文档(
    guidelines/cli.md
    guidelines/simulations.md
    )——仅当用户明确要求对规格进行模型检查时才使用,切勿自行使用。
  • 关注“是什么”而非“怎么做”。对可观察的状态转换及其影响进行建模,抽象掉实现细节(序列化、内存管理、重试机制等)。若某细节不影响你关心的不变量,则不应纳入规格。

Reference examples

参考示例

Two complete, runnable specs are bundled in
examples/
, one for each of the two ways components coordinate — by passing messages or by sharing state. Almost any system maps onto one of them; pick by that question, not by domain label, and read the closer match as a worked instance of the spine patterns (Step 2 state-shaping, Step 4 guarded actions, Step 5 witnesses + invariants) — to see how the pieces compose into a whole spec. (They are canonical shapes to learn from, not the only valid ones.)
ExampleCoordinates by…Read it when modelling…
examples/tendermint/
message passing (
choreo::
broadcast/send; real BFT consensus)
any distributed protocol where parties exchange messages — consensus, BFT, replication, atomic commit, leader election, reliable broadcast/gossip, request/response, a mempool. This is the default reference for distributed systems. Shows: grouped
Id -> LocalState
(Step 2), guarded transitions (Step 4),
choreo::cue
listen/act split, safety invariants (
agreement
/
validity
) + an accountability/liveness angle + a counterexample demo (Step 5),
const
-instantiation, and test separation. Its banner has a "quick read" path so you needn't read all ~760 lines. See also Step 2's "When to use Choreo" and
../quint-lang/guidelines/choreo.md
.
examples/ewd426.qnt
shared state (no messages)systems whose parts coordinate through common state rather than messages — mutexes/locks, token rings, shared registers, self-stabilization, anything where a process reads its neighbours/the environment directly. Shows: grouped-map state (Step 2), guarded
step
(Step 4),
const
-instantiation, and
temporal
liveness properties (Step 5).
If a system has both (e.g. message-passing nodes that also touch a shared ledger), start from tendermint (the message-passing structure dominates) and add shared state as its own
var
, per Step 2. Plain message soup (
Set[Msg]
) without the Choreo framework isn't a separate file — Choreo's broadcast is soup underneath, so
tendermint/
is also the reference for that shape (read the state decls + a guarded transition; the
choreo::
wrapper is the only added layer).
examples/
目录下包含两个完整的可运行规格,分别对应组件协作的两种方式——消息传递状态共享。几乎所有系统都可映射到其中一种方式,请根据组件协作方式选择参考示例,而非领域标签,并阅读更匹配的示例以了解主干流程模式(步骤2的状态塑造、步骤4的受保护动作、步骤5的见证者与不变量)——从而了解各部分如何组合成完整规格。(这些是供学习的标准模板,并非唯一有效形式。)
示例协作方式适用建模场景
examples/tendermint/
消息传递
choreo::
广播/发送;真实BFT共识)
任何通过消息交换协作的分布式协议——共识、BFT、复制、原子提交、 leader选举、可靠广播/ gossip、请求/响应、内存池。这是分布式系统的默认参考示例。展示内容:分组的
Id -> LocalState
(步骤2)、受保护转换(步骤4)、
choreo::cue
监听/动作分离、安全不变量(
agreement
/
validity
)+ 问责/活性角度 + 反例演示(步骤5)、
const
实例化、测试分离。其顶部有“快速阅读”路径,无需通读全部约760行代码。另请参阅步骤2的“何时使用Choreo”及
../quint-lang/guidelines/choreo.md
examples/ewd426.qnt
状态共享(无消息)通过公共状态而非消息协作的系统——互斥锁/锁、令牌环、共享寄存器、自稳定系统、任何进程直接读取邻居/环境状态的场景。展示内容:分组映射状态(步骤2)、受保护
step
(步骤4)、
const
实例化、
temporal
活性属性(步骤5)。
若系统同时包含两种协作方式(例如既传递消息又访问共享账本的节点),请从tendermint开始(消息传递结构占主导),并按照步骤2的要求将共享状态作为独立
var
添加。未使用Choreo框架的纯消息“池”(
Set[Msg]
)没有单独的示例文件——Choreo的广播底层就是消息池,因此
tendermint/
也可作为该形式的参考示例(只需阅读状态声明及受保护转换;
choreo::
包装是唯一新增的层)。

Step 1 — Separate concerns

步骤1 — 分离关注点

Partition the understanding into three layers. Keeping them apart is what makes a spec testable; mixing them is the most common source of untestable specs.
  • State machine — variables, initialization, transitions →
    var
    ,
    init
    , actions
  • Functional logic — pure computations: guards, quorum checks, state updates →
    pure def
  • Properties — what must always hold, what must be reachable, what must eventually happen →
    val
    invariants + witnesses, plus
    temporal
    properties for liveness (Step 5; quint-lang has the detail)
Also pin down the system-model assumptions — they shape every invariant built on top. Encode them as
const
declarations
. To actually check an assumption (e.g. a quorum condition like
2 * f < n
), write a
run
test
that asserts it — the
assume
keyword documents a premise but is not enforced, so don't rely on it as a check (see quint-lang's
## Assume
for why and the
run
-test pattern). Each flow's intake fills this in from its own source (interview, requirements doc, code, or TLA+); the checklist is the same regardless:
ConcernQuestions to answer
CommunicationHow do the actors communicate — messages or shared state? If messages: reliable or lossy? ordered or unordered? broadcast or point-to-point? (The other shaping question — answer it right after actors; it picks the message medium in Step 2 and, with the actor count, settles the Choreo decision.)
FailuresCrash-stop? Crash-recovery? Byzantine? What fraction
f
of
n
?
TimeSynchronous? Asynchronous? Partial synchrony? Are timeouts modelled?
Actors / ParticipantsWho are the actors and roles, and what local state does each hold? (This is the most concrete intake question — answer it first; it feeds Step 2's
Id -> LocalState
shaping directly.) Fixed membership or dynamic? How many processes?
If an assumption is wrong, every property built on it is wrong — surface them before building.
将对系统的认知划分为三层。保持分层是规格可测试的关键;混合分层是导致规格不可测试的最常见原因。
  • 状态机——变量、初始化、转换 →
    var
    init
    、动作
  • 功能逻辑——纯计算:守卫条件、法定人数检查、状态更新 →
    pure def
  • 属性——必须始终成立的规则、必须可达的状态、最终必须发生的事件 →
    val
    不变量 + 见证者,以及用于活性的
    temporal
    属性(步骤5;quint-lang有详细说明)
同时明确系统模型假设——它们会影响所有基于此构建的不变量。将假设编码为**
const
声明**。要实际检查假设(例如
2 * f < n
这样的法定人数条件),请编写**
run
测试**来断言该假设——
assume
关键字仅用于记录前提,不强制执行,因此不要依赖它进行检查(请参阅quint-lang的
## Assume
了解原因及
run
测试模式)。各流程的信息提取环节会从自身输入源(访谈、需求文档、代码或TLA+)填充这些内容,无论输入源是什么,检查清单都是相同的:
关注点需回答的问题
通信参与者如何通信——消息传递还是状态共享?若为消息传递:可靠还是不可靠?有序还是无序?广播还是点对点?(这是另一个关键问题——在确定参与者后立即回答,它会决定步骤2的消息媒介,并结合参与者数量确定是否使用Choreo。)
故障崩溃停止?崩溃恢复?拜占庭故障?故障节点占总节点数
n
的比例
f
是多少?
时间同步?异步?部分同步?是否建模超时?
参与者/角色参与者和角色是谁,每个参与者持有哪些本地状态?(这是最具体的信息提取问题——首先回答;它直接为步骤2的
Id -> LocalState
塑造提供输入。)成员固定还是动态?进程数量是多少?
若假设错误,基于此构建的所有属性都会错误——请在开始构建前明确这些假设。

Step 2 — Shape the state

步骤2 — 塑造状态

Start from the two questions you answered in Step 1 — who the actors are and how they communicate. Together they drive every structural decision this step makes. They are not just background; they pick the shape, the message medium, and the framework:
  • How many actors → the state shape. One actor → a single
    var s: Record
    . N actors of the same kind → one
    Id -> LocalState
    map (the record describes one actor; see "Scaling to N" below). Distinct roles → either a field on
    LocalState
    (e.g.
    role: Coordinator | Participant
    ) or separate maps. Get the actor count/roles right and the shape falls out.
  • How actors communicate → the message medium. Messages → a separate
    var
    for the medium, not crammed into
    LocalState
    : unordered
    Set[Message]
    soup (the default), or
    ... -> List[Message]
    ordered per-pair queues only when delivery order is load-bearing. No messages, coordination through common state → shared memory (no medium var; see
    examples/ewd426.qnt
    ). This is the "Communication shapes" choice detailed just below.
  • Actors + communication → Choreo or not. Multiple actors coordinating by exchanging messages → default to Choreo (see "Default to Choreo" below). A single actor, or actors coordinating through shared state → plain Quint. So answer "who, how many, and how do they talk?" first, and all three decisions below are mostly settled before you write a type.
Group cohesive state into a record and let the functional layer operate on that record — this is the recommended shape, because most of the time the variables that describe one entity are cohesive, and grouping keeps the functional layer clean and the invariants readable. Keeping variables flat is the deliberate exception, not a co-equal default:
  • Group into a record when the fields are the local state of one entity (a node's phase + log + vote), are always passed together, or an invariant relates several of them. This is the usual case.
  • Keep flat (separate
    var
    s) only when
    the variables are genuinely independent concerns that change on their own, or the spec is small enough that grouping adds no clarity. (Some canonical examples, e.g.
    ewd840
    , keep per-field maps flat because the fields don't form an obvious unit.)
The cohesive case — one unit:
quint
type State = {
  phase:    str,
  balances: NodeId -> int,
  members:  Set[NodeId],
}

var state: State
The
apply…
pure functions take and return
State
; actions assign the next-state record directly (
state' = applyTransfer(state, …)
). No per-field reassembly.
Scaling to N instances — when there are N actors, the record describes one actor's local state, and the variable becomes a map keyed by actor id. The pure functions are unchanged — they still operate on a single
LocalState
; only the wiring around them changes:
quint
type NodeId = int
type LocalState = { phase: str, balance: int, voted: bool }

var nodes:    NodeId -> LocalState   // per-actor cohesive state
var messages: Set[Message]           // genuinely global concern → its OWN var, not inside LocalState
One instanceN instances
state var
var state: State
var nodes: NodeId -> LocalState
pure fns
can…(State, …): bool
+
apply…(State, …): State
can…(LocalState, …): bool
+
apply…(LocalState, …): LocalState
— same shape
action readreads
state
nodes.get(id)
action write
state' = applyOp(state, …)
nodes' = nodes.set(id, applyOp(nodes.get(id), …))
init
one record literal
NODES.mapBy(_ => <record>)
The guideline, stated once: group cohesive state into a record (one var for one unit, an
Id -> LocalState
map for N), and keep genuinely independent concerns — the message soup, a shared registry — as their own separate vars
, never crammed into the per-instance record. Reach for flat per-field vars only when the fields don't cohere into a unit.
Communication shapes (decide alongside the state shape):
  • Message soup
    var network: Set[Message]
    , processes receive any in-flight message. The default for asynchronous protocols; use it unless ordering genuinely matters.
  • Shared memory — no message medium; processes read/write common state directly. See the bundled
    examples/ewd426.qnt
    (a token ring where each node reads its neighbours).
  • Ordered per-pair queues
    ... -> List[Message]
    with head/tail ops. Use only when delivery order is semantically required (e.g. logical-clock causality); it is more expensive to model-check than soup. The corpus spec
    LamportMutex
    is the canonical example — prefer soup unless you can name why ordering is load-bearing.
**从步骤1中回答的两个问题入手——参与者是谁以及他们如何通信。这两个问题共同驱动本步骤的所有结构决策。**它们并非背景信息,而是决定状态形状、消息媒介及框架的关键:
  • 参与者数量 → 状态形状。单个参与者 → 单个
    var s: Record
    。N个同类参与者 → 一个
    Id -> LocalState
    映射(记录描述单个参与者的状态;请参阅下方“扩展到N个实例”)。不同角色 → 要么在
    LocalState
    中添加字段(例如
    role: Coordinator | Participant
    ),要么使用单独的映射。只要正确确定参与者数量/角色,状态形状就会自然确定。
  • 参与者通信方式 → 消息媒介。消息传递 → 为媒介创建单独的
    var
    不要塞进
    LocalState
    :默认使用无序的
    Set[Message]
    消息池,仅当交付顺序至关重要时才使用
    ... -> List[Message]
    的点对点有序队列。无消息传递,通过公共状态协作 → 共享内存(无需媒介var;请参阅
    examples/ewd426.qnt
    )。这是下方详细说明的“通信形状”选择。
  • 参与者 + 通信方式 → 是否使用Choreo。多个参与者通过消息传递协作 → 默认使用Choreo(请参阅下方“默认使用Choreo”)。单个参与者,或通过状态共享协作的参与者 → 使用纯Quint。因此请先回答“谁、数量多少、如何通信?”这三个问题,下方的三个决策基本就已确定,无需编写类型定义。
将内聚状态分组为记录,让功能层操作该记录——这是推荐的形状,因为大多数情况下描述一个实体的变量是内聚的,分组可保持功能层简洁、不变量可读性强。保持变量扁平化是刻意的例外,而非同等默认选择:
  • 分组为记录的场景:字段是一个实体的本地状态(节点的阶段+日志+投票)、始终一起传递,或者某个不变量关联多个字段。这是常见场景。
  • 保持扁平化(单独
    var
    )的场景
    :变量是真正独立的关注点,各自独立变化,或者规格足够小,分组不会增加清晰度。(一些标准示例,例如
    ewd840
    ,保持每个字段的映射扁平化,因为这些字段无法形成明显的单元。)
内聚场景——单个单元:
quint
type State = {
  phase:    str,
  balances: NodeId -> int,
  members:  Set[NodeId],
}

var state: State
apply…
纯函数接收并返回
State
;动作直接分配下一个状态记录(
state' = applyTransfer(state, …)
),无需按字段重新组装。
扩展到N个实例——当有N个参与者时,记录描述单个参与者的本地状态,变量变为以参与者ID为键的映射。纯函数保持不变——它们仍操作单个
LocalState
;仅周围的 wiring 发生变化:
quint
type NodeId = int
type LocalState = { phase: str, balance: int, voted: bool }

var nodes:    NodeId -> LocalState   // 每个参与者的内聚状态
var messages: Set[Message]           // 真正的全局关注点 → 单独的var,不放在LocalState中
单个实例N个实例
状态变量
var state: State
var nodes: NodeId -> LocalState
纯函数
can…(State, …): bool
+
apply…(State, …): State
can…(LocalState, …): bool
+
apply…(LocalState, …): LocalState
—— 形状相同
动作读取读取
state
nodes.get(id)
动作写入
state' = applyOp(state, …)
nodes' = nodes.set(id, applyOp(nodes.get(id), …))
init
单个记录字面量
NODES.mapBy(_ => <record>)
统一规则:将内聚状态分组为记录(单个单元对应一个var,N个单元对应
Id -> LocalState
映射),将真正独立的关注点——消息池、共享注册表——作为单独的var
,绝不要塞进每个实例的记录中。仅当字段无法内聚为单元时,才使用扁平化的每个字段var。
通信形状(与状态形状一起决定):
  • 消息池——
    var network: Set[Message]
    ,进程接收任何在途消息。异步协议的默认选择;除非排序真正重要,否则使用此方式。
  • 共享内存——无消息媒介;进程直接读写公共状态。请参阅附带的**
    examples/ewd426.qnt
    **(每个节点读取邻居状态的令牌环)。
  • 点对点有序队列——
    ... -> List[Message]
    ,包含头部/尾部操作。仅当交付顺序在语义上是必需的(例如逻辑时钟因果关系)时才使用;它的模型检查成本比消息池更高。标准示例
    LamportMutex
    是典型场景——除非能说明排序至关重要的原因,否则优先选择消息池。

Default to Choreo for distributed protocols

分布式协议默认使用Choreo

For any distributed, message-passing protocol with N processes (consensus, BFT, replication, multi-phase commit, broadcast/gossip, leader election), default to the Choreo framework — and make a deliberate, justified decision if you are not going to. This isn't a stylistic toss-up: Choreo's listen → react structure and its clean split between local-state updates and network effects (the
choreo::cue
pattern) actively push you toward a better-organized, more reviewable spec than a hand-rolled
NodeId -> LocalState
+ message-soup
step
. The default expectation is Choreo; plain Quint for a message-passing protocol is the thing that needs a reason.
The one legitimate reason to decline: on a small protocol, Choreo's scaffolding (the type boilerplate —
LocalContext
/
Transition
/effects — and framework wiring) can add more ceremony than the spec's structure is worth, making it materially more verbose for little gain. That's a real judgment call — but it is the only routine exception, and even then lean toward Choreo. Two structural cases also fall outside Choreo: a single actor (no inter-process messaging to choreograph) and shared-memory coordination (no message medium at all → plain Quint, see
examples/ewd426.qnt
).
Not a reason: "still exploring the design." You can explore in Choreo, and switching frameworks later is expensive — so don't defer the decision on those grounds.
This is a structural fork — decide it before writing logic. If you intend to go plain Quint for a protocol that would otherwise be a Choreo candidate, say so to the user, name the verbosity/benefit tradeoff, and get agreement (fold this into the type-sketch sign-off below).
When Choreo fits, the per-process state still follows the grouping rule above — Choreo just supplies the messaging and handler scaffolding around it. See
../quint-lang/guidelines/choreo.md
for the framework's API and patterns, and
examples/tendermint/
for a complete, runnable Choreo spec — real BFT consensus demonstrating the
choreo::cue
listen/act split,
agreement
/
validity
/
accountability
invariants, and
with_cue
-based tests in a separate
tendermintTest.qnt
(run via
--main=valid
).
Decide grouping (and Choreo-or-not) with the user and get explicit approval on the type sketch before writing any logic (Step 3). Typecheck the types +
var
declarations first; this is the highest-leverage review point — a wrong shape is expensive to unwind later.
对于任何包含N个进程的分布式消息传递协议(共识、BFT、复制、多阶段提交、广播/gossip、 leader选举),默认使用Choreo框架——如果不使用,请做出明确且合理的决策。这并非风格选择:Choreo的监听→响应结构,以及本地状态更新与网络效果的清晰分离(
choreo::cue
模式),会主动引导你构建组织更良好、更易于审查的规格,而非手动实现的
NodeId -> LocalState
+ 消息池
step
。默认预期是使用Choreo;对于消息传递协议,使用纯Quint需要理由。
唯一合理的不使用理由:在小型协议中,Choreo的脚手架(类型样板——
LocalContext
/
Transition
/effects——及框架wiring)会增加不必要的繁琐,导致规格明显更冗长,却几乎没有收益。这是一个真正的判断问题——但这是唯一常规例外,即使如此也应倾向于使用Choreo。还有两种结构场景也不适用Choreo:单个参与者(无需编排进程间消息传递)和共享内存协作(无消息媒介→使用纯Quint,请参阅
examples/ewd426.qnt
)。
不构成理由:*“仍在探索设计。”*你可以在Choreo中探索设计,之后切换框架成本很高——因此不要以此为由推迟决策。
这是一个结构性分支——编写逻辑前必须决定。如果对于原本适合Choreo的协议,你打算使用纯Quint,请告知用户,说明冗长性/收益的权衡,并获得同意(将此纳入下方的类型草图签署环节)。
当Choreo适用时,每个进程的状态仍遵循上述分组规则——Choreo只是在其周围提供消息传递和处理程序脚手架。请参阅
../quint-lang/guidelines/choreo.md
了解框架的API和模式,以及**
examples/tendermint/
**——一个完整的可运行Choreo规格,展示了真实BFT共识中的
choreo::cue
监听/动作分离、
agreement
/
validity
/
accountability
不变量,以及单独
tendermintTest.qnt
中基于
with_cue
的测试(通过
--main=valid
运行)。
与用户一起决定分组(及是否使用Choreo),并在编写任何逻辑前获得对类型草图的明确批准(步骤3)。先对类型+
var
声明进行类型检查;这是最高效的审查环节——错误的形状后期修正成本很高。

Step 3 — Write the functional logic (pure functions)

步骤3 — 编写功能逻辑(纯函数)

Implement each operation's logic as
pure def
s — they take the state record and arguments and return plain values, so you can exercise them in the REPL with hand-built states. Split the two distinct questions a transition answers:
  • Can it happen? — a
    pure def … : bool
    guard (the precondition).
  • What's the resulting state? — a
    pure def … : State
    that computes the next state, assuming the guard holds.
quint
pure def canTransfer(s: State, from: NodeId, recipient: NodeId, amount: int): bool =
  amount > 0 and from != recipient and s.balances.get(from) >= amount

pure def applyTransfer(s: State, from: NodeId, recipient: NodeId, amount: int): State =
  { ...s, balances: s.balances.setBy(from, b => b - amount)
                              .setBy(recipient, b => b + amount) }
Write signatures first (stub the bodies), typecheck, then fill in one function at a time and exercise each in the REPL with a hand-built state before moving on. If the output surprises you, the function has a bug — fix it before wiring it into the state machine.
Keep the guard separate from the update because they are separate facts about the operation: one says when it is allowed, the other says what it does. The action (Step 4) uses the guard to decide whether it fires at all. A single
pure def
returning
{ success, newState }
— handing back the unchanged state when the guard fails — fuses the two and pushes the action toward a no-op branch; prefer the split. (If a failed operation is itself something the system observes and reacts to, model that failure as its own guarded action, not as a fallback.)
将每个操作的逻辑实现为
pure def
——它们接收状态记录和参数并返回纯值,因此你可以在REPL中使用手动构建的状态测试这些函数。拆分转换需要回答的两个不同问题:
  • 是否允许执行?——
    pure def … : bool
    守卫条件(前置条件)。
  • 执行后的状态是什么?——
    pure def … : State
    ,计算下一个状态,假设守卫条件成立。
quint
pure def canTransfer(s: State, from: NodeId, recipient: NodeId, amount: int): bool =
  amount > 0 and from != recipient and s.balances.get(from) >= amount

pure def applyTransfer(s: State, from: NodeId, recipient: NodeId, amount: int): State =
  { ...s, balances: s.balances.setBy(from, b => b - amount)
                              .setBy(recipient, b => b + amount) }
先编写签名(存根实现),进行类型检查,然后逐个填充函数体,并在编写下一个函数前,在REPL中使用手动构建的状态测试每个函数。如果输出不符合预期,说明函数存在bug——在将其连接到状态机前修复。
将守卫条件与更新逻辑分离,因为它们是关于操作的两个独立事实:一个说明何时允许执行,另一个说明执行后会发生什么。动作(步骤4)使用守卫条件决定是否触发。单个
pure def
返回
{ success, newState }
——当守卫条件失败时返回不变的状态——会将两者融合,并导致动作倾向于无操作分支;因此优先选择拆分。(如果失败的操作本身是系统需要观察并响应的事件,请将该失败建模为单独的受保护动作,而非作为回退。)

Step 4 — Wire the state machine (guarded actions)

步骤4 — 连接状态机(受保护动作)

An action puts the guard and the update together in one
all { ... }
: the guard gates whether the action fires, the update sets the next state. When the guard is false the action is disabled — it does not fire and does not change anything. No
success
flag, no no-op branch.
quint
action init: bool = all {
  state' = { phase: "idle",
             balances: NODES.mapBy(_ => INITIAL_BALANCE),   // pre-populate every map
             members: NODES },
}

action transferAction(from: NodeId, recipient: NodeId, amount: int): bool = all {
  canTransfer(state, from, recipient, amount),          // guard — disables the action when false
  state' = applyTransfer(state, from, recipient, amount),
}
A disabled action is the faithful model of "this cannot happen in this state." Resist adding a blanket
else
/
unchanged_all
fallback to keep things moving — that puts a transition in the model that the real system can't take, which is a modeling inaccuracy regardless of what you do with the spec afterward.
An action and a
pure def
cannot share a name. Pre-populate every map in
init
.get()
on an absent key is undefined behavior. See quint-lang for the full list of such gotchas.
动作在
all { ... }
中将守卫条件和更新逻辑结合在一起:守卫条件决定动作是否触发,更新逻辑设置下一个状态。当守卫条件为false时,动作被禁用——不会触发,也不会改变任何状态。无需
success
标志,无需无操作分支。
quint
action init: bool = all {
  state' = { phase: "idle",
             balances: NODES.mapBy(_ => INITIAL_BALANCE),   // 预填充每个映射
             members: NODES },
}

action transferAction(from: NodeId, recipient: NodeId, amount: int): bool = all {
  canTransfer(state, from, recipient, amount),          // 守卫条件——为false时禁用动作
  state' = applyTransfer(state, from, recipient, amount),
}
被禁用的动作是“在该状态下无法执行此操作”的忠实模型。不要添加通用的
else
/
unchanged_all
回退来推进流程——这会在模型中添加真实系统无法执行的转换,无论后续如何使用规格,这都是建模不准确的表现。
动作和
pure def
不能同名。在
init
中预填充每个映射——对不存在的键调用
.get()
是未定义行为。请参阅quint-lang了解此类陷阱的完整列表。

Step 5 — Properties: witnesses first, then invariants

步骤5 — 属性:先见证者,后不变量

Witnesses first. A witness names a target state and asks whether it is reachable — write the predicate positively (the state you hope to reach) and pass it to
quint run --witnesses
, which reports how many sampled traces reached it. Add one per major action: a witness reached in 0 traces means the action is dead (its precondition can never hold), so fix that before spending effort on safety.
quint
// the state you want to be reachable — written plainly, no negation
val someBalanceChanged: bool =
  state.members.exists(n => state.balances.get(n) != INITIAL_BALANCE)
quint run spec.qnt --witnesses someBalanceChanged --max-steps 10
  → someBalanceChanged was witnessed in 90 trace(s) out of 100 explored (90.00%)
A non-zero count means the state is reachable; 0% means it never happened — investigate.
There is a second, complementary form: write the negation as an invariant —
val w = not(target)
— so a reported "violation" is an execution that reaches the state. It can't batch with the other invariants and you read a "violation" as success, but it gives you something
--witnesses
cannot: an actual trace that reaches the state. Pick by what you need —
--witnesses
for the routine reachability + coverage check, the negated-invariant form when you want to see the path (to understand or document how the state is reached, or to debug a witness that fires unexpectedly).
Then invariants — the safety properties that must hold in every reachable state:
quint
val noNegativeBalances: bool =
  state.members.forall(n => state.balances.get(n) >= 0)
Invariants and witnesses run together in one pass:
quint run --invariant noNegativeBalances --witnesses someBalanceChanged
.
When a source yields many candidate properties and you need to prioritize verification effort, the from-requirements flow describes a High/Medium/Low impact-scoring aid (
guidelines/from-requirements.md
); it applies wherever you're modelling against stated requirements.
(Witness vs invariant interpretation, fairness, and temporal properties are detailed in quint-lang's
guidelines/simulations.md
.
someBalanceChanged
is state evidence — phrase such a target so only an action can make it true, since a predicate that already holds at
init
is "reached" at step 0 and witnesses nothing. To confirm a specific action fired when several could reach the same state, use a per-action flag
var
— quint-lang's
guidelines/patterns.md
§7 covers it and how to debug a witness that never fires.)
先见证者。见证者指定目标状态,并询问该状态是否可达——正向编写谓词(你希望达到的状态),并将其传递给
quint run --witnesses
,该命令会报告有多少抽样轨迹到达了该状态。为每个主要动作添加一个见证者:如果见证者的轨迹数为0,说明该动作是死的(其前置条件永远无法满足),因此在投入精力处理安全属性前修复该问题。
quint
// 你希望可达的状态——直接编写,不要否定
val someBalanceChanged: bool =
  state.members.exists(n => state.balances.get(n) != INITIAL_BALANCE)
quint run spec.qnt --witnesses someBalanceChanged --max-steps 10
  → someBalanceChanged在100条探索轨迹中被见证了90条(90.00%)
非零计数表示状态可达;0%表示从未达到——请调查原因。
还有一种互补形式:将目标状态的否定编写为不变量——
val w = not(target)
——这样报告的“违规”就是到达该状态的执行轨迹。它不能与其他不变量批量运行,并且你需要将“违规”视为成功,但它能提供
--witnesses
无法提供的内容:到达该状态的实际轨迹。根据需求选择——
--witnesses
用于常规可达性+覆盖检查,否定不变量形式用于查看路径(了解或记录如何到达该状态,或调试意外触发的见证者)。
然后是不变量——必须在所有可达状态中成立的安全属性:
quint
val noNegativeBalances: bool =
  state.members.forall(n => state.balances.get(n) >= 0)
不变量和见证者可在一次运行中同时检查:
quint run --invariant noNegativeBalances --witnesses someBalanceChanged
当输入源提供大量候选属性,而你需要优先验证工作时,基于需求的流程描述了高/中/低影响评分辅助工具(
guidelines/from-requirements.md
);该工具适用于任何基于既定需求建模的场景。
(见证者与不变量的解释、公平性及时态属性在quint-lang的
guidelines/simulations.md
中有详细说明。
someBalanceChanged
状态证据——请将此类目标表述为只有动作才能使其为真,因为在
init
时已成立的谓词会在步骤0“到达”,无法见证任何内容。要确认多个动作中哪个触发了状态变化,请使用每个动作对应的标志
var
——quint-lang的
guidelines/patterns.md
第7节涵盖了此内容及如何调试从未触发的见证者。)

Step 6 — Compose
step
and simulate

步骤6 — 组合
step
并模拟

Compose the actions in
any { ... }
and stress the model with random walks.
quint
action step: bool = {
  nondet from = NODES.oneOf()
  nondet dst  = NODES.oneOf()   // `dst`, not `to` (a built-in) — see quint-lang
  any {
    transferAction(from, dst, 10),
    // other actions…
  }
}
  • Check witnesses with
    quint run --witnesses
    — expect a non-zero trace count (reachable); 0% means a dead action.
  • Check invariants with
    quint run
    (sampled) — expect no violation; read counterexample traces step by step when one appears. Use
    quint run
    for this, not
    quint verify
    (see the "Executable from the first line" principle —
    verify
    is only for when the user explicitly asks to model-check).
any { ... }
中组合动作,并通过随机游走测试模型。
quint
action step: bool = {
  nondet from = NODES.oneOf()
  nondet dst  = NODES.oneOf()   // 使用`dst`而非`to`(`to`是内置关键字)——请参阅quint-lang
  any {
    transferAction(from, dst, 10),
    // 其他动作…
  }
}
  • 使用
    quint run --witnesses
    检查见证者——预期轨迹数非零(可达);0%表示动作已死。
  • 使用
    quint run
    (抽样)检查不变量——预期无违规;若出现违规,请逐步阅读反例轨迹。使用
    quint run
    进行此操作,不要使用
    quint verify
    (请参阅“从第一行代码即可执行”原则——
    verify
    仅用于用户明确要求模型检查的场景)。

A guarded model can reach a state where nothing is enabled

受保护模型可能进入无动作可用的状态

Because actions are guarded (Step 4), the model can reach a state with no enabled action. The trap during a build:
quint run
does not detect deadlocks
— it stops early and prints
[ok] No violation found
with a trace shorter than
--max-steps
. A short trace is the only hint, and the run reads as success, so watch trace length when you expect the protocol to keep going. (For the record,
quint verify
reports a deadlock outright —
Found a deadlock
— but that's a model-checking step, only in play if the user explicitly asks for it; don't switch to
verify
just to chase a suspected deadlock mid-build — inspect the short trace with
quint run
instead.)
Whether reaching such a state is correct depends on the system: one that should keep serving must never get stuck, while one that genuinely finishes (everyone committed, nothing left to do) is supposed to end in a terminal state. The tool can't tell these apart — only you know which you intended. (This is the other reason Step 4 avoids the no-op fallback: a blanket
unchanged_all
lets every trace extend forever, so the model can never reach — or reveal — such a state.)
A note on bounding.
--max-steps
(how deep a run explores) is not a modeling decision — it's always there. Whether the model itself has a bounded state space is: some protocols are terminating by construction (a one-shot commit reaches a final state and stops), and modeling them that way is correct — reaching a terminal state is the design, not a deadlock. You may also bound an otherwise-unbounded model deliberately to shrink the state space, but never with a no-op escape hatch (Step 4) — bound it by modeling the real terminal states, not by a fake step.
Tests (
run
definitions) go in a separate
*_test.qnt
file
that imports the spec — keep the main module free of test scenarios. Build incrementally: typecheck after every addition, simulate after every action.
由于动作是受保护的(步骤4),模型可能进入无可用动作的状态。构建过程中的陷阱:
quint run
不会检测死锁
——它会提前停止并打印
[ok] No violation found
,且轨迹长度短于
--max-steps
。短轨迹是唯一的提示,且运行结果显示为成功,因此当你预期协议会持续运行时,请关注轨迹长度。(需要说明的是,
quint verify
会直接报告死锁——
Found a deadlock
——但这是模型检查步骤,仅当用户明确要求时才使用;不要在构建过程中为了追查疑似死锁而切换到
verify
——请改用
quint run
检查短轨迹。)
进入此类状态是否正确取决于系统:应持续提供服务的系统绝不能卡住,而真正会结束的系统(所有参与者已提交,无剩余操作)应该进入终端状态。工具无法区分这两种情况——只有你知道预期的结果。(这也是步骤4避免无操作回退的另一个原因:通用的
unchanged_all
会让所有轨迹无限延长,因此模型永远无法到达——或揭示——此类状态。)
关于边界的说明
--max-steps
(运行探索的深度)不是建模决策——它始终存在。而模型本身是否有有界状态空间是建模决策:一些协议本质上是终止的(一次性提交会到达最终状态并停止),按此建模是正确的——到达终端状态是设计预期,而非死锁。你也可以故意限制原本无界的模型以缩小状态空间,但绝不要使用无操作逃逸舱(步骤4)——通过建模真实的终端状态来限制,而非添加虚假步骤。
测试(
run
定义)应放在单独的
*_test.qnt
文件
中,该文件导入主规格——保持主模块不含测试场景。增量构建:每次添加内容后进行类型检查,每次添加动作后进行模拟。

Step 7 — Self-review, then hand off the spec

步骤7 — 自我审查,然后交付规格

Before writing the handoff record, do a quick pass over your own spec against the same bar a reviewer would apply — catching these now is cheaper than having them bounce back. Most you've already followed while building; this is the closing check that they all hold together:
  • Guarded actions — no business logic in an action beyond the guard and assignments; it lives in
    pure def
    s, and the guard is lifted into the action so it's disabled when it can't fire (Step 4).
  • Witness per major action — every action in
    step
    has a witness, and each one is reached in
    0 traces under
    quint run --witnesses
    , not dead at 0 traces (Step 5).
  • At least one protocol-level invariant — a real safety property (no two leaders, conservation, agreement), not just a type/bounds check; and every invariant references a
    var
    .
  • init
    assigns every
    var
    and every action assigns every
    var
    (a missing
    x' = x
    is a silent stutter bug); every map is pre-populated (Step 4).
  • Right abstraction — IDs are opaque (no string manipulation), messages are a
    Set
    unless ordering is the property, no serialization/retry/memory detail leaked in.
If a spec is large or being handed to someone else, run the full audit in
guidelines/review.md
(the C1–C6 / R1–R2 checklist with a report) — reach for it when a self-pass isn't enough.
A finished spec needs a short record so the next reader knows its scope and can trust it. Produce one (as a comment block, a
README.md
, or whatever the flow prefers) covering:
  • What it covers — which modules/protocols are modelled, which assumptions (
    const
    declarations, with
    run
    -test checks for the ones that must hold) are encoded, which properties are verified.
  • What it does NOT cover — implementation detail left out on purpose, edge cases excluded (and why), properties still to add. This honesty note is what stops the spec from being over-trusted.
  • When to update it — for a spec that grounds an artifact (code, a TLA+ source), update the spec and re-verify before changing the artifact.
The spec is the ground truth. Never edit the spec to match broken code. If the spec is wrong, stop and discuss with the user — that is the highest-leverage review point.
Each flow adds its own format detail (source-file correspondence for code, a translation README with recorded
quint run
output for TLA+, a comment block for an interactive build).

在编写交付记录前,对照审查者的标准快速检查自己的规格——现在发现问题比后续返工成本更低。大多数规则在构建过程中已遵循;这是确保所有规则协同工作的最终检查:
  • 受保护动作——动作中除守卫条件和赋值外无业务逻辑;业务逻辑位于
    pure def
    中,守卫条件被提升到动作中,以便在无法触发时禁用动作(步骤4)。
  • 每个主要动作对应一个见证者——
    step
    中的每个动作都有见证者,且在
    quint run --witnesses
    下的轨迹数>0,而非0轨迹的死动作(步骤5)。
  • 至少一个协议级不变量——真实的安全属性(无双leader、守恒、一致性),而非仅类型/边界检查;且每个不变量都引用
    var
  • init
    为每个
    var
    赋值
    ,每个动作都为每个
    var
    赋值(缺少
    x' = x
    是静默的停滞bug);每个映射都已预填充(步骤4)。
  • 正确的抽象——ID是不透明的(无字符串操作),消息是
    Set
    (除非排序是要验证的属性),无序列化/重试/内存细节泄露。
若规格较大或要交付给他人,请运行
guidelines/review.md
中的完整审计(包含C1–C6 / R1–R2清单及报告)——当自我检查不够时使用此方法。
完成的规格需要简短的记录,以便后续读者了解其范围并信任它。生成一份记录(作为注释块、
README.md
或流程偏好的格式),涵盖:
  • 涵盖内容——建模了哪些模块/协议,编码了哪些假设(
    const
    声明,包含对必须成立的假设的
    run
    测试检查),验证了哪些属性。
  • 未涵盖内容——故意省略的实现细节,排除的边缘情况(及原因),仍需添加的属性。这份诚实的说明可防止规格被过度信任。
  • 更新时机——对于作为工件(代码、TLA+源)基础的规格,在更改工件之前更新规格并重新验证。
**规格是基准事实。绝不要为了匹配有问题的代码而修改规格。**若规格错误,请停止并与用户讨论——这是最高效的审查环节。
各流程会添加自己的格式细节(代码的源文件对应关系、包含记录的
quint run
输出的转换README、交互式构建的注释块)。

Conventions

约定

  • NodeId
    /
    LocalState
    for per-actor state, and
    NODES
    for the participant set (the
    Set[NodeId]
    the model ranges over); descriptive type names after the protocol (
    VoteRequest
    ,
    PrepareMsg
    ) not
    Msg1
    ,
    Msg2
    .
  • Small domains:
    N = 3
    actors (or
    N = 2
    for first exploration) and value ranges like
    0..10
    are enough for most safety properties and keep simulation cheap. Use symbolic IDs (
    int
    or
    str
    ) for participants.
  • Message soup: store sent messages in one
    Set
    and don't model delivery order unless ordering is what you're verifying.
  • Abstract time: when the protocol has a notion of time or progress, model it as an integer round/epoch counter (
    round: int
    ), not wall-clock time or timer mechanics — a timeout is just the round advancing. Exception: model timing explicitly only when the timing bound itself is what you're verifying.
  • Start with one action: get
    init
    + one action + one witness working before adding the rest. For N actors, model one actor first, verify, then generalize to the map — never wire up all N at once.
  • Test pure functions in isolation in the REPL with hand-built states; that catches logic bugs before they get tangled into the state machine.
For everything syntactic — operators, undefined-behavior rules,
basicSpells
, the CLI flags — consult the quint-lang reference rather than reproducing it here.
  • 使用
    NodeId
    /
    LocalState
    表示每个参与者的状态,使用
    NODES
    表示参与者集合(模型覆盖的
    Set[NodeId]
    );使用协议相关的描述性类型名称(
    VoteRequest
    PrepareMsg
    )而非
    Msg1
    Msg2
  • 小型领域:
    N = 3
    个参与者(或首次探索时
    N = 2
    ),值范围如
    0..10
    足以验证大多数安全属性,并保持模拟成本低廉。使用符号ID
    int
    str
    )表示参与者。
  • 消息池:将已发送的消息存储在一个
    Set
    中,除非要验证排序,否则不要建模交付顺序。
  • 抽象时间:当协议有时间或进度概念时,将其建模为整数轮次/纪元计数器
    round: int
    ),而非 wall-clock时间或计时器机制——超时仅表示轮次推进。例外情况:仅当要验证时间边界本身时,才显式建模时间。
  • 一个动作开始:在添加其他动作前,先让
    init
    +一个动作+一个见证者正常工作。对于N个参与者,先建模一个参与者,验证后再推广到映射——不要同时连接所有N个参与者。
  • REPL中单独测试纯函数,使用手动构建的状态;这会在逻辑bug卷入状态机前发现它们。
所有语法相关内容——运算符、未定义行为规则、
basicSpells
、CLI标志——请查阅quint-lang参考文档,而非在此处重复。