Skip to main content

trustformers_debug/
hooks.rs

1//! Debugging hooks for automatic tensor and gradient tracking
2
3use anyhow::Result;
4use scirs2_core::ndarray::{ArrayD, IxDyn};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9use uuid::Uuid;
10
11use crate::activation_visualizer::ActivationVisualizer;
12use crate::tensor_inspector::TensorInspector;
13
14/// Hook trigger conditions
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub enum HookTrigger {
17    /// Trigger on every forward pass
18    EveryForward,
19    /// Trigger on every backward pass
20    EveryBackward,
21    /// Trigger every N steps
22    EveryNSteps(usize),
23    /// Trigger when specific conditions are met
24    Conditional(HookCondition),
25    /// Trigger once and then remove
26    Once,
27    /// Trigger on specific layers only
28    LayerSpecific(Vec<String>),
29}
30
31/// Conditions for conditional hooks
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub enum HookCondition {
34    /// Trigger when loss exceeds threshold
35    LossThreshold {
36        threshold: f64,
37        comparison: Comparison,
38    },
39    /// Trigger when gradient norm exceeds threshold
40    GradientNormThreshold {
41        threshold: f64,
42        comparison: Comparison,
43    },
44    /// Trigger when memory usage exceeds threshold
45    MemoryThreshold { threshold_mb: f64 },
46    /// Trigger on specific training steps
47    StepRange { start: usize, end: usize },
48    /// Fires when the named key is present in
49    /// [`HookContext::metadata`] -- the caller controls the flag.
50    Custom(String),
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub enum Comparison {
55    Greater,
56    Less,
57    Equal,
58    GreaterEqual,
59    LessEqual,
60}
61
62impl Comparison {
63    /// Evaluate `value <comparison> threshold`. `Equal` uses a small
64    /// relative epsilon rather than exact `==`, since the values being
65    /// compared (loss, gradient norm, memory usage) are computed floats
66    /// that are never expected to match a configured threshold bit-for-bit.
67    fn apply(&self, value: f64, threshold: f64) -> bool {
68        match self {
69            Comparison::Greater => value > threshold,
70            Comparison::Less => value < threshold,
71            Comparison::GreaterEqual => value >= threshold,
72            Comparison::LessEqual => value <= threshold,
73            Comparison::Equal => (value - threshold).abs() <= 1e-9_f64.max(threshold.abs() * 1e-9),
74        }
75    }
76}
77
78/// Hook action types
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub enum HookAction {
81    /// Inspect tensor values
82    InspectTensor,
83    /// Track gradient flow
84    TrackGradients,
85    /// Record layer activations
86    RecordActivations,
87    /// Save tensor snapshot to file
88    SaveSnapshot { path: String },
89    /// Generate alert
90    Alert {
91        message: String,
92        severity: AlertSeverity,
93    },
94    /// Execute custom callback
95    CustomCallback { name: String },
96    /// Pause training for manual inspection
97    PauseTraining,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub enum AlertSeverity {
102    Info,
103    Warning,
104    Critical,
105}
106
107/// Hook configuration
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct HookConfig {
110    pub id: Uuid,
111    pub name: String,
112    pub trigger: HookTrigger,
113    pub actions: Vec<HookAction>,
114    pub enabled: bool,
115    pub max_executions: Option<usize>,
116    pub layer_patterns: Vec<String>, // Regex patterns for layer names
117}
118
119/// Hook execution context
120#[derive(Debug)]
121pub struct HookContext {
122    pub step: usize,
123    pub layer_name: String,
124    pub tensor_shape: Vec<usize>,
125    pub is_forward: bool,
126    pub metadata: HashMap<String, String>,
127    /// Current training loss, if the caller has reported one via
128    /// [`HookManager::set_loss`]. `None` (rather than a fabricated 0.0 or a
129    /// stale value) means no loss has been reported yet for this session --
130    /// [`HookCondition::LossThreshold`] never fires on a hook it has no
131    /// real data to evaluate.
132    pub loss: Option<f64>,
133    /// Current gradient norm, if reported via
134    /// [`HookManager::set_gradient_norm`]. See `loss` for the `None`
135    /// semantics.
136    pub grad_norm: Option<f64>,
137    /// Current memory usage in MB, if reported via
138    /// [`HookManager::set_memory_mb`]. See `loss` for the `None` semantics.
139    pub memory_mb: Option<f64>,
140}
141
142/// Hook execution statistics
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct HookStats {
145    pub hook_id: Uuid,
146    pub hook_name: String,
147    pub total_executions: usize,
148    pub last_execution_step: Option<usize>,
149    pub total_execution_time_ms: f64,
150    pub avg_execution_time_ms: f64,
151    pub errors: usize,
152}
153
154/// Hook execution result
155#[derive(Debug)]
156pub enum HookResult {
157    Success,
158    Error(String),
159    Skipped(String),
160}
161
162/// Callback function type for custom hooks
163pub type HookCallback = Box<dyn Fn(&HookContext, &[u8]) -> Result<()> + Send + Sync>;
164
165/// Hook manager for coordinating debugging hooks
166pub struct HookManager {
167    hooks: HashMap<Uuid, HookConfig>,
168    hook_stats: HashMap<Uuid, HookStats>,
169    callbacks: HashMap<String, HookCallback>,
170    execution_count: HashMap<Uuid, usize>,
171    global_step: usize,
172    enabled: bool,
173    /// Latest reported loss, memory (MB) and gradient norm -- fed into every
174    /// [`HookContext`] built by [`HookManager::execute_hooks`], so
175    /// [`HookCondition::LossThreshold`], [`HookCondition::GradientNormThreshold`]
176    /// and [`HookCondition::MemoryThreshold`] have real values to compare
177    /// against instead of firing unconditionally. See
178    /// [`HookManager::set_loss`] / [`HookManager::set_gradient_norm`] /
179    /// [`HookManager::set_memory_mb`].
180    current_loss: Option<f64>,
181    current_grad_norm: Option<f64>,
182    current_memory_mb: Option<f64>,
183    /// Real tensor/gradient inspector wired to [`HookAction::InspectTensor`]
184    /// and [`HookAction::TrackGradients`]: both actions compute genuine
185    /// statistics (mean/std/min/max/NaN & Inf counts, ...) over the tensor
186    /// data the hook actually received, retrievable afterwards via
187    /// [`Self::tensor_inspector`]. Neither action is a logged no-op.
188    tensor_inspector: TensorInspector,
189    /// Real activation recorder wired to [`HookAction::RecordActivations`]:
190    /// registers the layer's real values with genuine statistics
191    /// (mean/std/median/quartiles/sparsity/outliers), retrievable via
192    /// [`Self::activation_visualizer`].
193    activation_visualizer: ActivationVisualizer,
194    /// Last tensor tracked in `tensor_inspector` (via `InspectTensor` or
195    /// `RecordActivations`) for each layer name, so a later
196    /// `TrackGradients` call on the same layer attaches real gradient
197    /// statistics to that same entry via
198    /// [`TensorInspector::inspect_gradients`] rather than creating an
199    /// unrelated one. See [`Self::execute_action`].
200    layer_tensor_ids: HashMap<String, Uuid>,
201    /// Shared pause flag set by [`HookAction::PauseTraining`]. See the
202    /// contract documented on [`Self::pause_flag`].
203    pause_flag: Arc<AtomicBool>,
204}
205
206impl std::fmt::Debug for HookManager {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        f.debug_struct("HookManager")
209            .field("hooks", &self.hooks)
210            .field("hook_stats", &self.hook_stats)
211            .field("execution_count", &self.execution_count)
212            .field("global_step", &self.global_step)
213            .field("enabled", &self.enabled)
214            .field("callbacks", &format!("{} callbacks", self.callbacks.len()))
215            .field("current_loss", &self.current_loss)
216            .field("current_grad_norm", &self.current_grad_norm)
217            .field("current_memory_mb", &self.current_memory_mb)
218            .field("tensor_inspector", &self.tensor_inspector)
219            .field("activation_visualizer", &self.activation_visualizer)
220            .field("layer_tensor_ids", &self.layer_tensor_ids)
221            .field("paused", &self.pause_flag.load(Ordering::Relaxed))
222            .finish()
223    }
224}
225
226impl HookManager {
227    /// Create a new hook manager
228    pub fn new() -> Self {
229        Self {
230            hooks: HashMap::new(),
231            hook_stats: HashMap::new(),
232            callbacks: HashMap::new(),
233            execution_count: HashMap::new(),
234            global_step: 0,
235            enabled: true,
236            current_loss: None,
237            current_grad_norm: None,
238            current_memory_mb: None,
239            tensor_inspector: TensorInspector::new(&crate::DebugConfig::default()),
240            activation_visualizer: ActivationVisualizer::new(),
241            layer_tensor_ids: HashMap::new(),
242            pause_flag: Arc::new(AtomicBool::new(false)),
243        }
244    }
245
246    /// Register a new hook
247    pub fn register_hook(&mut self, config: HookConfig) -> Result<Uuid> {
248        let hook_id = config.id;
249
250        // Initialize statistics
251        self.hook_stats.insert(
252            hook_id,
253            HookStats {
254                hook_id,
255                hook_name: config.name.clone(),
256                total_executions: 0,
257                last_execution_step: None,
258                total_execution_time_ms: 0.0,
259                avg_execution_time_ms: 0.0,
260                errors: 0,
261            },
262        );
263
264        self.execution_count.insert(hook_id, 0);
265        self.hooks.insert(hook_id, config);
266
267        tracing::debug!("Registered hook {}", hook_id);
268        Ok(hook_id)
269    }
270
271    /// Register a custom callback
272    pub fn register_callback(&mut self, name: String, callback: HookCallback) {
273        self.callbacks.insert(name, callback);
274    }
275
276    /// Remove a hook
277    pub fn remove_hook(&mut self, hook_id: Uuid) -> Option<HookConfig> {
278        self.hook_stats.remove(&hook_id);
279        self.execution_count.remove(&hook_id);
280        self.hooks.remove(&hook_id)
281    }
282
283    /// Enable/disable a specific hook
284    pub fn set_hook_enabled(&mut self, hook_id: Uuid, enabled: bool) -> Result<()> {
285        if let Some(hook) = self.hooks.get_mut(&hook_id) {
286            hook.enabled = enabled;
287            Ok(())
288        } else {
289            Err(anyhow::anyhow!("Hook {} not found", hook_id))
290        }
291    }
292
293    /// Enable/disable all hooks
294    pub fn set_enabled(&mut self, enabled: bool) {
295        self.enabled = enabled;
296    }
297
298    /// Update global step counter
299    pub fn set_step(&mut self, step: usize) {
300        self.global_step = step;
301    }
302
303    /// Report the current training loss for [`HookCondition::LossThreshold`]
304    /// evaluation. Call this once per step before [`Self::execute_hooks`];
305    /// without it, `LossThreshold` conditions never fire (see
306    /// `Self::evaluate_condition`).
307    pub fn set_loss(&mut self, loss: f64) {
308        self.current_loss = Some(loss);
309    }
310
311    /// Report the current gradient norm for
312    /// [`HookCondition::GradientNormThreshold`] evaluation. See
313    /// [`Self::set_loss`].
314    pub fn set_gradient_norm(&mut self, grad_norm: f64) {
315        self.current_grad_norm = Some(grad_norm);
316    }
317
318    /// Report current memory usage (in MB) for
319    /// [`HookCondition::MemoryThreshold`] evaluation. See [`Self::set_loss`].
320    pub fn set_memory_mb(&mut self, memory_mb: f64) {
321        self.current_memory_mb = Some(memory_mb);
322    }
323
324    /// Real statistics recorded by [`HookAction::InspectTensor`] and
325    /// [`HookAction::TrackGradients`] hooks executed so far -- shapes,
326    /// mean/std/min/max, NaN/Inf counts, per-tensor alerts, etc, computed
327    /// from the actual tensor data each hook received.
328    pub fn tensor_inspector(&self) -> &TensorInspector {
329        &self.tensor_inspector
330    }
331
332    /// Mutable access to the wired [`TensorInspector`], e.g. to call
333    /// [`TensorInspector::clear`] between epochs.
334    pub fn tensor_inspector_mut(&mut self) -> &mut TensorInspector {
335        &mut self.tensor_inspector
336    }
337
338    /// Real per-layer activation statistics recorded by
339    /// [`HookAction::RecordActivations`] hooks executed so far.
340    pub fn activation_visualizer(&self) -> &ActivationVisualizer {
341        &self.activation_visualizer
342    }
343
344    /// Mutable access to the wired [`ActivationVisualizer`].
345    pub fn activation_visualizer_mut(&mut self) -> &mut ActivationVisualizer {
346        &mut self.activation_visualizer
347    }
348
349    /// Returns a clone of the shared pause flag that
350    /// [`HookAction::PauseTraining`] sets.
351    ///
352    /// # Contract
353    ///
354    /// The hook system runs *inside* [`Self::execute_hooks`], called by
355    /// whatever training loop is driving it -- it has no independent
356    /// thread of control and therefore cannot itself halt that loop.
357    /// What [`HookAction::PauseTraining`] truthfully *can* do, and does,
358    /// is set this flag to `true` (see `Self::execute_action`). For
359    /// "pause on hook" behaviour, the training loop must cooperate:
360    ///
361    ///  1. Once, after constructing the [`HookManager`], clone this flag
362    ///     out with `manager.pause_flag()` and keep the `Arc` alongside
363    ///     the loop state.
364    ///  2. On each step (or at another convenient point), check
365    ///     `flag.load(Ordering::SeqCst)` -- equivalently
366    ///     [`Self::is_paused`] on the manager, if the loop still has
367    ///     access to it -- and if `true`, actually stop advancing (block
368    ///     for operator input, yield to a debug console, etc).
369    ///  3. Call [`Self::resume_training`] (or `flag.store(false, ...)`
370    ///     directly) once ready to continue.
371    ///
372    /// A training loop that never polls this flag is simply not pausable
373    /// by hooks; [`HookAction::PauseTraining`] does not claim otherwise --
374    /// it only guarantees the flag itself is set truthfully when it fires.
375    pub fn pause_flag(&self) -> Arc<AtomicBool> {
376        Arc::clone(&self.pause_flag)
377    }
378
379    /// `true` if a [`HookAction::PauseTraining`] hook has fired and
380    /// nothing has called [`Self::resume_training`] since. See
381    /// [`Self::pause_flag`] for the full contract.
382    pub fn is_paused(&self) -> bool {
383        self.pause_flag.load(Ordering::SeqCst)
384    }
385
386    /// Clears the pause flag set by [`HookAction::PauseTraining`]. See
387    /// [`Self::pause_flag`] for the full contract.
388    pub fn resume_training(&self) {
389        self.pause_flag.store(false, Ordering::SeqCst);
390    }
391
392    /// Execute hooks for a tensor operation
393    pub fn execute_hooks<T>(
394        &mut self,
395        layer_name: &str,
396        tensor_data: &[T],
397        tensor_shape: &[usize],
398        is_forward: bool,
399        metadata: Option<HashMap<String, String>>,
400    ) -> Vec<(Uuid, HookResult)>
401    where
402        T: Clone + Into<f64> + 'static,
403    {
404        if !self.enabled {
405            return Vec::new();
406        }
407
408        let context = HookContext {
409            step: self.global_step,
410            layer_name: layer_name.to_string(),
411            tensor_shape: tensor_shape.to_vec(),
412            is_forward,
413            metadata: metadata.unwrap_or_default(),
414            loss: self.current_loss,
415            grad_norm: self.current_grad_norm,
416            memory_mb: self.current_memory_mb,
417        };
418
419        let mut results = Vec::new();
420
421        // Convert tensor data to bytes for callbacks / snapshotting, which
422        // want the tensor's raw in-memory representation regardless of `T`.
423        let tensor_bytes = unsafe {
424            std::slice::from_raw_parts(
425                tensor_data.as_ptr() as *const u8,
426                std::mem::size_of_val(tensor_data),
427            )
428        };
429        // Real numeric values (widened via `T: Into<f64>`), used by the
430        // analysis actions (InspectTensor / TrackGradients /
431        // RecordActivations) to compute genuine statistics -- see
432        // `execute_action`. Computed once here, before `T` is erased.
433        let tensor_values: Vec<f64> = tensor_data.iter().cloned().map(Into::into).collect();
434
435        // Collect hook IDs and configs to avoid borrowing conflicts
436        let hooks_to_execute: Vec<(Uuid, HookConfig)> =
437            self.hooks.iter().map(|(id, config)| (*id, config.clone())).collect();
438
439        for (hook_id, hook_config) in hooks_to_execute {
440            if !hook_config.enabled {
441                continue;
442            }
443
444            // Check if we should execute this hook
445            if let Some(should_execute) = self.should_execute_hook(&hook_config, &context) {
446                if !should_execute {
447                    results.push((
448                        hook_id,
449                        HookResult::Skipped("Condition not met".to_string()),
450                    ));
451                    continue;
452                }
453            }
454
455            // Check execution count limits
456            let current_count = self.execution_count.get(&hook_id).copied().unwrap_or(0);
457            if let Some(max_executions) = hook_config.max_executions {
458                if current_count >= max_executions {
459                    results.push((
460                        hook_id,
461                        HookResult::Skipped("Max executions reached".to_string()),
462                    ));
463                    continue;
464                }
465            }
466
467            // Execute hook
468            let start_time = std::time::Instant::now();
469            let result =
470                self.execute_single_hook(&hook_config, &context, tensor_bytes, &tensor_values);
471            let execution_time = start_time.elapsed().as_millis() as f64;
472
473            // Update statistics
474            if let Some(stats) = self.hook_stats.get_mut(&hook_id) {
475                stats.total_executions += 1;
476                stats.last_execution_step = Some(self.global_step);
477                stats.total_execution_time_ms += execution_time;
478                stats.avg_execution_time_ms =
479                    stats.total_execution_time_ms / stats.total_executions as f64;
480
481                if matches!(result, HookResult::Error(_)) {
482                    stats.errors += 1;
483                }
484            }
485
486            // Update execution count
487            if let Some(count) = self.execution_count.get_mut(&hook_id) {
488                *count += 1;
489            }
490
491            results.push((hook_id, result));
492        }
493
494        results
495    }
496
497    /// Get hook configuration
498    pub fn get_hook(&self, hook_id: Uuid) -> Option<&HookConfig> {
499        self.hooks.get(&hook_id)
500    }
501
502    /// Get all hooks
503    pub fn get_all_hooks(&self) -> Vec<&HookConfig> {
504        self.hooks.values().collect()
505    }
506
507    /// Get hook statistics
508    pub fn get_hook_stats(&self, hook_id: Uuid) -> Option<&HookStats> {
509        self.hook_stats.get(&hook_id)
510    }
511
512    /// Get all hook statistics
513    pub fn get_all_stats(&self) -> Vec<&HookStats> {
514        self.hook_stats.values().collect()
515    }
516
517    /// Clear all hooks
518    pub fn clear_hooks(&mut self) {
519        self.hooks.clear();
520        self.hook_stats.clear();
521        self.execution_count.clear();
522        self.callbacks.clear();
523    }
524
525    /// Create a convenient tensor inspection hook
526    pub fn create_tensor_inspection_hook(&mut self, layer_patterns: Vec<String>) -> Result<Uuid> {
527        let config = HookConfig {
528            id: Uuid::new_v4(),
529            name: "Tensor Inspector".to_string(),
530            trigger: HookTrigger::EveryForward,
531            actions: vec![HookAction::InspectTensor],
532            enabled: true,
533            max_executions: None,
534            layer_patterns,
535        };
536
537        self.register_hook(config)
538    }
539
540    /// Create a gradient tracking hook
541    pub fn create_gradient_tracking_hook(&mut self, layer_patterns: Vec<String>) -> Result<Uuid> {
542        let config = HookConfig {
543            id: Uuid::new_v4(),
544            name: "Gradient Tracker".to_string(),
545            trigger: HookTrigger::EveryBackward,
546            actions: vec![HookAction::TrackGradients],
547            enabled: true,
548            max_executions: None,
549            layer_patterns,
550        };
551
552        self.register_hook(config)
553    }
554
555    /// Create a conditional alert hook
556    pub fn create_alert_hook(
557        &mut self,
558        condition: HookCondition,
559        message: String,
560        severity: AlertSeverity,
561    ) -> Result<Uuid> {
562        let config = HookConfig {
563            id: Uuid::new_v4(),
564            name: "Alert Hook".to_string(),
565            trigger: HookTrigger::Conditional(condition),
566            actions: vec![HookAction::Alert { message, severity }],
567            enabled: true,
568            max_executions: None,
569            layer_patterns: vec![".*".to_string()], // Match all layers
570        };
571
572        self.register_hook(config)
573    }
574
575    // Private helper methods
576
577    fn should_execute_hook(&self, hook: &HookConfig, context: &HookContext) -> Option<bool> {
578        // Check layer pattern matching
579        if !hook.layer_patterns.is_empty() {
580            let matches_pattern = hook.layer_patterns.iter().any(|pattern| {
581                regex::Regex::new(pattern)
582                    .map(|re| re.is_match(&context.layer_name))
583                    .unwrap_or(false)
584            });
585
586            if !matches_pattern {
587                return Some(false);
588            }
589        }
590
591        match &hook.trigger {
592            HookTrigger::EveryForward => Some(context.is_forward),
593            HookTrigger::EveryBackward => Some(!context.is_forward),
594            HookTrigger::EveryNSteps(n) => Some(context.step.is_multiple_of(*n)),
595            HookTrigger::Conditional(condition) => {
596                Some(self.evaluate_condition(condition, context))
597            },
598            HookTrigger::Once => {
599                let count = self.execution_count.get(&hook.id).copied().unwrap_or(0);
600                Some(count == 0)
601            },
602            HookTrigger::LayerSpecific(layers) => Some(layers.contains(&context.layer_name)),
603        }
604    }
605
606    /// Evaluate a single [`HookCondition`] against the current context.
607    ///
608    /// `LossThreshold` / `GradientNormThreshold` / `MemoryThreshold` compare
609    /// against real values reported via [`Self::set_loss`] /
610    /// [`Self::set_gradient_norm`] / [`Self::set_memory_mb`]. If the caller
611    /// never reported that metric for this session, the corresponding
612    /// `context` field is `None` and the condition returns `false` -- never
613    /// `true` -- since a threshold cannot honestly be judged "met" against
614    /// data that was never provided. This intentionally differs from the
615    /// old behavior, where every one of these three conditions fired
616    /// unconditionally (`_ => true`) regardless of whether any relevant
617    /// data existed.
618    fn evaluate_condition(&self, condition: &HookCondition, context: &HookContext) -> bool {
619        match condition {
620            HookCondition::StepRange { start, end } => {
621                context.step >= *start && context.step <= *end
622            },
623            HookCondition::Custom(name) => {
624                // A custom condition fires when the caller has put `name` into
625                // the hook context's metadata. That IS the contract -- the
626                // caller decides when the flag is present -- not a stand-in for
627                // some richer predicate.
628                context.metadata.contains_key(name)
629            },
630            HookCondition::LossThreshold {
631                threshold,
632                comparison,
633            } => context.loss.map(|loss| comparison.apply(loss, *threshold)).unwrap_or(false),
634            HookCondition::GradientNormThreshold {
635                threshold,
636                comparison,
637            } => context
638                .grad_norm
639                .map(|grad_norm| comparison.apply(grad_norm, *threshold))
640                .unwrap_or(false),
641            HookCondition::MemoryThreshold { threshold_mb } => {
642                context.memory_mb.map(|memory_mb| memory_mb > *threshold_mb).unwrap_or(false)
643            },
644        }
645    }
646
647    fn execute_single_hook(
648        &mut self,
649        hook: &HookConfig,
650        context: &HookContext,
651        tensor_data: &[u8],
652        tensor_values: &[f64],
653    ) -> HookResult {
654        for action in &hook.actions {
655            match self.execute_action(action, context, tensor_data, tensor_values) {
656                Ok(()) => continue,
657                Err(e) => return HookResult::Error(e.to_string()),
658            }
659        }
660        HookResult::Success
661    }
662
663    /// Reshape `tensor_values` into an `ArrayD<f64>` using the shape
664    /// reported in `context`, for handoff to [`TensorInspector`]. Returns a
665    /// structured error (never a fabricated/garbage array) if the element
666    /// count does not match the declared shape.
667    fn build_array(context: &HookContext, tensor_values: &[f64]) -> Result<ArrayD<f64>> {
668        ArrayD::from_shape_vec(IxDyn(&context.tensor_shape), tensor_values.to_vec()).map_err(|e| {
669            anyhow::anyhow!(
670                "hook tensor shape {:?} does not match {} data element(s) reported for \
671                     layer '{}': {}",
672                context.tensor_shape,
673                tensor_values.len(),
674                context.layer_name,
675                e
676            )
677        })
678    }
679
680    /// Real implementation shared by [`HookAction::InspectTensor`] and
681    /// [`HookAction::RecordActivations`]-as-tensor-tracking: reshapes the
682    /// real tensor values and hands them to [`TensorInspector::inspect_tensor`],
683    /// then remembers the resulting id for this layer so a later
684    /// `TrackGradients` call can attach gradient statistics to the same
685    /// entry. Returns the real tensor id on success.
686    fn inspect_and_track(
687        &mut self,
688        context: &HookContext,
689        tensor_values: &[f64],
690        operation: &str,
691    ) -> Result<Uuid> {
692        let array = Self::build_array(context, tensor_values)?;
693        let id = self.tensor_inspector.inspect_tensor(
694            &array,
695            &context.layer_name,
696            Some(context.layer_name.as_str()),
697            Some(operation),
698        )?;
699        self.layer_tensor_ids.insert(context.layer_name.clone(), id);
700        Ok(id)
701    }
702
703    fn execute_action(
704        &mut self,
705        action: &HookAction,
706        context: &HookContext,
707        tensor_data: &[u8],
708        tensor_values: &[f64],
709    ) -> Result<()> {
710        match action {
711            HookAction::InspectTensor => {
712                let id = self.inspect_and_track(context, tensor_values, "hook: InspectTensor")?;
713                tracing::debug!(
714                    "Inspected tensor in layer '{}' at step {} -> tensor id {}",
715                    context.layer_name,
716                    context.step,
717                    id
718                );
719                Ok(())
720            },
721            HookAction::TrackGradients => {
722                // Attach real gradient statistics to the tensor previously
723                // tracked for this layer (via InspectTensor /
724                // RecordActivations on an earlier forward pass) when one
725                // exists; otherwise this data becomes its own tracked
726                // entry so it is never silently dropped.
727                let id = if let Some(&existing) = self.layer_tensor_ids.get(&context.layer_name) {
728                    let array = Self::build_array(context, tensor_values)?;
729                    self.tensor_inspector.inspect_gradients(existing, &array)?;
730                    existing
731                } else {
732                    self.inspect_and_track(
733                        context,
734                        tensor_values,
735                        "hook: TrackGradients (no prior forward tensor tracked for this layer)",
736                    )?
737                };
738                tracing::debug!(
739                    "Tracked gradients in layer '{}' at step {} -> tensor id {}",
740                    context.layer_name,
741                    context.step,
742                    id
743                );
744                Ok(())
745            },
746            HookAction::RecordActivations => {
747                let values_f32: Vec<f32> = tensor_values.iter().map(|&v| v as f32).collect();
748                self.activation_visualizer.register(
749                    &context.layer_name,
750                    values_f32,
751                    context.tensor_shape.clone(),
752                )?;
753                tracing::debug!(
754                    "Recorded {} activation value(s) in layer '{}' at step {}",
755                    tensor_values.len(),
756                    context.layer_name,
757                    context.step
758                );
759                Ok(())
760            },
761            HookAction::SaveSnapshot { path } => {
762                let file_path =
763                    format!("{}_{}_step_{}.bin", path, context.layer_name, context.step);
764                std::fs::write(&file_path, tensor_data)?;
765                tracing::info!("Saved tensor snapshot to {}", file_path);
766                Ok(())
767            },
768            HookAction::Alert { message, severity } => {
769                match severity {
770                    AlertSeverity::Info => tracing::info!("Hook Alert: {}", message),
771                    AlertSeverity::Warning => tracing::warn!("Hook Alert: {}", message),
772                    AlertSeverity::Critical => tracing::error!("Hook Alert: {}", message),
773                }
774                Ok(())
775            },
776            HookAction::CustomCallback { name } => {
777                if let Some(callback) = self.callbacks.get(name) {
778                    callback(context, tensor_data)?;
779                } else {
780                    return Err(anyhow::anyhow!("Callback '{}' not found", name));
781                }
782                Ok(())
783            },
784            HookAction::PauseTraining => {
785                // Real action: flips the shared flag a training loop can
786                // poll. See `Self::pause_flag` for the full cooperative
787                // contract -- the hook system cannot halt the caller's
788                // loop directly, only signal it truthfully.
789                self.pause_flag.store(true, Ordering::SeqCst);
790                tracing::warn!(
791                    "Training paused by hook at step {} in layer '{}' -- poll \
792                     HookManager::is_paused()/pause_flag() from the training loop to observe \
793                     this, and call HookManager::resume_training() to clear it",
794                    context.step,
795                    context.layer_name
796                );
797                Ok(())
798            },
799        }
800    }
801}
802
803impl Default for HookManager {
804    fn default() -> Self {
805        Self::new()
806    }
807}
808
809/// Builder for creating hook configurations
810pub struct HookBuilder {
811    config: HookConfig,
812}
813
814impl HookBuilder {
815    pub fn new(name: &str) -> Self {
816        Self {
817            config: HookConfig {
818                id: Uuid::new_v4(),
819                name: name.to_string(),
820                trigger: HookTrigger::EveryForward,
821                actions: Vec::new(),
822                enabled: true,
823                max_executions: None,
824                layer_patterns: Vec::new(),
825            },
826        }
827    }
828
829    pub fn trigger(mut self, trigger: HookTrigger) -> Self {
830        self.config.trigger = trigger;
831        self
832    }
833
834    pub fn action(mut self, action: HookAction) -> Self {
835        self.config.actions.push(action);
836        self
837    }
838
839    pub fn actions(mut self, actions: Vec<HookAction>) -> Self {
840        self.config.actions = actions;
841        self
842    }
843
844    pub fn max_executions(mut self, max: usize) -> Self {
845        self.config.max_executions = Some(max);
846        self
847    }
848
849    pub fn layer_patterns(mut self, patterns: Vec<String>) -> Self {
850        self.config.layer_patterns = patterns;
851        self
852    }
853
854    pub fn enabled(mut self, enabled: bool) -> Self {
855        self.config.enabled = enabled;
856        self
857    }
858
859    pub fn build(self) -> HookConfig {
860        self.config
861    }
862}
863
864/// Convenience macros for creating hooks
865#[macro_export]
866macro_rules! tensor_hook {
867    ($name:expr, $patterns:expr) => {
868        HookBuilder::new($name)
869            .trigger(HookTrigger::EveryForward)
870            .action(HookAction::InspectTensor)
871            .layer_patterns($patterns)
872            .build()
873    };
874}
875
876#[macro_export]
877macro_rules! gradient_hook {
878    ($name:expr, $patterns:expr) => {
879        HookBuilder::new($name)
880            .trigger(HookTrigger::EveryBackward)
881            .action(HookAction::TrackGradients)
882            .layer_patterns($patterns)
883            .build()
884    };
885}
886
887#[macro_export]
888macro_rules! alert_hook {
889    ($condition:expr, $message:expr, $severity:expr) => {
890        HookBuilder::new("Alert Hook")
891            .trigger(HookTrigger::Conditional($condition))
892            .action(HookAction::Alert {
893                message: $message.to_string(),
894                severity: $severity,
895            })
896            .build()
897    };
898}
899
900// ─────────────────────────────────────────────────────────────────────────────
901// Tests
902// ─────────────────────────────────────────────────────────────────────────────
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    fn make_hook_config(name: &str, trigger: HookTrigger) -> HookConfig {
909        HookConfig {
910            id: Uuid::new_v4(),
911            name: name.to_string(),
912            trigger,
913            actions: vec![HookAction::InspectTensor],
914            enabled: true,
915            max_executions: None,
916            layer_patterns: vec![],
917        }
918    }
919
920    // ── HookManager construction ────────────────────────────────────────────
921
922    #[test]
923    fn test_hook_manager_new_defaults() {
924        let mgr = HookManager::new();
925        assert!(mgr.enabled);
926        assert_eq!(mgr.global_step, 0);
927        assert!(mgr.get_all_hooks().is_empty());
928        assert!(mgr.get_all_stats().is_empty());
929    }
930
931    #[test]
932    fn test_hook_manager_default_equals_new() {
933        let mgr = HookManager::default();
934        assert!(mgr.enabled);
935    }
936
937    // ── register_hook ──────────────────────────────────────────────────────
938
939    #[test]
940    fn test_register_hook_returns_uuid() {
941        let mut mgr = HookManager::new();
942        let config = make_hook_config("test", HookTrigger::EveryForward);
943        let id = config.id;
944        let returned = mgr.register_hook(config).expect("register should succeed");
945        assert_eq!(returned, id);
946    }
947
948    #[test]
949    fn test_register_multiple_hooks() {
950        let mut mgr = HookManager::new();
951        for i in 0..5 {
952            let cfg = make_hook_config(&format!("h{}", i), HookTrigger::EveryForward);
953            mgr.register_hook(cfg).expect("register should succeed");
954        }
955        assert_eq!(mgr.get_all_hooks().len(), 5);
956    }
957
958    #[test]
959    fn test_hook_stats_initialized_on_register() {
960        let mut mgr = HookManager::new();
961        let cfg = make_hook_config("h0", HookTrigger::EveryForward);
962        let id = mgr.register_hook(cfg).expect("register should succeed");
963        let stats = mgr.get_hook_stats(id).expect("stats should exist");
964        assert_eq!(stats.total_executions, 0);
965        assert_eq!(stats.errors, 0);
966    }
967
968    // ── remove_hook ────────────────────────────────────────────────────────
969
970    #[test]
971    fn test_remove_hook_returns_config() {
972        let mut mgr = HookManager::new();
973        let cfg = make_hook_config("remove_me", HookTrigger::EveryBackward);
974        let id = mgr.register_hook(cfg).expect("register");
975        let removed = mgr.remove_hook(id);
976        assert!(removed.is_some());
977        assert_eq!(removed.expect("should be some").name, "remove_me");
978    }
979
980    #[test]
981    fn test_remove_nonexistent_hook_returns_none() {
982        let mut mgr = HookManager::new();
983        let id = Uuid::new_v4();
984        assert!(mgr.remove_hook(id).is_none());
985    }
986
987    // ── set_hook_enabled ───────────────────────────────────────────────────
988
989    #[test]
990    fn test_set_hook_enabled_ok() {
991        let mut mgr = HookManager::new();
992        let cfg = make_hook_config("h", HookTrigger::EveryForward);
993        let id = mgr.register_hook(cfg).expect("register");
994        mgr.set_hook_enabled(id, false).expect("should succeed");
995        let hook = mgr.get_hook(id).expect("hook should exist");
996        assert!(!hook.enabled);
997        mgr.set_hook_enabled(id, true).expect("re-enable");
998        let hook = mgr.get_hook(id).expect("hook should exist");
999        assert!(hook.enabled);
1000    }
1001
1002    #[test]
1003    fn test_set_hook_enabled_nonexistent_errors() {
1004        let mut mgr = HookManager::new();
1005        let result = mgr.set_hook_enabled(Uuid::new_v4(), true);
1006        assert!(result.is_err());
1007    }
1008
1009    // ── set_enabled (global) ───────────────────────────────────────────────
1010
1011    #[test]
1012    fn test_global_disable_stops_execution() {
1013        let mut mgr = HookManager::new();
1014        mgr.set_enabled(false);
1015        mgr.register_hook(make_hook_config("h", HookTrigger::EveryForward))
1016            .expect("register");
1017        let results = mgr.execute_hooks("layer", &[1u8, 2u8], &[2], true, None);
1018        assert!(
1019            results.is_empty(),
1020            "globally disabled manager should execute nothing"
1021        );
1022    }
1023
1024    // ── set_step ───────────────────────────────────────────────────────────
1025
1026    #[test]
1027    fn test_set_step_updates_counter() {
1028        let mut mgr = HookManager::new();
1029        mgr.set_step(42);
1030        assert_eq!(mgr.global_step, 42);
1031    }
1032
1033    // ── execute_hooks ──────────────────────────────────────────────────────
1034
1035    #[test]
1036    fn test_execute_hooks_disabled_hook_skipped() {
1037        let mut mgr = HookManager::new();
1038        let mut cfg = make_hook_config("h", HookTrigger::EveryForward);
1039        cfg.enabled = false;
1040        mgr.register_hook(cfg).expect("register");
1041        let results = mgr.execute_hooks("layer", &[0u8], &[1], true, None);
1042        // Disabled hook → no results (the impl skips it without adding an entry)
1043        assert_eq!(results.len(), 0);
1044    }
1045
1046    #[test]
1047    fn test_execute_hooks_every_forward_fires_on_forward() {
1048        let mut mgr = HookManager::new();
1049        mgr.register_hook(make_hook_config("h", HookTrigger::EveryForward))
1050            .expect("register");
1051        let results = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1052        assert_eq!(results.len(), 1);
1053    }
1054
1055    #[test]
1056    fn test_execute_hooks_every_forward_skipped_on_backward() {
1057        let mut mgr = HookManager::new();
1058        // No layer_patterns → pattern check skipped, trigger decides.
1059        let cfg = make_hook_config("h", HookTrigger::EveryForward);
1060        mgr.register_hook(cfg).expect("register");
1061        let results = mgr.execute_hooks("layer", &[1u8], &[1], false, None);
1062        // is_forward=false → the hook's should_execute returns Some(false) → Skipped
1063        assert_eq!(results.len(), 1);
1064        let (_, ref outcome) = results[0];
1065        assert!(matches!(outcome, HookResult::Skipped(_)));
1066    }
1067
1068    #[test]
1069    fn test_execute_hooks_max_executions_respected() {
1070        let mut mgr = HookManager::new();
1071        let mut cfg = make_hook_config("once", HookTrigger::EveryForward);
1072        cfg.max_executions = Some(1);
1073        mgr.register_hook(cfg).expect("register");
1074
1075        // First execution should succeed
1076        let r1 = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1077        assert_eq!(r1.len(), 1);
1078        assert!(matches!(r1[0].1, HookResult::Success));
1079
1080        // Second execution should be Skipped
1081        let r2 = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1082        assert_eq!(r2.len(), 1);
1083        assert!(matches!(r2[0].1, HookResult::Skipped(_)));
1084    }
1085
1086    // ── HookCondition metric thresholds ─────────────────────────────────────
1087    //
1088    // Regression tests for the `_ => true` bug: LossThreshold /
1089    // GradientNormThreshold / MemoryThreshold used to fire on every single
1090    // step regardless of the configured threshold. Each test below would
1091    // have failed against that old behavior (the "never met" and
1092    // "no data reported" cases would incorrectly have produced `Success`).
1093
1094    fn make_conditional_hook_config(condition: HookCondition) -> HookConfig {
1095        HookConfig {
1096            id: Uuid::new_v4(),
1097            name: "conditional".to_string(),
1098            trigger: HookTrigger::Conditional(condition),
1099            actions: vec![HookAction::InspectTensor],
1100            enabled: true,
1101            max_executions: None,
1102            layer_patterns: vec![],
1103        }
1104    }
1105
1106    #[test]
1107    fn test_loss_threshold_does_not_fire_without_reported_loss() {
1108        let mut mgr = HookManager::new();
1109        let cond = HookCondition::LossThreshold {
1110            threshold: 1.0,
1111            comparison: Comparison::Greater,
1112        };
1113        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1114
1115        // No `set_loss` call: the old `_ => true` fallback would fire this
1116        // unconditionally even though no loss was ever reported.
1117        let results = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1118        assert_eq!(results.len(), 1);
1119        assert!(
1120            matches!(results[0].1, HookResult::Skipped(_)),
1121            "must not fire when no loss has been reported, got {:?}",
1122            results[0].1
1123        );
1124    }
1125
1126    #[test]
1127    fn test_loss_threshold_fires_when_exceeded() {
1128        let mut mgr = HookManager::new();
1129        let cond = HookCondition::LossThreshold {
1130            threshold: 1.0,
1131            comparison: Comparison::Greater,
1132        };
1133        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1134
1135        mgr.set_loss(5.0); // loss spiked above threshold
1136        let results = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1137        assert_eq!(results.len(), 1);
1138        assert!(matches!(results[0].1, HookResult::Success));
1139    }
1140
1141    #[test]
1142    fn test_loss_threshold_does_not_fire_when_below_threshold() {
1143        let mut mgr = HookManager::new();
1144        let cond = HookCondition::LossThreshold {
1145            threshold: 1.0,
1146            comparison: Comparison::Greater,
1147        };
1148        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1149
1150        mgr.set_loss(0.1); // well below threshold
1151        let results = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1152        assert_eq!(results.len(), 1);
1153        assert!(
1154            matches!(results[0].1, HookResult::Skipped(_)),
1155            "must not fire when loss is below the threshold, got {:?}",
1156            results[0].1
1157        );
1158    }
1159
1160    #[test]
1161    fn test_gradient_norm_threshold_does_not_fire_without_reported_norm() {
1162        let mut mgr = HookManager::new();
1163        let cond = HookCondition::GradientNormThreshold {
1164            threshold: 10.0,
1165            comparison: Comparison::Greater,
1166        };
1167        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1168
1169        let results = mgr.execute_hooks("layer", &[1u8], &[1], false, None);
1170        assert_eq!(results.len(), 1);
1171        assert!(matches!(results[0].1, HookResult::Skipped(_)));
1172    }
1173
1174    #[test]
1175    fn test_gradient_norm_threshold_fires_on_explosion() {
1176        let mut mgr = HookManager::new();
1177        let cond = HookCondition::GradientNormThreshold {
1178            threshold: 10.0,
1179            comparison: Comparison::Greater,
1180        };
1181        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1182
1183        mgr.set_gradient_norm(1000.0); // exploding gradient
1184        let results = mgr.execute_hooks("layer", &[1u8], &[1], false, None);
1185        assert_eq!(results.len(), 1);
1186        assert!(matches!(results[0].1, HookResult::Success));
1187    }
1188
1189    #[test]
1190    fn test_gradient_norm_threshold_respects_less_comparison() {
1191        let mut mgr = HookManager::new();
1192        // "fire when gradient vanishes below 1e-6"
1193        let cond = HookCondition::GradientNormThreshold {
1194            threshold: 1e-6,
1195            comparison: Comparison::Less,
1196        };
1197        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1198
1199        mgr.set_gradient_norm(0.5); // healthy gradient, should not fire
1200        let healthy = mgr.execute_hooks("layer", &[1u8], &[1], false, None);
1201        assert!(matches!(healthy[0].1, HookResult::Skipped(_)));
1202
1203        mgr.set_gradient_norm(1e-9); // vanished, should fire
1204        let vanished = mgr.execute_hooks("layer", &[1u8], &[1], false, None);
1205        assert!(matches!(vanished[0].1, HookResult::Success));
1206    }
1207
1208    #[test]
1209    fn test_memory_threshold_does_not_fire_without_reported_memory() {
1210        let mut mgr = HookManager::new();
1211        let cond = HookCondition::MemoryThreshold {
1212            threshold_mb: 1000.0,
1213        };
1214        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1215
1216        let results = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1217        assert_eq!(results.len(), 1);
1218        assert!(matches!(results[0].1, HookResult::Skipped(_)));
1219    }
1220
1221    #[test]
1222    fn test_memory_threshold_fires_over_limit() {
1223        let mut mgr = HookManager::new();
1224        let cond = HookCondition::MemoryThreshold {
1225            threshold_mb: 1000.0,
1226        };
1227        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1228
1229        mgr.set_memory_mb(4096.0);
1230        let results = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1231        assert_eq!(results.len(), 1);
1232        assert!(matches!(results[0].1, HookResult::Success));
1233    }
1234
1235    #[test]
1236    fn test_memory_threshold_does_not_fire_under_limit() {
1237        let mut mgr = HookManager::new();
1238        let cond = HookCondition::MemoryThreshold {
1239            threshold_mb: 1000.0,
1240        };
1241        mgr.register_hook(make_conditional_hook_config(cond)).expect("register");
1242
1243        mgr.set_memory_mb(50.0);
1244        let results = mgr.execute_hooks("layer", &[1u8], &[1], true, None);
1245        assert_eq!(results.len(), 1);
1246        assert!(matches!(results[0].1, HookResult::Skipped(_)));
1247    }
1248
1249    #[test]
1250    fn test_comparison_apply_all_variants() {
1251        assert!(Comparison::Greater.apply(2.0, 1.0));
1252        assert!(!Comparison::Greater.apply(1.0, 1.0));
1253        assert!(Comparison::Less.apply(0.5, 1.0));
1254        assert!(!Comparison::Less.apply(1.0, 1.0));
1255        assert!(Comparison::GreaterEqual.apply(1.0, 1.0));
1256        assert!(Comparison::LessEqual.apply(1.0, 1.0));
1257        assert!(Comparison::Equal.apply(1.0, 1.0));
1258        assert!(!Comparison::Equal.apply(1.5, 1.0));
1259    }
1260
1261    // ── clear_hooks ────────────────────────────────────────────────────────
1262
1263    #[test]
1264    fn test_clear_hooks_empties_everything() {
1265        let mut mgr = HookManager::new();
1266        mgr.register_hook(make_hook_config("h0", HookTrigger::EveryForward))
1267            .expect("register");
1268        mgr.register_hook(make_hook_config("h1", HookTrigger::EveryBackward))
1269            .expect("register");
1270        mgr.clear_hooks();
1271        assert!(mgr.get_all_hooks().is_empty());
1272        assert!(mgr.get_all_stats().is_empty());
1273    }
1274
1275    // ── convenience builders ────────────────────────────────────────────────
1276
1277    #[test]
1278    fn test_create_tensor_inspection_hook() {
1279        let mut mgr = HookManager::new();
1280        let id = mgr
1281            .create_tensor_inspection_hook(vec!["attention.*".to_string()])
1282            .expect("should succeed");
1283        assert!(mgr.get_hook(id).is_some());
1284    }
1285
1286    #[test]
1287    fn test_create_gradient_tracking_hook() {
1288        let mut mgr = HookManager::new();
1289        let id = mgr
1290            .create_gradient_tracking_hook(vec!["fc.*".to_string()])
1291            .expect("should succeed");
1292        let hook = mgr.get_hook(id).expect("should exist");
1293        assert!(matches!(hook.trigger, HookTrigger::EveryBackward));
1294    }
1295
1296    #[test]
1297    fn test_create_alert_hook() {
1298        let mut mgr = HookManager::new();
1299        let cond = HookCondition::StepRange { start: 0, end: 100 };
1300        let id = mgr
1301            .create_alert_hook(cond, "loss exploded".to_string(), AlertSeverity::Critical)
1302            .expect("should succeed");
1303        let hook = mgr.get_hook(id).expect("should exist");
1304        assert!(matches!(hook.trigger, HookTrigger::Conditional(_)));
1305    }
1306
1307    // ── HookBuilder ────────────────────────────────────────────────────────
1308
1309    #[test]
1310    fn test_hook_builder_basic() {
1311        let cfg = HookBuilder::new("my_hook")
1312            .trigger(HookTrigger::EveryNSteps(10))
1313            .action(HookAction::TrackGradients)
1314            .max_executions(50)
1315            .layer_patterns(vec!["norm".to_string()])
1316            .enabled(true)
1317            .build();
1318
1319        assert_eq!(cfg.name, "my_hook");
1320        assert!(matches!(cfg.trigger, HookTrigger::EveryNSteps(10)));
1321        assert_eq!(cfg.max_executions, Some(50));
1322        assert!(cfg.enabled);
1323    }
1324
1325    // ── enum variants ──────────────────────────────────────────────────────
1326
1327    #[test]
1328    fn test_hook_trigger_variants() {
1329        let triggers: Vec<String> = vec![
1330            format!("{:?}", HookTrigger::EveryForward),
1331            format!("{:?}", HookTrigger::EveryBackward),
1332            format!("{:?}", HookTrigger::EveryNSteps(5)),
1333            format!("{:?}", HookTrigger::Once),
1334            format!("{:?}", HookTrigger::LayerSpecific(vec![])),
1335        ];
1336        for t in &triggers {
1337            assert!(!t.is_empty());
1338        }
1339    }
1340
1341    #[test]
1342    fn test_hook_action_variants() {
1343        let actions: Vec<String> = vec![
1344            format!("{:?}", HookAction::InspectTensor),
1345            format!("{:?}", HookAction::TrackGradients),
1346            format!("{:?}", HookAction::RecordActivations),
1347            format!(
1348                "{:?}",
1349                HookAction::SaveSnapshot {
1350                    path: "/tmp".to_string()
1351                }
1352            ),
1353            format!(
1354                "{:?}",
1355                HookAction::Alert {
1356                    message: "x".to_string(),
1357                    severity: AlertSeverity::Info
1358                }
1359            ),
1360            format!(
1361                "{:?}",
1362                HookAction::CustomCallback {
1363                    name: "cb".to_string()
1364                }
1365            ),
1366            format!("{:?}", HookAction::PauseTraining),
1367        ];
1368        for a in &actions {
1369            assert!(!a.is_empty());
1370        }
1371    }
1372
1373    #[test]
1374    fn test_alert_severity_variants() {
1375        let severities = [
1376            AlertSeverity::Info,
1377            AlertSeverity::Warning,
1378            AlertSeverity::Critical,
1379        ];
1380        for s in &severities {
1381            assert!(!format!("{:?}", s).is_empty());
1382        }
1383    }
1384
1385    #[test]
1386    fn test_comparison_variants() {
1387        let comps = [
1388            Comparison::Greater,
1389            Comparison::Less,
1390            Comparison::Equal,
1391            Comparison::GreaterEqual,
1392            Comparison::LessEqual,
1393        ];
1394        for c in &comps {
1395            assert!(!format!("{:?}", c).is_empty());
1396        }
1397    }
1398
1399    #[test]
1400    fn test_hook_stats_fields() {
1401        let id = Uuid::new_v4();
1402        let stats = HookStats {
1403            hook_id: id,
1404            hook_name: "perf_hook".to_string(),
1405            total_executions: 100,
1406            last_execution_step: Some(99),
1407            total_execution_time_ms: 500.0,
1408            avg_execution_time_ms: 5.0,
1409            errors: 2,
1410        };
1411        assert_eq!(stats.total_executions, 100);
1412        assert_eq!(stats.errors, 2);
1413        assert_eq!(stats.last_execution_step, Some(99));
1414    }
1415
1416    // ── execute_action honesty: InspectTensor / TrackGradients /
1417    //    RecordActivations / PauseTraining must really act, not just log ──
1418    //
1419    // Regression tests for the bug where these four actions logged a debug
1420    // message and returned `Ok(())` with no other effect. Each test below
1421    // would fail against that old behavior (no tensor would ever be
1422    // tracked, no activation ever registered, no flag ever set).
1423
1424    fn make_action_hook(action: HookAction, trigger: HookTrigger) -> HookConfig {
1425        HookConfig {
1426            id: Uuid::new_v4(),
1427            name: "action_hook".to_string(),
1428            trigger,
1429            actions: vec![action],
1430            enabled: true,
1431            max_executions: None,
1432            layer_patterns: vec![],
1433        }
1434    }
1435
1436    #[test]
1437    fn test_inspect_tensor_computes_real_statistics() {
1438        let mut mgr = HookManager::new();
1439        mgr.register_hook(make_action_hook(
1440            HookAction::InspectTensor,
1441            HookTrigger::EveryForward,
1442        ))
1443        .expect("register");
1444
1445        let data = [2.0f64, 4.0, 6.0, 8.0];
1446        let results = mgr.execute_hooks("dense", &data, &[4], true, None);
1447        assert_eq!(results.len(), 1);
1448        assert!(
1449            matches!(results[0].1, HookResult::Success),
1450            "got {:?}",
1451            results[0].1
1452        );
1453
1454        let tracked = mgr.tensor_inspector().get_all_tensors();
1455        assert_eq!(
1456            tracked.len(),
1457            1,
1458            "InspectTensor must actually register a tracked tensor"
1459        );
1460        let info = tracked[0];
1461        assert_eq!(info.layer_name.as_deref(), Some("dense"));
1462        // mean/min/max of [2,4,6,8] -- real, not a placeholder constant.
1463        assert!((info.stats.mean - 5.0).abs() < 1e-9);
1464        assert_eq!(info.stats.min, 2.0);
1465        assert_eq!(info.stats.max, 8.0);
1466        assert_eq!(info.stats.total_elements, 4);
1467    }
1468
1469    #[test]
1470    fn test_inspect_tensor_flags_real_nan_alert() {
1471        let mut mgr = HookManager::new();
1472        mgr.register_hook(make_action_hook(
1473            HookAction::InspectTensor,
1474            HookTrigger::EveryForward,
1475        ))
1476        .expect("register");
1477
1478        let data = [1.0f64, f64::NAN, 3.0];
1479        let results = mgr.execute_hooks("nan_layer", &data, &[3], true, None);
1480        assert!(matches!(results[0].1, HookResult::Success));
1481
1482        let alerts = mgr.tensor_inspector().get_alerts();
1483        assert!(
1484            alerts.iter().any(|a| a.tensor_name == "nan_layer"
1485                && matches!(
1486                    a.alert_type,
1487                    crate::tensor_inspector::TensorAlertType::NaNValues
1488                )),
1489            "a real NaN in the tensor must produce a real NaN alert, got {:?}",
1490            alerts
1491        );
1492    }
1493
1494    #[test]
1495    fn test_track_gradients_links_real_stats_to_prior_forward_tensor() {
1496        let mut mgr = HookManager::new();
1497        // `execute_hooks` returns results keyed by hook id in HashMap
1498        // iteration order (unspecified), so capture the ids up front and
1499        // look results up by id rather than assuming position 0.
1500        let inspect_id = mgr
1501            .register_hook(make_action_hook(
1502                HookAction::InspectTensor,
1503                HookTrigger::EveryForward,
1504            ))
1505            .expect("register forward hook");
1506        let grad_id = mgr
1507            .register_hook(make_action_hook(
1508                HookAction::TrackGradients,
1509                HookTrigger::EveryBackward,
1510            ))
1511            .expect("register backward hook");
1512
1513        // Forward pass: activations. Only the EveryForward hook should fire.
1514        let activations = [1.0f64, 2.0, 3.0];
1515        let fwd = mgr.execute_hooks("linear", &activations, &[3], true, None);
1516        let fwd_result = fwd.iter().find(|(id, _)| *id == inspect_id).map(|(_, r)| r);
1517        assert!(
1518            matches!(fwd_result, Some(HookResult::Success)),
1519            "got {:?}",
1520            fwd_result
1521        );
1522
1523        // Backward pass on the SAME layer: gradients, deliberately a
1524        // different distribution from the activations above. Only the
1525        // EveryBackward hook should fire.
1526        let gradients = [0.1f64, 0.2, 0.3];
1527        let bwd = mgr.execute_hooks("linear", &gradients, &[3], false, None);
1528        let bwd_result = bwd.iter().find(|(id, _)| *id == grad_id).map(|(_, r)| r);
1529        assert!(
1530            matches!(bwd_result, Some(HookResult::Success)),
1531            "got {:?}",
1532            bwd_result
1533        );
1534
1535        let tracked = mgr.tensor_inspector().get_all_tensors();
1536        assert_eq!(
1537            tracked.len(),
1538            1,
1539            "gradient stats must attach to the existing forward tensor, not spawn a second one"
1540        );
1541        let grad_stats = tracked[0]
1542            .gradient_stats
1543            .as_ref()
1544            .expect("TrackGradients must populate gradient_stats with real data");
1545        assert!(
1546            (grad_stats.mean - 0.2).abs() < 1e-9,
1547            "gradient mean must reflect the real gradient values, got {}",
1548            grad_stats.mean
1549        );
1550        // The forward tensor's own stats must be untouched by the gradient call.
1551        assert!((tracked[0].stats.mean - 2.0).abs() < 1e-9);
1552    }
1553
1554    #[test]
1555    fn test_track_gradients_without_prior_forward_still_tracks_real_data() {
1556        let mut mgr = HookManager::new();
1557        mgr.register_hook(make_action_hook(
1558            HookAction::TrackGradients,
1559            HookTrigger::EveryBackward,
1560        ))
1561        .expect("register");
1562
1563        // No InspectTensor ever ran for "orphan" -- TrackGradients must not
1564        // silently no-op just because there is nothing to attach to.
1565        let gradients = [10.0f64, 20.0, 30.0];
1566        let results = mgr.execute_hooks("orphan", &gradients, &[3], false, None);
1567        assert!(matches!(results[0].1, HookResult::Success));
1568
1569        let tracked = mgr.tensor_inspector().get_all_tensors();
1570        assert_eq!(tracked.len(), 1);
1571        assert!((tracked[0].stats.mean - 20.0).abs() < 1e-9);
1572    }
1573
1574    #[test]
1575    fn test_record_activations_computes_real_statistics() {
1576        let mut mgr = HookManager::new();
1577        mgr.register_hook(make_action_hook(
1578            HookAction::RecordActivations,
1579            HookTrigger::EveryForward,
1580        ))
1581        .expect("register");
1582
1583        let data = [0.0f64, 1.0, 2.0, 3.0];
1584        let results = mgr.execute_hooks("relu_1", &data, &[4], true, None);
1585        assert!(matches!(results[0].1, HookResult::Success));
1586
1587        let recorded = mgr
1588            .activation_visualizer()
1589            .get_activations("relu_1")
1590            .expect("RecordActivations must register real activation data");
1591        assert_eq!(recorded.values, vec![0.0f32, 1.0, 2.0, 3.0]);
1592        assert!((recorded.statistics.mean - 1.5).abs() < 1e-6);
1593        assert_eq!(recorded.shape, vec![4]);
1594    }
1595
1596    #[test]
1597    fn test_pause_training_sets_shared_flag_and_resume_clears_it() {
1598        let mut mgr = HookManager::new();
1599        let flag = mgr.pause_flag();
1600        assert!(!mgr.is_paused());
1601        assert!(!flag.load(Ordering::SeqCst));
1602
1603        mgr.register_hook(make_action_hook(
1604            HookAction::PauseTraining,
1605            HookTrigger::EveryForward,
1606        ))
1607        .expect("register");
1608
1609        let results = mgr.execute_hooks("any_layer", &[0.0f64], &[1], true, None);
1610        assert!(matches!(results[0].1, HookResult::Success));
1611
1612        // Real, observable side effect: the SAME shared flag handed out
1613        // before the hook ever ran now reads true, and the manager agrees.
1614        assert!(
1615            flag.load(Ordering::SeqCst),
1616            "PauseTraining must set the real shared pause flag"
1617        );
1618        assert!(mgr.is_paused());
1619
1620        mgr.resume_training();
1621        assert!(!mgr.is_paused());
1622        assert!(
1623            !flag.load(Ordering::SeqCst),
1624            "resume_training must clear the SAME shared flag"
1625        );
1626    }
1627
1628    #[test]
1629    fn test_inspect_tensor_shape_mismatch_is_a_structured_error_not_silent_success() {
1630        let mut mgr = HookManager::new();
1631        mgr.register_hook(make_action_hook(
1632            HookAction::InspectTensor,
1633            HookTrigger::EveryForward,
1634        ))
1635        .expect("register");
1636
1637        // 3 real values, but a shape claiming 10 -- must surface as an
1638        // error, never silently succeed or fabricate padding.
1639        let data = [1.0f64, 2.0, 3.0];
1640        let results = mgr.execute_hooks("mismatched", &data, &[10], true, None);
1641        assert_eq!(results.len(), 1);
1642        match &results[0].1 {
1643            HookResult::Error(msg) => {
1644                assert!(
1645                    msg.contains("mismatched"),
1646                    "error should name the layer: {}",
1647                    msg
1648                );
1649            },
1650            other => panic!("expected a structured Error, got {:?}", other),
1651        }
1652        assert!(
1653            mgr.tensor_inspector().get_all_tensors().is_empty(),
1654            "a shape mismatch must not fabricate a tracked tensor"
1655        );
1656    }
1657}