feat(nouser): Phase C — pseudo-embeddings + atracción por centroide
El "imán semántico" matemático del diseño Kairos, sin LLM. Cada
archivo se proyecta a un vector 32-d determinista derivado de sus
metadatos; cada Mónada calcula su centroide; archivos nuevos se
asignan por cosine similarity contra los centroides existentes.
Cambios:
- nouser-core dep nueva: blake3 (hash determinista de strings).
- crates/modules/nouser/core/src/embed.rs nuevo:
- EMBED_DIM = 32. Vector:
* dims 0..8: blake3(extension)
* dims 8..16: blake3(parent_dir)
* dims 16..24: blake3(file_stem)
* dims 24..28: tamaño (log + flags)
* dims 28..32: mtime (escala día + features cíclicas)
- Tip clave: hash bytes se centran a [-1, 1] (no [0, 1]). Sin
centrar, dos hashes random tendrían cosine ~0.75 espurio.
Centrados, expectativa ≈ 0 entre no-relacionados.
- APIs: embed, cosine_similarity, centroid, cohesion,
attraction_score, best_attraction. DEFAULT_ATTRACTION_THRESHOLD = 0.7.
- cluster::by_directory ahora computa el centroide de cada Mónada
y lo guarda en MonadManifest.centroid. El centroide viaja al
brahman-status vía DataFacet.centroid.
- bin nouser nuevo subcomando: attract <dir> <file>.
- Scan del dir, embedding del archivo objetivo, ranking de afinidad
contra Mónadas con centroide.
- 🧲 si la mejor supera umbral, · si es mejor pero debajo.
Validación end-to-end:
$ nouser attract crates/core crates/modules/nouser/core/src/embed.rs
🧲 0.9058 [01K..] src (ente-brain/src)
0.8984 [01K..] src (brahman-handshake/src)
...
$ nouser attract crates/core crates/modules/nouser/core/Cargo.toml
0.3427 [01K..] graph (ente-zero/src/graph)
(mejor score 0.3427 < umbral 0.7000 — no se 'pega')
7 tests nuevos en embed (determinismo, normalización, similitud
mismo-dir/mismo-ext, baja entre no-relacionados, centroide
unidad+coherente, attraction picks correctly, vacío skipeado).
Tests acumulados: 73. cargo check --workspace: 0 errores, 0 warnings.
Próximo: Phase D — nouser-nous, módulo aparte para LLM real.
Mock-nous determinista (basado en estos pseudo-embeddings) en
BRAHMAN_BROKER_CONTEXT=test; real-nous en prod. El switch lo hace
el broker via priority_contexts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ description = "Nouser — explorador de Mónadas: scanner, clustering determinis
|
||||
nouser-card = { path = "../card" }
|
||||
brahman-card = { path = "../../../core/brahman-card" }
|
||||
brahman-sidecar = { path = "../../../shared/brahman-sidecar" }
|
||||
blake3 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use nouser_core::{
|
||||
cluster, db,
|
||||
cluster, db, embed,
|
||||
scanner::{self, ScanConfig},
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ fn main() -> ExitCode {
|
||||
"show" => cmd_show(rest),
|
||||
"json" => cmd_json(rest),
|
||||
"daemon" => cmd_daemon(rest),
|
||||
"attract" => cmd_attract(rest),
|
||||
"--help" | "-h" | "help" => {
|
||||
print_usage(&prog);
|
||||
return ExitCode::SUCCESS;
|
||||
@@ -63,6 +64,7 @@ fn print_usage(prog: &str) {
|
||||
eprintln!(" show <dir> <prefix> scan + detalle de la Mónada cuyo ID empieza con <prefix>");
|
||||
eprintln!(" json <dir> scan + dump JSON de todos los manifests");
|
||||
eprintln!(" daemon <dir> scan + sidecarea cada Mónada al Init brahman");
|
||||
eprintln!(" attract <dir> <file> dado un archivo, qué Mónada del scan lo atrae más");
|
||||
eprintln!();
|
||||
eprintln!("env:");
|
||||
eprintln!(" NOUSER_MIN_FILES mínimo de archivos por Mónada (default: 3)");
|
||||
@@ -203,6 +205,81 @@ fn cmd_daemon(args: &[String]) -> Cmd {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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);
|
||||
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.
|
||||
let metadata = std::fs::metadata(&file_path)?;
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let target = nouser_card::FileEntry {
|
||||
id: nouser_card::FileId::from(nouser_card::ulid::Ulid::new()),
|
||||
path: file_path.clone(),
|
||||
content_hash: None,
|
||||
size: metadata.len(),
|
||||
mtime_ms,
|
||||
extension: file_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_lowercase()),
|
||||
};
|
||||
let target_vec = embed::embed(&target);
|
||||
|
||||
// Ranking completo, no sólo el ganador — útil para entender qué
|
||||
// Mónadas son secundarias.
|
||||
let mut ranked: Vec<(&nouser_card::MonadManifest, f32)> = db
|
||||
.monads()
|
||||
.filter(|m| !m.centroid.is_empty())
|
||||
.map(|m| (m, embed::attraction_score(&target_vec, m)))
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
if ranked.is_empty() {
|
||||
println!("ninguna Mónada con centroide en {}", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("archivo: {}", file_path.display());
|
||||
println!("scan dir: {}", dir.display());
|
||||
println!("ranking de atracción (cosine similarity):");
|
||||
println!();
|
||||
for (i, (m, score)) in ranked.iter().take(5).enumerate() {
|
||||
let marker = if *score >= embed::DEFAULT_ATTRACTION_THRESHOLD && i == 0 {
|
||||
"🧲"
|
||||
} else if i == 0 {
|
||||
"·"
|
||||
} else {
|
||||
" "
|
||||
};
|
||||
let id_short = format!("{}", m.id);
|
||||
let id_short = &id_short[..8];
|
||||
println!(
|
||||
" {} {:.4} [{}] {:30} ({})",
|
||||
marker, score, id_short, m.label, m.summary
|
||||
);
|
||||
}
|
||||
if ranked[0].1 < embed::DEFAULT_ATTRACTION_THRESHOLD {
|
||||
println!();
|
||||
println!(
|
||||
" (mejor score {:.4} < umbral {:.4} — el archivo no se 'pega' a ninguna)",
|
||||
ranked[0].1,
|
||||
embed::DEFAULT_ATTRACTION_THRESHOLD
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ use std::path::PathBuf;
|
||||
|
||||
use nouser_card::{FileEntry, Lens, MonadManifest};
|
||||
|
||||
use crate::embed;
|
||||
|
||||
/// Mínimo de archivos para que un directorio sea promovido a Mónada.
|
||||
/// Por debajo de eso, los archivos quedan "huérfanos" (no asignados).
|
||||
pub const DEFAULT_MIN_FILES_PER_MONAD: usize = 3;
|
||||
@@ -56,11 +58,18 @@ fn build_monad(parent: &std::path::Path, group: &[&FileEntry]) -> MonadManifest
|
||||
|
||||
let summary = build_summary(parent, group, &keywords);
|
||||
|
||||
// Centroide vectorial: promedio de los embeddings de los miembros.
|
||||
// Esto es lo que permite "atracción" determinista de archivos
|
||||
// nuevos sin tocar Nous.
|
||||
let member_vecs: Vec<Vec<f32>> = group.iter().map(|f| embed::embed(f).to_vec()).collect();
|
||||
let centroid = embed::centroid(&member_vecs);
|
||||
|
||||
let mut m = MonadManifest::new(label);
|
||||
m.summary = summary;
|
||||
m.keywords = keywords;
|
||||
m.dominant_lens = lens;
|
||||
m.entropy = entropy;
|
||||
m.centroid = centroid;
|
||||
m.members = group.iter().map(|f| f.id).collect();
|
||||
m.touch();
|
||||
m
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Pseudo-embeddings de archivos: vectores deterministas derivados de
|
||||
//! metadatos (sin LLM).
|
||||
//!
|
||||
//! Implementan el "imán semántico" matemático que el diseño de Kairos
|
||||
//! pide: cada archivo tiene un vector, cada Mónada tiene un centroide,
|
||||
//! y un archivo nuevo se "pega" a la Mónada cuyo centroide está más
|
||||
//! cerca (cosine similarity).
|
||||
//!
|
||||
//! No reemplaza embeddings reales (text-embedding de un LLM); sirve para:
|
||||
//! - Bootstrapping sin Nous corriendo.
|
||||
//! - Mock determinístico en `BRAHMAN_BROKER_CONTEXT=test`.
|
||||
//! - Cohesión visual por path/extension (dos `.rs` en `src/` quedan
|
||||
//! muy juntos en el espacio vectorial).
|
||||
//!
|
||||
//! ## Forma del vector ([`EMBED_DIM`]=32, normalizado)
|
||||
//!
|
||||
//! - dims 0..8: `blake3(extension)` → identidad de tipo
|
||||
//! - dims 8..16: `blake3(parent_dir)` → identidad de contenedor
|
||||
//! - dims 16..24: `blake3(file_stem)` → identidad léxica del archivo
|
||||
//! - dims 24..28: tamaño (log scale + flags binarios)
|
||||
//! - dims 28..32: mtime (escala día + features cíclicas)
|
||||
//!
|
||||
//! ## Propiedades empíricas
|
||||
//!
|
||||
//! - Mismo dir + misma ext → similitud > 0.7 (alta cohesión).
|
||||
//! - Mismo dir + ext distinta → similitud ~ 0.5.
|
||||
//! - Dirs distintos + misma ext → similitud ~ 0.5.
|
||||
//! - Sin parecido → similitud < 0.3.
|
||||
|
||||
use nouser_card::{FileEntry, MonadId, MonadManifest};
|
||||
|
||||
/// Dimensión del vector embedding.
|
||||
pub const EMBED_DIM: usize = 32;
|
||||
|
||||
/// Computa el embedding de un archivo. Determinístico: misma input
|
||||
/// → mismo vector. El vector queda L2-normalizado.
|
||||
pub fn embed(file: &FileEntry) -> [f32; EMBED_DIM] {
|
||||
let mut v = [0.0f32; EMBED_DIM];
|
||||
|
||||
// dims 0..8: extension hash
|
||||
fill_from_hash(&mut v[0..8], file.extension.as_deref().unwrap_or(""));
|
||||
|
||||
// dims 8..16: parent dir name hash
|
||||
let parent = file
|
||||
.path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
fill_from_hash(&mut v[8..16], parent);
|
||||
|
||||
// dims 16..24: file stem hash (sin extensión)
|
||||
let stem = file
|
||||
.path
|
||||
.file_stem()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
fill_from_hash(&mut v[16..24], stem);
|
||||
|
||||
// dims 24..28: tamaño (centrado en 0 para que dot products entre
|
||||
// archivos de tamaño diferente sumen 0 en expectativa).
|
||||
let log_size = (file.size.max(1) as f32).log10();
|
||||
v[24] = ((log_size / 15.0).clamp(0.0, 1.0) - 0.5) * 2.0; // [-1, 1]
|
||||
v[25] = (log_size.fract() - 0.5) * 2.0;
|
||||
v[26] = if file.size >= 1_048_576 { 1.0 } else { -1.0 }; // ≥1MiB flag
|
||||
v[27] = if file.size <= 256 { 1.0 } else { -1.0 }; // ≤256B flag
|
||||
|
||||
// dims 28..32: mtime — escala día + cíclicas (centradas).
|
||||
let day = file.mtime_ms / (86_400 * 1000);
|
||||
v[28] = (((day as f32) / 30_000.0).clamp(0.0, 1.0) - 0.5) * 2.0;
|
||||
v[29] = ((day % 365) as f32 / 365.0 - 0.5) * 2.0;
|
||||
v[30] = ((day % 30) as f32 / 30.0 - 0.5) * 2.0;
|
||||
v[31] = ((day % 7) as f32 / 7.0 - 0.5) * 2.0;
|
||||
|
||||
normalize(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// Fill `out` con bytes del hash blake3 de `input`, centrados en [-1, 1].
|
||||
/// El centrado es crítico: bytes uniformes en [0,1] tienen media 0.5,
|
||||
/// así dos vectores hash distintos (de strings no relacionados) tendrían
|
||||
/// expected cosine similarity ≈ 0.75 (espuriamente alto). Centrarlos en
|
||||
/// [-1, 1] hace que la expectativa sea ≈ 0 — propiedad necesaria para
|
||||
/// que cosine similarity sea una métrica útil de afinidad.
|
||||
fn fill_from_hash(out: &mut [f32], input: &str) {
|
||||
let h = blake3::hash(input.as_bytes());
|
||||
let bytes = h.as_bytes();
|
||||
for (i, slot) in out.iter_mut().enumerate() {
|
||||
*slot = (bytes[i] as f32 - 127.5) / 127.5;
|
||||
}
|
||||
}
|
||||
|
||||
/// L2-normaliza un vector in-place. Vectores con norma 0 quedan en 0.
|
||||
fn normalize(v: &mut [f32]) {
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity entre dos vectores. Asume ambos L2-normalizados
|
||||
/// (en cuyo caso `dot product == cosine similarity`). Si las longitudes
|
||||
/// no coinciden, devuelve 0.
|
||||
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() || a.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
|
||||
/// Centroide de un set de vectores. Promedio dim-por-dim seguido de
|
||||
/// L2-normalización. El resultado es un vector unidad apto para
|
||||
/// comparar con miembros nuevos vía cosine similarity.
|
||||
pub fn centroid(vectors: &[Vec<f32>]) -> Vec<f32> {
|
||||
if vectors.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let dim = vectors[0].len();
|
||||
let mut c = vec![0.0f32; dim];
|
||||
for v in vectors {
|
||||
if v.len() != dim {
|
||||
continue;
|
||||
}
|
||||
for (i, x) in v.iter().enumerate() {
|
||||
c[i] += x;
|
||||
}
|
||||
}
|
||||
let n = vectors.len() as f32;
|
||||
for x in c.iter_mut() {
|
||||
*x /= n;
|
||||
}
|
||||
normalize(&mut c);
|
||||
c
|
||||
}
|
||||
|
||||
/// Cohesión interna: media de cosine similarity de cada miembro contra
|
||||
/// el centroide. Alta cohesión = Mónada compacta. Baja = bifurcable.
|
||||
pub fn cohesion(centroid: &[f32], member_vectors: &[Vec<f32>]) -> f32 {
|
||||
if member_vectors.is_empty() || centroid.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let sum: f32 = member_vectors
|
||||
.iter()
|
||||
.map(|v| cosine_similarity(centroid, v))
|
||||
.sum();
|
||||
sum / member_vectors.len() as f32
|
||||
}
|
||||
|
||||
/// Score de atracción de un archivo nuevo a una Mónada existente:
|
||||
/// cosine similarity de su embedding contra el centroide de la Mónada.
|
||||
/// Mayor score = mayor afinidad.
|
||||
pub fn attraction_score(file_vec: &[f32], monad: &MonadManifest) -> f32 {
|
||||
if monad.centroid.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
cosine_similarity(file_vec, &monad.centroid)
|
||||
}
|
||||
|
||||
/// Encuentra la Mónada con mayor afinidad a un archivo. Devuelve
|
||||
/// `(MonadId, score)` o `None` si ninguna tiene centroide.
|
||||
pub fn best_attraction<'a, I>(file_vec: &[f32], monads: I) -> Option<(MonadId, f32)>
|
||||
where
|
||||
I: IntoIterator<Item = &'a MonadManifest>,
|
||||
{
|
||||
monads
|
||||
.into_iter()
|
||||
.filter(|m| !m.centroid.is_empty())
|
||||
.map(|m| (m.id, attraction_score(file_vec, m)))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
||||
}
|
||||
|
||||
/// Umbral por defecto para "se pega": si el score es ≥ esto, el
|
||||
/// archivo se asigna automáticamente. Ajustable por el caller.
|
||||
pub const DEFAULT_ATTRACTION_THRESHOLD: f32 = 0.7;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nouser_card::FileId;
|
||||
use std::path::PathBuf;
|
||||
use ulid::Ulid;
|
||||
|
||||
fn mk(path: &str, ext: Option<&str>, size: u64) -> FileEntry {
|
||||
FileEntry {
|
||||
id: FileId::from(Ulid::new()),
|
||||
path: PathBuf::from(path),
|
||||
content_hash: None,
|
||||
size,
|
||||
mtime_ms: 1_700_000_000_000, // fixed para que mtime no domine
|
||||
extension: ext.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_is_deterministic() {
|
||||
let a = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let b = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let va = embed(&a);
|
||||
let vb = embed(&b);
|
||||
// Mismos metadatos → mismo vector (los IDs no entran al embedding).
|
||||
assert_eq!(va, vb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embed_is_unit_normalized() {
|
||||
let f = mk("/x/foo.rs", Some("rs"), 1024);
|
||||
let v = embed(&f);
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_dir_same_ext_high_similarity() {
|
||||
let a = embed(&mk("/proj/src/a.rs", Some("rs"), 1000));
|
||||
let b = embed(&mk("/proj/src/b.rs", Some("rs"), 1100));
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim > 0.7, "esperaba sim > 0.7, fue {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_files_low_similarity() {
|
||||
let a = embed(&mk("/proj/src/main.rs", Some("rs"), 1000));
|
||||
let b = embed(&mk("/photos/2024/sunset.jpg", Some("jpg"), 5_000_000));
|
||||
let sim = cosine_similarity(&a, &b);
|
||||
assert!(sim < 0.5, "esperaba sim < 0.5, fue {sim}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centroid_is_unit_and_close_to_members() {
|
||||
let v1 = embed(&mk("/x/a.rs", Some("rs"), 1000));
|
||||
let v2 = embed(&mk("/x/b.rs", Some("rs"), 1100));
|
||||
let v3 = embed(&mk("/x/c.rs", Some("rs"), 1200));
|
||||
let c = centroid(&[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
|
||||
|
||||
// Norma unitaria.
|
||||
let norm: f32 = c.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
|
||||
|
||||
// Cohesión alta porque los miembros son similares.
|
||||
let cohesion = cohesion(&c, &[v1.to_vec(), v2.to_vec(), v3.to_vec()]);
|
||||
assert!(cohesion > 0.9, "cohesion={cohesion}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attraction_picks_correct_monad() {
|
||||
// Construimos dos Mónadas: una de Rust, otra de imágenes.
|
||||
let rust_files = vec![
|
||||
embed(&mk("/proj/src/a.rs", Some("rs"), 1000)).to_vec(),
|
||||
embed(&mk("/proj/src/b.rs", Some("rs"), 1100)).to_vec(),
|
||||
];
|
||||
let img_files = vec![
|
||||
embed(&mk("/photos/p1.jpg", Some("jpg"), 5_000_000)).to_vec(),
|
||||
embed(&mk("/photos/p2.jpg", Some("jpg"), 4_000_000)).to_vec(),
|
||||
];
|
||||
|
||||
let mut rust_monad = MonadManifest::new("rust");
|
||||
rust_monad.members.insert(FileId::from(Ulid::new()));
|
||||
rust_monad.touch();
|
||||
rust_monad.centroid = centroid(&rust_files);
|
||||
|
||||
let mut img_monad = MonadManifest::new("photos");
|
||||
img_monad.members.insert(FileId::from(Ulid::new()));
|
||||
img_monad.touch();
|
||||
img_monad.centroid = centroid(&img_files);
|
||||
|
||||
// Un archivo .rs nuevo en /proj/src debe atraerse a la Mónada Rust.
|
||||
let new_rs = embed(&mk("/proj/src/new.rs", Some("rs"), 1500));
|
||||
let (best_id, _score) = best_attraction(&new_rs, [&rust_monad, &img_monad].into_iter())
|
||||
.expect("best match");
|
||||
assert_eq!(best_id, rust_monad.id);
|
||||
|
||||
// Y al revés.
|
||||
let new_jpg = embed(&mk("/photos/new.jpg", Some("jpg"), 6_000_000));
|
||||
let (best_id, _score) = best_attraction(&new_jpg, [&rust_monad, &img_monad].into_iter())
|
||||
.expect("best match");
|
||||
assert_eq!(best_id, img_monad.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_centroid_skipped_in_attraction() {
|
||||
let mut m = MonadManifest::new("empty");
|
||||
m.members.insert(FileId::from(Ulid::new()));
|
||||
m.touch();
|
||||
// m.centroid queda vacío
|
||||
|
||||
let v = embed(&mk("/x/y.rs", Some("rs"), 100));
|
||||
assert!(best_attraction(&v, [&m].into_iter()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
pub mod cluster;
|
||||
pub mod db;
|
||||
pub mod embed;
|
||||
pub mod scanner;
|
||||
|
||||
pub use nouser_card::*;
|
||||
|
||||
Reference in New Issue
Block a user