sprawling-runtime 0.0.10

One run of a sprawling resident, from dispatch to freeze: turns, tools, replay and the sandbox that executes code.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
-- This Source Code Form is subject to the terms of the Mozilla Public
-- License, v. 2.0. If a copy of the MPL was not distributed with this
-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-- Copyright (c) 2026 2youg1 and the sprawling contributors

/-!
# runtime::turn

规定 `turn`、`turn::boundary`、`turn::report`、`turn::wave`、`turn::wave::reorder`、`turn::ledger`、`turn::prompt`(`crates/runtime/src/` 下同名的文件)。回合的 typestate 四相、四个取消点、工具波的入账次序与回合的账本门。本文件是 `crates/runtime/Spec.lean` 的一个分部;下面每一节保留它在 runtime 规格里的标签 §8-n,别处引作 `crates/runtime/Spec.lean §8-n`。
-/

/-!
### 8-3 runtime::turn(形状 5 typestate 机)+bench/conversation


**一个 typestate 机、一张工作台、一份会话历史,三种形状三个模块。**
- `bench`(形状 1 判定):`ToolBench`/`BenchOutcome` 与三条必要前提次序(去重先于副作用;exec 的 discard 预报先于 Write 门;Deny 以 `tool_result` 回去而不结束回合)。它拥有的是**次序**;工具本身以 `Box<dyn Tool>` 递入,沙盒在缝上,副作用不归它。
- `conversation`(形状 2 值):`Conversation`,以及再导出的 `Opening`(定义住 kernel,因为 `run_started` 要带它,`crates/kernel/Spec.lean` §8-82-1)。它是会话,不是 transcript(冻结后写下的逐 run 文件),也不是 `kernel::Window`(上下文大小)。一条不变量在每一个入口上成立——**连续的 user 内容并进已开的那条消息,而不另开一条**;steer、工具结果与开场任务是同一条规则的三扇门。
- 根重导出:`runtime::Opening` 在根上;`ToolBench` 只经 `runtime::bench`。

```rust
pub struct Turn<'h, S> { /* journal(借来的 `HeldLines`、t、钟、redacted)、state —— 全私有;相内数据在别的相不可表示 */ }
pub struct Assembling(/* 私有 */);  pub struct Calling { /* prefix 哈希 */ }
pub struct ToolWave { /* calls */ }   pub struct Recording { /* refs */ }

pub enum Interrupt { None, Cancel, Steer { source: String, text: String } }
pub enum PhaseOutcome<Next> { Advanced(Next), Cancelled(TurnCancelled) }
// PhaseOutcome 刻意穷尽:新结局必须逼每个执行器表态,不得掉 catch-all(全库枚举皆闭)。
pub struct TurnCancelled { /* refs:含 cancel_received —— 私有,getter 取 */ }
pub struct TurnReport { /* refs、model_returned_ref、wave_len —— getter 取 */ }

impl<'h> Turn<'h, Assembling> {
    pub fn begin(run: RunId, who: String, t: TimeMs, now: &'h mut dyn FnMut() -> Result<TimeMs, AxError>)
        -> Turn<'h, Assembling>;   // t 是回合时间戳;now 只给四种等来的时刻采样
    /// Boundary 1 (组装前). Cancel here consumes before any model bytes.
    pub fn assemble<'c>(self, interrupt: Interrupt, ledger: &mut dyn Ledger, prompt: RunPrompt<'_>, …)
        -> Result<PhaseOutcome<Turn<'h, Calling>>, AxError>;      // 每 run 一条 prompt_assembled(§8-39 第 5 条)
}
impl Turn<'_, Calling> {
    /// Boundary 2 (provider 调用前).
    pub fn call(self, interrupt: Interrupt, ledger: &mut dyn Ledger, model: &mut dyn Model,
                policy: &BuildingPolicy, generating: Generating<'_, '_>)
        -> Result<PhaseOutcome<Turn<'_, ToolWave>>, AxError>;     // 产 model_called+model_returned
}
/// 模型还在生成时,谁据它的回答行动:走哪扇门由这里一处定。
/// 生产路径(run::lifecycle)每回合都传 Speculating,没有页面在看时 deltas 为 None;
/// call_speculating 的默认实现落到 call_streaming,所以没人看的 run 也走流,ModelCall 的 streamed 为真,
/// 流式失败的重发修复对它同样适用。Unwatched 与 Watched 今天只有测试调用。
pub enum Generating<'a, 'sink> {
    Unwatched,                                            // 阻塞门 Model::call
    Watched(&'a mut (dyn FnMut(&Increment) + 'sink)),     // 流式门 call_streaming,增量给页面
    Speculating { deltas: Option<&'a mut (dyn FnMut(&Increment) + 'sink)>,
                  tools: &'a dyn ConcurrentInvoke },      // 推测门 call_speculating,见下「生成中起跑」
}
impl Turn<ToolWave> {
    /// Boundary 3 (工具执行前);per call tool_called + tool_result,按调用序入账。
    /// 开头连续的 Effect::Read 调用同时执行,结果进重排缓冲;
    /// 第一条非只读调用等它们全部收齐后再开始,此后串行。
    /// still_going 在每条 call 之前被问一次(见 §8-28-2)。
    /// 一条入账失败即返回,排在它后面的已放行只读调用不入账:`admit` 对 Effect::Read
    /// 不写去重表、taint 与检查点,所以它们没有留下账本不知道的状态。
    pub fn execute_concurrent(self, interrupt: Interrupt, ledger: &mut dyn Ledger,
                              tools: &mut dyn ConcurrentInvoke,
                              still_going: &mut dyn FnMut(u32) -> Interrupt)
        -> Result<PhaseOutcome<Turn<Recording>>, AxError>;
}
pub trait ConcurrentInvoke {          // 在 crate 根重导出:runtime::ConcurrentInvoke/runtime::Admitted
    fn effect_of(&self, call: &ToolCall) -> Option<Effect>;   // None=目录不认识的工具,按非只读处理
    fn ahead(&self, call: &ToolCall) -> Option<&dyn Tool> { None }  // 未放行就借出的只读工具;None=不提前起跑
    fn admit(&mut self, call: &ToolCall, t: TimeMs) -> Admitted;          // 一段:按调用序串行放行
    fn tool(&self, ticket: &bench::Ticket) -> Result<&dyn Tool, AxError>; // 二段:借出工具,多线程调
    fn account(&mut self, call: &ToolCall, ticket: bench::Ticket,
               answered: Result<ToolOutcome, AxError>) -> Result<ToolOutcome, AxError>;  // 三段:按调用序串行记账
}
pub enum Admitted { Answered(Result<ToolOutcome, AxError>), Cleared(bench::Ticket) }
impl<F: FnMut(&ToolCall, TimeMs) -> Result<ToolOutcome, AxError>> ConcurrentInvoke for F { /* 放行即作答;不报效果,故整波串行 */ }
pub enum NextCall { Allowed, Halted }   // RunHooks::wait 的答案:等待中只有「停」可以立刻执行
impl Turn<Recording> {
    pub fn record(self, interrupt: Interrupt, ledger: &mut dyn Ledger) -> Result<PhaseOutcome<TurnReport>, AxError>;
}
```

- **相变函数携 `&mut dyn Ledger`,相内字段私有**;无返回既往相的方法;跳相/相内取消/字面量构造中间相,三者编译不过(trybuild)。
- **取消只在边界**:每相变函数首参即边界快照;命中 Cancel → 追加 cancel_received → 返回 Cancelled(回合终止,后续 handoff_written+run_frozen 归执行器)。相内无任何中断入口=A9 的结构化一半;另一半(事件序断言)在 citysim。**工具波内的每条 call 同样是一道边界**:一波是 N 件副作用而不是一件,于是 `execute_concurrent` 在每条 call 之前问 `still_going`,答案交给同一个 `consume_boundary`:Cancel 写下同一条 `cancel_received`,Steer 写下同一条 `steer_received`——消费中断的地方仍然只有一处。
- **四取消点**:组装前/provider 调用前/工具执行前/派生前,四点全住本模块。第四点由 `Turn<Recording>::record` 收边界快照,故 `record` 与前三相同形——收 `Interrupt`、答 `PhaseOutcome`。它买到的是别处买不到的一件事:**一个回合把活派下去之后、子 Run 起来之前,仍停得住**;`calls_made == 0` 的收尾回合尤其如此,那一刻在第四点之前根本没有下一个边界。
- **model_called 载荷**:segments 哈希(与 prompt_assembled 同源);model_returned 载荷=message+calls 数。
- **前缀冻结是运行时不变量,不只是测试。** `assemble` 走 `prefix.verified_segment_hashes()?`:从 `bytes()` 重算四段哈希并与构造时记录的对拍,不等即 `E_CAS_CORRUPT` **拒绝**(不是警告),恢复语指名一条走得通的路——换一个地址派这件活(§8-4-1);`call` 在写 `model_called` 之前对 `chat.system` 的四块做同一断言(`prefix::verified_system_hashes`),哈希不等或某块丢掉断点同拒。**两处都接在既有的每回合摘要上,不另起记录点**;离线口径同一条断言(`replay::rebuild_prefix` 从载荷与同源文档重算对拍)。
- **CallShape 的冻结由 `CallShape::verified_against(frozen)` 一处判定。** model/effort/`max_tokens` 三个上线字段任一变了即 `E_CONFIG_INVALID` 拒绝,恢复语先指「把动过的那一项改回去」,再指同一句换地址(§8-4-1);`context_tokens` 只喂本地提醒、不上线,不参与比较。派活面的拦截点(`Command::Dispatch { effort }` → `accounting::worker::dispatching::running` → `city::config_layers::write_session`)在放行写房间 effort 之前问这一句;运行时只立判定与拒绝路径,拦在哪里归装配层。
- **只读调用并行执行,按调用序入账(确定性 5)。** 效果由工具自己声明(`kernel::Effect`),执行器不猜:一波开头连续的 `Effect::Read` 调用同时起跑,第一条在本线程跑,其余各占一个 `std::thread::scope` 线程,scope 返回前全部 join;结果按调用序进重排缓冲,再逐条经 `account` 写 `tool_called`+`tool_result`——入账只此一处,串行段与并行段共用,所以事件的次序与载荷与串行执行一致;各行的时刻是量出来的,并行时本就不同(§8-15)。`tests/run_driver.rs` 与 `turn/tests/concurrent.rs` 在停住的时钟下对拍,逐字节相同。第一条非只读调用就是 checkpoint:它等前面的只读调用收齐才开始,此后整波串行,因为写与写、写与读之间的先后是可观察的。`still_going` 对开头那段只读调用在起跑前逐条先问,Cancel 落在第 k 条就只起跑前 k 条——正是串行波在同一处停下之前会做的那几条;各条的答案留到该条入账之前才交给 `consume_boundary`,所以 Steer 的 `steer_received` 落在串行波写它的同一位置。线程崩溃不是回合错误:该条以 `E_TOOL_UNAVAILABLE` 回给模型。
- **`ConcurrentInvoke` 是三段,不是一个闭包。** 放行(`admit`,`&mut`,按调用序)、执行(`tool` 借出 `&dyn Tool`,`&self`,各条在 scope 线程上调它的 `invoke`)、记账(`account`,`&mut`,按调用序)。一个包着 bench 的闭包表达不了这个次序:放行与记账写同一张去重表与同一份 taint,而 bench 不是 `Sync`(checkpoint 持有 git 仓库句柄),工具是(`kernel::Tool: Send + Sync`,`invoke(&self)`;有内部状态的工具把状态放在自己的锁后)。三个闭包也不行:三者要借同一个 bench,一个要 `&`、两个要 `&mut`。所以它是 trait——第二实现在缝上已经存在:闭包的全覆盖实现(放行即作答,不报效果,于是 citysim 与脚本化工具的测试走串行、字节不动),与装配层 `accounting::worker::driving::placing` 的 bench 实现(`crates/sprawling/Spec.lean` §8-31)。只读调用的门不读 taint,所以先放行后记账不改变任何一扇门的判定;`IdemKey` 的位置在放行时按调用序定下,exec 计数与 checkpoint 记录在记账时按调用序累加,所以它们与串行波逐字相同。落选的是「lane 把整个 bench 放进锁、闭包取 `Sync`」:锁把三条读排成一条队,并行只剩名字。生产路径由 `tests/run_driver.rs` 的三读测试守着:`drive` 走 `lifecycle` 到 `execute_concurrent`,三条读彼此重叠,次序与载荷与串行逐行相同。

- **一个回合的落盘屏障(runtime D24,TF1):只读调用不在执行前等落盘,写调用的意图先落盘。** 一道屏障的价钱几乎与它带几条记录无关(`kernel::Ledger::append_all` 的文档:一条一屏障约 585 µs,五十条共用一道时每条约 13 µs),所以要省的是屏障的次数。**参照**是逐条落盘:每次追加一次 `Ledger::append`,经 `accounting::worker::relay` 一个阻塞的往返,一回合 `4 + 2 × 调用数` 道屏障(连同 run 自己的两行,`reference_turn_barriers`),且若 `tool_called` 在工具执行之后才写,写动手之后、记录落盘之前掉电,重启后的历史里没有这次写,`replay::DanglingCalls` 也找不到它——ARCHITECTURE §5 第 4 步要排除的正是这个。**决定的次序**:只读调用(`Effect::Read`)追加 `tool_called` 后不等它落盘就执行,`tool_result` 也只追加;写调用追加 `tool_called` 后等它落盘再动手(意图先落盘),它的 `tool_result` 搭下一道屏障;下一个对外可见的效果——下一次模型调用、一次写、run 的冻结——之前一道屏障把此前追加的记录一起落盘。一个回合因此付 `1 + 写调用数` 道,一波最多一条写调用时不超过 2。`EventRef` 仍只为已落盘的记录交出:只读调用的 ref 在那道屏障处才到回合手里,所以「`Ok(ref)` 即已落盘」的端口契约(`kernel::ledger`)一字不改,改的是回合在什么时候需要 ref。性质的权威是 `crates/runtime/spec/Turn/Durability.lean`:任一崩溃点,对外效果之前此前追加的每条记录都已落盘(`tf1_effect_after_durability`);写动手时它的 `tool_called` 已在盘上(`tf1_write_intent_durable`);记录与逐条落盘的参照次序逐条相同,盘上的总是它的前缀(`tf1_records_match_reference`、`tf1_durable_is_reference_prefix`),于是 `resume` 面对的是参照次序也会留下的历史,缺的只有没有对外效果的只读调用,重做它们不改变世界;屏障数见 `tf1_turn_barriers`、`reference_turn_barriers`。**形状**:「追加未落盘」住在 run 的 `turn::ledger::HeldLines` 里,回合经它的账本门(`Journal`)借用它——`append_authored`/`append_redacted` 只把 draft 攒下并交回它在 run 记录里的位置(`Entry`),`HeldLines::barrier` 用一次 `Ledger::append_all` 交出攒下的全部并在此刻收下 ref;屏障在三处:`model_called` 之后、模型调用之前(`recovery::ModelCall::record`;run 第一回合的 `prompt_assembled`、插话的 `steer_received` 与 run 自己的 `prompt_shape_compared` 都搭这一道),放行的非只读调用的 `tool_called` 之后、工具动手之前(`wave.rs` 的 `alone`),以及取消的 `cancel_received` 之后;回合收尾(`record`)不付屏障,run 自己在三处补一道:没有调用的回合结束 run 之前、`Run::freeze` 进门、`run::drive` 写承载事件之前(D36)。`TurnReport` 带的是 `model_returned` 的 `Entry`,`HeldLines::durable` 在下一道屏障之后才把它换成 ref。seq 在交出时由账本按攒下的次序给,所以次序仍是调用序(relay 的那一半见 `crates/sprawling/Spec.lean` §8-42-2)。**run 自己的行走回合的账本门**:run 在相与相之间写两行(`run::lifecycle`:组装之后的 `prompt_shape_compared`,回答之后、工具波之前的 `checkpoint_committed`),它们经 `Turn::hold_run_line` 作为第三种记录(`turn::ledger::RunLine`,run 写的、带 run 的地址、不扫秘密)排进回合攒下的记录里,于是攒下的记录跨过相而 seq 仍是追加的次序。检查点本身(把写域提交成 commit)不算对外边界:在它之后、下一道屏障之前掉电,只留下一个账本没有提到的 commit,世界里谁也看不见它。所以 Rust 一回合付 `1 + 写调用数` 道(`tf1_turn_barriers`),只读调用一道也不加,run 末尾再一道;攒下的记录跨过回合(runtime D36),性质(ref 在下一个对外效果之前到齐、崩溃点历史与收尾付屏障的 `closedTurn` 相同)在 `Durability.lean` 末两节。派生检查:`turn::tests::held`(经整个 run:两次模型调用之间 `1 + 写调用数`、记录次序同参照、任一行掉电留下前缀)、`turn::tests::durability` 的 `tf1_turn_barriers`(一个回合)与 `tf1_write_intent_is_durable_before_the_write_runs`(写动手时读盘上的记录),以及 `run::lifecycle::tests::tf1_run_turn_barriers`(run 自己的行不另付屏障)。代价:页面在那道屏障之后才看到只读调用的两行,晚的是那几条读的执行时间。**落选**:一波结束才一道屏障、写也不先落盘——写动手之后掉电留下一件账本不知道的事,正是第 4 步要排除的;逐条落盘(今天的参照)——安全,但每条只读调用多付两道屏障而不换来任何可恢复性。重新打开它的参数:一次屏障的价钱与它带的记录数变得成正比,或者某个只读工具开始有对外效果(那时它的 `Effect` 就不是 `Read`)。D24 在三个平台上相同:它只决定屏障的次数与位置,屏障本身怎样落盘归 `crates/storage/spec/Jsonl/Barrier.lean`。

- **生成中起跑只读调用(`runtime::turn::speculation`,形状 2 值:按位置的缓存 `Speculated`)。** `Generating::Speculating` 让 `call` 走 `Model::call_speculating`;模型每交出一条调用,只要它排在本回答第一条非只读调用之前、`effect_of` 答 `Effect::Read`、`ahead` 借得出工具,它就在一个 `std::thread::scope` 线程上起跑,scope 在模型调用返回前 join 全部线程,于是一回合的墙钟约等于 max(工具, 生成),而不是两者之和。结果按调用在回答中的位置缓存进 `Turn<ToolWave>`,连同起跑时的那条调用;入账时仍按调用序先 `admit`,放行(`Cleared`)且该位置缓存的调用与结算后那条逐字段相等,才用缓存结果,否则照常执行——所以 `tool_called`/`tool_result` 的载荷与顺序与串行波相同,时刻按 §8-15 的时点采,推测结果本身不是事件。回答失败(流被切断、恢复段重发)时整份缓存随那次尝试丢弃;取消落在 k 处时 k 之后的缓存随 `Turn` 丢弃;`admit` 自己作答(重放、门拒绝)时该位置的缓存丢弃。哪些调用可以提前、缓存按什么序入账,权威是 `crates/runtime/spec/Turn/Speculation.lean`:越过第一条写调用推测会让读看到写之前的世界(`speculating_past_a_write_changes_the_ledger`)。**起跑不问 `still_going`**:一个已立的取消挡不住早读,它们的结果随 `Turn` 丢弃;代价是被停的 run 仍做完这些读,模型调用失败时也要等它们 join 才返回,而它们都无副作用,所以不越过任何门。**起跑先于放行**:放行写去重表与 taint,被截断的回答得把它们撤回,而只读调用的结果在放行前算出、放行后才用,被拒的那条结果从不到达模型与账本。落选的是「推测时就 `admit`」:它要为截断与取消各写一条撤销路径。`ahead` 有默认 `None`,闭包的全覆盖实现因此不提前起跑,citysim 字节不动;bench 的实现按名借出(`ToolBench::tool_named`,与 `tool_for` 同一张表)。
-/

/-!
### 8-6 turn/prefix/handoff 的会话面(形状不变,参数长入)


typestate 四相、边界消费、事件序、私有字段三不变量不动;会话、工具与调用形状作为相变函数的入参进来。被否替代:平行第二条 call 路径——同一相两个入口即两个权威,落选。

```rust
// kernel::model(缝上 canonical 会话类型,`crates/kernel/Spec.lean` §8-24):
// ChatRequest<'a> { system: Vec<SystemBlock>, messages: Cow<'a, [ChatMessage]>, tools: Cow<'a, [ToolDef]>, breakpoint: MessageBreakpoint }
// SystemBlock { text, cache };ChatMessage { role, content: Vec<ContentBlock> };Role { User, Assistant }
// ContentBlock { Text{text} | ToolUse{id,name,input:Payload} | ToolResult{tool_use_id,content,is_error} }
// ToolDef { name, description, input_schema: Payload };ModelUsage 四整数;StopReason { EndTurn, ToolUse, MaxTokens }
// ModelRequest<'a> 携 chat: ChatRequest<'a>;ModelReturn 携 usage: Option<ModelUsage>、stop: Option<StopReason>、billed: Option<UsdMicros>

// runtime::conversation
pub enum Opening { FromJob, Inherited, WithPerson }   // 穷尽三臂,城在写 brief 时已决定;Inherited 只由 fork 选
pub struct Conversation { /* messages、sent、held —— 私有;执行器持有,逐回合推进 */ }
impl Conversation { pub fn new() -> Conversation;
    pub fn push_task_lines(&mut self, task: &str, goal: &str, opening: Opening);  // 首轮,run_started 可重建
    pub fn push_steer(&mut self, source: &str, text: &str);          // 「user」或「@ID」前缀形
    pub fn push_reminder(&mut self, reminder: &ContextReminder);    // §8-34
    pub fn push_inherited(&mut self, messages: &[ChatMessage]);     // 分支开场继承的那段(§8-2)
    pub fn push_assistant(&mut self, content: Vec<ContentBlock>);
    pub fn push_tool_results(&mut self, results: Vec<ContentBlock>); // ToolResult 块(pipeline 产出的成品文本)
    pub fn mark_sent(&mut self);                                     // §8-47
    pub fn messages(&self) -> &[ChatMessage]; }

pub struct CallShape { pub model: String, pub max_tokens: Option<Ceiling>, pub effort: Option<Effort>,
                       pub context_tokens: u64 }   // 窗口大小;只喂本地提醒、不上线(§8-3)
impl CallShape { pub fn verified_against(&self, frozen: &CallShape) -> Result<(), AxError>; }   // 冻结判定(§8-3)
                    // 三个上线字段全部来自选型点,无一项在调用处手写。model 与 max_tokens 解自
                    // 模型目录行(gateway::market::ModelEntry);effort 解自 kernel::FrozenConfig,
                    // Run 内恒不变——改它就换缓存前缀(理由与出处在 `crates/kernel/Spec.lean` §8-22)
impl Turn<Assembling> {
    pub fn assemble<'c>(self, interrupt: Interrupt, ledger: &mut dyn Ledger, prompt: RunPrompt<'_>,
                    conversation: &'c Conversation, tools: &'c [ToolDef], shape: &CallShape)
        -> Result<PhaseOutcome<Turn<Calling<'c>>>, AxError>;   // Calling<'c> 相私持已组 ChatRequest,借用 conversation 与 tools('c),调用返回前会话不动;prompt_assembled 载荷长入
}
pub struct RunPrompt<'r> { /* prefix: &'r FrozenPrefix, recorded: &'r mut PromptRecord */ }
impl<'r> RunPrompt<'r> { pub fn new(prefix: &'r FrozenPrefix, recorded: &'r mut PromptRecord) -> RunPrompt<'r>; }
#[derive(Default)] pub struct PromptRecord { /* written: Option<Payload> */ }   // 住 Run 的 Active 态,跨回合
// Interrupt 增 Steer { source: String, text: String }:边界消费→追加 steer_received(in-window)→照常 Advanced(不终止回合);
// 文本回折入 Window 归执行器(它持 Window 与 Steer 原文),呼应「追加在结果末尾」。
// TurnReport 长入:model_content: Vec<ContentBlock>(助手内容)与 wave_results: Vec<ContentBlock>(ToolResult 块)——
// 执行器据此折叠 Window;两份材料是收尾边界压缩后的 exchange(8-44),离线重建同源于
// model_returned.data.content 与 tool_result 事件并在同一边界重放同一判定(C16 由 8-44 的对拍承接)。
// kernel::ToolCall 增 id 字段:tool_use↔tool_result 对号是两 Dialect 的 wire 硬性要求;
// tool_called 载荷增 id,tool_result 载荷增 tool_use_id。
```

**prefix 四段全量**(新增构建面;既有 FrozenSegment/assemble 不动):

```rust
pub struct SourceDoc { pub addr: Address, pub bytes: Option<Vec<u8>> }   // None=缺失或不可读(跳过入账)
    // `generation` 数的是摘要的代数(链上第一份=1,摘要的摘要加一),`NonZeroU32`
    // 使「还没数」拼不出 0——零与未知是两件事,与 `ModelReturned` 缺席≠报零同族。
pub struct SegmentCaps { pub city: u64, pub building: u64, pub resident: u64, pub run: u64 }  // 字节上限;来源=调用方(`startup_default` 取 STARTUP_BUDGET_TOKENS × BYTES_PER_TOKEN ÷ PREFIX_SLOTS)
pub struct PrefixPlan { pub city: Vec<SourceDoc>, pub building: Vec<SourceDoc>,
                        pub resident: Vec<SourceDoc>, pub run: Vec<SourceDoc>, pub caps: SegmentCaps }
pub struct PrefixBuild { pub prefix: FrozenPrefix, pub notes: Payload }   // notes=逐段 sources/skips/truncations(prompt_assembled 载荷入口)
pub fn build_prefix(plan: PrefixPlan) -> Result<PrefixBuild, AxError>;
```

- **首轮不指向任何文件**:`JOB.md` 的正文已是 Run 段,所以开场行不携 `cas:b3-…` 一类的内容哈希——城里没有一个工具解析得了它,而溯源在 Ledger 里已记两遍。`Opening` 的两臂不是排版偏好:被派了一件活的会话与正在和人说话的会话要的第一句话不同,而把人那句话包成 `Task:`/`Goal:` 表单,换回来的也是一张表单。**`FromJob` 的开场行不复述任务**:它只写一行 `The task is in JOB.md above, handed down by <from>.`,`<from>` 是 `kernel::event::Who::handed_down_by` 给的交活者("the User"、"the city" 或 "@room, run …")——任务正文已在 Run 段,再抄一遍,人贴的一段话每次请求就付两遍(Run 段不在缓存里,每回合全价)。Goal 也不再写在这里:它已转义后在 JOB.md 的 `<goal>` 里,而居民交下的 Goal 落进 user 角色的消息,一行 `user: …` 就能冒充 User(`crates/city/spec/SpineFiles.lean` D21)。**分叉重建的母亲开场用 `Inherited`**:母亲的 `JOB.md` 在她的房间里,不在分叉的 Run 段里,所以那一行写 `Task: <task>` 与 `Goal: <goal>`;若照搬 `FromJob`,分叉读到的「在上面的 JOB.md 里」指向一个它看不见的文件,母亲的任务就丢了。
- **read 的两条路,差别在于谁选的**:**路径是模型选的,故受审**——`Address::parse` 杀穿越,`is_reserved` 杀保留子树(`E_GATE_DENIED`);**catalog 里的名字是人选的**——楼的阅览室写下它时准入就已发生,故它解到的 skill 可以住在保留空间里。两条路共用一个参数,因为对模型而言它们是同一件事(把一份东西调到眼前);**先问 catalog** ,一个同名文件不得遮蔽楼已经准入的 skill。
- **`Catalog::expand` 答 `Expansion { Skill { addr, package }, Said { text } }` 而不是 `String`**:skill 展开成一个可打开的地址,其余展开成目录自己持有的正文;两者压成一个字符串时,调用方只能拿它去试解析成地址,而一段恰好能解析成地址的正文就会被当成文件打开。
- **正文不在 prompt 里,所以交出去而不是拒绝**:`render()` 只写每条的 disclosure,`expansion` 从未进过窗口。
- **它是 `Catalog::expand` 的调用者**:没有它,一栋楼的阅览室能报出一个 skill 的名字而永远交不出它。
- 截断:文件超段位余额即截到边界,原处留 ASCII 标记(文本与切口规则属 `runtime::elision`,§8-42),恒不静默丢尾;标记字节从段预算先扣。
- 单位换算写成代码:`SegmentCaps` 四个字段是**字节**,`STARTUP_BUDGET_TOKENS` 是**token**,`startup_default` 用 `BYTES_PER_TOKEN` 与 `PREFIX_SLOTS`(`NonZeroU64`,与 `SegmentSlot` 变体数由 `prefix::tests` 钉住)把前者换算成后者。两个换算常量住 `kernel::consts_policy`(`BYTES_PER_TOKEN` 与 `PREFIX_SLOTS`),与 `STARTUP_BUDGET_TOKENS` 同一个家;prefix.rs 只读。
- 断点只有一个作者:`prefix::BreakpointPlan`(`prefix/breakpoint.rs`,形状 1 判定,纯函数)。`BreakpointPlan::for_conversation(&[ChatMessage])` 决定一次请求实际发出的断点:前三段(city/building/resident)的段界各一个,对话非空时尾消息再一个,合计 ≤ `CACHE_BREAKPOINTS_MAX`(4);run 段界不放,因为尾锚紧随其后已覆盖它。`FrozenPrefix::system_blocks()` 以 `BreakpointPlan::marks_edge(slot)` 标 system 块,`BreakpointPlan::message_breakpoint` 标请求(回合借用会话,不复制它,kernel D6),`prompt_payload(&plan)` 把 `plan.breakpoints()` 逐个拼成 `breakpoints` 行(段界写 slot 名,尾写 `tail`);`verified_system_hashes` 以同一个 `marks_edge` 核对线上的块。兼容格式只负责拼写(Anthropic:被标记消息的最后一块带 `cache_control`),不决定任何断点。
```rust
pub enum Breakpoint { Edge(SegmentSlot), Tail }
pub struct BreakpointPlan { /* tail: Option<usize> */ }
impl BreakpointPlan {
    pub fn for_conversation(messages: &[ChatMessage]) -> BreakpointPlan;
    pub fn marks_edge(slot: SegmentSlot) -> bool;
    pub fn message_breakpoint(&self) -> MessageBreakpoint;   // 写进 ChatRequest.breakpoint;会话本身不动
    pub fn breakpoints(&self) -> Vec<Breakpoint>;
}
```
- A15 重建器:`replay::rebuild_prefix(data: &serde_json::Value, resolver: &dyn Fn(&Address) -> Option<Vec<u8>>) -> Result<[B3Hash; 4], AxError>`——从 prompt_assembled 载荷(逐源的键以 `kernel::event::record::PromptSource` 为准:`{addr, kept, marker, dropped, producer}`,其中 `producer` 不参与对拍,因为哈希只盖段字节、指纹不是字节的一部分)与同源文档重算逐段哈希对拍;resolver 以 Address 取文(钉版 oid 级解析随 checkpoint 接入升级,接口不变)。拼接分隔符的唯一权威住 prefix.rs(`DOC_JOIN`),截断标记的唯一权威住 `runtime::elision`(§8-42),replay 同 crate 复用不另拷。
- E_TOOL_OUTCOME_UNKNOWN 补写面:`replay::DanglingCalls` 逐条看已验证的记录,`observe(&EventRecord)`;看完 `into_calls() -> Vec<EventRecord>` 交出没有结果的那些 `tool_called`,按 seq 升序(`tool_called` 之后同一 run 里没有 `tool_result` 即悬空;同一 run 前一个未合上又来一个,前一个同样悬空)。`replay::outcome_unknown_draft(call, t) -> EventDraft` 写补记的 `tool_result`,携 E_TOOL_OUTCOME_UNKNOWN 错误体。消费者=`resume` 的启动扫描:它把 `fold_ledger_dir` 验过的每条记录交给 `observe`,验链与检出是同一遍流式读,常驻的是一段字节,外加尚未合上的那几条调用。补写方经 `ToolCalled`/`ToolResult` 两个结构读写,读不懂即拒绝:若手挑 `id` 与 `name` 两个键、读不出就写下 `"unknown"`,一条这个 build 读不懂的调用就会被关在一个谁也答不上的 id 上。**被否:先得到 `VerifiedLedger` 再检出**——整本原始行与整本记录同时常驻,只为找几条没有结果的调用;检出只交回 `(RunId, Seq)`,调用方还要回到整本记录里逐条找那一行。

```rust
#[derive(Debug, Default)]
pub struct DanglingCalls { /* 私有:未合上的调用(按 run)、已判悬空的调用 */ }
impl DanglingCalls {
    pub fn observe(&mut self, record: &EventRecord);
    pub fn into_calls(self) -> Vec<EventRecord>;
}
pub fn outcome_unknown_draft(call: &EventRecord, t: TimeMs) -> Result<EventDraft, AxError>;
```

- 单位换算写成代码:`SegmentCaps` 四个字段是**字节**,`STARTUP_BUDGET_TOKENS` 是**token**,`startup_default` 用 `BYTES_PER_TOKEN` 与 `PREFIX_SLOTS`(`NonZeroU64`,与 `SegmentSlot` 变体数由 `prefix::tests` 钉住)把前者换算成后者。两个换算常量住 `kernel::consts_policy`(`BYTES_PER_TOKEN` 与 `PREFIX_SLOTS`),与 `STARTUP_BUDGET_TOKENS` 同一个家;prefix.rs 只读不再自定。
- 断点:`FrozenPrefix::system_blocks()` 产四块、逐块 cache=true=断点恒 4=`CACHE_BREAKPOINTS_MAX`,断点只落段界。
- handoff:五段+构造点+resume 消费;「下一步段首列用户指定动作」属生产者纪律(回合层与 spine 文件),类型不另加钩。
- 第四取消点(派生前):`collab::delegate_tool` 是它的生产者:`SafePoint::BeforeSpawn` + `Turn<Recording>::record(interrupt, ledger)`,装配层在 `Completion::Cancelled` 时清空派生台,**被取消的 Run 一件活也交不下去**。
-/

/-!
### 8-18 runtime::turn 目录化


| 文件 | 管什么 |
|---|---|
| `turn.rs` | typestate 载体 `Turn<S>` 与四个相类型,`assemble`/`call`/`record`,以及唯一的中断消费点 `consume_boundary`。`assemble` 与 `call` 带 `argument_count` 豁免,故留在原路径 |
| `turn/boundary.rs` | 执行器在相变处交出什么、相变答什么:`Interrupt`、`PhaseOutcome`、`TurnCancelled` |
| `turn/report.rs` | 一轮跑完交给 run loop 的东西与它被给定的调用形状:`TurnReport`、`CallShape` |
| `turn/recovery.rs` | 模型调用的恢复管线(§8-49) |
| `turn/prompt.rs` | 一次 run 的 prompt 材料 `RunPrompt`,`assemble` 的入参 |
| `turn/speculation.rs` | 生成中起跑只读调用的门与缓存(§8-3) |
| `turn/wave.rs` | 边界 3:`impl Turn<ToolWave>` 的工具波,只读前缀并行执行,按调用序入账 |
| `turn/wave/reorder.rs` | 开头只读段同时起跑、按调用序交回答案的重排缓冲:`all_at_once` 与 `lost_answer` |
| `turn/ledger.rs` | 本模块通往账本的唯一一道门:`Journal`、`Authored`、`Carried`,以及四种等来的时刻从哪只钟读(§8-41、§8-15) |
| `turn/tests.rs` | 纯索引 |
| `turn/tests/helpers.rs` | 三处共用的夹具:`TestLedger`、`OneShotModel`、`prefix`/`run_id`/`shape`/`advance`/`probe_call` |
| `turn/tests/phases.rs` | 四个边界跑在真账本链上 |
| `turn/tests/concurrent.rs` | 只读前缀并行:波内停下只起跑停点之前的调用,steer 落在串行波写它的位置;停住的时钟下两者账本字节与串行一致 |
| `turn/tests/window.rs` | 开场白与 steer 在窗口里留下什么 |
| `turn/tests/redaction.rs` | 工具参数与工具结果里的密钥进不了账本,其余字段完好 |
-/

/-!
### 8-41 回合带进账本的明文,必经打码那道门


**不变量:`tool_called`、`tool_result`、`model_returned` 三类事件的载荷,在写进账本之前逐串跑过 `kernel::secret::scan::scan`,命中的区段换成 `secret:redacted/<b3-16>` 标记。** 三类都要:工具参数与工具结果若**逐字**入账,一次 `read` 读出的别人项目的 `.env` 正文、一次 `exec` 的 stdout,就会进入只增且可导出的历史。账本不可重写,所以这类损害不可逆——这是它排在安全清单最前的理由。

```rust
// turn/ledger.rs —— 本模块通往账本的唯一一道门
enum Authored { PromptAssembled, ModelCalled, CancelReceived, SteerReceived }  // 回合自己算出的值
enum Carried  { ModelReturned, ToolCalled, ToolResult }                        // 供应方或工具交回的值
enum RunLine  { PromptShapeCompared, CheckpointCommitted }                     // run 写的、带 run 的地址、不扫秘密
pub struct HeldLines { /* run、who、攒下的 draft、已换出的 ref —— 全私有,归 Run<Active>(D36) */ }
impl HeldLines {
    pub fn barrier(&mut self, ledger: &mut dyn Ledger) -> Result<(), AxError>;  // 本模块唯一的 `Ledger::append_all` 调用
    pub fn durable(&self, entry: Entry) -> Result<EventRef, AxError>;           // 屏障之后才交出 ref
}
struct Journal<'h> { /* lines: &'h mut HeldLines、t、钟、redacted —— 全私有 */ }
impl Journal<'_> {
    fn append_authored(&mut self, event: Authored, data: Payload) -> Entry;
    fn append_run_line(&mut self, line: RunLine, addr: Address, data: Payload) -> Entry;
    fn append_redacted(&mut self, event: Carried, data: Payload)
        -> Result<Entry, AxError>;   // 载荷经 Payload::of 由记录结构构造,扫描在门内做
    fn append(&mut self, …) -> Entry;   // 本模块唯一构造 `EventDraft` 之处,draft 攒进 HeldLines
}
```

**四条口径:**

1. **「必经」由类型保证,不由注释约定。** `Authored` 与 `Carried` 把本模块写的七类事件切成互不相交的两半,各自只在一个 append 函数里出现;`EventDraft` 的构造收进私有的 `Journal::append`。于是「不打码就写 `tool_called`」这件事在本模块里**没有可写出来的形式**——要绕过它,得先手写一个 `EventDraft`,那是一个审阅时看得见的动作。
2. **打码器只有一个。** `Journal::append_redacted` 调 `runtime::redact::redact`,后者调 `kernel::secret::scan::scan`;本 crate 不存在第二个扫描器或第二套标记文法。
3. **计数进 `TurnReport::redacted()`,内容不进。** 一个数目足以让诊断行说出「打掉了 N 段」,而说不出打掉的是什么;`Journal` 用饱和加法累计,跨相随 `journal` 一起搬。
4. **窗口留住账本丢掉的。** `wave_results` 与 `assistant` 在打码之前就已从 `ToolOutcome` 与 `ModelReturn` 取出,模型因此仍看得见工具的真实输出,思考块的签名也不受影响——历史与上下文是两个汇,只有账本是永久的。

**关门测试**(`turn/tests/redaction.rs`):一次 `edit` 调用的参数与结果各带一串 `sk-ant-` 开头的密钥,跑完整回合后账本每一行都不含那串明文,而 `id`、工具名、`path` 参数与替换点周围的散文完好,`report.redacted() >= 2`,`replay::verify_lines` 仍自证。
-/

/-!
### 8-51 一次调用在账上带出它的登记,与它被裁掉的原文在哪(`turn::wave`、`pipeline`)


**(a) 工具面交出整份登记**

```rust
pub trait ConcurrentInvoke {
    /// 这次调用那件工具的登记;None 是工具台不认识的名字(它不是只读的)。
    fn meta_of(&self, call: &ToolCall) -> Option<&ToolMeta>;
    /// 经 `call` 的一次调用换成它所指的那件;默认原样交回(§8-61)。
    fn resolve_call(&self, call: ToolCall) -> ToolCall { call }
    // ahead、admit、tool、account 不变
}
```

- `meta_of` 取代原来的 `effect_of`:工具波判只读前缀、推测门判能否提前起跑,读的都是登记里的 `effect`;`tool_called` 还要照录 `effect` 与 `render`(`crates/kernel/Spec.lean` §8-75(b))。一个方法交出整份登记,三处读同一个答案;两个方法各交一项,就是两处各自去查同一份登记。
- 闭包工具面(citysim 与测试)答 `None`:它的调用写 `tool_called` 时两键缺席,与它今天不声明效果、波次恒串行是同一件事。

**(b) 被裁掉的结果,原文在哪**

```rust
// runtime::pipeline
pub(crate) const EXEC_ACCOUNTS: &str = "sieve";         // exec 结果里账目住的键
pub(crate) const CONNECTOR_ACCOUNTS: &str = "offload";  // 外包服务答复里账目住的键
/// 一次工具结果里第一笔离窗账目的 original;结果里没有账目时为 None。
pub fn pinned_original(result: &serde_json::Value) -> Option<Locator>;
```

- **账目随它裁掉的那个结果走。** `package_exec` 把 `package` 记下的 `ResultOffloaded` 放进结果的 `sieve` 键,`package_connector` 放进 `offload` 键(§8-27、connector 一节);这个结果写进 `tool_result`,而 `tool_result` 带着它所答那次调用的 `tool_use_id`。所以一笔账目属于哪次调用,由它所在的那一行说出,账目自己不另记调用 id:多记一份就是同一个 id 的第二个家,而且这个结果整份进模型的字节。今天没有任何写方单独写 `result_offloaded` 行。
- **第一笔就是原文**:同一个结果先经 sieve 裁、裁后仍大再被普通搬运存一次时,账目按管线次序排——sieve 的那笔在前,它的 `original` 是命令写出的全部字节;后一笔的 `original` 是 sieve 留下的替身。读者要的是命令原本说了什么,所以取第一笔。
- 键名的权威是这两个常量;读它们的是 `pinned_original`,写它们的是两扇 `package_*` 门。

**(c) 一次 exec 以哪个码结束**

```rust
// runtime::pipeline
/// 一次 exec 结果里的 `exit_code`;结果里没有码时为 None。
pub fn exit_code_in(result: &serde_json::Map<String, serde_json::Value>) -> Option<i64>;
```

- **写方只有一处,读方也只有一处。** 键名与「有码才写码」的规则住 `tools::exec::outcome` 的 `ending`;读它的是 `exit_code_in`,`package_exec` 交给 sieve 的码与读面答给页面的 `Call.exit_code`(`crates/wire/Spec.lean` §8-76)都经它读。信号停下的、没等到的命令没有码,读出 `None`,不读成某个数。
-/

namespace Runtime.Turn

/-- 一道边界上到达的信号(`turn::Interrupt`)。Steer 的来源与正文不影响写下哪一行,所以模型不带它们。 -/
inductive Interrupt where
  | None
  | Cancel
  | Steer
  deriving DecidableEq, Repr

/-- 一个回合结束的方式(`turn::PhaseOutcome` 的两臂)。 -/
inductive Ending where
  | Advanced
  | Cancelled
  deriving DecidableEq, Repr

/-- 一个回合写进账本的行,以 `kernel::EventKind` 的名字命名;`prompt_assembled` 每个 run 只写一次(§8-39),不在这里。 -/
inductive Line where
  | PromptShapeCompared
  | ModelCalled
  | ModelReturned
  | ToolCalled (call : Nat)
  | ToolResult (call : Nat)
  | SteerReceived
  | CancelReceived
  deriving DecidableEq, Repr

/-! D5 一回合里等来的四个时刻各采各的,钟交给回合的账本门

**决定**:`model_called`、`model_returned`、`tool_called`、`tool_result` 的信封 `t` 记各自那一刻,每条工具调用采开始与答复两次。时钟在 `Turn::begin` 交给 `turn::ledger::Journal`;`Authored::ModelCalled`、`Carried::ModelReturned`、`Carried::ToolCalled`、`Carried::ToolResult` 四个变体各带一个读数,其余变体只能带回合时间戳。

**理由**:结果上的 UTC 戳(§8-10)、`view --since` 与 `--until` 的时间窗与 run 页的调用用时读的是同一个值,一行只有一个时间。只采答复不采开始,调用用时只能推算,会把准入里的检查点提交算进工具用时。钟放在 `Journal` 里,`call` 与 `execute_concurrent` 的参数表不动,采样只发生在回合自己那一道账本门后面。

**被否**:①`ToolResult` 载荷加 `returned_ms`:一行两个时间,读者要知道信哪一个;②时钟随 `Generating` 进 `call`,再与工具面、`still_going` 合成一个值进 `execute_concurrent`:同一只钟两个入口,而 `call` 的参数已按 `budgets.toml` 钉在 5;③只采答复:两条并行对拍测试可以原样保留,但用时量不出来。

**代价**:同一 run 内 `t` 不再随 `seq` 单调,次序以 `seq` 为准;并行对拍测试改用停住的时钟比字节;`golden-p0` 重生成一次。
-/

/-- 哪几种行的 `t` 是为它自己采的读数,其余的带回合时间戳(`turn::ledger` 的 `Authored`/`Carried` 变体)。 -/
def measured : Line → Bool
  | .ModelCalled => true
  | .ModelReturned => true
  | .ToolCalled _ => true
  | .ToolResult _ => true
  | .PromptShapeCompared => false
  | .SteerReceived => false
  | .CancelReceived => false

/-- `consume_boundary`:一道边界消费一个信号,写下什么、回合还走不走。Steer 记一行照常前进;Cancel 记一行,回合到此为止。 -/
def consume_boundary : Interrupt → List Line × Bool
  | .None => ([], true)
  | .Steer => ([.SteerReceived], true)
  | .Cancel => ([.CancelReceived], false)

/-- 第 `first` 条起的 `count` 条调用各自的两行,按调用序。 -/
def accounted (first : Nat) : Nat → List Line
  | 0 => []
  | count + 1 => [.ToolCalled first, .ToolResult first] ++ accounted (first + 1) count

/-- 工具波:第 `call` 条调用之前问 `still_going call`,答案交给同一个 `consume_boundary`;走下去就按调用序写它的 `tool_called` 与 `tool_result`。开头只读段并行执行时次序与载荷不变,由 `spec/Turn/Speculation.lean` 证明;这里只管边界。 -/
def wave (still_going : Nat → Interrupt) (call : Nat) : Nat → List Line × Bool
  | 0 => ([], true)
  | remaining + 1 =>
    let boundary := consume_boundary (still_going call)
    if boundary.2 then
      let rest := wave still_going (call + 1) remaining
      (boundary.1 ++ [.ToolCalled call, .ToolResult call] ++ rest.1, rest.2)
    else (boundary.1, false)

/-- `wave` 走一步,写成一条等式,好让证明一次只展开最外一层。 -/
theorem wave_succ (still_going : Nat → Interrupt) (call remaining : Nat) :
    wave still_going call (remaining + 1) =
      if (consume_boundary (still_going call)).2 then
        ((consume_boundary (still_going call)).1 ++ [.ToolCalled call, .ToolResult call] ++
            (wave still_going (call + 1) remaining).1,
          (wave still_going (call + 1) remaining).2)
      else ((consume_boundary (still_going call)).1, false) := rfl

/-- 一个回合在四个取消点上收到的信号,以及波里每条调用之前的那一问。 -/
structure Signals where
  before_assemble : Interrupt
  before_call : Interrupt
  before_wave : Interrupt
  still_going : Nat → Interrupt
  before_spawn : Interrupt

/-- 回合的四相:组装、调用、工具波、收尾。每一相先消费它的边界,命中 Cancel 就写下 `cancel_received` 并结束;否则做这一相的事。`calls` 是模型这一答发出的调用数。 -/
def turn (signals : Signals) (calls : Nat) : List Line × Ending :=
  let assemble := consume_boundary signals.before_assemble
  if !assemble.2 then (assemble.1, .Cancelled) else
  let call := consume_boundary signals.before_call
  let called := assemble.1 ++ [.PromptShapeCompared] ++ call.1
  if !call.2 then (called, .Cancelled) else
  let opening := consume_boundary signals.before_wave
  let returned := called ++ [.ModelCalled, .ModelReturned] ++ opening.1
  if !opening.2 then (returned, .Cancelled) else
  let landed := wave signals.still_going 0 calls
  let waved := returned ++ landed.1
  if !landed.2 then (waved, .Cancelled) else
  let spawn := consume_boundary signals.before_spawn
  (waved ++ spawn.1, if spawn.2 then .Advanced else .Cancelled)

/-- 没有人喊停的一波,按调用序给每条调用记下两行。 -/
theorem an_uninterrupted_wave_accounts_every_call (still_going : Nat → Interrupt)
    (quiet : ∀ call, still_going call = .None) :
    ∀ (count first : Nat), wave still_going first count = (accounted first count, true)
  | 0, _ => rfl
  | count + 1, first => by
    rw [wave_succ, an_uninterrupted_wave_accounts_every_call still_going quiet count (first + 1)]
    simp [consume_boundary, quiet, accounted]

/-- **取消落在第 k 条调用之前,账本上恰好是前 k 条调用的两行,再一条 `cancel_received`。** 串行的波在同一处停下之前做的就是这几条,后面的调用一条也不写,所以账本上没有一条没有结果的调用。 -/
theorem a_cancel_before_a_call_accounts_exactly_the_calls_before_it
    (still_going : Nat → Interrupt) :
    ∀ (before first remaining : Nat),
      (∀ j, j < before → still_going (first + j) = .None) →
      still_going (first + before) = .Cancel →
      wave still_going first (before + remaining + 1) =
        (accounted first before ++ [.CancelReceived], false)
  | 0, first, remaining, _, stopped => by
    have stopsHere : still_going first = .Cancel := by simpa using stopped
    rw [show 0 + remaining + 1 = remaining + 1 by omega, wave_succ]
    simp [accounted, consume_boundary, stopsHere]
  | before + 1, first, remaining, quiet, stopped => by
    have here : still_going first = .None := by
      simpa using quiet 0 (Nat.succ_pos before)
    have later : ∀ j, j < before → still_going (first + 1 + j) = .None := by
      intro j below
      have shifted := quiet (j + 1) (by omega)
      rwa [show first + (j + 1) = first + 1 + j by omega] at shifted
    have stoppedLater : still_going (first + 1 + before) = .Cancel := by
      rwa [show first + (before + 1) = first + 1 + before by omega] at stopped
    have rest := a_cancel_before_a_call_accounts_exactly_the_calls_before_it still_going
      before (first + 1) remaining later stoppedLater
    rw [show before + 1 + remaining + 1 = (before + remaining + 1) + 1 by omega, wave_succ, rest]
    simp [accounted, consume_boundary, here]

/-- 一道边界上的 Steer 不结束回合:只要四个取消点与波里每一问都不是 Cancel,回合就前进。 -/
theorem a_steer_never_ends_a_turn (signals : Signals) (calls : Nat)
    (assemble : signals.before_assemble ≠ .Cancel) (call : signals.before_call ≠ .Cancel)
    (opening : signals.before_wave ≠ .Cancel) (spawn : signals.before_spawn ≠ .Cancel)
    (going : ∀ k, signals.still_going k ≠ .Cancel) :
    (turn signals calls).2 = .Advanced := by
  have keeps : ∀ signal : Interrupt, signal ≠ .Cancel → (consume_boundary signal).2 = true := by
    intro signal notCancel
    cases signal with
    | None => rfl
    | Steer => rfl
    | Cancel => exact absurd rfl notCancel
  have waves : ∀ (count first : Nat), (wave signals.still_going first count).2 = true := by
    intro count
    induction count with
    | zero => intro _; rfl
    | succ count ih =>
      intro first
      rw [wave_succ]
      simp [keeps _ (going first), ih]
  simp [turn, keeps _ assemble, keeps _ call, keeps _ opening, keeps _ spawn, waves]

/-- **被取消的波以它的 `cancel_received` 结束,之后什么都不写**:回合后面的 `handoff_written` 与 `run_frozen` 归 `run` 的 `freeze`(§8-15)。 -/
theorem a_cancelled_wave_ends_with_its_cancel (still_going : Nat → Interrupt) :
    ∀ (count first : Nat), (wave still_going first count).2 = false →
      ∃ before, (wave still_going first count).1 = before ++ [.CancelReceived]
  | 0, _, ended => by simp [wave] at ended
  | count + 1, first, ended => by
    rw [wave_succ] at ended
    rw [wave_succ]
    cases signal : still_going first with
    | Cancel => exact ⟨[], by simp [consume_boundary]⟩
    | None =>
      have restEnded : (wave still_going (first + 1) count).2 = false := by
        simpa [consume_boundary, signal] using ended
      obtain ⟨before, ends⟩ :=
        a_cancelled_wave_ends_with_its_cancel still_going count (first + 1) restEnded
      exact ⟨[.ToolCalled first, .ToolResult first] ++ before, by simp [consume_boundary, ends]⟩
    | Steer =>
      have restEnded : (wave still_going (first + 1) count).2 = false := by
        simpa [consume_boundary, signal] using ended
      obtain ⟨before, ends⟩ :=
        a_cancelled_wave_ends_with_its_cancel still_going count (first + 1) restEnded
      exact ⟨[.SteerReceived, .ToolCalled first, .ToolResult first] ++ before,
        by simp [consume_boundary, ends]⟩

end Runtime.Turn