"""Publish a strict duplicate-only subset from a current-source replay.""" import argparse, copy, hashlib, json, os, sys, tempfile, time from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from slab_duplicate_reconciliation import reconcile def main(): p=argparse.ArgumentParser(description=__doc__) p.add_argument('job',type=Path);p.add_argument('candidate',type=Path) p.add_argument('--replay-input',type=Path) p.add_argument('--stacked-additions',action='store_true',help='Only add independently witnessed stacked section dots') p.add_argument('--source-faces',action='store_true',help='Replace CAD-replayed slab faces after pair and tube checks') p.add_argument('--source-walls',action='store_true',help='Replace wall rows after own elevation and tube checks') p.add_argument('--group-id',action='append',help='Publish only these verified groups; retain full replay pair checks') p.add_argument('--apply',action='store_true');a=p.parse_args() import server server.require_modeling_rules() raw=a.job.read_bytes();job=json.loads(raw);candidate=json.loads(a.candidate.read_text()) if job.get('status')=='running':raise ValueError('active worker') replay_raw=a.replay_input.read_bytes() if a.replay_input else raw if hashlib.sha256(replay_raw).hexdigest()!=candidate['source_job_sha256'] and not a.stacked_additions: raise ValueError('job differs from replay input; recheck before publishing') # Add-only reconciliation independently requires identical concrete and # every current target path to remain in the candidate. CAD hashes and # added block coordinates are checked below; unrelated job metadata is # not authority to discard a verified additional occurrence. baseline=json.loads(replay_raw)['result'] def geometry(plan): return [plan['concrete_components'],[{k:g.get(k)for k in ('id','diameter_mm','count','instance_paths_m','occurrence_instance_ids','host_component_ids')} for g in plan['rebar_groups']]] if geometry(baseline)!=geometry(job['result']): # Allow an idempotent continuation of this exact candidate only. # Any other intervening geometry still rejects publication. known={g.get('id'):g for g in candidate['rebar_groups']} normalized=copy.deepcopy(job['result']) original={g.get('id'):g for g in baseline['rebar_groups']} keys=('id','diameter_mm','count','instance_paths_m','occurrence_instance_ids','host_component_ids') for i,g in enumerate(normalized['rebar_groups']): k=g.get('id');old=original.get(k) if old and (a.source_faces or a.stacked_additions) and k in known and all(g.get(key)==known[k].get(key)for key in keys): normalized['rebar_groups'][i]=copy.deepcopy(old) if a.stacked_additions or a.source_faces or a.source_walls: if baseline['concrete_components']!=job['result']['concrete_components']: raise ValueError('concrete changed since source replay') current={g['id']:g for g in job['result']['rebar_groups']} for identity,new in known.items(): old=original.get(identity);now=current.get(identity) if old and (now is None or not any(all(now.get(k)==ref.get(k)for k in keys) for ref in (old,new))): raise ValueError('target face changed since source replay') elif geometry(baseline)!=geometry(normalized):raise ValueError('geometry changed since source replay') from cad_source_access import source_manifest sources=source_manifest(job['sourceContext']['analysis'],server.UPLOAD_ROOT)['sources'] if sorted(s['sha256']for s in sources)!=sorted(candidate['source_hashes']): raise ValueError('original CAD differs from replay input') # Re-read the wall-view leaders, not the saved wall allocation. A world # overlap alone cannot authorize removal of a slab occurrence. from cad_rebar_recipe import DrawingSource from cad_source_catalog import normalized from local_wall_elevation_ownership import bind_frame_ownership drawing_sources={s['sha256']:DrawingSource(Path(s['path']).read_bytes())for s in sources} for group in candidate['rebar_groups']: section=group.get('standard_section_placement',{}) for placement in section.get('placements',[]): if placement.get('source_domain')!='drawn_inner_face_block':continue from local_face_layout_discovery import symbol_center source=drawing_sources[section['source_sha256']] entity=source.entity(placement['entity']) if entity.get('type')!='CIRCLE':raise ValueError('additional occurrence is not an original circle block') center,_=symbol_center(entity) proof=placement['source_coordinate_proof'] if any(abs(a-b)>1e-5 for a,b in zip(center,proof['center_xy'])): raise ValueError('additional block coordinate differs from original CAD') datum=section['datum'];datum_entity=source.entity(datum['entity']) if abs(datum_entity['points'][datum['vertex']][1]-proof['datum_top'])>1e-5: raise ValueError('additional block datum differs from original CAD') row=group.get('standard_section_placement',{}).get('row_binding',{}) if row.get('installation_kind')!='wall' or not row.get('longitudinal_ends'):continue frame={'source_wall_end_bindings':{row['host_role']:row['longitudinal_ends']}} proof=row['longitudinal_ends']['start']['source'] scale=proof.get('cad_to_m') or proof.get('chain',{}).get('cad_to_mm',0)/1000 if not scale:continue ownership=bind_frame_ownership(frame,drawing_sources,scale) row['wall_elevation_occurrences']=[r for r in ownership if r['mark']==normalized(group['mark'])] reconcile_candidate=reconcile if a.stacked_additions: from slab_stacked_reconciliation import reconcile as reconcile_candidate if a.source_faces: if a.stacked_additions:raise ValueError('choose one scoped publication mode') from slab_source_reconciliation import reconcile as reconcile_candidate if a.source_walls: if a.source_faces or a.stacked_additions:raise ValueError('choose one scoped publication mode') from wall_source_reconciliation import reconcile as reconcile_candidate if a.group_id: requested=set(a.group_id) available={g['id'] for g in candidate['rebar_groups']} if not requested<=available:raise ValueError('requested group absent from source replay') candidate=copy.deepcopy(candidate) candidate['rebar_groups']=[g for g in candidate['rebar_groups'] if g['id'] in requested] result,report=reconcile_candidate(job['result'],candidate) if a.apply and report['changed_groups']: from box_member_schema import member_model result['memberModel']=member_model(result) job.update(result=result,partialResult=copy.deepcopy(result),geometryRevision=time.time_ns()) if a.job.read_bytes()!=raw:raise ValueError('concurrent job change') backup=a.job.with_name(a.job.stem+f'.before-slab-duplicate-{time.time_ns()}.json') with backup.open('xb')as f:f.write(raw) with tempfile.NamedTemporaryFile(mode='w',dir=a.job.parent,delete=False)as f: json.dump(job,f,ensure_ascii=False,allow_nan=False);temporary=f.name if a.job.read_bytes()!=raw:raise ValueError('concurrent job change before replacement') os.replace(temporary,a.job);report['backup']=str(backup) report['published']=bool(a.apply and report['changed_groups']) print(json.dumps(report,ensure_ascii=False)) if __name__=='__main__':main()