#!/usr/bin/env python3
"""COMPLEXA C241–C250: source-derived cellular automata and agent-based modeling SVG/CSV illustrations.

Pure Python 3.12+ standard library; deterministic charts, numerical audits and ZIP.
Run: python make_figures.py --out /path/to/content/supplementary
     python make_figures.py --check /path/to/content/supplementary
Every panel derives from the manuscript-defined examples, never unreported observations.
"""
from __future__ import annotations
import argparse
import csv
import hashlib
import io
import math
from pathlib import Path
from xml.sax.saxutils import escape
from zipfile import ZipFile, ZipInfo, ZIP_STORED

BASE = Path(__file__).resolve().parent
TEAL = '#087f8c'; BLUE = '#326d98'; ORANGE = '#c26c38'; PLUM = '#754c96'
INK = '#183348'; MUTED = '#566c78'; GRID = '#dae4eb'; BG = '#ffffff'; LT = '#f3f7fa'; GREEN='#2d7865'
COLORS=[BLUE,TEAL,ORANGE,PLUM,GREEN]
W,H=1120,680

def h(p):
    if p<=0 or p>=1:
        assert 0<=p<=1
        return 0.
    return -p*math.log2(p)-(1-p)*math.log2(1-p)

def entropy(p):
    return -sum(v*math.log2(v) for v in p if v>0)

def kl(p,q):
    return sum(a*math.log2(a/b) for a,b in zip(p,q) if a>0)

def fmt(x,n=6):
    return f'{x:.{n}f}'.rstrip('0').rstrip('.') if isinstance(x,float) else str(x)

def number(x):
    return f'{x:.10f}' if isinstance(x,float) else str(x)

def csv_content(cols,rows):
    out=io.StringIO(newline='')
    writer=csv.writer(out,lineterminator='\n');writer.writerow(cols)
    for row in rows: writer.writerow([number(x) for x in row])
    return out.getvalue().encode('utf-8')

class Figure:
    def __init__(self,n,title,subtitle,detail):
        self.n=n;self.a=[]
        self.add(f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" role="img" aria-labelledby="title desc">')
        self.add(f'<title id="title">{escape(title)}</title><desc id="desc">{escape(detail)}</desc>')
        self.rect(0,0,W,H,BG);self.rect(0,0,W,9,TEAL)
        self.txt(65,47,f'COMPLEXA · FIGURA {n}.1',14,TEAL,True)
        self.txt(65,87,title,26,INK,True)
        self.txt(65,119,subtitle,14,MUTED)
        self.line(65,139,W-65,139,GRID,1.5)
    def add(self,s): self.a.append(s)
    def rect(self,x,y,w,h,color,r=0,stroke=None):
        b=f' stroke="{stroke}"' if stroke else ''
        self.add(f'<rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" height="{h:.2f}" rx="{r:.1f}" fill="{color}"{b}/>')
    def line(self,x,y,x2,y2,color=GRID,width=1,dash=None):
        dd=f' stroke-dasharray="{dash}"' if dash else ''
        self.add(f'<line x1="{x:.2f}" y1="{y:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" stroke="{color}" stroke-width="{width:.2f}"{dd}/>')
    def txt(self,x,y,s,size=14,color=INK,bold=False,anchor='start'):
        self.add(f'<text x="{x:.2f}" y="{y:.2f}" text-anchor="{anchor}" fill="{color}" font-family="Arial,Helvetica,sans-serif" font-size="{size:.1f}" font-weight="{700 if bold else 400}">{escape(str(s))}</text>')
    def circle(self,x,y,r,fill):self.add(f'<circle cx="{x:.2f}" cy="{y:.2f}" r="{r:.2f}" fill="{fill}" stroke="#fff" stroke-width="1.6"/>')
    def poly(self,xy,col,w=3,dash=None):
        pts=' '.join(f'{x:.2f},{y:.2f}' for x,y in xy)
        d=f' stroke-dasharray="{dash}"' if dash else ''
        self.add(f'<polyline points="{pts}" fill="none" stroke="{col}" stroke-width="{w:.2f}" stroke-linejoin="round" stroke-linecap="round"{d}/>')
    def legend(self,parts,x=735,y=166):
        for i,(name,color) in enumerate(parts):
            yy=y+i*23
            self.rect(x,yy-12,24,9,color,2);self.txt(x+34,yy-3,name,13,MUTED)
    def note(self,lead,txt):
        self.rect(65,583,W-130,68,LT,8,GRID)
        self.txt(82,610,lead,14,TEAL,True)
        self.txt(82,635,txt,13,MUTED)
    def output(self):return ('\n'.join(self.a+['</svg>'])+'\n').encode('utf-8')

class Axes:
    def __init__(self,f,x0=119,y0=188,w=847,h=322,xmin=0,xmax=1,ymin=0,ymax=1,xlabel='',ylabel=''):
        assert xmax>xmin and ymax>ymin
        self.f=f;self.x0=x0;self.y0=y0;self.w=w;self.h=h
        self.xmin=xmin;self.xmax=xmax;self.ymin=ymin;self.ymax=ymax
        f.rect(x0,y0,w,h,BG,4,GRID)
        f.line(x0,y0+h,x0+w,y0+h,INK,1.8);f.line(x0,y0,x0,y0+h,INK,1.8)
        f.txt(x0+w/2,y0+h+53,xlabel,15,INK,anchor='middle')
        f.txt(x0-13,y0-14,ylabel,13,MUTED)
    def xy(self,x,y):return (self.x0+(x-self.xmin)/(self.xmax-self.xmin)*self.w,
                                  self.y0+self.h-(y-self.ymin)/(self.ymax-self.ymin)*self.h)
    def ticks(self,xs,ys):
        for x,s in xs:
            px,_=self.xy(x,self.ymin);self.f.line(px,self.y0,px,self.y0+self.h,GRID,1)
            self.f.txt(px,self.y0+self.h+24,s,12,MUTED,anchor='middle')
        for y,s in ys:
            _,py=self.xy(self.xmin,y);self.f.line(self.x0,py,self.x0+self.w,py,GRID,1)
            self.f.txt(self.x0-12,py+4,s,12,MUTED,anchor='end')
    def curve(self,pts,col,width=3):self.f.poly([self.xy(*xy) for xy in pts],col,width)
    def dot(self,x,y,color=ORANGE,r=5):self.f.circle(*self.xy(x,y),r,color)
    def dotted(self,x,y,col=ORANGE):
        p1=self.xy(x,self.ymin);p2=self.xy(x,y);self.f.line(*p1,*p2,col,1.5,'4 6')



# The calculations implement specified experiments in content/chapters/241.md–250.md.
# No plotted values come from observational or clinical data.
import itertools
from collections import Counter


def ca_rule(state,rule):
    n=len(state)
    return [((rule>>((state[(i-1)%n]<<2)|(state[i]<<1)|state[(i+1)%n]))&1) for i in range(n)]


def movement184(state):
    n=len(state)
    return sum(state[i]*(1-state[(i+1)%n]) for i in range(n))


def build241():
    cur=[int(x) for x in '11110000'];rows=[];states=[]
    for t in range(6):
        flux=movement184(cur)
        states.append(cur[:]);rows.append((t,''.join(map(str,cur)),sum(cur),flux,*cur))
        nextstate=ca_rule(cur,184)
        assert all(nextstate[i]==cur[i]+cur[(i-1)%8]*(1-cur[i])-cur[i]*(1-cur[(i+1)%8]) for i in range(8))
        assert sum(nextstate)==4
        cur=nextstate
    assert [''.join(map(str,s)) for s in states]==['11110000','11101000','11010100','10101010','01010101','10101010']
    assert [r[3] for r in rows]==[1,2,3,4,4,4]
    assert ca_rule([int(z) for z in '00110011'],232)==[int(z) for z in '00110011']
    f=Figure(241,'Partículas que se desplazan sin desaparecer','Autómata elemental 184 · anillo periódico de 8 celdas · 4 partículas',
      'Seis generaciones exactas de la regla celular 184, conservación de cuatro partículas y números de movimientos 1, 2, 3, 4, 4 y 4. El borde derecho es vecino del izquierdo.')
    x0,y0=155,191;cw=78;ch=52
    f.txt(x0+4*cw,179,'Celda del anillo (índice 0–7)',14,INK,anchor='middle')
    for i in range(8):f.txt(x0+i*cw+cw/2,y0-2,str(i),13,MUTED,anchor='middle')
    for t,state in enumerate(states):
        y=y0+24+t*ch
        f.txt(x0-25,y+33,str(t),14,INK,anchor='end')
        for i,val in enumerate(state):
            f.rect(x0+i*cw,y,cw-5,ch-6,TEAL if val else LT,5,GRID)
            if val:f.circle(x0+i*cw+cw/2,y+23,10,'#ffffff')
        f.txt(x0+8*cw+24,y+33,str(rows[t][3])+' mov.',13,ORANGE)
    f.txt(78,365,'Tiempo',14,MUTED)
    f.note('Se conserva la cantidad, cambia la movilidad','Cuatro ocupaciones en todas las filas; movimientos posibles: 1 → 2 → 3 → 4.')
    return f.output(),csv_content(['t','estado_binario','n_particulas','movimientos_posibles',*[f'celda_{i}' for i in range(8)]],rows)


def threshold_step(state,threshold):
    n=len(state)
    return [int(bool(state[i]) or state[(i-1)%n]+state[(i+1)%n]>=threshold[i]) for i in range(n)]


def evolve_threshold(threshold,seed=0):
    state=[int(i==seed) for i in range(len(threshold))];trajectory=[state[:]]
    for _ in range(len(threshold)+1):
        new=threshold_step(state,threshold)
        if new==state:break
        assert all(a<=b for a,b in zip(state,new))
        state=new;trajectory.append(state[:])
    else:raise AssertionError('Irreversible threshold process did not converge')
    return trajectory


def build242():
    aa=[1,1,1,2,2,2];bb=[1,2,1,2,1,2]
    A=evolve_threshold(aa);B=evolve_threshold(bb)
    assert [sum(x) for x in A]==[1,2,3] and len(B)==1
    counts=Counter()
    details=[]
    for a,b in itertools.combinations(range(1,6),2):
        th=[2]*6;th[0]=th[a]=th[b]=1
        last=evolve_threshold(th)[-1];counts[sum(last)]+=1
        details.append((a,b,sum(last),''.join(map(str,last))))
    assert dict(counts)=={1:3,2:4,3:3}
    assert sum(k*v for k,v in counts.items())/10==2
    assert sum((k-2)**2*v for k,v in counts.items())/10==.6
    f=Figure(242,'Misma composición, distintos resultados','Seis agentes en anillo · 3 umbrales de uno y 3 umbrales de dos · semilla en 0',
      'La ubicación de los umbrales permite tres agentes activos en la configuración A y uno en B. En las diez asignaciones equiprobables, los tamaños finales 1, 2 y 3 aparecen 3, 4 y 3 veces.')
    for idx,(label,thr,trajectory,color) in enumerate([('A: umbrales agrupados',aa,A,TEAL),('B: umbrales alternos',bb,B,ORANGE)]):
        yy=191+idx*159
        f.txt(82,yy,label,17,INK,True)
        f.txt(82,yy+21,'Umbrales: '+', '.join(map(str,thr)),13,MUTED)
        for t in range(4):
            s=trajectory[min(t,len(trajectory)-1)]
            f.txt(100,yy+53+t*24,f't={t}',12,MUTED)
            for j,v in enumerate(s):
                xx=154+j*46
                f.rect(xx,yy+37+t*24,38,20,color if v else LT,3,GRID)
                f.txt(xx+19,yy+52+t*24,str(j) if v else '·',12,'#ffffff' if v else MUTED,anchor='middle')
    f.txt(703,182,'Diez distribuciones iniciales',18,INK,True)
    f.txt(703,204,'Distribución exacta del total activo',13,MUTED)
    for j,k in enumerate([1,2,3]):
        y=253+j*77
        f.txt(704,y+13,f'{k} activos',15,INK)
        f.rect(806,y-7,49*counts[k],31,[BLUE,TEAL,ORANGE][j],4)
        f.txt(806+49*counts[k]+12,y+13,f'{counts[k]}/10',15,INK,True)
    f.note('La media de umbrales no fija la cascada','En diez asignaciones exactas: E[activos]=2 y varianza=0,6; no hay error de Monte Carlo.')
    rows=[(a,b,k,status) for a,b,k,status in details]
    return f.output(),csv_content(['umbral_uno_posicion_1','umbral_uno_posicion_2','n_activos_final','estado_final'],rows)


def life_step(cells):
    neighbors=Counter((x+dx,y+dy) for x,y in cells for dx in (-1,0,1) for dy in (-1,0,1) if dx or dy)
    return {p for p,c in neighbors.items() if c==3 or (c==2 and p in cells)}


def build243():
    initial={(1,0),(2,1),(0,2),(1,2),(2,2)}
    cur=initial.copy();states=[]
    for t in range(5):
        states.append(cur);cur=life_step(cur)
    assert cur=={(x+1,y+1) for x,y in states[1]} # after 5 generations = offset of t=1
    assert states[-1]=={(x+1,y+1) for x,y in initial}
    assert all(len(s)==5 for s in states)
    assert life_step({(0,0),(1,0),(0,1),(1,1)})=={(0,0),(1,0),(0,1),(1,1)}
    assert life_step({(-1,0),(0,0),(1,0)})=={(0,-1),(0,0),(0,1)}
    f=Figure(243,'Un planeador: ciclo de forma y traslación','Juego de la Vida B3/S23 · generaciones 0 a 4 · coordenadas x a la derecha, y hacia abajo',
       'Cinco paneles muestran las cinco celdas vivas del planeador en cada generación. A la cuarta, la forma inicial reaparece desplazada una celda hacia la derecha y abajo.')
    cols=5;cell=34;xstart=82;panelW=195;top=232
    for t,state in enumerate(states):
        left=xstart+t*panelW
        f.txt(left+77,193,f't = {t}',17,INK,True,anchor='middle')
        for y in range(5):
            for x in range(5):
                f.rect(left+x*cell,top+y*cell,cell-2,cell-2,BLUE if (x,y) in state else LT,3,GRID)
        f.txt(left+77,top+5*cell+27,'5 células',13,MUTED,anchor='middle')
    f.txt(82,449,'El patrón conserva 5 células, pero las coordenadas y las relaciones temporales cambian.',14,MUTED)
    f.note('Traslación exacta, no una nueva criatura','G₄ = G₀ + (1,1); la dinámica es local y simultánea, no se impone ningún movimiento global.')
    return f.output(),csv_content(['generacion','x','y','viva'],[(t,x,y,1) for t,s in enumerate(states) for x,y in sorted(s)])


def build244():
    scenarios={}
    for kind in ['Sincrónica','Primero A','Primero B']:
        a,b=0,1;states=[(a,b)]
        for t in range(6):
            if kind=='Sincrónica':a,b=b,a
            elif kind=='Primero A':a=b;b=a
            else:b=a;a=b
            states.append((a,b))
        scenarios[kind]=states
    assert scenarios['Sincrónica']==[(0,1),(1,0),(0,1),(1,0),(0,1),(1,0),(0,1)]
    assert scenarios['Primero A'][-1]==(1,1) and scenarios['Primero B'][-1]==(0,0)
    f=Figure(244,'Las mismas reglas, tres calendarios incompatibles','Dos agentes que copian el estado actual del otro · condición inicial A=0, B=1',
      'Una actualización simultánea alterna indefinidamente 01 y 10. Si A copia primero se llega a 11; si B copia primero, a 00. No hay azar ni cambio de regla individual.')
    startx=178;dx=108
    for row,(title,color) in enumerate([('Sincrónica',TEAL),('A luego B',BLUE),('B luego A',ORANGE)]):
        seq=scenarios[['Sincrónica','Primero A','Primero B'][row]]
        y=213+row*118
        f.txt(84,y-14,title,16,INK,True)
        for t,(a,b) in enumerate(seq):
            x=startx+t*dx
            f.rect(x,y-28,72,56,LT,6,GRID)
            f.circle(x+22,y,10,color if a else '#ffffff');f.circle(x+50,y,10,color if b else '#ffffff')
            f.txt(x+36,y+47,f'{a}{b}',15,INK,anchor='middle')
            if row==0:f.txt(x+36,180,str(t),13,MUTED,anchor='middle')
            if t<6:f.line(x+73,y,x+dx-6,y,GRID,1.5)
    f.txt(startx+320,164,'Rondas completas desde t=0',13,MUTED,anchor='middle')
    f.note('La media oculta la dinámica','Sincronía: 1 activo y ciclo; orden A→B: 2 activos; orden B→A: 0 activos.')
    rows=[]
    for t in range(7):
        s,a,b=[scenarios[k][t] for k in ('Sincrónica','Primero A','Primero B')]
        rows.append((t,*s,sum(s),*a,sum(a),*b,sum(b)))
    return f.output(),csv_content(['ronda','sync_A','sync_B','sync_activos','A_primero_A','A_primero_B','A_primero_activos','B_primero_A','B_primero_B','B_primero_activos'],rows)


def satisfaction(state):
    n=len(state);out=[]
    for i,ch in enumerate(state):
        if ch=='.':continue
        ngh=[state[(i-1)%n],state[(i+1)%n]]
        occ=[x for x in ngh if x!='.']
        out.append((i,True if not occ else sum(x==ch for x in occ)/len(occ)>=.5))
    return dict(out)


def link_counts(state):
    occ=same=0
    for i in range(len(state)):
        a,b=state[i],state[(i+1)%len(state)]
        if a!='.' and b!='.':
            occ+=1;same+=int(a==b)
    return same,occ


def build245():
    states=['ABABABAB..','.BABABAB.A','..ABABABBA','A..BABABBA','A.B.ABABBA','AAB..BABBA','AA.B.BABBA','AABB..ABBA','AABB.A.BBA']
    move=[('inicio',-1,-1),('0→9',0,9),('1→8',1,8),('2→0',2,0),('3→2',3,2),('4→1',4,1),('2→3',2,3),('5→2',5,2),('6→5',6,5)]
    want_sat=[0,1,2,4,5,5,6,7,8]
    f=Figure(245,'Segregación local sin objetivo global','Anillo de 10 posiciones · 4 A, 4 B, 2 vacantes · satisfacción de vecindad ≥ 1/2',
      'Nueve estados exactos durante ocho movimientos de Schelling: la satisfacción pasa de cero a ocho y los contactos de igual tipo de cero entre siete a cuatro entre seis.')
    xx,yy,cw,ch=157,199,78,38
    rows=[]
    for t,state in enumerate(states):
        assert len(state)==10 and state.count('A')==state.count('B')==4 and state.count('.')==2
        sat=sum(satisfaction(state).values());assert sat==want_sat[t],(t,sat)
        same,occ=link_counts(state)
        rows.append((t,move[t][0],state,sat,same,occ,same/occ if occ else 0))
        f.txt(82,yy+t*ch+24,str(t),13,INK,True)
        for i,char in enumerate(state):
            f.rect(xx+i*cw,yy+t*ch,cw-5,ch-4,BLUE if char=='A' else ORANGE if char=='B' else LT,3,GRID)
            f.txt(xx+i*cw+cw/2-2,yy+t*ch+24,char,15,'#ffffff' if char!='.' else MUTED,True,anchor='middle')
        f.txt(xx+10*cw+12,yy+t*ch+23,str(sat)+'/8',13,TEAL,True)
    for i in range(10):f.txt(xx+i*cw+cw/2-2,179,str(i),12,MUTED,anchor='middle')
    f.note('El criterio de aceptación no maximiza segregación','Vínculos iguales: 0/7 al inicio, 4/6 al final; todos satisfechos después de 8 traslados.')
    return f.output(),csv_content(['evento','traslado','configuracion','agentes_satisfechos','vinculos_iguales','vinculos_ocupados','fraccion_iguales'],rows)


def circular_mean(degrees):
    sx=sum(math.cos(math.radians(x)) for x in degrees)
    sy=sum(math.sin(math.radians(x)) for x in degrees)
    return math.degrees(math.atan2(sy,sx))%360, math.hypot(sx,sy)/len(degrees)


def build246():
    angles0=[0,90,180];pos=[(0.,0.),(1.,0.),(2.,0.)];after=[45,90,135]
    positions_next=[(.2,0.),(1.,.2),(1.8,0.)]
    for i in range(3):
        observed=[angles0[j] for j in range(3) if abs(i-j)<=1]
        assert math.isclose(circular_mean(observed)[0],after[i],abs_tol=1e-12)
    p0=circular_mean(angles0)[1];p1=circular_mean(after)[1]
    assert math.isclose(p0,1/3,rel_tol=1e-12) and math.isclose(p1,(1+2**.5)/3,rel_tol=1e-12)
    assert min(abs(circular_mean([350,0,10])[0]),abs(circular_mean([350,0,10])[0]-360))<1e-12
    f=Figure(246,'La orientación común se obtiene sobre un círculo','Tres agentes · radio 1,1 · orientaciones 0°, 90° y 180° · rapidez 0,2',
      'Los ángulos después de una actualización local son 45°, 90° y 135°. El desplazamiento simultáneo utiliza las velocidades anteriores y la polarización aumenta de un tercio a aproximadamente 0,804738.')
    sx,sy=153,456;spacing=210;factor=100
    f.txt(89,187,'Vectores iniciales (gris) y nuevos (color)',17,INK,True)
    for i in range(3):
        x=sx+i*spacing;y=sy
        f.circle(x,y,13,BLUE)
        for angle,color,offset in [(angles0[i],MUTED,0),(after[i],TEAL,0)]:
            th=math.radians(angle)
            ex=x+90*math.cos(th);ey=y-90*math.sin(th)
            f.line(x,y,ex,ey,color,3.5)
            f.circle(ex,ey,4,color)
        f.txt(x,y+36,f'Agente {i}',14,INK,True,anchor='middle')
        f.txt(x,y+57,f'{angles0[i]}° → {after[i]}°',14,MUTED,anchor='middle')
    f.rect(747,221,286,277,LT,7,GRID)
    f.txt(769,251,'Polarización vectorial',18,INK,True)
    f.txt(771,301,f'Inicial: {fmt(p0,5)}',17,BLUE)
    f.rect(771,320,p0*217,20,BLUE,3)
    f.txt(771,377,f'Tras giro: {fmt(p1,5)}',17,TEAL)
    f.rect(771,396,p1*217,20,TEAL,3)
    f.txt(770,459,'Media circular (350°, 0°, 10°): 0°',13,MUTED)
    f.note('Mismo paso temporal, lecturas no mezcladas','Dirección nueva calculada desde posiciones antiguas; luego se desplaza con velocidad antigua.')
    rows=[(i,pos[i][0],pos[i][1],angles0[i],after[i],positions_next[i][0],positions_next[i][1],p0,p1) for i in range(3)]
    return f.output(),csv_content(['agente','x_inicial','y_inicial','angulo_inicial_grados','angulo_nuevo_grados','x_tras_paso','y_tras_paso','P_inicial','P_tras_cambio_orientaciones'],rows)


def allocations(rule):
    demands={'A':2,'B':1,'C':2};resource=4.;result=[]
    for t in range(3):
        alloc={k:0. for k in 'ABC'}
        if rule=='Proporcional':
            mul=min(1,resource/sum(demands.values()))
            for k in demands:alloc[k]=demands[k]*mul
        else:
            priority={'Fija':['A','B','C'],'Rotatoria':[['A','B','C'],['B','C','A'],['C','A','B']][t]}[rule]
            remaining=resource
            for k in priority:
                alloc[k]=min(demands[k],remaining);remaining-=alloc[k]
        used=sum(alloc.values());assert used<=resource+1e-12
        new=min(4,resource-used+2)
        result.append((rule,t+1,resource,alloc['A'],alloc['B'],alloc['C'],used,new))
        resource=new
    return result


def build247():
    rows=sum((allocations(k) for k in ('Fija','Rotatoria','Proporcional')),[])
    totals={name:[sum(r[3+i] for r in rows if r[0]==name) for i in range(3)] for name in ('Fija','Rotatoria','Proporcional')}
    for v in totals.values():assert math.isclose(sum(v),8.,abs_tol=1e-12)
    assert totals['Fija']==[6,1,1] and totals['Rotatoria']==[2,2,4]
    assert all(math.isclose(x,y) for x,y in zip(totals['Proporcional'],[3.2,1.6,3.2]))
    f=Figure(247,'Misma extracción, desigual reparto','Tres agentes con demandas 2, 1 y 2 · depósito inicial 4 · reposición 2 por período',
       'Barras apiladas muestran asignaciones acumuladas A, B y C tras tres períodos: prioridad fija 6,1,1; rotatoria 2,2,4; proporcional 3,2,1,6,3,2. Todas extraen ocho unidades.')
    x0=144;barw=177;base=506;scale=34
    for j,(name,series) in enumerate(totals.items()):
        x=x0+j*322
        f.txt(x+barw/2,203,name,18,INK,True,anchor='middle')
        y=base
        for i,value in enumerate(series):
            hgt=value*scale;y-=hgt
            f.rect(x,y,barw,hgt,[BLUE,TEAL,ORANGE][i],3)
            if value>0.8:f.txt(x+barw/2,y+hgt/2+6,f'{"ABC"[i]}: {fmt(value,2).replace(".",",")}',15,'#ffffff',True,anchor='middle')
        f.txt(x+barw/2,540,'8 unidades',14,INK,anchor='middle')
    for j,(name,color) in enumerate([('Agente A',BLUE),('Agente B',TEAL),('Agente C',ORANGE)]):
        x=108+j*235
        f.rect(x,166,25,10,color,3)
        f.txt(x+35,176,name,14,MUTED)
    f.note('Invariante contable, no invariante distributiva','Todas las reglas: 8 unidades extraídas en 3 períodos, con repartos diferentes.')
    return f.output(),csv_content(['regla','periodo','recurso_inicio','asignado_A','asignado_B','asignado_C','extraido_total','recurso_inicio_siguiente'],rows)


def beta_pdf(x,a,b):
    if x<=0 or x>=1:return 0.
    lognorm=math.lgamma(a+b)-math.lgamma(a)-math.lgamma(b)
    return math.exp(lognorm+(a-1)*math.log(x)+(b-1)*math.log1p(-x))


def build248():
    rows=[]
    for i in range(1,200):
        p=i/200
        exact=beta_pdf(p,8,4)
        tolerant=(beta_pdf(p,7,5)+exact+beta_pdf(p,9,3))/3
        rows.append((p,exact,tolerant,1.,1/11,3/11))
    assert math.isclose(8/12,2/3)
    from fractions import Fraction
    ev=Fraction(2,117);tv=Fraction(5,234)
    assert ev<tv and math.isclose(float(ev),0.017094017094,rel_tol=1e-8)
    assert math.isclose(float(tv),0.0213675213675,rel_tol=1e-8)
    f=Figure(248,'ABC: conservar la media no conserva la posterior','10 agentes independientes · 7 activaciones observadas · previa uniforme en p',
      'La posterior exacta Beta(8,4) y la mezcla ABC con tolerancia de una activación tienen la misma media dos tercios, pero distinta varianza, 2/117 frente a 5/234.')
    a=Axes(f,xmin=0,xmax=1,ymin=0,ymax=4,xlabel='Probabilidad de activación p',ylabel='Densidad posterior (1/unidad p)')
    a.ticks([(i/10,f'{i/10:.1f}'.replace('.',',')) for i in range(11)],[(i,str(i)) for i in range(5)])
    a.curve([(p,e) for p,e,*_ in rows],TEAL,3.5)
    a.curve([(p,t) for p,_,t,*_ in rows],ORANGE,3.5)
    a.curve([(0,1),(1,1)],MUTED,1.8)
    f.legend([('Tolerancia 0: Beta(8,4)',TEAL),('Tolerancia 1: mezcla',ORANGE),('Previa uniforme',MUTED)],716,166)
    f.note('Mismo promedio (2/3), diferente dispersión','Varianzas: 2/117 y 5/234; aceptación ABC: 1/11 y 3/11.')
    return f.output(),csv_content(['p','densidad_exacta_eps0','densidad_mezcla_eps1','densidad_previa','aceptacion_eps0','aceptacion_eps1'],rows)


def build249():
    A=[1,1,1,2,2,2];B=[1,2,1,2,1,2]
    ta=evolve_threshold(A);tb=evolve_threshold(B)
    vals=[]
    for t in range(4):
        sa=ta[min(t,len(ta)-1)];sb=tb[min(t,len(tb)-1)]
        vals.append((t,sum(sa),sum(sb),''.join(map(str,sa)),''.join(map(str,sb)),6,6))
    assert [x[1] for x in vals]==[1,2,3,3] and [x[2] for x in vals]==[1,1,1,1]
    f=Figure(249,'ODD: una especificación que genera pruebas','Siete componentes documentales · un mismo anillo de 6 agentes y dos asignaciones de umbrales',
      'La ficha ODD define propósito, estados, calendario, diseño, inicialización, entradas y submodelos. Su ejecución reproducible distingue activación A de 1 a 3 y B fija en 1.')
    odd=['1. Propósito y patrones','2. Entidades y escalas','3. Procesos y calendario','4. Conceptos de diseño','5. Inicialización','6. Datos externos','7. Submodelos']
    f.txt(87,180,'Componentes de ODD',17,INK,True)
    for i,label in enumerate(odd):
        y=209+i*46
        f.rect(84,y-20,473,38,LT,6,GRID)
        f.txt(98,y+4,label,14,INK)
        f.circle(538,y-1,7,TEAL)
    f.txt(651,184,'Consecuencia reproducible',18,INK,True)
    chart=Axes(f,x0=665,y0=233,w=355,h=239,xmin=0,xmax=3,ymin=0,ymax=3.5,xlabel='Ronda',ylabel='Agentes activos')
    chart.ticks([(0,'0'),(1,'1'),(2,'2'),(3,'3')],[(0,'0'),(1,'1'),(2,'2'),(3,'3')])
    chart.curve([(v[0],v[1]) for v in vals],TEAL,3.5)
    chart.curve([(v[0],v[2]) for v in vals],ORANGE,3.5)
    for t,xa,xb,*_ in vals:chart.dot(t,xa,TEAL,4);chart.dot(t,xb,ORANGE,4)
    f.legend([('Umbrales A: 1,1,1,2,2,2',TEAL),('Umbrales B: 1,2,1,2,1,2',ORANGE)],667,505)
    f.note('Pruebas independientes de la visualización','6 nodos, 6 enlaces, 1 semilla, actualización sincrónica; A→3 y B→1.')
    return f.output(),csv_content(['ronda','activos_A','activos_B','estado_A','estado_B','n_nodos','n_aristas'],vals)


def local_lookup_counts():
    items=[]
    for left,right in itertools.combinations(range(1,6),2):
        th=[2]*6;th[0]=th[left]=th[right]=1
        traj=evolve_threshold(th)
        initial=traj[0];first=traj[min(1,len(traj)-1)];final=traj[-1]
        # The stored-result procedure jumps to the final activation set at t=1.
        items.append((left,right,sum(initial),sum(first),sum(final),sum(final),int(first!=final)))
    return items


def build250():
    cases=local_lookup_counts()
    assert len(cases)==10 and sum(x[-1] for x in cases)==2
    final_counts=Counter(x[4] for x in cases)
    assert final_counts=={1:3,2:4,3:3}
    rprob=[]
    for m in range(1,31):
        p=1-(.8)**m
        rprob.append((m,p,1-p,0.2,0.8,10,2))
    assert math.isclose(rprob[9][1],1-.8**10,abs_tol=1e-14)
    f=Figure(250,'Dos mecanismos, la misma imagen final','Anillo de seis agentes · 10 asignaciones uniformes de umbrales · observar el primer paso',
      'El modelo local L y el modelo de tabla I coinciden en 10 de 10 estados finales; difieren tras el primer paso en 2 de 10. La probabilidad de incluir al menos un caso discriminante tras m pruebas es 1−0,8 elevado a m.')
    a=Axes(f,xmin=0,xmax=30,ymin=0,ymax=1.05,xlabel='Condiciones muestreadas con reemplazo (m)',ylabel='P(detectar alguna discrepancia)')
    a.ticks([(x,str(x)) for x in (0,5,10,15,20,25,30)],[(x,fmt(x,1)) for x in (0,.2,.4,.6,.8,1)])
    a.curve([(0,0)]+[(m,p) for m,p,*_ in rprob],TEAL,3.8)
    a.dot(10,rprob[9][1],ORANGE,7)
    f.txt(555,303,'m = 10: 89,26 %',20,INK,True)
    f.txt(555,327,'Estados finales: coincidencia perfecta',14,MUTED)
    f.txt(555,350,'Primer paso: discrepancia 2/10',14,MUTED)
    f.note('La prueba discrimina predicciones, no mecanismos verdaderos','Final: 10/10 coincidencias; paso 1: 8/10; P(detección en 10)=0,892626.')
    return f.output(),csv_content(['numero_experimentos','prob_al_menos_una_discrepancia','prob_ninguna_discrepancia','fraccion_casos_discriminantes','fraccion_casos_iguales_primer_paso','total_condiciones','condiciones_discriminantes'],rprob)


CASES={n:globals()[f'build{n}'] for n in range(241,251)}

def generated():
    result={}
    for n,fn in CASES.items():
        svg,data=fn()
        result[f'figures/complexa-c{n}-fig01.svg']=svg
        result[f'repro/c241-250/c{n}.csv']=data
    return result

def bundle(files):
    buffer=io.BytesIO()
    with ZipFile(buffer,'w',compression=ZIP_STORED) as archive:
        for source,content in sorted(files.items()):
            name=source.removeprefix('figures/').removeprefix('repro/c241-250/')
            info=ZipInfo(name,(2026,1,1,0,0,0))
            info.compress_type=ZIP_STORED;info.create_system=3;info.external_attr=0o644<<16
            archive.writestr(info,content)
    return buffer.getvalue()

def run(check:bool,root:Path):
    files=generated()
    import xml.etree.ElementTree as ET
    assert len(files)==20
    for n in CASES:
        svg=files[f'figures/complexa-c{n}-fig01.svg']
        root_svg=ET.fromstring(svg)
        assert root_svg.attrib.get('role')=='img'
        assert any(c.tag.endswith('title') for c in root_svg)
        assert any(c.tag.endswith('desc') for c in root_svg)
        dat=list(csv.reader(io.StringIO(files[f'repro/c241-250/c{n}.csv'].decode('utf-8'))))
        assert len(dat)>=4 and len(dat[0])>=3, n
        assert all(len(x)==len(dat[0]) for x in dat[1:]),n
    extras={'repro/c241-250/make_figures.py':BASE.joinpath('make_figures.py').read_bytes(),
            'repro/c241-250/README.md':BASE.joinpath('README.md').read_bytes()}
    zipped=bundle({**files,**extras})
    zp=root/'zip-data/complexa-c241-c250-reproducible.zip.b64'
    import base64
    if check:
        for path,expected in files.items():
            actual=root/path
            assert actual.is_file() and actual.read_bytes()==expected,f'Not reproducible: {actual}'
        assert zp.is_file() and base64.b64decode(zp.read_text().strip())==zipped,'Archive differs from generator'
        print('PASS C241–C250: 10 SVG, 10 CSV, 1 ZIP byte-exact, numerical model tests')
    else:
        for path,data in files.items():
            target=root/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_bytes(data)
        zp.parent.mkdir(parents=True,exist_ok=True)
        zp.write_text(base64.b64encode(zipped).decode('ascii')+'\n')
        direct=root/'complexa-c241-c250-reproducible.zip'
        direct.write_bytes(zipped)
        print(f'PASS C241–C250: {len(files)} source files, ZIP {len(zipped)} bytes, SHA-256 {hashlib.sha256(zipped).hexdigest()}')

if __name__=='__main__':
    ap=argparse.ArgumentParser();g=ap.add_mutually_exclusive_group(required=True)
    g.add_argument('--out',type=Path);g.add_argument('--check',type=Path)
    args=ap.parse_args()
    run(args.check is not None,args.check or args.out)
