diff --git a/docs/book/src/reference/mcp-tools.md b/docs/book/src/reference/mcp-tools.md index 376be25..b109099 100644 --- a/docs/book/src/reference/mcp-tools.md +++ b/docs/book/src/reference/mcp-tools.md @@ -133,7 +133,7 @@ Graph projection and algorithms. | Parameter | Required | Description | |-----------|----------|-------------| -| `algorithm` | No | `stats`, `in_degree`, `pagerank`, or `ppr` (default: `stats`) | +| `algorithm` | No | `stats`, `in_degree`, `pagerank`/`ppr`, `components`, `louvain`, or `shortest_path` (default: `stats`) | | `type` | No | Restrict projection to this rdf:type IRI | | `predicate` | No | Restrict projection to edges with this predicate IRI | | `limit` | No | Max results for in_degree/pagerank (default: 20) | @@ -141,6 +141,12 @@ Graph projection and algorithms. | `damping` | No | PageRank damping factor (default: 0.85) | | `max_iters` | No | PageRank max iterations (default: 100) | | `tolerance` | No | PageRank convergence tolerance (default: 1e-6) | +| `from` / `to` | No | Source/target entity IRIs for `shortest_path` | +| `persist` | No | `louvain` only: persist `quipu:memberOfCommunity` facts, superseding any prior derivation (default: `false`). Emergent clustering, **not** an access boundary. | + +The `louvain` algorithm runs deterministic modularity-based community detection +and returns `{ communities: [{ community, entities, size }], modularity }`. +Read-only unless `persist: true`. ### `quipu_context` diff --git a/docs/design/pagerank.md b/docs/design/pagerank.md index 9510e06..cc1f1e0 100644 --- a/docs/design/pagerank.md +++ b/docs/design/pagerank.md @@ -50,10 +50,40 @@ This spec does not invent a feature — it builds one already on the books: ## Non-goals (v1) -- Louvain / community detection (future; same Projection API). +- ~~Louvain / community detection (future; same Projection API).~~ **Delivered + (hq-zlph) — see [Community detection](#community-detection-louvain) below.** - Approximate / streaming PageRank at write time. - Distributed computation (single-process, "SQLite energy"). +## Community detection (Louvain) + +Delivered on the same Projection API as PageRank (hq-zlph, aegis-1p0 Gap 3). +`graph::louvain` runs Louvain's modularity local-moving phase over the projected +graph and returns a partition plus its Newman–Girvan modularity. Exposed as +`tool_project` `algorithm: "louvain"` (alias `"community"`). + +- **Undirected weighting.** The projected directed multigraph is read undirected: + parallel edges sum into one weight, self-loops dropped, direction ignored — the + standard input shape for modularity-based clustering. +- **Determinism is a hard requirement.** Nodes are visited in ascending entity-id + order, candidate communities evaluated in ascending-label order, and ties in + modularity gain broken toward the lowest label. The same graph always yields the + same partition, independent of hash iteration order. (Hand-rolled, pure-Rust, no + C-backed clustering dependency.) +- **Opt-in persistence.** Read-only by default. With `persist: true`, each + entity's community is written as a `quipu:memberOfCommunity` fact through the + store (provenance `source = "algo:louvain"`), so clusters become queryable, + time-travelable knowledge — exactly like persisted PageRank scores. +- **Supersede on re-run.** Membership is *derived* and goes stale when the graph + changes, so a re-run bitemporally supersedes the prior derivation: changed + memberships are retracted (`valid_to` closed) and new ones asserted, reconciled + by `(entity, community-term)`. A default current-facts query returns only the + latest partition — stale clusters never accumulate. +- **Not an access boundary.** Community membership is *emergent clustering*, not a + tenancy or authorization primitive. Like `group_id` (hq-2u3: provenance, not + isolation), it must never gate access; consumers must not build access control + on `quipu:memberOfCommunity`. + ## API design A pure function in `src/graph.rs`, no `Store` coupling in the math: diff --git a/src/cli.rs b/src/cli.rs index 72f163c..61ece6e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -295,7 +295,7 @@ pub fn cmd_project(args: &[String], db_path: &str) { input["seeds"] = serde_json::Value::Array(seeds); } - let store = match quipu::Store::open(db_path) { + let mut store = match quipu::Store::open(db_path) { Ok(s) => s, Err(e) => { eprintln!("error opening store: {e}"); @@ -303,7 +303,7 @@ pub fn cmd_project(args: &[String], db_path: &str) { } }; - match quipu::tool_project(&store, &input) { + match quipu::tool_project(&mut store, &input) { Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()), Err(e) => { eprintln!("error: {e}"); diff --git a/src/graph.rs b/src/graph.rs index b282bdd..b98fe84 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -1,5 +1,13 @@ //! Graph projection — materialize the fact store into a petgraph `DiGraph` -//! for running graph algorithms (`PageRank`, shortest path, connected components). +//! for running graph algorithms (`PageRank`, shortest path, connected +//! components, Louvain community detection). +//! +//! **Communities are not an access boundary.** `louvain` (with `persist:true`) +//! writes `quipu:memberOfCommunity` facts, but community membership is *emergent +//! clustering* derived from graph structure — it is not a tenancy or +//! authorization primitive. Like `group_id` (hq-2u3: provenance, not isolation), +//! it must never be used to gate access; consumers must not build access control +//! on community membership. use std::collections::HashMap; @@ -9,8 +17,8 @@ use serde_json::Value as JsonValue; use crate::error::Result; use crate::namespace; -use crate::store::Store; -use crate::types::Value; +use crate::store::{Datum, Store}; +use crate::types::{Op, Value}; /// A projected graph with entity-to-index mappings. pub struct ProjectedGraph { @@ -291,6 +299,186 @@ pub fn connected_components(pg: &ProjectedGraph) -> Vec> { .collect() } +/// A community partition: groups of entity ids plus the modularity score. +pub struct Communities { + /// One group of entity ids per community. Each group is sorted ascending, + /// and the outer vector is ordered by each group's minimum entity id, so a + /// given partition has exactly one canonical representation. + pub groups: Vec>, + /// Newman–Girvan modularity of the partition (higher = stronger community + /// structure). `0.0` for an edgeless graph. + pub modularity: f64, +} + +/// Deterministic Louvain community detection (modularity local-moving phase). +/// +/// The projected directed multigraph is read as an **undirected weighted** +/// graph: parallel edges sum into one weight, self-loops are ignored, and edge +/// direction is dropped — the standard input shape for modularity-based +/// community detection. +/// +/// **Determinism is a hard requirement** (the same graph must always yield the +/// same partition, regardless of `HashMap` iteration order): nodes are visited +/// in ascending entity-id order, candidate communities are evaluated in +/// ascending-label order, and ties in modularity gain are broken toward the +/// lowest community label. No randomness, no hash-order dependence. +/// +/// This is Louvain's first (local-moving) level run to convergence — sufficient +/// to separate well-defined communities; the multi-level aggregation step is +/// intentionally omitted to keep the implementation auditable and dependency-free. +pub fn louvain(pg: &ProjectedGraph) -> Communities { + let n = pg.graph.node_count(); + if n == 0 { + return Communities { + groups: Vec::new(), + modularity: 0.0, + }; + } + + // Stable index space: entity ids sorted ascending → contiguous 0..n. Visiting + // nodes in this order (rather than NodeIndex / hash order) is what makes the + // partition deterministic. + let mut ids: Vec = pg.node_to_entity.values().copied().collect(); + ids.sort_unstable(); + let index_of: HashMap = ids.iter().enumerate().map(|(i, &e)| (e, i)).collect(); + + // Undirected weighted adjacency: each directed edge contributes 1.0 to the + // weight between its endpoints (stored symmetrically); self-loops dropped. + let mut adj: Vec> = vec![HashMap::new(); n]; + for idx in pg.graph.node_indices() { + let u = index_of[&pg.node_to_entity[&idx]]; + for edge in pg.graph.edges_directed(idx, petgraph::Direction::Outgoing) { + let v = index_of[&pg.node_to_entity[&petgraph::visit::EdgeRef::target(&edge)]]; + if u == v { + continue; + } + *adj[u].entry(v).or_insert(0.0) += 1.0; + *adj[v].entry(u).or_insert(0.0) += 1.0; + } + } + + // Weighted degree per node and 2m (twice the total edge weight). + let k: Vec = adj.iter().map(|a| a.values().sum()).collect(); + let two_m: f64 = k.iter().sum(); + + // Edgeless graph: every node is its own community, modularity 0. + if two_m <= f64::EPSILON { + return Communities { + groups: ids.iter().map(|&e| vec![e]).collect(), + modularity: 0.0, + }; + } + + // comm[i] = community label of node i. Start with every node isolated. + let mut comm: Vec = (0..n).collect(); + // sigma_tot[c] = sum of weighted degrees of nodes currently in community c. + let mut sigma_tot: HashMap = (0..n).map(|i| (i, k[i])).collect(); + let mut next_label = n; // fresh labels for re-isolated nodes + + const EPS: f64 = 1e-12; + const MAX_PASSES: usize = 100; // convergence guard (deterministic upper bound) + + for _ in 0..MAX_PASSES { + let mut moved = false; + for i in 0..n { + let ci = comm[i]; + let ci_total = *sigma_tot.get(&ci).unwrap_or(&0.0); + let was_alone = (ci_total - k[i]).abs() <= EPS; // i was the only member + + // Remove i from its community. + let st = sigma_tot.entry(ci).or_insert(0.0); + *st -= k[i]; + if *st <= EPS { + sigma_tot.remove(&ci); + } + + // Sum edge weight from i into each neighbouring community. + let mut k_i_to: HashMap = HashMap::new(); + for (&j, &w) in &adj[i] { + if j == i { + continue; + } + *k_i_to.entry(comm[j]).or_insert(0.0) += w; + } + + // Best gain vs. staying isolated (baseline gain 0). Evaluate candidate + // communities in ascending-label order; strict-greater wins, exact ties + // resolve to the lowest label — both deterministic. + let mut cands: Vec = k_i_to.keys().copied().collect(); + cands.sort_unstable(); + let mut best_gain = 0.0_f64; + let mut best_c: Option = None; + for &c in &cands { + let gain = k_i_to[&c] - k[i] * sigma_tot.get(&c).copied().unwrap_or(0.0) / two_m; + if gain > best_gain + EPS { + best_gain = gain; + best_c = Some(c); + } + } + + let target = match best_c { + Some(c) => c, + // No positive-gain community: stay isolated. Reuse ci if i was + // already alone (no real move), else take a fresh singleton label. + None if was_alone => ci, + None => { + let l = next_label; + next_label += 1; + l + } + }; + + *sigma_tot.entry(target).or_insert(0.0) += k[i]; + comm[i] = target; + if target != ci { + moved = true; + } + } + if !moved { + break; + } + } + + // Group nodes by community label, then canonicalise ordering. + let mut by_label: HashMap> = HashMap::new(); + for (i, &c) in comm.iter().enumerate() { + by_label.entry(c).or_default().push(ids[i]); + } + let mut groups: Vec> = by_label.into_values().collect(); + for g in &mut groups { + g.sort_unstable(); + } + groups.sort_by_key(|g| g[0]); // order by each group's minimum entity id + + let modularity = modularity_of(&comm, &adj, &k, two_m); + Communities { groups, modularity } +} + +/// Newman–Girvan modularity of a partition over the undirected weighted graph. +fn modularity_of(comm: &[usize], adj: &[HashMap], k: &[f64], two_m: f64) -> f64 { + if two_m <= f64::EPSILON { + return 0.0; + } + // Σ_in: edge weight inside each community (each undirected edge counted once). + let mut sigma_in: HashMap = HashMap::new(); + let mut sigma_tot: HashMap = HashMap::new(); + for (i, &ci) in comm.iter().enumerate() { + *sigma_tot.entry(ci).or_insert(0.0) += k[i]; + for (&j, &w) in &adj[i] { + if comm[j] == ci { + *sigma_in.entry(ci).or_insert(0.0) += w; // counts each edge twice → /2 below + } + } + } + sigma_tot + .iter() + .map(|(c, &tot)| { + let in_w = sigma_in.get(c).copied().unwrap_or(0.0) / 2.0; + in_w / (two_m / 2.0) - (tot / two_m).powi(2) + }) + .sum() +} + /// Shortest path between two entities (by IRI), returns the path as entity IRIs. pub fn shortest_path( store: &Store, @@ -335,9 +523,13 @@ pub fn shortest_path( /// MCP tool: `quipu_project` — Project the knowledge graph and run algorithms. /// /// Input: `{ "type": "", "predicate": "", -/// "algorithm": "stats|in_degree|components|shortest_path", -/// "from": "", "to": "" }` -pub fn tool_project(store: &Store, input: &JsonValue) -> Result { +/// "algorithm": "stats|in_degree|pagerank|components|louvain|shortest_path", +/// "from": "", "to": "", "persist": }` +/// +/// Read-only by default. The `louvain` algorithm additionally accepts +/// `persist: true`, which writes `quipu:memberOfCommunity` facts through the +/// store (see [`persist_communities`]); all other algorithms ignore it. +pub fn tool_project(store: &mut Store, input: &JsonValue) -> Result { let type_filter = input.get("type").and_then(|v| v.as_str()); let pred_filter = input.get("predicate").and_then(|v| v.as_str()); let algorithm = input @@ -449,6 +641,53 @@ pub fn tool_project(store: &Store, input: &JsonValue) -> Result { "count": results.len() })) } + "louvain" | "community" => { + let communities = louvain(&pg); + + // Resolve entity ids to IRIs (immutable reads) into owned JSON before + // any write, so nothing borrows the store across the persist call. + let results: Vec = communities + .groups + .iter() + .enumerate() + .map(|(k, group)| { + let entities: Vec = group + .iter() + .map(|&id| store.resolve(id).unwrap_or_else(|_| format!("ref:{id}"))) + .collect(); + serde_json::json!({ + "community": format!("{}community_{k}", namespace::QUIPU), + "entities": entities, + "size": group.len(), + }) + }) + .collect(); + + // Opt-in persistence (read-only by default). Writes + // quipu:memberOfCommunity facts, superseding any prior derivation. + let persist = input + .get("persist") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let persisted = if persist { + let now = crate::time::now_iso(); + let timestamp = input + .get("timestamp") + .and_then(|v| v.as_str()) + .unwrap_or(&now); + Some(persist_communities(store, &communities.groups, timestamp)?) + } else { + None + }; + + Ok(serde_json::json!({ + "algorithm": "louvain", + "communities": results, + "count": results.len(), + "modularity": communities.modularity, + "persisted": persisted, + })) + } "shortest_path" => { let from = input.get("from").and_then(|v| v.as_str()).ok_or_else(|| { crate::Error::InvalidValue("missing 'from' IRI for shortest_path".into()) @@ -466,11 +705,87 @@ pub fn tool_project(store: &Store, input: &JsonValue) -> Result { })) } other => Err(crate::Error::InvalidValue(format!( - "unknown algorithm: {other} (try: stats, in_degree, pagerank, components, shortest_path)" + "unknown algorithm: {other} (try: stats, in_degree, pagerank, components, louvain, shortest_path)" ))), } } +/// Persist a community partition as `quipu:memberOfCommunity` facts, bitemporally +/// **superseding** any prior derivation (hq-zlph AC 5). +/// +/// Community membership is derived from graph structure, so it goes stale when the +/// graph changes. This reconciles the stored membership against the fresh +/// partition in a single transaction stamped with `source = "algo:louvain"`: +/// memberships that changed are retracted (their `valid_to` closed), genuinely +/// new ones are asserted, and unchanged ones are left untouched (retaining their +/// original assertion time). A default current-facts query (`valid_to IS NULL`) +/// therefore always returns exactly the latest partition — no stale clusters +/// accumulate. Community labels are positional (`community_`) and not stable +/// across runs, so the reconcile compares by `(entity, community-term)` rather +/// than trusting a label. +/// +/// (Retract-and-reassert is folded into a diff because the `facts` table is keyed +/// by `(e, a, v, tx)` — retracting *and* re-asserting an identical triple in one +/// transaction would collide on that key.) +/// +/// This is emergent clustering, **not** an access boundary — see the module note +/// and hq-zlph AC 6. Returns the total number of memberships in the new partition. +pub fn persist_communities( + store: &mut Store, + groups: &[Vec], + timestamp: &str, +) -> Result { + let pred_id = store.intern(&format!("{}memberOfCommunity", namespace::QUIPU))?; + + // Desired membership set: (entity, community-term-id). Intern labels up front. + let mut desired: std::collections::HashSet<(i64, i64)> = std::collections::HashSet::new(); + for (k, group) in groups.iter().enumerate() { + let comm_id = store.intern(&format!("{}community_{k}", namespace::QUIPU))?; + for &entity in group { + desired.insert((entity, comm_id)); + } + } + let total = desired.len(); + + let mut datums: Vec = Vec::new(); + + // Reconcile against current state: drop already-correct memberships from + // `desired`; retract the rest (stale). + for fact in store.current_facts()? { + if fact.attribute != pred_id { + continue; + } + let keep = matches!(fact.value, Value::Ref(cid) if desired.remove(&(fact.entity, cid))); + if !keep { + datums.push(Datum { + entity: fact.entity, + attribute: pred_id, + value: fact.value, + valid_from: timestamp.to_string(), + valid_to: None, + op: Op::Retract, + }); + } + } + + // Assert the genuinely-new memberships (deterministic order). + let mut to_assert: Vec<(i64, i64)> = desired.into_iter().collect(); + to_assert.sort_unstable(); + for (entity, comm_id) in to_assert { + datums.push(Datum { + entity, + attribute: pred_id, + value: Value::Ref(comm_id), + valid_from: timestamp.to_string(), + valid_to: None, + op: Op::Assert, + }); + } + + store.transact(&datums, timestamp, None, Some("algo:louvain"))?; + Ok(total) +} + #[cfg(test)] mod tests { use super::*; @@ -612,13 +927,13 @@ ex:app1 a ex:App ; ex:uses ex:server1 . #[test] fn test_tool_project_pagerank() { - let store = test_graph_store(); + let mut store = test_graph_store(); let input = serde_json::json!({ "algorithm": "pagerank", "predicate": "http://example.org/knows", "limit": 5 }); - let result = tool_project(&store, &input).unwrap(); + let result = tool_project(&mut store, &input).unwrap(); assert_eq!(result["algorithm"], "pagerank"); assert_eq!(result["personalized"], false); assert!(result["count"].as_u64().unwrap() > 0); @@ -627,13 +942,13 @@ ex:app1 a ex:App ; ex:uses ex:server1 . #[test] fn test_tool_project_ppr_with_seeds() { - let store = test_graph_store(); + let mut store = test_graph_store(); let input = serde_json::json!({ "algorithm": "ppr", "predicate": "http://example.org/knows", "seeds": ["http://example.org/alice"] }); - let result = tool_project(&store, &input).unwrap(); + let result = tool_project(&mut store, &input).unwrap(); assert_eq!(result["algorithm"], "pagerank"); assert_eq!(result["personalized"], true); } @@ -648,37 +963,176 @@ ex:app1 a ex:App ; ex:uses ex:server1 . #[test] fn test_tool_project_stats() { - let store = test_graph_store(); + let mut store = test_graph_store(); let input = serde_json::json!({"algorithm": "stats"}); - let result = tool_project(&store, &input).unwrap(); + let result = tool_project(&mut store, &input).unwrap(); assert!(result["nodes"].as_u64().unwrap() >= 6); assert!(result["edges"].as_u64().unwrap() >= 4); } #[test] fn test_tool_project_in_degree() { - let store = test_graph_store(); + let mut store = test_graph_store(); let input = serde_json::json!({ "algorithm": "in_degree", "predicate": "http://example.org/knows", "limit": 5 }); - let result = tool_project(&store, &input).unwrap(); + let result = tool_project(&mut store, &input).unwrap(); assert_eq!(result["algorithm"], "in_degree"); assert!(result["count"].as_u64().unwrap() > 0); } #[test] fn test_tool_project_shortest_path() { - let store = test_graph_store(); + let mut store = test_graph_store(); let input = serde_json::json!({ "algorithm": "shortest_path", "predicate": "http://example.org/knows", "from": "http://example.org/alice", "to": "http://example.org/dave" }); - let result = tool_project(&store, &input).unwrap(); + let result = tool_project(&mut store, &input).unwrap(); assert!(result["path"].is_array()); assert!(result["length"].as_u64().unwrap() >= 2); } + + // ── Louvain community detection (hq-zlph) ──────────────────────────────── + + /// Two 3-cliques joined by a single bridge edge — an unambiguous two-community + /// structure. Edges use ex:link so a predicate-filtered projection is clean. + fn two_cluster_store() -> Store { + let mut store = Store::open_in_memory().unwrap(); + let turtle = r#" +@prefix ex: . +ex:a1 ex:link ex:a2 . ex:a1 ex:link ex:a3 . ex:a2 ex:link ex:a3 . +ex:b1 ex:link ex:b2 . ex:b1 ex:link ex:b3 . ex:b2 ex:link ex:b3 . +ex:a1 ex:link ex:b1 . +"#; + ingest_rdf( + &mut store, + turtle.as_bytes(), + oxrdfio::RdfFormat::Turtle, + None, + "2026-04-04T00:00:00Z", + None, + None, + ) + .unwrap(); + store + } + + /// AC 1/3: louvain separates the two cliques and is deterministic (same graph + /// → identical partition, independent of hash iteration order). + #[test] + fn louvain_finds_two_clusters_deterministically() { + let store = two_cluster_store(); + let pg = project(&store, None, Some("http://example.org/link")).unwrap(); + + let c1 = louvain(&pg); + let c2 = louvain(&pg); + assert_eq!( + c1.groups, c2.groups, + "same graph must yield identical partition" + ); + assert_eq!(c1.groups.len(), 2, "exactly two communities"); + for g in &c1.groups { + assert_eq!(g.len(), 3, "each clique is one community of 3"); + } + assert!(c1.modularity > 0.0, "clear structure → positive modularity"); + + let id = |iri: &str| store.lookup(iri).unwrap().unwrap(); + let group_of = |e: i64| c1.groups.iter().position(|g| g.contains(&e)).unwrap(); + assert_eq!( + group_of(id("http://example.org/a1")), + group_of(id("http://example.org/a2")), + "a-clique stays together" + ); + assert_ne!( + group_of(id("http://example.org/a1")), + group_of(id("http://example.org/b1")), + "the two cliques are distinct communities" + ); + } + + /// AC 2: persist:true writes quipu:memberOfCommunity facts, queryable via SPARQL. + #[test] + fn louvain_persist_writes_queryable_membership() { + let mut store = two_cluster_store(); + let input = serde_json::json!({ + "algorithm": "louvain", + "predicate": "http://example.org/link", + "persist": true + }); + let result = tool_project(&mut store, &input).unwrap(); + assert_eq!(result["algorithm"], "louvain"); + assert_eq!( + result["persisted"].as_u64().unwrap(), + 6, + "6 entities got a community" + ); + + // SPARQL: a1 has exactly one community membership. + let pred = format!("{}memberOfCommunity", namespace::QUIPU); + let q = format!("SELECT ?c WHERE {{ <{pred}> ?c }}"); + let rows = crate::sparql::query(&store, &q).unwrap(); + assert_eq!( + rows.rows().len(), + 1, + "a1's community is queryable via SPARQL" + ); + + // Read-only default: no persist flag writes nothing new. + let mut store2 = two_cluster_store(); + let ro = tool_project( + &mut store2, + &serde_json::json!({"algorithm":"louvain","predicate":"http://example.org/link"}), + ) + .unwrap(); + assert!(ro["persisted"].is_null(), "default is read-only"); + } + + /// AC 5: a re-run bitemporally SUPERSEDES prior membership — no stale + /// accumulation. Driven through persist_communities directly with changed + /// partitions so the supersede is unambiguous. + #[test] + fn persist_supersedes_prior_membership() { + let mut store = Store::open_in_memory().unwrap(); + let e1 = store.intern("http://example.org/e1").unwrap(); + let e2 = store.intern("http://example.org/e2").unwrap(); + + // Run 1: two singleton communities. + persist_communities(&mut store, &[vec![e1], vec![e2]], "2026-01-01T00:00:00Z").unwrap(); + // Run 2: both entities merge into one community. + persist_communities(&mut store, &[vec![e1, e2]], "2026-01-02T00:00:00Z").unwrap(); + + let pred_id = store + .lookup(&format!("{}memberOfCommunity", namespace::QUIPU)) + .unwrap() + .unwrap(); + let active: Vec<_> = store + .current_facts() + .unwrap() + .into_iter() + .filter(|f| f.attribute == pred_id) + .collect(); + assert_eq!( + active.len(), + 2, + "exactly one active membership per entity (no accumulation)" + ); + + // Both now point at the latest derivation's community_0. + let comm0 = store + .lookup(&format!("{}community_0", namespace::QUIPU)) + .unwrap() + .unwrap(); + for f in &active { + assert_eq!( + f.value, + Value::Ref(comm0), + "memberships reflect the latest run" + ); + } + } } diff --git a/src/lib.rs b/src/lib.rs index 316ec89..ee27e16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,7 +52,10 @@ pub use episode::{ ingest_episode_with_resolution, }; pub use error::{Error, Result}; -pub use graph::{PageRankConfig, ProjectedGraph, page_rank, tool_project}; +pub use graph::{ + Communities, PageRankConfig, ProjectedGraph, louvain, page_rank, persist_communities, + tool_project, +}; pub use impact::{DEFAULT_HOPS, ImpactNode, ImpactOptions, ImpactReport, impact, speculate_remove}; pub use mcp::graphiti::tool_episodes_complete; pub use mcp::impact::tool_impact; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 9ca5f08..cbab1f4 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -493,18 +493,21 @@ pub fn tool_definitions() -> Vec { }), serde_json::json!({ "name": "quipu_project", - "description": "Project the knowledge graph and run a graph algorithm over it: stats (node/edge counts), in_degree (most-referenced entities), or pagerank/ppr (global or personalized PageRank from seed entities). Optionally restrict the projection to a node type or predicate.", + "description": "Project the knowledge graph and run a graph algorithm over it: stats (node/edge counts), in_degree (most-referenced entities), pagerank/ppr (global or personalized PageRank from seed entities), components (weakly-connected components), louvain (modularity community detection), or shortest_path. Optionally restrict the projection to a node type or predicate. Read-only by default; louvain with persist:true writes quipu:memberOfCommunity facts (superseding any prior derivation).", "inputSchema": { "type": "object", "properties": { - "algorithm": { "type": "string", "enum": ["stats", "in_degree", "pagerank", "ppr"], "description": "Algorithm to run (default: stats)" }, + "algorithm": { "type": "string", "enum": ["stats", "in_degree", "pagerank", "ppr", "components", "louvain", "shortest_path"], "description": "Algorithm to run (default: stats)" }, "type": { "type": "string", "description": "Restrict the projection to nodes of this rdf:type IRI" }, "predicate": { "type": "string", "description": "Restrict the projection to edges with this predicate IRI" }, "limit": { "type": "integer", "description": "Max results for in_degree/pagerank (default: 20)" }, "seeds": { "type": "array", "items": { "type": "string" }, "description": "Seed entity IRIs (or raw term IDs) for personalized PageRank; non-empty switches pagerank to PPR" }, "damping": { "type": "number", "description": "PageRank damping factor (default: 0.85)" }, "max_iters": { "type": "integer", "description": "PageRank max iterations (default: 100)" }, - "tolerance": { "type": "number", "description": "PageRank convergence tolerance (default: 1e-6)" } + "tolerance": { "type": "number", "description": "PageRank convergence tolerance (default: 1e-6)" }, + "from": { "type": "string", "description": "Source entity IRI for shortest_path" }, + "to": { "type": "string", "description": "Target entity IRI for shortest_path" }, + "persist": { "type": "boolean", "description": "louvain only: persist quipu:memberOfCommunity facts (emergent clustering, NOT an access boundary), bitemporally superseding any prior derivation (default: false)" } } } }), diff --git a/src/server.rs b/src/server.rs index f72162d..6761930 100644 --- a/src/server.rs +++ b/src/server.rs @@ -320,7 +320,9 @@ ro_handler!( quipu::mcp::graphiti::tool_search_nodes ); ro_handler!(shapes, quipu::tool_shapes); -ro_handler!(project_graph, quipu::tool_project); +// `tool_project` is read-only by default but the `louvain` algorithm can write +// `quipu:memberOfCommunity` facts when `persist:true`, so it needs a mutable store. +rw_handler!(project_graph, quipu::tool_project); ro_handler!(context, quipu::tool_context); ro_handler!(propose_schema_change, quipu::tool_propose_schema_change);