tawasuyu

tawasuyu/brahman

Rust 326TOML 123JSON 17Markdown 144606 Definiciones

⎇ d3649bfd1a80153652c2a028315369bb4b184024 crates/modules/nouser/nous-mock/src/main.rs Rust · 247 líneas · 8.5 KB Historia Quién tocó qué crudo

feat(nouser): centroid_model — versionado de embeddings 820a1a33b · Sergio ·

1//! `nouser-nous-mock` — proveedor de embeddings determinista (sin LLM).
2//!
3//! Implementa el contrato `nouser-nous` usando los pseudo-embeddings
4//! de Phase C (`nouser_core::embed`). Sirve como:
5//!
6//! - **Mock para tests**: en `BRAHMAN_BROKER_CONTEXT=test`, el
7//!   `priority_offset` per-contexto declarado en su Card lo prioriza
8//!   sobre cualquier proveedor real.
9//! - **Bootstrap**: hasta que llegue el LLM real (Phase D futura), el
10//!   sistema funciona end-to-end con embeddings determinísticos.
11//!
12//! ## Vida del proceso
13//!
14//! 1. Sidecarea a brahman-init declarando una Card con flow output
15//!    `embed-result:json` y flow input `embed-request:json`. Su
16//!    `priority_contexts.test = { priority_offset: +1 }` lo prioriza
17//!    cuando el broker corre bajo contexto test.
18//! 2. Bind del Unix socket en `$NOUSER_NOUS_SOCKET` (default
19//!    `$XDG_RUNTIME_DIR/nouser-nous.sock`).
20//! 3. Loop: accept → read line JSON → process → write line JSON → close.
21//! 4. Cada request se loggea (info) — útil para verificar que el
22//!    consumidor está usando este proveedor.
23
24use std::collections::BTreeMap;
25use std::path::PathBuf;
26use std::time::{Instant, SystemTime, UNIX_EPOCH};
27
28use brahman_card::{
29    ulid::Ulid, Card, CardKind, ContextBias, Flow, Flows, Lifecycle, Payload, Priority,
30    Supervision, TypeRef,
31};
32use nouser_card::FileEntry;
33use nouser_core::embed;
34use nouser_nous::{
35    transport, EmbedFilePayload, EmbedRequest, EmbedResponse, EmbedTextPayload, ErrorResponse,
36    PingResponse, RequestKind, FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NAME,
37};
38use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
39use tokio::net::{UnixListener, UnixStream};
40use tracing::{info, warn};
41
42/// El mock implementa el MISMO algoritmo que `nouser_core::embed`,
43/// así que reportamos el mismo `MODEL_ID` que él. De otro modo el
44/// consumer filtraría las Mónadas como "modelo distinto" y los
45/// scores quedarían vacíos.
46const MODEL_ID: &str = nouser_core::embed::MODEL_ID;
47
48#[tokio::main(flavor = "current_thread")]
49async fn main() -> std::io::Result<()> {
50    init_tracing();
51
52    // 1. Resolver socket del data-plane ANTES de armar la Card, para
53    //    declararlo en `Card.service_socket` y que los consumidores lo
54    //    descubran vía MatchEvent.
55    let sock_path = transport::provider_socket_path("mock");
56    if sock_path.exists() {
57        std::fs::remove_file(&sock_path)?;
58    }
59    if let Some(parent) = sock_path.parent() {
60        std::fs::create_dir_all(parent)?;
61    }
62    let listener = UnixListener::bind(&sock_path)?;
63    info!(socket = %sock_path.display(), "nouser-nous-mock escuchando");
64
65    // 2. Sidecar al brahman-init con la Card que declara el socket.
66    let card = build_card(sock_path.clone());
67    info!(label = %card.label, "publicando Card al brahman-init");
68    brahman_sidecar::spawn(card);
69
70    // 3. Accept loop.
71    loop {
72        let (stream, _addr) = listener.accept().await?;
73        tokio::spawn(async move {
74            if let Err(e) = handle_conn(stream).await {
75                warn!(error = %e, "conn falló");
76            }
77        });
78    }
79}
80
81fn init_tracing() {
82    tracing_subscriber::fmt()
83        .with_env_filter(
84            tracing_subscriber::EnvFilter::try_from_default_env()
85                .unwrap_or_else(|_| "info".into()),
86        )
87        .with_target(false)
88        .compact()
89        .init();
90}
91
92/// Card que el mock anuncia al brahman-init. Es kind=Ente (un proceso),
93/// con flujos JSON, bias de prioridad para contexto `test`, y el socket
94/// data-plane declarado en `service_socket` (consumidores lo reciben
95/// directo en el `MatchEvent::Available`).
96fn build_card(service_socket: std::path::PathBuf) -> Card {
97    let mut priority_contexts = BTreeMap::new();
98    priority_contexts.insert(
99        "test".into(),
100        ContextBias {
101            pin_to: None,
102            // En contexto test, este mock gana sobre cualquier real-nous.
103            priority_offset: 1,
104        },
105    );
106
107    Card {
108        schema_version: brahman_card::CARD_SCHEMA_VERSION,
109        id: Ulid::new(),
110        label: "nouser.nous_mock".into(),
111        payload: Payload::Virtual,
112        supervision: Supervision::Delegate,
113        lifecycle: Lifecycle::Daemon,
114        priority: Priority::Normal,
115        kind: CardKind::Ente,
116        service_socket: Some(service_socket),
117        flow: Flows {
118            input: vec![Flow {
119                name: FLOW_EMBED_REQUEST.into(),
120                ty: TypeRef::Primitive {
121                    name: FLOW_TYPE_NAME.into(),
122                },
123                pin_to: None,
124            }],
125            output: vec![Flow {
126                name: FLOW_EMBED_RESULT.into(),
127                ty: TypeRef::Primitive {
128                    name: FLOW_TYPE_NAME.into(),
129                },
130                pin_to: None,
131            }],
132        },
133        priority_contexts,
134        ..Default::default()
135    }
136}
137
138/// Procesa una conexión single-shot: lee una línea JSON, despacha,
139/// escribe una línea JSON, cierra.
140async fn handle_conn(stream: UnixStream) -> std::io::Result<()> {
141    let mut reader = BufReader::new(stream);
142    let mut line = String::new();
143    let n = reader.read_line(&mut line).await?;
144    if n == 0 {
145        return Ok(());
146    }
147    let req: EmbedRequest = match serde_json::from_str(&line) {
148        Ok(r) => r,
149        Err(e) => {
150            return write_error(reader.into_inner(), format!("JSON inválido: {e}")).await;
151        }
152    };
153
154    let started = Instant::now();
155    let result = match req.kind {
156        RequestKind::EmbedFile => handle_embed_file(req.payload, started),
157        RequestKind::EmbedText => handle_embed_text(req.payload, started),
158        RequestKind::Ping => handle_ping(),
159    };
160
161    let mut stream = reader.into_inner();
162    match result {
163        Ok(payload) => {
164            stream.write_all(payload.as_bytes()).await?;
165            stream.write_all(b"\n").await?;
166        }
167        Err(msg) => {
168            return write_error(stream, msg).await;
169        }
170    }
171    stream.shutdown().await?;
172    Ok(())
173}
174
175fn handle_embed_file(payload: serde_json::Value, started: Instant) -> Result<String, String> {
176    let p: EmbedFilePayload =
177        serde_json::from_value(payload).map_err(|e| format!("payload inválido: {e}"))?;
178    info!(path = %p.path, "embed_file");
179
180    let file = FileEntry {
181        id: nouser_card::FileId::from(Ulid::new()),
182        path: PathBuf::from(p.path),
183        content_hash: None,
184        size: p.size,
185        mtime_ms: p.mtime_ms,
186        extension: p.extension,
187    };
188    let v = embed::embed(&file);
189
190    let resp = EmbedResponse {
191        embedding: v.to_vec(),
192        model: MODEL_ID.into(),
193        elapsed_ms: started.elapsed().as_millis() as u64,
194    };
195    serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
196}
197
198fn handle_embed_text(payload: serde_json::Value, started: Instant) -> Result<String, String> {
199    let p: EmbedTextPayload =
200        serde_json::from_value(payload).map_err(|e| format!("payload inválido: {e}"))?;
201    info!(text_len = p.text.len(), "embed_text");
202
203    // Mock: tratamos el texto como un "stem" sintético y rellenamos el
204    // resto del vector con ceros. No es semánticamente útil, pero respeta
205    // la forma para que el cliente no se rompa.
206    let synthetic = FileEntry {
207        id: nouser_card::FileId::from(Ulid::new()),
208        path: PathBuf::from(format!("synthetic://{}", p.text)),
209        content_hash: None,
210        size: p.text.len() as u64,
211        mtime_ms: now_ms(),
212        extension: Some("text".into()),
213    };
214    let v = embed::embed(&synthetic);
215
216    let resp = EmbedResponse {
217        embedding: v.to_vec(),
218        model: MODEL_ID.into(),
219        elapsed_ms: started.elapsed().as_millis() as u64,
220    };
221    serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
222}
223
224fn handle_ping() -> Result<String, String> {
225    let resp = PingResponse {
226        model: MODEL_ID.into(),
227        embed_dim: embed::EMBED_DIM as u32,
228    };
229    serde_json::to_string(&resp).map_err(|e| format!("encode: {e}"))
230}
231
232async fn write_error(mut stream: UnixStream, msg: String) -> std::io::Result<()> {
233    warn!(error = %msg, "respuesta de error");
234    let resp = ErrorResponse { error: msg };
235    let json = serde_json::to_string(&resp).unwrap_or_else(|_| "{\"error\":\"encode\"}".into());
236    stream.write_all(json.as_bytes()).await?;
237    stream.write_all(b"\n").await?;
238    stream.shutdown().await?;
239    Ok(())
240}
241
242fn now_ms() -> u64 {
243    SystemTime::now()
244        .duration_since(UNIX_EPOCH)
245        .map(|d| d.as_millis() as u64)
246        .unwrap_or(0)
247}