feat(nouser): Phase D — proveedor Nous mock + cliente remoto
Cierra el patrón "Nous como módulo aparte intercambiable": el contrato
del proveedor de embeddings vive en su crate, el mock determinístico
implementa ese contrato sirviéndolo por Unix socket, y nouser-core
sabe consumirlo remotamente. El switch mock↔real (futuro) será vía
priority_contexts en el broker.
Crates nuevos:
- crates/modules/nouser/nous: contrato compartido.
- EmbedRequest { kind: { EmbedFile | EmbedText | Ping }, payload }.
- EmbedFilePayload (path, ext, size, mtime), EmbedTextPayload.
- EmbedResponse (embedding, model, elapsed_ms), PingResponse,
ErrorResponse.
- Wire: line-delimited JSON sobre Unix socket, single-shot.
- Constants FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NAME.
- transport::default_socket_path con env NOUSER_NOUS_SOCKET.
- crates/modules/nouser/nous-mock: bin nouser-nous-mock.
- Sidecarea a brahman-init con Card kind=Ente declarando los flows
embed-request/embed-result + priority_contexts.test = +1.
- Bind del socket Nous + accept loop tokio.
- EmbedFile delega a nouser_core::embed::embed (Phase C).
- Modelo: "mock-pseudo-32d".
Cambios:
- nouser-core: dep nueva nouser-nous. Subcomando attract --remote
abre un UnixStream blocking, envía EmbedRequest, lee response.
Imprime "embed: local|remote" para ver cuál ruta corrió.
Bug encontrado y corregido:
- ContextBias tenía #[serde(skip_serializing_if = ...)] en sus campos.
Postcard NO soporta skip-condicional en formatos no self-describing:
el serializer omitía bytes que el deserializer esperaba, rompiendo
la wire de cualquier Card con priority_contexts poblada.
Síntoma: "postcard decode: Hit the end of buffer" en el server,
"early eof" en el cliente.
- Fix: removidos los skip_serializing_if de ContextBias. JSON pretty
ahora emite {"pin_to": null, "priority_offset": 0} pero el wire
funciona. Trade-off aceptado.
- Test wirecard_postcard_with_priority_contexts en brahman-card que
ejercita el roundtrip postcard con biases poblados.
Validación end-to-end:
$ ente-zero & nouser-nous-mock & nouser daemon crates/core
$ brahman-status
Sessions (7):
[ente] nouser.nous_mock flows: embed-request, embed-result
[ente] brahman.nouser_engine
[data] src summary: 6 archivos en crates/core/brahman-handshake/src
[data] graph summary: 7 archivos en crates/core/ente-zero/src/graph
...
$ nouser attract --remote crates/core <archivo>.rs
embed: remote
🧲 0.9058 src ...
(mock log: embed_file path=...)
Tests: 75. cargo check --workspace: 0 errores, 0 warnings.
Próximo natural: Phase D-2 — real-nous con ONNX/Llama text-embedding.
Declara la misma Card con priority_contexts.prod = +1 y el swap es
transparente para el consumer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -206,16 +206,28 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
||||
}
|
||||
|
||||
fn cmd_attract(args: &[String]) -> Cmd {
|
||||
let dir = require_dir(args)?;
|
||||
let file_path = args.get(1).ok_or("falta argumento <file>")?;
|
||||
let file_path = std::path::PathBuf::from(file_path);
|
||||
let mut remote = false;
|
||||
let mut positional: Vec<&String> = Vec::new();
|
||||
for a in args {
|
||||
if a == "--remote" {
|
||||
remote = true;
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
let dir = positional
|
||||
.first()
|
||||
.map(|s| std::path::PathBuf::from(s.as_str()))
|
||||
.ok_or("falta argumento <dir>")?;
|
||||
let file_path = positional.get(1).ok_or("falta argumento <file>")?;
|
||||
let file_path = std::path::PathBuf::from(file_path.as_str());
|
||||
if !file_path.exists() {
|
||||
return Err(format!("archivo no existe: {}", file_path.display()).into());
|
||||
}
|
||||
|
||||
let (db, _) = run_scan(&dir)?;
|
||||
|
||||
// Construimos un FileEntry para el archivo objetivo y sacamos su embedding.
|
||||
// Construimos un FileEntry para el archivo objetivo.
|
||||
let metadata = std::fs::metadata(&file_path)?;
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
@@ -234,7 +246,16 @@ fn cmd_attract(args: &[String]) -> Cmd {
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_lowercase()),
|
||||
};
|
||||
let target_vec = embed::embed(&target);
|
||||
|
||||
// Embedding: --remote consulta al socket de nouser-nous; sin flag,
|
||||
// se computa localmente. El resultado debe ser idéntico mientras
|
||||
// el proveedor sea el mock determinista.
|
||||
let (target_vec, source) = if remote {
|
||||
let v = remote_embed(&target)?;
|
||||
(v, "remote")
|
||||
} else {
|
||||
(embed::embed(&target).to_vec(), "local")
|
||||
};
|
||||
|
||||
// Ranking completo, no sólo el ganador — útil para entender qué
|
||||
// Mónadas son secundarias.
|
||||
@@ -252,6 +273,7 @@ fn cmd_attract(args: &[String]) -> Cmd {
|
||||
|
||||
println!("archivo: {}", file_path.display());
|
||||
println!("scan dir: {}", dir.display());
|
||||
println!("embed: {}", source);
|
||||
println!("ranking de atracción (cosine similarity):");
|
||||
println!();
|
||||
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
|
||||
@@ -280,6 +302,51 @@ fn cmd_attract(args: &[String]) -> Cmd {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cliente blocking del socket nouser-nous. Conecta, envía un
|
||||
/// `EmbedRequest`, lee la response, devuelve el vector. Single-shot.
|
||||
fn remote_embed(file: &nouser_card::FileEntry) -> Result<Vec<f32>, Box<dyn std::error::Error>> {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let sock_path = nouser_nous::transport::default_socket_path();
|
||||
if !sock_path.exists() {
|
||||
return Err(format!(
|
||||
"socket nouser-nous no existe en {} — corrió nouser-nous-mock?",
|
||||
sock_path.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let mut stream = UnixStream::connect(&sock_path)?;
|
||||
let req = nouser_nous::EmbedRequest {
|
||||
kind: nouser_nous::RequestKind::EmbedFile,
|
||||
payload: serde_json::to_value(nouser_nous::EmbedFilePayload {
|
||||
path: file.path.display().to_string(),
|
||||
extension: file.extension.clone(),
|
||||
size: file.size,
|
||||
mtime_ms: file.mtime_ms,
|
||||
})?,
|
||||
};
|
||||
let line = serde_json::to_string(&req)?;
|
||||
stream.write_all(line.as_bytes())?;
|
||||
stream.write_all(b"\n")?;
|
||||
stream.flush()?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response)?;
|
||||
if response.is_empty() {
|
||||
return Err("nouser-nous cerró sin respuesta".into());
|
||||
}
|
||||
|
||||
// Intentamos primero como response normal; si falla, como error.
|
||||
if let Ok(resp) = serde_json::from_str::<nouser_nous::EmbedResponse>(&response) {
|
||||
return Ok(resp.embedding);
|
||||
}
|
||||
let err: nouser_nous::ErrorResponse = serde_json::from_str(&response)?;
|
||||
Err(format!("nouser-nous: {}", err.error).into())
|
||||
}
|
||||
|
||||
/// Card del propio engine (kind=Ente). Es el "ser" que produce y
|
||||
/// administra Mónadas; aparece en brahman-status junto a sus Mónadas.
|
||||
fn build_engine_card() -> brahman_card::Card {
|
||||
|
||||
Reference in New Issue
Block a user