> 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…
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.
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())