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 1569Shell 203Rust 109Markdown 993728 Definiciones

atuq §6.3.ter: preguntarle al archivo por lo que DECÍA, no por las palabras exactas

El muro no era un daemon ni un LLM — y el propio comentario de la extensión lo decía mal, copiando
lo que hace willay-rag. Medido: para ordenar por parecido no hace falta ningún LLM (eso es un
coseno) y el «daemon de embeddings» resultó ser el mismo llama-server que ya levanta el chat, con
otro modelo. La corrección quedó escrita donde estaba la afirmación.

Del lado de la suite (tawasuyu 22527f7d1 y b4ecffea8): el protocolo de llama-server salió a
`shared/foreign-llama` (regla 4 — vivía dentro de puriy-costura y ya tenía dos consumidores), el
`Provider` es `rimay-verbo-llama` (regla 10 — la familia verbo YA es la abstracción de embeddings, y
éste es su primer backend sin nube ni descargas), y el índice es `rimay-verbo-index::VectorIndex`.

Acá: la receta del modelo (multilingual-e5-small, MIT), que se pinea en fp32 y la receta CUANTIZA a
Q8_0 con nuestro llama-quantize — así la procedencia es de quien declara la licencia, la imagen se
lleva 126 MB en vez de 476, y la transformación es nuestra y verificable. Más el guardián y la
extensión, que ahora expone `archive.ask` al lado del `archive.search` literal: son dos preguntas
distintas y conviven.

⚠ El guardián está escrito pero NO corrió todavía: `ia-modelo-embeddings` quedó en la cola del flock
detrás del build de otro agente. Lo medido hasta acá es el modelo a mano (384 dimensiones, el pasaje
correcto gana con y sin los prefijos de e5) y 45 tests en tawasuyu. La línea del SDD que lo dice se
borra cuando dé verde.

0ca88fdcd5e0bafe5b5e3444c2c568c88424128c · por Sergio · (hace 1 mes) · padre d604d850d · ver el árbol en este commit

Delta semántico · 10

recipes/atuq/extensions/archivo/fondo.js

scripts/test-atuq-archivo-semantico.py

5 archivos · +388 −5

diff --git a/docs/26-atuq-envoltorio-gecko.md b/docs/26-atuq-envoltorio-gecko.md
index a4488b33..6eefb9ac 100644
--- a/docs/26-atuq-envoltorio-gecko.md
+++ b/docs/26-atuq-envoltorio-gecko.md
@@ -674,6 +674,54 @@ archiva lo privado» sin saber cuál de las dos cosas estaba pasando.
 por byte, porque `truncate` sobre medio multibyte entra en pánico y en un archivo personal el texto
 con acentos es el caso normal.
 
+#### 6.3.ter La otra mitad: preguntarle al archivo por lo que DECÍA (2026-09-12)
+
+Hasta acá el archivo se buscaba **literal**: todas las palabras tienen que aparecer. Ahora también
+se le puede preguntar por el significado (`archive.ask`), y las dos conviven porque son dos
+preguntas: «la página que decía `EADDRINUSE`» es literal y no necesita ningún modelo.
+
+**El muro no era un daemon ni un LLM, y el propio código lo decía mal.** El comentario de la
+extensión afirmaba que el semántico «necesita un daemon de embeddings y un LLM de verdad», copiando
+lo que hace `willay-rag`. Medido: para ordenar por parecido **no hace falta ningún LLM** —eso es un
+coseno— y el «daemon de embeddings» resultó ser el mismo `llama-server` que ya levanta el chat, con
+otro modelo. La corrección quedó escrita donde estaba la afirmación.
+
+**Lo que se construyó, y dónde vive cada cosa** — que es la parte que las reglas del repo deciden,
+no el gusto:
+
+| pieza | dónde | por qué ahí |
+|---|---|---|
+| el protocolo de `llama-server` | `shared/foreign-llama` | **regla 4**: un dialecto ajeno entra por un puente. La primera versión vivía DENTRO de `puriy-costura::ia`, y cuando apareció el segundo consumidor —dos horas después— había dos clientes del mismo protocolo |
+| el `Provider` de embeddings | `rimay-verbo-llama` | **regla 10**: la familia `rimay-verbo-*` ya es la abstracción de embeddings de la suite. Es el primer backend suyo que no pide nube ni descarga nada al arrancar |
+| el índice y el coseno | `rimay-verbo-index::VectorIndex` | ya existían, con postcard y top-K. Escribir otro sería un segundo espacio vectorial sin dueño |
+| cuándo se embebe y qué se pregunta | `puriy-costura` | es lo nuestro |
+
+**Tres decisiones con su porqué:**
+
+- **los vectores se calculan al PREGUNTAR, no al archivar.** Archivar una página no puede quedarse
+  esperando a que cargue un modelo, ni fallar porque la imagen no lo traiga. El precio es que la
+  primera pregunta paga el atraso, y por eso la respuesta dice cuántas quedaron indexadas;
+- **es un SEGUNDO `llama-server`** (`--embedding --pooling mean`): llama.cpp carga un modelo por
+  proceso y el de chat no sabe embeber. Ese `--pooling` no es un gusto — sin él el endpoint contesta
+  un `400` que parece del cliente;
+- **sin umbral de puntaje.** Los cosenos de e5 dan ~0,9 entre frases sin relación: sus vectores viven
+  en un cono estrecho, así que cualquier umbral «razonable» deja pasar todo o corta todo. Lo que
+  significa algo es el **orden**, y de eso se ocupa el top-K.
+
+El guardián (`scripts/test-atuq-archivo-semantico.py`) archiva tres páginas con el navegador y
+después le pregunta al host **sin navegador**, por el cable de 4 bytes y con los paths de producción
+— así se comprueba de paso lo que el §6.3 promete: que el archivo quedó en disco. Pregunta **dos**
+cosas con ganadores distintos, a propósito: con una sola, un ranking constante —o uno que devuelva
+siempre la más reciente— pasaría sin haber entendido nada. Y las preguntas **no comparten palabras**
+con el texto de su página; si las compartieran, la búsqueda literal también acertaría y esto no
+probaría nada semántico.
+
+⚠ **Al cerrar esta entrada el guardián está escrito pero NO ha corrido todavía** contra el artefacto
+sellado: `ia-modelo-embeddings` estaba en la cola del `flock` detrás del build de otro agente. Lo que
+sí está medido es todo lo de abajo del cable —35 tests de `puriy-costura`, 6 del puente, 4 del
+proveedor— y el modelo mismo, probado a mano antes de pinearlo. Esta línea se borra cuando el
+guardián dé verde.
+
 ### 6.4 `qullqa` con su advertencia puesta
 
 Almacenamiento con negación plausible y modelo de adversario **escrito** — ningún navegador tiene
diff --git a/recipes/atuq/extensions/archivo/fondo.js b/recipes/atuq/extensions/archivo/fondo.js
index 06d6fe23..7263fb29 100644
--- a/recipes/atuq/extensions/archivo/fondo.js
+++ b/recipes/atuq/extensions/archivo/fondo.js
@@ -15,10 +15,18 @@
 // Y no se archiva lo que no tiene texto visible: guardar el HTML de un visor de PDF
 // llena el archivo de cosas que después no se encuentran.
 //
-// ⚠ LO QUE ESTO NO ES: preguntarle al historial en lenguaje natural. `archive.search`
-// es el registro LITERAL —todas las palabras tienen que aparecer—. El semántico es un
-// motor `rag-motor::RagMotor` como `willay-rag`, que necesita daemon de embeddings y
-// un LLM de verdad. Cuando exista, se enchufa del mismo lado y esto no cambia.
+// ── LAS DOS BÚSQUEDAS, Y POR QUÉ SON DOS ─────────────────────────────────────
+// `archive.search` es el registro LITERAL: todas las palabras tienen que aparecer, no
+// necesita modelo y contesta al instante. `archive.ask` (§6.3, desde 2026-09-12) ordena
+// por PARECIDO: embebe el archivo con el motor local y devuelve las páginas con su
+// puntaje. Son dos preguntas distintas —«la página que decía EADDRINUSE» es literal— y
+// por eso conviven.
+//
+// ⚠ Este comentario decía que el semántico «necesita un daemon de embeddings y un LLM
+// de verdad», copiando lo que hacía `willay-rag`. Era falso para este caso: no hace
+// falta ningún LLM para ordenar por parecido —eso es coseno— y el daemon resultó ser el
+// mismo `llama-server` que ya levanta el chat, con otro modelo. Quedó escrito acá el día
+// que se midió.
 
 const HOST = "puriy_costura";
 
@@ -71,6 +79,13 @@ function alRecibir(r) {
     for (const p of r.pages) {
       di("HALLADA " + p.hash.slice(0, 12) + " " + p.url + " — " + p.title);
     }
+  } else if (r.verb === "archive.ask" && r.matches) {
+    di("PREGUNTA " + r.matches.length + " de " + r.archived + " (indexadas " + r.indexed + ")");
+    for (const m of r.matches) {
+      // El puntaje se muestra con el ORDEN, nunca como un porcentaje de acierto: los
+      // cosenos de este modelo son ~0,9 entre frases sin relación.
+      di("COINCIDENCIA " + m.score.toFixed(4) + " " + m.page.url + " — " + m.page.title);
+    }
   }
 }
 
@@ -110,6 +125,8 @@ browser.runtime.onMessage.addListener(async (msg, emisor) => {
 browser.runtime.onMessageExternal.addListener(msg => {
   if (msg && msg.tipo === "buscar") {
     enviar({ verb: "archive.search", query: msg.query });
+  } else if (msg && msg.tipo === "preguntar") {
+    enviar({ verb: "archive.ask", query: msg.query });
   }
 });
 
diff --git a/recipes/ia-modelo-embeddings.toml b/recipes/ia-modelo-embeddings.toml
new file mode 100644
index 00000000..1b5ac7da
--- /dev/null
+++ b/recipes/ia-modelo-embeddings.toml
@@ -0,0 +1,76 @@
+# ia-modelo-embeddings — el modelo con el que se le pregunta al archivo personal en lenguaje natural
+# (SDD 26 §6.3, mitad semántica).
+#
+# ══ QUÉ ES, Y POR QUÉ ÉSTE ═════════════════════════════════════════════════════════════════════
+# **multilingual-e5-small**: 384 dimensiones, 118 M de parámetros. Elegido por una razón que se
+# puede medir: **el archivo personal va a estar en español**, y un modelo de embeddings entrenado
+# sólo en inglés buscaría peor justo donde más hay. Licencia MIT (del modelo base, `intfloat`).
+#
+# ══ POR QUÉ LA RECETA CUANTIZA EN VEZ DE PINEAR EL GGUF YA CUANTIZADO ══════════════════════════
+# La fuente pineada es el **fp32 (476 MB)** del único repo que declara licencia (MIT) y la receta lo
+# baja a **Q8_0 (126 MB)** con NUESTRO `llama-quantize` —el del corpus—. Así:
+#   · la procedencia es de quien declara la licencia, no de un repo cualquiera con un q8 subido;
+#   · la imagen se lleva 126 MB y no 476;
+#   · y la cuantización es una transformación DETERMINISTA nuestra, verificable con
+#     `verificar-repro.sh`, en vez de bytes de un tercero que nadie puede reproducir.
+#
+# ⚠ EL MODELO EXIGE PREFIJOS, Y NO ES UN DETALLE DE ESTILO. e5 se entrenó con `query: ` en la
+# consulta y `passage: ` en el documento. Medido con este mismo artefacto contra tres pasajes y una
+# pregunta: el pasaje correcto gana en los dos casos, pero con prefijos la distancia al segundo se
+# abre (0,9371 vs 0,9212 con prefijos; 0,9182 vs 0,8976 sin ellos). Los pone el consumidor
+# (`rimay-verbo-llama`), no esta receta, pero queda escrito acá porque el que pinea el modelo es el
+# que sabe qué convención tiene.
+#
+# ⚠ Y LOS COSENOS SON ALTOS EN ABSOLUTO (~0,9 entre frases sin relación). Es normal en e5: sus
+# vectores viven en un cono estrecho. Quien ponga un umbral absoluto («>0,8 es relevante») va a
+# aceptar todo; lo que vale es el ORDEN.
+#
+# ══ EL TARBALL Y LA PROCEDENCIA ════════════════════════════════════════════════════════════════
+# takana extrae toda fuente con `tar` y un `.gguf` pelado no es un tar ⇒ el objeto pineado es un tar
+# que lo envuelve, publicado en el mirror y servido por sha256 (ADR 0013: la URL no entra en
+# `hash_inputs`). Igual que `ia-modelo-chat` y `firefox-pgo-profile`.
+#
+#   upstream  https://huggingface.co/rodion-m/multilingual-e5-small-gguf   (licencia MIT declarada)
+#   fichero   multilingual-e5-small-fp32.gguf
+#   sha256    b3bf20a4ba914b630be1941dc3f5136cc99871d8fa2b402564a3b8752cf010c2   ← del GGUF, no del tar
+#   modelo base  https://huggingface.co/intfloat/multilingual-e5-small (MIT)
+name    = "ia-modelo-embeddings"
+version = "multilingual-e5-small-q8_0"
+license = "MIT"
+
+[source]
+tarball = "https://no-hay-upstream.invalid/takana/ia-modelo-embeddings.tar"
+sha256  = "886098319440f73a79aea2b3ced5a2e17a45f7d65792f6794ffa0c333583e696"
+strip_components = 0
+
+[build]
+compiler = "zig-cc"
+
+[build.phases]
+configure = "true"
+compile   = "true"
+install = '''
+set -e
+mkdir -p /out/usr/share/takana/ia
+llama-quantize multilingual-e5-small-fp32.gguf /out/usr/share/takana/ia/embeddings.gguf Q8_0
+
+M=/out/usr/share/takana/ia/embeddings.gguf
+
+# ── GUARDIÁN: QUE SEA UN GGUF CUANTIZADO, NO UN FICHERO ─────────────────────────────────────
+# Dos modos de fallo callados: que `llama-quantize` escriba algo que no es un GGUF, y que escriba el
+# fp32 tal cual (o sea que la cuantización no haya pasado). Se comprueban el mágico y la BANDA de
+# tamaño: un Q8_0 de 118 M de parámetros pesa ~126 MB, así que 80–200 MB deja fuera tanto el
+# truncado como el fp32 de 476 MB.
+head -c 4 "$M" | grep -q GGUF || {
+  echo "!! lo 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 80000000 || { echo "!! el GGUF pesa $n bytes: está truncado" >&2; exit 1; }
+test "$n" -lt 200000000 || { echo "!! el GGUF pesa $n bytes: parece el fp32 sin cuantizar" >&2; exit 1; }
+echo "guardián: GGUF Q8_0 válido — $n bytes"
+'''
+
+[deps]
+build = ["llama-cpp"]
diff --git a/recipes/puriy-costura.toml b/recipes/puriy-costura.toml
index 89ed036c..de72a7a5 100644
--- a/recipes/puriy-costura.toml
+++ b/recipes/puriy-costura.toml
@@ -43,7 +43,7 @@ license = "MIT OR Apache-2.0"
 
 [source]
 repo   = "gitea@git.tawasuyu.net:tawasuyu/tawasuyu.git"
-commit = "fea0e89036fda8dcdd61191f3953a50e93b1e6a6"
+commit = "b4ecffea828a7514e7d397253715c83c1b9b5255"
 # ⚠ SIN ESTO EL BUILD MUERE EN UN SITIO QUE NO NOMBRA LA CAUSA — medido acá el 2026-09-10.
 # tawasuyu COMMITEA su propio `vendor/`: adentro tiene un `smithay` parcheado a mano (tearing/flip
 # async para el compositor), y lo enchufa con `[patch.crates-io] smithay = { path = "vendor/smithay" }`.
diff --git a/scripts/test-atuq-archivo-semantico.py b/scripts/test-atuq-archivo-semantico.py
new file mode 100644
index 00000000..b108d8bb
--- /dev/null
+++ b/scripts/test-atuq-archivo-semantico.py
@@ -0,0 +1,242 @@
+#!/usr/bin/env python3
+"""¿Se le puede preguntar al archivo personal por lo que DECÍA? (SDD 26 §6.3, mitad semántica)
+
+    python3 scripts/test-atuq-archivo-semantico.py                    # con el modelo de la imagen
+    python3 scripts/test-atuq-archivo-semantico.py --negative-control  # sin modelo: lo DICE
+
+── QUÉ SE EJERCITA ───────────────────────────────────────────────────────────────────────────
+El navegador archiva tres páginas de temas distintos —eso es el §6.3 literal, que ya existía— y
+después se le pregunta al host **desde dentro de la jaula y por el cable de verdad** (marco de 4
+bytes), con los paths de PRODUCCIÓN. La respuesta tiene que ordenar por parecido: la página del
+tema preguntado primero.
+
+Y se pregunta DOS cosas distintas, que ganan páginas distintas. Con una sola pregunta, un ranking
+constante —o uno que siempre devuelve la más reciente— pasaría el test sin haber entendido nada.
+
+── POR QUÉ EL HOST SE INVOCA A MANO Y NO POR LA EXTENSIÓN ────────────────────────────────────
+Porque así se comprueba lo que de verdad importa del archivo: **que quedó en disco y se consulta
+sin el navegador**. Es el mismo camino que usa `scripts/test-atuq-archivo.py` para la búsqueda
+literal.
+
+REQUISITOS: rootfs de `atuq-nested.sh`, y `atuq` + `puriy-costura` + `llama-cpp` +
+`ia-modelo-embeddings` sellados.
+"""
+import http.server
+import json
+import os
+import shutil
+import struct
+import subprocess
+import sys
+import tempfile
+import threading
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
+ALPINE = os.path.join(ROOT, ".dev-fs/alpine")
+HAMMER = os.path.join(ROOT, "target/release/takana")
+STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
+MODELO_EN_IMAGEN = "/usr/share/takana/ia/embeddings.gguf"
+
+# Tres páginas de temas bien separados, y dos preguntas que NO comparten palabras con el texto de
+# su página: si compartieran, la literal también acertaría y esto no probaría nada semántico.
+PAGINAS = {
+    "/gato.html": ("El felino en el tejado",
+                   "Un minino atigrado dormía sobre las tejas calientes toda la siesta."),
+    "/red.html": ("Cerrar la salida de una aplicación",
+                  "Reglas de egress por cgroup, denegar por defecto y permitir sólo lo declarado."),
+    "/pan.html": ("Masa madre",
+                  "Harina, agua y sal; fermentación lenta durante la noche y horno muy caliente."),
+}
+PREGUNTAS = [
+    ("¿dónde se echó a dormir el gato?", "/gato.html"),
+    ("cómo bloqueo internet a un programa", "/red.html"),
+]
+
+
+def fatal(msg):
+    print(f"✗ {msg}", file=sys.stderr)
+    raise SystemExit(1)
+
+
+def artefacto(receta, nombre, escotilla=None):
+    forzado = os.environ.get(escotilla) if escotilla else None
+    if forzado:
+        if not os.path.isdir(forzado):
+            fatal(f"{escotilla}={forzado} no existe")
+        print(f"⚠ {escotilla}: se mide {os.path.basename(forzado)}, que NO es necesariamente el vigente")
+        return forzado
+    r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
+                       cwd=ROOT, capture_output=True, text=True)
+    if r.returncode != 0:
+        fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
+    h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
+    d = os.path.join(STORE, f"{h}-{nombre}")
+    if not os.path.isdir(d):
+        fatal(f"{nombre} b3:{h[:8]} no está sellado — construilo antes")
+    if not os.listdir(d):
+        fatal(f"{nombre} b3:{h[:8]} está VACÍO: eso no es un artefacto, es un nombre")
+    return d
+
+
+class Servidor(http.server.SimpleHTTPRequestHandler):
+    def log_message(self, *a):
+        pass
+
+    def do_GET(self):
+        ruta = self.path.split("?")[0]
+        if ruta in PAGINAS:
+            titulo, texto = PAGINAS[ruta]
+            datos = (f'<!doctype html><html><head><meta charset="utf-8"><title>{titulo}</title>'
+                     f"</head><body><h1>{titulo}</h1><p>{texto}</p></body></html>").encode()
+        elif ruta == "/ir.html":
+            # Una página que pasea por las tres: el guión de contenido archiva cada una al cargar.
+            saltos = "".join(f'<iframe src="{r}" width="10" height="10"></iframe>' for r in PAGINAS)
+            datos = (f'<!doctype html><html><head><meta charset="utf-8"></head><body>{saltos}'
+                     "</body></html>").encode()
+        else:
+            datos = b"<html><body>nada</body></html>"
+        self.send_response(200)
+        self.send_header("Content-Type", "text/html; charset=utf-8")
+        self.send_header("Content-Length", str(len(datos)))
+        self.send_header("Cache-Control", "no-store")
+        self.end_headers()
+        self.wfile.write(datos)
+
+
+def puerto_libre():
+    import socket
+    s = socket.socket()
+    s.bind(("127.0.0.1", 0))
+    p = s.getsockname()[1]
+    s.close()
+    return p
+
+
+def marco(obj):
+    """El cable de native messaging: 4 bytes little-endian y el cuerpo."""
+    cuerpo = json.dumps(obj).encode()
+    return struct.pack("<I", len(cuerpo)) + cuerpo
+
+
+def correr(capas, salida, guion):
+    subprocess.run([
+        "bwrap",
+        "--overlay-src", ALPINE, "--overlay-src", RFS, *sum((["--overlay-src", c] for c in capas), []),
+        "--tmp-overlay", "/",
+        "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--bind", "/sys", "/sys",
+        "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-pid",
+        "--bind", salida, "/salida",
+        "--uid", "0", "--gid", "0",
+        "--setenv", "PATH", "/usr/bin:/bin:/usr/sbin:/sbin",
+        "--setenv", "XDG_RUNTIME_DIR", "/run",
+        "--setenv", "XDG_DATA_DIRS", "/usr/share", "--setenv", "MOZ_HEADLESS", "1",
+        "--setenv", "MOZ_DISABLE_CONTENT_SANDBOX", "1",
+        "--setenv", "MOZ_DISABLE_GMP_SANDBOX", "1",
+        "--setenv", "MOZ_DISABLE_RDD_SANDBOX", "1",
+        "--setenv", "MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "1",
+        "--setenv", "MOZ_DISABLE_UTILITY_SANDBOX", "1",
+        "sh", "-c", guion,
+    ], check=False)
+
+
+def main():
+    negativo = "--negative-control" in sys.argv[1:]
+    if negativo:
+        print("== MODO CONTROL NEGATIVO: sin modelo de embeddings; tiene que DECIRLO")
+    if not os.path.isdir(RFS):
+        fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
+    atuq = artefacto("atuq", "atuq", "ATUQ_DIR")
+    costura = artefacto("puriy-costura", "puriy-costura")
+    llama = artefacto("llama-cpp", "llama-cpp", "LLAMA_DIR")
+    modelo = artefacto("ia-modelo-embeddings", "ia-modelo-embeddings")
+    print(f"artefactos: {os.path.basename(atuq)}\n            {os.path.basename(costura)}"
+          f"\n            {os.path.basename(llama)}\n            {os.path.basename(modelo)}")
+
+    puerto = puerto_libre()
+    servidor = http.server.ThreadingHTTPServer(("127.0.0.1", puerto), Servidor)
+    threading.Thread(target=servidor.serve_forever, daemon=True).start()
+
+    tmp = tempfile.mkdtemp(prefix="atuq-archivo-sem-")
+    salida = os.path.join(tmp, "salida")
+    os.makedirs(os.path.join(salida, "perfil"))
+    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')
+
+        # Las preguntas se escriben ya enmarcadas: así el guión de adentro sólo tiene que
+        # volcarlas al binario, sin hacer aritmética de bytes en `sh`.
+        for i, (q, _) in enumerate(PREGUNTAS):
+            with open(os.path.join(salida, f"pregunta-{i}.bin"), "wb") as fh:
+                fh.write(marco({"verb": "archive.ask", "query": q}))
+
+        borrar = f"rm -f {MODELO_EN_IMAGEN}" if negativo else "true"
+        guion = f"""
+        mkdir -p /root /salida/hogar
+        [ -s /etc/machine-id ] || tr -d - < /proc/sys/kernel/random/uuid > /etc/machine-id
+        export HOME=/salida/hogar
+        {borrar}
+        timeout 90 /usr/bin/atuq --profile /salida/perfil --no-remote --headless \
+            'http://127.0.0.1:{puerto}/ir.html' > /salida/consola.log 2>&1
+        # Y ahora, SIN navegador: el archivo quedó en disco y se le pregunta por el cable.
+        E=/salida/hogar/.local/state/puriy-costura
+        for i in 0 1; do
+            /usr/bin/puriy-costura --state $E < /salida/pregunta-$i.bin > /salida/respuesta-$i.bin 2>/salida/err-$i.txt
+        done
+        exit 0
+        """
+        capas = [atuq, costura, llama] + ([] if negativo else [modelo])
+        # En el control negativo el modelo NO se monta: `rm -f` sobre una capa de sólo lectura
+        # borraría el fichero de la capa temporal, pero montar y borrar es contar dos veces.
+        correr(capas if not negativo else [atuq, costura, llama], salida, guion)
+
+        log = open(os.path.join(salida, "consola.log"), errors="replace").read()
+        lineas = [l[len("ARCHIVO "):] for l in log.splitlines() if l.startswith("ARCHIVO ")]
+        archivadas = [l for l in lineas if l.startswith("ARCHIVADA ")]
+        for l in archivadas:
+            print("   " + l)
+        if len(archivadas) < 3:
+            destino = os.path.join(ROOT, "work", "atuq-archivo-sem.log")
+            os.makedirs(os.path.dirname(destino), exist_ok=True)
+            shutil.copy(os.path.join(salida, "consola.log"), destino)
+            fatal(f"se archivaron {len(archivadas)} páginas de 3 — log en {destino}")
+
+        for i, (q, esperada) in enumerate(PREGUNTAS):
+            crudo = open(os.path.join(salida, f"respuesta-{i}.bin"), "rb").read()
+            if len(crudo) < 4:
+                err = open(os.path.join(salida, f"err-{i}.txt"), errors="replace").read()
+                fatal(f"el host no contestó a «{q}»: {err.strip()[:300]}")
+            n = struct.unpack("<I", crudo[:4])[0]
+            r = json.loads(crudo[4:4 + n])
+            if negativo:
+                if r.get("ok"):
+                    fatal("sin modelo CONTESTÓ igual: eso es inventar un orden")
+                if "no hay modelo de embeddings" not in (r.get("error") or ""):
+                    fatal(f"la causa no nombra lo que falta: {r.get('error')}")
+                print(f"\n   RECHAZO {r['error']}")
+                print("\n✓ control negativo: sin modelo lo dice, nombrando lo que falta")
+                print("✓ y no inventa un orden, que es el único fallo inaceptable en una búsqueda")
+                return 0
+            if not r.get("ok"):
+                fatal(f"la pregunta «{q}» falló: {r.get('error')}")
+            m = r.get("matches") or []
+            if not m:
+                fatal(f"«{q}» no devolvió ninguna coincidencia sobre {r.get('archived')} páginas")
+            print(f"\n   «{q}» → indexadas {r['indexed']}/{r['archived']}")
+            for c in m:
+                print(f"      {c['score']:.4f}  {c['page']['title']}")
+            if esperada not in m[0]["page"]["url"]:
+                fatal(f"«{q}» puso primera a {m[0]['page']['url']} y esperaba {esperada}"
+                      " — el orden no sigue al SIGNIFICADO")
+
+        print("\n✓ el archivo contesta por PARECIDO: cada pregunta pone primera a su página")
+        print("✓ y son dos preguntas con ganadores distintos — un ranking constante no pasaría")
+        print("✓ todo sin navegador: el archivo quedó en disco y se consulta por el cable")
+        return 0
+    finally:
+        servidor.shutdown()
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
+sys.exit(main())