diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a64f81..55a37f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Route natural-language usage, dependency, impact, and connection questions + into typed graph queries. Continue ambiguous questions with a disclosed, + deterministically ranked candidate and retain alternative identities. +- Include owned method calls in natural-language class dependency answers. + Present retained approximate results as useful candidates while preserving + provenance, coverage caveats, and strict structured-command matching. +- Default natural discovery to 800-token pages, show relationships first, + and version its text cursor to 3. Fix sub-chunk store relationship reads + that previously cut impact traversal short. + ## 0.4.0 - 2026-09-28 - Resolve source-proven Go field selectors to exact named struct fields and diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 1783e58e..92875aae 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -496,8 +496,12 @@ layout declaration. See the supported boundaries in the Compass adds the additive strict projection `compass.query.agent-view/1` for typed CLI and MCP consumers. It is derived from, and digest-bound to, the raw `compass.query/1` or `compass.query.discovery/1` response. The raw CLI `json` -shape, MCP `structuredContent.result`, graph schemas, and discovery -`compass.query.discovery-text-page/2` cursor meaning are unchanged. +shape, MCP `structuredContent.result`, and graph schemas are unchanged. +Discovery text pagination now uses +`compass.query.discovery-text-page/3`: compact pages show relationships before +node inventories. Older discovery cursors fail with an explicit version error; +restart the query to obtain a new cursor. Natural discovery text now defaults +to an 800-token page; `--text-budget N` and `--cursor` retain caller control. The typed commands accept `--format agent-json`; default text is an answer-first presentation. MCP keeps `compass.mcp.tool-result/1` and adds the @@ -512,6 +516,31 @@ The additive `relationship_inconsistency` diagnostic extends the strict TypeScript consumers and the checked-in manifest must accept the new value before interpreting a relationship result that carries it. +### Natural-language answer selection + +Natural-language queries use `query-planner/2` to recognize dependency, usage, +impact, and connection phrases. `compass query` preserves its +`compass.query.discovery/1` envelope while delegating recognized, unfiltered +questions to typed execution. Explicit direction, scope, context, and DFS +controls continue through filtered discovery. `compass ask` uses the same +operand selection policy. + +Natural language may select a ranked candidate when the name is ambiguous. +The selected identity and alternative names remain explicit; inferred operand +matching does not change the provenance of structural edges. Explicit +`callers`, `callees`, `impact`, `node`, and `search` commands retain strict +identity behavior. Consumers must distinguish a useful candidate answer from +an exact match using the retained seeds and diagnostics. Discovery Agent View +now presents retained fuzzy/ambiguous neighborhoods as candidates, instead of +requiring resolution before showing their results. No graph or query schema +major changes. Existing cursors reject a changed semantic result by digest. + +Typed Agent View also uses `resultState: "candidates"` with +`matchState: "ambiguous"` for disclosed auto-picked answers; their witnessed +relationships remain available. Strict unresolved ambiguity still uses +`needs_resolution`. Owner-qualified operands retain suffix validation and +reject missing owners or incomplete leaf-name postings before ranking. + ### Typed text pages and store self-check Typed commands (`ask`, `search`, `callers`, `callees`, `impact`, `explore`, and diff --git a/MIGRATION.md b/MIGRATION.md index 11fb15ea..f48620b5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -609,3 +609,18 @@ compass install --platform codex --project ``` Keep the old Graphify installation and `graphify-out/` directory until the new `compass-out/` graph has passed your project checks. The two tools don't share runtime output paths. + +## Natural-language candidate selection + +`compass query` and `compass ask` now continue with a ranked symbol when a +natural-language operand has multiple matches. Review the auto-picked or +approximate label and the alternatives before attributing the result to the +original question. Use an exact node ID to override the choice. Explicit +structured commands retain their strict matching behavior. No graph rebuild +is needed for this query change; cursors created for a different semantic +answer fail explicitly and must be restarted. + +Discovery text cursors now use version 3 because compact pages show relationships +before declarations. Restart a query if a saved version 2 cursor is rejected. +Natural discovery pages default to 800 tokens. Increase `--text-budget` or +follow the printed `--cursor` to retrieve more of the same bounded answer. diff --git a/PERFORMANCE.md b/PERFORMANCE.md index cc8984c1..4b7d5252 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -24,6 +24,33 @@ Compare a proposed change with a previously approved Compass result captured on the same runner and corpus. A median regression above 10% requires explicit review and evidence explaining the tradeoff. +## Natural-language answer cost qualification + +The developer-side [agent query harness](benchmarks/agent_query/README.md) +qualifies answer recall and output cost separately from runtime performance. +Run `suite_natural.toml` on its five pinned repositories with the same question +and an 800-token first page for each tool; no follow-up pages are counted. +The harness records binary identities, source revisions, bounded stdout, +source-reviewed oracle results, and query wall time. Token estimates use +UTF-8 output bytes divided by four, rather than a model tokenizer. + +On 2026-09-30, a macOS debug build of Compass 0.3.29 with natural-language +candidate selection passed 24 of 25 questions, compared with 17 of 25 for +Graphify 0.9.67. Mean output cost across all questions was 544 versus 757 +estimated tokens. On the 17 questions both tools answered, median output cost +was 746 versus 754 estimated tokens. One broad Cobra question still omitted +a required anchor from its first page. + +These measurements precede integration with the Compass 0.4.0 main branch; +they remain historical evidence for that candidate binary, not measurements +of the merged build. + +This focused anchor-recall check is not an independent precision oracle or a +population-wide accuracy claim. Compass's median query wall time was 3,843 ms, +compared with 532 ms for Graphify. This run does not qualify a latency +improvement, cold/warm cache behavior, peak RSS, or a production performance +baseline; those remain governed by the baseline policy above. + ## Community detection performance The 2026-09-12 Leiden hot-path qualification used Compass `0.3.24` candidate @@ -1256,6 +1283,14 @@ controls, the static layout, the 200-row community DOM bound, and the visible edge disclosure to appear within three seconds. These are runner-specific diagnostic observations, not a cross-platform latency guarantee. +Browser wall-clock qualification runs in the single-worker +`chromium-performance` Playwright project after the functional Chromium tests +finish, so concurrent test pages cannot consume its startup budget. The +one-second small-graph and three-second large-graph limits and readiness +assertions remain unchanged. Run only this qualification with +`npm run test:performance -w @compass/viewer-tests`; the normal `npm run test:js` +includes both projects in order. + ### Django parallel fact-state qualification The 2026-08-05 large-repository fact-state hardening was measured from Compass diff --git a/benchmarks/agent_query/README.md b/benchmarks/agent_query/README.md index a1af7248..c0c20d48 100644 --- a/benchmarks/agent_query/README.md +++ b/benchmarks/agent_query/README.md @@ -9,11 +9,12 @@ question/evidence matrix, including ask, communities, clusters, and god nodes. The audit report distinguishes completed checks from surfaces still awaiting source or design-quality judgments. -Four suites share the harness: +Five suites share the harness: | Suite | Questions | Shape | | --- | ---: | --- | | `suite.toml` | 47 | The first five-repository suite, including Compass's compact and paged projections | +| `suite_natural.toml` | 25 | Same natural-language question on both tools, source-reviewed v2 oracles, 800-token pages, no follow-ups | | `suite_v2.toml` | 50 | A blackbox-fair extension: same questions for both tools, default output forms, no tool-specific projections | | `suite_fd.toml` | 12 | Separate pinned `sharkdp/fd` sample, recorded from source before either tool's first extraction/query run | | `suite_ask.toml` | 10 | Same natural-language caller/callee questions and 2,000-token budget for Compass `ask` and Graphify `query` across five languages | diff --git a/benchmarks/agent_query/suite_natural.toml b/benchmarks/agent_query/suite_natural.toml new file mode 100644 index 00000000..a9e9ff7e --- /dev/null +++ b/benchmarks/agent_query/suite_natural.toml @@ -0,0 +1,430 @@ +# Natural-language qualification: 25 source-reviewed questions, five languages. +# Reuses the pinned sources and unchanged answer oracles from suite_v2.toml. +# Both tools receive the same question through their public query command. +# No continuations: the first answer must contain the reviewed result. +schema = "compass.agent-query-suite/1" + +[[repository]] +name = "cobra" +language = "Go" +url = "https://github.com/spf13/cobra.git" +commit = "adbc8813901bba65827259daa8e22ff94ec1f30e" + +[[repository.anchor]] +file = "command.go" +line = 1868 +symbol = "ParseFlags" +judgment = "command.go:1868 declares func (c *Command) ParseFlags(args []string) error." + +[[repository.anchor]] +file = "command.go" +line = 757 +symbol = "Find" +judgment = "command.go:757 declares func (c *Command) Find(args []string) (*Command, []string, error)." + +[[repository.anchor]] +file = "doc/md_docs.go" +line = 52 +symbol = "GenMarkdown" +judgment = "doc/md_docs.go:52 declares GenMarkdown, which consumes the cobra package it imports at line 27." + +[[repository.question]] +id = "cobra-natural-callers" +kind = "broad" +subject = "who uses cobra.Command::Find?" +compass = ["query", "who uses cobra.Command::Find?"] +graphify = ["query", "who uses cobra.Command::Find?"] +expect = "answer" +required = ["ExecuteC", "command.go"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "Find is called by ExecuteC at command.go:1123 and by InitDefaultHelpCmd (command.go:1263) at command.go:1276 and command.go:1294." + +[[repository.question]] +id = "cobra-natural-callees" +kind = "broad" +subject = "what does cobra.Command::ExecuteC depend on?" +compass = ["query", "what does cobra.Command::ExecuteC depend on?"] +graphify = ["query", "what does cobra.Command::ExecuteC depend on?"] +expect = "answer" +required = ["Find", "execute", "Traverse"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "ExecuteC calls Traverse (command.go:1121), Find (command.go:1123) and execute (command.go:1148)." + +[[repository.question]] +id = "cobra-natural-impact" +kind = "broad" +subject = "what breaks if cobra.Command::ParseFlags changes?" +compass = ["query", "what breaks if cobra.Command::ParseFlags changes?"] +graphify = ["query", "what breaks if cobra.Command::ParseFlags changes?"] +expect = "answer" +required = ["execute", "Traverse"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "ParseFlags is called directly by Traverse (command.go:854), execute (command.go:919) and getCompletions (completions.go:372); execute is called by ExecuteC at command.go:1148. The oracle requires the reviewed direct callers because the depth-three closure is 945 retained dependents: requiring one specific depth-two node would reward a four-row answer over a complete one." + +[[repository.question]] +id = "cobra-natural-path" +kind = "broad" +subject = "how does cobra.Command::Find relate to cobra.legacyArgs?" +compass = ["query", "how does cobra.Command::Find relate to cobra.legacyArgs?"] +graphify = ["query", "how does cobra.Command::Find relate to cobra.legacyArgs?"] +expect = "answer" +required = ["Find", "legacyArgs"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "Find returns legacyArgs(commandFound, stripFlags(a, commandFound)) at command.go:776; args.go:28 declares legacyArgs." +forbidden = ["NO PATH FOUND", "No directed path found", "No path found"] + +[[repository.question]] +id = "cobra-natural-broad" +kind = "broad" +subject = "how does cobra resolve a subcommand name and then run the resolved command" +compass = ["query", "how does cobra resolve a subcommand name and then run the resolved command"] +graphify = ["query", "how does cobra resolve a subcommand name and then run the resolved command"] +expect = "answer" +required = ["Find", "execute"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "Find (command.go:757) resolves the target command; execute (command.go:905) runs it." + +[[repository]] +name = "flask" +language = "Python" +url = "https://github.com/pallets/flask.git" +commit = "d73fa1cdcbd8b1465c151db8924ba58b1dd14e35" + +[[repository.anchor]] +file = "src/flask/app.py" +line = 969 +symbol = "dispatch_request" +judgment = "src/flask/app.py:969 declares Flask.dispatch_request." + +[[repository.anchor]] +file = "src/flask/ctx.py" +line = 260 +symbol = "AppContext" +judgment = "src/flask/ctx.py:260 declares AppContext." + +[[repository.anchor]] +file = "src/flask/views.py" +line = 78 +symbol = "View.dispatch_request" +judgment = "src/flask/views.py:78 declares View.dispatch_request." + +[[repository.question]] +id = "flask-natural-callers" +kind = "broad" +subject = "who uses src.flask.app.Flask::full_dispatch_request?" +compass = ["query", "who uses src.flask.app.Flask::full_dispatch_request?"] +graphify = ["query", "who uses src.flask.app.Flask::full_dispatch_request?"] +expect = "answer" +required = ["wsgi_app"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "wsgi_app calls self.full_dispatch_request(ctx) at src/flask/app.py:1600." + +[[repository.question]] +id = "flask-natural-callees" +kind = "broad" +subject = "what does src.flask.app.Flask::full_dispatch_request depend on?" +compass = ["query", "what does src.flask.app.Flask::full_dispatch_request depend on?"] +graphify = ["query", "what does src.flask.app.Flask::full_dispatch_request depend on?"] +expect = "answer" +required = ["preprocess_request", "dispatch_request", "finalize_request"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "full_dispatch_request calls preprocess_request (app.py:1017), dispatch_request (1019) and finalize_request (1022)." + +[[repository.question]] +id = "flask-natural-impact" +kind = "broad" +subject = "what breaks if src.flask.app.Flask::full_dispatch_request changes?" +compass = ["query", "what breaks if src.flask.app.Flask::full_dispatch_request changes?"] +graphify = ["query", "what breaks if src.flask.app.Flask::full_dispatch_request changes?"] +expect = "answer" +required = ["wsgi_app", "__call__"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "wsgi_app calls full_dispatch_request at app.py:1600, and Flask.__call__ (app.py:1621) calls wsgi_app at app.py:1628." + +[[repository.question]] +id = "flask-natural-path" +kind = "broad" +subject = "how does src.flask.app.Flask::full_dispatch_request relate to src.flask.app.Flask::finalize_request?" +compass = ["query", "how does src.flask.app.Flask::full_dispatch_request relate to src.flask.app.Flask::finalize_request?"] +graphify = ["query", "how does src.flask.app.Flask::full_dispatch_request relate to src.flask.app.Flask::finalize_request?"] +expect = "answer" +required = ["full_dispatch_request", "finalize_request"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "full_dispatch_request calls self.finalize_request(ctx, rv) at src/flask/app.py:1022, which is declared at line 1024." +forbidden = ["NO PATH FOUND", "No directed path found", "No path found"] + +[[repository.question]] +id = "flask-natural-broad" +kind = "broad" +subject = "how does flask turn a view function into a url rule" +compass = ["query", "how does flask turn a view function into a url rule"] +graphify = ["query", "how does flask turn a view function into a url rule"] +expect = "answer" +required = ["add_url_rule", "scaffold.py"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "Scaffold.route (src/flask/sansio/scaffold.py:344) calls self.add_url_rule at line 370, and add_url_rule is declared at src/flask/sansio/app.py:605." + +[[repository]] +name = "gson" +language = "Java" +url = "https://github.com/google/gson.git" +commit = "15ca7360379cf3c1502b59981569050489f2d73e" + +[[repository.anchor]] +file = "gson/src/main/java/com/google/gson/Gson.java" +line = 565 +symbol = "toJson(Object)" +judgment = "Gson.java:565 declares public String toJson(Object src)." + +[[repository.anchor]] +file = "gson/src/main/java/com/google/gson/stream/JsonWriter.java" +line = 527 +symbol = "value(String)" +judgment = "JsonWriter.java:527 declares public JsonWriter value(String value)." + +[[repository.anchor]] +file = "gson/src/main/java/com/google/gson/TypeAdapter.java" +line = 143 +symbol = "toJson(Writer, T)" +judgment = "TypeAdapter.java:143 declares final void toJson(Writer out, T value)." + +[[repository.question]] +id = "gson-natural-callers" +kind = "broad" +subject = "who uses com.google.gson.Gson::newJsonWriter?" +compass = ["query", "who uses com.google.gson.Gson::newJsonWriter?"] +graphify = ["query", "who uses com.google.gson.Gson::newJsonWriter?"] +expect = "answer" +required = ["toJson", "Gson.java"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "newJsonWriter is called by the toJson overloads at Gson.java:642 and Gson.java:724." + +[[repository.question]] +id = "gson-natural-callees" +kind = "broad" +subject = "what does com.google.gson.Gson::newJsonWriter depend on?" +compass = ["query", "what does com.google.gson.Gson::newJsonWriter depend on?"] +graphify = ["query", "what does com.google.gson.Gson::newJsonWriter depend on?"] +expect = "answer" +required = ["setHtmlSafe", "setSerializeNulls"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "newJsonWriter calls setFormattingStyle (Gson.java:802), setHtmlSafe (803), setStrictness (804) and setSerializeNulls (805) on the writer it returns." + +[[repository.question]] +id = "gson-natural-impact" +kind = "broad" +subject = "what breaks if com.google.gson.Gson::newJsonWriter changes?" +compass = ["query", "what breaks if com.google.gson.Gson::newJsonWriter changes?"] +graphify = ["query", "what breaks if com.google.gson.Gson::newJsonWriter changes?"] +expect = "answer" +required = ["toJson"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "newJsonWriter is reached from the Gson.toJson overloads (Gson.java:642, 724) that serialize to an Appendable." + +[[repository.question]] +id = "gson-natural-path" +kind = "broad" +subject = "how does com.google.gson.Gson::newJsonWriter relate to com.google.gson.stream.JsonWriter?" +compass = ["query", "how does com.google.gson.Gson::newJsonWriter relate to com.google.gson.stream.JsonWriter?"] +graphify = ["query", "how does com.google.gson.Gson::newJsonWriter relate to com.google.gson.stream.JsonWriter?"] +expect = "answer" +required = ["JsonWriter"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "newJsonWriter constructs and returns a JsonWriter (Gson.java:801), the class declared at stream/JsonWriter.java:162." +forbidden = ["NO PATH FOUND", "No directed path found", "No path found"] + +[[repository.question]] +id = "gson-natural-broad" +kind = "broad" +subject = "how does gson read json into an object" +compass = ["query", "how does gson read json into an object"] +graphify = ["query", "how does gson read json into an object"] +expect = "answer" +required = ["fromJson", "Gson.java"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "The reviewed entry points are the Gson.fromJson overloads (Gson.java:850 String+Class, 879 String+Type, 909 String+TypeToken, 940 Reader+Class); TypeAdapter.read is the collaborator rather than required content." + +[[repository]] +name = "zod" +language = "TypeScript" +url = "https://github.com/colinhacks/zod.git" +commit = "d2b135cfb7a3582b9eb515756b9166bcb9521f4a" + +[[repository.anchor]] +file = "packages/zod/src/v4/classic/schemas.ts" +line = 303 +symbol = "ZodType.safeParse" +judgment = "packages/zod/src/v4/classic/schemas.ts:303 implements ZodType.safeParse." + +[[repository.anchor]] +file = "packages/zod/src/v4/classic/from-json-schema.ts" +line = 105 +symbol = "detectVersion" +judgment = "packages/zod/src/v4/classic/from-json-schema.ts:105 declares detectVersion." + +[[repository.anchor]] +file = "packages/zod/src/v4/classic/parse.ts" +line = 22 +symbol = "safeParse" +judgment = "packages/zod/src/v4/classic/parse.ts:22 exports the classic safeParse entry point." + +[[repository.question]] +id = "zod-natural-callers" +kind = "broad" +subject = "who uses detectVersion?" +compass = ["query", "who uses detectVersion?"] +graphify = ["query", "who uses detectVersion?"] +expect = "answer" +required = ["fromJSONSchema", "from-json-schema.ts"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "fromJSONSchema calls detectVersion(normalized, params?.defaultTarget) at from-json-schema.ts:931." + +[[repository.question]] +id = "zod-natural-callees" +kind = "broad" +subject = "what does convertSchema depend on?" +compass = ["query", "what does convertSchema depend on?"] +graphify = ["query", "what does convertSchema depend on?"] +expect = "answer" +required = ["convertBaseSchema"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "convertSchema calls convertBaseSchema(schema, ctx) at from-json-schema.ts:814." + +[[repository.question]] +id = "zod-natural-impact" +kind = "broad" +subject = "what breaks if detectVersion changes?" +compass = ["query", "what breaks if detectVersion changes?"] +graphify = ["query", "what breaks if detectVersion changes?"] +expect = "answer" +required = ["fromJSONSchema"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "detectVersion is called by fromJSONSchema (from-json-schema.ts:917), the public JSON Schema entry point." + +[[repository.question]] +id = "zod-natural-path" +kind = "broad" +subject = "how does detectVersion relate to convertSchema?" +compass = ["query", "how does detectVersion relate to convertSchema?"] +graphify = ["query", "how does detectVersion relate to convertSchema?"] +expect = "answer" +required = ["detectVersion", "convertSchema"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "fromJSONSchema calls detectVersion (from-json-schema.ts:931) and convertSchema (line 952), connecting the two." +forbidden = ["NO PATH FOUND", "No directed path found", "No path found"] + +[[repository.question]] +id = "zod-natural-broad" +kind = "broad" +subject = "how does zod convert a json schema into a zod schema" +compass = ["query", "how does zod convert a json schema into a zod schema"] +graphify = ["query", "how does zod convert a json schema into a zod schema"] +expect = "answer" +required = ["fromJSONSchema", "convertSchema"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "fromJSONSchema (from-json-schema.ts:917) validates the version with detectVersion and converts through convertSchema (808)." + +[[repository]] +name = "axum" +language = "Rust" +url = "https://github.com/tokio-rs/axum.git" +commit = "af1345b53a259b0990be1ff853f9b56c05040ef7" + +[[repository.anchor]] +file = "src/routing/mod.rs" +line = 192 +symbol = "Router::route" +judgment = "src/routing/mod.rs:192 declares pub fn route(self, path: &str, method_router: MethodRouter) -> Self." + +[[repository.anchor]] +file = "src/routing/path_router.rs" +line = 22 +symbol = "validate_path" +judgment = "src/routing/path_router.rs:22 declares fn validate_path." + +[[repository.anchor]] +file = "src/serve/mod.rs" +line = 106 +symbol = "serve" +judgment = "src/serve/mod.rs:106 declares pub fn serve." + +[[repository.question]] +id = "axum-natural-callers" +kind = "broad" +subject = "who uses validate_path?" +compass = ["query", "who uses validate_path?"] +graphify = ["query", "who uses validate_path?"] +expect = "answer" +required = ["route"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "validate_path is called by PathRouter::route at path_router.rs:71 and PathRouter::route_endpoint at line 125." + +[[repository.question]] +id = "axum-natural-callees" +kind = "broad" +subject = "what does validate_path depend on?" +compass = ["query", "what does validate_path depend on?"] +graphify = ["query", "what does validate_path depend on?"] +expect = "answer" +required = ["validate_v07_paths"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "validate_path (path_router.rs:22) calls validate_v07_paths(path) at path_router.rs:30, declared at path_router.rs:36." + +[[repository.question]] +id = "axum-natural-impact" +kind = "broad" +subject = "what breaks if validate_path changes?" +compass = ["query", "what breaks if validate_path changes?"] +graphify = ["query", "what breaks if validate_path changes?"] +expect = "answer" +required = ["route", "route_endpoint"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "validate_path is called by PathRouter::route (path_router.rs:71) and PathRouter::route_endpoint (line 125); PathRouter::route is itself called by merge (line 164) and nest (line 201)." + +[[repository.question]] +id = "axum-natural-path" +kind = "broad" +subject = "how does validate_path relate to validate_v07_paths?" +compass = ["query", "how does validate_path relate to validate_v07_paths?"] +graphify = ["query", "how does validate_path relate to validate_v07_paths?"] +expect = "answer" +required = ["validate_v07_paths"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "validate_path calls validate_v07_paths at src/routing/path_router.rs:30, declared at src/routing/path_router.rs:36." +forbidden = ["NO PATH FOUND", "No directed path found", "No path found"] + +[[repository.question]] +id = "axum-natural-broad" +kind = "broad" +subject = "how does axum route an incoming request to a handler" +compass = ["query", "how does axum route an incoming request to a handler"] +graphify = ["query", "how does axum route an incoming request to a handler"] +expect = "answer" +required = ["Router", "MethodRouter"] +budget_tokens = 800 +max_follow_ups = 0 +judgment = "Router (routing/mod.rs) stores MethodRouter values in its PathRouter, and routing hands the request to the matched MethodRouter." diff --git a/crates/compass-cli/src/code_query_commands.rs b/crates/compass-cli/src/code_query_commands.rs index d995a1ec..41d359ee 100644 --- a/crates/compass-cli/src/code_query_commands.rs +++ b/crates/compass-cli/src/code_query_commands.rs @@ -282,6 +282,7 @@ fn execute( let role = match plan.intent() { NaturalQueryIntent::Callers | NaturalQueryIntent::Callees + | NaturalQueryIntent::Dependencies | NaturalQueryIntent::Impact => AgentOperandRole::Symbol, NaturalQueryIntent::NodeTrail if index == 0 => AgentOperandRole::Source, NaturalQueryIntent::NodeTrail => AgentOperandRole::Target, diff --git a/crates/compass-cli/src/lib.rs b/crates/compass-cli/src/lib.rs index fb74cbab..306d22a4 100644 --- a/crates/compass-cli/src/lib.rs +++ b/crates/compass-cli/src/lib.rs @@ -6167,6 +6167,8 @@ fn discovery_query( open_code_query(&graph, None, &cache).map_err(|error| error.to_string())?; let graph_identity = engine.build_generation_identity().to_owned(); let graph_digest = engine.graph_identity().to_owned(); + let engine = engine + .with_deadline(Instant::now() + Duration::from_millis(request.limits.timeout_ms)); let response = engine .discover(request) .map_err(|error| error.to_string())?; @@ -6199,6 +6201,8 @@ fn discovery_query( .map_err(|error| error.to_string())?; let graph_identity = engine.build_generation_identity().to_owned(); let graph_digest = engine.graph_identity().to_owned(); + let engine = engine + .with_deadline(Instant::now() + Duration::from_millis(request.limits.timeout_ms)); let response = engine .discover(request) .map_err(|error| error.to_string())?; @@ -7283,7 +7287,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] [--evidence] [--format text|agent-json|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." + "Usage: {prefix} query \"\" [--direction auto|incoming|outgoing|both] [--scope KIND:VALUE] [--context VALUE] [--dfs] [--evidence] [--format text|agent-json|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: 800]\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( diff --git a/crates/compass-cli/tests/code_query_cli.rs b/crates/compass-cli/tests/code_query_cli.rs index 8b67e7eb..c160dc6d 100644 --- a/crates/compass-cli/tests/code_query_cli.rs +++ b/crates/compass-cli/tests/code_query_cli.rs @@ -74,6 +74,39 @@ fn ask_preserves_typed_operands_in_agent_and_text_answers() -> Result<(), Box Result<(), Box> +{ + let directory = tempfile::tempdir()?; + let graph = support::write_typed_graph(directory.path())?; + let outcome = run( + Frontend::Compass, + [ + OsString::from("ask"), + OsString::from("what does Caller depend on?"), + OsString::from("--graph"), + graph.as_os_str().to_owned(), + OsString::from("--format"), + OsString::from("agent-json"), + ], + ); + assert_eq!(outcome.code, 0, "{}", outcome.stderr); + let view = AgentQueryView::from_json(outcome.stdout.as_bytes())?; + assert_eq!(view.request.operation, AgentOperation::Explore); + assert_eq!(view.request.operands.len(), 1); + assert_eq!( + view.request.operands[0].role, + compass_output::AgentOperandRole::Symbol + ); + assert_eq!(view.request.operands[0].value, "Caller"); + assert!( + view.relationships + .iter() + .any(|edge| { edge.source.id == "n:caller" && edge.target.id == "n:target" }) + ); + Ok(()) +} + #[test] fn ask_resolves_a_unique_owner_suffix_without_guessing_a_short_name() -> Result<(), Box> { @@ -95,7 +128,7 @@ fn ask_resolves_a_unique_owner_suffix_without_guessing_a_short_name() -> Result< for (question, expected_code, expected_edges) in [ ("what does Controller.Caller call?", None, 1), ("what does Controller::Caller call?", None, 1), - ("what does Caller call?", Some("ambiguous_match"), 0), + ("what does Caller call?", Some("ambiguous_match"), 1), ("what does Missing.Caller call?", Some("no_match"), 0), ("what does Missing::Caller call?", Some("no_match"), 0), ] { @@ -978,7 +1011,7 @@ fn natural_discovery_help_documents_only_the_public_contract() { "--format ", "--result-envelope", "--text-budget ", - "default: 8000", + "default: 800", "--evidence", "full provenance", "--cursor ", @@ -1266,7 +1299,8 @@ fn natural_and_typed_queries_signal_missing_exact_matches_before_fallbacks() ); assert_eq!(outcome.code, 0, "{command}: {}", outcome.stderr); assert!( - outcome.stdout.starts_with("RESULT no_match"), + (outcome.stdout.starts_with("RESULT candidates") + || outcome.stdout.starts_with("RESULT answered")), "{command}: {}", outcome.stdout ); @@ -1792,11 +1826,7 @@ fn ambiguous_typed_lookup_returns_a_pick_list_instead_of_an_empty_result() "{}", outcome.stdout ); - for argv in [ - vec!["callers", "run"], - vec!["callees", "run"], - vec!["ask", "who calls run"], - ] { + for argv in [vec!["callers", "run"], vec!["callees", "run"]] { let mut args = argv.into_iter().map(OsString::from).collect::>(); args.extend([OsString::from("--graph"), graph.as_os_str().to_owned()]); let text = run(Frontend::Compass, args); @@ -1815,6 +1845,29 @@ fn ambiguous_typed_lookup_returns_a_pick_list_instead_of_an_empty_result() ); assert!(!text.stdout.contains("No exact match"), "{}", text.stdout); } + let auto_picked = run( + Frontend::Compass, + [ + OsString::from("ask"), + OsString::from("who calls run"), + OsString::from("--graph"), + graph.as_os_str().to_owned(), + OsString::from("--format"), + OsString::from("agent-json"), + ], + ); + assert_eq!(auto_picked.code, 0, "{}", auto_picked.stderr); + let view: Value = serde_json::from_str(&auto_picked.stdout)?; + assert_eq!(view["status"]["resultState"], "candidates"); + assert_eq!(view["status"]["matchState"], "ambiguous"); + assert!(view["answer"]["headline"].as_str().is_some_and(|text| { + text.contains("Auto-picked") && text.contains("multiple candidates") + })); + assert!(view["caveats"].as_array().is_some_and(|caveats| { + caveats + .iter() + .any(|caveat| caveat["nodeId"] == "n:alpha-run") + })); Ok(()) } diff --git a/crates/compass-output/src/agent_query.rs b/crates/compass-output/src/agent_query.rs index 3205dd01..e1b331db 100644 --- a/crates/compass-output/src/agent_query.rs +++ b/crates/compass-output/src/agent_query.rs @@ -712,14 +712,22 @@ pub fn build_discovery_query_view( ); let match_state = if ambiguous { AgentMatch::Ambiguous - } else if no_match { + } else if no_match && response.seeds.is_empty() { AgentMatch::None + } else if !response.seeds.is_empty() + && response.seeds.iter().all(|seed| { + matches!( + seed.candidate_source, + compass_model::query_contract::DiscoverySeedSource::ExactId + | compass_model::query_contract::DiscoverySeedSource::ExactName + ) + }) + { + AgentMatch::Exact } else { AgentMatch::Fuzzy }; - let result_state = if ambiguous { - AgentResultState::NeedsResolution - } else if no_match { + let result_state = if no_match && response.seeds.is_empty() { AgentResultState::NoMatch } else { AgentResultState::Candidates @@ -744,13 +752,33 @@ pub fn build_discovery_query_view( } else { AgentCoverage::Unknown }; - let answer = answer_for_discovery( + let mut answer = answer_for_discovery( result_state, &response.question, response.seeds.len(), relationships.len(), &primary_results, ); + if !response.seeds.is_empty() { + let labels = response + .seeds + .iter() + .filter_map(|seed| nodes.get(&seed.node_id)) + .map(|node| display_label(node)) + .collect::>() + .join(", "); + let basis = if ambiguous { + "Auto-picked" + } else if match_state == AgentMatch::Exact { + "Exact" + } else { + "Approximate — reached by traversal from" + }; + answer.headline = format!( + "{basis}: {labels}; {} relationship(s).", + response.edges.len() + ); + } let next_actions = next_actions_for_discovery(&context, &primary_results, source_truncated); let mut view = AgentQueryView { schema: AGENT_QUERY_VIEW_SCHEMA.to_owned(), @@ -2469,6 +2497,9 @@ fn resolution_rank(value: ResolutionState) -> u8 { fn agent_caveat(diagnostic: &QueryDiagnostic) -> AgentCaveat { let (severity, statement) = match diagnostic.code { + QueryDiagnosticCode::AmbiguousMatch if diagnostic.node_id.is_some() => { + (AgentSeverity::Warning, diagnostic.message.clone()) + } QueryDiagnosticCode::AmbiguousMatch => ( AgentSeverity::Blocker, format!( @@ -2483,6 +2514,9 @@ fn agent_caveat(diagnostic: &QueryDiagnostic) -> AgentCaveat { diagnostic.message ), ), + QueryDiagnosticCode::NoMatch if diagnostic.node_id.is_some() => { + (AgentSeverity::Warning, diagnostic.message.clone()) + } QueryDiagnosticCode::NoMatch => ( AgentSeverity::Blocker, format!( @@ -2584,7 +2618,17 @@ fn code_match_state( response.diagnostics.as_slice(), QueryDiagnosticCode::NoMatch, ) { - return AgentMatch::None; + return if response.diagnostics.iter().any(|diagnostic| { + diagnostic.code == QueryDiagnosticCode::NoMatch + && diagnostic + .node_id + .as_ref() + .is_some_and(|id| nodes.contains_key(id)) + }) { + AgentMatch::Fuzzy + } else { + AgentMatch::None + }; } if response.operation == CodeQueryOperation::Search { let query = context @@ -2631,7 +2675,17 @@ fn code_result_state( response: &CodeQueryResponse, ) -> AgentResultState { if match_state == AgentMatch::Ambiguous { - return AgentResultState::NeedsResolution; + return if response.diagnostics.iter().any(|diagnostic| { + diagnostic.code == QueryDiagnosticCode::AmbiguousMatch + && diagnostic + .node_id + .as_ref() + .is_some_and(|id| response.nodes.iter().any(|node| &node.id == id)) + }) { + AgentResultState::Candidates + } else { + AgentResultState::NeedsResolution + }; } if match_state == AgentMatch::None { return AgentResultState::NoMatch; @@ -2705,6 +2759,26 @@ fn answer_for_code( }], }; } + if match_state == AgentMatch::Ambiguous + && let Some(selected) = response.diagnostics.iter().find_map(|diagnostic| { + (diagnostic.code == QueryDiagnosticCode::AmbiguousMatch) + .then_some(diagnostic.node_id.as_ref()) + .flatten() + .and_then(|id| response.nodes.iter().find(|node| &node.id == id)) + }) + { + return AgentAnswer { + headline: format!( + "Auto-picked {} from multiple candidates; returned {} relationship(s).", + display_label(selected), + response.edges.len() + ), + basis: vec![AgentBasis { + kind: "operation".to_owned(), + id: context.operation.label().to_owned(), + }], + }; + } let subject = primary_results .first() .map(|entity| entity.label.clone()) diff --git a/crates/compass-output/tests/agent_query.rs b/crates/compass-output/tests/agent_query.rs index a04ba64d..45e8bc7c 100644 --- a/crates/compass-output/tests/agent_query.rs +++ b/crates/compass-output/tests/agent_query.rs @@ -853,6 +853,47 @@ fn legacy_page_cursor_encoding_is_rejected_with_a_version_error() -> Result<(), Ok(()) } +#[test] +fn auto_picked_ambiguity_is_a_valid_candidate_answer_with_retained_relationships() +-> Result<(), Box> { + let source = anchor("src/lib.rs", 1); + let mut response = response(CodeQueryOperation::Callees); + response.nodes = vec![ + node("n:chosen", "Chosen.run", &source), + node("n:target", "Target", &source), + ]; + response.edges.push(QueryEdge { + id: "e:chosen-target".to_owned(), + source: "n:chosen".to_owned(), + target: "n:target".to_owned(), + kind: EdgeKind::Calls, + relationship_site: Some(source.clone()), + details: None, + evidence: vec![evidence(&source)], + }); + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::AmbiguousMatch, + message: "Auto-picked Chosen.run; also matched Other.run".to_owned(), + node_id: Some("n:chosen".to_owned()), + path: None, + }); + let view = build_code_query_view( + &response, + context(AgentOperation::Callees) + .with_operand(compass_output::AgentOperandRole::Symbol, "run"), + )?; + assert_eq!(view.status.result_state, AgentResultState::Candidates); + assert_eq!(view.status.match_state, AgentMatch::Ambiguous); + assert!( + view.answer + .headline + .contains("Auto-picked Fixture.Chosen.run") + ); + assert_eq!(view.relationships.len(), 1); + assert!(render_agent_query_text(&view)?.contains("also matched Other.run")); + Ok(()) +} + #[test] fn ambiguity_never_selects_a_headline_subject_or_claims_no_path() -> Result<(), Box> { for (operation, agent_operation) in [ diff --git a/crates/compass-query/src/code_query.rs b/crates/compass-query/src/code_query.rs index fa3b9c10..1c6a1fcb 100644 --- a/crates/compass-query/src/code_query.rs +++ b/crates/compass-query/src/code_query.rs @@ -62,6 +62,12 @@ const RELATIONSHIP_TERM_LIMIT: usize = 128; const RELATIONSHIP_SOURCE_EDGE_SCAN_LIMIT: usize = 256; type ContainmentPath = (Vec, Vec); +pub(crate) struct OwnerQualifiedCandidates { + pub(crate) nodes: Vec, + pub(crate) truncated: bool, + pub(crate) has_leaf_candidates: bool, +} + const ALL_EDGE_KINDS: &[EdgeKind] = &[ EdgeKind::Contains, EdgeKind::Embeds, @@ -1242,7 +1248,14 @@ impl PinnedDiscoveryBackend<'_> { }) } Self::Store(reader) => { - let read_limits = snapshot_limits(limit)?; + // Store postings are encoded in chunks. Decode at least one + // whole chunk, then apply the caller's logical candidate cap. + // A sub-chunk cap otherwise reports truncation even when the + // posting does not exist, prematurely stopping impact walks. + let read_envelope = limit + .div_ceil(GRAPH_TERM_POSTING_CHUNK_ITEMS) + .saturating_mul(GRAPH_TERM_POSTING_CHUNK_ITEMS); + let read_limits = snapshot_limits(read_envelope)?; let (mut source_ids, truncated, work) = if reader .supports_relationship_search_terms() .map_err(snapshot_error)? @@ -1556,6 +1569,32 @@ fn sort_edge_indices(edges: &mut [usize], graph: &GraphDocument) { } impl CodeQueryEngine { + pub(crate) fn finish_natural_response( + &self, + mut response: CodeQueryResponse, + ) -> Result { + response.sort_stable(); + self.enforce_graph_bounds(&mut response); + if response.truncated + && !response + .diagnostics + .iter() + .any(|d| d.code == QueryDiagnosticCode::BoundedTruncation) + { + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::BoundedTruncation, + message: "Natural-language candidate or response bounds withheld results" + .to_owned(), + node_id: None, + path: None, + }); + } + response.sort_stable(); + response.diagnostics.dedup(); + enforce_response_size(&mut response)?; + Ok(response) + } + /// Bound every following typed query with an absolute deadline. /// /// The CLI arms one deadline per command and reuses it across retries so a @@ -3068,7 +3107,10 @@ impl CodeQueryEngine { queue.push_back((edge.source.clone(), nodes, edges)); } } - if response.truncated { + // A withheld owner-level posting or over-depth trail does not + // exhaust the graph budget. Continue the already witnessed direct + // frontier so unrelated partial coverage cannot hide its callers. + if selected_edges.len() >= max_edges || included_nodes.len() >= max_nodes { break; } } @@ -3311,6 +3353,43 @@ impl CodeQueryEngine { &self.build_generation_identity } + pub(crate) fn owner_qualified_candidates( + &self, + normalized: &str, + candidate_limit: usize, + instrumentation: &mut QueryInstrumentation, + ) -> Result, QueryError> { + self.check_deadline()?; + let qualified_query = normalized.replace("::", "."); + let Some((_, leaf)) = qualified_query.rsplit_once('.') else { + return Ok(None); + }; + if leaf.is_empty() { + return Ok(None); + } + let (leaf_nodes, truncated) = self + .backend + .nodes_by_normalized_name(leaf, candidate_limit)?; + instrumentation.work.candidates_read = instrumentation + .work + .candidates_read + .saturating_add(u64::try_from(leaf_nodes.len()).unwrap_or(u64::MAX)); + let has_leaf_candidates = !leaf_nodes.is_empty(); + let suffix = format!(".{qualified_query}"); + let nodes = leaf_nodes + .into_iter() + .filter(|node| { + let qualified_name = normalize_symbol(&node.qualified_name).replace("::", "."); + qualified_name == qualified_query || qualified_name.ends_with(&suffix) + }) + .collect(); + Ok(Some(OwnerQualifiedCandidates { + nodes, + truncated, + has_leaf_candidates, + })) + } + fn resolve_symbol( &self, query: &str, @@ -3337,28 +3416,14 @@ impl CodeQueryEngine { // prefix or the stored `::` separator. Verify that suffix against the // complete bounded leaf-name posting, never against a ranked prefix. // If the posting is truncated, uniqueness cannot be proved. - let qualified_query = normalized.replace("::", "."); if exact_nodes.is_empty() && !exact_truncated - && let Some((_, leaf)) = qualified_query.rsplit_once('.') - && !leaf.is_empty() + && let Some(qualified_candidates) = + self.owner_qualified_candidates(&normalized, candidate_limit, instrumentation)? { - let (leaf_nodes, leaf_truncated) = self - .backend - .nodes_by_normalized_name(leaf, candidate_limit)?; - instrumentation.work.candidates_read = instrumentation - .work - .candidates_read - .saturating_add(u64::try_from(leaf_nodes.len()).unwrap_or(u64::MAX)); - let leaf_has_exact_candidates = !leaf_nodes.is_empty(); - let suffix = format!(".{qualified_query}"); - let qualified = leaf_nodes - .into_iter() - .filter(|node| { - let qualified_name = normalize_symbol(&node.qualified_name).replace("::", "."); - qualified_name == qualified_query || qualified_name.ends_with(&suffix) - }) - .collect::>(); + let qualified = qualified_candidates.nodes; + let leaf_truncated = qualified_candidates.truncated; + let leaf_has_exact_candidates = qualified_candidates.has_leaf_candidates; if leaf_truncated { response.truncated = true; response.diagnostics.push(QueryDiagnostic { diff --git a/crates/compass-query/src/discovery.rs b/crates/compass-query/src/discovery.rs index 88ba5583..ddd23c60 100644 --- a/crates/compass-query/src/discovery.rs +++ b/crates/compass-query/src/discovery.rs @@ -130,6 +130,215 @@ impl<'a> DiscoveryGuard<'a> { } impl CodeQueryEngine { + fn structured_discovery_answer( + &self, + request: &DiscoveryQueryRequest, + ) -> Result, QueryError> { + use crate::intent::{NaturalQueryIntent, NaturalQueryRequest, plan_natural_query}; + use compass_model::query_contract::CodeQueryLimits; + // Explicit traversal controls remain authoritative. They use scoped + // discovery rather than silently losing their filters in a typed API. + if request.limits.max_expanded_relationships + < compass_model::query_contract::MAX_DISCOVERY_EXPANDED_RELATIONSHIPS + || request.direction != DiscoveryDirection::Auto + || !request.scope.is_empty() + || !request.relation_contexts.is_empty() + || request.traversal != compass_model::query_contract::DiscoveryTraversal::Bfs + { + return Ok(None); + } + let Ok(plan) = plan_natural_query(&request.question) else { + return Ok(None); + }; + if !plan.routes_to_typed_query() + || matches!( + plan.intent(), + NaturalQueryIntent::Search | NaturalQueryIntent::Fallback + ) + { + return Ok(None); + } + if plan.intent() == NaturalQueryIntent::NodeTrail && request.limits.max_seeds < 2 { + return Ok(None); + } + let (profiled, selections) = self.query_natural_with_selections(NaturalQueryRequest { + question: request.question.clone(), + include_heuristic: request.include_heuristic, + limits: CodeQueryLimits { + max_depth: request.limits.max_depth, + max_nodes: request.limits.max_nodes, + max_edges: request.limits.max_edges, + max_candidates: request.limits.max_candidates, + max_response_bytes: request.limits.max_response_bytes, + ..CodeQueryLimits::default() + }, + })?; + let typed = profiled.response; + if typed.nodes.is_empty() { + return Ok(None); + } + if profiled.profile.work.edges_expanded > request.limits.max_expanded_relationships { + return Err(QueryError::new( + QueryErrorKind::ExpansionLimit, + "natural_relationship_limit", + "Structured natural-language execution exceeded --max-expanded-relationships; increase the limit or narrow the question", + )); + } + let mut seeds = Vec::new(); + let backend = self.backend.pin_discovery()?; + for operand in plan.operands() { + let normalized = normalize_symbol(operand); + let selected = selections + .iter() + .find(|(original, _)| original == operand) + .and_then(|(_, id)| typed.nodes.iter().find(|node| &node.id == id)) + .or_else(|| { + typed.nodes.iter().find(|node| { + node.id == *operand + || normalize_symbol(&node.qualified_name) == normalized + || normalize_symbol(&node.name) == normalized + }) + }); + let Some(selected) = selected else { + continue; + }; + let id_match = selected.id == *operand; + let exact = id_match + || normalize_symbol(&selected.name) == normalized + || normalize_symbol(&selected.qualified_name) == normalized; + let (others, truncated) = backend.nodes_by_normalized_name(&normalized, 5)?; + let alternatives = others + .into_iter() + .filter(|node| node.id != selected.id) + .take(MAX_DISCOVERY_ALTERNATIVES_PER_SEED) + .map(|node| DiscoveryAlternative { + node_id: node.id, + qualified_name: node.qualified_name, + source: node.source, + score: "exact".to_owned(), + }) + .collect::>(); + seeds.push(DiscoverySeed { + node_id: selected.id.clone(), + score: if exact { "exact" } else { "approximate" }.to_owned(), + score_tier: if id_match { + DiscoveryScoreTier::ExactId + } else if exact { + DiscoveryScoreTier::ExactName + } else { + DiscoveryScoreTier::Lexical + }, + rank: u32::try_from(seeds.len() + 1).unwrap_or(u32::MAX), + matched_terms: vec![operand.clone()], + matched_fields: vec![format!("intent:{:?}", plan.intent())], + source: selected.source.clone(), + candidate_source: if id_match { + DiscoverySeedSource::ExactId + } else if exact { + DiscoverySeedSource::ExactName + } else { + DiscoverySeedSource::Fuzzy + }, + ambiguous: !alternatives.is_empty() || truncated, + alternatives, + }); + } + if seeds.is_empty() { + return Ok(None); + } + let ids = typed + .edges + .iter() + .map(|edge| edge.id.clone()) + .collect::>(); + let mut edge_records = backend.edges_by_ids(&ids)?; + // Keep the nearest dependency facts ahead of owner context on the + // first page. Exact IDs still break every tie deterministically. + let mut distances = seeds + .iter() + .map(|seed| (seed.node_id.clone(), 0_usize)) + .collect::>(); + let mut pending = distances.keys().cloned().collect::>(); + while let Some(node) = pending.pop_front() { + self.check_deadline()?; + let next_depth = distances[&node] + 1; + for edge in &edge_records { + let neighbor = if edge.source == node { + Some(&edge.target) + } else if edge.target == node { + Some(&edge.source) + } else { + None + }; + if let Some(neighbor) = neighbor + && !distances.contains_key(neighbor) + { + distances.insert(neighbor.clone(), next_depth); + pending.push_back(neighbor.clone()); + } + } + } + let edge_distance = |edge: &EdgeRecord| { + distances + .get(&edge.source) + .into_iter() + .chain(distances.get(&edge.target)) + .copied() + .min() + .unwrap_or(usize::MAX) + }; + edge_records.sort_by(|left, right| { + edge_distance(left) + .cmp(&edge_distance(right)) + .then_with(|| { + left.kind + .dependency_strength() + .cmp(&right.kind.dependency_strength()) + }) + .then_with(|| left.id.cmp(&right.id)) + }); + let edges = edge_records.iter().map(discovery_edge).collect(); + let (selected_direction, direction_source) = infer_discovery_direction(&request.question); + let mut diagnostics = typed.diagnostics; + if let Some(message) = &self.partial_graph_message { + diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::IncompleteCoverage, + message: message.clone(), + node_id: None, + path: None, + }); + } + let complete = !typed.truncated; + Ok(Some(DiscoveryQueryResponse { + schema: DISCOVERY_QUERY_SCHEMA_V1.to_owned(), + question: request.question.clone(), + selected_direction, + direction_source, + relation_contexts: Vec::new(), + scope: Vec::new(), + traversal: request.traversal, + seeds, + nodes: typed.nodes, + edges, + diagnostics, + limits: request.limits.clone(), + stats: DiscoveryStats { + candidate_nodes: profiled.profile.work.candidates_read, + expanded_relationships: profiled.profile.work.edges_expanded, + visited_nodes: profiled.profile.work.nodes_expanded, + ..DiscoveryStats::default() + }, + omissions: DiscoveryOmissions { + candidates: complete.then_some(0), + alternatives: None, + nodes: complete.then_some(0), + edges: complete.then_some(0), + expanded_relationships: complete.then_some(0), + }, + truncated: typed.truncated, + })) + } + /// Discover likely code-graph seeds and a bounded structural neighborhood. pub fn discover( &self, @@ -146,11 +355,26 @@ impl CodeQueryEngine { ) -> Result { validate_request(&request)?; let guard = DiscoveryGuard::new(request.limits.timeout_ms, cancelled); + guard.check()?; + if cancelled.is_none() + && let Some(mut response) = self.structured_discovery_answer(&request)? + { + finish_response(&guard, &mut response)?; + return Ok(response); + } + self.discover_neighborhood(request, &guard) + } + + fn discover_neighborhood( + &self, + request: DiscoveryQueryRequest, + guard: &DiscoveryGuard<'_>, + ) -> Result { guard.check()?; let backend = self.backend.pin_discovery()?; let relation_contexts = validate_and_normalize_contexts(&request.relation_contexts)?; - let resolved_scope = self.resolve_scopes(&backend, &request.scope, &guard)?; + 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( @@ -158,7 +382,7 @@ impl CodeQueryEngine { &exact_operands, &resolved_scope, &request.limits, - &guard, + guard, )?; let (selected_direction, direction_source) = match request.direction { DiscoveryDirection::Auto => infer_discovery_direction(&request.question), @@ -188,7 +412,7 @@ impl CodeQueryEngine { &response.scope, selected_direction, &request.limits, - &guard, + guard, )?; let trimmed_question = request.question.trim(); if exact_operands.is_empty() @@ -272,7 +496,7 @@ impl CodeQueryEngine { response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::AmbiguousMatch, message: format!( - "Seed {} is ambiguous; retry with an exact node ID or run `compass explain {}`", + "Auto-picked {}; also matched the listed alternatives. Traversal continues from this ranked seed; use `compass explain {}` to switch candidates", seed.node_id, seed.node_id ), node_id: Some(seed.node_id.clone()), @@ -312,11 +536,11 @@ impl CodeQueryEngine { path: None, }); }; - finish_response(&guard, &mut response)?; + finish_response(guard, &mut response)?; return Ok(response); } - self.expand_reference_neighborhood(&backend, &request, &guard, &mut response)?; + self.expand_reference_neighborhood(&backend, &request, guard, &mut response)?; if !backend.supports_identifier_subwords()? { response.diagnostics.push(QueryDiagnostic { code: QueryDiagnosticCode::IncompleteCoverage, @@ -333,7 +557,7 @@ impl CodeQueryEngine { path: None, }); } - finish_response(&guard, &mut response)?; + finish_response(guard, &mut response)?; Ok(response) } @@ -1494,7 +1718,26 @@ fn retain_specific_discovery_candidates( terms: &[String], ranked: &mut Vec, ) -> bool { - if crate::intent::plan_natural_query(question).is_ok_and(|plan| plan.routes_to_typed_query()) { + if let Ok(plan) = crate::intent::plan_natural_query(question) + && plan.routes_to_typed_query() + { + // A structural operand that failed typed resolution must not fall + // back to unrelated code sharing only a generic identifier subword. + if plan + .operands() + .iter() + .any(|operand| search_tokens(operand).len() >= 3) + { + ranked.retain(|candidate| { + plan.operands().iter().any(|operand| { + crate::natural_answers::natural_fuzzy_relevant( + operand, + &query_node(&candidate.node), + ) + }) + }); + return ranked.is_empty(); + } return false; } let distinct_terms = terms.iter().collect::>().len(); @@ -1812,6 +2055,12 @@ fn infer_discovery_direction(question: &str) -> (DiscoveryDirection, DiscoveryDi DiscoveryDirectionSource::Heuristic, ); } + crate::intent::NaturalQueryIntent::Dependencies => { + return ( + DiscoveryDirection::Outgoing, + DiscoveryDirectionSource::Heuristic, + ); + } crate::intent::NaturalQueryIntent::NodeTrail => { return ( DiscoveryDirection::Outgoing, @@ -2251,6 +2500,7 @@ fn finish_response( .then_with(|| left.message.cmp(&right.message)) .then_with(|| left.node_id.cmp(&right.node_id)) }); + response.diagnostics.dedup(); ensure_truncation_diagnostic(response); enforce_response_bytes(response)?; Ok(()) diff --git a/crates/compass-query/src/discovery_text.rs b/crates/compass-query/src/discovery_text.rs index e15aa4eb..8fd80573 100644 --- a/crates/compass-query/src/discovery_text.rs +++ b/crates/compass-query/src/discovery_text.rs @@ -12,8 +12,8 @@ use crate::text_cursor::{ CursorTokenError, decode_cursor_token, encode_cursor_token, is_cursor_digest, }; -pub const DISCOVERY_TEXT_PAGE_VERSION: &str = "compass.query.discovery-text-page/2"; -pub const DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET: usize = 8_000; +pub const DISCOVERY_TEXT_PAGE_VERSION: &str = "compass.query.discovery-text-page/3"; +pub const DEFAULT_DISCOVERY_TEXT_TOKEN_BUDGET: usize = 800; const MAX_CURSOR_BYTES: usize = 4_096; const MIN_TEXT_BUDGET: usize = 256; const MAX_TEXT_BUDGET: usize = 65_536; @@ -123,7 +123,7 @@ struct CursorEnvelope { } /// Cursor wire version. Older encodings fail with an explicit version error. -const CURSOR_WIRE_VERSION: u8 = 2; +const CURSOR_WIRE_VERSION: u8 = 3; /// Hex characters stored for each digest the cursor binds. const CURSOR_DIGEST_CHARS: usize = 16; /// Fields this cursor carries behind its wire version. @@ -213,7 +213,7 @@ pub fn render_discovery_text_page( /// Render a discovery page using caller-owned, already escaped prefix lines. /// /// Cursor validation and the ordered entry ledger remain owned by this crate; -/// the prefix is presentation-only and therefore cannot change a v2 cursor's +/// the prefix is presentation-only and therefore cannot change a v3 cursor's /// semantic position. The ordinary renderer above keeps the historical prefix /// for library callers that do not opt into the Agent View. pub fn render_discovery_text_page_with_prefix( @@ -577,15 +577,23 @@ fn entries(response: &DiscoveryQueryResponse, include_evidence: bool) -> Vec Vec Vec 0, + "alternatives" => 1, + "edges" => 2, + "nodes" => 3, + _ => 4, }); } entries @@ -752,7 +779,14 @@ fn match_signal_lines(response: &DiscoveryQueryResponse) -> Vec { && diagnostic.message.starts_with("NO EXACT MATCH") }) { return vec![ - "match_confidence: none".to_owned(), + format!( + "match_confidence: {}", + if response.seeds.is_empty() { + "none" + } else { + "approximate" + } + ), rendered_scalar(&diagnostic.message), ]; } @@ -1172,7 +1206,7 @@ mod tests { } #[test] - fn an_oversized_entry_is_truncated_instead_of_failing_the_page() + fn an_oversized_compact_diagnostic_is_capped_instead_of_failing_the_page() -> Result<(), Box> { let mut response = response()?; response.diagnostics[0].message = "x".repeat(4_000); @@ -1180,9 +1214,8 @@ mod tests { let page = render_discovery_text_page( &response, DiscoveryTextPageOptions { - // The minimum budget: the page's fixed lines plus one capped - // entry exceed it, so the oversized entry must be shortened - // instead of failing the page. + // The minimum budget must fit visibly capped compact + // diagnostics rather than failing on oversized source text. token_budget: MIN_TEXT_BUDGET, cursor: None, request_digest: &"a".repeat(64), @@ -1192,16 +1225,13 @@ mod tests { }, )?; assert!( - page.text - .contains("[truncated: entry exceeds --text-budget]"), - "entries {}..{} of {}: {}", - page.entry_start, - page.entry_end, - page.entry_total, - page.text.chars().take(400).collect::() + page.text.contains('…'), + "oversized scalar must be visibly capped" ); + assert!(page.text.chars().count() <= MIN_TEXT_BUDGET * 4); assert!(page.entry_end > page.entry_start); - assert!(page.next_cursor.is_some()); + // Compact diagnostics fit on one page after their scalar cap; the + // evidence renderer still exercises oversized-entry pagination below. Ok(()) } diff --git a/crates/compass-query/src/intent.rs b/crates/compass-query/src/intent.rs index f3b09ac7..0d28fe40 100644 --- a/crates/compass-query/src/intent.rs +++ b/crates/compass-query/src/intent.rs @@ -10,8 +10,10 @@ use crate::ranking::QUERY_RANKER_PROFILE_V1; use crate::telemetry::{ProfiledCodeQueryResponse, QueryInstrumentation}; pub const QUERY_PLANNER_PROFILE_V1: &str = "query-planner/1"; +pub const QUERY_PLANNER_PROFILE_V2: &str = "query-planner/2"; const MAX_NATURAL_QUERY_BYTES: usize = 4_096; const AUTO_ROUTE_CONFIDENCE: u8 = 90; +pub(crate) type NaturalSelections = Vec<(String, String)>; #[derive(Clone, Debug, Eq, PartialEq)] pub struct NaturalQueryRequest { @@ -26,6 +28,7 @@ pub enum NaturalQueryIntent { Search, Callers, Callees, + Dependencies, Impact, NodeTrail, Fallback, @@ -79,35 +82,71 @@ impl CodeQueryEngine { request: NaturalQueryRequest, ) -> Result { self.execute_natural_query(request) - .map(|(response, _)| response) + .map(|(response, _, _)| response) } pub fn query_natural_profiled( &self, request: NaturalQueryRequest, ) -> Result { + self.query_natural_with_selections(request) + .map(|(profiled, _)| profiled) + } + + pub(crate) fn query_natural_with_selections( + &self, + request: NaturalQueryRequest, + ) -> Result<(ProfiledCodeQueryResponse, NaturalSelections), QueryError> { let total_started = Instant::now(); - let (response, instrumentation) = self.execute_natural_query(request)?; - Ok(instrumentation.finish( - response, - total_started.elapsed(), - QUERY_PLANNER_PROFILE_V1, - QUERY_RANKER_PROFILE_V1, + let (response, instrumentation, selections) = self.execute_natural_query(request)?; + Ok(( + instrumentation.finish( + response, + total_started.elapsed(), + QUERY_PLANNER_PROFILE_V2, + QUERY_RANKER_PROFILE_V1, + ), + selections, )) } fn execute_natural_query( &self, request: NaturalQueryRequest, - ) -> Result<(CodeQueryResponse, QueryInstrumentation), QueryError> { + ) -> Result<(CodeQueryResponse, QueryInstrumentation, NaturalSelections), QueryError> { self.check_deadline()?; validate_limits(&request.limits)?; let mut instrumentation = QueryInstrumentation::default(); let intent_started = Instant::now(); let plan = plan_natural_query(&request.question)?; instrumentation.intent += intent_started.elapsed(); - let primary = plan.operands.first().cloned().unwrap_or_default(); - let response = match plan.intent { + let mut operands = plan.operands.clone(); + let mut selections = Vec::new(); + let mut match_diagnostics = Vec::new(); + let mut match_truncated = false; + if plan.routes_to_typed_query() + && !matches!( + plan.intent, + NaturalQueryIntent::Search | NaturalQueryIntent::Fallback + ) + { + for operand in &mut operands { + let (selected, diagnostics, truncated) = self.select_natural_symbol( + operand, + &request.question, + &request.limits, + &mut instrumentation, + )?; + if let Some(selected) = selected { + selections.push((operand.clone(), selected.clone())); + *operand = selected; + } + match_diagnostics.extend(diagnostics); + match_truncated |= truncated; + } + } + let primary = operands.first().cloned().unwrap_or_default(); + let mut response = match plan.intent { NaturalQueryIntent::Search | NaturalQueryIntent::Fallback => self.search_instrumented( SearchRequest { query: primary, @@ -133,6 +172,12 @@ impl CodeQueryEngine { false, &mut instrumentation, ), + NaturalQueryIntent::Dependencies => self.natural_dependencies( + &primary, + request.include_heuristic, + request.limits, + &mut instrumentation, + ), NaturalQueryIntent::Impact => self.impact_instrumented( ImpactRequest { symbol: primary, @@ -142,7 +187,7 @@ impl CodeQueryEngine { &mut instrumentation, ), NaturalQueryIntent::NodeTrail => { - let target = plan.operands.get(1).cloned().ok_or_else(|| { + let target = operands.get(1).cloned().ok_or_else(|| { QueryError::new( QueryErrorKind::Internal, "invalid_natural_query_plan", @@ -161,7 +206,10 @@ impl CodeQueryEngine { ) } }?; - Ok((response, instrumentation)) + response.diagnostics.extend(match_diagnostics); + response.truncated |= match_truncated; + let response = self.finish_natural_response(response)?; + Ok((response, instrumentation, selections)) } } @@ -178,6 +226,13 @@ pub fn plan_natural_query(question: &str) -> Result NaturalQueryPlan { let original = question.trim().trim_end_matches(['?', '!', '.']).trim(); + let lower = original.to_ascii_lowercase(); + let original = [" in tests", " in test code", " in production code"] + .iter() + .find(|suffix| lower.ends_with(**suffix)) + .map_or(original, |suffix| { + original[..original.len() - suffix.len()].trim() + }); if original.is_empty() { return plan(NaturalQueryIntent::Fallback, 0, [String::new()]); } @@ -209,6 +264,9 @@ fn plan_validated_natural_query(question: &str) -> NaturalQueryPlan { if let Some((source, target)) = split_operands(original, &lower, "how is ", " connected to ") { return plan(NaturalQueryIntent::NodeTrail, 100, [source, target]); } + if let Some((source, target)) = split_operands(original, &lower, "how does ", " relate to ") { + return plan(NaturalQueryIntent::NodeTrail, 100, [source, target]); + } for prefix in [ "find callers of ", @@ -216,6 +274,8 @@ fn plan_validated_natural_query(question: &str) -> NaturalQueryPlan { "callers of ", "who calls ", "what calls ", + "who uses ", + "what uses ", "what functions call ", "what methods call ", "which functions call ", @@ -228,6 +288,9 @@ fn plan_validated_natural_query(question: &str) -> NaturalQueryPlan { if let Some(symbol) = operand_between(original, &lower, "where is ", " called") { return plan(NaturalQueryIntent::Callers, 95, [symbol]); } + if let Some(symbol) = operand_between(original, &lower, "where is ", " used") { + return plan(NaturalQueryIntent::Callers, 95, [symbol]); + } for prefix in [ "find callees of ", @@ -245,6 +308,14 @@ fn plan_validated_natural_query(question: &str) -> NaturalQueryPlan { return plan(NaturalQueryIntent::Callees, 100, [symbol]); } } + for suffix in [" depend on", " depends on", " use", " uses"] { + if let Some(symbol) = operand_between(original, &lower, prefix, suffix) { + return plan(NaturalQueryIntent::Dependencies, 100, [symbol]); + } + } + } + if let Some(symbol) = operand_after_prefix(original, &lower, "dependencies of ") { + return plan(NaturalQueryIntent::Dependencies, 100, [symbol]); } // Contradictory direction words are deliberately not resolved by choosing @@ -336,7 +407,7 @@ fn plan( operands: impl IntoIterator, ) -> NaturalQueryPlan { NaturalQueryPlan { - profile: QUERY_PLANNER_PROFILE_V1.to_owned(), + profile: QUERY_PLANNER_PROFILE_V2.to_owned(), intent, confidence, operands: operands.into_iter().collect(), diff --git a/crates/compass-query/src/lib.rs b/crates/compass-query/src/lib.rs index 84d0e501..1502b6c4 100644 --- a/crates/compass-query/src/lib.rs +++ b/crates/compass-query/src/lib.rs @@ -14,6 +14,7 @@ mod export_binding; mod graph_engine; mod index; mod intent; +mod natural_answers; mod neighbors; mod program_join; mod ranking; @@ -57,7 +58,7 @@ pub use index::{ }; pub use intent::{ NaturalQueryIntent, NaturalQueryPlan, NaturalQueryRequest, QUERY_PLANNER_PROFILE_V1, - plan_natural_query, + QUERY_PLANNER_PROFILE_V2, plan_natural_query, }; pub use neighbors::{ MAX_NEIGHBOR_ADJACENCY_ENTRIES, MAX_NEIGHBOR_RECORDS, MAX_NEIGHBOR_RESPONSE_BYTES, diff --git a/crates/compass-query/src/natural_answers.rs b/crates/compass-query/src/natural_answers.rs new file mode 100644 index 00000000..9f7b41fe --- /dev/null +++ b/crates/compass-query/src/natural_answers.rs @@ -0,0 +1,349 @@ +//! Best-effort operand selection for natural language. Exact structured APIs +//! deliberately retain their strict ambiguity behavior. +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::time::Instant; + +use compass_model::code_graph::{EdgeKind, NodeKind}; +use compass_model::query_contract::{ + CodeQueryLimits, CodeQueryOperation, CodeQueryResponse, QueryDiagnostic, QueryDiagnosticCode, + QueryNode, SearchRequest, +}; + +use crate::QueryError; +use crate::code_query::{CodeQueryEngine, normalize_symbol, query_edge, query_node}; +use crate::telemetry::QueryInstrumentation; + +const NATURAL_ALTERNATIVES: usize = 4; +const CONNECTIVITY_PROBE: usize = 32; +const NATURAL_RANK_CANDIDATES: usize = 32; + +impl CodeQueryEngine { + pub(crate) fn select_natural_symbol( + &self, + operand: &str, + question: &str, + limits: &CodeQueryLimits, + instrumentation: &mut QueryInstrumentation, + ) -> Result<(Option, Vec, bool), QueryError> { + self.check_deadline()?; + if let Some(node) = self.backend.node_by_id(operand)? { + instrumentation.work.candidates_read += 1; + return Ok((Some(node.id), Vec::new(), false)); + } + let normalized = normalize_symbol(operand); + let (mut exact_nodes, exact_truncated) = self.backend.nodes_by_normalized_name( + &normalized, + usize::try_from(limits.max_candidates).unwrap_or(usize::MAX), + )?; + instrumentation.work.candidates_read += + u64::try_from(exact_nodes.len()).unwrap_or(u64::MAX); + // Preserve owner-qualified matching before lexical ranking. A missing + // owner must never silently select the same method on another owner. + if exact_nodes.is_empty() + && !exact_truncated + && let Some(qualified) = self.owner_qualified_candidates( + &normalized, + usize::try_from(limits.max_candidates).unwrap_or(usize::MAX), + instrumentation, + )? + { + if qualified.truncated { + return Ok(( + None, + vec![QueryDiagnostic { + code: QueryDiagnosticCode::AmbiguousMatch, + message: format!( + "Owner-qualified symbol {operand:?} cannot be resolved within the {}-candidate leaf-name bound", + limits.max_candidates + ), + node_id: None, + path: None, + }], + true, + )); + } + if qualified.nodes.is_empty() && qualified.has_leaf_candidates { + return Ok(( + None, + vec![QueryDiagnostic { + code: QueryDiagnosticCode::NoMatch, + message: format!("NO OWNER-QUALIFIED MATCH for {operand:?}"), + node_id: None, + path: None, + }], + false, + )); + } + exact_nodes = qualified.nodes; + } + let has_exact = !exact_nodes.is_empty(); + let (mut candidates, mut diagnostics, mut candidate_truncated) = if has_exact { + ( + exact_nodes + .iter() + .map(|node| (query_node(node), 1.0)) + .collect::>(), + Vec::new(), + exact_truncated, + ) + } else { + let search = self.search_instrumented( + SearchRequest { + query: operand.to_owned(), + limits: limits.clone(), + }, + instrumentation, + )?; + let candidates = search + .results + .iter() + .filter_map(|hit| { + search + .nodes + .iter() + .find(|node| node.id == hit.node_id) + .filter(|node| natural_fuzzy_relevant(operand, node)) + .map(|node| (node.clone(), hit.score)) + }) + .collect(); + (candidates, search.diagnostics, search.truncated) + }; + let wants_tests = question.split(|c: char| !c.is_alphanumeric()).any(|word| { + matches!( + word.to_ascii_lowercase().as_str(), + "test" | "tests" | "testing" + ) + }); + candidates.sort_by(|(left, left_score), (right, right_score)| { + u8::from(natural_test_source(right) == wants_tests) + .cmp(&u8::from(natural_test_source(left) == wants_tests)) + .then_with(|| { + u8::from(natural_declaration(right)).cmp(&u8::from(natural_declaration(left))) + }) + .then_with(|| right_score.total_cmp(left_score)) + .then_with(|| left.id.cmp(&right.id)) + }); + candidate_truncated |= candidates.len() > NATURAL_RANK_CANDIDATES; + let mut ranked = Vec::new(); + // Apply source preferences before the connectivity probe cap, so a + // request about tests can select a test behind many production matches. + for (node, score) in candidates.iter().take(NATURAL_RANK_CANDIDATES) { + self.check_deadline()?; + let is_test = natural_test_source(node); + let declaration = natural_declaration(node); + let (incoming, _) = self.backend.matching_bounded( + &node.id, + true, + NATURAL_DEPENDENCY_KINDS, + false, + CONNECTIVITY_PROBE, + )?; + let (outgoing, _) = self.backend.matching_bounded( + &node.id, + false, + NATURAL_DEPENDENCY_KINDS, + false, + CONNECTIVITY_PROBE, + )?; + instrumentation.work.edges_expanded += + u64::try_from(incoming.len() + outgoing.len()).unwrap_or(u64::MAX); + ranked.push(( + node, + u8::from(is_test == wants_tests), + u8::from(declaration), + *score, + incoming.len() + outgoing.len(), + )); + } + ranked.sort_by(|left, right| { + right + .1 + .cmp(&left.1) + .then_with(|| right.2.cmp(&left.2)) + .then_with(|| { + if has_exact { + std::cmp::Ordering::Equal + } else { + right.3.total_cmp(&left.3) + } + }) + .then_with(|| right.4.cmp(&left.4)) + .then_with(|| left.0.id.cmp(&right.0.id)) + }); + let Some((selected, ..)) = ranked.first() else { + return Ok((None, diagnostics, candidate_truncated)); + }; + let alternatives = ranked + .iter() + .skip(1) + .take(NATURAL_ALTERNATIVES) + .map(|(node, ..)| format!("{} ({})", node.qualified_name, node.id)) + .collect::>(); + if !has_exact { + // This is a statement about operand matching, not uncertainty in + // the witnessed edges subsequently returned by the typed query. + diagnostics.retain(|diagnostic| diagnostic.code != QueryDiagnosticCode::NoMatch); + diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::NoMatch, + message: format!("NO EXACT MATCH for {operand:?}; approximate — selected {} by fuzzy/lexical matching{}", + selected.qualified_name, + if alternatives.is_empty() { String::new() } else { format!("; also matched: {}", alternatives.join(", ")) }), + node_id: Some(selected.id.clone()), path: None, + }); + } else if !alternatives.is_empty() || candidate_truncated { + diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::AmbiguousMatch, + message: format!("Auto-picked {} for {operand:?} using declaration, source scope and bounded connectivity; also matched: {}{}", + selected.qualified_name, alternatives.join(", "), + if candidate_truncated { "; candidate coverage incomplete" } else { "" }), + node_id: Some(selected.id.clone()), path: None, + }); + } + Ok((Some(selected.id.clone()), diagnostics, candidate_truncated)) + } + + pub(crate) fn natural_dependencies( + &self, + symbol: &str, + include_heuristic: bool, + limits: CodeQueryLimits, + instrumentation: &mut QueryInstrumentation, + ) -> Result { + let started = Instant::now(); + let mut response = CodeQueryResponse::empty(CodeQueryOperation::Explore, limits.clone()); + let Some(seed) = self.backend.node_by_id(symbol)? else { + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::NoMatch, + message: format!("NO EXACT MATCH for {symbol:?}"), + node_id: None, + path: None, + }); + return Ok(response); + }; + let max_nodes = usize::try_from(limits.max_nodes).unwrap_or(usize::MAX); + let max_edges = usize::try_from(limits.max_edges).unwrap_or(usize::MAX); + let mut nodes = BTreeMap::from([(seed.id.clone(), query_node(&seed))]); + let mut seen = BTreeSet::new(); + let mut pending = VecDeque::from([(seed.id.clone(), 0)]); + let mut edges = BTreeMap::new(); + while let Some((owner, depth)) = pending.pop_front() { + self.check_deadline()?; + if !seen.insert(owner.clone()) { + continue; + } + instrumentation.work.nodes_expanded += 1; + let remaining = max_edges.saturating_sub(edges.len()); + if remaining == 0 { + response.truncated = true; + break; + } + let (outgoing, truncated) = self.backend.matching_bounded( + &owner, + false, + NATURAL_OUTGOING_KINDS, + include_heuristic, + remaining, + )?; + response.truncated |= truncated; + instrumentation.work.edges_expanded += + u64::try_from(outgoing.len()).unwrap_or(u64::MAX); + for edge in outgoing { + let member = edge.kind == EdgeKind::Contains; + if member && depth >= limits.max_depth { + response.truncated = true; + continue; + } + if !nodes.contains_key(&edge.target) { + if nodes.len() == max_nodes { + response.truncated = true; + continue; + } + if let Some(target) = self.backend.node_by_id(&edge.target)? { + nodes.insert(target.id.clone(), query_node(&target)); + } + } + if member { + pending.push_back((edge.target.clone(), depth + 1)); + } + edges.insert(edge.id.clone(), query_edge(&edge)); + } + } + response.nodes = nodes.into_values().collect(); + response.edges = edges.into_values().collect(); + if let Some(program) = &self.program { + crate::join_program_evidence(&mut response, Some(program)); + } + if let Some(message) = &self.partial_graph_message { + response.diagnostics.push(QueryDiagnostic { + code: QueryDiagnosticCode::IncompleteCoverage, + message: message.clone(), + node_id: None, + path: None, + }); + } + instrumentation.execution += started.elapsed(); + self.finish_natural_response(response) + } +} + +const NATURAL_DEPENDENCY_KINDS: &[EdgeKind] = &[ + EdgeKind::Calls, + EdgeKind::Imports, + EdgeKind::References, + EdgeKind::DependsOn, +]; +const NATURAL_OUTGOING_KINDS: &[EdgeKind] = &[ + EdgeKind::Contains, + EdgeKind::Calls, + EdgeKind::Imports, + EdgeKind::DependsOn, +]; + +fn natural_declaration(node: &QueryNode) -> bool { + node.kind.is_callable() + || matches!( + node.kind, + NodeKind::Class + | NodeKind::Struct + | NodeKind::Interface + | NodeKind::Trait + | NodeKind::Enum + ) +} + +fn natural_test_source(node: &QueryNode) -> bool { + node.source.as_ref().is_some_and(|anchor| { + anchor.file.replace('\\', "/").split('/').any(|part| { + matches!(part, "test" | "tests" | "__tests__" | "generated") + || part.starts_with("test_") + || part.ends_with("_test.py") + || part.ends_with("_test.rs") + || part.contains(".test.") + || part.contains(".spec.") + }) + }) +} + +// Do not turn an absent compound symbol into a confident query about the +// generic "Service" or "Repository" token. Typo variants may still match +// the complete identifier, while partial names need multiple shared terms. +pub(crate) fn natural_fuzzy_relevant(operand: &str, node: &QueryNode) -> bool { + let terms = crate::text::search_tokens(operand); + if terms.len() < 3 { + return true; + } + let node_terms = crate::text::search_tokens(&node.qualified_name); + if terms + .iter() + .filter(|term| node_terms.contains(term)) + .count() + >= 2 + { + return true; + } + let variants = crate::code_query::recall_fuzzy_term_variants(&[operand.to_owned()]); + let name = normalize_symbol(&node.name); + variants + .iter() + .any(|variant| normalize_symbol(variant) == name) +} diff --git a/crates/compass-query/tests/code_impact.rs b/crates/compass-query/tests/code_impact.rs index 0e1a5b5f..2b0c5633 100644 --- a/crates/compass-query/tests/code_impact.rs +++ b/crates/compass-query/tests/code_impact.rs @@ -477,3 +477,52 @@ fn impact_includes_inbound_renderers_without_promoting_them_to_callers() ); Ok(()) } + +#[test] +fn partial_owner_coverage_does_not_stop_the_witnessed_direct_frontier() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("graph.json"); + support::write_graph(&path)?; + let mut graph = GraphDocument::load(&path)?; + graph + .nodes + .push(support::node("n:owner", NodeKind::Class, "Store", "Store")); + graph.nodes.push(support::node( + "n:file", + NodeKind::File, + "lib.rs", + "src/lib.rs", + )); + graph.links.extend([ + edge("n:file", EdgeKind::Contains, "n:owner"), + edge("n:owner", EdgeKind::Contains, "n:callee"), + // Three edges through the file and class exceed the requested depth. + edge("n:dependent", EdgeKind::Imports, "n:file"), + ]); + fs::write(&path, serde_json::to_vec(&graph)?)?; + let engine = open(&path, None, &directory.path().join("cache"))?; + let response = engine.impact(ImpactRequest { + symbol: "n:callee".to_owned(), + include_heuristic: false, + limits: CodeQueryLimits { + max_depth: 2, + ..CodeQueryLimits::default() + }, + })?; + assert!( + response.truncated, + "the excluded owner trail must stay disclosed" + ); + assert!( + response.nodes.iter().any(|node| node.id == "n:caller"), + "the second-hop direct caller must remain reachable" + ); + assert!( + response + .edges + .iter() + .any(|edge| edge.source == "n:caller" && edge.target == "n:list") + ); + Ok(()) +} diff --git a/crates/compass-query/tests/natural_answers.rs b/crates/compass-query/tests/natural_answers.rs new file mode 100644 index 00000000..13105713 --- /dev/null +++ b/crates/compass-query/tests/natural_answers.rs @@ -0,0 +1,362 @@ +mod support; + +use compass_graph::GraphSnapshotBuilder; +use compass_model::code_graph::{EdgeKind, GraphDocument, NodeKind}; +use compass_model::query_contract::{ + CallRequest, CodeQueryLimits, DiscoveryDirection, DiscoveryQueryRequest, DiscoveryScope, + DiscoveryScopeKind, QueryDiagnosticCode, +}; +use compass_query::{EngineSelection, open_with_engine}; +use compass_store::{STORE_FILE_NAME, STORE_REF_FILE_NAME, SqliteStore}; +use std::fs; +use std::path::Path; + +fn request(question: &str) -> DiscoveryQueryRequest { + DiscoveryQueryRequest { + question: question.to_owned(), + direction: DiscoveryDirection::Auto, + relation_contexts: vec![], + scope: vec![], + traversal: Default::default(), + include_heuristic: false, + limits: Default::default(), + } +} + +fn fixture(path: &Path) -> Result<(), Box> { + support::write_graph(path)?; + let mut graph = GraphDocument::load(path)?; + for (id, kind, name, qualified) in [ + ( + "svc", + NodeKind::Class, + "FieldSectionMutationService", + "app.FieldSectionMutationService", + ), + ( + "mutate", + NodeKind::Method, + "mutate", + "app.FieldSectionMutationService.mutate", + ), + ("repo", NodeKind::Method, "get", "app.FieldRepository.get"), + ( + "validation", + NodeKind::Function, + "validate", + "app.validation.validate", + ), + ( + "authz", + NodeKind::Function, + "authorization", + "app.authz.authorization", + ), + ] { + graph.nodes.push(support::node(id, kind, name, qualified)); + } + let template = graph.links[0].clone(); + for (_id, source, kind, target) in [ + ("owns", "svc", EdgeKind::Contains, "mutate"), + ("repo-call", "mutate", EdgeKind::Calls, "repo"), + ("validate-call", "mutate", EdgeKind::Calls, "validation"), + ("authz-call", "mutate", EdgeKind::Calls, "authz"), + ("svc-import", "svc", EdgeKind::Imports, "n:dependent"), + ] { + let mut edge = template.clone(); + edge.id = compass_model::identity::edge_id( + source, + kind, + target, + edge.relationship_site.as_ref(), + edge.occurrence_rule.as_ref().map(|rule| rule.as_str()), + ); + edge.key = edge.id.clone(); + edge.source = source.to_owned(); + edge.target = target.to_owned(); + edge.kind = kind; + graph.links.push(edge); + } + fs::write(path, serde_json::to_vec(&graph)?)?; + let root = path.parent().ok_or("missing fixture root")?; + let store = SqliteStore::open(root.join(STORE_FILE_NAME))?; + let prepared = GraphSnapshotBuilder::new().prepare(&store, &graph)?; + GraphSnapshotBuilder::new().activate(&store, &prepared)?; + fs::write( + root.join(STORE_REF_FILE_NAME), + serde_json::to_vec(&store.snapshot_reference()?)?, + )?; + store.checkpoint()?; + Ok(()) +} + +#[test] +fn reviewed_natural_questions_return_witnessed_answers_with_backend_parity() +-> Result<(), Box> { + let dir = tempfile::tempdir()?; + let graph_path = dir.path().join("graph.json"); + fixture(&graph_path)?; + let json = open_with_engine( + &graph_path, + None, + &dir.path().join("json-cache"), + EngineSelection::Json, + )?; + let store = open_with_engine( + &graph_path, + None, + &dir.path().join("store-cache"), + EngineSelection::Store, + )?; + // Each oracle names an edge or declaration in the fixture above, rather + // than accepting any nonempty answer as evidence of useful recall. + let cases = [ + ("what does FieldSectionMutationService depend on", "repo"), + ("what does FieldSectionMutationService use", "validation"), + ("dependencies of FieldSectionMutationService", "n:dependent"), + ( + "what does app.FieldSectionMutationService depend on?", + "authz", + ), + ("what calls app.FieldRepository.get?", "mutate"), + ("who uses app.FieldRepository.get?", "mutate"), + ("where is app.FieldRepository.get used?", "mutate"), + ("who calls app.validation.validate?", "mutate"), + ("what uses app.authz.authorization?", "mutate"), + ("what breaks if app.FieldRepository.get changes?", "mutate"), + ( + "what would break if app.validation.validate changes?", + "mutate", + ), + ("what depends on app.authz.authorization?", "mutate"), + ( + "how does app.FieldSectionMutationService.mutate relate to app.FieldRepository.get?", + "repo", + ), + ( + "how is app.FieldSectionMutationService.mutate connected to app.validation.validate?", + "validation", + ), + ( + "path from app.FieldSectionMutationService.mutate to app.authz.authorization", + "authz", + ), + ( + "what does app.FieldSectionMutationService.mutate call?", + "repo", + ), + ( + "calls made by app.FieldSectionMutationService.mutate", + "validation", + ), + ( + "what functions does app.FieldSectionMutationService.mutate invoke?", + "authz", + ), + ("who calls list?", "n:caller"), + ("who uses list?", "n:caller"), + ("where is list used?", "n:caller"), + ("what breaks if list changes?", "n:caller"), + ("what handles validation", "validation"), + ("how does authorization work", "authz"), + ("what does FieldSectionMutationServcie depend on?", "repo"), + ]; + let mut answered = 0; + let mut failures = vec![]; + for (question, expected) in cases { + let a = json.discover(request(question))?; + let b = store.discover(request(question))?; + // Backend work budgets can withhold different low-ranked broad + // candidates; both must return the same witnessed neighborhood here. + assert_eq!(a.nodes, b.nodes, "{question}"); + assert_eq!(a.edges, b.edges, "{question}"); + if question != "what handles validation" && question != "how does authorization work" { + assert_eq!( + compass_query::discovery_response_digest(&a)?, + compass_query::discovery_response_digest(&b)?, + "{question}" + ); + } + assert_eq!( + a, + json.discover(request(question))?, + "{question}: nondeterministic answer" + ); + if a.nodes.iter().any(|node| node.id == expected) { + answered += 1; + } else { + failures.push(format!( + "{question}: expected {expected}, seeds={:?}", + a.seeds + )); + } + assert!( + !a.diagnostics + .iter() + .any(|d| d.code == QueryDiagnosticCode::AmbiguousMatch) + || !a.nodes.is_empty(), + "{question}" + ); + } + assert_eq!( + answered, + cases.len(), + "{} of {} answered:\n{}", + answered, + cases.len(), + failures.join("\n") + ); + Ok(()) +} + +#[test] +fn ambiguous_natural_question_executes_but_explicit_callers_stays_strict() +-> Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("graph.json"); + fixture(&path)?; + for selection in [EngineSelection::Json, EngineSelection::Store] { + let engine = open_with_engine( + &path, + None, + &dir.path().join(format!("{selection:?}")), + selection, + )?; + let natural = engine.discover(request("who calls list?"))?; + assert_eq!(natural.seeds[0].node_id, "n:list"); + assert!( + natural.seeds[0] + .alternatives + .iter() + .any(|other| other.node_id == "n:other") + ); + assert!( + natural + .edges + .iter() + .any(|edge| edge.source == "n:caller" && edge.target == "n:list") + ); + let strict = engine.callers(CallRequest { + symbol: "list".to_owned(), + include_heuristic: false, + limits: CodeQueryLimits::default(), + })?; + assert!(strict.edges.is_empty()); + assert!( + strict + .diagnostics + .iter() + .any(|d| d.code == QueryDiagnosticCode::AmbiguousMatch) + ); + } + Ok(()) +} + +#[test] +fn natural_dependencies_keep_imports_member_calls_and_limits_coherent() +-> Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("graph.json"); + fixture(&path)?; + let engine = open_with_engine( + &path, + None, + &dir.path().join("cache"), + EngineSelection::Json, + )?; + let answer = engine.discover(request("what does FieldSectionMutationService depend on?"))?; + assert!(answer.edges.iter().any(|e| e.kind == EdgeKind::Imports)); + assert!( + answer + .edges + .iter() + .any(|e| e.source == "mutate" && e.target == "repo") + ); + let mut bounded = request("what does FieldSectionMutationService depend on?"); + bounded.limits.max_nodes = 2; + let bounded = engine.discover(bounded)?; + assert!(bounded.truncated); + assert!(bounded.nodes.len() <= 2); + assert!(bounded.edges.iter().all( + |edge| bounded.nodes.iter().any(|node| node.id == edge.source) + && bounded.nodes.iter().any(|node| node.id == edge.target) + )); + let mut scoped = request("what does FieldSectionMutationService depend on?"); + scoped.scope.push(DiscoveryScope { + kind: DiscoveryScopeKind::Node, + value: "svc".to_owned(), + }); + let scoped = engine.discover(scoped)?; + assert!(scoped.nodes.iter().all(|node| node.id == "svc")); + for question in [ + "QuantumBananaMissingService", + "what does QuantumBananaMissingService depend on?", + ] { + let absent = engine.discover(request(question))?; + assert!(absent.nodes.is_empty(), "{question}: {:?}", absent.seeds); + assert!(absent.edges.is_empty()); + } + Ok(()) +} + +#[test] +fn natural_selection_ranks_connectivity_and_respects_test_intent() +-> Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("graph.json"); + fixture(&path)?; + let mut graph = GraphDocument::load(&path)?; + let template = graph.links[0].clone(); + for index in 0..10 { + let id = format!("handler-{index:02}"); + graph.nodes.push(support::node( + &id, + NodeKind::Function, + "handle", + &format!("app.Handler{index}.handle"), + )); + if index == 9 { + for target in [ + "repo", + "validation", + "authz", + "mutate", + "n:caller", + "n:list", + ] { + let mut edge = template.clone(); + edge.source = id.clone(); + edge.target = target.to_owned(); + edge.kind = EdgeKind::Calls; + edge.id = compass_model::identity::edge_id( + &edge.source, + edge.kind, + &edge.target, + edge.relationship_site.as_ref(), + edge.occurrence_rule.as_ref().map(|rule| rule.as_str()), + ); + edge.key = edge.id.clone(); + graph.links.push(edge); + } + } + } + let mut test_node = support::node("test-handle", NodeKind::Function, "handle", "tests.handle"); + if let Some(source) = &mut test_node.source { + source.file = "tests/generated/payment_gateway.rs".to_owned(); + } + graph.nodes.push(test_node); + fs::write(&path, serde_json::to_vec(&graph)?)?; + let engine = open_with_engine( + &path, + None, + &dir.path().join("cache"), + EngineSelection::Json, + )?; + let production = engine.discover(request("what does handle depend on?"))?; + assert_eq!(production.seeds[0].node_id, "handler-09"); + assert!(production.edges.iter().any(|edge| edge.target == "repo")); + let tests = engine.discover(request("who calls handle in tests?"))?; + assert_eq!(tests.seeds[0].node_id, "test-handle"); + assert!(tests.seeds[0].ambiguous); + Ok(()) +} diff --git a/crates/compass-query/tests/natural_intent.rs b/crates/compass-query/tests/natural_intent.rs index 4935a825..3081ca6a 100644 --- a/crates/compass-query/tests/natural_intent.rs +++ b/crates/compass-query/tests/natural_intent.rs @@ -12,7 +12,7 @@ use compass_model::query_contract::{ }; use compass_query::{ EngineSelection, NaturalQueryIntent, NaturalQueryRequest, ProfiledCodeQueryResponse, - QUERY_EXECUTION_PROFILE_V1, QUERY_PLANNER_PROFILE_V1, QUERY_RANKER_PROFILE_V1, QueryErrorKind, + QUERY_EXECUTION_PROFILE_V1, QUERY_PLANNER_PROFILE_V2, QUERY_RANKER_PROFILE_V1, QueryErrorKind, open_with_engine, plan_natural_query, }; use compass_store::{STORE_FILE_NAME, STORE_REF_FILE_NAME, SqliteStore}; @@ -105,7 +105,7 @@ fn natural_intents_route_to_typed_operations_with_backend_parity() } #[test] -fn owner_qualified_calls_resolve_only_a_proven_unique_suffix() +fn owner_qualified_calls_rank_matching_owners_and_reject_missing_or_bounded_owners() -> Result<(), Box> { for duplicate_owner in [false, true] { let directory = tempfile::tempdir()?; @@ -160,11 +160,18 @@ fn owner_qualified_calls_resolve_only_a_proven_unique_suffix() let short_owner = engine.query_natural(request(&format!("what does {owner} call?")))?; if duplicate_owner { - assert!(short_owner.edges.is_empty()); + assert!( + short_owner + .edges + .iter() + .any(|edge| { edge.source == "n:caller" && edge.target == "n:list" }) + ); assert!(short_owner.diagnostics.iter().any(|diagnostic| { diagnostic.code == QueryDiagnosticCode::AmbiguousMatch + && diagnostic.node_id.as_deref() == Some("n:caller") + && diagnostic.message.contains("Auto-picked") + && diagnostic.message.contains("docs.APIRoute") })); - assert_eq!(short_owner.nodes.len(), 2); } else { assert!( short_owner @@ -274,11 +281,18 @@ fn contradictory_and_ambiguous_questions_never_invent_direction() .iter() .any(|diagnostic| { diagnostic.code == QueryDiagnosticCode::AmbiguousMatch }) ); - // Ambiguity retains the exact-name candidates so the next request can - // disambiguate in one step, and still invents no usage relationship. - assert_eq!(ambiguous.nodes.len(), 2); - assert_eq!(ambiguous.results.len(), 2); - assert!(ambiguous.edges.is_empty()); + // Natural language selects the connected declaration, keeps the other + // identity in the diagnostic, and executes its witnessed relationships. + assert!(ambiguous.nodes.iter().any(|node| node.id == "n:caller")); + assert!( + ambiguous + .edges + .iter() + .any(|edge| edge.source == "n:caller" && edge.target == "n:list") + ); + assert!(ambiguous.diagnostics.iter().any(|diagnostic| { + diagnostic.message.contains("Auto-picked") && diagnostic.message.contains("Other.list") + })); assert!(ambiguous.paths.is_empty()); Ok(()) } @@ -344,7 +358,7 @@ fn profiled_natural_queries_report_real_stage_work_without_changing_the_response assert_eq!(profiled.response, ordinary); assert_eq!(profiled.response, repeated.response); assert_eq!(profiled.profile.schema, QUERY_EXECUTION_PROFILE_V1); - assert_eq!(profiled.profile.planner_profile, QUERY_PLANNER_PROFILE_V1); + assert_eq!(profiled.profile.planner_profile, QUERY_PLANNER_PROFILE_V2); assert_eq!(profiled.profile.ranker_profile, QUERY_RANKER_PROFILE_V1); assert!(profiled.profile.work.candidates_read > 0); assert_eq!( @@ -496,7 +510,7 @@ fn planner_profile_covers_reviewed_phrase_variants_and_safe_fallbacks() ]; for (question, expected, auto_route) in cases { let plan = plan_natural_query(question)?; - assert_eq!(plan.profile(), QUERY_PLANNER_PROFILE_V1, "{question:?}"); + assert_eq!(plan.profile(), QUERY_PLANNER_PROFILE_V2, "{question:?}"); assert_eq!(plan.intent(), expected, "{question:?}"); assert_eq!(plan.routes_to_typed_query(), auto_route, "{question:?}"); assert_eq!( diff --git a/docs/implementation/query-engine.md b/docs/implementation/query-engine.md index 6a900026..ac40f788 100644 --- a/docs/implementation/query-engine.md +++ b/docs/implementation/query-engine.md @@ -129,7 +129,7 @@ an otherwise equal production declaration remains first. ## Natural-language intent routing -`compass ask ""` uses the deterministic `query-planner/1` profile +`compass ask ""` uses the deterministic `query-planner/2` profile before executing the existing typed query operations. `compass query` also selects this path for a high-confidence question against a current typed graph. High-confidence forms route callers, callees, impact, and source-to-target path @@ -140,13 +140,16 @@ spending recall terms on question prose. Explicit `search`, `callers`, The planner is deliberately bounded and conservative. Questions above 4,096 bytes fail before graph work. Empty, low-confidence, or contradictory requests -fall back to bounded search; ambiguous symbol resolution remains an explicit -`ambiguous_match` diagnostic and never selects a convenient candidate. Planner +fall back to bounded search; natural-language symbol resolution ranks declarations by exact name, source +scope, declaration kind, and bounded connectivity. It executes the selected +identity and retains an `ambiguous_match` diagnostic with the other candidates. +Explicit structured commands retain strict ambiguity behavior. Planner rules are local, credential-free, deterministic, and share the request limits, heuristic-evidence gate, backend behavior, and response envelope of the typed -operation they select. Generic or contradictory questions, historical `--at` -queries, and requests carrying `--traverse` or a text-traversal control remain -on the established relevance traversal. MCP `query_graph` uses the same routing +operation they select. Generic or contradictory `compass query` questions +remain on bounded discovery. Explicit direction, scope, context, and DFS +controls are honored by filtered discovery; `--traverse` selects the legacy +relevance traversal. Historical queries retain their immutable graph identity. MCP `query_graph` uses the same routing rule for typed graphs unless a legacy `mode`, `depth`, `token_budget`, or `context_filter` field is present. @@ -458,7 +461,9 @@ covers the projection without mutating the raw result. CLI and MCP call the same projector and text renderer. Raw `json` output and MCP `structuredContent.result` remain authoritative and unchanged. The discovery page renderer accepts an already escaped fixed header but keeps its -`compass.query.discovery-text-page/2` entry ledger and cursor semantics. +`compass.query.discovery-text-page/3` entry ledger and cursor semantics. +Compact discovery pages list relationships before the declaration inventory; +`--evidence` retains the detailed occurrence and provenance ledger. ## Explain and profile diff --git a/tests/viewer/package.json b/tests/viewer/package.json index b7ed1b12..641d5675 100644 --- a/tests/viewer/package.json +++ b/tests/viewer/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "test": "playwright test", - "test:performance": "playwright test performance.spec.ts" + "test:performance": "playwright test --project chromium-performance --no-deps" }, "devDependencies": { "@axe-core/playwright": "^4.11.0", diff --git a/tests/viewer/playwright.config.ts b/tests/viewer/playwright.config.ts index 8150fd32..dcfe3d19 100644 --- a/tests/viewer/playwright.config.ts +++ b/tests/viewer/playwright.config.ts @@ -18,6 +18,18 @@ export default defineConfig({ reuseExistingServer: !process.env.CI }, projects: [ - { name: "chromium", use: { browserName: "chromium" } } + { + name: "chromium", + testIgnore: "performance.spec.ts", + use: { browserName: "chromium" } + }, + { + name: "chromium-performance", + testMatch: "performance.spec.ts", + // Wall-clock qualification must not share CPU with other browser tests. + dependencies: ["chromium"], + workers: 1, + use: { browserName: "chromium" } + } ] });