atuq §6.7: entra el motor de inferencia local — y SOURCE_DATE_EPOCH le había apagado el AVX2
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqBhowvFe3aiieGCKgvxwa
This commit is contained in:
@@ -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"]
|
||||
Executable
+263
@@ -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())
|
||||
Reference in New Issue
Block a user