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