inference-artifact-lab 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ """Inference Artifact Lab package."""
2
+
3
+ from .gate import canonical_manifest_digest, current_environment, load_manifest, run_gate, run_gate_from_file, sha256_file
4
+ from .adapters import OnnxRuntimeAdapter, RuntimeUnavailable, TensorRTAdapter
5
+ from .models import ArtifactSpec, CheckResult, GateReport, GateStatus, Manifest, ManifestError, TensorSpec
6
+ from .release import run_release_gate
7
+ from .runtime import BenchmarkResult, benchmark, compare_outputs
8
+
9
+ __all__ = [
10
+ "__version__", "ArtifactSpec", "CheckResult", "GateReport", "GateStatus", "Manifest", "ManifestError", "TensorSpec",
11
+ "canonical_manifest_digest", "current_environment", "load_manifest", "run_gate", "run_gate_from_file", "run_release_gate", "sha256_file",
12
+ "BenchmarkResult", "benchmark", "compare_outputs", "OnnxRuntimeAdapter", "TensorRTAdapter", "RuntimeUnavailable",
13
+ ]
14
+ __version__ = "0.1.0.dev0"
@@ -0,0 +1,85 @@
1
+ """Command line entry point: ``python -m inference_artifact_lab``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .gate import run_gate_from_file
12
+ from .release import run_release_gate
13
+ from .runtime import benchmark
14
+
15
+
16
+ def main(argv: list[str] | None = None) -> int:
17
+ parser = argparse.ArgumentParser(description="Run core Model Release Gate checks")
18
+ parser.add_argument("manifest", help="path to a JSON manifest")
19
+ parser.add_argument("--artifact", help="artifact path (defaults to manifest artifact.path)")
20
+ parser.add_argument("--observed-contract", help="JSON file containing observed inputs and outputs")
21
+ parser.add_argument("--environment", help="JSON file containing the observed environment fingerprint")
22
+ parser.add_argument("--reference-output", help="JSON file containing reference runtime output")
23
+ parser.add_argument("--target-output", help="JSON file containing target runtime output")
24
+ parser.add_argument("--benchmark-evidence", help="JSON file containing benchmark evidence")
25
+ parser.add_argument("--report", help="write the release report JSON to this path")
26
+ parser.add_argument("--runtime", choices=["onnx-cpu"], help="run a real package adapter before composing the release report")
27
+ parser.add_argument("--inputs-npy", help="NumPy .npy input for the selected runtime adapter")
28
+ args = parser.parse_args(argv)
29
+ observed = None
30
+ if args.observed_contract:
31
+ with open(args.observed_contract, encoding="utf-8") as handle:
32
+ observed = json.load(handle)
33
+ environment = None
34
+ if args.environment:
35
+ with open(args.environment, encoding="utf-8") as handle:
36
+ environment = json.load(handle)
37
+ def load_json(path: str | None):
38
+ if not path:
39
+ return None
40
+ with open(path, encoding="utf-8") as handle:
41
+ return json.load(handle)
42
+ try:
43
+ reference = load_json(args.reference_output)
44
+ target = load_json(args.target_output)
45
+ benchmark_evidence = load_json(args.benchmark_evidence)
46
+ if args.runtime:
47
+ if not args.inputs_npy or reference is None or environment is None:
48
+ raise ValueError("--runtime requires --inputs-npy, --reference-output, and --environment")
49
+ if args.runtime != "onnx-cpu": # argparse currently prevents this; retain an explicit guard.
50
+ raise ValueError(f"unsupported runtime {args.runtime}")
51
+ import numpy as np
52
+ from .adapters import OnnxRuntimeAdapter
53
+ from .gate import load_manifest
54
+ manifest = load_manifest(args.manifest)
55
+ manifest_file = Path(args.manifest)
56
+ artifact = Path(args.artifact) if args.artifact else Path(manifest.artifact.path)
57
+ if not artifact.is_absolute():
58
+ artifact = Path(os.path.normpath(str(manifest_file.parent / artifact)))
59
+ values = np.load(args.inputs_npy, allow_pickle=False)
60
+ adapter = OnnxRuntimeAdapter(artifact, providers=["CPUExecutionProvider"])
61
+ output_name = adapter.contract()["outputs"][0]["name"]
62
+ input_name = adapter.contract()["inputs"][0]["name"]
63
+ target = adapter.run({input_name: values})[output_name]
64
+ reference_value = reference.get(output_name) if isinstance(reference, dict) else reference
65
+ measured = benchmark(lambda: adapter.run({input_name: values}), warmup_runs=1, measured_runs=5)
66
+ report = run_release_gate(manifest, artifact, observed_contract=adapter.contract(), observed_environment=environment, reference_outputs=reference_value, target_outputs=target, benchmark_evidence=measured.to_dict())
67
+ elif reference is not None or target is not None or benchmark_evidence is not None:
68
+ from .gate import load_manifest
69
+ manifest = load_manifest(args.manifest)
70
+ report = run_release_gate(manifest, args.artifact, observed_contract=observed, observed_environment=environment, reference_outputs=reference, target_outputs=target, benchmark_evidence=benchmark_evidence)
71
+ else:
72
+ report = run_gate_from_file(args.manifest, args.artifact, observed_contract=observed, observed_environment=environment)
73
+ except (OSError, ValueError) as exc:
74
+ print(json.dumps({"status": "fail", "error": str(exc)}), file=sys.stdout)
75
+ return 2
76
+ rendered = json.dumps(report.to_dict(), indent=2, sort_keys=True)
77
+ if args.report:
78
+ with open(args.report, "w", encoding="utf-8") as handle:
79
+ handle.write(rendered + "\n")
80
+ print(rendered)
81
+ return 0 if report.status.value == "pass" else 1
82
+
83
+
84
+ if __name__ == "__main__":
85
+ raise SystemExit(main())
@@ -0,0 +1,296 @@
1
+ """Optional runtime adapters used by the Phase 1 evidence harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import platform
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+
11
+ class RuntimeUnavailable(RuntimeError):
12
+ """Raised when a declared runtime dependency is not installed."""
13
+
14
+
15
+ class OnnxRuntimeAdapter:
16
+ """Small ONNX Runtime adapter with no dependency on the gate domain."""
17
+
18
+ def __init__(self, artifact: str | Path, *, providers: list[str] | None = None) -> None:
19
+ try:
20
+ import onnxruntime as ort
21
+ except ImportError as exc: # pragma: no cover - optional dependency
22
+ raise RuntimeUnavailable("install the runtime-cpu extra for ONNX Runtime") from exc
23
+ self._session = ort.InferenceSession(str(artifact), providers=providers)
24
+ self._ort_version = ort.__version__
25
+ self._requested_providers = tuple(providers) if providers is not None else None
26
+
27
+ def contract(self) -> dict[str, list[dict[str, Any]]]:
28
+ def describe(value: Any) -> dict[str, Any]:
29
+ return {"name": value.name, "dtype": value.type, "shape": list(value.shape)}
30
+
31
+ return {
32
+ "artifact_format": "onnx",
33
+ "inputs": [describe(value) for value in self._session.get_inputs()],
34
+ "outputs": [describe(value) for value in self._session.get_outputs()],
35
+ }
36
+
37
+ def runtime_info(self) -> dict[str, Any]:
38
+ """Return selected providers, including an explicit fallback provider."""
39
+
40
+ return {
41
+ "runtime": "onnxruntime",
42
+ "version": self._ort_version,
43
+ "requested_providers": list(self._requested_providers) if self._requested_providers is not None else None,
44
+ "providers": list(self._session.get_providers()),
45
+ "python": platform.python_version(),
46
+ "system": platform.system(),
47
+ "machine": platform.machine(),
48
+ }
49
+
50
+ def run(self, inputs: Mapping[str, Any]) -> dict[str, Any]:
51
+ values = self._session.run(None, dict(inputs))
52
+ return {
53
+ spec["name"]: value.tolist() if hasattr(value, "tolist") else value
54
+ for spec, value in zip(self.contract()["outputs"], values)
55
+ }
56
+
57
+
58
+ class _CtypesCudaRuntime:
59
+ """Minimal libcudart binding used when the optional cuda-python is absent."""
60
+
61
+ binding_name = "ctypes-libcudart"
62
+
63
+ class _MemcpyKind:
64
+ cudaMemcpyHostToDevice = 1
65
+ cudaMemcpyDeviceToHost = 2
66
+
67
+ cudaMemcpyKind = _MemcpyKind
68
+
69
+ def __init__(self) -> None:
70
+ last_error: OSError | None = None
71
+ for name in ("libcudart.so", "libcudart.so.12", "cudart64_*.dll"):
72
+ try:
73
+ self._lib = ctypes.CDLL(name)
74
+ break
75
+ except OSError as exc:
76
+ last_error = exc
77
+ else:
78
+ raise RuntimeUnavailable("TensorRTAdapter requires libcudart or cuda-python") from last_error
79
+ self._configure()
80
+
81
+ def _configure(self) -> None:
82
+ ptr, size = ctypes.c_void_p, ctypes.c_size_t
83
+ self._lib.cudaStreamCreate.argtypes = [ctypes.POINTER(ptr)]
84
+ self._lib.cudaStreamCreate.restype = ctypes.c_int
85
+ self._lib.cudaStreamDestroy.argtypes = [ptr]
86
+ self._lib.cudaStreamDestroy.restype = ctypes.c_int
87
+ self._lib.cudaStreamSynchronize.argtypes = [ptr]
88
+ self._lib.cudaStreamSynchronize.restype = ctypes.c_int
89
+ self._lib.cudaMalloc.argtypes = [ctypes.POINTER(ptr), size]
90
+ self._lib.cudaMalloc.restype = ctypes.c_int
91
+ self._lib.cudaFree.argtypes = [ptr]
92
+ self._lib.cudaFree.restype = ctypes.c_int
93
+ self._lib.cudaMemcpyAsync.argtypes = [ptr, ptr, size, ctypes.c_int, ptr]
94
+ self._lib.cudaMemcpyAsync.restype = ctypes.c_int
95
+
96
+ def cudaStreamCreate(self) -> tuple[int, int | None]:
97
+ value = ctypes.c_void_p()
98
+ return self._lib.cudaStreamCreate(ctypes.byref(value)), value.value
99
+
100
+ def cudaStreamDestroy(self, stream: int | None) -> int:
101
+ return self._lib.cudaStreamDestroy(ctypes.c_void_p(stream))
102
+
103
+ def cudaStreamSynchronize(self, stream: int | None) -> int:
104
+ return self._lib.cudaStreamSynchronize(ctypes.c_void_p(stream))
105
+
106
+ def cudaMalloc(self, size: int) -> tuple[int, int | None]:
107
+ value = ctypes.c_void_p()
108
+ return self._lib.cudaMalloc(ctypes.byref(value), ctypes.c_size_t(size)), value.value
109
+
110
+ def cudaFree(self, device: int | None) -> int:
111
+ return self._lib.cudaFree(ctypes.c_void_p(device))
112
+
113
+ def cudaMemcpyAsync(self, destination: int, source: int, size: int, kind: int, stream: int | None) -> int:
114
+ return self._lib.cudaMemcpyAsync(ctypes.c_void_p(destination), ctypes.c_void_p(source), ctypes.c_size_t(size), int(kind), ctypes.c_void_p(stream))
115
+
116
+
117
+ def _load_cuda_runtime() -> Any:
118
+ try:
119
+ from cuda import cudart # type: ignore[import-not-found]
120
+ except ImportError:
121
+ return _CtypesCudaRuntime()
122
+ # Kept as evidence in runtime_info; provider selection is never implicit.
123
+ try:
124
+ cudart.binding_name = "cuda-python" # type: ignore[attr-defined]
125
+ except AttributeError:
126
+ pass
127
+ return cudart
128
+
129
+
130
+ def _status_is_success(status: Any) -> bool:
131
+ value = getattr(status, "value", status)
132
+ if isinstance(value, bool):
133
+ return value
134
+ if isinstance(value, int):
135
+ return value == 0
136
+ name = str(getattr(status, "name", status)).lower()
137
+ return name in {"success", "cudasuccess", "cuda_success"}
138
+
139
+
140
+ def _check_cuda(result: Any, operation: str) -> Any:
141
+ """Check cuda-python/libcudart status and return an optional payload."""
142
+
143
+ if isinstance(result, tuple):
144
+ status, payload = result[0], result[1] if len(result) > 1 else None
145
+ else:
146
+ status, payload = result, None
147
+ if not _status_is_success(status):
148
+ raise RuntimeError(f"{operation} failed with CUDA status {status!r}")
149
+ return payload
150
+
151
+
152
+ class TensorRTAdapter:
153
+ """TensorRT engine adapter with explicit CUDA ownership and validation."""
154
+
155
+ def __init__(self, artifact: str | Path, *, trt_module: Any | None = None, cuda_runtime: Any | None = None, numpy_module: Any | None = None) -> None:
156
+ try:
157
+ trt = trt_module or __import__("tensorrt")
158
+ np = numpy_module or __import__("numpy")
159
+ except ImportError as exc: # pragma: no cover - optional container dependency
160
+ raise RuntimeUnavailable("TensorRTAdapter requires the pinned NVIDIA TensorRT container") from exc
161
+ self._trt, self._np = trt, np
162
+ self._cuda = cuda_runtime or _load_cuda_runtime()
163
+ self._logger = trt.Logger(trt.Logger.ERROR)
164
+ with Path(artifact).open("rb") as handle:
165
+ self._runtime = trt.Runtime(self._logger)
166
+ self._engine = self._runtime.deserialize_cuda_engine(handle.read())
167
+ if self._engine is None:
168
+ raise RuntimeError("TensorRT engine deserialization failed")
169
+ self._context = self._engine.create_execution_context()
170
+ if self._context is None:
171
+ raise RuntimeError("TensorRT execution context creation failed")
172
+
173
+ @staticmethod
174
+ def _dtype_name(dtype: Any, *, np: Any, trt: Any) -> str:
175
+ try:
176
+ value = np.dtype(trt.nptype(dtype))
177
+ except (TypeError, ValueError, AttributeError) as exc:
178
+ raise RuntimeError(f"unsupported TensorRT tensor dtype {dtype!r}") from exc
179
+ names = {"float32": "float", "float16": "float16", "float64": "double", "int8": "int8", "int16": "int16", "int32": "int32", "int64": "int64", "uint8": "uint8", "uint16": "uint16", "uint32": "uint32", "uint64": "uint64", "bool": "bool"}
180
+ try:
181
+ return f"tensor({names[value.name]})"
182
+ except KeyError as exc:
183
+ raise RuntimeError(f"unsupported TensorRT tensor dtype {value}") from exc
184
+
185
+ def _tensor_shape(self, name: str) -> list[int | None]:
186
+ return [None if int(dimension) < 0 else int(dimension) for dimension in self._engine.get_tensor_shape(name)]
187
+
188
+ def contract(self) -> dict[str, list[dict[str, Any]]]:
189
+ inputs: list[dict[str, Any]] = []
190
+ outputs: list[dict[str, Any]] = []
191
+ for index in range(self._engine.num_io_tensors):
192
+ name = self._engine.get_tensor_name(index)
193
+ value = {"name": name, "dtype": self._dtype_name(self._engine.get_tensor_dtype(name), np=self._np, trt=self._trt), "shape": self._tensor_shape(name)}
194
+ target = inputs if self._engine.get_tensor_mode(name) == self._trt.TensorIOMode.INPUT else outputs
195
+ target.append(value)
196
+ return {"artifact_format": "tensorrt-engine", "inputs": inputs, "outputs": outputs}
197
+
198
+ def optimization_profiles(self) -> list[dict[str, Any]]:
199
+ """Return engine profile bounds without inventing a manifest schema."""
200
+ count = int(getattr(self._engine, "num_optimization_profiles", 0) or 0)
201
+ profiles: list[dict[str, Any]] = []
202
+ for profile_index in range(count):
203
+ tensors: dict[str, dict[str, list[int]]] = {}
204
+ for index in range(self._engine.num_io_tensors):
205
+ name = self._engine.get_tensor_name(index)
206
+ if self._engine.get_tensor_mode(name) != self._trt.TensorIOMode.INPUT:
207
+ continue
208
+ try:
209
+ minimum, optimum, maximum = self._engine.get_tensor_profile_shape(name, profile_index)
210
+ except (AttributeError, RuntimeError):
211
+ continue
212
+ tensors[name] = {"min": list(minimum), "opt": list(optimum), "max": list(maximum)}
213
+ profiles.append({"index": profile_index, "inputs": tensors})
214
+ return profiles
215
+
216
+ def runtime_info(self) -> dict[str, Any]:
217
+ return {"runtime": "tensorrt", "tensorrt_version": getattr(self._trt, "__version__", None), "cuda_binding": getattr(self._cuda, "binding_name", type(self._cuda).__name__), "optimization_profiles": self.optimization_profiles(), "python": platform.python_version(), "system": platform.system(), "machine": platform.machine()}
218
+
219
+ def _names(self, mode: Any) -> list[str]:
220
+ return [self._engine.get_tensor_name(index) for index in range(self._engine.num_io_tensors) if self._engine.get_tensor_mode(self._engine.get_tensor_name(index)) == mode]
221
+
222
+ def run(self, inputs: Mapping[str, Any]) -> dict[str, Any]:
223
+ expected_inputs, expected_outputs = self._names(self._trt.TensorIOMode.INPUT), self._names(self._trt.TensorIOMode.OUTPUT)
224
+ missing, extra = [name for name in expected_inputs if name not in inputs], [name for name in inputs if name not in expected_inputs]
225
+ if missing or extra:
226
+ raise ValueError(f"TensorRT inputs do not match engine (missing={missing}, extra={extra})")
227
+ prepared: dict[str, Any] = {}
228
+ for name in expected_inputs:
229
+ value = self._np.asarray(inputs[name])
230
+ expected_dtype = self._np.dtype(self._trt.nptype(self._engine.get_tensor_dtype(name)))
231
+ if value.dtype != expected_dtype:
232
+ raise TypeError(f"input {name!r} has dtype {value.dtype}, expected {expected_dtype}")
233
+ if not value.flags.c_contiguous:
234
+ raise ValueError(f"input {name!r} must be C-contiguous")
235
+ engine_shape = self._engine.get_tensor_shape(name)
236
+ if len(value.shape) != len(engine_shape) or any(int(want) >= 0 and int(want) != got for want, got in zip(engine_shape, value.shape)):
237
+ raise ValueError(f"input {name!r} shape {tuple(value.shape)} does not satisfy engine shape {tuple(engine_shape)}")
238
+ prepared[name] = value
239
+ if self._context.set_input_shape(name, tuple(value.shape)) is False:
240
+ raise ValueError(f"TensorRT rejected input shape for {name!r}: {tuple(value.shape)}")
241
+
242
+ allocations: list[tuple[str, int, Any]] = []
243
+ stream: Any = None
244
+ synchronized, active_error = False, None
245
+ try:
246
+ stream = _check_cuda(self._cuda.cudaStreamCreate(), "cudaStreamCreate")
247
+ if stream is None:
248
+ raise RuntimeError("cudaStreamCreate returned a null stream")
249
+ for name in expected_inputs + expected_outputs:
250
+ if name in prepared:
251
+ value = prepared[name]
252
+ else:
253
+ shape = tuple(int(dimension) for dimension in self._context.get_tensor_shape(name))
254
+ if any(dimension < 0 for dimension in shape):
255
+ raise ValueError(f"TensorRT output {name!r} has unresolved dynamic shape {shape}")
256
+ value = self._np.empty(shape, dtype=self._np.dtype(self._trt.nptype(self._engine.get_tensor_dtype(name))))
257
+ if value.nbytes <= 0:
258
+ raise ValueError(f"TensorRT tensor {name!r} has an empty buffer")
259
+ device = _check_cuda(self._cuda.cudaMalloc(value.nbytes), f"cudaMalloc({name})")
260
+ if not device:
261
+ raise RuntimeError(f"cudaMalloc({name}) returned a null pointer")
262
+ allocations.append((name, int(device), value))
263
+ self._context.set_tensor_address(name, int(device))
264
+ if name in prepared:
265
+ _check_cuda(self._cuda.cudaMemcpyAsync(int(device), int(value.ctypes.data), value.nbytes, self._cuda.cudaMemcpyKind.cudaMemcpyHostToDevice, stream), f"cudaMemcpyAsync(HtoD:{name})")
266
+ if self._context.execute_async_v3(stream_handle=stream) is False:
267
+ raise RuntimeError("TensorRT execution failed")
268
+ for name, device, value in allocations:
269
+ if name not in expected_outputs:
270
+ continue
271
+ _check_cuda(self._cuda.cudaMemcpyAsync(int(value.ctypes.data), device, value.nbytes, self._cuda.cudaMemcpyKind.cudaMemcpyDeviceToHost, stream), f"cudaMemcpyAsync(DtoH:{name})")
272
+ _check_cuda(self._cuda.cudaStreamSynchronize(stream), "cudaStreamSynchronize")
273
+ synchronized = True
274
+ return {name: value.tolist() for name, _, value in allocations if name in expected_outputs}
275
+ except BaseException as exc:
276
+ active_error = exc
277
+ raise
278
+ finally:
279
+ cleanup_errors: list[str] = []
280
+ if stream is not None and not synchronized:
281
+ try:
282
+ _check_cuda(self._cuda.cudaStreamSynchronize(stream), "cudaStreamSynchronize(cleanup)")
283
+ except Exception as exc:
284
+ cleanup_errors.append(str(exc))
285
+ for name, device, _ in reversed(allocations):
286
+ try:
287
+ _check_cuda(self._cuda.cudaFree(device), f"cudaFree({name})")
288
+ except Exception as exc:
289
+ cleanup_errors.append(str(exc))
290
+ if stream is not None:
291
+ try:
292
+ _check_cuda(self._cuda.cudaStreamDestroy(stream), "cudaStreamDestroy")
293
+ except Exception as exc:
294
+ cleanup_errors.append(str(exc))
295
+ if active_error is None and cleanup_errors:
296
+ raise RuntimeError("TensorRT CUDA cleanup failed: " + "; ".join(cleanup_errors))
@@ -0,0 +1,163 @@
1
+ """Integrity and contract checks for Model Release Gate reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import platform
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+
12
+ from .models import CheckResult, GateReport, GateStatus, Manifest, ManifestError, TensorSpec
13
+
14
+
15
+ def canonical_manifest_digest(manifest: Manifest) -> str:
16
+ payload = json.dumps(manifest.to_dict(), sort_keys=True, separators=(",", ":")).encode("utf-8")
17
+ return hashlib.sha256(payload).hexdigest()
18
+
19
+
20
+ def load_manifest(path: str | Path) -> Manifest:
21
+ manifest_path = Path(path)
22
+ try:
23
+ value = json.loads(manifest_path.read_text(encoding="utf-8"))
24
+ except (OSError, json.JSONDecodeError) as exc:
25
+ raise ManifestError(f"unable to read manifest {manifest_path}: {exc}") from exc
26
+ return Manifest.from_dict(value)
27
+
28
+
29
+ def sha256_file(path: str | Path, chunk_size: int = 1024 * 1024) -> str:
30
+ digest = hashlib.sha256()
31
+ with Path(path).open("rb") as handle:
32
+ for chunk in iter(lambda: handle.read(chunk_size), b""):
33
+ digest.update(chunk)
34
+ return digest.hexdigest()
35
+
36
+
37
+ def _integrity_checks(manifest: Manifest, artifact_path: Path) -> list[CheckResult]:
38
+ expected = manifest.artifact
39
+ if not artifact_path.exists():
40
+ return [CheckResult("artifact.exists", GateStatus.FAIL, "artifact file does not exist", {"path": str(artifact_path)})]
41
+ if not artifact_path.is_file():
42
+ return [CheckResult("artifact.exists", GateStatus.FAIL, "artifact path is not a regular file", {"path": str(artifact_path)})]
43
+ observed_size = artifact_path.stat().st_size
44
+ checks = [CheckResult("artifact.exists", GateStatus.PASS, "artifact file exists", {"path": str(artifact_path)})]
45
+ if expected.size_bytes is not None and observed_size != expected.size_bytes:
46
+ checks.append(CheckResult("artifact.size", GateStatus.FAIL, "artifact size does not match manifest", {"expected": expected.size_bytes, "observed": observed_size}))
47
+ else:
48
+ checks.append(CheckResult("artifact.size", GateStatus.PASS, "artifact size matches manifest", {"observed": observed_size}))
49
+ observed_digest = sha256_file(artifact_path)
50
+ if observed_digest != expected.sha256:
51
+ checks.append(CheckResult("artifact.sha256", GateStatus.FAIL, "artifact SHA-256 does not match manifest", {"expected": expected.sha256, "observed": observed_digest}))
52
+ else:
53
+ checks.append(CheckResult("artifact.sha256", GateStatus.PASS, "artifact SHA-256 matches manifest", {"sha256": observed_digest}))
54
+ return checks
55
+
56
+
57
+ def _compare_tensor_specs(expected: tuple[TensorSpec, ...], observed: Any, direction: str) -> CheckResult:
58
+ if observed is None:
59
+ return CheckResult(f"contract.{direction}", GateStatus.BLOCKED, f"observed artifact {direction} contract is unavailable", {})
60
+ if not isinstance(observed, list):
61
+ return CheckResult(f"contract.{direction}", GateStatus.FAIL, f"observed artifact {direction} contract must be a list", {})
62
+ actual = []
63
+ try:
64
+ actual = [TensorSpec.from_dict(item, f"observed.{direction}[{i}]") for i, item in enumerate(observed)]
65
+ except ManifestError as exc:
66
+ return CheckResult(f"contract.{direction}", GateStatus.FAIL, str(exc), {})
67
+ if len(actual) != len(expected):
68
+ return CheckResult(f"contract.{direction}", GateStatus.FAIL, f"artifact {direction} count does not match manifest", {"expected": len(expected), "observed": len(actual)})
69
+ for index, (want, got) in enumerate(zip(expected, actual)):
70
+ if want != got:
71
+ return CheckResult(f"contract.{direction}", GateStatus.FAIL, f"artifact {direction} entry {index} does not match manifest", {"expected": want.to_dict(), "observed": got.to_dict()})
72
+ return CheckResult(f"contract.{direction}", GateStatus.PASS, f"artifact {direction} match manifest", {"count": len(expected)})
73
+
74
+
75
+ def _environment_check(manifest: Manifest, observed: Mapping[str, Any] | None) -> CheckResult:
76
+ if not manifest.environment:
77
+ return CheckResult("environment.compatibility", GateStatus.NOT_VERIFIED, "manifest declares no environment compatibility scope", {})
78
+ if observed is None:
79
+ return CheckResult("environment.compatibility", GateStatus.BLOCKED, "declared environment was not tested", {"required": dict(manifest.environment)})
80
+ if not isinstance(observed, Mapping):
81
+ return CheckResult("environment.compatibility", GateStatus.FAIL, "observed environment must be an object", {})
82
+ mismatches = {key: {"expected": value, "observed": observed.get(key)} for key, value in manifest.environment.items() if observed.get(key) != value}
83
+ if mismatches:
84
+ return CheckResult("environment.compatibility", GateStatus.FAIL, "observed environment does not satisfy manifest", {"mismatches": mismatches})
85
+ return CheckResult("environment.compatibility", GateStatus.PASS, "observed environment satisfies manifest", {"observed": dict(observed)})
86
+
87
+
88
+ def _profiles_check(manifest: Manifest, observed_contract: Mapping[str, Any]) -> CheckResult | None:
89
+ if not manifest.profiles:
90
+ return None
91
+ observed = observed_contract.get("profiles")
92
+ if observed is None:
93
+ return CheckResult("contract.profiles", GateStatus.BLOCKED, "observed artifact profiles are unavailable", {"required": list(manifest.profiles)})
94
+ if not isinstance(observed, list) or any(not isinstance(item, str) for item in observed):
95
+ return CheckResult("contract.profiles", GateStatus.FAIL, "observed artifact profiles must be a list of strings", {})
96
+ if set(observed) != set(manifest.profiles):
97
+ return CheckResult("contract.profiles", GateStatus.FAIL, "artifact profiles do not match manifest", {"expected": list(manifest.profiles), "observed": observed})
98
+ return CheckResult("contract.profiles", GateStatus.PASS, "artifact profiles match manifest", {"profiles": observed})
99
+
100
+
101
+ def _format_check(manifest: Manifest, observed_contract: Mapping[str, Any]) -> CheckResult | None:
102
+ observed = observed_contract.get("artifact_format")
103
+ if observed is None:
104
+ return None
105
+ if observed != manifest.artifact.format:
106
+ return CheckResult("artifact.format", GateStatus.FAIL, "observed artifact format does not match manifest", {"expected": manifest.artifact.format, "observed": observed})
107
+ return CheckResult("artifact.format", GateStatus.PASS, "observed artifact format matches manifest", {"format": observed})
108
+
109
+
110
+ def current_environment() -> dict[str, str]:
111
+ return {"python": platform.python_version(), "system": platform.system(), "machine": platform.machine()}
112
+
113
+
114
+ def _aggregate(checks: list[CheckResult]) -> GateStatus:
115
+ statuses = {check.status for check in checks}
116
+ if GateStatus.FAIL in statuses:
117
+ return GateStatus.FAIL
118
+ if GateStatus.BLOCKED in statuses:
119
+ return GateStatus.BLOCKED
120
+ if GateStatus.NOT_VERIFIED in statuses:
121
+ return GateStatus.NOT_VERIFIED
122
+ return GateStatus.PASS
123
+
124
+
125
+ def run_gate(
126
+ manifest: Manifest,
127
+ artifact_path: str | Path | None = None,
128
+ *,
129
+ observed_contract: Mapping[str, Any] | None = None,
130
+ observed_environment: Mapping[str, Any] | None = None,
131
+ ) -> GateReport:
132
+ """Run deterministic core checks and return a serializable report.
133
+
134
+ Runtime numerical checks and benchmarks are deliberately represented by
135
+ later checks; this core has no runtime dependency and reports unavailable
136
+ runtime evidence as ``blocked`` rather than guessing.
137
+ """
138
+ path = Path(artifact_path) if artifact_path is not None else Path(manifest.artifact.path)
139
+ checks = _integrity_checks(manifest, path)
140
+ if observed_contract is None:
141
+ observed_contract = {}
142
+ elif not isinstance(observed_contract, Mapping):
143
+ observed_contract = {"__invalid__": observed_contract}
144
+ checks.extend((_compare_tensor_specs(manifest.inputs, observed_contract.get("inputs"), "inputs"), _compare_tensor_specs(manifest.outputs, observed_contract.get("outputs"), "outputs")))
145
+ profiles_check = _profiles_check(manifest, observed_contract)
146
+ if profiles_check is not None:
147
+ checks.append(profiles_check)
148
+ format_check = _format_check(manifest, observed_contract)
149
+ if format_check is not None:
150
+ checks.append(format_check)
151
+ checks.append(_environment_check(manifest, observed_environment))
152
+ return GateReport(_aggregate(checks), canonical_manifest_digest(manifest), {**manifest.artifact.to_dict(), "path": str(path)}, tuple(checks), ("Runtime numerical equivalence and benchmarks are not implemented by the core gate.",))
153
+
154
+
155
+ def run_gate_from_file(manifest_path: str | Path, artifact_path: str | Path | None = None, **kwargs: Any) -> GateReport:
156
+ manifest_file = Path(manifest_path)
157
+ manifest = load_manifest(manifest_file)
158
+ if artifact_path is None:
159
+ candidate = Path(manifest.artifact.path)
160
+ if not candidate.is_absolute():
161
+ candidate = manifest_file.parent / candidate
162
+ artifact_path = candidate
163
+ return run_gate(manifest, artifact_path, **kwargs)
@@ -0,0 +1,264 @@
1
+ """Public data structures for the Model Release Gate.
2
+
3
+ The structures intentionally use plain JSON-compatible values. This keeps a
4
+ manifest and its resulting report portable between the CLI and other tools
5
+ without making a model runtime a dependency of the gate core.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum
12
+ import math
13
+ from typing import Any, Mapping
14
+
15
+
16
+ class GateStatus(StrEnum):
17
+ PASS = "pass"
18
+ FAIL = "fail"
19
+ BLOCKED = "blocked"
20
+ NOT_VERIFIED = "not_verified"
21
+
22
+
23
+ class ManifestError(ValueError):
24
+ """Raised when a manifest is malformed or incomplete."""
25
+
26
+
27
+ # These are the formats understood by the public P1 adapters and fixture
28
+ # harness. Rejecting arbitrary labels matters because ``format`` participates
29
+ # in the release contract; a typo must not silently become a new format.
30
+ SUPPORTED_ARTIFACT_FORMATS = frozenset({"fixture", "onnx", "tensorrt", "tensorrt-engine"})
31
+ SUPPORTED_TENSOR_DTYPES = frozenset(
32
+ {
33
+ "bool",
34
+ "bfloat16",
35
+ "float16",
36
+ "float32",
37
+ "float64",
38
+ "int8",
39
+ "int16",
40
+ "int32",
41
+ "int64",
42
+ "uint8",
43
+ "uint16",
44
+ "uint32",
45
+ "uint64",
46
+ "string",
47
+ "tensor(bool)",
48
+ "tensor(bfloat16)",
49
+ "tensor(float16)",
50
+ "tensor(float)",
51
+ "tensor(double)",
52
+ "tensor(int8)",
53
+ "tensor(int16)",
54
+ "tensor(int32)",
55
+ "tensor(int64)",
56
+ "tensor(uint8)",
57
+ "tensor(uint16)",
58
+ "tensor(uint32)",
59
+ "tensor(uint64)",
60
+ "tensor(string)",
61
+ }
62
+ )
63
+
64
+
65
+ def _required_string(value: Any, field_name: str) -> str:
66
+ if not isinstance(value, str) or not value.strip():
67
+ raise ManifestError(f"{field_name} must be a non-empty string")
68
+ return value
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ArtifactSpec:
73
+ """Identity and integrity expectations for one artifact file."""
74
+
75
+ path: str
76
+ format: str
77
+ sha256: str
78
+ size_bytes: int | None = None
79
+ build_inputs: Mapping[str, Any] = field(default_factory=dict)
80
+ tools: Mapping[str, Any] = field(default_factory=dict)
81
+
82
+ @classmethod
83
+ def from_dict(cls, value: Mapping[str, Any]) -> "ArtifactSpec":
84
+ if not isinstance(value, Mapping):
85
+ raise ManifestError("artifact must be an object")
86
+ path = _required_string(value.get("path"), "artifact.path")
87
+ fmt = _required_string(value.get("format"), "artifact.format")
88
+ if fmt not in SUPPORTED_ARTIFACT_FORMATS:
89
+ raise ManifestError(
90
+ f"artifact.format must be one of {sorted(SUPPORTED_ARTIFACT_FORMATS)}"
91
+ )
92
+ digest = _required_string(value.get("sha256"), "artifact.sha256").lower()
93
+ if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
94
+ raise ManifestError("artifact.sha256 must be a 64-character hexadecimal digest")
95
+ size = value.get("size_bytes")
96
+ if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size < 0):
97
+ raise ManifestError("artifact.size_bytes must be a non-negative integer")
98
+ build_inputs = value.get("build_inputs", {})
99
+ tools = value.get("tools", {})
100
+ if not isinstance(build_inputs, Mapping) or not isinstance(tools, Mapping):
101
+ raise ManifestError("artifact.build_inputs and artifact.tools must be objects")
102
+ return cls(path=path, format=fmt, sha256=digest, size_bytes=size, build_inputs=dict(build_inputs), tools=dict(tools))
103
+
104
+ def to_dict(self) -> dict[str, Any]:
105
+ result: dict[str, Any] = {"path": self.path, "format": self.format, "sha256": self.sha256}
106
+ if self.size_bytes is not None:
107
+ result["size_bytes"] = self.size_bytes
108
+ if self.build_inputs:
109
+ result["build_inputs"] = dict(self.build_inputs)
110
+ if self.tools:
111
+ result["tools"] = dict(self.tools)
112
+ return result
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class TensorSpec:
117
+ """A single input or output contract entry."""
118
+
119
+ name: str
120
+ dtype: str
121
+ shape: tuple[int | str | None, ...]
122
+
123
+ @classmethod
124
+ def from_dict(cls, value: Mapping[str, Any], field_name: str) -> "TensorSpec":
125
+ if not isinstance(value, Mapping):
126
+ raise ManifestError(f"{field_name} must be an object")
127
+ name = _required_string(value.get("name"), f"{field_name}.name")
128
+ dtype = _required_string(value.get("dtype"), f"{field_name}.dtype")
129
+ if dtype not in SUPPORTED_TENSOR_DTYPES:
130
+ raise ManifestError(
131
+ f"{field_name}.dtype must be a supported tensor dtype"
132
+ )
133
+ shape_value = value.get("shape")
134
+ if not isinstance(shape_value, list):
135
+ raise ManifestError(f"{field_name}.shape must be a list")
136
+ shape: list[int | str | None] = []
137
+ for index, dimension in enumerate(shape_value):
138
+ if dimension is None or isinstance(dimension, str):
139
+ if isinstance(dimension, str) and not dimension.strip():
140
+ raise ManifestError(
141
+ f"{field_name}.shape[{index}] must not be an empty dimension name"
142
+ )
143
+ shape.append(dimension)
144
+ elif isinstance(dimension, int) and not isinstance(dimension, bool) and dimension >= 0:
145
+ shape.append(dimension)
146
+ else:
147
+ raise ManifestError(f"{field_name}.shape[{index}] must be a non-negative integer, string, or null")
148
+ return cls(name=name, dtype=dtype, shape=tuple(shape))
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ return {"name": self.name, "dtype": self.dtype, "shape": list(self.shape)}
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Manifest:
156
+ """Versioned release contract used as the gate's source of truth."""
157
+
158
+ schema_version: str
159
+ model_name: str
160
+ model_version: str
161
+ source: str
162
+ artifact: ArtifactSpec
163
+ inputs: tuple[TensorSpec, ...]
164
+ outputs: tuple[TensorSpec, ...]
165
+ runtime: Mapping[str, Any] = field(default_factory=dict)
166
+ tolerances: Mapping[str, float] = field(default_factory=dict)
167
+ environment: Mapping[str, Any] = field(default_factory=dict)
168
+ profiles: tuple[str, ...] = ()
169
+
170
+ @classmethod
171
+ def from_dict(cls, value: Mapping[str, Any]) -> "Manifest":
172
+ if not isinstance(value, Mapping):
173
+ raise ManifestError("manifest must be a JSON object")
174
+ schema = _required_string(value.get("schema_version"), "schema_version")
175
+ if schema != "1":
176
+ raise ManifestError("schema_version must be '1'")
177
+ model = value.get("model")
178
+ if not isinstance(model, Mapping):
179
+ raise ManifestError("model must be an object")
180
+ model_name = _required_string(model.get("name"), "model.name")
181
+ model_version = _required_string(model.get("version"), "model.version")
182
+ source = _required_string(model.get("source"), "model.source")
183
+ artifact_value = value.get("artifact")
184
+ if not isinstance(artifact_value, Mapping):
185
+ raise ManifestError("artifact must be an object")
186
+ contract = value.get("contract")
187
+ if not isinstance(contract, Mapping):
188
+ raise ManifestError("contract must be an object")
189
+ inputs_value = contract.get("inputs")
190
+ outputs_value = contract.get("outputs")
191
+ if not isinstance(inputs_value, list) or not inputs_value:
192
+ raise ManifestError("contract.inputs must be a non-empty list")
193
+ if not isinstance(outputs_value, list) or not outputs_value:
194
+ raise ManifestError("contract.outputs must be a non-empty list")
195
+ inputs = tuple(TensorSpec.from_dict(item, f"contract.inputs[{i}]") for i, item in enumerate(inputs_value))
196
+ outputs = tuple(TensorSpec.from_dict(item, f"contract.outputs[{i}]") for i, item in enumerate(outputs_value))
197
+ for direction, tensors in (("inputs", inputs), ("outputs", outputs)):
198
+ names = [tensor.name for tensor in tensors]
199
+ if len(names) != len(set(names)):
200
+ raise ManifestError(f"contract.{direction} tensor names must be unique")
201
+ runtime = value.get("runtime", {})
202
+ tolerances = value.get("tolerances", {})
203
+ environment = value.get("environment", {})
204
+ profiles_value = contract.get("profiles", [])
205
+ for name, section in (("runtime", runtime), ("tolerances", tolerances), ("environment", environment)):
206
+ if not isinstance(section, Mapping):
207
+ raise ManifestError(f"{name} must be an object")
208
+ if not isinstance(profiles_value, list) or any(not isinstance(item, str) or not item.strip() for item in profiles_value):
209
+ raise ManifestError("contract.profiles must be a list of non-empty strings")
210
+ profiles = tuple(profiles_value)
211
+ if len(profiles) != len(set(profiles)):
212
+ raise ManifestError("contract.profiles must be unique")
213
+ parsed_tolerances: dict[str, float] = {}
214
+ for name, tolerance in tolerances.items():
215
+ if not isinstance(name, str) or not name.strip():
216
+ raise ManifestError("tolerance names must be non-empty strings")
217
+ if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool) or tolerance < 0:
218
+ raise ManifestError(f"tolerances.{name} must be a non-negative number")
219
+ if not math.isfinite(float(tolerance)):
220
+ raise ManifestError(f"tolerances.{name} must be finite")
221
+ parsed_tolerances[str(name)] = float(tolerance)
222
+ return cls(schema, model_name, model_version, source, ArtifactSpec.from_dict(artifact_value), inputs, outputs, dict(runtime), parsed_tolerances, dict(environment), profiles)
223
+
224
+ def to_dict(self) -> dict[str, Any]:
225
+ return {
226
+ "schema_version": self.schema_version,
227
+ "model": {"name": self.model_name, "version": self.model_version, "source": self.source},
228
+ "artifact": self.artifact.to_dict(),
229
+ "contract": {"inputs": [item.to_dict() for item in self.inputs], "outputs": [item.to_dict() for item in self.outputs], **({"profiles": list(self.profiles)} if self.profiles else {})},
230
+ "runtime": dict(self.runtime),
231
+ "tolerances": dict(self.tolerances),
232
+ "environment": dict(self.environment),
233
+ }
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class CheckResult:
238
+ check_id: str
239
+ status: GateStatus
240
+ message: str
241
+ evidence: Mapping[str, Any] = field(default_factory=dict)
242
+
243
+ def to_dict(self) -> dict[str, Any]:
244
+ return {"id": self.check_id, "status": self.status.value, "message": self.message, "evidence": dict(self.evidence)}
245
+
246
+
247
+ @dataclass(frozen=True)
248
+ class GateReport:
249
+ status: GateStatus
250
+ manifest_digest: str | None
251
+ artifact: Mapping[str, Any]
252
+ checks: tuple[CheckResult, ...]
253
+ limitations: tuple[str, ...] = ()
254
+ schema_version: str = "1"
255
+
256
+ def to_dict(self) -> dict[str, Any]:
257
+ return {
258
+ "schema_version": self.schema_version,
259
+ "status": self.status.value,
260
+ "manifest_digest": self.manifest_digest,
261
+ "artifact": dict(self.artifact),
262
+ "checks": [check.to_dict() for check in self.checks],
263
+ "limitations": list(self.limitations),
264
+ }
@@ -0,0 +1,70 @@
1
+ """Full Phase 1 release decision composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .gate import run_gate
10
+ from .models import CheckResult, GateReport, GateStatus, Manifest
11
+ from .runtime import benchmark, compare_outputs
12
+
13
+
14
+ def _aggregate(checks: list[CheckResult]) -> GateStatus:
15
+ statuses = {check.status for check in checks}
16
+ if GateStatus.FAIL in statuses:
17
+ return GateStatus.FAIL
18
+ if GateStatus.BLOCKED in statuses:
19
+ return GateStatus.BLOCKED
20
+ if GateStatus.NOT_VERIFIED in statuses:
21
+ return GateStatus.NOT_VERIFIED
22
+ return GateStatus.PASS
23
+
24
+
25
+ def run_release_gate(
26
+ manifest: Manifest,
27
+ artifact_path: str | Path | None = None,
28
+ *,
29
+ observed_contract: Mapping[str, Any] | None = None,
30
+ observed_environment: Mapping[str, Any] | None = None,
31
+ reference_outputs: Any = None,
32
+ target_outputs: Any = None,
33
+ benchmark_call: Callable[[], Any] | None = None,
34
+ benchmark_evidence: Mapping[str, Any] | None = None,
35
+ ) -> GateReport:
36
+ """Run the complete Phase 1 decision for supplied adapter observations.
37
+
38
+ Runtime adapters remain outside the core package. They provide plain output
39
+ values and a callable workload; this function applies the same evidence and
40
+ status rules to those observations.
41
+ """
42
+
43
+ core = run_gate(
44
+ manifest,
45
+ artifact_path,
46
+ observed_contract=observed_contract,
47
+ observed_environment=observed_environment,
48
+ )
49
+ checks = list(core.checks)
50
+ core_failed = any(check.status is GateStatus.FAIL for check in checks)
51
+ if core_failed:
52
+ checks.append(CheckResult("runtime.equivalence", GateStatus.BLOCKED, "runtime verification skipped because an integrity, contract, or environment check failed", {}))
53
+ checks.append(CheckResult("benchmark.workload", GateStatus.BLOCKED, "benchmark skipped because an integrity, contract, or environment check failed", {}))
54
+ limitations = tuple(item for item in core.limitations if "not implemented" not in item)
55
+ return GateReport(_aggregate(checks), core.manifest_digest, core.artifact, tuple(checks), limitations)
56
+ if reference_outputs is None or target_outputs is None:
57
+ checks.append(CheckResult("runtime.equivalence", GateStatus.BLOCKED, "reference and target runtime outputs are required", {}))
58
+ else:
59
+ absolute = float(manifest.tolerances.get("absolute", 0.0))
60
+ relative = float(manifest.tolerances.get("relative", 0.0))
61
+ checks.append(compare_outputs(reference_outputs, target_outputs, absolute_tolerance=absolute, relative_tolerance=relative))
62
+ if benchmark_evidence is not None:
63
+ checks.append(CheckResult("benchmark.workload", GateStatus.PASS, "benchmark workload evidence supplied by the declared runtime harness", dict(benchmark_evidence)))
64
+ elif benchmark_call is None:
65
+ checks.append(CheckResult("benchmark.workload", GateStatus.BLOCKED, "benchmark workload evidence is required", {}))
66
+ else:
67
+ result = benchmark(benchmark_call)
68
+ checks.append(CheckResult("benchmark.workload", GateStatus.PASS, "benchmark workload completed", result.to_dict()))
69
+ limitations = tuple(item for item in core.limitations if "not implemented" not in item)
70
+ return GateReport(_aggregate(checks), core.manifest_digest, core.artifact, tuple(checks), limitations)
@@ -0,0 +1,177 @@
1
+ """Runtime-output comparison and bounded benchmark helpers.
2
+
3
+ The gate core stays independent of ONNX/TensorRT. Adapters provide plain
4
+ JSON-compatible outputs, allowing the same evidence rules to be tested without
5
+ making a runtime dependency part of the package core.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import statistics
12
+ import time
13
+ import tracemalloc
14
+ from collections.abc import Callable, Mapping
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from .models import CheckResult, GateStatus
19
+
20
+
21
+ def _json_value(value: Any) -> Any:
22
+ """Convert common array-like values without requiring NumPy."""
23
+ tolist = getattr(value, "tolist", None)
24
+ return tolist() if callable(tolist) else value
25
+
26
+
27
+ def _structure(value: Any, path: str = "output") -> tuple[tuple[Any, ...], list[tuple[str, float]]]:
28
+ """Return a topology signature and numeric leaves for an output value.
29
+
30
+ Keeping the topology separate from flattened values prevents a ragged list,
31
+ reordered mapping, or scalar/list mismatch from passing merely because the
32
+ number of leaves happens to match.
33
+ """
34
+
35
+ value = _json_value(value)
36
+ if isinstance(value, Mapping):
37
+ keys = list(value.keys())
38
+ if any(not isinstance(key, str) for key in keys):
39
+ raise TypeError(f"{path} mapping keys must be strings")
40
+ if len(keys) != len(set(keys)):
41
+ raise TypeError(f"{path} mapping keys must be unique")
42
+ children = []
43
+ leaves: list[tuple[str, float]] = []
44
+ for key in sorted(keys):
45
+ child_shape, child_leaves = _structure(value[key], f"{path}.{key}")
46
+ children.append((key, child_shape))
47
+ leaves.extend(child_leaves)
48
+ return ("mapping", tuple(children)), leaves
49
+ if isinstance(value, (list, tuple)):
50
+ children = []
51
+ leaves = []
52
+ for index, item in enumerate(value):
53
+ child_shape, child_leaves = _structure(item, f"{path}[{index}]")
54
+ children.append(child_shape)
55
+ leaves.extend(child_leaves)
56
+ if children and any(child != children[0] for child in children[1:]):
57
+ raise TypeError(f"{path} contains a ragged sequence")
58
+ return ("sequence", tuple(children)), leaves
59
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
60
+ raise TypeError(f"{path} contains a non-numeric value")
61
+ return ("scalar",), [(path, float(value))]
62
+
63
+
64
+ def compare_outputs(
65
+ expected: Any,
66
+ observed: Any,
67
+ *,
68
+ absolute_tolerance: float,
69
+ relative_tolerance: float = 0.0,
70
+ ) -> CheckResult:
71
+ """Compare finite numeric outputs under explicit absolute/relative tolerances."""
72
+
73
+ if (
74
+ isinstance(absolute_tolerance, bool)
75
+ or not isinstance(absolute_tolerance, (int, float))
76
+ or isinstance(relative_tolerance, bool)
77
+ or not isinstance(relative_tolerance, (int, float))
78
+ or not math.isfinite(float(absolute_tolerance))
79
+ or not math.isfinite(float(relative_tolerance))
80
+ or absolute_tolerance < 0
81
+ or relative_tolerance < 0
82
+ ):
83
+ return CheckResult("runtime.equivalence", GateStatus.FAIL, "tolerances must be finite non-negative numbers", {})
84
+ try:
85
+ expected_shape, expected_values = _structure(expected)
86
+ observed_shape, observed_values = _structure(observed)
87
+ except TypeError as exc:
88
+ return CheckResult("runtime.equivalence", GateStatus.FAIL, str(exc), {})
89
+ if expected_shape != observed_shape:
90
+ return CheckResult(
91
+ "runtime.equivalence",
92
+ GateStatus.FAIL,
93
+ "reference and target output shapes differ",
94
+ {"expected_shape": repr(expected_shape), "observed_shape": repr(observed_shape)},
95
+ )
96
+ if not expected_values:
97
+ return CheckResult("runtime.equivalence", GateStatus.FAIL, "reference and target outputs are empty", {})
98
+ if len(expected_values) != len(observed_values):
99
+ return CheckResult(
100
+ "runtime.equivalence",
101
+ GateStatus.FAIL,
102
+ "reference and target output sizes differ",
103
+ {"expected_values": len(expected_values), "observed_values": len(observed_values)},
104
+ )
105
+ max_error = 0.0
106
+ mismatches = 0
107
+ for (expected_path, expected_number), (_, observed_number) in zip(expected_values, observed_values):
108
+ if not math.isfinite(expected_number) or not math.isfinite(observed_number):
109
+ return CheckResult("runtime.equivalence", GateStatus.FAIL, "reference or target output contains a non-finite value", {"path": expected_path})
110
+ error = abs(expected_number - observed_number)
111
+ max_error = max(max_error, error)
112
+ if error > absolute_tolerance + relative_tolerance * abs(expected_number):
113
+ mismatches += 1
114
+ if mismatches:
115
+ return CheckResult("runtime.equivalence", GateStatus.FAIL, "target output exceeds declared tolerance", {"mismatches": mismatches, "max_absolute_error": max_error})
116
+ return CheckResult("runtime.equivalence", GateStatus.PASS, "target output is within declared tolerance", {"compared_values": len(expected_values), "max_absolute_error": max_error})
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class BenchmarkResult:
121
+ warmup_runs: int
122
+ measured_runs: int
123
+ latency_seconds: tuple[float, ...]
124
+ peak_bytes: int
125
+ memory_scope: str = "python_tracemalloc"
126
+
127
+ def to_dict(self) -> dict[str, Any]:
128
+ return {
129
+ "warmup_runs": self.warmup_runs,
130
+ "measured_runs": self.measured_runs,
131
+ "latency_seconds": list(self.latency_seconds),
132
+ "peak_bytes": self.peak_bytes,
133
+ "latency_min_seconds": min(self.latency_seconds),
134
+ "latency_max_seconds": max(self.latency_seconds),
135
+ "latency_mean_seconds": statistics.fmean(self.latency_seconds),
136
+ "latency_median_seconds": statistics.median(self.latency_seconds),
137
+ "latency_p95_seconds": _percentile(self.latency_seconds, 0.95),
138
+ # Samples are sequential calls. Counting the run count again would
139
+ # overstate throughput by the number of samples.
140
+ "throughput_runs_per_second": 1.0 / statistics.fmean(self.latency_seconds),
141
+ "peak_memory_scope": self.memory_scope,
142
+ }
143
+
144
+
145
+ def _percentile(values: tuple[float, ...], quantile: float) -> float:
146
+ """Linear-interpolated percentile with no external statistics dependency."""
147
+
148
+ if not values:
149
+ raise ValueError("cannot compute a percentile for no samples")
150
+ ordered = sorted(values)
151
+ position = (len(ordered) - 1) * quantile
152
+ lower = math.floor(position)
153
+ upper = math.ceil(position)
154
+ if lower == upper:
155
+ return ordered[lower]
156
+ fraction = position - lower
157
+ return ordered[lower] + (ordered[upper] - ordered[lower]) * fraction
158
+
159
+
160
+ def benchmark(call: Callable[[], Any], *, warmup_runs: int = 1, measured_runs: int = 5) -> BenchmarkResult:
161
+ """Measure a bounded callable workload with an explicit sample count."""
162
+
163
+ if isinstance(warmup_runs, bool) or isinstance(measured_runs, bool) or warmup_runs < 0 or measured_runs <= 0:
164
+ raise ValueError("warmup_runs must be non-negative and measured_runs must be positive")
165
+ for _ in range(warmup_runs):
166
+ call()
167
+ tracemalloc.start()
168
+ samples: list[float] = []
169
+ try:
170
+ for _ in range(measured_runs):
171
+ started = time.perf_counter()
172
+ call()
173
+ samples.append(time.perf_counter() - started)
174
+ _, peak_bytes = tracemalloc.get_traced_memory()
175
+ finally:
176
+ tracemalloc.stop()
177
+ return BenchmarkResult(warmup_runs, measured_runs, tuple(samples), peak_bytes)
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: inference-artifact-lab
3
+ Version: 0.1.0.dev0
4
+ Summary: Reproducible validation gates for machine-learning inference artifacts
5
+ Author: Ray Carter
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=8.0; extra == "test"
12
+ Provides-Extra: runtime-cpu
13
+ Requires-Dist: numpy>=1.26; extra == "runtime-cpu"
14
+ Requires-Dist: onnxruntime==1.30.0; extra == "runtime-cpu"
15
+ Dynamic: license-file
16
+
17
+ # Inference Artifact Lab
18
+
19
+ Inference Artifact Lab is a clean-room, public-by-design project for validating
20
+ machine-learning deployment artifacts before release. Its first product increment
21
+ is the **Model Release Gate**: a reproducible gate for artifact integrity,
22
+ input/output contracts, runtime correctness, and environment compatibility.
23
+
24
+ The project uses public models, public datasets, and generated fixtures. It does
25
+ not train models, provide a general model-serving gateway, or claim model quality
26
+ beyond the declared validation evidence.
27
+
28
+ ## Current status
29
+
30
+ Development preview: public SqueezeNet CPU and TensorRT smoke runs are recorded.
31
+ Phase 1 acceptance remains incomplete; report delivery and clean reproduction
32
+ need further work. See [publication review](docs/publication-review.md) for
33
+ known limitations. The commands below are development examples, not a verified
34
+ from-scratch reproduction procedure.
35
+
36
+ ## Planned flow
37
+
38
+ ```text
39
+ public model
40
+ -> source and artifact manifest
41
+ -> export/build
42
+ -> integrity and contract checks
43
+ -> reference/runtime equivalence checks
44
+ -> environment compatibility checks
45
+ -> resource benchmark
46
+ -> machine-readable release report
47
+ ```
48
+
49
+ Run the public-reference smoke gate with:
50
+
51
+ ```text
52
+ uv run --with torch --with torchvision --with onnx --with onnxruntime python scripts/run_torchvision_gate.py
53
+ ```
54
+
55
+ It writes `reports/phase-1/squeezenet11-torchvision-onnx-cpu.json`.
56
+
57
+ Build and verify the TensorRT profile after pulling the pinned public image:
58
+
59
+ ```text
60
+ pwsh scripts/build_tensorrt_engine.ps1
61
+ pwsh scripts/benchmark_tensorrt_engine.ps1
62
+ docker run --rm --gpus all -v "${PWD}:/workspace" -w /workspace `
63
+ -e MODEL_RELEASE_GATE_CONTAINER_DIGEST=sha256:814325e2b8a653f354c30bbcf5ecc8d4c780cf878a88a320ae648fbfdd9dd82d `
64
+ nvcr.io/nvidia/tensorrt:25.02-py3 bash -lc `
65
+ "python -m pip install --quiet --index-url https://pypi.org/simple cuda-python==12.8.0; `
66
+ PYTHONPATH=/workspace/src python scripts/run_tensorrt_in_container.py `
67
+ --engine artifacts/squeezenet1.1-fp32.engine `
68
+ --fixture artifacts/squeezenet11-fixture.npy `
69
+ --output artifacts/squeezenet11-tensorrt-output.json"
70
+ uv run --with numpy==2.4.6 python scripts/compose_tensorrt_report.py `
71
+ --trtexec-log reports/phase-1/tensorrt-trtexec-benchmark.log
72
+ ```
73
+
74
+ The generated TensorRT report includes the contract, engine digest, fixture
75
+ equivalence, declared GPU/container fingerprint, and `trtexec` benchmark scope.
76
+
77
+ Render any JSON report for review with:
78
+
79
+ ```text
80
+ uv run python scripts/render_report.py reports/phase-1/squeezenet11-tensorrt.json
81
+ ```
82
+
83
+ The report contract is defined by
84
+ `schemas/release-report.schema.json`. A clean CPU reproduction starts with
85
+ `pwsh scripts/clean_reproduction.ps1` in a fresh checkout.
86
+
87
+ After generating the public fixture and reference output, the package CLI can
88
+ execute the ONNX CPU adapter directly:
89
+
90
+ ```text
91
+ python -m inference_artifact_lab examples/squeezenet11-torchvision.manifest.json `
92
+ --runtime onnx-cpu --inputs-npy artifacts/squeezenet11-fixture.npy `
93
+ --reference-output reference-output.json --environment environment.json `
94
+ --report reports/phase-1/cli-onnx-cpu.json
95
+ ```
96
+
97
+ The authoritative development documentation follows the same phase/stage model
98
+ used by the other portfolio repositories. Start at the [Codex document index](docs/codex/README.md), then read the [product contract](docs/codex/product-contract.md) and [Phase 1 plan](docs/codex/phases/phase-1-model-release-gate/README.md).
99
+
100
+ For the human-readable brief and clean-room record, see [Product Requirements](docs/product-requirements.md), [Acceptance Contract](docs/acceptance-contract.md), and [Clean-room Record](docs/clean-room-record.md).
@@ -0,0 +1,13 @@
1
+ inference_artifact_lab/__init__.py,sha256=eq18fL3SPg0T1IcZAs3wTU1zpPdrqefm8OXLXNReZV4,873
2
+ inference_artifact_lab/__main__.py,sha256=aish58BchefowXKIKisQp8kzGRK45saklUht6wkjvW8,4758
3
+ inference_artifact_lab/adapters.py,sha256=0JK-NLMKSj_GeFlPWuvXnqAxOxZQcGyLyRt78Q0McdQ,15134
4
+ inference_artifact_lab/gate.py,sha256=yyw3FRfdvqQMf5-TcWXHLbr4JipK3nlhb6YpBQ36xq4,9046
5
+ inference_artifact_lab/models.py,sha256=lRa-_xsNIV9gknWNOfS10wmSLLMnFMqhLjggvhi6ig4,11276
6
+ inference_artifact_lab/release.py,sha256=0Sleewk6nZEt6-jfL_Ng5HIGvXAlErkjXL3BrNF1HSw,3424
7
+ inference_artifact_lab/runtime.py,sha256=ytr5P_Uw0MwFz0uDyIU_0cdxrGMsomen6QHypxIaXME,7630
8
+ inference_artifact_lab-0.1.0.dev0.dist-info/licenses/LICENSE,sha256=cZ7PD6Jl--JcP-KqFD6GXX56-dNsFlJKUADp7V1Nk8c,1067
9
+ inference_artifact_lab-0.1.0.dev0.dist-info/METADATA,sha256=Dg7vaVwlctoBoSKP8HTzQ_ixASSlg-An495CJyfc72c,4279
10
+ inference_artifact_lab-0.1.0.dev0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ inference_artifact_lab-0.1.0.dev0.dist-info/entry_points.txt,sha256=s-aVGFxwMi_Bzlsd4E6Lnr7fAST6Dmqeon6sZ_UhY2I,76
12
+ inference_artifact_lab-0.1.0.dev0.dist-info/top_level.txt,sha256=Je03_0iKNirWu8359N9ttOtqfyGB4Xkhx17mGNkOkdA,23
13
+ inference_artifact_lab-0.1.0.dev0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ model-release-gate = inference_artifact_lab.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ray Carter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ inference_artifact_lab