From 114fad75ded9ec8ec588c496228487e3734c8e75 Mon Sep 17 00:00:00 2001 From: bobbin/crew/strider Date: Mon, 29 Jun 2026 21:19:57 -0400 Subject: [PATCH] feat(report): live graph report endpoint + quipu_report MCP tool (hq-ct27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit graphify's GRAPH_REPORT.md equivalent, but live/queryable. New read-only `tool_report` (src/report.rs) synthesizes three views in one immutable pass: - hubs ("god-nodes"): top entities by PageRank, in-degree as secondary signal - surprising_connections: low-prior cross-community edges — bridges between otherwise-separate Louvain communities, rarer bridges (bridge_rarity) first, ties broken toward higher-PageRank endpoints - suggested_questions: deterministic templates seeded by hubs + bridges Wiring: GET|POST /report (read-only, GET = defaults), quipu_report MCP tool (24 tools default / 25 with owl), `quipu report` CLI subcommand. Reuses the existing graph primitives (project/page_rank/in_degree/louvain) — no new deps. Communities are emergent clustering for surfacing, not an access boundary. Tests: three-section shape, hub PageRank ordering + in_degree reporting, bridge-is-the-surprise, determinism across runs, limit handling. fmt + clippy (default/--no-default-features/--features shacl) + tests all green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H7b5eTqAZrisQN6NJ4Kn2z --- docs/book/src/reference/mcp-tools.md | 32 ++- docs/book/src/reference/rest-api.md | 15 ++ src/cli.rs | 41 ++++ src/lib.rs | 2 + src/main.rs | 2 + src/mcp/mod.rs | 14 ++ src/mcp/tests.rs | 6 +- src/report.rs | 292 +++++++++++++++++++++++++++ src/server.rs | 11 + 9 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 src/report.rs diff --git a/docs/book/src/reference/mcp-tools.md b/docs/book/src/reference/mcp-tools.md index b109099..e6d6183 100644 --- a/docs/book/src/reference/mcp-tools.md +++ b/docs/book/src/reference/mcp-tools.md @@ -4,8 +4,8 @@ Quipu exposes its API as MCP (Model Context Protocol) tools for agent integration. These tools are available when Quipu runs as a Bobbin subsystem or standalone MCP server. -The registry (`tool_definitions()`) exposes **23 tools** in a default build, or -**24** when built with the `owl` feature (which adds `quipu_load_ontology`). +The registry (`tool_definitions()`) exposes **24 tools** in a default build, or +**25** when built with the `owl` feature (which adds `quipu_load_ontology`). ## Tool Reference @@ -158,6 +158,34 @@ Unified knowledge context pipeline. | `max_entities` | No | Max entities (default from pipeline config) | | `expand_links` | No | Follow relationships to linked entities | +### `quipu_report` + +Live graph report — graphify's `GRAPH_REPORT.md` equivalent, but queryable. +Read-only. + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `type` | No | Restrict the projection to this rdf:type IRI | +| `predicate` | No | Restrict the projection to edges with this predicate IRI | +| `hubs` | No | Number of top hubs to return (default: 10) | +| `surprises` | No | Number of surprising connections to return (default: 10) | +| `questions` | No | Number of suggested questions to return (default: 8) | + +Returns three sections: + +- `hubs` — "god-nodes": the most central entities by PageRank, each with its + `in_degree` as a secondary signal. +- `surprising_connections` — low-prior edges that bridge two otherwise-separate + Louvain communities. Rarer bridges (fewer edges crossing between the same two + communities — `bridge_rarity`) rank first; ties break toward bridges touching + higher-PageRank endpoints. +- `suggested_questions` — deterministic, template-generated prompts seeded by the + hubs and bridges above. + +Plus a `graph` summary (`nodes`, `edges`, `communities`, `modularity`). +Communities here are emergent clustering for surfacing, **not** an access +boundary. + ### `quipu_search_nodes` Search for entities by natural-language query (text matching on names, labels, diff --git a/docs/book/src/reference/rest-api.md b/docs/book/src/reference/rest-api.md index 5774eb2..ed13a69 100644 --- a/docs/book/src/reference/rest-api.md +++ b/docs/book/src/reference/rest-api.md @@ -174,6 +174,21 @@ curl -s localhost:3030/project -X POST \ -d '{"algorithm": "in_degree", "limit": 10}' ``` +### `GET|POST /report` + +Live graph report: top hubs (god-nodes), surprising cross-community connections, +and auto-suggested questions (see `quipu_report` in the +[MCP tools reference](./mcp-tools.md)). Read-only. `GET` returns the report with +defaults; `POST` accepts an options body (`type`, `predicate`, `hubs`, +`surprises`, `questions`). + +```bash +curl -s localhost:3030/report +curl -s localhost:3030/report -X POST \ + -H "Content-Type: application/json" \ + -d '{"hubs": 5, "surprises": 5, "questions": 6}' +``` + ### `POST /context` Knowledge context pipeline. diff --git a/src/cli.rs b/src/cli.rs index 61ece6e..167d482 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -312,6 +312,47 @@ pub fn cmd_project(args: &[String], db_path: &str) { } } +/// `quipu report` — god-nodes + surprising connections + suggested questions +/// for the current graph (hq-ct27). +pub fn cmd_report(args: &[String], db_path: &str) { + let get_val = |flag: &str| -> Option { + args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone()) + }; + + let mut input = serde_json::json!({}); + if let Some(t) = get_val("--type") { + input["type"] = serde_json::Value::String(t); + } + if let Some(p) = get_val("--predicate") { + input["predicate"] = serde_json::Value::String(p); + } + if let Some(h) = get_val("--hubs").and_then(|v| v.parse::().ok()) { + input["hubs"] = serde_json::json!(h); + } + if let Some(s) = get_val("--surprises").and_then(|v| v.parse::().ok()) { + input["surprises"] = serde_json::json!(s); + } + if let Some(q) = get_val("--questions").and_then(|v| v.parse::().ok()) { + input["questions"] = serde_json::json!(q); + } + + let store = match quipu::Store::open(db_path) { + Ok(s) => s, + Err(e) => { + eprintln!("error opening store: {e}"); + std::process::exit(1); + } + }; + + match quipu::tool_report(&store, &input) { + Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } +} + pub fn cmd_impact(args: &[String], db_path: &str) { let entity_iri = match args.get(2) { Some(iri) if !iri.starts_with("--") => iri.as_str(), diff --git a/src/lib.rs b/src/lib.rs index ee27e16..37de7ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ pub mod provider; pub mod rdf; pub mod reasoner; pub mod reconcile; +pub mod report; pub mod resolution; pub mod schema; pub mod semweb; @@ -87,6 +88,7 @@ pub use reconcile::{ GoResolver, ImportResolver, PythonResolver, ReconcileReport, RustResolver, default_resolvers, reconcile, }; +pub use report::tool_report; pub use resolution::{EntityCandidate, ResolutionResult, resolve_entity}; #[cfg(feature = "shacl")] pub use shacl::{ValidationFeedback, Validator, validate_shapes}; diff --git a/src/main.rs b/src/main.rs index 96fd914..a2ec904 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ fn main() { "unravel" => cli::cmd_unravel(&args, db_path), "impact" => cli::cmd_impact(&args, db_path), "project" => cli::cmd_project(&args, db_path), + "report" => cli::cmd_report(&args, db_path), "reason" => cli::cmd_reason(&args, db_path), "episode" => cli_commands::cmd_episode(&args, db_path), "retract" => cli_commands::cmd_retract(&args, db_path), @@ -191,6 +192,7 @@ COMMANDS: quipu unravel [--tx N] [--valid-at ] [--db ] quipu impact [--remove] [--hops N] [--predicate ]... [--db ] quipu project [--algorithm pagerank] [--seed ]... [--damping 0.85] [--predicate ] [--db ] + quipu report [--hubs N] [--surprises N] [--questions N] [--type ] [--predicate ] [--db ] quipu reason [--rules ] [--db ] quipu episode [--db ] quipu retract [--predicate ] [--db ] diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index cbab1f4..081cf6e 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -524,6 +524,20 @@ pub fn tool_definitions() -> Vec { "required": ["query"] } }), + serde_json::json!({ + "name": "quipu_report", + "description": "Generate a live graph report (graphify's GRAPH_REPORT.md equivalent, but queryable): top hubs / 'god-nodes' (by PageRank with in-degree as a secondary signal), surprising connections (low-prior edges that bridge two otherwise-separate Louvain communities — rarer bridges rank first), and auto-suggested questions seeded by those hubs and bridges. Read-only; derived from current graph structure. Communities here are emergent clustering for surfacing, NOT an access boundary.", + "inputSchema": { + "type": "object", + "properties": { + "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" }, + "hubs": { "type": "integer", "description": "Number of top hubs to return (default: 10)" }, + "surprises": { "type": "integer", "description": "Number of surprising connections to return (default: 10)" }, + "questions": { "type": "integer", "description": "Number of suggested questions to return (default: 8)" } + } + } + }), ]; // OWL reasoning is gated behind the (non-default) `owl` feature; only diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 7c73209..bf32070 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -383,17 +383,19 @@ fn test_tool_definitions() { assert!(names.contains(&"quipu_context")); // Named-query catalog tool (hq-h75). assert!(names.contains(&"quipu_ask")); + // Graph report tool (hq-ct27). + assert!(names.contains(&"quipu_report")); // quipu_load_ontology is only advertised when the `owl` feature compiles in // its handler (hq-8wd) — otherwise the call would always fail. #[cfg(feature = "owl")] { - assert_eq!(defs.len(), 24); + assert_eq!(defs.len(), 25); assert!(names.contains(&"quipu_load_ontology")); } #[cfg(not(feature = "owl"))] { - assert_eq!(defs.len(), 23); + assert_eq!(defs.len(), 24); assert!(!names.contains(&"quipu_load_ontology")); } } diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..fd95abe --- /dev/null +++ b/src/report.rs @@ -0,0 +1,292 @@ +//! Graph report — a live, queryable equivalent of graphify's static +//! `GRAPH_REPORT.md`. Synthesizes three views of the current knowledge graph in +//! one read-only pass (hq-ct27, backend for the graph-report skill / aegis-1p0 +//! Gap 4): +//! +//! 1. **Hubs** ("god-nodes") — the most structurally central entities, by +//! `PageRank` with in-degree as a secondary signal. +//! 2. **Surprising connections** — low-prior cross-community edges: relationships +//! that bridge two otherwise-separate Louvain clusters. The rarer the bridge +//! (the fewer edges that cross between the same two communities), the more +//! surprising it is. +//! 3. **Suggested questions** — deterministic, template-generated prompts seeded +//! by the hubs and bridges above, to give an agent or human a starting point. +//! +//! Everything is derived from graph structure in a single immutable read; nothing +//! is persisted. Community membership here is *emergent clustering for surfacing*, +//! **not** an access boundary (see `graph` module note and hq-2u3 / hq-zlph). + +use std::collections::HashMap; + +use petgraph::visit::EdgeRef; +use serde_json::Value as JsonValue; + +use crate::error::Result; +use crate::graph::{PageRankConfig, in_degree, louvain, page_rank, project}; +use crate::namespace; +use crate::store::Store; + +/// Shorten an IRI to a human-readable local name (the part after the last `#`, +/// `/`, or `:`). Falls back to the whole string when no separator is present. +fn short(iri: &str) -> &str { + iri.rsplit(['#', '/', ':']).next().unwrap_or(iri) +} + +/// MCP tool: `quipu_report` — god-nodes, surprising connections, and suggested +/// questions for the current graph (read-only). +/// +/// Input (all optional): +/// - `type`: restrict the projection to this rdf:type IRI +/// - `predicate`: restrict the projection to edges with this predicate IRI +/// - `hubs`: number of top hubs to return (default 10) +/// - `surprises`: number of surprising connections to return (default 10) +/// - `questions`: number of suggested questions to return (default 8) +pub fn tool_report(store: &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 n_hubs = input + .get("hubs") + .and_then(serde_json::Value::as_u64) + .unwrap_or(10) as usize; + let n_surprises = input + .get("surprises") + .and_then(serde_json::Value::as_u64) + .unwrap_or(10) as usize; + let n_questions = input + .get("questions") + .and_then(serde_json::Value::as_u64) + .unwrap_or(8) as usize; + + let pg = project(store, type_filter, pred_filter)?; + + // ── Hubs ("god-nodes"): PageRank, in-degree as secondary signal ────────── + let degrees: HashMap = in_degree(&pg).into_iter().collect(); + let mut ranked = page_rank(&pg, &PageRankConfig::default())?; + // page_rank sorts by score desc but leaves equal scores in projection order; + // break ties by ascending entity id so the report is deterministic. + ranked.sort_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.0.cmp(&b.0)) + }); + let hubs: Vec = ranked + .iter() + .take(n_hubs) + .map(|&(entity_id, score)| { + let iri = store + .resolve(entity_id) + .unwrap_or_else(|_| format!("ref:{entity_id}")); + serde_json::json!({ + "entity": iri, + "pagerank": score, + "in_degree": degrees.get(&entity_id).copied().unwrap_or(0), + }) + }) + .collect(); + // Quick lookup of an entity's PageRank for bridge tie-breaking below. + let pr: HashMap = ranked.iter().copied().collect(); + + // ── Surprising connections: low-prior cross-community edges ────────────── + let communities = louvain(&pg); + // entity id -> community index. + let mut community_of: HashMap = HashMap::new(); + for (k, group) in communities.groups.iter().enumerate() { + for &entity in group { + community_of.insert(entity, k); + } + } + + // Collect cross-community edges in a deterministic order, and count how many + // edges cross between each unordered community pair (the bridge's rarity). + let mut cross: Vec<(i64, i64, i64, usize, usize)> = Vec::new(); // (src, tgt, pred, ca, cb) + let mut pair_count: HashMap<(usize, usize), usize> = HashMap::new(); + for edge in pg.graph.edge_references() { + let src = pg.node_to_entity[&edge.source()]; + let tgt = pg.node_to_entity[&edge.target()]; + let (Some(&ca), Some(&cb)) = (community_of.get(&src), community_of.get(&tgt)) else { + continue; + }; + if ca == cb { + continue; + } + let pair = if ca <= cb { (ca, cb) } else { (cb, ca) }; + *pair_count.entry(pair).or_insert(0) += 1; + cross.push((src, tgt, *edge.weight(), ca, cb)); + } + + // Surprise ranking: rarer bridges first (low pair_count = low-prior), then + // bridges touching more important nodes (higher combined PageRank), then a + // stable id-based tiebreak. + cross.sort_by(|a, b| { + let pa = pair_count[&if a.3 <= a.4 { (a.3, a.4) } else { (a.4, a.3) }]; + let pb = pair_count[&if b.3 <= b.4 { (b.3, b.4) } else { (b.4, b.3) }]; + let impa = pr.get(&a.0).copied().unwrap_or(0.0) + pr.get(&a.1).copied().unwrap_or(0.0); + let impb = pr.get(&b.0).copied().unwrap_or(0.0) + pr.get(&b.1).copied().unwrap_or(0.0); + pa.cmp(&pb) + .then(impb.partial_cmp(&impa).unwrap_or(std::cmp::Ordering::Equal)) + .then(a.0.cmp(&b.0)) + .then(a.1.cmp(&b.1)) + }); + + let surprising: Vec = cross + .iter() + .take(n_surprises) + .map(|&(src, tgt, pred, ca, cb)| { + let pair = if ca <= cb { (ca, cb) } else { (cb, ca) }; + serde_json::json!({ + "from": store.resolve(src).unwrap_or_else(|_| format!("ref:{src}")), + "to": store.resolve(tgt).unwrap_or_else(|_| format!("ref:{tgt}")), + "predicate": store.resolve(pred).unwrap_or_else(|_| format!("ref:{pred}")), + "community_from": format!("{}community_{ca}", namespace::QUIPU), + "community_to": format!("{}community_{cb}", namespace::QUIPU), + "bridge_rarity": pair_count[&pair], + }) + }) + .collect(); + + // ── Suggested questions: deterministic templates over hubs + bridges ───── + let mut questions: Vec = Vec::new(); + for h in &hubs { + if let Some(iri) = h["entity"].as_str() { + questions.push(format!("What is {}, and why is it so central?", short(iri))); + } + } + for s in &surprising { + if let (Some(a), Some(b)) = (s["from"].as_str(), s["to"].as_str()) { + questions.push(format!( + "Why is {} connected to {} across otherwise-separate clusters?", + short(a), + short(b) + )); + } + } + if let Some(largest) = communities.groups.iter().max_by_key(|g| g.len()) + && let Some(&example) = largest.first() + { + let name = store + .resolve(example) + .unwrap_or_else(|_| format!("ref:{example}")); + questions.push(format!( + "What ties together the largest cluster ({} entities, e.g. {})?", + largest.len(), + short(&name) + )); + } + questions.truncate(n_questions); + + Ok(serde_json::json!({ + "graph": { + "nodes": pg.node_count(), + "edges": pg.edge_count(), + "communities": communities.groups.len(), + "modularity": communities.modularity, + }, + "hubs": hubs, + "surprising_connections": surprising, + "suggested_questions": questions, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rdf::ingest_rdf; + use crate::store::Store; + + /// Build a two-cluster graph with a single rare bridge between them, plus a + /// clear hub (`hubA`, referenced by a1/a2/a3) inside cluster A. + fn seeded_store() -> Store { + let mut store = Store::open_in_memory().unwrap(); + let turtle = r#" +@prefix ex: . +ex:a1 ex:rel ex:hubA , ex:a2 . +ex:a2 ex:rel ex:hubA , ex:a3 . +ex:a3 ex:rel ex:hubA . +ex:b1 ex:rel ex:b2 . +ex:b2 ex:rel ex:b3 . +ex:b3 ex:rel ex:b1 . +ex:a1 ex:bridge ex:b1 . +"#; + ingest_rdf( + &mut store, + turtle.as_bytes(), + oxrdfio::RdfFormat::Turtle, + None, + "2026-04-04T00:00:00Z", + None, + None, + ) + .unwrap(); + store + } + + #[test] + fn report_has_three_sections() { + let store = seeded_store(); + let out = tool_report(&store, &serde_json::json!({})).unwrap(); + assert!(out["hubs"].is_array()); + assert!(out["surprising_connections"].is_array()); + assert!(out["suggested_questions"].is_array()); + assert!(out["graph"]["nodes"].as_u64().unwrap() > 0); + } + + #[test] + fn hubs_ranked_by_pagerank_with_indegree_reported() { + let store = seeded_store(); + let out = tool_report(&store, &serde_json::json!({ "hubs": 10 })).unwrap(); + let hubs = out["hubs"].as_array().unwrap(); + assert!(!hubs.is_empty()); + // Hubs are ordered by PageRank descending (the primary "god-node" signal). + let scores: Vec = hubs + .iter() + .map(|h| h["pagerank"].as_f64().unwrap()) + .collect(); + assert!( + scores.windows(2).all(|w| w[0] >= w[1]), + "hubs must be sorted by pagerank desc, got {scores:?}" + ); + // in_degree is reported as a secondary signal: hubA is referenced by + // a1/a2/a3, the highest in-degree in the graph. + let huba = hubs + .iter() + .find(|h| h["entity"].as_str().unwrap().ends_with("hubA")) + .expect("hubA should be present in the hub list"); + assert_eq!(huba["in_degree"].as_u64().unwrap(), 3); + } + + #[test] + fn surprising_connection_is_the_bridge() { + let store = seeded_store(); + let out = tool_report(&store, &serde_json::json!({})).unwrap(); + let surprises = out["surprising_connections"].as_array().unwrap(); + assert!( + !surprises.is_empty(), + "expected at least one cross-community edge" + ); + let top = &surprises[0]; + assert!(top["predicate"].as_str().unwrap().ends_with("bridge")); + assert_eq!(top["bridge_rarity"].as_u64().unwrap(), 1); + assert_ne!(top["community_from"], top["community_to"]); + } + + #[test] + fn deterministic_across_runs() { + let store = seeded_store(); + let a = tool_report(&store, &serde_json::json!({})).unwrap(); + let b = tool_report(&store, &serde_json::json!({})).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn respects_limits() { + let store = seeded_store(); + let out = tool_report( + &store, + &serde_json::json!({ "hubs": 2, "surprises": 1, "questions": 3 }), + ) + .unwrap(); + assert!(out["hubs"].as_array().unwrap().len() <= 2); + assert!(out["surprising_connections"].as_array().unwrap().len() <= 1); + assert!(out["suggested_questions"].as_array().unwrap().len() <= 3); + } +} diff --git a/src/server.rs b/src/server.rs index 6761930..3b08952 100644 --- a/src/server.rs +++ b/src/server.rs @@ -168,6 +168,7 @@ async fn main() { .route("/proposal/accept", post(accept_proposal)) .route("/proposal/reject", post(reject_proposal)) .route("/project", post(project_graph)) + .route("/report", get(report_get).post(report)) .route("/context", post(context)) .route("/embed_backfill", post(embed_backfill)) // Entity + history @@ -323,6 +324,16 @@ ro_handler!(shapes, quipu::tool_shapes); // `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); +// `/report` is read-only (god-nodes + surprising connections + suggested +// questions; hq-ct27). POST takes a JSON body of options; GET returns the +// report with defaults so a browser/skill can fetch it with no payload. +ro_handler!(report, quipu::tool_report); +async fn report_get(State(s): State) -> Result, AppError> { + Ok(axum::Json(quipu::tool_report( + &s.lock().unwrap(), + &serde_json::json!({}), + )?)) +} ro_handler!(context, quipu::tool_context); ro_handler!(propose_schema_change, quipu::tool_propose_schema_change);