"""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()