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.
This commit is contained in:
Sergio
2026-09-12 01:32:12 +00:00
parent d604d850db
commit 0ca88fdcd5
5 changed files with 388 additions and 5 deletions
+48
View File
@@ -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
+21 -4
View File
@@ -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 });
}
});
+76
View File
@@ -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 80200 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"]
+1 -1
View File
@@ -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" }`.
+242
View File
@@ -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())