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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user