simloom 0.2.0__tar.gz → 0.4.0__tar.gz

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.
Files changed (37) hide show
  1. {simloom-0.2.0 → simloom-0.4.0}/CHANGELOG.md +83 -1
  2. {simloom-0.2.0 → simloom-0.4.0}/PKG-INFO +32 -8
  3. {simloom-0.2.0 → simloom-0.4.0}/README.md +30 -7
  4. {simloom-0.2.0 → simloom-0.4.0}/pyproject.toml +1 -0
  5. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/__init__.py +12 -1
  6. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_cli.py +83 -1
  7. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_consistency.py +131 -15
  8. simloom-0.4.0/src/simloom/_debugger.py +173 -0
  9. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_errors.py +1 -1
  10. simloom-0.4.0/src/simloom/_linearizability.py +244 -0
  11. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_run.py +16 -1
  12. simloom-0.4.0/src/simloom/_services.py +322 -0
  13. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_version.py +1 -1
  14. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_world.py +16 -0
  15. simloom-0.2.0/src/simloom/_services.py +0 -175
  16. {simloom-0.2.0 → simloom-0.4.0}/.gitignore +0 -0
  17. {simloom-0.2.0 → simloom-0.4.0}/LICENSE +0 -0
  18. {simloom-0.2.0 → simloom-0.4.0}/examples/README.md +0 -0
  19. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_boundary.py +0 -0
  20. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_buggify.py +0 -0
  21. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_context.py +0 -0
  22. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_eventlog.py +0 -0
  23. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_explore.py +0 -0
  24. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_fingerprint.py +0 -0
  25. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_hashseed.py +0 -0
  26. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_loop.py +0 -0
  27. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_monitors.py +0 -0
  28. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_net.py +0 -0
  29. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_patches.py +0 -0
  30. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_pytest_plugin.py +0 -0
  31. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_sched.py +0 -0
  32. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_shrink.py +0 -0
  33. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_soak.py +0 -0
  34. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_systematic.py +0 -0
  35. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_tape.py +0 -0
  36. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/_testing.py +0 -0
  37. {simloom-0.2.0 → simloom-0.4.0}/src/simloom/py.typed +0 -0
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-07-06
11
+
12
+ ### Added
13
+ - **World/fault-matrix fuzzer.** Generated distributed programs (hosts running
14
+ TCP echo servers and UDP endpoints, clients hitting random peers) while a
15
+ director executes a random fault script — partitions, heals, asymmetric
16
+ blocks, crashes, restarts, connection resets, latency/loss, datagram faults —
17
+ at random virtual times. Properties: run==run==replay byte-identical whatever
18
+ the faults, and errors are only ever user-level (a replay never misaligns).
19
+ 2,000-case offline campaign, zero divergences.
20
+ - **Adversarial API-misuse suite + hostile-input hardening.** Every misuse is a
21
+ clean, named error (nested `run()`, coroutine objects, bad bounds, raising
22
+ monitor predicates/protocol callbacks/`__repr__`s, double-completes), the CLI
23
+ reports unreadable logs instead of tracebacking, unhashable register values
24
+ get a clear message, and 100 failing/deadlocking runs leave zero residue.
25
+ - **Unpinned-PYTHONHASHSEED CI job + shrinker properties.** The determinism-
26
+ critical suites now also run with hash randomization ON (any internal
27
+ hash-order dependence surfaces as a divergence), and the shrinker is
28
+ property-tested over fuzzer-generated failures: the minimized tape must still
29
+ fail with the same error class, deterministically (400-case sweep).
30
+ - **Systematic-verifier cross-validation.** `proven_correct` — the most
31
+ dangerous claim simloom makes — is cross-checked against random exploration
32
+ on fuzzer-generated programs: a proof must never coexist with a random
33
+ failure, every reported witness must replay, and an exhaustive search must
34
+ find any bug random finds. 400-case offline campaign, zero contradictions.
35
+ - **asyncio semantics-parity fuzzer.** Generated programs with interleaving-
36
+ independent results must produce exactly what the stock asyncio loop produces
37
+ — values, exception types, timeouts, shields, gathers, cancellation. Offline:
38
+ 4,000 programs x 3 sim seeds vs `asyncio.run`, zero divergences.
39
+ - **Random-program determinism fuzzer.** Arbitrary generated async programs
40
+ (queues, locks, events, sleeps, tape draws, task spawning, cancellation) must
41
+ satisfy run==run==replay byte-identically under both schedulers — deadlocking
42
+ programs must deadlock identically. Offline campaign: 20,000 programs
43
+ (~100,000 executions), zero divergences; a randomized slice runs in CI.
44
+
45
+ ### Fixed
46
+ - **Serializability checker: four incompleteness bugs**, found by a
47
+ differential campaign against an independent brute-force reference (450k+
48
+ random histories). The checker was always *sound* (no false accusations) but
49
+ missed ~20% of real violations: (1) incompatible reads — two reads neither of
50
+ which is a prefix of the other; (2) intra-transaction inconsistencies — a
51
+ transaction reading its own future append, shrinking reads, or two different
52
+ pre-transaction states; (3) torn atomic append groups — a read observing part
53
+ of a transaction's appends; (4) rw anti-dependency edges were only generated
54
+ for values in the recovered version order, missing cross-key atomicity cycles
55
+ — now a read precedes the append of *every* value it did not observe. After
56
+ the fixes: zero disagreements over 450k serializability and 320k
57
+ linearizability cases; the differential suite is now part of CI.
58
+
59
+ ## [0.3.0] - 2026-07-06
60
+
61
+ ### Added
62
+ - **Time-travel debugging v1.** `run(..., debug=True)` (implies `causal=True`)
63
+ installs a `sys.monitoring` recorder over *user code*: every line executed,
64
+ every local-variable change (`name = repr`, address-scrubbed and truncated),
65
+ and every call/return with arguments and values — all keyed to the scheduler
66
+ step that ran them. `simloom debug LOG` answers omniscient queries over the
67
+ recording: `--step N` (the code that ran during a step), `--var x` (the full
68
+ timeline of a variable — a lost update's double stale read is one query),
69
+ `--fn name` (calls/returns). Stdlib/site-packages/simloom are filtered out and
70
+ their monitoring locations disabled after first sight; debug runs are
71
+ deterministic and replay exactly.
72
+ - **Stand-in services: sim-nats.** `world.run_service(SimNats(), host=, port=)`
73
+ starts an in-sim NATS broker an *unmodified* `nats-py` client speaks to —
74
+ publish/subscribe with subject wildcards (`*`/`>`), request/reply, and queue
75
+ groups. Which member of a queue group receives each message is a **tape
76
+ draw**, so exploration covers every delivery assignment a real broker could
77
+ make (measured: distinct worker assignments across seeds, exactly-once per
78
+ message on all of them). Wire faults (partition the broker away) surface as
79
+ deterministic, replayable timeouts.
80
+ - **Wing-Gong linearizability checking.** `world.registers` records
81
+ read/write/cas operations (invoke/complete pairs, so real-time precedence is
82
+ exact); `world.check_linearizable()` / `assert_linearizable()` run Wing &
83
+ Gong's algorithm with memoisation. Stricter than serializability: the order
84
+ must respect real time, so a *stale read* is caught even when a serial order
85
+ could excuse it. Pending (crashed) operations are handled the Jepsen way —
86
+ they may or may not have taken effect, and both worlds are explored. Keys are
87
+ independent registers (linearizability is local), and a violation reports the
88
+ deepest valid prefix plus exactly which operation cannot take effect and why.
89
+
10
90
  ## [0.2.0] - 2026-06-25
11
91
 
12
92
  ### Added
@@ -180,6 +260,8 @@ explorer, shrinker, and pytest plugin are all present and exercised by a
180
260
  race (bpo-42130) reproduced deterministically from a seed; the modern stdlib
181
261
  survives the identical torture. Launch-post draft in `docs/launch-post.md`.
182
262
 
183
- [Unreleased]: https://github.com/mandipadk/simloom/compare/v0.2.0...HEAD
263
+ [Unreleased]: https://github.com/mandipadk/simloom/compare/v0.4.0...HEAD
264
+ [0.4.0]: https://github.com/mandipadk/simloom/compare/v0.3.0...v0.4.0
265
+ [0.3.0]: https://github.com/mandipadk/simloom/compare/v0.2.0...v0.3.0
184
266
  [0.2.0]: https://github.com/mandipadk/simloom/compare/v0.1.0...v0.2.0
185
267
  [0.1.0]: https://github.com/mandipadk/simloom/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simloom
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Deterministic simulation testing for asyncio: explore every schedule, inject faults, assert properties, prove correctness, and replay any failure from a seed.
5
5
  Project-URL: Homepage, https://github.com/mandipadk/simloom
6
6
  Project-URL: Repository, https://github.com/mandipadk/simloom
@@ -30,6 +30,7 @@ Requires-Dist: ruff>=0.9; extra == 'dev'
30
30
  Provides-Extra: spikes
31
31
  Requires-Dist: aiohttp>=3.11; extra == 'spikes'
32
32
  Requires-Dist: httpx>=0.28; extra == 'spikes'
33
+ Requires-Dist: nats-py>=2.9; extra == 'spikes'
33
34
  Requires-Dist: redis>=5.0; extra == 'spikes'
34
35
  Description-Content-Type: text/markdown
35
36
 
@@ -197,8 +198,10 @@ faults injected, replayable byte-for-byte from a seed.
197
198
  (memory-BIO SSL, so aiohttp HTTPS just works).
198
199
  - **💥 Honest crashes & disk** — `host.crash()` is a power cut: tasks stop with no
199
200
  `finally` blocks, unsynced disk writes are lost or *torn*, peers see resets.
200
- - **🧩 Stand-in services** — `world.run_service(SimRedis(), …)` gives you an in-sim Redis
201
- (SET/GET, WATCH/MULTI/EXEC) to test your logic against, with wire faults applied.
201
+ - **🧩 Stand-in services** — `world.run_service(...)` gives you an in-sim **Redis**
202
+ (SET/GET, WATCH/MULTI/EXEC, for unmodified redis-py) and an in-sim **NATS** broker
203
+ (pub/sub, wildcards, queue groups, for unmodified nats-py) — with wire faults applied,
204
+ and queue-group delivery choices explored from the tape.
202
205
 
203
206
  **Break it.** Explore the schedules and faults your laptop never will.
204
207
 
@@ -218,8 +221,10 @@ faults injected, replayable byte-for-byte from a seed.
218
221
  *exhaustive* delay-bounded model checking: it finds a deep interleaving bug
219
222
  deterministically, or **passes as a bounded proof of correctness**.
220
223
  - **🧾 Find wrong answers** — an Elle-style serializability checker
221
- (`world.check_serializable`) catches a store that returns a stale or impossible read —
222
- not just one that crashes — and reports the cyclic read/write dependency.
224
+ (`world.check_serializable`) reports the cyclic read/write dependency behind a lost
225
+ update, and a Wing-Gong **linearizability** checker (`world.check_linearizable`)
226
+ catches the stale read serializability would excuse — including may-or-may-not-have-
227
+ applied writes from crashed clients.
223
228
 
224
229
  **Reproduce & debug it.**
225
230
 
@@ -227,9 +232,11 @@ faults injected, replayable byte-for-byte from a seed.
227
232
  replayable artifact on disk.
228
233
  - **🔁 Determinism guarantees** — a per-test self-check (run twice, locate the first
229
234
  diverging event), `PYTHONHASHSEED` auto-pin, and a queryable boundary registry.
230
- - **🕰 Causal trace** — record with `causal=True` and walk it: `simloom trace LOG --step N`
231
- reconstructs state and the *happens-before stack* that woke it; `--changed x` is an
232
- omniscient query; `simloom diff A B` finds the first divergence between two universes.
235
+ - **🕰 Causal trace & time travel** — record with `causal=True` and walk it: `simloom trace
236
+ LOG --step N` reconstructs state and the *happens-before stack* that woke it; `simloom
237
+ diff A B` finds the first divergence between two universes. Record with `debug=True`
238
+ and query **everything**: `simloom debug LOG --var balance` prints every value a local
239
+ variable ever held — a lost update's double stale read is one query.
233
240
  - **🚨 Escape detection** — touch a real socket, signal, subprocess, or (un-patched) wall
234
241
  clock from inside the sim and you get an `EscapedSimulationError` at the exact call site
235
242
  instead of silent nondeterminism.
@@ -249,6 +256,23 @@ double-vote bug and exploration elects two leaders in one term in ~1 seed of 5;
249
256
  version survives, with coverage counters proving the faults actually fired.
250
257
  → `examples/toy_raft.py`
251
258
 
259
+ ## The harness is tested harder than your code
260
+
261
+ A correctness tool's worst failure mode is *false confidence*, so simloom attacks itself
262
+ the way it attacks your code. Beyond the 10,000-seed determinism torture, every CI run:
263
+
264
+ - **differentially validates both consistency checkers** against independent brute-force
265
+ references (the offline campaign — 450k+ serializability and 320k+ linearizability
266
+ histories — found and fixed four checker incompleteness bugs before you ever saw them);
267
+ - **fuzzes determinism with generated programs** — random workers, queues, locks,
268
+ cancellation, fault scripts against hosts and the network — every one must satisfy
269
+ `run == run == replay` byte-for-byte (20k programs + 2k fault-scripted worlds offline);
270
+ - **checks semantics parity against stock asyncio** — programs with interleaving-
271
+ independent results must behave identically on the real loop (4k programs offline);
272
+ - **cross-validates every `proven_correct` claim** against random exploration, runs the
273
+ determinism-critical suites with `PYTHONHASHSEED` *unpinned*, property-tests the
274
+ shrinker, and asserts every API misuse is a clean, named error.
275
+
252
276
  ## Honesty first
253
277
 
254
278
  A determinism claim is only as good as its disclosed limits. simloom raises a loud error
@@ -162,8 +162,10 @@ faults injected, replayable byte-for-byte from a seed.
162
162
  (memory-BIO SSL, so aiohttp HTTPS just works).
163
163
  - **💥 Honest crashes & disk** — `host.crash()` is a power cut: tasks stop with no
164
164
  `finally` blocks, unsynced disk writes are lost or *torn*, peers see resets.
165
- - **🧩 Stand-in services** — `world.run_service(SimRedis(), …)` gives you an in-sim Redis
166
- (SET/GET, WATCH/MULTI/EXEC) to test your logic against, with wire faults applied.
165
+ - **🧩 Stand-in services** — `world.run_service(...)` gives you an in-sim **Redis**
166
+ (SET/GET, WATCH/MULTI/EXEC, for unmodified redis-py) and an in-sim **NATS** broker
167
+ (pub/sub, wildcards, queue groups, for unmodified nats-py) — with wire faults applied,
168
+ and queue-group delivery choices explored from the tape.
167
169
 
168
170
  **Break it.** Explore the schedules and faults your laptop never will.
169
171
 
@@ -183,8 +185,10 @@ faults injected, replayable byte-for-byte from a seed.
183
185
  *exhaustive* delay-bounded model checking: it finds a deep interleaving bug
184
186
  deterministically, or **passes as a bounded proof of correctness**.
185
187
  - **🧾 Find wrong answers** — an Elle-style serializability checker
186
- (`world.check_serializable`) catches a store that returns a stale or impossible read —
187
- not just one that crashes — and reports the cyclic read/write dependency.
188
+ (`world.check_serializable`) reports the cyclic read/write dependency behind a lost
189
+ update, and a Wing-Gong **linearizability** checker (`world.check_linearizable`)
190
+ catches the stale read serializability would excuse — including may-or-may-not-have-
191
+ applied writes from crashed clients.
188
192
 
189
193
  **Reproduce & debug it.**
190
194
 
@@ -192,9 +196,11 @@ faults injected, replayable byte-for-byte from a seed.
192
196
  replayable artifact on disk.
193
197
  - **🔁 Determinism guarantees** — a per-test self-check (run twice, locate the first
194
198
  diverging event), `PYTHONHASHSEED` auto-pin, and a queryable boundary registry.
195
- - **🕰 Causal trace** — record with `causal=True` and walk it: `simloom trace LOG --step N`
196
- reconstructs state and the *happens-before stack* that woke it; `--changed x` is an
197
- omniscient query; `simloom diff A B` finds the first divergence between two universes.
199
+ - **🕰 Causal trace & time travel** — record with `causal=True` and walk it: `simloom trace
200
+ LOG --step N` reconstructs state and the *happens-before stack* that woke it; `simloom
201
+ diff A B` finds the first divergence between two universes. Record with `debug=True`
202
+ and query **everything**: `simloom debug LOG --var balance` prints every value a local
203
+ variable ever held — a lost update's double stale read is one query.
198
204
  - **🚨 Escape detection** — touch a real socket, signal, subprocess, or (un-patched) wall
199
205
  clock from inside the sim and you get an `EscapedSimulationError` at the exact call site
200
206
  instead of silent nondeterminism.
@@ -214,6 +220,23 @@ double-vote bug and exploration elects two leaders in one term in ~1 seed of 5;
214
220
  version survives, with coverage counters proving the faults actually fired.
215
221
  → `examples/toy_raft.py`
216
222
 
223
+ ## The harness is tested harder than your code
224
+
225
+ A correctness tool's worst failure mode is *false confidence*, so simloom attacks itself
226
+ the way it attacks your code. Beyond the 10,000-seed determinism torture, every CI run:
227
+
228
+ - **differentially validates both consistency checkers** against independent brute-force
229
+ references (the offline campaign — 450k+ serializability and 320k+ linearizability
230
+ histories — found and fixed four checker incompleteness bugs before you ever saw them);
231
+ - **fuzzes determinism with generated programs** — random workers, queues, locks,
232
+ cancellation, fault scripts against hosts and the network — every one must satisfy
233
+ `run == run == replay` byte-for-byte (20k programs + 2k fault-scripted worlds offline);
234
+ - **checks semantics parity against stock asyncio** — programs with interleaving-
235
+ independent results must behave identically on the real loop (4k programs offline);
236
+ - **cross-validates every `proven_correct` claim** against random exploration, runs the
237
+ determinism-critical suites with `PYTHONHASHSEED` *unpinned*, property-tests the
238
+ shrinker, and asserts every API misuse is a clean, named error.
239
+
217
240
  ## Honesty first
218
241
 
219
242
  A determinism claim is only as good as its disclosed limits. simloom raises a loud error
@@ -51,6 +51,7 @@ spikes = [
51
51
  "aiohttp>=3.11",
52
52
  "httpx>=0.28",
53
53
  "redis>=5.0",
54
+ "nats-py>=2.9",
54
55
  ]
55
56
 
56
57
  [project.scripts]
@@ -27,12 +27,18 @@ from ._eventlog import EVENT_LOG_FORMAT_VERSION, EventLog
27
27
  from ._explore import Exploration, Failure, explore
28
28
  from ._fingerprint import CorpusEntry, InterleavingCorpus, fingerprint, interleaving_edges
29
29
  from ._hashseed import is_pinned, pin_hashseed
30
+ from ._linearizability import (
31
+ LinCall,
32
+ LinearizabilityResult,
33
+ RegisterHistory,
34
+ check_linearizable,
35
+ )
30
36
  from ._loop import SimLoop
31
37
  from ._monitors import always, eventually, leads_to
32
38
  from ._net import SimDatagramTransport, SimNetwork, SimServer, SimTransport
33
39
  from ._run import RunResult, replay, run
34
40
  from ._sched import PCT, RandomWalk
35
- from ._services import SimRedis
41
+ from ._services import SimNats, SimRedis
36
42
  from ._shrink import ShrinkResult, shrink
37
43
  from ._soak import SoakReport, soak
38
44
  from ._systematic import SystematicResult, explore_systematic
@@ -58,8 +64,11 @@ __all__ = [
58
64
  "Host",
59
65
  "InterleavingCorpus",
60
66
  "InvariantViolation",
67
+ "LinCall",
68
+ "LinearizabilityResult",
61
69
  "MisalignmentPolicy",
62
70
  "RandomWalk",
71
+ "RegisterHistory",
63
72
  "RunResult",
64
73
  "SerializabilityResult",
65
74
  "Settings",
@@ -69,6 +78,7 @@ __all__ = [
69
78
  "SimDisk",
70
79
  "SimLivelockError",
71
80
  "SimLoop",
81
+ "SimNats",
72
82
  "SimNetwork",
73
83
  "SimRedis",
74
84
  "SimServer",
@@ -85,6 +95,7 @@ __all__ = [
85
95
  "__version__",
86
96
  "always",
87
97
  "boundary",
98
+ "check_linearizable",
88
99
  "check_serializable",
89
100
  "draw",
90
101
  "eventually",
@@ -139,6 +139,77 @@ def _cmd_diff(args: argparse.Namespace) -> int:
139
139
  return 1
140
140
 
141
141
 
142
+ def var_timeline(events: Sequence[Event], name: str) -> list[Event]:
143
+ """Every point a local variable (debug mode) or observed value changed."""
144
+ out: list[Event] = []
145
+ for event in events:
146
+ if event.get("kind") == "trace" and name in (event.get("changed") or {}):
147
+ out.append(event)
148
+ elif event.get("kind") == "trace" and event.get("event") == "call":
149
+ if name in (event.get("args") or {}):
150
+ out.append(event)
151
+ elif event.get("kind") == "observe" and event.get("name") == name:
152
+ out.append(event)
153
+ return out
154
+
155
+
156
+ def _format_trace(event: Event, name: str | None = None) -> str:
157
+ step = event.get("step")
158
+ prefix = f"step {step:>4}" if step is not None else "step -"
159
+ kind = event.get("event")
160
+ if kind == "call":
161
+ args = ", ".join(f"{k}={v}" for k, v in (event.get("args") or {}).items())
162
+ return f"{prefix} → {event.get('fn')}({args})"
163
+ if kind == "return":
164
+ return f"{prefix} ← {event.get('fn')} returned {event.get('value')}"
165
+ if kind == "unwind":
166
+ return f"{prefix} ✗ {event.get('fn')} raised {event.get('error')}"
167
+ location = f"{event.get('file', '?')}:{event.get('line', '?')}"
168
+ if name is not None:
169
+ value = (event.get("changed") or {}).get(name, (event.get("args") or {}).get(name))
170
+ return f"{prefix} {location} {name} = {value}"
171
+ changed = event.get("changed")
172
+ suffix = " " + ", ".join(f"{k}={v}" for k, v in changed.items()) if changed else ""
173
+ return f"{prefix} {location}{suffix}"
174
+
175
+
176
+ def _cmd_debug(args: argparse.Namespace) -> int:
177
+ _header, events = load_log(args.log)
178
+ traces = [e for e in events if e.get("kind") == "trace"]
179
+ if not traces:
180
+ print("no trace events: record with run(..., debug=True)")
181
+ return 2
182
+ if args.var is not None:
183
+ for event in var_timeline(events, args.var):
184
+ if event.get("kind") == "observe":
185
+ print(f"step {event.get('step')!s:>4} observe {args.var} = {event.get('value')}")
186
+ else:
187
+ print(_format_trace(event, args.var))
188
+ return 0
189
+ if args.fn is not None:
190
+ for event in traces:
191
+ if event.get("event") in ("call", "return", "unwind") and args.fn in str(
192
+ event.get("fn", "")
193
+ ):
194
+ print(_format_trace(event))
195
+ return 0
196
+ if args.step is not None:
197
+ during = [e for e in traces if e.get("step") == args.step]
198
+ print(f"=== user code executed during step {args.step} ({len(during)} events) ===")
199
+ for event in during:
200
+ print(_format_trace(event))
201
+ return 0
202
+ kinds: dict[str, int] = {}
203
+ for event in traces:
204
+ kinds[str(event.get("event"))] = kinds.get(str(event.get("event")), 0) + 1
205
+ steps = {e.get("step") for e in traces if e.get("step") is not None}
206
+ print(
207
+ f"{len(traces)} trace events across {len(steps)} steps "
208
+ f"({', '.join(f'{k}: {v}' for k, v in sorted(kinds.items()))})"
209
+ )
210
+ return 0
211
+
212
+
142
213
  def main(argv: Sequence[str] | None = None) -> int:
143
214
  parser = argparse.ArgumentParser(prog="simloom", description="simloom debugging tools")
144
215
  sub = parser.add_subparsers(dest="command", required=True)
@@ -150,13 +221,24 @@ def main(argv: Sequence[str] | None = None) -> int:
150
221
  trace.add_argument("--changed", default=None, help="steps where an observed value changed")
151
222
  trace.set_defaults(func=_cmd_trace)
152
223
 
224
+ debug = sub.add_parser("debug", help="omniscient queries over a debug-mode log")
225
+ debug.add_argument("log", help="a JSONL event log recorded with debug=True")
226
+ debug.add_argument("--step", type=int, default=None, help="code executed during step N")
227
+ debug.add_argument("--var", default=None, help="full timeline of a local variable")
228
+ debug.add_argument("--fn", default=None, help="calls/returns of a function (substring)")
229
+ debug.set_defaults(func=_cmd_debug)
230
+
153
231
  diff = sub.add_parser("diff", help="first divergence between two event logs")
154
232
  diff.add_argument("a", help="first JSONL event log")
155
233
  diff.add_argument("b", help="second JSONL event log")
156
234
  diff.set_defaults(func=_cmd_diff)
157
235
 
158
236
  args = parser.parse_args(argv)
159
- result: int = args.func(args)
237
+ try:
238
+ result: int = args.func(args)
239
+ except OSError as exc:
240
+ print(f"error: {exc}")
241
+ return 2
160
242
  return result
161
243
 
162
244
 
@@ -145,6 +145,103 @@ class _Graph:
145
145
  self.types[(u, v)] = kind
146
146
 
147
147
 
148
+ def _is_prefix(shorter: list[Any], longer: list[Any]) -> bool:
149
+ return len(shorter) <= len(longer) and longer[: len(shorter)] == shorter
150
+
151
+
152
+ def _intra_transaction_violation(txn: Transaction) -> str | None:
153
+ """In a serial order a transaction executes atomically: outside state is
154
+ frozen while it runs. So every read of key k must observe exactly
155
+ ``base + (this transaction's own prior appends to k)`` where ``base`` — the
156
+ pre-transaction state — is the same for all of the transaction's reads of k
157
+ and cannot contain the transaction's own (globally unique) values."""
158
+ own_values = {op.value for op in txn.ops if op.f == "append"}
159
+ appended_so_far: dict[str, list[Any]] = {}
160
+ base_of: dict[str, list[Any]] = {}
161
+ for op in txn.ops:
162
+ if op.f == "append":
163
+ appended_so_far.setdefault(op.key, []).append(op.value)
164
+ continue
165
+ observed = list(op.value)
166
+ own = appended_so_far.get(op.key, [])
167
+ if own and observed[len(observed) - len(own) :] != own:
168
+ return (
169
+ f"transaction {txn.index} read {observed!r} from {op.key!r}, which does "
170
+ f"not end with its own prior appends {own!r}"
171
+ )
172
+ base = observed[: len(observed) - len(own)] if own else observed
173
+ if any(value in own_values for value in base):
174
+ return (
175
+ f"transaction {txn.index} read {observed!r} from {op.key!r} containing "
176
+ f"a value it had not appended yet"
177
+ )
178
+ if op.key in base_of and base_of[op.key] != base:
179
+ return (
180
+ f"transaction {txn.index} observed two different pre-transaction states "
181
+ f"for {op.key!r}: {base_of[op.key]!r} and {base!r}"
182
+ )
183
+ base_of[op.key] = base
184
+ return None
185
+
186
+
187
+ def _torn_transaction_violation(txns: tuple[Transaction, ...]) -> str | None:
188
+ """A transaction executes atomically, so its appends to a key land in the
189
+ serial sequence as one contiguous block — and states only exist at
190
+ transaction boundaries. A read observing *any* of a transaction's appends
191
+ to a key must therefore observe *all* of them, contiguously and in order.
192
+
193
+ An external read observing a torn slice of another transaction (or its
194
+ values out of order) can never be explained by any serial order. A
195
+ transaction's *own* mid-flight reads legitimately see a prefix of its own
196
+ appends; those are validated by ``_intra_transaction_violation`` instead.
197
+ """
198
+ appends_of: dict[tuple[int, str], list[Any]] = {}
199
+ appender: dict[tuple[str, Any], int] = {}
200
+ for txn in txns:
201
+ for op in txn.ops:
202
+ if op.f == "append":
203
+ appends_of.setdefault((txn.index, op.key), []).append(op.value)
204
+ appender[(op.key, op.value)] = txn.index
205
+ for txn in txns:
206
+ for op in txn.ops:
207
+ if op.f != "read":
208
+ continue
209
+ observed = list(op.value)
210
+ for writer in {appender.get((op.key, v)) for v in observed}:
211
+ if writer is None or writer == txn.index:
212
+ continue # unknown value, or an own-read (checked elsewhere)
213
+ block = appends_of[(writer, op.key)]
214
+ positions = [i for i, v in enumerate(observed) if v in set(block)]
215
+ if [observed[i] for i in positions] != block or positions != list(
216
+ range(positions[0], positions[0] + len(block))
217
+ ):
218
+ return (
219
+ f"transaction {txn.index} read {observed!r} from {op.key!r}: it "
220
+ f"observes a torn slice of transaction {writer}'s atomic appends "
221
+ f"{block!r}"
222
+ )
223
+ return None
224
+
225
+
226
+ def _incompatible_reads_violation(txns: tuple[Transaction, ...]) -> str | None:
227
+ """A key's list only ever grows, so every read is a prefix of the final
228
+ sequence — any two reads of the same key must be prefix-related."""
229
+ reads_by_key: dict[str, list[list[Any]]] = {}
230
+ for txn in txns:
231
+ for op in txn.ops:
232
+ if op.f == "read":
233
+ reads_by_key.setdefault(op.key, []).append(list(op.value))
234
+ for key, reads in reads_by_key.items():
235
+ reads.sort(key=len)
236
+ for i in range(len(reads) - 1):
237
+ if not _is_prefix(reads[i], reads[i + 1]):
238
+ return (
239
+ f"incompatible reads of {key!r}: {reads[i]!r} and {reads[i + 1]!r} "
240
+ f"(neither is a prefix of the other, impossible for an append-only list)"
241
+ )
242
+ return None
243
+
244
+
148
245
  def check_serializable(history: History) -> SerializabilityResult:
149
246
  """Check a list-append history for serializability (Elle-style). Returns a
150
247
  result whose ``cycle`` (with per-edge ``edge_types``) witnesses any
@@ -153,40 +250,59 @@ def check_serializable(history: History) -> SerializabilityResult:
153
250
  if not txns:
154
251
  return SerializabilityResult(ok=True, message="empty history is serializable")
155
252
 
253
+ # 0. Sound structural pre-checks: reads that no serial order can explain,
254
+ # regardless of transaction ordering (found by differential testing
255
+ # against a brute-force reference — see tests/test_adversarial_checkers.py).
256
+ for txn in txns:
257
+ reason = _intra_transaction_violation(txn)
258
+ if reason is not None:
259
+ return SerializabilityResult(ok=False, message=f"serializability violation: {reason}")
260
+ reason = _incompatible_reads_violation(txns)
261
+ if reason is not None:
262
+ return SerializabilityResult(ok=False, message=f"serializability violation: {reason}")
263
+ reason = _torn_transaction_violation(txns)
264
+ if reason is not None:
265
+ return SerializabilityResult(ok=False, message=f"serializability violation: {reason}")
266
+
156
267
  # 1. Recover each key's version order from the longest read that observed it,
157
268
  # and which transaction appended each (unique) value.
158
269
  version_order: dict[str, list[Any]] = {}
159
270
  appender: dict[Any, int] = {}
271
+ appends_by_key: dict[str, list[Any]] = {}
160
272
  for txn in txns:
161
273
  for op in txn.ops:
162
274
  if op.f == "append":
163
275
  appender[op.value] = txn.index
276
+ appends_by_key.setdefault(op.key, []).append(op.value)
164
277
  elif op.f == "read":
165
278
  observed = list(op.value)
166
279
  if op.key not in version_order or len(observed) > len(version_order[op.key]):
167
280
  version_order[op.key] = observed
168
281
 
169
282
  graph = _Graph()
170
- for key, order in version_order.items():
171
- index_of = {v: i for i, v in enumerate(order)}
283
+ for order in version_order.values():
172
284
  # 2. ww edges along the version order.
173
285
  for i in range(len(order) - 1):
174
286
  a, b = appender.get(order[i]), appender.get(order[i + 1])
175
287
  if a is not None and b is not None:
176
288
  graph.add(a, b, "ww")
177
- # 3. wr and rw edges from each read of this key.
178
- for txn in txns:
179
- for op in txn.ops:
180
- if op.f != "read" or op.key != key:
181
- continue
182
- observed = list(op.value)
183
- for v in observed: # wr: the writer of an observed value precedes us
184
- w = appender.get(v)
185
- if w is not None:
186
- graph.add(w, txn.index, "wr")
187
- # rw: we precede the writer of every version we did NOT see
188
- last_seen = index_of.get(observed[-1], -1) if observed else -1
189
- for v in order[last_seen + 1 :]:
289
+ # 3. wr and rw edges from every read. The rw rule is deliberately broader
290
+ # than the version order: an append is never un-observed (the list only
291
+ # grows), so a read that does NOT contain value v must precede v's
292
+ # append — even if v was never observed by any read. (The narrow
293
+ # version-order-only rule missed cross-key atomicity cycles; found by
294
+ # differential testing against a brute-force reference.)
295
+ for txn in txns:
296
+ for op in txn.ops:
297
+ if op.f != "read":
298
+ continue
299
+ observed_set = set(op.value)
300
+ for v in op.value: # wr: the writer of an observed value precedes us
301
+ w = appender.get(v)
302
+ if w is not None:
303
+ graph.add(w, txn.index, "wr")
304
+ for v in appends_by_key.get(op.key, ()):
305
+ if v not in observed_set:
190
306
  w = appender.get(v)
191
307
  if w is not None:
192
308
  graph.add(txn.index, w, "rw")