snail-dsl 0.1.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.
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Tico Internet LLC
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: snail-dsl
3
+ Version: 0.1.0
4
+ Summary: Single Node Activated Inference Layer — a Python DSL for composing frozen neural primitives into typed dataflow programs.
5
+ Author: Tico Internet LLC
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/lordxmen2k/SNAIL-DSL
8
+ Project-URL: Repository, https://github.com/lordxmen2k/SNAIL-DSL
9
+ Keywords: ai,ml,dsl,frozen-models,dataflow,neural-networks,agents
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: pydantic>=2.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
27
+ Requires-Dist: build>=0.10; extra == "dev"
28
+ Requires-Dist: twine>=4.0; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # SNAIL — Single Node Activated Inference Layer
32
+
33
+ A Python DSL for composing **frozen, single-pass neural primitives** into **statically-typed dataflow programs**.
34
+
35
+ If an MCP tool and a markdown skill had a baby, and you told the LLM it wasn't allowed to interpret the recipe — it was just one of many nodes in the recipe — you'd get SNAIL.
36
+
37
+ ## Why
38
+
39
+ Most AI models are trained to be comprehensive. SNAIL trains tiny, single-task nodes — **activated exactly once**, **locked output**, **frozen forever** — and composes them into deterministic graphs.
40
+
41
+ A SNAIL program is a **recipe that's more resilient than a `.md` spec** and **more deterministic than an MCP tool call**. Every step is a typed function call, not a prompt the LLM might re-interpret. Every run emits a manifest. Every node knows what it doesn't know (OOD is a first-class type).
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install snail-dsl
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```python
52
+ from snail import node, Program, edge
53
+ from pydantic import BaseModel
54
+
55
+ # Declare the type contract for each node's output.
56
+ class InvoiceTotal(BaseModel):
57
+ ok: dict | None = None # { "total": float, "currency": str, "confidence": float }
58
+ ood: dict | None = None # { "reason": str, "confidence": float, "threshold": float }
59
+
60
+ @node(
61
+ name="extract_invoice_total",
62
+ input_schema=dict, # replace with a real pydantic model
63
+ output_schema=InvoiceTotal,
64
+ distribution="synthetic_invoices_v2",
65
+ frozen_weights="weights/extract_invoice_total_v3.snail",
66
+ confidence_threshold=0.7,
67
+ )
68
+ def extract_invoice_total(ctx, weights, image):
69
+ raw = weights.model.forward(image["tensor"])
70
+ return InvoiceTotal(ok={
71
+ "total": raw["total"],
72
+ "currency": raw["currency"],
73
+ "confidence": raw["confidence"],
74
+ })
75
+
76
+ # Compose into a typed DAG.
77
+ invoice_pipeline = Program(
78
+ name="invoice_pipeline_v1",
79
+ nodes=[extract_invoice_total],
80
+ edges=[],
81
+ )
82
+
83
+ result = invoice_pipeline.run({"image_path": "scan.png"})
84
+ print(result.manifest)
85
+ ```
86
+
87
+ The decorator enforces:
88
+
89
+ - Single forward pass per call — no retries, no second thoughts.
90
+ - Weights loaded once, frozen for the lifetime of the program.
91
+ - Output type-checked against `output_schema`; mismatches become OOD.
92
+ - Confidence below `confidence_threshold` is silently flipped to OOD.
93
+ - Output is locked (immutable) before being handed back to the caller.
94
+
95
+ ## The four primitives
96
+
97
+ | Primitive | What it does |
98
+ |---|---|
99
+ | `@node` | Decorator that wraps a Python function as a frozen, single-pass, OOD-aware node. |
100
+ | `Program` | Container that builds a typed DAG of nodes. Validates at construction time. |
101
+ | `edge()` | Builder for typed field-to-field connections between nodes. |
102
+ | `manifest` | Structured per-run log: which nodes fired, what they got, what they returned. |
103
+
104
+ ## Architecture
105
+
106
+ - **Nodes** are frozen, single forward pass, locked output.
107
+ - **Composition** is a static DAG declared in Python.
108
+ - **Training** is 100% synthetic, offline.
109
+ - **OOD** is a first-class type (Ok / OOD discriminated union).
110
+ - **Every node ships with golden tests** (`pytest`).
111
+ - **Every run emits a manifest.**
112
+
113
+ ## Wrapping external models
114
+
115
+ External models (HuggingFace, hosted APIs, pure functions) become nodes through wrappers. They never get called directly.
116
+
117
+ ```python
118
+ from snail.wrappers import ExternalLocalNode, HostedNode, DeterministicNode
119
+
120
+ # Pin a local model to exact weights hash
121
+ classify_layout = ExternalLocalNode(
122
+ name="classify_layout",
123
+ input_schema=InvoiceImage,
124
+ output_schema=LayoutClass,
125
+ distribution="rvl_cdip_subset",
126
+ call=lambda img: yolo_model.predict(img.path),
127
+ weight_pin="yolov8n@sha256:abc123...",
128
+ )
129
+
130
+ # Wrap a hosted API
131
+ summarize = HostedNode(
132
+ name="summarize",
133
+ input_schema=InvoiceText,
134
+ output_schema=InvoiceSummary,
135
+ endpoint="anthropic://claude-sonnet-4-5",
136
+ prompt_template="Summarize: {text}",
137
+ api_key_env="ANTHROPIC_API_KEY",
138
+ )
139
+
140
+ # Or just a pure function
141
+ validate_email = DeterministicNode(
142
+ name="validate_email",
143
+ input_schema=EmailField,
144
+ output_schema=EmailValidated,
145
+ fn=lambda x: x if "@" in x.value else None,
146
+ ood_on_none=True,
147
+ )
148
+ ```
149
+
150
+ ## Testing
151
+
152
+ ```bash
153
+ pip install -e ".[dev]"
154
+ pytest
155
+ ```
156
+
157
+ Golden tests live in `tests/golden/`. The lint rule (catches direct `import openai`, `import anthropic`, etc. outside wrappers) is enforced via the `snail.lint` module.
158
+
159
+ ## License
160
+
161
+ Apache License 2.0. Copyright 2026 Tico Internet LLC.
162
+
163
+ ## Status
164
+
165
+ v0.1.0 — alpha. The core primitives (`@node`, `Program`, `edge()`, wrappers, manifest, lint) are working. The training pipeline for producing `.snail` weights is not yet included — that ships in v0.2.0.
@@ -0,0 +1,135 @@
1
+ # SNAIL — Single Node Activated Inference Layer
2
+
3
+ A Python DSL for composing **frozen, single-pass neural primitives** into **statically-typed dataflow programs**.
4
+
5
+ If an MCP tool and a markdown skill had a baby, and you told the LLM it wasn't allowed to interpret the recipe — it was just one of many nodes in the recipe — you'd get SNAIL.
6
+
7
+ ## Why
8
+
9
+ Most AI models are trained to be comprehensive. SNAIL trains tiny, single-task nodes — **activated exactly once**, **locked output**, **frozen forever** — and composes them into deterministic graphs.
10
+
11
+ A SNAIL program is a **recipe that's more resilient than a `.md` spec** and **more deterministic than an MCP tool call**. Every step is a typed function call, not a prompt the LLM might re-interpret. Every run emits a manifest. Every node knows what it doesn't know (OOD is a first-class type).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install snail-dsl
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from snail import node, Program, edge
23
+ from pydantic import BaseModel
24
+
25
+ # Declare the type contract for each node's output.
26
+ class InvoiceTotal(BaseModel):
27
+ ok: dict | None = None # { "total": float, "currency": str, "confidence": float }
28
+ ood: dict | None = None # { "reason": str, "confidence": float, "threshold": float }
29
+
30
+ @node(
31
+ name="extract_invoice_total",
32
+ input_schema=dict, # replace with a real pydantic model
33
+ output_schema=InvoiceTotal,
34
+ distribution="synthetic_invoices_v2",
35
+ frozen_weights="weights/extract_invoice_total_v3.snail",
36
+ confidence_threshold=0.7,
37
+ )
38
+ def extract_invoice_total(ctx, weights, image):
39
+ raw = weights.model.forward(image["tensor"])
40
+ return InvoiceTotal(ok={
41
+ "total": raw["total"],
42
+ "currency": raw["currency"],
43
+ "confidence": raw["confidence"],
44
+ })
45
+
46
+ # Compose into a typed DAG.
47
+ invoice_pipeline = Program(
48
+ name="invoice_pipeline_v1",
49
+ nodes=[extract_invoice_total],
50
+ edges=[],
51
+ )
52
+
53
+ result = invoice_pipeline.run({"image_path": "scan.png"})
54
+ print(result.manifest)
55
+ ```
56
+
57
+ The decorator enforces:
58
+
59
+ - Single forward pass per call — no retries, no second thoughts.
60
+ - Weights loaded once, frozen for the lifetime of the program.
61
+ - Output type-checked against `output_schema`; mismatches become OOD.
62
+ - Confidence below `confidence_threshold` is silently flipped to OOD.
63
+ - Output is locked (immutable) before being handed back to the caller.
64
+
65
+ ## The four primitives
66
+
67
+ | Primitive | What it does |
68
+ |---|---|
69
+ | `@node` | Decorator that wraps a Python function as a frozen, single-pass, OOD-aware node. |
70
+ | `Program` | Container that builds a typed DAG of nodes. Validates at construction time. |
71
+ | `edge()` | Builder for typed field-to-field connections between nodes. |
72
+ | `manifest` | Structured per-run log: which nodes fired, what they got, what they returned. |
73
+
74
+ ## Architecture
75
+
76
+ - **Nodes** are frozen, single forward pass, locked output.
77
+ - **Composition** is a static DAG declared in Python.
78
+ - **Training** is 100% synthetic, offline.
79
+ - **OOD** is a first-class type (Ok / OOD discriminated union).
80
+ - **Every node ships with golden tests** (`pytest`).
81
+ - **Every run emits a manifest.**
82
+
83
+ ## Wrapping external models
84
+
85
+ External models (HuggingFace, hosted APIs, pure functions) become nodes through wrappers. They never get called directly.
86
+
87
+ ```python
88
+ from snail.wrappers import ExternalLocalNode, HostedNode, DeterministicNode
89
+
90
+ # Pin a local model to exact weights hash
91
+ classify_layout = ExternalLocalNode(
92
+ name="classify_layout",
93
+ input_schema=InvoiceImage,
94
+ output_schema=LayoutClass,
95
+ distribution="rvl_cdip_subset",
96
+ call=lambda img: yolo_model.predict(img.path),
97
+ weight_pin="yolov8n@sha256:abc123...",
98
+ )
99
+
100
+ # Wrap a hosted API
101
+ summarize = HostedNode(
102
+ name="summarize",
103
+ input_schema=InvoiceText,
104
+ output_schema=InvoiceSummary,
105
+ endpoint="anthropic://claude-sonnet-4-5",
106
+ prompt_template="Summarize: {text}",
107
+ api_key_env="ANTHROPIC_API_KEY",
108
+ )
109
+
110
+ # Or just a pure function
111
+ validate_email = DeterministicNode(
112
+ name="validate_email",
113
+ input_schema=EmailField,
114
+ output_schema=EmailValidated,
115
+ fn=lambda x: x if "@" in x.value else None,
116
+ ood_on_none=True,
117
+ )
118
+ ```
119
+
120
+ ## Testing
121
+
122
+ ```bash
123
+ pip install -e ".[dev]"
124
+ pytest
125
+ ```
126
+
127
+ Golden tests live in `tests/golden/`. The lint rule (catches direct `import openai`, `import anthropic`, etc. outside wrappers) is enforced via the `snail.lint` module.
128
+
129
+ ## License
130
+
131
+ Apache License 2.0. Copyright 2026 Tico Internet LLC.
132
+
133
+ ## Status
134
+
135
+ v0.1.0 — alpha. The core primitives (`@node`, `Program`, `edge()`, wrappers, manifest, lint) are working. The training pipeline for producing `.snail` weights is not yet included — that ships in v0.2.0.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "snail-dsl"
7
+ version = "0.1.0"
8
+ description = "Single Node Activated Inference Layer — a Python DSL for composing frozen neural primitives into typed dataflow programs."
9
+ readme = "README.md"
10
+ license = {text = "Apache-2.0"}
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "Tico Internet LLC"},
14
+ ]
15
+ keywords = ["ai", "ml", "dsl", "frozen-models", "dataflow", "neural-networks", "agents"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: Apache Software License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = [
29
+ "pydantic>=2.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0",
35
+ "pytest-cov>=4.0",
36
+ "build>=0.10",
37
+ "twine>=4.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/lordxmen2k/SNAIL-DSL"
42
+ Repository = "https://github.com/lordxmen2k/SNAIL-DSL"
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ python_files = ["test_*.py"]
50
+ python_classes = ["Test*"]
51
+ python_functions = ["test_*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,47 @@
1
+ """SNAIL — Single Node Activated Inference Layer.
2
+
3
+ A Python DSL for composing frozen, single-pass neural primitives
4
+ into statically-typed dataflow programs.
5
+
6
+ Public API (import from `snail`):
7
+ node — decorator that wraps a function as a frozen SNAIL node
8
+ NodeContext — per-call context passed to node bodies
9
+ Program — typed DAG container for nodes
10
+ edge — builder for typed connections between nodes
11
+ NodeResult — base class for all node output schemas
12
+ OODSignal — out-of-distribution signal type
13
+ Manifest — per-run audit log
14
+
15
+ Subpackages:
16
+ snail.wrappers — ExternalLocalNode, HostedNode, DeterministicNode
17
+ snail.lint — discipline check that catches direct model SDK imports
18
+ snail.types — internal type definitions
19
+
20
+ License: Apache 2.0. Copyright 2026 Tico Internet LLC.
21
+ """
22
+
23
+ from snail.node import node, NodeContext, FrozenWeights
24
+ from snail.program import Program, Edge, edge, NodeProxy, ProgramRunResult
25
+ from snail.types.result import NodeResult
26
+ from snail.types.ood import OODSignal
27
+ from snail.manifest import Manifest, ManifestBuilder
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ # core
33
+ "node",
34
+ "NodeContext",
35
+ "FrozenWeights",
36
+ "Program",
37
+ "Edge",
38
+ "edge",
39
+ "NodeProxy",
40
+ "ProgramRunResult",
41
+ # types
42
+ "NodeResult",
43
+ "OODSignal",
44
+ # observability
45
+ "Manifest",
46
+ "ManifestBuilder",
47
+ ]
@@ -0,0 +1,88 @@
1
+ """SNAIL lint rule — catches direct imports of model SDKs outside wrappers.
2
+
3
+ If you import openai/anthropic/transformers/etc. at module level in a
4
+ file that isn't a SNAIL wrapper, that's a violation. All model access
5
+ must go through @node or one of the snail.wrappers factories.
6
+
7
+ This is a discipline check, not a style preference. The lint module
8
+ exposes a `check_source(path)` function for CI integration and a
9
+ `FORBIDDEN_TOP_LEVEL_IMPORTS` set for users to extend.
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import ast
14
+ from pathlib import Path
15
+
16
+ # These modules, if imported at module level outside snail.wrappers,
17
+ # indicate someone is calling a model directly without going through @node.
18
+ FORBIDDEN_TOP_LEVEL_IMPORTS: set[str] = {
19
+ "openai",
20
+ "anthropic",
21
+ "transformers",
22
+ "torch", # torch at module level outside training scripts
23
+ "tensorflow",
24
+ "jax",
25
+ "flax",
26
+ "replicate",
27
+ "huggingface_hub",
28
+ "litellm",
29
+ "ollama",
30
+ "langchain",
31
+ "langgraph",
32
+ "crewai",
33
+ }
34
+
35
+
36
+ def check_source(path: str | Path) -> list[str]:
37
+ """Return a list of violation messages for the given Python file.
38
+
39
+ Empty list = clean.
40
+ """
41
+ p = Path(path)
42
+ if not p.exists():
43
+ return [f"file not found: {p}"]
44
+
45
+ # Don't lint the wrappers module itself — that's where model SDKs are
46
+ # allowed to be imported.
47
+ if "snail/wrappers" in str(p) or "snail\\wrappers" in str(p):
48
+ return []
49
+ # Don't lint the lint module itself.
50
+ if "snail/lint" in str(p):
51
+ return []
52
+
53
+ try:
54
+ tree = ast.parse(p.read_text())
55
+ except SyntaxError as e:
56
+ return [f"SyntaxError: {e}"]
57
+
58
+ violations: list[str] = []
59
+ for node in ast.walk(tree):
60
+ if isinstance(node, ast.Import):
61
+ for alias in node.names:
62
+ top = alias.name.split(".")[0]
63
+ if top in FORBIDDEN_TOP_LEVEL_IMPORTS:
64
+ violations.append(
65
+ f"{p}:{node.lineno}: direct import of {alias.name!r}. "
66
+ "Model SDKs must go through @node or snail.wrappers."
67
+ )
68
+ elif isinstance(node, ast.ImportFrom):
69
+ if node.module:
70
+ top = node.module.split(".")[0]
71
+ if top in FORBIDDEN_TOP_LEVEL_IMPORTS:
72
+ violations.append(
73
+ f"{p}:{node.lineno}: direct import from {node.module!r}. "
74
+ "Model SDKs must go through @node or snail.wrappers."
75
+ )
76
+ return violations
77
+
78
+
79
+ def check_directory(root: str | Path, exclude_dirs: set[str] | None = None) -> list[str]:
80
+ """Walk a directory tree and lint every .py file. Returns all violations."""
81
+ exclude = exclude_dirs or {".venv", "venv", "build", "dist", "__pycache__", ".git"}
82
+ root = Path(root)
83
+ all_violations: list[str] = []
84
+ for p in root.rglob("*.py"):
85
+ if any(part in exclude for part in p.parts):
86
+ continue
87
+ all_violations.extend(check_source(p))
88
+ return all_violations
@@ -0,0 +1,108 @@
1
+ """Run manifests — structured per-run log of what fired, when, and what it returned.
2
+
3
+ Every Program.run() emits a manifest. Manifests are JSON-serializable.
4
+ They are the audit trail for any SNAIL program — show them to compliance,
5
+ to your CEO, to the user who asked why their refund took 2 seconds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import json
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field, asdict
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class ManifestNodeEvent:
18
+ node_name: str
19
+ variant: str # "ok" or "ood"
20
+ latency_ms: float
21
+ confidence: float | None = None
22
+ timestamp: float = field(default_factory=time.time)
23
+
24
+
25
+ @dataclass
26
+ class ManifestErrorEvent:
27
+ node_name: str
28
+ error: str
29
+ timestamp: float = field(default_factory=time.time)
30
+
31
+
32
+ @dataclass
33
+ class Manifest:
34
+ program_name: str
35
+ run_id: str
36
+ started_at: float
37
+ ended_at: float | None
38
+ total_duration_ms: float
39
+ node_events: list[ManifestNodeEvent]
40
+ errors: list[ManifestErrorEvent]
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "program_name": self.program_name,
45
+ "run_id": self.run_id,
46
+ "started_at": self.started_at,
47
+ "ended_at": self.ended_at,
48
+ "total_duration_ms": self.total_duration_ms,
49
+ "node_events": [asdict(e) for e in self.node_events],
50
+ "errors": [asdict(e) for e in self.errors],
51
+ }
52
+
53
+ def to_json(self, indent: int | None = 2) -> str:
54
+ return json.dumps(self.to_dict(), indent=indent, default=str)
55
+
56
+
57
+ class ManifestBuilder:
58
+ """Accumulates events during a single Program.run() and emits a Manifest."""
59
+
60
+ def __init__(self, program_name: str, run_id: str | None = None):
61
+ self.program_name = program_name
62
+ self.run_id = run_id or str(uuid.uuid4())
63
+ self._started_at: float | None = None
64
+ self._ended_at: float | None = None
65
+ self._node_events: list[ManifestNodeEvent] = []
66
+ self._errors: list[ManifestErrorEvent] = []
67
+
68
+ def record_start(self) -> None:
69
+ self._started_at = time.time()
70
+
71
+ def record_end(self) -> None:
72
+ self._ended_at = time.time()
73
+
74
+ def record_node(
75
+ self,
76
+ *,
77
+ node_name: str,
78
+ variant: str,
79
+ latency_ms: float,
80
+ confidence: float | None = None,
81
+ ) -> None:
82
+ self._node_events.append(
83
+ ManifestNodeEvent(
84
+ node_name=node_name,
85
+ variant=variant,
86
+ latency_ms=latency_ms,
87
+ confidence=confidence,
88
+ )
89
+ )
90
+
91
+ def record_error(self, *, node_name: str, error: str) -> None:
92
+ self._errors.append(
93
+ ManifestErrorEvent(node_name=node_name, error=error)
94
+ )
95
+
96
+ def build(self) -> Manifest:
97
+ if self._started_at is None:
98
+ raise RuntimeError("ManifestBuilder.build() called before record_start()")
99
+ ended = self._ended_at if self._ended_at is not None else time.time()
100
+ return Manifest(
101
+ program_name=self.program_name,
102
+ run_id=self.run_id,
103
+ started_at=self._started_at,
104
+ ended_at=self._ended_at,
105
+ total_duration_ms=(ended - self._started_at) * 1000.0,
106
+ node_events=list(self._node_events),
107
+ errors=list(self._errors),
108
+ )