diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md index 5dcbcdae..a4488b33 100644 --- a/docs/26-atuq-envoltorio-gecko.md +++ b/docs/26-atuq-envoltorio-gecko.md @@ -1004,11 +1004,40 @@ intento de romperlo **tampoco rompía nada**: el motor de mentira moría al hace socket estaba en `/salida`, que se comparte entre las dos corridas; con el socket en `/tmp` —tmpfs nuevo por corrida— la rotura rompe. -**Lo que queda es UNA decisión: qué modelo se pinea.** No es trabajo pendiente: la cadena funciona y -el modelo entra por sha256 como el perfil de PGO de firefox. Son dos —uno de chat para esto y uno de -embeddings para el §6.3— y las dos preguntas que las gobiernan son el tamaño de la imagen y la -licencia. Hasta que se decidan, la imagen no trae modelo y la barra lateral **lo dice**: -`no hay modelo en /usr/share/takana/ia/modelo.gguf: esta imagen no trae ninguno pineado`. +**El modelo de chat, PINEADO (2026-09-11).** `recipes/ia-modelo-chat.toml` ⇒ **Qwen2.5-1.5B-Instruct +Q4_K_M**, 1.117.320.736 bytes. Elegido por tres cosas y las tres discutibles cambiando un sha256: +**Apache-2.0** (lo que una distro puede shipear sin letra chica, al revés que Llama-3.2 o Gemma), +**habla español** —medido antes de pinearlo: se le preguntó qué es una distribución de GNU/Linux y +contestó dos frases correctas— y **corre en CPU**: 20,1 tokens/s en el hub sin GPU. + +El objeto pineado es un **tar que envuelve el `.gguf`**, publicado en el mirror de fuentes y servido +por sha256, porque takana extrae toda fuente con `tar` y un GGUF pelado no es un tar. Es el camino de +`firefox-pgo-profile`. La procedencia queda escrita en la receta con **el sha256 del GGUF de +upstream** (el `lfs.oid` que publica HuggingFace, verificado al bajarlo), no sólo el del tar nuestro. +Round-trip verificado como manda el ADR 0013: apartados el caché local y el artefacto, `takana build` +lo **bajó del mirror** y selló el mismo `ArtifactHash`. + +La receta trae guardián propio, porque el modo de fallo es callado: un fichero truncado o un HTML de +error renombrado a `.gguf` se instala igual y sella en verde. Se comprueban el mágico `GGUF` y el +tamaño. + +⚠ **Y el modelo de verdad destapó una carrera en el guardián del §6.7.bis.** El censo de motores +contaba **en el instante** del cierre: con el modelo de juguete daba 0 y con el de la imagen daba 1 — +y eso se lee como fuga cuando lo que pasa es que matar un proceso con un giga mapeado tarda ~1 s. +Ahora el censo **espera hasta 15 s** y anota cuánto tardó: `0 llama-server vivos (1s después)`. La +rotura a propósito sigue fallando, ahora con el tiempo a la vista. + +`scripts/test-atuq-ia.py --image-model` corre la cadena entera con el modelo de la imagen (llega como +**capa**, no copiado: mover 1 GiB a un tmpfs llenó `/tmp` y mató al guardián con un ENOSPC que no +tenía nada que ver con lo que mide). El default sigue siendo el modelo de juguete, que certifica la +misma cadena en un segundo. + +**Lo que queda decidir: qué imágenes lo llevan.** `llama-cpp` (199 M) y el modelo (1,04 GiB) suman +~1,25 GiB a cada perfil donde se declaren, y `atuq` está en cuatro escritorios. Sigue sin declararse +en ninguno: una imagen no crece 1,25 G por decisión de un commit. + +**Y falta el de embeddings** para la mitad semántica del §6.3 (multilingual-e5-small, decidido pero +no pineado todavía). ### 6.8 Proxy por contenedor — HECHO (v0.5, 2026-09-06) diff --git a/recipes/atuq/README.md b/recipes/atuq/README.md index 7976d61e..19c8dd12 100644 --- a/recipes/atuq/README.md +++ b/recipes/atuq/README.md @@ -262,8 +262,10 @@ no puede distinguir «no hay modelo» de «el modelo dijo eso». comprueba además que al cerrarse el navegador queden **cero** `llama-server`. Control negativo: sin modelo, la causa aparece y **no hay respuesta inventada**. -⚠ **La imagen todavía no trae modelo**, y es una decisión pendiente (tamaño y licencia), no trabajo: -ver el §6.7 del SDD 26. +El modelo ya está elegido y pineado: **Qwen2.5-1.5B-Instruct Q4_K_M** (`recipes/ia-modelo-chat.toml`, +Apache-2.0, 1,04 GiB, 20,1 tok/s en CPU, habla español — las tres cosas medidas antes de pinearlo). +Lo que sigue sin decidirse es **en qué imágenes se declara**: con el motor son ~1,25 GiB por perfil. +Mientras no se declare, la imagen no lo trae y el panel lo dice. ## `foco` — el navegador MUESTRA el foco, y no tiene con qué apagarlo (§6.5) diff --git a/recipes/ia-modelo-chat.toml b/recipes/ia-modelo-chat.toml new file mode 100644 index 00000000..c02e8d7e --- /dev/null +++ b/recipes/ia-modelo-chat.toml @@ -0,0 +1,70 @@ +# ia-modelo-chat — el modelo que contesta en la barra lateral de `atuq` (SDD 26 §6.7). +# +# ══ QUÉ ES ═════════════════════════════════════════════════════════════════════════════════════ +# **Qwen2.5-1.5B-Instruct, cuantizado Q4_K_M**: 1.117.320.736 bytes de GGUF. Es el modelo de chat de +# la distro, y entra como los pesos entran en cualquier lado — **fuente pineada por sha256**, no un +# build que lo produzca. Nadie entrena nada acá. +# +# ══ POR QUÉ ÉSTE ═══════════════════════════════════════════════════════════════════════════════ +# Tres cosas, y las tres se pueden discutir cambiando un sha256: +# +# · **licencia Apache-2.0**, que es lo que una distro puede shipear sin letra chica. Las +# alternativas obvias (Llama-3.2, Gemma) traen licencias propias con restricciones de uso, y eso +# en una imagen que se distribuye no es un detalle; +# · **habla español**, que es el idioma de quien va a usar esto. Medido antes de pinearlo, no +# supuesto: se le preguntó qué es una distribución de GNU/Linux y contestó dos frases correctas +# en español; +# · **entra en la imagen y corre en CPU**: 1,04 GiB y **20,1 tokens/s** medidos en el hub sin GPU +# (89 tokens en 4,4 s). Un modelo de 7B con la misma licencia daría mejores respuestas y +# quintuplicaría el peso, con la mitad de la velocidad. +# +# ══ POR QUÉ EL TARBALL ES NUESTRO Y LA URL NO RESUELVE ═════════════════════════════════════════ +# takana extrae toda fuente con `tar`, y un `.gguf` pelado no es un tar. Así que el objeto pineado es +# un tar que envuelve el fichero, publicado en el mirror de fuentes y servido **por sha256** — el +# mismo camino que `firefox-pgo-profile`. La URL `.invalid` no resuelve a propósito (ADR 0013: la URL +# no entra en `hash_inputs`, sólo el sha256). +# +# La PROCEDENCIA, para que nadie tenga que confiar en nuestro tar: +# upstream https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF +# fichero qwen2.5-1.5b-instruct-q4_k_m.gguf +# sha256 6a1a2eb6d15622bf3c96857206351ba97e1af16c30d7a74ee38970e434e9407e ← del GGUF, no del tar +# Ese sha256 es el que HuggingFace publica como `lfs.oid`, y es el que se verificó al bajarlo. +# +# ⚠ `strip_components = 0`: el tar lleva el fichero en la raíz. Con el default (1) se recorta lo +# único que hay y el install muere con un `cp: cannot stat` que no menciona el recorte. +name = "ia-modelo-chat" +version = "qwen2.5-1.5b-instruct-q4_k_m" +license = "Apache-2.0" + +[source] +tarball = "https://no-hay-upstream.invalid/takana/ia-modelo-chat.tar" +sha256 = "2560ee9452a979116ae24278615c51ecced38d2e62288ffe29f4a4bf7ddfc1c6" +strip_components = 0 + +[build] +compiler = "zig-cc" + +[build.phases] +configure = "true" +compile = "true" +install = ''' +set -e +mkdir -p /out/usr/share/takana/ia +cp qwen2.5-1.5b-instruct-q4_k_m.gguf /out/usr/share/takana/ia/modelo.gguf + +M=/out/usr/share/takana/ia/modelo.gguf + +# ── GUARDIÁN: QUE SEA UN GGUF, NO UN FICHERO GRANDE ───────────────────────────────────────── +# El modo de fallo callado acá es el de siempre: un fichero truncado o un HTML de error de 200 K +# renombrado a `.gguf` se instala igual, sella en verde, y lo único que falla es la respuesta — +# cuando ya no hay nadie mirando el build. Se comprueban las dos cosas que un GGUF de verdad tiene: +# el número mágico `GGUF` en los primeros cuatro bytes, y el tamaño. +head -c 4 "$M" | grep -q GGUF || { + echo "!! el fichero instalado no empieza con el mágico GGUF" >&2 + head -c 64 "$M" | od -c | head -3 >&2 + exit 1 +} +n=$(stat -c%s "$M") +test "$n" -gt 900000000 || { echo "!! el GGUF pesa $n bytes: está truncado" >&2; exit 1; } +echo "guardián: GGUF válido — $n bytes" +''' diff --git a/scripts/test-atuq-ia.py b/scripts/test-atuq-ia.py index 1c68104c..565b5d3b 100644 --- a/scripts/test-atuq-ia.py +++ b/scripts/test-atuq-ia.py @@ -3,6 +3,7 @@ python3 scripts/test-atuq-ia.py # sale una respuesta generada acá python3 scripts/test-atuq-ia.py --negative-control # sin modelo: lo DICE, no inventa + python3 scripts/test-atuq-ia.py --image-model # con el modelo DE LA IMAGEN (1 GiB, lento) ── QUÉ SE EJERCITA, DE PUNTA A PUNTA ───────────────────────────────────────────────────────── El navegador abre el panel de la extensión con una pregunta (`panel.html?q=…`), el panel se la pasa @@ -75,15 +76,18 @@ def modelo_de_prueba(): return str(mod.bajar_modelo()) -def correr(atuq, costura, llama, modelo, salida, url, negativo): +def correr(atuq, costura, llama, capa_modelo, salida, url, negativo, tope=90): borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true" + # El modelo de la imagen llega como CAPA (su artefacto ya lo pone en el path de producción); el + # de juguete se copia, que son 1,1 MB. + poner_modelo = "true" if capa_modelo else f"cp /salida/modelo.gguf {MODELO_EN_IMAGEN}" guion = f""" mkdir -p /root /salida/hogar /salida/run {os.path.dirname(MODELO_EN_IMAGEN)} [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id export HOME=/salida/hogar XDG_RUNTIME_DIR=/salida/run - cp /salida/modelo.gguf {MODELO_EN_IMAGEN} + {poner_modelo} {borrar} - timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ + timeout {tope} /usr/bin/atuq --profile /salida/perfil --no-remote --headless '{url}' \ > /salida/consola.log 2>&1 # ¿Se fue el motor con el navegador? Se cuenta ACÁ ADENTRO, con el namespace de PIDs todavía # vivo: desde el hub no se vería nada —`--unshare-pid` se lleva todo al cerrar la jaula— y esa @@ -92,17 +96,27 @@ def correr(atuq, costura, llama, modelo, salida, url, negativo): # Se cuenta leyendo /proc a mano, y no con `pgrep -c`: busybox NO tiene esa opción, y el # `|| echo 0` que parecía prudente convertía el error en un CERO — o sea en un veredicto verde. # Medido: con un motor vivo a propósito, el guardián pasaba igual. - n=0 - for p in /proc/[0-9]*; do - [ "$(cat $p/comm 2>/dev/null)" = "llama-server" ] && n=$((n+1)) + # Se espera a que SE MUERA, hasta 15 s, y se anota cuánto tardó. Matar a un proceso con un + # giga de modelo mapeado no es instantáneo, y contar en el instante exacto del cierre confundía + # «tardando en morir» con «fuga»: con el modelo de juguete daba 0 y con el de la imagen daba 1. + esperado=0 + while [ $esperado -lt 15 ]; do + n=0 + for p in /proc/[0-9]*; do + [ "$(cat $p/comm 2>/dev/null)" = "llama-server" ] && n=$((n+1)) + done + [ "$n" = "0" ] && break + esperado=$((esperado+1)) + sleep 1 done - echo $n > /salida/motores-vivos + echo "$n $esperado" > /salida/motores-vivos exit 0 """ subprocess.run([ "bwrap", "--overlay-src", ALPINE, "--overlay-src", RFS, "--overlay-src", atuq, "--overlay-src", costura, "--overlay-src", llama, + *(["--overlay-src", capa_modelo] if capa_modelo else []), "--tmp-overlay", "/", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys", "--tmpfs", "/tmp", "--unshare-pid", "--unshare-net", @@ -128,6 +142,10 @@ def correr(atuq, costura, llama, modelo, salida, url, negativo): def main(): negativo = "--negative-control" in sys.argv[1:] + # Por defecto se mide con el modelo de juguete: 1,1 MB, instantáneo, y alcanza para certificar la + # CADENA. Con `--image-model` se mide el de la imagen — la misma cadena, pero contestando de + # verdad; cuesta 1 GiB de overlay y unos segundos de generación, y por eso no es el default. + de_la_imagen = "--image-model" in sys.argv[1:] if negativo: print("== MODO CONTROL NEGATIVO: sin modelo en la imagen; el panel tiene que DECIR la causa") if not os.path.isdir(RFS): @@ -137,20 +155,30 @@ def main(): llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR") print(f"artefactos: {os.path.basename(atuq)}\n {os.path.basename(costura)}" f"\n {os.path.basename(llama)}") - modelo = modelo_de_prueba() - print(f"modelo de prueba: {os.path.basename(modelo)}") + capa_modelo = None + modelo = None + if de_la_imagen: + # Llega como CAPA: su artefacto ya instala el modelo en el path de producción. Copiarlo + # sería mover 1 GiB a un tmpfs — lento, y la primera vez llenó /tmp y el guardián murió con + # un ENOSPC que no tenía nada que ver con lo que mide. + capa_modelo = artefacto("ia-modelo-chat", "ia-modelo-chat") + print(f"modelo: el de la imagen — {os.path.basename(capa_modelo)}") + else: + modelo = modelo_de_prueba() + print(f"modelo de prueba: {os.path.basename(modelo)}") tmp = tempfile.mkdtemp(prefix="atuq-ia-") salida = os.path.join(tmp, "salida") os.makedirs(os.path.join(salida, "perfil")) - shutil.copy(modelo, os.path.join(salida, "modelo.gguf")) + if modelo: + shutil.copy(modelo, os.path.join(salida, "modelo.gguf")) try: with open(os.path.join(salida, "perfil", "user.js"), "w") as fh: fh.write('user_pref("browser.dom.window.dump.enabled", true);\n') fh.write('user_pref("browser.shell.checkDefaultBrowser", false);\n') # Primera corrida: descubrir la URL base que Gecko le asignó a la extensión (es por perfil). - lineas, log, _ = correr(atuq, costura, llama, modelo, salida, "about:blank", negativo) + lineas, log, _ = correr(atuq, costura, llama, capa_modelo, salida, "about:blank", negativo) base = next((l[len("BASE "):].strip() for l in lineas if l.startswith("BASE ")), None) if not base: destino = os.path.join(ROOT, "work", "atuq-ia.log") @@ -160,7 +188,9 @@ def main(): print(f"\nextensión en {base}") panel = f"{base}panel.html?q={PREGUNTA.replace(' ', '%20')}" - lineas, log, motores = correr(atuq, costura, llama, modelo, salida, panel, negativo) + # 512 tokens a ~20 tok/s son ~25 s, más la carga del modelo: el tope se acompaña solo. + lineas, log, motores = correr(atuq, costura, llama, capa_modelo, salida, panel, negativo, + tope=180 if de_la_imagen else 90) for l in lineas: print(" " + l) @@ -188,13 +218,15 @@ def main(): texto = r.split("texto=", 1)[1] if "texto=" in r else "" if len(texto.strip(' "')) < 2: fatal(f"la respuesta vino VACÍA: {r}") - if motores != "0": - fatal(f"quedaron {motores} `llama-server` vivos al cerrarse el navegador —" - " el motor tiene que irse con él (§6.7): medio giga de modelo huérfano no se ve") + quedaron, esperado = (motores.split() + ["?"])[:2] + if quedaron != "0": + fatal(f"quedaron {quedaron} `llama-server` vivos {esperado}s después de cerrarse el" + " navegador — el motor tiene que irse con él (§6.7): medio giga de modelo huérfano" + " no se ve hasta que la máquina se queda sin RAM") print("\n✓ el navegador preguntó y un modelo de ESTA máquina contestó") print("✓ y nadie levantó el servidor: no hay servicio de IA en la imagen — lo levantó el host") print("✓ la jaula corre con `--unshare-net`: no hubo red para consultar a nadie más") - print("✓ y el motor se fue con el navegador: 0 `llama-server` vivos al cerrarse") + print(f"✓ y el motor se fue con el navegador: 0 `llama-server` vivos ({esperado}s después)") return 0 finally: shutil.rmtree(tmp, ignore_errors=True)