From aad33612fcda8dbde6bd07f5726933565dad8e4a Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Fri, 24 Jul 2026 09:34:19 +0900 Subject: [PATCH 01/14] Reduce CPU->GPU transfer every time step with learning. --- bindsnet/learning/MCC_learning.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py index 33ebacd0a..3fe2014c9 100644 --- a/bindsnet/learning/MCC_learning.py +++ b/bindsnet/learning/MCC_learning.py @@ -503,13 +503,28 @@ def _connection_update(self, **kwargs) -> None: source_s = self.source.s.view(batch_size, -1).float() target_s = self.target.s.view(batch_size, -1).float() - # Parse keyword arguments. + # Reward from current time step reward = kwargs["reward"] - a_plus = torch.tensor( - kwargs.get("a_plus", 1.0), device=self.feature_value.device + + # Build learning-rate and decay tensors + if not hasattr(self, "_a_plus_default"): + dev = self.feature_value.device + self._a_plus_default = torch.tensor(1.0, device=dev) + self._a_minus_default = torch.tensor(-1.0, device=dev) + dt = float(self.connection.dt) + self._decay_plus = torch.exp(-dt / self.tc_plus.to(dev)) + self._decay_minus = torch.exp(-dt / self.tc_minus.to(dev)) + a_plus = kwargs.get("a_plus", None) + a_plus = ( + self._a_plus_default + if a_plus is None + else torch.as_tensor(a_plus, device=self.feature_value.device) ) - a_minus = torch.tensor( - kwargs.get("a_minus", -1.0), device=self.feature_value.device + a_minus = kwargs.get("a_minus", None) + a_minus = ( + self._a_minus_default + if a_minus is None + else torch.as_tensor(a_minus, device=self.feature_value.device) ) # Compute weight update based on the eligibility value of the past timestep. @@ -535,9 +550,9 @@ def _connection_update(self, **kwargs) -> None: self.feature_value += update # Update P^+ and P^- values. - self.p_plus *= torch.exp(-self.connection.dt / self.tc_plus) + self.p_plus *= self._decay_plus self.p_plus += a_plus * source_s - self.p_minus *= torch.exp(-self.connection.dt / self.tc_minus) + self.p_minus *= self._decay_minus self.p_minus += a_minus * target_s # Calculate point eligibility value. From 1b6e4a2e56d46106b7f4b18c32adf6fd52c8b435 Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Fri, 24 Jul 2026 10:00:06 +0900 Subject: [PATCH 02/14] Foldable pipelines --- bindsnet/network/topology.py | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index 10df45824..42d687030 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -413,6 +413,7 @@ def __init__( pipeline: list = [], manual_update: bool = False, traces: bool = False, + compute_dtype: Optional[torch.dtype] = None, **kwargs, ) -> None: # language=rst @@ -426,6 +427,8 @@ def __init__( :param manual_update: Set to :code:`True` to disable automatic updates (applying learning rules) to connection features. False by default, updates called after each time step :param traces: Set to :code:`True` to record history of connection activity (for monitors) + :param compute_dtype: Optional low-precision dtype (e.g. ``torch.bfloat16``) + for the matmul fast path only; ``None`` (default) keeps full ``float32``. """ super().__init__(source, target, device, pipeline, **kwargs) @@ -434,6 +437,49 @@ def __init__( if self.traces: self.activity = None + self._w_eff = None # 'Folded' weight matrix for faster computing + self._has_learning = None + self.compute_dtype = compute_dtype + + def _pipeline_has_learning(self) -> bool: + # language=rst + """Whether any pipeline feature carries a real (non-``NoOp``) learning + rule, i.e. whether the folded-weight cache must be invalidated on update.""" + if self._has_learning is None: + from ..learning.MCC_learning import NoOp + + self._has_learning = any( + not isinstance(getattr(f, "learning_rule", None), NoOp) + for f in self.pipeline + ) + return self._has_learning + + def _folded_weight(self) -> Optional[torch.Tensor]: + # language=rst + """ + Return the product of the pipeline's foldable feature values. If the + pipeline is empty or contains a non-foldable feature, returns none. + """ + if not self.pipeline: + return None + if self._w_eff is not None: + return self._w_eff + w_eff = None + for f in self.pipeline: + v = f.matmul_fold_value() + if v is None: + return None # a non-foldable feature -> use the generic path + w_eff = v if w_eff is None else w_eff * v + # A product of only boolean masks would be non-float; force the matmul + # dtype (float32 by default, or the opt-in low-precision compute_dtype). + if self.compute_dtype is not None: + w_eff = w_eff.to(self.compute_dtype) + elif not torch.is_floating_point(w_eff): + w_eff = w_eff.float() + if not self.manual_update: + self._w_eff = w_eff + return w_eff + def compute(self, s: torch.Tensor) -> torch.Tensor: # language=rst """ @@ -444,6 +490,18 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: decaying spike activation). """ + # Fast path: when the whole pipeline folds to a static elementwise-multiply + w_eff = self._folded_weight() + if w_eff is not None: + out_signal = s.view(s.size(0), self.source.n).to(w_eff.dtype) @ w_eff + if out_signal.dtype != torch.float32: + out_signal = out_signal.float() + if self.traces: + self.activity = out_signal + if out_signal.size() != torch.Size([s.size(0)] + self.target.shape): + return out_signal.view(s.size(0), *self.target.shape) + return out_signal + # Change to numeric type (torch doesn't like booleans for matrix ops) # Note: .float() is an expensive operation. Use as minimally as possible! # if s.dtype != torch.float32: @@ -516,6 +574,8 @@ def update(self, **kwargs) -> None: # Pipeline learning for f in self.pipeline: f.update(**kwargs) + if self._pipeline_has_learning(): + self._w_eff = None # weights changed -> rebuild fold next compute def normalize(self) -> None: # language=rst @@ -525,6 +585,7 @@ def normalize(self) -> None: # Normalize pipeline features for f in self.pipeline: f.normalize() + self._w_eff = None # normalization may change weights -> invalidate fold def reset_state_variables(self) -> None: # language=rst @@ -535,6 +596,7 @@ def reset_state_variables(self) -> None: for f in self.pipeline: f.reset_state_variables() + self._w_eff = None # rebuild the fold cache at the next sample class Conv1dConnection(AbstractConnection): From 2428476a5d8d4aea4561bd70cabfad307e04c68f Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Fri, 24 Jul 2026 10:01:02 +0900 Subject: [PATCH 03/14] More files for foldable pipelines --- bindsnet/network/topology_features.py | 29 +++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/bindsnet/network/topology_features.py b/bindsnet/network/topology_features.py index 43ccc1389..c39288020 100644 --- a/bindsnet/network/topology_features.py +++ b/bindsnet/network/topology_features.py @@ -170,6 +170,21 @@ def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: """ pass + def matmul_fold_value(self) -> Optional[torch.Tensor]: + # language=rst + """ + Fast-path hook for :class:`MulticompartmentConnection`. + + If this feature is a *pure elementwise multiply* by a static + ``[source.n, target.n]`` tensor -- i.e. ``compute(x) == x * V`` with no + per-call side effects on ``V`` -- return that ``V`` so the connection can + fold the whole pipeline into one weight matrix and evaluate as a matmul + (``out = s @ W_eff``) instead of materialising ``[B, source.n, + target.n]``. Returning ``None`` (the safe default) forces the generic + pipeline path. + """ + return None + def prime_feature(self, connection, device, **kwargs) -> None: # language=rst """ @@ -507,6 +522,12 @@ def __init__( def compute(self, conn_spikes) -> torch.Tensor: return conn_spikes * self.value + def matmul_fold_value(self) -> Optional[torch.Tensor]: + # A boolean mask is a pure elementwise multiply (True->1, False->0). + if getattr(self, "sparse", False): + return None + return self.value + def reset_state_variables(self) -> None: pass @@ -644,6 +665,14 @@ def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: return return_val + def matmul_fold_value(self) -> Optional[torch.Tensor]: + # Foldable only as a plain multiply: skip when polarity enforcement or + # per-time-step normalisation would mutate ``value`` during compute, or + # when the value is stored sparse. + if self.enforce_polarity or self.norm_frequency == "time step" or self.sparse: + return None + return self.value + def prime_feature(self, connection, device, **kwargs) -> None: #### Initialize value #### if self.value is None: From 677d196e47895a47d60827e5fbc24b2bc8f5c62c Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Sat, 25 Jul 2026 03:38:40 +0900 Subject: [PATCH 04/14] Sparse compute --- bindsnet/network/topology.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index 42d687030..07ee8a5b6 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -414,6 +414,7 @@ def __init__( manual_update: bool = False, traces: bool = False, compute_dtype: Optional[torch.dtype] = None, + sparse_compute: bool = False, **kwargs, ) -> None: # language=rst @@ -429,6 +430,8 @@ def __init__( :param traces: Set to :code:`True` to record history of connection activity (for monitors) :param compute_dtype: Optional low-precision dtype (e.g. ``torch.bfloat16``) for the matmul fast path only; ``None`` (default) keeps full ``float32``. + :param sparse_compute: Set to :code:`True` to compute sparse activity (<~20% neurons active) + efficiently """ super().__init__(source, target, device, pipeline, **kwargs) @@ -440,6 +443,7 @@ def __init__( self._w_eff = None # 'Folded' weight matrix for faster computing self._has_learning = None self.compute_dtype = compute_dtype + self.sparse_compute = sparse_compute def _pipeline_has_learning(self) -> bool: # language=rst @@ -480,6 +484,20 @@ def _folded_weight(self) -> Optional[torch.Tensor]: self._w_eff = w_eff return w_eff + def _sparse_matmul(self, s_flat: torch.Tensor, w_eff: torch.Tensor) -> torch.Tensor: + # language=rst + """ + Activity-sparse form of ``s_flat @ w_eff``. Read only + the rows of ``w_eff`` for source neurons that spiked this step. Numerically + identical to the dense matmul. More efficient when few source neurons are active. + """ + active = s_flat.any(dim=0).nonzero(as_tuple=False).squeeze(1) + if active.numel() == 0: + return torch.zeros( + s_flat.size(0), self.target.n, device=w_eff.device, dtype=w_eff.dtype + ) + return s_flat[:, active].to(w_eff.dtype) @ w_eff.index_select(0, active) + def compute(self, s: torch.Tensor) -> torch.Tensor: # language=rst """ @@ -493,7 +511,11 @@ def compute(self, s: torch.Tensor) -> torch.Tensor: # Fast path: when the whole pipeline folds to a static elementwise-multiply w_eff = self._folded_weight() if w_eff is not None: - out_signal = s.view(s.size(0), self.source.n).to(w_eff.dtype) @ w_eff + s_flat = s.view(s.size(0), self.source.n) + if self.sparse_compute: + out_signal = self._sparse_matmul(s_flat, w_eff) + else: + out_signal = s_flat.to(w_eff.dtype) @ w_eff if out_signal.dtype != torch.float32: out_signal = out_signal.float() if self.traces: From 884010b09253cfe322c25ebd6ba1d155314fafb3 Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Sun, 26 Jul 2026 16:07:14 +0900 Subject: [PATCH 05/14] Folding pipeline computation --- bindsnet/network/topology.py | 173 +++++++++----------------- bindsnet/network/topology_features.py | 113 +++++++++-------- 2 files changed, 115 insertions(+), 171 deletions(-) diff --git a/bindsnet/network/topology.py b/bindsnet/network/topology.py index 07ee8a5b6..eaa4485f8 100644 --- a/bindsnet/network/topology.py +++ b/bindsnet/network/topology.py @@ -413,7 +413,6 @@ def __init__( pipeline: list = [], manual_update: bool = False, traces: bool = False, - compute_dtype: Optional[torch.dtype] = None, sparse_compute: bool = False, **kwargs, ) -> None: @@ -428,10 +427,8 @@ def __init__( :param manual_update: Set to :code:`True` to disable automatic updates (applying learning rules) to connection features. False by default, updates called after each time step :param traces: Set to :code:`True` to record history of connection activity (for monitors) - :param compute_dtype: Optional low-precision dtype (e.g. ``torch.bfloat16``) - for the matmul fast path only; ``None`` (default) keeps full ``float32``. - :param sparse_compute: Set to :code:`True` to compute sparse activity (<~20% neurons active) - efficiently + :param sparse_compute: Set to :code:`True` to read only the rows of the effective + weight for source neurons that spiked (a win only when few are active). """ super().__init__(source, target, device, pipeline, **kwargs) @@ -440,123 +437,75 @@ def __init__( if self.traces: self.activity = None - self._w_eff = None # 'Folded' weight matrix for faster computing - self._has_learning = None - self.compute_dtype = compute_dtype self.sparse_compute = sparse_compute - def _pipeline_has_learning(self) -> bool: - # language=rst - """Whether any pipeline feature carries a real (non-``NoOp``) learning - rule, i.e. whether the folded-weight cache must be invalidated on update.""" - if self._has_learning is None: - from ..learning.MCC_learning import NoOp - - self._has_learning = any( - not isinstance(getattr(f, "learning_rule", None), NoOp) - for f in self.pipeline - ) - return self._has_learning - - def _folded_weight(self) -> Optional[torch.Tensor]: - # language=rst - """ - Return the product of the pipeline's foldable feature values. If the - pipeline is empty or contains a non-foldable feature, returns none. - """ - if not self.pipeline: - return None - if self._w_eff is not None: - return self._w_eff - w_eff = None - for f in self.pipeline: - v = f.matmul_fold_value() - if v is None: - return None # a non-foldable feature -> use the generic path - w_eff = v if w_eff is None else w_eff * v - # A product of only boolean masks would be non-float; force the matmul - # dtype (float32 by default, or the opt-in low-precision compute_dtype). - if self.compute_dtype is not None: - w_eff = w_eff.to(self.compute_dtype) - elif not torch.is_floating_point(w_eff): - w_eff = w_eff.float() - if not self.manual_update: - self._w_eff = w_eff - return w_eff - - def _sparse_matmul(self, s_flat: torch.Tensor, w_eff: torch.Tensor) -> torch.Tensor: - # language=rst - """ - Activity-sparse form of ``s_flat @ w_eff``. Read only - the rows of ``w_eff`` for source neurons that spiked this step. Numerically - identical to the dense matmul. More efficient when few source neurons are active. - """ - active = s_flat.any(dim=0).nonzero(as_tuple=False).squeeze(1) - if active.numel() == 0: - return torch.zeros( - s_flat.size(0), self.target.n, device=w_eff.device, dtype=w_eff.dtype - ) - return s_flat[:, active].to(w_eff.dtype) @ w_eff.index_select(0, active) - def compute(self, s: torch.Tensor) -> torch.Tensor: # language=rst """ - Compute pre-activations given spikes using connection weights. + Direct incoming spikes through the connection's feature pipeline. - :param s: Incoming spikes. - :return: Incoming spikes multiplied by synaptic weights (with or without - decaying spike activation). - """ + Each feature's ``compute`` returns its ``[source.n, target.n]`` value; how + it folds is set by the feature's ``op`` (``"mul"`` default, ``"add"``, + ``"sub"``). Folding the recurrence (start ``A = 1``, ``B = 0``): - # Fast path: when the whole pipeline folds to a static elementwise-multiply - w_eff = self._folded_weight() - if w_eff is not None: - s_flat = s.view(s.size(0), self.source.n) - if self.sparse_compute: - out_signal = self._sparse_matmul(s_flat, w_eff) - else: - out_signal = s_flat.to(w_eff.dtype) @ w_eff - if out_signal.dtype != torch.float32: - out_signal = out_signal.float() - if self.traces: - self.activity = out_signal - if out_signal.size() != torch.Size([s.size(0)] + self.target.shape): - return out_signal.view(s.size(0), *self.target.shape) - return out_signal - - # Change to numeric type (torch doesn't like booleans for matrix ops) - # Note: .float() is an expensive operation. Use as minimally as possible! - # if s.dtype != torch.float32: - # s = s.float() - - # Prepare broadcast from incoming spikes to all output neurons - # |conn_spikes| = [batch_size, source.n * target.n] - conn_spikes = s.view(s.size(0), self.source.n, 1).repeat(1, 1, self.target.n) - # TODO: ^ This could probably be optimized - - # Run through pipeline - for f in self.pipeline: - conn_spikes = f.compute(conn_spikes) + * ``mul`` factor ``a``: ``A <- a * A`` and ``B <- a * B`` + * ``add`` term ``b``: ``B <- B + b`` + * ``sub`` term ``b``: ``B <- B - b`` + + ``B`` stays ``None`` (unallocated) unless an additive feature is present, + so a purely multiplicative pipeline is exactly the single ``s @ A`` matmul. - # Sum signals for each of the output/terminal neurons - # |out_signal| = [batch_size, target.n] - if conn_spikes.size() != torch.Size([s.size(0), self.source.n, self.target.n]): - if conn_spikes.is_sparse: - conn_spikes = conn_spikes.to_dense() - conn_spikes = conn_spikes.view(s.size(0), self.source.n, self.target.n) + :param s: Incoming spikes, shape ``[batch, *source.shape]``. + :return: Post-synaptic input, shape ``[batch, *target.shape]``. + """ + s = s.view(s.size(0), self.source.n) - if conn_spikes.is_sparse: - out_signal = conn_spikes.to_dense().sum(1) + # running product of multiplicative factors, [source.n, target.n] + a_eff = None + # running additive offset, [source.n, target.n]; None while still zero + b_eff = None + for f in self.pipeline: + factor = f.compute(s) + op = getattr(f, "op", "mul") + if op == "mul": + a_eff = factor if a_eff is None else a_eff * factor + if b_eff is not None: + b_eff = b_eff * factor + else: # additive contribution: "add" -> +factor, "sub" -> -factor + term = factor if op == "add" else -factor + b_eff = term if b_eff is None else b_eff + term + + if a_eff is None: + # Degenerate pipeline with no multiplicative feature: every source + # neuron contributes with unit weight. + a_eff = torch.ones(s.size(1), b_eff.size(-1), device=s.device) + if not torch.is_floating_point(a_eff): + a_eff = a_eff.float() + + if self.sparse_compute: + # Read only the rows of A for source neurons that spiked this step + # (numerically identical; faster only when few are active). + active = s.any(dim=0).nonzero(as_tuple=False).squeeze(1) + if active.numel() == 0: + out = torch.zeros( + s.size(0), a_eff.size(-1), device=a_eff.device, dtype=a_eff.dtype + ) + else: + out = s[:, active].to(a_eff.dtype) @ a_eff.index_select(0, active) else: - out_signal = conn_spikes.sum(1) + out = s.to(a_eff.dtype) @ a_eff - if self.traces: - self.activity = out_signal + # Additive terms apply to every synapse regardless of spikes, so sum over + # all source rows (the closed form of the [batch, source.n, target.n] + # source-sum). + if b_eff is not None: + out = out + b_eff.sum(dim=0) - if out_signal.size() != torch.Size([s.size(0)] + self.target.shape): - return out_signal.view(s.size(0), *self.target.shape) - else: - return out_signal + if self.traces: + self.activity = out + if out.size() != torch.Size([s.size(0)] + self.target.shape): + return out.view(s.size(0), *self.target.shape) + return out def compute_window(self, s: torch.Tensor) -> torch.Tensor: # language=rst @@ -596,8 +545,6 @@ def update(self, **kwargs) -> None: # Pipeline learning for f in self.pipeline: f.update(**kwargs) - if self._pipeline_has_learning(): - self._w_eff = None # weights changed -> rebuild fold next compute def normalize(self) -> None: # language=rst @@ -607,7 +554,6 @@ def normalize(self) -> None: # Normalize pipeline features for f in self.pipeline: f.normalize() - self._w_eff = None # normalization may change weights -> invalidate fold def reset_state_variables(self) -> None: # language=rst @@ -618,7 +564,6 @@ def reset_state_variables(self) -> None: for f in self.pipeline: f.reset_state_variables() - self._w_eff = None # rebuild the fold cache at the next sample class Conv1dConnection(AbstractConnection): diff --git a/bindsnet/network/topology_features.py b/bindsnet/network/topology_features.py index c39288020..f6126eba2 100644 --- a/bindsnet/network/topology_features.py +++ b/bindsnet/network/topology_features.py @@ -18,6 +18,12 @@ class AbstractFeature(ABC): Features to operate on signals traversing a connection. """ + # How this feature folds in the connection's affine pipeline (see + # :meth:`MulticompartmentConnection.compute`): ``"mul"`` (elementwise multiply, + # the default), ``"add"`` or ``"sub"`` (elementwise add/subtract of the value + # returned by :meth:`compute`). + op = "mul" + @abstractmethod def __init__( self, @@ -163,28 +169,16 @@ def reset_state_variables(self) -> None: pass @abstractmethod - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: + def compute(self, s) -> Union[torch.Tensor, float, int]: # language=rst """ - Computes the feature being operated on a set of incoming signals. + Return this feature's ``[source.n, target.n]`` value, given pre-synaptic + spikes ``s`` of shape ``[batch, source.n]``. How the value folds into the + connection is set by :attr:`op` -- a multiplicative factor (``"mul"``, the + default), or an additive/subtractive offset (``"add"``/``"sub"``). """ pass - def matmul_fold_value(self) -> Optional[torch.Tensor]: - # language=rst - """ - Fast-path hook for :class:`MulticompartmentConnection`. - - If this feature is a *pure elementwise multiply* by a static - ``[source.n, target.n]`` tensor -- i.e. ``compute(x) == x * V`` with no - per-call side effects on ``V`` -- return that ``V`` so the connection can - fold the whole pipeline into one weight matrix and evaluate as a matmul - (``out = s @ W_eff``) instead of materialising ``[B, source.n, - target.n]``. Returning ``None`` (the safe default) forces the generic - pipeline path. - """ - return None - def prime_feature(self, connection, device, **kwargs) -> None: # language=rst """ @@ -437,11 +431,11 @@ def sparse_bernoulli(self): non_zero = values[mask] return torch.sparse_coo_tensor(indices, non_zero, self.value.size()) - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: + def compute(self, s) -> Union[torch.Tensor, float, int]: + # Factor: a fresh Bernoulli draw each step (resampled, never cached). if self.sparse: - return conn_spikes * self.sparse_bernoulli() - else: - return conn_spikes * torch.bernoulli(self.value) + return self.sparse_bernoulli() + return torch.bernoulli(self.value) def reset_state_variables(self) -> None: pass @@ -519,13 +513,7 @@ def __init__( self.name = name self.value = value - def compute(self, conn_spikes) -> torch.Tensor: - return conn_spikes * self.value - - def matmul_fold_value(self) -> Optional[torch.Tensor]: - # A boolean mask is a pure elementwise multiply (True->1, False->0). - if getattr(self, "sparse", False): - return None + def compute(self, s) -> torch.Tensor: return self.value def reset_state_variables(self) -> None: @@ -581,9 +569,9 @@ def __init__(self) -> None: def reset_state_variables(self) -> None: pass - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: - return conn_spikes.mean() * torch.ones( - self.source_n * self.target_n, device=self.device + def compute(self, s) -> Union[torch.Tensor, float, int]: + return s.float().mean() * torch.ones( + self.source_n, self.target_n, device=s.device ) def prime_feature(self, connection, device, **kwargs) -> None: @@ -651,7 +639,7 @@ def __init__( def reset_state_variables(self) -> None: pass - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: + def compute(self, s) -> Union[torch.Tensor, float, int]: if self.enforce_polarity: pos_mask = ~torch.logical_xor(self.value > 0, self.positive_mask) neg_mask = ~torch.logical_xor(self.value < 0, ~self.positive_mask) @@ -659,19 +647,11 @@ def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: self.value[~pos_mask] = 0.0001 self.value[~neg_mask] = -0.0001 - return_val = self.value * conn_spikes + factor = self.value if self.norm_frequency == "time step": + factor = factor.clone() self.normalize(time_step_norm=True) - - return return_val - - def matmul_fold_value(self) -> Optional[torch.Tensor]: - # Foldable only as a plain multiply: skip when polarity enforcement or - # per-time-step normalisation would mutate ``value`` during compute, or - # when the value is stored sparse. - if self.enforce_polarity or self.norm_frequency == "time step" or self.sparse: - return None - return self.value + return factor def prime_feature(self, connection, device, **kwargs) -> None: #### Initialize value #### @@ -734,11 +714,15 @@ def __init__( batch_size=batch_size, ) + # Bias is additive: folds as ``B <- B + value`` in the connection's pipeline. + op = "add" + def reset_state_variables(self) -> None: pass - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: - return conn_spikes + self.value + def compute(self, s) -> Union[torch.Tensor, float, int]: + # Additive offset added to every synapse (independent of the spikes). + return self.value def prime_feature(self, connection, device, **kwargs) -> None: #### Initialize value #### @@ -762,7 +746,7 @@ def __init__( ) -> None: # language=rst """ - Adds scalars to signals + Multiply all signals by a scalar :param name: Name of the feature :param value: Values to scale signals by :param value_dtype: Data type for :code:`value` tensor @@ -781,8 +765,8 @@ def __init__( def reset_state_variables(self) -> None: pass - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: - return conn_spikes * self.value + def compute(self, s) -> Union[torch.Tensor, float, int]: + return self.value def prime_feature(self, connection, device, **kwargs) -> None: #### Initialize value #### @@ -835,11 +819,17 @@ def __init__( self.degrade_function = degrade_function + # Degradation is subtractive: folded as ``B <- B - degrade_function(value)``. + op = "sub" + def reset_state_variables(self) -> None: pass - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: - return conn_spikes - self.degrade_function(self.value) + def compute(self, s) -> Union[torch.Tensor, float, int]: + # Subtractive offset (via degrade_function) applied to every synapse. + if self.degrade_function is not None: + return self.degrade_function(self.value) + return self.value class AdaptationBaseSynapsHistory(AbstractFeature): @@ -911,7 +901,11 @@ def forward(self, x): batch_size=batch_size, ) - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: + def compute(self, s) -> Union[torch.Tensor, float, int]: + # This feature needs the per-synapse spikes, so build them from s + conn_spikes = s.view(s.size(0), -1, 1).expand( + s.size(0), s.size(1), self.value.size(-1) + ) # Update the spike buffer if self.start_counter == False or conn_spikes.sum() > 0: @@ -940,7 +934,7 @@ def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: if self.sparse: self.value = self.value.to_sparse() - return conn_spikes * self.value + return self.value def reset_state_variables( self, @@ -1021,7 +1015,11 @@ def forward(self, x): batch_size=batch_size, ) - def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: + def compute(self, s) -> Union[torch.Tensor, float, int]: + # This feature needs the per-synapse spikes, so build them from s + conn_spikes = s.view(s.size(0), -1, 1).expand( + s.size(0), s.size(1), self.value.size(-1) + ) # Update the spike buffer if self.start_counter == False or conn_spikes.sum() > 0: @@ -1050,7 +1048,7 @@ def compute(self, conn_spikes) -> Union[torch.Tensor, float, int]: if self.sparse: self.value = self.value.to_sparse() - return conn_spikes * self.value + return self.value def reset_state_variables( self, @@ -1089,15 +1087,16 @@ def __init__( self.parent = parent_feature self.sub_feature = None # <-- Defined in non-abstract constructor - def compute(self, _) -> None: + def compute(self, s): # language=rst """ - Proxy function to catch a pipeline execution from topology.py's :code:`compute` function. Allows :code:`SubFeature` - objects to be executed like real features in the pipeline. + Proxy to run a parent feature's side-effect (e.g. normalize/update) from + inside the pipeline. Returns ``1`` so it is the identity factor in the fold. """ # sub_feature should be defined in the non-abstract constructor self.sub_feature() + return 1 class Normalization(AbstractSubFeature): From 48c98c04bed38b65ffeaa8104c3aa97c1ef4b92e Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Sun, 26 Jul 2026 20:21:56 +0900 Subject: [PATCH 06/14] Fixed indentation error with learning rule class --- bindsnet/learning/MCC_learning.py | 137 +++++++++++++++--------------- 1 file changed, 69 insertions(+), 68 deletions(-) diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py index 3fe2014c9..660bd538c 100644 --- a/bindsnet/learning/MCC_learning.py +++ b/bindsnet/learning/MCC_learning.py @@ -304,89 +304,90 @@ def _connection_update(self, **kwargs) -> None: def reset_state_variables(self): return - class Hebbian(MCC_LearningRule): + +class Hebbian(MCC_LearningRule): + # language=rst + """ + Simple Hebbian learning rule. Pre- and post-synaptic updates are both positive. + """ + + def __init__( + self, + connection: AbstractMulticompartmentConnection, + feature_value: Union[torch.Tensor, float, int], + nu: Optional[Union[float, Sequence[float]]] = None, + reduction: Optional[callable] = None, + decay: float = 0.0, + **kwargs, + ) -> None: # language=rst """ - Simple Hebbian learning rule. Pre- and post-synaptic updates are both positive. - """ + Constructor for ``Hebbian`` learning rule. - def __init__( - self, - connection: AbstractMulticompartmentConnection, - feature_value: Union[torch.Tensor, float, int], - nu: Optional[Union[float, Sequence[float]]] = None, - reduction: Optional[callable] = None, - decay: float = 0.0, + :param connection: An ``AbstractConnection`` object whose weights the + ``Hebbian`` learning rule will modify. + :param nu: Single or pair of learning rates for pre- and post-synaptic events. + :param reduction: Method for reducing parameter updates along the batch + dimension. + :param decay: Coefficient controlling rate of decay of the weights each iteration. + """ + super().__init__( + connection=connection, + feature_value=feature_value, + nu=nu, + reduction=reduction, + decay=decay, **kwargs, - ) -> None: - # language=rst - """ - Constructor for ``Hebbian`` learning rule. - - :param connection: An ``AbstractConnection`` object whose weights the - ``Hebbian`` learning rule will modify. - :param nu: Single or pair of learning rates for pre- and post-synaptic events. - :param reduction: Method for reducing parameter updates along the batch - dimension. - :param decay: Coefficient controlling rate of decay of the weights each iteration. - """ - super().__init__( - connection=connection, - feature_value=feature_value, - nu=nu, - reduction=reduction, - decay=decay, - **kwargs, - ) + ) - assert ( - self.source.traces and self.target.traces - ), "Both pre- and post-synaptic nodes must record spike traces." + assert ( + self.source.traces and self.target.traces + ), "Both pre- and post-synaptic nodes must record spike traces." - if isinstance(MulticompartmentConnection): - self.update = self._connection_update - self.feature_value = feature_value - # elif isinstance(connection, Conv2dConnection): - # self.update = self._conv2d_connection_update - else: - raise NotImplementedError( - "This learning rule is not supported for this Connection type." - ) + if isinstance(MulticompartmentConnection): + self.update = self._connection_update + self.feature_value = feature_value + # elif isinstance(connection, Conv2dConnection): + # self.update = self._conv2d_connection_update + else: + raise NotImplementedError( + "This learning rule is not supported for this Connection type." + ) - def _connection_update(self, **kwargs) -> None: - # language=rst - """ - Hebbian learning rule for ``Connection`` subclass of ``AbstractConnection`` - class. - """ + def _connection_update(self, **kwargs) -> None: + # language=rst + """ + Hebbian learning rule for ``Connection`` subclass of ``AbstractConnection`` + class. + """ - # Add polarities back to feature after updates - if self.enforce_polarity: - self.feature_value = torch.abs(self.feature_value) + # Add polarities back to feature after updates + if self.enforce_polarity: + self.feature_value = torch.abs(self.feature_value) - batch_size = self.source.batch_size + batch_size = self.source.batch_size - source_s = self.source.s.view(batch_size, -1).unsqueeze(2).float() - source_x = self.source.x.view(batch_size, -1).unsqueeze(2) - target_s = self.target.s.view(batch_size, -1).unsqueeze(1).float() - target_x = self.target.x.view(batch_size, -1).unsqueeze(1) + source_s = self.source.s.view(batch_size, -1).unsqueeze(2).float() + source_x = self.source.x.view(batch_size, -1).unsqueeze(2) + target_s = self.target.s.view(batch_size, -1).unsqueeze(1).float() + target_x = self.target.x.view(batch_size, -1).unsqueeze(1) - # Pre-synaptic update. - update = self.reduction(torch.bmm(source_s, target_x), dim=0) - self.feature_value += self.nu[0] * update + # Pre-synaptic update. + update = self.reduction(torch.bmm(source_s, target_x), dim=0) + self.feature_value += self.nu[0] * update - # Post-synaptic update. - update = self.reduction(torch.bmm(source_x, target_s), dim=0) - self.feature_value += self.nu[1] * update + # Post-synaptic update. + update = self.reduction(torch.bmm(source_x, target_s), dim=0) + self.feature_value += self.nu[1] * update - # Add polarities back to feature after updates - if self.enforce_polarity: - self.feature_value = self.feature_value * self.polarities + # Add polarities back to feature after updates + if self.enforce_polarity: + self.feature_value = self.feature_value * self.polarities - super().update() + super().update() - def reset_state_variables(self): - return + def reset_state_variables(self): + return class MSTDP(MCC_LearningRule): From 6f3c5a68151c745574a292c654a392805b27c6e7 Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Sun, 26 Jul 2026 21:45:25 +0900 Subject: [PATCH 07/14] Test cases and minor bug fixes --- bindsnet/learning/MCC_learning.py | 4 +- bindsnet/network/topology_features.py | 2 + test/network/test_connections.py | 408 ++++++++++++++++++++++++++ 3 files changed, 413 insertions(+), 1 deletion(-) diff --git a/bindsnet/learning/MCC_learning.py b/bindsnet/learning/MCC_learning.py index 660bd538c..9c2a7b781 100644 --- a/bindsnet/learning/MCC_learning.py +++ b/bindsnet/learning/MCC_learning.py @@ -315,6 +315,7 @@ def __init__( self, connection: AbstractMulticompartmentConnection, feature_value: Union[torch.Tensor, float, int], + range: Optional[Sequence[float]] = None, nu: Optional[Union[float, Sequence[float]]] = None, reduction: Optional[callable] = None, decay: float = 0.0, @@ -334,6 +335,7 @@ def __init__( super().__init__( connection=connection, feature_value=feature_value, + range=[-1, +1] if range is None else range, nu=nu, reduction=reduction, decay=decay, @@ -344,7 +346,7 @@ def __init__( self.source.traces and self.target.traces ), "Both pre- and post-synaptic nodes must record spike traces." - if isinstance(MulticompartmentConnection): + if isinstance(connection, MulticompartmentConnection): self.update = self._connection_update self.feature_value = feature_value # elif isinstance(connection, Conv2dConnection): diff --git a/bindsnet/network/topology_features.py b/bindsnet/network/topology_features.py index f6126eba2..3cfc5a672 100644 --- a/bindsnet/network/topology_features.py +++ b/bindsnet/network/topology_features.py @@ -84,6 +84,7 @@ def __init__( from ..learning.MCC_learning import ( NoOp, PostPre, + Hebbian, MSTDP, MSTDPET, ) @@ -91,6 +92,7 @@ def __init__( supported_rules = [ NoOp, PostPre, + Hebbian, MSTDP, MSTDPET, ] diff --git a/test/network/test_connections.py b/test/network/test_connections.py index 24f2333e8..416855359 100644 --- a/test/network/test_connections.py +++ b/test/network/test_connections.py @@ -1,4 +1,5 @@ import torch +import math from bindsnet.learning import ( MSTDP, @@ -12,6 +13,8 @@ from bindsnet.network import Network from bindsnet.network.nodes import Input, LIFNodes, SRM0Nodes from bindsnet.network.topology import * +import bindsnet.learning.MCC_learning as mcc +import bindsnet.network.topology_features as tf class TestConnection: @@ -164,7 +167,412 @@ def test_weights(self, conn_type, shape_a, shape_b, shape_w, *args, **kwargs): ) +class TestMultiCompartmentConnection: + + def __init__(self): + self.device = torch.device("cpu") + print(f"Using device '{self.device}' for the MCC test") + + # ----------------------------------------------------------------------- # + # Helpers # + # ----------------------------------------------------------------------- # + + def _make_mcc(self, pipeline, src_n, tgt_n, batch=1, sparse_compute=False): + """Build (and prime) a standalone MCC: Input(src_n) -> LIFNodes(tgt_n).""" + src = Input(n=src_n, traces=True) + tgt = LIFNodes(n=tgt_n, traces=True) + # batch_size is None until a Network sets it; the learning rules need it. + src.batch_size = batch + tgt.batch_size = batch + conn = MulticompartmentConnection( + source=src, + target=tgt, + device=self.device, + pipeline=pipeline, + sparse_compute=sparse_compute, + ) + conn.dt = 1.0 # not set until added to a Network; rules read connection.dt + return conn + + def _reference_expansion(self, pipeline, s, tgt_n): + """Pre-collapse pipeline features """ + b, src = s.shape + x = s.view(b, src, 1).expand(b, src, tgt_n).clone().float() + for f in pipeline: + op = getattr(f, "op", "mul") + if isinstance(f, tf.Degradation): + v = ( + f.degrade_function(f.value) + if f.degrade_function is not None + else f.value + ) + else: + v = f.value + if torch.is_tensor(v): + v = v.float() + if op == "mul": + x = x * v + elif op == "add": + x = x + v + else: # "sub" + x = x - v + return x.sum(dim=1) + + def _learning_conn(self, rule, w0, nu, rng=(-1.0, 1.0)): + """MCC with a single learnable Weight; returns (connection, feature).""" + src_n, tgt_n = w0.shape + conn = self._make_mcc( + [tf.Weight(name="w", value=w0.clone(), learning_rule=rule, nu=nu, range=rng)], + src_n, + tgt_n, + batch=1, + ) + return conn, conn.pipeline[0] + + def _mstdp_seq(self): + return [ + (torch.tensor([1.0, 0.0, 0.0]), torch.tensor([0.0, 0.0]), 0.0), + (torch.tensor([0.0, 0.0, 0.0]), torch.tensor([1.0, 0.0]), 1.0), + (torch.tensor([0.0, 0.0, 0.0]), torch.tensor([0.0, 0.0]), 1.0), + (torch.tensor([0.0, 0.0, 0.0]), torch.tensor([0.0, 0.0]), 1.0), + ] + + def _mstdp_reference(self, w0, seq, nu0, dt, tc_plus=20.0, tc_minus=20.0, rng=(-1.0, 1.0)): + w = w0.clone().float() + src_n, tgt_n = w.shape + p_plus, p_minus = torch.zeros(src_n), torch.zeros(tgt_n) + elig = torch.zeros(src_n, tgt_n) + dp, dm = math.exp(-dt / tc_plus), math.exp(-dt / tc_minus) + for src_s, tgt_s, reward in seq: + w = w + nu0 * reward * elig + p_plus = dp * p_plus + src_s + p_minus = dm * p_minus - tgt_s + elig = torch.outer(p_plus, tgt_s) + torch.outer(src_s, p_minus) + w = torch.clamp(w, rng[0], rng[1]) + return w + + def _mstdpet_reference( + self, w0, seq, nu0, dt, tc_plus=20.0, tc_minus=20.0, tc_e=25.0, rng=(-1.0, 1.0) + ): + w = w0.clone().float() + src_n, tgt_n = w.shape + p_plus, p_minus = torch.zeros(src_n), torch.zeros(tgt_n) + elig = torch.zeros(src_n, tgt_n) + elig_tr = torch.zeros(src_n, tgt_n) + for src_s, tgt_s, reward in seq: + elig_tr = elig_tr * math.exp(-dt / tc_e) + elig / tc_e + w = w + nu0 * dt * reward * elig_tr + p_plus = p_plus * math.exp(-dt / tc_plus) + src_s + p_minus = p_minus * math.exp(-dt / tc_minus) - tgt_s + elig = torch.outer(p_plus, tgt_s) + torch.outer(src_s, p_minus) + w = torch.clamp(w, rng[0], rng[1]) + return w + + # ----------------------------------------------------------------------- # + # Individual feature outputs # + # ----------------------------------------------------------------------- # + + def test_weight_feature_output(self): + s = torch.tensor([[1.0, 0.0, 1.0], [0.0, 1.0, 1.0]]) + w = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) + conn = self._make_mcc([tf.Weight(name="w", value=w.clone())], 3, 2, batch=2) + assert torch.allclose(conn.compute(s), s @ w, atol=1e-6) + + def test_mask_feature_output(self): + s = torch.tensor([[1.0, 1.0, 1.0]]) + w = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) + mask = torch.tensor([[True, False], [True, True], [False, True]]) + conn = self._make_mcc( + [tf.Weight(name="w", value=w.clone()), tf.Mask(name="m", value=mask.clone())], + 3, + 2, + ) + assert torch.allclose(conn.compute(s), s @ (w * mask.float()), atol=1e-6) + + def test_bias_feature_output(self): + # Bias is additive per-synapse; after the source-sum it adds bias.sum(0). + s = torch.tensor([[1.0, 0.0, 1.0], [1.0, 1.0, 0.0]]) + w = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) + bias = torch.tensor([[0.5, -0.5], [0.1, 0.2], [0.0, 1.0]]) + conn = self._make_mcc( + [tf.Weight(name="w", value=w.clone()), tf.Bias(name="b", value=bias.clone())], + 3, + 2, + batch=2, + ) + assert torch.allclose(conn.compute(s), s @ w + bias.sum(0), atol=1e-6) + + def test_intensity_feature_output(self): + # Intensity's value is a per-synapse [src, tgt] tensor (a constant 2.0 here). + s = torch.tensor([[1.0, 0.0, 1.0]]) + w = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) + intensity = torch.full((3, 2), 2.0) + conn = self._make_mcc( + [ + tf.Weight(name="w", value=w.clone()), + tf.Intensity(name="i", value=intensity.clone(), range=(-5.0, 5.0)), + ], + 3, + 2, + ) + assert torch.allclose(conn.compute(s), s @ (w * intensity), atol=1e-6) + + def test_degradation_feature_output(self): + # Degradation subtracts degrade_function(value) per-synapse -> -sum(0). + s = torch.tensor([[1.0, 1.0, 0.0]]) + w = torch.tensor([[0.4, 0.2], [0.3, 0.4], [0.5, 0.6]]) + deg = torch.tensor([[0.2, 0.4], [0.6, 0.8], [0.1, 0.3]]) + conn = self._make_mcc( + [ + tf.Weight(name="w", value=w.clone()), + tf.Degradation( + name="d", value=deg.clone(), degrade_function=lambda v: v * 0.5 + ), + ], + 3, + 2, + ) + assert torch.allclose(conn.compute(s), s @ w - (0.5 * deg).sum(0), atol=1e-6) + + def test_probability_feature_deterministic_bounds(self): + # bernoulli(1) == 1 (always passes); bernoulli(0) == 0 (always blocked). + s = torch.tensor([[1.0, 0.0, 1.0], [1.0, 1.0, 1.0]]) + w = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) + passes = self._make_mcc( + [ + tf.Probability(name="p", value=torch.ones(3, 2)), + tf.Weight(name="w", value=w.clone()), + ], + 3, + 2, + batch=2, + ) + assert torch.allclose(passes.compute(s), s @ w, atol=1e-6) + blocked = self._make_mcc( + [ + tf.Probability(name="p", value=torch.zeros(3, 2)), + tf.Weight(name="w", value=w.clone()), + ], + 3, + 2, + batch=2, + ) + assert torch.allclose(blocked.compute(s), torch.zeros(2, 2), atol=1e-6) + + def test_adaptation_features_output(self): + for cls in (tf.AdaptationBaseSynapsHistory, tf.AdaptationBaseOtherSynaps): + src_n, tgt_n = 3, 2 + s = torch.tensor([[1.0, 0.0, 1.0]]) + feat = cls( + name="a", + value=torch.zeros(src_n, tgt_n), + ann_values=[torch.zeros(1, 1), torch.zeros(1, 1)], + ) + conn = self._make_mcc([feat], src_n, tgt_n, batch=1) + out = conn.compute(s) + assert torch.allclose(feat.value.float(), torch.ones(src_n, tgt_n)) + assert torch.allclose(out, s @ torch.ones(src_n, tgt_n), atol=1e-6) + + # ----------------------------------------------------------------------- # + # Combined pipelines == pre-collapse expansion # + # ----------------------------------------------------------------------- # + + def test_pipeline_matches_expansion(self): + torch.manual_seed(0) + src_n, tgt_n, batch = 4, 3, 2 + s = (torch.rand(batch, src_n) > 0.4).float() + w = torch.randn(src_n, tgt_n) + w2 = torch.randn(src_n, tgt_n) + mask = torch.rand(src_n, tgt_n) > 0.5 + bias = torch.randn(src_n, tgt_n) * 0.2 + deg = torch.rand(src_n, tgt_n) + + pipelines = { + "weight": [tf.Weight(name="w", value=w.clone())], + "weight+mask": [ + tf.Weight(name="w", value=w.clone()), + tf.Mask(name="m", value=mask.clone()), + ], + "weight+bias": [ + tf.Weight(name="w", value=w.clone()), + tf.Bias(name="b", value=bias.clone()), + ], + "weight+bias+degradation": [ + tf.Weight(name="w", value=w.clone()), + tf.Bias(name="b", value=bias.clone()), + tf.Degradation( + name="d", value=deg.clone(), degrade_function=lambda v: v * 0.3 + ), + ], + "weight+intensity+bias": [ + tf.Weight(name="w", value=w.clone()), + tf.Intensity( + name="i", value=torch.full((src_n, tgt_n), 1.5), range=(-5.0, 5.0) + ), + tf.Bias(name="b", value=bias.clone()), + ], + "weight,bias,weight,bias": [ + tf.Weight(name="w", value=w.clone()), + tf.Bias(name="b", value=bias.clone()), + tf.Weight(name="w2", value=w2.clone()), + tf.Bias(name="b2", value=(bias * 0.5).clone()), + ], + } + + for name, pipe in pipelines.items(): + conn = self._make_mcc(pipe, src_n, tgt_n, batch=batch) + out = conn.compute(s) + ref = self._reference_expansion(pipe, s, tgt_n) + assert torch.allclose( + out, ref, atol=1e-5 + ), f"{name}: {(out - ref).abs().max()}" + + # ----------------------------------------------------------------------- # + # Sparse activity / sparse_compute == dense # + # ----------------------------------------------------------------------- # + + def test_sparse_compute_matches_dense(self): + torch.manual_seed(1) + src_n, tgt_n, batch = 6, 4, 3 + w = torch.randn(src_n, tgt_n) + bias = torch.randn(src_n, tgt_n) * 0.2 + deg = torch.rand(src_n, tgt_n) + + def pipes(): + return [ + [tf.Weight(name="w", value=w.clone())], + [ + tf.Weight(name="w", value=w.clone()), + tf.Bias(name="b", value=bias.clone()), + ], + [ + tf.Weight(name="w", value=w.clone()), + tf.Degradation( + name="d", value=deg.clone(), degrade_function=lambda v: v * 0.4 + ), + ], + ] + + spike_sets = [ + (torch.rand(batch, src_n) > 0.5).float(), + torch.zeros(batch, src_n), # empty-spike edge case + ] + for dense_pipe, sparse_pipe in zip(pipes(), pipes()): + for s in spike_sets: + dense = self._make_mcc(dense_pipe, src_n, tgt_n, batch=batch).compute(s) + sparse = self._make_mcc( + sparse_pipe, src_n, tgt_n, batch=batch, sparse_compute=True + ).compute(s) + assert torch.allclose(dense, sparse, atol=1e-5) + + # ----------------------------------------------------------------------- # + # MCC learning rules # + # ----------------------------------------------------------------------- # + + def test_noop_leaves_weight_unchanged(self): + w0 = torch.rand(3, 2) + conn, feat = self._learning_conn(mcc.NoOp, w0, nu=(0.1, 0.1)) + conn.source.s = torch.ones(1, 3) + conn.target.s = torch.ones(1, 2) + feat.learning_rule.update(reward=1.0) + assert torch.allclose(feat.value, w0) + + def test_postpre_predictable(self): + # PostPre: dW = -nu0 * outer(src_s, tgt_x) + nu1 * outer(src_x, tgt_s). + w0 = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.0, -0.2]]) + conn, feat = self._learning_conn(mcc.PostPre, w0, nu=(0.1, 0.2)) + conn.source.s = torch.tensor([[1.0, 0.0, 1.0]]) + conn.target.s = torch.tensor([[1.0, 1.0]]) + conn.source.x = torch.tensor([[0.3, 0.7, 0.2]]) + conn.target.x = torch.tensor([[0.5, 0.4]]) + feat.learning_rule.update() + dw = -0.1 * torch.outer(conn.source.s[0], conn.target.x[0]) + 0.2 * torch.outer( + conn.source.x[0], conn.target.s[0] + ) + expected = torch.clamp(w0 + dw, -1.0, 1.0) + assert torch.allclose(feat.value, expected, atol=1e-6) + assert not torch.allclose(feat.value, w0) # sanity: it actually changed + + def test_learning_respects_range_clamp(self): + # Post-only potentiation of +5 per synapse must clamp to the range max (1.0). + w0 = torch.full((2, 2), 0.9) + conn, feat = self._learning_conn(mcc.PostPre, w0, nu=(0.0, 5.0), rng=(-1.0, 1.0)) + conn.source.s = torch.zeros(1, 2) + conn.target.s = torch.ones(1, 2) + conn.source.x = torch.ones(1, 2) + conn.target.x = torch.zeros(1, 2) + feat.learning_rule.update() + assert torch.allclose(feat.value, torch.ones(2, 2)) + + def test_mstdp_predictable(self): + w0 = torch.zeros(3, 2) + conn, feat = self._learning_conn(mcc.MSTDP, w0, nu=(0.5, 0.0)) + seq = self._mstdp_seq() + for src_v, tgt_v, reward in seq: + conn.source.s = src_v.unsqueeze(0) + conn.target.s = tgt_v.unsqueeze(0) + feat.learning_rule.update(reward=reward) + expected = self._mstdp_reference(w0, seq, nu0=0.5, dt=1.0) + assert torch.allclose(feat.value, expected, atol=1e-5) + assert not torch.allclose(feat.value, w0) + + def test_mstdpet_predictable(self): + w0 = torch.zeros(3, 2) + conn, feat = self._learning_conn(mcc.MSTDPET, w0, nu=(0.5, 0.5)) + seq = self._mstdp_seq() + for src_v, tgt_v, reward in seq: + conn.source.s = src_v.unsqueeze(0) + conn.target.s = tgt_v.unsqueeze(0) + feat.learning_rule.update(reward=reward) + expected = self._mstdpet_reference(w0, seq, nu0=0.5, dt=1.0) + assert torch.allclose(feat.value, expected, atol=1e-5) + assert not torch.allclose(feat.value, w0) + + def test_hebbian_predictable(self): + # Hebbian: dW = nu0 * outer(src_s, tgt_x) + nu1 * outer(src_x, tgt_s), then + # clamped. Both pre- and post-synaptic terms are positive (contrast PostPre, + # which subtracts the pre term). + w0 = torch.tensor([[0.1, 0.2], [0.3, 0.4], [0.0, -0.2]]) + conn, feat = self._learning_conn(mcc.Hebbian, w0, nu=(0.1, 0.2)) + conn.source.s = torch.tensor([[1.0, 0.0, 1.0]]) + conn.target.s = torch.tensor([[1.0, 1.0]]) + conn.source.x = torch.tensor([[0.3, 0.7, 0.2]]) + conn.target.x = torch.tensor([[0.5, 0.4]]) + feat.learning_rule.update() + dw = 0.1 * torch.outer(conn.source.s[0], conn.target.x[0]) + 0.2 * torch.outer( + conn.source.x[0], conn.target.s[0] + ) + expected = torch.clamp(w0 + dw, -1.0, 1.0) + assert torch.allclose(feat.value, expected, atol=1e-6) + assert not torch.allclose(feat.value, w0) # sanity: it actually changed + + if __name__ == "__main__": + # MulticompartmentConnection + MCC learning-rule tests. + mcc_tester = TestMultiCompartmentConnection() + mcc_tests = [ + mcc_tester.test_weight_feature_output, + mcc_tester.test_mask_feature_output, + mcc_tester.test_bias_feature_output, + mcc_tester.test_intensity_feature_output, + mcc_tester.test_degradation_feature_output, + mcc_tester.test_probability_feature_deterministic_bounds, + mcc_tester.test_meanfield_not_implemented, + mcc_tester.test_adaptation_features_output, + mcc_tester.test_pipeline_matches_expansion, + mcc_tester.test_sparse_compute_matches_dense, + mcc_tester.test_noop_leaves_weight_unchanged, + mcc_tester.test_postpre_predictable, + mcc_tester.test_learning_respects_range_clamp, + mcc_tester.test_mstdp_predictable, + mcc_tester.test_mstdpet_predictable, + mcc_tester.test_hebbian_predictable, + ] + for mcc_test in mcc_tests: + mcc_test() + print(f" PASSED: {mcc_test.__name__}") + print(f"All {len(mcc_tests)} MulticompartmentConnection tests passed.") + tester = TestConnection() # tester.test_transfer() From a81c05dbb2b05baa556b7bb4a41d76c10ef4b1e0 Mon Sep 17 00:00:00 2001 From: christopher-earl Date: Sun, 26 Jul 2026 23:04:16 +0900 Subject: [PATCH 08/14] Benchmark tests --- .../_bench_common.py | 137 +++++++++++++ .../foldable_pipelines.py | 7 + .../run_both.py | 9 + .../sparse_compute.py | 8 + examples/stress_test/example_network.py | 185 ++++++++++++++++++ 5 files changed, 346 insertions(+) create mode 100644 examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py create mode 100644 examples/benchmark/sparse_compute and foldable pipeline/foldable_pipelines.py create mode 100644 examples/benchmark/sparse_compute and foldable pipeline/run_both.py create mode 100644 examples/benchmark/sparse_compute and foldable pipeline/sparse_compute.py create mode 100644 examples/stress_test/example_network.py diff --git a/examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py b/examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py new file mode 100644 index 000000000..564de6591 --- /dev/null +++ b/examples/benchmark/sparse_compute and foldable pipeline/_bench_common.py @@ -0,0 +1,137 @@ +""" +Shared machinery for the ExampleNetwork MCC benchmarks (imported by +``foldable_pipelines.py``, ``sparse_compute.py`` and ``both.py``). + +Each benchmark reports the %-time speedup of one MCC configuration over another by +timing the *same* ExampleNetwork under different ``compute`` modes: + + * ``expansion`` -- the pre-optimization path, reconstructed here as a + monkeypatch: materialize ``[batch, src, tgt]``, apply every + feature elementwise, then sum over source. (This path was + removed from the code when the fold landed, so we rebuild it + to serve as the baseline.) + * ``fold`` -- the current folded path: ``out = s @ A + B.sum(0)``. + * ``fold_sparse`` -- the fold plus activity-sparse compute (``sparse_compute``): + read only the weight rows of source neurons that spiked. + +Speedup is wall-time reduction: ``(t_baseline - t_new) / t_baseline * 100`` +(positive = faster). Learning is disabled so we time the pure forward path (the +part these optimizations affect). +""" + +import os +import statistics +import sys +import time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(os.path.dirname(_HERE)) # repo root (for ``bindsnet``) +_STRESS = os.path.join(_ROOT, "stress_test") # for ``example_network`` +for _p in (_ROOT, _STRESS): + if _p not in sys.path: + sys.path.insert(0, _p) + +import torch + +from bindsnet.network.topology import MulticompartmentConnection +from bindsnet.network.topology_features import Degradation, Probability +from example_network import ExampleNetwork + +# ExampleNetwork sizes per device: 20k excitatory neurons on GPU (where the fold +# shines), a smaller net on CPU so the baseline finishes in reasonable time. +GPU_CONFIG = dict(in_size=100, exc_size=20_000, inh_size=2_000) +CPU_CONFIG = dict(in_size=100, exc_size=2_000, inh_size=200) +GPU_TIME, CPU_TIME = 50, 20 +REPS, WARMUP = 5, 2 + + +def _expansion_compute(self, s): + """The pre-fold ``[batch, src, tgt]`` expansion path (baseline).""" + s = s.view(s.size(0), self.source.n) + cs = s.view(s.size(0), self.source.n, 1).repeat(1, 1, self.target.n) + for f in self.pipeline: + op = getattr(f, "op", "mul") + if isinstance(f, Probability): + v = torch.bernoulli(f.value) + elif isinstance(f, Degradation): + v = f.degrade_function(f.value) if f.degrade_function is not None else f.value + else: + v = f.value + if op == "mul": + cs = cs * v + elif op == "add": + cs = cs + v + else: + cs = cs - v + out = cs.sum(1) + if getattr(self, "traces", False): + self.activity = out + if out.size() != torch.Size([s.size(0)] + self.target.shape): + return out.view(s.size(0), *self.target.shape) + return out + + +def _set_mode(net, mode): + for c in net.connections.values(): + if isinstance(c, MulticompartmentConnection): + if mode == "expansion": + c.compute = _expansion_compute.__get__(c) # instance override + c.sparse_compute = False + else: + c.__dict__.pop("compute", None) # restore the class (fold) method + c.sparse_compute = mode == "fold_sparse" + + +def _bench(net, inputs, T, device, modes): + cuda = device.startswith("cuda") + net.train(False) # forward-only: time the compute path the optimizations touch + for m in modes: # warmup each mode + _set_mode(net, m) + for _ in range(WARMUP): + net.reset_state_variables() + net.run(inputs=inputs, time=T) + if cuda: + torch.cuda.synchronize() + samples = {m: [] for m in modes} + for _ in range(REPS): # interleave modes each rep to counter thermal drift + for m in modes: + _set_mode(net, m) + net.reset_state_variables() + if cuda: + torch.cuda.synchronize() + t0 = time.perf_counter() + net.run(inputs=inputs, time=T) + if cuda: + torch.cuda.synchronize() + samples[m].append(time.perf_counter() - t0) + return {m: statistics.median(v) * 1e3 for m, v in samples.items()} + + +def run_speedup(baseline_mode, test_mode, technique): + """Build the ExampleNetwork on CPU and GPU, time both modes, print the speedup.""" + print("=" * 72) + print(f"{technique}") + print(f" (%-time speedup of '{test_mode}' vs baseline '{baseline_mode}')") + print("=" * 72) + + devices = ["cpu"] + (["cuda"] if torch.cuda.is_available() else []) + if not torch.cuda.is_available(): + print("[note] CUDA not available -> reporting CPU only.\n") + + for dev in devices: + cfg = GPU_CONFIG if dev == "cuda" else CPU_CONFIG + T = GPU_TIME if dev == "cuda" else CPU_TIME + net = ExampleNetwork(device=dev, **cfg) + inputs = net.make_input(T) + res = _bench(net, inputs, T, dev, [baseline_mode, test_mode]) + base, new = res[baseline_mode], res[test_mode] + speedup = (base - new) / base * 100.0 + print( + f" [{dev.upper():4s}] exc={cfg['exc_size']:>6d} time={T:>3d} | " + f"{baseline_mode}={base:8.2f} ms {test_mode}={new:8.2f} ms " + f"| speedup = {speedup:+6.1f}% time" + ) + del net + if torch.cuda.is_available(): + torch.cuda.empty_cache() + print() diff --git a/examples/benchmark/sparse_compute and foldable pipeline/foldable_pipelines.py b/examples/benchmark/sparse_compute and foldable pipeline/foldable_pipelines.py new file mode 100644 index 000000000..ea43adfbe --- /dev/null +++ b/examples/benchmark/sparse_compute and foldable pipeline/foldable_pipelines.py @@ -0,0 +1,7 @@ +from _bench_common import run_speedup + +### Benchmark: foldable pipelines ### +# Measures the speedup of the folded MultiCompartmentConnection compute (``out = s @ A + B.sum(0)``) over +# the pre-fold ``[batch, src, tgt]`` expansion path, on the ExampleNetwork +if __name__ == "__main__": + run_speedup(baseline_mode="expansion", test_mode="fold", technique="Foldable pipelines") diff --git a/examples/benchmark/sparse_compute and foldable pipeline/run_both.py b/examples/benchmark/sparse_compute and foldable pipeline/run_both.py new file mode 100644 index 000000000..0752da254 --- /dev/null +++ b/examples/benchmark/sparse_compute and foldable pipeline/run_both.py @@ -0,0 +1,9 @@ +from _bench_common import run_speedup + +# Run both sparse_compute.py and foldable_pipelines.py +if __name__ == "__main__": + run_speedup( + baseline_mode="expansion", + test_mode="fold_sparse", + technique="Foldable pipelines + sparse compute (combined)", + ) diff --git a/examples/benchmark/sparse_compute and foldable pipeline/sparse_compute.py b/examples/benchmark/sparse_compute and foldable pipeline/sparse_compute.py new file mode 100644 index 000000000..3a4a6f733 --- /dev/null +++ b/examples/benchmark/sparse_compute and foldable pipeline/sparse_compute.py @@ -0,0 +1,8 @@ +from _bench_common import run_speedup + +### Benchmark: sparse compute ### +# Measures the speedup of activity-sparse compute (``sparse_compute=True`` -- read +# only the weight rows of source neurons that spiked) over the dense folded compute, +# on the ExampleNetwork (CPU and GPU). +if __name__ == "__main__": + run_speedup(baseline_mode="fold", test_mode="fold_sparse", technique="Sparse compute") diff --git a/examples/stress_test/example_network.py b/examples/stress_test/example_network.py new file mode 100644 index 000000000..7562a2736 --- /dev/null +++ b/examples/stress_test/example_network.py @@ -0,0 +1,185 @@ +""" +ExampleNetwork -- a large, sparse, recurrent ``MulticompartmentConnection`` (MCC) +stress workload. + +Topology: ``Input(I) -> EXC_LIF <-> INH_LIF``. Four single-``Weight`` MCC. +The ``I -> EXC`` connection also carries an ``MSTDP`` learning rule. + +Run it directly to stress the simulator: + + python examples/stress_test/example_network.py --device cuda --exc 20000 --time 50 + python examples/stress_test/example_network.py --device cpu --exc 2000 --time 20 +""" + +import argparse +import os +import sys +import time + +# Make ``bindsnet`` importable when this file is run as a standalone script. +sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +) + +import torch + +from bindsnet.learning.MCC_learning import MSTDP +from bindsnet.network.network import Network +from bindsnet.network.nodes import Input, LIFNodes +from bindsnet.network.topology import MulticompartmentConnection +from bindsnet.network.topology_features import Weight + + +class ExampleNetwork(Network): + def __init__( + self, + device="cpu", + in_size=100, + exc_size=20_000, + inh_size=2_000, + batch_size=1, + i_to_exc_connectivity=0.15, + i_to_inh_connectivity=0.05, + inh_to_exc_connectivity=0.05, + exc_to_inh_connectivity=0.05, + ): + super().__init__() + self.device = device + self.in_size = in_size + self.exc_size = exc_size + self.inh_size = inh_size + self.batch_size = batch_size + self.i_to_exc_connectivity = i_to_exc_connectivity + self.i_to_inh_connectivity = i_to_inh_connectivity + self.inh_to_exc_connectivity = inh_to_exc_connectivity + self.exc_to_inh_connectivity = exc_to_inh_connectivity + self.build() + + def _sparse_weight(self, rows, cols, connectivity, sign=1.0): + w = sign * torch.rand(rows, cols, device=self.device) + keep = torch.rand(rows, cols, device=self.device) > (1 - connectivity) + return w * keep + + def build(self): + device = self.device + self.add_layer(layer=Input(self.in_size), name="I") + self.add_layer(layer=LIFNodes(self.exc_size), name="EXC_LIF") + self.add_layer(layer=LIFNodes(self.inh_size), name="INH_LIF") + self.add_connection( + connection=MulticompartmentConnection( + source=self.layers["I"], + target=self.layers["EXC_LIF"], + device=device, + pipeline=[ + Weight( + name="I_to_EXC_weight", + value=self._sparse_weight( + self.in_size, self.exc_size, self.i_to_exc_connectivity + ), + learning_rule=MSTDP, + range=(0, 1), + ) + ], + ), + source="I", + target="EXC_LIF", + ) + self.add_connection( + connection=MulticompartmentConnection( + source=self.layers["I"], + target=self.layers["INH_LIF"], + device=device, + pipeline=[ + Weight( + name="I_to_INH_weight", + value=self._sparse_weight( + self.in_size, self.inh_size, self.i_to_inh_connectivity + ), + ) + ], + ), + source="I", + target="INH_LIF", + ) + self.add_connection( + connection=MulticompartmentConnection( + source=self.layers["INH_LIF"], + target=self.layers["EXC_LIF"], + device=device, + pipeline=[ + Weight( + name="INH_to_EXC_weight", + value=self._sparse_weight( + self.inh_size, + self.exc_size, + self.inh_to_exc_connectivity, + sign=-1.0, + ), + ) + ], + ), + source="INH_LIF", + target="EXC_LIF", + ) + self.add_connection( + connection=MulticompartmentConnection( + source=self.layers["EXC_LIF"], + target=self.layers["INH_LIF"], + device=device, + pipeline=[ + Weight( + name="EXC_to_INH_weight", + value=self._sparse_weight( + self.exc_size, self.inh_size, self.exc_to_inh_connectivity + ), + ) + ], + ), + source="EXC_LIF", + target="INH_LIF", + ) + self.to(device) + + def make_input(self, runtime): + # Poisson-ish random spike train into the input layer. + return { + "I": torch.rand( + runtime, self.batch_size, self.in_size, device=self.device + ) + > 0.90 + } + + +if __name__ == "__main__": + p = argparse.ArgumentParser(description="Stress-test the ExampleNetwork.") + p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") + p.add_argument("--in-size", type=int, default=100) + p.add_argument("--exc", type=int, default=20_000) + p.add_argument("--inh", type=int, default=2_000) + p.add_argument("--time", type=int, default=50) + args = p.parse_args() + + device = args.device + if device.startswith("cuda") and not torch.cuda.is_available(): + print("[note] CUDA unavailable; falling back to CPU.") + device = "cpu" + + net = ExampleNetwork( + device=device, in_size=args.in_size, exc_size=args.exc, inh_size=args.inh + ) + net.train(False) # forward-only stress (no learning) + inputs = net.make_input(args.time) + + net.run(inputs=inputs, time=args.time) # warmup + if device.startswith("cuda"): + torch.cuda.synchronize() + net.reset_state_variables() + t0 = time.perf_counter() + net.run(inputs=inputs, time=args.time) + if device.startswith("cuda"): + torch.cuda.synchronize() + ms = (time.perf_counter() - t0) * 1e3 + print( + f"ExampleNetwork [{device}] in={args.in_size} exc={args.exc} inh={args.inh} " + f"time={args.time}: {ms:.1f} ms total ({ms / args.time:.3f} ms/step)" + ) From 79de6b4cffdea957c21d1fab76adb414e9d9e77f Mon Sep 17 00:00:00 2001 From: Hananel Hazan Date: Mon, 3 Aug 2026 20:22:13 -0400 Subject: [PATCH 09/14] black formater --- .claude/settings.json | 8 + .claude/skills/archive-log/SKILL.md | 100 ++++ .claude/skills/archive-search/SKILL.md | 95 ++++ .claude/skills/generate-leaderboard/SKILL.md | 87 ++++ .claude/skills/generate-report/SKILL.md | 120 +++++ .claude/skills/hpc-submit/SKILL.md | 256 ++++++++++ .claude/skills/intake-results/SKILL.md | 101 ++++ .claude/skills/log-entry/SKILL.md | 205 ++++++++ .claude/skills/pax-hpc/SKILL.md | 292 +++++++++++ .claude/skills/pax-hpc/references/intake.md | 92 ++++ .../skills/pax-hpc/references/new-cluster.md | 125 +++++ .../skills/pax-hpc/references/old-cluster.md | 105 ++++ .../skills/pax-hpc/references/resilience.md | 457 ++++++++++++++++++ .claude/skills/report/SKILL.md | 53 ++ .claude/skills/slurm-harvest/SKILL.md | 38 ++ .claude/skills/slurm-harvest/harvest_slurm.py | 189 ++++++++ bindsnet/datasets/torchvision_wrapper.py | 9 +- .../_bench_common.py | 6 +- .../foldable_pipelines.py | 4 +- .../sparse_compute.py | 4 +- examples/stress_test/example_network.py | 4 +- test/network/test_connections.py | 26 +- 22 files changed, 2362 insertions(+), 14 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .claude/skills/archive-log/SKILL.md create mode 100644 .claude/skills/archive-search/SKILL.md create mode 100644 .claude/skills/generate-leaderboard/SKILL.md create mode 100644 .claude/skills/generate-report/SKILL.md create mode 100644 .claude/skills/hpc-submit/SKILL.md create mode 100644 .claude/skills/intake-results/SKILL.md create mode 100644 .claude/skills/log-entry/SKILL.md create mode 100644 .claude/skills/pax-hpc/SKILL.md create mode 100644 .claude/skills/pax-hpc/references/intake.md create mode 100644 .claude/skills/pax-hpc/references/new-cluster.md create mode 100644 .claude/skills/pax-hpc/references/old-cluster.md create mode 100644 .claude/skills/pax-hpc/references/resilience.md create mode 100644 .claude/skills/report/SKILL.md create mode 100644 .claude/skills/slurm-harvest/SKILL.md create mode 100644 .claude/skills/slurm-harvest/harvest_slurm.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..3050c4333 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -sL \"https://zenodo.org/api/records/20695116\")", + "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('doi:', d.get\\('doi'\\)\\); print\\('conceptdoi:', d.get\\('conceptdoi'\\)\\); print\\('title:', d.get\\('metadata',{}\\).get\\('title'\\)\\); print\\('conceptrecid:', d.get\\('conceptrecid'\\)\\)\")" + ] + } +} diff --git a/.claude/skills/archive-log/SKILL.md b/.claude/skills/archive-log/SKILL.md new file mode 100644 index 000000000..a1416230c --- /dev/null +++ b/.claude/skills/archive-log/SKILL.md @@ -0,0 +1,100 @@ +--- +name: archive-log +description: Archives old entries from a per-task log file when it grows too large. Moves entries beyond the keep-last threshold to logs/archive/. Also use when the user says "archive the log", "trim the log", or "the log is too long". +allowed-tools: Bash, Read, Edit +--- + +# Archive Old Log Entries + +## When to invoke + +- Manually, when a task log is getting unwieldy +- Automatically, called by `/log-entry` when entry count > 10 +- Automatically, called by `/intake-results` Step 8 +- During migration from the old monolithic LOG.md + +## Step 1 -- Check the current state + +```bash +# Count entries +grep -c "^## " logs/LOG..md + +# See the oldest entries that will be archived +grep "^## " logs/LOG..md | tail -10 +``` + +## Step 2 -- Dry-run the archive script + +```bash +python scripts/archive_log.py --task --keep-last 5 --dry-run +``` + +Read the output carefully: +- How many entries will be archived? +- What is the archive filename? +- What pointer line will be written into the active log? + +## Step 3 -- Execute + +```bash +python scripts/archive_log.py --task --keep-last 5 +``` + +## Step 4 -- Verify + +```bash +# Active log should now have 5 entries + 1 pointer line near the bottom +grep -c "^## " logs/LOG..md +grep "^> Archived entries" logs/LOG..md + +# Archive file should exist +ls logs/archive/LOG..*.md +``` + +### Pinned block check + +```bash +head -5 logs/LOG..md | grep "📌" +``` + +The `## 📌 Current Best` block must still be present at the top after archival. +If it is missing, the `split_entries` function did not handle it correctly — +restore from the archive and fix the script before continuing. + +## Step 5 -- Update global index + +Open `LOG.md` and confirm the row for `` still points to `logs/LOG..md` (the active file -- not the archive). + +## Step 6 -- Rebuild the search index + +The archive script auto-triggers this, but if it didn't run, do it manually: + +```bash +python scripts/build_search_index.py +``` + +This regenerates `logs/archive/INDEX.jsonl` with all entries (active + archived). + +## Step 7 -- Refresh STATUS + +The hot log just changed; regenerate the compact STATUS file (Claude's default +read surface): + +```bash +python scripts/generate_status.py --task +``` + +## Archive file naming + +`logs/archive/LOG....md` + +The `` suffix (01, 02, ...) prevents collisions if you archive the same task multiple times in one day. + +## Pointer line format (written into the active log) + +```markdown +> Archived entries 001--NNN -> logs/archive/LOG....md +``` + +This line is always kept at the **bottom** of the active log, below all `## ` entries. +Multiple pointer lines accumulate as more archives are created -- do not delete them. diff --git a/.claude/skills/archive-search/SKILL.md b/.claude/skills/archive-search/SKILL.md new file mode 100644 index 000000000..79ac3c828 --- /dev/null +++ b/.claude/skills/archive-search/SKILL.md @@ -0,0 +1,95 @@ +--- +name: archive-search +description: Searches archived log entries across all tasks or a specific task. Use when the user says "what did we try before", "search the archive", "find old runs with X", or when the agent needs historical context beyond the 5 hot entries in the active log. +allowed-tools: Bash, Read +--- + +# Search Archived Log Entries + +## Step 1 — Identify scope + +Determine: is this a single-task search or cross-task? + +```bash +# List all archive files +ls logs/archive/ + +# For a specific task: +ls logs/archive/LOG..*.md + +# Total archive size +wc -l logs/archive/*.md | tail -1 +``` + +## Step 1.5 — Check the search index (fast path) + +If the search can be answered by metadata alone (run number, date, metric, title keyword, task), +use the structured index first: + +```bash +python3 -c " +import json +for line in open('logs/archive/INDEX.jsonl'): + e = json.loads(line) + if '' in e.get('title','').lower() or '' in str(e.get('tags',[])).lower(): + metric = f\"{e.get('key_metric','')}: {e.get('key_value','')}\" if e.get('key_value') else '' + print(f\"{e['date']} Run {e.get('run','?'):>3} [{e['task']}] {e['title']} {metric}\") +" +``` + +For metric-based queries (e.g. "accuracy > 90%"): + +```bash +python3 -c " +import json +for line in open('logs/archive/INDEX.jsonl'): + e = json.loads(line) + if e.get('key_value') is not None and e['key_value'] > 90: + print(f\"{e['date']} Run {e.get('run','?'):>3} [{e['task']}] {e.get('key_metric','')}: {e['key_value']}\") +" +``` + +If this returns sufficient results, you can skip the full-text grep in Steps 2-3. + +## Step 2 — Search + +```bash +# Search for a keyword across all archives +grep -l "" logs/archive/*.md + +# Search within a specific task's archives +grep -n "" logs/archive/LOG..*.md + +# Find runs matching a config value (e.g. learning rate) +grep -n "lr=1e-3\|learning_rate: 0.001" logs/archive/LOG..*.md + +# Find runs by approximate date range +ls logs/archive/LOG..2025-0[1-3]*.md +``` + +## Step 3 — Read matching entries + +Once you have a filename and line number, read the surrounding context: + +```bash +# Read a specific archive file +cat logs/archive/LOG....md + +# Or just the matching section (N lines around match) +grep -A 20 "" logs/archive/LOG..*.md | head -60 +``` + +## Step 4 — Summarize findings + +Report to the user: +- Which archive files matched +- Run numbers and dates of matching entries +- The key content (config, result, notes) from each match +- Whether any matching run is a candidate to resume or replicate + +## Notes + +- Archive files are read-only. Never write to them. +- If a keyword matches many entries, filter by date or metric value. +- Active log entries (logs/LOG..md) are not searched here — + the agent can read those directly. diff --git a/.claude/skills/generate-leaderboard/SKILL.md b/.claude/skills/generate-leaderboard/SKILL.md new file mode 100644 index 000000000..782cc0be7 --- /dev/null +++ b/.claude/skills/generate-leaderboard/SKILL.md @@ -0,0 +1,87 @@ +--- +name: generate-leaderboard +description: Reads the Current Best pinned block from every per-task log and produces a ranked leaderboard table across all tasks. Use when the user says "show me the leaderboard", "what is the best result per task", "update the summary table", or "what should I work on next". +allowed-tools: Read, Write, Bash, Glob +--- + +# Generate Cross-Task Leaderboard + +## Step 1a — Fast path: aggregate run.json sidecars (preferred) + +Every promoted run has a flat `run.json`. Aggregate them without touching markdown: + +```bash +python3 -c " +import json, pathlib +from collections import defaultdict +best = defaultdict(lambda: (None, None)) # task -> (value, record) +for p in pathlib.Path('experiments').rglob('run.json'): + try: + r = json.loads(p.read_text()) + except Exception: + continue + task = r.get('task'); v = r.get('key_value'); km = r.get('key_metric') + if task is None or v is None: continue + # Lower-is-better for ppl/loss/mae/mse; higher-is-better otherwise + lower_better = km in {'test_ppl','val_ppl','mean_ppl','loss','mae','mse'} + cur_v, _ = best[task] + if cur_v is None or (lower_better and v < cur_v) or (not lower_better and v > cur_v): + best[task] = (v, r) +for task, (v, r) in sorted(best.items()): + print(f\"{task:<30} {r.get('key_metric',''):<10} {v:>10} run={r.get('run_id')} {r.get('date','')}\") +" +``` + +If a task has no sidecars, fall back to Step 1b. + +## Step 1b — Fallback: pinned blocks from STATUS / LOG + +```bash +for f in logs/STATUS.*.md logs/LOG.*.md; do + echo "=== $f ===" + awk '/^## 📌 Current Best/{found=1} found{print} /^---/{if(found) exit}' "$f" + echo "" +done +``` + +Prefer `logs/STATUS.*.md` — smaller and authoritative for the pinned block. + +## Step 2 — Build the leaderboard table + +For each task, extract the 🥇 row from its pinned block. +Produce a single markdown table sorted by metric value (best first). +Group tasks by metric type if they use different metrics. + +Output format: + +```markdown +## Leaderboard — + +### (higher is better / lower is better) + +| Rank | Task | Run | Date | Metric | Value | Config | +|------|------|-----|------|--------|-------|--------| +| 1 | mnist_cnn | Run 057 | 2025-04-10 | acc | 98.3% | lr=1e-3 | +| 2 | cifar10 | Run 031 | 2025-03-22 | acc | 94.1% | lr=3e-4 | +... + +### No result yet + +Tasks with no quantitative result: +``` + +## Step 3 — Write output + +Save to `experiments/_leaderboard/leaderboard_.md`. +Also print to stdout for immediate review. + +## Step 4 — Embed in PAPER.md (optional) + +If the user asks to update PAPER.md, replace the content under +`## Summary` or `## Current Results` with the leaderboard table. +Do not touch other sections. + +## Step 5 — Bi-directional sync + +No LOG entry needed for leaderboard generation unless results changed. +If you updated PAPER.md, note it in the relevant task's log entry. diff --git a/.claude/skills/generate-report/SKILL.md b/.claude/skills/generate-report/SKILL.md new file mode 100644 index 000000000..239a7aa76 --- /dev/null +++ b/.claude/skills/generate-report/SKILL.md @@ -0,0 +1,120 @@ +--- +name: generate-report +description: Analyzes a promoted experiment directory and produces a structured markdown report under experiments/_reports/. Use after intaking and promoting HPC results to summarize findings. Also use when the user says "generate a report", "summarize results", or "write the experiment report". +allowed-tools: Read, Write, Bash, Glob, Grep +--- + +# Generate Experiment Report + +## Step 1 -- Identify the experiment and task + +Ask (or infer from context): what is the promoted experiment path and which task does it belong to? + +```bash +ls experiments// +``` + +The task name determines which log file to update: `logs/LOG..md`. + +## Step 2 -- Inventory results + +```bash +# Check what's in the experiment root +ls experiments/// + +# Find all result/metric files +find experiments/// -name "*.json" -o -name "*.csv" | head -30 + +# Check existing reports +ls experiments///_reports/ 2>/dev/null || echo "No reports yet" +``` + +## Step 3 -- Read key files + +- `status.json` -- final state and task metadata +- `manifest.json` -- expected vs observed jobs +- Per-run metric files (e.g. `results.json`, `metrics.csv`, `summary.json`) +- Existing `README.md` inside the package + +## Step 4 -- Write the report + +Output path: `experiments///_reports//.md` + +### Report structure + +```markdown +# -- Report + +**Date:** YYYY-MM-DD +**Task:** +**Status:** complete / incomplete (N/M runs) + +## Summary + +One paragraph: what was tested, key result, headline number. + +## Configuration + +Table of key hyperparameters swept. + +## Results + +### Main metric table + +| Method | Metric | Mean | P10 | Std | +|--------|--------|------|-----|-----| + +### Figures + +Embed any generated plots (use relative paths). + +### Key findings + +Bullet list of the 3-5 most important observations. + +## Failure / Incomplete Jobs (if any) + +List missing jobs and their impact on conclusions. + +## Next Steps + +What should be done based on these results? +``` + +## Step 5 -- Update canonical summary + +Ensure `_reports//summary.json` exists with required fields: +- `experiment_id`, `source_packages`, `expected_jobs`, `observed_jobs`, `missing_jobs`, `status` + +## Step 6 -- Bi-directional sync (required by AGENTS.md) + +### PAPER.md + +1. In the **Results** section: update the current best result for this task. +2. If the previous best result is being displaced, move it (with its config) to **Appendix: Ablation History -- \**. +3. In **Ablation Summary**: add or update the row for this variant in the per-task table. + +```markdown + +| Run | Key change | Metric | vs. previous | +|-----|------------|--------|--------------| +``` + +After adding the new row to the Ablation History table, re-sort it: + +```bash +conda activate normal +python scripts/sort_ablation_tables.py +``` + +### README.md + +Update the "Current Results" row for this task (one line per task, overwrite -- do not append). + +### logs/LOG.\.md + +Invoke `/log-entry` for this task. The entry should reference the report path and headline number. + +### LOG.md (global index) + +Update the row for `` with the new Last Run, Status, and link. diff --git a/.claude/skills/hpc-submit/SKILL.md b/.claude/skills/hpc-submit/SKILL.md new file mode 100644 index 000000000..53a5b6362 --- /dev/null +++ b/.claude/skills/hpc-submit/SKILL.md @@ -0,0 +1,256 @@ +--- +name: hpc-submit +description: Guides creation of a compliant SLURM submitter + runner script pair for this project. Use when preparing a new experiment for cluster submission, writing new HPC scripts, or auditing existing scripts against project policy. Also use when the user says "create a submitter", "write SLURM scripts", or "submit to cluster". +allowed-tools: Read, Write, Edit, Bash, Glob, Grep +--- + +# HPC Submit — New Experiment + +Full policy: [docs/policy/HPC_RESULT_INTAKE_POLICY.md](docs/policy/HPC_RESULT_INTAKE_POLICY.md). + +**Cluster facts + resilience templates live in the global `pax-hpc` skill** +(`~/.claude/skills/pax-hpc/`): cluster choice (NEW=login-p02/conda/2d cap; +OLD=login-prod/Singularity-always/6d cap), VRAM-precise GPU labels +(`references/new-cluster.md`), and the copy-paste requeue/resume/VRAM-guard +scaffolding (`references/resilience.md`). This project skill is the *house style* +on top of that. Verified on both clusters, re-confirmed 2026-07-17: `preempt` +`GraceTime=0` (no window on a preempt kill — only the last periodic checkpoint +survives; the 30 s `KillWait` grace is the *walltime*/`scancel` path, not +preemption), `PreemptMode=REQUEUE` **does not auto-requeue** — so the resilience +contract below is mandatory, not optional. **Preempt jobs also require +`--qos=preempt`** (not just `-p preempt`): without it the job silently runs under +the `normal` QOS (cpu=250) and gets none of the higher preempt ceiling. The +`normal` and `preempt` GPU caps are separate 10-GPU pools, so preempt spillover +buys up to 20 concurrent GPUs (verified 2026-07-17). + +## Script pair overview + +Every experiment needs exactly two scripts: + +| Script | Runs on | Does | +|---|---|---| +| `scripts//submit__.sh` | Gateway/login node | `sbatch` orchestration only | +| `scripts//run__.sh` (or `.py`) | Compute node via SLURM | Training, logging, packaging | + +## Submitter checklist (`bash` on gateway) + +- [ ] `set -euo pipefail` at top +- [ ] Verifies `sbatch` is available +- [ ] Validates required files/paths before submission +- [ ] Prints usage and key env overrides +- [ ] GPU constraint default uses **VRAM-precise labels**, not bare type names: + `a100-80G|h100-80G|h200-141G|l40s-48G` (bare `a100` silently admits the + 40 GB card → OOM). Use the `constraint_for_tier` recipe from the global + `pax-hpc` skill's `references/new-cluster.md`. +- [ ] Supports override via `GPU_CONSTRAINT` or `SBATCH_CONSTRAINT` env vars +- [ ] Constraint fallback: try full VRAM-precise alternation → retry token-by-token → retry without `--constraint` +- [ ] **Resilience SBATCH directives on EVERY job** (walltime trap is universal — + see contract below): `--requeue`, `--signal=B:USR1@30`, `--open-mode=append` +- [ ] **`preempt` jobs pass BOTH `--partition=preempt` and `--qos=preempt`** — the + QOS flag is required or you silently fall back to the `normal` ceiling (cpu=250) +- [ ] `--time` within the cap of the target cluster (**2d new / 6d old**); never rely on the 15-min default +- [ ] **No hardcoded `--exclude`** — bad nodes are learned at runtime by the runner +- [ ] Token rotation: when splitting across tokens, rotate preference across jobs (not all on first accepted token) +- [ ] One SLURM job per independent sweep point (mode × seed grid) — no bundling unless true dependency +- [ ] Big / multi-stage work uses `--dependency` chains, not one mega-job +- [ ] Does NOT run Python, training, plotting, or `singularity exec` on gateway + +## Runner checklist (compute node) + +- [ ] Writes only inside one unique package root per task (no out-of-package writes) +- [ ] Emits all four required files inside package root: + - `status.json` with `task_id` and `state` (`finished`/`partial`/`failed`) + - `manifest.json` with `required_files` and `promote_to` + - `README.md` (human-readable run context) + - `INTAKE.md` (local post-transfer commands) +- [ ] Uses fail-fast validation for required runtime inputs +- [ ] Verifies required Python modules before training +- [ ] Records bootstrap/failure outcome in package metadata +- [ ] Logs node/hardware info at startup (see **Hardware & timing log block** below) +- [ ] Logs job start time and wall-clock end time (success and failure paths) +- [ ] Registers EXIT trap that logs reason + elapsed time on any exit (OOM, kill, error) +- [ ] **VRAM guard**: read `nvidia-smi` at startup; if VRAM `< MIN_GPU_MEM_MB`, + append the node to `ExcNodeList` and `scontrol requeue` (don't just requeue — + exclude, or it loops onto the same small card) +- [ ] **CUDA-init guard**: `torch.zeros(1, device="cuda")` probe AFTER env activate; + on failure, exclude-node + requeue (a healthy `nvidia-smi` can still have a dead CUDA ctx) +- [ ] **Signal traps** (`SIGUSR1` + `SIGTERM`) on EVERY job: requeue on the walltime + warning (universal — a job that overruns `--time` must requeue, never die) AND on + preemption; on preemption requeue *first* (GraceTime=0 → no window). Gate the + requeue loop on a `DONE` sentinel so it stops once the work is complete. See contract below. +- [ ] **Robust resume**: resolve the resume dir by probing for the **highest-step + checkpoint** across `run_*/`, not a single sentinel file; honor `RESUME_RUN_DIR` +- [ ] **Deliverable checkpoints off by default** (see **Checkpoint policy** below): + - Training code does not call `torch.save` / `save_pretrained` / Keras `save_best=True` + for *deliverable weights* unless gated by `--save-checkpoint` (or `KEEP_CHECKPOINT=1`), default **off** + - `required_files` in manifest.json omits any `*.pt`/`*.pth`/`*.safetensors`/`*.bin` entries + - **EXCEPTION — resume checkpoints are mandatory for preempt/long jobs**: a single + latest, atomically-written (`*.tmp` → `mv`) checkpoint every ≤~30 min of compute, + **deleted on clean finish**. This is transient resume state, not a deliverable, so + it stays out of `required_files` but is required for the job to survive `GraceTime=0`. + +## Walltime policy (user directive 2026-07-11 — HARD RULE) + +- **`--time=23:55:00` is the MAXIMUM walltime for any job.** Do not request more. +- **Every task MUST be able to requeue itself** on timeout OR on an HPC kill, and + resume from its last checkpoint — never from step 0. A job that cannot save and + resume cannot be submitted. +- Rationale (measured): a tied-LoRA run at ~270M on FineWeb-Edu took **22h31m**; + a 14h walltime would have failed it. Long jobs are normal, so the 23:55 cap + + self-requeue is what makes them finish. Jobs longer than 23:55 complete by + requeueing across multiple windows, resuming from the periodic checkpoint. +- This means the periodic atomic checkpoint (≤ ~30 min) is **mandatory for every + job**, not just preempt/near-cap ones — it is what a requeue resumes from. + +## Resilience contract (walltime trap MANDATORY for ALL jobs) + +> **User directive (2026-06-10): every HPC task traps the walltime SIGTERM and +> auto-requeues until completed — no exceptions.** Walltime estimates are +> routinely wrong; an overrun must resume, never silently die. The periodic +> *checkpoint* layer below stays scoped to preempt / near-cap jobs (it's what +> lets a requeue resume near where it died); the *trap + `--requeue`* layer is +> universal. The requeue loop is gated on a `DONE` sentinel — requeue while +> unfinished, stop once the run completes. + + +Copy-paste bash/Python templates: global `pax-hpc` skill → +`references/resilience.md`. The canonical in-repo example is +[scripts/wikitext/run_wikitext_v6_slurm.sh](scripts/wikitext/run_wikitext_v6_slurm.sh) +(already implements `--requeue`, USR1/TERM handlers, VRAM guard, cuda-exclude). + +Why mandatory: `preempt` has `GraceTime=0` on both clusters — a preempted job is +SIGKILLed with **no checkpoint window**, and `PreemptMode=REQUEUE` does **not** +auto-requeue unless the job is `--requeue`-eligible. Several tasks also exceed the +cap (AWD-LSTM ≈3d vs the new 2d cap), so they *cannot finish* without resume. + +The contract has three layers — get all three or the job is not resilient: + +1. **Eligibility (submitter):** `#SBATCH --requeue` + `--signal=B:USR1@30` + + `--open-mode=append`, `--time` within the cluster cap. On `preempt`, also + `#SBATCH --qos=preempt` (required for the higher ceiling; a bare `-p preempt` + falls back to the `normal` QOS). +2. **Survival (runner, bash):** trap `SIGUSR1`/`SIGTERM` → on preemption requeue + *first* then best-effort checkpoint, on walltime checkpoint-then-requeue; + VRAM-guard + cuda-guard that exclude-node + requeue; resume by highest-step probe. +3. **State (trainer, Python):** trap `SIGUSR1` → write latest checkpoint → exit; + **periodic atomic checkpoint every ≤~30 min** (the only thing that survives a + 0-grace preemption); resume from the highest-step checkpoint; delete on clean finish. + +Requeue lives in the **bash** layer (survives a hung trainer); the Python layer only +checkpoints. The walltime trap + `--requeue` are NOT optional for any job — +CPU-only and sub-hour jobs on `batch`/`gpu` still carry them (a wrong runtime +estimate must requeue, not die); they may skip only the *periodic checkpoint* +layer if a from-scratch restart on requeue is acceptable. + +## Checkpoint policy + +> Full policy: [docs/policy/HPC_RESULT_INTAKE_POLICY.md](docs/policy/HPC_RESULT_INTAKE_POLICY.md) +> section **Checkpoint Persistence Policy**. + +**Default**: evaluation-only experiments persist config + metrics + history, +not weights. `config + code commit + seed = same result`; checkpoints add no +reproducibility and a lot of disk. + +**Opt in** (explicit flag, default off) only when: +1. The weight artifact itself is the deliverable (released model, teacher, warmstart) +2. Multi-stage training needs mid-run state (ReLoRA merge, warmstart handoff) +3. Active investigation needs post-hoc weight probing (eigenvalues, activations) + +When opting in, record `extra.checkpoint_reason` in manifest.json and keep the +file optional (never in `required_files`) so evaluation reruns don't trip +`INCOMPLETE`. + +**Cleanup of legacy dirs**: `python scripts/strip_checkpoints.py --scan experiments//` +removes `*.pt`/`*.pth`/`*.safetensors`/`*.bin` while preserving all metadata. + +## Hardware & timing log block + +Paste this block **once, near the top of every runner script**, immediately after the SLURM env debug section: + +```bash +# ============================================================================= +# HARDWARE & TIMING INFO +# ============================================================================= +JOB_START_TIME=$(date +"%Y-%m-%dT%H:%M:%S") +JOB_START_EPOCH=${SECONDS} +echo "===== JOB START: ${JOB_START_TIME} =====" +echo "Node: ${SLURMD_NODENAME:-unknown}" +echo "NodeList: ${SLURM_JOB_NODELIST:-unknown}" +echo "JobID: ${SLURM_JOB_ID:-unknown}" +echo "ArrayTask: ${SLURM_ARRAY_TASK_ID:-none}" +echo "Cluster: $(hostname -d 2>/dev/null || hostname)" +echo "SLURM job: ${SLURM_JOB_ID}" + +# GPU info (model + VRAM) +if command -v nvidia-smi &>/dev/null; then + echo "--- GPU ---" + nvidia-smi --query-gpu=index,name,memory.total,driver_version \ + --format=csv,noheader,nounits 2>/dev/null \ + | awk -F',' '{printf " GPU %s: %s | VRAM: %s MiB | Driver: %s\n",$1,$2,$3,$4}' +else + echo "GPU: not available" +fi + +# CPU memory +echo "--- CPU Memory ---" +free -h | grep -E '^Mem' + +# Register EXIT trap: always log finish time + status + elapsed +_log_job_exit() { + local exit_code=$? + local elapsed=$(( SECONDS - JOB_START_EPOCH )) + local end_time + end_time=$(date +"%Y-%m-%dT%H:%M:%S") + echo "" + echo "===== JOB EXIT: ${end_time} =====" + echo "Exit code: ${exit_code}" + echo "Elapsed: ${elapsed}s (~$(( elapsed/3600 ))h $(( (elapsed%3600)/60 ))m $(( elapsed%60 ))s)" + if [ "${exit_code}" -eq 0 ]; then + echo "Status: SUCCESS" + elif [ "${exit_code}" -eq 137 ]; then + echo "Status: KILLED (OOM or external kill signal — check memory limits)" + else + echo "Status: FAILED (see Python traceback above)" + fi + echo "Start: ${JOB_START_TIME}" + echo "End: ${end_time}" +} +trap '_log_job_exit' EXIT +# ============================================================================= +``` + +**Debug notes captured by this block:** +- GPU model + VRAM → tells you if a different GPU type ran (A100 vs L40s affects numerics/OOM thresholds) +- Exit code 137 → OOM kill (not a Python crash); increase `--mem` or reduce batch size +- Elapsed time → compare against `--time` budget; if close, job was likely preempted or killed at limit +- Python loss/parameter logs → emitted to the SLURM `.out` file by the training script; grep `loss` or `param` in that file to trace divergence + +## Policy alignment checklist + +- [ ] Config/wrapper paths remain under task-scoped roots (not repo root) +- [ ] Script behavior aligns with `scripts/validate_experiment_policy.py` +- [ ] Docs synchronized: `README.md`, `PAPER.md`, `LOG.md` +- [ ] Aggregation of split-job outputs happens locally after intake + +## Common failure patterns to avoid + +1. `Invalid feature specification` — use bracket constraint with token fallback +2. Running `bash submitter.sh` instead of `sbatch submitter.sh` (or vice versa) +3. Python/container execution on gateway node +4. Shared HF cache across parallel jobs — use per-job `HF_HOME` or `TRANSFORMERS_CACHE` +5. Non-self-contained outputs (writes outside package root) +6. **Silent OOM from a bare `a100` constraint** landing on a 40 GB card — use a + VRAM-precise label (`a100-80G`) + the runtime VRAM guard with `MIN_GPU_MEM_MB` +7. **Preempted job vanishes instead of requeuing** — missing `#SBATCH --requeue` + (`PreemptMode=REQUEUE` alone does not requeue your job) +8. **Resume starts from zero after ≥2 requeues** — trusting a stale sentinel file + instead of probing for the highest-step checkpoint + +## After writing scripts + +Run the policy validator: +```bash +python scripts/validate_experiment_policy.py +``` + +Then invoke `/log-entry` to record the new scripts. diff --git a/.claude/skills/intake-results/SKILL.md b/.claude/skills/intake-results/SKILL.md new file mode 100644 index 000000000..807ad891e --- /dev/null +++ b/.claude/skills/intake-results/SKILL.md @@ -0,0 +1,101 @@ +--- +name: intake-results +description: Runs the full HPC result intake pipeline for this project. Use when experiment results have been transferred from the cluster into experiments/from_HPC and need to be validated, promoted, and archived. Also use when the user says "intake", "promote results", or "process HPC output". +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +--- + +# Intake Results from HPC + +Full policy is in [docs/policy/HPC_RESULT_INTAKE_POLICY.md](docs/policy/HPC_RESULT_INTAKE_POLICY.md). + +## Step 1 -- Check what arrived + +```bash +ls experiments/from_HPC/ +``` + +If packages landed under a staging subdirectory (`hpc_staging`), normalize first: + +```bash +bash scripts/hpc/intake_from_hpc_staging.sh --dry-run +# If dry-run looks correct: +bash scripts/hpc/intake_from_hpc_staging.sh --execute --run-intake --interactive +``` + +```bash +# Extract SLURM job IDs from stdout logs in the incoming package +grep -r "SLURM job:" experiments/from_HPC/ 2>/dev/null | head -20 +``` + +Copy the job ID(s) into the log entry SLURM field when writing the entry. + +## Step 2 -- Run intake (dry-run first, always) + +```bash +python scripts/hpc/hpc_result_intake.py --interactive --dry-run +``` + +Review the output, then run live: + +```bash +python scripts/hpc/hpc_result_intake.py --interactive +``` + +## Step 3 -- Decide on each package + +| Classification | Condition | Action | +|----------------|-----------|--------| +| `COMPLETE` | `status.json` valid, `state=finished`, all required files present | Auto-promote | +| `INCOMPLETE` | `state=partial/failed` or required files missing | Choose: rerun missing, rerun all, accept partial, or skip | +| `UNKNOWN` | `status.json` missing or malformed | Do NOT silently promote -- investigate first | + +Quarantine path (if promotion fails): `experiments/_quarantine/_/` +Audit log: `experiments/from_HPC/intake_events.jsonl` + +## Step 4 -- Unify results + +```bash +python scripts/hpc/unify_hpc_results.py --dry-run +python scripts/hpc/unify_hpc_results.py --archive-complete +``` + +## Step 5 -- Identify the task + +From the promoted package, determine the task name. This is the subdirectory under `experiments/` where the package landed. All subsequent log writes target `logs/LOG..md`. + +## Step 6 -- Generate report + +After promotion, invoke `/generate-report` with the promoted experiment path. That skill handles PAPER.md, README.md, and the per-task log in one step. + +## Step 7 -- Post-intake refresh (single command) + +Run the wrapper. It writes `run.json` sidecars for the task's runs, archives +`LOG..md` if it exceeds 10 active entries, regenerates STATUS, rebuilds +`LOG.md`, and rebuilds the archive search index. + +```bash +python scripts/post_intake_refresh.py --task +``` + +For a multi-task intake pass the flag more than once, or omit `--task` to +refresh every active task: + +```bash +python scripts/post_intake_refresh.py --task wikitext --task surrogate +python scripts/post_intake_refresh.py # all tasks +``` + +This replaces the previous manual sequence of `write_run_sidecar.py` + +`archive_log.py` + `generate_status.py` + `refresh_index.py` + +`build_search_index.py`. + +## Step 8 -- Bi-directional sync (required by AGENTS.md) + +The `/generate-report` skill covers this. Confirm before closing the session: +- [ ] `logs/LOG..md` has a new entry +- [ ] `logs/STATUS..md` regenerated +- [ ] `run.json` sidecars present for every promoted run +- [ ] `LOG.md` global index row is updated +- [ ] PAPER.md Results section is current +- [ ] PAPER.md Ablation Summary row exists for this variant +- [ ] README.md "Current Results" row is current diff --git a/.claude/skills/log-entry/SKILL.md b/.claude/skills/log-entry/SKILL.md new file mode 100644 index 000000000..7a7b8ef79 --- /dev/null +++ b/.claude/skills/log-entry/SKILL.md @@ -0,0 +1,205 @@ +--- +name: log-entry +description: Appends a new structured entry to LOG.md following the project's experimental log format. Use after completing an experiment run, implementing a feature, fixing a bug, or any change that should be recorded. Also use when the user says "update the log", "add a log entry", or "record this run". +allowed-tools: Read, Edit +--- + +# Add a Log Entry + +## Step 0 — Check and update the pinned block + +After writing the entry (Step 3), decide whether this run is a new best or +second-best for this task: + +- Read the existing `## 📌 Current Best` block (if any) +- Compare the new result against the current 🥇 and 🥈 +- If the new result is better than 🥇: demote current 🥇 to 🥈, set new 🥇 +- If the new result is better than 🥈 only: replace 🥈 +- If the new result is not top-2: leave the block unchanged +- If no block exists yet: create it with this run as 🥇 (and 🥈 empty) + +**The pinned block must always appear before the first `## ` entry in the file.** +When in doubt about ranking (e.g. the metric changed), leave the block as-is +and note "metric changed — manual review needed" in the Notes row. + +### PAPER.md sync (required when pinned block changes) + +If the pinned block was updated (🥇 changed): + +1. Open `PAPER.md` and locate the Results section for this task. + It will be under `## Results` or a subsection like `### `. +2. Replace the current best result line with the new 🥇 values. +3. Move the displaced result (the old 🥇) into the task's + `## Appendix: Ablation History — ` table as a new row. + Format: `| YYYY-MM-DD | Run NNN | | | |` +4. Do not rewrite the whole Results section — surgical edit only. + +If the metric or task does not appear in PAPER.md Results yet, add it. +If PAPER.md does not exist, skip this step and note it in the log entry. + +## Step 1 -- Identify the task + +Determine the task name from context (e.g. the experiment directory, user instruction, +or the most recently modified code). The task name is the subdirectory under `experiments/`. + +The target file is `logs/LOG..md`. + +## Step 2 -- Read the current log + +```bash +head -10 logs/LOG..md +``` + +Find the current run number from the first `## ` heading. New run number = last + 1. + +## Step 3 -- Write the entry + +Use today's date (YYYY-MM-DD). **Prepend** the new entry immediately after the file +header and before the first existing `## ` heading -- logs are reverse-chronological +(newest first). + +### Entry format + +```markdown +## YYYY-MM-DD Run NNN (Short Title -- Key Distinction) + +```yaml meta +run: NNN +date: "YYYY-MM-DD" +task: +type: experiment # experiment | infrastructure | bugfix | intake +slurm_job: # omit if not a SLURM run +key_metric: # omit if no quantitative result +key_value: # the single most important result +key_unit: "%" # omit if dimensionless +walltime_mean_s: # mean wallclock seconds across all tasks; omit if not a SLURM run +walltime_std_s: # std of wallclock seconds across all tasks; omit if not a SLURM run +params: + model: + rank: + method: + epochs: + seeds: +tags: [, ] +``` + +**SLURM job:** `` | **Array:** `` | **Cluster:** `` + +### Objective + +One paragraph explaining what this run/change set out to do and why. + +### Differential Update + +**`path/to/changed/file.py`** (brief scope summary): +- Bullet explaining each specific change and rationale + +**`configs/task/config.yaml`**: +- What changed and why + +### Wallclock (required for SLURM runs) + +For any entry that corresponds to one or more SLURM jobs, always include a +`### Wallclock` section in the entry body. Extract elapsed seconds from the +`.out` files (the `Elapsed: s` line written by the runner) and compute +mean and std across all tasks in the sweep: + +```bash +grep -h "Elapsed:" experiments/from_HPC//.*.out \ + | awk '{print $2}' | sed 's/s//' \ + | python3 -c " +import sys, statistics +vals=[int(l) for l in sys.stdin] +print(f'N={len(vals)} mean={statistics.mean(vals):.0f}s std={statistics.stdev(vals):.0f}s min={min(vals)}s max={max(vals)}s') +" +``` + +Format the section as: + +```markdown +### Wallclock + +**Overall (N= tasks):** mean=s (h) ± s (h) | min=s | max=s + +| Config | N | mean (s) | std (s) | mean (h) | +|---|---|---:|---:|---:| +| | | | | h | +``` + +Include per-config breakdown whenever the sweep spans multiple configurations. +Omit if all tasks share the same config (report overall only). + +### Smoke Test (if applicable) + +``` +result_1: shape OK +result_2: value OK +``` + +Brief interpretation. + +--- +``` + +### Metadata block rules + +The ` ```yaml meta ``` ` block is **required** for every entry: +- Always include `run`, `date`, `task`, `type` +- Include `key_metric`/`key_value`/`key_unit` when the entry reports a quantitative result +- Include `slurm_job` when applicable +- Include relevant hyperparameters in `params` (free-form dict, task-specific) +- Add descriptive `tags` for filtering (e.g. `rank-sweep`, `promoted`, `negative-result`) + +The `**SLURM job:**` line is optional — omit it for non-SLURM runs (local tests, debug runs). +When present it must be the first line of the entry body, before Objective. + +## Step 4 -- Check if archiving is needed + +```bash +count=$(grep -c "^## " logs/LOG..md) +echo "$count entries" +``` + +If count > 10, invoke `/archive-log` with `--task --keep-last 5`. +(Hot logs stay small so STATUS + grep remain fast.) + +## Step 5 -- Update the global index + +Open `LOG.md` and update the row for this task with the new Last Run and Status. + +## Step 6 -- Bi-directional sync (required) + +After writing the log entry, verify that PAPER.md, README.md, and code are +consistent with what was logged. Any methodological change in code must be +reflected in PAPER.md in the same session. + +## Step 7 — Never archive the pinned block + +The `## 📌 Current Best` block is permanent. Confirm it is still present at +the top of the file after any archival operation: + +```bash +head -5 logs/LOG..md | grep "📌" +``` + +If it is missing, restore it from the archive file's header or from memory. + +## Step 8 — Refresh STATUS, global index, and search index + +After every log entry, regenerate the STATUS file (what Claude loads by default), +the global index, and the search index from ground truth: + +```bash +conda activate normal +python scripts/generate_status.py --task +python scripts/refresh_index.py +python scripts/build_search_index.py +``` + +This replaces the manual row-update instruction. Do not hand-edit LOG.md or +STATUS..md — both are regenerated from the per-task log. + +## Image embedding + +If embedding images, copy to `log/img/[original_name]_[YYYY-MM-DD-H_M_S].[ext]` +first, then embed with a relative path. diff --git a/.claude/skills/pax-hpc/SKILL.md b/.claude/skills/pax-hpc/SKILL.md new file mode 100644 index 000000000..a3a95d06e --- /dev/null +++ b/.claude/skills/pax-hpc/SKILL.md @@ -0,0 +1,292 @@ +--- +name: pax-hpc +description: >- + Prepare and launch compute work on the user's two Tufts "Pax" SLURM HPC + clusters. Use this skill WHENEVER the user wants to run, batch, submit, + array, schedule, profile, or GPU-accelerate any job, experiment, script, or + training run that is meant for the cluster — even if they don't say the word + "cluster". Trigger on mentions of sbatch, srun, salloc, squeue, slurm, + "the cluster", "on pax", "submit a job", "run this on a GPU", singularity / + apptainer containers, conda environments meant for the cluster, login-p02, + login-prod-03, preempt / requeue / resume, or any request to turn local code + into something that runs on HPC. There are two clusters: OLD (login-prod-03, + RHEL7, runs programs via Singularity) and NEW (login-p02, Rocky9, runs + programs via conda). Always ask which one first. +--- + +# Pax HPC (two clusters) + +This skill captures how the user works with their two Tufts Pax SLURM clusters +so they never have to re-explain it. Both clusters share ONE filesystem, so +data, repos, conda envs, and Singularity images live in the same place on both. +The clusters differ in OS, software runtime, walltime limits, and GPU hardware. + +| | OLD cluster | NEW cluster | +|---|---|---| +| Login host | `login-prod-0X.pax.tufts.edu` (01, 03, …) | `login-p02.pax.tufts.edu` | +| Compute nodes | legacy `cc1gpu / s1cmp / p1cmp / d1cmp###` | `pax001`–`pax120` | +| OS / Slurm | RHEL 7.5 / Slurm 23.02 | Rocky 9.6 / Slurm 23.11 | +| Runs programs via | **Singularity** (`module load singularity`) | **conda** (`conda activate`) | +| Max walltime | 6 days | **2 days** | +| GPUs | p100, v100, t4, a100(40G), + some newer | h100, h200, a100(40/80G), l40s, + more | +| Why not the other runtime | host glibc too old for modern conda binaries | conda is native; apptainer also available | + +Username: `hhazan01`. Full hardware, partitions, QOS, and GPU **VRAM-tagged +feature labels** are in `references/old-cluster.md` and +`references/new-cluster.md` — read the relevant one when sizing a job. +Resilience mechanics (requeue, resume, preempt signal-trap, walltime +self-requeue, adaptive node-exclude, VRAM guard, dependency chains) live in +`references/resilience.md`. Bringing results back and classifying them lives in +`references/intake.md`. + +## The non-negotiable rules + +1. **Never run ANY task on a login node — under any circumstances** (OLD or NEW). + This is absolute (user directive, 2026-06-07). No training, no data crunching, + no `python long_thing.py`, no compiling, no `singularity exec` of a workload — + **and also no "lightweight" inline work**: no `python scripts/intake.py`, + `consolidate.py`, results-aggregation, jsonl parsing, or env-python one-liners + on the login node, even for a quick verification. If you need to run code + against cluster data, submit it (`sbatch`) or use an interactive compute + allocation (`srun --pty`) — or pull the data local and run it here. The login + node is ONLY for: editing files, submitting jobs (`sbatch`), launching + allocations (`srun --pty`/`salloc`), monitoring (`squeue`, `sacct`, + `scancel`), small `ls`/`tail`/`grep` on output files, and `rsync`. **This + applies to `login-p02` even when it is used as a no-Duo ProxyJump gateway to + the OLD cluster — gateway convenience is not a compute license.** When in + doubt, it does not run on login: pull the data local and run it here, or + `sbatch`/`srun`. + +2. **Always ask which cluster first: OLD or NEW?** The answer changes the + runtime wrapper (Singularity vs conda), the walltime ceiling (6d vs 2d), and + which GPUs are valid. Do not guess. (If the user already said this session, + don't re-ask.) + +3. **Prepare everything locally.** Build the job script and any code/config in a + local staging folder. Do not assume you can write on the cluster. + +4. **The user does the rsync** unless they explicitly say otherwise. Give them + the exact `rsync` command, but they run it. + +5. **End every preparation with ONE line** the user pastes on the login node to + launch — normally `sbatch