diff --git a/crates/tracedecay-graph-query/src/verified_query.rs b/crates/tracedecay-graph-query/src/verified_query.rs index e48d78b1fc..4cd73ca3ee 100644 --- a/crates/tracedecay-graph-query/src/verified_query.rs +++ b/crates/tracedecay-graph-query/src/verified_query.rs @@ -548,56 +548,40 @@ impl VerifiedGraphQuery { .map_err(graph_projection_error) } - /// Finds files containing functions targeted by canonical annotation - /// edges whose source is a recognized test annotation marker. + /// Finds the requested files that contain functions targeted by canonical + /// annotation edges whose source is a recognized test annotation marker. /// - /// A request scoped to specific files resolves through the per-file - /// catalog index instead of the whole-corpus symbol stream: the four - /// recognized markers are lexically attached attributes, so a marker - /// always occupies the same file as the function it annotates, and only - /// the requested files can contribute either endpoint. The unscoped - /// census keeps the corpus sweep, that is its job. + /// The request resolves through the per-file catalog index instead of + /// the whole-corpus symbol stream: the four recognized markers are + /// lexically attached attributes, so a marker always occupies the same + /// file as the function it annotates, and only the requested files can + /// contribute either endpoint. #[hotpath::measure(label = "usecases.graph.verified.test_annotated_files")] pub fn test_annotated_logical_files( &self, - logical_paths: Option<&HashSet>, + logical_paths: &HashSet, max_symbols: usize, max_relations: usize, ) -> Result> { self.refuse_if_bound_closed()?; - let symbols = match logical_paths { - Some(requested) => { - let mut symbols = Vec::new(); - for path in requested { - let budget = max_symbols - .checked_sub(symbols.len()) - .filter(|remaining| *remaining > 0) - .ok_or_else(|| { - graph_budget_exhausted( - "verified test-attribution census exceeded its symbol budget", - ) - })?; - let mut in_file = - self.symbols_in_logical_file(path, budget.saturating_add(1))?; - if in_file.len() > budget { - return Err(graph_budget_exhausted( - "verified test-attribution census exceeded its symbol budget", - )); - } - symbols.append(&mut in_file); - } - symbols - } - None => { - let page = self.symbols_page(None, max_symbols)?; - if page.has_more { - return Err(graph_budget_exhausted( + let mut symbols = Vec::new(); + for path in logical_paths { + let budget = max_symbols + .checked_sub(symbols.len()) + .filter(|remaining| *remaining > 0) + .ok_or_else(|| { + graph_budget_exhausted( "verified test-attribution census exceeded its symbol budget", - )); - } - page.symbols + ) + })?; + let mut in_file = self.symbols_in_logical_file(path, budget.saturating_add(1))?; + if in_file.len() > budget { + return Err(graph_budget_exhausted( + "verified test-attribution census exceeded its symbol budget", + )); } - }; + symbols.append(&mut in_file); + } let mut paths = HashMap::new(); let mut test_markers = HashSet::new(); for symbol in &symbols { @@ -615,35 +599,17 @@ impl VerifiedGraphQuery { test_markers.insert(symbol.occurrence.clone()); } } - if logical_paths.is_some() { - if test_markers.is_empty() { - return Ok(HashSet::new()); - } - // Outgoing annotation edges from the scoped markers alone: the - // corpus-seeded `edges_among` variant below needs every endpoint - // in its seed set, which is exactly the full-corpus hydration a - // file-scoped request must not pay. - let markers = test_markers.iter().cloned().collect::>(); - return Ok(self - .callees(&markers, &[RelationEdgeKindV1::Annotates], max_relations)? - .into_iter() - .flatten() - .filter_map(|edge| paths.get(&edge.edge.to_occurrence).cloned()) - .collect()); + if test_markers.is_empty() { + return Ok(HashSet::new()); } - let occurrences = symbols - .iter() - .map(|symbol| symbol.occurrence.clone()) - .collect::>(); + // Outgoing annotation edges from the scoped markers alone; an + // `edges_among` read would need every endpoint in its seed set. + let markers = test_markers.iter().cloned().collect::>(); Ok(self - .edges_among( - &occurrences, - &[RelationEdgeKindV1::Annotates], - max_relations, - )? + .callees(&markers, &[RelationEdgeKindV1::Annotates], max_relations)? .into_iter() - .filter(|edge| test_markers.contains(&edge.from_occurrence)) - .filter_map(|edge| paths.get(&edge.to_occurrence).cloned()) + .flatten() + .filter_map(|edge| paths.get(&edge.edge.to_occurrence).cloned()) .collect()) } } diff --git a/crates/tracedecay-mcp/src/handlers/git/affected.rs b/crates/tracedecay-mcp/src/handlers/git/affected.rs index 9ba97bf6e8..7260f0e378 100644 --- a/crates/tracedecay-mcp/src/handlers/git/affected.rs +++ b/crates/tracedecay-mcp/src/handlers/git/affected.rs @@ -32,7 +32,7 @@ pub(super) async fn collect_verified_affected_test_files( files, max_depth, custom_glob, - |paths| graph.test_annotated_logical_files(Some(paths), 500_000, 2_000_000), + |paths| graph.test_annotated_logical_files(paths, 500_000, 2_000_000), ) .await } diff --git a/crates/tracedecay-mcp/src/handlers/git/context.rs b/crates/tracedecay-mcp/src/handlers/git/context.rs index 7804d8582f..1fdd1edf17 100644 --- a/crates/tracedecay-mcp/src/handlers/git/context.rs +++ b/crates/tracedecay-mcp/src/handlers/git/context.rs @@ -533,7 +533,7 @@ where let files_with_inline_tests = hotpath::measure_block!( "mcp.git.diff_context.test_annotations", graph.test_annotated_logical_files( - Some(&annotation_paths), + &annotation_paths, VERIFIED_GRAPH_MAX_SYMBOLS, VERIFIED_GRAPH_MAX_RELATIONS, )? @@ -744,7 +744,7 @@ where let files_with_inline_tests = hotpath::measure_block!( "mcp.git.commit_context.test_annotations", graph.test_annotated_logical_files( - Some(&changed_paths), + &changed_paths, VERIFIED_GRAPH_MAX_SYMBOLS, VERIFIED_GRAPH_MAX_RELATIONS, )? @@ -1331,7 +1331,7 @@ where let mut files_with_inline_tests = hotpath::measure_block!( "mcp.pr_context.test_annotations.changed", graph.test_annotated_logical_files( - Some(&changed_paths), + &changed_paths, VERIFIED_GRAPH_MAX_SYMBOLS, VERIFIED_GRAPH_MAX_RELATIONS, )? @@ -1440,7 +1440,7 @@ where files_with_inline_tests.extend(hotpath::measure_block!( "mcp.pr_context.test_annotations.impacted", graph.test_annotated_logical_files( - Some(&impact_path_set), + &impact_path_set, VERIFIED_GRAPH_MAX_SYMBOLS, VERIFIED_GRAPH_MAX_RELATIONS, )? diff --git a/crates/tracedecay-mcp/src/handlers/graph/context.rs b/crates/tracedecay-mcp/src/handlers/graph/context.rs index 820b4838be..4348a3a105 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/context.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/context.rs @@ -34,7 +34,8 @@ use super::primitive_surface::{ use super::search::execute_code_index_search; use super::search_evidence::{bind_verified_graph_to_search, race_primary_search_with_graph}; use super::search_freshness::{ - ServedGenerationV1, search_freshness, worktree_freshness_from_payload, + ServedGenerationV1, lanes_under_scheduler_freshness, search_freshness, + worktree_freshness_from_payload, }; use super::{ graph_symbol_end_line, graph_symbol_paths, required_graph_file_path, required_graph_metadata, @@ -321,10 +322,15 @@ where let search_matches = context_search_matches(&complete, scope_prefix); let lexical_anchors = context_lexical_anchors(&complete, scope_prefix); let code_generation = Some(complete.code_generation.clone()); - let coverage = primitive_search_coverage(&complete.coverage); + let lanes = lanes_under_scheduler_freshness( + complete.coverage.clone(), + &complete.code_generation, + &worktree_freshness, + ); + let coverage = primitive_search_coverage(&lanes); let freshness = search_freshness( ServedGenerationV1::Served(&complete.code_generation), - &complete.coverage, + &lanes, &worktree_freshness, ); ( @@ -474,6 +480,9 @@ where ), ..retrieval }; + let cost = graph + .as_ref() + .map(tracedecay_graph_query::VerifiedGraphQuery::read_cost); let result = ContextResultV1 { task: request.task, mode, @@ -498,7 +507,7 @@ where touched_files, code_graph: None, analytics: Some(analytics), - cost: None, + cost, }) } diff --git a/crates/tracedecay-mcp/src/handlers/graph/context_markdown.rs b/crates/tracedecay-mcp/src/handlers/graph/context_markdown.rs index c7313f7815..f0e65e089a 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/context_markdown.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/context_markdown.rs @@ -284,8 +284,8 @@ pub(super) fn verified_plan_context( test_files: None, }); } - let annotated_files = graph.test_annotated_logical_files(None, 500_000, 2_000_000)?; let mut test_files = HashSet::new(); + let mut unnamed_caller_files = HashSet::new(); for symbol in symbols { for caller in traverse_verified_neighbors( graph, @@ -295,12 +295,18 @@ pub(super) fn verified_plan_context( 2, )? { let file_path = required_graph_file_path(&caller.symbol)?; - if tracedecay_code_index::is_test_file(file_path) || annotated_files.contains(file_path) - { + if tracedecay_code_index::is_test_file(file_path) { test_files.insert(file_path.to_owned()); + } else { + unnamed_caller_files.insert(file_path.to_owned()); } } } + test_files.extend(graph.test_annotated_logical_files( + &unnamed_caller_files, + 500_000, + 2_000_000, + )?); let mut test_files = test_files.into_iter().collect::>(); test_files.sort(); Ok(ContextPlanV1 { diff --git a/crates/tracedecay-mcp/src/handlers/graph/search.rs b/crates/tracedecay-mcp/src/handlers/graph/search.rs index 502fad942a..9df28b3b8e 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/search.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/search.rs @@ -30,7 +30,8 @@ use super::search_evidence::{ SearchGraphEvidence, bind_verified_graph_to_search, race_primary_search_with_graph, }; use super::search_freshness::{ - ServedGenerationV1, freshness_lines, search_freshness, worktree_freshness_from_payload, + ServedGenerationV1, freshness_lines, lanes_under_scheduler_freshness, search_freshness, + worktree_freshness_from_payload, }; use super::verified::CODE_SYMBOL_EVIDENCE_PREFIX; use super::{graph_occurrence_id, graph_tool_completion}; @@ -269,10 +270,15 @@ where } else { None }; + let coverage = lanes_under_scheduler_freshness( + complete.coverage.clone(), + &complete.code_generation, + &worktree_freshness, + ); let result = SearchCompleteV1 { freshness: search_freshness( ServedGenerationV1::Served(&complete.code_generation), - &complete.coverage, + &coverage, &worktree_freshness, ), query_fallback_digest: complete.query_fallback.digest.as_str().to_owned(), @@ -281,7 +287,7 @@ where .as_ref() .map(serde_json::to_string) .transpose()?, - coverage: search_coverage(&complete.coverage), + coverage: search_coverage(&coverage), code_generation: complete.code_generation, results, lexical_routes, diff --git a/crates/tracedecay-mcp/src/handlers/graph/search_freshness.rs b/crates/tracedecay-mcp/src/handlers/graph/search_freshness.rs index 11191ed05c..69bf2922aa 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/search_freshness.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/search_freshness.rs @@ -75,6 +75,57 @@ fn stale_lanes(coverage: &CodeIndexSearchCoverageV1) -> Vec { .collect() } +/// Whether the scheduler's reading proves `served_generation` current: the +/// worktree is fresh, nothing is rebuilding, and no newer generation sealed. +fn scheduler_proves_current( + state: &CodeIndexWorktreeFreshnessV1, + served_generation: Option<&str>, +) -> bool { + state.staleness_state == Some(CodeIndexStalenessStateV1::Fresh) + && !state.rebuild_in_flight + && (state.latest_generation_id.is_none() + || served_generation.is_none() + || state.latest_generation_id.as_deref() == served_generation) +} + +/// The executor's lane coverage restated under the scheduler's reading, so +/// the lanes and the verdict answer to one freshness authority. The query +/// gate marks a lane stale when it could not prove freshness itself; once +/// the scheduler proves the generation that lane served current, the lane +/// is complete for it. +pub(super) fn lanes_under_scheduler_freshness( + mut coverage: CodeIndexSearchCoverageV1, + served_generation: &str, + worktree: &WorktreeFreshnessSourceV1, +) -> CodeIndexSearchCoverageV1 { + let WorktreeFreshnessSourceV1::Worktree(state) = worktree else { + return coverage; + }; + if state.latest_generation_id.as_deref() != Some(served_generation) + || !scheduler_proves_current(state, Some(served_generation)) + { + return coverage; + } + for lane in [ + &mut coverage.exact, + &mut coverage.lexical, + &mut coverage.graph, + ] { + match lane { + CodeIndexLaneStatusV1::Stale { generation } if generation == served_generation => { + *lane = CodeIndexLaneStatusV1::Complete; + } + CodeIndexLaneStatusV1::Partial { generation, .. } + if generation.as_deref() == Some(served_generation) => + { + *generation = None; + } + _ => {} + } + } + coverage +} + /// Derive the verdict from the executor's lane coverage and the scheduler's /// worktree state. pub(super) fn search_freshness( @@ -89,11 +140,7 @@ pub(super) fn search_freshness( }; let scheduler_says_stale = match worktree { WorktreeFreshnessSourceV1::Worktree(state) => { - state.staleness_state != Some(CodeIndexStalenessStateV1::Fresh) - || state.rebuild_in_flight - || (state.latest_generation_id.is_some() - && served_generation.is_some() - && state.latest_generation_id != served_generation) + !scheduler_proves_current(state, served_generation.as_deref()) } WorktreeFreshnessSourceV1::NotMounted | WorktreeFreshnessSourceV1::Unattached => false, }; diff --git a/crates/tracedecay-mcp/src/handlers/workflow/affected_tests_tests.rs b/crates/tracedecay-mcp/src/handlers/workflow/affected_tests_tests.rs index 2032951374..f34ed52e38 100644 --- a/crates/tracedecay-mcp/src/handlers/workflow/affected_tests_tests.rs +++ b/crates/tracedecay-mcp/src/handlers/workflow/affected_tests_tests.rs @@ -945,7 +945,7 @@ fn scoped_test_annotation_lookup_needs_only_a_file_scale_budget() { .collect(); let annotated = graph - .test_annotated_logical_files(Some(&requested), 8, 64) + .test_annotated_logical_files(&requested, 8, 64) .expect("a two-file question must not require a corpus-scale symbol budget"); assert_eq!( annotated, @@ -955,18 +955,11 @@ fn scoped_test_annotation_lookup_needs_only_a_file_scale_budget() { "only the file whose function carries a test marker is reported" ); - // The unscoped census keeps its corpus sweep and its budget contract. - let census = graph.test_annotated_logical_files(None, 8, 64); - assert!( - census.is_err(), - "the whole-corpus census still refuses a budget below the corpus size" - ); - // The scoped budget still bounds the requested files themselves. let hot_only: HashSet = ["src/hot.rs".to_owned()].into_iter().collect(); assert!( graph - .test_annotated_logical_files(Some(&hot_only), 1, 64) + .test_annotated_logical_files(&hot_only, 1, 64) .is_err(), "requested files larger than the budget stay a typed refusal" ); diff --git a/crates/tracedecay/src/mcp/tools/handlers/graph_search_dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/graph_search_dispatch_tests.rs index 682986afac..342e43f555 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/graph_search_dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/graph_search_dispatch_tests.rs @@ -429,6 +429,106 @@ async fn search_opens_with_a_freshness_verdict_from_typed_state_case() { cg.close(); } +#[test] +fn search_lanes_answer_to_the_freshness_the_verdict_reports() { + run_on_current_thread(search_lanes_answer_to_the_freshness_the_verdict_reports_case()); +} + +/// The query gate served the latest generation without proving it current, +/// so the executor marked its lanes stale. When the scheduler reports that +/// same generation fresh, the response carries one verdict: fresh, with no +/// lane calling itself stale. A newer sealed generation keeps them stale. +async fn search_lanes_answer_to_the_freshness_the_verdict_reports_case() { + let dir = tempfile::TempDir::new().expect("lane freshness isolation"); + let profile = + crate::mcp::tools::handlers::dispatch_test_support::SelectorProfile::new(dir.path()); + let project = dir.path().join("lane-freshness-search"); + std::fs::create_dir_all(project.join("src")).expect("create lane freshness sources"); + std::fs::write( + project.join("src/lib.rs"), + "pub fn SparseLexicalWidget() {}\n", + ) + .expect("write lane freshness fixture"); + let (cg, _runtime) = TraceDecay::init_test_fixture_with_registered_runtime( + profile.data_dir(), + &project, + "project.lane-freshness-search", + ) + .await + .expect("registered lane freshness fixture"); + let served = "generation.mcp-verified-graph-fixture.1"; + let executor: tracedecay_query::code_search::CodeIndexSearchExecutor = std::sync::Arc::new( + move |_| { + Box::pin(async move { + let tracedecay_query::code_search::CodeIndexSearchOutcomeV1::Complete(mut complete) = + completed_sparse_search_for_generation(served) + else { + panic!("sparse search fixture is complete"); + }; + let stale = tracedecay_query::code_search::CodeIndexLaneStatusV1::Stale { + generation: served.to_owned(), + }; + complete.coverage = tracedecay_query::code_search::CodeIndexSearchCoverageV1 { + exact: stale.clone(), + lexical: tracedecay_query::code_search::CodeIndexLaneStatusV1::Partial { + generation: Some(served.to_owned()), + reason: Some("candidate_sources_pruned"), + }, + graph: stale, + }; + tracedecay_query::code_search::CodeIndexSearchOutcomeV1::Complete(complete) + }) + }, + ); + let search = |latest: &str| { + let options = crate::mcp::tools::handlers::ToolCallRegistryOptions { + code_index_freshness_reader: Some(freshness_reader(Some(latest), "fresh", false)), + ..search_test_options(&cg, executor.clone()) + }; + crate::mcp::tools::handlers::dispatch_test_support::dispatch_on_graph_authority( + &cg, + "tracedecay_search", + json!({"query": "SparseLexicalWidget", "limit": 5, "format": "json"}), + options, + ) + }; + + let fresh = search(served).await.expect("fresh search renders"); + let payload: Value = serde_json::from_str(&response_text(&fresh)).expect("search JSON"); + assert_eq!( + payload["freshness"], + json!({"state": "fresh"}), + "{payload:#}" + ); + assert_eq!( + payload["coverage"], + json!({ + "exact": "complete", + "lexical": { + "status": "partial", + "generation": null, + "reason": "candidate_sources_pruned", + }, + "graph": "complete", + "recall": "partial", + }), + "{payload:#}" + ); + + let newer = search("generation.mcp-verified-graph-fixture.2") + .await + .expect("superseded search renders"); + let payload: Value = serde_json::from_str(&response_text(&newer)).expect("search JSON"); + assert_eq!(payload["freshness"]["state"], "possibly_stale"); + assert_eq!( + payload["freshness"]["indexing"]["stale_lanes"], + json!(["exact", "graph"]), + "{payload:#}" + ); + assert_eq!(payload["coverage"]["exact"]["status"], "stale"); + cg.close(); +} + #[test] fn search_forwards_lexical_routing_and_renders_route_evidence() { run_on_current_thread(search_forwards_lexical_routing_and_renders_route_evidence_case()); diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs index 793b62956f..915d1516dd 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs @@ -159,6 +159,68 @@ async fn plan_context_returns_its_plan_sections_and_the_accounting_footer() { shutdown_graph_fixture(fixture).await; } +/// Unrelated files whose inline tests a plan read has no reason to visit. +const UNRELATED_TEST_FILES: usize = 40; + +/// Plan mode attributes test coverage from the anchors' two-hop caller +/// neighborhood, so its store cost does not grow with test files elsewhere +/// in the corpus: a whole-corpus test-annotation census fanned out over +/// every annotation edge in the generation. +#[tokio::test] +async fn plan_context_reads_only_the_anchor_neighborhood() { + let fixture = graph_query_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/walk.rs"), WALK_RS).unwrap(); + fs::write( + project.join("src/lib.rs"), + "mod walk;\nuse walk::Walk;\npub fn known(walk: &Walk) { walk.read(); }\n\ + #[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn covers_known() { known(&Walk); }\n}\n", + ) + .unwrap(); + for index in 0..UNRELATED_TEST_FILES { + fs::write( + project.join(format!("src/unrelated_{index}.rs")), + format!( + "pub fn unrelated_{index}() {{}}\n#[cfg(test)]\nmod tests {{\n \ + use super::*;\n\n #[test]\n fn unrelated_{index}_works() {{ unrelated_{index}(); }}\n}}\n" + ), + ) + .unwrap(); + } + }) + .await; + // Asking for code makes the read wait for graph admission. + let arguments = json!({ + "task": "known", + "mode": "plan", + "max_nodes": 1, + "include_code": true, + "max_code_blocks": 1, + }); + + let mut json_arguments = arguments.clone(); + json_arguments["format"] = json!("json"); + let texts = call(&fixture, "tracedecay_context", json_arguments).await; + let payload: Value = serde_json::from_str(&texts[0]).unwrap(); + assert_eq!(payload["symbols"][0]["name"], "known", "{payload:#}"); + assert_eq!( + payload["plan"]["test_files"], + json!(["src/lib.rs"]), + "{payload:#}" + ); + let trailer = cost_trailer(&texts); + let adjacency_rows = trailer + .split_whitespace() + .find_map(|field| field.strip_prefix("adjacency_rows=")) + .and_then(|rows| rows.parse::().ok()) + .unwrap_or_else(|| panic!("adjacency rows in {trailer:?}")); + assert!( + adjacency_rows < UNRELATED_TEST_FILES, + "a plan read visited unrelated test annotations: {trailer}" + ); + shutdown_graph_fixture(fixture).await; +} + /// A typed symbol-graph read reports the files it answered from and the /// generation it served on its envelope, so the MCP response carries the /// same footer as every other code read.