use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result};
use crate::iri::{CODE_BASE, ONTOLOGY_NS};
use crate::types::{Chunk, ChunkEdge, ChunkEdgeType};
pub(crate) fn mentions_iri() -> String {
format!("{ONTOLOGY_NS}mentions")
}
fn name_iri() -> String {
format!("{ONTOLOGY_NS}name")
}
fn is_symbol_edge(t: ChunkEdgeType) -> bool {
matches!(
t,
ChunkEdgeType::Implements
| ChunkEdgeType::ImplFor
| ChunkEdgeType::Extends
| ChunkEdgeType::Tests
)
}
pub(crate) fn edge_mention_map(edges: &[ChunkEdge]) -> HashMap<&str, Vec<&str>> {
let mut map: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in edges {
if is_symbol_edge(edge.edge_type) && !edge.target_name.is_empty() {
map.entry(edge.source_chunk.as_str())
.or_default()
.push(edge.target_name.as_str());
}
}
map
}
pub(crate) fn chunk_mention_names<'a>(
chunk: &'a Chunk,
edge_mentions: &HashMap<&str, Vec<&'a str>>,
) -> Vec<&'a str> {
let mut names: Vec<&str> = Vec::new();
if chunk.chunk_type.is_code_symbol() {
if let Some(name) = chunk.name.as_deref() {
names.push(name);
}
}
if let Some(targets) = edge_mentions.get(chunk.id.as_str()) {
names.extend(targets.iter().copied());
}
names.sort_unstable();
names.dedup();
names
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status")]
pub enum MentionResolution {
Resolved {
chunk_iri: String,
name: String,
entity_iri: String,
},
Dangling { chunk_iri: String, name: String },
Ambiguous {
chunk_iri: String,
name: String,
candidates: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct MentionReport {
pub resolved: usize,
pub dangling: usize,
pub ambiguous: usize,
pub edges_written: usize,
pub details: Vec<MentionResolution>,
}
pub fn reconcile_mentions(store: &mut quipu::Store, timestamp: &str) -> Result<MentionReport> {
let mut report = MentionReport {
resolved: 0,
dangling: 0,
ambiguous: 0,
edges_written: 0,
details: Vec::new(),
};
let Some(mentions_id) = store
.lookup(&mentions_iri())
.map_err(|e| anyhow::anyhow!("mentions predicate lookup failed: {e}"))?
else {
return Ok(report);
};
let name_id = store
.lookup(&name_iri())
.map_err(|e| anyhow::anyhow!("name predicate lookup failed: {e}"))?;
let facts = store
.current_facts()
.map_err(|e| anyhow::anyhow!("reading current facts failed: {e}"))?;
let mut name_index: HashMap<&str, Vec<(i64, String)>> = HashMap::new();
let mut entity_names: HashMap<i64, Vec<&str>> = HashMap::new();
if let Some(name_id) = name_id {
for fact in &facts {
if fact.attribute != name_id {
continue;
}
if let quipu::types::Value::Str(ref name) = fact.value {
let iri = store
.resolve(fact.entity)
.map_err(|e| anyhow::anyhow!("IRI resolve failed: {e}"))?;
if iri.starts_with(CODE_BASE) {
name_index
.entry(name.as_str())
.or_default()
.push((fact.entity, iri));
entity_names.entry(fact.entity).or_default().push(name);
}
}
}
}
let mut existing_refs: HashMap<i64, Vec<i64>> = HashMap::new();
for fact in &facts {
if fact.attribute != mentions_id {
continue;
}
if let quipu::types::Value::Ref(target) = fact.value {
existing_refs.entry(fact.entity).or_default().push(target);
}
}
let mut datums: Vec<quipu::store::Datum> = Vec::new();
for fact in &facts {
if fact.attribute != mentions_id {
continue;
}
let quipu::types::Value::Str(ref name) = fact.value else {
continue;
};
let chunk_iri = store
.resolve(fact.entity)
.map_err(|e| anyhow::anyhow!("IRI resolve failed: {e}"))?;
let prior = existing_refs.get(&fact.entity).and_then(|targets| {
targets.iter().find(|t| {
entity_names
.get(t)
.is_some_and(|names| names.contains(&name.as_str()))
})
});
if let Some(&target) = prior {
let entity_iri = store
.resolve(target)
.map_err(|e| anyhow::anyhow!("IRI resolve failed: {e}"))?;
report.resolved += 1;
report.details.push(MentionResolution::Resolved {
chunk_iri,
name: name.clone(),
entity_iri,
});
continue;
}
let matches: &[(i64, String)] = name_index.get(name.as_str()).map_or(&[], |v| v.as_slice());
let chosen = match matches {
[] => None,
[only] => Some(only),
many => {
let module_prefix = crate::iri::code_module_prefix_of(&chunk_iri);
let scoped: Vec<&(i64, String)> = module_prefix
.as_deref()
.map(|prefix| {
many.iter()
.filter(|(_, iri)| iri.starts_with(prefix))
.collect()
})
.unwrap_or_default();
match scoped.as_slice() {
[only] => Some(*only),
_ => None,
}
}
};
match chosen {
Some((target_id, target_iri)) => {
datums.push(quipu::store::Datum {
entity: fact.entity,
attribute: mentions_id,
value: quipu::types::Value::Ref(*target_id),
valid_from: timestamp.to_string(),
valid_to: None,
op: quipu::types::Op::Assert,
});
report.resolved += 1;
report.edges_written += 1;
report.details.push(MentionResolution::Resolved {
chunk_iri,
name: name.clone(),
entity_iri: target_iri.clone(),
});
}
None if matches.is_empty() => {
report.dangling += 1;
report.details.push(MentionResolution::Dangling {
chunk_iri,
name: name.clone(),
});
}
None => {
report.ambiguous += 1;
report.details.push(MentionResolution::Ambiguous {
chunk_iri,
name: name.clone(),
candidates: matches.iter().map(|(_, iri)| iri.clone()).collect(),
});
}
}
}
if !datums.is_empty() {
store
.transact(
&datums,
timestamp,
Some("bobbin"),
Some("mention-reconcile"),
)
.map_err(|e| anyhow::anyhow!("writing resolved mention edges failed: {e}"))?;
}
Ok(report)
}
pub fn reconcile_mentions_at(repo_root: &Path) -> Result<MentionReport> {
let quipu_config = quipu::QuipuConfig::load(repo_root);
let db_path = if quipu_config.store_path.is_relative() {
repo_root.join(&quipu_config.store_path)
} else {
quipu_config.store_path.clone()
};
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).context("Failed to create quipu store directory")?;
}
let mut store = quipu::Store::open(db_path.to_string_lossy().as_ref())
.map_err(|e| anyhow::anyhow!("Failed to open quipu store: {e}"))?;
reconcile_mentions(&mut store, &chrono::Utc::now().to_rfc3339())
}
#[cfg(test)]
#[path = "mentions_tests.rs"]
mod tests;