Skip to main content

pounce_observability/
lib.rs

1//! Observability wiring for POUNCE (pounce#71).
2//!
3//! This crate owns the `tracing` subscriber install and the bridge
4//! between the structured per-iteration event and the JSON solve
5//! report. It is kept separate from the leaf `pounce-common` (which
6//! holds the pure color palette) because the collector needs both
7//! [`pounce_nlp::solve_statistics::IterRecord`] and
8//! `tracing-subscriber`.
9//!
10//! ## Two output channels, one event
11//!
12//! Each Newton iteration emits a single structured event at
13//! [`ITER_TARGET`] carrying `iter`, `mu`, `alpha_primal`, … The human
14//! terminal never sees this event (the colored fixed-width table,
15//! printed directly by `pounce-algorithm`, is its visual form, so the
16//! text console layer filters `pounce::iteration` out). Machines get
17//! it two ways:
18//!
19//! * `POUNCE_LOG_FORMAT=json` → the JSON layer prints it to stderr;
20//! * the [`IterCollectorLayer`] rebuilds an `IterRecord` from its
21//!   fields and appends it to the active [`IterCaptureGuard`] slot,
22//!   which the application drains into the solve report.
23//!
24//! The collector skips events nested inside a `restoration` span, so
25//! the report captures only the outer solve's iterations (including
26//! `'R'`-marked outer iters) and not the restoration sub-solve's inner
27//! IPM iterations — matching the pre-tracing behavior exactly.
28//!
29//! ## Thread-scoped capture for embedders
30//!
31//! Hosts that must not claim the global subscriber (libraries embedding
32//! POUNCE as a solver backend) use the scoped helpers instead of
33//! [`init_subscriber`]: [`with_iter_capture`] wraps one solve in a
34//! closure and returns its trajectory, [`ScopedIterCapture`] is the
35//! guard-shaped equivalent, and [`collector_scope`] installs just the
36//! collector for drivers that manage their own [`IterCaptureGuard`]
37//! (the iteration-history application path).
38
39#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
40
41use std::cell::RefCell;
42
43use pounce_common::types::{Index, Number};
44use pounce_nlp::solve_statistics::IterRecord;
45use tracing::field::{Field, Visit};
46use tracing_subscriber::layer::{Context, Layer};
47use tracing_subscriber::registry::LookupSpan;
48
49/// Target of the structured per-iteration event. The text console
50/// layer filters this target out (the colored table is its human
51/// form); the JSON layer and [`IterCollectorLayer`] keep it.
52pub const ITER_TARGET: &str = "pounce::iteration";
53
54/// Span name whose presence in an event's ancestry marks the event as
55/// belonging to the restoration sub-solve. The collector uses it to
56/// exclude inner restoration iterations from the report.
57pub const RESTORATION_SPAN: &str = "restoration";
58
59// ---- Per-solve capture slot ----
60
61thread_local! {
62    /// Active capture buffer for the current solve, or `None` when no
63    /// solve on this thread is recording its iteration history.
64    static CAPTURE: RefCell<Option<Vec<IterRecord>>> = const { RefCell::new(None) };
65}
66
67/// Set once at subscriber install when `POUNCE_LOG_FORMAT=json`, so the
68/// per-iteration event is emitted (for the JSON sink) even when no
69/// in-process capture is active.
70static JSON_LOGGING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
71
72/// Set when [`install`] successfully claimed the global subscriber, whose
73/// layers include the collector. [`collector_scope`] then skips its
74/// shadowing thread-default install, so capture composes with the
75/// global console/JSON output instead of silencing it.
76static GLOBAL_COLLECTOR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
77
78/// Whether the per-iteration `pounce::iteration` event has a consumer
79/// right now, so the algorithm can skip emitting it (and the field
80/// evaluation it entails) when nothing would observe it.
81///
82/// True when either an [`IterCaptureGuard`] is active on this thread
83/// (the JSON report wants the trajectory) or JSON logging is installed
84/// (the stderr sink wants it). In the common default run — text logs,
85/// no iter-history capture — this is `false`, so the event costs
86/// nothing.
87pub fn iteration_event_wanted() -> bool {
88    if JSON_LOGGING.load(std::sync::atomic::Ordering::Relaxed) {
89        return true;
90    }
91    CAPTURE.with(|c| c.borrow().is_some())
92}
93
94/// RAII activation of per-iteration capture for one solve.
95///
96/// Construct with [`IterCaptureGuard::start`] immediately before the
97/// solve and call [`IterCaptureGuard::finish`] after it to take the
98/// collected records. Solves run synchronously on one thread, so a
99/// thread-local slot suffices; restoration sub-solves are excluded by
100/// span scoping in the collector rather than by nesting guards.
101#[must_use = "call finish() to retrieve the captured iteration history"]
102pub struct IterCaptureGuard {
103    /// Any buffer that was active before this guard, restored on drop
104    /// so sequential or accidentally-nested solves don't clobber it.
105    prev: Option<Vec<IterRecord>>,
106}
107
108impl IterCaptureGuard {
109    /// Begin capturing iteration records on this thread.
110    pub fn start() -> Self {
111        let prev = CAPTURE.with(|c| c.borrow_mut().replace(Vec::new()));
112        Self { prev }
113    }
114
115    /// End capture and return the records collected since [`start`].
116    ///
117    /// [`start`]: IterCaptureGuard::start
118    pub fn finish(mut self) -> Vec<IterRecord> {
119        let prev = self.prev.take();
120        let captured = CAPTURE
121            .with(|c| std::mem::replace(&mut *c.borrow_mut(), prev))
122            .unwrap_or_default();
123        // Skip `Drop`: it would re-restore `self.prev` (now `None`) and
124        // clobber the buffer we just put back for an enclosing guard.
125        std::mem::forget(self);
126        captured
127    }
128}
129
130impl Drop for IterCaptureGuard {
131    fn drop(&mut self) {
132        // Restore the previous buffer if `finish` wasn't called.
133        let prev = self.prev.take();
134        CAPTURE.with(|c| *c.borrow_mut() = prev);
135    }
136}
137
138/// Append a record to the active capture slot, if any.
139fn push_record(rec: IterRecord) {
140    CAPTURE.with(|c| {
141        if let Some(buf) = c.borrow_mut().as_mut() {
142            buf.push(rec);
143        }
144    });
145}
146
147/// Append copies of `records` to the active capture slot, if any.
148///
149/// Called by the solver driver after it drains its own iteration-history
150/// capture, so an enclosing [`IterCaptureGuard`] still receives the
151/// trajectory instead of finding its buffer emptied by the driver's
152/// inner guard. No-op when no capture is active on this thread.
153pub fn extend_active_capture(records: &[IterRecord]) {
154    if records.is_empty() {
155        return;
156    }
157    CAPTURE.with(|c| {
158        if let Some(buf) = c.borrow_mut().as_mut() {
159            buf.extend_from_slice(records);
160        }
161    });
162}
163
164// ---- Event → IterRecord visitor ----
165
166#[derive(Default)]
167struct IterVisitor {
168    rec: IterRecord,
169}
170
171impl Visit for IterVisitor {
172    fn record_f64(&mut self, field: &Field, value: f64) {
173        let v = value as Number;
174        match field.name() {
175            "objective" => self.rec.objective = v,
176            "inf_pr" => self.rec.inf_pr = v,
177            "inf_du" => self.rec.inf_du = v,
178            "mu" => self.rec.mu = v,
179            "d_norm" => self.rec.d_norm = v,
180            "regularization" => self.rec.regularization = v,
181            "alpha_dual" => self.rec.alpha_dual = v,
182            "alpha_primal" => self.rec.alpha_primal = v,
183            _ => {}
184        }
185    }
186
187    fn record_i64(&mut self, field: &Field, value: i64) {
188        match field.name() {
189            "iter" => self.rec.iter = value as Index,
190            "ls_trials" => self.rec.ls_trials = value as Index,
191            _ => {}
192        }
193    }
194
195    fn record_u64(&mut self, field: &Field, value: u64) {
196        // Match the field directly rather than routing through
197        // `record_i64` (which would `value as i64`-truncate a value
198        // above `i64::MAX`). `iter`/`ls_trials` never approach that, but
199        // matching here keeps the cast localized to the two real fields.
200        match field.name() {
201            "iter" => self.rec.iter = value as Index,
202            "ls_trials" => self.rec.ls_trials = value as Index,
203            _ => {}
204        }
205    }
206
207    fn record_str(&mut self, field: &Field, value: &str) {
208        if field.name() == "alpha_char" {
209            self.rec.alpha_primal_char = value.chars().next().unwrap_or(' ');
210        }
211    }
212
213    fn record_debug(&mut self, _field: &Field, _value: &dyn std::fmt::Debug) {
214        // The message and any Debug-formatted fields are irrelevant to
215        // the numeric record.
216    }
217}
218
219// ---- Collector layer ----
220
221/// `tracing` layer that rebuilds [`IterRecord`]s from [`ITER_TARGET`]
222/// events into the active [`IterCaptureGuard`] slot.
223#[derive(Debug, Default, Clone)]
224pub struct IterCollectorLayer;
225
226impl<S> Layer<S> for IterCollectorLayer
227where
228    S: tracing::Subscriber + for<'a> LookupSpan<'a>,
229{
230    fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) {
231        if event.metadata().target() != ITER_TARGET {
232            return;
233        }
234        // Skip iterations belonging to a restoration sub-solve so the
235        // report keeps only the outer trajectory.
236        if let Some(scope) = ctx.event_scope(event) {
237            for span in scope.from_root() {
238                if span.name() == RESTORATION_SPAN {
239                    return;
240                }
241            }
242        }
243        let mut visitor = IterVisitor::default();
244        event.record(&mut visitor);
245        push_record(visitor.rec);
246    }
247}
248
249/// Per-layer filter for [`IterCollectorLayer`]: admit spans (so the
250/// collector's `event_scope` can see the `restoration` ancestor for
251/// scoping) plus the iteration event itself.
252///
253/// A per-layer filter is required so the collector does not force every
254/// callsite globally enabled; without admitting spans here the filtered
255/// `Context` would hide span ancestry. Defined once and reused at every
256/// `with_filter` site — note the `with_filter` *call* must still live in
257/// each branch, because the resulting `Filtered` type carries the
258/// per-branch subscriber type parameter.
259fn collector_admits(m: &tracing::Metadata<'_>) -> bool {
260    m.is_span() || m.target() == ITER_TARGET
261}
262
263// ---- Scoped capture helpers ----
264
265/// Opaque RAII scope guaranteeing [`IterCollectorLayer`] is active on
266/// this thread until drop.
267#[must_use = "the collector uninstalls as soon as this guard drops"]
268pub struct CollectorScope {
269    _default: Option<tracing::subscriber::DefaultGuard>,
270}
271
272/// Ensure [`IterCollectorLayer`] is active on this thread until the
273/// returned guard drops.
274///
275/// For the [`IterCaptureGuard`]-managed application path: with iteration
276/// history enabled the solver driver runs its own capture guard around
277/// the solve and drains the records into its solve statistics, so the
278/// caller only needs the collector active for the solve's duration:
279///
280/// ```ignore
281/// let _scope = pounce_observability::collector_scope();
282/// app.enable_iter_history();
283/// app.optimize_tnlp(problem)?;
284/// let iters = app.statistics().iterations;
285/// ```
286///
287/// When [`init_subscriber`] has claimed the global subscriber, its
288/// collector already covers this thread and the scope is a no-op.
289/// Otherwise a collector-only registry is installed as the thread-default
290/// subscriber, which shadows the host's own subscriber (its log output
291/// from this thread is dropped) for the scope's lifetime.
292pub fn collector_scope() -> CollectorScope {
293    use tracing_subscriber::filter::filter_fn;
294    use tracing_subscriber::prelude::*;
295
296    if GLOBAL_COLLECTOR.load(std::sync::atomic::Ordering::Relaxed) {
297        return CollectorScope { _default: None };
298    }
299    let collector = IterCollectorLayer.with_filter(filter_fn(collector_admits));
300    let subscriber = tracing_subscriber::registry().with(collector);
301    CollectorScope {
302        _default: Some(tracing::subscriber::set_default(subscriber)),
303    }
304}
305
306/// [`IterCaptureGuard`] bundled with a [`collector_scope`] subscriber
307/// install: everything needed to capture one solve's iteration history
308/// on this thread, with no `tracing` wiring on the caller's side.
309///
310/// For solves that don't fit inside a closure; otherwise prefer
311/// [`with_iter_capture`]. Combining with the driver's own
312/// iteration-history capture (`enable_iter_history`) is safe.
313#[must_use = "call finish() to retrieve the captured iteration history"]
314pub struct ScopedIterCapture {
315    capture: IterCaptureGuard,
316    _scope: CollectorScope,
317}
318
319impl ScopedIterCapture {
320    /// Install the collector on this thread and begin capturing.
321    pub fn start() -> Self {
322        let scope = collector_scope();
323        let capture = IterCaptureGuard::start();
324        Self {
325            capture,
326            _scope: scope,
327        }
328    }
329
330    /// End capture, uninstall the collector, and return the records
331    /// collected since [`start`].
332    ///
333    /// [`start`]: ScopedIterCapture::start
334    pub fn finish(self) -> Vec<IterRecord> {
335        let Self { capture, _scope } = self;
336        let records = capture.finish();
337        drop(_scope);
338        records
339    }
340}
341
342/// Run `f` with iteration capture active on this thread, returning its
343/// result alongside the recorded trajectory.
344///
345/// Equivalent to wrapping `f` in a [`ScopedIterCapture`]: installs
346/// [`IterCollectorLayer`] as the thread-default subscriber,
347/// activates an [`IterCaptureGuard`], runs `f`, and tears both
348/// down before returning.
349///
350/// Records exist only for solver paths that emit the [`ITER_TARGET`]
351/// event. An active-set SQP solve inside `f` captures nothing.
352///
353/// ```ignore
354/// let (solution, iters) = pounce_observability::with_iter_capture(|| nlp.solve());
355/// ```
356pub fn with_iter_capture<R>(f: impl FnOnce() -> R) -> (R, Vec<IterRecord>) {
357    let scope = ScopedIterCapture::start();
358    let result = f();
359    (result, scope.finish())
360}
361
362// ---- Tiger/rust themed text formatter ----
363
364/// Foreground style for a log level in the tiger/rust theme.
365fn level_style(level: tracing::Level) -> anstyle::Style {
366    use pounce_common::style::{ALPHA_HOT, TAN, TIGER_ORANGE};
367    let color = match level {
368        tracing::Level::ERROR => ALPHA_HOT,
369        tracing::Level::WARN => TIGER_ORANGE,
370        tracing::Level::INFO => TAN,
371        tracing::Level::DEBUG => anstyle::RgbColor(0x9a, 0x8c, 0x70),
372        tracing::Level::TRACE => anstyle::RgbColor(0x6a, 0x5d, 0x48),
373    };
374    anstyle::Style::new().fg_color(Some(anstyle::Color::Rgb(color)))
375}
376
377/// Compact event formatter: `LEVEL target: message field=…`, with the
378/// level rendered in the tiger/rust palette when ANSI is enabled.
379struct TigerFormat;
380
381impl<S, N> tracing_subscriber::fmt::FormatEvent<S, N> for TigerFormat
382where
383    S: tracing::Subscriber + for<'a> LookupSpan<'a>,
384    N: for<'a> tracing_subscriber::fmt::FormatFields<'a> + 'static,
385{
386    fn format_event(
387        &self,
388        ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>,
389        mut writer: tracing_subscriber::fmt::format::Writer<'_>,
390        event: &tracing::Event<'_>,
391    ) -> std::fmt::Result {
392        let meta = event.metadata();
393        let level = *meta.level();
394        if writer.has_ansi_escapes() {
395            let style = level_style(level);
396            write!(
397                writer,
398                "{}{:>5}{} ",
399                style.render(),
400                level,
401                style.render_reset()
402            )?;
403        } else {
404            write!(writer, "{level:>5} ")?;
405        }
406        write!(writer, "{}: ", meta.target())?;
407        ctx.field_format().format_fields(writer.by_ref(), event)?;
408        writeln!(writer)
409    }
410}
411
412// ---- Subscriber install ----
413
414/// Install the global tracing subscriber for a normal run. Idempotent
415/// (`try_init`): safe to call from multiple frontends or repeated
416/// Python imports.
417///
418/// Reads `RUST_LOG` (filtering, default `info`), `POUNCE_LOG_FORMAT`
419/// (`text` | `json`), and `NO_COLOR`/`CLICOLOR_FORCE` (color policy).
420pub fn init_subscriber() {
421    install();
422}
423
424/// Install a subscriber suitable for tests: same layers as
425/// [`init_subscriber`], so iteration capture works under
426/// [`IterCaptureGuard`]. Idempotent.
427///
428/// Currently identical to [`init_subscriber`]; kept as a distinct entry
429/// point so test setup can diverge (e.g. an in-memory sink) without
430/// touching the production install path. Tests needing an *isolated*
431/// subscriber should build their own with `with_default` instead.
432pub fn init_for_tests() {
433    install();
434}
435
436fn install() {
437    use tracing_subscriber::EnvFilter;
438    use tracing_subscriber::filter::filter_fn;
439    use tracing_subscriber::prelude::*;
440
441    // Bridge the `log` crate into `tracing` so any remaining `log::*`
442    // call sites — chiefly transitive dependencies — surface through
443    // our subscriber and obey `RUST_LOG`. Idempotent; the `Err` when a
444    // logger is already installed is intentionally ignored.
445    let _ = tracing_log::LogTracer::init();
446
447    let want_json = std::env::var("POUNCE_LOG_FORMAT")
448        .map(|v| v.eq_ignore_ascii_case("json"))
449        .unwrap_or(false);
450    // Record the JSON-sink decision so `iteration_event_wanted()` keeps
451    // emitting the per-iteration event for the stderr stream even when
452    // no in-process capture is active.
453    JSON_LOGGING.store(want_json, std::sync::atomic::Ordering::Relaxed);
454
455    // The collector only ever wants the iteration event; it must NOT be
456    // subject to the console's `pounce::iteration=off` suppression, so
457    // it carries its own target filter. It is constructed inside each
458    // branch so its subscriber type parameter is inferred per-branch.
459    let claimed = if want_json {
460        let collector = IterCollectorLayer.with_filter(filter_fn(collector_admits));
461        let json_layer = tracing_subscriber::fmt::layer()
462            .json()
463            .with_writer(std::io::stderr)
464            .with_filter(env_filter());
465        tracing_subscriber::registry()
466            .with(json_layer)
467            .with(collector)
468            .try_init()
469            .is_ok()
470    } else {
471        let collector = IterCollectorLayer.with_filter(filter_fn(collector_admits));
472        let ansi = ansi_enabled();
473        let text_layer = tracing_subscriber::fmt::layer()
474            .event_format(TigerFormat)
475            .with_ansi(ansi)
476            .with_writer(std::io::stderr)
477            .with_filter(console_filter());
478        tracing_subscriber::registry()
479            .with(text_layer)
480            .with(collector)
481            .try_init()
482            .is_ok()
483    };
484    if claimed {
485        GLOBAL_COLLECTOR.store(true, std::sync::atomic::Ordering::Relaxed);
486    }
487
488    /// `RUST_LOG` filter, defaulting to `info`.
489    fn env_filter() -> EnvFilter {
490        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
491    }
492
493    /// Console filter: `RUST_LOG` plus suppression of the iteration
494    /// target (its human form is the colored table on stdout).
495    fn console_filter() -> EnvFilter {
496        let base = env_filter();
497        match format!("{ITER_TARGET}=off").parse() {
498            Ok(directive) => base.add_directive(directive),
499            Err(_) => base,
500        }
501    }
502
503    /// ANSI on unless `NO_COLOR`, with `CLICOLOR_FORCE` overriding and
504    /// a terminal-capability check otherwise.
505    fn ansi_enabled() -> bool {
506        if anstyle_query::clicolor_force() {
507            return true;
508        }
509        if anstyle_query::no_color() {
510            return false;
511        }
512        anstyle_query::term_supports_ansi_color()
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    fn sample_record(iter: i32, alpha: f64, c: char) -> IterRecord {
521        IterRecord {
522            iter,
523            objective: 1.0,
524            inf_pr: 2.0,
525            inf_du: 3.0,
526            mu: 4.0,
527            d_norm: 5.0,
528            regularization: 6.0,
529            alpha_dual: 7.0,
530            alpha_primal: alpha,
531            alpha_primal_char: c,
532            ls_trials: 1,
533        }
534    }
535
536    #[test]
537    fn iteration_event_wanted_tracks_active_capture() {
538        // Default (no capture on this thread, JSON sink off): nothing
539        // consumes the event, so it should be suppressed.
540        assert!(!iteration_event_wanted());
541        let guard = IterCaptureGuard::start();
542        assert!(iteration_event_wanted(), "capture active → event wanted");
543        let _ = guard.finish();
544        assert!(
545            !iteration_event_wanted(),
546            "capture ended → event suppressed"
547        );
548    }
549
550    #[test]
551    fn guard_captures_pushed_records() {
552        let guard = IterCaptureGuard::start();
553        push_record(sample_record(0, 1.0, ' '));
554        push_record(sample_record(1, 0.5, 'R'));
555        let got = guard.finish();
556        assert_eq!(got.len(), 2);
557        assert_eq!(got[1].iter, 1);
558        assert_eq!(got[1].alpha_primal_char, 'R');
559    }
560
561    #[test]
562    fn no_guard_means_records_are_dropped() {
563        // Without an active guard, push is a no-op (no panic, no leak).
564        push_record(sample_record(0, 1.0, ' '));
565        let guard = IterCaptureGuard::start();
566        let got = guard.finish();
567        assert!(got.is_empty());
568    }
569
570    #[test]
571    fn guard_restores_previous_slot_on_finish() {
572        let outer = IterCaptureGuard::start();
573        push_record(sample_record(0, 1.0, ' '));
574        {
575            let inner = IterCaptureGuard::start();
576            push_record(sample_record(99, 0.1, 'R'));
577            let inner_got = inner.finish();
578            assert_eq!(inner_got.len(), 1);
579            assert_eq!(inner_got[0].iter, 99);
580        }
581        // Outer slot must still hold only its own record.
582        push_record(sample_record(1, 1.0, ' '));
583        let outer_got = outer.finish();
584        assert_eq!(outer_got.len(), 2);
585        assert_eq!(outer_got[0].iter, 0);
586        assert_eq!(outer_got[1].iter, 1);
587    }
588
589    #[test]
590    fn collector_excludes_restoration_nested_iterations() {
591        use tracing_subscriber::filter::filter_fn;
592        use tracing_subscriber::prelude::*;
593
594        fn emit(iter: i64, ch: char) {
595            let s = ch.to_string();
596            tracing::info!(
597                target: ITER_TARGET,
598                iter = iter,
599                objective = 0.0,
600                alpha_primal = 1.0,
601                alpha_char = s.as_str(),
602            );
603        }
604
605        // Same layer wiring as `install()`: the filter must admit spans
606        // so the collector's `event_scope` can see the `restoration`
607        // ancestor. Regression guard for the per-layer-filter bug where
608        // a `target`-only filter hid span ancestry and let inner
609        // restoration iterations leak into the report.
610        let collector = IterCollectorLayer.with_filter(filter_fn(collector_admits));
611        let subscriber = tracing_subscriber::registry().with(collector);
612
613        let captured = tracing::subscriber::with_default(subscriber, || {
614            let guard = IterCaptureGuard::start();
615            emit(0, ' '); // outer -> captured
616            {
617                let _resto = tracing::info_span!("restoration").entered();
618                let _inner_solve = tracing::info_span!("solve").entered();
619                let _inner_iter = tracing::info_span!("iteration").entered();
620                emit(99, 'R'); // inner restoration sub-solve -> excluded
621            }
622            emit(1, ' '); // outer -> captured
623            guard.finish()
624        });
625
626        let iters: Vec<i32> = captured.iter().map(|r| r.iter).collect();
627        assert_eq!(
628            iters,
629            vec![0, 1],
630            "inner restoration iteration leaked: {iters:?}"
631        );
632    }
633
634    #[test]
635    fn log_records_bridge_into_tracing() {
636        use std::sync::{Arc, Mutex};
637        use tracing_subscriber::prelude::*;
638
639        // Minimal layer that records each event's `message` field.
640        #[derive(Clone)]
641        struct CaptureLayer {
642            buf: Arc<Mutex<Vec<String>>>,
643        }
644        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CaptureLayer {
645            fn on_event(
646                &self,
647                event: &tracing::Event<'_>,
648                _ctx: tracing_subscriber::layer::Context<'_, S>,
649            ) {
650                struct V<'a>(&'a mut Vec<String>);
651                impl tracing::field::Visit for V<'_> {
652                    fn record_debug(&mut self, f: &Field, value: &dyn std::fmt::Debug) {
653                        if f.name() == "message" {
654                            self.0.push(format!("{value:?}"));
655                        }
656                    }
657                }
658                let mut g = self.buf.lock().unwrap_or_else(|p| p.into_inner());
659                event.record(&mut V(&mut g));
660            }
661        }
662
663        let buf = Arc::new(Mutex::new(Vec::new()));
664        let subscriber = tracing_subscriber::registry().with(CaptureLayer { buf: buf.clone() });
665
666        // The same bridge `install()` sets up. Global + idempotent.
667        let _ = tracing_log::LogTracer::init();
668        tracing::subscriber::with_default(subscriber, || {
669            // A `log` record as a transitive dependency would emit.
670            log::error!(target: "some_transitive_dep", "bridged log record");
671        });
672
673        let got = buf.lock().unwrap_or_else(|p| p.into_inner());
674        assert!(
675            got.iter().any(|m| m.contains("bridged log record")),
676            "log record did not reach the tracing layer; captured: {got:?}"
677        );
678    }
679
680    /// Emit a synthetic `pounce::iteration` event as the algorithm does.
681    fn emit_iter(iter: i64, ch: char) {
682        let s = ch.to_string();
683        tracing::info!(
684            target: ITER_TARGET,
685            iter = iter,
686            objective = 0.5,
687            alpha_primal = 1.0,
688            alpha_char = s.as_str(),
689        );
690    }
691
692    #[test]
693    fn extend_active_capture_appends_to_enclosing_buffer() {
694        let outer = IterCaptureGuard::start();
695        push_record(sample_record(0, 1.0, ' '));
696        let inner = IterCaptureGuard::start();
697        push_record(sample_record(1, 0.5, ' '));
698        let inner_got = inner.finish();
699        extend_active_capture(&inner_got);
700        let outer_got = outer.finish();
701        let iters: Vec<i32> = outer_got.iter().map(|r| r.iter).collect();
702        assert_eq!(iters, vec![0, 1]);
703
704        extend_active_capture(&inner_got);
705        let fresh = IterCaptureGuard::start();
706        assert!(fresh.finish().is_empty());
707    }
708
709    #[test]
710    fn with_iter_capture_captures_events_and_threads_result() {
711        let (result, records) = with_iter_capture(|| {
712            emit_iter(0, ' ');
713            emit_iter(1, 'R');
714            "sentinel"
715        });
716        assert_eq!(result, "sentinel");
717        assert_eq!(records.len(), 2);
718        assert_eq!(records[0].iter, 0);
719        assert_eq!(records[1].iter, 1);
720        assert_eq!(records[1].alpha_primal_char, 'R');
721        assert!((records[1].objective - 0.5).abs() < 1e-12);
722    }
723
724    #[test]
725    fn with_iter_capture_ignores_events_outside_scope() {
726        emit_iter(7, ' '); // before the scope: no consumer
727        let ((), records) = with_iter_capture(|| ());
728        emit_iter(8, ' '); // after the scope: no consumer
729        assert!(records.is_empty());
730        assert!(
731            !iteration_event_wanted(),
732            "capture slot must be torn down after with_iter_capture returns"
733        );
734    }
735
736    #[test]
737    fn with_iter_capture_excludes_restoration_subsolve() {
738        let ((), records) = with_iter_capture(|| {
739            emit_iter(0, ' ');
740            {
741                let _resto = tracing::info_span!("restoration").entered();
742                let _inner_iter = tracing::info_span!("iteration").entered();
743                emit_iter(99, 'R');
744            }
745            emit_iter(1, ' ');
746        });
747        let iters: Vec<i32> = records.iter().map(|r| r.iter).collect();
748        assert_eq!(
749            iters,
750            vec![0, 1],
751            "inner restoration iteration leaked: {iters:?}"
752        );
753    }
754
755    #[test]
756    fn scoped_iter_capture_nesting_restores_outer_buffer() {
757        let outer = ScopedIterCapture::start();
758        emit_iter(0, ' ');
759        let ((), inner) = with_iter_capture(|| emit_iter(99, 'R'));
760        emit_iter(1, ' ');
761        let outer_got = outer.finish();
762        assert_eq!(inner.len(), 1);
763        assert_eq!(inner[0].iter, 99);
764        let iters: Vec<i32> = outer_got.iter().map(|r| r.iter).collect();
765        assert_eq!(iters, vec![0, 1]);
766    }
767
768    #[test]
769    fn collector_scope_feeds_manual_guard() {
770        let scope = collector_scope();
771        let guard = IterCaptureGuard::start();
772        emit_iter(0, ' ');
773        let records = guard.finish();
774        drop(scope);
775        assert_eq!(records.len(), 1);
776        assert_eq!(records[0].iter, 0);
777
778        let guard = IterCaptureGuard::start();
779        emit_iter(1, ' ');
780        assert!(guard.finish().is_empty());
781    }
782
783    #[test]
784    fn with_iter_capture_is_panic_safe() {
785        let unwound = std::panic::catch_unwind(|| {
786            let _ = with_iter_capture(|| panic!("solve blew up"));
787        });
788        assert!(unwound.is_err());
789        assert!(
790            !iteration_event_wanted(),
791            "capture slot must be restored when the closure unwinds"
792        );
793    }
794
795    #[test]
796    fn iter_record_default_and_assignment() {
797        // This checks `IterRecord` field assignment, not the `Visit`
798        // impl — constructing a real `tracing::field::Field` standalone
799        // needs a callsite, so the visitor's record_* arms are covered
800        // end-to-end by `collector_excludes_restoration_nested_iterations`
801        // (which emits real events and asserts the rebuilt `iter`s).
802        let mut v = IterVisitor::default();
803        v.rec.iter = 7;
804        v.rec.alpha_primal = 0.25;
805        v.rec.alpha_primal_char = 'S';
806        assert_eq!(v.rec.iter, 7);
807        assert_eq!(v.rec.alpha_primal_char, 'S');
808    }
809}