diff --git a/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs b/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs index 6a780a9eab..2fa35e280c 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/tool_hints.rs @@ -84,15 +84,84 @@ struct HintCategorySpec { skill: &'static str, message: &'static str, context: &'static str, - /// Machine-readable list of the tracedecay MCP tools this hint steers the - /// model toward, derived from the tools named in `context`. The - /// hint-outcome correlator (`super::hint_outcomes`) treats a hint as - /// "acted" when one of these tools fires in the session after the hint, - /// instead of re-parsing the prose. Keep in sync with `context`. + /// Tools a later call credits as acting on this hint. Each name must be an + /// exact token in `message` or `context`; [`HintCategorySpec::checked`] + /// refuses the spec otherwise, so a slash-joined shorthand such as + /// `tracedecay_context/search` cannot silently drop `tracedecay_search`. expected_tools: &'static [&'static str], nonblocking: bool, } +const fn is_tool_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +/// True when `needle` appears in `haystack` as a whole tool token, not as a +/// slash-joined shorthand or a prefix of a longer name. +const fn contains_tool_token(haystack: &str, needle: &str) -> bool { + let haystack = haystack.as_bytes(); + let needle = needle.as_bytes(); + if needle.is_empty() || haystack.len() < needle.len() { + return false; + } + let mut start = 0; + while start + needle.len() <= haystack.len() { + let mut offset = 0; + while offset < needle.len() && haystack[start + offset] == needle[offset] { + offset += 1; + } + if offset == needle.len() { + let preceded = start > 0 && is_tool_token_byte(haystack[start - 1]); + let followed = start + needle.len() < haystack.len() + && is_tool_token_byte(haystack[start + needle.len()]); + if !preceded && !followed { + return true; + } + } + start += 1; + } + false +} + +impl HintCategorySpec { + const fn names_every_expected_tool(&self) -> bool { + let mut index = 0; + while index < self.expected_tools.len() { + let tool = self.expected_tools[index]; + if !contains_tool_token(self.message, tool) && !contains_tool_token(self.context, tool) + { + return false; + } + index += 1; + } + true + } + + const fn checked(self) -> Self { + assert!( + self.names_every_expected_tool(), + "hint expected_tools must appear as exact tool tokens in message or context" + ); + self + } +} + +#[cfg(test)] +mod expected_tool_token_tests { + use super::contains_tool_token; + + #[test] + fn slash_joined_shorthand_does_not_name_the_second_tool() { + let prose = + "pass project_path to tracedecay_context/search before scanning parent directories"; + assert!(contains_tool_token(prose, "tracedecay_context")); + assert!( + !contains_tool_token(prose, "tracedecay_search"), + "a slash-joined shorthand must not count as naming tracedecay_search" + ); + } +} + const CATEGORY_SPECS: &[HintCategorySpec] = &[ HintCategorySpec { category: HintCategory::Search, @@ -108,7 +177,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_context", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::SemanticSearch, key: "semantic_search", @@ -118,7 +187,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_context answers concept-level queries from the pre-built code graph (add keywords to expand synonyms); tracedecay_search ranks symbols by name/keyword; tracedecay_grep matches a literal or regex string when you want exact text, not a concept.", expected_tools: &["tracedecay_context", "tracedecay_search", "tracedecay_grep"], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::FileRead, key: "file_read", @@ -133,7 +202,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_grep", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::ToolDescriptorRead, key: "tool_descriptor_read", @@ -147,7 +216,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_callees", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::BroadRead, key: "broad_read", @@ -157,7 +226,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_context gathers relevant code slices without reading entire directories or the whole repository; tracedecay_grep sweeps the indexed tree for a literal or regex string when you are hunting for exact text rather than a concept.", expected_tools: &["tracedecay_context", "tracedecay_grep"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::CallGraph, key: "call_graph", @@ -173,7 +242,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_impact", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::Impact, key: "impact", @@ -188,7 +257,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_test_map", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::SymbolLookup, key: "symbol_lookup", @@ -198,7 +267,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_context and tracedecay_node can locate definitions and nearby relationships from the code graph.", expected_tools: &["tracedecay_context", "tracedecay_node"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::FileLookup, key: "file_lookup", @@ -208,14 +277,14 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_files can list indexed files and narrow file lookup before opening individual files.", expected_tools: &["tracedecay_files"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::ProjectContext, key: "project_context", label: "project context", skill: "code-health", message: "For other repos or registered projects, consider TraceDecay project registry tools.", - context: "tracedecay_project_list shows known projects; tracedecay_project_search can find a sibling repo by name/path/remote; pass project_path or project_id to tracedecay_context/search for cross-project code context before scanning parent directories.", + context: "tracedecay_project_list shows known projects; tracedecay_project_search can find a sibling repo by name/path/remote; pass project_path or project_id to tracedecay_context or tracedecay_search for cross-project code context before scanning parent directories.", expected_tools: &[ "tracedecay_project_list", "tracedecay_project_search", @@ -223,7 +292,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_search", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::SessionRecall, key: "session_recall", @@ -233,14 +302,14 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_message_search searches ingested agent transcripts across providers; tracedecay_lcm_grep can search bounded raw-message snippets and summaries when you need session-level recall before re-discovering context.", expected_tools: &["tracedecay_message_search", "tracedecay_lcm_grep"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::AtomicEdit, key: "atomic_edit", label: "atomic edit", skill: "editing-safely", message: "For safe mechanical edits, use TraceDecay's anchored edit tools.", - context: "Use tracedecay_str_replace for one exact swap, tracedecay_multi_str_replace for an all-or-nothing batch, tracedecay_insert_at or tracedecay_insert_at_symbol for anchored insertion, tracedecay_replace_symbol for one resolved symbol, and tracedecay_ast_grep_rewrite for structural rewrites.", + context: "Use tracedecay_str_replace for one exact swap, tracedecay_multi_str_replace for an all-or-nothing batch, tracedecay_insert_at or tracedecay_insert_at_symbol for anchored insertion, tracedecay_replace_symbol for one resolved symbol, tracedecay_move_symbol to relocate one resolved symbol, and tracedecay_ast_grep_rewrite for structural rewrites.", expected_tools: &[ "tracedecay_str_replace", "tracedecay_multi_str_replace", @@ -251,7 +320,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_move_symbol", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::TypeOrientation, key: "type_orientation", @@ -267,7 +336,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_type_hierarchy", ], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::ExploreSubagent, key: "explore_subagent", @@ -282,7 +351,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_impact", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::SubagentStartContext, key: "subagent_start_context", @@ -296,7 +365,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_impact", ], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::BuildDiagnostics, key: "build_diagnostics", @@ -306,7 +375,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_diagnostics runs (or reads) the project's diagnostics and maps each error to its enclosing symbol; tracedecay_diagnose adds caller/impact context for a specific failure so you fix the root cause, not just the line the compiler points at.", expected_tools: &["tracedecay_diagnostics", "tracedecay_diagnose"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::ReviewChanges, key: "review_changes", @@ -316,7 +385,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_diff_context maps local changed files to touched symbols, dependents, and tests; tracedecay_pr_context does the same for a PR branch when available, so use GitHub only for review comments, metadata, and CI state.", expected_tools: &["tracedecay_diff_context", "tracedecay_pr_context"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::MemoryStore, key: "memory_store", @@ -326,7 +395,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_fact_store_add persists a trust-ranked project/user fact that survives across sessions and is recalled by tracedecay_context and tracedecay_recall; a memory markdown edit is only visible to the current harness. Keep secrets and unnecessary PII out of stored facts.", expected_tools: &["tracedecay_fact_store_add"], nonblocking: false, - }, + }.checked(), HintCategorySpec { category: HintCategory::EditRedundancy, key: "edit_redundancy", @@ -336,7 +405,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ context: "tracedecay_search finds existing helpers by name, signature, or body text and tracedecay_similar finds similarly named symbols; if a match exists, reuse or refactor the existing helper instead of keeping a second copy.", expected_tools: &["tracedecay_search", "tracedecay_similar"], nonblocking: true, - }, + }.checked(), HintCategorySpec { category: HintCategory::UnexpectedChanges, key: "unexpected_changes", @@ -351,7 +420,7 @@ const CATEGORY_SPECS: &[HintCategorySpec] = &[ "tracedecay_message_search", ], nonblocking: false, - }, + }.checked(), ]; #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/tracedecay-dashboard-api/src/tracedecay.rs b/crates/tracedecay-dashboard-api/src/tracedecay.rs index ce5d59bd82..282efa79d9 100644 --- a/crates/tracedecay-dashboard-api/src/tracedecay.rs +++ b/crates/tracedecay-dashboard-api/src/tracedecay.rs @@ -24,9 +24,7 @@ pub struct DashboardProjectContext { } pub mod facts { - // The shared resolvers live in `tracedecay_session_memory::memory` — the crate - // that owns `MemoryApplication`/`MemoryApplicationError` — rather than a - // copy kept in sync by hand here. `tracedecay::facts::memory_application_for_db` - // remains the stable call-site path for this crate's ~20 dashboard routes. + // Resolvers live with `MemoryApplication`. This re-export is the stable + // call-site path for this crate's dashboard routes. pub use tracedecay_session_memory::memory::memory_application_for_db; } diff --git a/crates/tracedecay-graph-db/Cargo.toml b/crates/tracedecay-graph-db/Cargo.toml index a6c55f357a..8d56ec7dac 100644 --- a/crates/tracedecay-graph-db/Cargo.toml +++ b/crates/tracedecay-graph-db/Cargo.toml @@ -67,8 +67,9 @@ graph-disk-tier = ["grafeo-engine/spill"] # a flat address over the epoch's chunks, so an epoch grows to the 4 GiB a # u32 can reach and exhaustion is a typed error. # -# The fix holds -- `tests/tiered_storage_rss.rs` commits 65,536-row pages, -# twice the old ceiling, without panicking. It just does not pay off. The +# The fix holds -- `tests/tiered_storage_rss.rs` commits full native staging +# pages (`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`), twice the old ~32 Ki +# ceiling, without panicking. It just does not pay off. The # arena costs resident memory rather than saving it, because nothing here is # ever written to disk: measured with that harness on the fork, live VmRSS # with the store open runs +22% at 100k rows, +32% at 500k, and +28% at 1M diff --git a/crates/tracedecay-graph-db/src/generation_runtime.rs b/crates/tracedecay-graph-db/src/generation_runtime.rs index df82f3ef63..9467c245c3 100644 --- a/crates/tracedecay-graph-db/src/generation_runtime.rs +++ b/crates/tracedecay-graph-db/src/generation_runtime.rs @@ -3705,7 +3705,7 @@ mod tests { SourceGeneration::new("source-wide-native-stage").unwrap(), GraphWatermark::new("watermark-wide-native-stage").unwrap(), vec![], - (0..65_536) + (0..MAX_NATIVE_GENERATION_STAGE_MUTATIONS) .map(|index| { GraphEntity::new( GraphEntityId::new(format!("entity:{index:05}")).unwrap(), @@ -3723,7 +3723,7 @@ mod tests { assert_eq!( pages.len(), 1, - "native generation staging must avoid a full-graph Grafeo commit scan every 4,096 rows" + "native generation staging must avoid a full-graph Grafeo commit scan every {MAX_VERIFIED_GENERATION_BATCH_MUTATIONS} rows" ); let temp = TempDir::new().unwrap(); diff --git a/crates/tracedecay-graph-db/src/lib.rs b/crates/tracedecay-graph-db/src/lib.rs index 6f81102df6..baf03eab9d 100644 --- a/crates/tracedecay-graph-db/src/lib.rs +++ b/crates/tracedecay-graph-db/src/lib.rs @@ -46,9 +46,9 @@ pub use lease::{VerifiedGraphSnapshot, VerifiedTraversalResult, VerifiedTraversa pub use limits::{ MAX_GRAPH_BATCH_CANONICAL_BYTES, MAX_GRAPH_ENTITY_LABEL_BYTES, MAX_GRAPH_ENTITY_LABELS, MAX_GRAPH_IDENTIFIER_BYTES, MAX_GRAPH_PROPERTIES, MAX_GRAPH_PROPERTY_AGGREGATE_BYTES, - MAX_GRAPH_PROPERTY_VALUE_BYTES, MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, - MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, MAX_VERIFIED_GENERATION_ENTITIES, - MAX_VERIFIED_GENERATION_RELATIONS, + MAX_GRAPH_PROPERTY_VALUE_BYTES, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, + MAX_VERIFIED_GENERATION_ENTITIES, MAX_VERIFIED_GENERATION_RELATIONS, }; #[cfg(any(feature = "test-helpers", feature = "eval-helpers"))] pub use location::{GraphDbLocation, GraphDbOpenOptions, GraphDurability, GraphFormatVersion}; diff --git a/crates/tracedecay-graph-db/src/limits.rs b/crates/tracedecay-graph-db/src/limits.rs index 1072f01b0a..e09a5cdfb3 100644 --- a/crates/tracedecay-graph-db/src/limits.rs +++ b/crates/tracedecay-graph-db/src/limits.rs @@ -22,7 +22,7 @@ pub const MAX_VERIFIED_GENERATION_BATCH_LIVE_BYTES: usize = 32 * 1024 * 1024; /// quadratic in its page count. The daemon's resident-memory admission owns /// the full manifest; this additional page clone remains bounded independently /// and cancellation is observed before and after every durable page. -pub(crate) const MAX_NATIVE_GENERATION_STAGE_MUTATIONS: usize = 65_536; +pub const MAX_NATIVE_GENERATION_STAGE_MUTATIONS: usize = 65_536; pub(crate) const MAX_NATIVE_GENERATION_STAGE_LIVE_BYTES: usize = 128 * 1024 * 1024; pub const MAX_GRAPH_IDENTIFIER_BYTES: usize = 1_024; pub const MAX_GRAPH_ENTITY_LABELS: usize = 128; diff --git a/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs b/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs index 7ec041bf63..72833441dc 100644 --- a/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs +++ b/crates/tracedecay-graph-db/tests/at_rest_snapshot.rs @@ -63,17 +63,13 @@ use tracedecay_graph_db::{ GraphCancellation, GraphDbLeaseV1, GraphEntity, GraphEntityId, GraphLabel, GraphMutation, GraphNamespace, GraphProjectionId, GraphProperty, GraphPropertyName, GraphRelation, GraphRelationId, GraphRelationKind, GraphTraversalDirection, GraphWatermark, GraphWriteBatch, - NeverCancelled, SourceGeneration, TraversalRequest, + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, NeverCancelled, SourceGeneration, TraversalRequest, }; mod support; use support::RegisteredGraph; -/// Mirrors the crate-private `limits::MAX_NATIVE_GENERATION_STAGE_MUTATIONS`, -/// the page size the native generation runtime actually flushes at. -const PRODUCTION_STAGE_PAGE: usize = 65_536; - /// Default row count when `TRACEDECAY_ATREST_ROWS` is unset. const DEFAULT_ROWS: usize = 500_000; @@ -195,8 +191,11 @@ impl Mode { /// Stages `rows` entities and `rows / RELATION_DIVISOR` relations in /// production-sized pages. fn stage(db: &GraphDbLeaseV1, rows: usize) { - for (page, start) in (0..rows).step_by(PRODUCTION_STAGE_PAGE).enumerate() { - let end = (start + PRODUCTION_STAGE_PAGE).min(rows); + for (page, start) in (0..rows) + .step_by(MAX_NATIVE_GENERATION_STAGE_MUTATIONS) + .enumerate() + { + let end = (start + MAX_NATIVE_GENERATION_STAGE_MUTATIONS).min(rows); let mutations = (start..end) .map(|index| GraphMutation::UpsertEntity(entity(index))) .collect(); @@ -205,8 +204,11 @@ fn stage(db: &GraphDbLeaseV1, rows: usize) { } let relations = rows / RELATION_DIVISOR; - for (page, start) in (0..relations).step_by(PRODUCTION_STAGE_PAGE).enumerate() { - let end = (start + PRODUCTION_STAGE_PAGE).min(relations); + for (page, start) in (0..relations) + .step_by(MAX_NATIVE_GENERATION_STAGE_MUTATIONS) + .enumerate() + { + let end = (start + MAX_NATIVE_GENERATION_STAGE_MUTATIONS).min(relations); let mutations = (start..end) .map(|index| GraphMutation::UpsertRelation(relation(index))) .collect(); @@ -361,7 +363,7 @@ fn at_rest_reopen_probe() { println!("=== at-rest reopen probe ==="); println!("mode : {}", mode.label()); println!( - "rows : {rows} entities + {relations} relations, pages of {PRODUCTION_STAGE_PAGE}" + "rows : {rows} entities + {relations} relations, pages of {MAX_NATIVE_GENERATION_STAGE_MUTATIONS}" ); println!("--- staging ---"); println!("stage wall : {:.2}s", stage_wall.as_secs_f64()); diff --git a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs index 66a5b073ce..f2ec241b09 100644 --- a/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs +++ b/crates/tracedecay-graph-db/tests/graph_db_suite/verified_generation_contract/staging_footprint.rs @@ -7,7 +7,9 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc; use tracedecay_graph_db::{ - GraphGenerationManifestProvider, GraphTraversalDirection, TraversalRequest, + GraphGenerationManifestProvider, GraphTraversalDirection, + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, MAX_VERIFIED_GENERATION_BATCH_MUTATIONS, + TraversalRequest, }; use super::*; @@ -1649,15 +1651,6 @@ fn a_cancelled_release_never_leaves_a_row_set_recovery_cannot_serve() { eprintln!("cancelled_release_partial_row_sets_observed={observed_partial}"); } -/// Mirrors the crate-private `limits::MAX_NATIVE_GENERATION_STAGE_MUTATIONS`, -/// the mutation ceiling one native staging page flushes at. Kept in sync by -/// hand; the geometry assertion below fails loudly if it drifts. -const NATIVE_STAGE_PAGE_MUTATIONS: usize = 65_536; - -/// Mirrors `limits::MAX_VERIFIED_GENERATION_BATCH_MUTATIONS`, the rows one -/// durable release transaction removes. -const RELEASE_PAGE_MUTATIONS: usize = 4_096; - /// A manifest in the two-page staging geometry an interrupted release can /// misrepresent: one full native page plus a second page wider than a single /// release transaction. Carries the same readable evidence entities and @@ -1732,7 +1725,8 @@ fn two_page_manifest( /// leaves the partial row set in the container. #[test] fn a_partially_released_generation_is_repaired_from_its_manifest_and_swept() { - let entity_count = NATIVE_STAGE_PAGE_MUTATIONS + 2 * RELEASE_PAGE_MUTATIONS; + let entity_count = + MAX_NATIVE_GENERATION_STAGE_MUTATIONS + 2 * MAX_VERIFIED_GENERATION_BATCH_MUTATIONS; let temp = TempDir::new().unwrap(); let registered = RegisteredGraph::new_mounted(temp.path()).unwrap(); let mut authority = RelationalAuthority::default(); @@ -1768,7 +1762,7 @@ fn a_partially_released_generation_is_repaired_from_its_manifest_and_swept() { // bounded page. // // The budget is measured in cancellation *polls*, and a release page polls - // once per row it enumerates before it commits `RELEASE_PAGE_MUTATIONS` of + // once per row it enumerates before it commits `MAX_VERIFIED_GENERATION_BATCH_MUTATIONS` of // them -- so the first durable page costs on the order of the whole row // count in polls, not the few dozen a short linear walk reaches. Start at // that scale and double: the first budget that commits cannot reach a @@ -1801,9 +1795,9 @@ fn a_partially_released_generation_is_repaired_from_its_manifest_and_swept() { let counts = partial.expect("an interrupted release must commit at least one durable entity page"); assert!( - counts.0 >= NATIVE_STAGE_PAGE_MUTATIONS && counts.0 < full.0, + counts.0 >= MAX_NATIVE_GENERATION_STAGE_MUTATIONS && counts.0 < full.0, "this fixture must reach the state the count heuristic misreads: \ - {counts:?} of {full:?}, first stage page ends at {NATIVE_STAGE_PAGE_MUTATIONS}" + {counts:?} of {full:?}, first stage page ends at {MAX_NATIVE_GENERATION_STAGE_MUTATIONS}" ); assert!(registered.close().unwrap()); diff --git a/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs b/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs index 4aba12cfb6..38356b3ae8 100644 --- a/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs +++ b/crates/tracedecay-graph-db/tests/tiered_storage_rss.rs @@ -57,7 +57,7 @@ //! records) and panicked. The fork this workspace patches in spans an epoch //! arena across chunks, so the page commits instead of aborting. //! -//! [`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`]: (crate-private; mirrored below) +//! [`MAX_NATIVE_GENERATION_STAGE_MUTATIONS`]: tracedecay_graph_db::MAX_NATIVE_GENERATION_STAGE_MUTATIONS use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -66,7 +66,8 @@ use std::time::Instant; use tempfile::TempDir; use tracedecay_graph_db::{ GraphCancellation, GraphEntity, GraphEntityId, GraphMutation, GraphNamespace, - GraphProjectionId, GraphWatermark, GraphWriteBatch, NeverCancelled, SourceGeneration, + GraphProjectionId, GraphWatermark, GraphWriteBatch, MAX_NATIVE_GENERATION_STAGE_MUTATIONS, + NeverCancelled, SourceGeneration, }; mod support; @@ -80,12 +81,6 @@ const ENTITY_COUNT: usize = 100_000; /// production staging page so the two probes bracket the arena behaviour. const SMALL_BATCH_SIZE: usize = 5_000; -/// Mirrors the crate-private `limits::MAX_NATIVE_GENERATION_STAGE_MUTATIONS`. -/// The native generation runtime flushes a staging page once it reaches this -/// many mutations, so this is the largest single-epoch commit production ever -/// performs. Kept in sync by hand; it is a measurement input, not a contract. -const PRODUCTION_STAGE_PAGE: usize = 65_536; - /// Total entities in the production-shaped probe. Override with /// `TRACEDECAY_RSS_ENTITIES` to scale the sweep without a rebuild. const PRODUCTION_ENTITY_COUNT: usize = 500_000; @@ -266,6 +261,6 @@ fn production_page_generation_rss() { measure( "tiered storage peak RSS probe (production staging page)", entity_count, - PRODUCTION_STAGE_PAGE, + MAX_NATIVE_GENERATION_STAGE_MUTATIONS, ); } diff --git a/crates/tracedecay-lcm/src/types.rs b/crates/tracedecay-lcm/src/types.rs index 901e81c5de..436b10c6a1 100644 --- a/crates/tracedecay-lcm/src/types.rs +++ b/crates/tracedecay-lcm/src/types.rs @@ -367,15 +367,12 @@ pub struct LcmSummaryConvergenceStatus { } /// Default fresh-tail size applied when the host omits `fresh_tail_count`. -/// Mirrors `compression.rs` `DEFAULT_FRESH_TAIL_COUNT`; keep in sync. pub const LCM_DEFAULT_FRESH_TAIL_COUNT: usize = 2; /// Default condensation fan-in applied when the host omits `summary_fan_in`. -/// Mirrors `compression.rs` `DEFAULT_SUMMARY_FAN_IN`; keep in sync. pub const LCM_DEFAULT_SUMMARY_FAN_IN: usize = 4; -/// Compression-boundary skip cooldown in seconds. Mirrors `compression.rs` -/// `COMPRESSION_BOUNDARY_COOLDOWN_SECONDS`; keep in sync. +/// Compression-boundary skip cooldown in seconds. pub const LCM_COMPRESSION_BOUNDARY_COOLDOWN_SECONDS: i64 = 60; /// Raw-store size diagnostics mirroring the hermes-lcm `lcm_status` `store` diff --git a/crates/tracedecay-mcp/src/handlers/analysis/circular.rs b/crates/tracedecay-mcp/src/handlers/analysis/circular.rs index eeedebffed..7a58686a94 100644 --- a/crates/tracedecay-mcp/src/handlers/analysis/circular.rs +++ b/crates/tracedecay-mcp/src/handlers/analysis/circular.rs @@ -178,10 +178,7 @@ fn render_circular_md( #[cfg(test)] mod circular_render_tests { use super::{CIRCULAR_DEFAULT_MEMBER_LIMIT, bound_cycles, circular_output, render_circular_md}; - - /// Mirrors [`tracedecay_mcp_catalog::MAX_RESPONSE_CHARS`], the point at which a - /// response is replaced by a preview envelope plus a retrieval handle. - const RESPONSE_BUDGET: usize = 15_000; + use crate::MAX_RESPONSE_CHARS; fn cycle(files: &[&str]) -> Vec { files.iter().map(|file| (*file).to_string()).collect() @@ -235,15 +232,15 @@ mod circular_render_tests { let payload = circular_output(&page, 1, omitted, 3, CIRCULAR_DEFAULT_MEMBER_LIMIT); let serialized = serde_json::to_string_pretty(&payload).expect("payload serializes"); assert!( - serialized.len() <= RESPONSE_BUDGET, - "bounded payload is {} chars, over the {RESPONSE_BUDGET} budget", + serialized.len() <= MAX_RESPONSE_CHARS, + "bounded payload is {} chars, over the {MAX_RESPONSE_CHARS} budget", serialized.len() ); let markdown = render_circular_md(&page, 1, omitted, 3); assert!( - markdown.len() <= RESPONSE_BUDGET, - "bounded markdown is {} chars, over the {RESPONSE_BUDGET} budget", + markdown.len() <= MAX_RESPONSE_CHARS, + "bounded markdown is {} chars, over the {MAX_RESPONSE_CHARS} budget", markdown.len() ); assert!( diff --git a/crates/tracedecay-mcp/src/server/protocol.rs b/crates/tracedecay-mcp/src/server/protocol.rs index 0844a04ebf..8e4382f555 100644 --- a/crates/tracedecay-mcp/src/server/protocol.rs +++ b/crates/tracedecay-mcp/src/server/protocol.rs @@ -87,7 +87,7 @@ pub fn resources_list_result() -> Value { { "uri": "tracedecay://schema", "name": "SQLite Schema", - "description": "Documentation for the .tracedecay/tracedecay.db schema: tables, columns, indexes, and common query recipes. Use when MCP tools don't cover your query and you need to drop down to raw SQL.", + "description": "SQLite tables, indexes, triggers, and views this binary creates, rendered from the migration inventory.", "mimeType": "text/markdown" } ] diff --git a/crates/tracedecay-runtime-core/src/db/migrations.rs b/crates/tracedecay-runtime-core/src/db/migrations.rs index 779457133c..8982a31fd2 100644 --- a/crates/tracedecay-runtime-core/src/db/migrations.rs +++ b/crates/tracedecay-runtime-core/src/db/migrations.rs @@ -15,7 +15,10 @@ use tracedecay_rusqlite_runtime::runtime_ledger; mod final_shape; mod released_shape; -pub use final_shape::{expected_final_schema_fingerprint, fingerprint_schema_objects}; +pub use final_shape::{ + expected_final_schema_fingerprint, fingerprint_schema_objects, + render_expected_final_schema_markdown, +}; const ROOT_SCHEMA: &str = "CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, diff --git a/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs b/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs index 5f28211419..989de7e9c0 100644 --- a/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs +++ b/crates/tracedecay-runtime-core/src/db/migrations/final_shape.rs @@ -1,6 +1,7 @@ //! Query-only admission for the exact relational shape created by this binary. use std::collections::BTreeMap; +use std::fmt::Write as _; use std::sync::LazyLock; use crate::db::engine::QueryExecutor; @@ -149,6 +150,66 @@ where canonical_framed_sha256(SCHEMA_SHAPE_FINGERPRINT_DOMAIN, &parts) } +/// Markdown listing of the canonical final `SQLite` shape this binary creates. +/// +/// The body is the `sqlite_master` inventory admission already builds from the +/// migration DDL. Callers that need the schema read this instead of keeping a +/// second document. +pub fn render_expected_final_schema_markdown() -> Result { + let inventory = EXPECTED_FINAL_SHAPE + .as_ref() + .map_err(|error| database_error(error.clone()))?; + Ok(render_schema_markdown(inventory)) +} + +fn render_schema_markdown(inventory: &SchemaInventory) -> String { + let mut tables = Vec::new(); + let mut indexes = Vec::new(); + let mut triggers = Vec::new(); + let mut views = Vec::new(); + for (name, object) in inventory { + match object.object_type.as_str() { + "table" => tables.push((name.as_str(), object)), + "index" => indexes.push((name.as_str(), object)), + "trigger" => triggers.push((name.as_str(), object)), + "view" => views.push((name.as_str(), object)), + _ => {} + } + } + let mut out = String::new(); + out.push_str("# tracedecay SQLite schema\n\n"); + out.push_str( + "Relational shape this binary creates and admits. Code topology lives in the verified graph generation, not in these tables.\n\n", + ); + let _ = writeln!(out, "schema_version: {}\n", super::SCHEMA_VERSION); + write_schema_section(&mut out, "Tables", &tables); + write_schema_section(&mut out, "Indexes", &indexes); + write_schema_section(&mut out, "Triggers", &triggers); + write_schema_section(&mut out, "Views", &views); + out +} + +fn write_schema_section(out: &mut String, title: &str, objects: &[(&str, &SchemaObject)]) { + let _ = writeln!(out, "## {title}\n"); + if objects.is_empty() { + out.push_str("None.\n\n"); + return; + } + for (name, object) in objects { + let _ = writeln!(out, "### {title} `{name}`\n"); + if object.object_type != "table" && !object.table.is_empty() { + let _ = writeln!(out, "table: `{}`\n", object.table); + } + let sql = object.sql.trim(); + if sql.is_empty() { + out.push_str("no stored SQL\n\n"); + continue; + } + let fence = if sql.contains("```") { "~~~~" } else { "```" }; + let _ = writeln!(out, "{fence}sql\n{sql}\n{fence}\n"); + } +} + /// The DDL this binary creates for one schema object, or `None` when the /// object is not part of the final shape. /// @@ -412,6 +473,41 @@ fn require_final_shape_inventory( Ok(shipped_trigger_found) } +#[cfg(test)] +mod render_tests { + use std::collections::BTreeSet; + + use super::{EXPECTED_FINAL_SHAPE, render_expected_final_schema_markdown}; + + #[test] + fn rendered_schema_markdown_is_the_admission_inventory() { + let inventory = EXPECTED_FINAL_SHAPE + .as_ref() + .expect("canonical schema inventory"); + let markdown = render_expected_final_schema_markdown().expect("schema markdown"); + let headings: BTreeSet<&str> = markdown + .lines() + .filter_map(|line| { + let rest = line.strip_prefix("### Tables `")?; + rest.strip_suffix('`') + }) + .collect(); + let tables: BTreeSet<&str> = inventory + .iter() + .filter(|(_, object)| object.object_type == "table") + .map(|(name, _)| name.as_str()) + .collect(); + assert_eq!( + headings, tables, + "schema markdown table headings must be exactly the admission inventory" + ); + assert!( + markdown.contains("schema_version: "), + "schema markdown must name the stamp the migrator writes" + ); + } +} + #[cfg(test)] mod fingerprint_tests { use super::fingerprint_schema_objects; diff --git a/crates/tracedecay/src/mcp/server/requests.rs b/crates/tracedecay/src/mcp/server/requests.rs index 8b3ca94d09..6df6d34333 100644 --- a/crates/tracedecay/src/mcp/server/requests.rs +++ b/crates/tracedecay/src/mcp/server/requests.rs @@ -13,6 +13,7 @@ use tracedecay_mcp::{ ToolResult, mark_semantic_tool_error, semantic_failure_reason, server::resources_list_result, tool_error_response, tool_result_has_semantic_error, }; +use tracedecay_runtime_core::db::migrations::render_expected_final_schema_markdown; use tracedecay_tool_catalog::ApplicationSurfaceOperation; /// Prefix of the out-of-band token-accounting block appended after a tool's @@ -241,106 +242,6 @@ impl McpServer { } } -/// Hand-maintained schema documentation for the `tracedecay://schema` resource. -/// Mirrors `src/db/migrations.rs::create_schema`. Update both together. -const SCHEMA_MARKDOWN: &str = r"# tracedecay SQLite schema - -The active project database lives in the user-level TraceDecay profile store -(`~/.tracedecay/projects//tracedecay.db` by default), scoped to the -current project. Linked worktrees share this durable project store. All tables -are plain SQLite; safe to query with any client. WAL mode is used, so readers -do not block writers. - -## Tables - -### `nodes` — every indexed symbol -- `id` TEXT PRIMARY KEY — content-hashed identifier (changes when symbol moves or renames) -- `kind` TEXT — e.g. `function`, `struct`, `trait`, `impl`, `method`, `module`, `file` -- `name` TEXT — local identifier -- `qualified_name` TEXT — language-style path (e.g. `crate::module::Type::method`) -- `file_path` TEXT — relative to the project root -- `start_line`, `end_line` INTEGER — 1-based inclusive line range of the symbol -- `start_column`, `end_column` INTEGER — 0-based column range -- `attrs_start_line` INTEGER — first line of leading doc-comments / attributes (or `start_line` if none) -- `signature` TEXT NULL — extracted source-level signature -- `docstring` TEXT NULL — leading doc-comment -- `visibility` TEXT — one of `public`, `pub_crate`, `pub_super`, `private` -- `is_async` INTEGER (0/1) -- `branches`, `loops`, `returns`, `max_nesting`, `unsafe_blocks`, `unchecked_calls`, `assertions` INTEGER — complexity metrics -- `updated_at` INTEGER — UNIX epoch seconds - -Indexes: `kind`, `name`, `qualified_name`, `file_path`, `(file_path,start_line)`, `lower(name)`. - -### `edges` — directed relationships between nodes -- `id` INTEGER PRIMARY KEY AUTOINCREMENT -- `source` TEXT — FK → `nodes.id` (CASCADE DELETE) -- `target` TEXT — FK → `nodes.id` (CASCADE DELETE) -- `kind` TEXT — one of `contains`, `calls`, `returns`, `type_of`, `uses`, `implements`, `extends`, `annotates`, `derives_macro`, `receives` -- `line` INTEGER NULL — source line of the relationship - -Unique constraint: `(source, target, kind, COALESCE(line, -1))`. Indexes on `source`, `target`, `kind`, `(source,kind)`, `(target,kind)`. - -### `files` — index bookkeeping -- `path` TEXT PRIMARY KEY -- `content_hash` TEXT — sha256 of file contents at index time -- `size` INTEGER — file size in bytes -- `modified_at`, `indexed_at` INTEGER — UNIX epoch seconds -- `node_count` INTEGER — number of nodes extracted from this file - -### `unresolved_refs` — references the resolver could not bind -- `from_node_id` FK → `nodes.id` -- `reference_name` TEXT -- `reference_kind` TEXT -- `line`, `col` INTEGER -- `file_path` TEXT - -### `metadata` — key/value store -Common keys: `tokens_saved`, schema-version markers. - -### `read_cache` — rendered `tracedecay_read` responses -- primary key: `(project_id, session_id, file_path, mode, args_hash)` -- stores `mtime_ns`, `digest`, rendered `body` BLOB, token count, and `created_at` - -## Recipes - -### Find every impl block of a trait -```sql -SELECT n.id, n.qualified_name, n.file_path, n.start_line -FROM nodes n -JOIN edges e ON e.source = n.id -WHERE e.kind = 'implements' - AND e.target IN (SELECT id FROM nodes WHERE qualified_name = ?1); -``` - -### Top callers of a node -```sql -SELECT n.qualified_name, COUNT(*) AS call_count -FROM edges e -JOIN nodes n ON n.id = e.source -WHERE e.target = ?1 AND e.kind = 'calls' -GROUP BY n.qualified_name -ORDER BY call_count DESC -LIMIT 20; -``` - -### Files modified since last index -Compare `files.modified_at` against the live filesystem mtime — `tracedecay_affected` does this with extra git plumbing. - -### Largest functions by line span -```sql -SELECT qualified_name, file_path, end_line - start_line + 1 AS lines -FROM nodes -WHERE kind IN ('function', 'method') -ORDER BY lines DESC -LIMIT 20; -``` - -## Gotchas -- `nodes.id` is a content hash, so it changes when the symbol moves. For cross-run lookups use `qualified_name` (or `tracedecay_by_qualified_name`). -- `edges.kind = 'calls'` may reference a *trait method* node rather than the resolved concrete impl — trait dispatch is not currently rewritten. -- `derives_macro` edges record `#[derive(...)]` usage but generated impls are not in the graph. -"; - impl McpServer { pub(crate) fn cancel_application_surface_request( &self, @@ -824,10 +725,14 @@ impl McpServer { ) } - /// Returns the `SQLite` schema documentation as a markdown resource. - /// Sourced from `src/db/migrations.rs::create_schema` — keep in sync. + /// Returns the `SQLite` shape this binary creates, rendered from the migration inventory. pub(crate) fn read_resource_schema(id: Value) -> JsonRpcResponse { - Self::resource_contents(id, "tracedecay://schema", "text/markdown", SCHEMA_MARKDOWN) + match render_expected_final_schema_markdown() { + Ok(markdown) => { + Self::resource_contents(id, "tracedecay://schema", "text/markdown", &markdown) + } + Err(error) => JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()), + } } /// Returns typed file-inventory availability for the active project. diff --git a/crates/tracedecay/src/mcp/server/rmcp.rs b/crates/tracedecay/src/mcp/server/rmcp.rs index 475dd7e002..bc75bc6b0d 100644 --- a/crates/tracedecay/src/mcp/server/rmcp.rs +++ b/crates/tracedecay/src/mcp/server/rmcp.rs @@ -496,7 +496,8 @@ mod tests { large_envelope["original_chars"] .as_u64() .unwrap_or_default() - >= 15_000, + >= u64::try_from(tracedecay_mcp::MAX_RESPONSE_CHARS) + .expect("response budget fits u64"), "large response must cross the production response budget", ); assert!( @@ -581,6 +582,17 @@ mod tests { ) && schema.ends_with(r#","uri":"tracedecay://schema"}]}}"#), "legacy resources/read frame shape changed: {schema}", ); + let parsed: Value = serde_json::from_str(&schema).expect("schema frame"); + let text = parsed["result"]["contents"][0]["text"] + .as_str() + .expect("schema text"); + let authority = + tracedecay_runtime_core::db::migrations::render_expected_final_schema_markdown() + .expect("canonical schema"); + assert_eq!( + text, authority, + "schema resource must be the migration inventory, not a second document" + ); // Notifications stay responseless on the legacy transport. for notification in [ diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs index d4012e71ca..46fb58895a 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/bounded_analysis_test.rs @@ -14,10 +14,7 @@ use crate::support::*; use serde_json::{Value, json}; use std::fmt::Write as _; use std::fs; - -/// Mirrors `crate::mcp::tools::MAX_RESPONSE_CHARS`, the point at which a tool -/// response is replaced by a preview envelope plus a retrieval handle. -const MAX_RESPONSE_CHARS: usize = 15_000; +use tracedecay_mcp::MAX_RESPONSE_CHARS; fn assert_not_truncated(text: &str, tool: &str) { let payload: Value = serde_json::from_str(text) diff --git a/crates/tracedecay/tests/mcp_suite/support.rs b/crates/tracedecay/tests/mcp_suite/support.rs index 6d3e43dfa4..b5a9382279 100644 --- a/crates/tracedecay/tests/mcp_suite/support.rs +++ b/crates/tracedecay/tests/mcp_suite/support.rs @@ -68,7 +68,7 @@ use tracedecay_temporal_query::ports::ExecutionControl; pub(crate) static GLOBAL_DB_ENV_LOCK: Mutex<()> = Mutex::const_new(()); #[cfg(feature = "test-transport")] -pub(crate) const MCP_TEST_RESPONSE_CHAR_LIMIT: usize = 15_000; +pub(crate) const MCP_TEST_RESPONSE_CHAR_LIMIT: usize = tracedecay_mcp::MAX_RESPONSE_CHARS; #[cfg(feature = "test-transport")] static NEXT_SOURCE_EDIT_TEST_KEY: AtomicU64 = AtomicU64::new(1);