specmodule 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ # module_harness/checkpoint.py
2
+ """Module 层快照/回滚:运行输入存档 + 兼容性校验(roadmap #5)。
3
+
4
+ - ``ModuleInputStore``:run.sqlite 内 ``module_inputs`` 表(本次运行使用的
5
+ spec/tasklist 存档,供兼容性对比与跨进程查询)。
6
+ - ``check_resume_compat``:新 tasklist 与已执行节点的兼容性校验。
7
+
8
+ 零修改 tickflow:全部实现位于 module_harness 层;module_inputs 表独立于
9
+ SqliteBackend 的 snapshots/firings/checkpoints 表,通过独立 sqlite3 连接
10
+ 打开同一 run.sqlite(WAL 模式多连接安全)。
11
+
12
+ 注:自动检查点(auto_checkpoints 表)已退役(S2)——每 tick 快照由
13
+ tickflow 的 _persist_tick 直接写入 snapshots 表(最小快照,D1)。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import re
21
+ import sqlite3
22
+ import time
23
+ from dataclasses import asdict, dataclass
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from tickflow import Graph
28
+
29
+ from .graph_builder import _is_constant_ref
30
+ from .spec import TaskDefinition, Tasklist
31
+ from .translator import prepare_flow
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ def _run_db_path(module_id: str, base_dir: Path | None = None) -> Path:
37
+ """``<base_dir>/.specmodule/runs/<module_id>/run.sqlite``(与 Module._persist_dir 对齐)。"""
38
+ base = base_dir if base_dir is not None else Path.cwd()
39
+ return base / ".specmodule" / "runs" / module_id / "run.sqlite"
40
+
41
+
42
+ def tasklist_to_dict(tl: Tasklist) -> dict[str, Any]:
43
+ """Tasklist → JSON 可序列化 dict(``Tasklist.to_dict`` 薄封装,导出兼容)。"""
44
+ return tl.to_dict()
45
+
46
+
47
+ def tasklist_from_dict(d: dict[str, Any]) -> Tasklist:
48
+ """tasklist_to_dict 的逆操作。"""
49
+ return Tasklist.from_json(d)
50
+
51
+
52
+ class ModuleInputStore:
53
+ """run.sqlite 内运行输入存档(module_inputs 表)。
54
+
55
+ ``module_inputs(id INT PK CHECK(id=1), spec TEXT, tasklist TEXT,
56
+ saved_at REAL)``——单行,覆盖式,供兼容性校验(警告 1)与跨进程查询
57
+ "这次 run 用了什么输入"。
58
+
59
+ 连接策略:构造时打开独立连接(WAL 模式,与 SqliteBackend 并存安全);
60
+ 写失败仅 log 不阻断(对齐 status.json 容错哲学)。
61
+ """
62
+
63
+ def __init__(self, module_id: str, base_dir: Path | None = None) -> None:
64
+ self.module_id = module_id
65
+ self.db_path = _run_db_path(module_id, base_dir)
66
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
67
+ self._conn = sqlite3.connect(str(self.db_path))
68
+ self._conn.execute("PRAGMA journal_mode=WAL")
69
+ self._init_tables()
70
+
71
+ def _init_tables(self) -> None:
72
+ self._conn.executescript(
73
+ """
74
+ CREATE TABLE IF NOT EXISTS module_inputs (
75
+ id INTEGER PRIMARY KEY CHECK (id = 1),
76
+ spec TEXT NOT NULL,
77
+ tasklist TEXT NOT NULL,
78
+ saved_at REAL NOT NULL
79
+ );
80
+ """
81
+ )
82
+
83
+ def close(self) -> None:
84
+ """关闭连接。Module 生命周期结束或临时 store 用完后调用。"""
85
+ try:
86
+ self._conn.close()
87
+ except sqlite3.Error:
88
+ pass
89
+
90
+ def save_module_inputs(self, spec: dict[str, Any], tasklist: dict[str, Any]) -> None:
91
+ """覆盖式存档本次运行的 spec/tasklist(JSON 深拷贝语义)。"""
92
+ try:
93
+ self._conn.execute(
94
+ "INSERT OR REPLACE INTO module_inputs(id, spec, tasklist, saved_at) "
95
+ "VALUES (1, ?, ?, ?)",
96
+ (json.dumps(spec, ensure_ascii=False),
97
+ json.dumps(tasklist, ensure_ascii=False),
98
+ time.time()),
99
+ )
100
+ self._conn.commit()
101
+ except (sqlite3.Error, OSError, TypeError):
102
+ log.exception("module_inputs 存档失败(不阻断): %s", self.db_path)
103
+
104
+ def load_module_inputs(self) -> dict[str, Any] | None:
105
+ """读回存档;无存档或损坏返回 None。返回 ``{"spec": dict, "tasklist": dict}``。"""
106
+ try:
107
+ row = self._conn.execute(
108
+ "SELECT spec, tasklist FROM module_inputs WHERE id = 1"
109
+ ).fetchone()
110
+ except sqlite3.Error:
111
+ log.exception("module_inputs 读取失败: %s", self.db_path)
112
+ return None
113
+ if row is None:
114
+ return None
115
+ try:
116
+ return {"spec": json.loads(row[0]), "tasklist": json.loads(row[1])}
117
+ except json.JSONDecodeError:
118
+ log.warning("module_inputs 存档损坏,忽略")
119
+ return None
120
+
121
+
122
+ # ------------------------------------------------------------------
123
+ # 兼容性校验
124
+ # ------------------------------------------------------------------
125
+
126
+
127
+ class ResumeError(Exception):
128
+ """resume 兼容性硬错误。含全部错误明细(换行分隔)。"""
129
+
130
+ def __init__(self, errors: list[str]) -> None:
131
+ self.errors = list(errors)
132
+ super().__init__("\n".join(errors))
133
+
134
+
135
+ @dataclass
136
+ class ResumeCheck:
137
+ """兼容性校验结果。hard_errors 非空则拒绝 resume。"""
138
+
139
+ hard_errors: list[str]
140
+ warnings: list[str]
141
+
142
+
143
+ def _is_transitive_upstream(graph: Graph, producer: str, consumer: str) -> bool:
144
+ """BFS:producer 能否沿出边到达 consumer(含 producer == consumer)。"""
145
+ seen = {producer}
146
+ stack = [producer]
147
+ while stack:
148
+ n = stack.pop()
149
+ if n == consumer:
150
+ return True
151
+ for e in graph.out_edges(n):
152
+ if e.dst not in seen:
153
+ seen.add(e.dst)
154
+ stack.append(e.dst)
155
+ return False
156
+
157
+
158
+ def _reachable_from_marking(
159
+ graph: Graph,
160
+ executed_nodes: set[str],
161
+ marking_slots: dict[str, bool],
162
+ armed_starts: set[str] | list[str] | None = None,
163
+ ) -> set[str]:
164
+ """不动点模拟:从检查点 marking 出发,判定哪些未执行非 start 节点最终会 fire。
165
+
166
+ 警告 3 的核心问题:只看节点自身入边在检查点的 slot 直接值,会误报
167
+ 深回退(回退到 ≥2 层上游)场景——入边已满足的上游节点尚未执行,它
168
+ 一旦 fire 就会产出下游节点的入边 slot。此处模拟"将 fire"的传播:
169
+
170
+ 1. 初始 ``satisfied`` = 检查点 marking 中值为 True 的边键集合
171
+ (键格式 ``"dst|src"``,与 Marking.to_json 一致)∪ 武装 start 的
172
+ 出边键(见下)。
173
+ 2. 迭代:对每个未执行且非 start 的节点 M,若 M 的所有入边(AND join)
174
+ 或任一入边(OR join)都在 ``satisfied`` 中(与
175
+ ``engine._join_satisfied`` 的语义一致)→ M 将 fire → M 的所有出边
176
+ 加入 ``satisfied``。
177
+ 3. 循环至 ``satisfied`` 不再增长(不动点)。
178
+
179
+ ``armed_starts`` 分支(``engine._join_satisfied`` 首条分支,
180
+ engine.py:103-106):武装的 start 在续跑的第一个 tick 无条件 fire 并写
181
+ 下游 slot——即使其入边在检查点全部未满足。典型场景是 resume 到"运行前
182
+ 手动检查点"(build_runner() 后、run 前打点:armed_starts 非空、slots
183
+ 全空)。模拟与之一致:把每个在图中且武装的 start 的所有出边键并入初始
184
+ ``satisfied``(guard 出边同样乐观加入,与下述取舍一致)。格式与
185
+ ``Marking.to_json`` 的 ``armed_starts`` 一致(排序 list,engine.py:72);
186
+ 为 None/空时跳过。
187
+
188
+ guard 边(``e.guard is not None``)**乐观加入**:guard 结果运行时才知,
189
+ 此处假定为 True。取舍:警告语义是"可能不会执行"的提示性警告——乐观
190
+ 会减少误报(深回退是核心工作流,上游重跑后 guard 通常复现原结果,如
191
+ loop 的 guard);代价是 guard 实际为 False 时可能漏报(节点确实不执行
192
+ 但没警告)。提示性警告宁可少误报,故乐观传播。
193
+
194
+ 返回判定为"将 fire"的节点集合(已执行节点与 start 永不参与)。
195
+ """
196
+ satisfied = {k for k, v in marking_slots.items() if v}
197
+ for n in set(armed_starts or []):
198
+ if n in graph.nodes:
199
+ satisfied.update(f"{e.dst}|{e.src}" for e in graph.out_edges(n))
200
+ reachable: set[str] = set()
201
+ changed = True
202
+ while changed:
203
+ changed = False
204
+ for n in graph.nodes:
205
+ if n in executed_nodes or n in graph.starts or n in reachable:
206
+ continue
207
+ in_edges = [f"{e.dst}|{e.src}" for e in graph.edges if e.dst == n]
208
+ if not in_edges:
209
+ # 无入边的非 start 节点永不 fire(engine 空 producer 规则),跳过
210
+ continue
211
+ if graph.nodes[n].join == "OR":
212
+ fire = any(k in satisfied for k in in_edges)
213
+ else:
214
+ fire = all(k in satisfied for k in in_edges)
215
+ if not fire:
216
+ continue
217
+ reachable.add(n)
218
+ for e in graph.out_edges(n):
219
+ key = f"{e.dst}|{e.src}"
220
+ if key not in satisfied:
221
+ satisfied.add(key)
222
+ changed = True
223
+ return reachable
224
+
225
+
226
+ def check_resume_compat(
227
+ new_tasklist: Tasklist,
228
+ graph: Graph,
229
+ executed_nodes: set[str],
230
+ old_tasklist: Tasklist | None = None,
231
+ marking_slots: dict[str, bool] | None = None,
232
+ armed_starts: set[str] | list[str] | None = None,
233
+ ) -> ResumeCheck:
234
+ """新 tasklist 与已执行节点的兼容性校验。
235
+
236
+ - 硬错误 1:新 task 的 inputs 引用的 producer 不在新图节点集合中
237
+ (常量引用跳过:``{spec.xxx}`` 与裸 token ``{spec}``/``{tasklist}``/
238
+ ``{node}``——graph_builder 注册时解析为 spec_inputs,此处复用
239
+ ``_is_constant_ref`` 保持单一事实源)。
240
+ - 硬错误 2:新图中**新成为** start 且有历史输出的节点(armed_starts
241
+ 一次性,永不重跑;底层 ``_warn_graph_changes`` 在 remap old==new 时
242
+ 不触发,此处补上)。"新成为"判定:与旧 tasklist flow 的 start 集合
243
+ 对比(flow 先经 ``prepare_flow`` 规范化——无 ``[`` 标记时自动把首
244
+ token 包成 start,与 graph 构建一致——再正则 ``\\[(\\w+)\\]`` 提取
245
+ start 集合)——正常 resume 里旧图 start 有历史是常态,不能误报。无存档
246
+ (old_tasklist=None)时降级为警告(保守)。
247
+ - 警告 1:已执行节点在新 tasklist 中被修改(对比 module_inputs 存档,
248
+ 修改对已执行部分不生效)。
249
+ - 警告 2:inputs 引用的 producer 未执行、且不是 consumer 的拓扑上游
250
+ (运行时 resolve 为 Missing,prompt 占位符保留字面量)。
251
+ - 警告 3:未执行且非 start 的节点,从检查点 marking 出发经不动点模拟
252
+ 仍不可达——该节点永远不会 fire。``remap_graph`` 移植 slot 时新边取
253
+ ``old_slots.get(key, False)``(runner.py:405):新节点/改名节点的入边
254
+ 在旧 marking 中不存在 → False;旧边已消费也是 False。不动点模拟考虑
255
+ "入边已满足的节点将 fire 并产出下游 slot"(深回退场景上游将重跑),
256
+ guard 边乐观传播(见 ``_reachable_from_marking`` 的取舍说明)。需回退
257
+ 到更早检查点让其上游重新执行,或设为 start。``marking_slots`` 为检查点
258
+ snapshot 的 ``marking.slots``,键格式 ``"dst|src"``(与
259
+ ``Marking.to_json`` 一致,engine.py:71);为 None 时跳过本检查。
260
+ ``armed_starts`` 为检查点 snapshot 的 ``marking.armed_starts``(排序
261
+ list,engine.py:72)——武装的 start 续跑时无条件 fire 并写下游 slot
262
+ (``engine._join_satisfied`` 首分支,engine.py:103-106),模拟将其出边
263
+ 并入初始 satisfied(如 resume 到"运行前手动检查点":armed_starts 非空、
264
+ slots 全空);为 None 时跳过。
265
+
266
+ 返回 ResumeCheck;调用方在 hard_errors 非空时 raise ResumeError。
267
+ """
268
+ hard_errors: list[str] = []
269
+ warnings: list[str] = []
270
+ tasks = new_tasklist.tasks
271
+
272
+ for key, task in tasks.items():
273
+ for field, producer in (task.inputs or {}).items():
274
+ if _is_constant_ref(producer):
275
+ continue
276
+ if producer not in graph.nodes:
277
+ hard_errors.append(
278
+ f"Task '{key}': inputs 引用 '{producer}' 不在新图中"
279
+ )
280
+ elif producer not in executed_nodes and not _is_transitive_upstream(
281
+ graph, producer, key
282
+ ):
283
+ warnings.append(
284
+ f"Task '{key}': inputs 引用 '{producer}' 未执行且非其拓扑上游"
285
+ f"——运行时可能 resolve 为 Missing"
286
+ )
287
+
288
+ # 硬错误 2:新图中"新成为" start 且有历史输出。旧图 start 有历史是正常
289
+ # resume 场景(如回退到中途,A 是 start 且已执行),不能误报——用旧
290
+ # tasklist flow 的 start 集合判定"新成为"。flow 先经 prepare_flow 规范化
291
+ # (无 [ 标记时首 token 自动包成 start,与 graph 构建一致),再提取 [A]。
292
+ old_starts: set[str] = set()
293
+ if old_tasklist is not None:
294
+ old_starts = set(re.findall(r"\[(\w+)\]", prepare_flow(old_tasklist.flow)))
295
+ for n in graph.starts:
296
+ if n in executed_nodes:
297
+ if old_tasklist is not None and n not in old_starts:
298
+ hard_errors.append(
299
+ f"Node '{n}' 新成为 start 但已有执行历史——armed_starts "
300
+ f"一次性,永不重跑。请回退到更早的 tick 或改回非 start。"
301
+ )
302
+ elif old_tasklist is None:
303
+ warnings.append(
304
+ f"Node '{n}' 是 start 且已有执行历史(无存档可对比是否"
305
+ f"新成为)——若期望其重跑需回退到 tick 0"
306
+ )
307
+
308
+ if old_tasklist is not None:
309
+ for n in sorted(executed_nodes & set(tasks)):
310
+ old = old_tasklist.tasks.get(n)
311
+ if old is not None and asdict(old) != asdict(tasks[n]):
312
+ warnings.append(
313
+ f"已执行节点 '{n}' 的 task 定义被修改——修改对已执行部分不生效,"
314
+ f"需回退到更早的检查点"
315
+ )
316
+
317
+ # 警告 3:未执行非 start 节点,从检查点 marking 出发经不动点模拟仍不可达
318
+ # → 永不 fire。模拟考虑"入边已满足的上游节点将 fire 并产出下游 slot",
319
+ # 消除深回退(回退到 ≥2 层上游)场景的误报——那是核心工作流。
320
+ if marking_slots is not None:
321
+ reachable = _reachable_from_marking(
322
+ graph, executed_nodes, marking_slots, armed_starts
323
+ )
324
+ for n in graph.nodes:
325
+ if n in executed_nodes or n in graph.starts or n in reachable:
326
+ continue
327
+ in_edges = [f"{e.dst}|{e.src}" for e in graph.edges if e.dst == n]
328
+ if not in_edges:
329
+ continue
330
+ warnings.append(
331
+ f"Node '{n}' 的入边在检查点均未满足(新边或已消费)——"
332
+ f"该节点不会自动执行。需回退到更早检查点使其上游重新执行,"
333
+ f"或将其设为 start。"
334
+ )
335
+
336
+ return ResumeCheck(hard_errors=hard_errors, warnings=warnings)