Skip to main content

ralph_workflow/reducer/state/
metrics.rs

1// Run-level execution metrics for the pipeline.
2//
3// This is the single source of truth for all iteration/attempt/retry/fallback statistics.
4//
5// # Where Metrics Are Updated
6//
7// Metrics are updated **only** in reducer code paths (`state_reduction/*.rs`):
8//
9// - `development.rs`: dev_iterations_started, dev_iterations_completed,
10//                     dev_attempts_total, dev_continuation_attempt,
11//                     analysis_attempts_*, xsd_retry_development
12// - `review.rs`: review_passes_started, review_passes_completed, review_runs_total,
13//                fix_runs_total, fix_continuations_total, fix_continuation_attempt,
14//                current_review_pass, xsd_retry_review, xsd_retry_fix
15// - `commit.rs`: commits_created_total, xsd_retry_commit
16// - `planning.rs`: xsd_retry_planning
17// - `agent.rs`: same_agent_retry_attempts_total, agent_fallbacks_total,
18//               model_fallbacks_total, retry_cycles_started_total
19//
20// # Event-to-Metric Mapping
21//
22// | Metric                              | Incremented On Event                                      | Notes                                    |
23// |-------------------------------------|-----------------------------------------------------------|------------------------------------------|
24// | dev_iterations_started              | DevelopmentEvent::IterationStarted                        | Not incremented on continuations         |
25// | dev_iterations_completed            | DevelopmentEvent::IterationCompleted { output_valid: true } | Advanced to commit phase                |
26// |                                     | DevelopmentEvent::ContinuationSucceeded                   | Continuation advanced to commit phase    |
27// | dev_attempts_total                  | DevelopmentEvent::AgentInvoked                            | Includes initial + continuations         |
28// | dev_continuation_attempt            | DevelopmentEvent::ContinuationTriggered                   | Reset on IterationStarted                |
29// | analysis_attempts_total             | DevelopmentEvent::AnalysisAgentInvoked                    | Total across all iterations              |
30// | analysis_attempts_in_current_iteration | DevelopmentEvent::AnalysisAgentInvoked                 | Reset on IterationStarted                |
31// | review_passes_started               | ReviewEvent::PassStarted                                  | Increments when pass != previous         |
32// | review_passes_completed             | ReviewEvent::Completed { issues_found: false }            | Clean pass                               |
33// |                                     | ReviewEvent::PassCompletedClean                           | Alternative event for clean pass         |
34// |                                     | ReviewEvent::FixAttemptCompleted                          | Fix completed, pass advances             |
35// | review_runs_total                   | ReviewEvent::AgentInvoked                                 | Total reviewer invocations               |
36// | fix_runs_total                      | ReviewEvent::FixAgentInvoked                              | Total fix invocations                    |
37// | fix_continuations_total             | ReviewEvent::FixContinuationTriggered                     | Fix continuation attempts                |
38// | fix_continuation_attempt            | ReviewEvent::FixContinuationTriggered                     | Reset on PassStarted                     |
39// | current_review_pass                 | ReviewEvent::PassStarted                                  | Tracks current pass number               |
40// | xsd_retry_*                         | *Event::OutputValidationFailed (when will_retry == true)  | Only when retrying, not when exhausted   |
41// | same_agent_retry_attempts_total     | AgentEvent::TimedOut / InternalError (when will_retry)    | Only when retrying same agent            |
42// | timeout_no_output_agent_switches_total | AgentEvent::TimedOut { output_kind: NoResult }         | NoResult timeout triggered immediate switch |
43// | agent_fallbacks_total               | AgentEvent::FallbackTriggered                             | Agent switched in chain                  |
44// | model_fallbacks_total               | AgentEvent::ModelFallbackTriggered                        | Model switched for agent                 |
45// | retry_cycles_started_total          | AgentEvent::RetryCycleStarted                             | Chain exhausted, restarting              |
46// | commits_created_total               | CommitEvent::Created                                      | Actual git commit created                |
47// | connectivity_interruptions_total    | AgentEvent::ConnectivityCheckFailed (offline entry only)   | Only on false->true is_offline transition|
48//
49// # How to Add New Metrics
50//
51// 1. Add field to `RunMetrics` struct with `#[serde(default)]`
52// 2. Update `RunMetrics::new()` if config-derived display field
53// 3. Update appropriate reducer in `state_reduction/` to increment on event
54// 4. Add unit test in `state_reduction/tests/metrics.rs`
55// 5. Update `finalize_pipeline()` if displayed in final summary
56// 6. Add checkpoint compatibility test
57//
58// # Checkpoint Compatibility
59//
60// All fields have `#[serde(default)]` to ensure old checkpoints can be loaded
61// with new metrics fields defaulting to 0.
62
63/// Run-level execution metrics tracked by the reducer.
64///
65/// This struct provides a complete picture of pipeline execution progress,
66/// including iteration counts, attempt counts, retry counts, and fallback events.
67/// All fields are monotonic counters that only increment during a run.
68///
69/// # Checkpoint Compatibility
70///
71/// All fields have `#[serde(default)]` to ensure backward compatibility when
72/// loading checkpoints created before metrics were added or when new fields
73/// are introduced in future versions.
74///
75/// # Single Source of Truth
76///
77/// The reducer is the **only** code that mutates these metrics. They are
78/// updated deterministically based on events, ensuring:
79/// - Metrics survive checkpoint/resume
80/// - No drift between runtime state and actual progress
81/// - Final summary is always consistent with reducer state
82#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
83pub struct RunMetrics {
84    // Development iteration tracking
85    /// Number of development iterations started.
86    /// Incremented on `DevelopmentEvent::IterationStarted` (not on continuations).
87    #[serde(default)]
88    pub dev_iterations_started: u32,
89    /// Number of development iterations completed (advanced to commit phase).
90    /// A dev iteration is "completed" when the reducer transitions to `PipelinePhase::CommitMessage`
91    /// after dev output is valid, regardless of whether an actual git commit is created.
92    /// Incremented on `DevelopmentEvent::IterationCompleted { output_valid: true }` and
93    /// `DevelopmentEvent::ContinuationSucceeded`.
94    #[serde(default)]
95    pub dev_iterations_completed: u32,
96    /// Total number of developer agent invocations (includes continuations).
97    #[serde(default)]
98    pub dev_attempts_total: u32,
99    /// Current continuation attempt within the current development iteration (0 = initial).
100    /// Reset when starting a new iteration.
101    #[serde(default)]
102    pub dev_continuation_attempt: u32,
103
104    // Analysis tracking
105    /// Total number of analysis agent invocations across all iterations.
106    #[serde(default)]
107    pub analysis_attempts_total: u32,
108    /// Analysis attempts in the current development iteration (reset per iteration).
109    #[serde(default)]
110    pub analysis_attempts_in_current_iteration: u32,
111
112    // Review tracking
113    /// Number of review passes started.
114    /// Incremented on `ReviewEvent::PassStarted` when `pass != previous_pass`.
115    #[serde(default)]
116    pub review_passes_started: u32,
117    /// Number of review passes completed (advanced past without issues or after fixes).
118    /// A review pass is "completed" when it advances to the next pass or to commit phase,
119    /// either because no issues were found or because fixes were successfully applied.
120    /// Incremented on `ReviewEvent::Completed { issues_found: false }`,
121    /// `ReviewEvent::PassCompletedClean`, and `ReviewEvent::FixAttemptCompleted`.
122    #[serde(default)]
123    pub review_passes_completed: u32,
124    /// Total number of reviewer agent invocations.
125    #[serde(default)]
126    pub review_runs_total: u32,
127    /// Total number of fix agent invocations.
128    #[serde(default)]
129    pub fix_runs_total: u32,
130    /// Total number of fix analysis agent invocations.
131    ///
132    /// This tracks the independent verification step after every fix agent invocation.
133    #[serde(default)]
134    pub fix_analysis_runs_total: u32,
135    /// Total number of fix continuation attempts.
136    #[serde(default)]
137    pub fix_continuations_total: u32,
138    /// Current fix continuation attempt within the current review pass (0 = initial).
139    ///
140    /// Reset when starting a new review pass.
141    /// Note: fix-attempt boundaries do not reset this counter; it is scoped to the review pass.
142    #[serde(default)]
143    pub fix_continuation_attempt: u32,
144    /// Current review pass number (for X/Y display).
145    #[serde(default)]
146    pub current_review_pass: u32,
147
148    // XSD retry tracking
149    /// Total XSD retry attempts across all phases.
150    #[serde(default)]
151    pub xsd_retry_attempts_total: u32,
152    /// XSD retry attempts in planning phase.
153    #[serde(default)]
154    pub xsd_retry_planning: u32,
155    /// XSD retry attempts in development/analysis phase.
156    #[serde(default)]
157    pub xsd_retry_development: u32,
158    /// XSD retry attempts in review phase.
159    #[serde(default)]
160    pub xsd_retry_review: u32,
161    /// XSD retry attempts in fix phase.
162    #[serde(default)]
163    pub xsd_retry_fix: u32,
164    /// XSD retry attempts in commit phase.
165    #[serde(default)]
166    pub xsd_retry_commit: u32,
167
168    // Same-agent retry tracking
169    /// Total same-agent retry attempts (for transient failures like timeout).
170    #[serde(default)]
171    pub same_agent_retry_attempts_total: u32,
172
173    /// Agent switches caused by no-result timeouts.
174    ///
175    /// Incremented only in the reducer's `TimedOut { output_kind: NoResult }` arm.
176    /// Distinct from `agent_fallbacks_total` (which tracks `FallbackTriggered` events).
177    /// Distinct from `same_agent_retry_attempts_total` (`NoResult` does not retry same agent).
178    #[serde(default)]
179    pub timeout_no_output_agent_switches_total: u32,
180
181    // Agent/model fallback tracking
182    /// Total agent fallback events.
183    #[serde(default)]
184    pub agent_fallbacks_total: u32,
185    /// Total model fallback events.
186    #[serde(default)]
187    pub model_fallbacks_total: u32,
188    /// Total retry cycles started (agent chain exhaustion + restart).
189    #[serde(default)]
190    pub retry_cycles_started_total: u32,
191
192    // Commit tracking
193    /// Total commits created during the run.
194    #[serde(default)]
195    pub commits_created_total: u32,
196
197    // Connectivity interruption tracking
198    /// Number of times the pipeline entered offline mode (connectivity interruption count).
199    ///
200    /// Incremented exactly once per offline entry (false → true transition of is_offline).
201    /// Does NOT increment for subsequent poll failures within the same offline window.
202    /// Use this to distinguish connectivity interruptions from agent failures in reporting.
203    #[serde(default)]
204    pub connectivity_interruptions_total: u32,
205
206    // Config-derived display fields (set once at init, not serialized from events)
207    /// Maximum development iterations (from config, for X/Y display).
208    #[serde(default)]
209    pub max_dev_iterations: u32,
210    /// Maximum review passes (from config, for X/Y display).
211    #[serde(default)]
212    pub max_review_passes: u32,
213    /// Maximum XSD retry count (from config, for X/max display).
214    #[serde(default)]
215    pub max_xsd_retry_count: u32,
216    /// Maximum development continuation count (from config, for X/max display).
217    #[serde(default)]
218    pub max_dev_continuation_count: u32,
219    /// Maximum fix continuation count (from config, for X/max display).
220    #[serde(default)]
221    pub max_fix_continuation_count: u32,
222    /// Maximum same-agent retry count (from config, for X/max display).
223    #[serde(default)]
224    pub max_same_agent_retry_count: u32,
225}
226
227impl RunMetrics {
228    /// Create metrics with config-derived display fields.
229    #[must_use]
230    pub fn new(
231        max_dev_iterations: u32,
232        max_review_passes: u32,
233        continuation: &ContinuationState,
234    ) -> Self {
235        Self {
236            max_dev_iterations,
237            max_review_passes,
238            max_xsd_retry_count: continuation.max_xsd_retry_count,
239            max_dev_continuation_count: continuation.max_continue_count,
240            max_fix_continuation_count: continuation.max_fix_continue_count,
241            max_same_agent_retry_count: continuation.max_same_agent_retry_count,
242            ..Self::default()
243        }
244    }
245
246    // =========================================================================
247    // Builder methods for updating metrics without full struct clones
248    // =========================================================================
249    // These methods consume self and return a new instance with the updated field.
250    // This eliminates the need to clone all 40+ fields when updating a single metric.
251
252    // Development metrics
253    #[must_use]
254    pub const fn increment_dev_iterations_started(self) -> Self {
255        Self { dev_iterations_started: self.dev_iterations_started.saturating_add(1), ..self }
256    }
257
258    #[must_use]
259    pub const fn increment_dev_iterations_completed(self) -> Self {
260        Self { dev_iterations_completed: self.dev_iterations_completed.saturating_add(1), ..self }
261    }
262
263    #[must_use]
264    pub const fn increment_dev_attempts_total(self) -> Self {
265        Self { dev_attempts_total: self.dev_attempts_total.saturating_add(1), ..self }
266    }
267
268    #[must_use]
269    pub const fn increment_dev_continuation_attempt(self) -> Self {
270        Self { dev_continuation_attempt: self.dev_continuation_attempt.saturating_add(1), ..self }
271    }
272
273    #[must_use]
274    pub const fn reset_dev_continuation_attempt(self) -> Self {
275        Self { dev_continuation_attempt: 0, ..self }
276    }
277
278    // Analysis metrics
279    #[must_use]
280    pub const fn increment_analysis_attempts_total(self) -> Self {
281        Self { analysis_attempts_total: self.analysis_attempts_total.saturating_add(1), ..self }
282    }
283
284    #[must_use]
285    pub const fn increment_analysis_attempts_in_current_iteration(self) -> Self {
286        Self { analysis_attempts_in_current_iteration: self.analysis_attempts_in_current_iteration.saturating_add(1), ..self }
287    }
288
289    #[must_use]
290    pub const fn reset_analysis_attempts_in_current_iteration(self) -> Self {
291        Self { analysis_attempts_in_current_iteration: 0, ..self }
292    }
293
294    // Review metrics
295    #[must_use]
296    pub const fn increment_review_passes_started(self) -> Self {
297        Self { review_passes_started: self.review_passes_started.saturating_add(1), ..self }
298    }
299
300    #[must_use]
301    pub const fn increment_review_passes_completed(self) -> Self {
302        Self { review_passes_completed: self.review_passes_completed.saturating_add(1), ..self }
303    }
304
305    #[must_use]
306    pub const fn increment_review_runs_total(self) -> Self {
307        Self { review_runs_total: self.review_runs_total.saturating_add(1), ..self }
308    }
309
310    #[must_use]
311    pub const fn increment_fix_runs_total(self) -> Self {
312        Self { fix_runs_total: self.fix_runs_total.saturating_add(1), ..self }
313    }
314
315    #[must_use]
316    pub const fn increment_fix_analysis_runs_total(self) -> Self {
317        Self { fix_analysis_runs_total: self.fix_analysis_runs_total.saturating_add(1), ..self }
318    }
319
320    #[must_use]
321    pub const fn increment_fix_continuations_total(self) -> Self {
322        Self { fix_continuations_total: self.fix_continuations_total.saturating_add(1), ..self }
323    }
324
325    #[must_use]
326    pub const fn increment_fix_continuation_attempt(self) -> Self {
327        Self { fix_continuation_attempt: self.fix_continuation_attempt.saturating_add(1), ..self }
328    }
329
330    #[must_use]
331    pub const fn reset_fix_continuation_attempt(self) -> Self {
332        Self { fix_continuation_attempt: 0, ..self }
333    }
334
335    #[must_use]
336    pub const fn set_current_review_pass(self, pass: u32) -> Self {
337        Self { current_review_pass: pass, ..self }
338    }
339
340    // XSD retry metrics
341    #[must_use]
342    pub const fn increment_xsd_retry_attempts_total(self) -> Self {
343        Self { xsd_retry_attempts_total: self.xsd_retry_attempts_total.saturating_add(1), ..self }
344    }
345
346    #[must_use]
347    pub const fn increment_xsd_retry_planning(self) -> Self {
348        Self { xsd_retry_planning: self.xsd_retry_planning.saturating_add(1), xsd_retry_attempts_total: self.xsd_retry_attempts_total.saturating_add(1), ..self }
349    }
350
351    #[must_use]
352    pub const fn increment_xsd_retry_development(self) -> Self {
353        Self { xsd_retry_development: self.xsd_retry_development.saturating_add(1), xsd_retry_attempts_total: self.xsd_retry_attempts_total.saturating_add(1), ..self }
354    }
355
356    #[must_use]
357    pub const fn increment_xsd_retry_review(self) -> Self {
358        Self { xsd_retry_review: self.xsd_retry_review.saturating_add(1), xsd_retry_attempts_total: self.xsd_retry_attempts_total.saturating_add(1), ..self }
359    }
360
361    #[must_use]
362    pub const fn increment_xsd_retry_fix(self) -> Self {
363        Self { xsd_retry_fix: self.xsd_retry_fix.saturating_add(1), xsd_retry_attempts_total: self.xsd_retry_attempts_total.saturating_add(1), ..self }
364    }
365
366    #[must_use]
367    pub const fn increment_xsd_retry_commit(self) -> Self {
368        Self { xsd_retry_commit: self.xsd_retry_commit.saturating_add(1), xsd_retry_attempts_total: self.xsd_retry_attempts_total.saturating_add(1), ..self }
369    }
370
371    // Same-agent retry metrics
372    #[must_use]
373    pub const fn increment_same_agent_retry_attempts_total(self) -> Self {
374        Self { same_agent_retry_attempts_total: self.same_agent_retry_attempts_total.saturating_add(1), ..self }
375    }
376
377    /// Increment `timeout_no_output_agent_switches_total` counter.
378    ///
379    /// Called only when a `TimedOut { output_kind: NoResult }` event triggers
380    /// an immediate agent switch.
381    #[must_use]
382    pub const fn increment_timeout_no_output_agent_switches_total(self) -> Self {
383        Self { timeout_no_output_agent_switches_total: self.timeout_no_output_agent_switches_total.saturating_add(1), ..self }
384    }
385
386    // Agent/model fallback metrics
387    #[must_use]
388    pub const fn increment_agent_fallbacks_total(self) -> Self {
389        Self { agent_fallbacks_total: self.agent_fallbacks_total.saturating_add(1), ..self }
390    }
391
392    #[must_use]
393    pub const fn increment_model_fallbacks_total(self) -> Self {
394        Self { model_fallbacks_total: self.model_fallbacks_total.saturating_add(1), ..self }
395    }
396
397    #[must_use]
398    pub const fn increment_retry_cycles_started_total(self) -> Self {
399        Self { retry_cycles_started_total: self.retry_cycles_started_total.saturating_add(1), ..self }
400    }
401
402    // Commit metrics
403    #[must_use]
404    pub const fn increment_commits_created_total(self) -> Self {
405        Self { commits_created_total: self.commits_created_total.saturating_add(1), ..self }
406    }
407
408}