Skip to main content

rustbrain_core/
autolink.rs

1//! Orphan detection and soft auto-links (algorithmic, low weight).
2//!
3//! **Explicit** edges (`relates_to`, `anchors`, …) are user/agent-authored.
4//! **Auto** edges use `relation_type` values prefixed with `auto_` and lower
5//! weights so context hops prefer human links.
6
7use crate::error::Result;
8use crate::storage::Database;
9use crate::types::{Edge, Node, NodeType};
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14
15/// Soft edge: same filename stem under different paths (e.g. goals/x.md ↔ concepts/x.md).
16pub const REL_AUTO_FILENAME: &str = "auto_filename";
17/// Soft edge: shared tag (excluding trivial type-only tags).
18pub const REL_AUTO_TAG: &str = "auto_tag";
19
20/// Default weight for filename-stem matches.
21pub const WEIGHT_AUTO_FILENAME: f32 = 0.40;
22/// Default weight for shared-tag matches.
23pub const WEIGHT_AUTO_TAG: f32 = 0.25;
24
25/// True when `relation_type` is a soft auto-link.
26pub fn is_auto_relation(relation_type: &str) -> bool {
27    relation_type.starts_with("auto_")
28}
29
30/// True when the relation is user/agent-authored (not auto).
31pub fn is_explicit_relation(relation_type: &str) -> bool {
32    !is_auto_relation(relation_type)
33}
34
35/// A note with no explicit (non-auto) graph edges.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct OrphanNote {
38    /// Node id.
39    pub id: String,
40    /// Domain type.
41    pub node_type: String,
42    /// Title.
43    pub title: String,
44    /// Repo-relative path if known.
45    pub file_path: Option<String>,
46    /// Suggested soft links (not yet written unless auto-link is run).
47    pub suggestions: Vec<AutoLinkSuggestion>,
48}
49
50/// One proposed or applied soft edge endpoint.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AutoLinkSuggestion {
53    /// Other node id.
54    pub target_id: String,
55    /// Other node title (best-effort).
56    pub target_title: String,
57    /// Other path if known.
58    pub target_path: Option<String>,
59    /// `auto_filename` / `auto_tag` / …
60    pub relation_type: String,
61    /// Soft weight.
62    pub weight: f32,
63    /// Human-readable reason.
64    pub reason: String,
65}
66
67/// Report from [`run_auto_link`].
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AutoLinkReport {
70    /// Edges inserted or updated this run.
71    pub edges_upserted: usize,
72    /// Unique undirected pairs considered.
73    pub pairs_considered: usize,
74    /// Target scope description.
75    pub scope: String,
76    /// Sample of applied links (up to 50).
77    pub applied: Vec<AutoLinkSuggestion>,
78}
79
80/// Filename stem for auto-filename matching (`docs/goals/rust-fluency.md` → `rust-fluency`).
81pub fn path_stem(file_path: &str) -> Option<String> {
82    let p = Path::new(file_path);
83    let stem = p.file_stem()?.to_str()?.to_ascii_lowercase();
84    if stem.is_empty() || stem == "readme" || stem == "template" {
85        return None;
86    }
87    // Ignore common generated / index stems
88    if stem.contains("generated") || stem == "from-readme" || stem == "bootstrap-checklist" {
89        return None;
90    }
91    Some(stem)
92}
93
94fn trivial_tag(tag: &str) -> bool {
95    matches!(
96        tag.to_ascii_lowercase().as_str(),
97        "goal"
98            | "adr"
99            | "concept"
100            | "symbol"
101            | "reference"
102            | "edge_case"
103            | "alternative"
104            | "readme"
105            | "generated"
106            | "index"
107    )
108}
109
110/// List non-symbol notes that have zero **explicit** edges (auto edges ignored).
111pub fn list_orphan_notes(db: &Database) -> Result<Vec<OrphanNote>> {
112    let notes = list_note_nodes(db)?;
113    let explicit = explicit_degree(db)?;
114    let mut out = Vec::new();
115    for n in &notes {
116        let deg = explicit.get(&n.id).copied().unwrap_or(0);
117        if deg > 0 {
118            continue;
119        }
120        // Skip pure scaffold ids for the orphan list noise (still linkable via --auto path)
121        if is_noise_orphan_id(&n.id) {
122            continue;
123        }
124        let suggestions = suggest_for_node(db, n, &notes)?;
125        out.push(OrphanNote {
126            id: n.id.clone(),
127            node_type: n.node_type.as_str().to_string(),
128            title: n.title.clone(),
129            file_path: n.file_path.clone(),
130            suggestions,
131        });
132    }
133    out.sort_by(|a, b| a.id.cmp(&b.id));
134    Ok(out)
135}
136
137fn is_noise_orphan_id(id: &str) -> bool {
138    let id = id.to_lowercase();
139    id.contains("template")
140        || id.contains("bootstrap")
141        || id.contains("module-map.generated")
142        || id == "agents"
143        || id.ends_with("/agents")
144        || id == "docs/goals/readme"
145}
146
147fn list_note_nodes(db: &Database) -> Result<Vec<Node>> {
148    let mut nodes = Vec::new();
149    for ty in [
150        NodeType::Goal,
151        NodeType::Adr,
152        NodeType::Concept,
153        NodeType::EdgeCase,
154        NodeType::Reference,
155        NodeType::Alternative,
156    ] {
157        for id in db.list_node_ids_by_type(ty.as_str())? {
158            if let Some(n) = db.get_node(&id)? {
159                nodes.push(n);
160            }
161        }
162    }
163    // Root README hub is a goal-like note
164    if let Some(n) = db.get_node("readme")? {
165        if !nodes.iter().any(|x| x.id == "readme") {
166            nodes.push(n);
167        }
168    }
169    Ok(nodes)
170}
171
172fn explicit_degree(db: &Database) -> Result<HashMap<String, usize>> {
173    let edges = db.get_all_edges()?;
174    let mut deg: HashMap<String, usize> = HashMap::new();
175    for e in edges {
176        if !is_explicit_relation(&e.relation_type) {
177            continue;
178        }
179        *deg.entry(e.source_id).or_default() += 1;
180        *deg.entry(e.target_id).or_default() += 1;
181    }
182    Ok(deg)
183}
184
185fn suggest_for_node(db: &Database, node: &Node, all: &[Node]) -> Result<Vec<AutoLinkSuggestion>> {
186    let mut suggestions = Vec::new();
187    let mut seen = HashSet::new();
188
189    // Filename stem matches
190    if let Some(path) = &node.file_path {
191        if let Some(stem) = path_stem(path) {
192            for other in all {
193                if other.id == node.id {
194                    continue;
195                }
196                let Some(op) = other.file_path.as_ref() else {
197                    continue;
198                };
199                let Some(ostem) = path_stem(op) else {
200                    continue;
201                };
202                if ostem != stem {
203                    continue;
204                }
205                let key = format!("fn:{}:{}", node.id, other.id);
206                if !seen.insert(key) {
207                    continue;
208                }
209                suggestions.push(AutoLinkSuggestion {
210                    target_id: other.id.clone(),
211                    target_title: other.title.clone(),
212                    target_path: other.file_path.clone(),
213                    relation_type: REL_AUTO_FILENAME.into(),
214                    weight: WEIGHT_AUTO_FILENAME,
215                    reason: format!("same filename stem `{stem}`"),
216                });
217            }
218        }
219    }
220
221    // Shared tags
222    let tags: HashSet<String> = db
223        .get_tags_for(&node.id)?
224        .into_iter()
225        .filter(|t| !trivial_tag(t))
226        .map(|t| t.to_ascii_lowercase())
227        .collect();
228    if !tags.is_empty() {
229        for other in all {
230            if other.id == node.id {
231                continue;
232            }
233            let otags = db.get_tags_for(&other.id)?;
234            let shared: Vec<String> = otags
235                .into_iter()
236                .filter(|t| !trivial_tag(t) && tags.contains(&t.to_ascii_lowercase()))
237                .collect();
238            if shared.is_empty() {
239                continue;
240            }
241            let key = format!("tag:{}:{}", node.id, other.id);
242            if !seen.insert(key) {
243                continue;
244            }
245            suggestions.push(AutoLinkSuggestion {
246                target_id: other.id.clone(),
247                target_title: other.title.clone(),
248                target_path: other.file_path.clone(),
249                relation_type: REL_AUTO_TAG.into(),
250                weight: WEIGHT_AUTO_TAG,
251                reason: format!("shared tag(s): {}", shared.join(", ")),
252            });
253        }
254    }
255
256    suggestions.sort_by(|a, b| {
257        b.weight
258            .partial_cmp(&a.weight)
259            .unwrap_or(std::cmp::Ordering::Equal)
260            .then_with(|| a.target_id.cmp(&b.target_id))
261    });
262    suggestions.truncate(24);
263    Ok(suggestions)
264}
265
266/// Create soft auto-links for all orphans, or for one file/path/node id.
267///
268/// When `target` is `None`, processes every orphan (plus always applies
269/// filename-stem matches among all notes so `goals/x.md` ↔ `concepts/x.md`
270/// even if neither was classified orphan).
271pub fn run_auto_link(db: &Database, target: Option<&Path>) -> Result<AutoLinkReport> {
272    let all = list_note_nodes(db)?;
273    let now = Utc::now().timestamp();
274    let mut applied = Vec::new();
275    let mut edges_upserted = 0usize;
276    let mut pairs = HashSet::new();
277
278    let scope = if let Some(t) = target {
279        let focus = resolve_target_node(db, t, &all)?;
280        // Clear prior auto edges involving this node so re-run is idempotent.
281        db.clear_auto_edges_involving(&focus.id)?;
282        let suggestions = suggest_for_node(db, &focus, &all)?;
283        for s in &suggestions {
284            edges_upserted += upsert_auto_pair(
285                db,
286                &focus.id,
287                &s.target_id,
288                &s.relation_type,
289                s.weight,
290                now,
291                &mut pairs,
292            )?;
293            applied.push(s.clone());
294        }
295        format!("node {}", focus.id)
296    } else {
297        // Full rebuild of auto edges from filename + tags (orphans prioritized in report).
298        db.clear_all_auto_edges()?;
299        // Filename: group by stem
300        let mut by_stem: HashMap<String, Vec<&Node>> = HashMap::new();
301        for n in &all {
302            if let Some(p) = &n.file_path {
303                if let Some(stem) = path_stem(p) {
304                    by_stem.entry(stem).or_default().push(n);
305                }
306            }
307        }
308        for (stem, group) in &by_stem {
309            if group.len() < 2 {
310                continue;
311            }
312            for i in 0..group.len() {
313                for j in (i + 1)..group.len() {
314                    let a = group[i];
315                    let b = group[j];
316                    edges_upserted += upsert_auto_pair(
317                        db,
318                        &a.id,
319                        &b.id,
320                        REL_AUTO_FILENAME,
321                        WEIGHT_AUTO_FILENAME,
322                        now,
323                        &mut pairs,
324                    )?;
325                    if applied.len() < 50 {
326                        applied.push(AutoLinkSuggestion {
327                            target_id: b.id.clone(),
328                            target_title: b.title.clone(),
329                            target_path: b.file_path.clone(),
330                            relation_type: REL_AUTO_FILENAME.into(),
331                            weight: WEIGHT_AUTO_FILENAME,
332                            reason: format!("same filename stem `{stem}` ({} ↔ {})", a.id, b.id),
333                        });
334                    }
335                }
336            }
337        }
338        // Tags among all notes
339        let mut tag_index: HashMap<String, Vec<String>> = HashMap::new();
340        for n in &all {
341            for t in db.get_tags_for(&n.id)? {
342                if trivial_tag(&t) {
343                    continue;
344                }
345                tag_index
346                    .entry(t.to_ascii_lowercase())
347                    .or_default()
348                    .push(n.id.clone());
349            }
350        }
351        for (tag, ids) in &tag_index {
352            let mut uniq = ids.clone();
353            uniq.sort();
354            uniq.dedup();
355            if uniq.len() < 2 {
356                continue;
357            }
358            for i in 0..uniq.len() {
359                for j in (i + 1)..uniq.len() {
360                    let a = &uniq[i];
361                    let b = &uniq[j];
362                    edges_upserted += upsert_auto_pair(
363                        db,
364                        a,
365                        b,
366                        REL_AUTO_TAG,
367                        WEIGHT_AUTO_TAG,
368                        now,
369                        &mut pairs,
370                    )?;
371                    if applied.len() < 50 {
372                        let bt = all
373                            .iter()
374                            .find(|n| n.id == *b)
375                            .map(|n| n.title.clone())
376                            .unwrap_or_else(|| b.clone());
377                        let bp = all
378                            .iter()
379                            .find(|n| n.id == *b)
380                            .and_then(|n| n.file_path.clone());
381                        applied.push(AutoLinkSuggestion {
382                            target_id: b.clone(),
383                            target_title: bt,
384                            target_path: bp,
385                            relation_type: REL_AUTO_TAG.into(),
386                            weight: WEIGHT_AUTO_TAG,
387                            reason: format!("shared tag `{tag}` ({a} ↔ {b})"),
388                        });
389                    }
390                }
391            }
392        }
393        "all notes (filename stems + shared tags)".into()
394    };
395
396    Ok(AutoLinkReport {
397        edges_upserted,
398        pairs_considered: pairs.len(),
399        scope,
400        applied,
401    })
402}
403
404fn upsert_auto_pair(
405    db: &Database,
406    a: &str,
407    b: &str,
408    rel: &str,
409    weight: f32,
410    now: i64,
411    pairs: &mut HashSet<String>,
412) -> Result<usize> {
413    if a == b {
414        return Ok(0);
415    }
416    // Canonical undirected pair key
417    let (lo, hi) = if a < b { (a, b) } else { (b, a) };
418    let key = format!("{rel}:{lo}:{hi}");
419    if !pairs.insert(key) {
420        return Ok(0);
421    }
422    // Store both directions so CSR hops work either way.
423    let mut n = 0;
424    for (src, tgt) in [(lo, hi), (hi, lo)] {
425        db.insert_edge(&Edge {
426            source_id: src.to_string(),
427            target_id: tgt.to_string(),
428            relation_type: rel.to_string(),
429            weight,
430            decay_rate: 0.0,
431            created_at: now,
432        })?;
433        n += 1;
434    }
435    Ok(n)
436}
437
438fn resolve_target_node(db: &Database, target: &Path, all: &[Node]) -> Result<Node> {
439    let raw = target.to_string_lossy().replace('\\', "/");
440    let raw = raw.trim_start_matches("./");
441
442    // Exact node id
443    if let Some(n) = db.get_node(raw)? {
444        return Ok(n);
445    }
446
447    // Match file_path suffix / equality
448    let candidates: Vec<&Node> = all
449        .iter()
450        .filter(|n| {
451            n.file_path
452                .as_ref()
453                .map(|p| {
454                    let p = p.replace('\\', "/");
455                    p == raw || p.ends_with(raw) || p.ends_with(&format!("/{raw}"))
456                })
457                .unwrap_or(false)
458        })
459        .collect();
460    if candidates.len() == 1 {
461        return Ok(candidates[0].clone());
462    }
463    if candidates.len() > 1 {
464        return Err(crate::error::BrainError::other(format!(
465            "ambiguous path `{raw}` matches {} nodes",
466            candidates.len()
467        )));
468    }
469
470    // Try with docs/ prefix
471    let with_docs = if raw.starts_with("docs/") {
472        raw.to_string()
473    } else {
474        format!("docs/{raw}")
475    };
476    if let Some(n) = all.iter().find(|n| {
477        n.file_path
478            .as_ref()
479            .map(|p| p.replace('\\', "/") == with_docs)
480            .unwrap_or(false)
481    }) {
482        return Ok(n.clone());
483    }
484
485    Err(crate::error::BrainError::other(format!(
486        "no note node found for `{raw}` — pass a path like docs/goals/foo.md or a node id"
487    )))
488}
489
490/// Normalize a user path argument to something resolve_target can use.
491pub fn normalize_target_arg(s: &str) -> PathBuf {
492    PathBuf::from(s.trim().trim_start_matches("./"))
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::types::NodeType;
499
500    fn note(id: &str, ty: NodeType, path: &str, title: &str) -> Node {
501        Node {
502            id: id.into(),
503            node_type: ty,
504            title: title.into(),
505            file_path: Some(path.into()),
506            symbol_hash: None,
507            summary: Some(title.into()),
508            content_hash: None,
509            created_at: 1,
510            updated_at: 1,
511        }
512    }
513
514    #[test]
515    fn path_stem_extracts_filename() {
516        assert_eq!(
517            path_stem("docs/goals/rust-fluency.md").as_deref(),
518            Some("rust-fluency")
519        );
520        assert_eq!(
521            path_stem("docs/concepts/rust-fluency.md").as_deref(),
522            Some("rust-fluency")
523        );
524        assert!(path_stem("docs/adr/TEMPLATE.md").is_none());
525    }
526
527    #[test]
528    fn filename_stem_auto_links_and_orphans() {
529        let db = Database::open_in_memory().unwrap();
530        let g = note(
531            "docs/goals/rust-fluency",
532            NodeType::Goal,
533            "docs/goals/rust-fluency.md",
534            "Rust fluency goal",
535        );
536        let c = note(
537            "docs/concepts/rust-fluency",
538            NodeType::Concept,
539            "docs/concepts/rust-fluency.md",
540            "Rust fluency deep dive",
541        );
542        db.insert_node(&g).unwrap();
543        db.insert_node(&c).unwrap();
544        db.index_fts(&g.id, &g.title, "goal body", "goal").unwrap();
545        db.index_fts(&c.id, &c.title, "concept body", "concept")
546            .unwrap();
547
548        let orphans = list_orphan_notes(&db).unwrap();
549        assert_eq!(orphans.len(), 2);
550        assert!(orphans
551            .iter()
552            .any(|o| o.suggestions.iter().any(|s| s.relation_type == REL_AUTO_FILENAME)));
553
554        let report = run_auto_link(&db, None).unwrap();
555        assert!(report.edges_upserted >= 2);
556        let edges = db.get_all_edges().unwrap();
557        assert!(edges.iter().any(|e| e.relation_type == REL_AUTO_FILENAME));
558
559        // After auto-link only, still orphan re: *explicit* edges
560        let orphans2 = list_orphan_notes(&db).unwrap();
561        assert_eq!(
562            orphans2.len(),
563            2,
564            "auto edges must not count as explicit"
565        );
566    }
567
568    #[test]
569    fn targeted_auto_link_by_path() {
570        let db = Database::open_in_memory().unwrap();
571        let g = note(
572            "docs/goals/foo",
573            NodeType::Goal,
574            "docs/goals/foo.md",
575            "Foo goal",
576        );
577        let c = note(
578            "docs/concepts/foo",
579            NodeType::Concept,
580            "docs/concepts/foo.md",
581            "Foo concept",
582        );
583        db.insert_node(&g).unwrap();
584        db.insert_node(&c).unwrap();
585        let report = run_auto_link(&db, Some(Path::new("docs/goals/foo.md"))).unwrap();
586        assert!(report.edges_upserted >= 2);
587        assert!(report.scope.contains("docs/goals/foo"));
588    }
589}