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
+59 -6
View File
@@ -472,20 +472,20 @@ pub enum Priority {
pub struct ContextBias {
/// Override del `pin_to` estático cuando el broker está en este
/// contexto y la Card actúa como consumidor.
#[serde(default, skip_serializing_if = "Option::is_none")]
///
/// **No se usa `skip_serializing_if` aquí**: postcard requiere
/// layout fijo. La verbosidad extra en JSON (campos null/cero
/// emitidos) es el costo aceptado para compatibilidad de wire.
#[serde(default)]
pub pin_to: Option<String>,
/// Modifica la priority efectiva del Card como productor.
/// `+1` lo eleva, `-1` lo baja. El resultado se clampa al rango de
/// `Priority` ([Low, Critical]).
#[serde(default, skip_serializing_if = "is_zero_i8")]
#[serde(default)]
pub priority_offset: i8,
}
fn is_zero_i8(v: &i8) -> bool {
*v == 0
}
// =====================================================================
// Flujos tipados (del modelo brahman)
// =====================================================================
@@ -1093,6 +1093,59 @@ mod tests {
assert!(c_back.extensions.is_empty(), "extensions sobreviven al wire");
}
#[test]
fn wirecard_postcard_with_priority_contexts() {
// Repro del bug que rompía nouser-nous-mock: ContextBias con
// skip_serializing_if causaba que postcard leyera bytes
// equivocados. Sin esos atributos, el roundtrip es estable.
let src = r#"{
"schema_version": 1,
"id": "01HQAR53D4M2NBV8KZTYXFGS01",
"label": "x",
"soma": {
"namespaces": {"mount":false,"pid":false,"net":false,"uts":false,"ipc":false,"user":false,"cgroup":false},
"rlimits": {"mem_bytes":null,"nproc":null,"nofile":null},
"cgroup": {"path":"x","cpu_weight":null,"io_weight":null},
"cpu_affinity": null
},
"payload": "Virtual",
"supervision": "OneShot"
}"#;
let mut c = Card::from_json(src).unwrap();
c.priority_contexts.insert(
"test".into(),
ContextBias {
pin_to: None,
priority_offset: 1,
},
);
c.priority_contexts.insert(
"prod".into(),
ContextBias {
pin_to: Some("real-nous".into()),
priority_offset: 2,
},
);
let wire: WireCard = c.into();
let bytes = postcard::to_allocvec(&wire).expect("postcard encode");
let decoded: WireCard = postcard::from_bytes(&bytes).expect("postcard decode");
assert_eq!(decoded.priority_contexts.len(), 2);
let test_bias = decoded
.priority_contexts
.get("test")
.expect("test context");
assert_eq!(test_bias.priority_offset, 1);
assert!(test_bias.pin_to.is_none());
let prod_bias = decoded
.priority_contexts
.get("prod")
.expect("prod context");
assert_eq!(prod_bias.pin_to.as_deref(), Some("real-nous"));
assert_eq!(prod_bias.priority_offset, 2);
}
#[test]
fn wire_card_postcard_friendly() {
// Validación: WireCard puede ser postcard-encoded sin error.