File size: 31,910 Bytes
1d0f191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
#!/usr/bin/env python
"""The recorded walkthrough (design §18), as a script that verifies itself.

Design §18 is a timed demo with eight beats. Two are the presenter's words -- the
problem in one sentence, and the limitations -- so they print as text and are not
checked. The other six are claims about what the system does, and this script
drives each one against the real pipeline: real weights, the real ledger, the
real worker, the real reaper. Every beat asserts the state it claims to
demonstrate, so "the demo script runs end to end" is something a gate can check
rather than something a person has to watch for.

Driven in-process rather than against a running compose stack, for two reasons.
A demo needing five services up cannot be a gate. And the failure beat exists to
kill a worker, which is awkward against a supervised container and trivial
against a `Worker` object.

    uv run python scripts/walkthrough.py            # every beat
    uv run python scripts/walkthrough.py --only 4 6 # a subset

Exit code is non-zero if any beat fails to demonstrate what it claims.
"""

from __future__ import annotations

import argparse
import asyncio
import contextlib
import io
import json
import os
import sys
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))

from forgesight.db.migrate import migrate
from forgesight.db.pool import get_pool, ph
from forgesight.ledger.claims import Ledger, PredictionIn
from forgesight.settings import configure, get_settings
from forgesight.vision.types import (
    Candidate,
    Detection,
    ModelArtifact,
    PreprocessProfile,
    RuntimeProfile,
)

# Wide enough to reach any item under test, given the queue is global.
WIDE_CLAIM = 256

NARRATIVE = {
    0: (
        "The problem, in one sentence:\n"
        '  "Is a cheaper CPU configuration of a layout model safe to ship,\n'
        '   and how would I know?"\n'
        "Laptop CPU only. Every page is generated and labelled SYNTHETIC."
    ),
    8: (
        "Limitations, stated plainly:\n"
        "  - the synthetic ground truth uses this repo's box conventions, not\n"
        "    DocLayNet's, so absolute mAP does not transfer to real documents\n"
        "  - one host, CPU only\n"
        "  - every number is valid for that host and date, and no other\n"
        "  - the assumed memory model did not reproduce here; admission runs on\n"
        "    measured peaks rather than a fitted line (ADR 0004)"
    ),
}


class Failed(Exception):
    """A beat could not even attempt its claim."""


class Walkthrough:
    def __init__(self, database_url: str, object_dir: Path | None = None) -> None:
        self.s = get_settings().model_copy(update={
            "database_url": database_url,
            **({"objects_dir": object_dir} if object_dir else {}),
        })
        # configure(), not just model_copy. seed_demo.main() calls get_pool() with
        # no argument, which reads the process-wide settings. With only
        # model_copy, the seed wrote to the default database while every check
        # read this script's own, so nothing matched and every beat failed on a
        # query timeout.
        configure(self.s)
        self.s.ensure_dirs()
        if database_url.startswith("sqlite:///"):
            path = Path(database_url.removeprefix("sqlite:///"))
            path.parent.mkdir(parents=True, exist_ok=True)
            if path.exists():
                path.unlink()
        self.pool = get_pool(self.s.database_url)
        migrate(self.pool, verbose=False)
        self.pool_ledger = Ledger(self.pool)
        self.p = ph(self.pool.dialect)
        self.failures: list[str] = []
        self.t0 = time.perf_counter()
        self.ctx: dict = {}

    # -- reporting --------------------------------------------------------

    def beat(self, n: int, title: str) -> None:
        print(f"\n{'=' * 74}\n[{n}] {title}   (t+{time.perf_counter() - self.t0:5.1f}s)\n{'=' * 74}")

    def check(self, ok: bool, what: str, detail: str = "") -> bool:
        print(f"      [{'ok  ' if ok else 'FAIL'}] {what}" + (f" -- {detail}" if detail else ""))
        if not ok:
            self.failures.append(what)
        return ok

    def note(self, text: str) -> None:
        print(f"      {text}")

    # -- plumbing ---------------------------------------------------------

    def client(self):
        import forgesight.api.deps as deps

        deps.set_pool(self.pool)
        from fastapi.testclient import TestClient

        from forgesight.api.app import create_app

        return TestClient(create_app())

    def store(self):
        from forgesight.storage.object_store import FsObjectStore

        return FsObjectStore(self.s.objects_dir)

    def seed(self, pages: int, shadow: bool = False) -> dict:
        """Register candidates, seed a release and upload a batch.

        Delegates to seed_demo.py so the walkthrough and the manual demo start
        from exactly the same state; a walkthrough that built its own state would
        be demonstrating a setup nobody else runs.
        """
        from seed_demo import main as seed_main

        argv = ["--pages", str(pages)] + (["--shadow"] if shadow else [])
        buf = io.StringIO()
        with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(io.StringIO()):
            rc = seed_main(argv)
        if rc != 0:
            raise Failed(f"seed_demo returned {rc}")
        return json.loads(buf.getvalue())

    def drain(self, pool_name: str, max_rounds: int = 8) -> int:
        """Run the worker until its pool's queue is empty. Returns rounds used."""
        from forgesight.worker.main import Worker

        rounds = 0
        for _ in range(max_rounds):
            depth = self.scalar(
                f"SELECT COUNT(*) AS n FROM work_item WHERE state = {self.p} "
                f"AND pool = {self.p}", ("queued", pool_name))
            if not depth:
                break
            asyncio.run(Worker(self.s, self.pool, self.store(),
                               pool_name=pool_name).run(once=True))
            rounds += 1
        return rounds

    def scalar(self, sql: str, params: tuple = ()) -> int:
        with self.pool.connection() as conn:
            return int(conn.fetchone(sql, params)["n"])

    # -- beat 1: upload and the timing split ------------------------------

    def beat_1_upload(self) -> None:
        info = self.seed(pages=12, shadow=True)
        self.check(info["pages"] == 12, "a 12-page batch was accepted",
                   f"batch {info['batch_id']}")

        # Drain, don't tick. `Worker.run(once=True)` claims at most claim_batch
        # (8) and returns, so two calls process 16 of the 24 items a 12-page
        # shadow batch creates and beat 1 could never pass.
        for pool_name in ("torch", "onnxruntime"):
            self.drain(pool_name)

        client = self.client()
        h = {"Authorization": f"Bearer {info['token']}"}
        batch = client.get(f"/v1/batches/{info['batch_id']}", headers=h).json()
        self.check(batch["status"] == "succeeded", "batch reached succeeded",
                   f"counts={batch['counts']}")

        items = client.get(f"/v1/batches/{info['batch_id']}/items", headers=h).json()
        # Filter to succeeded items. `timings` is a required field on every row and
        # is present-but-null on an unfinished one, so indexing timed[0] picked a
        # uniformly random item and the beat failed roughly one run in three on
        # correct code.
        done = [i for i in items if i["state"] == "succeeded"]
        self.check(len(done) == len(items) and bool(items),
                   "every item succeeded and recorded timings",
                   f"{len(done)}/{len(items)} succeeded")
        if not done:
            return

        t = done[0]["timings"]
        qi, svc = t.get("queue_inclusive_ms"), t.get("service_ms")
        self.check(qi is not None and svc is not None,
                   "both latency figures are reported",
                   f"queue_inclusive_ms={qi}, service_ms={svc}")
        if qi is not None and svc is not None:
            # The point of reporting two numbers: if queue-inclusive omitted the
            # wait it would read smaller than service.
            self.check(qi >= svc - 1.0,
                       "queue-inclusive is not smaller than service",
                       f"{qi:.0f} ms >= {svc:.0f} ms")
        self.check((t.get("infer_ms") or 0) > 0, "inference time recorded",
                   f"{t.get('infer_ms')} ms")
        self.note(f"sample split: {json.dumps(t)}")

        self.ctx = {**info, "headers": h, "items": items}

    # -- beat 2: boxes and provenance -------------------------------------

    def beat_2_provenance(self) -> None:
        if not self.ctx:
            raise Failed("beat 1 must run first; --only needs its prerequisites")
        client = self.client()
        h = self.ctx["headers"]
        done = next((i for i in self.ctx["items"] if i["state"] == "succeeded"), None)
        if not self.check(done is not None, "a finished item to inspect"):
            return

        img = client.get(f"/v1/items/{done['id']}/image", headers=h,
                         follow_redirects=False)
        self.check(img.status_code in (200, 302), "page image served to its owner",
                   f"HTTP {img.status_code}")

        rel = client.get("/v1/releases", headers=h).json()
        self.check(bool(rel.get("active_release_id")), "an active release is pinned",
                   str(rel.get("active_release_id")))

        n = self.scalar(
            f"SELECT COUNT(*) AS n FROM prediction WHERE workspace_id = {self.p}",
            (self.ctx["workspace_id"],))
        self.check(n > 0, "predictions persisted", f"{n} rows")

        # Provenance: every prediction names the artifact it came from, by sha.
        with self.pool.connection() as conn:
            row = conn.fetchone(
                f"SELECT COUNT(*) AS n FROM prediction p JOIN work_item w "
                f"ON p.work_item_id = w.id JOIN candidate c ON w.candidate_id = c.id "
                f"JOIN model_artifact a ON c.artifact_id = a.id "
                f"WHERE p.workspace_id = {self.p} AND a.sha256 <> ''",
                (self.ctx["workspace_id"],))
        self.check(row["n"] > 0, "every prediction traces to a pinned artifact sha",
                   f"{row['n']} traced")

    # -- beat 3: shadow run -----------------------------------------------

    def beat_3_shadow(self) -> None:
        shadow = self.ctx.get("shadow_candidate")
        if not self.check(bool(shadow), "a shadow candidate was registered"):
            return
        n = self.scalar(
            f"SELECT COUNT(*) AS n FROM prediction WHERE workspace_id = {self.p} "
            f"AND candidate_id = {self.p}", (self.ctx["workspace_id"], shadow))
        self.check(n > 0, "the shadow candidate produced its own predictions",
                   f"{n} rows on the same pages")

        # A shadow run creates its own work items, one per candidate per page, so
        # asking "two candidates on one work_item" is the wrong question -- there
        # is only ever one candidate per item by construction. The diff view joins
        # on the *page*, so that is what has to be checked here.
        both = self.scalar(
            f"SELECT COUNT(*) AS n FROM (SELECT w.page_id FROM prediction p "
            f"JOIN work_item w ON p.work_item_id = w.id "
            f"WHERE p.workspace_id = {self.p} GROUP BY w.page_id "
            f"HAVING COUNT(DISTINCT p.candidate_id) > 1) t",
            (self.ctx["workspace_id"],))
        self.check(both > 0, "some pages carry both the release and the shadow result",
                   f"{both} pages have two candidates")

    # -- beat 4: gates, and the negative control --------------------------

    def beat_4_gates(self) -> None:
        from forgesight.eval.gates import GATE_POLICY, judge, negative_control_blocked

        def cand(name: str) -> Candidate:
            return Candidate(
                name=name,
                artifact=ModelArtifact(
                    name=name, path="/nonexistent", sha256=f"{name}-sha",
                    format="safetensors", repo_id=f"example/{name}",
                    revision="0" * 40, license="Apache-2.0"),
                preprocess=PreprocessProfile(
                    resize="pil_bilinear", target=640, render_dpi=150,
                    tile_long_side_px=2200, tile_aspect=1.6, tile_overlap=0.2,
                    label_map_hash="lm", processor_class="example"),
                runtime=RuntimeProfile(
                    runtime="torch", intra_op_threads=4, inter_op_threads=1,
                    graph_opt_level="ORT_ENABLE_ALL", max_batch=8, batch_window_ms=25),
                score_threshold=0.3, label_map_hash="lm")

        metrics = {"map": 0.50, "map50": 0.50,
                   "per_class_ap": {k: 0.5 for k in ("table", "picture", "text", "title")}}
        ref = cand("reference")

        good = judge(candidate=cand("candidate"), reference=ref,
                     clean={"reference": metrics, "candidate": dict(metrics)},
                     shift={"reference": metrics, "candidate": dict(metrics)},
                     agreement=0.995, peak_bytes=None,
                     budget_bytes=self.s.worker_mem_budget, provenance_ok=True)
        self.check(all(v["passed"] for v in good), "an identical candidate passes every gate",
                   f"{len(good)} verdicts")
        self.check({v["gate"] for v in good} == set(GATE_POLICY),
                   "all six gates were evaluated, not just the ones that pass")

        # The negative control: degraded, so it must be refused. A gate suite that
        # stopped blocking bad candidates is worse than one that never existed.
        bad = judge(candidate=cand("candidate"), reference=ref,
                    clean={"reference": metrics, "candidate": {**metrics, "map": 0.40}},
                    shift={"reference": metrics, "candidate": {**metrics, "map": 0.40}},
                    agreement=None, peak_bytes=None,
                    budget_bytes=self.s.worker_mem_budget, provenance_ok=False)
        by = {v["gate"]: v for v in bad}
        g1 = by["G1_quality_clean"]
        self.check(not g1["passed"], "the degraded 320-px control is blocked by G1",
                   f"observed {g1['observed']} against threshold {g1['threshold']}")
        self.check(negative_control_blocked(bad), "the refusal is recognised as a negative control")
        # G4 (runtime parity) and G5 (memory fit) report "passed" when they are
        # not applicable -- there was no shadow comparison and no calibration to
        # judge. Asserting that *nothing* passes would be asserting a bug. What
        # must fail is everything that had evidence to judge on.
        must_fail = {"G1_quality_clean", "G2_quality_shift", "G6_provenance"}
        self.check(all(not by[g]["passed"] for g in must_fail),
                   "every gate with evidence against it failed",
                   ", ".join(sorted(must_fail)))

    # -- beat 5: promote and roll back ------------------------------------

    def beat_5_promote_rollback(self) -> None:
        if not self.ctx:
            raise Failed("beat 1 must run first; --only needs its prerequisites")
        client = self.client()
        h = self.ctx["headers"]
        ws = self.ctx["workspace_id"]
        self._build_datasets(ws)

        cands = client.get("/v1/candidates", headers=h).json()
        if not self.check(len(cands) >= 2, "more than one candidate is available",
                          f"{len(cands)} registered"):
            return

        # The release rides on a measurement, so start from the pinned reference,
        # which is the one candidate that has to pass against itself.
        ref = next((c for c in cands if c["name"].startswith("ref-")), None)
        if not self.check(ref is not None, "a pinned reference is registered"):
            return

        res = self._evaluate(ws, ref["id"])
        self._assert_measured(res, ref["name"])
        failed = [v["gate"] for v in res.verdicts if not v["passed"]]
        if not self.check(not failed, "the reference passes every gate on real data",
                          ", ".join(failed) or "all six passed"):
            return

        before = client.get("/v1/releases", headers=h).json()
        r = client.post("/v1/releases/promote", headers=h, json={
            "candidate_id": ref["id"],
            "expected_channel_version": before["version"],
            "reason": f"walkthrough: promote {ref['name']}",
        })
        if not self.check(r.status_code == 200, "promote accepted at the current version",
                          f"HTTP {r.status_code} {r.text[:120]}"):
            return

        after = client.get("/v1/releases", headers=h).json()
        self.check(after["version"] > before["version"], "channel version advanced",
                   f"{before['version']} -> {after['version']}")

        stale = client.post("/v1/releases/promote", headers=h, json={
            "candidate_id": ref["id"],
            "expected_channel_version": before["version"],
            "reason": "walkthrough: stale version must be refused",
        })
        self.check(stale.status_code == 409,
                   "a promote against a stale version is refused (409)",
                   f"HTTP {stale.status_code}")

        rb = client.post("/v1/releases/rollback", headers=h, json={
            "expected_channel_version": after["version"],
            "reason": "walkthrough: roll back",
        })
        self.check(rb.status_code == 200, "rollback accepted", f"HTTP {rb.status_code}")
        hist = client.get("/v1/releases", headers=h).json().get("history", [])
        self.check(len(hist) >= 2, "history records both events", f"{len(hist)} releases")

        # The half of the story that matters: a candidate that fails a gate must be
        # refused, and the refusal must name the gate. An earlier version of this
        # beat picked "the first candidate that is not ref-" and promoted it after
        # writing its own `passed: true` verdict row -- which meant the demo
        # promoted whatever it was handed and called it a gate suite.
        other = next((c for c in cands
                      if not c["name"].startswith("ref-")
                      and "negctl" not in c["name"]), None)
        if other is None:
            self.note("no second candidate registered; skipped the refusal path")
            return

        res2 = self._evaluate(ws, other["id"])
        self._assert_measured(res2, other["name"])
        failed2 = [v["gate"] for v in res2.verdicts if not v["passed"]]
        if not failed2:
            self.note(f"{other['name']} passed all gates on this host; "
                      "nothing to refuse")
            return

        self.check("negctl" not in other["name"],
                   "the refused candidate is not being confused with the control",
                   other["name"])
        cur = client.get("/v1/releases", headers=h).json()
        r2 = client.post("/v1/releases/promote", headers=h, json={
            "candidate_id": other["id"],
            "expected_channel_version": cur["version"],
            "reason": f"walkthrough: try to promote {other['name']} anyway",
        })
        # FastAPI nests a dict detail one level down: {"detail": {"code": ...}}.
        body = r2.json() if "json" in r2.headers.get("content-type", "") else {}
        inner = body.get("detail") if isinstance(body, dict) else None
        inner = inner if isinstance(inner, dict) else {}
        code = inner.get("code") or ""
        named = [g for g in (inner.get("failed") or []) if g in failed2]
        self.check(r2.status_code == 422 and code == "gate_failed",
                   "promote is refused for a candidate that failed a gate",
                   f"HTTP {r2.status_code} code={code or 'none'}")
        self.check(bool(named),
                   "the refusal names the gates that actually failed",
                   ", ".join(named) or f"failed={failed2}")

    def _assert_measured(self, result, name: str) -> None:
        """Refuse to call it an evaluation unless metrics were really computed."""
        clean = result.metrics.get("clean") or {}
        cand_map = (clean.get("candidate") or {}).get("map")
        ref_map = (clean.get("reference") or {}).get("map")
        self.check(cand_map is not None and ref_map is not None,
                   f"{name}: real mAP was measured, not stubbed",
                   f"candidate={cand_map}, reference={ref_map}")
        self.check(len(result.verdicts) == 6,
                   f"{name}: the release carries six real verdicts",
                   f"{len(result.verdicts)} verdicts")

    def _build_datasets(self, ws: str, limit: int = 6) -> None:
        """Build the clean and shift splits with ground truth, for real evaluation."""
        from forgesight.eval import dataset as ds

        for name in ("synth-clean", "synth-scan"):
            if ds.load_truth(self.pool, ws, name, "test"):
                continue
            built = ds.build(name, self.s, self.store(), limit=limit)
            did = ds.persist(self.pool, ws, built)
            ds.persist_pages(self.pool, ws, did, built)
            self.note(f"built {name} v{built.semver} "
                      f"({built.counts['pages']} pages, manifest={built.manifest_hash[:12]})")

    def _evaluate(self, ws: str, candidate_id: str):
        """Run the real evaluator and persist exactly what it returned.

        An earlier version of this beat inserted an evaluation_run row whose gates
        were all `passed: true` with `observed: 1.0`, then promoted off it. That is
        a fabricated result: the release history said a candidate had passed six
        gates, and nothing had been measured. It is the exact thing the gate suite
        exists to prevent, done by the demo.

        This runs forgesight.eval.runner.run_evaluation instead: it loads the
        calibration/clean/shift splits, picks the operating threshold on calib,
        scores both candidate and reference on clean and shift, and lets judge()
        decide. Whatever it says is what gets stored, including a failure.
        """
        from forgesight.eval import dataset as ds
        from forgesight.eval.runner import run_evaluation
        from forgesight.ledger.claims import new_id

        with self.pool.write() as conn:
            rows = [dict(r) for r in conn.execute(
                f"SELECT * FROM candidate WHERE id = {self.p}", (candidate_id,))]
        if not rows:
            raise RuntimeError(f"candidate {candidate_id} is not registered")
        row = rows[0]

        # A missing dataset must fail the run, not skip a gate.
        for label, split in (("synth-clean", "test"), ("synth-scan", "test")):
            if not ds.load_truth(self.pool, ws, label, split):
                raise RuntimeError(
                    f"dataset {label}/{split} is not built for {ws}; "
                    "a missing dataset must fail the evaluation")

        started = time.monotonic()
        result = run_evaluation(row, self.s, self.pool, self.store())

        cols = ("id", "workspace_id", "candidate_id", "status", "dataset_versions",
                "metrics", "gates", "code_sha", "env_manifest")
        metrics = dict(result.metrics)
        metrics["walkthrough_elapsed_s"] = round(time.monotonic() - started, 2)
        with self.pool.write() as conn:
            conn.execute(
                f"INSERT INTO evaluation_run({', '.join(cols)}) "
                f"VALUES ({','.join([self.p] * len(cols))})",
                (new_id("ev"), ws, candidate_id, "done",
                 json.dumps(result.dataset_versions), json.dumps(metrics),
                 json.dumps(result.verdicts), "walkthrough",
                 json.dumps(result.env_manifest)))
        return result

    # -- beat 6: the worker dies ------------------------------------------

    def beat_6_failure(self) -> None:
        from forgesight.ledger.reaper import Reaper

        info = self.seed(pages=4)
        ws = info["workspace_id"]
        ledger = self.pool_ledger

        # claim() is a global, oldest-first work queue: it is not scoped to a
        # workspace. Claiming n=4 here returned an earlier beat's leftovers, so the
        # "worker killed mid-batch" beat never touched the batch it had just
        # seeded -- it claimed beat 1's pages, while every count below was scoped
        # to `ws`. Take a wide slice, keep this beat's own items, and return the
        # rest: there is no release, so an item leaves a worker by completing,
        # failing, or having its lease reaped.
        everything = ledger.claim("torch", "doomed-worker", n=WIDE_CLAIM, lease_s=60)
        held = [it for it in everything if it.workspace_id == ws]
        for it in everything:
            if it.workspace_id != ws:
                ledger.fail(it, "walkthrough: not under test")
        if not self.check(len(held) == 4, "the seeded batch was claimed in full",
                          f"{len(held)} of 4 items in {ws}"):
            return
        zombie = held[0]
        before = self.scalar(f"SELECT COUNT(*) AS n FROM prediction "
                             f"WHERE workspace_id = {self.p}", (ws,))

        # The lease lapses. Forced into the past rather than slept through: the
        # point is the fencing, and a sleep would be slower and no more
        # convincing. Scoped to the item ids this worker held -- expiring a whole
        # workspace once made the outcome depend on what a previous run had left
        # behind, and the reaper reported 0 against a database whose items were
        # already finished.
        past = datetime.now(UTC) - timedelta(seconds=120)
        ids = [it.id for it in held]
        with self.pool.write() as conn:
            conn.execute(
                f"UPDATE work_item SET lease_expires_at = {self.p} "
                f"WHERE id IN ({','.join([self.p] * len(ids))})",
                (past, *ids))

        result = Reaper(self.pool, self.s).reap_expired_leases()
        self.check(result.requeued >= len(held),
                   "the reaper requeued every abandoned item",
                   f"{result.requeued} requeued of {len(held)} abandoned")

        # A replacement takes the work. This is what rotates the fencing token:
        # an expired lease on its own does not block a write, which is the whole
        # point of the token. An earlier version of this beat asserted fencing
        # without a re-claim and was asserting a fiction -- it completed
        # successfully and reported that as the zombie being fenced out.
        taken = ledger.claim("torch", "replacement-worker", n=WIDE_CLAIM, lease_s=60)
        live = [it for it in taken if it.workspace_id == ws]
        for it in taken:
            if it.workspace_id != ws:
                ledger.fail(it, "walkthrough: not under test")
        if not self.check(bool(live), "a replacement worker can claim the same work",
                          f"{len(live)} items in {ws}"):
            return
        live_item = next((it for it in live if it.id == zombie.id), None)
        if not self.check(live_item is not None,
                          "the replacement took over the zombie's own item",
                          f"zombie={zombie.id}"):
            return
        for it in live:
            if it.id != zombie.id:
                ledger.fail(it, "walkthrough: not under test")
        self.check(live_item.fencing_token > zombie.fencing_token,
                   "the replacement's fencing token is newer",
                   f"{zombie.fencing_token} -> {live_item.fencing_token}")

        self.check(ledger.complete(live_item, self._one_detection()) is True,
                   "the live worker's result is accepted")

        # Now the zombie wakes up. Every write it attempts must be refused.
        self.check(ledger.complete(zombie, self._one_detection()) is False,
                   "the zombie's completion is refused")
        self.check(ledger.fail(zombie, "inference_failed") is False,
                   "the zombie's failure report is refused")
        self.check(ledger.mark_cancelled(zombie) is False,
                   "the zombie cannot cancel an item it no longer owns")

        after = self.scalar(f"SELECT COUNT(*) AS n FROM prediction "
                            f"WHERE workspace_id = {self.p}", (ws,))
        self.check(after == before + 1, "exactly one prediction exists: the live one",
                   f"{before} -> {after}")

    @staticmethod
    def _one_detection():
        return PredictionIn([Detection(class_id=0, label="text", score=0.9,
                                       box=(0.1, 0.1, 0.2, 0.2))])

    # -- runner -----------------------------------------------------------

    def run(self, only: list[int]) -> int:
        print("ForgeSight walkthrough -- design §18")
        print(f"database: {self.s.database_url}")

        beats = [
            (0, "the problem, in one sentence", lambda: self._say(0)),
            (1, "upload 12 synthetic pages, timing split", self.beat_1_upload),
            (2, "boxes by class, and provenance", self.beat_2_provenance),
            (3, "shadow run on the same pages", self.beat_3_shadow),
            (4, "gate report, negative control blocked", self.beat_4_gates),
            (5, "promote, then roll back", self.beat_5_promote_rollback),
            (6, "worker killed mid-batch, reaper requeues, no duplicates",
             self.beat_6_failure),
            (8, "limitations", lambda: self._say(8)),
        ]
        titles = {0: "the problem, in one sentence", 1: "upload 12 synthetic pages, timing split",
                  2: "boxes by class, and provenance", 3: "shadow run on the same pages",
                  4: "gate report, negative control blocked", 5: "promote, then roll back",
                  6: "worker killed mid-batch, reaper requeues, no duplicates",
                  8: "limitations"}
        for n, _, fn in beats:
            if only and n not in only:
                continue
            self.beat(n, titles[n])
            try:
                fn()
            except Exception as exc:  # a beat that raises is a beat that failed
                self.check(False, f"beat {n} could not complete",
                           f"{type(exc).__name__}: {exc}")

        print(f"\n{'=' * 74}")
        if self.failures:
            print(f"WALKTHROUGH FAILED -- {len(self.failures)} claim(s) did not hold:")
            for f in self.failures:
                print(f"  - {f}")
            return 1
        print("WALKTHROUGH OK -- every checkable claim in design §18 was demonstrated.")
        return 0

    def _say(self, n: int) -> None:
        print(NARRATIVE[n])


def main() -> int:
    ap = argparse.ArgumentParser(description="Drive and verify the design §18 walkthrough.")
    ap.add_argument("--only", nargs="*", type=int, default=[],
                    help="beat numbers to run (default: all)")
    ap.add_argument("--database", default=None,
                    help="DSN to run against. Defaults to a private SQLite file "
                         "which is deleted first, so the walkthrough always "
                         "starts from an empty database and its results do not "
                         "depend on whatever a previous run left behind.")
    args = ap.parse_args()
    url = args.database or os.environ.get("FORGESIGHT_DATABASE_URL") \
        or f"sqlite:///{Path(get_settings().data_dir) / 'walkthrough.db'}"
    return Walkthrough(url).run(args.only)


if __name__ == "__main__":
    sys.exit(main())