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}