77//! TS source of truth: `packages/cli/src/commands/compare.ts`. The wire
88//! shape (cells ordering, rounding rules, fidelity-summary key order)
99//! mirrors that file byte-for-byte against the cli-golden snapshot.
10+ //!
11+ //! ## Pre-query ingest decision
12+ //!
13+ //! Unlike `burn summary` and `burn hotspots`, this command intentionally does
14+ //! NOT run a pre-query `ingest_all` sweep. Two reasons:
15+ //!
16+ //! 1. The JSON envelope above (`analyzedTurns`, `models`, `categories`,
17+ //! `cells`, `fidelity`) is byte-equivalent with the TS golden snapshot;
18+ //! bolting on an `ingest` block to mirror summary would break that.
19+ //! 2. Compare is a presenter verb — answering "given my ledger, which model
20+ //! won?". Callers who want the freshest answer can chain
21+ //! `burn ingest && burn compare`; the steady-state setup is to run
22+ //! `burn ingest --watch` once per host so every read verb sees current data.
23+ //!
24+ //! Filter wiring: `--project` / `--session` / `--since` lower into the
25+ //! `Query` struct directly; `--workflow` / `--agent` fold through
26+ //! `Query.enrichment` (`workflowId` / `agentId` keys, both required when
27+ //! both flags are passed); `--provider` is a post-query CSV allow-list
28+ //! resolved via `provider_for`, matching `summary --by-provider`'s
29+ //! synthetic-rule-aware classification.
1030
11- use std:: collections:: BTreeSet ;
31+ use std:: collections:: { BTreeMap , BTreeSet } ;
1232
1333use anyhow:: { anyhow, Result } ;
1434use relayburn_sdk:: {
15- build_compare_table, has_minimum_fidelity, load_pricing, summarize_fidelity,
35+ build_compare_table, has_minimum_fidelity, load_pricing, provider_for , summarize_fidelity,
1636 AnalyzeCompareOptions as CompareOptions , CompareCell , CompareTable , EnrichedTurn ,
17- FidelityClass , FidelitySummary , Ledger , LedgerOpenOptions , Query , UsageGranularity ,
18- DEFAULT_MIN_SAMPLE ,
37+ FidelityClass , FidelitySummary , Ledger , LedgerOpenOptions , ProviderFilter , Query ,
38+ UsageGranularity , DEFAULT_MIN_SAMPLE ,
1939} ;
2040use serde_json:: { json, Value } ;
2141
@@ -111,17 +131,10 @@ fn run_inner(globals: &GlobalArgs, args: CompareArgs) -> Result<i32> {
111131 return Err ( anyhow ! ( "--json and --csv are mutually exclusive; pick one." ) ) ;
112132 }
113133
114- // 4. Provider filter. Surfaced as an explicit "not yet wired" error
115- // rather than a silent no-op — the SDK's provider filter is private
116- // to the analyze module today, and exposing it through a typed
117- // top-level surface is part of the broader provider-classifier
118- // follow-up. The cli-golden corpus exercises compare without a
119- // provider filter, so this is unblocked for parity.
120- if args. provider . is_some ( ) {
121- return Err ( anyhow ! (
122- "burn compare: --provider filter is not yet wired through the Rust SDK (#246 follow-up)"
123- ) ) ;
124- }
134+ // 4. Provider filter. Lower-cased CSV; turns whose effective provider
135+ // (per `provider_for`) isn't in the set get dropped after the ledger
136+ // query but before the fidelity gate, matching the TS pipeline order.
137+ let provider_filter = parse_provider_filter ( args. provider . as_deref ( ) ) ?;
125138
126139 // 5. min-sample.
127140 let min_sample = args. min_sample . unwrap_or ( DEFAULT_MIN_SAMPLE ) ;
@@ -144,14 +157,19 @@ fn run_inner(globals: &GlobalArgs, args: CompareArgs) -> Result<i32> {
144157 if let Some ( s) = args. session . as_deref ( ) {
145158 q. session_id = Some ( s. to_string ( ) ) ;
146159 }
147- // `workflow` / `agent` flow through the stamp-based enrichment filter
148- // which the Rust ledger query layer doesn't yet expose. Surface the
149- // gap explicitly rather than silently dropping the flag — when the
150- // ledger gains enrichment-filter support, this branch comes out.
151- if args. workflow . is_some ( ) || args. agent . is_some ( ) {
152- return Err ( anyhow ! (
153- "burn compare: --workflow / --agent filters are not yet wired through the Rust ledger query (#246 follow-up)"
154- ) ) ;
160+ // `workflow` / `agent` fold through stamp enrichment. Both keys live in
161+ // the same `Enrichment` map (`workflowId`, `agentId`), and the ledger's
162+ // `Query.enrichment` predicate requires every key/value pair to match —
163+ // so passing both narrows to the intersection.
164+ let mut enrichment = BTreeMap :: new ( ) ;
165+ if let Some ( workflow) = args. workflow . as_deref ( ) {
166+ enrichment. insert ( "workflowId" . to_string ( ) , workflow. to_string ( ) ) ;
167+ }
168+ if let Some ( agent) = args. agent . as_deref ( ) {
169+ enrichment. insert ( "agentId" . to_string ( ) , agent. to_string ( ) ) ;
170+ }
171+ if !enrichment. is_empty ( ) {
172+ q. enrichment = Some ( enrichment) ;
155173 }
156174
157175 // 8. Open ledger and walk turns.
@@ -165,9 +183,20 @@ fn run_inner(globals: &GlobalArgs, args: CompareArgs) -> Result<i32> {
165183 progress. set_task ( "loading turns" ) ;
166184 let queried_turns: Vec < EnrichedTurn > = handle. raw ( ) . query_turns ( & q) ?;
167185
168- // 9. Provider filter is rejected up-front (see step 4). Pipeline
169- // treats every queried turn as eligible.
170- let filtered_by_provider: Vec < EnrichedTurn > = queried_turns;
186+ // 9. Drop turns whose effective provider isn't in the allow-list. The
187+ // provider is resolved via `provider_for` (synthetic-rule first,
188+ // then `provider/model` prefix, then collector-implied) so the CLI
189+ // matches `summary --by-provider` semantics 1:1.
190+ let filtered_by_provider: Vec < EnrichedTurn > = match provider_filter. as_ref ( ) {
191+ Some ( filter) => queried_turns
192+ . into_iter ( )
193+ . filter ( |et| {
194+ let provider = provider_for ( & et. turn ) . provider ;
195+ filter. contains ( & provider. to_ascii_lowercase ( ) )
196+ } )
197+ . collect ( ) ,
198+ None => queried_turns,
199+ } ;
171200
172201 // 10. Fidelity summary is computed BEFORE the fidelity gate so the
173202 // `summary` block in the JSON envelope reflects the queried slice.
@@ -223,6 +252,26 @@ fn run_inner(globals: &GlobalArgs, args: CompareArgs) -> Result<i32> {
223252// helpers
224253// ---------------------------------------------------------------------------
225254
255+ /// Parse `--provider` CSV → lower-cased `ProviderFilter`. Mirrors the
256+ /// `summary --provider` parser: trim entries, drop empties, lower-case for
257+ /// case-insensitive matches, and reject an all-empty list with the same
258+ /// error shape (`burn: --provider requires a value`). Returns `Ok(None)`
259+ /// when the flag wasn't passed.
260+ fn parse_provider_filter ( raw : Option < & str > ) -> Result < Option < ProviderFilter > > {
261+ let Some ( raw) = raw else {
262+ return Ok ( None ) ;
263+ } ;
264+ let providers: ProviderFilter = raw
265+ . split ( ',' )
266+ . map ( |s| s. trim ( ) . to_ascii_lowercase ( ) )
267+ . filter ( |s| !s. is_empty ( ) )
268+ . collect ( ) ;
269+ if providers. is_empty ( ) {
270+ return Err ( anyhow ! ( "--provider requires a value" ) ) ;
271+ }
272+ Ok ( Some ( providers) )
273+ }
274+
226275fn parse_fidelity ( s : & str ) -> Result < FidelityClass > {
227276 match s {
228277 "full" => Ok ( FidelityClass :: Full ) ,
@@ -1114,6 +1163,27 @@ mod tests {
11141163 assert_eq ! ( v. to_string( ) , "0.01125" ) ;
11151164 }
11161165
1166+ #[ test]
1167+ fn parse_provider_filter_trims_lowercases_and_dedupes ( ) {
1168+ let got = parse_provider_filter ( Some ( " Anthropic,OPENAI ,, anthropic" ) )
1169+ . unwrap ( )
1170+ . unwrap ( ) ;
1171+ assert ! ( got. contains( "anthropic" ) ) ;
1172+ assert ! ( got. contains( "openai" ) ) ;
1173+ assert_eq ! ( got. len( ) , 2 , "duplicates should collapse: got {got:?}" ) ;
1174+ }
1175+
1176+ #[ test]
1177+ fn parse_provider_filter_returns_none_when_flag_absent ( ) {
1178+ assert ! ( parse_provider_filter( None ) . unwrap( ) . is_none( ) ) ;
1179+ }
1180+
1181+ #[ test]
1182+ fn parse_provider_filter_rejects_all_empty_input ( ) {
1183+ let err = parse_provider_filter ( Some ( " , ,, " ) ) . unwrap_err ( ) ;
1184+ assert ! ( format!( "{err}" ) . contains( "--provider requires a value" ) ) ;
1185+ }
1186+
11171187 #[ test]
11181188 fn parse_fidelity_known_classes ( ) {
11191189 assert ! ( matches!( parse_fidelity( "full" ) . unwrap( ) , FidelityClass :: Full ) ) ;
0 commit comments