sprawling-storage 0.0.9

The append-only, hash-chained Ledger of a sprawling city, its content store, checkpoints and worktrees.
Documentation
-- 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

/-!
# storage::hot

规定 `hot`(`crates/storage/src/` 下同名的文件)。内存热视图:界面查询在此命中,不读盘。本文件是 `crates/storage/Spec.lean` 的一个分部;下面每一节保留它在 storage 规格里的标签 §8-n,别处引作 `crates/storage/Spec.lean §8-n`,决定引作 `storage D<n>`。

这一分部大半是文字,由 Rust 的类型与模块旁的测试守住(`crates/storage/Spec.lean` §16);`waiting` 这一折(D29)有一个小模型与它的证明,模块旁的 proptest 从同一条性质导出。
-/

/-!
### 8-5 storage::hot(形状 7)

```rust
pub struct HotView { /* runs: BTreeMap<RunId, RunHot>、evicted: BTreeSet<RunId> —— 私有 */ }
pub struct RunHot { pub phase: RunPhase, pub last_seq: Seq, pub last_kind: EventKind, pub who: String,
                    pub addr: Option<Address>, pub started: Option<TimeMs>,     // 房间与开始时刻
                    pub completion: Option<String>, pub pr: Option<String>, pub ask: Option<String>,  // 结局、PR、所等之事
                    pub task: Option<String>, pub goal: Option<String>,                               // 人交给它的任务与目标
                    pub waiting: Option<RunWaiting> }                                                 // 停在同步 send 上等的房间与期限
pub struct RunWaiting { pub on: Address, pub until: TimeMs }
pub enum RunPhase { Active, Frozen }
impl HotView {
    pub fn new() -> HotView;
    pub fn apply(&mut self, record: &EventRecord) -> Result<(), StorageError>;   // 增量;重复 seq 幂等(只前进)
    pub fn runs(&self) -> impl Iterator<Item = (&RunId, &RunHot)>;              // BTreeMap 序
    pub fn get(&self, run: &RunId) -> Option<&RunHot>;
    pub fn was_evicted(&self, run: &RunId) -> bool;                            // 墓碑:这次跑冻结后被逐出
    pub fn active_count(&self) -> u64;  pub fn frozen_count(&self) -> u64;
}
pub const RECENT_FROZEN: usize = 32;
```

- **热视图只留活跃的跑和最近冻结的 `RECENT_FROZEN` 个**:一次跑冻结后,若留着的冻结跑超过 `RECENT_FROZEN`,`last_seq` 最小的那个被逐出,只留一块墓碑(它的 RunId)。所以 `runs()` 本身就是一页城景该带的那几次跑,城景的大小只随活跃数增长,热视图的内存也一样(墓碑每次跑 16 字节);更早的冻结跑经分页的 `History`/`RunHistory` 读,`frozen_count` 把墓碑也数进去,页面知道列表之外还有多少。每次冻结至多逐出一个,找最小 `last_seq` 扫一遍留着的跑,O(活跃+N),不另建按 seq 排的索引。`RECENT_FROZEN` 是线上答复的大小上界,不随机器变,所以是常量;它只在这里定义一次。
- **落在墓碑上的记录归冷的一侧**:冻结在热视图里是终态,被逐出的跑不会再活跃,所以一条记录的 RunId 在墓碑里时,`apply` 什么也不改——那条记录在账本里,分页的历史读得到它。没有墓碑的话,一条没有开场的尾巴会被当成一次新跑的检查点,把旧跑重新记成活跃的(活跃数多一,城景多一行)。

- 界面查询在此命中不读盘;run_started→Active,run_frozen→Frozen;其余事件只推进 last_seq/last_kind。
- **`addr` 与 `started` 从 `run_started` 记下**:`record.addr()` 是这次跑的房间,`record.t()` 是它开始的时刻;二者只在这一种记录上赋值,其余记录不动它们,所以一次跑的房间不会被后来的城市级记录改写。`Option`,因为热视图可能在 `run_started` 之前先看到同一次跑的 `checkpoint_committed`(检查点先于开场落账),也可能只看到一段没有开场的尾巴——**看不到的事不猜**。理由:`RunSummary.who` 是首条记录的作者、恒为 `city`,单靠它无法把一次跑归到 `hall/mayor` 这个房间,「与 Mayor 的对话」就在线上拼不出来。
- **`completion`、`pr`、`ask` 各从一种记录记下**:`run_frozen` 的 `completion` 字段;`pr_opened` 的 `branch` 字段(后一条覆盖前一条);`approval_requested` 的 `action_desc` 字段,且只活到这次跑的下一条记录——任何别的记录清掉它,所以 `ask` 有值当且仅当 `last_kind` 是 `approval_requested`,页面据 `last_kind` 判「在等」,据 `ask` 写「等什么」,两者同源。按键读字段而不整条 `Payload::read`:热视图每条记录都折,整条反序列化要复制整个 map;字段缺失记 `None`,同 `addr` 的口径,看不到的事不猜。
- **`task` 与 `goal` 从 `run_started` 记下**:那条记录的同名两个字段,与 `addr`、`started` 同一处赋值、同一个口径——只在这一种记录上写,其余记录不动它们;字段缺失或是空串记 `None`,因为一句空的任务不是一个名字。理由:run 板以它们给一行 run 起名,而重载后的页面只有 `RunSummary`(`crates/wire/Spec.lean` §8-48e)。
- **城市级记录不进 run 表**:`RunId::CITY`(nil)标记的是属于城而不属于任何 Run 的记录——创世记录、`building_created`。把它们折进 run 表会让 `active_count()` 在一座**从未派过活的城**里返回 1:城市页读服务端的这个数、写「1 run in flight」,而总览页折同一条流写「什么都没在跑」——**一个问题两个答案,而错的那个是服务端的**。
-/

/-! D29 `RunHot.waiting` 是这次跑的最后一行 `signal_wait_started` 还没被 `signal_wait_ended` 或 `run_frozen` 结束时的那对值

**决定**:`signal_wait_started`(kernel D32)把 `waiting` 设成 `{ on, until = deadline_ms }`,按键读那一行的 `on` 与 `deadline_ms`,与本节其余字段同一个口径——字段缺失或读不成就记 `None`,看不到的事不猜;`signal_wait_ended` 或 `run_frozen` 把它清掉;别的记录不动它。冻结之后到的 `signal_wait_started` 不再设它,因为冻结在热视图里是终态:一次冻结了的跑不在等任何人。一次跑同时只开一个等待(collab D9 拒绝第二个),所以结束行不按 `signal` 配对,任何一条结束行都清掉当前的等待。`until` 直接是 `deadline_ms`:那是城的注入时钟(epoch 毫秒)读出的时刻,wire D34 说的「换成墙钟」在三个平台上都是恒等。

**理由**:页面与 watchdog 要把「停着等回信」和「卡住」分开,而 `last_kind` 在两者上一样;重放与远程设备读到的状态要从账本的两行折出,所以这一折放在已经折每一条记录的热视图里,`accounting` 的 `summarize` 只是搬过去。

**被否**:①按 `signal` 配对、存下等待的 `SignalId`:一次跑只有一个开着的等待,配对只多一个字段而不多一种答案;②让 `accounting` 另折一遍这对种类:同一个状态两处折,两份答案。

**重开参数**:一次跑可以同时等两个房间时(wire D34 的重开参数),`waiting` 改成按 `signal` 键的表,结束行按 `signal` 配对。
-/
namespace Storage.Hot

/-- 一条记录在 `waiting` 这一折里是什么。房间与期限是抽象的值;其余种类一律是 `other`。 -/
inductive Line where
  | waitStarted (on deadline : Nat)
  | waitEnded
  | frozen
  | other

structure Waiting where
  on : Nat
  deadline : Nat
deriving DecidableEq

/-- 一次跑在这一折里的状态:是否已冻结,以及在等什么。 -/
structure Fold where
  frozen : Bool
  waiting : Option Waiting

def Fold.start : Fold := ⟨false, none⟩

/-- 一条记录怎样折进来,对应 `RunHot::absorb`。 -/
def step (s : Fold) : Line → Fold
  | .waitStarted on deadline => if s.frozen then s else { s with waiting := some ⟨on, deadline⟩ }
  | .waitEnded => { s with waiting := none }
  | .frozen => ⟨true, none⟩
  | .other => s

def fold (trace : List Line) : Fold := trace.foldl step Fold.start

/-- 不变式:冻结的跑不在等。 -/
def Settled (s : Fold) : Prop := s.frozen = true → s.waiting = none

theorem step_keeps_settled (s : Fold) (line : Line) (h : Settled s) : Settled (step s line) := by
  cases line with
  | waitStarted on deadline =>
    unfold step
    by_cases hf : s.frozen = true
    · simp [hf]; exact h
    · simp [hf, Settled]
  | waitEnded => intro _; rfl
  | frozen => intro _; rfl
  | other => exact h

theorem foldl_keeps_settled (trace : List Line) (s : Fold) (h : Settled s) :
    Settled (trace.foldl step s) := by
  induction trace generalizing s with
  | nil => exact h
  | cons line rest ih => exact ih (step s line) (step_keeps_settled s line h)

/-- 任何一段记录折完,一次冻结了的跑都不在等。 -/
theorem frozen_run_never_waits (trace : List Line) :
    (fold trace).frozen = true → (fold trace).waiting = none :=
  foldl_keeps_settled trace Fold.start (fun h => nomatch h)

/-- 一条结束行之后不在等。 -/
theorem ended_wait_is_gone (trace : List Line) :
    (fold (trace ++ [.waitEnded])).waiting = none := by
  simp [fold, List.foldl_append, step]

/-- 一次没冻结的跑,最后一行是开始等待时,`waiting` 正是那一行的房间与期限。 -/
theorem started_wait_is_shown (trace : List Line) (on deadline : Nat)
    (h : (fold trace).frozen = false) :
    (fold (trace ++ [.waitStarted on deadline])).waiting = some ⟨on, deadline⟩ := by
  simp only [fold, List.foldl_append, List.foldl_cons, List.foldl_nil] at *
  simp [step, h]

end Storage.Hot