refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "akasha-card"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Nouser — manifiesto de Mónada (agrupación semántica de archivos). Espejo de brahman-card pero para datos."
[dependencies]
brahman-card = { path = "../../../protocol/brahman-card" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
+431
View File
@@ -0,0 +1,431 @@
//! `akasha-card` — manifiesto de Mónada.
//!
//! Una **Mónada** es una agrupación semántica de archivos: el archivo
//! físico no se mueve, pero su pertenencia se modela por un objeto
//! ([`MonadManifest`]) con identidad propia, métricas y un "lente" de
//! visualización. La idea hereda el espíritu de la Tarjeta de
//! Presentación de Brahman (`brahman-card::Card`): un manifiesto
//! tipado, validado y serializable que define qué es la entidad y
//! cómo el sistema debe interactuar con ella.
//!
//! Diferencia con `brahman-card::Card`:
//!
//! | brahman::Card | akasha::MonadManifest |
//! |-------------------------------------|-------------------------------|
//! | Describe una **entidad runtime** | Describe una **agrupación** |
//! | Tiene `payload`/`soma`/`supervision`| No tiene proceso detrás |
//! | Vive durante una sesión | Vive en una DB persistente |
//! | Fluye por handshake/postcard | Fluye por queries del backend |
//!
//! Este crate sólo define los tipos. La lógica de scan, cluster,
//! attraction vive en `akasha-core`.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ulid::Ulid;
// Re-export para consumidores
pub use ::ulid;
pub mod query;
/// Versión del esquema del manifiesto. Bump al cambiar el schema.
pub const MONAD_SCHEMA_VERSION: u16 = 1;
/// Identificador opaco de un archivo registrado en la DB.
pub type FileId = Ulid;
/// Identificador opaco de una Mónada.
pub type MonadId = Ulid;
// =====================================================================
// FileEntry — el archivo como dato indexado
// =====================================================================
/// Registro físico de un archivo en la DB. Es la unidad atómica que
/// pertenece a (potencialmente varias) Mónadas.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
pub id: FileId,
pub path: PathBuf,
/// Hash de contenido (blake3) — sólo se computa si el archivo es
/// chico o el usuario lo pidió. `None` por default en Phase 0.
#[serde(default)]
pub content_hash: Option<[u8; 32]>,
/// Tamaño en bytes.
pub size: u64,
/// `mtime` como ms desde UNIX_EPOCH.
pub mtime_ms: u64,
/// Extensión normalizada en lowercase, sin punto. `None` si no tiene.
#[serde(default)]
pub extension: Option<String>,
}
// =====================================================================
// Lens — la "vista" preferida de una Mónada
// =====================================================================
/// Lente de visualización dominante. La UI (nahual) elige cómo renderizar
/// los miembros de una Mónada según este hint.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Lens {
/// Grid genérico: thumbnail + nombre + meta.
#[default]
Grid,
/// Editor de código con highlighting (rs, py, ts, ...).
Code,
/// Galería de imágenes (png, jpg, svg, ...).
Gallery,
/// Vista tabular (csv, sqlite, ...).
Database,
/// Texto renderizado (md, rst, txt).
Markdown,
/// Árbol jerárquico (cuando la Mónada es estructural).
Tree,
}
// =====================================================================
// MonadManifest — la Tarjeta de Presentación de la Mónada
// =====================================================================
/// Manifiesto de una Mónada. Equivalente conceptual a la Tarjeta de
/// Presentación de Brahman, pero para una agrupación de datos.
///
/// Se serializa a JSON/TOML para persistencia y debugging; es el
/// "ADN" que la UI lee para saber cómo presentar la Mónada sin tocar
/// el disco.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonadManifest {
/// Versión del esquema. Bump = romper compatibilidad de DB.
pub schema_version: u16,
/// Identificador opaco. ULID — orderable por tiempo de creación.
pub id: MonadId,
/// Mónada de la que ésta fue derivada (split, merge), si aplica.
#[serde(default)]
pub lineage: Option<MonadId>,
/// Nombre humano corto (1-4 palabras, generado por reglas o por Nous).
pub label: String,
/// Resumen de propósito (1-2 oraciones). Generado por Nous cuando
/// la masa de la Mónada justifica la consulta.
#[serde(default)]
pub summary: String,
/// Centroide vectorial (embedding promedio de los miembros). Vacío
/// en Phase 0 (sin embeddings); se llena cuando entran las
/// pseudo-embeddings o el modelo real.
#[serde(default)]
pub centroid: Vec<f32>,
/// Identificador del modelo que produjo `centroid`. Si está set, los
/// consumidores deben verificar coincidencia antes de comparar vía
/// cosine similarity con embeddings recientes; al cambiar de modelo
/// (mock-pseudo-32d → real-fastembed-384d, etc.) los centroides
/// previos quedan inválidos por dimensión y semántica.
/// `None` = legacy (centroides sin tag, pre-versioning).
#[serde(default)]
pub centroid_model: Option<String>,
/// Identidad estable derivada del origen de los miembros. Para
/// Mónadas creadas por `cluster::by_directory`, es el path
/// canónico del directorio padre. Permite que la hidratación
/// reuse el mismo ULID across re-scans (mismo path_hint = misma
/// identidad, aunque cambien los miembros internamente).
/// `None` para Mónadas creadas por estrategias que no se anclan a
/// un origen físico.
#[serde(default)]
pub path_hint: Option<String>,
/// Tokens dominantes: extensiones, palabras clave, etc.
/// 5-10 elementos típicamente.
#[serde(default)]
pub keywords: Vec<String>,
/// Cantidad de miembros (== `members.len()`). Cacheado para evitar
/// el cost de leer la lista cada vez.
pub cardinality: u32,
/// Métrica de dispersión interna [0.0, 1.0]:
/// - 0.0: todos los miembros son muy similares (Mónada coherente).
/// - 1.0: miembros muy heterogéneos (sugerencia: bifurcar).
///
/// Calculada como entropía de Shannon normalizada sobre las
/// extensiones de los miembros.
#[serde(default)]
pub entropy: f32,
/// Lente preferido para visualización en la UI.
#[serde(default)]
pub dominant_lens: Lens,
/// Archivos anclados manualmente: NO se mueven en re-clustering
/// automático. El usuario "fija" estos miembros.
#[serde(default)]
pub pins: BTreeSet<FileId>,
/// IDs de archivos miembros (incluye pins).
pub members: BTreeSet<FileId>,
/// Unix ms de creación de la Mónada.
pub created_at_ms: u64,
/// Unix ms de la última actualización (re-cluster, re-name, ...).
pub updated_at_ms: u64,
/// Forward-compat: campos JSON desconocidos preservados.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, serde_json::Value>,
}
// =====================================================================
// Errores y validación
// =====================================================================
#[derive(Debug, Error)]
pub enum MonadError {
#[error("schema mismatch: got {got}, expected {expected}")]
SchemaMismatch { got: u16, expected: u16 },
#[error("label vacío")]
EmptyLabel,
#[error("label demasiado largo: {0} bytes (max 256)")]
LabelTooLong(usize),
#[error("entropía fuera de [0,1]: {0}")]
InvalidEntropy(f32),
#[error("Monad sin miembros y sin pins")]
Empty,
#[error("cardinalidad declarada {declared} ≠ members.len() {actual}")]
CardinalityMismatch { declared: u32, actual: u32 },
#[error("JSON inválido: {0}")]
Json(#[from] serde_json::Error),
}
impl MonadManifest {
/// Constructor con defaults razonables. `id` y timestamps se
/// generan; resto vacío.
pub fn new(label: impl Into<String>) -> Self {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
Self {
schema_version: MONAD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
summary: String::new(),
centroid: Vec::new(),
centroid_model: None,
path_hint: None,
keywords: Vec::new(),
cardinality: 0,
entropy: 0.0,
dominant_lens: Lens::default(),
pins: BTreeSet::new(),
members: BTreeSet::new(),
created_at_ms: now_ms,
updated_at_ms: now_ms,
extensions: BTreeMap::new(),
}
}
/// Validación semántica.
pub fn validate(&self) -> Result<(), MonadError> {
if self.schema_version != MONAD_SCHEMA_VERSION {
return Err(MonadError::SchemaMismatch {
got: self.schema_version,
expected: MONAD_SCHEMA_VERSION,
});
}
if self.label.trim().is_empty() {
return Err(MonadError::EmptyLabel);
}
if self.label.len() > 256 {
return Err(MonadError::LabelTooLong(self.label.len()));
}
if !(0.0..=1.0).contains(&self.entropy) {
return Err(MonadError::InvalidEntropy(self.entropy));
}
if self.members.is_empty() && self.pins.is_empty() {
return Err(MonadError::Empty);
}
let actual = self.members.len() as u32;
if self.cardinality != actual {
return Err(MonadError::CardinalityMismatch {
declared: self.cardinality,
actual,
});
}
Ok(())
}
/// Serializa a JSON pretty.
pub fn to_json_pretty(&self) -> Result<String, MonadError> {
Ok(serde_json::to_string_pretty(self)?)
}
/// Deserializa desde JSON y valida.
pub fn from_json(src: &str) -> Result<Self, MonadError> {
let m: Self = serde_json::from_str(src)?;
m.validate()?;
Ok(m)
}
/// Recalcula `cardinality` y `updated_at_ms` desde `members`.
/// Usar tras mutaciones del set de miembros.
pub fn touch(&mut self) {
self.cardinality = self.members.len() as u32;
self.updated_at_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
}
/// Proyecta el `MonadManifest` a la `brahman_card::Card` que viaja
/// por el protocolo. La Card resultante:
///
/// - hereda `id` y `label` del manifiesto (ULID estable).
/// - `kind = CardKind::Data` (se distingue de un Ente).
/// - `payload = Virtual`, `supervision = Delegate`,
/// `lifecycle = Daemon` — placeholder semántico: la Mónada no se
/// "ejecuta", el daemon dueño la mantiene viva.
/// - `data = Some(DataFacet { ... })` con summary, keywords,
/// centroide, member_count, dispersión y un hint de presentación
/// derivado del `dominant_lens`.
/// - Los miembros completos NO viajan en la Card — se consultan al
/// daemon dueño bajo demanda. Lo que viaja es metadata liviana
/// apta para el wire postcard.
pub fn to_brahman_card(&self) -> brahman_card::Card {
use brahman_card::{
Card, CardKind, DataFacet, Lifecycle, Payload, Priority, Supervision,
};
let presentation_hint = match self.dominant_lens {
Lens::Grid => "grid",
Lens::Code => "code",
Lens::Gallery => "gallery",
Lens::Database => "database",
Lens::Markdown => "markdown",
Lens::Tree => "tree",
}
.to_string();
Card {
schema_version: brahman_card::CARD_SCHEMA_VERSION,
id: self.id,
label: self.label.clone(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Data,
data: Some(DataFacet {
summary: self.summary.clone(),
keywords: self.keywords.clone(),
centroid: self.centroid.clone(),
member_count: self.cardinality,
dispersion: self.entropy,
presentation_hint,
}),
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_minimal() {
let mut m = MonadManifest::new("test");
m.members.insert(Ulid::new());
m.touch();
m.validate().expect("debe validar");
}
#[test]
fn empty_label_rejected() {
let mut m = MonadManifest::new("x");
m.label = String::new();
m.members.insert(Ulid::new());
m.touch();
assert!(matches!(m.validate(), Err(MonadError::EmptyLabel)));
}
#[test]
fn entropy_out_of_range_rejected() {
let mut m = MonadManifest::new("x");
m.members.insert(Ulid::new());
m.entropy = 1.5;
m.touch();
assert!(matches!(m.validate(), Err(MonadError::InvalidEntropy(_))));
}
#[test]
fn empty_members_rejected() {
let m = MonadManifest::new("x");
assert!(matches!(m.validate(), Err(MonadError::Empty)));
}
#[test]
fn cardinality_mismatch_caught() {
let mut m = MonadManifest::new("x");
m.members.insert(Ulid::new());
// No llamamos touch — cardinality queda en 0 con 1 miembro.
assert!(matches!(
m.validate(),
Err(MonadError::CardinalityMismatch { .. })
));
}
#[test]
fn projects_to_brahman_card() {
let mut m = MonadManifest::new("test-monad");
m.summary = "monad de prueba".into();
m.keywords = vec!["rs".into(), "toml".into()];
m.dominant_lens = Lens::Code;
m.entropy = 0.42;
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.touch();
let bc = m.to_brahman_card();
assert_eq!(bc.id, m.id);
assert_eq!(bc.label, "test-monad");
assert_eq!(bc.kind, brahman_card::CardKind::Data);
let data = bc.data.expect("data facet presente");
assert_eq!(data.summary, "monad de prueba");
assert_eq!(data.keywords, vec!["rs".to_string(), "toml".to_string()]);
assert_eq!(data.member_count, 3);
assert!((data.dispersion - 0.42).abs() < 1e-6);
assert_eq!(data.presentation_hint, "code");
}
#[test]
fn json_roundtrip() {
let mut m = MonadManifest::new("test-monad");
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.keywords = vec!["rs".into(), "toml".into()];
m.summary = "test summary".into();
m.dominant_lens = Lens::Code;
m.touch();
let s = m.to_json_pretty().unwrap();
let m2 = MonadManifest::from_json(&s).unwrap();
assert_eq!(m2.label, m.label);
assert_eq!(m2.cardinality, 2);
assert_eq!(m2.dominant_lens, Lens::Code);
assert_eq!(m2.keywords, m.keywords);
}
}
+278
View File
@@ -0,0 +1,278 @@
//! Wire types para consultar al daemon `akasha` por sus Mónadas.
//!
//! El daemon expone un Unix socket (cuyo path se publica en
//! `Card.service_socket` y se descubre vía broker MatchEvent). Cada
//! conexión es single-shot: una request JSON terminada en `\n`,
//! una response JSON terminada en `\n`, cierre.
//!
//! Mismo patrón que `akasha-nous` (mock/real ↔ akasha-core), reusado
//! ahora para que la UI (`akasha-explorer`) descubra y consulte al
//! daemon sin hardcodear sockets ni pasar por brahman-admin.
//!
//! ## Contrato
//!
//! ```text
//! C → S: {"kind":"list_monads"}\n
//! S → C: {"engine":{...},"monads":[...]}\n
//! ```
//!
//! En caso de error:
//!
//! ```text
//! S → C: {"error":"unsupported kind"}\n
//! ```
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ulid::Ulid;
use crate::{Lens, MonadId, MonadManifest};
// =====================================================================
// Constants compartidos para el broker brahman
// =====================================================================
/// Nombre del flow output del daemon (input del consumer/explorer).
pub const FLOW_MONAD_LIST: &str = "monad-list";
/// Tipo del flow: el wire es JSON, así que el TypeRef es `primitive::json`.
pub const FLOW_TYPE_NAME: &str = "json";
// =====================================================================
// Wire request
// =====================================================================
/// Request al daemon. El wire es JSON line-delimited (un objeto + `\n`
/// por conexión).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum QueryRequest {
/// Lista todas las Mónadas vivas del daemon, junto con metadata
/// del engine. Pensado para que la UI haga snapshot polling.
ListMonads,
}
// =====================================================================
// Wire response
// =====================================================================
/// Response a `ListMonads`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMonadsResponse {
/// Datos del engine (la Card que es "dueña" de las Mónadas).
pub engine: EngineInfo,
/// Mónadas vivas en este momento. Vista slim sin centroide ni
/// member set para que el wire sea liviano: una Mónada con 50k
/// archivos no debe transmitir 50k ULIDs cada poll.
pub monads: Vec<MonadView>,
}
/// Identidad del engine (Card kind=Ente que owns las Mónadas).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineInfo {
pub id: Ulid,
pub label: String,
/// Path del directorio que el daemon está observando. `None` si
/// el daemon corre sin watcher.
#[serde(default)]
pub watching: Option<String>,
}
/// Vista slim de una Mónada — los campos que la UI necesita para
/// renderizar una card sin pull del centroide ni del member set.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonadView {
pub id: MonadId,
pub label: String,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub keywords: Vec<String>,
pub cardinality: u32,
#[serde(default)]
pub entropy: f32,
#[serde(default)]
pub dominant_lens: Lens,
#[serde(default)]
pub path_hint: Option<String>,
#[serde(default)]
pub centroid_model: Option<String>,
}
impl MonadView {
/// Proyecta un MonadManifest completo a su vista slim para wire.
pub fn from_manifest(m: &MonadManifest) -> Self {
Self {
id: m.id,
label: m.label.clone(),
summary: m.summary.clone(),
keywords: m.keywords.clone(),
cardinality: m.cardinality,
entropy: m.entropy,
dominant_lens: m.dominant_lens,
path_hint: m.path_hint.clone(),
centroid_model: m.centroid_model.clone(),
}
}
}
/// Error de protocolo retornado en lugar de la response normal.
#[derive(Debug, Clone, Serialize, Deserialize, Error)]
#[error("akasha-engine: {error}")]
pub struct ErrorResponse {
pub error: String,
}
// =====================================================================
// Transport
// =====================================================================
pub mod transport {
use std::path::PathBuf;
/// Variable de entorno para sobreescribir la ruta del socket del
/// daemon (útil para tests / multi-daemon).
pub const SOCKET_ENV: &str = "NOUSER_ENGINE_SOCKET";
/// Nombre por defecto del socket.
pub const SOCKET_NAME: &str = "akasha-engine.sock";
/// Ruta canónica al socket del daemon. Honra `NOUSER_ENGINE_SOCKET`
/// si está set, sino arma sobre `$XDG_RUNTIME_DIR` (con fallback
/// `$TMPDIR`).
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join(SOCKET_NAME)
}
}
// =====================================================================
// Cliente blocking — vive con los wire types para que un consumer
// (UI, CLI, otro módulo) pueda hablar con el daemon importando sólo
// `akasha-card`, sin arrastrar `akasha-core` (notify/walkdir/sled/blake3).
// =====================================================================
/// Cliente síncrono para el query socket del daemon. Sólo Unix (el
/// resto del ecosistema brahman es Unix-only de facto).
#[cfg(unix)]
pub mod client {
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use super::{ErrorResponse, ListMonadsResponse, QueryRequest};
#[derive(Debug, thiserror::Error)]
pub enum QueryError {
#[error("conectar a {path}: {source}")]
Connect {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("I/O: {0}")]
Io(#[from] std::io::Error),
#[error("serializacion: {0}")]
Serde(#[from] serde_json::Error),
#[error("daemon: {0}")]
Daemon(String),
#[error("response vacía del daemon")]
Empty,
}
/// Envía `ListMonads` al daemon en `socket` y devuelve la response.
/// `timeout` se aplica tanto al read como al write del stream.
pub fn list_monads(
socket: &Path,
timeout: Duration,
) -> Result<ListMonadsResponse, QueryError> {
let mut stream = UnixStream::connect(socket).map_err(|e| QueryError::Connect {
path: socket.to_path_buf(),
source: e,
})?;
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
let req = QueryRequest::ListMonads;
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();
let n = reader.read_line(&mut response)?;
if n == 0 {
return Err(QueryError::Empty);
}
if let Ok(resp) = serde_json::from_str::<ListMonadsResponse>(response.trim()) {
return Ok(resp);
}
let err: ErrorResponse = serde_json::from_str(response.trim())?;
Err(QueryError::Daemon(err.error))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_roundtrips_json_with_tag() {
let req = QueryRequest::ListMonads;
let s = serde_json::to_string(&req).unwrap();
assert_eq!(s, r#"{"kind":"list_monads"}"#);
let back: QueryRequest = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn response_roundtrip_preserves_view() {
let m = MonadManifest::new("x/src");
let view = MonadView::from_manifest(&m);
let resp = ListMonadsResponse {
engine: EngineInfo {
id: Ulid::new(),
label: "brahman.nouser_engine".into(),
watching: Some("/tmp/x".into()),
},
monads: vec![view.clone()],
};
let s = serde_json::to_string(&resp).unwrap();
let back: ListMonadsResponse = serde_json::from_str(&s).unwrap();
assert_eq!(back.monads.len(), 1);
assert_eq!(back.monads[0].label, view.label);
assert_eq!(back.engine.label, "brahman.nouser_engine");
}
#[test]
fn view_is_slim_no_centroid_no_members() {
// Construimos una Mónada con centroid + members "pesados",
// proyectamos a view, verificamos que esos campos no viajan.
let mut m = MonadManifest::new("test");
m.centroid = vec![0.1; 384]; // peso "real-fastembed"
m.members.insert(Ulid::new());
m.members.insert(Ulid::new());
m.cardinality = 2;
let view = MonadView::from_manifest(&m);
let s = serde_json::to_string(&view).unwrap();
// Chequeo con `:` para distinguir el field "centroid" del
// field "centroid_model" (que sí es metadata liviana y debe ir).
assert!(
!s.contains("\"centroid\":"),
"MonadView no debe serializar el vector centroid: {s}"
);
assert!(
!s.contains("\"members\":"),
"MonadView no debe serializar members: {s}"
);
assert!(s.contains("\"cardinality\":2"), "cardinality sí va: {s}");
}
}