tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1543Shell 203Rust 109Markdown 993728 Definiciones

⎇ 8b1673d689b25a7854e74067da850d4a8f8a09ea scripts/test-atuq-ia.py Python · 235 líneas · 12.4 KB Historia Quién tocó qué crudo

ia: el modelo de chat, pineado — Qwen2.5-1.5B-Instruct Q4_K_M (Apache-2.0) 54bf3e810 · Sergio ·

1#!/usr/bin/env python3
2"""¿La barra lateral le pregunta a un modelo que corre EN ESTA MÁQUINA? (SDD 26 §6.7)
3
4    python3 scripts/test-atuq-ia.py                      # sale una respuesta generada acá
5    python3 scripts/test-atuq-ia.py --negative-control    # sin modelo: lo DICE, no inventa
6    python3 scripts/test-atuq-ia.py --image-model         # con el modelo DE LA IMAGEN (1 GiB, lento)
7
8── QUÉ SE EJERCITA, DE PUNTA A PUNTA ─────────────────────────────────────────────────────────
9El navegador abre el panel de la extensión con una pregunta (`panel.html?q=…`), el panel se la pasa
10al fondo, el fondo al host por native messaging, el host levanta un `llama-server` —que NO estaba
11corriendo: no hay servicio de IA en la imagen— y la respuesta vuelve por el mismo camino. La
12inferencia es REAL: tokens generados por `llama-cpp` del corpus, contra un modelo pineado por
13sha256, dentro de la jaula y sin red.
14
15El modelo de prueba es el MISMO que pinea `scripts/test-llama-cpp.py`, importado de ahí para que el
16sha256 viva en un solo sitio: `stories260K`, 1,1 MB, que dice tonterías con gramática — que es
17exactamente lo que hace falta para comprobar el CAMINO sin pinear un modelo de producción, que es
18otra decisión (y otros cientos de megas).
19
20── EL CONTROL NEGATIVO ───────────────────────────────────────────────────────────────────────
21`--negative-control` borra el modelo de la jaula y exige que el panel muestre **la causa**. Una IA
22que ante un fallo contesta algo genérico es peor que una que no contesta: el usuario no puede
23distinguir «no hay modelo» de «el modelo dijo eso».
24
25REQUISITOS: el rootfs mínimo de `atuq-nested.sh`, y `atuq` + `puriy-costura` + `llama-cpp` sellados.
26"""
27import importlib.util
28import os
29import shutil
30import subprocess
31import sys
32import tempfile
33
34ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
36ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
37HAMMER = os.path.join(ROOT, "target/release/takana")
38STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
39# El path de PRODUCCIÓN del modelo, el mismo que la constante del host. Se mide éste y no la
40# escotilla de pruebas: una escotilla mide el código, no el contrato con la imagen.
41MODELO_EN_IMAGEN = "/usr/share/takana/ia/modelo.gguf"
42PREGUNTA = "Once upon a time"
43
44
45def fatal(msg):
46    print(f"✗ {msg}", file=sys.stderr)
47    raise SystemExit(1)
48
49
50def artefacto(receta, nombre, escotilla=None):
51    forzado = os.environ.get(escotilla) if escotilla else None
52    if forzado:
53        if not os.path.isdir(forzado):
54            fatal(f"{escotilla}={forzado} no existe")
55        print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
56        return forzado
57    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
58                       cwd=ROOT, capture_output=True, text=True)
59    if r.returncode != 0:
60        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
61    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
62    d = os.path.join(STORE, f"{h}-{nombre}")
63    if not os.path.isdir(d):
64        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
65    if not os.listdir(d):
66        fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre")
67    return d
68
69
70def modelo_de_prueba():
71    """El fetcher del guardián del motor, reusado: así el sha256 del modelo vive en UN sitio."""
72    ruta = os.path.join(ROOT, "scripts", "test-llama-cpp.py")
73    spec = importlib.util.spec_from_file_location("guardian_llama", ruta)
74    mod = importlib.util.module_from_spec(spec)
75    spec.loader.exec_module(mod)      # tiene guarda `__main__`: importarlo no corre nada
76    return str(mod.bajar_modelo())
77
78
79def correr(atuq, costura, llama, capa_modelo, salida, url, negativo, tope=90):
80    borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true"
81    # El modelo de la imagen llega como CAPA (su artefacto ya lo pone en el path de producción); el
82    # de juguete se copia, que son 1,1 MB.
83    poner_modelo = "true" if capa_modelo else f"cp /salida/modelo.gguf {MODELO_EN_IMAGEN}"
84    guion = f"""
85    mkdir -p /root /salida/hogar /salida/run {os.path.dirname(MODELO_EN_IMAGEN)}
86    [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
87    export HOME=/salida/hogar XDG_RUNTIME_DIR=/salida/run
88    {poner_modelo}
89    {borrar}
90    timeout {tope} /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \
91        > /salida/consola.log 2>&1
92    # ¿Se fue el motor con el navegador? Se cuenta ACÁ ADENTRO, con el namespace de PIDs todavía
93    # vivo: desde el hub no se vería nada —`--unshare-pid` se lleva todo al cerrar la jaula— y esa
94    # limpieza del arnés taparía justo la promesa que hay que medir. Medio giga de modelo que
95    # sobrevive al navegador no se nota hasta que la máquina se queda sin RAM.
96    # Se cuenta leyendo /proc a mano, y no con `pgrep -c`: busybox NO tiene esa opción, y el
97    # `|| echo 0` que parecía prudente convertía el error en un CERO — o sea en un veredicto verde.
98    # Medido: con un motor vivo a propósito, el guardián pasaba igual.
99    # Se espera a que SE MUERA, hasta 15 s, y se anota cuánto tardó. Matar a un proceso con un
100    # giga de modelo mapeado no es instantáneo, y contar en el instante exacto del cierre confundía
101    # «tardando en morir» con «fuga»: con el modelo de juguete daba 0 y con el de la imagen daba 1.
102    esperado=0
103    while [ $esperado -lt 15 ]; do
104        n=0
105        for p in /proc/[0-9]*; do
106            [ "$(cat $p/comm 2>/dev/null)" = "llama-server" ] && n=$((n+1))
107        done
108        [ "$n" = "0" ] && break
109        esperado=$((esperado+1))
110        sleep 1
111    done
112    echo "$n $esperado" > /salida/motores-vivos
113    exit 0
114    """
115    subprocess.run([
116        "bwrap",
117        "--overlay-src", ALPINE, "--overlay-src", RFS,
118        "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", llama,
119        *(["--overlay-src", capa_modelo] if capa_modelo else []),
120        "--tmp-overlay", "/",
121        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
122        "--tmpfs", "/tmp", "--unshare-pid", "--unshare-net",
123        "--bind", salida, "/salida",
124        "--uid", "0", "--gid", "0",
125        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
126        "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
127        "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
128        "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
129        "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
130        "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
131        "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
132        "sh", "-c", guion,
133    ], check=False)
134    log = open(os.path.join(salida, "consola.log"), errors="replace").read()
135    vivos = "?"
136    try:
137        vivos = open(os.path.join(salida, "motores-vivos"), errors="replace").read().strip()
138    except OSError:
139        pass
140    return [l[len("IA "):] for l in log.splitlines() if l.startswith("IA ")], log, vivos
141
142
143def main():
144    negativo = "--negative-control" in sys.argv[1:]
145    # Por defecto se mide con el modelo de juguete: 1,1 MB, instantáneo, y alcanza para certificar la
146    # CADENA. Con `--image-model` se mide el de la imagen — la misma cadena, pero contestando de
147    # verdad; cuesta 1 GiB de overlay y unos segundos de generación, y por eso no es el default.
148    de_la_imagen = "--image-model" in sys.argv[1:]
149    if negativo:
150        print("== MODO CONTROL NEGATIVO: sin modelo en la imagen; el panel tiene que DECIR la causa")
151    if not os.path.isdir(RFS):
152        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
153    atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
154    costura = artefacto("puriy-costura", "puriy-costura")
155    llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR")
156    print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}"
157          f"\n            {os.path.basename(llama)}")
158    capa_modelo = None
159    modelo = None
160    if de_la_imagen:
161        # Llega como CAPA: su artefacto ya instala el modelo en el path de producción. Copiarlo
162        # sería mover 1 GiB a un tmpfs — lento, y la primera vez llenó /tmp y el guardián murió con
163        # un ENOSPC que no tenía nada que ver con lo que mide.
164        capa_modelo = artefacto("ia-modelo-chat", "ia-modelo-chat")
165        print(f"modelo: el de la imagen — {os.path.basename(capa_modelo)}")
166    else:
167        modelo = modelo_de_prueba()
168        print(f"modelo de prueba: {os.path.basename(modelo)}")
169
170    tmp = tempfile.mkdtemp(prefix="atuq-ia-")
171    salida = os.path.join(tmp, "salida")
172    os.makedirs(os.path.join(salida, "perfil"))
173    if modelo:
174        shutil.copy(modelo, os.path.join(salida, "modelo.gguf"))
175    try:
176        with open(os.path.join(salida, "perfil", "user.js"), "w") as fh:
177            fh.write('user_pref("browser.dom.window.dump.enabled", true);\n')
178            fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n')
179
180        # Primera corrida: descubrir la URL base que Gecko le asignó a la extensión (es por perfil).
181        lineas, log, _ = correr(atuq, costura, llama, capa_modelo, salida, "about:blank", negativo)
182        base = next((l[len("BASE "):].strip() for l in lineas if l.startswith("BASE ")), None)
183        if not base:
184            destino = os.path.join(ROOT, "work", "atuq-ia.log")
185            os.makedirs(os.path.dirname(destino), exist_ok=True)
186            shutil.copy(os.path.join(salida, "consola.log"), destino)
187            fatal(f"la extensión no dijo su URL base — log en {destino}")
188        print(f"\nextensión en {base}")
189
190        panel = f"{base}panel.html?q={PREGUNTA.replace(' ', '%20')}"
191        # 512 tokens a ~20 tok/s son ~25 s, más la carga del modelo: el tope se acompaña solo.
192        lineas, log, motores = correr(atuq, costura, llama, capa_modelo, salida, panel, negativo,
193                                      tope=180 if de_la_imagen else 90)
194        for l in lineas:
195            print("   " + l)
196
197        errores = [l for l in lineas if l.startswith("ERROR ")]
198        if negativo:
199            if not errores:
200                fatal("sin modelo no dijo nada: el panel se habría quedado pensando para siempre")
201            if "no hay modelo" not in errores[0]:
202                fatal(f"la causa no nombra lo que falta: {errores[0]}")
203            if any(l.startswith("RESPUESTA ") for l in lineas):
204                fatal("sin modelo CONTESTÓ igual: eso es inventar una respuesta")
205            print("\n✓ control negativo: sin modelo lo dice, nombrando lo que falta")
206            print("✓ y no inventa una respuesta, que es el único fallo inaceptable en esto")
207            return 0
208
209        if errores:
210            fatal(f"el host falló: {errores[0]}")
211        respuestas = [l for l in lineas if l.startswith("RESPUESTA ")]
212        if not respuestas:
213            fatal("no volvió ninguna respuesta del modelo")
214        r = respuestas[0]
215        if f"modelo={MODELO_EN_IMAGEN}" not in r:
216            fatal(f"la respuesta no sale del modelo de la imagen: {r}")
217        # Un `texto=""` es lo que devolvería un camino que «funciona» sin generar nada.
218        texto = r.split("texto=", 1)[1] if "texto=" in r else ""
219        if len(texto.strip(' "')) < 2:
220            fatal(f"la respuesta vino VACÍA: {r}")
221        quedaron, esperado = (motores.split() + ["?"])[:2]
222        if quedaron != "0":
223            fatal(f"quedaron {quedaron} `llama-server` vivos {esperado}s después de cerrarse el"
224                  " navegador — el motor tiene que irse con él (§6.7): medio giga de modelo huérfano"
225                  " no se ve hasta que la máquina se queda sin RAM")
226        print("\n✓ el navegador preguntó y un modelo de ESTA máquina contestó")
227        print("✓ y nadie levantó el servidor: no hay servicio de IA en la imagen — lo levantó el host")
228        print("✓ la jaula corre con `--unshare-net`: no hubo red para consultar a nadie más")
229        print(f"✓ y el motor se fue con el navegador: 0 `llama-server` vivos ({esperado}s después)")
230        return 0
231    finally:
232        shutil.rmtree(tmp, ignore_errors=True)
233
234
235sys.exit(main())