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 · by sergio · 2026-05-20T15:54:42Z (5 months ago) · parent e0ad7315b · browse the tree at this commit
Semantic delta · 13
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<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);
+ }
+}