From cbc467a0f49bd7c5ff8e6cd20e2469685a5780f3 Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Thu, 22 Oct 2026 15:41:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(WO83):=20GraphIoGate=20=E2=80=94=20no?= =?UTF-8?q?=20artifact=20ships=20fp16=20at=20the=20graph=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry gate 'graph_io': inspects the artifact zip's ONNX members and refuses any fp16 graph input/output (weights may stay fp16 — the WO79 owner decision; ruby has no Float16 materialization and node ORT corrupts the boundary). Tests build synthetic artifacts at both boundaries, including the legal fp16-weights/fp32-IO shape. --- src/pipeline/gates.py | 44 ++++++++++++++ tests/test_pipeline_graph_io_gate.py | 85 ++++++++++++++++++++++++++++ tests/test_pipeline_plan_gate.py | 1 + 3 files changed, 130 insertions(+) create mode 100644 tests/test_pipeline_graph_io_gate.py diff --git a/src/pipeline/gates.py b/src/pipeline/gates.py index a7bd8062..0ce7a2e5 100644 --- a/src/pipeline/gates.py +++ b/src/pipeline/gates.py @@ -257,12 +257,56 @@ def check(self, recipe: RecipeSpec, ctx: GateContext) -> GateResult: f"{float(size_mb):.1f} MB budget") +class GraphIoGate(BaseGate): + """WO83: no artifact ships fp16 at the graph boundary. Weights may + stay fp16 (WO79 owner decision); every graph input and output must + be fp32 (or non-float). Enforced here so the exporter default can + never silently regress.""" + + name = "graph_io" + + def check(self, recipe: RecipeSpec, ctx: GateContext) -> GateResult: + art = (ctx.extra or {}).get("artifact_path") + if not art: + return GateResult(self.name, True, + "artifact not provided — IO guard not " + "evaluated (enforced at export/publish)") + path = Path(art) + if not path.is_file(): + return GateResult(self.name, False, + f"artifact missing: {path}") + import zipfile + + import onnx + from onnx import TensorProto + + offenders = [] + with zipfile.ZipFile(path) as z: + for member in z.namelist(): + if not member.endswith(".onnx"): + continue + m = onnx.load_from_string(z.read(member)) + for kind, infos in (("input", m.graph.input), + ("output", m.graph.output)): + for t in infos: + tt = t.type.tensor_type + if tt.elem_type == TensorProto.FLOAT16: + offenders.append(f"{member}:{t.name}") + if offenders: + return GateResult( + self.name, False, + "fp16 at the graph boundary (WO79): " + + ", ".join(offenders)) + return GateResult(self.name, True, "graph boundary fp32-clean") + + register_gate(QualityGate()) register_gate(ParityGate()) register_gate(LicenseGate()) register_gate(ConformanceGate()) register_gate(RegressionGate()) register_gate(BudgetGate()) +register_gate(GraphIoGate()) def evaluate( diff --git a/tests/test_pipeline_graph_io_gate.py b/tests/test_pipeline_graph_io_gate.py new file mode 100644 index 00000000..e7ee5dca --- /dev/null +++ b/tests/test_pipeline_graph_io_gate.py @@ -0,0 +1,85 @@ +"""WO83 — the graph-IO dtype gate: no artifact ships fp16 at the graph +boundary (weights may stay fp16; ruby has no Float16 and node ORT +corrupts the boundary — WO79). +""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +import onnx +from onnx import TensorProto, helper + +from pipeline.gates import GateContext, GraphIoGate, registered_gates + + +def _save_graph(path: Path, in_type: int, out_type: int) -> None: + g = helper.make_graph( + [helper.make_node("Cast", ["x"], ["c1"], to=TensorProto.FLOAT), + helper.make_node("Cast", ["c1"], ["y"], to=out_type)], + "g", + [helper.make_tensor_value_info("x", in_type, [2, 2])], + [helper.make_tensor_value_info("y", out_type, [2, 2])], + ) + m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 14)]) + m.ir_version = 10 + onnx.save(m, str(path)) + + +def _artifact(tmp_path: Path, in_type: int, out_type: int) -> Path: + graph = tmp_path / "encoder.onnx" + _save_graph(graph, in_type, out_type) + art = tmp_path / "artifact.zip" + with zipfile.ZipFile(art, "w") as z: + z.writestr("metadata.yaml", "format: imf-v1\n") + z.write(graph, "encoder.onnx") + return art + + +def test_graph_io_gate_is_registered() -> None: + assert "graph_io" in registered_gates() + + +def test_fp16_boundary_fails(tmp_path: Path) -> None: + art = _artifact(tmp_path, TensorProto.FLOAT16, TensorProto.FLOAT16) + result = GraphIoGate().check( + recipe=None, ctx=GateContext(extra={"artifact_path": str(art)})) + assert not result.passed + assert "fp16" in result.detail + + +def test_fp32_boundary_passes(tmp_path: Path) -> None: + art = _artifact(tmp_path, TensorProto.FLOAT, TensorProto.FLOAT) + result = GraphIoGate().check( + recipe=None, ctx=GateContext(extra={"artifact_path": str(art)})) + assert result.passed + + +def test_fp16_weights_with_fp32_boundary_pass(tmp_path: Path) -> None: + """The WO79 shape: fp16 graph math inside, fp32 IO — legal.""" + art = tmp_path / "artifact.zip" + graph = tmp_path / "encoder.onnx" + g = helper.make_graph( + [helper.make_node("Cast", ["x"], ["h"], to=TensorProto.FLOAT16), + helper.make_node("Cast", ["h"], ["y_raw"], to=TensorProto.FLOAT16), + helper.make_node("Cast", ["y_raw"], ["y"], to=TensorProto.FLOAT)], + "g", + [helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 2])], + [helper.make_tensor_value_info("y", TensorProto.FLOAT, [2, 2])], + ) + m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 14)]) + m.ir_version = 10 + onnx.save(m, str(graph)) + with zipfile.ZipFile(art, "w") as z: + z.writestr("metadata.yaml", "format: imf-v1\n") + z.write(graph, "encoder.onnx") + result = GraphIoGate().check( + recipe=None, ctx=GateContext(extra={"artifact_path": str(art)})) + assert result.passed + + +def test_missing_artifact_notes_without_failing() -> None: + result = GraphIoGate().check(recipe=None, ctx=GateContext()) + assert result.passed + assert "not evaluated" in result.detail diff --git a/tests/test_pipeline_plan_gate.py b/tests/test_pipeline_plan_gate.py index 13163f5a..2703a8c7 100644 --- a/tests/test_pipeline_plan_gate.py +++ b/tests/test_pipeline_plan_gate.py @@ -158,6 +158,7 @@ def test_gate_results_files_end_to_end(tmp_path: Path) -> None: "conformance": False, "regression": True, "budgets": True, + "graph_io": True, } From 6af1f93bf4d484c54b6e7d4be2b7aac4583cf38c Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Thu, 22 Oct 2026 16:00:28 +0800 Subject: [PATCH 2/2] style: use the io-kind in the graph_io offender diagnostic (ruff B007) --- src/pipeline/gates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pipeline/gates.py b/src/pipeline/gates.py index 0ce7a2e5..161f3ba8 100644 --- a/src/pipeline/gates.py +++ b/src/pipeline/gates.py @@ -291,7 +291,7 @@ def check(self, recipe: RecipeSpec, ctx: GateContext) -> GateResult: for t in infos: tt = t.type.tensor_type if tt.elem_type == TensorProto.FLOAT16: - offenders.append(f"{member}:{t.name}") + offenders.append(f"{member} {kind} {t.name}") if offenders: return GateResult( self.name, False,