tawasuyu

tawasuyu/brahman

Rust 521TOML 202Markdown 57JSON 197505 Sutichaykuna

feat(fana): fana-semantic — scoring de intensidad semántica

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 <noreply@anthropic.com>

191e6b06e1414c4e5af7fd7ec38ccc8b2cb9dc22 · ruwaq sergio · (5 killa ñawpaq) · tayta e0ad7315b · kay commitpi sach'ata qhaway

Semántico sayk'uy · 13

crates/modules/fana/fana-semantic/src/lib.rs

4 qillqakuna · +164 −0

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<String, EmbeddingVector>,
+}
+
+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<Self, EmbedError> {
+        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<Item = &str> {
+        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);
+    }
+}