From 191e6b06e1414c4e5af7fd7ec38ccc8b2cb9dc22 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 15:54:42 +0000 Subject: [PATCH] =?UTF-8?q?feat(fana):=20fana-semantic=20=E2=80=94=20scori?= =?UTF-8?q?ng=20de=20intensidad=20sem=C3=A1ntica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desbloqueado por verbo. fana-semantic embebe los átomos y mide su afinidad a un conjunto de conceptos. - ConceptSet — embebe el texto de referencia de cada concepto como su vector ancla (vía cualquier verbo Provider). - SemanticScorer — embebe el contenido de un NarrativeAtom y llena atom.semantic_vectors con la similitud coseno concepto→intensidad. Limpia el scoring previo en cada pasada. Agnóstico del backend (verbo_core::Provider). 3 tests verdes con verbo-mock — incluye: texto idéntico al ancla puntúa coseno ≈ 1. cargo check --workspace verde. Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 10 ++ Cargo.toml | 1 + crates/modules/fana/fana-semantic/Cargo.toml | 16 +++ crates/modules/fana/fana-semantic/src/lib.rs | 137 +++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 crates/modules/fana/fana-semantic/Cargo.toml create mode 100644 crates/modules/fana/fana-semantic/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 9910d8d..e0ed09d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3980,6 +3980,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "fana-semantic" +version = "0.1.0" +dependencies = [ + "fana-core", + "tokio", + "verbo-core", + "verbo-mock", +] + [[package]] name = "fana-store" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 186a6c9..e347c8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -156,6 +156,7 @@ members = [ "crates/modules/fana/fana-core", "crates/modules/fana/fana-graph", "crates/modules/fana/fana-store", + "crates/modules/fana/fana-semantic", "crates/modules/fana/fana-md", "crates/modules/fana/fana-md-reader-web", diff --git a/crates/modules/fana/fana-semantic/Cargo.toml b/crates/modules/fana/fana-semantic/Cargo.toml new file mode 100644 index 0000000..5169e98 --- /dev/null +++ b/crates/modules/fana/fana-semantic/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fana-semantic" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "fana — scoring de intensidad semántica: embebe los átomos vía verbo y mide su afinidad coseno a un conjunto de conceptos." + +[dependencies] +fana-core = { path = "../fana-core" } +verbo-core = { path = "../../verbo/verbo-core" } + +[dev-dependencies] +verbo-mock = { path = "../../verbo/verbo-mock" } +tokio = { workspace = true } diff --git a/crates/modules/fana/fana-semantic/src/lib.rs b/crates/modules/fana/fana-semantic/src/lib.rs new file mode 100644 index 0000000..2f9a2d3 --- /dev/null +++ b/crates/modules/fana/fana-semantic/src/lib.rs @@ -0,0 +1,137 @@ +//! `fana-semantic` — scoring de intensidad semántica de los átomos. +//! +//! Un [`ConceptSet`] embebe el texto de referencia de cada concepto como +//! su vector ancla. El [`SemanticScorer`] embebe el contenido de un +//! [`NarrativeAtom`] y mide su similitud coseno contra cada ancla, +//! llenando `atom.semantic_vectors` con el gradiente concepto→intensidad. +//! +//! Agnóstico del backend: opera contra cualquier `verbo_core::Provider` +//! (mock para tests, bge/cohere en producción). + +#![forbid(unsafe_code)] + +use fana_core::NarrativeAtom; +use std::collections::HashMap; +use verbo_core::{EmbedError, EmbeddingVector, Provider}; + +/// Conjunto de conceptos, cada uno con su vector ancla. +pub struct ConceptSet { + anchors: HashMap, +} + +impl ConceptSet { + /// Embebe el texto de referencia de cada `(concepto, texto)` como su + /// ancla. Todas las anclas quedan en el espacio del `provider` dado. + pub async fn build( + provider: &dyn Provider, + concepts: &[(String, String)], + ) -> Result { + let mut anchors = HashMap::with_capacity(concepts.len()); + for (name, reference) in concepts { + anchors.insert(name.clone(), provider.embed(reference).await?); + } + Ok(Self { anchors }) + } + + pub fn len(&self) -> usize { + self.anchors.len() + } + + pub fn is_empty(&self) -> bool { + self.anchors.is_empty() + } + + /// Nombres de los conceptos del set. + pub fn concepts(&self) -> impl Iterator { + self.anchors.keys().map(String::as_str) + } +} + +/// Calcula el gradiente de intensidad semántica de los átomos. +pub struct SemanticScorer { + concepts: ConceptSet, +} + +impl SemanticScorer { + pub fn new(concepts: ConceptSet) -> Self { + Self { concepts } + } + + /// Embebe el contenido del átomo y llena `atom.semantic_vectors` con + /// la similitud coseno a cada concepto. El `provider` debe ser del + /// mismo modelo con que se construyó el `ConceptSet` (si no, falla + /// con `ModelMismatch`). + pub async fn score( + &self, + provider: &dyn Provider, + atom: &mut NarrativeAtom, + ) -> Result<(), EmbedError> { + let atom_vec = provider.embed(&atom.content).await?; + atom.semantic_vectors.clear(); + for (name, anchor) in &self.concepts.anchors { + let sim = atom_vec.cosine(anchor)?; + atom.semantic_vectors.insert(name.clone(), sim); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use verbo_mock::MockProvider; + + fn concept_list() -> Vec<(String, String)> { + vec![ + ("tensión".into(), "el conflicto crece, la amenaza es inminente".into()), + ("calma".into(), "todo está en paz, sereno y tranquilo".into()), + ] + } + + #[tokio::test] + async fn score_fills_one_entry_per_concept() { + let p = MockProvider::new(128); + let cs = ConceptSet::build(&p, &concept_list()).await.unwrap(); + let scorer = SemanticScorer::new(cs); + + let mut atom = NarrativeAtom::new("la batalla final", "main"); + scorer.score(&p, &mut atom).await.unwrap(); + + assert_eq!(atom.semantic_vectors.len(), 2); + assert!(atom.semantic_vectors.contains_key("tensión")); + assert!(atom.semantic_vectors.contains_key("calma")); + for &v in atom.semantic_vectors.values() { + assert!((-1.0..=1.0).contains(&v)); + } + } + + #[tokio::test] + async fn atom_matching_a_concept_anchor_scores_near_one() { + let p = MockProvider::new(256); + let reference = "este es el texto de referencia exacto"; + let cs = ConceptSet::build(&p, &[("eco".into(), reference.into())]) + .await + .unwrap(); + let scorer = SemanticScorer::new(cs); + + // Un átomo con el MISMO texto que el ancla → coseno ≈ 1. + let mut atom = NarrativeAtom::new(reference, "main"); + scorer.score(&p, &mut atom).await.unwrap(); + let eco = atom.semantic_vectors["eco"]; + assert!((eco - 1.0).abs() < 1e-4, "coseno de texto idéntico = {eco}"); + } + + #[tokio::test] + async fn rescoring_replaces_previous_vectors() { + let p = MockProvider::new(64); + let cs = ConceptSet::build(&p, &concept_list()).await.unwrap(); + let scorer = SemanticScorer::new(cs); + + let mut atom = NarrativeAtom::new("v1", "main"); + atom.semantic_vectors.insert("viejo".into(), 0.5); + scorer.score(&p, &mut atom).await.unwrap(); + // La entrada vieja se limpió; quedan sólo los conceptos del set. + assert!(!atom.semantic_vectors.contains_key("viejo")); + assert_eq!(atom.semantic_vectors.len(), 2); + } +}