Files
Pier_v1/Code/교각모델링앱/engine/gates.py
T
ahnyongjooandClaude Sonnet 5.5 964a841de1 chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합
- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환
- Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용
- pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관)
- worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md)
- D:\Rhino-MCP 경로 참조를 새 위치로 치환

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 11:20:18 +09:00

199 lines
11 KiB
Python

"""Gate runner: build a gate model, re-open it, check it, run negative tests and judge the gate.
A gate Gk passes when all of:
checks every FAIL of checker Gk is an approved planned exception (expected.json `planned_fail`)
negative a horizontal and a vertical move of one gate object each raise the FAIL count
reload the re-opened file has the same object count as the built document
unchanged objects of earlier gates are identical to the previous gate model (fast fingerprint)
Files go to <run_dir>: Gk.3dm, Gk.json, Gk_negH.json, Gk_negV.json, Gk_fp.json, Gk_gate.json.
"""
import json
import sys
import time
from pathlib import Path
from engine.pipeline import (CHECKERS, GATE_PREFIXES, GATES, REBAR_GATES, RHINO_DIR, TOLERANCE_MM, build_steps,
builder_of, check_steps, checker_of, gate_ids, scope_of)
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from compare_fingerprints import diff_obj # noqa: E402
from runner import run_job # noqa: E402
# Negative tests per gate: one horizontal and one vertical move of a gate object (mm).
NEGATIVE = {
"G1": ({"id": "BS08", "d": [3, 0, 0]}, {"id": "BP03", "d": [0, 0, 2]}),
"G2": ({"mark": "P2", "d": [5, 0, 0]}, {"mark": "P3", "d": [0, 0, 5]}),
"G3": ({"mark": "B4", "d": [5, 0, 0]}, {"mark": "B9", "d": [0, 0, 5]}),
"G4": ({"mark": "C11", "d": [5, 0, 0]}, {"mark": "S1", "d": [0, 0, 5]}),
"G5": ({"id": "BB05", "d": [5, 0, 0]}, {"id": "SP08", "d": [0, 0, 5]}),
}
def _load(p):
return json.loads(Path(p).read_text(encoding="utf-8"))
def planned(expected, gate, item):
for pf in expected.get("planned_fail", []):
if pf["gate"] != gate:
continue
if pf.get("item") == item or (pf.get("item_prefix") and item.startswith(pf["item_prefix"])):
return pf
return None
def _fails(res):
return [r for r in res["results"] if r["result"] == "FAIL"]
def negative_moves(X, gate):
"""Negative-test moves of a gate: expected["negative"][gate] (pier specific) or the Ungok default.
A default bearing id beyond this pier's bearings (BS08 / SP08 with 4 bearings) is replaced by the last bearing
number of expected G5 el_x (OPEN #19 A7); with 10 bearings the default is unchanged."""
ov = X.get("negative", {}).get(gate)
if ov:
return tuple(ov)
nos = sorted(int(k) for k in (X.get("G5", {}).get("el_x") or {}) if str(k).isdigit())
return tuple(dict(mv, id=f"{mv['id'][:2]}{nos[-1]:02d}") if "id" in mv and nos and int(mv["id"][2:]) not in nos
else mv for mv in NEGATIVE[gate])
def _negative_steps(gate, pid, expected, run_dir):
"""Rhino steps: checker, then horizontal / vertical move -> checker -> undo (same document)."""
neg_h, neg_v = negative_moves(_load(expected), gate)
pert = RHINO_DIR / "perturb.py"
steps = check_steps(expected, run_dir, [gate])
for tag, mv in (("_negH", neg_h), ("_negV", neg_v)):
glb = {"PIER_ID": pid, "MOVES": [mv]}
steps.append({"name": f"{gate}:perturb{tag}", "script": pert, "globals": glb})
steps += check_steps(expected, run_dir, [gate], tag)
steps.append({"name": f"{gate}:undo{tag}", "script": pert, "globals": dict(glb, UNDO=True)})
return steps
def _judge(gate, X, run_dir):
"""Read checker + negative-test results: (base, unplanned, planned_seen, neg)."""
base = _load(run_dir / f"{gate}.json")
fails = _fails(base)
unplanned = [f["item"] for f in fails if not planned(X, gate, f["item"])]
planned_seen = [{"item": f["item"], "reason": planned(X, gate, f["item"])["reason"]}
for f in fails if planned(X, gate, f["item"])]
neg = {}
for tag, mv in zip(("negH", "negV"), negative_moves(X, gate)):
r = _load(run_dir / f"{gate}_{tag}.json")
new = [f["item"] for f in _fails(r) if f["item"] not in {x["item"] for x in fails}]
neg[tag] = {"move": mv, "new_fails": new[:10], "detected": len(_fails(r)) > len(fails)}
return base, unplanned, planned_seen, neg
def run_batch_session(gates, params, expected, run_dir, log=print):
"""Batch mode: build G1..G5 in ONE Rhino session; after each gate in `gates` is built, run its checker and
negative tests in the same document. No per-gate save and no reload; the final model is saved once as G5.3dm.
Writes <gate>_gate.json for each gate in `gates` (reload / unchanged = skipped) and returns {gate: report}."""
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
X = _load(expected)
pid = X["pier_id"]
ids = gate_ids()
for g in gates: # results / models of these gates are replaced: drop the old ones
for suffix in ("_fp.json",) + ((".3dm",) if g != "G5" else ()):
(run_dir / f"{g}{suffix}").unlink(missing_ok=True)
glb = {"PARAMS_PATH": str(Path(params).resolve())}
steps = []
scope = scope_of(params)
for g, _, _ in GATES:
if g not in scope:
continue
script = builder_of(params, g)
steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)})
if g in REBAR_GATES:
steps.append({"name": f"{g}:rebar_colours.py", "script": RHINO_DIR / "rebar_colours.py", "globals": glb})
if g in gates:
steps += _negative_steps(g, pid, expected, run_dir)
steps.append({"name": "display.py", "script": RHINO_DIR / "display.py", "globals": glb})
t0 = time.time()
log(f"[batch] one Rhino session: build G1..G5, check {', '.join(gates)} (no per-gate save / reload)")
res = run_job(steps, save=run_dir / "G5.3dm", units_mm_tolerance=TOLERANCE_MM)
secs = {}
for st in res["steps"]:
g = st["name"].split(":")[0]
secs[g] = secs.get(g, 0) + st["secs"]
out = {}
for g in gates:
base, unplanned, planned_seen, neg = _judge(g, X, run_dir)
verdict = "PASS" if not unplanned and all(v["detected"] for v in neg.values()) else "FAIL"
rep = {"gate": g, "checker": checker_of(expected, g), "verdict": verdict, "summary": base["summary"],
"unplanned_fails": unplanned, "planned_fails": planned_seen, "negative": neg,
"reload": {"ok": None, "skipped": "일괄 실행(단일 세션) — 저장·재로드 안 함"},
"unchanged": {"ok": None, "compared": 0, "diffs": [], "missing": [], "against": None,
"skipped": "일괄 실행(단일 세션) — 앞 게이트 객체를 다시 만들지 않음"},
"model": str(run_dir / "G5.3dm") if g == "G5" else None, "mode": "batch",
"secs": round(secs.get(g, 0), 1), "session_secs": res["wall_secs"]}
(run_dir / f"{g}_gate.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
log(f"[{g}] {verdict} checks {base['summary']['passed']}/{base['summary']['total']} "
f"(planned FAIL {len(planned_seen)}, unplanned {len(unplanned)}) negative H/V "
f"{neg['negH']['detected']}/{neg['negV']['detected']} (batch)")
out[g] = rep
log(f"[batch] done in {round(time.time() - t0, 1)}s, objects {res['objects']}, model {run_dir / 'G5.3dm'}")
return out
def run_gate(gate, params, expected, run_dir, log=print):
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
X = _load(expected)
pid = X["pier_id"]
t0 = time.time()
model = run_dir / f"{gate}.3dm"
log(f"[{gate}] build G1..{gate}")
built = run_job(build_steps(params, gate), save=model, units_mm_tolerance=TOLERANCE_MM)
steps = _negative_steps(gate, pid, expected, run_dir)
steps.append({"name": "fingerprint", "script": RHINO_DIR / "fingerprint.py",
"globals": {"MODEL_PATH": str(model), "OUT_PATH": str(run_dir / f"{gate}_fp.json"), "FAST": True}})
log(f"[{gate}] re-open, check, negative tests, fingerprint")
checked = run_job(steps, open_path=model)
base, unplanned, planned_seen, neg = _judge(gate, X, run_dir)
fp = _load(run_dir / f"{gate}_fp.json")
unchanged = {"compared": 0, "diffs": [], "missing": [], "ok": True, "against": None}
ids = scope_of(params) # previous gate = previous gate in this pier's scope
k = ids.index(gate)
if k > 0:
prev_fp = run_dir / f"{ids[k - 1]}_fp.json"
if k > 0 and not prev_fp.exists():
unchanged.update(ok=None, skipped=f"{ids[k - 1]} 지문 없음(일괄 실행 결과) — 비교 생략")
elif k > 0:
prev = _load(prev_fp)
earlier = tuple(f"{pid}-{p}" for g in ids[:k] for p in GATE_PREFIXES[g])
keys = [oid for oid in prev["objects"] if oid.startswith(earlier)]
unchanged["against"] = prev_fp.name
unchanged["compared"] = len(keys)
unchanged["missing"] = [oid for oid in keys if oid not in fp["objects"]][:10]
for oid in keys:
if oid in fp["objects"]:
d = diff_obj(prev["objects"][oid], fp["objects"][oid])
if d:
unchanged["diffs"].append([oid, d[:3]])
unchanged["diffs"] = unchanged["diffs"][:10]
unchanged["ok"] = not unchanged["missing"] and not unchanged["diffs"]
reload_ok = built["objects"] == checked["objects"] == fp["count"]
verdict = ("PASS" if not unplanned and all(v["detected"] for v in neg.values()) and reload_ok
and unchanged["ok"] is not False else "FAIL")
rep = {"gate": gate, "checker": checker_of(expected, gate), "verdict": verdict, "summary": base["summary"],
"unplanned_fails": unplanned, "planned_fails": planned_seen,
"negative": neg, "reload": {"built": built["objects"], "reopened": checked["objects"],
"fingerprint": fp["count"], "ok": reload_ok},
"unchanged": unchanged, "model": str(model), "mode": "single", "secs": round(time.time() - t0, 1),
"build_steps": [{k_: s[k_] for k_ in ("name", "secs")} for s in built["steps"]]}
(run_dir / f"{gate}_gate.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
log(f"[{gate}] {verdict} checks {base['summary']['passed']}/{base['summary']['total']} "
f"(planned FAIL {len(planned_seen)}, unplanned {len(unplanned)}) negative H/V "
f"{neg['negH']['detected']}/{neg['negV']['detected']} reload {reload_ok} "
f"unchanged {unchanged['ok']} ({unchanged['compared']}) {rep['secs']}s")
return rep