        rows=output.get('tiles',[]);offset={1:0,2:640};tile_heights={1:760,2:610};allrows=[]
        for tile in rows:
            tile_no=tile.get('tile');check('裁片编号合法',tile_no in offset)
            if tile_no not in offset:continue
            for r in tile.get('rows',[]):
                y=r.get('y_norm');allrows.append({'tile':tile_no,'id':r.get('id'),'text':r.get('text',''),'source_y':offset[tile_no]+y*tile_heights[tile_no]/1000 if isinstance(y,(float,int)) and 0<=y<=1000 else None})
        merged={};collisions=[]
        for r in sorted(allrows,key=lambda r:r['source_y'] if r['source_y'] is not None else 100000):
            key=r['id']
            if key in merged:
                a=merged[key]
                if a['text']!=r['text'] or a['source_y'] is None or r['source_y'] is None or abs(a['source_y']-r['source_y'])>65:collisions.append({'before':a,'after':r})
            else:merged[key]=r
        golden=[f'R{n:02}' for n in range(1,9)];check('两裁片共同覆盖8行且无新行',set(merged)==set(golden));check('源坐标合并后排序正确',[r['id'] for r in merged.values()]==golden);check('重叠R05实际重复出现再去重',sum(r['id']=='R05' for r in allrows)==2 and len(merged)==8);check('重叠内容与源坐标一致',not collisions)
        write_csv(artifact('merged-rows.csv'),merged.values(),['tile','id','text','source_y']);result={'rawRows':allrows,'merged':list(merged.values()),'collisions':collisions,'tileOffsets':offset,'coordinateContract':'y_norm 0..1000; source_y = offset + y_norm * actual_height / 1000'};dump(artifact('merge-audit.json'),result)
