Spaces:
Running
Running
| """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 | |
| def test_assembled_passes_frontend_contract(name): | |
| spec = load_spec(name) | |
| contract, warnings = assembler.build(spec) | |
| errors = validate_shape(contract) | |
| assert errors == [], errors | |
| 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"] | |