File size: 17,900 Bytes
feb1b1c
2b16e51
feb1b1c
 
2b16e51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feb1b1c
2b16e51
 
 
 
feb1b1c
2b16e51
 
feb1b1c
2b16e51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0894e2
2b16e51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb77312
 
 
2b16e51
 
 
 
 
bb77312
2b16e51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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


from __future__ import annotations

import hashlib
import json
import shutil
import time
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import TYPE_CHECKING, Protocol

from redstack.domain.errors import ArtifactContractError, DomainError
from redstack.pipelines.offline.graph import OfflineExecutionGraph
from redstack.pipelines.offline.registry import (
    OfflineArtifactRegistry,
    ValidationOutcome,
)

if TYPE_CHECKING:
    from redstack.pipelines.offline.context import OfflinePipelineContext

__all__: tuple[str, ...] = (
    "StageStatus",
    "ArtifactPayload",
    "StageResult",
    "StageCallable",
    "StageReceipt",
    "OfflinePipelineRunner",
    "RunnerError",
)


class RunnerError(DomainError):
    """A runner-level orchestration failure (checkpoint corruption, IO)."""


class StageStatus(StrEnum):
    """The terminal disposition of a stage in a single ``run()`` invocation."""

    EXECUTED = "executed"
    SKIPPED = "skipped"
    QUARANTINED = "quarantined"
    FAILED = "failed"


# An artifact payload is the parsed, registry-validatable view of one produced
# artifact: for JSON/YAML the decoded mapping; for npy/parquet the header/schema
# metadata the stage reports. The runner never re-reads bytes to validate β€”
# stages hand back exactly what the registry validator inspects, plus the raw
# bytes' sha256 the stage already computed when it wrote the artifact.
@dataclass(frozen=True, slots=True)
class ArtifactPayload:
    """One artifact produced by a stage, ready for registry validation + hashing.

    Attributes:
        key: The registry key this artifact satisfies.
        sha256: The hex sha256 the stage computed over the written bytes (the
            value that lands in ``MANIFEST.json``). The runner trusts the stage's
            hash for the receipt; O17 re-streams it during packaging.
        bytes_written: Size on disk, recorded for the manifest/report.
        validation_view: The structural payload the registry validator inspects
            (decoded mapping or extracted npy/parquet metadata).
    """

    key: str
    sha256: str
    bytes_written: int
    validation_view: Mapping[str, object]


@dataclass(frozen=True, slots=True)
class StageResult:
    """What a stage callable returns: its produced artifacts + metrics.

    Attributes:
        artifacts: The artifacts the stage produced this run, each registry-keyed.
        metrics: Deterministic stage metrics for the build report (census stats,
            honeypot counts, NDCG, cluster quality, …). Must be JSON-serializable
            and reproducible; excludes wall-clock.
    """

    artifacts: tuple[ArtifactPayload, ...]
    metrics: Mapping[str, object] = field(default_factory=dict)


class StageCallable(Protocol):
    """The injected stage contract the runner orchestrates (Part 1).

    A stage is ``f(ctx, upstream) -> StageResult``: pure given the context's
    ports + injected entropy/clock. ``upstream`` exposes the receipts of the
    stage's dependencies so a stage can read its inputs' identities (hashes)
    without the runner knowing stage internals. ``stage_version`` is the stage's
    own semver, part of its staleness key β€” bump it to force recompute on a
    behavior change.
    """

    @property
    def stage_id(self) -> str: ...

    @property
    def stage_version(self) -> str: ...

    def __call__(
        self,
        ctx: OfflinePipelineContext,
        upstream: Mapping[str, StageReceipt],
    ) -> StageResult: ...


@dataclass(frozen=True, slots=True)
class StageReceipt:
   
    stage_id: str
    stage_version: str
    staleness_key: str
    artifact_hashes: Mapping[str, str]
    artifact_bytes: Mapping[str, int]
    metrics: Mapping[str, object] = field(default_factory=dict)
    status: StageStatus = StageStatus.EXECUTED
    wall_ms: float = 0.0

    def reproducible_view(self) -> dict[str, object]:
        """Return the deterministic subset for checkpoint serialization."""
        return {
            "stage_id": self.stage_id,
            "stage_version": self.stage_version,
            "staleness_key": self.staleness_key,
            "artifact_hashes": dict(sorted(self.artifact_hashes.items())),
            "artifact_bytes": dict(sorted(self.artifact_bytes.items())),
            "metrics": _canonical_jsonable(self.metrics),
        }


def _canonical_jsonable(value: object) -> object:
    """Round-trip ``value`` through canonical JSON to normalize for hashing."""
    return json.loads(json.dumps(value, sort_keys=True, default=str))


def _sha256_hex(text: str) -> str:
    """Return the hex sha256 of a UTF-8 string."""
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


MonotonicClock = Callable[[], float]


@dataclass(frozen=True, slots=True)
class OfflinePipelineRunner:
    """Executes an offline plan with resume, checkpointing, and quarantine.

    Stateless across invocations except for the on-disk checkpoint store under
    ``ctx.checkpoints_root``; two runs with identical inputs produce identical
    receipts' reproducible views (the audit ``wall_ms`` differs and is ignored).

    Attributes:
        graph: The dependency DAG providing topo order + downstream closure.
        registry: The artifact catalog; every produced artifact is validated
            against it before its receipt is written.
        continue_on_error: If ``True``, a *non-critical* stage failure is
            flagged and the build continues; a critical failure always aborts.
        clock: Injected monotonic clock for stage timing (audit only).
            Defaults to ``time.perf_counter``, the highest-resolution monotonic
            clock available, consistent with :class:`~redstack.observability
            .timing.StageTimer` and the online pipeline's per-stage timing.
    """

    graph: OfflineExecutionGraph
    registry: OfflineArtifactRegistry
    continue_on_error: bool = False
    clock: MonotonicClock = time.perf_counter

    # ------------------------------------------------------------------ #
    # Staleness                                                          #
    # ------------------------------------------------------------------ #
    def _staleness_key(
        self,
        stage: StageCallable,
        upstream: Mapping[str, StageReceipt],
        config_hash: str,
    ) -> str:
        """Compute ``hash(input_hashes + stage_version + config_slice)`` (Part 11).

        Input hashes are the produced-artifact sha256s of *this stage's direct
        dependencies*, taken in deterministic id order, so the key is stable and
        order-independent. The ``config_slice`` is the context's canonical
        ``config_hash`` β€” a config change invalidates dependent stages.
        """
        parts: list[str] = [stage.stage_id, stage.stage_version, config_hash]
        for dep_id in sorted(self.graph.dependencies(stage.stage_id)):
            receipt = upstream.get(dep_id)
            if receipt is None:
                # Dependency produced no receipt (e.g. skipped non-critical
                # branch): contribute a stable sentinel so the key still differs
                # from a present-dependency key.
                parts.append(f"{dep_id}=βˆ…")
                continue
            for key, digest in sorted(receipt.artifact_hashes.items()):
                parts.append(f"{dep_id}:{key}={digest}")
        return _sha256_hex("\u0000".join(parts))

    # ------------------------------------------------------------------ #
    # Checkpoint persistence                                             #
    # ------------------------------------------------------------------ #
    def _checkpoint_path(self, ctx: OfflinePipelineContext, stage_id: str) -> Path:
        return ctx.checkpoints_root / f"{stage_id}.receipt.json"

    def _load_checkpoint(
        self, ctx: OfflinePipelineContext, stage_id: str
    ) -> StageReceipt | None:
        """Load a persisted receipt, or ``None`` if absent/corrupt.

        Checkpoint corruption is treated as "recompute" (Part 11: "checkpoint
        corruption β†’ recompute"), not a hard failure: a malformed sidecar simply
        yields ``None`` so the stage re-runs.
        """
        path = self._checkpoint_path(ctx, stage_id)
        if not path.is_file():
            return None
        try:
            raw = json.loads(path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            return None
        if not isinstance(raw, dict):
            return None
        try:
            return StageReceipt(
                stage_id=str(raw["stage_id"]),
                stage_version=str(raw["stage_version"]),
                staleness_key=str(raw["staleness_key"]),
                artifact_hashes=dict(raw["artifact_hashes"]),
                artifact_bytes=dict(raw["artifact_bytes"]),
                metrics=dict(raw.get("metrics", {})),
                status=StageStatus.SKIPPED,
                wall_ms=0.0,
            )
        except (KeyError, TypeError, ValueError):
            return None

    def _persist_checkpoint(
        self, ctx: OfflinePipelineContext, receipt: StageReceipt
    ) -> None:
        """Atomically persist a receipt's reproducible view as a sidecar."""
        ctx.checkpoints_root.mkdir(parents=True, exist_ok=True)
        path = self._checkpoint_path(ctx, receipt.stage_id)
        tmp = path.with_suffix(".json.tmp")
        payload = json.dumps(
            receipt.reproducible_view(),
            sort_keys=True,
            separators=(",", ":"),
            ensure_ascii=False,
        )
        try:
            tmp.write_text(payload, encoding="utf-8")
            tmp.replace(path)
        except OSError as exc:
            msg = f"failed to persist checkpoint for {receipt.stage_id!r}: {exc}"
            raise RunnerError(msg) from exc

    # ------------------------------------------------------------------ #
    # Validation + quarantine                                            #
    # ------------------------------------------------------------------ #
    def _validate_outputs(self, result: StageResult) -> None:
        """Validate every produced artifact against the registry; raise on breach.

        A validator rejection is an invariant breach (Offline-testing Β§8:
        "validated against the registry before manifesting"), so it raises
        ``ArtifactContractError`` naming the offending key + reason β€” the hard
        fail path. The happy path returns ``None``.
        """
        for payload in result.artifacts:
            outcome: ValidationOutcome = self.registry.validate(
                payload.key, payload.validation_view
            )
            if not outcome.ok:
                msg = (
                    f"artifact {payload.key!r} failed registry validation: "
                    f"{outcome.reason}"
                )
                raise ArtifactContractError(msg)

    def _quarantine(
        self, ctx: OfflinePipelineContext, stage_id: str
    ) -> None:
        """Move a failed stage's partial outputs out of the manifested tree.

        Best-effort: any artifact whose relative path is owned by ``stage_id`` is
        moved under ``quarantine_root/<stage_id>/`` so it can never be hashed
        into ``MANIFEST.json`` (Part 11: "partial/failed outputs are
        quarantined"). Missing files are ignored β€” a stage may have failed before
        writing anything.
        """
        owned = [
            spec
            for spec in self.registry.specs
            if stage_id in spec.owner_stages
        ]
        if not owned:
            return
        dest_root = ctx.quarantine_root / stage_id
        dest_root.mkdir(parents=True, exist_ok=True)
        for spec in owned:
            src = ctx.artifacts_root / spec.relative_path
            if not src.exists():
                continue
            dest = dest_root / Path(spec.relative_path).name
            try:
                shutil.move(str(src), str(dest))
            except OSError:
                # Quarantine is defence-in-depth; never let it mask the original
                # stage error. A failed move leaves the partial file, which O17's
                # required-key/coherence pass will still reject.
                continue

    # ------------------------------------------------------------------ #
    # Plan + run                                                         #
    # ------------------------------------------------------------------ #
    def plan(self, only: Sequence[str] | None = None) -> tuple[str, ...]:
        """Return the deterministic execution order, optionally restricted.

        With ``only`` given, the plan is the topo-ordered downstream closure of
        those stages (their transitive dependents), so a forced re-run of an
        upstream stage drags its closure (Part 11). Without it, the full topo
        order is returned. Cycle detection already happened at graph construction.
        """
        full = self.graph.topo_order()
        if only is None:
            return full
        closure = self.graph.downstream_closure(only)
        return tuple(sid for sid in full if sid in closure)

    def run(
        self,
        ctx: OfflinePipelineContext,
        stages: Mapping[str, StageCallable],
        *,
        force: Sequence[str] | None = None,
    ) -> dict[str, StageReceipt]:
        """Execute the plan, skipping up-to-date stages, and return receipts.

        Args:
            ctx: The immutable build context (ports + roots + provenance).
            stages: ``stage_id -> callable`` for every stage in the plan. A plan
                stage absent from this mapping is a programming error.
            force: Stage ids to recompute regardless of checkpoint freshness;
                their downstream closure is forced stale too.

        Returns:
            ``stage_id -> StageReceipt`` for every planned stage (executed,
            skipped, or β€” under ``continue_on_error`` β€” flagged failed).

        Raises:
            RunnerError: a planned stage has no registered callable, or
                checkpoint IO failed.
            ArtifactContractError: a produced artifact failed registry validation.
            Exception: re-raised from a *critical* stage after quarantine.
        """
        order = self.plan()
        forced = self.graph.downstream_closure(force) if force else frozenset()
        receipts: dict[str, StageReceipt] = {}
        reran: set[str] = set()

        for stage_id in order:
            stage = stages.get(stage_id)
            if stage is None:
                msg = f"no stage callable registered for planned stage {stage_id!r}"
                raise RunnerError(msg)

            upstream = {
                dep: receipts[dep]
                for dep in self.graph.dependencies(stage_id)
                if dep in receipts
            }
            staleness_key = self._staleness_key(stage, upstream, ctx.config_hash)
            checkpoint = self._load_checkpoint(ctx, stage_id)

            upstream_reran = any(
                dep in reran for dep in self.graph.dependencies(stage_id)
            )
            is_forced = stage_id in forced
            is_fresh = (
                checkpoint is not None
                and checkpoint.staleness_key == staleness_key
                and checkpoint.stage_version == stage.stage_version
                and not upstream_reran
                and not is_forced
            )

            if is_fresh and checkpoint is not None:
                receipts[stage_id] = checkpoint  # status == SKIPPED
                continue

            receipts[stage_id] = self._execute_stage(
                ctx, stage, upstream, staleness_key
            )
            if receipts[stage_id].status is StageStatus.EXECUTED:
                reran.add(stage_id)

        return receipts

    def _execute_stage(
        self,
        ctx: OfflinePipelineContext,
        stage: StageCallable,
        upstream: Mapping[str, StageReceipt],
        staleness_key: str,
    ) -> StageReceipt:
        """Run one stage, validate + checkpoint on success, quarantine on failure.

        Encapsulates the per-stage failure policy: validator breach and critical
        stage exceptions propagate (after quarantine); a non-critical failure
        under ``continue_on_error`` is captured as a ``FAILED`` receipt and the
        build continues.
        """
        started = self.clock()
        try:
            result = stage(ctx, upstream)
            self._validate_outputs(result)
        except ArtifactContractError:
            self._quarantine(ctx, stage.stage_id)
            raise
        except Exception:
            self._quarantine(ctx, stage.stage_id)
            node = self.graph.node(stage.stage_id)
            if node.critical or not self.continue_on_error:
                raise
            return StageReceipt(
                stage_id=stage.stage_id,
                stage_version=stage.stage_version,
                staleness_key=staleness_key,
                artifact_hashes={},
                artifact_bytes={},
                metrics={},
                status=StageStatus.FAILED,
                wall_ms=(self.clock() - started) * 1000.0,
            )

        receipt = StageReceipt(
            stage_id=stage.stage_id,
            stage_version=stage.stage_version,
            staleness_key=staleness_key,
            artifact_hashes={a.key: a.sha256 for a in result.artifacts},
            artifact_bytes={a.key: a.bytes_written for a in result.artifacts},
            metrics=result.metrics,
            status=StageStatus.EXECUTED,
            wall_ms=(self.clock() - started) * 1000.0,
        )
        self._persist_checkpoint(ctx, receipt)
        return receipt