Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,10 @@ where
)
}
}?;
let lexical_routes = LexicalRouteReceiptV1 {
decision: route_plan.decision(),
..lexical_routes
};
let graph_seeds = graph_seeds_from_outcomes(&exact, &lexical);
let graph_activation_enabled = schedulers.graph_activation_enabled_for_scope(scope).await;
let graph = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6853,3 +6853,92 @@ async fn status_keeps_the_committed_generation_through_a_refresh() {
assert_eq!(advertised.len(), 2, "status advertised {advertised:?}");
assert_eq!(advertised[0].as_deref(), Some(committed.as_str()));
}

/// A name-shaped query takes the symbol-name route without `prefer_symbol`
/// and reports the decision: qualified spellings the strict query cannot
/// match still rank their definition first, while a prose query keeps the
/// strict query alone and ranks exactly as with the route suppressed.
#[tokio::test]
async fn name_shaped_queries_take_the_symbol_name_route_by_query_shape() {
use tracedecay_query::retrieval::lexical::{
LexicalQueryRouteV1, LexicalRouteDeciderV1, LexicalRouteDecisionV1,
};
let fixture = GitFixture::new(&[
(
"src/users.ts",
"export function getUserById(id: number): number {\n return id;\n}\n",
),
(
"src/session.ts",
"import { getUserById } from './users';\n\
/** Session lookup: loads the current user for each request. */\n\
export function loadSessionUser(session: number): number {\n \
return getUserById(session);\n}\n",
),
]);
let store = TempDir::new().expect("store root");
let (registry, scope) = mounted_core_query_worktree(&fixture, &store).await;
let latest = wait_for_live_complete_generation(&registry, fixture.path()).await;

for query in ["UserStore::getUserById", "users.getUserById"] {
let routed = registry
.execute_query_search(
&scope,
routed_core_search_request(query, LexicalRoutingV1::default()),
)
.await
.expect("name-shaped search composes");
assert_eq!(
ranked_symbol_names(&routed, &latest).first(),
Some(&Some("src/users.ts::getUserById".to_owned())),
"{query} ranks its definition first"
);
assert_eq!(
routed.lexical_routes.decision,
LexicalRouteDecisionV1 {
route: LexicalQueryRouteV1::Name,
margin_micros: 500_000,
decided_by: LexicalRouteDeciderV1::QueryShape,
},
"{query}"
);
}

let prose = "session lookup for each request";
let routed = registry
.execute_query_search(
&scope,
routed_core_search_request(prose, LexicalRoutingV1::default()),
)
.await
.expect("prose search composes");
let suppressed = registry
.execute_query_search(
&scope,
routed_core_search_request(
prose,
LexicalRoutingV1::new(Vec::new(), false).expect("routing"),
),
)
.await
.expect("prose search composes");
assert_eq!(
routed.lexical_routes.decision,
LexicalRouteDecisionV1 {
route: LexicalQueryRouteV1::Prose,
margin_micros: -500_000,
decided_by: LexicalRouteDeciderV1::QueryShape,
}
);
assert_eq!(
routed.lexical_routes.routes,
vec![LexicalRouteKindV1::Query]
);
let prose_ranking = ranked_symbol_names(&routed, &latest);
assert_eq!(
prose_ranking.first(),
Some(&Some("src/session.ts::loadSessionUser".to_owned()))
);
assert_eq!(prose_ranking, ranked_symbol_names(&suppressed, &latest));
registry.shutdown().await;
}
3 changes: 2 additions & 1 deletion crates/tracedecay-contracts/src/retrieval/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,8 @@ pub use search_surface::{
SearchDisplayUnavailableV1, SearchExternalImportCandidatesV1, SearchExternalImportHintV1,
SearchExternalImportV1, SearchLaneStateV1, SearchLaneStatusV1, SearchLexicalAliasV1,
SearchLexicalAlternativeReasonV1, SearchLexicalFieldFilterV1, SearchLexicalFieldV1,
SearchLexicalProximityV1, SearchLexicalRouteV1, SearchResultDisplayV1, SearchResultRowV1,
SearchLexicalProximityV1, SearchLexicalRouteV1, SearchQueryRouteDeciderV1,
SearchQueryRouteKindV1, SearchQueryRouteV1, SearchResultDisplayV1, SearchResultRowV1,
SearchResultV1, SearchRouteMatchV1, SearchSpellingVariantV1, SearchSurfaceRequestV1,
SearchUnavailableV1,
};
Expand Down
10 changes: 9 additions & 1 deletion crates/tracedecay-contracts/src/retrieval/primitive_surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use tracedecay_domain::{
use crate::code_index_freshness::{CodeIndexConvergenceParkedV1, CodeIndexStalenessStateV1};
use crate::memory::{FactSearchGraphCoverageV1, FactSearchHitV1};

use super::search_surface::SearchQueryRouteV1;

pub const MAX_REDUNDANCY_FAMILIES_V1: u32 = 100;
pub const MAX_REDUNDANCY_PULL_REQUEST_PATHS_V1: usize = 256;
pub const MAX_REDUNDANCY_WORK_V1: u32 = 10_000;
Expand Down Expand Up @@ -59,7 +61,8 @@ pub struct ContextSurfaceRequestV1 {
/// (`snake_case`, `camelCase`, `a::b`) are anchored after these.
pub lexical_anchors: Option<Vec<String>>,
/// Add a symbol-name lexical route for the identifier-shaped words of the
/// task text.
/// task text. Omitted, it runs when the task is name-shaped; true forces
/// it, false suppresses it. `query_route` reports the decision.
pub prefer_symbol: Option<bool>,
}

Expand Down Expand Up @@ -402,6 +405,10 @@ pub struct ContextResultV1 {
/// identifier the task names; empty when there are neither.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lexical_anchors: Vec<ContextLexicalAnchorV1>,
/// Which lane the task's shape selected, and its margin; absent when no
/// code generation answered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query_route: Option<SearchQueryRouteV1>,
pub symbols: Vec<PrimitiveSymbolLocationV1>,
/// Neighbors of `symbols`, ranked before the `max_nodes` cut: best edge
/// kind to a selected symbol (calls, implements, extends, `type_of`,
Expand Down Expand Up @@ -917,6 +924,7 @@ mod tests {
code_generation: Some("generation.test".to_owned()),
search_matches: vec![],
lexical_anchors: vec![],
query_route: None,
symbols: vec![],
related_symbols: vec![],
related_omission: None,
Expand Down
37 changes: 36 additions & 1 deletion crates/tracedecay-contracts/src/retrieval/search_surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub struct SearchSurfaceRequestV1 {
/// Exact identifiers or technical terms (e.g. 'reserve_stock', 'Foo::bar', 'E0308') that the answer must be about. Each is ranked through the lexical lane as its own route: a hit carrying an anchor outranks every hit that carries none, exact hits included, every anchor with matches keeps at least its best sites through the lane cap, and `lexical_anchors` in the response reports each anchor's outcome (`matched` rows, `admitted` sites this response returns, `dropped` admitted sites it could not carry with the reason, `unmatched`, or `not_served`). Ranked retrieval, not exhaustive grep (use tracedecay_grep for that). Each result names the routes that ranked it. At most 8 anchors, each one whitespace-free term of at most 128 bytes, no repeats.
#[schemars(length(max = SEARCH_MAX_LEXICAL_ANCHORS))]
pub lexical_anchors: Option<Vec<String>>,
/// Add a lexical route restricted to symbol-name matches for the identifier-shaped words of the query (default: false). Query words such as class/struct/function/find/explain are ignored; 'Foo::bar' and 'Foo.bar' contribute 'bar'.
/// Add a lexical route restricted to symbol-name matches for the identifier-shaped words of the query. Omitted, it runs when the query is name-shaped (at least half its content words are identifiers such as 'getUserById' or 'Foo::bar'); true forces it, false suppresses it. `query_route` in the response reports the decision and its margin. Query words such as class/struct/function/find/explain are ignored; 'Foo::bar' and 'Foo.bar' contribute 'bar'.
pub prefer_symbol: Option<bool>,
/// Named query-time vocabulary aliases. The strict query always ranks first. Alias-only hits follow it with the strict query, alternative, and configured-vocabulary reason disclosed.
#[schemars(length(max = SEARCH_MAX_LEXICAL_ALIASES))]
Expand Down Expand Up @@ -98,6 +98,38 @@ pub struct SearchLexicalFieldFilterV1 {
pub include: bool,
}

/// Which lexical lane the query's shape selected.
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchQueryRouteKindV1 {
/// The symbol-name route ran beside the strict query.
Name,
/// The strict query ran without the symbol-name route.
Prose,
}

/// Who chose the query route: the query-shape gate or the caller's
/// `prefer_symbol`.
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SearchQueryRouteDeciderV1 {
QueryShape,
Caller,
}

/// The query-shape routing decision behind a page.
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SearchQueryRouteV1 {
pub route: SearchQueryRouteKindV1,
/// Distance from the name gate in [-0.5, 0.5]: the share of the query's
/// content words that are identifier-shaped, minus one half. The gate
/// selects `name` at or above 0. Reported when the caller decided too.
/// It describes query shape only and is not an answerability signal.
pub margin: f64,
pub decided_by: SearchQueryRouteDeciderV1,
}

/// What a search served: a ranked page, or the typed reason no generation
/// could answer.
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
Expand All @@ -119,6 +151,9 @@ pub struct SearchCompleteV1 {
pub next_cursor: Option<String>,
pub coverage: SearchCoverageV1,
pub results: Vec<SearchResultRowV1>,
/// Which lane the query's shape selected, and its margin.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query_route: Option<SearchQueryRouteV1>,
/// Every lexical route fused into this page; present only when a route
/// beyond the strict query ran or ranked a result.
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
5 changes: 4 additions & 1 deletion crates/tracedecay-mcp/src/handlers/graph/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ where
.map_or(5, |value| value.clamp(1, 20) as usize);
let lexical_routing = lexical_routing::routing_from_parts(
request.lexical_anchors.clone().unwrap_or_default(),
request.prefer_symbol.unwrap_or(false),
request.prefer_symbol,
)?
.with_task_identifiers(task);
let requested_anchors: Vec<String> = lexical_routing
Expand Down Expand Up @@ -533,6 +533,9 @@ where
code_generation,
search_matches,
lexical_anchors,
query_route: complete
.as_ref()
.map(|complete| lexical_routing::query_route(&complete.lexical_routes)),
symbols,
related_symbols,
related_omission: projection.related_omission,
Expand Down
7 changes: 6 additions & 1 deletion crates/tracedecay-mcp/src/handlers/graph/context_markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use tracedecay_domain::errors::Result;
use tracedecay_graph_query::VerifiedGraphQuery;

use super::context_support::{context_markdown_lane_preview, insert_context_memory_section};
use super::lexical_routing::matched_anchor_line;
use super::lexical_routing::{matched_anchor_line, query_route_line};
use super::search::append_coverage_md;
use super::search_evidence::append_verified_graph_evidence_md;

Expand All @@ -55,6 +55,11 @@ pub(crate) fn render_context(
append_context_search_matches(&mut output, &result.search_matches);
}
append_context_lexical_anchors(&mut output, &result.lexical_anchors);
if let Some(route) = &result.query_route {
output.push('\n');
output.push_str(&query_route_line(route));
output.push('\n');
}
insert_context_memory_section(
&mut output,
&result.memory_matches,
Expand Down
52 changes: 45 additions & 7 deletions crates/tracedecay-mcp/src/handlers/graph/lexical_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ use serde_json::Value;
use tracedecay_contracts::retrieval::{
ContextLexicalAnchorV1, LexicalAnchorDropReasonV1, LexicalAnchorDropV1,
SearchLexicalAlternativeReasonV1, SearchLexicalFieldV1, SearchLexicalRouteV1,
SearchResultRowV1, SearchRouteMatchV1, SearchSpellingVariantV1, SearchSurfaceRequestV1,
SearchQueryRouteDeciderV1, SearchQueryRouteKindV1, SearchQueryRouteV1, SearchResultRowV1,
SearchRouteMatchV1, SearchSpellingVariantV1, SearchSurfaceRequestV1,
};
use tracedecay_domain::errors::{Result, TraceDecayError};
use tracedecay_query::retrieval::lexical::{
LexicalAliasV1, LexicalAlternativeReasonV1, LexicalAnchorOutcomeV1, LexicalAnchorReceiptV1,
LexicalFieldFilterV1, LexicalFieldV1, LexicalProximityV1, LexicalRouteKindV1,
LexicalRouteReceiptV1, LexicalRoutingV1,
LexicalFieldFilterV1, LexicalFieldV1, LexicalProximityV1, LexicalQueryRouteV1,
LexicalRouteDeciderV1, LexicalRouteKindV1, LexicalRouteReceiptV1, LexicalRoutingV1,
};

use crate::tools::render::Md;
Expand All @@ -27,7 +28,7 @@ pub(super) fn routing_from_request(request: &SearchSurfaceRequestV1) -> Result<L
.collect();
let mut routing = routing_from_parts(
request.lexical_anchors.clone().unwrap_or_default(),
request.prefer_symbol.unwrap_or(false),
request.prefer_symbol,
)?
.with_aliases(aliases)
.map_err(|error| TraceDecayError::Config {
Expand Down Expand Up @@ -71,7 +72,7 @@ fn lexical_field(field: SearchLexicalFieldV1) -> LexicalFieldV1 {

pub(super) fn routing_from_parts(
anchors: Vec<String>,
prefer_symbol: bool,
prefer_symbol: Option<bool>,
) -> Result<LexicalRoutingV1> {
LexicalRoutingV1::new(anchors, prefer_symbol).map_err(|error| TraceDecayError::Config {
message: error.to_string(),
Expand All @@ -92,6 +93,38 @@ pub(super) fn route_label(route: &LexicalRouteKindV1) -> String {
}
}

/// The query-shape route the kernel planned, with its margin.
pub(super) fn query_route(receipt: &LexicalRouteReceiptV1) -> SearchQueryRouteV1 {
let decision = receipt.decision;
SearchQueryRouteV1 {
route: match decision.route {
LexicalQueryRouteV1::Name => SearchQueryRouteKindV1::Name,
LexicalQueryRouteV1::Prose => SearchQueryRouteKindV1::Prose,
},
margin: f64::from(decision.margin_micros) / 1_000_000.0,
decided_by: match decision.decided_by {
LexicalRouteDeciderV1::QueryShape => SearchQueryRouteDeciderV1::QueryShape,
LexicalRouteDeciderV1::Caller => SearchQueryRouteDeciderV1::Caller,
},
}
}

/// One markdown line naming the query route, its margin, and its decider.
pub(super) fn query_route_line(route: &SearchQueryRouteV1) -> String {
let lane = match route.route {
SearchQueryRouteKindV1::Name => "name",
SearchQueryRouteKindV1::Prose => "prose",
};
let decided_by = match route.decided_by {
SearchQueryRouteDeciderV1::QueryShape => "query shape",
SearchQueryRouteDeciderV1::Caller => "caller prefer_symbol",
};
format!(
"Query route: {lane} (margin {:+.2}, decided by {decided_by})",
route.margin
)
}

/// The page-level route and anchor evidence, attached only when a route
/// beyond the strict query ran or ranked a result, and each ranked row's
/// matching routes.
Expand Down Expand Up @@ -312,7 +345,10 @@ mod tests {
use std::collections::BTreeMap;

use serde_json::json;
use tracedecay_query::retrieval::lexical::{LexicalRouteMatchV1, MAX_LEXICAL_ANCHORS_V1};
use tracedecay_query::retrieval::lexical::{
LexicalRouteDecisionV1, LexicalRouteMatchV1, MAX_LEXICAL_ANCHORS_V1,
SymbolRoutePreferenceV1,
};

use super::*;

Expand Down Expand Up @@ -344,7 +380,7 @@ mod tests {
}))
.expect("valid routing");
assert_eq!(routing_plan.anchors.len(), 2);
assert!(routing_plan.prefer_symbol);
assert_eq!(routing_plan.prefer_symbol, SymbolRoutePreferenceV1::Always);
assert_eq!(routing_plan.aliases[0].strict_query, "memoization");
assert_eq!(routing_plan.aliases[0].alternative, "cache");
assert_eq!(routing_plan.phrases, ["durable cache"]);
Expand Down Expand Up @@ -434,6 +470,7 @@ mod tests {
anchors: Vec::new(),
dropped_sites: BTreeMap::new(),
declaring_sites: std::collections::BTreeSet::new(),
decision: LexicalRouteDecisionV1::default(),
};
assert_eq!(route_evidence(&mut results, &query_only), (None, None));
assert_eq!(results[0].lexical_routes, None);
Expand Down Expand Up @@ -508,6 +545,7 @@ mod tests {
],
dropped_sites: BTreeMap::new(),
declaring_sites: std::collections::BTreeSet::new(),
decision: LexicalRouteDecisionV1::default(),
};
let (routes, anchors) = route_evidence(&mut results, &receipt);
let output = json!({"lexical_routes": routes, "lexical_anchors": anchors});
Expand Down
7 changes: 7 additions & 0 deletions crates/tracedecay-mcp/src/handlers/graph/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ where
coverage: search_coverage(&coverage),
code_generation: complete.code_generation,
results,
query_route: Some(lexical_routing::query_route(&complete.lexical_routes)),
lexical_routes,
lexical_anchors,
scope_prefix: scope_prefix.map(str::to_owned),
Expand Down Expand Up @@ -529,6 +530,12 @@ fn render_search_md(value: &Value) -> String {
.heading(3, "Availability")
.line(&format!("Search unavailable: {reason}."));
}
if let Some(route) = value
.get("query_route")
.and_then(|route| serde_json::from_value(route.clone()).ok())
{
md.blank().line(&lexical_routing::query_route_line(&route));
}
lexical_routing::append_routes_md(&mut md, value);
append_coverage_md(&mut md, value);
if let Some(msg) = value
Expand Down
1 change: 1 addition & 0 deletions crates/tracedecay-mcp/src/handlers/graph_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ mod tests {
code_generation: Some("generation.context".to_owned()),
search_matches: Vec::new(),
lexical_anchors: Vec::new(),
query_route: None,
symbols: vec![PrimitiveSymbolLocationV1 {
node_id: "symbol.store".to_owned(),
name: "Store".to_owned(),
Expand Down
Loading
Loading