From 70dc749696b9e2fce74674a1d108298ec754bf22 Mon Sep 17 00:00:00 2001 From: forhappy Date: Tue, 15 Sep 2026 00:07:55 -0700 Subject: [PATCH 1/2] Improve query and path result fidelity --- CHANGELOG.md | 11 + COMPATIBILITY.md | 16 +- MIGRATION.md | 18 + README.md | 6 +- crates/compass-cli/src/code_query_commands.rs | 65 +++- crates/compass-cli/src/help.rs | 4 +- crates/compass-cli/src/lib.rs | 75 +++- crates/compass-cli/tests/code_query_cli.rs | 186 +++++++++- crates/compass-query/src/code_query.rs | 128 +++++-- crates/compass-query/src/discovery.rs | 122 ++++++- crates/compass-query/src/discovery_text.rs | 272 ++++++++++++--- crates/compass-query/src/lib.rs | 71 +++- crates/compass-query/src/text.rs | 180 ++++++++++ crates/compass-query/src/traversal.rs | 320 +++++++++++++----- crates/compass-query/tests/coverage_paths.rs | 5 +- crates/compass-query/tests/natural_intent.rs | 7 +- docs/guides/exploring-a-codebase.md | 13 + docs/implementation/query-engine.md | 23 +- docs/reference/commands.md | 27 +- docs/reference/configuration.md | 7 +- 20 files changed, 1337 insertions(+), 219 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6407ac48a..77f1e03df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Make query failures and paths more trustworthy: exact-looking missing symbols + now return structured `no_match` signals across discovery and typed natural + queries; `compass path` requires exact endpoints, reports unreachable targets, + and uses deterministic relation-weighted routing with a visible shorter weak + alternative. + +- Make plain query output concise by default, add `--evidence` for full + provenance, raise the default text-page budget to 8,000 tokens, and move text + cursors to `compass.query.discovery-text-page/2`. Generic relationship words + no longer dominate discovery seeding. + ## 0.3.25 - 2026-09-13 - Raise the immutable-history aggregate authoritative-evidence limit from diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index a92367f95..8e10b6a7f 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -358,8 +358,20 @@ now defaults to `compass.query.discovery/1`; `--dfs` and `--context` compose with discovery. Explicit `--traverse` or legacy-only `--budget`/`--page` preserve the established text traversal and reject discovery controls. CompassQL and explicit typed query commands remain unchanged. Discovery text -pagination uses the versioned `compass.query.discovery-text-page/1` cursor; -JSON rejects those presentation-only controls. +pagination now uses the versioned `compass.query.discovery-text-page/2` cursor. +Text is concise by default, `--evidence` restores full provenance detail, and +the selected tier is bound into the cursor. Version-1 cursors fail explicitly +rather than resuming into a different representation. The default text-page +budget is 8,000 approximate tokens. JSON rejects those presentation-only +controls and the discovery JSON schema remains `compass.query.discovery/1`. + +Exact-looking discovery or typed-query operands that have only fuzzy or lexical +candidates now carry a structured `no_match` diagnostic before any fallback +content or bounded natural-query execution. Dedicated `compass path` endpoints +require unique exact identities; +weighted path selection prefers structural evidence over reference/document +shortcuts and reports an eligible shorter-but-weaker alternative separately. +These are human-query semantic changes, not graph or JSON schema changes. `compass ask --at REV` uses the same immutable trusted `compass.graph/1` realization selection as revision discovery. The response remains the unchanged `compass.query/1` contract; an older realization without that trusted graph is diff --git a/MIGRATION.md b/MIGRATION.md index f73ea261f..1e2c7cf57 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -5,6 +5,24 @@ sidecars. Its output root now preserves the familiar flat artifact shape so file-based workflows can transition while Compass's snapshot and store layout remains visible and clearly owned. +## Query text and path resolution + +Plain `compass query` output is now concise by default and its page budget is +8,000 approximate tokens. Scripts or review workflows that need the previous +expanded provenance should pass `--evidence`. Existing +`compass.query.discovery-text-page/1` cursors cannot be resumed; start the query +again to receive a `/2` cursor, and keep the concise/evidence tier unchanged +while paging. Discovery JSON remains `compass.query.discovery/1`. + +Natural structural queries now mark every fuzzy execution or suggestion with +`NO EXACT MATCH`/the typed `no_match` diagnostic. Handle that signal and retry +with a suggested exact ID when exact identity is required. `compass path` no +longer promotes a fuzzy symbol candidate into an endpoint; it is +weighted toward structural relations, defaults to an eight-hop bound, and +reports `NO PATH FOUND` separately when both endpoints exist but are +unreachable. Consumers that parsed the prior human path prose should migrate to +these explicit signals; machine-query schema versions are unchanged. + ## Rebuild SQLite adjacency sidecars Store snapshots now declare edge-ID-ordered directional adjacency so bounded diff --git a/README.md b/README.md index 74c75168d..8093b2367 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Run `compass watch` in a second terminal. For a focused task, an installed assis ```bash compass query "where is authentication enforced?" -compass query "where is authentication enforced?" --text-budget 8000 +compass query "where is authentication enforced?" --evidence compass query "where is authentication enforced?" --cursor 'query_cursor_token' compass explain TokenVerifier compass path ApiHandler TokenVerifier @@ -201,9 +201,9 @@ compass affected TokenVerifier --depth 3 These commands read the saved graph locally. Results are bounded, read-only, and tied to graph evidence instead of a model-generated guess. -Plain-language `query` uses bounded structured discovery. Its text projection returns complete deterministic entries within the `--text-budget` value, which defaults to 2,000. Follow `next=query_cursor_token` with the unchanged question and options until `next=none`. +Plain-language `query` uses bounded structured discovery. Its default text is a concise map of seeds, nodes, edges, and source locations; pass `--evidence` for full provenance and semantic digests. Each page contains complete deterministic entries within the `--text-budget` value, which defaults to 8,000. Follow `next=query_cursor_token` with the unchanged question and evidence tier until `next=none`. -The cursor fails when semantic inputs, the selected graph, or the semantic result changes. The `--traverse`, `--budget`, and `--page` options retain the legacy traversal contract; CompassQL uses its own versioned contract. +The cursor fails when semantic inputs, evidence tier, the selected graph, or the semantic result changes. Exact-looking symbol operands that do not resolve are reported as `NO EXACT MATCH` before any fuzzy or lexical fallback candidates. The `--traverse`, `--budget`, and `--page` options retain the legacy traversal contract; CompassQL uses its own versioned contract. ### Open the workbench diff --git a/crates/compass-cli/src/code_query_commands.rs b/crates/compass-cli/src/code_query_commands.rs index bb5fb8132..90d1a4499 100644 --- a/crates/compass-cli/src/code_query_commands.rs +++ b/crates/compass-cli/src/code_query_commands.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::PathBuf; use compass_model::query_contract::{ @@ -223,13 +224,23 @@ fn required<'a>(values: &'a [String], index: usize, usage: &str) -> Result<&'a s } fn render_text(response: &CodeQueryResponse) -> String { - let mut lines = vec![format!( + let no_match = response.diagnostics.iter().find(|diagnostic| { + diagnostic.code == compass_model::query_contract::QueryDiagnosticCode::NoMatch + }); + let mut lines = Vec::new(); + if let Some(diagnostic) = no_match { + lines.push("match_confidence: none".to_owned()); + lines.push(diagnostic.message.clone()); + } else if !response.nodes.is_empty() { + lines.push("match_confidence: exact".to_owned()); + } + lines.push(format!( "{:?}: {} node(s), {} edge(s), {} path(s)", response.operation, response.nodes.len(), response.edges.len(), response.paths.len() - )]; + )); lines.extend(response.nodes.iter().map(|node| { format!( "{} [{}] {}", @@ -241,10 +252,60 @@ fn render_text(response: &CodeQueryResponse) -> String { .unwrap_or_default() ) })); + let node_labels = response + .nodes + .iter() + .map(|node| (node.id.as_str(), node.qualified_name.as_str())) + .collect::>(); + let edges = response + .edges + .iter() + .map(|edge| (edge.id.as_str(), edge)) + .collect::>(); + for (index, path) in response.paths.iter().enumerate() { + if let Some(target) = path.node_ids.last() { + lines.push(format!("Target resolved: {target}")); + } + let mut segments = path + .node_ids + .first() + .map(|node| { + node_labels + .get(node.as_str()) + .copied() + .unwrap_or(node.as_str()) + .to_owned() + }) + .into_iter() + .collect::>(); + for (edge_id, nodes) in path.edge_ids.iter().zip(path.node_ids.windows(2)) { + let Some(edge) = edges.get(edge_id.as_str()) else { + continue; + }; + let right = node_labels + .get(nodes[1].as_str()) + .copied() + .unwrap_or(nodes[1].as_str()); + if edge.source == nodes[0] && edge.target == nodes[1] { + segments.push(format!("--{}--> {right}", edge.kind.as_str())); + } else { + segments.push(format!("<--{}-- {right}", edge.kind.as_str())); + } + } + lines.push(format!( + "{} path (weighted, {} hops): {}", + if index == 0 { "Best" } else { "Alternative" }, + path.edge_ids.len(), + segments.join(" ") + )); + } lines.extend( response .diagnostics .iter() + .filter(|diagnostic| { + diagnostic.code != compass_model::query_contract::QueryDiagnosticCode::NoMatch + }) .map(|diagnostic| format!("! {:?}: {}", diagnostic.code, diagnostic.message)), ); lines.join("\n") diff --git a/crates/compass-cli/src/help.rs b/crates/compass-cli/src/help.rs index 4e9acf6db..5f9f6b2c5 100644 --- a/crates/compass-cli/src/help.rs +++ b/crates/compass-cli/src/help.rs @@ -365,7 +365,7 @@ const PAGES: &[Page] = &[ "compass query --text-budget ", "compass query --cursor " ], - "Arguments:\n Natural-language graph question\n Inline CompassQL query\n\nOptions:\nNatural discovery:\n --direction Direction: auto, incoming, outgoing, or both [default: auto]\n --scope Repeatable OR scope: community, source, package, or node\n --context Repeatable strict relationship-context filter\n --dfs Use depth-first expansion [default: breadth-first]\n --include-heuristic Include heuristic evidence [default: excluded]\n --format Discovery output [default: text]\n --text-budget Approximate tokens in one text page [default: 2000]\n --cursor Continue the same immutable semantic result (text only)\n --max-depth Traversal depth [default: 2; hard maximum: 8]\n --max-seeds Ranked seed count [default: 3; hard maximum: 3]\n --max-candidates Ranked candidate count [default/hard maximum: 256]\n --max-nodes Returned node count [default/hard maximum: 500]\n --max-edges Returned edge count [default/hard maximum: 1000]\n --max-expanded-relationships Examined relationships [default/hard maximum: 10000]\n --max-response-bytes Serialized response bytes [default/hard maximum: 8388608]\n --timeout-ms Discovery deadline in milliseconds [default/hard maximum: 30000]\n\nLegacy traversal:\n --traverse Force legacy relevance traversal\n --budget Approximate tokens per page [default: 2000]\n --page Result page, starting at 1 [default: 1]\n\nGraph selection:\n --graph Read a graph JSON file\n --at Query an exact immutable realization; conflicts with --graph\n\nCompassQL:\n --cql Use CompassQL mode\n --file Read CompassQL from a file\n --stdin Read CompassQL from standard input\n --repl Start the interactive CompassQL shell\n --param Bind a parameter; repeatable\n --params-file Read parameters from JSON\n --format CompassQL result format [default: table]\n --output Write CompassQL results to a file\n --timeout-ms CompassQL execution timeout\n --max-rows CompassQL row limit [default: 10000]\n --max-path-depth CompassQL path-depth limit [default: 32]\n --max-expanded-relationships CompassQL relationship expansion limit\n --max-memory-bytes CompassQL memory limit [default: 268435456]\n\nExamples:\n compass query \"who calls PaymentService.charge?\"\n compass query \"authentication flow\" --direction both --scope package:auth\n compass query \"what uses charge?\" --direction incoming --context call --format json\n compass query \"authentication flow\" --text-budget 8000\n compass query \"authentication flow\" --cursor ''\n compass query \"authentication flow\" --budget 8000 --page 2\n compass query --cql \"MATCH (n) RETURN n LIMIT 10\" --format json\n compass query --cql --file report.cql --params-file params.json\n\nTips:\n Direction, scope, context, and DFS compose through the bounded typed discovery contract. Scope is repeatable OR and never guesses a kind. Discovery limits must be positive; values above a hard maximum are rejected rather than clamped. Legacy --traverse, --budget, and --page cannot be mixed with discovery controls.\n Historical discovery requires an immutable realization retaining trusted compass.graph/1 data." + "Arguments:\n Natural-language graph question\n Inline CompassQL query\n\nOptions:\nNatural discovery:\n --direction Direction: auto, incoming, outgoing, or both [default: auto]\n --scope Repeatable OR scope: community, source, package, or node\n --context Repeatable strict relationship-context filter\n --dfs Use depth-first expansion [default: breadth-first]\n --include-heuristic Include heuristic evidence [default: excluded]\n --evidence Include full provenance and typed detail in text\n --format Discovery output [default: text]\n --text-budget Approximate tokens in one text page [default: 8000]\n --cursor Continue the same immutable semantic result (text only)\n --max-depth Traversal depth [default: 2; hard maximum: 8]\n --max-seeds Ranked seed count [default: 3; hard maximum: 3]\n --max-candidates Ranked candidate count [default/hard maximum: 256]\n --max-nodes Returned node count [default/hard maximum: 500]\n --max-edges Returned edge count [default/hard maximum: 1000]\n --max-expanded-relationships Examined relationships [default/hard maximum: 10000]\n --max-response-bytes Serialized response bytes [default/hard maximum: 8388608]\n --timeout-ms Discovery deadline in milliseconds [default/hard maximum: 30000]\n\nLegacy traversal:\n --traverse Force legacy relevance traversal\n --budget Approximate tokens per page [default: 2000]\n --page Result page, starting at 1 [default: 1]\n\nGraph selection:\n --graph Read a graph JSON file\n --at Query an exact immutable realization; conflicts with --graph\n\nCompassQL:\n --cql Use CompassQL mode\n --file Read CompassQL from a file\n --stdin Read CompassQL from standard input\n --repl Start the interactive CompassQL shell\n --param Bind a parameter; repeatable\n --params-file Read parameters from JSON\n --format CompassQL result format [default: table]\n --output Write CompassQL results to a file\n --timeout-ms CompassQL execution timeout\n --max-rows CompassQL row limit [default: 10000]\n --max-path-depth CompassQL path-depth limit [default: 32]\n --max-expanded-relationships CompassQL relationship expansion limit\n --max-memory-bytes CompassQL memory limit [default: 268435456]\n\nExamples:\n compass query \"who calls PaymentService.charge?\"\n compass query \"authentication flow\" --direction both --scope package:auth\n compass query \"what uses charge?\" --direction incoming --context call --format json\n compass query \"authentication flow\" --evidence\n compass query \"authentication flow\" --cursor ''\n compass query \"authentication flow\" --budget 8000 --page 2\n compass query --cql \"MATCH (n) RETURN n LIMIT 10\" --format json\n compass query --cql --file report.cql --params-file params.json\n\nTips:\n Direction, scope, context, and DFS compose through the bounded typed discovery contract. Scope is repeatable OR and never guesses a kind. Plain text is concise; pass --evidence for provenance. Discovery limits must be positive; values above a hard maximum are rejected rather than clamped. Legacy --traverse, --budget, and --page cannot be mixed with discovery controls.\n Historical discovery requires an immutable realization retaining trusted compass.graph/1 data." ), page!( "program", @@ -425,7 +425,7 @@ const PAGES: &[Page] = &[ "path", "Find the shortest relationship path between two graph nodes", ["compass path [OPTIONS]"], - "Arguments:\n Source node name or identifier\n Target node name or identifier\n\nOptions:\n --graph Read a graph JSON file\n --at Use an immutable Git revision; conflicts with --graph\n\nExamples:\n compass path CheckoutHandler PaymentGateway\n compass path api route --at v1.2.0" + "Arguments:\n Exact source node name, qualified name, or ID\n Exact target node name, qualified name, or ID\n\nOptions:\n --max-depth Maximum hops examined [default: 8]\n --graph Read a graph JSON file\n --at Use an immutable Git revision; conflicts with --graph\n\nExamples:\n compass path CheckoutHandler PaymentGateway\n compass path api route --max-depth 5 --at v1.2.0\n\nNotes:\n Resolution completes before traversal. The final path node is always the resolved target ID. Relations are weighted so structural chains beat weak shared-reference shortcuts; a close shorter-but-weaker alternative is reported separately." ), page!( "explain", diff --git a/crates/compass-cli/src/lib.rs b/crates/compass-cli/src/lib.rs index 87bceefb7..8d2902394 100644 --- a/crates/compass-cli/src/lib.rs +++ b/crates/compass-cli/src/lib.rs @@ -80,10 +80,11 @@ use compass_output::{ }; use compass_prs::{ProcessRunner, SystemRunner}; use compass_query::{ - DEFAULT_AFFECTED_RELATIONS, DEFAULT_TEXT_TOKEN_BUDGET, DiscoveryTextPageOptions, - TextPageOptions, TraversalMode, discovery_request_digest, format_affected, format_benchmark, - open as open_code_query, open_with_verified_document, query_graph_text_page, - render_discovery_text_page, render_explanation_page, render_shortest_path, run_benchmark, + DEFAULT_AFFECTED_RELATIONS, DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET, DEFAULT_PATH_DEPTH_LIMIT, + DEFAULT_TEXT_TOKEN_BUDGET, DiscoveryTextPageOptions, TextPageOptions, TraversalMode, + discovery_request_digest, format_affected, format_benchmark, open as open_code_query, + open_with_verified_document, query_graph_text_page, render_discovery_text_page, + render_explanation_page, render_shortest_path_with_limit, run_benchmark, }; use compass_semantic::{ CachedCorpusExtractionOptions, CorpusExtractionOptions, PreparedDocumentInputs, @@ -5217,8 +5218,9 @@ pub(crate) fn command_natural_query(frontend: Frontend, args: &[String]) -> Outc let mode = TraversalMode::Bfs; let mut legacy_requested = false; let mut discovery_requested = false; - let mut discovery_text_budget = DEFAULT_TEXT_TOKEN_BUDGET; + let mut discovery_text_budget = DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET; let mut discovery_cursor = None::; + let mut discovery_evidence = false; let mut discovery_text_pagination_requested = false; let mut discovery_direction = DiscoveryDirection::Auto; let mut discovery_scope = Vec::new(); @@ -5321,6 +5323,14 @@ pub(crate) fn command_natural_query(frontend: Frontend, args: &[String]) -> Outc discovery_requested = true; index += 1; } + "--evidence" => { + if !seen_discovery_options.insert("--evidence".to_owned()) { + return Outcome::failure("error: --evidence must not be repeated".to_owned()); + } + discovery_evidence = true; + discovery_requested = true; + index += 1; + } "--result-envelope" => { if !seen_discovery_options.insert("--result-envelope".to_owned()) { return Outcome::failure( @@ -5432,9 +5442,10 @@ pub(crate) fn command_natural_query(frontend: Frontend, args: &[String]) -> Outc ); } if !legacy_requested { - if discovery_format == "json" && discovery_text_pagination_requested { + if discovery_format == "json" && (discovery_text_pagination_requested || discovery_evidence) + { return Outcome::failure( - "error: --cursor and --text-budget are text-only and cannot be used with --format json" + "error: --cursor, --text-budget, and --evidence are text-only and cannot be used with --format json" .to_owned(), ); } @@ -5457,6 +5468,7 @@ pub(crate) fn command_natural_query(frontend: Frontend, args: &[String]) -> Outc discovery_text_budget, discovery_cursor.as_deref(), discovery_result_envelope, + discovery_evidence, ); if outcome.code == 0 { touch_selected_query_stamp(&selection); @@ -5591,6 +5603,7 @@ fn command_discovery_query( text_budget: usize, cursor: Option<&str>, result_envelope: bool, + include_evidence: bool, ) -> Outcome { let include_heuristic = request.include_heuristic; let execution = match discovery_query(selection, request) { @@ -5625,6 +5638,7 @@ fn command_discovery_query( request_digest: &request_digest, graph_identity: &execution.graph_identity, graph_digest: &execution.graph_digest, + include_evidence, }, ) { Ok(page) => Outcome::success(page.text), @@ -5710,14 +5724,51 @@ fn command_path(frontend: Frontend, args: &[String]) -> Outcome { Ok(parsed) => parsed, Err(error) => return Outcome::failure(format!("error: {error}")), }; - if args.len() != 2 { + let Some(source) = args.first() else { + return Outcome::failure(path_help(frontend)); + }; + let Some(target) = args.get(1) else { return Outcome::failure(path_help(frontend)); + }; + let mut max_depth = DEFAULT_PATH_DEPTH_LIMIT; + let mut index = 2; + while index < args.len() { + match args[index].as_str() { + "--max-depth" => { + let Some(value) = args.get(index + 1) else { + return Outcome::failure( + "error: --max-depth requires a positive integer".to_owned(), + ); + }; + max_depth = match value.parse::().ok().filter(|value| *value > 0) { + Some(value) => value, + None => { + return Outcome::failure( + "error: --max-depth requires a positive integer".to_owned(), + ); + } + }; + index += 2; + } + value if value.starts_with("--max-depth=") => { + max_depth = match value[12..].parse::().ok().filter(|value| *value > 0) { + Some(value) => value, + None => { + return Outcome::failure( + "error: --max-depth requires a positive integer".to_owned(), + ); + } + }; + index += 1; + } + value => return Outcome::failure(format!("error: unexpected path argument {value}")), + } } let loaded = match load_selection(frontend, &selection, true) { Ok(loaded) => loaded, Err(outcome) => return outcome, }; - match render_shortest_path(&loaded.graph, &args[0], &args[1]) { + match render_shortest_path_with_limit(&loaded.graph, source, target, max_depth) { Ok(output) => { touch_selected_query_stamp(&selection); Outcome::success(output) @@ -5990,7 +6041,7 @@ fn touch_selected_query_stamp(selection: &GraphSelection) { fn query_help(frontend: Frontend) -> String { let prefix = frontend_name(frontend); let help = format!( - "Usage: {prefix} query \"\" [--direction auto|incoming|outgoing|both] [--scope KIND:VALUE] [--context VALUE] [--dfs] [--format text|json] [--graph PATH|--at REV]\n\nNatural discovery options (default for a typed graph):\n --direction Direction: auto, incoming, outgoing, or both [default: auto]\n --scope Repeatable OR scope; KIND is community, source, package, or node\n --context Repeatable strict relationship-context filter\n --dfs Use depth-first expansion [default: breadth-first]\n --include-heuristic Include heuristic evidence [default: excluded]\n --format Discovery output [default: text]\n --text-budget Approximate tokens in one text page [default: 2000]\n --cursor Continue the same immutable semantic result (text only)\n --max-depth Traversal depth [default: 2; hard maximum: 8]\n --max-seeds Ranked seed count [default: 3; hard maximum: 3]\n --max-candidates Ranked candidate count [default/hard maximum: 256]\n --max-nodes Returned node count [default/hard maximum: 500]\n --max-edges Returned edge count [default/hard maximum: 1000]\n --max-expanded-relationships Examined relationships [default/hard maximum: 10000]\n --max-response-bytes Serialized response bytes [default/hard maximum: 8388608]\n --timeout-ms Discovery deadline in milliseconds [default/hard maximum: 30000]\n\nLegacy traversal options:\n --traverse Force legacy relevance traversal\n --budget Approximate tokens per page [default: 2000]\n --page Result page, starting at 1 [default: 1]\n\nGraph selection:\n --graph Read a graph JSON file\n --at Resolve REV once to an immutable typed realization; conflicts with --graph\n\nCompassQL options:\n --cql Use CompassQL mode\n --timeout-ms CompassQL execution timeout\n --max-expanded-relationships CompassQL relationship expansion limit\n Run `{prefix} help query` for all CompassQL controls and examples.\n\nDiscovery limits must be positive; values above a hard maximum are rejected rather than clamped. JSON rejects text pagination controls. Legacy --traverse, --budget, and --page cannot be mixed with discovery controls." + "Usage: {prefix} query \"\" [--direction auto|incoming|outgoing|both] [--scope KIND:VALUE] [--context VALUE] [--dfs] [--evidence] [--format text|json] [--graph PATH|--at REV]\n\nNatural discovery options (default for a typed graph):\n --direction Direction: auto, incoming, outgoing, or both [default: auto]\n --scope Repeatable OR scope; KIND is community, source, package, or node\n --context Repeatable strict relationship-context filter\n --dfs Use depth-first expansion [default: breadth-first]\n --include-heuristic Include heuristic evidence [default: excluded]\n --evidence Include full provenance and typed detail in text\n --format Discovery output [default: text]\n --text-budget Approximate tokens in one text page [default: 8000]\n --cursor Continue the same immutable semantic result (text only)\n --max-depth Traversal depth [default: 2; hard maximum: 8]\n --max-seeds Ranked seed count [default: 3; hard maximum: 3]\n --max-candidates Ranked candidate count [default/hard maximum: 256]\n --max-nodes Returned node count [default/hard maximum: 500]\n --max-edges Returned edge count [default/hard maximum: 1000]\n --max-expanded-relationships Examined relationships [default/hard maximum: 10000]\n --max-response-bytes Serialized response bytes [default/hard maximum: 8388608]\n --timeout-ms Discovery deadline in milliseconds [default/hard maximum: 30000]\n\nLegacy traversal options:\n --traverse Force legacy relevance traversal\n --budget Approximate tokens per page [default: 2000]\n --page Result page, starting at 1 [default: 1]\n\nGraph selection:\n --graph Read a graph JSON file\n --at Resolve REV once to an immutable typed realization; conflicts with --graph\n\nCompassQL options:\n --cql Use CompassQL mode\n --timeout-ms CompassQL execution timeout\n --max-expanded-relationships CompassQL relationship expansion limit\n Run `{prefix} help query` for all CompassQL controls and examples.\n\nDiscovery limits must be positive; values above a hard maximum are rejected rather than clamped. JSON rejects text-only pagination/evidence controls. Legacy --traverse, --budget, and --page cannot be mixed with discovery controls." ); let help = help .replace( @@ -6008,7 +6059,9 @@ fn query_help(frontend: Frontend) -> String { fn path_help(frontend: Frontend) -> String { let prefix = frontend_name(frontend); - format!("Usage: {prefix} path \"\" \"\" [--graph PATH|--at REV]") + format!( + "Usage: {prefix} path \"\" \"\" [--max-depth N] [--graph PATH|--at REV]" + ) } fn explain_help(frontend: Frontend) -> String { diff --git a/crates/compass-cli/tests/code_query_cli.rs b/crates/compass-cli/tests/code_query_cli.rs index 1f7681338..248f5f6e9 100644 --- a/crates/compass-cli/tests/code_query_cli.rs +++ b/crates/compass-cli/tests/code_query_cli.rs @@ -6,7 +6,7 @@ use std::ffi::OsString; use compass_cli::{Frontend, run}; use compass_files::BuildGuard; use compass_graph::GraphSnapshotBuilder; -use compass_model::code_graph::GraphDocument; +use compass_model::code_graph::{EdgeKind, GraphDocument}; use compass_store::{STORE_FILE_NAME, STORE_REF_FILE_NAME, SqliteStore}; use serde_json::Value; @@ -139,7 +139,11 @@ fn natural_query_defaults_to_discovery_and_preserves_explicit_legacy_traversal() ], ); assert_eq!(outcome.code, 0, "{question}: {}", outcome.stderr); - assert!(outcome.stdout.starts_with("Discovery:"), "{question}"); + assert!( + outcome.stdout.starts_with("match_confidence:"), + "{question}" + ); + assert!(outcome.stdout.contains("Discovery:"), "{question}"); assert!( outcome.stdout.contains(expected_node), "{question}: {}", @@ -160,7 +164,11 @@ fn natural_query_defaults_to_discovery_and_preserves_explicit_legacy_traversal() ], ); assert_eq!(generic.code, 0, "{}", generic.stderr); - assert!(generic.stdout.starts_with("Discovery:"), "{question}"); + assert!( + generic.stdout.starts_with("match_confidence:"), + "{question}" + ); + assert!(generic.stdout.contains("Discovery:"), "{question}"); assert!(generic.stdout.contains("Completeness:"), "{question}"); } @@ -452,6 +460,10 @@ fn natural_discovery_rejects_invalid_duplicate_and_mixed_public_controls() vec!["--format", "json", "--cursor", "not-a-cursor"], "text-only and cannot be used with --format json", ), + ( + vec!["--format", "json", "--evidence"], + "text-only and cannot be used with --format json", + ), ] { let mut args = vec![OsString::from("query"), OsString::from("Target")]; args.extend(arguments.iter().map(OsString::from)); @@ -483,6 +495,9 @@ fn natural_discovery_help_documents_only_the_public_contract() { "--format ", "--result-envelope", "--text-budget ", + "default: 8000", + "--evidence", + "full provenance", "--cursor ", "Natural discovery:", "--include-heuristic", @@ -712,8 +727,171 @@ fn natural_query_renders_typed_source_locations() -> Result<(), Box> assert!( outcome .stdout - .contains("Node: n:target [function] Fixture.Target @ src/lib.rs:1") + .contains("NODE Fixture.Target [function] src/lib.rs:1") + ); + Ok(()) +} + +#[test] +fn natural_query_is_concise_by_default_and_evidence_is_opt_in() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph = support::write_typed_graph(directory.path())?; + let base = [ + OsString::from("query"), + OsString::from("Target"), + OsString::from("--graph"), + graph.into_os_string(), + ]; + + let concise = run(Frontend::Compass, base.clone()); + assert_eq!(concise.code, 0, "{}", concise.stderr); + assert!(concise.stdout.starts_with("match_confidence: exact")); + assert!(concise.stdout.contains("NODE Fixture.Target [function]")); + assert!(concise.stdout.contains("provenance record(s) hidden")); + assert!(!concise.stdout.contains("Node evidence:")); + assert!(!concise.stdout.contains("Semantic result:")); + + let mut evidence_args = base.to_vec(); + evidence_args.push(OsString::from("--evidence")); + let evidence = run(Frontend::Compass, evidence_args); + assert_eq!(evidence.code, 0, "{}", evidence.stderr); + assert!(evidence.stdout.contains("Node evidence:")); + assert!(evidence.stdout.contains("Semantic result:")); + Ok(()) +} + +#[test] +fn natural_and_typed_queries_signal_missing_exact_matches_before_fallbacks() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph = support::write_typed_graph(directory.path())?; + for command in ["query", "ask"] { + let outcome = run( + Frontend::Compass, + [ + OsString::from(command), + OsString::from("who calls Targat?"), + OsString::from("--graph"), + graph.clone().into_os_string(), + ], + ); + assert_eq!(outcome.code, 0, "{command}: {}", outcome.stderr); + assert!( + outcome.stdout.starts_with("match_confidence: none"), + "{command}: {}", + outcome.stdout + ); + assert!( + outcome.stdout.contains("NO EXACT MATCH"), + "{command}: {}", + outcome.stdout + ); + } + Ok(()) +} + +#[test] +fn path_resolves_exact_targets_and_ranks_structural_evidence_end_to_end() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let graph_path = support::write_typed_graph(directory.path())?; + let mut graph = GraphDocument::load(&graph_path)?; + let template_node = graph.nodes[0].clone(); + for (id, name) in [ + ("n:strong-one", "StrongOne"), + ("n:strong-two", "StrongTwo"), + ("n:weak", "WeakShortcut"), + ("n:isolated", "Isolated"), + ] { + let mut node = template_node.clone(); + node.id = id.to_owned(); + node.name = name.to_owned(); + node.qualified_name = format!("Fixture.{name}"); + graph.nodes.push(node); + } + graph.nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let template_edge = graph.links[0].clone(); + graph.links.clear(); + for (id, source, target, kind) in [ + ("e:strong-1", "n:caller", "n:strong-one", EdgeKind::Calls), + ( + "e:strong-2", + "n:strong-one", + "n:strong-two", + EdgeKind::Contains, + ), + ( + "e:strong-3", + "n:strong-two", + "n:target", + EdgeKind::DependsOn, + ), + ("e:weak-1", "n:caller", "n:weak", EdgeKind::References), + ("e:weak-2", "n:weak", "n:target", EdgeKind::Documents), + ] { + let mut edge = template_edge.clone(); + edge.id = id.to_owned(); + edge.key = id.to_owned(); + edge.source = source.to_owned(); + edge.target = target.to_owned(); + edge.kind = kind; + graph.links.push(edge); + } + std::fs::write(&graph_path, serde_json::to_vec_pretty(&graph)?)?; + + let path = run( + Frontend::Compass, + [ + OsString::from("path"), + OsString::from("Caller"), + OsString::from("Target"), + OsString::from("--graph"), + graph_path.clone().into_os_string(), + ], + ); + assert_eq!(path.code, 0, "{}", path.stderr); + assert!( + path.stdout + .contains("Target resolved: Target [id=n:target]") + ); + assert!( + path.stdout + .contains("Best path (weighted, 3 hops, weight 3)") + ); + assert!(path.stdout.contains("shorter (2-hop) but weaker path")); + + let unreachable = run( + Frontend::Compass, + [ + OsString::from("path"), + OsString::from("Caller"), + OsString::from("Isolated"), + OsString::from("--max-depth"), + OsString::from("4"), + OsString::from("--graph"), + graph_path.clone().into_os_string(), + ], + ); + assert_eq!(unreachable.code, 0, "{}", unreachable.stderr); + assert!( + unreachable + .stdout + .contains("NO PATH FOUND to resolved target") + ); + assert!(unreachable.stdout.contains("depth limit 4")); + + let missing = run( + Frontend::Compass, + [ + OsString::from("path"), + OsString::from("Call"), + OsString::from("Target"), + OsString::from("--graph"), + graph_path.into_os_string(), + ], ); + assert_ne!(missing.code, 0); + assert!(missing.stderr.contains("NO EXACT MATCH")); Ok(()) } diff --git a/crates/compass-query/src/code_query.rs b/crates/compass-query/src/code_query.rs index ece651c29..0fdd9af4b 100644 --- a/crates/compass-query/src/code_query.rs +++ b/crates/compass-query/src/code_query.rs @@ -1,4 +1,5 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Instant; @@ -1525,6 +1526,12 @@ impl CodeQueryEngine { let max_nodes = usize::try_from(request.limits.max_nodes).unwrap_or(usize::MAX); let ranking_started = Instant::now(); let ranked = rank_search_candidates(&request.query, &terms, candidates, max_nodes); + let has_exact_match = ranked.iter().any(|result| { + matches!( + result.candidate_source, + CandidateSource::ExactId | CandidateSource::ExactName + ) + }); instrumentation.ranking += ranking_started.elapsed(); let execution_started = Instant::now(); @@ -1537,6 +1544,7 @@ impl CodeQueryEngine { path: None, }); } + let fallback_count = ranked.len(); for result in ranked { let score = result.score; let id = result.node_id; @@ -1549,10 +1557,18 @@ impl CodeQueryEngine { }); response.nodes.push(query_node(&node)); } - if response.results.is_empty() { + if !has_exact_match { + let related_terms = terms.join(", "); response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::NoMatch, - message: format!("No symbol matched {:?}", request.query), + message: if fallback_count == 0 { + format!("NO EXACT MATCH for {:?}", request.query) + } else { + format!( + "NO EXACT MATCH for {:?}. Showing {fallback_count} fuzzy/lexical fallback result(s) for related terms: {related_terms}", + request.query + ) + }, node_id: None, path: None, }); @@ -1828,7 +1844,9 @@ impl CodeQueryEngine { } } - let recall_terms = query_recall_terms(query) + let discovery_terms = crate::text::discovery_term_selection(query); + let recall_terms = discovery_terms + .recall_terms .into_iter() .filter(|term| { !matches!( @@ -1839,13 +1857,7 @@ impl CodeQueryEngine { .take(compass_model::query_contract::MAX_INDEXED_QUERY_TERMS.saturating_add(1)) .collect::>(); validate_search_term_count(&recall_terms)?; - let ranking_terms = recall_terms - .iter() - .cloned() - .map(canonical_query_token) - .collect::>() - .into_iter() - .collect::>(); + let ranking_terms = discovery_terms.ranking_terms; let mut terms = recall_terms.clone(); for term in &ranking_terms { if terms.len() >= compass_model::query_contract::MAX_INDEXED_QUERY_TERMS { @@ -2622,7 +2634,7 @@ impl CodeQueryEngine { if prepared.fts_query.is_empty() { response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::NoMatch, - message: format!("No symbol matched {query:?}"), + message: format!("NO EXACT MATCH for {query:?}"), node_id: None, path: None, }); @@ -2669,13 +2681,19 @@ impl CodeQueryEngine { if candidates.is_empty() { response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::NoMatch, - message: format!("No symbol matched {query:?}"), + message: format!("NO EXACT MATCH for {query:?}"), node_id: None, path: None, }); return Ok(None); } if response.truncated { + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::NoMatch, + message: format!("NO EXACT MATCH for {query:?}"), + node_id: None, + path: None, + }); response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::AmbiguousMatch, message: format!( @@ -2687,9 +2705,23 @@ impl CodeQueryEngine { }); return Ok(None); } - let candidate_count = candidates.len(); - let ranked = - rank_search_candidates(query, &prepared.ranking_terms, candidates, candidate_limit); + let max_fallback = usize::try_from(response.limits.max_nodes).unwrap_or(usize::MAX); + let ranked = rank_search_candidates( + query, + &prepared.ranking_terms, + candidates, + candidate_limit.min(max_fallback), + ); + let fallback_count = ranked.len(); + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::NoMatch, + message: format!( + "NO EXACT MATCH for {query:?}. Showing {fallback_count} fuzzy/lexical fallback result(s) for related terms: {}", + prepared.ranking_terms.join(", ") + ), + node_id: None, + path: None, + }); if let [candidate] = ranked.as_slice() { return Ok(Some(candidate.node.id.clone())); } @@ -2698,11 +2730,18 @@ impl CodeQueryEngine { { return Ok(Some(candidate.node.id.clone())); } + for result in ranked { + response.results.push(SearchHit { + node_id: result.node_id, + score: result.score, + matched_fields: result.matched_fields, + }); + response.nodes.push(query_node(&result.node)); + } response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::AmbiguousMatch, message: format!( - "Symbol {query:?} recalled {} candidates; provide a qualified name or exact ID", - candidate_count + "Fallback for {query:?} recalled {fallback_count} candidates; provide a qualified name or exact ID" ), node_id: None, path: None, @@ -2770,11 +2809,22 @@ impl CodeQueryEngine { if !budget.consume_node() { return Ok((None, true)); } - let mut queue = VecDeque::from([(source.to_owned(), 0_usize)]); - let mut visited = HashSet::from([source.to_owned()]); + let mut queue = BinaryHeap::from([Reverse(( + 0_u32, + 0_usize, + source.to_owned(), + source.to_owned(), + ))]); + let mut best = HashMap::from([(source.to_owned(), (0_u32, 0_usize, source.to_owned()))]); + let mut admitted = HashSet::from([source.to_owned()]); let mut predecessor = HashMap::::new(); let mut truncated = false; - while let Some((node, depth)) = queue.pop_front() { + while let Some(Reverse((cost, depth, path_key, node))) = queue.pop() { + if best.get(&node).is_none_or(|current| { + current.0 != cost || current.1 != depth || current.2 != path_key + }) { + continue; + } if node == target { let mut nodes = vec![target.to_owned()]; let mut edges = Vec::new(); @@ -2820,21 +2870,30 @@ impl CodeQueryEngine { } } adjacent.sort_by(|left, right| { - evidence_quality(&right.1) - .cmp(&evidence_quality(&left.1)) + code_relation_weight(left.1.kind) + .cmp(&code_relation_weight(right.1.kind)) + .then_with(|| evidence_quality(&right.1).cmp(&evidence_quality(&left.1))) + .then_with(|| left.0.cmp(&right.0)) .then_with(|| left.1.id.cmp(&right.1.id)) }); for (next, edge) in adjacent { - if visited.contains(&next) { + let next_depth = depth.saturating_add(1); + let next_cost = cost.saturating_add(code_relation_weight(edge.kind)); + let next_key = format!("{path_key}\0{}\0{next}", edge.id); + let candidate = (next_cost, next_depth, next_key.clone()); + if best + .get(&next) + .is_some_and(|current| candidate >= current.clone()) + { continue; } - if !budget.consume_node() { + if admitted.insert(next.clone()) && !budget.consume_node() { truncated = true; continue; } - visited.insert(next.clone()); + best.insert(next.clone(), candidate); predecessor.insert(next.clone(), (node.clone(), edge.id.clone())); - queue.push_back((next, depth + 1)); + queue.push(Reverse((next_cost, next_depth, next_key, next))); } } Ok((None, truncated)) @@ -3078,6 +3137,21 @@ fn evidence_quality(edge: &EdgeRecord) -> u8 { .unwrap_or(0) } +const fn code_relation_weight(kind: EdgeKind) -> u32 { + match kind { + EdgeKind::Contains + | EdgeKind::Calls + | EdgeKind::Imports + | EdgeKind::Extends + | EdgeKind::Implements + | EdgeKind::RoutesTo + | EdgeKind::Handles + | EdgeKind::DependsOn => 1, + EdgeKind::References | EdgeKind::Documents => 4, + _ => 2, + } +} + pub(crate) fn query_node(node: &NodeRecord) -> QueryNode { QueryNode { id: node.id.clone(), diff --git a/crates/compass-query/src/discovery.rs b/crates/compass-query/src/discovery.rs index 4387c00bc..e9fc26b96 100644 --- a/crates/compass-query/src/discovery.rs +++ b/crates/compass-query/src/discovery.rs @@ -16,7 +16,8 @@ use compass_model::query_contract::{ use compass_model::search::OPERATION_ROLE_TOKENS; use crate::code_query::{ - PinnedDiscoveryBackend, query_edge, query_node, recall_fuzzy_term_variants, search_query_terms, + PinnedDiscoveryBackend, normalize_symbol, query_edge, query_node, recall_fuzzy_term_variants, + search_query_terms, }; use crate::ranking::{ OperationRootRank, RelationEvidenceRank, is_explicit_operation_predicate, @@ -25,7 +26,9 @@ use crate::ranking::{ use crate::recall::{ CandidateSource, RecallBudget, RelationshipTermMatch, SearchCandidate, SearchCandidatePool, }; -use crate::text::{normalize_context_filters, search_tokens}; +use crate::text::{ + discovery_operands, discovery_term_selection, normalize_context_filters, search_tokens, +}; use crate::{CodeQueryEngine, QueryError, QueryErrorKind}; const ALL_EDGE_KINDS: &[EdgeKind] = &[ @@ -148,6 +151,15 @@ impl CodeQueryEngine { let relation_contexts = validate_and_normalize_contexts(&request.relation_contexts)?; let resolved_scope = self.resolve_scopes(&backend, &request.scope, &guard)?; + let term_selection = discovery_term_selection(&request.question); + let exact_operands = exact_match_operands(&request.question); + let mut exact_check = check_exact_operands( + &backend, + &exact_operands, + &resolved_scope, + &request.limits, + &guard, + )?; let (selected_direction, direction_source) = match request.direction { DiscoveryDirection::Auto => infer_discovery_direction(&request.question), direction => (direction, DiscoveryDirectionSource::Explicit), @@ -178,12 +190,33 @@ impl CodeQueryEngine { &request.limits, &guard, )?; + let trimmed_question = request.question.trim(); + if exact_operands.is_empty() + && !trimmed_question.is_empty() + && !trimmed_question.chars().any(char::is_whitespace) + && !selection.candidates.iter().any(|candidate| { + matches!( + candidate.source, + DiscoverySeedSource::ExactId | DiscoverySeedSource::ExactName + ) + }) + { + exact_check.missing.push(trimmed_question.to_owned()); + } response.stats.candidate_nodes = selection.nodes_read; response.stats.candidate_probes = selection.probes; + response.stats.candidate_nodes = response + .stats + .candidate_nodes + .saturating_add(exact_check.nodes_read); + response.stats.candidate_probes = response + .stats + .candidate_probes + .saturating_add(exact_check.probes); response.stats.expanded_relationships = selection.expanded_relationships; response.stats.candidates_admitted = u64::try_from(selection.candidates.len()).unwrap_or(u64::MAX); - if selection.truncated { + if selection.truncated || exact_check.truncated { response.truncated = true; } else { response.omissions.candidates = Some(0); @@ -202,6 +235,22 @@ impl CodeQueryEngine { .min(usize::try_from(request.limits.max_nodes).unwrap_or(usize::MAX)); let (seeds, omitted_alternatives) = discovery_seeds(&selection.candidates, max_seeds); response.seeds = seeds; + for operand in &exact_check.missing { + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::NoMatch, + message: if response.seeds.is_empty() { + format!("NO EXACT MATCH for {operand:?}") + } else { + format!( + "NO EXACT MATCH for {operand:?}. Showing {} fuzzy/lexical fallback result(s) for related terms: {}", + response.seeds.len(), + term_selection.ranking_terms.join(", ") + ) + }, + node_id: None, + path: None, + }); + } if !selection.ambiguity_complete { for seed in &mut response.seeds { seed.ambiguous = true; @@ -241,10 +290,24 @@ impl CodeQueryEngine { node_id: None, path: None, }); - } else { + } else if !response + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == QueryDiagnosticCode::NoMatch) + { response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::NoMatch, - message: format!("No node matched {:?}", request.question), + message: if term_selection.ranking_terms.is_empty() + && !term_selection.discarded_generic_terms.is_empty() + { + format!( + "NO EXACT MATCH for {:?}: no specific seed term remains after discarding generic relational vocabulary ({})", + request.question, + term_selection.discarded_generic_terms.join(", ") + ) + } else { + format!("NO EXACT MATCH for {:?}", request.question) + }, node_id: None, path: None, }); @@ -1323,6 +1386,55 @@ impl CodeQueryEngine { } } +#[derive(Default)] +struct ExactOperandCheck { + missing: Vec, + nodes_read: u64, + probes: u64, + truncated: bool, +} + +fn exact_match_operands(question: &str) -> Vec { + discovery_operands(question) +} + +fn check_exact_operands( + backend: &PinnedDiscoveryBackend<'_>, + operands: &[String], + scope: &[DiscoveryScope], + limits: &DiscoveryLimits, + guard: &DiscoveryGuard<'_>, +) -> Result { + let mut check = ExactOperandCheck::default(); + let limit = usize::try_from(limits.max_candidates).unwrap_or(usize::MAX); + for operand in operands.iter().take(2) { + guard.check()?; + check.probes = check.probes.saturating_add(1); + if backend + .node_by_id(operand)? + .is_some_and(|node| discovery_scope_matches(&node, scope)) + { + check.nodes_read = check.nodes_read.saturating_add(1); + continue; + } + check.probes = check.probes.saturating_add(1); + let (nodes, truncated) = + backend.nodes_by_normalized_name(&normalize_symbol(operand), limit.max(1))?; + check.nodes_read = check + .nodes_read + .saturating_add(u64::try_from(nodes.len()).unwrap_or(u64::MAX)); + check.truncated |= truncated; + if !truncated + && !nodes + .iter() + .any(|node| discovery_scope_matches(node, scope)) + { + check.missing.push(operand.clone()); + } + } + Ok(check) +} + fn retain_specific_discovery_candidates( question: &str, terms: &[String], diff --git a/crates/compass-query/src/discovery_text.rs b/crates/compass-query/src/discovery_text.rs index 7dbde321f..cee95f01b 100644 --- a/crates/compass-query/src/discovery_text.rs +++ b/crates/compass-query/src/discovery_text.rs @@ -8,8 +8,10 @@ use compass_model::query_contract::{ }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; -pub const DISCOVERY_TEXT_PAGE_VERSION: &str = "compass.query.discovery-text-page/1"; +pub const DISCOVERY_TEXT_PAGE_VERSION: &str = "compass.query.discovery-text-page/2"; +pub const DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET: usize = 8_000; const MAX_CURSOR_BYTES: usize = 4_096; const MIN_TEXT_BUDGET: usize = 256; const MAX_TEXT_BUDGET: usize = 65_536; @@ -23,6 +25,7 @@ pub struct DiscoveryTextPageOptions<'a> { pub request_digest: &'a str, pub graph_identity: &'a str, pub graph_digest: &'a str, + pub include_evidence: bool, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -111,6 +114,7 @@ struct CursorEnvelope { graph_identity: String, graph_digest: String, semantic_result_digest: String, + include_evidence: bool, section: String, item: usize, offset: usize, @@ -132,7 +136,7 @@ pub fn render_discovery_text_page( return Err(DiscoveryTextPageError::InvalidCursorEncoding); } let semantic_result_digest = discovery_response_digest(response)?; - let entries = entries(response); + let entries = entries(response, options.include_evidence); let start = match options.cursor { Some(cursor) => { let envelope = decode_cursor(cursor)?; @@ -143,13 +147,32 @@ pub fn render_discovery_text_page( options.graph_digest, &semantic_result_digest, &entries, + options.include_evidence, )?; envelope.offset } None => 0, }; let ambiguity = response.seeds.iter().filter(|seed| seed.ambiguous).count(); - let fixed = vec![ + let term_selection = crate::text::discovery_term_selection(&response.question); + let mut fixed = match_signal_lines(response); + fixed.push(format!( + "Seed terms: {}{}", + if term_selection.ranking_terms.is_empty() { + "none".to_owned() + } else { + rendered_values(&term_selection.ranking_terms) + }, + if term_selection.discarded_generic_terms.is_empty() { + String::new() + } else { + format!( + " (discarded as too generic: {})", + rendered_quoted_values(&term_selection.discarded_generic_terms) + ) + } + )); + fixed.extend([ format!( "Discovery: {} seed(s), {} node(s), {} edge(s)", response.seeds.len(), @@ -189,8 +212,10 @@ pub fn render_discovery_text_page( rendered_values(&response.relation_contexts) ), format!("Scope (OR): {}", rendered_scopes(response)), - format!("Semantic result: sha256:{semantic_result_digest}"), - ]; + ]); + if options.include_evidence { + fixed.push(format!("Semantic result: sha256:{semantic_result_digest}")); + } let max_chars = options.token_budget.saturating_mul(4); let fixed_chars = fixed .iter() @@ -209,6 +234,7 @@ pub fn render_discovery_text_page( candidate_end, entries.len(), candidate_cursor.as_deref(), + options.include_evidence, ); let candidate_entry_chars = entry.text.chars().count().saturating_add(1); let footer_chars = footer @@ -239,6 +265,7 @@ pub fn render_discovery_text_page( end, entries.len(), next_cursor.as_deref(), + options.include_evidence, ); if entries.is_empty() && fixed_chars.saturating_add( @@ -276,6 +303,7 @@ fn continuation_cursor( graph_identity: options.graph_identity.to_owned(), graph_digest: options.graph_digest.to_owned(), semantic_result_digest: semantic_result_digest.to_owned(), + include_evidence: options.include_evidence, section: entry.section.to_owned(), item: entry.item, offset, @@ -291,8 +319,9 @@ fn footer( end: usize, entry_total: usize, next_cursor: Option<&str>, -) -> [String; 2] { - [ + include_evidence: bool, +) -> Vec { + let mut lines = vec![ format!( "Completeness: {} (candidates={}, alternatives={}, nodes={}, edges={}, expandedRelationships={})", if response.truncated { @@ -307,56 +336,110 @@ fn footer( omission(response.omissions.expanded_relationships), ), format!( - "Pagination: version={} digest=sha256:{} range={}-{} of {} next={}", + "Pagination: version={}{} range={}-{} of {} next={}", DISCOVERY_TEXT_PAGE_VERSION, - semantic_result_digest, + if include_evidence { + format!(" digest=sha256:{semantic_result_digest}") + } else { + String::new() + }, if entry_total == 0 { 0 } else { start + 1 }, end, entry_total, next_cursor.unwrap_or("none") ), - ] + ]; + if !include_evidence { + let hidden = response + .nodes + .iter() + .map(|node| node.evidence.len()) + .sum::() + .saturating_add( + response + .edges + .iter() + .map(|edge| edge.evidence.len()) + .sum::(), + ); + if hidden > 0 { + lines.push(format!( + "({hidden} provenance record(s) hidden — pass --evidence for full detail)" + )); + } + } + lines } -fn entries(response: &DiscoveryQueryResponse) -> Vec { +fn entries(response: &DiscoveryQueryResponse, include_evidence: bool) -> Vec { let mut entries = Vec::new(); let mut alternative_item = 0_usize; let mut node_evidence_item = 0_usize; let mut edge_evidence_item = 0_usize; + let labels = response + .nodes + .iter() + .map(|node| (node.id.as_str(), node.qualified_name.as_str())) + .collect::>(); for (item, seed) in response.seeds.iter().enumerate() { entries.push(Entry { section: "seeds", item, - text: format!( - "Seed: {} [{}; source={}; score={}; matchedFields={}; matchedTerms={}]{}", - rendered_scalar(&seed.node_id), - score_tier_name(seed.score_tier), - seed_source_name(seed.candidate_source), - rendered_scalar(&seed.score), - rendered_values(&seed.matched_fields), - rendered_values(&seed.matched_terms), - seed.source - .as_ref() - .map(|source| format!(" @ {}", rendered_anchor(source))) - .unwrap_or_default(), - ), + text: if include_evidence { + format!( + "Seed: {} [{}; source={}; score={}; matchedFields={}; matchedTerms={}]{}", + rendered_scalar(&seed.node_id), + score_tier_name(seed.score_tier), + seed_source_name(seed.candidate_source), + rendered_scalar(&seed.score), + rendered_values(&seed.matched_fields), + rendered_values(&seed.matched_terms), + seed.source + .as_ref() + .map(|source| format!(" @ {}", rendered_anchor(source))) + .unwrap_or_default(), + ) + } else { + format!( + "SEED {} [source={}; matched={}]", + rendered_scalar( + labels + .get(seed.node_id.as_str()) + .copied() + .unwrap_or(seed.node_id.as_str()), + ), + seed_source_name(seed.candidate_source), + rendered_values(&seed.matched_terms), + ) + }, }); for alternative in &seed.alternatives { entries.push(Entry { section: "alternatives", item: alternative_item, - text: format!( - "Alternative: seed={} node={} qualifiedName={} score={}{}", - rendered_scalar(&seed.node_id), - rendered_scalar(&alternative.node_id), - rendered_scalar(&alternative.qualified_name), - rendered_scalar(&alternative.score), - alternative - .source - .as_ref() - .map(|source| format!(" @ {}", rendered_anchor(source))) - .unwrap_or_default(), - ), + text: if include_evidence { + format!( + "Alternative: seed={} node={} qualifiedName={} score={}{}", + rendered_scalar(&seed.node_id), + rendered_scalar(&alternative.node_id), + rendered_scalar(&alternative.qualified_name), + rendered_scalar(&alternative.score), + alternative + .source + .as_ref() + .map(|source| format!(" @ {}", rendered_anchor(source))) + .unwrap_or_default(), + ) + } else { + format!( + "ALTERNATIVE {} @ {}", + rendered_scalar(&alternative.qualified_name), + alternative + .source + .as_ref() + .map_or_else(|| "unknown".to_owned(), rendered_anchor), + ) + }, }); alternative_item += 1; } @@ -365,20 +448,36 @@ fn entries(response: &DiscoveryQueryResponse) -> Vec { entries.push(Entry { section: "nodes", item, - text: format!( - "Node: {} [{}] {}{} [evidence={}; details={}]", - rendered_scalar(&node.id), - node.kind.as_str(), - rendered_scalar(&node.qualified_name), - node.source - .as_ref() - .map(|source| format!(" @ {}", rendered_anchor(source))) - .unwrap_or_default(), - node.evidence.len(), - rendered_details(node.details.as_ref()), - ), + text: if include_evidence { + format!( + "Node: {} [{}] {}{} [evidence={}; details={}]", + rendered_scalar(&node.id), + node.kind.as_str(), + rendered_scalar(&node.qualified_name), + node.source + .as_ref() + .map(|source| format!(" @ {}", rendered_anchor(source))) + .unwrap_or_default(), + node.evidence.len(), + rendered_details(node.details.as_ref()), + ) + } else { + format!( + "NODE {} [{}] {}", + rendered_scalar(&node.qualified_name), + node.kind.as_str(), + node.source + .as_ref() + .map_or_else(|| "unknown".to_owned(), rendered_anchor), + ) + }, }); - for (evidence_index, evidence) in node.evidence.iter().enumerate() { + for (evidence_index, evidence) in node + .evidence + .iter() + .enumerate() + .filter(|_| include_evidence) + { entries.push(Entry { section: "node_evidence", item: node_evidence_item, @@ -396,7 +495,7 @@ fn entries(response: &DiscoveryQueryResponse) -> Vec { entries.push(Entry { section: "edges", item, - text: format!( + text: if include_evidence { format!( "Edge #{}: {} -{}-> {} [id={}; context={}; site={}; occurrenceRule={}; evidence={}; details={}]", item + 1, rendered_scalar(&edge.source), @@ -413,13 +512,34 @@ fn entries(response: &DiscoveryQueryResponse) -> Vec { rendered_details(edge.occurrence_rule.as_ref()), edge.evidence.len(), rendered_details(edge.details.as_ref()), - ), + ) } else { format!( + "EDGE {} --{}--> {} [{}]", + rendered_scalar( + labels + .get(edge.source.as_str()) + .copied() + .unwrap_or(edge.source.as_str()), + ), + edge.kind.as_str(), + rendered_scalar( + labels + .get(edge.target.as_str()) + .copied() + .unwrap_or(edge.target.as_str()), + ), + site.map_or_else(|| "site unavailable".to_owned(), rendered_anchor), + ) }, }); let edge_identity = edge .id .as_deref() .map_or_else(|| format!("anonymous#{}", item + 1), rendered_scalar); - for (evidence_index, evidence) in edge.evidence.iter().enumerate() { + for (evidence_index, evidence) in edge + .evidence + .iter() + .enumerate() + .filter(|_| include_evidence) + { entries.push(Entry { section: "edge_evidence", item: edge_evidence_item, @@ -450,6 +570,28 @@ fn entries(response: &DiscoveryQueryResponse) -> Vec { entries } +fn match_signal_lines(response: &DiscoveryQueryResponse) -> Vec { + if let Some(diagnostic) = response.diagnostics.iter().find(|diagnostic| { + diagnostic.code == QueryDiagnosticCode::NoMatch + && diagnostic.message.starts_with("NO EXACT MATCH") + }) { + return vec![ + "match_confidence: none".to_owned(), + rendered_scalar(&diagnostic.message), + ]; + } + let exact_shape = !crate::text::discovery_operands(&response.question).is_empty() + || !response.question.trim().chars().any(char::is_whitespace); + vec![format!( + "match_confidence: {}", + if exact_shape { + "exact" + } else { + "not_applicable" + } + )] +} + fn canonical_response_bytes( response: &DiscoveryQueryResponse, ) -> Result, serde_json::Error> { @@ -494,6 +636,7 @@ fn validate_cursor( graph_digest: &str, result_digest: &str, entries: &[Entry], + include_evidence: bool, ) -> Result<(), DiscoveryTextPageError> { if cursor.version != DISCOVERY_TEXT_PAGE_VERSION { return Err(DiscoveryTextPageError::UnsupportedCursorVersion); @@ -507,6 +650,9 @@ fn validate_cursor( if cursor.semantic_result_digest != result_digest { return Err(DiscoveryTextPageError::ResultChanged); } + if cursor.include_evidence != include_evidence { + return Err(DiscoveryTextPageError::RequestChanged); + } let Some(entry) = entries.get(cursor.offset) else { return Err(DiscoveryTextPageError::CursorOutOfRange); }; @@ -552,6 +698,14 @@ fn rendered_values(values: &[String]) -> String { } } +fn rendered_quoted_values(values: &[String]) -> String { + rendered_list( + values + .iter() + .map(|value| format!("\"{}\"", rendered_scalar(value))), + ) +} + fn rendered_scopes(response: &DiscoveryQueryResponse) -> String { if response.scope.is_empty() { return "none".to_owned(); @@ -854,6 +1008,7 @@ mod tests { request_digest: &"a".repeat(64), graph_identity: "generation-1", graph_digest: &"b".repeat(64), + include_evidence: false, }, )?; let cursor = first.next_cursor.ok_or("expected a continuation")?; @@ -865,6 +1020,7 @@ mod tests { request_digest: &"a".repeat(64), graph_identity: "generation-1", graph_digest: &"b".repeat(64), + include_evidence: false, }, )?; assert_eq!(second.entry_start, first.entry_end); @@ -888,6 +1044,7 @@ mod tests { request_digest: &"c".repeat(64), graph_identity: "generation-1", graph_digest: &"b".repeat(64), + include_evidence: false, }, ); assert!(matches!( @@ -906,6 +1063,7 @@ mod tests { request_digest: &"a".repeat(64), graph_identity: "generation-1", graph_digest: &"b".repeat(64), + include_evidence: false, }, ), Err(DiscoveryTextPageError::InvalidCursorChecksum) @@ -939,6 +1097,7 @@ mod tests { request_digest: &request_digest, graph_identity: "generation-1", graph_digest: &graph_digest, + include_evidence: false, }, )?; if let Some(expected) = &semantic_digest { @@ -963,6 +1122,7 @@ mod tests { request_digest: &request_digest, graph_identity: "generation-1", graph_digest: &graph_digest, + include_evidence: false, }, )?; assert_eq!( @@ -978,6 +1138,7 @@ mod tests { request_digest: &request_digest, graph_identity: "generation-1", graph_digest: &graph_digest, + include_evidence: false, }, )?; let first_cursor = first.next_cursor.ok_or("expected continuation")?; @@ -989,6 +1150,7 @@ mod tests { request_digest: &request_digest, graph_identity: "generation-2", graph_digest: &graph_digest, + include_evidence: false, }, ); assert!(matches!( @@ -1006,6 +1168,7 @@ mod tests { request_digest: &request_digest, graph_identity: "generation-1", graph_digest: &graph_digest, + include_evidence: false, }, ); assert!(matches!( @@ -1027,6 +1190,7 @@ mod tests { request_digest: &request_digest, graph_identity: "generation-1", graph_digest: &graph_digest, + include_evidence: false, }, ), Err(DiscoveryTextPageError::CursorOutOfRange) @@ -1082,6 +1246,7 @@ mod tests { request_digest: &"a".repeat(64), graph_identity: "generation-1", graph_digest: &"b".repeat(64), + include_evidence: true, }, )?; @@ -1150,6 +1315,7 @@ mod tests { request_digest: &request_digest, graph_identity: "generation-1", graph_digest: &graph_digest, + include_evidence: true, }, )?; covered.extend(page.entry_start..page.entry_end); @@ -1161,7 +1327,7 @@ mod tests { } assert_eq!(covered, (0..401).collect::>()); assert!( - entries(&response) + entries(&response, true) .iter() .all(|entry| entry.text.len() < 8_192) ); diff --git a/crates/compass-query/src/lib.rs b/crates/compass-query/src/lib.rs index 1a507a7b1..97531cb06 100644 --- a/crates/compass-query/src/lib.rs +++ b/crates/compass-query/src/lib.rs @@ -28,9 +28,9 @@ pub use cql::{ QueryErrorKind, QueryLimits, QueryProfile, QueryRequest, QueryResult, execute, }; pub use discovery_text::{ - DISCOVERY_TEXT_PAGE_VERSION, DiscoveryTextPage, DiscoveryTextPageError, - DiscoveryTextPageOptions, discovery_request_digest, discovery_response_digest, - discovery_result_envelope, render_discovery_text_page, + DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET, DISCOVERY_TEXT_PAGE_VERSION, DiscoveryTextPage, + DiscoveryTextPageError, DiscoveryTextPageOptions, discovery_request_digest, + discovery_response_digest, discovery_result_envelope, render_discovery_text_page, }; pub use graph_engine::{ DirectGraphEngine, EffectiveGraphEngine, GraphEngine, JsonGraphEngine, StoreGraphEngine, @@ -65,9 +65,10 @@ pub use telemetry::{ }; pub use text::{normalize_context_filters, query_terms, sanitize_label, search_tokens}; pub use traversal::{ - DEFAULT_TEXT_TOKEN_BUDGET, ProfiledTextPageOptions, TextPageOptions, TextPaginationError, - TraversalMode, query_graph_text, query_graph_text_page, query_graph_text_page_with_profile, - render_explanation, render_explanation_page, render_shortest_path, + DEFAULT_PATH_DEPTH_LIMIT, DEFAULT_TEXT_TOKEN_BUDGET, ProfiledTextPageOptions, TextPageOptions, + TextPaginationError, TraversalMode, query_graph_text, query_graph_text_page, + query_graph_text_page_with_profile, render_explanation, render_explanation_page, + render_shortest_path, render_shortest_path_with_limit, }; #[cfg(test)] @@ -414,6 +415,64 @@ mod tests { Ok(()) } + #[test] + fn shortest_path_prefers_strong_evidence_and_reports_a_shorter_weak_route() + -> Result<(), Box> { + let graph = load( + r#"{ + "directed": true, "multigraph": false, "graph": {}, + "nodes": [ + {"id":"source","label":"Source"}, + {"id":"strong-one","label":"StrongOne"}, + {"id":"strong-two","label":"StrongTwo"}, + {"id":"target","label":"Target"}, + {"id":"weak","label":"WeakShortcut"} + ], + "links": [ + {"source":"source","target":"strong-one","relation":"calls","confidence":"EXTRACTED"}, + {"source":"strong-one","target":"strong-two","relation":"contains","confidence":"EXTRACTED"}, + {"source":"strong-two","target":"target","relation":"depends_on","confidence":"EXTRACTED"}, + {"source":"source","target":"weak","relation":"references","confidence":"INFERRED"}, + {"source":"weak","target":"target","relation":"documents","confidence":"INFERRED"} + ] + }"#, + )?; + + let output = render_shortest_path(&graph, "source", "target")?; + assert!(output.contains("Target resolved: Target [id=target]")); + assert!(output.contains("Best path (weighted, 3 hops, weight 3)")); + assert!(output.contains("Source --calls [EXTRACTED]--> StrongOne")); + assert!(output.contains("StrongTwo --depends_on [EXTRACTED]--> Target")); + assert!(output.contains("shorter (2-hop) but weaker path also exists (weight 8)")); + assert!(output.contains("references")); + assert!(output.contains("documents")); + Ok(()) + } + + #[test] + fn shortest_path_requires_exact_endpoints_and_reports_unreachable_targets() + -> Result<(), Box> { + let graph = load( + r#"{ + "directed": true, "multigraph": false, "graph": {}, + "nodes": [ + {"id":"source","label":"Source"}, + {"id":"source-helper","label":"SourceHelper"}, + {"id":"target","label":"Target"} + ], + "links": [] + }"#, + )?; + + let missing = render_shortest_path(&graph, "Sour", "Target"); + assert!(matches!(missing, Err(message) if message.contains("NO EXACT MATCH"))); + assert!( + render_shortest_path(&graph, "Source", "Target")? + .contains("NO PATH FOUND to resolved target") + ); + Ok(()) + } + #[test] fn explanation_separates_inbound_and_outbound_edges() -> Result<(), Box> { let graph = load( diff --git a/crates/compass-query/src/text.rs b/crates/compass-query/src/text.rs index f1d85ba1c..20533b572 100644 --- a/crates/compass-query/src/text.rs +++ b/crates/compass-query/src/text.rs @@ -3,6 +3,38 @@ use std::collections::HashSet; pub use compass_model::strip_diacritics; use compass_model::{canonical_code_token, identifier_tokens}; +const GENERIC_RELATIONAL_TERMS: &[&str] = &[ + "call", + "called", + "caller", + "callers", + "calls", + "connect", + "connected", + "connection", + "connections", + "depend", + "depended", + "dependency", + "dependent", + "dependents", + "depends", + "path", + "reach", + "reaches", + "relate", + "related", + "relation", + "relations", + "relationship", + "relationships", + "route", + "use", + "used", + "uses", + "using", +]; + const QUERY_STOPWORDS: &[&str] = &[ "how", "what", @@ -214,6 +246,105 @@ pub(crate) fn query_recall_terms(question: &str) -> Vec { if content.is_empty() { terms } else { content } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DiscoveryTermSelection { + pub recall_terms: Vec, + pub ranking_terms: Vec, + pub discarded_generic_terms: Vec, +} + +/// Select concrete discovery anchors while keeping relational vocabulary +/// available to the intent and direction classifiers. High-confidence natural +/// query shapes seed from their parsed operands; broad questions use their +/// remaining concrete terms. Generic relationship verbs never become graph +/// anchors by themselves. +pub(crate) fn discovery_term_selection(question: &str) -> DiscoveryTermSelection { + let operands = discovery_operands(question); + let seed_source = if operands.is_empty() { + question.to_owned() + } else { + operands.join(" ") + }; + let explicit_operand_terms = operands + .iter() + .flat_map(|operand| search_tokens(operand)) + .map(canonical_query_token) + .collect::>(); + let mut recall_terms = Vec::new(); + let mut ranking_terms = Vec::new(); + let mut seen_recall = HashSet::new(); + let mut seen_ranking = HashSet::new(); + for term in query_recall_terms(&seed_source) { + let canonical = canonical_query_token(term.clone()); + if (is_generic_relational_term(&term) || is_generic_relational_term(&canonical)) + && !explicit_operand_terms.contains(&canonical) + { + continue; + } + if seen_recall.insert(term.clone()) { + recall_terms.push(term); + } + if seen_ranking.insert(canonical.clone()) { + ranking_terms.push(canonical); + } + } + ranking_terms.sort(); + + let mut discarded_generic_terms = search_tokens(question) + .into_iter() + .map(canonical_query_token) + .filter(|term| is_generic_relational_term(term) && !explicit_operand_terms.contains(term)) + .collect::>(); + discarded_generic_terms.sort(); + discarded_generic_terms.dedup(); + DiscoveryTermSelection { + recall_terms, + ranking_terms, + discarded_generic_terms, + } +} + +/// Return the concrete symbol operands named by a supported natural query +/// shape. Neutral comparisons need a small explicit grammar because their +/// relationship word classifies the request but neither subject may be lost. +pub(crate) fn discovery_operands(question: &str) -> Vec { + let planned = crate::intent::plan_natural_query(question) + .ok() + .filter(|plan| plan.routes_to_typed_query()) + .map(|plan| plan.operands().to_vec()) + .unwrap_or_default(); + if !planned.is_empty() { + return planned; + } + + let trimmed = question.trim().trim_end_matches('?').trim(); + let lowered = trimmed.to_ascii_lowercase(); + let Some(body) = lowered.strip_prefix("how are ") else { + return Vec::new(); + }; + let body_offset = trimmed.len().saturating_sub(body.len()); + for suffix in [" related", " connected", " dependent"] { + let Some(subjects) = body.strip_suffix(suffix) else { + continue; + }; + let Some(separator) = subjects.find(" and ") else { + continue; + }; + let left = trimmed[body_offset..body_offset + separator].trim(); + let right_start = body_offset + separator + " and ".len(); + let right_end = body_offset + subjects.len(); + let right = trimmed[right_start..right_end].trim(); + if !left.is_empty() && !right.is_empty() { + return vec![left.to_owned(), right.to_owned()]; + } + } + Vec::new() +} + +fn is_generic_relational_term(term: &str) -> bool { + GENERIC_RELATIONAL_TERMS.contains(&term) +} + #[must_use] pub fn sanitize_label(text: &str) -> String { text.chars() @@ -335,3 +466,52 @@ fn is_searchable(term: &str) -> bool { true } } + +#[cfg(test)] +mod tests { + use super::discovery_term_selection; + + #[test] + fn discovery_terms_discard_generic_relationship_words() { + let selected = + discovery_term_selection("how are PaymentGateway and CheckoutHandler related?"); + assert!(selected.ranking_terms.contains(&"payment".to_owned())); + assert!(selected.ranking_terms.contains(&"gateway".to_owned())); + assert!(selected.ranking_terms.contains(&"checkout".to_owned())); + assert!(selected.ranking_terms.contains(&"handler".to_owned())); + assert!(!selected.ranking_terms.contains(&"relate".to_owned())); + assert!( + selected + .discarded_generic_terms + .contains(&"relate".to_owned()) + ); + } + + #[test] + fn parsed_operands_keep_symbols_that_happen_to_use_generic_words() { + let selected = discovery_term_selection("path from Caller to Target"); + assert!(selected.ranking_terms.contains(&"caller".to_owned())); + assert!(selected.ranking_terms.contains(&"target".to_owned())); + assert!(!selected.ranking_terms.contains(&"path".to_owned())); + assert!( + selected + .discarded_generic_terms + .contains(&"path".to_owned()) + ); + } + + #[test] + fn comparison_questions_keep_both_explicit_subjects() { + let selected = discovery_term_selection("how are Target and Caller connected?"); + assert!(selected.ranking_terms.contains(&"target".to_owned())); + assert!(selected.ranking_terms.contains(&"caller".to_owned())); + assert_eq!(selected.discarded_generic_terms, ["connect"]); + } + + #[test] + fn generic_relationship_words_cannot_seed_discovery_alone() { + let selected = discovery_term_selection("how are these connected and related?"); + assert!(selected.ranking_terms.is_empty()); + assert_eq!(selected.discarded_generic_terms, ["connect", "relate"]); + } +} diff --git a/crates/compass-query/src/traversal.rs b/crates/compass-query/src/traversal.rs index d0167b7bc..128042642 100644 --- a/crates/compass-query/src/traversal.rs +++ b/crates/compass-query/src/traversal.rs @@ -1,4 +1,5 @@ -use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; use compass_model::query_contract::{ DiscoveryLimits, MAX_DISCOVERY_EDGES, MAX_DISCOVERY_EXPANDED_RELATIONSHIPS, MAX_DISCOVERY_NODES, @@ -8,8 +9,7 @@ use serde_json::{Map, Value}; use thiserror::Error; use crate::score::{ - TextRankProfile, find_exact_nodes, find_node, pick_scored_endpoint, pick_seeds, score_nodes, - score_nodes_with_profile, + TextRankProfile, find_exact_nodes, find_node, pick_seeds, score_nodes_with_profile, }; use crate::text::{infer_context_filters, normalize_context_filters, query_terms, sanitize_label}; @@ -20,6 +20,7 @@ pub enum TraversalMode { } pub const DEFAULT_TEXT_TOKEN_BUDGET: usize = 2_000; +pub const DEFAULT_PATH_DEPTH_LIMIT: usize = 8; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct TextPageOptions { @@ -295,67 +296,255 @@ pub fn render_shortest_path( source_query: &str, target_query: &str, ) -> Result { - let source_scores = score_nodes( - graph, - &source_query - .split_whitespace() - .map(str::to_lowercase) - .collect::>(), - false, - ); - let target_scores = score_nodes( - graph, - &target_query - .split_whitespace() - .map(str::to_lowercase) - .collect::>(), - false, - ); - if source_scores.ranked.is_empty() { - return Err(format!("No node matching '{source_query}' found.")); - } - if target_scores.ranked.is_empty() { - return Err(format!("No node matching '{target_query}' found.")); + render_shortest_path_with_limit(graph, source_query, target_query, DEFAULT_PATH_DEPTH_LIMIT) +} + +pub fn render_shortest_path_with_limit( + graph: &Graph, + source_query: &str, + target_query: &str, + max_depth: usize, +) -> Result { + if max_depth == 0 { + return Err("path depth limit must be greater than zero".to_owned()); } - let source = pick_scored_endpoint(graph, &source_scores.ranked, source_query); - let target = pick_scored_endpoint(graph, &target_scores.ranked, target_query); + let source = resolve_exact_path_endpoint(graph, source_query)?; + let target = resolve_exact_path_endpoint(graph, target_query)?; if source == target { return Err(format!( "'{source_query}' and '{target_query}' both resolved to the same node '{}'. Use a more specific label or the exact node ID.", graph.node(source).id )); } - let Some(path) = shortest_path_undirected(graph, source, target) else { + let weighted = ranked_path_undirected(graph, source, target, max_depth, PathRanking::Weighted); + let Some(path) = weighted.path else { return Ok(format!( - "No path found between '{source_query}' and '{target_query}'." + "Source resolved: {}\nTarget resolved: {}\nNO PATH FOUND to resolved target (depth limit {max_depth}, {} nodes visited)", + rendered_path_endpoint(graph, source), + rendered_path_endpoint(graph, target), + weighted.visited_nodes, )); }; - let mut segments = vec![graph.node(path[0]).label().to_owned()]; - for pair in path.windows(2) { - let left = pair[0]; - let right = pair[1]; - if let Some(edge_index) = graph.edge_between(left, right) { - let edge = graph.edge(edge_index); - let confidence = edge.string("confidence"); - let suffix = if confidence.is_empty() { - String::new() - } else { - format!(" [{confidence}]") + let hops = path.nodes.len().saturating_sub(1); + let mut lines = vec![ + format!("Source resolved: {}", rendered_path_endpoint(graph, source)), + format!("Target resolved: {}", rendered_path_endpoint(graph, target)), + format!( + "Best path (weighted, {hops} hops, weight {}):\n {}", + path.weight, + render_graph_path(graph, &path) + ), + ]; + let shorter = ranked_path_undirected(graph, source, target, max_depth, PathRanking::Hops); + if let Some(alternative) = shorter.path + && alternative.edges != path.edges + && alternative.nodes.len() < path.nodes.len() + && alternative.weight > path.weight + && hops <= alternative.nodes.len().saturating_sub(1).saturating_add(2) + { + lines.push(format!( + "Note: a shorter ({}-hop) but weaker path also exists (weight {}):\n {}", + alternative.nodes.len().saturating_sub(1), + alternative.weight, + render_graph_path(graph, &alternative) + )); + } + Ok(lines.join("\n")) +} + +fn rendered_path_endpoint(graph: &Graph, index: NodeIndex) -> String { + let node = graph.node(index); + format!("{} [id={}]", node.label(), node.id) +} + +fn resolve_exact_path_endpoint(graph: &Graph, query: &str) -> Result { + let matches = find_exact_nodes(graph, query); + match matches.as_slice() { + [node] => Ok(*node), + [] => Err(format!("NO EXACT MATCH for {query:?}")), + _ => { + let mut ids = matches + .iter() + .map(|node| graph.node(*node).id.clone()) + .collect::>(); + ids.sort(); + Err(format!( + "AMBIGUOUS EXACT MATCH for {query:?}: {}. Pass an exact node ID.", + ids.join(", ") + )) + } + } +} + +#[derive(Clone, Copy)] +enum PathRanking { + Weighted, + Hops, +} + +struct GraphPathResult { + path: Option, + visited_nodes: usize, +} + +struct WeightedGraphPath { + nodes: Vec, + edges: Vec, + weight: u32, +} + +fn ranked_path_undirected( + graph: &Graph, + source: NodeIndex, + target: NodeIndex, + max_depth: usize, + ranking: PathRanking, +) -> GraphPathResult { + let source_id = graph.node(source).id.clone(); + let mut queue = BinaryHeap::from([Reverse((0_u32, 0_u32, source_id.clone(), source))]); + let mut best = BTreeMap::from([(source, (0_u32, 0_u32, source_id))]); + let mut predecessor = BTreeMap::::new(); + let mut visited = BTreeSet::new(); + while let Some(Reverse((primary, secondary, path_key, node))) = queue.pop() { + if best.get(&node).is_none_or(|current| { + current.0 != primary || current.1 != secondary || current.2 != path_key + }) { + continue; + } + visited.insert(node); + if node == target { + break; + } + let hops = match ranking { + PathRanking::Weighted => secondary, + PathRanking::Hops => primary, + }; + if usize::try_from(hops).unwrap_or(usize::MAX) >= max_depth { + continue; + } + for (neighbor, edge_index, weight, edge_key) in graph_adjacency(graph, node) { + let next_hops = hops.saturating_add(1); + let current_weight = match ranking { + PathRanking::Weighted => primary, + PathRanking::Hops => secondary, + }; + let next_weight = current_weight.saturating_add(weight); + let (next_primary, next_secondary) = match ranking { + PathRanking::Weighted => (next_weight, next_hops), + PathRanking::Hops => (next_hops, next_weight), + }; + let next_key = format!("{path_key}\0{edge_key}\0{}", graph.node(neighbor).id); + let candidate = (next_primary, next_secondary, next_key.clone()); + if best + .get(&neighbor) + .is_none_or(|current| candidate < current.clone()) + { + best.insert(neighbor, candidate); + predecessor.insert(neighbor, (node, edge_index)); + queue.push(Reverse((next_primary, next_secondary, next_key, neighbor))); + } + } + } + if !best.contains_key(&target) || !visited.contains(&target) { + return GraphPathResult { + path: None, + visited_nodes: visited.len(), + }; + } + let mut nodes = vec![target]; + let mut edges = Vec::new(); + let mut cursor = target; + while cursor != source { + let Some((previous, edge)) = predecessor.get(&cursor).copied() else { + return GraphPathResult { + path: None, + visited_nodes: visited.len(), }; + }; + edges.push(edge); + nodes.push(previous); + cursor = previous; + } + nodes.reverse(); + edges.reverse(); + let weight = edges + .iter() + .map(|edge| relation_weight(&graph.edge(*edge).string("relation"))) + .fold(0_u32, u32::saturating_add); + GraphPathResult { + path: Some(WeightedGraphPath { + nodes, + edges, + weight, + }), + visited_nodes: visited.len(), + } +} + +fn graph_adjacency(graph: &Graph, node: NodeIndex) -> Vec<(NodeIndex, EdgeIndex, u32, String)> { + let edge_indices = graph + .outgoing_edges(node) + .chain(graph.incoming_edges(node)) + .collect::>(); + let mut adjacent = Vec::with_capacity(edge_indices.len()); + for edge_index in edge_indices.iter().copied() { + let Some((source, target)) = graph.edge_endpoints(edge_index) else { + continue; + }; + let neighbor = if source == node { target } else { source }; + let edge = graph.edge(edge_index); + let relation = edge.string("relation"); + let public_id = edge.string("id"); + let edge_key = if public_id.is_empty() { + format!("{}:{}:{}:{edge_index}", edge.source, relation, edge.target) + } else { + public_id + }; + adjacent.push((neighbor, edge_index, relation_weight(&relation), edge_key)); + } + adjacent.sort_by(|left, right| { + left.2 + .cmp(&right.2) + .then_with(|| graph.node(left.0).id.cmp(&graph.node(right.0).id)) + .then_with(|| left.3.cmp(&right.3)) + }); + adjacent +} + +fn relation_weight(relation: &str) -> u32 { + match relation { + "calls" | "contains" | "depends_on" | "extends" | "implements" | "imports" + | "routes_to" | "handles" => 1, + "references" | "documents" | "co_occurs" | "co-occurs" => 4, + _ => 2, + } +} + +fn render_graph_path(graph: &Graph, path: &WeightedGraphPath) -> String { + let mut segments = vec![graph.node(path.nodes[0]).label().to_owned()]; + for ((left, right), edge_index) in path + .nodes + .windows(2) + .map(|pair| (pair[0], pair[1])) + .zip(path.edges.iter().copied()) + { + let edge = graph.edge(edge_index); + let confidence = edge.string("confidence"); + let suffix = if confidence.is_empty() { + String::new() + } else { + format!(" [{confidence}]") + }; + if graph.node_index(&edge.source) == Some(left) + && graph.node_index(&edge.target) == Some(right) + { segments.push(format!( "--{}{}--> {}", edge.string("relation"), suffix, graph.node(right).label() )); - } else if let Some(edge_index) = graph.edge_between(right, left) { - let edge = graph.edge(edge_index); - let confidence = edge.string("confidence"); - let suffix = if confidence.is_empty() { - String::new() - } else { - format!(" [{confidence}]") - }; + } else { segments.push(format!( "<--{}{}-- {}", edge.string("relation"), @@ -364,11 +553,7 @@ pub fn render_shortest_path( )); } } - Ok(format!( - "Shortest path ({} hops):\n {}", - path.len() - 1, - segments.join(" ") - )) + segments.join(" ") } #[must_use] @@ -1058,37 +1243,6 @@ fn render_paginated_groups( } } -fn shortest_path_undirected( - graph: &Graph, - source: NodeIndex, - target: NodeIndex, -) -> Option> { - let mut queue = VecDeque::from([source]); - let mut previous = HashMap::from([(source, source)]); - while let Some(node) = queue.pop_front() { - if node == target { - break; - } - for neighbor in graph.successors(node).chain(graph.predecessors(node)) { - if let std::collections::hash_map::Entry::Vacant(entry) = previous.entry(neighbor) { - entry.insert(node); - queue.push_back(neighbor); - } - } - } - if !previous.contains_key(&target) { - return None; - } - let mut path = vec![target]; - let mut current = target; - while current != source { - current = previous[¤t]; - path.push(current); - } - path.reverse(); - Some(path) -} - fn json_string(value: Option<&Value>) -> String { match value { None | Some(Value::Null) => String::new(), diff --git a/crates/compass-query/tests/coverage_paths.rs b/crates/compass-query/tests/coverage_paths.rs index 886d67fab..052202ee0 100644 --- a/crates/compass-query/tests/coverage_paths.rs +++ b/crates/compass-query/tests/coverage_paths.rs @@ -202,7 +202,10 @@ fn traversal_path_and_explanation_cover_success_and_error_rendering() -> Result< assert!(render_shortest_path(&graph, "absent", "run").is_err()); assert!(render_shortest_path(&graph, "run", "absent").is_err()); assert!(render_shortest_path(&graph, "run", "run").is_err()); - assert!(render_shortest_path(&graph, "run", "Isolated")?.contains("No path found")); + assert!( + render_shortest_path(&graph, "run", "Isolated")? + .contains("NO PATH FOUND to resolved target") + ); assert!(render_shortest_path(&graph, "OtherThing", "run")?.contains("2 hops")); assert!(render_explanation(&graph, "absent", &HashMap::new()).contains("No node matching")); diff --git a/crates/compass-query/tests/natural_intent.rs b/crates/compass-query/tests/natural_intent.rs index abf259c23..47ac3b0ad 100644 --- a/crates/compass-query/tests/natural_intent.rs +++ b/crates/compass-query/tests/natural_intent.rs @@ -131,7 +131,7 @@ fn contradictory_and_ambiguous_questions_never_invent_direction() } #[test] -fn structural_intents_use_bounded_fuzzy_recall_and_relation_evidence() +fn structural_intents_disclose_bounded_fuzzy_fallback_before_execution() -> Result<(), Box> { let directory = tempfile::tempdir()?; let graph_path = directory.path().join("graph.json"); @@ -163,8 +163,11 @@ fn structural_intents_use_bounded_fuzzy_recall_and_relation_evidence() response.nodes.iter().any(|node| node.id == "n:list"), "{response:#?}" ); - assert!(!response.nodes.iter().any(|node| node.id == "n:ilts")); assert!(response.edges.iter().any(|edge| edge.target == "n:list")); + assert!(response.diagnostics.iter().any(|diagnostic| { + diagnostic.code == QueryDiagnosticCode::NoMatch + && diagnostic.message.starts_with("NO EXACT MATCH") + })); } Ok(()) } diff --git a/docs/guides/exploring-a-codebase.md b/docs/guides/exploring-a-codebase.md index 4635bb147..1e0ad5a75 100644 --- a/docs/guides/exploring-a-codebase.md +++ b/docs/guides/exploring-a-codebase.md @@ -124,6 +124,13 @@ Save useful output when comparing questions: compass query "API token verification failure" > /tmp/compass-auth.txt ``` +The default result is a concise source-located graph map. Add `--evidence` when +you need the full provenance audit. If an exact-looking symbol is absent, +Compass says `NO EXACT MATCH` before listing any fuzzy suggestions. Generic +relationship words such as “connect,” “depend,” “path,” and “use” guide intent +but do not become discovery anchors unless they are themselves explicit symbol +operands. + Do not treat temporary result text as a durable schema. For automation, use CompassQL JSON/JSONL. @@ -161,6 +168,12 @@ Once you know two boundaries, connect them: compass path HttpHandler TokenVerifier ``` +Both endpoints must resolve exactly. The path ranking prefers direct structural +evidence over weak reference or documentation shortcuts, while still showing a +shorter-but-weaker alternative when it is close. Use `--max-depth N` to change +the default eight-hop bound. `NO PATH FOUND` means the exact target resolved but +was unreachable within that bound; it is distinct from `NO EXACT MATCH`. + Useful boundary pairs include: - endpoint to persistence; diff --git a/docs/implementation/query-engine.md b/docs/implementation/query-engine.md index 030fbe880..2ec3897f9 100644 --- a/docs/implementation/query-engine.md +++ b/docs/implementation/query-engine.md @@ -150,15 +150,20 @@ on the established relevance traversal. MCP `query_graph` uses the same routing rule for typed graphs unless a legacy `mode`, `depth`, `token_budget`, or `context_filter` field is present. -Structural operand resolution uses the same bounded exact-ID, normalized-name, -alias, term-posting, and typo recall assembly as search. Duplicate exact names -remain ambiguous. For a non-exact operand, a candidate with unique evidence in -the operation's required relationship role can resolve the operand; otherwise -the engine returns `ambiguous_match` rather than selecting the top-ranked -candidate. Relation probes are bounded by the request's candidate limit and a -one-edge existence check per candidate. - -Node trails traverse published edges from source to target. When no directed +Structural operand resolution checks unique exact IDs, normalized names, and +qualified names before fallback recall. Duplicate exact names remain ambiguous. +Bounded alias, term-posting, relationship, and typo recall can still drive a +strictly dominant natural-query fallback, but the response always carries +`no_match` and the text projection begins with `NO EXACT MATCH`; the fallback +cannot masquerade as exact. Ambiguous fallbacks remain suggestions only. +Relation probes remain bounded by the request's candidate limit and a one-edge +existence check per candidate. The dedicated `compass path` command is stricter: +both endpoints must resolve exactly before traversal. + +Node trails traverse published edges from source to target with deterministic +relation costs: structural call, containment, import, dependency, routing, and +type-hierarchy evidence is preferred over reference and documentation edges. +When no directed path is found, one undirected probe using the remaining traversal budget distinguishes a true no-match from a route that requires traversing at least one edge backward. The latter returns diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 56f407d35..5758e5ac2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -300,6 +300,7 @@ compass query "" [--result-envelope] [--text-budget N] [--cursor TOKEN] + [--evidence] [--budget N] [--page N] [--max-nodes N] @@ -324,10 +325,17 @@ as `call`, `import`, or `route`. It is not a node, file, package, community, or subsystem selector. Use repeatable `--scope KIND:VALUE` for explicit OR scope over `community`, `source`, `package`, or `node`. -`--text-budget` bounds the discovery text projection. Its opaque cursor binds +The default text projection is concise: it prints match confidence, seed terms, +nodes, edges, and source locations without expanding provenance records or the +semantic digest. `--evidence` selects the full audit projection. Exact-looking +operands that do not resolve emit `NO EXACT MATCH`; bounded fuzzy and lexical +candidates can still follow as suggestions but are not represented as exact. + +`--text-budget` bounds the discovery text projection and defaults to 8,000 +approximate tokens. Its opaque cursor binds the contract version, normalized request/options, selected graph generation and -digest, semantic-response digest, and next stable section/item. Fetch the next -page with `--cursor TOKEN` and otherwise unchanged semantic inputs. The +digest, semantic-response digest, evidence tier, and next stable section/item. +Fetch the next page with `--cursor TOKEN` and otherwise unchanged semantic inputs. The presentation-only `--text-budget` may change between pages. Pages contain whole deterministic entries; changed inputs fail instead of silently continuing a different result. JSON rejects text pagination controls. Legacy `--budget` and @@ -387,12 +395,17 @@ Canonical language contract: [CompassQL](../COMPASSQL.md). ### `path` ```text -compass path "" "" [--graph PATH | --at REV] +compass path "" "" [--max-depth N] [--graph PATH | --at REV] ``` -Renders a shortest known graph path while preserving relationship direction. -If a route exists only by ignoring one or more edge directions, the typed response -reports `direction_mismatch`; swap the operands to request that route. +Resolves both endpoints by exact node ID, name, or qualified name before doing +any graph search; missing and ambiguous endpoints fail explicitly. The text path +search is bounded to eight hops by default and ranks structural relationships +such as calls, containment, imports, and dependencies ahead of weak references +or documentation links. When a meaningfully weaker route is up to two hops +shorter, Compass shows it separately. Output names the resolved target ID, and +an unreachable target is reported as `NO PATH FOUND` with the depth bound and +visited-node count. Relationship arrows always preserve their stored direction. ### `explain` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9a81978f0..1daa8caa4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -354,6 +354,7 @@ Natural-language discovery: --scope KIND:VALUE --text-budget N --cursor TOKEN +--evidence --graph PATH | --at REV --max-nodes N --max-edges N @@ -365,8 +366,10 @@ community, or subsystem. Use repeatable `--scope KIND:VALUE` for an explicit OR scope over `community`, `source`, `package`, or `node`. `--text-budget` controls approximate rendered tokens per discovery page -(default 2,000). Follow the opaque `next` cursor with the same semantic query; -the presentation-only text budget may change. `--traverse`, `--budget`, and +(default 8,000). The default projection is concise; `--evidence` includes full +node/edge provenance and semantic digests. Follow the opaque `next` cursor with +the same semantic query and evidence tier; the presentation-only text budget may +change. `--traverse`, `--budget`, and `--page` explicitly select the bounded legacy compatibility renderer. The default semantic neighborhood contains at most 64 nodes and 128 edges. `--max-nodes` and `--max-edges` may raise those bounds to the hard ceilings of From 47eb98c04685ff46bf27def67ff6c3b927fb6cbf Mon Sep 17 00:00:00 2001 From: forhappy Date: Tue, 15 Sep 2026 00:19:00 -0700 Subject: [PATCH 2/2] Bump rustls past security advisory --- Cargo.lock | 4 ++-- fuzz/Cargo.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de852bab0..88330c569 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5309,9 +5309,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 84e43293f..c32145173 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -3359,9 +3359,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell",