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.
- inference_artifact_lab/__init__.py +14 -0
- inference_artifact_lab/__main__.py +85 -0
- inference_artifact_lab/adapters.py +296 -0
- inference_artifact_lab/gate.py +163 -0
- inference_artifact_lab/models.py +264 -0
- inference_artifact_lab/release.py +70 -0
- inference_artifact_lab/runtime.py +177 -0
- inference_artifact_lab-0.1.0.dev0.dist-info/METADATA +100 -0
- inference_artifact_lab-0.1.0.dev0.dist-info/RECORD +13 -0
- inference_artifact_lab-0.1.0.dev0.dist-info/WHEEL +5 -0
- inference_artifact_lab-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- inference_artifact_lab-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- inference_artifact_lab-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|