Skip to main content

pounce_common/
timing.rs

1//! Per-task timing accumulator.
2//!
3//! Mirrors `Common/IpTimedTask.hpp` (`Common/IpDebug.{hpp,cpp}` is
4//! omitted — debug tracing is replaced by the journalist).
5
6use crate::types::Number;
7use crate::utils::{cpu_time, sys_time, wallclock_time};
8use std::cell::Cell;
9use std::rc::Rc;
10
11/// Which time budget a [`Deadline`] check found crossed.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DeadlineKind {
14    /// Wall-clock budget (`max_wall_time`) exceeded.
15    Wall,
16    /// CPU-time budget (`max_cpu_time`) exceeded.
17    Cpu,
18}
19
20/// A monotonic wall/CPU-clock deadline for a single solve (pounce#242).
21///
22/// Cheaply clonable (`Rc`-backed) so the outer loop, the KKT solver, the
23/// line search, and the *restoration inner IPM* can all check the same
24/// global budget — not just the outer-iteration convergence check. The
25/// motivating bug: `max_wall_time` was only tested between outer
26/// iterations (in `OptErrorConvCheck`), so a solve whose per-iteration
27/// cost is dominated by a single expensive step — a slow KKT
28/// factorization, or a restoration sub-solve that runs an entire nested
29/// IPM under one outer "iteration" — overshot the requested budget by up
30/// to a full iteration (~7x on the reported 1611-variable NLP). Checking
31/// this deadline at the granularity of the expensive inner steps bounds
32/// the overshoot to roughly one such step.
33///
34/// The elapsed time is measured from the instant the `Deadline` is
35/// constructed, using the same process clocks the timing subsystem uses.
36/// Unlike [`TimedTask::live_wallclock_time`] it does **not** depend on a
37/// `start()`/`end()` cycle, so it works inside the nested restoration
38/// solve — whose fresh [`TimingStatistics`] has an `overall_alg` timer
39/// that is never started, which is exactly why the inner loop used to run
40/// unbounded by wall time.
41#[derive(Debug, Clone)]
42pub struct Deadline {
43    inner: Rc<DeadlineInner>,
44}
45
46#[derive(Debug)]
47struct DeadlineInner {
48    wall_start: Number,
49    cpu_start: Number,
50    max_wall: Number,
51    max_cpu: Number,
52}
53
54impl Deadline {
55    /// Create a deadline that fires once `max_wall` wall seconds or
56    /// `max_cpu` CPU seconds have elapsed from *now*. The pounce defaults
57    /// for both budgets are `1e6`, i.e. effectively unbounded; a caller
58    /// that passes those gets a deadline that never trips in practice.
59    pub fn new(max_wall: Number, max_cpu: Number) -> Self {
60        Self {
61            inner: Rc::new(DeadlineInner {
62                wall_start: wallclock_time(),
63                cpu_start: cpu_time(),
64                max_wall,
65                max_cpu,
66            }),
67        }
68    }
69
70    /// Return `Some(kind)` if either budget has been crossed, else
71    /// `None`. CPU is tested before wall to match the branch order of
72    /// upstream `OptimalityErrorConvergenceCheck::CheckConvergence` (and
73    /// pounce's `OptErrorConvCheck`), so a solve that trips both in the
74    /// same check reports `MaximumCpuTimeExceeded` identically to the
75    /// coarse path.
76    pub fn exceeded(&self) -> Option<DeadlineKind> {
77        if cpu_time() - self.inner.cpu_start >= self.inner.max_cpu {
78            return Some(DeadlineKind::Cpu);
79        }
80        if wallclock_time() - self.inner.wall_start >= self.inner.max_wall {
81            return Some(DeadlineKind::Wall);
82        }
83        None
84    }
85
86    /// The wall-clock budget this deadline was built with.
87    pub fn max_wall(&self) -> Number {
88        self.inner.max_wall
89    }
90
91    /// The CPU-time budget this deadline was built with.
92    pub fn max_cpu(&self) -> Number {
93        self.inner.max_cpu
94    }
95
96    /// Wall-clock seconds remaining before the wall budget trips.
97    /// Negative once the budget is already crossed. Unlike
98    /// [`Self::exceeded`] this exposes *how much* budget is left, which
99    /// the KKT solver's predictive time guard (pounce#254) compares
100    /// against the observed cost of one factorization to decide whether
101    /// starting another would overshoot.
102    pub fn remaining_wall(&self) -> Number {
103        self.inner.max_wall - (wallclock_time() - self.inner.wall_start)
104    }
105
106    /// CPU-time counterpart of [`Self::remaining_wall`].
107    pub fn remaining_cpu(&self) -> Number {
108        self.inner.max_cpu - (cpu_time() - self.inner.cpu_start)
109    }
110}
111
112/// Equivalent to `Ipopt::TimedTask`. Use [`TimedTask::start`] /
113/// [`TimedTask::end`] around a section to accumulate cpu/system/wall
114/// time. [`TimedTask::end_if_started`] is the exception-safe variant.
115#[derive(Debug)]
116pub struct TimedTask {
117    enabled: Cell<bool>,
118    start_called: Cell<bool>,
119    end_called: Cell<bool>,
120    start_cpu: Cell<Number>,
121    start_sys: Cell<Number>,
122    start_wall: Cell<Number>,
123    total_cpu: Cell<Number>,
124    total_sys: Cell<Number>,
125    total_wall: Cell<Number>,
126}
127
128impl Default for TimedTask {
129    fn default() -> Self {
130        Self {
131            enabled: Cell::new(true),
132            start_called: Cell::new(false),
133            end_called: Cell::new(true),
134            start_cpu: Cell::new(0.0),
135            start_sys: Cell::new(0.0),
136            start_wall: Cell::new(0.0),
137            total_cpu: Cell::new(0.0),
138            total_sys: Cell::new(0.0),
139            total_wall: Cell::new(0.0),
140        }
141    }
142}
143
144impl TimedTask {
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    pub fn enable(&self) {
150        self.enabled.set(true);
151    }
152    pub fn disable(&self) {
153        self.enabled.set(false);
154    }
155    pub fn is_enabled(&self) -> bool {
156        self.enabled.get()
157    }
158    pub fn is_started(&self) -> bool {
159        self.start_called.get()
160    }
161
162    pub fn reset(&self) {
163        self.total_cpu.set(0.0);
164        self.total_sys.set(0.0);
165        self.total_wall.set(0.0);
166        self.start_called.set(false);
167        self.end_called.set(true);
168    }
169
170    pub fn start(&self) {
171        if !self.enabled.get() {
172            return;
173        }
174        self.end_called.set(false);
175        self.start_called.set(true);
176        self.start_cpu.set(cpu_time());
177        self.start_sys.set(sys_time());
178        self.start_wall.set(wallclock_time());
179    }
180
181    pub fn end(&self) {
182        if !self.enabled.get() {
183            return;
184        }
185        self.end_called.set(true);
186        self.start_called.set(false);
187        self.total_cpu
188            .set(self.total_cpu.get() + cpu_time() - self.start_cpu.get());
189        self.total_sys
190            .set(self.total_sys.get() + sys_time() - self.start_sys.get());
191        self.total_wall
192            .set(self.total_wall.get() + wallclock_time() - self.start_wall.get());
193    }
194
195    pub fn end_if_started(&self) {
196        if !self.enabled.get() {
197            return;
198        }
199        if self.start_called.get() {
200            self.end();
201        }
202    }
203
204    pub fn total_cpu_time(&self) -> Number {
205        self.total_cpu.get()
206    }
207    pub fn total_sys_time(&self) -> Number {
208        self.total_sys.get()
209    }
210    pub fn total_wallclock_time(&self) -> Number {
211        self.total_wall.get()
212    }
213
214    /// Running wallclock seconds since `start()` plus accumulated total
215    /// from prior start/end cycles. When the task is not currently
216    /// started this is the same as [`Self::total_wallclock_time`].
217    /// Used by `OptErrorConvCheck` to gate `max_wall_time` mid-solve
218    /// without forcing a `start()`/`end()` round-trip every iter.
219    pub fn live_wallclock_time(&self) -> Number {
220        if self.enabled.get() && self.start_called.get() {
221            self.total_wall.get() + wallclock_time() - self.start_wall.get()
222        } else {
223            self.total_wall.get()
224        }
225    }
226
227    /// Live counterpart of [`Self::total_cpu_time`]; see
228    /// [`Self::live_wallclock_time`] for the contract.
229    pub fn live_cpu_time(&self) -> Number {
230        if self.enabled.get() && self.start_called.get() {
231            self.total_cpu.get() + cpu_time() - self.start_cpu.get()
232        } else {
233            self.total_cpu.get()
234        }
235    }
236
237    /// RAII-style guard: start the timer immediately, end it when the
238    /// returned value is dropped (or when [`TimedGuard::stop`] is
239    /// called). Survives early returns / `?` in the caller scope.
240    pub fn guard(&self) -> TimedGuard<'_> {
241        self.start();
242        TimedGuard { task: Some(self) }
243    }
244}
245
246/// Drop-on-end guard returned by [`TimedTask::guard`]. Calls
247/// [`TimedTask::end_if_started`] in its destructor so a function with
248/// many exit paths can wrap a section with a single line.
249#[must_use = "the guard ends the timer when dropped; bind it to a variable"]
250pub struct TimedGuard<'a> {
251    task: Option<&'a TimedTask>,
252}
253
254impl<'a> TimedGuard<'a> {
255    /// End the timer immediately. Useful when you want to stop timing
256    /// before the natural scope exit (e.g. before a long-running
257    /// follow-up that should not be attributed to this section).
258    pub fn stop(mut self) {
259        if let Some(t) = self.task.take() {
260            t.end_if_started();
261        }
262    }
263}
264
265impl<'a> Drop for TimedGuard<'a> {
266    fn drop(&mut self) {
267        if let Some(t) = self.task.take() {
268            t.end_if_started();
269        }
270    }
271}
272
273/// Aggregate of per-subsystem [`TimedTask`] counters. Mirrors
274/// `Algorithm/IpTimingStatistics.{hpp,cpp}`. Owned by `IpoptApplication`
275/// and shared (via `Rc`) with the algorithm, NLP, and KKT solver so each
276/// subsystem can bump its own field. Reported at the end of a solve
277/// when `print_timing_statistics yes`.
278#[derive(Debug, Default)]
279pub struct TimingStatistics {
280    pub overall_alg: TimedTask,
281    pub print_problem_statistics: TimedTask,
282    pub initialize_iterates: TimedTask,
283    pub update_hessian: TimedTask,
284    pub output_iteration: TimedTask,
285    pub update_barrier_parameter: TimedTask,
286    pub compute_search_direction: TimedTask,
287    pub compute_acceptable_trial_point: TimedTask,
288    pub accept_trial_point: TimedTask,
289    pub check_convergence: TimedTask,
290
291    pub linear_system_factorization: TimedTask,
292    pub linear_system_back_solve: TimedTask,
293    pub linear_system_structure_converter: TimedTask,
294    pub linear_system_structure_converter_init: TimedTask,
295    pub quality_function_search: TimedTask,
296    pub total_callback_time: TimedTask,
297    pub total_function_evaluation_time: TimedTask,
298    pub eval_obj: TimedTask,
299    pub eval_grad_obj: TimedTask,
300    pub eval_constr: TimedTask,
301    pub eval_constr_jac: TimedTask,
302    pub eval_lag_hess: TimedTask,
303}
304
305impl TimingStatistics {
306    pub fn new() -> Self {
307        Self::default()
308    }
309
310    /// Format a per-subsystem timing report (wall-clock seconds, mirroring
311    /// upstream `IpoptApplication`'s end-of-run "Timing Statistics" block
312    /// but with sys/cpu columns omitted — pounce only tracks wall time).
313    /// Lines are indented to reflect the upstream visual nesting
314    /// (OverallAlgorithm → its phases; TotalFunctionEvaluations → its
315    /// per-callback breakdown). Returns a multi-line string ending in a
316    /// trailing newline so callers can `print!` it directly.
317    pub fn report(&self) -> String {
318        use std::fmt::Write as _;
319        let mut s = String::new();
320        let row = |s: &mut String, label: &str, t: &TimedTask| {
321            let _ = writeln!(
322                s,
323                "{label:<42} {wall:>10.3}s",
324                wall = t.total_wallclock_time()
325            );
326        };
327        s.push_str("\nTiming Statistics:\n");
328        row(
329            &mut s,
330            "OverallAlgorithm....................:",
331            &self.overall_alg,
332        );
333        row(
334            &mut s,
335            " InitializeIterates.................:",
336            &self.initialize_iterates,
337        );
338        row(
339            &mut s,
340            " UpdateHessian......................:",
341            &self.update_hessian,
342        );
343        row(
344            &mut s,
345            " OutputIteration....................:",
346            &self.output_iteration,
347        );
348        row(
349            &mut s,
350            " UpdateBarrierParameter.............:",
351            &self.update_barrier_parameter,
352        );
353        row(
354            &mut s,
355            " ComputeSearchDirection.............:",
356            &self.compute_search_direction,
357        );
358        row(
359            &mut s,
360            " ComputeAcceptableTrialPoint........:",
361            &self.compute_acceptable_trial_point,
362        );
363        row(
364            &mut s,
365            " AcceptTrialPoint...................:",
366            &self.accept_trial_point,
367        );
368        row(
369            &mut s,
370            " CheckConvergence...................:",
371            &self.check_convergence,
372        );
373        row(
374            &mut s,
375            "LinearSystemFactorization...........:",
376            &self.linear_system_factorization,
377        );
378        row(
379            &mut s,
380            "LinearSystemBackSolve...............:",
381            &self.linear_system_back_solve,
382        );
383        row(
384            &mut s,
385            "QualityFunctionSearch...............:",
386            &self.quality_function_search,
387        );
388        row(
389            &mut s,
390            "TotalFunctionEvaluations............:",
391            &self.total_function_evaluation_time,
392        );
393        row(
394            &mut s,
395            " ObjectiveFunctionEvaluations.......:",
396            &self.eval_obj,
397        );
398        row(
399            &mut s,
400            " ObjectiveGradientEvaluations.......:",
401            &self.eval_grad_obj,
402        );
403        row(
404            &mut s,
405            " ConstraintEvaluations..............:",
406            &self.eval_constr,
407        );
408        row(
409            &mut s,
410            " ConstraintJacobianEvaluations......:",
411            &self.eval_constr_jac,
412        );
413        row(
414            &mut s,
415            " LagrangianHessianEvaluations.......:",
416            &self.eval_lag_hess,
417        );
418        s
419    }
420
421    /// Structured wall-clock breakdown (seconds) of the major solve
422    /// subsystems, as ordered `(label, seconds)` pairs. Same numbers
423    /// [`Self::report`] prints, but as data rather than formatted text,
424    /// so a programmatic consumer (e.g. the Python `Problem.solve` `info`
425    /// dict) can attribute a solve's runtime without scraping the report
426    /// or patching the solver.
427    ///
428    /// Ordered coarse→fine: the overall algorithm total; the
429    /// linear-algebra split (`linear_system_total` = factorization +
430    /// back-solve, with factorization broken out); and the per-callback
431    /// function-evaluation split (objective / gradient / constraints /
432    /// Jacobian / Lagrangian Hessian). This is exactly the func /
433    /// Jacobian / Hessian time split issue #180 needs to reproduce a
434    /// Table-6-style "where did the time go" analysis for a
435    /// reduced-space / variable-aggregation solve.
436    pub fn wall_time_breakdown(&self) -> Vec<(&'static str, Number)> {
437        let factorization = self.linear_system_factorization.total_wallclock_time();
438        let back_solve = self.linear_system_back_solve.total_wallclock_time();
439        vec![
440            ("overall_alg", self.overall_alg.total_wallclock_time()),
441            ("update_hessian", self.update_hessian.total_wallclock_time()),
442            (
443                "compute_search_direction",
444                self.compute_search_direction.total_wallclock_time(),
445            ),
446            ("linear_system_total", factorization + back_solve),
447            ("linear_system_factorization", factorization),
448            ("linear_system_back_solve", back_solve),
449            (
450                "function_evaluations_total",
451                self.total_function_evaluation_time.total_wallclock_time(),
452            ),
453            ("eval_objective", self.eval_obj.total_wallclock_time()),
454            ("eval_gradient", self.eval_grad_obj.total_wallclock_time()),
455            ("eval_constraints", self.eval_constr.total_wallclock_time()),
456            (
457                "eval_constraint_jacobian",
458                self.eval_constr_jac.total_wallclock_time(),
459            ),
460            (
461                "eval_lagrangian_hessian",
462                self.eval_lag_hess.total_wallclock_time(),
463            ),
464            (
465                "total_callback",
466                self.total_callback_time.total_wallclock_time(),
467            ),
468        ]
469    }
470
471    /// Enable or disable the *detailed* per-subsystem timers, mirroring
472    /// upstream Ipopt's `timing_statistics` gating (`IpoptApplication`
473    /// only measures the detailed function/phase timers when
474    /// `timing_statistics=yes`). When `on` is `false` every `start()` /
475    /// `end()` on these tasks becomes a no-op, so a fast-objective solve
476    /// stops paying two `getrusage` syscalls per timed section (issue
477    /// #190).
478    ///
479    /// [`Self::overall_alg`] is deliberately left untouched: its
480    /// `live_cpu_time()` feeds the `max_cpu_time` convergence check and
481    /// its total is reported regardless of the option — upstream's help
482    /// text is explicit that "the overall algorithm time is unaffected by
483    /// this option". Callers that need the detailed
484    /// [`Self::wall_time_breakdown`] populated (the Python `info["timing"]`
485    /// dict, the CLI `timing.json`) must therefore set `timing_statistics`
486    /// (or `print_timing_statistics`, which implies it) to `yes`.
487    pub fn set_detailed_enabled(&self, on: bool) {
488        let set = |t: &TimedTask| {
489            if on {
490                t.enable();
491            } else {
492                t.disable();
493            }
494        };
495        // Every field except `overall_alg`.
496        set(&self.print_problem_statistics);
497        set(&self.initialize_iterates);
498        set(&self.update_hessian);
499        set(&self.output_iteration);
500        set(&self.update_barrier_parameter);
501        set(&self.compute_search_direction);
502        set(&self.compute_acceptable_trial_point);
503        set(&self.accept_trial_point);
504        set(&self.check_convergence);
505        set(&self.linear_system_factorization);
506        set(&self.linear_system_back_solve);
507        set(&self.linear_system_structure_converter);
508        set(&self.linear_system_structure_converter_init);
509        set(&self.quality_function_search);
510        set(&self.total_callback_time);
511        set(&self.total_function_evaluation_time);
512        set(&self.eval_obj);
513        set(&self.eval_grad_obj);
514        set(&self.eval_constr);
515        set(&self.eval_constr_jac);
516        set(&self.eval_lag_hess);
517    }
518
519    /// Reset all counters. Mirrors upstream `ResetTimes()`.
520    pub fn reset(&self) {
521        self.overall_alg.reset();
522        self.print_problem_statistics.reset();
523        self.initialize_iterates.reset();
524        self.update_hessian.reset();
525        self.output_iteration.reset();
526        self.update_barrier_parameter.reset();
527        self.compute_search_direction.reset();
528        self.compute_acceptable_trial_point.reset();
529        self.accept_trial_point.reset();
530        self.check_convergence.reset();
531        self.linear_system_factorization.reset();
532        self.linear_system_back_solve.reset();
533        self.linear_system_structure_converter.reset();
534        self.linear_system_structure_converter_init.reset();
535        self.quality_function_search.reset();
536        self.total_callback_time.reset();
537        self.total_function_evaluation_time.reset();
538        self.eval_obj.reset();
539        self.eval_grad_obj.reset();
540        self.eval_constr.reset();
541        self.eval_constr_jac.reset();
542        self.eval_lag_hess.reset();
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn deadline_unbounded_never_trips() {
552        // The pounce "no budget" defaults (1e6 seconds each) must never
553        // fire in any realistic test runtime.
554        let d = Deadline::new(1e6, 1e6);
555        assert!(d.exceeded().is_none());
556        assert_eq!(d.max_wall(), 1e6);
557        assert_eq!(d.max_cpu(), 1e6);
558    }
559
560    #[test]
561    fn deadline_zero_wall_trips_wall() {
562        // Zero wall budget, effectively-unbounded CPU budget: after any
563        // wall time elapses the check reports `Wall` (CPU is tested first
564        // but its budget is not crossed).
565        let d = Deadline::new(0.0, 1e6);
566        // Busy-spin until the monotonic wall clock has advanced past the
567        // start instant, so the assertion is not racing a zero-duration
568        // `elapsed()` on a coarse clock.
569        for _ in 0..10_000 {
570            if d.exceeded().is_some() {
571                break;
572            }
573            std::hint::black_box(0u64);
574        }
575        assert_eq!(d.exceeded(), Some(DeadlineKind::Wall));
576    }
577
578    #[test]
579    fn deadline_zero_cpu_takes_priority() {
580        // Both budgets at zero: CPU is checked first, so a solve that
581        // crosses both in the same check reports `Cpu` — matching the
582        // convergence check's branch order.
583        let d = Deadline::new(0.0, 0.0);
584        for _ in 0..10_000 {
585            if d.exceeded().is_some() {
586                break;
587            }
588            std::hint::black_box(0u64);
589        }
590        assert_eq!(d.exceeded(), Some(DeadlineKind::Cpu));
591    }
592
593    #[test]
594    fn deadline_remaining_reports_budget_left() {
595        // A large budget leaves ~the whole budget remaining right after
596        // construction (a hair less, since a moment has elapsed), and it
597        // is strictly positive.
598        let d = Deadline::new(1e6, 1e6);
599        let rw = d.remaining_wall();
600        let rc = d.remaining_cpu();
601        assert!(rw > 0.0 && rw <= 1e6, "remaining_wall out of range: {rw}");
602        assert!(rc > 0.0 && rc <= 1e6, "remaining_cpu out of range: {rc}");
603    }
604
605    #[test]
606    fn deadline_remaining_goes_nonpositive_once_crossed() {
607        // A zero wall budget is crossed after any elapsed time, so the
608        // remaining wall budget must be <= 0 (mirrors `exceeded`).
609        let d = Deadline::new(0.0, 1e6);
610        for _ in 0..10_000 {
611            if d.exceeded().is_some() {
612                break;
613            }
614            std::hint::black_box(0u64);
615        }
616        assert!(
617            d.remaining_wall() <= 0.0,
618            "remaining_wall should be non-positive once the budget is crossed"
619        );
620    }
621
622    #[test]
623    fn deadline_is_cheaply_clonable_and_shares_start() {
624        // Cloning shares the same start instant / budgets (the restoration
625        // inner IPM relies on this to be bounded by the outer solve's
626        // elapsed time, not its own).
627        let d = Deadline::new(1e6, 1e6);
628        let d2 = d.clone();
629        assert_eq!(d2.max_wall(), d.max_wall());
630        assert_eq!(d2.max_cpu(), d.max_cpu());
631        assert!(d2.exceeded().is_none());
632    }
633
634    #[test]
635    fn start_end_accumulates_nonneg() {
636        let t = TimedTask::new();
637        t.start();
638        for _ in 0..1000 {
639            std::hint::black_box(0u64);
640        }
641        t.end();
642        assert!(t.total_wallclock_time() >= 0.0);
643    }
644
645    #[test]
646    fn disabled_is_noop() {
647        let t = TimedTask::new();
648        t.disable();
649        t.start();
650        t.end();
651        assert_eq!(t.total_wallclock_time(), 0.0);
652    }
653
654    #[test]
655    fn set_detailed_enabled_gates_all_but_overall_alg() {
656        let stats = TimingStatistics::new();
657        // Default: every timer enabled.
658        assert!(stats.overall_alg.is_enabled());
659        assert!(stats.eval_obj.is_enabled());
660        assert!(stats.check_convergence.is_enabled());
661
662        // Disabling the detail timers (issue #190: `timing_statistics=no`)
663        // must leave `overall_alg` alive — it feeds the `max_cpu_time`
664        // check and is always reported — while every other timer becomes
665        // a no-op that skips the `getrusage` syscalls.
666        stats.set_detailed_enabled(false);
667        assert!(stats.overall_alg.is_enabled(), "overall_alg must stay live");
668        assert!(!stats.eval_obj.is_enabled());
669        assert!(!stats.check_convergence.is_enabled());
670        assert!(!stats.total_function_evaluation_time.is_enabled());
671        assert!(!stats.linear_system_factorization.is_enabled());
672
673        // A disabled detail timer accumulates nothing even across start/end.
674        stats.eval_obj.start();
675        stats.eval_obj.end();
676        assert_eq!(stats.eval_obj.total_wallclock_time(), 0.0);
677
678        // Re-enabling restores them.
679        stats.set_detailed_enabled(true);
680        assert!(stats.eval_obj.is_enabled());
681        assert!(stats.check_convergence.is_enabled());
682    }
683
684    #[test]
685    fn end_if_started_handles_unstarted() {
686        let t = TimedTask::new();
687        t.end_if_started();
688        assert_eq!(t.total_wallclock_time(), 0.0);
689    }
690
691    #[test]
692    fn wall_time_breakdown_reports_subsystems() {
693        let stats = TimingStatistics::new();
694        // Accumulate into two distinct subsystems so the breakdown is
695        // not trivially all-zero and the linear-algebra total is the
696        // sum of its two parts.
697        stats.linear_system_factorization.start();
698        stats.linear_system_factorization.end();
699        stats.eval_lag_hess.start();
700        stats.eval_lag_hess.end();
701
702        let bd = stats.wall_time_breakdown();
703        let get = |k: &str| bd.iter().find(|(label, _)| *label == k).map(|(_, v)| *v);
704
705        // Every advertised key is present and non-negative.
706        for key in [
707            "overall_alg",
708            "linear_system_total",
709            "linear_system_factorization",
710            "linear_system_back_solve",
711            "function_evaluations_total",
712            "eval_objective",
713            "eval_gradient",
714            "eval_constraints",
715            "eval_constraint_jacobian",
716            "eval_lagrangian_hessian",
717        ] {
718            assert!(get(key).is_some(), "missing breakdown key {key}");
719            assert!(get(key).unwrap() >= 0.0, "negative time for {key}");
720        }
721
722        // linear_system_total == factorization + back_solve, exactly.
723        let total = get("linear_system_total").unwrap();
724        let fact = get("linear_system_factorization").unwrap();
725        let back = get("linear_system_back_solve").unwrap();
726        assert_eq!(total, fact + back);
727    }
728}