> 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…
ia: el modelo de chat, pineado — Qwen2.5-1.5B-Instruct Q4_K_M (Apache-2.0)
Elegido por tres cosas, y las tres medidas antes de pinearlo: licencia Apache-2.0 (lo que una distro
puede shipear sin letra chica, al revés que Llama-3.2 o Gemma), habla español —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, 1,04 GiB.
El objeto pineado es un tar que envuelve el .gguf, publicado en el mirror y servido por sha256:
takana extrae toda fuente con `tar` y un GGUF pelado no lo es. Es el camino de firefox-pgo-profile.
La receta anota el sha256 del GGUF DE UPSTREAM (el lfs.oid de HuggingFace, verificado al bajarlo) y
no sólo el del tar nuestro, para que nadie tenga que confiar en nuestro tar. Round-trip verificado:
apartados el caché y el artefacto, el build lo bajó del mirror y selló el mismo ArtifactHash.
Guardián en la receta, porque el 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: el censo de motores contaba en
el instante del cierre — con el de juguete daba 0 y con el de la imagen daba 1, que se lee como fuga
cuando en realidad matar un proceso con un giga mapeado tarda ~1 s. Ahora espera hasta 15 s y anota
cuánto tardó. La rotura a propósito sigue fallando, ahora con el tiempo a la vista.
Falta decidir en qué imágenes se declara (con el motor son ~1,25 GiB por perfil) y pinear el de
embeddings (multilingual-e5-small) para la mitad semántica del §6.3.
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)