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:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "nouser-nous"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Nouser — protocolo Nous: contrato JSON line-delimited entre nouser-core y los proveedores de embeddings (mock o LLM real)."
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,182 @@
|
||||
//! `nouser-nous` — el contrato del proveedor de embeddings.
|
||||
//!
|
||||
//! Define el wire-format compartido entre `nouser-core` (consumidor) y
|
||||
//! cualquier implementación de Nous (mock determinista o LLM real). El
|
||||
//! protocolo es **line-delimited JSON** sobre Unix socket: cada conexión
|
||||
//! envía una request, recibe una response, y cierra. Single-shot por
|
||||
//! conexión, igual al admin de brahman.
|
||||
//!
|
||||
//! ## Contrato
|
||||
//!
|
||||
//! ```text
|
||||
//! C → S: {"kind":"embed_file","payload":{...}}\n
|
||||
//! S → C: {"embedding":[...],"model":"mock-pseudo-32d","elapsed_ms":1}\n
|
||||
//! ```
|
||||
//!
|
||||
//! En caso de error:
|
||||
//!
|
||||
//! ```text
|
||||
//! S → C: {"error":"unsupported kind"}\n
|
||||
//! ```
|
||||
//!
|
||||
//! ## Por qué un crate aparte
|
||||
//!
|
||||
//! El consumidor (nouser-core) y el proveedor (nouser-nous-mock,
|
||||
//! nouser-nous-real) deben acordar en types EXACTOS. Tener el contrato
|
||||
//! en su crate evita que cada lado declare structs paralelos que se
|
||||
//! desincronizan. Si bumpeás el wire, bumpeás aquí.
|
||||
//!
|
||||
//! ## Swap por priority_contexts
|
||||
//!
|
||||
//! Cuando existan dos proveedores (mock-nous y real-nous), ambos declaran
|
||||
//! el mismo `flow.output: { name: "embed-result", type: ... }` y
|
||||
//! `flow.input: "embed-request"`. El broker brahman los matchea contra
|
||||
//! los consumidores; el `priority_offset` per-contexto del Card hace que
|
||||
//! mock-nous gane en `test` y real-nous en `prod`. nouser-core sólo
|
||||
//! consume el flow, sin saber cuál implementación corre.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
// =====================================================================
|
||||
// Wire types
|
||||
// =====================================================================
|
||||
|
||||
/// Request al proveedor Nous.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedRequest {
|
||||
pub kind: RequestKind,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Tipo de request. El payload se interpreta según el kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RequestKind {
|
||||
/// payload = `EmbedFilePayload` (path + metadata mínima).
|
||||
EmbedFile,
|
||||
/// payload = `EmbedTextPayload` (string libre).
|
||||
EmbedText,
|
||||
/// payload = `{}`. Devuelve `PingResponse`.
|
||||
Ping,
|
||||
}
|
||||
|
||||
/// Payload para `EmbedFile`. Es la información mínima que el proveedor
|
||||
/// necesita para producir un embedding de archivo determinista.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedFilePayload {
|
||||
pub path: String,
|
||||
pub extension: Option<String>,
|
||||
pub size: u64,
|
||||
/// `mtime` en ms desde UNIX_EPOCH.
|
||||
pub mtime_ms: u64,
|
||||
}
|
||||
|
||||
/// Payload para `EmbedText`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedTextPayload {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Response exitosa con un embedding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbedResponse {
|
||||
/// Vector. Su longitud depende del modelo (mock=32, llama=384, etc.).
|
||||
pub embedding: Vec<f32>,
|
||||
/// Identificador del modelo que produjo el embedding (útil para logs
|
||||
/// y para invalidar caches al cambiar de proveedor).
|
||||
pub model: String,
|
||||
/// Tiempo de cómputo en ms (proveedor lo reporta).
|
||||
pub elapsed_ms: u64,
|
||||
}
|
||||
|
||||
/// Response a Ping. Útil para health-checks y discovery.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PingResponse {
|
||||
pub model: String,
|
||||
pub embed_dim: u32,
|
||||
}
|
||||
|
||||
/// Error retornado por el proveedor en lugar de una response normal.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
|
||||
#[error("nous: {error}")]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Transport
|
||||
// =====================================================================
|
||||
|
||||
pub mod transport {
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Variable de entorno para sobreescribir la ruta del socket.
|
||||
pub const SOCKET_ENV: &str = "NOUSER_NOUS_SOCKET";
|
||||
|
||||
/// Nombre por default del socket dentro del runtime dir.
|
||||
pub const SOCKET_NAME: &str = "nouser-nous.sock";
|
||||
|
||||
/// Ruta canónica al socket de Nous.
|
||||
pub fn default_socket_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var(SOCKET_ENV) {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
let base = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
base.join(SOCKET_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Names compartidos para el broker brahman
|
||||
// =====================================================================
|
||||
|
||||
/// Nombre del flow output del proveedor (entrada del consumidor).
|
||||
pub const FLOW_EMBED_RESULT: &str = "embed-result";
|
||||
|
||||
/// Nombre del flow input del proveedor (salida del consumidor).
|
||||
pub const FLOW_EMBED_REQUEST: &str = "embed-request";
|
||||
|
||||
/// Tipo del flow: el wire es JSON serializado, así que el TypeRef
|
||||
/// declarado en la Card es `primitive::json`.
|
||||
pub const FLOW_TYPE_NAME: &str = "json";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_roundtrip_json() {
|
||||
let req = EmbedRequest {
|
||||
kind: RequestKind::EmbedFile,
|
||||
payload: serde_json::to_value(EmbedFilePayload {
|
||||
path: "/x/y.rs".into(),
|
||||
extension: Some("rs".into()),
|
||||
size: 1024,
|
||||
mtime_ms: 1_700_000_000_000,
|
||||
})
|
||||
.unwrap(),
|
||||
};
|
||||
let s = serde_json::to_string(&req).unwrap();
|
||||
let parsed: EmbedRequest = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed.kind, RequestKind::EmbedFile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_roundtrip() {
|
||||
let resp = EmbedResponse {
|
||||
embedding: vec![0.1, 0.2, 0.3],
|
||||
model: "mock-pseudo-32d".into(),
|
||||
elapsed_ms: 1,
|
||||
};
|
||||
let s = serde_json::to_string(&resp).unwrap();
|
||||
let parsed: EmbedResponse = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(parsed.model, "mock-pseudo-32d");
|
||||
assert_eq!(parsed.embedding.len(), 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user