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