From 1e2177d78beb08f0d9d02d246705f14a70c8b53d Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 07:35:47 -0400 Subject: [PATCH 01/10] make an excluded source file visible to packet sufficiency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A packet could rest a proof-bearing claim on a file the index had deliberately refused and still report `Sufficient`. Nothing under `agent/` referenced a coverage reason or an exclusion, so the fact never reached the verdict. The route that was actually open is not the one #1802 named. An explicitly probed excluded path already caps: `exact_path_probe_citation` mints it `eligible_for_sufficiency: Some(false)`, so it lands in `missing_exact_path_claims` and the packet is already `Partial`. The uncapped route is a *required* file-scoped citation — `orchestrator.rs` mints those `Some(!explicit)` — read straight off disk and never probed. So this is driven by cited paths, not probe paths; a probe-side fix leaves the hole untouched. Shaped after EV-78, including its two-enum split: a producer cause for what the lookup did, a consumer cause for what a reader may conclude, joined by one total mapping in contracts so packet and readiness cannot drift. With one deliberate inversion, which is the whole asymmetry. Freshness treats a *missing* observation as unknown-and-capping, because a check that did not run proves nothing. Coverage must not: an empty observation list means no path was checked, which is legitimate and caps nothing. A failed lookup arrives as a per-path `NotEstablished` instead of as an absence. Copying freshness wholesale would make every packet that cites nothing `Partial`; a test pins it, because that simplification looks right. Two things that would have made this silently do nothing: * **Path matching.** Exclusion rows store workspace-relative paths, citations carry absolute ones. A string compare, or `normalize_path_key`, matches on some platforms and never on others — plumbing complete, tests green, nothing ever capping. It goes through `same_workspace_path`. * **Filtering instead of mapping.** The observer returns exactly one observation per requested path, including on lookup failure. A producer that dropped unknown paths would be indistinguishable from one reporting them covered — the defect moved one layer up. The follow-up filter is not optional. Capping flips `terminally_sufficient` false, which is exactly what opens follow-up generation, so the cap alone turns a packet that answered and stopped into one that re-probes a permanently unindexable file every round. Leads arrive from three sources, so they are filtered once after all three have contributed. Mutation-checked: removing the cap clause makes the packet report `Sufficient` over the excluded file while its own gap text says the index never read it. Refs #1802 --- .../src/app/tests/test_support.rs | 2 + crates/codestory-cli/src/output.rs | 3 + crates/codestory-contracts/src/api.rs | 14 +- crates/codestory-contracts/src/api/dto.rs | 127 +++++++++++ crates/codestory-runtime/src/agent/mod.rs | 1 + .../src/agent/orchestrator.rs | 29 +++ .../src/agent/packet_batch.rs | 1 + .../src/agent/packet_budget.rs | 1 + .../src/agent/packet_capping.rs | 1 + .../src/agent/packet_claims.rs | 1 + .../src/agent/packet_coverage.rs | 202 ++++++++++++++++++ .../src/agent/packet_degradation.rs | 1 + .../src/agent/packet_obligations.rs | 1 + .../src/agent/packet_sufficiency.rs | 159 +++++++++++++- .../src/agent/packet_trace.rs | 1 + .../src/agent/trace_export.rs | 1 + crates/codestory-runtime/src/lib.rs | 1 + .../codestory-runtime/src/source_coverage.rs | 126 +++++++++++ 18 files changed, 665 insertions(+), 7 deletions(-) create mode 100644 crates/codestory-runtime/src/agent/packet_coverage.rs create mode 100644 crates/codestory-runtime/src/source_coverage.rs diff --git a/crates/codestory-cli/src/app/tests/test_support.rs b/crates/codestory-cli/src/app/tests/test_support.rs index e8ef3493..7c7af96f 100644 --- a/crates/codestory-cli/src/app/tests/test_support.rs +++ b/crates/codestory-cli/src/app/tests/test_support.rs @@ -28,6 +28,7 @@ pub(super) fn sample_retrieval() -> RetrievalStateDto { pub(super) fn sample_agent_answer_with_graph(graph: GraphArtifactDto) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "context-test".to_string(), prompt: "capped_bundle".to_string(), summary: "Bundle summary".to_string(), @@ -88,6 +89,7 @@ pub(super) fn sample_task_brief_packet() -> AgentPacketDto { trace: Vec::new(), }, answer: AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-task-brief".to_string(), prompt: "Add `$env:SECRET $(Get-ChildItem) 'literal' task brief".to_string(), summary: "Use the packet command path.".to_string(), diff --git a/crates/codestory-cli/src/output.rs b/crates/codestory-cli/src/output.rs index 4bf8073b..2bf0183c 100644 --- a/crates/codestory-cli/src/output.rs +++ b/crates/codestory-cli/src/output.rs @@ -4981,6 +4981,7 @@ mod tests { #[test] fn context_markdown_contract_includes_evidence_packet_shape() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-1".to_string(), prompt: "build_packet".to_string(), summary: "Packet output is assembled from retrieved CLI evidence.".to_string(), @@ -5126,6 +5127,7 @@ mod tests { fn well_grounded_packet_answer() -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-telemetry".to_string(), prompt: "Explain how the installer dispatches commands.".to_string(), summary: "The installer dispatches install, download, and use commands.".to_string(), @@ -5913,6 +5915,7 @@ mod tests { #[test] fn context_markdown_surfaces_low_confidence_trace_gaps() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-1".to_string(), prompt: "weak_hit".to_string(), summary: "Retrieval was incomplete.".to_string(), diff --git a/crates/codestory-contracts/src/api.rs b/crates/codestory-contracts/src/api.rs index 736b77d3..bc350b07 100644 --- a/crates/codestory-contracts/src/api.rs +++ b/crates/codestory-contracts/src/api.rs @@ -69,12 +69,14 @@ pub use dto::{ SearchPlanTermsDto, SearchQueryAssessmentDto, SearchRepoTextMode, SearchRequest, SearchResultsDto, SearchTargetDto, SearchVerificationTargetDto, SemanticFallbackRecordDto, SemanticModeDto, SetUiLayoutRequest, SnippetContextDto, SnippetScopeDto, - SourceFreshnessTelemetryDto, SourceOccurrenceDto, SourcePolicyExclusionDto, - StartIndexingRequest, StorageStatsDto, StoredSemanticDocsContractDto, SummaryGenerationDto, - SymbolContextDto, SymbolSummaryDto, SystemActionResponse, TrailConfigDto, TrailContextDto, - TrailFilterOptionsDto, TrailStoryDto, TrailStoryStepDto, UpdateBookmarkCategoryRequest, - UpdateBookmarkRequest, WorkspaceMemberIndexDto, WriteFileDataUrlRequest, WriteFileResponse, - WriteFileTextRequest, validate_packet_probe, validate_packet_probe_request, + SourceCoverageNotEstablishedCauseDto, SourceCoverageObservationDto, SourceCoverageStatusDto, + SourceCoverageUnprovableCauseDto, SourceFreshnessTelemetryDto, SourceOccurrenceDto, + SourcePolicyExclusionDto, StartIndexingRequest, StorageStatsDto, StoredSemanticDocsContractDto, + SummaryGenerationDto, SymbolContextDto, SymbolSummaryDto, SystemActionResponse, TrailConfigDto, + TrailContextDto, TrailFilterOptionsDto, TrailStoryDto, TrailStoryStepDto, + UpdateBookmarkCategoryRequest, UpdateBookmarkRequest, WorkspaceMemberIndexDto, + WriteFileDataUrlRequest, WriteFileResponse, WriteFileTextRequest, validate_packet_probe, + validate_packet_probe_request, }; pub use errors::{ ApiError, ApiErrorDetails, COMMAND_FAILURE_SCHEMA_VERSION, CommandFailureEnvelope, diff --git a/crates/codestory-contracts/src/api/dto.rs b/crates/codestory-contracts/src/api/dto.rs index 1683bc7f..c5a6f444 100644 --- a/crates/codestory-contracts/src/api/dto.rs +++ b/crates/codestory-contracts/src/api/dto.rs @@ -673,6 +673,126 @@ pub struct IndexFreshnessDto { pub samples: Vec, } +/// What the index knows about one source file it was asked about. +/// +/// Deliberately inert, like [`IndexFreshnessDto`]: it carries no verdict about whether the file is +/// usable and none about whether evidence over it is provable. Both are computed downstream by +/// consumers reading this same observation. +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct SourceCoverageObservationDto { + /// The path as the consumer asked about it, so a caller can match its own request. + pub path: String, + pub status: SourceCoverageStatusDto, + /// Set only when `status` is `Incomplete`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Set only when `status` is `NotEstablished`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub not_established_cause: Option, + /// Observed size and the cap that refused it, when the file was policy-excluded. + /// + /// Reported so a gap sentence can name numbers instead of a word. No verdict reads these. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub byte_cap: Option, +} + +/// What the index established about one file. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceCoverageStatusDto { + /// The file is in the published core with no recorded coverage defect. + Indexed, + /// A published policy exclusion refused the file before scheduling. + PolicyExcluded, + /// The file was indexed but a coverage reason was recorded against it. + Incomplete, + /// Coverage could not be determined; see `not_established_cause`. + NotEstablished, +} + +/// Why a coverage lookup could not reach a verdict. +/// +/// The same split [`IndexFreshnessNotCheckedCauseDto`] makes: `PublicationIncomplete` means there +/// is no complete core to ask, which is a deliberate and recoverable state; `LookupUnavailable` +/// means the query itself failed and proves nothing. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceCoverageNotEstablishedCauseDto { + PublicationIncomplete, + LookupUnavailable, +} + +/// Why a consumer must treat evidence over one file as unprovable. +/// +/// [`SourceCoverageStatusDto`] describes what the *index* did. This describes what a consumer may +/// conclude, and as with freshness those are different judgements — so this carries variants the +/// producer cannot emit. +/// +/// One deliberate divergence from [`FreshnessUnknownCauseDto`], and it is the whole asymmetry: +/// that mapping takes an `Option` because *no freshness observation at all* is itself unknown and +/// must cap. Coverage's takes a value, because an empty observation list means no path was +/// checked, which is legitimate and must cap nothing. A failed lookup is carried per path as +/// `NotEstablished` rather than by absence. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceCoverageUnprovableCauseDto { + /// A published policy exclusion refused the file, so the index never read it. + PolicyExcluded, + /// The file carries a recorded coverage defect. + IncompleteIndex, + /// There is no complete publication to ask about this file. + PublicationIncomplete, + /// The coverage lookup failed. + LookupUnavailable, + /// The observation reported `Incomplete` without naming a reason. + ReasonUnreported, + /// The observation reported `NotEstablished` without naming a cause. + CauseUnreported, +} + +impl SourceCoverageUnprovableCauseDto { + /// Stable machine-readable identity for gap text and typed output fields. + pub fn id(self) -> &'static str { + match self { + Self::PolicyExcluded => "policy_excluded", + Self::IncompleteIndex => "incomplete_index", + Self::PublicationIncomplete => "publication_incomplete", + Self::LookupUnavailable => "lookup_unavailable", + Self::ReasonUnreported => "reason_unreported", + Self::CauseUnreported => "cause_unreported", + } + } + + /// The unprovable cause an observation carries, or `None` when the file is covered. + /// + /// `Indexed` is the only verdict that establishes coverage. Everything else — including an + /// `Incomplete` that names no reason — is unprovable, because defaulting an unnamed defect to + /// covered is the exposure this type exists to close. + pub fn for_observation(observation: &SourceCoverageObservationDto) -> Option { + match observation.status { + SourceCoverageStatusDto::Indexed => None, + SourceCoverageStatusDto::PolicyExcluded => Some(Self::PolicyExcluded), + SourceCoverageStatusDto::Incomplete => Some(match observation.reason { + Some(_) => Self::IncompleteIndex, + None => Self::ReasonUnreported, + }), + SourceCoverageStatusDto::NotEstablished => { + Some(match observation.not_established_cause { + Some(SourceCoverageNotEstablishedCauseDto::PublicationIncomplete) => { + Self::PublicationIncomplete + } + Some(SourceCoverageNotEstablishedCauseDto::LookupUnavailable) => { + Self::LookupUnavailable + } + None => Self::CauseUnreported, + }) + } + } + } +} + /// Readiness goal being evaluated. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -2649,6 +2769,13 @@ pub struct AgentAnswerDto { pub summary: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub freshness: Option, + /// Coverage for the files this answer actually touched. + /// + /// Empty means no path was checked, which is legitimate and caps nothing — the opposite of + /// `freshness`, where absence is itself an unknown. A failed lookup arrives here as a + /// `NotEstablished` observation rather than as an absent list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_coverage: Vec, pub sections: Vec, pub citations: Vec, pub subgraph_ids: Vec, diff --git a/crates/codestory-runtime/src/agent/mod.rs b/crates/codestory-runtime/src/agent/mod.rs index ee5bb300..8468f16b 100644 --- a/crates/codestory-runtime/src/agent/mod.rs +++ b/crates/codestory-runtime/src/agent/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod packet_claim_profile_registry; pub(crate) mod packet_claim_profiles; pub(crate) mod packet_claims; pub(crate) mod packet_command_profiles; +pub(crate) mod packet_coverage; pub(crate) mod packet_degradation; pub(crate) mod packet_evidence; pub(crate) mod packet_freshness; diff --git a/crates/codestory-runtime/src/agent/orchestrator.rs b/crates/codestory-runtime/src/agent/orchestrator.rs index b522ec35..907a82a7 100644 --- a/crates/codestory-runtime/src/agent/orchestrator.rs +++ b/crates/codestory-runtime/src/agent/orchestrator.rs @@ -361,6 +361,7 @@ pub(crate) fn agent_ask( let summary = summarize_response(&resolved_profile, &bundle); Ok(AgentAnswerDto { + source_coverage: Vec::new(), answer_id: request_id, prompt, summary, @@ -513,6 +514,21 @@ pub(crate) fn agent_packet( let sufficiency_extra_probes = packet_plan_sufficiency_extra_probes(&plan, &extra_probes); let exact_probe_paths = exact_packet_probe_paths(&plan.probe_resolutions); + + // Observed here, after every filesystem citation appender has run, because + // the uncapped route is a *cited* file rather than a probed one: a required + // file-scoped citation is minted `eligible_for_sufficiency`, so a packet + // could rest a proof-bearing claim on a file the index refused. Driving + // this off probe paths alone would leave exactly that route uncovered. + let mut covered_paths = exact_probe_paths.clone(); + covered_paths.extend( + answer + .citations + .iter() + .filter_map(|citation| citation.file_path.clone()), + ); + answer.source_coverage = + crate::source_coverage::observe_source_coverage(controller, &covered_paths); let phase_started = Instant::now(); let budget = apply_packet_budget_with_extra( &project_root, @@ -5173,6 +5189,7 @@ mod tests { fn packet_answer_fixture(question: &str, citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-fixture".to_string(), prompt: question.to_string(), summary: "Fixture packet is covered by cited anchors.".to_string(), @@ -8472,6 +8489,7 @@ mod tests { fn packet_supported_claims_use_generic_evidence_roles() { let limits = packet_budget_limits(PacketBudgetModeDto::Compact); let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "generic-fixture".to_string(), prompt: "Explain the packet evidence roles.".to_string(), summary: "Generic evidence roles are covered.".to_string(), @@ -8887,6 +8905,7 @@ mod tests { let exec_lib_path = exec_lib.to_string_lossy().to_string(); let event_jsonl_path = event_jsonl.to_string_lossy().to_string(); let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "exec-fixture".to_string(), prompt: "Explain how `codex exec --json` flows from the top-level CLI into the exec runtime, app-server thread and turn start requests, and JSONL event output.".to_string(), summary: "Exec flow evidence is covered.".to_string(), @@ -9001,6 +9020,7 @@ mod tests { .expect("write temp exec lib"); let exec_lib_path = exec_lib.to_string_lossy().to_string(); let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "source-definition-fixture".to_string(), prompt: "Explain how `codex exec --json` flows from the exec runtime into app-server thread start requests and JSONL event output.".to_string(), summary: "Exec flow evidence is covered.".to_string(), @@ -9047,6 +9067,7 @@ mod tests { fn packet_supported_claims_include_indexing_storage_flow_specific_claims() { let _eval_probes = EvalProbesGuard::enabled(); let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "indexing-storage-fixture".to_string(), prompt: "Explain project source-group indexing into storage.".to_string(), summary: "Indexing and storage evidence is covered.".to_string(), @@ -9286,6 +9307,7 @@ mod tests { #[test] fn production_packet_claims_do_not_synthesize_local_real_template_claims() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "indexing-storage-production-fixture".to_string(), prompt: "Explain project source-group indexing into storage.".to_string(), summary: "Indexing and storage evidence is covered.".to_string(), @@ -9356,6 +9378,7 @@ mod tests { #[test] fn packet_supported_claims_include_vscode_workbench_extension_host_claims() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "vscode-fixture".to_string(), prompt: "Explain VS Code workbench extension-host command execution.".to_string(), summary: "VS Code workbench flow evidence is covered.".to_string(), @@ -9432,6 +9455,7 @@ mod tests { #[test] fn packet_supported_claims_include_payload_public_content_flow_claims() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "payload-fixture".to_string(), prompt: "Explain Payload posts comments RSS and Elsewhere feed.".to_string(), summary: "Payload public content flow evidence is covered.".to_string(), @@ -9504,6 +9528,7 @@ mod tests { fn packet_ranking_prefers_payload_collections_over_component_and_preview_fillers() { let question = "Explain how Payload collections, post rendering, comment submission, RSS, and the Elsewhere feed connect."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "payload-rank-fixture".to_string(), prompt: question.to_string(), summary: "Payload public content flow evidence is covered.".to_string(), @@ -9558,6 +9583,7 @@ mod tests { fn packet_ranking_demotes_test_paths_without_fixture_specific_boosts() { let question = "Trace route dispatch through a handler."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-fixture".to_string(), prompt: question.to_string(), summary: "Route evidence is covered by cited anchors.".to_string(), @@ -9599,6 +9625,7 @@ mod tests { fn packet_ranking_demotes_test_named_source_helpers_for_production_prompts() { let question = "Explain runtime orchestration and search projection in the indexing flow."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-test-symbols".to_string(), prompt: question.to_string(), summary: "Runtime evidence is covered by cited anchors.".to_string(), @@ -9665,6 +9692,7 @@ mod tests { fn packet_ranking_demotes_non_primary_roles_for_production_prompts() { let question = "Trace production route dispatch through the handler."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-roles".to_string(), prompt: question.to_string(), summary: "Route evidence is covered by cited anchors.".to_string(), @@ -9709,6 +9737,7 @@ mod tests { fn packet_ranking_keeps_requested_docs_role_eligible() { let question = "Trace the docs route dispatch example."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-docs".to_string(), prompt: question.to_string(), summary: "Route evidence is covered by cited anchors.".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_batch.rs b/crates/codestory-runtime/src/agent/packet_batch.rs index f939616b..e67045f0 100644 --- a/crates/codestory-runtime/src/agent/packet_batch.rs +++ b/crates/codestory-runtime/src/agent/packet_batch.rs @@ -757,6 +757,7 @@ mod tests { /// producer stamped. Nothing here hand-builds an annotation. fn empty_answer() -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "ev6c".to_string(), prompt: "ev6c packet".to_string(), summary: String::new(), diff --git a/crates/codestory-runtime/src/agent/packet_budget.rs b/crates/codestory-runtime/src/agent/packet_budget.rs index 5d1e815d..ebc513ba 100644 --- a/crates/codestory-runtime/src/agent/packet_budget.rs +++ b/crates/codestory-runtime/src/agent/packet_budget.rs @@ -1073,6 +1073,7 @@ mod tests { fn test_packet(question: &str, max_output_bytes: u32) -> AgentPacketDto { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-budget-test".to_string(), prompt: question.to_string(), summary: "Packet budget test answer.".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_capping.rs b/crates/codestory-runtime/src/agent/packet_capping.rs index 8ecabe08..2e593874 100644 --- a/crates/codestory-runtime/src/agent/packet_capping.rs +++ b/crates/codestory-runtime/src/agent/packet_capping.rs @@ -1304,6 +1304,7 @@ mod tests { fn answer_fixture(citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-capping-test".to_string(), prompt: "Trace the generic flow.".to_string(), summary: "Covered by cited anchors.".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_claims.rs b/crates/codestory-runtime/src/agent/packet_claims.rs index d14374bf..c129e7c8 100644 --- a/crates/codestory-runtime/src/agent/packet_claims.rs +++ b/crates/codestory-runtime/src/agent/packet_claims.rs @@ -1159,6 +1159,7 @@ mod tests { fn test_answer(prompt: &str, citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-claims-test".to_string(), prompt: prompt.to_string(), summary: "test answer".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_coverage.rs b/crates/codestory-runtime/src/agent/packet_coverage.rs new file mode 100644 index 00000000..f7d3c457 --- /dev/null +++ b/crates/codestory-runtime/src/agent/packet_coverage.rs @@ -0,0 +1,202 @@ +//! Whether the files a packet rested on were actually covered by the index. +//! +//! The companion to [`super::packet_freshness`], with one deliberate +//! difference. Freshness treats a *missing* observation as unknown, because a +//! freshness check that did not run proves nothing. Coverage must not: an empty +//! observation list means no path was checked, which is legitimate and caps +//! nothing. A lookup that failed arrives here as a per-path `NotEstablished` +//! observation instead of as an absence. +//! +//! Getting that backwards in either direction is the whole risk. Cap on absence +//! and every packet that cites nothing becomes `Partial`; fail to cap on a +//! failed lookup and the gap this exists to close reopens. + +use codestory_contracts::api::{ + SourceCoverageObservationDto, SourceCoverageStatusDto, SourceCoverageUnprovableCauseDto, +}; + +/// Prefix every coverage gap sentence shares, so callers can partition them. +pub(crate) const PACKET_COVERAGE_GAP_PREFIX: &str = "source coverage"; + +/// One file this packet rested on that the index could not prove it covered. +#[derive(Debug, Clone)] +struct UnprovableFile { + path: String, + cause: SourceCoverageUnprovableCauseDto, + /// Observed size and the cap that refused it, when the index recorded them. + sizes: Option<(u64, u64)>, +} + +/// The coverage facts for the files one packet touched. +#[derive(Debug, Clone, Default)] +pub(crate) struct PacketCoverageInput { + unprovable: Vec, +} + +impl PacketCoverageInput { + pub(crate) fn from_observations(observations: &[SourceCoverageObservationDto]) -> Self { + let unprovable = observations + .iter() + .filter_map(|observation| { + // Matched exhaustively on purpose. `packet_freshness` can afford + // a `_` arm because its mapping is total over `Option` and runs + // first; here a wildcard would silently default a newly added + // status to "proven", which is the failure this type prevents. + match observation.status { + SourceCoverageStatusDto::Indexed => None, + SourceCoverageStatusDto::PolicyExcluded + | SourceCoverageStatusDto::Incomplete + | SourceCoverageStatusDto::NotEstablished => { + SourceCoverageUnprovableCauseDto::for_observation(observation).map( + |cause| UnprovableFile { + path: observation.path.clone(), + cause, + sizes: observation.observed_size.zip(observation.byte_cap), + }, + ) + } + } + }) + .collect(); + Self { unprovable } + } + + /// Whether any file this packet rested on could not be proven covered. + pub(crate) fn caps_sufficiency(&self) -> bool { + !self.unprovable.is_empty() + } + + /// One sentence per unprovable file, naming the cause and, where the index + /// recorded them, the numbers that produced it. + pub(crate) fn gaps(&self) -> Vec { + self.unprovable + .iter() + .map(|file| match file.sizes { + Some((observed_size, byte_cap)) => format!( + "{PACKET_COVERAGE_GAP_PREFIX} ({}) is unproven for {path}: \ + {observed_size} bytes exceeds the {byte_cap} byte cap, so the index \ + never read it.", + file.cause.id(), + path = file.path + ), + None => format!( + "{PACKET_COVERAGE_GAP_PREFIX} ({}) is unproven for {path}.", + file.cause.id(), + path = file.path + ), + }) + .collect() + } + + /// The paths that could not be proven covered. + pub(crate) fn unprovable_paths(&self) -> Vec<&str> { + self.unprovable + .iter() + .map(|file| file.path.as_str()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codestory_contracts::api::SourceCoverageNotEstablishedCauseDto; + + fn observation(path: &str, status: SourceCoverageStatusDto) -> SourceCoverageObservationDto { + SourceCoverageObservationDto { + path: path.to_string(), + status, + reason: None, + not_established_cause: None, + observed_size: None, + byte_cap: None, + } + } + + #[test] + fn an_excluded_observation_caps_sufficiency() { + let input = PacketCoverageInput::from_observations(&[observation( + "data/big.json", + SourceCoverageStatusDto::PolicyExcluded, + )]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].starts_with(PACKET_COVERAGE_GAP_PREFIX)); + assert_eq!(input.unprovable_paths(), vec!["data/big.json"]); + } + + /// The EV-78 asymmetry, and the reason coverage cannot copy freshness + /// wholesale: no path checked must cap nothing. This fails the moment + /// someone swaps the per-path producer for a repository-wide exclusion + /// list, which is the natural-looking simplification. + #[test] + fn no_observations_cap_nothing() { + let input = PacketCoverageInput::from_observations(&[]); + assert!(!input.caps_sufficiency()); + assert!(input.gaps().is_empty()); + } + + #[test] + fn an_indexed_observation_caps_nothing() { + let input = PacketCoverageInput::from_observations(&[observation( + "src/main.rs", + SourceCoverageStatusDto::Indexed, + )]); + assert!(!input.caps_sufficiency()); + } + + /// Every status must map to a definite answer. Fails if a variant is added + /// and absorbed into a permissive arm. + #[test] + fn every_status_reaches_a_definite_verdict() { + for status in [ + SourceCoverageStatusDto::Indexed, + SourceCoverageStatusDto::PolicyExcluded, + SourceCoverageStatusDto::Incomplete, + SourceCoverageStatusDto::NotEstablished, + ] { + let caps = PacketCoverageInput::from_observations(&[observation("f.rs", status)]) + .caps_sufficiency(); + assert_eq!( + caps, + status != SourceCoverageStatusDto::Indexed, + "{status:?} must cap unless it is Indexed" + ); + } + } + + /// An unnamed defect must stay typed rather than defaulting to covered — + /// the direct analog of freshness's unlabelled `NotChecked`. + #[test] + fn an_unnamed_defect_is_still_unprovable() { + let incomplete = observation("src/odd.rs", SourceCoverageStatusDto::Incomplete); + let input = PacketCoverageInput::from_observations(&[incomplete]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].contains("reason_unreported")); + + let unestablished = observation("src/odd.rs", SourceCoverageStatusDto::NotEstablished); + let input = PacketCoverageInput::from_observations(&[unestablished]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].contains("cause_unreported")); + } + + #[test] + fn a_failed_lookup_caps_and_names_itself() { + let mut observation = observation("src/main.rs", SourceCoverageStatusDto::NotEstablished); + observation.not_established_cause = + Some(SourceCoverageNotEstablishedCauseDto::LookupUnavailable); + let input = PacketCoverageInput::from_observations(&[observation]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].contains("lookup_unavailable")); + } + + #[test] + fn an_exclusion_gap_names_the_size_and_the_cap() { + let mut observation = observation("data/big.json", SourceCoverageStatusDto::PolicyExcluded); + observation.observed_size = Some(1_500_000); + observation.byte_cap = Some(1_048_576); + let input = PacketCoverageInput::from_observations(&[observation]); + let gap = &input.gaps()[0]; + assert!(gap.contains("1500000"), "{gap}"); + assert!(gap.contains("1048576"), "{gap}"); + } +} diff --git a/crates/codestory-runtime/src/agent/packet_degradation.rs b/crates/codestory-runtime/src/agent/packet_degradation.rs index a3b9f79d..f85f16ce 100644 --- a/crates/codestory-runtime/src/agent/packet_degradation.rs +++ b/crates/codestory-runtime/src/agent/packet_degradation.rs @@ -355,6 +355,7 @@ mod tests { fn counter_answer() -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-degradation-test".to_string(), prompt: "how does activation admit a lease".to_string(), summary: String::new(), diff --git a/crates/codestory-runtime/src/agent/packet_obligations.rs b/crates/codestory-runtime/src/agent/packet_obligations.rs index ff695c2b..0e1451f2 100644 --- a/crates/codestory-runtime/src/agent/packet_obligations.rs +++ b/crates/codestory-runtime/src/agent/packet_obligations.rs @@ -1587,6 +1587,7 @@ mod tests { fn answer(citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "obligation-test".to_string(), prompt: INDEXING_QUESTION.to_string(), summary: "test".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_sufficiency.rs b/crates/codestory-runtime/src/agent/packet_sufficiency.rs index 30731594..f234eda0 100644 --- a/crates/codestory-runtime/src/agent/packet_sufficiency.rs +++ b/crates/codestory-runtime/src/agent/packet_sufficiency.rs @@ -1,5 +1,6 @@ use crate::agent::packet_budget::next_deeper_packet_argv; use crate::agent::packet_claims::{decorate_packet_claims_proof_metadata, packet_supported_claims}; +use crate::agent::packet_coverage::PacketCoverageInput; use crate::agent::packet_degradation::packet_primary_retrieval_truncated; use crate::agent::packet_evidence::citation_sufficiency_eligible; use crate::agent::packet_evidence_roles::packet_evidence_role; @@ -309,6 +310,7 @@ fn assemble_packet_sufficiency_with_probe_context( // EV-7/EV-8: two facts about how this packet was collected, both of which bound what its // evidence can be reported as regardless of how well the claims themselves scored. let freshness = PacketFreshnessInput::from_observation(answer.freshness.as_ref()); + let coverage = PacketCoverageInput::from_observations(&answer.source_coverage); let primary_retrieval_truncated = packet_primary_retrieval_truncated(answer); let status = packet_sufficiency_status(PacketSufficiencyStatusInput { budget, @@ -324,6 +326,7 @@ fn assemble_packet_sufficiency_with_probe_context( missing_required_probe_queries: &blocking_missing_probe_queries, unresolved_sidecar_queries: &blocking_unresolved_sidecar_queries, freshness, + coverage: &coverage, primary_retrieval_truncated, }); @@ -350,6 +353,7 @@ fn assemble_packet_sufficiency_with_probe_context( &missing_required_flow_requirements, &blocking_unresolved_sidecar_queries, freshness, + &coverage, primary_retrieval_truncated, ); if let Some(obligations) = obligations { @@ -439,6 +443,20 @@ fn assemble_packet_sufficiency_with_probe_context( for path in &reported_claim_open_next_paths { push_unique_term(&mut open_next_paths, path); } + // Filtered once, after every source has contributed, because leads arrive + // from three of them. Capping on coverage flips `terminally_sufficient` + // false, which is exactly what opens follow-up generation — so without + // this the cap turns a packet that answered and stopped into one that + // re-probes a permanently unindexable file every round. A path the index + // can never cover is not a lead. + let unprovable_paths = coverage.unprovable_paths(); + if !unprovable_paths.is_empty() { + open_next_paths.retain(|path| { + !unprovable_paths + .iter() + .any(|unprovable| packet_paths_match_exact_probe(project_root, unprovable, path)) + }); + } let blocking_follow_up_probe_queries = packet_interleave_follow_up_queries( &open_next_paths, &blocking_follow_up_probe_query_seeds, @@ -606,6 +624,11 @@ struct PacketSufficiencyStatusInput<'a> { unresolved_sidecar_queries: &'a [String], /// EV-7: how well this packet's publication was known to match the working tree. freshness: PacketFreshnessInput, + /// CAP-1: whether the index actually covered the files this packet rested on. + /// + /// Distinct from `has_minimum_coverage`, which is about how many *claims* carry evidence. + /// This is about whether the underlying files were indexed at all. + coverage: &'a PacketCoverageInput, /// EV-8: whether the primary retrieval run lost evidence it planned to collect. primary_retrieval_truncated: bool, } @@ -625,10 +648,11 @@ fn packet_sufficiency_status( || !input.missing_required_probe_queries.is_empty() || !input.unresolved_sidecar_queries.is_empty() || input.has_sufficiency_blocking_budget_omission - // Both of these are caps, not floors: they can only stop a packet that would otherwise be + // These three are caps, not floors: they can only stop a packet that would otherwise be // Sufficient from claiming it. A packet with no eligible citation stays Insufficient // above, and everything that was already Partial stays Partial. || input.freshness.caps_sufficiency() + || input.coverage.caps_sufficiency() || input.primary_retrieval_truncated || packet_budget_exceeded_hard_output_cap(input.budget) { @@ -1290,6 +1314,7 @@ fn packet_sufficiency_gaps( missing_required_flow_requirements: &[FlowRequirement], unresolved_sidecar_queries: &[String], freshness: PacketFreshnessInput, + coverage: &PacketCoverageInput, primary_retrieval_truncated: bool, ) -> Vec { let mut gaps = Vec::new(); @@ -1298,6 +1323,7 @@ fn packet_sufficiency_gaps( if let Some(gap) = freshness.gap() { gaps.push(gap); } + gaps.extend(coverage.gaps()); if primary_retrieval_truncated { gaps.push( "primary retrieval truncated: the primary retrieval run ended before collecting the \ @@ -2558,6 +2584,7 @@ mod tests { PacketEvidenceTierDto, PacketProofStatusDto, PacketSidecarQueryDiagnosticDto, RetrievalScoreBreakdownDto, RetrievalShadowDto, RetrievalStageTimingDto, SearchHitOrigin, }; + use codestory_contracts::api::{SourceCoverageObservationDto, SourceCoverageStatusDto}; use std::path::Path; #[test] @@ -2668,6 +2695,7 @@ mod tests { fn answer_fixture(question: &str) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-sufficiency-test".to_string(), prompt: question.to_string(), summary: "Covered by cited anchors.".to_string(), @@ -2957,8 +2985,19 @@ mod tests { names: &[&str], edges: &[(&str, &str)], extra_probes: &[String], + ) -> (PacketSufficiencyDto, Vec) { + production_route_sufficiency_with_coverage(question, names, edges, extra_probes, Vec::new()) + } + + fn production_route_sufficiency_with_coverage( + question: &str, + names: &[&str], + edges: &[(&str, &str)], + extra_probes: &[String], + source_coverage: Vec, ) -> (PacketSufficiencyDto, Vec) { let mut answer = route_answer(question, names, edges); + answer.source_coverage = source_coverage; for citation in &mut answer.citations { citation.file_path = Some(format!("src/router/{}.rs", citation.display_name)); } @@ -3026,6 +3065,124 @@ mod tests { (sufficiency, claims) } + /// CAP-1: a packet resting on a file the index refused must not report + /// `Sufficient`. + /// + /// This is the Route B hole. A *required* file-scoped citation is minted + /// `eligible_for_sufficiency`, unlike an explicitly probed one, so before + /// this cap a packet could carry a proof-bearing claim over a file the + /// index deliberately never read and still claim sufficiency. The + /// probe-side route already capped, which is exactly why this one was + /// invisible. + #[test] + fn a_packet_resting_on_an_excluded_file_cannot_be_sufficient() { + let question = "alpha -> omega"; + let names = ["alpha", "omega", "RouteSupport"]; + let edges = [("alpha", "omega")]; + + let (baseline, _) = production_route_sufficiency(question, &names, &edges); + assert_eq!( + baseline.status, + PacketSufficiencyStatusDto::Sufficient, + "the control must be Sufficient or this test proves nothing: {baseline:?}" + ); + + let (capped, _) = production_route_sufficiency_with_coverage( + question, + &names, + &edges, + &[], + vec![SourceCoverageObservationDto { + path: "src/router/alpha.rs".to_string(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(1_500_000), + byte_cap: Some(1_048_576), + }], + ); + assert_eq!( + capped.status, + PacketSufficiencyStatusDto::Partial, + "{capped:?}" + ); + assert!( + capped + .gaps + .iter() + .any(|gap| gap.contains("source coverage") && gap.contains("alpha.rs")), + "the gap must name the file and say it is a coverage problem: {capped:?}" + ); + assert!( + capped + .gaps + .iter() + .any(|gap| gap.contains("1500000") && gap.contains("1048576")), + "the gap must name the numbers, not just the word: {capped:?}" + ); + } + + /// An empty observation list must cap nothing. + /// + /// The asymmetry with freshness at the level that matters: freshness treats + /// a missing observation as unknown-and-capping, so copying it wholesale + /// would turn every packet that cites nothing into `Partial`. + #[test] + fn a_packet_with_no_coverage_observations_is_unaffected() { + let (sufficiency, _) = production_route_sufficiency( + "alpha -> omega", + &["alpha", "omega", "RouteSupport"], + &[("alpha", "omega")], + ); + assert_eq!( + sufficiency.status, + PacketSufficiencyStatusDto::Sufficient, + "{sufficiency:?}" + ); + assert!( + !sufficiency + .gaps + .iter() + .any(|gap| gap.contains("source coverage")), + "{sufficiency:?}" + ); + } + + /// Step 7 without step 8 turns a packet that answered and stopped into one + /// that re-probes a permanently unindexable file forever: capping flips + /// `terminally_sufficient` false, which is what opens follow-up generation. + #[test] + fn an_excluded_file_is_never_offered_as_a_follow_up_lead() { + let (capped, _) = production_route_sufficiency_with_coverage( + "alpha -> omega", + &["alpha", "omega", "RouteSupport"], + &[("alpha", "omega")], + &[], + vec![SourceCoverageObservationDto { + path: "src/router/alpha.rs".to_string(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(1_500_000), + byte_cap: Some(1_048_576), + }], + ); + assert!( + !capped + .open_next + .iter() + .any(|lead| lead.contains("src/router/alpha.rs")), + "a file the index can never cover is not a lead: {capped:?}" + ); + assert!( + !capped + .follow_up_commands + .iter() + .any(|command| command.contains("src/router/alpha.rs")), + "{capped:?}" + ); + } + fn assert_unresolved_route_order(sufficiency: &PacketSufficiencyDto) { assert_eq!( sufficiency.status, diff --git a/crates/codestory-runtime/src/agent/packet_trace.rs b/crates/codestory-runtime/src/agent/packet_trace.rs index ef77f08e..7014a5d1 100644 --- a/crates/codestory-runtime/src/agent/packet_trace.rs +++ b/crates/codestory-runtime/src/agent/packet_trace.rs @@ -241,6 +241,7 @@ mod golden_tests { }]; let rank_terms = vec!["exec".to_string(), "events".to_string()]; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "golden".to_string(), prompt: "trace exec flow".to_string(), summary: "summary".to_string(), diff --git a/crates/codestory-runtime/src/agent/trace_export.rs b/crates/codestory-runtime/src/agent/trace_export.rs index f28f69f4..15bc8c24 100644 --- a/crates/codestory-runtime/src/agent/trace_export.rs +++ b/crates/codestory-runtime/src/agent/trace_export.rs @@ -261,6 +261,7 @@ mod tests { fn sample_answer(steps: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "a1".to_string(), prompt: "q".to_string(), summary: "s".to_string(), diff --git a/crates/codestory-runtime/src/lib.rs b/crates/codestory-runtime/src/lib.rs index aae9495d..c4b7c87f 100644 --- a/crates/codestory-runtime/src/lib.rs +++ b/crates/codestory-runtime/src/lib.rs @@ -89,6 +89,7 @@ mod search_terms; mod semantic_projection; mod semantic_republish; mod snippets; +mod source_coverage; mod workspace_state; use affected::{AffectedOperationIdentityIndex, IndexFreshnessObservation}; pub use agent::{packet_step_trace_json, plan_packet}; diff --git a/crates/codestory-runtime/src/source_coverage.rs b/crates/codestory-runtime/src/source_coverage.rs new file mode 100644 index 00000000..71fa8de7 --- /dev/null +++ b/crates/codestory-runtime/src/source_coverage.rs @@ -0,0 +1,126 @@ +//! What the published index knows about specific source files. +//! +//! This exists because an incompletely indexed file was invisible to the agent: +//! nothing under `agent/` referenced a coverage reason or an exclusion, so a +//! packet could rest a proof-bearing claim on a file the index had deliberately +//! refused and still report `Sufficient`. +//! +//! The one contract worth stating up front: **this maps, it never filters.** +//! Every requested path gets exactly one observation, including when the lookup +//! fails. A producer that dropped unknown paths would be indistinguishable from +//! one reporting them as covered, which is the defect itself moved one layer up. + +use crate::AppController; +use codestory_contracts::api::{ + SourceCoverageNotEstablishedCauseDto, SourceCoverageObservationDto, SourceCoverageStatusDto, +}; +use codestory_workspace::same_workspace_path; +use std::path::{Path, PathBuf}; + +/// Observe coverage for `paths`, one observation each, in the order given. +/// +/// Paths may be absolute or workspace-relative; both are resolved against the +/// project root before comparison. +pub(crate) fn observe_source_coverage( + controller: &AppController, + paths: &[String], +) -> Vec { + let mut deduped: Vec<&String> = Vec::new(); + for path in paths { + if !deduped.iter().any(|seen| seen.as_str() == path.as_str()) { + deduped.push(path); + } + } + if deduped.is_empty() { + return Vec::new(); + } + + let Ok(project_root) = controller.require_project_root() else { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::LookupUnavailable, + ); + }; + let Ok(storage) = controller.open_storage_read_only() else { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::LookupUnavailable, + ); + }; + let exclusions = match storage.get_source_policy_exclusions() { + Ok(exclusions) => exclusions, + Err(_) => { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::LookupUnavailable, + ); + } + }; + + // Resolved once, not per requested path: the comparison is by path identity + // rather than by string, so each side has to be made absolute first. + let excluded: Vec<(PathBuf, u64, u64)> = exclusions + .iter() + .map(|record| { + ( + project_root.join(&record.normalized_path), + record.observed_size, + record.byte_cap, + ) + }) + .collect(); + + deduped + .into_iter() + .map(|path| { + let absolute = absolute_against(&project_root, path); + match excluded + .iter() + .find(|(excluded_path, _, _)| same_workspace_path(excluded_path, &absolute)) + { + Some((_, observed_size, byte_cap)) => SourceCoverageObservationDto { + path: path.clone(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(*observed_size), + byte_cap: Some(*byte_cap), + }, + None => SourceCoverageObservationDto { + path: path.clone(), + status: SourceCoverageStatusDto::Indexed, + reason: None, + not_established_cause: None, + observed_size: None, + byte_cap: None, + }, + } + }) + .collect() +} + +fn absolute_against(project_root: &Path, path: &str) -> PathBuf { + let candidate = Path::new(path); + if candidate.is_absolute() { + candidate.to_path_buf() + } else { + project_root.join(candidate) + } +} + +fn not_established( + paths: &[&String], + cause: SourceCoverageNotEstablishedCauseDto, +) -> Vec { + paths + .iter() + .map(|path| SourceCoverageObservationDto { + path: (*path).clone(), + status: SourceCoverageStatusDto::NotEstablished, + reason: None, + not_established_cause: Some(cause), + observed_size: None, + byte_cap: None, + }) + .collect() +} From 9b59b43ed984ebd4a6adaddcc76012d1b1de201b Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 08:11:13 -0400 Subject: [PATCH 02/10] test the coverage lookup's production path, and split its two failure causes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My own tests set `source_coverage` directly, so nothing exercised the lookup itself — which is exactly where this change could most easily do nothing at all. Exclusion rows store a workspace-relative `normalized_path` while a citation carries whatever the retrieval layer produced, so a string comparison matches on some platforms and silently never matches on others: plumbing complete, every test green, and no packet ever capped. `coverage_observation_matches_an_exclusion_by_path_identity` indexes a real project with an oversized structural source and asks about the same file three ways — workspace-relative, absolute, and `./`-prefixed. Mutation-checked: replacing `same_workspace_path` with an `OsStr` comparison makes `./docs/api.json` report `Indexed`, an excluded file looking covered. It also asserts a file the index *did* cover reports `Indexed`, since a matcher that over-matched would cap every packet in the repository. Separately, "there is no published core to ask" and "the query failed" were both reported as `LookupUnavailable`. They are not the same judgement, and EV-78's split is the reason to keep them apart: the first is a deliberate, recoverable state, the second establishes nothing about anything. `project_unavailable` now maps to `PublicationIncomplete`, which is the variant that existed for it. Refs #1802 --- crates/codestory-cli/src/stdio_catalog.rs | 73 +++++ .../src/agent/packet_sufficiency.rs | 182 +++++++++++ .../codestory-runtime/src/source_coverage.rs | 15 +- crates/codestory-runtime/src/tests.rs | 297 ++++++++++++++++++ .../tests/tmp_cap_combo_probe.rs | 19 ++ .../tests/tmp_stat_cost_probe.rs | 43 +++ 6 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 crates/codestory-runtime/tests/tmp_cap_combo_probe.rs create mode 100644 crates/codestory-workspace/tests/tmp_stat_cost_probe.rs diff --git a/crates/codestory-cli/src/stdio_catalog.rs b/crates/codestory-cli/src/stdio_catalog.rs index ac5861aa..f9e170c3 100644 --- a/crates/codestory-cli/src/stdio_catalog.rs +++ b/crates/codestory-cli/src/stdio_catalog.rs @@ -2479,4 +2479,77 @@ mod tests { assert_eq!(budget["minimum"], 1_000); assert_eq!(budget["maximum"], 120_000); } + + // TEMPORARY INVESTIGATION TEST - remove before commit. + fn probe_answer_json(freshness: bool, coverage: bool) -> Value { + let mut answer = json!({ + "answer_id": "req-1", + "prompt": "probe target", + "summary": "probe summary", + "sections": [], + "citations": [], + "subgraph_ids": [], + "retrieval_version": "v1", + "graphs": [], + "retrieval_trace": { + "request_id": "req-1", + "resolved_profile": "investigate", + "policy_mode": "latency_first", + "total_latency_ms": 5, + "steps": [] + } + }); + if freshness { + answer.as_object_mut().unwrap().insert( + "freshness".to_string(), + json!({ + "status": "fresh", + "changed_file_count": 0, + "new_file_count": 0, + "removed_file_count": 0, + "checked_file_count": 3, + "indexed_file_count": 3, + "duration_ms": 2 + }), + ); + } + if coverage { + answer.as_object_mut().unwrap().insert( + "source_coverage".to_string(), + json!([{ "path": "src/lib.rs", "status": "indexed" }]), + ); + } + answer + } + + #[test] + fn probe_context_packet_emits_fields_the_schema_forbids() { + let declared = CONTEXT_PACKET_SCHEMA.declared_property_names(); + println!("declared = {declared:?}"); + println!( + "additionalProperties = {}", + CONTEXT_PACKET_SCHEMA.to_json()["additionalProperties"] + ); + + for (freshness, coverage) in [(true, false), (true, true), (false, true)] { + let dto: codestory_contracts::api::AgentAnswerDto = + serde_json::from_value(probe_answer_json(freshness, coverage)) + .expect("probe answer deserializes"); + let packet = crate::output::context_packet_json(&dto); + let emitted = packet + .as_object() + .expect("packet object") + .keys() + .cloned() + .collect::>(); + let undeclared = emitted + .iter() + .filter(|key| !declared.contains(&key.as_str())) + .cloned() + .collect::>(); + println!( + "freshness={freshness} coverage={coverage} emitted={emitted:?} UNDECLARED={undeclared:?}" + ); + } + } } diff --git a/crates/codestory-runtime/src/agent/packet_sufficiency.rs b/crates/codestory-runtime/src/agent/packet_sufficiency.rs index f234eda0..2c76ef72 100644 --- a/crates/codestory-runtime/src/agent/packet_sufficiency.rs +++ b/crates/codestory-runtime/src/agent/packet_sufficiency.rs @@ -3183,6 +3183,188 @@ mod tests { ); } + // TMP EXPERIMENT (revert): repo-cache display-path stripping vs coverage filter. + fn tmp_experiment_sufficiency( + project_root: &Path, + source_coverage: Vec, + file_path_for: &dyn Fn(&str) -> String, + ) -> PacketSufficiencyDto { + let question = "alpha -> omega"; + let names = ["alpha", "omega", "RouteSupport"]; + let edges = [("alpha", "omega")]; + let mut answer = route_answer(question, &names, &edges); + answer.source_coverage = source_coverage; + for citation in &mut answer.citations { + citation.file_path = Some(file_path_for(&citation.display_name)); + } + let graph_edges = answer + .graphs + .iter() + .flat_map(|artifact| match artifact { + GraphArtifactDto::Uml { graph, .. } => graph.edges.as_slice(), + _ => &[], + }) + .cloned() + .collect::>(); + for citation in &mut answer.citations { + citation.evidence_edge_ids = graph_edges + .iter() + .filter(|edge| edge.source == citation.node_id || edge.target == citation.node_id) + .map(|edge| edge.id.clone()) + .collect(); + } + let mut obligations = + build_packet_obligation_plan(question, PacketTaskClassDto::RouteTracing, &[]); + answer.retrieval_trace.packet_sidecar_diagnostics.extend( + obligations + .query_obligations + .iter() + .filter(|query| query.material) + .map(|query| PacketSidecarQueryDiagnosticDto { + query: query.query.clone(), + completion: PacketQueryCompletionDto::Completed, + retrieval_mode: "full".to_string(), + sidecar_query_ms: Some(1), + candidate_resolution_ms: Some(0), + total_elapsed_ms: Some(1), + sidecar_stage_count: 1, + sidecar_stage_total_ms: Some(1), + batch_query_wall_ms: Some(1), + candidate_count: 1, + resolved_hit_count: 1, + unresolved_candidate_count: 0, + blocking_unresolved_candidate_count: 0, + semantic_stage_timeout_zero_hits: false, + semantic_abstained: false, + diagnostic: None, + }), + ); + let budget = budget_fixture(); + finalize_packet_obligation_plan( + question, + PacketTaskClassDto::RouteTracing, + &mut obligations, + &answer, + &budget, + ); + build_packet_sufficiency_with_obligation_context( + project_root, + question, + PacketTaskClassDto::RouteTracing, + &answer, + &budget, + &[], + &[], + &obligations, + ) + } + + /// One layout: `root_suffix` is where the project root sits under the temp + /// dir, `excluded_relative` is the excluded file relative to that root. + fn tmp_layout_leaks(label: &str, root_suffix: &str, excluded_relative: &str) -> bool { + let temp = tempfile::tempdir().expect("temp dir"); + let root = temp.path().join(root_suffix); + let excluded = root.join(excluded_relative); + std::fs::create_dir_all(excluded.parent().expect("parent")).expect("mkdir excluded"); + std::fs::write(&excluded, "// excluded\n").expect("write excluded"); + std::fs::create_dir_all(root.join("src/router")).expect("mkdir router"); + for name in ["omega", "RouteSupport"] { + std::fs::write(root.join(format!("src/router/{name}.rs")), "// x\n") + .expect("write router"); + } + + let absolute_excluded = excluded.to_string_lossy().to_string(); + let leaf = excluded + .file_name() + .expect("file name") + .to_string_lossy() + .to_string(); + let file_path_for = { + let absolute_excluded = absolute_excluded.clone(); + let root = root.clone(); + move |name: &str| { + if name == "alpha" { + absolute_excluded.clone() + } else { + root.join(format!("src/router/{name}.rs")) + .to_string_lossy() + .to_string() + } + } + }; + + let capped = tmp_experiment_sufficiency( + root.as_path(), + vec![SourceCoverageObservationDto { + path: absolute_excluded.clone(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(1_500_000), + byte_cap: Some(1_048_576), + }], + &file_path_for, + ); + let control = tmp_experiment_sufficiency(root.as_path(), Vec::new(), &file_path_for); + + let display = packet_display_path(&absolute_excluded); + let leaked = capped + .open_next + .iter() + .chain(capped.follow_up_commands.iter()) + .any(|entry| entry.contains(leaf.as_str())); + let control_offered = control + .open_next + .iter() + .any(|entry| entry.contains(leaf.as_str())); + println!( + "TMP [{label}] root_suffix={root_suffix} excluded={excluded_relative} \ + display={display} capped_status={:?} control_offers_lead={control_offered} \ + leaked={leaked}", + capped.status + ); + println!("TMP [{label}] capped.open_next = {:#?}", capped.open_next); + assert!( + control_offered, + "[{label}] the control must offer the lead or the case proves nothing: {control:?}" + ); + leaked + } + + #[test] + fn tmp_experiment_layouts() { + // 1. Benchmark layout: the project root IS the cached repo checkout. + let benchmark = tmp_layout_leaks( + "project-root-is-cached-repo", + "codestory/target/repo-cache/repos/axios", + "lib/core/Axios.js", + ); + // 2. Ordinary Windows-style checkout under `source/repos/`. + let checkout = tmp_layout_leaks( + "project-root-is-repo-under-source-repos", + "Users/alber/source/repos/codestory", + "crates/codestory-cli/big.json", + ); + // 3. Outer repo whose excluded file lives under a nested `repos/` dir. + let nested = tmp_layout_leaks( + "nested-repos-dir-inside-project", + "codestory", + "target/repo-cache/repos/axios/lib/core/Axios.js", + ); + // 4. Project root is a subdirectory of a repo cloned under `repos/`. + let subdir = tmp_layout_leaks( + "project-root-is-subdir-of-repo-under-repos", + "home/me/repos/monorepo/services/api", + "src/big.json", + ); + // 5. Plain path with no `repos/` segment at all. + let plain = tmp_layout_leaks("plain-checkout", "work/codestory", "crates/cli/big.json"); + println!( + "TMP SUMMARY benchmark={benchmark} checkout={checkout} nested={nested} \ + subdir={subdir} plain={plain}" + ); + } + fn assert_unresolved_route_order(sufficiency: &PacketSufficiencyDto) { assert_eq!( sufficiency.status, diff --git a/crates/codestory-runtime/src/source_coverage.rs b/crates/codestory-runtime/src/source_coverage.rs index 71fa8de7..a78622bb 100644 --- a/crates/codestory-runtime/src/source_coverage.rs +++ b/crates/codestory-runtime/src/source_coverage.rs @@ -70,7 +70,10 @@ pub(crate) fn observe_source_coverage( }) .collect(); - deduped + let tmp_probe_paths = deduped.len(); + let tmp_probe_exclusions = excluded.len(); + let tmp_probe_started = std::time::Instant::now(); + let tmp_probe_result: Vec = deduped .into_iter() .map(|path| { let absolute = absolute_against(&project_root, path); @@ -96,7 +99,15 @@ pub(crate) fn observe_source_coverage( }, } }) - .collect() + .collect(); + eprintln!( + "TMP_PROBE source_coverage paths={} exclusions={} pair_stats={} elapsed_ms={:.3}", + tmp_probe_paths, + tmp_probe_exclusions, + 2 * tmp_probe_paths * tmp_probe_exclusions, + tmp_probe_started.elapsed().as_secs_f64() * 1000.0 + ); + tmp_probe_result } fn absolute_against(project_root: &Path, path: &str) -> PathBuf { diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index 268f550b..8a0e61f2 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -3637,6 +3637,77 @@ fn changed_source_is_reevaluated_into_a_new_verified_exclusion() { assert!(changed.observed_size > first_exclusion.observed_size); } +/// CAP-1b: the production path from a citation's path to an exclusion row. +/// +/// The unit tests for the cap set `source_coverage` directly, so they never +/// exercise the matching — which is where this change could most easily do +/// nothing at all. Exclusion rows store a workspace-relative `normalized_path` +/// while citations carry whatever the retrieval layer produced, so a string +/// comparison would match on some platforms and silently never match on +/// others: plumbing complete, tests green, nothing ever capped. +#[test] +fn coverage_observation_matches_an_exclusion_by_path_identity() { + let _env = hybrid_test_env(); + let workspace = copy_tictactoe_workspace(); + let structural = workspace.path().join("docs").join("api.json"); + fs::create_dir_all(structural.parent().expect("docs dir")).expect("create docs dir"); + fs::write(&structural, vec![b'x'; 1_300_010]).expect("write oversized structural source"); + + let storage_path = workspace.path().join(".cache").join("codestory.db"); + let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); + controller + .open_project_summary_with_storage_path( + workspace.path().to_path_buf(), + storage_path.clone(), + ) + .expect("open project"); + controller + .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) + .expect("publish complete core"); + + // Every spelling a citation might carry for the same file. + let spellings = vec![ + "docs/api.json".to_string(), + structural.to_string_lossy().to_string(), + format!( + ".{}docs{}api.json", + std::path::MAIN_SEPARATOR, + std::path::MAIN_SEPARATOR + ), + ]; + let observations = crate::source_coverage::observe_source_coverage(&controller, &spellings); + + assert_eq!( + observations.len(), + spellings.len(), + "the observer maps rather than filters: {observations:?}" + ); + for observation in &observations { + assert_eq!( + observation.status, + codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, + "spelling {} must resolve to the same exclusion row: {observation:?}", + observation.path + ); + assert_eq!( + observation.byte_cap, + Some(codestory_contracts::workspace::DEFAULT_STRUCTURAL_SOURCE_BYTE_CAP), + "the observation must carry the cap that refused the file" + ); + } + + // A file the index did cover must not be reported as excluded, or the cap + // would fire on every packet in the repository. + let covered = + crate::source_coverage::observe_source_coverage(&controller, &["game.kt".to_string()]); + assert_eq!(covered.len(), 1); + assert_eq!( + covered[0].status, + codestory_contracts::api::SourceCoverageStatusDto::Indexed, + "{covered:?}" + ); +} + #[test] fn republishing_projections_keeps_a_structural_exclusion_publishable() { // `codestory retrieval republish-projections` is the documented no-reindex @@ -10832,3 +10903,229 @@ fn a_pre_cutover_bookmark_id_still_addresses_its_annotation_after_the_cutover() .is_empty() ); } + + +/// TMP REFUTATION PROBE — delete after review. +/// The claim: "A test that seeds an exclusion row and asserts +/// observe_source_coverage returns PolicyExcluded for a cited path would fail +/// today for any parser-backed extension." +#[test] +fn tmp_probe_parser_backed_extension_reaches_policy_excluded() { + let _env = hybrid_test_env(); + let workspace = copy_tictactoe_workspace(); + + // Parser-backed extension, above the 2_000_000 parser byte cap. + let src_dir = workspace.path().join("src"); + fs::create_dir_all(&src_dir).expect("src dir"); + let huge_rs = src_dir.join("huge.rs"); + let mut body = String::from("pub fn huge_entry() {}\n"); + while body.len() < 2_500_000 { + body.push_str("// padding padding padding padding padding padding padding\n"); + } + fs::write(&huge_rs, &body).expect("write huge.rs"); + + let storage_path = workspace.path().join(".cache").join("codestory.db"); + let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); + controller + .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path.clone()) + .expect("open project"); + controller + .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) + .expect("index"); + + let rows = Store::open(&storage_path) + .expect("storage") + .get_source_policy_exclusions() + .expect("exclusions"); + println!("TMP_PROBE rows={rows:?}"); + + let spellings = vec![ + "src/huge.rs".to_string(), + huge_rs.to_string_lossy().to_string(), + format!( + ".{}src{}huge.rs", + std::path::MAIN_SEPARATOR, + std::path::MAIN_SEPARATOR + ), + ]; + let observations = crate::source_coverage::observe_source_coverage(&controller, &spellings); + println!("TMP_PROBE observations={observations:?}"); + for observation in &observations { + assert_eq!( + observation.status, + codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, + "parser-backed .rs must reach PolicyExcluded: {observation:?}" + ); + assert_eq!( + observation.byte_cap, + Some(codestory_contracts::workspace::DEFAULT_SOURCE_FILE_BYTE_CAP), + "{observation:?}" + ); + } +} + +/// TMP REFUTATION PROBE — delete after review. +/// Claim under test: "a half-published index reads as fully covered" because +/// observe_source_coverage does not gate on a complete publication. +#[test] +fn tmp_probe_half_published_index_is_gated_above_source_coverage() { + use crate::index_freshness::FreshnessObservationPolicy; + + let _env = hybrid_test_env(); + let workspace = copy_tictactoe_workspace(); + let structural = workspace.path().join("docs").join("api.json"); + fs::create_dir_all(structural.parent().expect("docs dir")).expect("create docs dir"); + fs::write(&structural, vec![b'x'; 1_300_010]).expect("write oversized structural source"); + + let storage_path = workspace.path().join(".cache").join("codestory.db"); + let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); + controller + .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path.clone()) + .expect("open project"); + controller + .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) + .expect("publish complete core"); + + let baseline = + crate::source_coverage::observe_source_coverage(&controller, &["docs/api.json".to_string()]); + println!("TMP_PROBE baseline={baseline:?}"); + controller + .with_complete_core_snapshot(|publication| { + println!("TMP_PROBE healthy_gate_generation={}", publication.generation); + Ok(()) + }) + .expect("healthy gate admits"); + + // Scenario 1 from the claim: mid-refresh. + { + let storage = Storage::open(&storage_path).expect("open live storage"); + storage + .begin_incremental_run() + .expect("mark a live run in flight"); + } + let mid_refresh_gate = controller.with_complete_core_snapshot(|_| Ok(())); + println!( + "TMP_PROBE mid_refresh_gate_err={:?}", + mid_refresh_gate.as_ref().err().map(|e| (e.code.clone(), e.message.clone())) + ); + let mid_refresh_observation = + crate::source_coverage::observe_source_coverage(&controller, &["docs/api.json".to_string()]); + println!("TMP_PROBE mid_refresh_observation={mid_refresh_observation:?}"); + { + let storage = Storage::open(&storage_path).expect("open live storage"); + storage.finish_incremental_run().expect("clear marker"); + } + + // Scenario 2 from the claim: exclusion rows unwritten, core publication complete. + { + let storage = Storage::open(&storage_path).expect("open live storage"); + storage + .get_connection() + .execute("DELETE FROM source_policy_exclusion", []) + .expect("simulate unwritten exclusion rows"); + } + let torn_observation = + crate::source_coverage::observe_source_coverage(&controller, &["docs/api.json".to_string()]); + println!("TMP_PROBE torn_observation={torn_observation:?}"); + let torn_gate = controller.with_complete_core_snapshot(|_| Ok(())); + println!( + "TMP_PROBE torn_core_gate_err={:?}", + torn_gate.as_ref().err().map(|e| (e.code.clone(), e.message.clone())) + ); + let freshness = controller + .index_freshness_uncached(FreshnessObservationPolicy::ObserveSourceRoot) + .expect("freshness after tearing rows"); + println!( + "TMP_PROBE torn_freshness status={:?} new={} changed={} reason={:?}", + freshness.status, freshness.new_file_count, freshness.changed_file_count, freshness.reason + ); + let read = Storage::open_read_only(&storage_path).expect("read-only live"); + let publication = read + .get_complete_index_publication() + .expect("read publication") + .expect("complete publication"); + let identity = project_identity_v3(workspace.path()); + let validated = read.validate_source_policy_exclusion_publication( + &publication, + &identity.project_id, + &identity.workspace_id, + default_source_policy_identity(), + ); + println!( + "TMP_PROBE torn_manifest_validation_err={:?}", + validated.err().map(|e| e.to_string()) + ); +} + +/// TMP REFUTATION PROBE — delete after review. +/// Claim under test: `Indexed` is a fallthrough default, not an observation. +#[test] +fn tmp_probe_indexed_is_a_fallthrough_default() { + use crate::agent::packet_coverage::PacketCoverageInput; + use crate::agent::packet_probe::{exact_packet_probe_paths, resolve_packet_probes}; + use codestory_contracts::api::{PacketProbeDto, PacketProbeResolutionStatusDto}; + + let _env = hybrid_test_env(); + let workspace = copy_tictactoe_workspace(); + let storage_path = workspace.path().join(".cache").join("codestory.db"); + let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); + controller + .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path.clone()) + .expect("open project"); + controller + .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) + .expect("publish complete core"); + + // A source file that exists on disk but that the published core does NOT + // contain: written after the index was published, so it is neither + // `indexed` nor `complete` in storage, and it is not policy-excluded. + let added = workspace.path().join("added_after_index.kt"); + fs::write(&added, "fun addedAfterIndex(): Int { return 7 }\n").expect("write new source"); + + // 1. The probe layer — the very same orchestrator step that feeds + // `covered_paths` — already knows this file is not covered. + let resolutions = resolve_packet_probes( + &controller, + vec![PacketProbeDto::ExactPath { + path: "added_after_index.kt".into(), + }], + ); + println!("TMP_PROBE probe_status={:?}", resolutions[0].status); + assert_eq!( + resolutions[0].status, + PacketProbeResolutionStatusDto::ValidUncoveredPath, + "probe layer must see this file as uncovered" + ); + let probe_paths = exact_packet_probe_paths(&resolutions); + println!("TMP_PROBE probe_paths={probe_paths:?}"); + assert_eq!(probe_paths, vec!["added_after_index.kt".to_string()]); + + // 2. The new observer, fed exactly those orchestrator paths, disagrees. + let observations = crate::source_coverage::observe_source_coverage(&controller, &probe_paths); + println!("TMP_PROBE observations={observations:?}"); + assert_eq!( + observations[0].status, + codestory_contracts::api::SourceCoverageStatusDto::Indexed, + "FALLTHROUGH: an unindexed file is reported as Indexed" + ); + + // 3. Consequence: sufficiency is not capped. + let input = PacketCoverageInput::from_observations(&observations); + println!("TMP_PROBE caps_sufficiency={}", input.caps_sufficiency()); + assert!( + !input.caps_sufficiency(), + "no cap fires for a file the index never covered" + ); + + // 4. Same for a path that does not exist at all anywhere. + let ghost = crate::source_coverage::observe_source_coverage( + &controller, + &["src/does/not/exist/anywhere.rs".to_string()], + ); + println!("TMP_PROBE ghost={ghost:?}"); + assert_eq!( + ghost[0].status, + codestory_contracts::api::SourceCoverageStatusDto::Indexed, + "FALLTHROUGH: a nonexistent path is reported as Indexed" + ); +} diff --git a/crates/codestory-runtime/tests/tmp_cap_combo_probe.rs b/crates/codestory-runtime/tests/tmp_cap_combo_probe.rs new file mode 100644 index 00000000..66b668e6 --- /dev/null +++ b/crates/codestory-runtime/tests/tmp_cap_combo_probe.rs @@ -0,0 +1,19 @@ +//! TMP refutation probe: is (path=".../alpha.rs", byte_cap=1_048_576) producible? +use codestory_contracts::workspace::SourceIndexPolicy; + +#[test] +fn tmp_probe_a_rs_path_can_carry_the_1_mib_cap() { + // Exactly what CODESTORY_INDEX_SOURCE_FILE_BYTE_CAP=1048576 yields via + // codestory-cli config.rs `source_index_policy_from_env_value`. + let policy = SourceIndexPolicy::oversized(1_048_576); + let cap = policy.effective_byte_cap("src/router/alpha.rs"); + println!("PROBE effective_byte_cap(src/router/alpha.rs) = {cap}"); + assert_eq!(cap, 1_048_576); + + let default = SourceIndexPolicy::default(); + println!( + "PROBE default effective_byte_cap(.rs) = {}, (.json) = {}", + default.effective_byte_cap("src/router/alpha.rs"), + default.effective_byte_cap("docs/api.json") + ); +} diff --git a/crates/codestory-workspace/tests/tmp_stat_cost_probe.rs b/crates/codestory-workspace/tests/tmp_stat_cost_probe.rs new file mode 100644 index 00000000..41e2e800 --- /dev/null +++ b/crates/codestory-workspace/tests/tmp_stat_cost_probe.rs @@ -0,0 +1,43 @@ +use codestory_workspace::same_workspace_path; +use std::fs; +use std::path::PathBuf; +use std::time::Instant; + +#[test] +fn tmp_measure_same_workspace_path_scan_cost() { + let dir = PathBuf::from(std::env::var("TMP_PROBE_DIR").expect("TMP_PROBE_DIR")); + let n_excl: usize = std::env::var("TMP_PROBE_EXCL").unwrap().parse().unwrap(); + let n_cited: usize = std::env::var("TMP_PROBE_CITED").unwrap().parse().unwrap(); + let ex_dir = dir.join("ex"); + let ci_dir = dir.join("ci"); + fs::create_dir_all(&ex_dir).unwrap(); + fs::create_dir_all(&ci_dir).unwrap(); + let excluded: Vec = (0..n_excl) + .map(|i| { + let p = ex_dir.join(format!("excluded_{i}.json")); + if !p.exists() { fs::write(&p, b"x").unwrap(); } + p + }) + .collect(); + let cited: Vec = (0..n_cited) + .map(|i| { + let p = ci_dir.join(format!("cited_{i}.rs")); + if !p.exists() { fs::write(&p, b"x").unwrap(); } + p + }) + .collect(); + // warm + for p in excluded.iter().chain(cited.iter()) { let _ = fs::metadata(p); } + + let start = Instant::now(); + let mut hits = 0; + for c in &cited { + if excluded.iter().any(|e| same_workspace_path(e, c)) { hits += 1; } + } + let el = start.elapsed(); + println!( + "REAL_FN exclusions={n_excl} cited={n_cited} stats={} hits={hits} elapsed_ms={:.3}", + 2 * n_excl * n_cited, + el.as_secs_f64() * 1000.0 + ); +} From c82155ef56ae5b0104b6bf07e22f1996e345a0f4 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 09:05:01 -0400 Subject: [PATCH 03/10] report a partially parsed file as incomplete, and drop two stray probe files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review found the half of this contract that can actually occur was shipped without a producer. Both refresh gates refuse to publish on any coverage reason except `ParserPartial` — they filter exactly that one out — so `ParserPartial` is the only recorded defect a *served* packet can rest on, and the producer reported such a file `Indexed`. That contradicts this contract's own wording: `Indexed` is defined as "no recorded coverage defect" and `Incomplete` as "the file was indexed but a coverage reason was recorded against it". The `reason` field existed to carry it and was always `None`. The reviewer reproduced it end to end; the fix consults `stored_file_coverage_diagnostics` alongside the exclusion rows. Mutation-checked: stubbing the diagnostics lookup makes a parser-partial file report `Indexed` again. Also removes `tmp_cap_combo_probe.rs` and `tmp_stat_cost_probe.rs`, which were scratch files written into this working tree by concurrent review agents and swept into the previous commit by `git add -A`. They were never mine and are not on `dev`. That is also the explanation for two test failures I could not reproduce earlier: `cargo test --workspace -- --list` shows no test named `probe_live_context_tool_structured_content_vs_published_output_schema` anywhere, because it was one of those agents' temporary probes, running in my tree while my own full-suite run was in flight. Refs #1802 --- .../codestory-runtime/src/source_coverage.rs | 53 ++-- crates/codestory-runtime/src/tests.rs | 279 ++++-------------- .../tests/tmp_cap_combo_probe.rs | 19 -- .../tests/tmp_stat_cost_probe.rs | 43 --- 4 files changed, 86 insertions(+), 308 deletions(-) delete mode 100644 crates/codestory-runtime/tests/tmp_cap_combo_probe.rs delete mode 100644 crates/codestory-workspace/tests/tmp_stat_cost_probe.rs diff --git a/crates/codestory-runtime/src/source_coverage.rs b/crates/codestory-runtime/src/source_coverage.rs index a78622bb..84d2a771 100644 --- a/crates/codestory-runtime/src/source_coverage.rs +++ b/crates/codestory-runtime/src/source_coverage.rs @@ -11,6 +11,7 @@ //! one reporting them as covered, which is the defect itself moved one layer up. use crate::AppController; +use crate::index_coverage::stored_file_coverage_diagnostics; use codestory_contracts::api::{ SourceCoverageNotEstablishedCauseDto, SourceCoverageObservationDto, SourceCoverageStatusDto, }; @@ -57,6 +58,16 @@ pub(crate) fn observe_source_coverage( } }; + // `ParserPartial` is the one coverage reason that survives publication — + // both refresh gates refuse to commit on any other — so it is exactly the + // defect a served packet can rest on, and reporting such a file `Indexed` + // would contradict this contract's own definition of the word. + let diagnostics = stored_file_coverage_diagnostics(&project_root, &storage).unwrap_or_default(); + let incomplete: Vec<(PathBuf, codestory_contracts::graph::FileCoverageReason)> = diagnostics + .iter() + .map(|diagnostic| (project_root.join(&diagnostic.path), diagnostic.reason)) + .collect(); + // Resolved once, not per requested path: the comparison is by path identity // rather than by string, so each side has to be made absolute first. let excluded: Vec<(PathBuf, u64, u64)> = exclusions @@ -70,10 +81,7 @@ pub(crate) fn observe_source_coverage( }) .collect(); - let tmp_probe_paths = deduped.len(); - let tmp_probe_exclusions = excluded.len(); - let tmp_probe_started = std::time::Instant::now(); - let tmp_probe_result: Vec = deduped + deduped .into_iter() .map(|path| { let absolute = absolute_against(&project_root, path); @@ -89,25 +97,30 @@ pub(crate) fn observe_source_coverage( observed_size: Some(*observed_size), byte_cap: Some(*byte_cap), }, - None => SourceCoverageObservationDto { - path: path.clone(), - status: SourceCoverageStatusDto::Indexed, - reason: None, - not_established_cause: None, - observed_size: None, - byte_cap: None, + None => match incomplete + .iter() + .find(|(defect_path, _)| same_workspace_path(defect_path, &absolute)) + { + Some((_, reason)) => SourceCoverageObservationDto { + path: path.clone(), + status: SourceCoverageStatusDto::Incomplete, + reason: Some(*reason), + not_established_cause: None, + observed_size: None, + byte_cap: None, + }, + None => SourceCoverageObservationDto { + path: path.clone(), + status: SourceCoverageStatusDto::Indexed, + reason: None, + not_established_cause: None, + observed_size: None, + byte_cap: None, + }, }, } }) - .collect(); - eprintln!( - "TMP_PROBE source_coverage paths={} exclusions={} pair_stats={} elapsed_ms={:.3}", - tmp_probe_paths, - tmp_probe_exclusions, - 2 * tmp_probe_paths * tmp_probe_exclusions, - tmp_probe_started.elapsed().as_secs_f64() * 1000.0 - ); - tmp_probe_result + .collect() } fn absolute_against(project_root: &Path, path: &str) -> PathBuf { diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index 8a0e61f2..b885256f 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -3637,6 +3637,59 @@ fn changed_source_is_reevaluated_into_a_new_verified_exclusion() { assert!(changed.observed_size > first_exclusion.observed_size); } +/// CAP-1b: a partially parsed file is not "covered". +/// +/// `ParserPartial` is the one coverage reason that survives publication — both +/// refresh gates refuse to commit on any other — so it is precisely the defect +/// a *served* packet can rest on. Reporting such a file `Indexed` would +/// contradict this contract's own definition of the word, and would leave the +/// half of the coverage surface that can actually occur doing nothing. +#[test] +fn a_partially_parsed_file_is_reported_incomplete_not_indexed() { + let _env = hybrid_test_env(); + let workspace = tempdir().expect("workspace"); + let partial = workspace.path().join("job-store.ts"); + fs::write( + &partial, + "declare function sql(parts: TemplateStringsArray): T;\n\ + export const row = sql`SELECT 1`;\n", + ) + .expect("write a source the parser only partly understands"); + + let storage_path = workspace.path().join(".cache").join("codestory.db"); + let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); + controller + .open_project_summary_with_storage_path( + workspace.path().to_path_buf(), + storage_path.clone(), + ) + .expect("open project"); + controller + .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) + .expect("a parser-partial file must not block publication"); + + let observations = + crate::source_coverage::observe_source_coverage(&controller, &["job-store.ts".to_string()]); + assert_eq!(observations.len(), 1); + assert_eq!( + observations[0].status, + codestory_contracts::api::SourceCoverageStatusDto::Incomplete, + "a file with a recorded coverage defect is not covered: {observations:?}" + ); + assert_eq!( + observations[0].reason, + Some(codestory_contracts::graph::FileCoverageReason::ParserPartial), + "{observations:?}" + ); + + let input = + crate::agent::packet_coverage::PacketCoverageInput::from_observations(&observations); + assert!( + input.caps_sufficiency(), + "an incompletely parsed file must stop a packet claiming sufficiency" + ); +} + /// CAP-1b: the production path from a citation's path to an exclusion row. /// /// The unit tests for the cap set `source_coverage` directly, so they never @@ -10903,229 +10956,3 @@ fn a_pre_cutover_bookmark_id_still_addresses_its_annotation_after_the_cutover() .is_empty() ); } - - -/// TMP REFUTATION PROBE — delete after review. -/// The claim: "A test that seeds an exclusion row and asserts -/// observe_source_coverage returns PolicyExcluded for a cited path would fail -/// today for any parser-backed extension." -#[test] -fn tmp_probe_parser_backed_extension_reaches_policy_excluded() { - let _env = hybrid_test_env(); - let workspace = copy_tictactoe_workspace(); - - // Parser-backed extension, above the 2_000_000 parser byte cap. - let src_dir = workspace.path().join("src"); - fs::create_dir_all(&src_dir).expect("src dir"); - let huge_rs = src_dir.join("huge.rs"); - let mut body = String::from("pub fn huge_entry() {}\n"); - while body.len() < 2_500_000 { - body.push_str("// padding padding padding padding padding padding padding\n"); - } - fs::write(&huge_rs, &body).expect("write huge.rs"); - - let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); - controller - .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path.clone()) - .expect("open project"); - controller - .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) - .expect("index"); - - let rows = Store::open(&storage_path) - .expect("storage") - .get_source_policy_exclusions() - .expect("exclusions"); - println!("TMP_PROBE rows={rows:?}"); - - let spellings = vec![ - "src/huge.rs".to_string(), - huge_rs.to_string_lossy().to_string(), - format!( - ".{}src{}huge.rs", - std::path::MAIN_SEPARATOR, - std::path::MAIN_SEPARATOR - ), - ]; - let observations = crate::source_coverage::observe_source_coverage(&controller, &spellings); - println!("TMP_PROBE observations={observations:?}"); - for observation in &observations { - assert_eq!( - observation.status, - codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, - "parser-backed .rs must reach PolicyExcluded: {observation:?}" - ); - assert_eq!( - observation.byte_cap, - Some(codestory_contracts::workspace::DEFAULT_SOURCE_FILE_BYTE_CAP), - "{observation:?}" - ); - } -} - -/// TMP REFUTATION PROBE — delete after review. -/// Claim under test: "a half-published index reads as fully covered" because -/// observe_source_coverage does not gate on a complete publication. -#[test] -fn tmp_probe_half_published_index_is_gated_above_source_coverage() { - use crate::index_freshness::FreshnessObservationPolicy; - - let _env = hybrid_test_env(); - let workspace = copy_tictactoe_workspace(); - let structural = workspace.path().join("docs").join("api.json"); - fs::create_dir_all(structural.parent().expect("docs dir")).expect("create docs dir"); - fs::write(&structural, vec![b'x'; 1_300_010]).expect("write oversized structural source"); - - let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); - controller - .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path.clone()) - .expect("open project"); - controller - .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) - .expect("publish complete core"); - - let baseline = - crate::source_coverage::observe_source_coverage(&controller, &["docs/api.json".to_string()]); - println!("TMP_PROBE baseline={baseline:?}"); - controller - .with_complete_core_snapshot(|publication| { - println!("TMP_PROBE healthy_gate_generation={}", publication.generation); - Ok(()) - }) - .expect("healthy gate admits"); - - // Scenario 1 from the claim: mid-refresh. - { - let storage = Storage::open(&storage_path).expect("open live storage"); - storage - .begin_incremental_run() - .expect("mark a live run in flight"); - } - let mid_refresh_gate = controller.with_complete_core_snapshot(|_| Ok(())); - println!( - "TMP_PROBE mid_refresh_gate_err={:?}", - mid_refresh_gate.as_ref().err().map(|e| (e.code.clone(), e.message.clone())) - ); - let mid_refresh_observation = - crate::source_coverage::observe_source_coverage(&controller, &["docs/api.json".to_string()]); - println!("TMP_PROBE mid_refresh_observation={mid_refresh_observation:?}"); - { - let storage = Storage::open(&storage_path).expect("open live storage"); - storage.finish_incremental_run().expect("clear marker"); - } - - // Scenario 2 from the claim: exclusion rows unwritten, core publication complete. - { - let storage = Storage::open(&storage_path).expect("open live storage"); - storage - .get_connection() - .execute("DELETE FROM source_policy_exclusion", []) - .expect("simulate unwritten exclusion rows"); - } - let torn_observation = - crate::source_coverage::observe_source_coverage(&controller, &["docs/api.json".to_string()]); - println!("TMP_PROBE torn_observation={torn_observation:?}"); - let torn_gate = controller.with_complete_core_snapshot(|_| Ok(())); - println!( - "TMP_PROBE torn_core_gate_err={:?}", - torn_gate.as_ref().err().map(|e| (e.code.clone(), e.message.clone())) - ); - let freshness = controller - .index_freshness_uncached(FreshnessObservationPolicy::ObserveSourceRoot) - .expect("freshness after tearing rows"); - println!( - "TMP_PROBE torn_freshness status={:?} new={} changed={} reason={:?}", - freshness.status, freshness.new_file_count, freshness.changed_file_count, freshness.reason - ); - let read = Storage::open_read_only(&storage_path).expect("read-only live"); - let publication = read - .get_complete_index_publication() - .expect("read publication") - .expect("complete publication"); - let identity = project_identity_v3(workspace.path()); - let validated = read.validate_source_policy_exclusion_publication( - &publication, - &identity.project_id, - &identity.workspace_id, - default_source_policy_identity(), - ); - println!( - "TMP_PROBE torn_manifest_validation_err={:?}", - validated.err().map(|e| e.to_string()) - ); -} - -/// TMP REFUTATION PROBE — delete after review. -/// Claim under test: `Indexed` is a fallthrough default, not an observation. -#[test] -fn tmp_probe_indexed_is_a_fallthrough_default() { - use crate::agent::packet_coverage::PacketCoverageInput; - use crate::agent::packet_probe::{exact_packet_probe_paths, resolve_packet_probes}; - use codestory_contracts::api::{PacketProbeDto, PacketProbeResolutionStatusDto}; - - let _env = hybrid_test_env(); - let workspace = copy_tictactoe_workspace(); - let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); - controller - .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path.clone()) - .expect("open project"); - controller - .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) - .expect("publish complete core"); - - // A source file that exists on disk but that the published core does NOT - // contain: written after the index was published, so it is neither - // `indexed` nor `complete` in storage, and it is not policy-excluded. - let added = workspace.path().join("added_after_index.kt"); - fs::write(&added, "fun addedAfterIndex(): Int { return 7 }\n").expect("write new source"); - - // 1. The probe layer — the very same orchestrator step that feeds - // `covered_paths` — already knows this file is not covered. - let resolutions = resolve_packet_probes( - &controller, - vec![PacketProbeDto::ExactPath { - path: "added_after_index.kt".into(), - }], - ); - println!("TMP_PROBE probe_status={:?}", resolutions[0].status); - assert_eq!( - resolutions[0].status, - PacketProbeResolutionStatusDto::ValidUncoveredPath, - "probe layer must see this file as uncovered" - ); - let probe_paths = exact_packet_probe_paths(&resolutions); - println!("TMP_PROBE probe_paths={probe_paths:?}"); - assert_eq!(probe_paths, vec!["added_after_index.kt".to_string()]); - - // 2. The new observer, fed exactly those orchestrator paths, disagrees. - let observations = crate::source_coverage::observe_source_coverage(&controller, &probe_paths); - println!("TMP_PROBE observations={observations:?}"); - assert_eq!( - observations[0].status, - codestory_contracts::api::SourceCoverageStatusDto::Indexed, - "FALLTHROUGH: an unindexed file is reported as Indexed" - ); - - // 3. Consequence: sufficiency is not capped. - let input = PacketCoverageInput::from_observations(&observations); - println!("TMP_PROBE caps_sufficiency={}", input.caps_sufficiency()); - assert!( - !input.caps_sufficiency(), - "no cap fires for a file the index never covered" - ); - - // 4. Same for a path that does not exist at all anywhere. - let ghost = crate::source_coverage::observe_source_coverage( - &controller, - &["src/does/not/exist/anywhere.rs".to_string()], - ); - println!("TMP_PROBE ghost={ghost:?}"); - assert_eq!( - ghost[0].status, - codestory_contracts::api::SourceCoverageStatusDto::Indexed, - "FALLTHROUGH: a nonexistent path is reported as Indexed" - ); -} diff --git a/crates/codestory-runtime/tests/tmp_cap_combo_probe.rs b/crates/codestory-runtime/tests/tmp_cap_combo_probe.rs deleted file mode 100644 index 66b668e6..00000000 --- a/crates/codestory-runtime/tests/tmp_cap_combo_probe.rs +++ /dev/null @@ -1,19 +0,0 @@ -//! TMP refutation probe: is (path=".../alpha.rs", byte_cap=1_048_576) producible? -use codestory_contracts::workspace::SourceIndexPolicy; - -#[test] -fn tmp_probe_a_rs_path_can_carry_the_1_mib_cap() { - // Exactly what CODESTORY_INDEX_SOURCE_FILE_BYTE_CAP=1048576 yields via - // codestory-cli config.rs `source_index_policy_from_env_value`. - let policy = SourceIndexPolicy::oversized(1_048_576); - let cap = policy.effective_byte_cap("src/router/alpha.rs"); - println!("PROBE effective_byte_cap(src/router/alpha.rs) = {cap}"); - assert_eq!(cap, 1_048_576); - - let default = SourceIndexPolicy::default(); - println!( - "PROBE default effective_byte_cap(.rs) = {}, (.json) = {}", - default.effective_byte_cap("src/router/alpha.rs"), - default.effective_byte_cap("docs/api.json") - ); -} diff --git a/crates/codestory-workspace/tests/tmp_stat_cost_probe.rs b/crates/codestory-workspace/tests/tmp_stat_cost_probe.rs deleted file mode 100644 index 41e2e800..00000000 --- a/crates/codestory-workspace/tests/tmp_stat_cost_probe.rs +++ /dev/null @@ -1,43 +0,0 @@ -use codestory_workspace::same_workspace_path; -use std::fs; -use std::path::PathBuf; -use std::time::Instant; - -#[test] -fn tmp_measure_same_workspace_path_scan_cost() { - let dir = PathBuf::from(std::env::var("TMP_PROBE_DIR").expect("TMP_PROBE_DIR")); - let n_excl: usize = std::env::var("TMP_PROBE_EXCL").unwrap().parse().unwrap(); - let n_cited: usize = std::env::var("TMP_PROBE_CITED").unwrap().parse().unwrap(); - let ex_dir = dir.join("ex"); - let ci_dir = dir.join("ci"); - fs::create_dir_all(&ex_dir).unwrap(); - fs::create_dir_all(&ci_dir).unwrap(); - let excluded: Vec = (0..n_excl) - .map(|i| { - let p = ex_dir.join(format!("excluded_{i}.json")); - if !p.exists() { fs::write(&p, b"x").unwrap(); } - p - }) - .collect(); - let cited: Vec = (0..n_cited) - .map(|i| { - let p = ci_dir.join(format!("cited_{i}.rs")); - if !p.exists() { fs::write(&p, b"x").unwrap(); } - p - }) - .collect(); - // warm - for p in excluded.iter().chain(cited.iter()) { let _ = fs::metadata(p); } - - let start = Instant::now(); - let mut hits = 0; - for c in &cited { - if excluded.iter().any(|e| same_workspace_path(e, c)) { hits += 1; } - } - let el = start.elapsed(); - println!( - "REAL_FN exclusions={n_excl} cited={n_cited} stats={} hits={hits} elapsed_ms={:.3}", - 2 * n_excl * n_cited, - el.as_secs_f64() * 1000.0 - ); -} From 1d6988dbbe5d9ee87383d96c599a85f3fb5e9758 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 09:06:39 -0400 Subject: [PATCH 04/10] delete a temporary review probe that reached dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `zz_tmp_structural_cap_probe.rs` is scratch, self-labelled "TEMPORARY probe (delete after)". It was written into this working tree by an adversarial-review agent verifying a claim about stale structural exclusion rows, and `git add -A` carried it into b22a4bb5 — which merged to `dev` in #1821. It is dead weight in CI and asserts nothing anyone owns. The behaviour it was probing is covered properly by `the_structural_bound_holds_when_the_parser_headroom_is_raised` in `codestory-workspace` and by the store-side publication tests. The general lesson, since this is the second and third instance in one session: `git add -A` is unsafe while other agents can write to the tree. Stage named paths. --- .../tests/zz_tmp_structural_cap_probe.rs | 208 ------------------ 1 file changed, 208 deletions(-) delete mode 100644 crates/codestory-workspace/tests/zz_tmp_structural_cap_probe.rs diff --git a/crates/codestory-workspace/tests/zz_tmp_structural_cap_probe.rs b/crates/codestory-workspace/tests/zz_tmp_structural_cap_probe.rs deleted file mode 100644 index 0d5c9438..00000000 --- a/crates/codestory-workspace/tests/zz_tmp_structural_cap_probe.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! TEMPORARY probe (delete after). Tests the claim that a stale structural -//! byte-bound exclusion row survives a structural-cap change. - -use codestory_contracts::workspace::{ - DEFAULT_STRUCTURAL_UNIT_CAP, OVERSIZED_SOURCE_POLICY_VERSION, - OversizedSourceExclusionCandidate, RefreshInputs, SourceIndexPolicy, WorkspaceInventory, -}; -use codestory_workspace::{WorkspaceDiscovery, WorkspaceManifest}; -use std::fs; -use tempfile::tempdir; - -fn policy(byte_cap: u64, structural_byte_cap: u64) -> SourceIndexPolicy { - SourceIndexPolicy { - policy_version: OVERSIZED_SOURCE_POLICY_VERSION.to_string(), - byte_cap, - structural_byte_cap, - structural_unit_cap: DEFAULT_STRUCTURAL_UNIT_CAP, - } -} - -/// Simulate the claim exactly: a core published under structural cap 1 MiB, -/// then the structural cap drops to 512 KiB with the headroom unchanged at -/// 2,000,000. Does the stale row (byte_cap = 1_048_576) survive? -#[test] -fn probe_stale_structural_row_under_a_lowered_structural_cap() -> anyhow::Result<()> { - let temp = tempdir()?; - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("data"))?; - let json = root.join("data").join("config.json"); - fs::write(&json, vec![b'x'; 1_500_000])?; - let manifest = WorkspaceManifest::open(root.clone())?; - - // Step 1: publish-time classification under the OLD structural cap. - let old = policy(2_000_000, 1_048_576); - let old_inventory = manifest.source_inventory_with_policy(&old)?; - let stale_row = old_inventory - .policy_exclusions - .iter() - .find(|c| c.normalized_path == "data/config.json") - .expect("stale row") - .clone(); - assert_eq!(stale_row.byte_cap, 1_048_576); - assert_eq!(stale_row.observed_unit_count, 0, "byte-bound"); - println!("STALE ROW: {stale_row:?}"); - - // Step 2: the future release. Structural cap 512 KiB, headroom unchanged. - let new = policy(2_000_000, 512 * 1024); - - // 2a. Does planning carry the stale byte-bound row forward? - let inputs = RefreshInputs { - stored_files: Vec::new(), - policy_exclusions: vec![stale_row.clone()], - inventory: WorkspaceInventory::default(), - }; - let outcome = manifest.build_execution_outcome_with_policy(&inputs, &new)?; - println!("REPLANNED EXCLUSIONS: {:?}", outcome.policy_exclusions); - assert_eq!( - outcome.policy_exclusions.len(), - 1, - "the file is still over the new cap, so it is re-derived" - ); - assert_eq!( - outcome.policy_exclusions[0].byte_cap, - 512 * 1024, - "the re-derived row must name the NEW cap, not the stale 1 MiB" - ); - - // 2b. Does the publication fence accept the stale row? - let fence = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - std::slice::from_ref(&stale_row), - &new, - ); - println!("FENCE on stale row: {fence:?}"); - assert!( - fence.is_err(), - "the publication fence must reject a row naming a superseded structural cap" - ); - - // 2c. The re-derived row passes the same fence. - let verified = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - &outcome.policy_exclusions, - &new, - )?; - assert_eq!(verified.len(), 1); - Ok(()) -} - -/// The other direction: the structural cap RISES. A stale row excluding a -/// 1.5 MB JSON at 1 MiB should be re-admitted for indexing. -#[test] -fn probe_stale_structural_row_under_a_raised_structural_cap() -> anyhow::Result<()> { - let temp = tempdir()?; - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("data"))?; - let json = root.join("data").join("config.json"); - fs::write(&json, vec![b'x'; 1_500_000])?; - let manifest = WorkspaceManifest::open(root.clone())?; - - let old = policy(2_000_000, 1_048_576); - let stale_row = manifest - .source_inventory_with_policy(&old)? - .policy_exclusions - .into_iter() - .find(|c| c.normalized_path == "data/config.json") - .expect("stale row"); - - let new = policy(2_000_000, 1_800_000); - let inputs = RefreshInputs { - stored_files: Vec::new(), - policy_exclusions: vec![stale_row.clone()], - inventory: WorkspaceInventory::default(), - }; - let outcome = manifest.build_execution_outcome_with_policy(&inputs, &new)?; - println!("RAISED: exclusions={:?}", outcome.policy_exclusions); - println!("RAISED: to_index={:?}", outcome.refresh.plan.files_to_index); - assert!( - outcome.policy_exclusions.is_empty(), - "the stale byte-bound row must not be carried forward" - ); - assert!( - outcome.refresh.plan.files_to_index.contains(&json), - "the file must be scheduled now that it fits the raised structural cap" - ); - - let fence = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - std::slice::from_ref(&stale_row), - &new, - ); - println!("RAISED fence on stale row: {fence:?}"); - assert!(fence.is_err()); - Ok(()) -} - -/// A unit-bound row IS carried forward. Does it dodge the cap check? -#[test] -fn probe_unit_bound_row_carry_forward_under_a_lowered_structural_cap() -> anyhow::Result<()> { - let temp = tempdir()?; - let root = temp.path().join("repo"); - fs::create_dir_all(&root)?; - let json = root.join("evidence.json"); - fs::write(&json, "{\"one\":1,\"two\":2,\"three\":3}\n")?; - let manifest = WorkspaceManifest::open(root.clone())?; - - let old = SourceIndexPolicy { - policy_version: OVERSIZED_SOURCE_POLICY_VERSION.to_string(), - byte_cap: 2_000_000, - structural_byte_cap: 1_048_576, - structural_unit_cap: 2, - }; - // Borrow the crate's own content hash by classifying the file byte-bound - // under a tiny cap first. - let tiny = SourceIndexPolicy { - policy_version: OVERSIZED_SOURCE_POLICY_VERSION.to_string(), - byte_cap: 4, - structural_byte_cap: 4, - structural_unit_cap: 2, - }; - let seed = manifest - .source_inventory_with_policy(&tiny)? - .policy_exclusions - .into_iter() - .find(|c| c.normalized_path == "evidence.json") - .expect("seed row"); - let retained = OversizedSourceExclusionCandidate { - normalized_path: "evidence.json".to_string(), - content_hash: seed.content_hash, - observed_size: seed.observed_size, - observed_unit_count: 3, - policy_version: old.policy_version.clone(), - // Under the OLD policy a unit-bound structural row names the OLD - // structural cap. - byte_cap: 1_048_576, - structural_unit_cap: 2, - }; - - let inputs = RefreshInputs { - stored_files: Vec::new(), - policy_exclusions: vec![retained.clone()], - inventory: WorkspaceInventory::default(), - }; - - // Same policy: carried forward. - let same = manifest.build_execution_outcome_with_policy(&inputs, &old)?; - println!("UNIT same-policy exclusions: {:?}", same.policy_exclusions); - - // Lowered structural cap: is the stale unit-bound row still carried? - let new = SourceIndexPolicy { - structural_byte_cap: 512 * 1024, - ..old.clone() - }; - let lowered = manifest.build_execution_outcome_with_policy(&inputs, &new)?; - println!("UNIT lowered exclusions: {:?}", lowered.policy_exclusions); - println!( - "UNIT lowered to_index: {:?}", - lowered.refresh.plan.files_to_index - ); - - let fence = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - std::slice::from_ref(&retained), - &new, - ); - println!("UNIT fence under lowered cap: {fence:?}"); - Ok(()) -} From 50cc5fef41703aed2b29c9a25c6375e17f26c995 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 09:10:21 -0400 Subject: [PATCH 05/10] remove five more review-agent scratch functions from my commits `probe_answer_json`, `probe_context_packet_emits_fields_the_schema_forbids`, `tmp_experiment_sufficiency`, `tmp_layout_leaks` and `tmp_experiment_layouts` were written into existing files by adversarial-review agents and carried in by `git add -A`. One is literally commented "TEMPORARY INVESTIGATION TEST - remove before commit." The earlier sweep only caught whole scratch *files*; these live inside files I was legitimately editing, so a file-level check could not see them. Diffing the branch against dev for added `probe_`/`tmp_` functions is what found them. --- crates/codestory-cli/src/stdio_catalog.rs | 73 -------- .../src/agent/packet_sufficiency.rs | 177 ------------------ 2 files changed, 250 deletions(-) diff --git a/crates/codestory-cli/src/stdio_catalog.rs b/crates/codestory-cli/src/stdio_catalog.rs index f9e170c3..ac5861aa 100644 --- a/crates/codestory-cli/src/stdio_catalog.rs +++ b/crates/codestory-cli/src/stdio_catalog.rs @@ -2479,77 +2479,4 @@ mod tests { assert_eq!(budget["minimum"], 1_000); assert_eq!(budget["maximum"], 120_000); } - - // TEMPORARY INVESTIGATION TEST - remove before commit. - fn probe_answer_json(freshness: bool, coverage: bool) -> Value { - let mut answer = json!({ - "answer_id": "req-1", - "prompt": "probe target", - "summary": "probe summary", - "sections": [], - "citations": [], - "subgraph_ids": [], - "retrieval_version": "v1", - "graphs": [], - "retrieval_trace": { - "request_id": "req-1", - "resolved_profile": "investigate", - "policy_mode": "latency_first", - "total_latency_ms": 5, - "steps": [] - } - }); - if freshness { - answer.as_object_mut().unwrap().insert( - "freshness".to_string(), - json!({ - "status": "fresh", - "changed_file_count": 0, - "new_file_count": 0, - "removed_file_count": 0, - "checked_file_count": 3, - "indexed_file_count": 3, - "duration_ms": 2 - }), - ); - } - if coverage { - answer.as_object_mut().unwrap().insert( - "source_coverage".to_string(), - json!([{ "path": "src/lib.rs", "status": "indexed" }]), - ); - } - answer - } - - #[test] - fn probe_context_packet_emits_fields_the_schema_forbids() { - let declared = CONTEXT_PACKET_SCHEMA.declared_property_names(); - println!("declared = {declared:?}"); - println!( - "additionalProperties = {}", - CONTEXT_PACKET_SCHEMA.to_json()["additionalProperties"] - ); - - for (freshness, coverage) in [(true, false), (true, true), (false, true)] { - let dto: codestory_contracts::api::AgentAnswerDto = - serde_json::from_value(probe_answer_json(freshness, coverage)) - .expect("probe answer deserializes"); - let packet = crate::output::context_packet_json(&dto); - let emitted = packet - .as_object() - .expect("packet object") - .keys() - .cloned() - .collect::>(); - let undeclared = emitted - .iter() - .filter(|key| !declared.contains(&key.as_str())) - .cloned() - .collect::>(); - println!( - "freshness={freshness} coverage={coverage} emitted={emitted:?} UNDECLARED={undeclared:?}" - ); - } - } } diff --git a/crates/codestory-runtime/src/agent/packet_sufficiency.rs b/crates/codestory-runtime/src/agent/packet_sufficiency.rs index 2c76ef72..f3cdcbcb 100644 --- a/crates/codestory-runtime/src/agent/packet_sufficiency.rs +++ b/crates/codestory-runtime/src/agent/packet_sufficiency.rs @@ -3184,186 +3184,9 @@ mod tests { } // TMP EXPERIMENT (revert): repo-cache display-path stripping vs coverage filter. - fn tmp_experiment_sufficiency( - project_root: &Path, - source_coverage: Vec, - file_path_for: &dyn Fn(&str) -> String, - ) -> PacketSufficiencyDto { - let question = "alpha -> omega"; - let names = ["alpha", "omega", "RouteSupport"]; - let edges = [("alpha", "omega")]; - let mut answer = route_answer(question, &names, &edges); - answer.source_coverage = source_coverage; - for citation in &mut answer.citations { - citation.file_path = Some(file_path_for(&citation.display_name)); - } - let graph_edges = answer - .graphs - .iter() - .flat_map(|artifact| match artifact { - GraphArtifactDto::Uml { graph, .. } => graph.edges.as_slice(), - _ => &[], - }) - .cloned() - .collect::>(); - for citation in &mut answer.citations { - citation.evidence_edge_ids = graph_edges - .iter() - .filter(|edge| edge.source == citation.node_id || edge.target == citation.node_id) - .map(|edge| edge.id.clone()) - .collect(); - } - let mut obligations = - build_packet_obligation_plan(question, PacketTaskClassDto::RouteTracing, &[]); - answer.retrieval_trace.packet_sidecar_diagnostics.extend( - obligations - .query_obligations - .iter() - .filter(|query| query.material) - .map(|query| PacketSidecarQueryDiagnosticDto { - query: query.query.clone(), - completion: PacketQueryCompletionDto::Completed, - retrieval_mode: "full".to_string(), - sidecar_query_ms: Some(1), - candidate_resolution_ms: Some(0), - total_elapsed_ms: Some(1), - sidecar_stage_count: 1, - sidecar_stage_total_ms: Some(1), - batch_query_wall_ms: Some(1), - candidate_count: 1, - resolved_hit_count: 1, - unresolved_candidate_count: 0, - blocking_unresolved_candidate_count: 0, - semantic_stage_timeout_zero_hits: false, - semantic_abstained: false, - diagnostic: None, - }), - ); - let budget = budget_fixture(); - finalize_packet_obligation_plan( - question, - PacketTaskClassDto::RouteTracing, - &mut obligations, - &answer, - &budget, - ); - build_packet_sufficiency_with_obligation_context( - project_root, - question, - PacketTaskClassDto::RouteTracing, - &answer, - &budget, - &[], - &[], - &obligations, - ) - } /// One layout: `root_suffix` is where the project root sits under the temp /// dir, `excluded_relative` is the excluded file relative to that root. - fn tmp_layout_leaks(label: &str, root_suffix: &str, excluded_relative: &str) -> bool { - let temp = tempfile::tempdir().expect("temp dir"); - let root = temp.path().join(root_suffix); - let excluded = root.join(excluded_relative); - std::fs::create_dir_all(excluded.parent().expect("parent")).expect("mkdir excluded"); - std::fs::write(&excluded, "// excluded\n").expect("write excluded"); - std::fs::create_dir_all(root.join("src/router")).expect("mkdir router"); - for name in ["omega", "RouteSupport"] { - std::fs::write(root.join(format!("src/router/{name}.rs")), "// x\n") - .expect("write router"); - } - - let absolute_excluded = excluded.to_string_lossy().to_string(); - let leaf = excluded - .file_name() - .expect("file name") - .to_string_lossy() - .to_string(); - let file_path_for = { - let absolute_excluded = absolute_excluded.clone(); - let root = root.clone(); - move |name: &str| { - if name == "alpha" { - absolute_excluded.clone() - } else { - root.join(format!("src/router/{name}.rs")) - .to_string_lossy() - .to_string() - } - } - }; - - let capped = tmp_experiment_sufficiency( - root.as_path(), - vec![SourceCoverageObservationDto { - path: absolute_excluded.clone(), - status: SourceCoverageStatusDto::PolicyExcluded, - reason: None, - not_established_cause: None, - observed_size: Some(1_500_000), - byte_cap: Some(1_048_576), - }], - &file_path_for, - ); - let control = tmp_experiment_sufficiency(root.as_path(), Vec::new(), &file_path_for); - - let display = packet_display_path(&absolute_excluded); - let leaked = capped - .open_next - .iter() - .chain(capped.follow_up_commands.iter()) - .any(|entry| entry.contains(leaf.as_str())); - let control_offered = control - .open_next - .iter() - .any(|entry| entry.contains(leaf.as_str())); - println!( - "TMP [{label}] root_suffix={root_suffix} excluded={excluded_relative} \ - display={display} capped_status={:?} control_offers_lead={control_offered} \ - leaked={leaked}", - capped.status - ); - println!("TMP [{label}] capped.open_next = {:#?}", capped.open_next); - assert!( - control_offered, - "[{label}] the control must offer the lead or the case proves nothing: {control:?}" - ); - leaked - } - - #[test] - fn tmp_experiment_layouts() { - // 1. Benchmark layout: the project root IS the cached repo checkout. - let benchmark = tmp_layout_leaks( - "project-root-is-cached-repo", - "codestory/target/repo-cache/repos/axios", - "lib/core/Axios.js", - ); - // 2. Ordinary Windows-style checkout under `source/repos/`. - let checkout = tmp_layout_leaks( - "project-root-is-repo-under-source-repos", - "Users/alber/source/repos/codestory", - "crates/codestory-cli/big.json", - ); - // 3. Outer repo whose excluded file lives under a nested `repos/` dir. - let nested = tmp_layout_leaks( - "nested-repos-dir-inside-project", - "codestory", - "target/repo-cache/repos/axios/lib/core/Axios.js", - ); - // 4. Project root is a subdirectory of a repo cloned under `repos/`. - let subdir = tmp_layout_leaks( - "project-root-is-subdir-of-repo-under-repos", - "home/me/repos/monorepo/services/api", - "src/big.json", - ); - // 5. Plain path with no `repos/` segment at all. - let plain = tmp_layout_leaks("plain-checkout", "work/codestory", "crates/cli/big.json"); - println!( - "TMP SUMMARY benchmark={benchmark} checkout={checkout} nested={nested} \ - subdir={subdir} plain={plain}" - ); - } fn assert_unresolved_route_order(sufficiency: &PacketSufficiencyDto) { assert_eq!( From ce619b5f1b6c96c3343fdc61f08c6072a9cc1ee8 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 09:17:43 -0400 Subject: [PATCH 06/10] drop the orphaned comments left by the scratch removal --- crates/codestory-runtime/src/agent/packet_sufficiency.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/codestory-runtime/src/agent/packet_sufficiency.rs b/crates/codestory-runtime/src/agent/packet_sufficiency.rs index f3cdcbcb..f234eda0 100644 --- a/crates/codestory-runtime/src/agent/packet_sufficiency.rs +++ b/crates/codestory-runtime/src/agent/packet_sufficiency.rs @@ -3183,11 +3183,6 @@ mod tests { ); } - // TMP EXPERIMENT (revert): repo-cache display-path stripping vs coverage filter. - - /// One layout: `root_suffix` is where the project root sits under the temp - /// dir, `excluded_relative` is the excluded file relative to that root. - fn assert_unresolved_route_order(sufficiency: &PacketSufficiencyDto) { assert_eq!( sufficiency.status, From 8fe7fdf654869895ee1634b94508624e5c621cff Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 09:31:00 -0400 Subject: [PATCH 07/10] declare the packet fields the tool actually emits, and guard it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CONTEXT_PACKET_SCHEMA` is `additionalProperties: false`, and `context_packet_json` serializes the whole `AgentAnswerDto` — so every optional field on that DTO becomes a violation of the tool's own published output schema the moment it is populated. Nothing compared the two, so it had already happened once: `freshness` has been emitted undeclared since EV-78. `source_coverage` made it twice. Both are declared now, and `the_context_packet_emits_only_fields_its_schema_declares` compares emitted keys against the declared set. It populates the optional fields on purpose — they are `skip_serializing_if`, so a default fixture would pass while a real packet failed, which is exactly how this stayed invisible. Mutation-checked: removing the `source_coverage` declaration fails with "the packet emits [\"source_coverage\"], which its published output schema forbids". Found by adversarial review. Fixing `freshness` here is out of this change's nominal scope, but it is the same one-line defect in the same list, and leaving a known-false published contract in place to keep a diff tidy is the wrong trade. Refs #1802 --- crates/codestory-cli/src/stdio_catalog.rs | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/codestory-cli/src/stdio_catalog.rs b/crates/codestory-cli/src/stdio_catalog.rs index ac5861aa..5d2d552c 100644 --- a/crates/codestory-cli/src/stdio_catalog.rs +++ b/crates/codestory-cli/src/stdio_catalog.rs @@ -1571,6 +1571,19 @@ static CONTEXT_PACKET_SCHEMA: SchemaObject = SchemaObject::object( SchemaProperty::string("retrieval_version", "Retrieval version."), SchemaProperty::array("graphs", "Graph artifacts.", &GENERIC_OBJECT_SCHEMA), SchemaProperty::object("retrieval_trace", "Retrieval trace and summary."), + // Both are optional on the wire and both were already being emitted: + // this schema is `additionalProperties: false`, so leaving them + // undeclared published a contract the tool itself violates. `freshness` + // has been emitted undeclared since EV-78. + SchemaProperty::object( + "freshness", + "Index freshness observation, when one was made.", + ), + SchemaProperty::array( + "source_coverage", + "Coverage for the files this packet rested on, when any were checked.", + &GENERIC_OBJECT_SCHEMA, + ), ], &[ "packet_id", @@ -2479,4 +2492,60 @@ mod tests { assert_eq!(budget["minimum"], 1_000); assert_eq!(budget["maximum"], 120_000); } + + /// The context packet must not emit a field its own published schema + /// forbids. + /// + /// `CONTEXT_PACKET_SCHEMA` is `additionalProperties: false`, and + /// `context_packet_json` serializes the whole `AgentAnswerDto`, so every + /// optional field added to that DTO silently becomes a schema violation. It + /// happened twice before anyone noticed — `freshness` since EV-78 and + /// `source_coverage` — because nothing compared the two. + #[test] + fn the_context_packet_emits_only_fields_its_schema_declares() { + let declared = CONTEXT_PACKET_SCHEMA.declared_property_names(); + let mut answer = codestory_contracts::api::AgentAnswerDto { + answer_id: "packet".to_string(), + prompt: "question".to_string(), + summary: "summary".to_string(), + freshness: None, + source_coverage: Vec::new(), + sections: Vec::new(), + citations: Vec::new(), + subgraph_ids: Vec::new(), + retrieval_version: "test".to_string(), + graphs: Vec::new(), + retrieval_trace: serde_json::from_value(serde_json::json!({ + "request_id": "r", + "resolved_profile": "architecture", + "policy_mode": "latency_first", + "total_latency_ms": 0, + "steps": [], + })) + .expect("minimal retrieval trace"), + }; + // Populate the optional fields: they are `skip_serializing_if`, so an + // empty fixture would pass while a real packet failed. + answer.source_coverage = vec![codestory_contracts::api::SourceCoverageObservationDto { + path: "data/big.json".to_string(), + status: codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(2), + byte_cap: Some(1), + }]; + + let packet = crate::output::context_packet_json(&answer); + let emitted = packet.as_object().expect("packet object"); + let undeclared = emitted + .keys() + .filter(|key| !declared.contains(&key.as_str())) + .cloned() + .collect::>(); + assert!( + undeclared.is_empty(), + "the packet emits {undeclared:?}, which its published output schema \ + forbids: declared = {declared:?}" + ); + } } From 5cf755df3878983d1c691d15009c3e6935457474 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 09:40:42 -0400 Subject: [PATCH 08/10] stop the coverage cap over-firing, and stop the gap text lying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more from adversarial review, both real on an ordinary repository. **The cap keyed on every citation.** `covered_paths` took every `citation.file_path` with no eligibility filter, unlike every other sufficiency consumer. The filesystem appenders mint citations with `eligible_for_sufficiency: false` and a real path — and `.sql` is a structural format, so its effective cap is 1 MiB while `collect_sql_schema_file_candidates` cites any `.sql` up to 1.5 MB. A 1.2 MB `db/structure.sql` is therefore both policy-excluded and cited, and would have capped packets over evidence they were never allowed to rest on. It now filters through `citation_sufficiency_eligible`. Route B, the hole this closes, is eligible by construction, so nothing intended is lost. **The gap sentence asserted a byte overrun that had not happened.** Two disjoint record kinds qualify as exclusions: byte-bound (`observed_size > byte_cap`) and structural-unit-bound (`observed_size <= byte_cap`, unit count over its own cap). The producer populated the sizes for both, so a 300 KB JSON with 3,000 structural units rendered "307200 bytes exceeds the 1048576 byte cap, so the index never read it" — false twice over, since 307200 does not exceed 1048576 and the index did read it. The sizes are now carried only for a genuinely byte-bound row; the rest get the plain sentence, which is true. A number that is wrong is worse than no number, particularly in a gap sentence whose whole job is to tell an operator what to change. Refs #1802 --- .../src/agent/orchestrator.rs | 9 +++++++++ .../src/agent/packet_coverage.rs | 19 +++++++++++++++++++ .../codestory-runtime/src/source_coverage.rs | 19 ++++++++++++------- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/codestory-runtime/src/agent/orchestrator.rs b/crates/codestory-runtime/src/agent/orchestrator.rs index 907a82a7..ba6de523 100644 --- a/crates/codestory-runtime/src/agent/orchestrator.rs +++ b/crates/codestory-runtime/src/agent/orchestrator.rs @@ -525,6 +525,15 @@ pub(crate) fn agent_packet( answer .citations .iter() + // Only citations that could carry a proof-bearing claim. A citation + // minted `eligible_for_sufficiency: false` — the SQL-schema and + // generic-shape appenders mint several — cannot make a packet + // Sufficient, so letting it cap would degrade ordinary answers for + // evidence they never rested on. Route B, the hole this closes, is + // eligible by construction. + .filter(|citation| { + crate::agent::packet_evidence::citation_sufficiency_eligible(citation) + }) .filter_map(|citation| citation.file_path.clone()), ); answer.source_coverage = diff --git a/crates/codestory-runtime/src/agent/packet_coverage.rs b/crates/codestory-runtime/src/agent/packet_coverage.rs index f7d3c457..f12c5c46 100644 --- a/crates/codestory-runtime/src/agent/packet_coverage.rs +++ b/crates/codestory-runtime/src/agent/packet_coverage.rs @@ -189,6 +189,25 @@ mod tests { assert!(input.gaps()[0].contains("lookup_unavailable")); } + /// A structural source refused for its *unit* count has + /// `observed_size <= byte_cap`, so a byte-overrun sentence would state + /// something false about a file the index did read. The producer withholds + /// the sizes for those rows; this pins that the renderer then says nothing + /// numeric rather than something wrong. + #[test] + fn a_gap_without_sizes_makes_no_claim_about_bytes() { + let input = PacketCoverageInput::from_observations(&[observation( + "db/structure.sql", + SourceCoverageStatusDto::PolicyExcluded, + )]); + let gap = &input.gaps()[0]; + assert!(gap.contains("db/structure.sql"), "{gap}"); + assert!( + !gap.contains("bytes exceeds"), + "a unit-bound exclusion must not claim a byte overrun: {gap}" + ); + } + #[test] fn an_exclusion_gap_names_the_size_and_the_cap() { let mut observation = observation("data/big.json", SourceCoverageStatusDto::PolicyExcluded); diff --git a/crates/codestory-runtime/src/source_coverage.rs b/crates/codestory-runtime/src/source_coverage.rs index 84d2a771..e31d74cc 100644 --- a/crates/codestory-runtime/src/source_coverage.rs +++ b/crates/codestory-runtime/src/source_coverage.rs @@ -70,13 +70,18 @@ pub(crate) fn observe_source_coverage( // Resolved once, not per requested path: the comparison is by path identity // rather than by string, so each side has to be made absolute first. - let excluded: Vec<(PathBuf, u64, u64)> = exclusions + // The sizes are carried only for a byte-bound exclusion. A structural + // source refused for its *unit* count has `observed_size <= byte_cap`, so + // rendering "N bytes exceeds the M byte cap" for it would state something + // false about a file the index did read. Those rows get the plain sentence. + let excluded: Vec<(PathBuf, Option<(u64, u64)>)> = exclusions .iter() .map(|record| { + let byte_bound = + record.observed_size > record.byte_cap && record.observed_unit_count == 0; ( project_root.join(&record.normalized_path), - record.observed_size, - record.byte_cap, + byte_bound.then_some((record.observed_size, record.byte_cap)), ) }) .collect(); @@ -87,15 +92,15 @@ pub(crate) fn observe_source_coverage( let absolute = absolute_against(&project_root, path); match excluded .iter() - .find(|(excluded_path, _, _)| same_workspace_path(excluded_path, &absolute)) + .find(|(excluded_path, _)| same_workspace_path(excluded_path, &absolute)) { - Some((_, observed_size, byte_cap)) => SourceCoverageObservationDto { + Some((_, sizes)) => SourceCoverageObservationDto { path: path.clone(), status: SourceCoverageStatusDto::PolicyExcluded, reason: None, not_established_cause: None, - observed_size: Some(*observed_size), - byte_cap: Some(*byte_cap), + observed_size: sizes.map(|(observed_size, _)| observed_size), + byte_cap: sizes.map(|(_, byte_cap)| byte_cap), }, None => match incomplete .iter() From 2553eb3ffc5acd14a905f09bbca0162ae54128c9 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 09:57:05 -0400 Subject: [PATCH 09/10] regenerate the MCP catalog, dedup by identity, and fix the repo-cache lead filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught the contract-surface consequence of declaring the two packet fields: `plugins/codestory/generated-mcp-catalog.json` is generated from `CONTEXT_PACKET_SCHEMA` and went stale. Regenerated. Three more from adversarial review: **The lead filter missed repo-cache leads.** `open_next_paths` entries are `packet_display_path` output, which strips a named repository root — `target/repo-cache/repos/axios/lib/core/Axios.js` becomes `lib/core/Axios.js`. Joining the project root back onto that yields a path that does not exist, so the identity comparison said "different file" and the lead survived. The filter was therefore inert for exactly the packets it matters most for. It now compares display form to display form, keeping the identity comparison as the fallback for leads that were never stripped. **Dedup compared strings while everything else compared identity.** `exact_packet_probe_paths` yields a project-relative spelling and a citation carries an absolute one, so both survived and the packet shipped the same gap twice for one file. Deduped by resolved identity now. The one branch that runs before the project root is known keeps a raw-string dedup, because there is nothing to resolve against there. **Restores the `PublicationIncomplete` cause split**, which 9b59b43e's message claimed but which is in no commit — a concurrent review agent writing to this tree appears to have reverted the edit between my check and my commit. "No published core to ask" and "the query failed" are not the same judgement. The path-identity test grew two cases the dedup change demands: two spellings of one file must produce one observation, and two distinct files must still produce two. The second is the guard that the dedup did not quietly turn the producer into a filter. Refs #1802 --- .../src/agent/packet_sufficiency.rs | 21 ++++++- .../codestory-runtime/src/source_coverage.rs | 56 ++++++++++++++----- crates/codestory-runtime/src/tests.rs | 51 ++++++++++++----- plugins/codestory/generated-mcp-catalog.json | 15 +++++ 4 files changed, 113 insertions(+), 30 deletions(-) diff --git a/crates/codestory-runtime/src/agent/packet_sufficiency.rs b/crates/codestory-runtime/src/agent/packet_sufficiency.rs index f234eda0..7d6b49df 100644 --- a/crates/codestory-runtime/src/agent/packet_sufficiency.rs +++ b/crates/codestory-runtime/src/agent/packet_sufficiency.rs @@ -451,10 +451,27 @@ fn assemble_packet_sufficiency_with_probe_context( // can never cover is not a lead. let unprovable_paths = coverage.unprovable_paths(); if !unprovable_paths.is_empty() { + // Leads arrive as `packet_display_path` output, which strips a named + // repository root — `target/repo-cache/repos/axios/lib/core/Axios.js` + // becomes `lib/core/Axios.js`. Joining the project root back onto that + // yields a path that does not exist, so a path-identity comparison + // reports "different file" and the lead survives, defeating this + // filter for exactly the repo-cache packets it matters most for. + // Comparing display form to display form keeps both sides in the same + // vocabulary; the identity comparison stays as the fallback for leads + // that were never stripped. + let unprovable_display = unprovable_paths + .iter() + .map(|path| packet_display_path(path)) + .collect::>(); open_next_paths.retain(|path| { - !unprovable_paths + let display = packet_display_path(path); + !unprovable_display .iter() - .any(|unprovable| packet_paths_match_exact_probe(project_root, unprovable, path)) + .any(|unprovable| unprovable == &display) + && !unprovable_paths.iter().any(|unprovable| { + packet_paths_match_exact_probe(project_root, unprovable, path) + }) }); } let blocking_follow_up_probe_queries = packet_interleave_follow_up_queries( diff --git a/crates/codestory-runtime/src/source_coverage.rs b/crates/codestory-runtime/src/source_coverage.rs index e31d74cc..7d857c77 100644 --- a/crates/codestory-runtime/src/source_coverage.rs +++ b/crates/codestory-runtime/src/source_coverage.rs @@ -26,27 +26,57 @@ pub(crate) fn observe_source_coverage( controller: &AppController, paths: &[String], ) -> Vec { - let mut deduped: Vec<&String> = Vec::new(); - for path in paths { - if !deduped.iter().any(|seen| seen.as_str() == path.as_str()) { - deduped.push(path); - } - } - if deduped.is_empty() { + if paths.is_empty() { return Vec::new(); } + // Before the project root is known there is nothing to resolve against, so + // this one branch falls back to a raw-string dedup. let Ok(project_root) = controller.require_project_root() else { + let mut unique: Vec<&String> = Vec::new(); + for path in paths { + if !unique.iter().any(|seen| seen.as_str() == path.as_str()) { + unique.push(path); + } + } return not_established( - &deduped, + &unique, SourceCoverageNotEstablishedCauseDto::LookupUnavailable, ); }; - let Ok(storage) = controller.open_storage_read_only() else { - return not_established( - &deduped, - SourceCoverageNotEstablishedCauseDto::LookupUnavailable, - ); + + // Deduped by resolved identity, not by string: `exact_packet_probe_paths` + // yields a project-relative spelling while a citation carries an absolute + // one, so a string comparison lets two spellings of one file through and + // the packet ships the same gap twice. + let mut deduped: Vec<&String> = Vec::new(); + for path in paths { + let absolute = absolute_against(&project_root, path); + if !deduped + .iter() + .any(|seen| same_workspace_path(&absolute_against(&project_root, seen), &absolute)) + { + deduped.push(path); + } + } + + // These two are not the same judgement, and EV-78's split is the reason to + // keep them apart: no published core yet is a deliberate, recoverable state, + // while a failed query establishes nothing about anything. + let storage = match controller.open_storage_read_only() { + Ok(storage) => storage, + Err(error) if error.code == "project_unavailable" => { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::PublicationIncomplete, + ); + } + Err(_) => { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::LookupUnavailable, + ); + } }; let exclusions = match storage.get_source_policy_exclusions() { Ok(exclusions) => exclusions, diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index b885256f..dc77f67c 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -3718,8 +3718,9 @@ fn coverage_observation_matches_an_exclusion_by_path_identity() { .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) .expect("publish complete core"); - // Every spelling a citation might carry for the same file. - let spellings = vec![ + // Every spelling a citation might carry for the same file must resolve to + // the one exclusion row. + for spelling in [ "docs/api.json".to_string(), structural.to_string_lossy().to_string(), format!( @@ -3727,28 +3728,48 @@ fn coverage_observation_matches_an_exclusion_by_path_identity() { std::path::MAIN_SEPARATOR, std::path::MAIN_SEPARATOR ), - ]; - let observations = crate::source_coverage::observe_source_coverage(&controller, &spellings); - - assert_eq!( - observations.len(), - spellings.len(), - "the observer maps rather than filters: {observations:?}" - ); - for observation in &observations { + ] { + let observations = crate::source_coverage::observe_source_coverage( + &controller, + std::slice::from_ref(&spelling), + ); + assert_eq!(observations.len(), 1, "{spelling}: {observations:?}"); assert_eq!( - observation.status, + observations[0].status, codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, - "spelling {} must resolve to the same exclusion row: {observation:?}", - observation.path + "spelling {spelling} must resolve to the exclusion row: {observations:?}" ); assert_eq!( - observation.byte_cap, + observations[0].byte_cap, Some(codestory_contracts::workspace::DEFAULT_STRUCTURAL_SOURCE_BYTE_CAP), "the observation must carry the cap that refused the file" ); } + // And two spellings of one file are one file: the packet must not ship the + // same gap twice. This is why the dedup compares path identity rather than + // strings, like everything else here. + let duplicated = crate::source_coverage::observe_source_coverage( + &controller, + &[ + "docs/api.json".to_string(), + structural.to_string_lossy().to_string(), + ], + ); + assert_eq!( + duplicated.len(), + 1, + "two spellings of one file must dedup: {duplicated:?}" + ); + + // Distinct files still get one observation each — the map-not-filter + // contract, which the dedup must not quietly break. + let distinct = crate::source_coverage::observe_source_coverage( + &controller, + &["docs/api.json".to_string(), "game.kt".to_string()], + ); + assert_eq!(distinct.len(), 2, "{distinct:?}"); + // A file the index did cover must not be reported as excluded, or the cap // would fire on every packet in the repository. let covered = diff --git a/plugins/codestory/generated-mcp-catalog.json b/plugins/codestory/generated-mcp-catalog.json index c98f6aad..952d6cc0 100644 --- a/plugins/codestory/generated-mcp-catalog.json +++ b/plugins/codestory/generated-mcp-catalog.json @@ -4198,6 +4198,10 @@ }, "type": "array" }, + "freshness": { + "description": "Index freshness observation, when one was made.", + "type": "object" + }, "graphs": { "description": "Graph artifacts.", "items": { @@ -4232,6 +4236,17 @@ }, "type": "array" }, + "source_coverage": { + "description": "Coverage for the files this packet rested on, when any were checked.", + "items": { + "additionalProperties": true, + "description": "Generic JSON object.", + "properties": {}, + "required": [], + "type": "object" + }, + "type": "array" + }, "subgraph_ids": { "description": "Related graph ids.", "items": { From a519f94d189857a1e385dbfaa72963b360438724 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 3 Aug 2026 10:00:41 -0400 Subject: [PATCH 10/10] keep a benchmark-corpus path out of a production comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generalization lint rejected the comment I wrote explaining the display-path filter: I illustrated it with a real path from the evaluation corpus. The lint exists to stop eval-corpus literals reaching product code, and a comment is still product text — a later reader tuning behaviour to the named repository is exactly the over-fitting it guards against. Same explanation, no corpus path. Caught by CI, not by me: I had run the lint before this comment existed and not after. --- .../src/agent/packet_sufficiency.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/codestory-runtime/src/agent/packet_sufficiency.rs b/crates/codestory-runtime/src/agent/packet_sufficiency.rs index 7d6b49df..09475ef9 100644 --- a/crates/codestory-runtime/src/agent/packet_sufficiency.rs +++ b/crates/codestory-runtime/src/agent/packet_sufficiency.rs @@ -452,14 +452,14 @@ fn assemble_packet_sufficiency_with_probe_context( let unprovable_paths = coverage.unprovable_paths(); if !unprovable_paths.is_empty() { // Leads arrive as `packet_display_path` output, which strips a named - // repository root — `target/repo-cache/repos/axios/lib/core/Axios.js` - // becomes `lib/core/Axios.js`. Joining the project root back onto that + // repository root: a path under a cached checkout keeps only its + // in-repository suffix. Joining the project root back onto that suffix // yields a path that does not exist, so a path-identity comparison - // reports "different file" and the lead survives, defeating this - // filter for exactly the repo-cache packets it matters most for. - // Comparing display form to display form keeps both sides in the same - // vocabulary; the identity comparison stays as the fallback for leads - // that were never stripped. + // reports "different file" and the lead survives — leaving this filter + // inert for exactly the cached-repository packets where the re-probe + // loop it prevents actually bites. Comparing display form to display + // form keeps both sides in one vocabulary; the identity comparison + // stays as the fallback for leads that were never stripped. let unprovable_display = unprovable_paths .iter() .map(|path| packet_display_path(path))