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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Cross-package release notes for relayburn. Package changelogs contain package-le

## [Unreleased]

- `summary` now reports context tokens per generated output token and p50/p95/max context size for the ten highest-ratio sessions; context is input + cache-read + cache-creation tokens, while the denominator includes reasoning whether a harness folds it into output (Codex) or reports it separately.
- `hotspots --findings` now flags high-volume sessions at or above a configurable context-to-output ratio (default 382:1 inclusive, with a configurable 1M-context-token floor), independently of dollar cost. The default is an inspection signal, not a length-normalized anomaly score.

## [4.0.0] - 2026-06-23

- **BREAKING (`relayburn-sdk`):** the published Rust SDK no longer re-exports its low-level `analyze`-layer internals (detector/aggregator functions and helper types such as `PricingTable`, `CompareTable`, `CompareCell`) — these were never the intended embedding surface. Embed through the verb layer instead: `LedgerHandle` methods / `summary_report` / `hotspots` / `compare`. CLI, MCP, and `@relayburn/sdk` behavior is unchanged.
Expand Down
20 changes: 20 additions & 0 deletions crates/relayburn-cli/src/commands/hotspots/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ pub struct HotspotsArgs {
#[arg(long, value_name = "PROVIDERS")]
pub provider: Option<String>,

/// Flag sessions at or above this context-token/output-token ratio.
/// Context includes input, cache-read, and cache-creation tokens.
#[arg(
long = "context-output-ratio-threshold",
value_name = "RATIO",
default_value_t = relayburn_sdk::DEFAULT_CONTEXT_OUTPUT_RATIO_THRESHOLD
)]
pub context_output_ratio_threshold: f64,

/// Ignore ratio findings below this session context-token volume.
#[arg(
long = "context-output-min-tokens",
value_name = "TOKENS",
default_value_t = relayburn_sdk::DEFAULT_CONTEXT_OUTPUT_MIN_TOKENS
)]
pub context_output_min_tokens: u64,

/// Show all rows in human mode instead of capping at the default
/// top-N (10).
#[arg(long)]
Expand Down Expand Up @@ -130,6 +147,7 @@ pub enum RankBy {
// the same set, so this list has to use the finding-kind spelling on every
// row.
const PATTERN_KINDS: &[&str] = &[
"context-output-ratio",
"retry-loop",
"failure-run",
"cancellation-run",
Expand Down Expand Up @@ -280,6 +298,8 @@ fn run_inner(globals: &GlobalArgs, args: HotspotsArgs) -> anyhow::Result<i32> {
patterns: patterns_selection,
workflow: args.workflow.clone(),
provider: provider_filter,
context_output_ratio_threshold: Some(args.context_output_ratio_threshold),
context_output_min_tokens: Some(args.context_output_min_tokens),
ledger_home,
})?;
progress.finish_and_clear();
Expand Down
59 changes: 59 additions & 0 deletions crates/relayburn-cli/src/commands/summary/human.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,7 @@ pub(super) fn emit_human(
"turns analyzed: {}",
format_uint(report.turn_count)
));
lines.push(format_context_efficiency_line(&report.context_efficiency));
lines.push(String::new());

if report.rows.is_empty() {
Expand Down Expand Up @@ -535,6 +536,36 @@ pub(super) fn emit_human(
}
lines.push(render_table(&rendered));
lines.push(String::new());

if !report.context_efficiency.sessions.is_empty() {
lines.push(format!(
"highest context-efficiency sessions ({} of {} sessions):",
format_uint(report.context_efficiency.sessions.len() as u64),
format_uint(report.context_efficiency.total_sessions),
));
let mut context_rows = vec![vec![
"session".into(),
"turns".into(),
"total context".into(),
"context:output".into(),
"p50 context".into(),
"p95 context".into(),
"max context".into(),
]];
for session in report.context_efficiency.sessions.iter().take(10) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
context_rows.push(vec![
session.session_id.clone(),
format_uint(session.turn_count),
format_uint(session.context_tokens),
format_context_ratio(session.context_tokens_per_output_token, session.unbounded),
format_uint(session.context_size.p50),
format_uint(session.context_size.p95),
format_uint(session.context_size.max),
]);
}
lines.push(render_table(&context_rows));
lines.push(String::new());
}
lines.push(format!(
"total cost: {}",
format_usd(report.total_cost.total)
Expand Down Expand Up @@ -599,6 +630,34 @@ pub(super) fn emit_human(
}
}

fn format_context_efficiency_line(efficiency: &relayburn_sdk::ContextEfficiencySummary) -> String {
format!(
"context efficiency: {} ({} context / {} output; {} zero-output turn{})",
format_context_ratio(
efficiency.context_tokens_per_output_token,
efficiency.unbounded,
),
format_uint(efficiency.context_tokens),
format_uint(efficiency.output_tokens),
format_uint(efficiency.zero_output_turns_with_context),
if efficiency.zero_output_turns_with_context == 1 {
""
} else {
"s"
},
)
}

fn format_context_ratio(ratio: Option<f64>, unbounded: bool) -> String {
if unbounded {
"unbounded".to_string()
} else {
ratio
.map(|value| format!("{value:.1}:1"))
.unwrap_or_else(|| "—".to_string())
}
}

pub(super) fn render_quality(q: &QualityResult) -> String {
if q.outcomes.is_empty() {
return "quality: (no sessions)".to_string();
Expand Down
1 change: 1 addition & 0 deletions crates/relayburn-cli/src/commands/summary/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub(super) fn grouped_json_value(
}),
);
payload.insert("turns".into(), json!(report.turn_count));
payload.insert("contextEfficiency".into(), json!(report.context_efficiency));
payload.insert(
"totalCost".into(),
cost_breakdown_to_json(&report.total_cost),
Expand Down
15 changes: 15 additions & 0 deletions crates/relayburn-cli/src/commands/summary/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,7 @@ mod tests {
tag_key: None,
tag_values: Vec::new(),
turn_count: 0,
context_efficiency: relayburn_sdk::ContextEfficiencySummary::default(),
rows: Vec::new(),
total_cost: CostBreakdown {
model: String::new().into(),
Expand All @@ -487,6 +488,19 @@ mod tests {
let value = grouped_json_value(&report, &relayburn_sdk::IngestReport::empty());

assert_eq!(value["quality"], json!({"outcomes": [], "oneShot": []}));
assert_eq!(
value["contextEfficiency"],
json!({
"contextTokens": 0,
"outputTokens": 0,
"contextTokensPerOutputToken": null,
"unbounded": false,
"zeroOutputTurnsWithContext": 0,
"totalSessions": 0,
"eligibleSessions": 0,
"sessions": [],
})
);
}

#[test]
Expand Down Expand Up @@ -519,6 +533,7 @@ mod tests {
tag_key: None,
tag_values: Vec::new(),
turn_count: 0,
context_efficiency: relayburn_sdk::ContextEfficiencySummary::default(),
rows: Vec::new(),
total_cost: CostBreakdown {
model: String::new().into(),
Expand Down
76 changes: 76 additions & 0 deletions crates/relayburn-sdk-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,71 @@ pub struct ReplacementSavingsSummary {
pub by_tool: Vec<ReplacementSavingsToolRow>,
}

#[napi(object)]
pub struct ContextSizeDistribution {
pub p50: BigInt,
pub p95: BigInt,
pub max: BigInt,
}

#[napi(object)]
pub struct SessionContextEfficiency {
pub session_id: String,
pub turn_count: BigInt,
pub context_tokens: BigInt,
pub output_tokens: BigInt,
pub context_tokens_per_output_token: Option<f64>,
pub unbounded: bool,
pub zero_output_turns_with_context: BigInt,
pub context_size: ContextSizeDistribution,
}

#[napi(object)]
pub struct ContextEfficiencySummary {
pub context_tokens: BigInt,
pub output_tokens: BigInt,
pub context_tokens_per_output_token: Option<f64>,
pub unbounded: bool,
pub zero_output_turns_with_context: BigInt,
pub total_sessions: BigInt,
pub eligible_sessions: BigInt,
pub sessions: Vec<SessionContextEfficiency>,
}

impl From<sdk::ContextEfficiencySummary> for ContextEfficiencySummary {
fn from(value: sdk::ContextEfficiencySummary) -> Self {
Self {
context_tokens: u64_to_bigint(value.context_tokens),
output_tokens: u64_to_bigint(value.output_tokens),
context_tokens_per_output_token: value.context_tokens_per_output_token,
unbounded: value.unbounded,
zero_output_turns_with_context: u64_to_bigint(value.zero_output_turns_with_context),
total_sessions: u64_to_bigint(value.total_sessions),
eligible_sessions: u64_to_bigint(value.eligible_sessions),
sessions: value
.sessions
.into_iter()
.map(|session| SessionContextEfficiency {
session_id: session.session_id,
turn_count: u64_to_bigint(session.turn_count),
context_tokens: u64_to_bigint(session.context_tokens),
output_tokens: u64_to_bigint(session.output_tokens),
context_tokens_per_output_token: session.context_tokens_per_output_token,
unbounded: session.unbounded,
zero_output_turns_with_context: u64_to_bigint(
session.zero_output_turns_with_context,
),
context_size: ContextSizeDistribution {
p50: u64_to_bigint(session.context_size.p50),
p95: u64_to_bigint(session.context_size.p95),
max: u64_to_bigint(session.context_size.max),
},
})
.collect(),
}
}
}

impl From<sdk::ReplacementSavingsSummary> for ReplacementSavingsSummary {
fn from(s: sdk::ReplacementSavingsSummary) -> Self {
ReplacementSavingsSummary {
Expand All @@ -714,6 +779,7 @@ pub struct Summary {
pub total_tokens: BigInt,
pub total_cost: f64,
pub turn_count: BigInt,
pub context_efficiency: ContextEfficiencySummary,
pub by_tool: Vec<SummaryToolRow>,
pub by_model: Vec<SummaryModelRow>,
pub by_tag: Option<Vec<SummaryTagRow>>,
Expand All @@ -726,6 +792,7 @@ impl From<sdk::Summary> for Summary {
total_tokens: u64_to_bigint(s.total_tokens),
total_cost: s.total_cost,
turn_count: u64_to_bigint(s.turn_count),
context_efficiency: s.context_efficiency.into(),
by_tool: s
.by_tool
.into_iter()
Expand Down Expand Up @@ -1024,6 +1091,8 @@ pub struct HotspotsOptions {
pub patterns: Option<Vec<String>>,
pub workflow: Option<String>,
pub provider: Option<Vec<String>>,
pub context_output_ratio_threshold: Option<f64>,
pub context_output_min_tokens: Option<BigInt>,
Comment thread
willwashburn marked this conversation as resolved.
pub ledger_home: Option<String>,
}

Expand All @@ -1043,6 +1112,8 @@ pub fn hotspots(opts: Option<HotspotsOptions>) -> Result<BigIntPromoting, BurnEr
patterns: None,
workflow: None,
provider: None,
context_output_ratio_threshold: None,
context_output_min_tokens: None,
ledger_home: None,
});
let raw = sdk::HotspotsOptions {
Expand All @@ -1053,6 +1124,11 @@ pub fn hotspots(opts: Option<HotspotsOptions>) -> Result<BigIntPromoting, BurnEr
patterns: opts.patterns,
workflow: opts.workflow,
provider: opts.provider,
context_output_ratio_threshold: opts.context_output_ratio_threshold,
context_output_min_tokens: opts
.context_output_min_tokens
.map(bigint_to_u64)
.transpose()?,
ledger_home: maybe_path(opts.ledger_home),
};
let result = sdk::hotspots(raw).map_err(sdk_err)?;
Expand Down
5 changes: 4 additions & 1 deletion crates/relayburn-sdk/src/analyze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,14 @@ pub(crate) use context_delta::deltas_for_session;
pub use context_delta::{
ContextDelta, ContextDeltaOpts, InterveningStep, OwnerFilter, OwnerRail, ReminderSource,
};
pub(crate) use cost::reasoning_mode_for_source;
pub(crate) use cost::sum_costs;
pub use cost::{cost_for_turn, tally_unpriced, CostBreakdown};
pub(crate) use fidelity::has_minimum_fidelity;
pub use fidelity::{summarize_fidelity, summarize_fidelity_from_iter, FidelitySummary};
pub(crate) use findings::findings_from_patterns;
pub(crate) use findings::{
context_output_ratio_finding, findings_from_patterns, ContextOutputRatioFindingInput,
};
pub use findings::{sort_findings, WasteFinding, WasteSeverity};
pub use flow_graph::{
flow_graph_from_trees, FlowEdge, FlowEdgeKind, FlowGraph, FlowNode, FlowNodeKind, FlowOpts,
Expand Down
2 changes: 1 addition & 1 deletion crates/relayburn-sdk/src/analyze/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ fn reasoning_cost(reasoning_tokens: u64, rate: &ModelCost, mode: ReasoningMode)
///
/// - Codex: `output_tokens` already includes reasoning; never bill it on top.
/// - Everyone else: defer to the model.
fn reasoning_mode_for_source(source: SourceKind) -> Option<ReasoningMode> {
pub(crate) fn reasoning_mode_for_source(source: SourceKind) -> Option<ReasoningMode> {
match source {
SourceKind::Codex => Some(ReasoningMode::IncludedInOutput),
_ => None,
Expand Down
37 changes: 37 additions & 0 deletions crates/relayburn-sdk/src/analyze/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,43 @@ pub(crate) fn hotspots_action(session_id: &str) -> WasteAction {
}
}

/// Build the ratio-driven finding used by the public hotspots verb. Severity
/// and inclusion are deliberately independent of dollar cost.
pub(crate) struct ContextOutputRatioFindingInput<'a> {
pub session_id: &'a str,
pub high: bool,
pub ratio_label: &'a str,
pub context_tokens: u64,
pub output_tokens: u64,
pub threshold: f64,
pub min_context_tokens: u64,
}

pub(crate) fn context_output_ratio_finding(
input: ContextOutputRatioFindingInput<'_>,
) -> WasteFinding {
WasteFinding {
kind: "context-output-ratio".to_string(),
severity: if input.high {
WasteSeverity::High
} else {
WasteSeverity::Warn
},
session_id: input.session_id.to_string(),
title: format!("{} context-to-output ratio", input.ratio_label),
detail: format!(
"{} context tokens (input + cache reads + cache creation) / {} generated output tokens (including reasoning); flat inspection threshold {:.1}:1 with {} minimum context tokens (not length-normalized)",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
input.context_tokens,
input.output_tokens,
input.threshold,
input.min_context_tokens,
),
estimated_savings: EstimatedSavings::default(),
actions: vec![hotspots_action(input.session_id)],
event_source: None,
}
}

impl WasteFinding {
/// Build a cost-driven, session-scoped finding: severity derived from
/// `cost` via [`severity_from_usd`], a `usd_per_session` saving of `cost`,
Expand Down
Loading
Loading