From 4190c7b43e5bd323a9b166340cfa16cd324ca2e4 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 11 Sep 2026 03:03:31 +0000 Subject: [PATCH] =?UTF-8?q?atuq=20=C2=A76.7:=20entra=20el=20motor=20de=20i?= =?UTF-8?q?nferencia=20local=20=E2=80=94=20y=20SOURCE=5FDATE=5FEPOCH=20le?= =?UTF-8?q?=20hab=C3=ADa=20apagado=20el=20AVX2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La IA local de la barra lateral (§6.7) y la mitad semántica del archivo personal (§6.3) figuraban como dos pendientes distintos. Son uno: el corpus no tenía con qué correr un modelo. `pluma-llm` sólo trae backends de NUBE y `rimay-verbo-fastembed` DESCARGA onnxruntime (glibc) y el modelo de HuggingFace en el primer arranque. `recipes/llama-cpp.toml` (b10901, estática, 199 M) derriba el muro entero: el mismo binario sirve `/v1/chat/completions` y `/v1/embeddings`. ⚠ Lo que este commit deja medido, y es lo que no se podía deducir: el PRIMER sello llevaba sólo `-DGGML_NATIVE=OFF` —leído en `ggml/CMakeLists.txt:141`, que con NATIVE=OFF debería ENCENDER las perillas explícitas de ISA— y salió con CERO instrucciones vectoriales. La causa está 36 líneas más arriba: `if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH})` apaga `GGML_NATIVE_DEFAULT`, y el sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`) justamente para que los builds REPRODUZCAN. O sea: la variable que nos da reproducibilidad apagaba todas las instrucciones vectoriales del motor de inferencia — y no falló nada. El artefacto selló, `--version` contestaba, y el binario era x86-64 pelado: 0 `%ymm`, 0 `vfmadd`, 0 `roundps` en 1.634.770 líneas de `objdump -d`. Ahora las seis van declaradas una por una y el `install` LAS COMPRUEBA en el binario: 42.356 `%ymm` / 1.298 `vfmadd` / 86 `roundps`. `strip_debug = true` porque zig cc emite debug_info por defecto y son 16 binarios estáticos: 1,8 G → 199 M. Guardián `scripts/test-llama-cpp.py`, sobre el artefacto VIGENTE (resuelto por `takana hash`, no por `ls store/*`) y con control negativo vivo (`--sin-modelo`, que TIENE que fallar): ISA, hermético (0 NEEDED), una pasada de inferencia REAL con `stories260K` pineado por sha256, y el endpoint de embeddings — vector de verdad, el mismo texto da el mismo vector, dos textos distintos dan vectores distintos, y no son ceros. Y `verificar-repro.sh`: REPRODUCE, 0 no-determinismos. ⚠ Esto es el MOTOR, no la función. Un motor sin modelo no contesta nada: el modelo de producción es fuente pineada aparte, como el perfil de PGO de firefox, y es su propia unidad de trabajo. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa --- recipes/llama-cpp.toml | 166 ++++++++++++++++++++++++ scripts/test-llama-cpp.py | 263 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 429 insertions(+) create mode 100644 recipes/llama-cpp.toml create mode 100755 scripts/test-llama-cpp.py diff --git a/recipes/llama-cpp.toml b/recipes/llama-cpp.toml new file mode 100644 index 00000000..bb3a32d7 --- /dev/null +++ b/recipes/llama-cpp.toml @@ -0,0 +1,166 @@ +# llama.cpp b10901 — el MOTOR DE INFERENCIA LOCAL del corpus. Es la pieza que destraba el §6.7 del +# SDD 26 (IA local en la barra lateral de `atuq`) y la mitad semántica del §6.3 (preguntarle al +# archivo personal en lenguaje natural). +# +# ══ POR QUÉ ESTA RECETA, Y NO «ENCHUFAR LO QUE YA HAY» ═════════════════════════════════════════ +# El §6.7 figuraba como costo «bajo» porque las piezas parecían estar escritas en tawasuyu. Se fue +# a mirar y NO alcanzan, por dos razones independientes: +# 1. `pluma-llm` tiene backends `anthropic`, `cohere`, `claude-cli` y `mock` — TODOS de nube o de +# juguete. Ninguno corre un modelo en la máquina; +# 2. `rimay-verbo-fastembed` —el daemon de embeddings que `rag-motor` exige— trae `fastembed` +# con la feature `ort-download-binaries`: **baja onnxruntime precompilado (glibc) y el modelo +# de HuggingFace en el primer arranque**. En un build hermético y en una distro musl eso no es +# «lento», es imposible. +# O sea que los dos pendientes comparten UN muro y el muro tiene forma de receta. llama.cpp lo +# derriba entero: el mismo binario sirve `/v1/chat/completions` (el LLM del §6.7) y `/v1/embeddings` +# (lo que `rag-motor` necesita para la mitad semántica del §6.3), sin ONNX, sin Python y sin red. +# +# ⚠ ESTA RECETA ES EL MOTOR, NO LA FUNCIÓN. Un motor sin modelo no responde nada. El modelo es una +# FUENTE PINEADA aparte —igual que el perfil de PGO de firefox—, y es su propia unidad de trabajo. +# Nadie debería leer «IA local» y esperar que esto conteste algo todavía. +# +# ══ LA VERSIÓN ES UN COMMIT, NO UNA SERIE ═════════════════════════════════════════════════════ +# llama.cpp no publica versiones semánticas: sus releases son `bNNNN`, una por commit de `master` +# (b10901 = 2026-09-10). El `LLAMA_VERSION` interno dice `0.4.0-dev` y no identifica nada. Se pinea +# el tag, que es lo único que nombra un árbol concreto, y subirlo es su propia unidad de trabajo. +# +# ══ LAS CUATRO PERILLAS QUE NO SON GUSTO ══════════════════════════════════════════════════════ +# +# 1. **`-DLLAMA_USE_PREBUILT_UI=OFF` — viene ON DE FÁBRICA Y DESCARGA EN TIEMPO DE BUILD.** +# `tools/ui/CMakeLists.txt` cuelga un `add_custom_target` que corre `scripts/ui-assets.cmake`, y +# ése hace `file(DOWNLOAD …)` contra un bucket de HuggingFace para hornear la web UI dentro de +# `llama-server`. Leído en la fuente del tarball pineado, no supuesto. Hoy el sandbox no tiene +# red y el guión sólo avisa y sigue — pero apagarlo por AUSENCIA DE RED es exactamente la +# protección que funciona por accidente: el día que el sandbox tenga red, entraría un blob que no +# está en `hash_inputs` y el artefacto dejaría de reproducir sin que nadie tocara la receta. Se +# apaga por declaración. La web UI no se pierde: no la queríamos — acá el cliente es el host de +# native messaging del §7, que habla HTTP, no un navegador mirando el puerto. +# +# 2. **LAS SEIS PERILLAS DE ISA VAN UNA POR UNA, Y LA RAZÓN SE MIDIÓ ACÁ.** +# El primer sello de esta receta llevaba sólo `-DGGML_NATIVE=OFF`, apoyado en leer +# `ggml/CMakeLists.txt:141` — `if (GGML_NATIVE OR NOT GGML_NATIVE_DEFAULT) set(INS_ENB OFF)`—, +# o sea: con NATIVE=OFF, ggml debería ENCENDER las perillas explícitas (SSE4.2/AVX/AVX2/BMI2/ +# FMA/F16C) en vez de apoyarse en `-march=native`. **Y es falso en ESTE sandbox**, por una línea +# 50 renglones más arriba (`ggml/CMakeLists.txt:105`): +# +# if (CMAKE_CROSSCOMPILING OR DEFINED ENV{SOURCE_DATE_EPOCH}) +# set(GGML_NATIVE_DEFAULT OFF) +# +# El sandbox de takana exporta `SOURCE_DATE_EPOCH=1` (`sandbox.rs:453`) — que es lo que nos da +# binarios reproducibles. Con eso `GGML_NATIVE_DEFAULT` queda OFF, `NOT GGML_NATIVE_DEFAULT` es +# verdadero, e `INS_ENB` termina en **OFF**: las seis perillas apagadas. O sea que +# **la variable que existe para que el build reproduzca apagó todas las instrucciones +# vectoriales del motor de inferencia.** +# +# ⚠ Y no falló nada. El artefacto selló, `llama-cli --version` contesta, y el binario es x86-64 +# pelado: **cero `%ymm`, cero `vfmadd`, cero `roundps`** en 1.634.770 líneas de `objdump -d`, +# contadas sobre el artefacto sellado. Un motor de inferencia sin AVX2 no está roto: está varias +# veces más lento, y ninguna métrica del repo lo dice. Es la figura de siempre — un ausente falla +# ruidosamente; un desviado llega hasta el final diciendo que todo fue bien. +# +# Por eso las seis van declaradas y NO se confía en el default de nadie. Y por eso el `install` +# las COMPRUEBA en el binario: es el único punto donde este modo de fallar se puede ver. +# +# ⚠ Eso fija un PISO declarado: x86-64-v3. Un CPU sin AVX2 (pre-2013) muere con SIGILL, no con un +# mensaje. Medido antes de elegirlo, no deducido: `momento`/gioser y el worker `dev.gioser.net` +# traen los seis flags, y el objetivo de metal (TigerLake, ver [[etapa-metal-usb]]) también. +# Bajar el piso o publicar variantes es `docs/plan-variantes-cpu.md`, no esta receta. +# `-DGGML_NATIVE=OFF` se queda igual, pero por lo que de verdad hace acá: prohibir `-march=native`, +# que sería un artefacto distinto por máquina — la familia del `MAKE_STAMP` de nftables. +# +# 3. **`-DGGML_CCACHE=OFF` — viene ON de fábrica y se autodetecta.** Si algún día el lab trae +# `ccache`, el build cambia de forma sin que el corpus haya cambiado en nada, y el lab NO entra +# en `hash_inputs` (ver [[lab-fuera-de-hash-inputs]]): dos labs sellarían bytes distintos en la +# misma dirección. Misma familia que el `CONFIG_SHELL=bash` que se rechazó en nftables. +# +# 4. **`-DGGML_OPENMP=OFF` — no es recorte, es evitar una dep invisible.** Con OpenMP encendido el +# binario arrastra la runtime de OpenMP del compilador. ggml trae su PROPIO pool de hilos y lo usa +# cuando OpenMP no está; el corpus prefiere no meterse la runtime de otro toolchain adentro +# (ver el `-static-libstdc++` de cmake.toml, misma lección). +# +# `-DLLAMA_OPENSSL=OFF`: su único uso es HTTPS para BAJAR modelos de HuggingFace desde el propio +# binario. Acá un modelo entra como entra cualquier fuente —pineado por sha256— así que esa función +# no sólo no hace falta: no queremos que exista. `-DLLAMA_BUILD_APP=OFF` por lo mismo (`app/` es el +# binario unificado con `download.cpp` adentro). +# +# ⚠ `LLAMA_BUILD_TOOLS=ON` NO es un extra: `llama-server` vive en `tools/server`, y `tools/CMakeLists.txt` +# sólo entra a `server/` (y a `cli/`) si `LLAMA_BUILD_SERVER` está encendido DENTRO de TOOLS. Con +# TOOLS=OFF no hay servidor. De yapa vienen `llama-quantize`, `llama-bench` y `llama-perplexity`, +# que son las tres herramientas con las que se mide un modelo antes de pinearlo. +# +# El wrapper `.zwrap` es el mismo de `poppler-glib` y por lo mismo: cmake le pasa +# `-Wl,--fatal-warnings` al linker y zig cc convierte avisos benignos en errores. +# +# El `install` comprueba que `llama-server` EXISTA. No es paranoia: si `LLAMA_BUILD_TOOLS` o +# `LLAMA_BUILD_SERVER` se apagan solos —que es lo que hizo `ENABLE_GLIB` en poppler—, el build sale +# 0 y sella un artefacto sin la única pieza por la que esta receta existe. +name = "llama-cpp" +version = "b10901" +license = "MIT" + +[source] +tarball = "https://github.com/ggml-org/llama.cpp/archive/refs/tags/b10901.tar.gz" +sha256 = "2cf2d648c6a8ee94f67a94d8c70479620ba481e01a1d930a9075bc7780ff4f9f" + +[build] +compiler = "zig-cc" +target = "x86_64-linux-musl" +link = "static" +flags = [] +# Sin esto el artefacto pesa 1,8 G: zig cc emite debug_info por defecto y acá son 16 binarios +# ESTÁTICOS, o sea 16 copias de los símbolos de libllama+libggml+libc++. Medido: `llama-server` +# pasa de 133 M a una fracción. Entra en el hash (`recipe.rs:574`), como tiene que ser. +strip_debug = true + +[build.phases] +configure = ''' +ZW="$PWD/.zwrap"; mkdir -p "$ZW" +cat > "$ZW/cc" <<'W' +#!/bin/sh +a=""; for x in "$@"; do [ "$x" = "-Wl,--fatal-warnings" ] && continue; a="$a $x"; done +exec /opt/zig/zig cc -mcpu=baseline $a +W +cat > "$ZW/cxx" <<'W' +#!/bin/sh +a=""; for x in "$@"; do [ "$x" = "-Wl,--fatal-warnings" ] && continue; a="$a $x"; done +exec /opt/zig/zig c++ -mcpu=baseline $a +W +chmod +x "$ZW/cc" "$ZW/cxx" +cmake -B build -G Ninja -Wno-dev \ + -DCMAKE_C_COMPILER="$ZW/cc" -DCMAKE_CXX_COMPILER="$ZW/cxx" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr \ + -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF -DBUILD_SHARED_LIBS=OFF \ + -DGGML_NATIVE=OFF -DGGML_CCACHE=OFF -DGGML_OPENMP=OFF -DGGML_BLAS=OFF \ + -DGGML_SSE42=ON -DGGML_AVX=ON -DGGML_AVX2=ON -DGGML_BMI2=ON -DGGML_FMA=ON -DGGML_F16C=ON \ + -DGGML_RPC=OFF -DGGML_BACKEND_DL=OFF \ + -DLLAMA_OPENSSL=OFF -DLLAMA_BUILD_UI=OFF -DLLAMA_USE_PREBUILT_UI=OFF \ + -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_APP=OFF \ + -DLLAMA_BUILD_TOOLS=ON -DLLAMA_BUILD_SERVER=ON +''' +compile = 'cmake --build build -j"$(nproc)"' +install = ''' +set -e +DESTDIR=/out cmake --install build +# Ver la cabecera: si TOOLS o SERVER se apagan solos, esto sella sin la pieza que justifica la receta. +test -x /out/usr/bin/llama-server || { + echo "ERROR: no se instaló llama-server — ¿LLAMA_BUILD_TOOLS/LLAMA_BUILD_SERVER apagados?" >&2 + exit 1 +} +test -x /out/usr/bin/llama-cli || { + echo "ERROR: no se instaló llama-cli" >&2 + exit 1 +} +# Ver la perilla 2 de la cabecera: SOURCE_DATE_EPOCH apaga INS_ENB y el binario sale sin una sola +# instrucción vectorial, sellando igual y corriendo igual. Esto es lo único que lo ve. +objdump -d build/ggml/src/libggml-cpu.a 2>/dev/null | grep -q '%ymm' || { + echo "ERROR: libggml-cpu.a NO tiene registros %ymm — AVX2 quedó apagado (¿INS_ENB=OFF?)" >&2 + exit 1 +} +objdump -d build/ggml/src/libggml-cpu.a 2>/dev/null | grep -q 'vfmadd' || { + echo "ERROR: libggml-cpu.a NO tiene vfmadd — FMA quedó apagado" >&2 + exit 1 +} +''' + +[deps] +build = ["cmake", "samurai", "busybox", "binutils", "pkgconf"] diff --git a/scripts/test-llama-cpp.py b/scripts/test-llama-cpp.py new file mode 100755 index 00000000..4d09c436 --- /dev/null +++ b/scripts/test-llama-cpp.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Guardián de `recipes/llama-cpp.toml` — el motor de inferencia local del §6.7 del SDD 26. + +Mide CUATRO afirmaciones sobre el ARTEFACTO VIGENTE, y ninguna se deduce de que el build haya +salido 0: + + 1. ISA el binario trae AVX2/FMA/SSE4.2 de verdad. + 2. hermético estáticamente enlazado, sin un solo NEEDED. + 3. inferencia una pasada real contra un modelo pineado por sha256 ⇒ salen tokens. + 4. embeddings `llama-server --embedding` + `POST /v1/embeddings` ⇒ sale un vector, y además: + · el MISMO texto dos veces da el MISMO vector (si no, no hay nada determinista), + · dos textos DISTINTOS dan vectores DISTINTOS (si no, es un stub de ceros). + El punto 4 es el que le importa al §6.3: la mitad semántica del archivo personal + se apoya EXACTAMENTE en ese endpoint. + +⚠ EL CONTROL NEGATIVO DEL PUNTO 1 NO ES HIPOTÉTICO, ESTÁ MEDIDO. El PRIMER sello de esta receta +(`b3:e3c62185`) llevaba sólo `-DGGML_NATIVE=OFF`, selló, corrió, contestó `--version` — y tenía +**cero** `%ymm`, **cero** `vfmadd` y **cero** `roundps` en 1.634.770 líneas de `objdump -d`. La causa +está en la cabecera de la receta: el sandbox exporta `SOURCE_DATE_EPOCH`, ggml lee esa variable para +decidir `GGML_NATIVE_DEFAULT` y termina apagando `INS_ENB`. O sea: la variable que existe para que +el build REPRODUZCA apagaba todas las instrucciones vectoriales del motor, sin un aviso. + +El control negativo VIVO es `--sin-modelo`: las mismas sondas contra un modelo que no existe tienen +que FALLAR. Sin eso, un arnés roto («no contestó») se lee igual que un motor que anda. + +El modelo de prueba es `stories260K` (1,1 MB, 260 mil parámetros — el mismo que usa el CI de +llama.cpp). Se pinea por **sha256**, que es la identidad; la URL no lo es (ADR 0013) y por eso se +puede servir desde donde sea. No es un modelo útil: es un modelo REAL, que es lo que hace falta para +ejercitar la cadena entera. El modelo de producción es su propia unidad de trabajo. + + scripts/test-llama-cpp.py + scripts/test-llama-cpp.py --sin-modelo # control negativo: TIENE que fallar +""" +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +RAIZ = Path(__file__).resolve().parent.parent +RECETA = RAIZ / "recipes/llama-cpp.toml" +TAKANA = RAIZ / "target/release/takana" + +MODELO_SHA = "270cba1bd5109f42d03350f60406024560464db173c0e387d91f0426d3bd256d" +MODELO_URL = "https://huggingface.co/ggml-org/models/resolve/main/tinyllamas/stories260K.gguf" +MODELO_CACHE = RAIZ / "work/llama-modelos" / f"{MODELO_SHA[:16]}-stories260K.gguf" + +fallos = [] + + +def ok(msg): + print(f" \033[32m✓\033[0m {msg}") + + +def mal(msg): + print(f" \033[31m✗\033[0m {msg}") + fallos.append(msg) + + +def artefacto(): + """El artefacto VIGENTE, resuelto por `takana hash` — nunca por `ls store/*`. + + Un `ls` agarra el primero que empiece igual, y el corpus es compartido: el de al lado puede ser + de un hash viejo que ya nadie construye. Pasó con `gmp` en el guardián del foco, y el síntoma + fue una librería que no relocaba. + """ + if os.environ.get("LLAMA_DIR"): + d = Path(os.environ["LLAMA_DIR"]) + print(f"\033[33m⚠ LLAMA_DIR: corriendo contra {d} A SABIENDAS — el resultado NO se puede\033[0m") + print("\033[33m citar como «el llama-cpp de hoy pasa».\033[0m") + return d + h = subprocess.run([str(TAKANA), "--store", str(RAIZ / "store"), "hash", str(RECETA)], + capture_output=True, text=True, cwd=RAIZ) + if h.returncode != 0: + sys.exit(f"no se pudo hashear la receta: {h.stderr.strip()}") + d = RAIZ / "store" / f"{h.stdout.strip().removeprefix('b3:')}-llama-cpp" + if not d.is_dir(): + sys.exit(f"el artefacto vigente NO está en el store: {d.name}\n" + f" construilo: flock -o work/.farm-build.lock ./target/release/takana " + f"--store ./store build recipes/llama-cpp.toml") + return d + + +def bajar_modelo(): + if MODELO_CACHE.is_file(): + got = hashlib.sha256(MODELO_CACHE.read_bytes()).hexdigest() + if got == MODELO_SHA: + return MODELO_CACHE + MODELO_CACHE.unlink() + MODELO_CACHE.parent.mkdir(parents=True, exist_ok=True) + print(f" · bajando el modelo de prueba ({MODELO_URL.rsplit('/', 1)[-1]}, 1,1 MB)…") + tmp = MODELO_CACHE.with_suffix(".incoming") + with urllib.request.urlopen(MODELO_URL, timeout=120) as r, open(tmp, "wb") as f: + f.write(r.read()) + got = hashlib.sha256(tmp.read_bytes()).hexdigest() + if got != MODELO_SHA: + tmp.unlink() + sys.exit(f"el modelo bajado NO es el pineado:\n esperado {MODELO_SHA}\n obtenido {got}") + tmp.rename(MODELO_CACHE) + return MODELO_CACHE + + +# ── 1. ISA ──────────────────────────────────────────────────────────────────────────────────── +def probar_isa(art): + print("\n\033[1m1. ISA — ¿el motor trae instrucciones vectoriales?\033[0m") + lib = art / "usr/lib/libggml-cpu.a" + if not lib.is_file(): + return mal("no hay libggml-cpu.a en el artefacto") + d = subprocess.run(["objdump", "-d", str(lib)], capture_output=True, text=True) + if d.returncode != 0: + return mal(f"objdump falló sobre libggml-cpu.a: {d.stderr.strip()[:120]}") + for patron, quien in (("%ymm", "AVX2 (registros de 256 bits)"), + ("vfmadd", "FMA"), + ("roundps", "SSE4.2")): + n = d.stdout.count(patron) + (ok if n else mal)(f"{quien}: {n} apariciones de `{patron}`" + + ("" if n else " ← INS_ENB quedó apagado (ver la cabecera)")) + + +# ── 2. hermético ────────────────────────────────────────────────────────────────────────────── +def probar_hermetico(art): + print("\n\033[1m2. Hermético — ¿el binario pide algo de afuera?\033[0m") + for nombre in ("llama-server", "llama-cli"): + b = art / "usr/bin" / nombre + if not b.is_file(): + mal(f"{nombre} no está en el artefacto") + continue + r = subprocess.run(["readelf", "-d", str(b)], capture_output=True, text=True) + needed = re.findall(r"\(NEEDED\).*\[(.+?)\]", r.stdout) + (ok if not needed else mal)( + f"{nombre}: {'sin NEEDED (estático)' if not needed else 'pide ' + ', '.join(needed)}") + + +# ── 3. inferencia ───────────────────────────────────────────────────────────────────────────── +def probar_inferencia(art, modelo): + print("\n\033[1m3. Inferencia — ¿sale un token de un modelo de verdad?\033[0m") + r = subprocess.run([str(art / "usr/bin/llama-cli"), "-m", str(modelo), + "-p", "Once upon a time", "-n", "24", "--no-warmup", + "--single-turn", "--seed", "1"], + capture_output=True, text=True, timeout=180) + if r.returncode != 0: + return mal(f"llama-cli salió {r.returncode}: {r.stderr.strip().splitlines()[-1:] or ''}") + # La salida del turno viene después del prompt echoado; nos alcanza con que haya texto nuevo. + cuerpo = r.stdout.split("Once upon a time", 1)[-1] + generado = "".join(l for l in cuerpo.splitlines() + if l.strip() and not l.startswith(("[", ">", "Exiting"))) + (ok if len(generado.strip()) >= 10 else mal)( + f"generó {len(generado.strip())} caracteres: {generado.strip()[:70]!r}") + + +# ── 4. embeddings ───────────────────────────────────────────────────────────────────────────── +def vector(puerto, texto): + req = urllib.request.Request( + f"http://127.0.0.1:{puerto}/v1/embeddings", + data=json.dumps({"input": texto, "model": "prueba"}).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=60) as r: + return json.load(r)["data"][0]["embedding"] + + +def probar_embeddings(art, modelo): + print("\n\033[1m4. Embeddings — el endpoint del que cuelga la mitad semántica del §6.3\033[0m") + puerto = 18131 + srv = subprocess.Popen( + # ⚠ `--pooling mean` NO es un gusto, y la razón conviene saberla ANTES de pinear el + # modelo de producción: un GGUF declara su tipo de pooling en los metadatos, y los modelos + # de EMBEDDING de verdad (e5, bge, nomic) lo traen. `stories260K` es un LM causal de + # juguete y no lo trae, así que llama.cpp cae en `none` y el endpoint compatible con OpenAI + # contesta **400: "Pooling type \'none\' is not OAI compatible"** — no un vector vacío, no + # un 500: un 400 que parece un error del cliente. Se fuerza `mean`, que es lo que hace + # cualquier motor de embeddings sobre un LM causal. + [str(art / "usr/bin/llama-server"), "-m", str(modelo), "--embedding", + "--pooling", "mean", + "--host", "127.0.0.1", "--port", str(puerto), "-c", "512", "--no-warmup"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + listo = False + for _ in range(120): + if srv.poll() is not None: + break + # ⚠ `/health` contesta MIENTRAS carga, con 503 y `{"status":"loading model"}`. Tomar + # «contestó» por «listo» manda la primera consulta contra un servidor a medio levantar + # y devuelve un 503 que se lee como «el endpoint no existe». Se espera el `ok`. + try: + with urllib.request.urlopen(f"http://127.0.0.1:{puerto}/health", timeout=2) as r: + if json.load(r).get("status") == "ok": + listo = True + break + except Exception: + pass + time.sleep(0.5) + if not listo: + return mal(f"llama-server no levantó (salió {srv.poll()})") + ok(f"llama-server --embedding escucha en :{puerto}") + + a1 = vector(puerto, "el zorro salta sobre el perro") + a2 = vector(puerto, "el zorro salta sobre el perro") + b1 = vector(puerto, "la reunión de contabilidad es el martes") + + (ok if len(a1) > 8 else mal)(f"devuelve un vector de {len(a1)} dimensiones") + (ok if a1 == a2 else mal)( + "el MISMO texto da el MISMO vector" if a1 == a2 + else "el mismo texto dio DOS vectores distintos — no es determinista") + (ok if a1 != b1 else mal)( + "dos textos DISTINTOS dan vectores distintos" if a1 != b1 + else "dos textos distintos dieron el MISMO vector — es un stub, no un modelo") + if any(x != 0.0 for x in a1): + ok("el vector no es todo ceros") + else: + mal("el vector es TODO CEROS — pasaría cualquier prueba de «¿hay un vector?»") + finally: + srv.terminate() + try: + srv.wait(timeout=10) + except subprocess.TimeoutExpired: + srv.kill() + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--sin-modelo", action="store_true", + help="control negativo: las sondas 3 y 4 corren contra un modelo inexistente " + "y TIENEN que fallar") + a = p.parse_args() + + art = artefacto() + print(f"\033[1martefacto:\033[0m {art.name}") + + if a.sin_modelo: + print("\n\033[33m── CONTROL NEGATIVO: modelo inexistente; lo correcto es que FALLE ──\033[0m") + modelo = RAIZ / "work/llama-modelos/NO-EXISTE.gguf" + probar_inferencia(art, modelo) + probar_embeddings(art, modelo) + if fallos: + print(f"\n\033[32m✓ CONTROL NEGATIVO OK\033[0m — {len(fallos)} sondas fallaron, " + "como tenían que fallar. El arnés mide algo.") + return 0 + print("\n\033[31m✗ CONTROL NEGATIVO ROTO\033[0m — todo pasó SIN modelo: el arnés no mide nada.") + return 1 + + probar_isa(art) + probar_hermetico(art) + modelo = bajar_modelo() + probar_inferencia(art, modelo) + probar_embeddings(art, modelo) + + print() + if fallos: + print(f"\033[31m✗ {len(fallos)} fallo(s)\033[0m") + for f in fallos: + print(f" · {f}") + return 1 + print("\033[32m✓ el motor de inferencia local del corpus infiere y vectoriza\033[0m") + return 0 + + +if __name__ == "__main__": + sys.exit(main())