#![allow(dead_code)]
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::Path;
use serde::Deserialize;
use uuid::Uuid;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::index_locorum::{self, LocusScheme};
use crate::sources::BibEntry;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
use crate::store::Store;
use crate::wordnet::{self, SenseNode, WordNet};
pub use crate::storage::edge_store::{Edge, EdgeKind, EdgeOrigin, EndpointRef};
use crate::storage::edge_store::{ExternRef, Registry};
#[derive(Debug, Clone, Copy)]
pub struct GraphRebuild {
pub cleared: usize,
pub added: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct LexicalRebuild {
pub installed: bool,
pub cleared: usize,
pub added: usize,
}
#[derive(Debug, Clone)]
pub struct GraphGroundingSignals {
pub recurring_pairs: Vec<(Uuid, Uuid)>,
pub open_contradictions: usize,
}
const LEXICAL_KINDS: &[EdgeKind] = &[
EdgeKind::Mentions,
EdgeKind::Hypernym,
EdgeKind::Hyponym,
EdgeKind::Antonym,
EdgeKind::Translates,
];
#[derive(Debug, Clone)]
pub struct GraphStats {
pub edges: usize,
pub nodes: usize,
pub by_kind: Vec<(String, usize)>,
}
impl Store {
pub fn add_edge(&self, edge: &Edge) -> Result<()> {
self.raw().add_edge(edge).map_err(map_edge_err)
}
pub fn add_edges(&self, edges: &[Edge]) -> Result<()> {
self.raw().add_edges(edges).map_err(map_edge_err)
}
pub fn edge(&self, id: Uuid) -> Result<Option<Edge>> {
self.raw().edge(id).map_err(map_edge_err)
}
pub fn edges_out(&self, node: Uuid, kinds: &[EdgeKind]) -> Result<Vec<Edge>> {
self.raw().edges_out(node, kinds).map_err(map_edge_err)
}
pub fn edges_in(&self, node: Uuid, kinds: &[EdgeKind]) -> Result<Vec<Edge>> {
self.raw().edges_in(node, kinds).map_err(map_edge_err)
}
pub fn neighbors(&self, node: Uuid, kinds: &[EdgeKind]) -> Result<Vec<Edge>> {
self.raw().edges_around(node, kinds).map_err(map_edge_err)
}
pub fn edges_of_kind(&self, kind: EdgeKind) -> Result<Vec<Edge>> {
self.raw().edges_of_kind(kind).map_err(map_edge_err)
}
pub fn delete_edge(&self, id: Uuid) -> Result<()> {
self.raw().delete_edge(id).map_err(map_edge_err)
}
pub fn graph_stats(&self) -> Result<GraphStats> {
let edges = self.raw().edge_count().map_err(map_edge_err)?;
let by_kind = self.raw().edges_by_kind().map_err(map_edge_err)?;
let nodes = self.raw().row_count().map_err(map_edge_err)?;
Ok(GraphStats { edges, nodes, by_kind })
}
pub fn graph_rebuild(&self, cfg: &Config) -> Result<GraphRebuild> {
let cleared = self
.raw()
.delete_edges_by_origin(EdgeOrigin::Structural)
.map_err(map_edge_err)?;
let hierarchy = Hierarchy::load(self)?;
let nodes: Vec<Node> = hierarchy.flatten().into_iter().map(|(n, _)| n.clone()).collect();
let ids: HashSet<Uuid> = nodes.iter().map(|n| n.id).collect();
let mut derived = derive_structural_edges(&nodes);
derived.extend(derive_sidecar_edges(self.project_root(), &ids));
derived.extend(self.gather_locus_edges(&hierarchy, cfg));
derived.extend(self.gather_declares_edges(&hierarchy));
let added = derived.len();
if !derived.is_empty() {
self.raw().add_edges(&derived).map_err(map_edge_err)?;
}
Ok(GraphRebuild { cleared, added })
}
fn gather_locus_edges(&self, h: &Hierarchy, cfg: &Config) -> Vec<Edge> {
let root = self.project_root();
let mut cites: Vec<(Uuid, String, String)> = Vec::new();
for book in h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
{
for id in h.collect_subtree(book.id) {
let Some(n) = h.get(id) else { continue };
if n.kind != NodeKind::Paragraph {
continue;
}
let Some(rel) = n.file.as_ref() else { continue };
let Ok(raw) = std::fs::read_to_string(root.join(rel)) else { continue };
for (key, locus) in crate::sources::extract_cite_loci(&raw) {
if let Some(l) = locus.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
cites.push((n.id, key, l));
}
}
}
}
if cites.is_empty() {
return Vec::new();
}
let declared = collect_declared_schemes(root, h);
let mut keys: Vec<String> = cites.iter().map(|c| c.1.clone()).collect();
keys.sort();
keys.dedup();
let (schemes, _errs) =
index_locorum::resolve_schemes(&cfg.sources.ref_schemes, &declared, &keys);
derive_locus_edges(&cites, &schemes)
}
fn gather_declares_edges(&self, h: &Hierarchy) -> Vec<Edge> {
let root = self.project_root();
let mut edges = Vec::new();
for book in h
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
{
let slug = book.slug.as_str();
let mut declared: Vec<(String, String, Option<String>)> = Vec::new();
if let Ok(cs) = crate::character::CharStore::open(root) {
if let Ok(decls) = cs.all_declarations(slug) {
for d in &decls {
declared.push((
"character".into(),
d.character_name.trim().to_string(),
Some(d.arc_type.as_code().to_string()),
));
}
}
}
if let Ok(ms) = crate::myth::MythStore::open(root) {
if let Ok(symbols) = ms.symbols(slug) {
for s in &symbols {
for v in &s.vocabulary {
declared.push(("symbol".into(), v.trim().to_string(), None));
}
}
}
if let Ok(motifs) = ms.motifs(slug) {
for m in &motifs {
declared.push(("motif".into(), m.name.trim().to_string(), None));
}
}
}
if let Ok(us) = crate::world::utopia::UtopiaStore::open(root) {
if let Ok(findings) = us.findings(slug, true) {
for i in 0..findings.len() {
declared.push(("tension".into(), format!("#{}", i + 1), None));
}
}
}
edges.extend(declares_edges(book.id, &declared));
}
edges
}
pub fn paths(
&self,
from: Uuid,
to: Uuid,
kinds: &[EdgeKind],
max_hops: usize,
) -> Result<Option<Vec<Uuid>>> {
bfs_path(from, to, max_hops, |node| {
let here = EndpointRef::Node(node);
let mut out = Vec::new();
for e in self.raw().edges_around(node, kinds).map_err(map_edge_err)? {
if let EndpointRef::Node(other) = e.other_endpoint(&here) {
out.push(*other);
}
}
Ok(out)
})
}
pub fn graph_integrity_check(&self) -> Result<String> {
self.raw().edges_integrity_check().map_err(map_edge_err)
}
pub fn replace_confront_edges(&self, node: Uuid, edges: &[Edge]) -> Result<()> {
let existing = self.raw().edges_out(node, STANCE_KINDS).map_err(map_edge_err)?;
for e in existing {
if e.origin == EdgeOrigin::Judged {
self.raw().delete_edge(e.id).map_err(map_edge_err)?;
}
}
if !edges.is_empty() {
self.raw().add_edges(edges).map_err(map_edge_err)?;
}
Ok(())
}
pub fn promote_edge(&self, id: Uuid) -> Result<bool> {
Ok(self.raw().set_edge_origin(id, EdgeOrigin::Promoted).map_err(map_edge_err)? > 0)
}
pub fn dismiss_edge(&self, id: Uuid) -> Result<()> {
self.raw().delete_edge(id).map_err(map_edge_err)
}
pub fn persist_cites(&self, edges: &[Edge]) -> Result<usize> {
if edges.is_empty() {
return Ok(0);
}
let key = |e: &Edge| -> (String, String, String, String) {
let (sk, sr) = e.src.as_columns();
let (dk, dr) = e.dst.as_columns();
(sk.to_string(), sr, dk.to_string(), dr)
};
let existing = self.raw().edges_of_kind(EdgeKind::Cites).map_err(map_edge_err)?;
let seen: HashSet<(String, String, String, String)> = existing.iter().map(key).collect();
let fresh: Vec<Edge> = edges.iter().filter(|e| !seen.contains(&key(e))).cloned().collect();
let n = fresh.len();
if n > 0 {
self.raw().add_edges(&fresh).map_err(map_edge_err)?;
}
Ok(n)
}
pub fn contradicting(&self, node: Uuid) -> Result<Vec<Edge>> {
self.raw()
.edges_around(node, &[EdgeKind::Contradicts, EdgeKind::InTension])
.map_err(map_edge_err)
}
pub fn pending_edges(&self) -> Result<Vec<Edge>> {
self.raw().edges_of_origin(EdgeOrigin::Judged).map_err(map_edge_err)
}
pub fn subgraph(&self, seed: Uuid, radius: usize, kinds: &[EdgeKind]) -> Result<Vec<Edge>> {
collect_subgraph(seed, radius, |node| {
self.raw().edges_around(node, kinds).map_err(map_edge_err)
})
}
pub fn grounding_signals(&self, book_event_nodes: &[Uuid]) -> Result<GraphGroundingSignals> {
let mut event_chars: Vec<(Uuid, Uuid)> = Vec::new();
for &ev in book_event_nodes {
for e in self.raw().edges_out(ev, &[EdgeKind::EventInvolves]).map_err(map_edge_err)? {
if e.attrs.get("role").and_then(|v| v.as_str()) == Some("character") {
if let EndpointRef::Node(ch) = &e.dst {
event_chars.push((ev, *ch));
}
}
}
}
let recurring_pairs = recurring_character_pairs(&event_chars, 2);
let by_kind = self.raw().edges_by_kind().map_err(map_edge_err)?;
let open_contradictions = by_kind
.iter()
.filter(|(k, _)| k == "contradicts" || k == "in_tension")
.map(|(_, n)| *n)
.sum();
Ok(GraphGroundingSignals { recurring_pairs, open_contradictions })
}
pub fn rebuild_lexical(&self, cfg: &Config) -> Result<LexicalRebuild> {
let lang = crate::ai::prompts::iso_from_long(&cfg.language).to_string();
let Some(path) = wordnet::index_path(&lang).filter(|p| p.exists()) else {
return Ok(LexicalRebuild { installed: false, cleared: 0, added: 0 });
};
let wn = WordNet::load(&path).map_err(Error::Store)?;
let hierarchy = Hierarchy::load(self)?;
let root = self.project_root();
let mut occ: Vec<(Uuid, String, Vec<SenseNode>)> = Vec::new();
for book in hierarchy
.children_of(None)
.into_iter()
.filter(|n| n.kind == NodeKind::Book && n.system_tag.is_none())
{
for id in hierarchy.collect_subtree(book.id) {
let Some(n) = hierarchy.get(id) else { continue };
if n.kind != NodeKind::Paragraph {
continue;
}
let Some(rel) = n.file.as_ref() else { continue };
let Ok(raw) = std::fs::read_to_string(root.join(rel)) else { continue };
let mut seen: HashSet<String> = HashSet::new();
for lemma in salient_lemmas(&raw) {
if !seen.insert(lemma.clone()) {
continue;
}
let senses = wn.sense_nodes(&lemma);
if !senses.is_empty() {
occ.push((n.id, lemma, senses));
}
}
}
}
let edges = derive_lexical_edges(&occ, &lang);
let cleared = self.raw().delete_edges_by_kinds(LEXICAL_KINDS).map_err(map_edge_err)?;
let added = edges.len();
if !edges.is_empty() {
self.raw().add_edges(&edges).map_err(map_edge_err)?;
}
Ok(LexicalRebuild { installed: true, cleared, added })
}
}
const STANCE_KINDS: &[EdgeKind] = &[
EdgeKind::Contradicts,
EdgeKind::InTension,
EdgeKind::Qualifies,
EdgeKind::Agrees,
];
fn map_edge_err(e: anyhow::Error) -> Error {
Error::Store(e.to_string())
}
pub(crate) fn derive_structural_edges(nodes: &[Node]) -> Vec<Edge> {
let ids: HashSet<Uuid> = nodes.iter().map(|n| n.id).collect();
let mut edges = Vec::new();
for n in nodes {
for target in &n.linked_paragraphs {
if ids.contains(target) {
edges.push(Edge::new(
EndpointRef::Node(n.id),
EdgeKind::LinksTo,
EndpointRef::Node(*target),
EdgeOrigin::Structural,
));
}
}
if let Some(ev) = &n.event {
for c in &ev.characters {
if ids.contains(c) {
edges.push(
Edge::new(
EndpointRef::Node(n.id),
EdgeKind::EventInvolves,
EndpointRef::Node(*c),
EdgeOrigin::Structural,
)
.with_attrs(serde_json::json!({ "role": "character" })),
);
}
}
for p in &ev.places {
if ids.contains(p) {
edges.push(
Edge::new(
EndpointRef::Node(n.id),
EdgeKind::EventInvolves,
EndpointRef::Node(*p),
EdgeOrigin::Structural,
)
.with_attrs(serde_json::json!({ "role": "place" })),
);
}
}
}
}
edges
}
#[derive(Debug, Clone, Deserialize)]
struct SidecarSource {
#[serde(default)]
origin: String,
#[serde(default)]
detail: String,
#[serde(default)]
query: String,
#[serde(default)]
thread: String,
#[serde(default)]
created_at: String,
}
#[derive(Debug, Default, Deserialize)]
struct SidecarSourceFile {
#[serde(default)]
facts: BTreeMap<String, SidecarSource>,
}
#[derive(Debug, Clone, Deserialize)]
struct SidecarVerdict {
level: String,
#[serde(default)]
reason: String,
#[serde(default)]
checked_at: String,
}
#[derive(Debug, Default, Deserialize)]
struct SidecarVerdictFile {
#[serde(default)]
facts: BTreeMap<String, SidecarVerdict>,
}
fn read_json_map<T: Default + serde::de::DeserializeOwned>(root: &Path, file: &str) -> T {
let p = root.join(".inkhaven").join(file);
std::fs::read_to_string(p)
.ok()
.and_then(|s| serde_json::from_str::<T>(&s).ok())
.unwrap_or_default()
}
fn derive_sidecar_edges(root: &Path, node_ids: &HashSet<Uuid>) -> Vec<Edge> {
let sources: SidecarSourceFile = read_json_map(root, "fact-sources.json");
let verdicts: SidecarVerdictFile = read_json_map(root, "fact-verdicts.json");
let mut edges = derive_provenance_edges(node_ids, &sources.facts);
edges.extend(derive_verdict_edges(node_ids, &verdicts.facts));
edges
}
fn registry_for_origin(origin: &str) -> Registry {
match origin {
"openalex" => Registry::OpenAlex,
"arxiv" => Registry::Arxiv,
"wikidata" => Registry::Wikidata,
"geonames" => Registry::Geonames,
_ => Registry::Other,
}
}
fn derive_provenance_edges(
node_ids: &HashSet<Uuid>,
sources: &BTreeMap<String, SidecarSource>,
) -> Vec<Edge> {
let mut edges = Vec::new();
for (fact_id, rec) in sources {
let Ok(fact) = Uuid::parse_str(fact_id) else { continue };
if !node_ids.contains(&fact) {
continue;
}
let id = if rec.detail.is_empty() { rec.origin.clone() } else { rec.detail.clone() };
let dst = EndpointRef::Extern(ExternRef::Work {
registry: registry_for_origin(&rec.origin),
id,
});
edges.push(
Edge::new(EndpointRef::Node(fact), EdgeKind::SourcedFrom, dst, EdgeOrigin::Structural)
.with_attrs(serde_json::json!({
"origin": rec.origin,
"detail": rec.detail,
"query": rec.query,
"thread": rec.thread,
"created_at": rec.created_at,
})),
);
}
edges
}
fn derive_verdict_edges(
node_ids: &HashSet<Uuid>,
verdicts: &BTreeMap<String, SidecarVerdict>,
) -> Vec<Edge> {
let mut edges = Vec::new();
for (fact_id, v) in verdicts {
let Ok(fact) = Uuid::parse_str(fact_id) else { continue };
if !node_ids.contains(&fact) {
continue;
}
let dst = EndpointRef::Extern(ExternRef::Grade { level: v.level.to_lowercase() });
edges.push(
Edge::new(EndpointRef::Node(fact), EdgeKind::GradedAs, dst, EdgeOrigin::Structural)
.with_attrs(serde_json::json!({
"level": v.level,
"reason": v.reason,
"checked_at": v.checked_at,
})),
);
}
edges
}
fn bfs_path(
from: Uuid,
to: Uuid,
max_hops: usize,
mut neighbors: impl FnMut(Uuid) -> Result<Vec<Uuid>>,
) -> Result<Option<Vec<Uuid>>> {
if from == to {
return Ok(Some(vec![from]));
}
const MAX_VISITS: usize = 20_000;
let mut visited: HashSet<Uuid> = HashSet::from([from]);
let mut queue: VecDeque<Vec<Uuid>> = VecDeque::from([vec![from]]);
let mut budget = MAX_VISITS;
while let Some(path) = queue.pop_front() {
if path.len() > max_hops {
continue;
}
let tail = *path.last().expect("path is non-empty");
for other in neighbors(tail)? {
if budget == 0 {
return Ok(None);
}
budget -= 1;
if other == to {
let mut p = path.clone();
p.push(other);
return Ok(Some(p));
}
if visited.insert(other) {
let mut p = path.clone();
p.push(other);
queue.push_back(p);
}
}
}
Ok(None)
}
fn collect_declared_schemes(root: &Path, h: &Hierarchy) -> HashMap<String, String> {
let mut declared = HashMap::new();
let Some(sources) = h.iter().find(|n| {
n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(crate::store::SYSTEM_TAG_SOURCES)
}) else {
return declared;
};
for id in h.collect_subtree(sources.id) {
let Some(n) = h.get(id) else { continue };
if n.kind != NodeKind::Paragraph {
continue;
}
let Some(rel) = &n.file else { continue };
let Ok(raw) = std::fs::read_to_string(root.join(rel)) else { continue };
let body = crate::typst_prose::strip_leading_heading(&raw);
if let Some(e) = BibEntry::from_hjson(&body) {
if let Some(scheme) = e.scheme.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) {
declared.insert(e.key.clone(), scheme.to_string());
}
}
}
declared
}
fn derive_locus_edges(
cites: &[(Uuid, String, String)],
schemes: &HashMap<String, LocusScheme>,
) -> Vec<Edge> {
let mut edges = Vec::new();
for (node, key, raw) in cites {
let raw = raw.trim();
if raw.is_empty() {
continue;
}
let (scheme, canonical) = match schemes.get(key) {
Some(s) => (s.name().to_string(), s.canonicalize(raw)),
None => (String::new(), raw.to_string()),
};
edges.push(
Edge::new(
EndpointRef::Node(*node),
EdgeKind::CitesLocus,
EndpointRef::Extern(ExternRef::Locus { scheme, canonical }),
EdgeOrigin::Structural,
)
.with_attrs(serde_json::json!({ "key": key })),
);
}
edges
}
fn declares_edges(book: Uuid, declared: &[(String, String, Option<String>)]) -> Vec<Edge> {
let mut edges = Vec::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
for (kind, label, arc) in declared {
let label = label.trim();
if label.is_empty() {
continue;
}
if !seen.insert((kind.clone(), label.to_string())) {
continue;
}
let mut e = Edge::new(
EndpointRef::Node(book),
EdgeKind::Declares,
EndpointRef::Extern(ExternRef::Declared { kind: kind.clone(), label: label.to_string() }),
EdgeOrigin::Structural,
);
if let Some(arc) = arc.as_ref().filter(|a| !a.trim().is_empty()) {
e = e.with_attrs(serde_json::json!({ "arc": arc }));
}
edges.push(e);
}
edges
}
fn salient_lemmas(prose: &str) -> Vec<String> {
prose
.split(|c: char| !c.is_alphabetic())
.filter(|w| w.chars().count() >= 3)
.map(|w| w.to_lowercase())
.collect()
}
fn derive_lexical_edges(occurrences: &[(Uuid, String, Vec<SenseNode>)], lang: &str) -> Vec<Edge> {
let sense = |synset: &str| {
EndpointRef::Extern(ExternRef::Sense { lang: lang.to_string(), synset: synset.to_string() })
};
let mut edges = Vec::new();
let mut structural: HashSet<(String, EdgeKind, String)> = HashSet::new();
let mut mentioned: HashSet<(Uuid, String)> = HashSet::new();
for (node, lemma, senses) in occurrences {
for sn in senses {
if mentioned.insert((*node, sn.synset.clone())) {
edges.push(
Edge::new(
EndpointRef::Node(*node),
EdgeKind::Mentions,
sense(&sn.synset),
EdgeOrigin::Imported,
)
.with_attrs(serde_json::json!({ "lemma": lemma })),
);
}
for (kind, targets) in [
(EdgeKind::Hypernym, &sn.hypernyms),
(EdgeKind::Hyponym, &sn.hyponyms),
(EdgeKind::Antonym, &sn.antonyms),
] {
for t in targets {
if structural.insert((sn.synset.clone(), kind, t.clone())) {
edges.push(Edge::new(sense(&sn.synset), kind, sense(t), EdgeOrigin::Imported));
}
}
}
if let Some(ili) = &sn.ili {
if structural.insert((sn.synset.clone(), EdgeKind::Translates, ili.clone())) {
edges.push(Edge::new(
sense(&sn.synset),
EdgeKind::Translates,
EndpointRef::Extern(ExternRef::Ili { id: ili.clone() }),
EdgeOrigin::Imported,
));
}
}
}
}
edges
}
fn recurring_character_pairs(event_chars: &[(Uuid, Uuid)], min_shared: usize) -> Vec<(Uuid, Uuid)> {
let mut by_char: HashMap<Uuid, HashSet<Uuid>> = HashMap::new();
for (event, ch) in event_chars {
by_char.entry(*ch).or_default().insert(*event);
}
let mut chars: Vec<Uuid> = by_char.keys().copied().collect();
chars.sort();
let mut pairs = Vec::new();
for i in 0..chars.len() {
for j in (i + 1)..chars.len() {
let shared = by_char[&chars[i]].intersection(&by_char[&chars[j]]).count();
if shared >= min_shared {
pairs.push((chars[i], chars[j]));
}
}
}
pairs
}
fn collect_subgraph(
seed: Uuid,
radius: usize,
mut neighbors: impl FnMut(Uuid) -> Result<Vec<Edge>>,
) -> Result<Vec<Edge>> {
const MAX_EDGES: usize = 500;
let mut collected: Vec<Edge> = Vec::new();
let mut seen_edges: HashSet<Uuid> = HashSet::new();
let mut visited: HashSet<Uuid> = HashSet::from([seed]);
let mut frontier: Vec<Uuid> = vec![seed];
for _ in 0..radius.max(1) {
let mut next: Vec<Uuid> = Vec::new();
for &node in &frontier {
for e in neighbors(node)? {
if seen_edges.insert(e.id) {
collected.push(e.clone());
if collected.len() >= MAX_EDGES {
return Ok(collected);
}
}
if let EndpointRef::Node(other) = e.other_endpoint(&EndpointRef::Node(node)) {
if visited.insert(*other) {
next.push(*other);
}
}
}
}
if next.is_empty() {
break;
}
frontier = next;
}
Ok(collected)
}
pub fn render_neighbourhood(
focus: Uuid,
edges: &[Edge],
label: impl Fn(&EndpointRef) -> String,
) -> String {
const PER_GROUP: usize = 8;
let here = EndpointRef::Node(focus);
let mut out = format!("◆ {}\n", label(&here));
if edges.is_empty() {
out.push_str(" (no edges — run `graph rebuild` / `graph lexical` to populate)\n");
return out;
}
let mut order: Vec<EdgeKind> = Vec::new();
let mut groups: HashMap<EdgeKind, Vec<&Edge>> = HashMap::new();
for e in edges {
if !groups.contains_key(&e.kind) {
order.push(e.kind);
}
groups.entry(e.kind).or_default().push(e);
}
for kind in &order {
let g = &groups[kind];
out.push_str(&format!("├─ {} ({})\n", kind.as_str(), g.len()));
for e in g.iter().take(PER_GROUP) {
let arrow = if !e.directed {
"⇄"
} else if e.src == here {
"→"
} else {
"←"
};
let other = label(e.other_endpoint(&here));
let reason = e
.reason
.as_deref()
.filter(|r| !r.is_empty())
.map(|r| format!(" — {r}"))
.unwrap_or_default();
out.push_str(&format!("│ {arrow} {other}{reason}\n"));
}
if g.len() > PER_GROUP {
out.push_str(&format!("│ … +{} more\n", g.len() - PER_GROUP));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::edge_store::EdgeStore;
use tempfile::TempDir;
fn para(id: Uuid, links: &[Uuid]) -> Node {
serde_json::from_value(serde_json::json!({
"id": id, "kind": "paragraph", "title": "p", "slug": "p",
"path": [], "parent_id": null, "order": 1, "file": null,
"modified_at": "2026-01-01T00:00:00Z",
"linked_paragraphs": links,
}))
.expect("test node deserialises")
}
fn event_node(id: Uuid, chars: &[Uuid], places: &[Uuid]) -> Node {
serde_json::from_value(serde_json::json!({
"id": id, "kind": "paragraph", "title": "e", "slug": "e",
"path": [], "parent_id": null, "order": 1, "file": null,
"modified_at": "2026-01-01T00:00:00Z",
"event": { "start_ticks": 0, "characters": chars, "places": places },
}))
.expect("test event node deserialises")
}
fn sig(e: &Edge) -> (EndpointRef, EdgeKind, EndpointRef) {
(e.src.clone(), e.kind, e.dst.clone())
}
#[test]
fn links_and_events_become_edges() {
let (a, b, c) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
let ev = Uuid::now_v7();
let nodes = vec![
para(a, &[b]),
para(b, &[]),
para(c, &[]),
event_node(ev, &[a], &[b]),
];
let edges = derive_structural_edges(&nodes);
assert_eq!(edges.len(), 3);
let links: Vec<_> = edges.iter().filter(|e| e.kind == EdgeKind::LinksTo).collect();
assert_eq!(links.len(), 1);
assert_eq!(links[0].src, EndpointRef::Node(a));
assert_eq!(links[0].dst, EndpointRef::Node(b));
assert!(links[0].directed);
assert_eq!(links[0].origin, EdgeOrigin::Structural);
let involves: Vec<_> = edges.iter().filter(|e| e.kind == EdgeKind::EventInvolves).collect();
assert_eq!(involves.len(), 2);
let char_edge = involves.iter().find(|e| e.dst == EndpointRef::Node(a)).unwrap();
assert_eq!(char_edge.attrs["role"], "character");
let place_edge = involves.iter().find(|e| e.dst == EndpointRef::Node(b)).unwrap();
assert_eq!(place_edge.attrs["role"], "place");
}
#[test]
fn dangling_targets_are_skipped() {
let a = Uuid::now_v7();
let ghost = Uuid::now_v7(); let ev = Uuid::now_v7();
let nodes = vec![para(a, &[ghost]), event_node(ev, &[ghost], &[ghost])];
assert!(
derive_structural_edges(&nodes).is_empty(),
"edges to non-existent nodes must be skipped, no dangling edges"
);
}
#[test]
fn derivation_is_idempotent_by_endpoints() {
let (a, b) = (Uuid::now_v7(), Uuid::now_v7());
let nodes = vec![para(a, &[b]), para(b, &[a])];
let mut first: Vec<_> = derive_structural_edges(&nodes).iter().map(sig).collect();
let mut second: Vec<_> = derive_structural_edges(&nodes).iter().map(sig).collect();
first.sort_by_key(|(s, _, d)| (format!("{s:?}"), format!("{d:?}")));
second.sort_by_key(|(s, _, d)| (format!("{s:?}"), format!("{d:?}")));
assert_eq!(first, second, "rebuild must be idempotent by endpoint set");
}
#[test]
fn derived_edges_answer_reverse_index_queries() {
let (a, b) = (Uuid::now_v7(), Uuid::now_v7());
let edges = derive_structural_edges(&[para(a, &[b]), para(b, &[])]);
let dir = TempDir::new().unwrap();
let store = EdgeStore::new(dir.path().join("edges.db"), 2).unwrap();
store.insert_batch(&edges).unwrap();
let into_b = store.incoming(&EndpointRef::Node(b), &[EdgeKind::LinksTo]).unwrap();
assert_eq!(into_b.len(), 1);
assert_eq!(into_b[0].src, EndpointRef::Node(a));
assert!(store.incoming(&EndpointRef::Node(a), &[EdgeKind::LinksTo]).unwrap().is_empty());
}
fn src_rec(origin: &str, detail: &str) -> SidecarSource {
SidecarSource {
origin: origin.into(),
detail: detail.into(),
query: "q".into(),
thread: "t".into(),
created_at: "2026-07-01T10:00:00Z".into(),
}
}
#[test]
fn provenance_becomes_sourced_from_with_registry() {
let fact = Uuid::now_v7();
let ids = HashSet::from([fact]);
let mut sources = BTreeMap::new();
sources.insert(fact.to_string(), src_rec("arxiv", "2401.00001"));
let edges = derive_provenance_edges(&ids, &sources);
assert_eq!(edges.len(), 1);
let e = &edges[0];
assert_eq!(e.kind, EdgeKind::SourcedFrom);
assert_eq!(e.src, EndpointRef::Node(fact));
assert_eq!(
e.dst,
EndpointRef::Extern(ExternRef::Work { registry: Registry::Arxiv, id: "2401.00001".into() })
);
assert_eq!(e.attrs["origin"], "arxiv");
assert_eq!(e.attrs["query"], "q");
assert_eq!(e.origin, EdgeOrigin::Structural);
}
#[test]
fn provenance_roundtrips_losslessly_across_the_origin_vocab() {
let origins = [
("model", ""),
("manual", ""),
("promoted", "notes/idea"),
("web", "https://example.org"),
("document", "sources/paper.pdf"),
("archive", "ia:xyz"),
("wikisource", "Page"),
("wikidata", "Q42"),
("geonames", "524901"),
("openalex", "W123"),
("arxiv", "2401.00001"),
("computed", "sum"),
("simulation", "Мир·тик-7"), ];
let mut ids = HashSet::new();
let mut sources = BTreeMap::new();
let mut want: BTreeMap<String, SidecarSource> = BTreeMap::new();
for (origin, detail) in origins {
let fact = Uuid::now_v7();
ids.insert(fact);
let rec = src_rec(origin, detail);
sources.insert(fact.to_string(), rec.clone());
want.insert(fact.to_string(), rec);
}
let edges = derive_provenance_edges(&ids, &sources);
assert_eq!(edges.len(), origins.len());
let mut got: BTreeMap<String, serde_json::Value> = BTreeMap::new();
for e in &edges {
let EndpointRef::Node(fact) = &e.src else { panic!("src must be a node") };
got.insert(fact.to_string(), e.attrs.clone());
}
for (fid, rec) in &want {
let a = &got[fid];
assert_eq!(a["origin"], rec.origin);
assert_eq!(a["detail"], rec.detail, "detail (incl. Cyrillic) must survive");
assert_eq!(a["query"], rec.query);
assert_eq!(a["thread"], rec.thread);
assert_eq!(a["created_at"], rec.created_at);
}
}
#[test]
fn verdict_becomes_graded_as_with_level_and_reason() {
let fact = Uuid::now_v7();
let ids = HashSet::from([fact]);
let mut verdicts = BTreeMap::new();
verdicts.insert(
fact.to_string(),
SidecarVerdict { level: "Inaccurate".into(), reason: "противоречит §3".into(), checked_at: "2026-07-02T09:00:00Z".into() },
);
let edges = derive_verdict_edges(&ids, &verdicts);
assert_eq!(edges.len(), 1);
let e = &edges[0];
assert_eq!(e.kind, EdgeKind::GradedAs);
assert_eq!(e.src, EndpointRef::Node(fact));
assert_eq!(e.dst, EndpointRef::Extern(ExternRef::Grade { level: "inaccurate".into() }));
assert_eq!(e.attrs["level"], "Inaccurate");
assert_eq!(e.attrs["reason"], "противоречит §3");
}
#[test]
fn sidecar_rows_for_missing_nodes_are_skipped() {
let live = Uuid::now_v7();
let ghost = Uuid::now_v7();
let ids = HashSet::from([live]);
let mut sources = BTreeMap::new();
sources.insert(ghost.to_string(), src_rec("model", ""));
sources.insert("not-a-uuid".to_string(), src_rec("model", ""));
assert!(derive_provenance_edges(&ids, &sources).is_empty());
let mut verdicts = BTreeMap::new();
verdicts.insert(
ghost.to_string(),
SidecarVerdict { level: "Dubious".into(), reason: String::new(), checked_at: String::new() },
);
assert!(derive_verdict_edges(&ids, &verdicts).is_empty());
}
#[test]
fn graded_facts_group_by_grade_via_reverse_index() {
let (f1, f2, f3) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
let ids = HashSet::from([f1, f2, f3]);
let mut verdicts = BTreeMap::new();
let mk = |lvl: &str| SidecarVerdict { level: lvl.into(), reason: String::new(), checked_at: String::new() };
verdicts.insert(f1.to_string(), mk("Inaccurate"));
verdicts.insert(f2.to_string(), mk("Inaccurate"));
verdicts.insert(f3.to_string(), mk("Accurate"));
let edges = derive_verdict_edges(&ids, &verdicts);
let dir = TempDir::new().unwrap();
let store = EdgeStore::new(dir.path().join("edges.db"), 2).unwrap();
store.insert_batch(&edges).unwrap();
let inaccurate = store
.incoming(&EndpointRef::Extern(ExternRef::Grade { level: "inaccurate".into() }), &[EdgeKind::GradedAs])
.unwrap();
assert_eq!(inaccurate.len(), 2, "two facts graded inaccurate group under the bucket");
}
fn bible_scheme() -> std::collections::HashMap<String, LocusScheme> {
let (schemes, _) = index_locorum::resolve_schemes(
&crate::config::Config::default().sources.ref_schemes,
&std::collections::HashMap::new(),
&["bible".to_string()],
);
schemes
}
#[test]
fn locus_edges_regroup_variant_scripture_spellings() {
let schemes = bible_scheme();
let (n1, n2, n3) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
let cites = vec![
(n1, "bible".to_string(), "Joh 3.16".to_string()), (n2, "bible".to_string(), "Иоанна 3:16".to_string()), (n3, "bible".to_string(), "John 3:16".to_string()), ];
let edges = derive_locus_edges(&cites, &schemes);
assert_eq!(edges.len(), 3);
let dsts: std::collections::HashSet<_> = edges.iter().map(|e| e.dst.clone()).collect();
assert_eq!(dsts.len(), 1, "all three variants collapse to one locus endpoint");
let want = EndpointRef::Extern(ExternRef::Locus { scheme: "bible".into(), canonical: "John 3:16".into() });
assert!(dsts.contains(&want), "canonical endpoint is {want:?}, got {dsts:?}");
assert_eq!(edges[0].kind, EdgeKind::CitesLocus);
assert_eq!(edges[0].origin, EdgeOrigin::Structural);
assert_eq!(edges[0].attrs["key"], "bible");
}
#[test]
fn declared_world_becomes_declares_edges() {
let book = Uuid::now_v7();
let declared = vec![
("character".to_string(), "Mara".to_string(), Some("corruption".to_string())),
("symbol".to_string(), "the tide".to_string(), None),
("character".to_string(), "Mara".to_string(), Some("corruption".to_string())), ("tension".to_string(), "#1".to_string(), None),
("symbol".to_string(), " ".to_string(), None), ];
let edges = declares_edges(book, &declared);
assert_eq!(edges.len(), 3, "the duplicate character and the empty symbol are dropped");
let ch = edges
.iter()
.find(|e| e.dst == EndpointRef::Extern(ExternRef::Declared { kind: "character".into(), label: "Mara".into() }))
.unwrap();
assert_eq!(ch.kind, EdgeKind::Declares);
assert_eq!(ch.src, EndpointRef::Node(book));
assert_eq!(ch.origin, EdgeOrigin::Structural);
assert_eq!(ch.attrs["arc"], "corruption");
assert!(edges.iter().any(|e| e.dst == EndpointRef::Extern(ExternRef::Declared { kind: "tension".into(), label: "#1".into() })));
}
#[test]
fn locus_without_scheme_passes_through_verbatim() {
let node = Uuid::now_v7();
let cites = vec![(node, "smith".to_string(), "§4.2".to_string())];
let edges = derive_locus_edges(&cites, &std::collections::HashMap::new());
assert_eq!(edges.len(), 1);
assert_eq!(
edges[0].dst,
EndpointRef::Extern(ExternRef::Locus { scheme: String::new(), canonical: "§4.2".into() })
);
}
fn sn(synset: &str, ili: Option<&str>, hyper: &[&str], hypo: &[&str], anto: &[&str]) -> SenseNode {
SenseNode {
synset: synset.into(),
ili: ili.map(|s| s.into()),
hypernyms: hyper.iter().map(|s| s.to_string()).collect(),
hyponyms: hypo.iter().map(|s| s.to_string()).collect(),
antonyms: anto.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn recurring_pairs_need_two_shared_events() {
let (a, b, c) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
let (e1, e2, e3) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
let ev = vec![(e1, a), (e1, b), (e2, a), (e2, b), (e3, a), (e3, c)];
let pairs = recurring_character_pairs(&ev, 2);
assert_eq!(pairs.len(), 1, "only A↔B recur (≥2 shared events)");
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
assert_eq!(pairs[0], (lo, hi));
assert!(recurring_character_pairs(&[(e1, a), (e1, c)], 2).is_empty());
}
#[test]
fn subgraph_is_radius_bounded() {
let ids: Vec<Uuid> = (0..4).map(|_| Uuid::now_v7()).collect();
let mk = |a: Uuid, b: Uuid| {
Edge::new(EndpointRef::Node(a), EdgeKind::LinksTo, EndpointRef::Node(b), EdgeOrigin::Structural)
};
let edges = vec![mk(ids[0], ids[1]), mk(ids[1], ids[2]), mk(ids[2], ids[3])];
let nb = |n: Uuid| {
Ok(edges
.iter()
.filter(|e| e.src == EndpointRef::Node(n) || e.dst == EndpointRef::Node(n))
.cloned()
.collect::<Vec<_>>())
};
assert_eq!(collect_subgraph(ids[0], 1, nb).unwrap().len(), 1);
assert_eq!(collect_subgraph(ids[0], 2, nb).unwrap().len(), 2);
assert_eq!(collect_subgraph(ids[0], 3, nb).unwrap().len(), 3);
}
#[test]
fn neighbourhood_render_groups_caps_and_shows_direction() {
let focus = Uuid::now_v7();
let here = EndpointRef::Node(focus);
let mut edges = vec![
Edge::new(EndpointRef::Node(Uuid::now_v7()), EdgeKind::LinksTo, here.clone(), EdgeOrigin::Structural),
Edge::new(here.clone(), EdgeKind::Contradicts, EndpointRef::Extern(ExternRef::Evidence { label: "fact: X".into() }), EdgeOrigin::Judged)
.with_reason("opposes §3"),
];
for i in 0..10 {
edges.push(Edge::new(here.clone(), EdgeKind::Mentions, EndpointRef::Extern(ExternRef::Sense { lang: "en".into(), synset: format!("s{i}") }), EdgeOrigin::Imported));
}
let label = |ep: &EndpointRef| match ep {
EndpointRef::Node(u) if *u == focus => "THIS".to_string(),
EndpointRef::Node(_) => "other-para".to_string(),
EndpointRef::Extern(_) => {
let (k, r) = ep.as_columns();
format!("{k} {r}")
}
};
let s = render_neighbourhood(focus, &edges, label);
assert!(s.starts_with("◆ THIS\n"));
assert!(s.contains("├─ contradicts (1)"));
assert!(s.contains("⇄ evidence fact: X — opposes §3"), "symmetric arrow + reason: {s}");
assert!(s.contains("← other-para"), "incoming link arrow");
assert!(s.contains("├─ mentions (10)"));
assert!(s.contains("… +2 more"), "mentions group capped at 8: {s}");
}
#[test]
fn wordnet_rel_becomes_edge_and_bridge() {
let node = Uuid::now_v7();
let occ = vec![(node, "dog".to_string(), vec![sn("s-dog", Some("i-dog"), &["s-animal"], &["s-puppy"], &[])])];
let edges = derive_lexical_edges(&occ, "en");
let sense = |s: &str| EndpointRef::Extern(ExternRef::Sense { lang: "en".into(), synset: s.into() });
let mentions = edges.iter().find(|e| e.kind == EdgeKind::Mentions).unwrap();
assert_eq!(mentions.src, EndpointRef::Node(node));
assert_eq!(mentions.dst, sense("s-dog"));
assert_eq!(mentions.attrs["lemma"], "dog");
assert_eq!(mentions.origin, EdgeOrigin::Imported);
assert!(edges.iter().any(|e| e.kind == EdgeKind::Hypernym && e.src == sense("s-dog") && e.dst == sense("s-animal")));
assert!(edges.iter().any(|e| e.kind == EdgeKind::Hyponym && e.dst == sense("s-puppy")));
assert!(edges.iter().any(|e| e.kind == EdgeKind::Translates
&& e.dst == EndpointRef::Extern(ExternRef::Ili { id: "i-dog".into() })));
}
#[test]
fn lexical_import_is_bounded_to_touched_senses() {
let node = Uuid::now_v7();
let occ = vec![(node, "cat".to_string(), vec![sn("s-cat", None, &["s-feline"], &[], &[])])];
let edges = derive_lexical_edges(&occ, "en");
let mut synsets: std::collections::HashSet<String> = std::collections::HashSet::new();
for e in &edges {
for ep in [&e.src, &e.dst] {
if let EndpointRef::Extern(ExternRef::Sense { synset, .. }) = ep {
synsets.insert(synset.clone());
}
}
}
assert_eq!(synsets, std::collections::HashSet::from(["s-cat".to_string(), "s-feline".to_string()]));
assert!(!edges.iter().any(|e| e.kind == EdgeKind::Translates));
}
#[test]
fn cross_lingual_senses_share_an_ili_bucket() {
let (n_ru, n_de) = (Uuid::now_v7(), Uuid::now_v7());
let mut edges = derive_lexical_edges(&[(n_ru, "кошка".into(), vec![sn("ru-1", Some("i-cat"), &[], &[], &[])])], "ru");
edges.extend(derive_lexical_edges(&[(n_de, "Katze".into(), vec![sn("de-1", Some("i-cat"), &[], &[], &[])])], "de"));
let dir = TempDir::new().unwrap();
let store = EdgeStore::new(dir.path().join("edges.db"), 2).unwrap();
store.insert_batch(&edges).unwrap();
let ili = EndpointRef::Extern(ExternRef::Ili { id: "i-cat".into() });
let into_ili = store.incoming(&ili, &[EdgeKind::Translates]).unwrap();
assert_eq!(into_ili.len(), 2, "ru + de senses both translate to the shared ILI");
let srcs: std::collections::HashSet<_> = into_ili.iter().map(|e| e.src.clone()).collect();
assert!(srcs.contains(&EndpointRef::Extern(ExternRef::Sense { lang: "ru".into(), synset: "ru-1".into() })));
assert!(srcs.contains(&EndpointRef::Extern(ExternRef::Sense { lang: "de".into(), synset: "de-1".into() })));
}
#[test]
fn salient_lemmas_are_lowercased_content_words() {
let got = salient_lemmas("The Quick brown fox, a #emph[test]! И кошка.");
assert!(got.contains(&"quick".to_string()));
assert!(got.contains(&"brown".to_string()));
assert!(got.contains(&"кошка".to_string()), "Unicode content words kept");
assert!(!got.iter().any(|w| w == "a"), "short tokens dropped");
}
#[test]
fn citation_path_is_hop_bounded() {
let ids: Vec<Uuid> = (0..5).map(|_| Uuid::now_v7()).collect();
let mut adj: BTreeMap<Uuid, Vec<Uuid>> = BTreeMap::new();
for w in ids.windows(2) {
adj.entry(w[0]).or_default().push(w[1]);
adj.entry(w[1]).or_default().push(w[0]); }
let nb = |n: Uuid| Ok(adj.get(&n).cloned().unwrap_or_default());
let p = bfs_path(ids[0], ids[4], 4, nb).unwrap().unwrap();
assert_eq!(p, ids, "full chain within 4 hops");
assert_eq!(p.len() - 1, 4, "exactly 4 hops");
assert!(bfs_path(ids[0], ids[4], 3, nb).unwrap().is_none(), "5 nodes need 4 hops; 3 is too few");
assert_eq!(bfs_path(ids[0], ids[0], 0, nb).unwrap(), Some(vec![ids[0]]), "self is zero hops");
}
}