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>
This commit is contained in:
sergio
2026-05-20 15:54:42 +00:00
parent e0ad7315be
commit 191e6b06e1
4 changed files with 164 additions and 0 deletions
Generated
+10
View File
@@ -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"
+1
View File
@@ -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",
@@ -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 }
@@ -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);
}
}