From bc10bd7dc5f5f3127668f87ae61debc5eddbad41 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Sat, 12 Sep 2026 19:56:27 +0700 Subject: [PATCH 1/4] Add fuzzy key match, pattern mining (P#) and IDF ranking to document graph Phase 3 of the structured document graph: fuzzy retrieval, cross-document pattern mining and rarity-based ranking (rare structure = high information, IDF-style scoring). - Fuzzy key match: search_key_fuzzy scores distinct keys by exact/prefix/ contains/Levenshtein similarity with an IDF bonus (rarer keys score higher). `doc_search` accepts `~segment` to force fuzzy and falls back exact-substring -> fuzzy when the full-path trie misses. - Pattern mining: mine_patterns() counts kind chains (wildcard FIELD/IDX payloads) ending at scalar leaves over a max_depth window, assigns stable pattern ids (P#, registry persisted in storage and restored on open) and indexes chains into the previously dead pattern_trie. Results carry node_count / doc_count / doc_freq, sorted by document frequency ascending so characteristic patterns surface first and background (~1.0) sinks. - Structural search: search_kind_chain() matches nodes whose ancestor kind window ends with the query chain (e.g. "MAP, FIELD, NUMBER"); results are ranked by pattern uniqueness IDF. search_path_scan() complements the radix trie whose leaf holds a single record per chain, returning all nodes with the same key path across documents (spec.replicas: 97 hits on the infra repo instead of 1). - Depth semantics fixed in doc_search: depth counts extra levels BELOW the pattern, so the radix key-length filter gets pattern_len + depth. - New MCP tools: doc_mine_patterns, doc_list_patterns, doc_search_struct. New CLI commands: `codegraph doc patterns`, `codegraph doc struct`. --- crates/codegraph-docs/src/graph.rs | 594 ++++++++++++++++++++++++++++- crates/codegraph-docs/src/lib.rs | 1 + crates/codegraph-mcp/src/lib.rs | 41 ++ crates/codegraph-mcp/src/tools.rs | 169 +++++++- crates/codegraph/src/main.rs | 115 +++++- 5 files changed, 889 insertions(+), 31 deletions(-) diff --git a/crates/codegraph-docs/src/graph.rs b/crates/codegraph-docs/src/graph.rs index f5076c668..54191fa7a 100644 --- a/crates/codegraph-docs/src/graph.rs +++ b/crates/codegraph-docs/src/graph.rs @@ -1,11 +1,11 @@ use crate::config::DocConfig; use crate::intern::Interner; use crate::ir::{Document, Kind, Node, Scalar}; -use crate::tokenize::DocToken; +use crate::tokenize::{DocTag, DocToken}; use anyhow::Result; use codegraph_graph::Search; use codegraph_graph::Storage; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::future::Future; use std::pin::Pin; @@ -33,6 +33,9 @@ const DOC_META_BASE: u64 = 10_000_000_000; /// Sentinel cho interner (mảng string JSON theo thứ tự id). Doc id nằm ở /// dải ≥ DOC_ID_BASE nên các slot nhỏ này không đụng doc metadata. const DOC_INTERNER_RECORD: u64 = DOC_META_BASE + 1; +/// Sentinel cho pattern registry (JSON) — pattern id P# giữ ổn định qua +/// các lần mine và qua restart. +const DOC_PATTERNS_RECORD: u64 = DOC_META_BASE + 2; /// Default sharding for document tries (mirrors code graph). const DEFAULT_SHARDING: usize = 64; @@ -53,10 +56,12 @@ pub struct DocumentGraph { type_trie: Search, value_trie: Search, struct_trie: Search, - /// Pattern-mining trie — reserve cho tính năng mined patterns, chưa có - /// reader (trước đây chỉ được clear trong rebuild). - #[allow(dead_code)] + /// Pattern-mining trie — index các structural pattern đã mine (leaf lưu + /// `PATTERN_RECORD_BASE + pattern_id`). pattern_trie: Search, + /// Registry các pattern đã mine — pattern id (P#) ổn định qua các lần + /// mine và qua restart. Persist ở `DOC_PATTERNS_RECORD`. + patterns: std::sync::Mutex, /// Base id global cho node/doc — id nhỏ hơn đây là id local của parser. doc_base: u64, next_doc_id: u64, @@ -83,6 +88,7 @@ impl DocumentGraph { value_trie: Search::with_shard_bias(sharding, storage.clone(), 2), struct_trie: Search::with_shard_bias(sharding, storage.clone(), 3), pattern_trie: Search::with_shard_bias(sharding, storage.clone(), 4), + patterns: std::sync::Mutex::new(PatternRegistry::default()), doc_base, next_doc_id: DOC_ID_BASE, next_node_id: doc_base, @@ -131,6 +137,7 @@ impl DocumentGraph { if !graph.restore_interner().await? { graph.rebuild_interner_from_docs(); } + graph.restore_patterns().await?; graph.materialize_node_cache(); Ok(graph) } @@ -470,6 +477,365 @@ impl DocumentGraph { hits } + /// Fuzzy key match — similarity (exact > prefix > contains > Levenshtein) + /// cộng bonus IDF của key: key càng hiếm (xuất hiện ở ít document) càng + /// khử tuyến, node match key hiếm lên trước. Query `~tên` ở `doc_search` + /// rẽ vào đây. + pub fn search_key_fuzzy(&self, query: &str, limit: usize) -> Vec { + let q = query.to_lowercase(); + let total_docs = self.docs.len().max(1) as f64; + // Điểm similarity cho từng distinct key + đếm doc chứa key. + let cache = self.nodes.lock().unwrap(); + let mut key_score: HashMap<&str, f64> = HashMap::new(); + let mut key_docs: HashMap<&str, std::collections::HashSet> = HashMap::new(); + for node in cache.values() { + let Some(k) = node.key.as_deref() else { continue }; + let kl = k.to_lowercase(); + let sim = if kl == q { + 1.0 + } else if kl.starts_with(&q) { + 0.8 + } else if kl.contains(&q) { + 0.6 + } else { + let ratio = levenshtein_similarity(&q, &kl); + if ratio >= 0.7 { + ratio + } else { + continue; + } + }; + let best = key_score.entry(k).or_insert(0.0); + *best = best.max(sim); + key_docs.entry(k).or_default().insert(node.doc); + } + if key_score.is_empty() { + return Vec::new(); + } + let mut hits: Vec = cache + .values() + .filter_map(|node| { + let k = node.key.as_deref()?; + let sim = *key_score.get(k)?; + let key_doc_count = key_docs[k].len().max(1) as f64; + // IDF của key — log2(total/df); key độc nhất df=1 → bonus lớn. + let idf = (total_docs / key_doc_count).log2().max(0.0); + let score = sim + 0.1 * idf; + Some(KeyHit { + node: node.clone(), + matched_key: k.to_string(), + score, + }) + }) + .collect(); + hits.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.node.id.cmp(&b.node.id)) + }); + hits.truncate(limit); + hits + } + + // ── Pattern mining (P#) + structural ranking ───────────────────── + + /// Mine structural patterns: đếm kind chain (root → node lá scalar, + /// FIELD/IDX wildcard) trên cửa sổ `max_depth` phần tử cuối. Chain mới + /// được cấp pattern id kế tiếp (id ổn định — registry persist); counts + /// refresh mỗi lần mine. Kết quả sort theo `doc_freq` tăng dần trong + /// nhóm đủ `min_count` — pattern đặc trưng (hiếm) lên đầu, noise nền + /// (~1.0) xuống cuối; kèm token pattern_trie để `search_patterns`. + pub async fn mine_patterns( + &mut self, + top_k: usize, + min_count: usize, + max_depth: usize, + ) -> Result> { + let total_docs = self.docs.len().max(1); + // Đếm chain: chain key → (node count, docs set, tokens). + let mut counts: HashMap, Vec)> = + HashMap::new(); + { + let cache = self.nodes.lock().unwrap(); + for node in cache.values() { + if node.value.is_none() { + continue; // chỉ node lá scalar — shape "MAP→FIELD→NUMBER". + } + let chain = self.kind_chain_of(node.id, &cache); + let slice = &chain[chain.len().saturating_sub(max_depth)..]; + let labels = kind_chain_labels(slice); + let key = labels.join("\u{1}"); + let entry = counts + .entry(key) + .or_insert_with(|| (0, std::collections::HashSet::new(), labels)); + entry.0 += 1; + entry.1.insert(node.doc); + } + } + // Merge vào registry: chain cũ giữ id, chain mới cấp id kế tiếp. + // Guard pattern registry phải đóng TRƯỚC mọi .await (non-Send). + let (mined, chains): (Vec, Vec<(Vec, u64)>) = { + let mut registry = self.patterns.lock().unwrap(); + let mut mined: Vec = Vec::new(); + for (_key, (node_count, docs, tokens)) in counts { + if node_count < min_count { + continue; + } + let id = match registry.by_chain.get(&_key) { + Some(&id) => id, + None => { + let id = registry.next_id; + registry.next_id += 1; + registry.by_chain.insert(_key.clone(), id); + id + } + }; + mined.push(PatternEntry { + pattern_id: id, + tokens, + node_count, + doc_count: docs.len(), + doc_freq: docs.len() as f64 / total_docs as f64, + }); + } + mined.sort_by(|a, b| { + a.doc_freq + .partial_cmp(&b.doc_freq) + .unwrap_or(std::cmp::Ordering::Equal) + .then(b.node_count.cmp(&a.node_count)) + .then(a.pattern_id.cmp(&b.pattern_id)) + }); + mined.truncate(top_k); + // Registry = union(chain đã biết, kết quả lần này) — chain không + // còn xuất hiện vẫn giữ id nhưng counts về 0. + for e in &mut registry.entries { + if let Some(p) = mined.iter().find(|p| p.pattern_id == e.pattern_id) { + *e = p.clone(); + } else { + e.node_count = 0; + e.doc_count = 0; + e.doc_freq = 0.0; + } + } + for p in &mined { + if !registry.entries.iter().any(|e| e.pattern_id == p.pattern_id) { + registry.entries.push(p.clone()); + } + } + registry.entries.sort_by_key(|e| e.pattern_id); + // Index mined chains vào pattern_trie (leaf = PATTERN base + id). + let chains = mined + .iter() + .map(|p| { + ( + p.tokens.iter().map(|t| parse_kind_label(t)).collect::>(), + p.pattern_id, + ) + }) + .collect(); + (mined, chains) + }; + for (tokens, id) in chains { + Self::insert_chain_allow_dup( + &mut self.pattern_trie, + (PATTERN_RECORD_BASE + id) as usize, + &tokens, + ) + .await?; + } + self.persist_patterns().await?; + Ok(mined) + } + + /// Registry hiện tại (counts từ lần mine gần nhất). + pub fn list_patterns(&self) -> Vec { + self.patterns.lock().unwrap().entries.clone() + } + + /// Search pattern theo kind chain đã mine — leaf lưu pattern id. + pub async fn search_patterns( + &self, + pattern: &[DocToken], + ) -> Result> { + let pages = self.pattern_trie.search(pattern, None).await?; + let registry = self.patterns.lock().unwrap(); + let mut out = Vec::new(); + for (record, _) in pages { + let r = record as u64; + if r >= PATTERN_RECORD_BASE + && let Some(e) = registry + .entries + .iter() + .find(|e| e.pattern_id == r - PATTERN_RECORD_BASE) + { + out.push(e.clone()); + } + } + out.sort_by_key(|e| e.pattern_id); + Ok(out) + } + + /// Search node theo kind chain (kind token payload 0) trên type trie. + /// Search node theo kind chain (kind token payload 0) — quét cache so + /// khớp suffix window. Không dùng trie ở đây vì radix leaf chỉ giữ MỘT + /// record per chain: các node trùng shape (cùng pattern ở nhiều doc) sẽ + /// bị collapse còn node đầu tiên. Trie giữ vai trò index của pattern P# + /// (`pattern_trie` — mỗi pattern một chain), node retrieval quét cache. + pub fn search_kind_chain(&self, pattern: &[DocToken], _depth: Option) -> Vec { + if pattern.is_empty() { + return Vec::new(); + } + let cache = self.nodes.lock().unwrap(); + let mut ids: Vec = cache + .values() + .filter(|node| { + let chain = self.kind_chain_of_unchecked(node.id, &cache); + chain.len() >= pattern.len() && chain[chain.len() - pattern.len()..] == *pattern + }) + .map(|node| node.id) + .collect(); + ids.sort_unstable(); + ids + } + + /// Như `kind_chain_of` nhưng nhận cache đã lock bên ngoài. + fn kind_chain_of_unchecked(&self, node_id: u64, cache: &HashMap) -> Vec { + let mut tokens = Vec::new(); + let mut cur = Some(node_id); + while let Some(id) = cur { + let Some(n) = cache.get(&id) else { break }; + tokens.push(kind_token(&n.kind)); + cur = n.parent; + } + tokens.reverse(); + tokens + } + + /// Bổ sung cho `search_path`: quét cache trả ĐỦ node có key-chain khớp + /// pattern (radix leaf chỉ giữ 1 record/chain nên trie chỉ đại diện node + /// đầu tiên — với repo nhiều doc trùng path thì thiếu). `pattern[0]` là + /// `DocToken::root()`, các segment sau là `DocToken::field(id)`. + pub fn search_path_scan(&self, pattern: &[DocToken], limit: usize) -> Vec { + if pattern.len() < 2 { + return Vec::new(); + } + let fields: Vec = pattern[1..].iter().map(|t| t.field_key_id()).collect(); + let last = *fields.last().unwrap(); + let cache = self.nodes.lock().unwrap(); + let mut ids: Vec = Vec::new(); + for node in cache.values() { + // Lọc thô: node phải mang key cuối của pattern. + let Some(key) = &node.key else { continue }; + if self.intern.get(key) != Some(last) { + continue; + } + // Xác minh tổ tiên: chuỗi key id từ node lên phải khớp reversed. + let mut up: Vec = Vec::with_capacity(fields.len()); + let mut cur = Some(node.id); + while let Some(id) = cur { + let Some(n) = cache.get(&id) else { break }; + if let Some(k) = &n.key { + up.push(self.intern.get(k).unwrap_or(0)); + } + cur = n.parent; + if up.len() == fields.len() { + break; + } + } + up.reverse(); + if up == fields { + ids.push(node.id); + if ids.len() >= limit { + break; + } + } + } + ids.sort_unstable(); + ids + } + + /// Uniqueness score của node theo pattern registry — pattern càng hiếm + /// (ít document chứa) càng điểm: IDF = log2(total_docs / doc_count). + /// Chain chưa từng mine coi như hiếm nhất (điểm +1). + pub fn pattern_uniqueness(&self, node_id: u64, total_docs: usize) -> f64 { + let cache = self.nodes.lock().unwrap(); + if !cache.contains_key(&node_id) { + return 0.0; + } + let chain = self.kind_chain_of(node_id, &cache); + drop(cache); + let registry = self.patterns.lock().unwrap(); + match registry.by_chain.get(&kind_labels_key(&chain)) { + Some(&id) => registry + .entries + .iter() + .find(|e| e.pattern_id == id) + .map(|e| { + if e.doc_count == 0 { + (total_docs.max(1) as f64).log2() + 1.0 + } else { + (total_docs.max(1) as f64 / e.doc_count as f64).log2().max(0.0) + } + }) + .unwrap_or(0.0), + None => (total_docs.max(1) as f64).log2() + 1.0, + } + } + + /// Kind chain root → node (FIELD/IDX payload wildcard) từ cache. + fn kind_chain_of(&self, node_id: u64, cache: &HashMap) -> Vec { + let mut tokens = Vec::new(); + let mut cur = Some(node_id); + while let Some(id) = cur { + let Some(n) = cache.get(&id) else { break }; + tokens.push(kind_token(&n.kind)); + cur = n.parent; + } + tokens.reverse(); + tokens + } + + // ── Pattern registry persist ───────────────────────────────────── + + async fn persist_patterns(&self) -> Result<()> { + // Guard phải đóng trước .await (non-Send) — scope block. + let blob = { + let registry = self.patterns.lock().unwrap(); + serde_json::to_vec(®istry.entries).map_err(|e| anyhow::anyhow!("{e}"))? + }; + self.storage + .write() + .await + .set_node_meta(DOC_PATTERNS_RECORD as usize, &blob) + .await + .map_err(|e| anyhow::anyhow!(e.to_string())) + } + + /// Restore registry từ storage. Trả `false` nếu chưa có (graph mới). + async fn restore_patterns(&mut self) -> Result { + let bytes = { + let guard = self.storage.read().await; + guard.get_node_meta(DOC_PATTERNS_RECORD as usize).await? + }; + let Some(bytes) = bytes else { + return Ok(false); + }; + if bytes.is_empty() { + return Ok(false); + } + let entries: Vec = serde_json::from_slice(&bytes) + .map_err(|e| anyhow::anyhow!("corrupt pattern registry: {e}"))?; + let mut reg = PatternRegistry::default(); + for e in entries { + reg.by_chain.insert(e.tokens.join("\u{1}"), e.pattern_id); + reg.next_id = reg.next_id.max(e.pattern_id + 1); + reg.entries.push(e); + } + self.patterns = std::sync::Mutex::new(reg); + Ok(true) + } + // ── Stats ───────────────────────────────────────────────────────── pub async fn stats(&self) -> Result { @@ -761,6 +1127,81 @@ fn kind_token(kind: &Kind) -> DocToken { } } +/// Nhãn hiển thị của một structural token ("MAP", "FIELD", ...). +fn token_label(tag: DocTag) -> &'static str { + match tag { + DocTag::Root => "ROOT", + DocTag::Map => "MAP", + DocTag::Arr => "ARRAY", + DocTag::Field => "FIELD", + DocTag::Idx => "INDEX", + DocTag::Str => "STRING", + DocTag::Num => "NUMBER", + DocTag::Bool => "BOOL", + DocTag::Null => "NULL", + } +} + +/// Parse nhãn kind ("MAP", "FIELD", ...) về kind token payload 0 — dùng cho +/// query `doc_search_struct`. Nhãn lạ → Map token. +pub fn parse_kind_label(label: &str) -> DocToken { + match label.trim().to_ascii_uppercase().as_str() { + "ROOT" => DocToken::root(), + "ARRAY" | "ARR" => DocToken::arr(), + "FIELD" => DocToken::field(0), + "INDEX" | "IDX" => DocToken::idx(0), + "STRING" | "STR" => DocToken::str(0), + "NUMBER" | "NUM" => DocToken::num(0), + "BOOL" => DocToken::bool(0), + "NULL" => DocToken::null(), + _ => DocToken::map(), + } +} + +fn kind_chain_labels(chain: &[DocToken]) -> Vec { + chain + .iter() + .map(|t| token_label(t.tag()).to_string()) + .collect() +} + +fn kind_labels_key(chain: &[DocToken]) -> String { + kind_chain_labels(chain).join("\u{1}") +} + +/// Similarity = 1 - d(a,b)/max(len) — 1.0 khi trùng khớp hoàn toàn. +fn levenshtein_similarity(a: &str, b: &str) -> f64 { + let max = a.chars().count().max(b.chars().count()); + if max == 0 { + return 1.0; + } + let d = levenshtein(a, b); + 1.0 - d as f64 / max as f64 +} + +/// Levenshtein chuẩn (một hàng, O(min·max)). +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.is_empty() { + return b.len(); + } + if b.is_empty() { + return a.len(); + } + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0usize; b.len() + 1]; + for (i, ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + /// Small payload returned to LLM after `hydrate`. #[derive(Debug, Clone, Serialize)] pub struct NodePayload { @@ -784,6 +1225,37 @@ pub struct DocInfo { pub nodes: usize, } +/// Một structural pattern đã mine — kind chain (FIELD/IDX wildcard payload) +/// từ một cửa sổ tổ tiên đến node lá scalar. `doc_freq` = tỷ lệ số document +/// chứa pattern (pattern ~1.0 là noise nền, nhỏ là đặc trưng). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PatternEntry { + pub pattern_id: u64, + /// Nhãn hiển thị, vd ["MAP", "FIELD", "NUMBER"]. + pub tokens: Vec, + pub node_count: usize, + pub doc_count: usize, + pub doc_freq: f64, +} + +/// Registry pattern — id (P#) ổn định: chain đã đăng ký giữ nguyên id giữa +/// các lần mine; counts refresh mỗi lần mine. +#[derive(Debug, Default)] +pub struct PatternRegistry { + entries: Vec, + by_chain: HashMap, + next_id: u64, +} + +/// Kết quả fuzzy match một key. +#[derive(Debug, Clone, Serialize)] +pub struct KeyHit { + pub node: Node, + pub matched_key: String, + /// Điểm similarity (0..1] cộng bonus IDF của key (key hiếm +điểm). + pub score: f64, +} + /// Summary returned by `codegraph doc stats`. #[derive(Debug, Default, Serialize)] pub struct DocStats { @@ -901,6 +1373,118 @@ mod tests { assert!(!ids.is_empty(), "search `replicas` sau reopen phải match"); } + /// Fuzzy key match: exact/prefix/contains và Levenshtein (sai chính tả) + /// phải tìm được `replicas`; key vô nghĩa thì không. + #[tokio::test] + async fn fuzzy_key_match_ranks_and_finds() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("a.yaml"); + std::fs::write(&p, "service:\n replicas: 3\n name: api\n").unwrap(); + let storage = Arc::new(TokioRwLock::new(InMemoryStorage::default())); + let mut graph = DocumentGraph::new(storage, DocConfig::default()); + graph.ingest_file(p.to_str().unwrap(), None).await.unwrap(); + + // exact (viết hoa vẫn match — case-insensitive). + let hits = graph.search_key_fuzzy("REPLICAS", 10); + assert!(hits.iter().any(|h| h.matched_key == "replicas")); + // sai chính tả 1 ký tự → Levenshtein. + let hits = graph.search_key_fuzzy("replcas", 10); + assert!( + hits.iter().any(|h| h.matched_key == "replicas"), + "fuzzy phải bắt được lỗi chính tả" + ); + // key không liên quan → rỗng. + assert!(graph.search_key_fuzzy("zzzzzz", 10).is_empty()); + } + + /// Pattern mining: hai doc cùng shape → pattern lặp với count/doc đúng; + /// id (P#) ổn định sau reopen + mine lại; kết quả sort theo doc_freq. + #[tokio::test] + async fn mine_patterns_stable_ids_and_frequency() { + let dir = tempfile::tempdir().unwrap(); + let mut paths = Vec::new(); + for i in 0..2 { + let p = dir.path().join(format!("d{i}.yaml")); + std::fs::write(&p, format!("svc{i}:\n name: a{i}\n replicas: {i}\n")).unwrap(); + paths.push(p); + } + let storage = Arc::new(TokioRwLock::new(InMemoryStorage::default())); + let mut graph = DocumentGraph::new(storage.clone(), DocConfig::default()); + for p in &paths { + graph.ingest_file(p.to_str().unwrap(), None).await.unwrap(); + } + let mined = graph.mine_patterns(10, 2, 4).await.unwrap(); + // Mỗi scalar lá (name x2, replicas x2) tạo chain [MAP, MAP, STRING|NUMBER]. + let string_pat = mined.iter().find(|p| p.tokens.last() == Some(&"STRING".to_string())); + let number_pat = mined.iter().find(|p| p.tokens.last() == Some(&"NUMBER".to_string())); + let string_pat = string_pat.expect("pattern STRING"); + assert_eq!(number_pat.expect("pattern NUMBER").node_count, 2); + assert_eq!(string_pat.node_count, 2); + assert_eq!(string_pat.doc_count, 2); + assert!((string_pat.doc_freq - 1.0).abs() < 1e-9); + let s_id = string_pat.pattern_id; + + // Reopen + mine lại — id giữ nguyên. + drop(graph); + let mut reopened = DocumentGraph::open(storage, DocConfig::default()).await.unwrap(); + let mined2 = reopened.mine_patterns(10, 2, 4).await.unwrap(); + let string_pat2 = mined2 + .iter() + .find(|p| p.tokens.last() == Some(&"STRING".to_string())) + .expect("pattern STRING sau reopen"); + assert_eq!(string_pat2.pattern_id, s_id, "pattern id phải ổn định"); + } + + /// Search cấu trúc theo nhãn kind + ranking IDF: node thuộc pattern hiếm + /// (ít doc) phải đứng trước node pattern nền. + #[tokio::test] + async fn struct_search_and_idf_ranking() { + let dir = tempfile::tempdir().unwrap(); + // d0, d1: shape phổ biến (MAP MAP NUMBER); d2: thêm nhánh hiếm hơn. + for (i, body) in [ + "svc:\n replicas: 1\n".to_string(), + "svc:\n replicas: 2\n".to_string(), + "svc:\n replicas: 3\n metrics:\n unique_metric: 9\n".to_string(), + ] + .into_iter() + .enumerate() + { + let p = dir.path().join(format!("d{i}.yaml")); + std::fs::write(&p, body).unwrap(); + } + let storage = Arc::new(TokioRwLock::new(InMemoryStorage::default())); + let mut graph = DocumentGraph::new(storage, DocConfig::default()); + for i in 0..3 { + let p = dir.path().join(format!("d{i}.yaml")); + graph.ingest_file(p.to_str().unwrap(), None).await.unwrap(); + } + graph.mine_patterns(10, 2, 4).await.unwrap(); + + // Chain [MAP, MAP, NUMBER] match cả 3 node replicas. + let tokens: Vec = ["MAP", "MAP", "NUMBER"] + .iter() + .map(|l| parse_kind_label(l)) + .collect(); + let ids = graph.search_kind_chain(&tokens, None); + // Window [MAP, MAP, NUMBER] match 3 node replicas + unique_metric + // (chain [MAP, MAP, MAP, NUMBER] có tail window trùng — đúng ngữ nghĩa + // cửa sổ của mining). + assert_eq!(ids.len(), 4); + // uniqueness: replicas ở 3/3 docs → IDF thấp; unique_metric 1/3 → cao. + let uniq_replicas = graph.pattern_uniqueness(ids[0], 3); + let metric_id = graph + .search_key_fuzzy("unique", 10) + .first() + .expect("unique_metric") + .node + .id; + let uniq_metric = graph.pattern_uniqueness(metric_id, 3); + assert!( + uniq_metric > uniq_replicas, + "cấu trúc hiếm phải có IDF cao hơn nền: {uniq_metric} vs {uniq_replicas}" + ); + } + #[tokio::test] async fn doc_ids_do_not_collide_with_node_ids() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/codegraph-docs/src/lib.rs b/crates/codegraph-docs/src/lib.rs index 1f3d2a744..8af774584 100644 --- a/crates/codegraph-docs/src/lib.rs +++ b/crates/codegraph-docs/src/lib.rs @@ -8,6 +8,7 @@ pub mod tokenize; pub use crate::config::DocConfig; pub use crate::config::StorageConfig; pub use crate::graph::DocumentGraph; +pub use crate::graph::parse_kind_label; pub use crate::graph::{DocStats, NodePayload}; pub use crate::ir::{ByteSpan, Document, Kind, Node, Scalar}; pub use crate::parsers::DocParser; diff --git a/crates/codegraph-mcp/src/lib.rs b/crates/codegraph-mcp/src/lib.rs index 7df78379e..d51178b3c 100644 --- a/crates/codegraph-mcp/src/lib.rs +++ b/crates/codegraph-mcp/src/lib.rs @@ -353,6 +353,47 @@ impl CodegraphServer { source_bytes: 0, }) } + "codegraph_doc_mine_patterns" => { + let top_k = args.get("top_k").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + let min_count = + args.get("min_count").and_then(|v| v.as_u64()).unwrap_or(3) as usize; + let max_depth = + args.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(4) as usize; + tools::dispatch_doc_mine_patterns(doc_graph, top_k, min_count, max_depth) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None)) + .map(|text| ToolOutput::Text { + text, + source_bytes: 0, + }) + } + "codegraph_doc_list_patterns" => tools::dispatch_doc_list_patterns(doc_graph) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None)) + .map(|text| ToolOutput::Text { + text, + source_bytes: 0, + }), + "codegraph_doc_search_struct" => { + let pattern = + args.get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + McpError::invalid_params( + "codegraph_doc_search_struct requires `pattern`", + None, + ) + })?; + let depth = args.get("depth").and_then(|v| v.as_u64()).unwrap_or(1) as usize; + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + tools::dispatch_doc_search_struct(doc_graph, pattern, depth, limit) + .await + .map_err(|e| McpError::internal_error(e.to_string(), None)) + .map(|text| ToolOutput::Text { + text, + source_bytes: 0, + }) + } "codegraph_doc_list" => tools::dispatch_doc_list(doc_graph) .await .map_err(|e| McpError::internal_error(e.to_string(), None)) diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 167be2cfd..79db6976c 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -335,6 +335,29 @@ fn tool_defs() -> Vec { "doc_id": { "type": "integer", "description": "Doc id returned by codegraph_doc_ingest / codegraph_doc_list." } }, "required": ["doc_id"] }), ), + tool( + "codegraph_doc_mine_patterns", + "Mine structural patterns across all ingested documents: counts kind chains (e.g. MAP → FIELD → NUMBER) ending at scalar leaves, assigns stable pattern ids (P#) and indexes them. Results are sorted by document frequency ascending — rare/characteristic patterns first, background noise (freq ≈ 1.0) last.", + json!({ "type": "object", "properties": { + "top_k": { "type": "integer", "default": 20, "description": "Max patterns to keep." }, + "min_count": { "type": "integer", "default": 3, "description": "Min node occurrences for a pattern to be kept." }, + "max_depth": { "type": "integer", "default": 4, "description": "Max kind-chain window length ending at the leaf." } + } }), + ), + tool( + "codegraph_doc_list_patterns", + "List the mined structural pattern registry (pattern id, kind tokens, node count, doc count, doc frequency) from the last mining run.", + json!({ "type": "object", "properties": {} }), + ), + tool( + "codegraph_doc_search_struct", + "Search document nodes by structural kind chain, e.g. `MAP, FIELD, NUMBER`. Results are ranked by IDF — nodes whose surrounding structure is rare across documents rank first; background structures rank last.", + json!({ "type": "object", "properties": { + "pattern": { "type": "string", "description": "Comma-separated kind labels: MAP, ARRAY, FIELD, INDEX, STRING, NUMBER, BOOL, NULL, ROOT." }, + "depth": { "type": "integer", "default": 1, "description": "Search depth." }, + "limit": { "type": "integer", "default": 20, "description": "Max results." } + }, "required": ["pattern"] }), + ), // ── Binary tools (dataset riêng .codegraph/binary.sqlite — lazy SQL) ── tool( "codegraph_binary_list", @@ -1117,30 +1140,62 @@ pub async fn dispatch_doc_search( let graph = graph.read().await; // Pattern "spec.replicas" → [root, FIELD(spec), FIELD(replicas)]. // Chain radix luôn bắt đầu từ root nên pattern phải là full path; - // không match → fallback quét key chứa segment cuối (case-insensitive). + // không match → fallback quét key (exact chứa) rồi fuzzy. Segment có + // tiền tố `~` (vd `spec.~replcas`) bỏ qua full-path, vào fuzzy trực tiếp. let mut tokens = vec![DocToken::root()]; - let mut unknown_seg = None; + let mut unknown_seg = false; + let mut fuzzy_seg: Option = None; for seg in pattern.split('.') { + if let Some(fz) = seg.strip_prefix('~') { + fuzzy_seg = Some(fz.to_string()); + break; + } match graph.intern_id(seg) { Some(id) => tokens.push(DocToken::field(id)), None => { - unknown_seg = Some(seg.to_string()); + unknown_seg = true; break; } } } - let ids = if unknown_seg.is_none() { - graph - .search_path(&tokens, Some(depth)) + // depth = số tầng thừa BÊN DƯỚI pattern; radix filter theo tổng chiều dài + // key nên phải cộng với độ dài pattern (depth=1 cho phép 1 segment kế tiếp). + let mut ids = if !unknown_seg && fuzzy_seg.is_none() { + // Trie trả nhanh node đại diện; scan bổ sung ĐỦ node trùng path ở + // các doc khác (radix leaf chỉ giữ 1 record/chain). + let mut ids = graph + .search_path(&tokens, Some(tokens.len() - 1 + depth)) .await - .unwrap_or_default() + .unwrap_or_default(); + ids.extend(graph.search_path_scan(&tokens, 100)); + ids.sort_unstable(); + ids.dedup(); + ids } else { Vec::new() }; if ids.is_empty() { - // Fallback: quét key theo segment cuối của pattern. - let last = pattern.rsplit('.').next().unwrap_or(pattern); - let hits = graph.search_key_substring(last, 100); + let last = fuzzy_seg + .clone() + .unwrap_or_else(|| pattern.rsplit('.').next().unwrap_or(pattern).to_string()); + let exact = graph.search_key_substring(&last, 100); + if !exact.is_empty() { + let results: Vec = exact + .iter() + .map(|n| { + json!({ + "id": n.id, + "doc": n.doc, + "key": n.key, + "kind": format!("{:?}", n.kind), + "value": n.value, + }) + }) + .collect(); + return serde_json::to_string_pretty(&results) + .map_err(|e| Error::Other(e.to_string())); + } + let hits = graph.search_key_fuzzy(&last, 50); if hits.is_empty() { return Ok(format!( "no nodes matched — key `{last}` not seen in any ingested document" @@ -1148,17 +1203,19 @@ pub async fn dispatch_doc_search( } let results: Vec = hits .iter() - .map(|n| { + .map(|h| { json!({ - "id": n.id, - "doc": n.doc, - "key": n.key, - "kind": format!("{:?}", n.kind), - "value": n.value, + "id": h.node.id, + "doc": h.node.doc, + "matched_key": h.matched_key, + "score": (h.score * 1000.0).round() / 1000.0, + "kind": format!("{:?}", h.node.kind), + "value": h.node.value, }) }) .collect(); - return serde_json::to_string_pretty(&results).map_err(|e| Error::Other(e.to_string())); + return serde_json::to_string_pretty(&results) + .map_err(|e| Error::Other(e.to_string())); } let mut results = Vec::new(); for id in ids.iter().take(100) { @@ -1177,6 +1234,84 @@ pub async fn dispatch_doc_search( serde_json::to_string_pretty(&results).map_err(|e| Error::Other(e.to_string())) } +/// Search node theo kind chain cấu trúc (vd "MAP, FIELD, NUMBER") — kết quả +/// rank theo IDF: node thuộc cấu trúc hiếm (ít document chứa) lên trước, +/// cấu trúc nền (xuất hiện ở ~mọi document) xuống cuối. +pub async fn dispatch_doc_search_struct( + doc_graph: Arc, + pattern: &str, + depth: usize, + limit: usize, +) -> Result { + let graph = doc_graph.graph().await; + let graph = graph.read().await; + let tokens: Vec = pattern + .split(',') + .filter_map(|s| { + let s = s.trim(); + (!s.is_empty()).then(|| codegraph_docs::parse_kind_label(s)) + }) + .collect(); + if tokens.is_empty() { + return Ok("empty pattern — expected kind labels like `MAP, FIELD, NUMBER`".to_string()); + } + let _ = depth; // search_kind_chain match suffix window — depth không áp dụng + let ids = graph.search_kind_chain(&tokens, None); + if ids.is_empty() { + return Ok("no nodes matched this structural pattern".to_string()); + } + let total_docs = graph.list_docs().len(); + let mut rows: Vec<(f64, Value)> = Vec::new(); + for id in ids.iter().take(limit * 5) { + let Some(payload) = graph.hydrate_depth(*id, Some(1)).await else { + continue; + }; + let uniq = graph.pattern_uniqueness(*id, total_docs); + rows.push(( + uniq, + json!({ + "id": payload.id, + "doc": payload.doc, + "path": payload.path, + "key": payload.key, + "kind": format!("{:?}", payload.kind), + "value": payload.value, + "idf": (uniq * 1000.0).round() / 1000.0, + }), + )); + } + rows.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + let results: Vec<&Value> = rows.iter().take(limit).map(|(_, v)| v).collect(); + serde_json::to_string_pretty(&results).map_err(|e| Error::Other(e.to_string())) +} + +/// Mine structural patterns — đếm kind chain trên mọi node lá scalar, cấp +/// pattern id (P#) ổn định, index vào pattern trie. Kết quả sort theo +/// doc_freq tăng dần: pattern đặc trưng (hiếm) lên đầu, nền (~1.0) cuối. +pub async fn dispatch_doc_mine_patterns( + doc_graph: Arc, + top_k: usize, + min_count: usize, + max_depth: usize, +) -> Result { + let mined = doc_graph + .graph() + .await + .write() + .await + .mine_patterns(top_k, min_count, max_depth) + .await + .map_err(|e| Error::Other(e.to_string()))?; + serde_json::to_string_pretty(&mined).map_err(|e| Error::Other(e.to_string())) +} + +pub async fn dispatch_doc_list_patterns(doc_graph: Arc) -> Result { + let graph = doc_graph.graph().await; + let graph = graph.read().await; + let entries = graph.list_patterns(); + serde_json::to_string_pretty(&entries).map_err(|e| Error::Other(e.to_string())) +} + pub async fn dispatch_doc_search_value( doc_graph: Arc, query: &str, diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs index 7c7ac5161..fa2382f97 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -185,6 +185,33 @@ enum DocCmd { List, /// Show document graph statistics. Stats, + /// Mine structural patterns (kind chains ending at scalar leaves) across + /// all ingested documents and list them sorted by doc frequency ascending + /// — rare/characteristic patterns first, background noise last. + Patterns { + /// Max patterns to keep. + #[arg(long, default_value_t = 20)] + top_k: usize, + /// Min node occurrences for a pattern to be kept. + #[arg(long, default_value_t = 3)] + min_count: usize, + /// Max kind-chain window length ending at the leaf. + #[arg(long, default_value_t = 4)] + max_depth: usize, + }, + /// Search nodes by structural kind chain, ranked by IDF (rare structures + /// first), e.g. `codegraph doc struct "MAP, FIELD, NUMBER"`. + Struct { + /// Comma-separated kind labels: MAP, ARRAY, FIELD, INDEX, STRING, NUMBER, BOOL, NULL, ROOT. + #[arg()] + pattern: String, + /// Search depth (default: 1). + #[arg(long, default_value_t = 1)] + depth: usize, + /// Max results (default: 20). + #[arg(long, default_value_t = 20)] + limit: usize, + }, } #[tokio::main] @@ -739,27 +766,44 @@ async fn cmd_doc(root: &Utf8Path, cmd: DocCmd) -> Result<()> { use codegraph_docs::DocToken; // Full path search qua trie; không match → fallback quét key. let mut tokens = vec![DocToken::root()]; + let mut fuzzy_seg: Option = None; for seg in pattern.split('.') { + if let Some(fz) = seg.strip_prefix('~') { + fuzzy_seg = Some(fz.to_string()); + break; + } match graph.intern_id(seg) { Some(id) => tokens.push(DocToken::field(id)), None => break, } } - let ids = graph - .search_path(&tokens, Some(depth)) - .await - .unwrap_or_default(); + // Có segment `~` → bỏ qua full-path, đi thẳng fuzzy. + // depth = số tầng thừa dưới pattern; radix filter theo tổng key len. + let mut ids = if fuzzy_seg.is_none() { + let mut ids = graph + .search_path(&tokens, Some(tokens.len() - 1 + depth)) + .await + .unwrap_or_default(); + ids.extend(graph.search_path_scan(&tokens, 100)); + ids.sort_unstable(); + ids.dedup(); + ids + } else { + Vec::new() + }; if ids.is_empty() { - let last = pattern.rsplit('.').next().unwrap_or(&pattern); - let hits = graph.search_key_substring(last, 100); + let last = fuzzy_seg.unwrap_or_else(|| { + pattern.rsplit('.').next().unwrap_or(&pattern).to_string() + }); + let hits = graph.search_key_fuzzy(&last, 50); if hits.is_empty() { println!("no nodes matched — key `{last}` not seen in any ingested document"); return Ok(()); } - for n in hits { + for h in hits { println!( - "node {} doc={} key={:?} kind={:?} value={:?}", - n.id, n.doc, n.key, n.kind, n.value + "node {} doc={} key={:?} score={:.3} kind={:?} value={:?}", + h.node.id, h.node.doc, h.matched_key, h.score, h.node.kind, h.node.value ); } return Ok(()); @@ -787,6 +831,59 @@ async fn cmd_doc(root: &Utf8Path, cmd: DocCmd) -> Result<()> { println!("documents: {}", stats.docs); println!("nodes: {}", stats.nodes); } + DocCmd::Patterns { + top_k, + min_count, + max_depth, + } => { + let mined = graph.mine_patterns(top_k, min_count, max_depth).await?; + if mined.is_empty() { + println!("no patterns matched (min_count={min_count})"); + } + for p in mined { + println!( + "P#{:<4} docs={:.1}% nodes={} chain={}", + p.pattern_id, + p.doc_freq * 100.0, + p.node_count, + p.tokens.join(" → ") + ); + } + } + DocCmd::Struct { + pattern, + depth, + limit, + } => { + let tokens: Vec = pattern + .split(',') + .filter_map(|s| { + let s = s.trim(); + (!s.is_empty()).then(|| codegraph_docs::parse_kind_label(s)) + }) + .collect(); + let ids = graph.search_kind_chain(&tokens, Some(depth)); + if ids.is_empty() { + println!("no nodes matched this structural pattern"); + return Ok(()); + } + let total_docs = graph.list_docs().len(); + let mut rows: Vec<(f64, u64)> = ids + .iter() + .take(limit * 5) + .map(|id| (graph.pattern_uniqueness(*id, total_docs), *id)) + .collect(); + rows.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + for (idf, id) in rows.iter().take(limit) { + match graph.hydrate_depth(*id, Some(1)).await { + Some(p) => println!( + "node {} doc={} idf={:.3} path={:?} key={:?} value={:?}", + p.id, p.doc, idf, p.path, p.key, p.value + ), + None => continue, + } + } + } } Ok(()) } From 22f9dbd1778821e93e432e8f3d36ae73bf064ff1 Mon Sep 17 00:00:00 2001 From: hungpham10 <136320753+hungpham10@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:47:53 +0000 Subject: [PATCH 2/4] style: apply rustfmt --- crates/codegraph-docs/src/graph.rs | 36 +++++++++++++++++++++--------- crates/codegraph-mcp/src/tools.rs | 6 ++--- crates/codegraph/src/main.rs | 5 ++--- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/crates/codegraph-docs/src/graph.rs b/crates/codegraph-docs/src/graph.rs index 54191fa7a..2c070d872 100644 --- a/crates/codegraph-docs/src/graph.rs +++ b/crates/codegraph-docs/src/graph.rs @@ -489,7 +489,9 @@ impl DocumentGraph { let mut key_score: HashMap<&str, f64> = HashMap::new(); let mut key_docs: HashMap<&str, std::collections::HashSet> = HashMap::new(); for node in cache.values() { - let Some(k) = node.key.as_deref() else { continue }; + let Some(k) = node.key.as_deref() else { + continue; + }; let kl = k.to_lowercase(); let sim = if kl == q { 1.0 @@ -619,7 +621,11 @@ impl DocumentGraph { } } for p in &mined { - if !registry.entries.iter().any(|e| e.pattern_id == p.pattern_id) { + if !registry + .entries + .iter() + .any(|e| e.pattern_id == p.pattern_id) + { registry.entries.push(p.clone()); } } @@ -629,7 +635,10 @@ impl DocumentGraph { .iter() .map(|p| { ( - p.tokens.iter().map(|t| parse_kind_label(t)).collect::>(), + p.tokens + .iter() + .map(|t| parse_kind_label(t)) + .collect::>(), p.pattern_id, ) }) @@ -654,10 +663,7 @@ impl DocumentGraph { } /// Search pattern theo kind chain đã mine — leaf lưu pattern id. - pub async fn search_patterns( - &self, - pattern: &[DocToken], - ) -> Result> { + pub async fn search_patterns(&self, pattern: &[DocToken]) -> Result> { let pages = self.pattern_trie.search(pattern, None).await?; let registry = self.patterns.lock().unwrap(); let mut out = Vec::new(); @@ -775,7 +781,9 @@ impl DocumentGraph { if e.doc_count == 0 { (total_docs.max(1) as f64).log2() + 1.0 } else { - (total_docs.max(1) as f64 / e.doc_count as f64).log2().max(0.0) + (total_docs.max(1) as f64 / e.doc_count as f64) + .log2() + .max(0.0) } }) .unwrap_or(0.0), @@ -1415,8 +1423,12 @@ mod tests { } let mined = graph.mine_patterns(10, 2, 4).await.unwrap(); // Mỗi scalar lá (name x2, replicas x2) tạo chain [MAP, MAP, STRING|NUMBER]. - let string_pat = mined.iter().find(|p| p.tokens.last() == Some(&"STRING".to_string())); - let number_pat = mined.iter().find(|p| p.tokens.last() == Some(&"NUMBER".to_string())); + let string_pat = mined + .iter() + .find(|p| p.tokens.last() == Some(&"STRING".to_string())); + let number_pat = mined + .iter() + .find(|p| p.tokens.last() == Some(&"NUMBER".to_string())); let string_pat = string_pat.expect("pattern STRING"); assert_eq!(number_pat.expect("pattern NUMBER").node_count, 2); assert_eq!(string_pat.node_count, 2); @@ -1426,7 +1438,9 @@ mod tests { // Reopen + mine lại — id giữ nguyên. drop(graph); - let mut reopened = DocumentGraph::open(storage, DocConfig::default()).await.unwrap(); + let mut reopened = DocumentGraph::open(storage, DocConfig::default()) + .await + .unwrap(); let mined2 = reopened.mine_patterns(10, 2, 4).await.unwrap(); let string_pat2 = mined2 .iter() diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 79db6976c..9a17e3ec5 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -1192,8 +1192,7 @@ pub async fn dispatch_doc_search( }) }) .collect(); - return serde_json::to_string_pretty(&results) - .map_err(|e| Error::Other(e.to_string())); + return serde_json::to_string_pretty(&results).map_err(|e| Error::Other(e.to_string())); } let hits = graph.search_key_fuzzy(&last, 50); if hits.is_empty() { @@ -1214,8 +1213,7 @@ pub async fn dispatch_doc_search( }) }) .collect(); - return serde_json::to_string_pretty(&results) - .map_err(|e| Error::Other(e.to_string())); + return serde_json::to_string_pretty(&results).map_err(|e| Error::Other(e.to_string())); } let mut results = Vec::new(); for id in ids.iter().take(100) { diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs index fa2382f97..4070818ec 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -792,9 +792,8 @@ async fn cmd_doc(root: &Utf8Path, cmd: DocCmd) -> Result<()> { Vec::new() }; if ids.is_empty() { - let last = fuzzy_seg.unwrap_or_else(|| { - pattern.rsplit('.').next().unwrap_or(&pattern).to_string() - }); + let last = fuzzy_seg + .unwrap_or_else(|| pattern.rsplit('.').next().unwrap_or(&pattern).to_string()); let hits = graph.search_key_fuzzy(&last, 50); if hits.is_empty() { println!("no nodes matched — key `{last}` not seen in any ingested document"); From 38bf9565b351bbd34f4ae0fe10fdf00efd5ea3c2 Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Sat, 12 Sep 2026 21:19:05 +0700 Subject: [PATCH 3/4] Fix lint --- crates/codegraph-mcp/src/tools.rs | 2 +- crates/codegraph/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-mcp/src/tools.rs b/crates/codegraph-mcp/src/tools.rs index 9a17e3ec5..949b9aaab 100644 --- a/crates/codegraph-mcp/src/tools.rs +++ b/crates/codegraph-mcp/src/tools.rs @@ -1160,7 +1160,7 @@ pub async fn dispatch_doc_search( } // depth = số tầng thừa BÊN DƯỚI pattern; radix filter theo tổng chiều dài // key nên phải cộng với độ dài pattern (depth=1 cho phép 1 segment kế tiếp). - let mut ids = if !unknown_seg && fuzzy_seg.is_none() { + let ids = if !unknown_seg && fuzzy_seg.is_none() { // Trie trả nhanh node đại diện; scan bổ sung ĐỦ node trùng path ở // các doc khác (radix leaf chỉ giữ 1 record/chain). let mut ids = graph diff --git a/crates/codegraph/src/main.rs b/crates/codegraph/src/main.rs index 4070818ec..02c462351 100644 --- a/crates/codegraph/src/main.rs +++ b/crates/codegraph/src/main.rs @@ -779,7 +779,7 @@ async fn cmd_doc(root: &Utf8Path, cmd: DocCmd) -> Result<()> { } // Có segment `~` → bỏ qua full-path, đi thẳng fuzzy. // depth = số tầng thừa dưới pattern; radix filter theo tổng key len. - let mut ids = if fuzzy_seg.is_none() { + let ids = if fuzzy_seg.is_none() { let mut ids = graph .search_path(&tokens, Some(tokens.len() - 1 + depth)) .await From 50a6c1fdae4a92843fbbc8ed31985bd40ec17cce Mon Sep 17 00:00:00 2001 From: Hung Pham Date: Sat, 12 Sep 2026 21:23:11 +0700 Subject: [PATCH 4/4] Bump to version v2.1.9 --- Cargo.lock | 26 ++++++++++++------------- Cargo.toml | 2 +- packaging/aur/codegraph-rs-bin/PKGBUILD | 2 +- packaging/choco/codegraph.nuspec | 2 +- packaging/winget/codegraph.yaml | 4 ++-- scripts/install.ps1 | 4 ++-- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9be850b0..a42166163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,7 +720,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "codegraph" -version = "2.1.8" +version = "2.1.9" dependencies = [ "anyhow", "camino", @@ -743,7 +743,7 @@ dependencies = [ [[package]] name = "codegraph-api" -version = "2.1.8" +version = "2.1.9" dependencies = [ "anyhow", "camino", @@ -760,7 +760,7 @@ dependencies = [ [[package]] name = "codegraph-bench" -version = "2.1.8" +version = "2.1.9" dependencies = [ "anyhow", "camino", @@ -778,7 +778,7 @@ dependencies = [ [[package]] name = "codegraph-binary" -version = "2.1.8" +version = "2.1.9" dependencies = [ "camino", "codegraph-core", @@ -795,7 +795,7 @@ dependencies = [ [[package]] name = "codegraph-context" -version = "2.1.8" +version = "2.1.9" dependencies = [ "codegraph-core", "codegraph-graph", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "codegraph-core" -version = "2.1.8" +version = "2.1.9" dependencies = [ "async-graphql", "camino", @@ -818,7 +818,7 @@ dependencies = [ [[package]] name = "codegraph-docs" -version = "2.1.8" +version = "2.1.9" dependencies = [ "anyhow", "codegraph-core", @@ -835,7 +835,7 @@ dependencies = [ [[package]] name = "codegraph-extract" -version = "2.1.8" +version = "2.1.9" dependencies = [ "camino", "codegraph-binary", @@ -874,7 +874,7 @@ dependencies = [ [[package]] name = "codegraph-graph" -version = "2.1.8" +version = "2.1.9" dependencies = [ "async-trait", "bincode", @@ -904,7 +904,7 @@ dependencies = [ [[package]] name = "codegraph-graphql" -version = "2.1.8" +version = "2.1.9" dependencies = [ "anyhow", "async-graphql", @@ -927,7 +927,7 @@ dependencies = [ [[package]] name = "codegraph-installer" -version = "2.1.8" +version = "2.1.9" dependencies = [ "anyhow", "camino", @@ -943,7 +943,7 @@ dependencies = [ [[package]] name = "codegraph-mcp" -version = "2.1.8" +version = "2.1.9" dependencies = [ "anyhow", "axum", @@ -966,7 +966,7 @@ dependencies = [ [[package]] name = "codegraph-sboxes" -version = "2.1.8" +version = "2.1.9" dependencies = [ "camino", "codegraph-core", diff --git a/Cargo.toml b/Cargo.toml index ab9c478ef..47ff1ed46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ ] [workspace.package] -version = "2.1.8" +version = "2.1.9" edition = "2021" rust-version = "1.80" license = "MIT" diff --git a/packaging/aur/codegraph-rs-bin/PKGBUILD b/packaging/aur/codegraph-rs-bin/PKGBUILD index 84d03b872..4e7f697c6 100644 --- a/packaging/aur/codegraph-rs-bin/PKGBUILD +++ b/packaging/aur/codegraph-rs-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Hung Pham pkgname=codegraph-rs-bin -pkgver=2.1.8 +pkgver=2.1.9 pkgrel=1 pkgdesc="Local-first code intelligence: tree-sitter knowledge graph + MCP server (prebuilt binary)" arch=('x86_64' 'aarch64') diff --git a/packaging/choco/codegraph.nuspec b/packaging/choco/codegraph.nuspec index ea24a2f41..3eee75120 100644 --- a/packaging/choco/codegraph.nuspec +++ b/packaging/choco/codegraph.nuspec @@ -2,7 +2,7 @@ codegraph - 2.1.8 + 2.1.9 codegraph Hung Pham https://github.com/hungpham10/codegraph-rs diff --git a/packaging/winget/codegraph.yaml b/packaging/winget/codegraph.yaml index fbebf44b0..78597e3db 100644 --- a/packaging/winget/codegraph.yaml +++ b/packaging/winget/codegraph.yaml @@ -6,7 +6,7 @@ # release time (or automate it in the release pipeline before submitting to # microsoft/winget-pkgs). PackageIdentifier: hungpham10.codegraph -PackageVersion: 2.1.8 +PackageVersion: 2.1.9 PackageName: codegraph Publisher: Hung Pham PublisherUrl: https://github.com/hungpham10/codegraph-rs @@ -18,7 +18,7 @@ PackageUrl: https://github.com/hungpham10/codegraph-rs InstallerType: zip Installers: - Architecture: x64 - InstallerUrl: https://github.com/hungpham10/codegraph-rs/releases/download/v2.1.8/codegraph-x86_64-pc-windows-msvc.zip + InstallerUrl: https://github.com/hungpham10/codegraph-rs/releases/download/v2.1.9/codegraph-x86_64-pc-windows-msvc.zip InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 InstallerType: zip ManifestType: singleton diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5e0f98ffb..36cfaa371 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -5,11 +5,11 @@ # # Usage (pin a version / download the script first): # irm https://raw.githubusercontent.com/hungpham10/codegraph-rs/main/scripts/install.ps1 -OutFile install.ps1 -# .\install.ps1 -Version 2.1.8 +# .\install.ps1 -Version 2.1.9 [CmdletBinding()] param( - # Pin a specific version, e.g. "2.1.8". Empty = latest release. + # Pin a specific version, e.g. "2.1.9". Empty = latest release. [string]$Version )