"""Phase A3 — assembler bridge proof (SPEC_OPTION_A.md §3.3, §10). The crucial offline gate: each build-spec fixture, run through the real generator + kernel + repair + assembler, produces output that passes BOTH the existing frontend contract (`validate_shape`) AND the geometric metric gate. Also proves model-free regeneration (new vane count -> valid contract). """ import importlib.util import json import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from geometry import assembler, metrics # noqa: E402 # Reuse the contract validator from the Phase 1 schema tests. _spec = importlib.util.spec_from_file_location("schema_mod", ROOT / "tests" / "test_schema.py") _schema = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_schema) validate_shape = _schema.validate_shape FIX = ROOT / "tests" / "fixtures" / "codegen" SPECS = {"octahedron": "spec_octahedron.json", "cube": "spec_cube.json", "cylindrical_vent": "spec_cylindrical_vent.json"} def load_spec(name): return json.loads((FIX / SPECS[name]).read_text()) def _geom(contract): verts = contract["geometry"]["vertices"] faces = [f["indices"] for f in contract["geometry"]["faces"]] return verts, faces @pytest.mark.parametrize("name", list(SPECS)) def test_assembled_passes_frontend_contract(name): spec = load_spec(name) contract, warnings = assembler.build(spec) errors = validate_shape(contract) assert errors == [], errors @pytest.mark.parametrize("name", list(SPECS)) def test_assembled_passes_metric_gate(name): spec = load_spec(name) contract, _ = assembler.build(spec) verts, faces = _geom(contract) ok, fails = metrics.passes(verts, faces, spec["regularity"]) assert ok, fails def test_octahedron_decomposed_into_8_components(): contract, _ = assembler.build(load_spec("octahedron")) assert contract["complexity"]["component_count"] == 8 assert contract["complexity"]["face_count"] == 8 def test_cube_six_components_twelve_triangles(): contract, _ = assembler.build(load_spec("cube")) assert contract["complexity"]["component_count"] == 6 assert contract["complexity"]["face_count"] == 12 def test_complexity_counts_match_arrays(): for name in SPECS: contract, _ = assembler.build(load_spec(name)) assert contract["complexity"]["vertex_count"] == len(contract["geometry"]["vertices"]) assert contract["complexity"]["face_count"] == len(contract["geometry"]["faces"]) assert contract["complexity"]["component_count"] == len(contract["components"]) def test_regeneration_more_vanes_is_model_free_and_valid(): # Re-running the cached generator with vanes=12 must yield a valid contract # with auto-reconciled vane components — no model call involved. spec = load_spec("cylindrical_vent") contract, warnings = assembler.build(spec, params={"vanes": 12, "height": 2.0}) assert validate_shape(contract) == [] vane_comps = [c for c in contract["components"] if c["id"].startswith("vane_")] assert len(vane_comps) == 12 assert any("auto-reconciled" in w for w in warnings) # vane_8..11 were undeclared def test_regeneration_fewer_vanes_valid(): spec = load_spec("cylindrical_vent") contract, _ = assembler.build(spec, params={"vanes": 5, "height": 2.0}) assert validate_shape(contract) == [] vane_comps = [c for c in contract["components"] if c["id"].startswith("vane_")] assert len(vane_comps) == 5 def test_sanitizes_invalid_transform_axis(): # Model emits axis "xy" (only x|y|z is valid) — assembler must coerce so the # contract validates instead of the frontend rejecting it. spec = load_spec("octahedron") spec["params"][0]["transform"] = {"op": "scale_axis", "axis": "xy", "components": "all"} contract, _ = assembler.build(spec) assert validate_shape(contract) == [] p = next(p for p in contract["params"] if p["id"] == "scale") assert p["transform"]["axis"] == "x" # coerced to first valid axis char def test_sanitizes_unfixable_axis_drops_transform(): spec = load_spec("octahedron") spec["params"][0]["transform"] = {"op": "rotate", "axis": "diagonal"} contract, _ = assembler.build(spec) assert validate_shape(contract) == [] p = next(p for p in contract["params"] if p["id"] == "scale") assert "transform" not in p # no valid axis -> transform dropped def test_sanitizes_out_of_range_valid_band_and_dropdown(): spec = load_spec("octahedron") spec["params"][0]["valid_min"] = spec["params"][0]["min"] - 5 # below min spec["params"][0]["valid_max"] = spec["params"][0]["max"] + 5 # above max spec["params"].append({"id": "style", "label": "Style", "type": "dropdown", "options": ["a", "b"], "default": "zzz"}) # bad default contract, _ = assembler.build(spec) assert validate_shape(contract) == [] style = next(p for p in contract["params"] if p["id"] == "style") assert style["default"] == "a" def test_constraint_referencing_dropped_param_removed(): spec = load_spec("octahedron") spec["constraints"] = [{"id": "c1", "params": ["ghost_param"], "rule": "x"}] contract, _ = assembler.build(spec) assert validate_shape(contract) == [] assert contract["constraints"] == [] def test_triangle_counts_sum_to_face_count(): for name in SPECS: contract, _ = assembler.build(load_spec(name)) total = sum(c["triangle_count"] for c in contract["components"]) assert total == contract["complexity"]["face_count"]