Skip to content

Commit 2c352ea

Browse files
willwashburnclaude
andauthored
Compute hotspots per-source coverage gap in SDK (#370)
The Rust hotspots presenter walked the ledger a second time after the SDK verb already walked it, just to recover the per-source breakdown that drives the coverage-notice clause. For multi-month ledgers that doubled the I/O cost of `burn hotspots`. Widen `HotspotsFidelityBlock` with a non-serialized `excluded_by_source` field that the SDK populates during the same eligible/excluded split. The CLI becomes a pure renderer over already-computed data, dropping its `describe_excluded_turns` re-walk. Closes #342 https://claude.ai/code/session_01M2cRDSDfUwi4MPSxRq3WaA Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f7d9d2 commit 2c352ea

2 files changed

Lines changed: 86 additions & 111 deletions

File tree

crates/relayburn-cli/src/commands/hotspots.rs

Lines changed: 28 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,12 @@
2424
//! discriminator and emits the inner `HotspotsAttributionResult`
2525
//! shape directly (TS contract).
2626
27-
use std::collections::BTreeMap;
28-
2927
use clap::Args;
3028
use relayburn_sdk::{
31-
hotspots as sdk_hotspots, ingest_all, normalize_since, AttributionMethod, BashAggregation,
32-
BashVerbAggregation, FileAggregation, HotspotsAttributionResult, HotspotsGroupBy,
33-
HotspotsOptions, HotspotsResult, HotspotsSessionTotal, Ledger, LedgerOpenOptions, Query,
34-
SubagentAggregation,
29+
hotspots as sdk_hotspots, ingest_all, AttributionMethod, BashAggregation,
30+
BashVerbAggregation, FileAggregation, HotspotsAttributionResult, HotspotsExcludedBreakdown,
31+
HotspotsExcludedSourceRow, HotspotsGroupBy, HotspotsOptions, HotspotsResult,
32+
HotspotsSessionTotal, Ledger, LedgerOpenOptions, SubagentAggregation,
3533
};
3634
use serde_json::{json, Map, Value};
3735

@@ -148,23 +146,6 @@ fn run_inner(globals: &GlobalArgs, args: HotspotsArgs) -> anyhow::Result<i32> {
148146
.build()?;
149147
let raw_opts = relayburn_sdk::RawIngestOptions::default();
150148
rt.block_on(ingest_all(handle.raw_mut(), &raw_opts))?;
151-
152-
// Pre-compute the per-source coverage-gap breakdown so the human
153-
// renderer can name *which* sources contributed excluded turns and
154-
// *what* they were missing. Reaching past the SDK verb here is a
155-
// small layering compromise: the verb internally walks the same
156-
// slice but only surfaces an aggregate `fidelity` block. Doing it
157-
// here keeps the source-attributed clause in `coverage_notice` honest
158-
// when the slice mixes sources (e.g. excluded codex + opencode turns
159-
// both showing up in the message).
160-
let mut q = Query::default();
161-
if let Some(p) = args.project.clone() {
162-
q.project = Some(p);
163-
}
164-
if let Some(since) = normalize_since(args.since.as_deref())? {
165-
q.since = Some(since);
166-
}
167-
let breakdown = describe_excluded_turns(&handle, &q)?;
168149
drop(handle);
169150

170151
let result = sdk_hotspots(HotspotsOptions {
@@ -181,72 +162,10 @@ fn run_inner(globals: &GlobalArgs, args: HotspotsArgs) -> anyhow::Result<i32> {
181162
return Ok(0);
182163
}
183164
let limit = if args.all { usize::MAX } else { DEFAULT_TOP_N };
184-
emit_human(&result, limit, &breakdown);
165+
emit_human(&result, limit);
185166
Ok(0)
186167
}
187168

188-
/// Per-source coverage-gap breakdown, mirroring the TS
189-
/// `describeExcluded` helper. Only counts turns that *fail* the
190-
/// hotspots-attribution gate (`hasToolCalls && hasToolResultEvents`);
191-
/// turns without a `fidelity` field are best-effort full and never
192-
/// excluded. The SDK's [`relayburn_sdk::HotspotsResult`] reports
193-
/// aggregate analyzed/excluded counts but not the source mix; we walk
194-
/// the ledger slice ourselves to recover that detail.
195-
#[derive(Debug, Clone, Default)]
196-
struct CoverageGapBreakdown {
197-
/// Sorted by source label so the rendered clause is deterministic.
198-
sources: BTreeMap<String, SourceClause>,
199-
/// Total excluded count across all sources. Equal to
200-
/// `attribution_result.fidelity.excluded` for non-empty slices.
201-
excluded: u64,
202-
/// Total analyzed (eligible) count.
203-
analyzed: u64,
204-
}
205-
206-
#[derive(Debug, Clone, Default)]
207-
struct SourceClause {
208-
/// Distinct missing-coverage labels (`tool-call records`, etc.).
209-
missing: BTreeMap<String, ()>,
210-
/// Distinct granularities observed on excluded turns from this
211-
/// source. Used by the inline rendered form `<missing>, <gran>
212-
/// granularity (<source>)`.
213-
granularities: BTreeMap<String, ()>,
214-
count: u64,
215-
}
216-
217-
fn describe_excluded_turns(
218-
handle: &relayburn_sdk::LedgerHandle,
219-
q: &Query,
220-
) -> anyhow::Result<CoverageGapBreakdown> {
221-
let mut out = CoverageGapBreakdown::default();
222-
for enriched in handle.raw().query_turns(q)? {
223-
let t = &enriched.turn;
224-
let Some(f) = t.fidelity.as_ref() else {
225-
// No fidelity → treat as best-effort full; never excluded.
226-
out.analyzed += 1;
227-
continue;
228-
};
229-
let passes = f.coverage.has_tool_calls && f.coverage.has_tool_result_events;
230-
if passes {
231-
out.analyzed += 1;
232-
continue;
233-
}
234-
out.excluded += 1;
235-
let entry = out.sources.entry(t.source.wire_str().to_string()).or_default();
236-
entry.count += 1;
237-
if !f.coverage.has_tool_calls {
238-
entry.missing.insert("tool-call records".to_string(), ());
239-
}
240-
if !f.coverage.has_tool_result_events {
241-
entry.missing.insert("tool-result events".to_string(), ());
242-
}
243-
entry
244-
.granularities
245-
.insert(f.granularity.wire_str().to_string(), ());
246-
}
247-
Ok(out)
248-
}
249-
250169
fn emit_json(result: &HotspotsResult) {
251170
let mut value = hotspots_result_to_json(result);
252171
coerce_whole_f64_to_int(&mut value);
@@ -492,9 +411,9 @@ fn subagent_to_json(s: &SubagentAggregation) -> Value {
492411

493412
// ---------- human rendering ----------
494413

495-
fn emit_human(result: &HotspotsResult, limit: usize, breakdown: &CoverageGapBreakdown) {
414+
fn emit_human(result: &HotspotsResult, limit: usize) {
496415
match result {
497-
HotspotsResult::Attribution(a) => emit_human_attribution(a, limit, breakdown),
416+
HotspotsResult::Attribution(a) => emit_human_attribution(a, limit),
498417
// The single-axis group_by surfaces aren't yet tied to a golden
499418
// snapshot (the snapshot covers the default attribution view),
500419
// so we render their tables on a best-effort basis with the same
@@ -608,17 +527,13 @@ where
608527
print!("{}", lines.join("\n"));
609528
}
610529

611-
fn emit_human_attribution(
612-
a: &HotspotsAttributionResult,
613-
limit: usize,
614-
breakdown: &CoverageGapBreakdown,
615-
) {
530+
fn emit_human_attribution(a: &HotspotsAttributionResult, limit: usize) {
616531
let degraded = a.attribution_degraded;
617532
let approx_suffix = if degraded { " (approximate)" } else { "" };
618533
let mut out: Vec<String> = Vec::new();
619534
out.push(String::new());
620535
out.push(format!("turns analyzed: {}", format_uint(a.turns_analyzed)));
621-
if let Some(notice) = coverage_notice(a, breakdown) {
536+
if let Some(notice) = coverage_notice(a) {
622537
out.push(notice);
623538
}
624539
out.push(format!(
@@ -773,10 +688,7 @@ fn emit_human_attribution(
773688
print!("{}", out.join("\n"));
774689
}
775690

776-
fn coverage_notice(
777-
a: &HotspotsAttributionResult,
778-
breakdown: &CoverageGapBreakdown,
779-
) -> Option<String> {
691+
fn coverage_notice(a: &HotspotsAttributionResult) -> Option<String> {
780692
let analyzed = a.fidelity.analyzed;
781693
let excluded = a.fidelity.excluded;
782694
if excluded == 0 {
@@ -786,16 +698,14 @@ fn coverage_notice(
786698
// The TS shape is one inline clause per source kind, joined with " and ".
787699
// Each clause names the missing field(s) + the granularity bucket the
788700
// excluded turns carried, with the source name in parens. Sources are
789-
// walked in BTreeMap order for stable rendering.
790-
let clauses: Vec<String> = breakdown
791-
.sources
792-
.iter()
793-
.map(|(source, row)| render_inline_source_clause(source, row))
794-
.collect();
701+
// walked in BTreeMap order for stable rendering. The breakdown is
702+
// computed by the SDK in the same pass that produced the rest of the
703+
// attribution result — no second ledger walk here.
704+
let clauses: Vec<String> = render_source_clauses(&a.fidelity.excluded_by_source);
795705
let suffix = if clauses.is_empty() {
796-
// Fall back to the SDK's aggregate counts if for some reason the
797-
// re-walk surfaced no breakdown (e.g. record without fidelity but
798-
// the SDK still excluded it). Don't fabricate a source label.
706+
// Fall back to the SDK's aggregate counts if the breakdown is empty
707+
// (e.g. a turn without a fidelity record that the SDK still excluded).
708+
// Don't fabricate a source label.
799709
String::new()
800710
} else {
801711
format!(" for {}", clauses.join(" and "))
@@ -809,14 +719,22 @@ fn coverage_notice(
809719
))
810720
}
811721

812-
fn render_inline_source_clause(source: &str, row: &SourceClause) -> String {
722+
fn render_source_clauses(breakdown: &HotspotsExcludedBreakdown) -> Vec<String> {
723+
breakdown
724+
.sources
725+
.iter()
726+
.map(|(source, row)| render_inline_source_clause(source, row))
727+
.collect()
728+
}
729+
730+
fn render_inline_source_clause(source: &str, row: &HotspotsExcludedSourceRow) -> String {
813731
let mut inner: Vec<String> = Vec::new();
814732
if !row.missing.is_empty() {
815-
let missing: Vec<&str> = row.missing.keys().map(String::as_str).collect();
733+
let missing: Vec<&str> = row.missing.iter().map(String::as_str).collect();
816734
inner.push(format!("missing {}", missing.join(", ")));
817735
}
818736
if !row.granularities.is_empty() {
819-
let grans: Vec<&str> = row.granularities.keys().map(String::as_str).collect();
737+
let grans: Vec<&str> = row.granularities.iter().map(String::as_str).collect();
820738
inner.push(format!("{} granularity", grans.join("+")));
821739
}
822740
if inner.is_empty() {

crates/relayburn-sdk/src/query_verbs.rs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! Option<PathBuf>` so callers don't have to mutate process env to point
99
//! at a non-default ledger.
1010
11-
use std::collections::{BTreeMap, HashMap, HashSet};
11+
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
1212
use std::fs;
1313
use std::path::{Path, PathBuf};
1414

@@ -844,6 +844,33 @@ pub struct HotspotsFidelityBlock {
844844
/// this JSON block directly.
845845
pub summary: serde_json::Value,
846846
pub refused: bool,
847+
/// Per-source coverage-gap breakdown. Computed in the same pass as the
848+
/// eligible/excluded split so CLI/MCP renderers don't need to re-walk the
849+
/// ledger to recover *which* sources contributed excluded turns. Not
850+
/// serialized — the JSON contract owns the aggregate counts above; this
851+
/// is an in-process renderer aid.
852+
#[serde(skip)]
853+
pub excluded_by_source: HotspotsExcludedBreakdown,
854+
}
855+
856+
/// Per-source breakdown of turns that failed the hotspots coverage gate.
857+
/// Sources are keyed by their wire string (e.g. `claude`, `codex`,
858+
/// `opencode`) so the renderer can produce stable ordering without a second
859+
/// ledger walk. See `HotspotsFidelityBlock::excluded_by_source`.
860+
#[derive(Debug, Clone, Default)]
861+
pub struct HotspotsExcludedBreakdown {
862+
pub sources: BTreeMap<String, HotspotsExcludedSourceRow>,
863+
}
864+
865+
#[derive(Debug, Clone, Default)]
866+
pub struct HotspotsExcludedSourceRow {
867+
pub count: u64,
868+
/// Distinct missing-coverage labels (e.g. `tool-call records`,
869+
/// `tool-result events`).
870+
pub missing: BTreeSet<String>,
871+
/// Distinct granularity buckets observed on excluded turns from this
872+
/// source.
873+
pub granularities: BTreeSet<String>,
847874
}
848875

849876
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -971,10 +998,12 @@ fn run_hotspots_attribution(
971998
) -> Result<HotspotsResult> {
972999
let mut eligible: Vec<TurnRecord> = Vec::new();
9731000
let mut excluded: Vec<TurnRecord> = Vec::new();
1001+
let mut excluded_by_source = HotspotsExcludedBreakdown::default();
9741002
for t in turns {
9751003
if turn_passes_hotspots_coverage(t) {
9761004
eligible.push(t.clone());
9771005
} else {
1006+
record_excluded_source(&mut excluded_by_source, t);
9781007
excluded.push(t.clone());
9791008
}
9801009
}
@@ -993,6 +1022,7 @@ fn run_hotspots_attribution(
9931022
refusal,
9941023
turns.len() as u64,
9951024
summary_value,
1025+
excluded_by_source,
9961026
));
9971027
}
9981028

@@ -1087,18 +1117,44 @@ fn run_hotspots_attribution(
10871117
excluded: excluded.len() as u64,
10881118
summary: summary_value,
10891119
refused: false,
1120+
excluded_by_source,
10901121
},
10911122
refused: None,
10921123
refusal_reason: None,
10931124
},
10941125
)))
10951126
}
10961127

1128+
/// Folds the coverage gap on `t` into the per-source breakdown. Mirrors
1129+
/// the CLI-side `describeExcluded` from `packages/cli/src/commands/hotspots.ts`
1130+
/// so callers can render the inline source clause without a second ledger
1131+
/// walk. Turns without `fidelity` are treated as best-effort full upstream
1132+
/// (`turn_passes_hotspots_coverage`) and never reach this function.
1133+
fn record_excluded_source(out: &mut HotspotsExcludedBreakdown, t: &TurnRecord) {
1134+
let entry = out
1135+
.sources
1136+
.entry(t.source.wire_str().to_string())
1137+
.or_default();
1138+
entry.count += 1;
1139+
if let Some(f) = t.fidelity.as_ref() {
1140+
if !f.coverage.has_tool_calls {
1141+
entry.missing.insert("tool-call records".to_string());
1142+
}
1143+
if !f.coverage.has_tool_result_events {
1144+
entry.missing.insert("tool-result events".to_string());
1145+
}
1146+
entry
1147+
.granularities
1148+
.insert(f.granularity.wire_str().to_string());
1149+
}
1150+
}
1151+
10971152
fn refused_for_group(
10981153
group: HotspotsGroupBy,
10991154
refusal: String,
11001155
excluded_total: u64,
11011156
summary_value: serde_json::Value,
1157+
excluded_by_source: HotspotsExcludedBreakdown,
11021158
) -> HotspotsResult {
11031159
match group {
11041160
HotspotsGroupBy::Bash => HotspotsResult::Bash {
@@ -1138,6 +1194,7 @@ fn refused_for_group(
11381194
excluded: excluded_total,
11391195
summary: summary_value,
11401196
refused: true,
1197+
excluded_by_source,
11411198
},
11421199
refused: Some(true),
11431200
refusal_reason: Some(refusal),

0 commit comments

Comments
 (0)