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:
Sergio
2026-05-08 18:49:25 +00:00
parent 77faf12e82
commit b3c3c00cf2
10 changed files with 693 additions and 11 deletions
+72 -5
View File
@@ -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 {