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:
Generated
+10
@@ -3980,6 +3980,16 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fana-semantic"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"fana-core",
|
||||||
|
"tokio",
|
||||||
|
"verbo-core",
|
||||||
|
"verbo-mock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fana-store"
|
name = "fana-store"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ members = [
|
|||||||
"crates/modules/fana/fana-core",
|
"crates/modules/fana/fana-core",
|
||||||
"crates/modules/fana/fana-graph",
|
"crates/modules/fana/fana-graph",
|
||||||
"crates/modules/fana/fana-store",
|
"crates/modules/fana/fana-store",
|
||||||
|
"crates/modules/fana/fana-semantic",
|
||||||
"crates/modules/fana/fana-md",
|
"crates/modules/fana/fana-md",
|
||||||
"crates/modules/fana/fana-md-reader-web",
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user