#!/usr/bin/env python3 """G2 — census sanity gate (design doc §3, gate table row "G2"). Runs after the `census` stage. Reads `/census.json` (written by `garment_pipeline.py` stage_census) and checks it against the resolved config's `expect.islands` block. Catches the silent killer the design calls out: an MD re-export reorders or renumbers islands, the config's `parts[*].islands` indices now point at the wrong geometry, and every downstream stage happily builds the wrong garment. python clothing/gates/g2_census.py --config work//resolved.json --work work/ Exit codes: 0 pass · 2 fail (thresholds violated) · 3 error (could not evaluate). Writes `/qc/g2.json` in the contract shape. census.json shape (verified against work/piupiu_sb, work/piupiu, work/pari): [ {"island": 0, "verts": 3620, "tris": 7104, "z": [5.184, 11.126], "x": [-2.228, 2.517], "centroid": [-0.034, 0.315, 8.416], "color": [0.9, 0.1, 0.1]} ] A list, one record per island, `island` is the index the config's `parts[*].islands` refers to. UNITS: raw source-mesh units, NOT metres — the piupiu census reads z 5.18..11.13 because `align.scale_z` (0.1) is applied later in `prepare`. Author `expect.islands` z bounds in census units (divide the metre figure by the align scale, or just read them off census.json). `expect.islands` schema understood by this gate (superset of PIPELINE-CONTRACT.md): "islands": { "count": 1, // exact island count in census.json "require_all_mapped": false, // unmapped non-trivial islands => fail (default warn) "mapped": { "0": { // key: island index, or a part name from parts{} "verts": [3500, 3700], // inclusive range on vert count "tris": [7000, 7200], // optional "z": [5.0, 11.3], // island z extent must be CONTAINED in this range "z_min": [5.1, 5.3], // optional: range on the low z endpoint "z_max": [11.0, 11.2], // optional: range on the high z endpoint "z_span": [5.8, 6.1], // optional: range on (zmax - zmin) "x": [-2.4, 2.6] // optional: x extent containment } } } Every range accepts `[lo, hi]` (either endpoint may be null for one-sided), a bare number (exact match), or `{"min": lo, "max": hi}`. Independent of `expect`, the gate ALWAYS cross-checks that every island index in `parts[*].islands` exists in census.json — that check needs no thresholds, so a config with no `expect.islands` still gets it (and passes with a note rather than erroring; the orchestrator only schedules this gate when the key exists). Stdlib only. """ from __future__ import annotations import argparse import json import os import sys GATE_ID = "g2" STAGE = "census" EXIT_PASS, EXIT_FAIL, EXIT_ERROR = 0, 2, 3 # ── report plumbing ────────────────────────────────────────────────────────── class Report: def __init__(self): self.metrics = {} self.failures = [] self.warnings = [] self.notes = [] self.artifacts = [] def fail(self, part, detail, **extra): entry = {"part": part, "detail": detail} entry.update(extra) self.failures.append(entry) def warn(self, part, detail, **extra): entry = {"part": part, "detail": detail} entry.update(extra) self.warnings.append(entry) def to_dict(self): return { "gate": GATE_ID, "pass": not self.failures, "checked_at_stage": STAGE, "metrics": self.metrics, "failures": self.failures, "warnings": self.warnings, "notes": self.notes, "artifacts": self.artifacts, } def write_report(work, report, out=None): """Write /qc/g2.json (or --out). Never raises; returns the path or None.""" path = out if not path: if not work: return None path = os.path.join(work, "qc", "%s.json" % GATE_ID) try: os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "w", encoding="utf-8") as fh: json.dump(report, fh, indent=2) fh.write("\n") return path except OSError as exc: # pragma: no cover - disk/permission edge sys.stderr.write("[g2] WARNING could not write report %s: %s\n" % (path, exc)) return None def die(work, detail, out=None): """Exit 3 with a contract-shaped error report on disk.""" rep = Report() rep.fail("config", detail, code="gate_error") doc = rep.to_dict() doc["error"] = detail write_report(work, doc, out) sys.stderr.write("[g2] ERROR %s\n" % detail) print(json.dumps(doc, indent=2)) return EXIT_ERROR # ── range helpers ──────────────────────────────────────────────────────────── def parse_range(spec): """Normalize a threshold spec to (lo, hi); either may be None. Raises ValueError.""" if spec is None: return (None, None) if isinstance(spec, (int, float)) and not isinstance(spec, bool): return (float(spec), float(spec)) if isinstance(spec, dict): lo, hi = spec.get("min"), spec.get("max") return (None if lo is None else float(lo), None if hi is None else float(hi)) if isinstance(spec, (list, tuple)): if len(spec) != 2: raise ValueError("range must have exactly 2 entries, got %r" % (spec,)) lo, hi = spec return (None if lo is None else float(lo), None if hi is None else float(hi)) raise ValueError("unsupported range spec %r" % (spec,)) def fmt_range(lo, hi): return "[%s, %s]" % ("-inf" if lo is None else round(lo, 4), "inf" if hi is None else round(hi, 4)) def check_scalar(report, part, island, label, value, spec): """value must sit inside the range. Returns True on pass.""" try: lo, hi = parse_range(spec) except ValueError as exc: report.fail(part, "expect.islands[%s].%s: %s" % (island, label, exc), island=island, code="bad_expect") return False if (lo is not None and value < lo) or (hi is not None and value > hi): report.fail(part, "%s = %s, outside expected %s" % (label, round(value, 4), fmt_range(lo, hi)), island=island, code="out_of_range", value=value, expected=[lo, hi], metric=label) return False return True def check_extent(report, part, island, label, extent, spec): """The [min,max] extent must be CONTAINED in the range. Returns True on pass.""" try: lo, hi = parse_range(spec) except ValueError as exc: report.fail(part, "expect.islands[%s].%s: %s" % (island, label, exc), island=island, code="bad_expect") return False emin, emax = float(extent[0]), float(extent[1]) bad = (lo is not None and emin < lo) or (hi is not None and emax > hi) if bad: report.fail(part, "%s extent [%s, %s] not contained in expected %s" % (label, round(emin, 4), round(emax, 4), fmt_range(lo, hi)), island=island, code="out_of_range", value=[emin, emax], expected=[lo, hi], metric=label) return False return True # ── census access ──────────────────────────────────────────────────────────── def load_json(path): with open(path, "r", encoding="utf-8") as fh: return json.load(fh) def census_index(records): """{island index: record}. Tolerates a dict-wrapped census ({"islands": [...]}).""" out = {} for i, rec in enumerate(records): if not isinstance(rec, dict): raise ValueError("census entry %d is %s, expected an object" % (i, type(rec).__name__)) idx = rec.get("island", rec.get("index", i)) out[int(idx)] = rec return out def resolve_mapped_key(key, parts): """expect.islands.mapped key -> island index. Accepts an index or a part name.""" try: return int(key), None except (TypeError, ValueError): pass part = parts.get(key) if part is None: return None, "no island index and no part named %r in parts{}" % (key,) islands = part.get("islands") or [] if len(islands) != 1: return None, ("part %r maps %d islands (%r) — key expect.islands.mapped by " "island index instead" % (key, len(islands), islands)) return int(islands[0]), None def part_for_island(parts, idx): for name, cfg in parts.items(): if idx in [int(i) for i in (cfg.get("islands") or [])]: return name return None # ── main ───────────────────────────────────────────────────────────────────── def main(argv=None): ap = argparse.ArgumentParser(description="G2 census sanity gate") ap.add_argument("--config", required=True, help="resolved config JSON (work//resolved.json)") ap.add_argument("--work", help="work dir (default: the config's directory)") ap.add_argument("--census", help="census.json override (default: /census.json)") ap.add_argument("--out", help="report path override (default: /qc/g2.json)") args = ap.parse_args(argv) work = args.work or os.path.dirname(os.path.abspath(args.config)) try: cfg = load_json(args.config) except (OSError, ValueError) as exc: return die(work, "cannot read config %s: %s" % (args.config, exc), args.out) if not isinstance(cfg, dict): return die(work, "config %s is not a JSON object" % args.config, args.out) census_path = args.census or os.path.join(work, "census.json") try: raw = load_json(census_path) except OSError as exc: return die(work, "census.json not found — run the census stage first (%s)" % exc, args.out) except ValueError as exc: return die(work, "census.json is not valid JSON: %s" % exc, args.out) if isinstance(raw, dict): # tolerate a future {"islands": [...]} wrapper raw = raw.get("islands", raw.get("records")) if not isinstance(raw, list): return die(work, "census.json must be a list of island records (got %s)" % type(raw).__name__, args.out) try: islands = census_index(raw) except ValueError as exc: return die(work, str(exc), args.out) parts = cfg.get("parts") or {} if not isinstance(parts, dict): return die(work, "config parts must be an object", args.out) expect = ((cfg.get("expect") or {}).get("islands")) or {} if not isinstance(expect, dict): return die(work, "expect.islands must be an object", args.out) rep = Report() try: rel_census = os.path.relpath(census_path, work).replace("\\", "/") except ValueError: # different drive rel_census = census_path.replace("\\", "/") rep.artifacts.append(rel_census) for name in ("qa_00_census_front.png", "qa_00_census_quarter.png"): if os.path.exists(os.path.join(work, name)): rep.artifacts.append(name) rep.metrics["census_islands"] = len(islands) rep.metrics["config_parts"] = len(parts) # ── 1. config parts[*].islands must exist in census.json (always) ──────── referenced = [] for part_name, part_cfg in sorted(parts.items()): if not isinstance(part_cfg, dict): rep.fail(part_name, "parts[%r] is not an object" % part_name, code="bad_config") continue idxs = part_cfg.get("islands") if idxs is None: rep.warn(part_name, "part declares no islands[] — nothing to cross-check", code="no_islands") continue if not isinstance(idxs, list): rep.fail(part_name, "parts[%r].islands must be a list" % part_name, code="bad_config") continue for raw_idx in idxs: try: idx = int(raw_idx) except (TypeError, ValueError): rep.fail(part_name, "island index %r is not an integer" % (raw_idx,), code="bad_config") continue referenced.append(idx) if idx not in islands: rep.fail(part_name, "config maps island %d but census.json has no such island " "(present: %s) — the source mesh was probably re-exported and " "islands renumbered" % (idx, sorted(islands)), island=idx, code="missing_island") rep.metrics["config_islands_referenced"] = len(referenced) # ── 2. island count ───────────────────────────────────────────────────── if "count" in expect: try: want = int(expect["count"]) except (TypeError, ValueError): rep.fail("config", "expect.islands.count must be an integer, got %r" % (expect["count"],), code="bad_expect") else: rep.metrics["expected_islands"] = want if want != len(islands): rep.fail("config", "island count = %d, expected %d — source mesh changed " "(island->part mapping is no longer trustworthy)" % (len(islands), want), code="island_count", value=len(islands), expected=want, metric="count") # ── 3. per-island thresholds ──────────────────────────────────────────── mapped = expect.get("mapped") or {} if not isinstance(mapped, dict): return die(work, "expect.islands.mapped must be an object", args.out) checked = 0 for key in sorted(mapped, key=lambda k: str(k)): spec = mapped[key] if not isinstance(spec, dict): rep.fail("config", "expect.islands.mapped[%r] must be an object" % (key,), code="bad_expect") continue idx, err = resolve_mapped_key(key, parts) if err: rep.fail("config", "expect.islands.mapped[%r]: %s" % (key, err), code="bad_expect") continue rec = islands.get(idx) label = part_for_island(parts, idx) or ("island_%d" % idx) if rec is None: rep.fail(label, "expect.islands.mapped[%r] targets island %d, absent from " "census.json (present: %s)" % (key, idx, sorted(islands)), island=idx, code="missing_island") continue checked += 1 if "verts" in spec: check_scalar(rep, label, idx, "verts", float(rec.get("verts", -1)), spec["verts"]) if "tris" in spec: check_scalar(rep, label, idx, "tris", float(rec.get("tris", -1)), spec["tris"]) z = rec.get("z") if z and len(z) == 2: if "z" in spec: check_extent(rep, label, idx, "z", z, spec["z"]) if "z_min" in spec: check_scalar(rep, label, idx, "z_min", float(z[0]), spec["z_min"]) if "z_max" in spec: check_scalar(rep, label, idx, "z_max", float(z[1]), spec["z_max"]) if "z_span" in spec: check_scalar(rep, label, idx, "z_span", float(z[1]) - float(z[0]), spec["z_span"]) elif any(k in spec for k in ("z", "z_min", "z_max", "z_span")): rep.fail(label, "census island %d has no usable z extent (%r)" % (idx, z), island=idx, code="bad_census") x = rec.get("x") if "x" in spec: if x and len(x) == 2: check_extent(rep, label, idx, "x", x, spec["x"]) else: rep.fail(label, "census island %d has no usable x extent (%r)" % (idx, x), island=idx, code="bad_census") rep.metrics["mapped_checked"] = checked # ── 4. unmapped islands ───────────────────────────────────────────────── min_verts = cfg.get("min_island_verts", 0) or 0 unmapped = [i for i in sorted(islands) if i not in referenced and int(islands[i].get("verts", 0)) >= min_verts] rep.metrics["unmapped_islands"] = len(unmapped) if unmapped: detail = ("islands %s (>= min_island_verts %s) are in census.json but no part " "claims them" % (unmapped, min_verts)) if expect.get("require_all_mapped"): rep.fail("config", detail, code="unmapped_island") else: rep.warn("config", detail + " — set expect.islands.require_all_mapped to fail on this", code="unmapped_island") if not expect: rep.notes.append("no expect.islands block in the config — ran the config/census " "island cross-check only; add expect.islands for vert/z thresholds") doc = rep.to_dict() path = write_report(work, doc, args.out) if path: doc["report_path"] = path.replace("\\", "/") print(json.dumps(doc, indent=2)) if rep.failures: sys.stderr.write("[g2] FAIL %d check(s)\n" % len(rep.failures)) return EXIT_FAIL sys.stderr.write("[g2] PASS (%d island(s), %d mapped checked, %d warning(s))\n" % (len(islands), checked, len(rep.warnings))) return EXIT_PASS if __name__ == "__main__": sys.exit(main())