sprawling-kernel 0.0.12

Every decision of a sprawling city - addresses, events, gates, plans and errors - with nothing that touches a disk.
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
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
-- 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

import crates.kernel.spec.Error

/-!
# kernel::account_recovery

规定 `kernel::account_recovery`(`crates/kernel/src/account_recovery.rs`):一次逻辑请求面对一个 Provider 的有序账号时,先用哪个账号、失败后在原号上再发还是换号、何时停下。本文件是 `crates/kernel/Spec.lean` 的一个分部,节标签 §8-86,别处引作 `crates/kernel/Spec.lean §8-86` 或它的决定 `kernel D54`、`kernel D55`。

模块图把它登记为 `state machine`。模型路径的驱动方已经接上它:`runtime::watchdog` 持有每一次模型调用的一轮,`runtime::run::drive` 执行它的处置(`crates/runtime/spec/Watchdog.lean` §8-9,runtime D89);`web_search` 工具还不存在。`Watchdog` 原来的 `Retry` 三态规则就是下面单账号名册(`Roster.Single`)的处置,所以单账号端点的行为不因接上本模块而变(`a_single_account_ignores_the_disposition`),`runtime::watchdog` 旁原有的测试一字不改照样通过。

模型证明,对任意名册、任意人设上限、任意失败与修复的轨迹:请求错误不再发送;说「换号」的失败不在原号上重发;一个账号一轮里的发送不超过 1+k;一轮里每个账号至多到一次,总发送不超过 n·(1+k),且不超过 `AtMost` 上限+1;效果不明的请求不换号;Session 的绑定只在回答时移动。

### 8-86 kernel::account_recovery(形状 5 状态机)

```rust
// kernel::account_recovery —— 纯状态机,无 I/O、无时钟;ServerLabel 是账号 id
#[serde(rename_all = "snake_case")]
pub enum AccountRetries { One, Two }                    // 同一账号上最多再发几次;线上与账本拼作 one/two;缺省值只在 gateway 的 EndpointTuning::DEFAULTS,Query::Config 读回给页面
pub enum Roster { Single, Several { accounts: Vec<ServerLabel>, retries: AccountRetries } }
                                                        // Several 恒有两个以上账号,名册序即优先级
pub enum RoundEnd { Refused, Unknown, Cap, Exhausted }
pub enum AccountStep { Resend, Switch { to: ServerLabel }, Stop { why: RoundEnd } }
pub struct AccountRound { /* roster、usable、cap: Retries、current、tried: BTreeSet、here、total —— 私有 */ }
impl AccountRound {
    /// 一次逻辑请求的第一次发送之前:held 仍在名册里且可兑付即用它,否则名册里第一个可兑付的。
    pub fn start(roster: Roster, usable: BTreeSet<ServerLabel>, cap: Retries, held: Option<ServerLabel>)
        -> Result<AccountRound, RoundEnd>;              // 一个可兑付的都没有:Err(Exhausted),不发请求
    pub fn current(&self) -> Option<&ServerLabel>;      // Single 答 None
    pub fn next(&self, failure: &AxError) -> AccountStep;   // 只读 failure.retry() 与 failure.account()
    pub fn apply(self, step: &AccountStep) -> AccountRound;  // Stop 不改状态
    pub fn admits_repair(&self) -> bool;                // 修复段换门重发之前问这一句
    pub fn repaired(self) -> AccountRound;              // 修复段真的重发了:Several 记作原号上的一次发送
}
```

`next` 只返回信息、`apply` 只推进状态(命令与查询分离),两个驱动方——模型路径的 `runtime::watchdog` 与 `runtime::run::drive`(`crates/runtime/spec/Watchdog.lean` §8-9),以及 accounting 的 `web_search` 工具——都只执行 `AccountStep`,各自不另写一个重试循环、不另判一次选哪个账号。下面的 Lean 定义就是这些方法的参考语义:`next` 一臂对一臂,`apply`、`admits_repair`、`repaired` 逐字段相同;`tried` 在 Rust 是集合,在模型是到过的顺序(最新的在前),因为证明要数它的长度。模型的 `held` 与 `answered` 只为陈述绑定的性质而在,Rust 的轮不存 `held`:只有 `start` 读它一次。计数在 Rust 里饱和而不回绕:单账号的轮在 `UntilHalted` 之下一直再发到 `Halt`,回绕的计数会读成一个新账号的余额。

**本轮**(round)是一次逻辑请求:从第一次发送起,到回答、停下或 `Halt` 为止。模型调用每一次 `Model::call` 开一轮,`web_search` 每一次工具调用开一轮。恢复轮次与已用的余额不入账、不持久化:进程重启后的下一次请求从绑定的账号开新的一轮(绑定本身由 Ledger 重建,`crates/gateway/spec/Router.lean` D30)。

**驱动方怎样执行每一种处置**(实现约束,由各驱动方旁的测试守住,不由本模型证明):

| 处置 | 模型路径(`Watchdog`/`drive`) | `web_search` |
|---|---|---|
| `Resend` | 按退避表与 `retry-after` 等,等待中 `Halt` 照样够得着,再在原号上发 | 同一张退避表,受工具 deadline 与取消约束 |
| `Switch { to }` | 不等,`Model::select_account(to)` 之后立刻发;连续失败的计数归零,因为退避是对那个失败账号的陈述 | 用 `to` 的凭据立刻发 |
| `Stop { Refused }`/`Stop { Cap }` | 原错误原样,冻结原因 `ProviderRefused`(与本模块之前相同) | 原错误作为失败的工具结果交给模型 |
| `Stop { Unknown }` | 原错误原样,带着 `effect_unknown`,冻结原因 `ProviderRefused` | 同上 |
| `Stop { Exhausted }` | 一条 `E_PROVIDER_ACCOUNTS_EXHAUSTED`,carrier 与 `E_PROVIDER` 相同,run 以 `Cancelled` 冻结;subject 列出 Provider 与每个账号 id 及其最后一种失败,不含 Key 与引用;恢复语:补录凭据或额度、或新增账号,然后再派活 | 同一个码作为失败的工具结果 |

模型路径的生产回归在 `crates/accounting/src/worker/credentials/tests/accounts/recovery.rs`:一个 Provider 的两个账号经真实 `RunWorker` 与按 Key 作答的回环 Provider,逐一检查忙碌时原号再发到余额用完后换号、401 与额度用完只发一次就换号、400 只发一次且不换号、答复丢失只在原号再发且停下时仍是 `effect_unknown`、全部失败只记一条 `E_PROVIDER_ACCOUNTS_EXHAUSTED`、等待中停下 run,以及每个请求只带 `model_called` 记下的那个账号的 Key、Ledger 里没有 Key 的明文。根 run 由人的 `Cancel` 停下,交下去的 run 由 `Halt` 经它的 backlog 成员停下,两者在等待里由同一处(`Interrupting::halted`)询问;回归驱动的是前者。

**谁看见 `held`**:模型路径的 `held` 是 Session 的绑定(`gateway::router` 的 `EndpointBook` 从 Ledger 折出,`crates/gateway/spec/Router.lean` D30),派活时取它开第一轮,此后每一轮取适配器当前的账号;回答之后 `model_returned` 提交绑定,下一轮从新绑定开始。`web_search` 不记 Session 绑定(D54),每一轮的 `held` 恒缺席,所以总从名册里第一个可兑付的账号开始。
-/

namespace Kernel.AccountRecovery

open Kernel.Error (Retry AccountDisposition)

/-- 同一账号上最多再发几次(`AccountRetries`)。人在 Provider 的高级表单上选一或二;只在名册有两个以上账号时被读。 -/
inductive AccountRetries where
  | One
  | Two
  deriving DecidableEq, Repr

def AccountRetries.count : AccountRetries → Nat
  | .One => 1
  | .Two => 2

/-- 人设的总上限(`kernel::Retries`,`spec/Retries.lean` §8-72)。在一轮里它封住整个逻辑请求跨账号的重发总数。 -/
inductive Retries where
  | UntilHalted
  | AtMost (retries : Nat)
  deriving DecidableEq, Repr

/-- 已经发出 `sent` 次之后,人设的上限还许不许再发一次。与 `Watchdog` 今天的读法相同:`AtMost(0)` 发一次就停。 -/
def Retries.admits : Retries → Nat → Bool
  | .UntilHalted, _ => true
  | .AtMost retries, sent => decide (sent ≤ retries)

/-- 上限守住了:`AtMost m` 之下一轮至多发 m+1 次。 -/
def Retries.holds : Retries → Nat → Prop
  | .UntilHalted, _ => True
  | .AtMost retries, sent => sent ≤ retries + 1

/-- 一次发送失败时 `next` 读的两格:`AxError::retry` 与 `AxError::account`(`spec/Error.lean` D53)。 -/
structure Failure where
  retry : Retry
  account : AccountDisposition
  deriving DecidableEq, Repr

/-- 这一轮面对的名册。`Single` 是旧的单凭据登记与只列一个账号的登记:没有第二个账号可换,处置照旧(D54)。`Several` 是两个以上账号,名册序就是优先级。 -/
inductive Roster (Account : Type) where
  | Single
  | Several (accounts : List Account) (retries : AccountRetries)

/-- 一轮为什么停下。 -/
inductive RoundEnd where
  | Refused
  | Unknown
  | Cap
  | Exhausted
  deriving DecidableEq, Repr

/-- 一次失败之后的处置:在原号上再发、换到名册里的另一个账号、或停下。 -/
inductive AccountStep (Account : Type) where
  | Resend
  | Switch (to : Account)
  | Stop (why : RoundEnd)
  deriving DecidableEq, Repr

/-- 一轮的状态。`usable` 是凭据能否兑付(vault 里有这个引用,或环境变量提供了它),开轮时读一次;`held` 是开轮时 Session 持有的账号;`tried` 是本轮到过的账号,最新的在前;`here` 是当前账号上的发送数;`total` 是本轮的发送数。 -/
structure AccountRound (Account : Type) where
  roster : Roster Account
  usable : Account → Bool
  cap : Retries
  held : Option Account
  current : Option Account
  tried : List Account
  here : Nat
  total : Nat

variable {Account : Type} [DecidableEq Account]

/-! D54 选号与恢复只有一个家:kernel 的 `AccountRound`

**决定**:一个 Provider 有多个账号时,「先用哪个账号」与「失败后原号再发、换号还是停下」都由 `AccountRound` 一处回答。规则:

1. **没有并列**。名册序就是优先级。开轮时,Session 持有的账号仍在名册里且可兑付就用它,否则用名册里第一个可兑付的;换号时用名册里第一个可兑付而本轮没到过的。
2. **说「换号」的失败(`Advance`)立刻换号**,不在原号上重发:401、额度用尽、该账号引用的凭据缺失(gateway D32)。
3. **说「稍后再问」的失败(`Yes` 且 `Keep`)在原号上再发至多 k 次**(k 取 `AccountRetries`,一或二),用完再换号;连接没建起、408、限流的 429、5xx 与列明的流内错误属于这一族。
4. **效果不明的失败(`Unknown`)只在原号上、在同一份 k 次余额内再发,从不换号**;余额用完以 `Unknown` 停下,原错误带着 `effect_unknown` 交出。
5. **请求错误(`No` 且 `Keep`)不再发**,以 `Refused` 停下。
6. **人设的 `AtMost(m)` 封住整个逻辑请求**:一轮的总发送数不超过 m+1,跨账号合计;`UntilHalted` 之下停它的是 `Halt`,多账号时还有 n·(1+k) 这道界。
7. **没有可换的账号时以 `Exhausted` 停下**,`Exhausted` 先于 `Cap` 判:一轮停下的原因若两者都成立,说出账号用尽的那句对人更有用。
8. **单账号名册照旧**:`Single` 不读 `account`,`Yes` 与 `Unknown` 再发直到人设上限,`No` 停下——与本模块之前 `Watchdog` 的规则逐臂相同,修复段的换门重发也照旧不计入上限。k 只在多账号时被读,`request_max_retries` 的含义不变。
9. **搜索账号不做 Session 亲和**:`web_search` 用同一个 `AccountRound`,`held` 恒缺席。亲和是为了保住按账号隔离的 prompt cache,搜索没有这份缓存;不出错时它总落在名册的第一个账号上,本来就不轮换。

**理由**:模型路径(runtime)与搜索路径(accounting)分处两个 crate,`kernel` 是二者唯一共同的内层,`Retries` 也住在这里(§8-72);两处各写一个循环,重试与选号就各有两个权威。账号名册是有序列表(gateway D28),用上移/下移表达优先级,所以不需要轮询游标:轮询会让新 Session 在账号之间轮换,打掉 prompt cache,而人没有要求分摊用量。`Unknown` 不换号,因为换号会在另一个账号上再计一次费,而丢了回答不证明是账号的问题。单账号照旧,因为单账号端点没有可换的账号,任何新规则对它只会是行为变化。

**被否**:①每个驱动方自己挑「第一个可兑付的账号」再自己重发一次——两个选号权威、两个重试循环;②`Watchdog::on_model_failure(&mut dyn Model)` 既改模型状态又返回处置——违反命令与查询分离,而且把模型路径的写法强加给搜索;③把 `Unknown` 一律改成冻结——改变每一个单账号端点的现行行为(`Watchdog` 的「不知道照样再问」),而这里要的只是请求错误不再发送、效果不明的请求不在别的账号上重发;④整数优先级加 `Active`/`Disabled`/`Revoked` 状态——优先级并列需要游标事件,撤销已经由「提交不含该账号的完整列表」表达(gateway D28)。

**重开参数**:人要求在账号之间分摊用量;或出现一种失败,`Unknown` 时能从对端读出效果是否落地。
-/

/-- 开轮时选哪个账号:持有的账号仍在名册里且可兑付就用它,否则名册里第一个可兑付的(D54 第 1 条)。 -/
def opening (accounts : List Account) (usable : Account → Bool) : Option Account → Option Account
  | some account => if account ∈ accounts ∧ usable account = true then some account else accounts.find? usable
  | none => accounts.find? usable

/-- `AccountRound::start`:第一次发送之前开轮。开轮即记下第一次发送;名册里一个可兑付的都没有时不发请求,答 `Exhausted`。 -/
def AccountRound.start (roster : Roster Account) (usable : Account → Bool) (cap : Retries)
    (held : Option Account) : Except RoundEnd (AccountRound Account) :=
  match roster with
  | .Single => .ok { roster, usable, cap, held, current := none, tried := [], here := 1, total := 1 }
  | .Several accounts _ =>
    match opening accounts usable held with
    | some first =>
      .ok { roster, usable, cap, held, current := some first, tried := [first], here := 1, total := 1 }
    | none => .error .Exhausted

/-! D55 凭据能否兑付在开轮时读一次;一个账号的凭据缺失只让这个账号出局,vault 本身的故障让整轮停下

**决定**:`usable` 是开轮时对名册里每个账号问一次「它的引用此刻能否兑付」(vault 里有这个条目,或环境变量提供了它),开轮与换号都跳过不可兑付的账号。问法是端点的兑付处(gateway `Redemption::holds`)向同一个 `SecretResolver` 兑付一次、只看结果、把封好的值当场丢掉(`Sealed` 丢弃即清零),值不离开那一格;只有 `E_CREDENTIAL_MISSING` 让账号不可兑付,别的兑付失败(vault 锁住、读不了)照旧算可兑付,让发送那一刻把 vault 的错误原样报出来并以 `Refused` 停下整轮。名册只有一个账号时不问:那里没有可换的账号,`Single` 不读 `usable`。发送时兑付仍可能失败:引用在 vault 里不见了,端点的兑付处(gateway D32)把那条 `E_CREDENTIAL_MISSING` 标成 `Advance`,于是只有这个账号出局;vault 锁住或不可用的错误不标 `Advance`,以 `Refused` 停下整轮。

**理由**:一个账号的引用缺失是这个账号的事,换下一个账号能修好;vault 锁住是所有账号共用的存储的事,换号只会把同一个错误在每个账号上各买一遍,最后报出的「账号用尽」也指错了出路。开轮时先问一次,免得把一次注定失败的兑付算作一次发送。

**被否**:①开轮不问、全靠发送时的 `E_CREDENTIAL_MISSING` 换号——结果相同,但每个缺凭据的账号都要占掉一次发送与一行 `model_called`;②给兑付处另接一个只描述不兑付的闭包——`Custodian::describe` 判「有没有」时同样把值从后端读出来,多一个闭包只是让每个装配点多接一根线,换不来少读一次;③给「整轮停下」另设一种 `AccountDisposition`——vault 故障本来就是 `No` 且 `Keep`,`Refused` 已经说出「不再发」,第三臂没有新的处置可给。

**重开参数**:出现一种存储,部分账号的凭据可读而其余不可读(例如按账号分开的硬件密钥)。
-/

/-- 可以换去的账号:可兑付,且本轮没到过。 -/
def AccountRound.fresh (round : AccountRound Account) (account : Account) : Bool :=
  round.usable account && !decide (account ∈ round.tried)

/-- 换号时的目标:名册里第一个可以换去的账号。 -/
def AccountRound.successor (round : AccountRound Account) : Option Account :=
  match round.roster with
  | .Single => none
  | .Several accounts _ => accounts.find? round.fresh

/-- 在原号上再发,除非人设的上限不许。 -/
def AccountRound.resend (round : AccountRound Account) : AccountStep Account :=
  if round.cap.admits round.total then .Resend else .Stop .Cap

/-- 换号;没有可换的账号即 `Exhausted`,它先于上限判(D54 第 7 条)。 -/
def AccountRound.switch (round : AccountRound Account) : AccountStep Account :=
  match round.successor with
  | none => .Stop .Exhausted
  | some to => if round.cap.admits round.total then .Switch to else .Stop .Cap

/-- `AccountRound::next`:一次失败之后的处置,一种 (`retry`, `account`) 一臂(D54)。`Advance` 只与 `No` 同行(`spec/Error.lean` D53),其余组合照样有答案,所以 `next` 是全函数。 -/
def AccountRound.next (round : AccountRound Account) (failure : Failure) : AccountStep Account :=
  match round.roster with
  | .Single =>
    match failure.retry with
    | .No => .Stop .Refused
    | .Yes => round.resend
    | .Unknown => round.resend
  | .Several _ retries =>
    match failure.retry, failure.account with
    | .Unknown, _ => if round.here ≤ retries.count then round.resend else .Stop .Unknown
    | .Yes, .Advance => round.switch
    | .No, .Advance => round.switch
    | .Yes, .Keep => if round.here ≤ retries.count then round.resend else round.switch
    | .No, .Keep => .Stop .Refused

/-- `AccountRound::apply`:驱动方执行了处置之后推进状态。`Stop` 不改状态。 -/
def AccountRound.apply (round : AccountRound Account) : AccountStep Account → AccountRound Account
  | .Resend => { round with here := round.here + 1, total := round.total + 1 }
  | .Switch to =>
    { round with current := some to, tried := to :: round.tried, here := 1, total := round.total + 1 }
  | .Stop _ => round

/-- `AccountRound::admits_repair`:修复段(`crates/runtime/spec/Turn/Recovery.lean` 的 `BlockingResend`)换阻塞门重发之前问这一句。多账号时那次重发是原号上的一次发送,要原号还有余额、人设的上限还许;单账号照旧放行(D54 第 8 条)。 -/
def AccountRound.admits_repair (round : AccountRound Account) : Bool :=
  match round.roster with
  | .Single => true
  | .Several _ retries => decide (round.here ≤ retries.count) && round.cap.admits round.total

/-- `AccountRound::repaired`:修复段真的重发了。多账号记作原号上的一次发送;单账号照旧不计。 -/
def AccountRound.repaired (round : AccountRound Account) : AccountRound Account :=
  match round.roster with
  | .Single => round
  | .Several _ _ => round.apply .Resend

/-- 一次回答绑定的账号:答出这次请求的当前账号;单账号名册没有账号可绑,绑定不动。 -/
def AccountRound.answered (round : AccountRound Account) : Option Account :=
  round.current.or round.held

/-- 驱动方在一轮里看见的事:一次发送失败了,或修复段想换门重发。 -/
inductive RoundEvent where
  | failed (failure : Failure)
  | repair
  deriving DecidableEq, Repr

/-- 驱动方照处置执行一条轨迹:失败问 `next`,停下即止;修复先问 `admits_repair`,不放行时修复段把原错误交给下一段,轮的状态不动。 -/
def AccountRound.walk (round : AccountRound Account) :
    List RoundEvent → AccountRound Account × Option RoundEnd
  | [] => (round, none)
  | .repair :: rest => (if round.admits_repair then round.repaired else round).walk rest
  | .failed failure :: rest =>
    match round.next failure with
    | .Stop why => (round, some why)
    | step => (round.apply step).walk rest

/-- 多账号一轮的不变量:到过的账号不重复且都在名册里,当前账号是最后到的那个,当前账号上的发送不超过 1+k,总发送数不超过「之前每个账号各 1+k,加上当前账号上的」。 -/
def Sound (accounts : List Account) (retries : AccountRetries) (round : AccountRound Account) : Prop :=
  round.tried.Nodup ∧ (∀ account, account ∈ round.tried → account ∈ accounts) ∧
  round.current = round.tried.head? ∧ round.tried ≠ [] ∧ round.here ≤ 1 + retries.count ∧
  round.total + (1 + retries.count) ≤ round.tried.length * (1 + retries.count) + round.here

/-- 一轮的不变量:人设的上限守住了;多账号时还有上面那几条。 -/
def AccountRound.Sound (round : AccountRound Account) : Prop :=
  round.cap.holds round.total ∧
  match round.roster with
  | .Single => True
  | .Several accounts retries => Kernel.AccountRecovery.Sound accounts retries round

omit [DecidableEq Account] in
theorem resend_sound (round : AccountRound Account) (h : round.Sound)
    (fits : round.cap.admits round.total = true)
    (within : ∀ accounts retries, round.roster = .Several accounts retries → round.here ≤ retries.count) :
    (round.apply .Resend).Sound := by
  obtain ⟨cap, shape⟩ := h
  refine ⟨?_, ?_⟩
  · cases hc : round.cap with
    | UntilHalted => simp [AccountRound.apply, Retries.holds, hc]
    | AtMost m =>
      simp [Retries.admits, hc] at fits
      simp [AccountRound.apply, Retries.holds, hc]
      omega
  · cases hr : round.roster with
    | Single => simp [AccountRound.apply, hr]
    | Several accounts retries =>
      have w := within accounts retries hr
      simp only [hr] at shape
      obtain ⟨nodup, inside, head, nonempty, here, total⟩ := shape
      simp only [AccountRound.apply, hr, Kernel.AccountRecovery.Sound]
      exact ⟨nodup, inside, head, nonempty, by omega, by omega⟩

theorem successor_fresh (round : AccountRound Account) (to : Account)
    (found : round.successor = some to) :
    to ∉ round.tried ∧ round.usable to = true ∧
      ∃ accounts retries, round.roster = .Several accounts retries ∧ to ∈ accounts := by
  unfold AccountRound.successor at found
  cases hr : round.roster with
  | Single => simp [hr] at found
  | Several accounts retries =>
    simp only [hr] at found
    have fresh := List.find?_some found
    have mem := List.mem_of_find?_eq_some found
    simp [AccountRound.fresh] at fresh
    exact ⟨fresh.2, fresh.1, accounts, retries, rfl, mem⟩

theorem switch_sound (round : AccountRound Account) (h : round.Sound)
    (fits : round.cap.admits round.total = true) (to : Account)
    (found : round.successor = some to) :
    (round.apply (.Switch to)).Sound := by
  obtain ⟨notin, _, accounts, retries, hr, mem⟩ := successor_fresh round to found
  obtain ⟨cap, shape⟩ := h
  refine ⟨?_, ?_⟩
  · cases hc : round.cap with
    | UntilHalted => simp [AccountRound.apply, Retries.holds, hc]
    | AtMost m =>
      simp [Retries.admits, hc] at fits
      simp [AccountRound.apply, Retries.holds, hc]
      omega
  · simp only [hr] at shape
    obtain ⟨nodup, inside, head, nonempty, here, total⟩ := shape
    simp only [AccountRound.apply, hr, Kernel.AccountRecovery.Sound]
    refine ⟨List.nodup_cons.mpr ⟨notin, nodup⟩, ?_, rfl, List.cons_ne_nil _ _, by omega, ?_⟩
    · intro account held
      cases List.mem_cons.mp held with
      | inl same => exact same ▸ mem
      | inr earlier => exact inside account earlier
    · simp only [List.length_cons, Nat.add_mul, Nat.one_mul]
      omega

/-- 处置只有两种会再发:`Resend` 要人设的上限还许、且(多账号时)原号还有余额;`Switch` 要上限还许、且目标是名册里下一个可兑付而本轮没到过的账号。 -/
theorem next_moves (round : AccountRound Account) (failure : Failure) :
    (round.next failure = .Resend → round.cap.admits round.total = true ∧
      ∀ accounts retries, round.roster = .Several accounts retries → round.here ≤ retries.count) ∧
    (∀ to, round.next failure = .Switch to →
      round.cap.admits round.total = true ∧ round.successor = some to) := by
  rcases failure with ⟨retry, account⟩
  unfold AccountRound.next
  cases hr : round.roster with
  | Single =>
    cases retry <;>
      simp [AccountRound.resend] <;> split <;> simp_all
  | Several accounts retries =>
    cases retry <;> cases account <;>
      simp [AccountRound.resend, AccountRound.switch] <;>
      (repeat' split) <;> simp_all

theorem walk_sound (round : AccountRound Account) (h : round.Sound) (events : List RoundEvent) :
    (round.walk events).1.Sound := by
  induction events generalizing round with
  | nil => exact h
  | cons event rest ih =>
    cases event with
    | repair =>
      simp only [AccountRound.walk]
      apply ih
      split
      · rename_i admitted
        unfold AccountRound.repaired
        cases hr : round.roster with
        | Single => exact h
        | Several accounts retries =>
          simp only [AccountRound.admits_repair, hr, Bool.and_eq_true, decide_eq_true_eq] at admitted
          apply resend_sound round h admitted.2
          intro accounts' retries' same
          rw [hr] at same
          cases same
          exact admitted.1
      · exact h
    | failed failure =>
      simp only [AccountRound.walk]
      have moves := next_moves round failure
      split
      · exact h
      · rename_i notStop
        apply ih
        generalize decided : round.next failure = step at notStop moves ⊢
        cases step with
        | Resend =>
          obtain ⟨fits, within⟩ := moves.1 rfl
          exact resend_sound round h fits within
        | Switch to =>
          obtain ⟨fits, found⟩ := moves.2 to rfl
          exact switch_sound round h fits to found
        | Stop why => exact absurd rfl (notStop why)

theorem walk_keeps (round : AccountRound Account) (events : List RoundEvent) :
    (round.walk events).1.roster = round.roster ∧ (round.walk events).1.held = round.held ∧
      (round.walk events).1.cap = round.cap := by
  induction events generalizing round with
  | nil => exact ⟨rfl, rfl, rfl⟩
  | cons event rest ih =>
    cases event with
    | repair =>
      simp only [AccountRound.walk]
      split
      · obtain ⟨roster, held, cap⟩ := ih round.repaired
        unfold AccountRound.repaired at roster held cap ⊢
        cases hr : round.roster <;> simp_all [AccountRound.apply]
      · exact ih round
    | failed failure =>
      simp only [AccountRound.walk]
      split
      · exact ⟨rfl, rfl, rfl⟩
      · obtain ⟨roster, held, cap⟩ := ih (round.apply (round.next failure))
        generalize round.next failure = step at roster held cap ⊢
        cases step <;> simp_all [AccountRound.apply]

private theorem nodup_length_le :
    ∀ (inner outer : List Account), inner.Nodup → (∀ a, a ∈ inner → a ∈ outer) →
      inner.length ≤ outer.length
  | [], _, _, _ => Nat.zero_le _
  | a :: rest, outer, nodup, inside => by
    obtain ⟨notin, nodup'⟩ := List.nodup_cons.mp nodup
    have present : a ∈ outer := inside a (List.mem_cons_self ..)
    have smaller : ∀ b, b ∈ rest → b ∈ outer.erase a := by
      intro b held
      have other : b ≠ a := fun same => notin (same ▸ held)
      exact (List.mem_erase_of_ne other).mpr (inside b (List.mem_cons_of_mem _ held))
    have shorter := nodup_length_le rest (outer.erase a) nodup' smaller
    have erased := List.length_erase_of_mem present
    have positive : 0 < outer.length := List.length_pos_of_mem present
    simp only [List.length_cons]
    omega

theorem opening_usable (accounts : List Account) (usable : Account → Bool) (held : Option Account)
    (first : Account) (opened : opening accounts usable held = some first) :
    first ∈ accounts ∧ usable first = true := by
  cases held with
  | none => exact ⟨List.mem_of_find?_eq_some opened, List.find?_some opened⟩
  | some account =>
    simp only [opening] at opened
    split at opened
    · rename_i keeps
      cases opened
      exact keeps
    · exact ⟨List.mem_of_find?_eq_some opened, List.find?_some opened⟩

theorem start_sound (roster : Roster Account) (usable : Account → Bool) (cap : Retries)
    (held : Option Account) (round : AccountRound Account)
    (started : AccountRound.start roster usable cap held = .ok round) : round.Sound := by
  have opens : cap.holds 1 := by cases cap <;> simp [Retries.holds]
  unfold AccountRound.start at started
  cases roster with
  | Single =>
    cases started
    exact ⟨opens, trivial⟩
  | Several accounts retries =>
    simp only at started
    split at started
    · rename_i first opened
      cases started
      have ⟨mem, _⟩ := opening_usable accounts usable held first opened
      refine ⟨opens, ?_⟩
      simp only [Kernel.AccountRecovery.Sound]
      refine ⟨List.nodup_cons.mpr ⟨List.not_mem_nil, List.nodup_nil⟩, ?_, rfl, List.cons_ne_nil _ _,
        by omega, by simp only [List.length_singleton]; omega⟩
      intro account held'
      rw [List.mem_singleton.mp held']
      exact mem
    · cases started

/-! ## 性质 -/

/-- **请求错误不再发送。** 不论名册、预算与本轮走到哪里,`No` 且 `Keep` 的失败一律停下:400、403、404、422、窗口溢出、读不懂的回答、本侧造不出的请求,以及 vault 锁住或不可用(它不标 `Advance`,见 D55)。 -/
theorem a_request_error_is_never_sent_again (round : AccountRound Account) :
    round.next ⟨.No, .Keep⟩ = .Stop .Refused := by
  unfold AccountRound.next
  cases round.roster <;> rfl

/-- **说「换号」的失败从不在原号上重发,换到的也不是本轮到过的账号。** 只看 `No` 且 `Advance`:`spec/Error.lean` 的 `no_order_of_calls_advances_a_resendable_failure` 证明错误的构造路径造不出别的 `Advance`。 -/
theorem an_unusable_account_is_never_asked_again (round : AccountRound Account) (h : round.Sound) :
    round.next ⟨.No, .Advance⟩ ≠ .Resend ∧
      ∀ to, round.next ⟨.No, .Advance⟩ = .Switch to → to ∉ round.tried ∧ round.current ≠ some to := by
  have moves := next_moves round ⟨.No, .Advance⟩
  refine ⟨?_, ?_⟩
  · unfold AccountRound.next AccountRound.switch
    cases round.roster with
    | Single => simp
    | Several accounts retries =>
      simp only
      split
      · simp
      · split <;> simp
  · intro to switched
    obtain ⟨_, found⟩ := moves.2 to switched
    obtain ⟨notin, _, accounts, retries, hr, _⟩ := successor_fresh round to found
    refine ⟨notin, ?_⟩
    intro same
    obtain ⟨_, shape⟩ := h
    simp only [hr, Kernel.AccountRecovery.Sound] at shape
    obtain ⟨_, _, head, _⟩ := shape
    rw [same] at head
    exact notin (List.mem_of_mem_head? head.symm)

/-- **效果不明的请求从不换号。** 在另一个账号上重发会在那个账号上再计一次费,而丢了回答不证明是账号的问题;它只在原号上、在原号的余额内重发(D54)。 -/
theorem an_unknown_effect_never_switches (round : AccountRound Account)
    (account : AccountDisposition) (to : Account) :
    round.next ⟨.Unknown, account⟩ ≠ .Switch to := by
  unfold AccountRound.next AccountRound.resend
  cases round.roster with
  | Single => simp only; split <;> simp
  | Several accounts retries =>
    cases account <;> simp only <;> (repeat' split) <;> simp

/-- **一个账号在一轮里的发送数不超过 1+k**,k 是 `AccountRetries` 的次数;修复段换门的重发也算在内。 -/
theorem sends_on_one_account_stay_within_the_budget (round : AccountRound Account) (h : round.Sound)
    (accounts : List Account) (retries : AccountRetries) (hr : round.roster = .Several accounts retries)
    (events : List RoundEvent) :
    (round.walk events).1.here ≤ 1 + retries.count := by
  obtain ⟨_, shape⟩ := walk_sound round h events
  rw [(walk_keeps round events).1, hr] at shape
  exact shape.2.2.2.2.1

/-- **一轮里每个账号至多到一次,总发送数不超过 n·(1+k)**,n 是名册的长度。 -/
theorem a_round_visits_each_account_once (round : AccountRound Account) (h : round.Sound)
    (accounts : List Account) (retries : AccountRetries) (hr : round.roster = .Several accounts retries)
    (events : List RoundEvent) :
    (round.walk events).1.tried.Nodup ∧
      (round.walk events).1.total ≤ accounts.length * (1 + retries.count) := by
  obtain ⟨_, shape⟩ := walk_sound round h events
  rw [(walk_keeps round events).1, hr] at shape
  obtain ⟨nodup, inside, _, _, here, total⟩ := shape
  have visits := nodup_length_le _ accounts nodup inside
  have scaled := Nat.mul_le_mul_right (1 + retries.count) visits
  exact ⟨nodup, by omega⟩

/-- **人设的上限 `AtMost m` 封住整个逻辑请求:一轮的总发送数不超过 m+1**,跨账号合计。 -/
theorem the_cap_bounds_every_round (round : AccountRound Account) (h : round.Sound)
    (retries : Nat) (hc : round.cap = .AtMost retries) (events : List RoundEvent) :
    (round.walk events).1.total ≤ retries + 1 := by
  obtain ⟨cap, _⟩ := walk_sound round h events
  rw [(walk_keeps round events).2.2, hc] at cap
  exact cap

/-- **绑定只在回答时移动。** 任意失败与修复的轨迹都不改动 Session 持有的账号;一轮以 `Exhausted` 或别的原因停下时,绑定留在上一次成功的账号上。 -/
theorem the_binding_moves_only_on_an_answer (round : AccountRound Account) (events : List RoundEvent) :
    (round.walk events).1.held = round.held :=
  (walk_keeps round events).2.1

omit [DecidableEq Account] in
/-- 回答绑定的是答出这次请求的账号:名册里的、本轮到过的那一个。 -/
theorem an_answer_binds_the_account_that_answered (round : AccountRound Account) (h : round.Sound)
    (accounts : List Account) (retries : AccountRetries) (hr : round.roster = .Several accounts retries) :
    ∃ account, round.answered = some account ∧ round.current = some account ∧ account ∈ accounts := by
  obtain ⟨_, shape⟩ := h
  simp only [hr, Kernel.AccountRecovery.Sound] at shape
  obtain ⟨_, inside, head, nonempty, _⟩ := shape
  cases tried : round.tried with
  | nil => exact absurd tried nonempty
  | cons first rest =>
    rw [tried] at head inside
    refine ⟨first, ?_, head, inside first (List.mem_cons_self ..)⟩
    simp [AccountRound.answered, head]

/-- **Session 亲和**:持有的账号仍在名册里且可兑付,新的一轮就从它开始,不论它排在第几。 -/
theorem a_held_account_opens_the_round (accounts : List Account) (retries : AccountRetries)
    (usable : Account → Bool) (cap : Retries) (account : Account)
    (listed : account ∈ accounts) (redeemable : usable account = true) :
    ∃ round, AccountRound.start (.Several accounts retries) usable cap (some account) = .ok round ∧
      round.current = some account := by
  simp [AccountRound.start, opening, listed, redeemable]

/-- 名册里没有一个可兑付的账号时,一轮不发请求就以 `Exhausted` 停下。 -/
theorem a_roster_with_nothing_redeemable_sends_nothing (accounts : List Account)
    (retries : AccountRetries) (usable : Account → Bool) (cap : Retries) (held : Option Account)
    (none_usable : ∀ account, account ∈ accounts → usable account = false) :
    AccountRound.start (.Several accounts retries) usable cap held = .error .Exhausted := by
  have absent : opening accounts usable held = none := by
    cases opened : opening accounts usable held with
    | none => rfl
    | some first =>
      have ⟨mem, ok⟩ := opening_usable accounts usable held first opened
      simp [none_usable first mem] at ok
  simp [AccountRound.start, absent]

/-- **单账号端点照旧**:没有第二个账号可换时,`account` 那一格不改变任何处置,处置只由 `retry` 与人设的上限决定,与这一模块出现之前 `Watchdog` 的规则相同(D54)。 -/
theorem a_single_account_ignores_the_disposition (round : AccountRound Account)
    (single : round.roster = .Single) (retry : Retry) :
    round.next ⟨retry, .Advance⟩ = round.next ⟨retry, .Keep⟩ := by
  unfold AccountRound.next
  rw [single]

/-! ## 轨迹向量

下面的 `#eval` 打印给 Rust 逐条重放的轨迹向量:每一条给出名册、不可兑付的账号、人设上限、开轮时持有的账号与一串事件,答出开轮选中的账号与每个事件之后的处置(修复行的处置是 `Resend`,或缺席表示不放行)。Rust 侧的 `account_recovery::tests::the_lean_trace_vectors_replay_one_by_one` 按同样的次序逐条重放它们与下面的 `decide` 例子;`every_round_keeps_the_lean_properties` 与 `a_single_account_round_reads_retry_alone` 用 proptest 在任意名册、可兑付子集、人设上限、持有账号与失败和修复的轨迹上检查本分部的量化定理,发送数由驱动方执行的处置数出,不读轮的内部。 -/

/-- 一条轨迹的处置序列;停下即止。 -/
def AccountRound.decisions (round : AccountRound Account) :
    List RoundEvent → List (RoundEvent × Option (AccountStep Account))
  | [] => []
  | .repair :: rest =>
    if round.admits_repair then (.repair, some .Resend) :: round.repaired.decisions rest
    else (.repair, none) :: round.decisions rest
  | .failed failure :: rest =>
    match round.next failure with
    | .Stop why => [(.failed failure, some (.Stop why))]
    | step => (.failed failure, some step) :: (round.apply step).decisions rest

/-- 一条向量:开轮,然后走事件。 -/
def vector (roster : Roster String) (missing : List String) (cap : Retries) (held : Option String)
    (events : List RoundEvent) : Option (Option String × List (RoundEvent × Option (AccountStep String))) :=
  match AccountRound.start roster (fun account => !missing.contains account) cap held with
  | .ok round => some (round.current, round.decisions events)
  | .error _ => none

/-- 限流的 429:原号再发两次,第三次失败换到 b。 -/
def busy : RoundEvent := .failed ⟨.Yes, .Keep⟩
/-- 401、额度用尽、凭据缺失。 -/
def unusable : RoundEvent := .failed ⟨.No, .Advance⟩
/-- 400 一类的请求错误。 -/
def refused : RoundEvent := .failed ⟨.No, .Keep⟩
/-- 断流:效果不明。 -/
def lost : RoundEvent := .failed ⟨.Unknown, .Keep⟩

#eval vector (.Several ["a", "b"] .Two) [] .UntilHalted none [busy, busy, busy, busy]
#eval vector (.Several ["a", "b"] .Two) [] .UntilHalted none [unusable, unusable]
#eval vector (.Several ["a", "b"] .Two) [] .UntilHalted none [refused]
#eval vector (.Several ["a", "b"] .Two) [] .UntilHalted none [lost, lost, lost]
#eval vector (.Several ["a", "b"] .Two) [] (.AtMost 1) none [busy, busy]
#eval vector (.Several ["a", "b", "c"] .One) ["b"] .UntilHalted (some "c") [busy, busy, unusable]
#eval vector (.Several ["a", "b"] .One) [] .UntilHalted (some "b") [.repair, busy, .repair]
#eval vector (.Several ["a", "b"] .Two) ["a", "b"] .UntilHalted none []
#eval vector .Single [] (.AtMost 2) none [busy, lost, busy]
#eval vector .Single [] .UntilHalted none [.repair, unusable]

/-- 正常路径可实现:两个账号、b 被持有,开轮落在 b,一次限流之后在 b 上再发。 -/
example : vector (.Several ["a", "b"] .Two) [] .UntilHalted (some "b") [busy] =
    some (some "b", [(busy, some .Resend)]) := by
  decide

end Kernel.AccountRecovery