- 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>
131 lines
7.2 KiB
Python
131 lines
7.2 KiB
Python
"""Read-only change detection, NOT drawing-concordance approval.
|
|
|
|
Saved paths are comparison data, never generation inputs or ground truth.
|
|
An unchanged result may reproduce an old defect. A changed result may correct
|
|
one. Both require independent current-drawing checks before publication.
|
|
"""
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
sys.path.insert(0,str(Path(__file__).resolve().parent.parent))
|
|
import server_8097
|
|
from cad_rebar_recipe import load_sources
|
|
from drawing_cycle_assembly import generate_groups
|
|
|
|
|
|
def path_key(path):
|
|
forward=tuple(tuple(round(float(v),6) for v in p) for p in path)
|
|
return min(forward,tuple(reversed(forward)))
|
|
|
|
|
|
def compare_groups(expected, actual):
|
|
old={g['id']:g for g in expected if g.get('id')}
|
|
if len(old)!=sum(bool(g.get('id')) for g in expected):
|
|
raise ValueError('duplicate saved group identity')
|
|
identities=[g.get('id') for g in actual]
|
|
if any(not identity for identity in identities) or len(set(identities))!=len(identities):
|
|
raise ValueError('missing or duplicate generated group identity')
|
|
def ownership(group):
|
|
paths=group.get('instance_paths_m')or[]
|
|
occurrences=group.get('occurrence_instance_ids')or[]
|
|
if not paths or len(paths)!=len(occurrences) or any(not o for o in occurrences):
|
|
return None
|
|
return Counter((path_key(path), occurrence) for path,occurrence in zip(paths,occurrences))
|
|
rows=[]
|
|
for g in actual:
|
|
previous=old.get(g['id'])
|
|
same=previous is not None and Counter(map(path_key,previous.get('instance_paths_m')or[])) == Counter(map(path_key,g.get('instance_paths_m')or[]))
|
|
rows.append(dict(id=g['id'],paths_equal_to_micrometer=same,
|
|
old_paths=len((previous or {}).get('instance_paths_m')or[]),
|
|
new_paths=len(g.get('instance_paths_m')or[]),
|
|
old_diameter_mm=(previous or {}).get('diameter_mm'),
|
|
new_diameter_mm=g.get('diameter_mm'),
|
|
diameter_equal=previous is not None and previous.get('diameter_mm')==g.get('diameter_mm'),
|
|
hosts_equal=previous is not None and bool(g.get('host_component_ids')) and
|
|
set(previous.get('host_component_ids')or[])==set(g.get('host_component_ids')or[]),
|
|
path_occurrences_equal=previous is not None and ownership(g) is not None and
|
|
ownership(previous)==ownership(g)))
|
|
return rows
|
|
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('job_id')
|
|
parser.add_argument('--report',type=Path)
|
|
parser.add_argument('--all-scopes',action='store_true')
|
|
parser.add_argument('--fresh-face-bindings',action='store_true',
|
|
help='withhold previously derived face placements as well as saved paths')
|
|
args=parser.parse_args()
|
|
if len(args.job_id)!=24 or any(c not in '0123456789abcdef' for c in args.job_id):raise ValueError('invalid job')
|
|
server=server_8097.server
|
|
server.require_modeling_rules()
|
|
path=server.JOB_ROOT/(args.job_id+'.json')
|
|
raw=path.read_bytes(); job=json.loads(raw)
|
|
stored=job['result']; saved=stored.get('rebar_groups')or[]
|
|
plan={key:copy.deepcopy(stored[key]) for key in
|
|
('concrete_components','drawingEvidence','global_parameters','category_id') if key in stored}
|
|
# The source resolver sees concrete and drawing evidence only. All old
|
|
# generated paths are withheld from the generator, including the preview.
|
|
preview=(plan.get('drawingEvidence')or{}).get('preview_geometry')or{}
|
|
preview['rebar_groups']=[]
|
|
from box_member_schema import migrate_payload_types
|
|
type_migrations=migrate_payload_types(plan)
|
|
before=copy.deepcopy(plan)
|
|
if args.fresh_face_bindings:
|
|
if not args.all_scopes:raise ValueError('--fresh-face-bindings requires --all-scopes')
|
|
plan.get('drawingEvidence',{}).pop('standard_section_layouts',None)
|
|
if args.all_scopes:
|
|
from fresh_rebar_8097 import _enrich_object_rows_from_current_ledger
|
|
from adaptive_block1_rebar import build_current_drawing_nonwing_groups
|
|
analysis=server.rehydrate_drawing_set_analysis(job['sourceContext']['analysis'])
|
|
_enrich_object_rows_from_current_ledger(plan,analysis)
|
|
generated=build_current_drawing_nonwing_groups(plan,analysis,collect_errors=True)
|
|
else:
|
|
from local_rebar_pipeline import discover_bindings
|
|
sources=load_sources(job['sourceContext']['analysis'])
|
|
binding=discover_bindings(plan,sources)
|
|
generated,_=generate_groups(plan,sources,binding['placements'],binding['groups'])
|
|
if plan!=before:raise ValueError('generator mutated input')
|
|
if plan['concrete_components']!=before['concrete_components']:raise ValueError('locked concrete changed')
|
|
rows=compare_groups(saved,generated)
|
|
report=dict(job_id=args.job_id,source_job_sha256=hashlib.sha256(raw).hexdigest(),
|
|
object_type_migrations=type_migrations,
|
|
comparison_purpose='change_detection_only',
|
|
stored_model_is_ground_truth=False,
|
|
drawing_concordance_status='not_established_by_replay',
|
|
publication_authorized=False,
|
|
generator='drawing_cycle_assembly.generate_groups (common pipeline)',
|
|
stored_paths_used_for_generation=False,model_modified=False,
|
|
saved_face_layouts_withheld=args.fresh_face_bindings,
|
|
full_replay=False,generated_groups=len(generated),stored_groups=len(saved),
|
|
stored_groups_without_id=sum(not g.get('id') for g in saved),
|
|
matching_groups=sum(r['paths_equal_to_micrometer'] and r['diameter_equal'] for r in rows),
|
|
ownership_matching_groups=sum(r['hosts_equal'] and r['path_occurrences_equal'] for r in rows),
|
|
missing_saved_group_ids=sorted({g['id'] for g in saved if g.get('id')}-{g['id'] for g in generated}),
|
|
concrete_regenerated_from_drawing=False, locked_concrete_unchanged=True, groups=rows)
|
|
if not args.all_scopes:
|
|
report['saved_selector_inputs_used']=binding['saved_selector_inputs_used']
|
|
report['automatic_source_binding_failures']=binding['unresolved']
|
|
if args.all_scopes:
|
|
report['generator']='adaptive_block1_rebar.build_current_drawing_nonwing_groups'
|
|
report['candidate_audit']=plan['commonRebarCandidateAudit']
|
|
report['slab_pair_audit']=plan.get('slabPairAudit')
|
|
report['face_quantity_spacing_audits']=[dict(group_id=g['id'],**g['placement_quantity_spacing_audit'])
|
|
for g in generated if g.get('placement_quantity_spacing_audit')]
|
|
report['unassigned_section_symbols']={key:layout.get('unassigned_section_symbols',[])
|
|
for key,layout in plan.get('drawingEvidence',{}).get('standard_section_layouts',{}).items()}
|
|
if path.read_bytes()!=raw:raise ValueError('job changed concurrently')
|
|
if args.report:
|
|
with args.report.open('x') as stream:json.dump(report,stream,ensure_ascii=False,indent=2)
|
|
print(json.dumps({k:v for k,v in report.items() if k!='groups'},ensure_ascii=False),flush=True)
|
|
for row in rows:
|
|
if not all(row[k] for k in ('paths_equal_to_micrometer','diameter_equal','hosts_equal','path_occurrences_equal')):
|
|
print(json.dumps(row),flush=True)
|
|
|
|
|
|
if __name__=='__main__':main()
|