maggraph 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {maggraph-0.2.0 → maggraph-0.3.0}/Cargo.lock +16 -18
  2. {maggraph-0.2.0 → maggraph-0.3.0}/Cargo.toml +3 -3
  3. {maggraph-0.2.0 → maggraph-0.3.0}/PKG-INFO +2 -1
  4. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/Cargo.toml +7 -2
  5. maggraph-0.3.0/maggraph/__init__.py +53 -0
  6. maggraph-0.3.0/maggraph/benches/index_scale.rs +85 -0
  7. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/graph.rs +13 -10
  8. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/index.rs +204 -26
  9. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/node/mod.rs +52 -1
  10. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/query.rs +7 -5
  11. {maggraph-0.2.0 → maggraph-0.3.0}/pyproject.toml +2 -1
  12. maggraph-0.2.0/maggraph/__init__.py +0 -31
  13. {maggraph-0.2.0 → maggraph-0.3.0}/README.md +0 -0
  14. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/__init__.pyi +0 -0
  15. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/benches/traversal.rs +0 -0
  16. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/py.typed +0 -0
  17. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/agent/mod.rs +0 -0
  18. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/config/mod.rs +0 -0
  19. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/config/schema.rs +0 -0
  20. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/error.rs +0 -0
  21. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/lakehouse/cache.rs +0 -0
  22. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/lakehouse/content.rs +0 -0
  23. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/lakehouse/mod.rs +0 -0
  24. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/lakehouse/resolver.rs +0 -0
  25. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/lakehouse/uri.rs +0 -0
  26. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/lib.rs +0 -0
  27. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/memory.rs +0 -0
  28. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/node/frontmatter.rs +0 -0
  29. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/python/mod.rs +0 -0
  30. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/recall.rs +0 -0
  31. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/security.rs +0 -0
  32. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/sync/engine.rs +0 -0
  33. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/sync/lock.rs +0 -0
  34. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/sync/mod.rs +0 -0
  35. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/sync/policy.rs +0 -0
  36. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/sync/repo.rs +0 -0
  37. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/ui/api.rs +0 -0
  38. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/ui/assets/app.js +0 -0
  39. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/ui/assets/index.html +0 -0
  40. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/ui/assets/style.css +0 -0
  41. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/ui/mod.rs +0 -0
  42. {maggraph-0.2.0 → maggraph-0.3.0}/maggraph/src/wikilink.rs +0 -0
@@ -839,7 +839,7 @@ checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
839
839
 
840
840
  [[package]]
841
841
  name = "maggraph"
842
- version = "0.2.0"
842
+ version = "0.3.0"
843
843
  dependencies = [
844
844
  "axum",
845
845
  "chrono",
@@ -862,7 +862,7 @@ dependencies = [
862
862
 
863
863
  [[package]]
864
864
  name = "maggraph-cli"
865
- version = "0.2.0"
865
+ version = "0.3.0"
866
866
  dependencies = [
867
867
  "assert_cmd",
868
868
  "axum",
@@ -1047,11 +1047,10 @@ dependencies = [
1047
1047
 
1048
1048
  [[package]]
1049
1049
  name = "pyo3"
1050
- version = "0.23.5"
1050
+ version = "0.27.2"
1051
1051
  source = "registry+https://github.com/rust-lang/crates.io-index"
1052
- checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872"
1052
+ checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
1053
1053
  dependencies = [
1054
- "cfg-if",
1055
1054
  "indoc",
1056
1055
  "libc",
1057
1056
  "memoffset",
@@ -1065,9 +1064,9 @@ dependencies = [
1065
1064
 
1066
1065
  [[package]]
1067
1066
  name = "pyo3-async-runtimes"
1068
- version = "0.23.0"
1067
+ version = "0.27.0"
1069
1068
  source = "registry+https://github.com/rust-lang/crates.io-index"
1070
- checksum = "977dc837525cfd22919ba6a831413854beb7c99a256c03bf8624ad707e45810e"
1069
+ checksum = "57ddb5b570751e93cc6777e81fee8087e59cd53b5043292f2a6d59d5bd80fdfd"
1071
1070
  dependencies = [
1072
1071
  "futures",
1073
1072
  "once_cell",
@@ -1078,19 +1077,18 @@ dependencies = [
1078
1077
 
1079
1078
  [[package]]
1080
1079
  name = "pyo3-build-config"
1081
- version = "0.23.5"
1080
+ version = "0.27.2"
1082
1081
  source = "registry+https://github.com/rust-lang/crates.io-index"
1083
- checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb"
1082
+ checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6"
1084
1083
  dependencies = [
1085
- "once_cell",
1086
1084
  "target-lexicon",
1087
1085
  ]
1088
1086
 
1089
1087
  [[package]]
1090
1088
  name = "pyo3-ffi"
1091
- version = "0.23.5"
1089
+ version = "0.27.2"
1092
1090
  source = "registry+https://github.com/rust-lang/crates.io-index"
1093
- checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d"
1091
+ checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089"
1094
1092
  dependencies = [
1095
1093
  "libc",
1096
1094
  "pyo3-build-config",
@@ -1098,9 +1096,9 @@ dependencies = [
1098
1096
 
1099
1097
  [[package]]
1100
1098
  name = "pyo3-macros"
1101
- version = "0.23.5"
1099
+ version = "0.27.2"
1102
1100
  source = "registry+https://github.com/rust-lang/crates.io-index"
1103
- checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da"
1101
+ checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02"
1104
1102
  dependencies = [
1105
1103
  "proc-macro2",
1106
1104
  "pyo3-macros-backend",
@@ -1110,9 +1108,9 @@ dependencies = [
1110
1108
 
1111
1109
  [[package]]
1112
1110
  name = "pyo3-macros-backend"
1113
- version = "0.23.5"
1111
+ version = "0.27.2"
1114
1112
  source = "registry+https://github.com/rust-lang/crates.io-index"
1115
- checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028"
1113
+ checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9"
1116
1114
  dependencies = [
1117
1115
  "heck",
1118
1116
  "proc-macro2",
@@ -1388,9 +1386,9 @@ dependencies = [
1388
1386
 
1389
1387
  [[package]]
1390
1388
  name = "target-lexicon"
1391
- version = "0.12.16"
1389
+ version = "0.13.5"
1392
1390
  source = "registry+https://github.com/rust-lang/crates.io-index"
1393
- checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
1391
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
1394
1392
 
1395
1393
  [[package]]
1396
1394
  name = "tempfile"
@@ -3,7 +3,7 @@ members = ["maggraph"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.2.0"
6
+ version = "0.3.0"
7
7
  edition = "2021"
8
8
  license = "MIT OR Apache-2.0"
9
9
  repository = "https://github.com/AlexMercedCoder/MagGraph"
@@ -26,8 +26,8 @@ tower = "0.5"
26
26
  tower-http = { version = "0.6", features = ["trace"] }
27
27
  tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
28
28
  serde_json = "1.0"
29
- pyo3 = "0.23"
30
- pyo3-async-runtimes = { version = "0.23", features = ["tokio-runtime"] }
29
+ pyo3 = "0.27"
30
+ pyo3-async-runtimes = { version = "0.27", features = ["tokio-runtime"] }
31
31
  tempfile = "3.14"
32
32
  assert_cmd = "2.0"
33
33
  predicates = "3.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maggraph
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
16
17
  Classifier: Programming Language :: Rust
17
18
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
19
  Classifier: Topic :: Database
@@ -13,7 +13,7 @@ name = "maggraph"
13
13
  [features]
14
14
  default = []
15
15
  python = ["dep:pyo3", "dep:pyo3-async-runtimes", "dep:tokio"]
16
- python-ext = ["python", "pyo3/extension-module"]
16
+ python-ext = ["python", "pyo3/extension-module", "pyo3/abi3-py39"]
17
17
  ui = [
18
18
  "dep:axum",
19
19
  "dep:tower",
@@ -39,12 +39,17 @@ axum = { workspace = true, optional = true }
39
39
  tower = { workspace = true, optional = true }
40
40
  tower-http = { workspace = true, optional = true }
41
41
  serde_json = { workspace = true, optional = true }
42
+ tempfile.workspace = true
42
43
 
43
44
  [dev-dependencies]
44
- tempfile = { workspace = true }
45
45
  tower = { workspace = true, features = ["util"] }
46
46
 
47
47
  [[bench]]
48
48
  name = "traversal"
49
49
  path = "benches/traversal.rs"
50
50
  harness = false
51
+
52
+ [[bench]]
53
+ name = "index_scale"
54
+ path = "benches/index_scale.rs"
55
+ harness = false
@@ -0,0 +1,53 @@
1
+ """MagGraph — Python bindings for the Rust graph engine."""
2
+
3
+ from maggraph._maggraph import (
4
+ GraphIndex,
5
+ LakehouseReader,
6
+ MagGraphError,
7
+ Node,
8
+ NodeWithContent,
9
+ ResolvedConfig,
10
+ ResolvedContent,
11
+ TraversalNode,
12
+ TraversalResult,
13
+ load_config,
14
+ open_index,
15
+ )
16
+
17
+ async def _graph_read_node_async(self: GraphIndex, node_id: str) -> Node:
18
+ return self.read_node(node_id)
19
+
20
+
21
+ async def _graph_traverse_async(
22
+ self: GraphIndex, from_id: str, depth: int = 2, order: str = "bfs"
23
+ ) -> TraversalResult:
24
+ return self.traverse(from_id, depth, order)
25
+
26
+
27
+ async def _lakehouse_read_node_async(
28
+ self: LakehouseReader, index: GraphIndex, node_id: str
29
+ ) -> NodeWithContent:
30
+ return self.read_node(index, node_id)
31
+
32
+
33
+ # Keep async convenience methods on the Python side so they follow Python 3.14
34
+ # asyncio semantics while reusing the Rust sync methods as the source of truth.
35
+ GraphIndex.read_node_async = _graph_read_node_async
36
+ GraphIndex.traverse_async = _graph_traverse_async
37
+ LakehouseReader.read_node_async = _lakehouse_read_node_async
38
+
39
+ __all__ = [
40
+ "GraphIndex",
41
+ "LakehouseReader",
42
+ "MagGraphError",
43
+ "Node",
44
+ "NodeWithContent",
45
+ "ResolvedConfig",
46
+ "ResolvedContent",
47
+ "TraversalNode",
48
+ "TraversalResult",
49
+ "load_config",
50
+ "open_index",
51
+ ]
52
+
53
+ __version__ = "0.3.0"
@@ -0,0 +1,85 @@
1
+ use std::env;
2
+ use std::fs;
3
+ use std::time::{Duration, Instant};
4
+
5
+ use maggraph::{GraphIndex, QueryOptions};
6
+ use tempfile::TempDir;
7
+
8
+ fn sizes() -> Vec<usize> {
9
+ if let Ok(value) = env::var("MAGGRAPH_BENCH_SIZES") {
10
+ return value
11
+ .split(',')
12
+ .filter_map(|item| item.trim().parse().ok())
13
+ .collect();
14
+ }
15
+ if env::var_os("MAGGRAPH_BENCH_FULL").is_some() {
16
+ vec![1_000, 10_000, 100_000]
17
+ } else {
18
+ vec![1_000, 10_000]
19
+ }
20
+ }
21
+
22
+ fn write_fixture(root: &std::path::Path, count: usize) {
23
+ fs::create_dir_all(root).expect("create fixture root");
24
+ for index in 0..count {
25
+ let previous = index.checked_sub(1).unwrap_or(count - 1);
26
+ let tags = if index % 25 == 0 {
27
+ "tags: [\"benchmark\", \"selected\"]\n"
28
+ } else {
29
+ "tags: [\"benchmark\"]\n"
30
+ };
31
+ let markdown = format!(
32
+ "---\nid: \"node_{index:06}\"\ntype: \"project_fact\"\n{tags}links: [\"node_{previous:06}\"]\n---\nSynthetic graph fact {index}. Search needle group {}.\n",
33
+ index % 100
34
+ );
35
+ fs::write(root.join(format!("node_{index:06}.md")), markdown).expect("write node");
36
+ }
37
+ }
38
+
39
+ fn timed<T>(operation: impl FnOnce() -> T) -> (T, Duration) {
40
+ let started = Instant::now();
41
+ let result = operation();
42
+ (result, started.elapsed())
43
+ }
44
+
45
+ fn main() {
46
+ println!("MagGraph index scale benchmark");
47
+ println!("nodes,open_ms,search_ms,backlinks_ms,recall_us,update_us");
48
+
49
+ for count in sizes() {
50
+ let temp = TempDir::new().expect("temp dir");
51
+ let root = temp.path().join("graph");
52
+ write_fixture(&root, count);
53
+
54
+ let (mut index, open) = timed(|| GraphIndex::open(&root).expect("open index"));
55
+ let options = QueryOptions {
56
+ text: Some("needle group 42".into()),
57
+ tags: vec!["benchmark".into()],
58
+ limit: 50,
59
+ ..QueryOptions::default()
60
+ };
61
+ let (results, search) = timed(|| index.search(&options).expect("search"));
62
+ assert!(!results.is_empty());
63
+
64
+ let (_, backlinks) = timed(|| index.backlinks("node_000042").expect("backlinks"));
65
+ let (_, recall) = timed(|| {
66
+ index
67
+ .recall_bundle("node_000042", "scale benchmark", 600)
68
+ .expect("recall bundle")
69
+ });
70
+ let (_, update) = timed(|| {
71
+ index
72
+ .update_file(root.join("node_000042.md"))
73
+ .expect("incremental update")
74
+ });
75
+
76
+ println!(
77
+ "{count},{:.3},{:.3},{:.3},{:.3},{:.3}",
78
+ open.as_secs_f64() * 1_000.0,
79
+ search.as_secs_f64() * 1_000.0,
80
+ backlinks.as_secs_f64() * 1_000.0,
81
+ recall.as_secs_f64() * 1_000_000.0,
82
+ update.as_secs_f64() * 1_000_000.0,
83
+ );
84
+ }
85
+ }
@@ -4,8 +4,6 @@ use std::collections::{HashMap, HashSet, VecDeque};
4
4
 
5
5
  use crate::error::{MagGraphError, Result};
6
6
  use crate::index::GraphIndex;
7
- use crate::node::Node;
8
- use crate::wikilink::extract_wikilink_targets;
9
7
 
10
8
  /// Edge direction: all edges are **outgoing** from source node to target node id.
11
9
  ///
@@ -20,7 +18,7 @@ pub struct GraphAdjacency {
20
18
  }
21
19
 
22
20
  impl GraphAdjacency {
23
- /// Build adjacency by reading all nodes in the index.
21
+ /// Build adjacency from metadata and wikilinks cached in the index.
24
22
  pub fn from_index(index: &GraphIndex) -> Result<Self> {
25
23
  let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
26
24
  let mut unresolved: HashMap<String, Vec<String>> = HashMap::new();
@@ -28,9 +26,13 @@ impl GraphAdjacency {
28
26
  let id_set: HashSet<&str> = index.iter().map(|(id, _)| id).collect();
29
27
  let path_stem_to_id = build_path_stem_index(index);
30
28
 
31
- for (id, _) in index.iter() {
32
- let node = index.read_node(id)?;
33
- let (targets, dangling) = collect_outgoing_targets(&node, &id_set, &path_stem_to_id);
29
+ for (id, entry) in index.iter() {
30
+ let (targets, dangling) = collect_outgoing_targets(
31
+ &entry.metadata.links,
32
+ &entry.wikilinks,
33
+ &id_set,
34
+ &path_stem_to_id,
35
+ );
34
36
  if !targets.is_empty() {
35
37
  outgoing.insert(id.to_string(), targets);
36
38
  }
@@ -114,7 +116,8 @@ fn build_path_stem_index(index: &GraphIndex) -> HashMap<String, String> {
114
116
  }
115
117
 
116
118
  fn collect_outgoing_targets(
117
- node: &Node,
119
+ frontmatter_links: &[String],
120
+ wikilinks: &[String],
118
121
  id_set: &HashSet<&str>,
119
122
  path_stem_to_id: &HashMap<String, String>,
120
123
  ) -> (Vec<String>, Vec<String>) {
@@ -140,12 +143,12 @@ fn collect_outgoing_targets(
140
143
  }
141
144
  };
142
145
 
143
- for link in &node.metadata.links {
146
+ for link in frontmatter_links {
144
147
  consider(link);
145
148
  }
146
149
 
147
- for target in extract_wikilink_targets(&node.body) {
148
- consider(&target);
150
+ for target in wikilinks {
151
+ consider(target);
149
152
  }
150
153
 
151
154
  (resolved, dangling)
@@ -1,6 +1,7 @@
1
1
  use std::collections::HashMap;
2
2
  use std::fs;
3
3
  use std::path::{Path, PathBuf};
4
+ use std::sync::Arc;
4
5
 
5
6
  use walkdir::WalkDir;
6
7
 
@@ -11,18 +12,26 @@ use crate::query::{changed_since, GraphChange, QueryOptions, SearchResult};
11
12
  use crate::recall::{recall_bundle, RecallBundle};
12
13
  use crate::security::validate_relative_node_path;
13
14
  use crate::sync::WritePolicy;
15
+ use crate::wikilink::extract_wikilink_targets;
14
16
 
15
- /// Lightweight index entry for a graph node (metadata + path, no body).
17
+ /// Parsed index entry used by graph, search, and retrieval operations.
16
18
  #[derive(Debug, Clone, PartialEq, Eq)]
17
19
  pub struct NodeIndexEntry {
18
20
  pub metadata: NodeMetadata,
19
21
  pub relative_path: PathBuf,
22
+ pub(crate) body: Arc<str>,
23
+ pub(crate) summary: String,
24
+ pub(crate) wikilinks: Vec<String>,
20
25
  }
21
26
 
22
27
  impl NodeIndexEntry {
23
28
  pub fn id(&self) -> &str {
24
29
  &self.metadata.id
25
30
  }
31
+
32
+ pub(crate) fn body(&self) -> &str {
33
+ &self.body
34
+ }
26
35
  }
27
36
 
28
37
  /// In-memory index of markdown nodes under a graph root.
@@ -99,7 +108,7 @@ impl GraphIndex {
99
108
  }
100
109
 
101
110
  let node = Node::from_file(path, &self.root_path)?;
102
- self.insert_entry(node.metadata, relative_path.to_path_buf())?;
111
+ self.insert_entry(node.metadata, relative_path.to_path_buf(), node.body)?;
103
112
  }
104
113
 
105
114
  Ok(())
@@ -158,17 +167,28 @@ impl GraphIndex {
158
167
  return Ok(None);
159
168
  }
160
169
 
161
- if let Some(existing_id) = self.by_path.remove(&relative_path) {
162
- self.by_id.remove(&existing_id);
163
- }
164
-
165
170
  if !full_path.exists() {
171
+ if let Some(existing_id) = self.by_path.remove(&relative_path) {
172
+ self.by_id.remove(&existing_id);
173
+ }
166
174
  return Ok(None);
167
175
  }
168
176
 
169
177
  let node = Node::from_file(&full_path, &self.root_path)?;
170
178
  let id = node.id().to_string();
171
- self.insert_entry(node.metadata, relative_path)?;
179
+ if let Some(existing) = self.by_id.get(&id) {
180
+ if existing.relative_path != relative_path {
181
+ return Err(MagGraphError::DuplicateNodeId {
182
+ id,
183
+ first: existing.relative_path.clone(),
184
+ second: relative_path,
185
+ });
186
+ }
187
+ }
188
+ if let Some(existing_id) = self.by_path.remove(&relative_path) {
189
+ self.by_id.remove(&existing_id);
190
+ }
191
+ self.insert_entry(node.metadata, relative_path, node.body)?;
172
192
  Ok(Some(id))
173
193
  }
174
194
 
@@ -260,7 +280,11 @@ impl GraphIndex {
260
280
  }
261
281
 
262
282
  node.write_to(&self.root_path)?;
263
- self.insert_entry(node.metadata.clone(), node.relative_path.clone())?;
283
+ self.insert_entry(
284
+ node.metadata.clone(),
285
+ node.relative_path.clone(),
286
+ node.body.clone(),
287
+ )?;
264
288
  Ok(node)
265
289
  }
266
290
 
@@ -298,6 +322,9 @@ impl GraphIndex {
298
322
  NodeIndexEntry {
299
323
  metadata: node.metadata,
300
324
  relative_path: node.relative_path,
325
+ summary: crate::query::summarize_text(&node.body),
326
+ wikilinks: extract_wikilink_targets(&node.body),
327
+ body: Arc::from(node.body),
301
328
  },
302
329
  );
303
330
  Ok(())
@@ -317,16 +344,18 @@ impl GraphIndex {
317
344
  fn delete_node_unchecked(&mut self, id: &str) -> Result<()> {
318
345
  let entry = self
319
346
  .by_id
320
- .remove(id)
347
+ .get(id)
348
+ .cloned()
321
349
  .ok_or_else(|| MagGraphError::NodeNotFound { id: id.to_string() })?;
322
350
 
323
- self.by_path.remove(&entry.relative_path);
324
-
325
351
  let path = self.root_path.join(&entry.relative_path);
326
352
  if path.exists() {
327
353
  fs::remove_file(&path).map_err(|source| MagGraphError::NodeDelete { path, source })?;
328
354
  }
329
355
 
356
+ self.by_id.remove(id);
357
+ self.by_path.remove(&entry.relative_path);
358
+
330
359
  Ok(())
331
360
  }
332
361
 
@@ -362,28 +391,55 @@ impl GraphIndex {
362
391
  }
363
392
  let mut target = self.read_node(target_id)?;
364
393
  let source = self.read_node(source_id)?;
365
- if !target.body.ends_with('\n') {
366
- target.body.push('\n');
394
+ let source_value = serde_yaml::Value::String(source_id.to_string());
395
+ let already_merged = target
396
+ .metadata
397
+ .extra
398
+ .get("merged_from")
399
+ .and_then(serde_yaml::Value::as_sequence)
400
+ .is_some_and(|values| values.contains(&source_value));
401
+ if !already_merged {
402
+ if !target.body.ends_with('\n') {
403
+ target.body.push('\n');
404
+ }
405
+ target.body.push_str(&format!(
406
+ "\n---\n\n## Merged from `{}`\n\n{}",
407
+ source.id(),
408
+ source.body
409
+ ));
410
+ for link in source.metadata.links {
411
+ if link != target_id && !target.metadata.links.contains(&link) {
412
+ target.metadata.links.push(link);
413
+ }
414
+ }
367
415
  }
368
- target.body.push_str(&format!(
369
- "\n---\n\n## Merged from `{}`\n\n{}",
370
- source.id(),
371
- source.body
372
- ));
373
- for link in source.metadata.links {
374
- if link != target_id && !target.metadata.links.contains(&link) {
375
- target.metadata.links.push(link);
416
+ let merged_from = target
417
+ .metadata
418
+ .extra
419
+ .entry("merged_from".to_string())
420
+ .or_insert_with(|| serde_yaml::Value::Sequence(Vec::new()));
421
+ let provenance = match merged_from {
422
+ serde_yaml::Value::Sequence(values) => values,
423
+ _ => {
424
+ *merged_from = serde_yaml::Value::Sequence(Vec::new());
425
+ merged_from
426
+ .as_sequence_mut()
427
+ .expect("sequence inserted above")
376
428
  }
429
+ };
430
+ if !provenance.contains(&source_value) {
431
+ provenance.push(source_value);
377
432
  }
378
- target.metadata.extra.insert(
379
- "merged_from".to_string(),
380
- serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(source_id.to_string())]),
381
- );
382
433
  self.update_node(target)?;
383
434
  self.delete_node(source_id)
384
435
  }
385
436
 
386
- fn insert_entry(&mut self, metadata: NodeMetadata, relative_path: PathBuf) -> Result<()> {
437
+ fn insert_entry(
438
+ &mut self,
439
+ metadata: NodeMetadata,
440
+ relative_path: PathBuf,
441
+ body: String,
442
+ ) -> Result<()> {
387
443
  let id = metadata.id.clone();
388
444
 
389
445
  if let Some(existing) = self.by_id.get(&id) {
@@ -406,6 +462,9 @@ impl GraphIndex {
406
462
  NodeIndexEntry {
407
463
  metadata,
408
464
  relative_path: relative_path.clone(),
465
+ summary: crate::query::summarize_text(&body),
466
+ wikilinks: extract_wikilink_targets(&body),
467
+ body: Arc::from(body),
409
468
  },
410
469
  );
411
470
  self.by_path.insert(relative_path, id);
@@ -677,4 +736,123 @@ tags: ["agent"]
677
736
  assert!(index.contains("fresh"));
678
737
  assert!(!index.changed_since(0).is_empty());
679
738
  }
739
+
740
+ #[test]
741
+ fn malformed_incremental_update_keeps_last_valid_index_state() {
742
+ let temp = TempDir::new().expect("temp dir");
743
+ let root = temp.path().join("graph");
744
+ write_example_graph(&root);
745
+ let mut index = GraphIndex::open(&root).expect("open index");
746
+
747
+ fs::write(root.join("welcome.md"), "# truncated without frontmatter")
748
+ .expect("write malformed external edit");
749
+ index
750
+ .update_file("welcome.md")
751
+ .expect_err("malformed update should fail");
752
+
753
+ assert!(index.contains("welcome"));
754
+ let results = index
755
+ .search(&QueryOptions {
756
+ text: Some("Welcome".into()),
757
+ ..QueryOptions::default()
758
+ })
759
+ .expect("cached index remains searchable");
760
+ assert_eq!(results[0].id, "welcome");
761
+ }
762
+
763
+ #[test]
764
+ fn failed_disk_delete_keeps_index_entry() {
765
+ let temp = TempDir::new().expect("temp dir");
766
+ let root = temp.path().join("graph");
767
+ write_example_graph(&root);
768
+ let mut index = GraphIndex::open(&root).expect("open index");
769
+ let path = root.join("getting_started.md");
770
+
771
+ fs::remove_file(&path).expect("remove fixture file");
772
+ fs::create_dir(&path).expect("replace file with directory");
773
+ let error = index
774
+ .delete_node("getting_started")
775
+ .expect_err("directory cannot be removed as a file");
776
+
777
+ assert!(error.to_string().contains("delete"));
778
+ assert!(index.contains("getting_started"));
779
+ assert_eq!(
780
+ index.get("getting_started").unwrap().relative_path,
781
+ PathBuf::from("getting_started.md")
782
+ );
783
+ }
784
+
785
+ #[test]
786
+ fn repeated_merges_preserve_all_provenance() {
787
+ let temp = TempDir::new().expect("temp dir");
788
+ let root = temp.path().join("graph");
789
+ write_example_graph(&root);
790
+ let mut index = GraphIndex::open(&root).expect("open index");
791
+ index
792
+ .create_node(NewNode {
793
+ metadata: NodeMetadata {
794
+ id: "third".into(),
795
+ node_type: "note".into(),
796
+ source: None,
797
+ links: vec![],
798
+ extra: Default::default(),
799
+ },
800
+ body: "Third body\n".into(),
801
+ relative_path: PathBuf::from("third.md"),
802
+ })
803
+ .expect("create third node");
804
+
805
+ index
806
+ .merge_nodes("welcome", "getting_started")
807
+ .expect("first merge");
808
+ index.merge_nodes("welcome", "third").expect("second merge");
809
+
810
+ let merged = index.read_node("welcome").expect("merged target");
811
+ let provenance = merged
812
+ .metadata
813
+ .extra
814
+ .get("merged_from")
815
+ .and_then(serde_yaml::Value::as_sequence)
816
+ .expect("merged_from sequence");
817
+ assert_eq!(
818
+ provenance,
819
+ &vec![
820
+ serde_yaml::Value::String("getting_started".into()),
821
+ serde_yaml::Value::String("third".into()),
822
+ ]
823
+ );
824
+ }
825
+
826
+ #[test]
827
+ fn merge_retry_does_not_duplicate_already_recorded_source() {
828
+ let temp = TempDir::new().expect("temp dir");
829
+ let root = temp.path().join("graph");
830
+ write_example_graph(&root);
831
+ let mut index = GraphIndex::open(&root).expect("open index");
832
+ let source_body = index.read_node("getting_started").expect("source").body;
833
+ let mut target = index.read_node("welcome").expect("target");
834
+ target.body.push_str(&format!(
835
+ "\n---\n\n## Merged from `getting_started`\n\n{source_body}"
836
+ ));
837
+ target.metadata.extra.insert(
838
+ "merged_from".into(),
839
+ serde_yaml::Value::Sequence(vec![serde_yaml::Value::String("getting_started".into())]),
840
+ );
841
+ index
842
+ .update_node(target)
843
+ .expect("record interrupted merge state");
844
+
845
+ index
846
+ .merge_nodes("welcome", "getting_started")
847
+ .expect("retry merge");
848
+ let merged = index.read_node("welcome").expect("merged target");
849
+ assert_eq!(
850
+ merged
851
+ .body
852
+ .matches("## Merged from `getting_started`")
853
+ .count(),
854
+ 1
855
+ );
856
+ assert!(!index.contains("getting_started"));
857
+ }
680
858
  }
@@ -2,6 +2,7 @@ mod frontmatter;
2
2
 
3
3
  use std::collections::BTreeMap;
4
4
  use std::fs;
5
+ use std::io::Write;
5
6
  use std::path::{Path, PathBuf};
6
7
 
7
8
  use serde::{Deserialize, Serialize};
@@ -93,7 +94,26 @@ impl Node {
93
94
  }
94
95
 
95
96
  let contents = self.to_markdown()?;
96
- fs::write(&path, contents).map_err(|source| MagGraphError::NodeWrite { path, source })
97
+ let mut pending =
98
+ tempfile::NamedTempFile::new_in(path.parent().unwrap_or_else(|| Path::new(".")))
99
+ .map_err(|source| MagGraphError::NodeWrite {
100
+ path: path.clone(),
101
+ source,
102
+ })?;
103
+ pending
104
+ .write_all(contents.as_bytes())
105
+ .and_then(|_| pending.as_file().sync_all())
106
+ .map_err(|source| MagGraphError::NodeWrite {
107
+ path: path.clone(),
108
+ source,
109
+ })?;
110
+ pending
111
+ .persist(&path)
112
+ .map_err(|error| MagGraphError::NodeWrite {
113
+ path,
114
+ source: error.error,
115
+ })?;
116
+ Ok(())
97
117
  }
98
118
 
99
119
  pub fn id(&self) -> &str {
@@ -228,6 +248,37 @@ links: ["getting_started"]
228
248
  assert_eq!(reloaded, node);
229
249
  }
230
250
 
251
+ #[test]
252
+ fn atomic_replacement_ignores_orphaned_pending_files() {
253
+ let temp = TempDir::new().expect("temp dir");
254
+ let root = temp.path().join("graph");
255
+ fs::create_dir_all(&root).expect("create root");
256
+
257
+ let mut node = Node {
258
+ metadata: NodeMetadata {
259
+ id: "durable".into(),
260
+ node_type: "note".into(),
261
+ source: None,
262
+ links: vec![],
263
+ extra: BTreeMap::new(),
264
+ },
265
+ body: "original\n".into(),
266
+ relative_path: PathBuf::from("durable.md"),
267
+ };
268
+ node.write_to(&root).expect("initial write");
269
+
270
+ fs::write(root.join(".tmp-interrupted-write"), "partial").expect("orphan temp");
271
+ node.body = "replacement\n".into();
272
+ node.write_to(&root).expect("atomic replacement");
273
+
274
+ let reloaded = Node::from_file(root.join("durable.md"), &root).expect("reload");
275
+ assert_eq!(reloaded.body, "replacement\n");
276
+ assert_eq!(
277
+ fs::read_to_string(root.join(".tmp-interrupted-write")).unwrap(),
278
+ "partial"
279
+ );
280
+ }
281
+
231
282
  #[test]
232
283
  fn rejects_missing_frontmatter() {
233
284
  let err = parse_markdown_node("# No frontmatter", Path::new("x.md")).expect_err("error");
@@ -63,7 +63,6 @@ pub fn search_index(index: &GraphIndex, options: &QueryOptions) -> Result<Vec<Se
63
63
  }
64
64
  }
65
65
 
66
- let node = index.read_node(id)?;
67
66
  let mut score = 0;
68
67
  let mut matched = Vec::new();
69
68
  if needle.is_empty() {
@@ -72,7 +71,7 @@ pub fn search_index(index: &GraphIndex, options: &QueryOptions) -> Result<Vec<Se
72
71
  } else {
73
72
  score += score_text(id, &needle, 30, "id", &mut matched);
74
73
  score += score_text(&entry.metadata.node_type, &needle, 12, "type", &mut matched);
75
- score += score_text(&node.body, &needle, 6, "body", &mut matched);
74
+ score += score_text(entry.body(), &needle, 6, "body", &mut matched);
76
75
  for link in &entry.metadata.links {
77
76
  score += score_text(link, &needle, 10, "links", &mut matched);
78
77
  }
@@ -98,7 +97,7 @@ pub fn search_index(index: &GraphIndex, options: &QueryOptions) -> Result<Vec<Se
98
97
  relative_path: entry.relative_path.display().to_string(),
99
98
  score,
100
99
  matched,
101
- summary: summarize_body(&node),
100
+ summary: entry.summary.clone(),
102
101
  modified_unix,
103
102
  });
104
103
  }
@@ -139,8 +138,11 @@ pub fn is_suppressed(node: &Node) -> bool {
139
138
  }
140
139
 
141
140
  pub fn summarize_body(node: &Node) -> String {
142
- node.body
143
- .lines()
141
+ summarize_text(&node.body)
142
+ }
143
+
144
+ pub(crate) fn summarize_text(body: &str) -> String {
145
+ body.lines()
144
146
  .filter(|line| !line.trim().is_empty())
145
147
  .take(3)
146
148
  .collect::<Vec<_>>()
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "maggraph"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "In-process graph engine for AI semantic layers — powered by Rust"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "Programming Language :: Python :: 3.11",
25
25
  "Programming Language :: Python :: 3.12",
26
26
  "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
27
28
  "Programming Language :: Rust",
28
29
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
29
30
  "Topic :: Database",
@@ -1,31 +0,0 @@
1
- """MagGraph — Python bindings for the Rust graph engine."""
2
-
3
- from maggraph._maggraph import (
4
- GraphIndex,
5
- LakehouseReader,
6
- MagGraphError,
7
- Node,
8
- NodeWithContent,
9
- ResolvedConfig,
10
- ResolvedContent,
11
- TraversalNode,
12
- TraversalResult,
13
- load_config,
14
- open_index,
15
- )
16
-
17
- __all__ = [
18
- "GraphIndex",
19
- "LakehouseReader",
20
- "MagGraphError",
21
- "Node",
22
- "NodeWithContent",
23
- "ResolvedConfig",
24
- "ResolvedContent",
25
- "TraversalNode",
26
- "TraversalResult",
27
- "load_config",
28
- "open_index",
29
- ]
30
-
31
- __version__ = "0.2.0"
File without changes
File without changes
File without changes
File without changes
File without changes