Repository: thousandbrainsproject/tbp.monty
Pull request #210: minor modifications to evidence updating
Author: login: ramyamounir
Labels: name: triaged
description: This issue or pull request was triaged
Created: 2025-03-13T12:57:23Z | Merged: 2025-03-26T23:29:48Z

While working on resampling hypotheses, I did some simplification to the `_update_evidence` and `_add_hypotheses_to_hpspace` functions as well as introduced a `ChannelMapper` class to replace the existing `channel_hypotheses_mapping` logic. I thought it would be a good idea to isolate these modifications from the actual resampling code and submit this as a separate PR (as opposed to a huge PR with big changes tangled up). Here is a brief summary of the changes:

### Channel Mapper
The current implementation stacks the hypotheses from all input channels in the same array to perform efficient vector operations on them. To identify which hypotheses correspond to which input channel, `channel_hypothesis_mapping` dictionary stores the ranges of indices for each channel and the total number of hypotheses over all channels. Here is an example:

```python
EvidenceGraphLM.channel_hypothesis_mapping = {'mug': {'patch_1':[0, 499], 'patch_2': [500, 789], 'num_hypotheses':790}
```
While this format doesn't necessarily pose a problem for resampling, it would not be easy to change the size of hypotheses for each input channel. For example, to change `patch_1` from [0,499] to [0,399], we would also have to update all subsequent channel ranges and update the total `num_hypotheses`. We want to allow for dynamic resizing of hypothesis space during resampling.

The proposed solution is to store only the size of the input channel hypotheses in a List or OrderedDict (any data structure that maintains the order will work fine). The ranges can be efficiently computed with cumulative sum calculation and the `num_hypotheses` is just the sum of all channel sizes. This functionality has been defined in the class `ChannelMapper()` in the `utils/evidence_matching.py` file. This change has already removed the need for much of the code that keeps track of `num_hypotheses`, such as [here](https://github.com/thousandbrainsproject/tbp.monty/blob/6d0f70db6440df99b46195d529217a7799ae9c93/src/tbp/monty/frameworks/models/evidence_matching.py#L880) and [here](https://github.com/thousandbrainsproject/tbp.monty/blob/6d0f70db6440df99b46195d529217a7799ae9c93/src/tbp/monty/frameworks/models/evidence_matching.py#L983).


### Initializing Hypothesis Space (Not implemented in this PR)
The `_update_evidence` draws a distinction between these two cases:
* We have no displacement
* We have displacement but no existing hypotheses

In both cases, we initialize a hypotheses space in roughly the same way. We `_get_initial_hypothesis_space` then add these hypotheses to the initialized space. I have rewritten this part of the `_update_evidence` function to apply the same logic for both cases. I also modified the `_add_hypotheses_to_hpspace` function to handle the case where there is already a hypothesis space initialized. In this case, we want to update the evidence with the mean of existing hypotheses.

### Explicit Evidence Reset (Not implemented in this PR)
The signal for resetting the evidence scores is not straightforward to find. The resetting depends on whether we have displacement or not. If we don't have displacement, we overwrite the existing evidence with new evidence scores, as shown [here](https://github.com/thousandbrainsproject/tbp.monty/blob/6d0f70db6440df99b46195d529217a7799ae9c93/src/tbp/monty/frameworks/models/evidence_matching.py#L990). I think this relies on the buffer to be reset. I suspect these indirect signals for evidence reset may cause some strange behavior in the unsupervised inference testbed. A direct `evidence={}` in the `EvidenceGraphLM.reset` function (called from pre_episode) will be easier to debug.

I explicitly reset evidence in the `EvidenceGraphLM.reset` function and removed the dependency on the `displacments is None` reset signal in `_update_evidence`. I think this is a more direct signal of reset. Since the evidence is now cleared out in the reset function, we can just check if `graph_id` is in evidence.keys() to decide on initializing hypotheses space vs. add to an existing space. This has been done in `_add_hypotheses_to_hpspace` now as proposed in this PR.

---

## Testing on Benchmarks
I ran benchmark tests on 10 objects (surface and distant agent), as well as 77 objects (surface agent), before and after the changes. I've seen no change in performance. The exact benchmark experiments I ran are:
* `randrot_noise_10distinctobj_surf_agent`
* `randrot_noise_10distinctobj_dist_agent`
* `randrot_noise_77obj_surf_agent`




Diff:
diff --git a/src/tbp/monty/frameworks/models/evidence_matching.py b/src/tbp/monty/frameworks/models/evidence_matching.py
index 27272d566..4e77d17dd 100644
--- a/src/tbp/monty/frameworks/models/evidence_matching.py
+++ b/src/tbp/monty/frameworks/models/evidence_matching.py
@@ -17,9 +17,7 @@
 from scipy.spatial import KDTree
 from scipy.spatial.transform import Rotation
 
-from tbp.monty.frameworks.models.goal_state_generation import (
-    EvidenceGoalStateGenerator,
-)
+from tbp.monty.frameworks.models.goal_state_generation import EvidenceGoalStateGenerator
 from tbp.monty.frameworks.models.graph_matching import (
     GraphLM,
     GraphMemory,
@@ -31,6 +29,7 @@
     GridTooSmallError,
 )
 from tbp.monty.frameworks.models.states import State
+from tbp.monty.frameworks.utils.evidence_matching import ChannelMapper
 from tbp.monty.frameworks.utils.graph_matching_utils import (
     add_pose_features_to_tolerances,
     get_custom_distances,
@@ -340,10 +339,8 @@ def __init__(
         self.evidence = {}
         self.possible_locations = {}
         self.possible_poses = {}
-        # Stores start and end indices of hypotheses in the above arrays for each graph
-        # corresponding to each input channel. This is used to make sure the right
-        # displacement is applied to the right hypotheses. Channel hypotheses are stored
-        # contiguously so we can just specify ranges here.
+
+        # A dictionary from graph_id to instances of `ChannelMapper`.
         self.channel_hypothesis_mapping = {}
 
         self.current_mlh = {
@@ -372,6 +369,7 @@ def reset(self):
             self.graph_memory.initialize_feature_arrays()
         self.symmetry_evidence = 0
         self.last_possible_hypotheses = None
+        self.channel_hypothesis_mapping = {}
 
         self.current_mlh["graph_id"] = "no_observations_yet"
         self.current_mlh["location"] = [0, 0, 0]
@@ -877,13 +875,8 @@ def _add_hypotheses_to_hpspace(
         )
         self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
         # Update channel hypothesis mapping
-        old_num_hypotheses = self.channel_hypothesis_mapping[graph_id]["num_hypotheses"]
-        new_num_hypotheses = old_num_hypotheses + len(new_loc_hypotheses)
-        self.channel_hypothesis_mapping[graph_id][input_channel] = [
-            old_num_hypotheses,
-            new_num_hypotheses,
-        ]
-        self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = new_num_hypotheses
+        channel_mapper = self.channel_hypothesis_mapping[graph_id]
+        channel_mapper.add_channel(input_channel, len(new_loc_hypotheses))
 
     def _update_possible_matches(self, query):
         """Update evidence for each hypothesis instead of removing them."""
@@ -962,8 +955,7 @@ def _update_evidence(self, features, displacements, graph_id):
             initial_possible_locations = []
             initial_possible_rotations = []
             initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
+            channel_mapper = ChannelMapper()
             for input_channel in input_channels_to_use:
                 (
                     initial_possible_channel_locations,
@@ -975,12 +967,7 @@ def _update_evidence(self, features, displacements, graph_id):
                 initial_possible_locations.append(initial_possible_channel_locations)
                 initial_possible_rotations.append(initial_possible_channel_rotations)
                 initial_evidence.append(channel_evidence)
-                self.channel_hypothesis_mapping[graph_id][input_channel] = [
-                    num_hypotheses,
-                    num_hypotheses + len(initial_possible_channel_locations),
-                ]
-                num_hypotheses += len(initial_possible_channel_locations)
-            self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = num_hypotheses
+                channel_mapper.add_channel(input_channel, len(channel_evidence))
             self.possible_locations[graph_id] = np.concatenate(
                 initial_possible_locations, axis=0
             )
@@ -990,6 +977,7 @@ def _update_evidence(self, features, displacements, graph_id):
             self.evidence[graph_id] = (
                 np.concatenate(initial_evidence, axis=0) * self.present_weight
             )
+            self.channel_hypothesis_mapping[graph_id] = channel_mapper
             logging.debug(
                 f"\nhypothesis space for {graph_id}: {self.evidence[graph_id].shape[0]}"
             )
@@ -1005,13 +993,12 @@ def _update_evidence(self, features, displacements, graph_id):
                     f"No input channels observed for {graph_id} that are stored in . "
                     "the model. Not updating evidence."
                 )
+
+            channel_mapper = self.channel_hypothesis_mapping[graph_id]
             for input_channel in input_channels_to_use:
                 # If channel features are observed for the first time, initialize
                 # hypotheses for them.
-                if (
-                    input_channel
-                    not in self.channel_hypothesis_mapping[graph_id].keys()
-                ):
+                if input_channel not in channel_mapper.channels:
                     # TODO H: When initializing a hypothesis for a channel later on,
                     # include most likely existing hypothesis from other channels?
                     (
@@ -1033,10 +1020,10 @@ def _update_evidence(self, features, displacements, graph_id):
                 else:
                     # Get the observed displacement for this channel
                     displacement = displacements[input_channel]
-                    # Get the IDs in hypothesis space for this channel
-                    channel_start, channel_end = self.channel_hypothesis_mapping[
-                        graph_id
-                    ][input_channel]
+                    # Get the IDs range in hypothesis space for this channel
+                    channel_start, channel_end = channel_mapper.channel_range(
+                        input_channel
+                    )
                     # Have to do this for all hypotheses so we don't loose the path
                     # information
                     rotated_displacements = self.possible_poses[graph_id][
diff --git a/src/tbp/monty/frameworks/utils/evidence_matching.py b/src/tbp/monty/frameworks/utils/evidence_matching.py
new file mode 100644
index 000000000..5c81a4afc
--- /dev/null
+++ b/src/tbp/monty/frameworks/utils/evidence_matching.py
@@ -0,0 +1,130 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of
+                {channel_name: size}.
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
+        """Increases or decreases the channel by a specific amount.
+
+        Args:
+            channel_name (str): The name of the channel.
+            value (int): The value used to modify the channel size.
+                Use a negative value to decrease the size.
+
+        Raises:
+            ValueError: If the channel is not found or the requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def add_channel(
+        self, channel_name: str, size: int, position: Optional[int] = None
+    ) -> None:
+        """Adds a new channel at a specified position (default is at the end).
+
+        Args:
+            channel_name (str): The name of the new channel.
+            size (int): The size of the new channel.
+            position (Optional[int]): The index at which to insert the channel.
+
+        Raises:
+            ValueError: If the channel already exists or position is out of bounds.
+        """
+        if channel_name in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' already exists.")
+
+        if type(position) == int and position >= len(self.channel_sizes):
+            raise ValueError(f"Position index '{position}' is out of bounds.")
+
+        if position is None:
+            self.channel_sizes[channel_name] = size
+        else:
+            items = list(self.channel_sizes.items())
+            items.insert(position, (channel_name, size))
+            self.channel_sizes = OrderedDict(items)
+
+    def __repr__(self) -> str:
+        """Returns a string representation of the current channel mapping.
+
+        Returns:
+            str: String representation of the channel mappings.
+        """
+        ranges = {ch: self.channel_range(ch) for ch in self.channel_sizes}
+        return f"ChannelMapper({ranges})"
diff --git a/tests/unit/channel_mapper_test.py b/tests/unit/channel_mapper_test.py
new file mode 100644
index 000000000..0ff90699d
--- /dev/null
+++ b/tests/unit/channel_mapper_test.py
@@ -0,0 +1,75 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+import unittest
+
+from tbp.monty.frameworks.utils.evidence_matching import ChannelMapper
+
+
+class ChannelMapperTest(unittest.TestCase):
+    def setUp(self) -> None:
+        """Sets up a default ChannelMapper instance for testing."""
+        self.mapper = ChannelMapper({"A": 5, "B": 10, "C": 15})
+
+    def test_initialization(self):
+        """Test initializing ChannelMapper with predefined sizes."""
+        self.assertEqual(self.mapper.channels, ["A", "B", "C"])
+        self.assertEqual(self.mapper.total_size, 30)
+
+    def test_channel_range(self):
+        """Test retrieving channel ranges and non-existent channels."""
+        self.assertEqual(self.mapper.channel_range("A"), (0, 5))
+        self.assertEqual(self.mapper.channel_range("B"), (5, 15))
+        self.assertEqual(self.mapper.channel_range("C"), (15, 30))
+        with self.assertRaises(ValueError):
+            self.mapper.channel_range("D")
+
+    def test_resize_channel_by_positive(self):
+        """Test increasing channel sizes."""
+        self.mapper.resize_channel_by("B", 5)
+        self.assertEqual(self.mapper.channel_range("B"), (5, 20))
+        self.assertEqual(self.mapper.total_size, 35)
+
+    def test_resize_channel_by_negative(self):
+        """Test decreasing channel sizes."""
+        self.mapper.resize_channel_by("B", -5)
+        self.assertEqual(self.mapper.channel_range("B"), (5, 10))
+        self.assertEqual(self.mapper.total_size, 25)
+
+        with self.assertRaises(ValueError):
+            self.mapper.resize_channel_by("A", -10)
+
+    def test_add_channel(self):
+        """Test adding a new channel."""
+        self.mapper.add_channel("D", 8)
+        self.assertIn("D", self.mapper.channels)
+        self.assertEqual(self.mapper.channel_range("D"), (30, 38))
+
+        with self.assertRaises(ValueError):
+            self.mapper.add_channel("A", 3)
+
+    def test_add_channel_at_position(self):
+        """Test inserting a channel at a specific position."""
+        self.mapper.add_channel("X", 7, position=1)
+        self.assertEqual(self.mapper.channels, ["A", "X", "B", "C"])
+        self.assertEqual(self.mapper.channel_range("X"), (5, 12))
+        self.assertEqual(self.mapper.channel_range("B"), (12, 22))
+        self.assertEqual(self.mapper.channel_range("C"), (22, 37))
+
+        with self.assertRaises(ValueError):
+            self.mapper.add_channel("Y", 5, position=10)
+
+    def test_repr(self):
+        """Test string representation of the ChannelMapper."""
+        expected_repr = "ChannelMapper({'A': (0, 5), 'B': (5, 15), 'C': (15, 30)})"
+        self.assertEqual(repr(self.mapper), expected_repr)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/unit/evidence_lm_test.py b/tests/unit/evidence_lm_test.py
index a610be9c5..a6458da15 100644
--- a/tests/unit/evidence_lm_test.py
+++ b/tests/unit/evidence_lm_test.py
@@ -1385,6 +1385,29 @@ def test_different_locations_not_recognized_elm(self):
                     "Should have no possible matches.",
                 )
 
+    def test_channel_mapper_shape_elm(self):
+        """Test that the channel mapper matches evidence keys and shape."""
+        fake_obs_test = copy.deepcopy(self.fake_obs_learn)
+
+        graph_lm = self.get_elm_with_fake_object(self.fake_obs_learn)
+
+        graph_lm.mode = "eval"
+        graph_lm.pre_episode(primary_target=self.placeholder_target)
+        graph_lm.add_lm_processing_to_buffer_stats(lm_processed=True)
+        graph_lm.matching_step([fake_obs_test[0]])
+
+        self.assertEqual(
+            graph_lm.evidence.keys(),
+            graph_lm.channel_hypothesis_mapping.keys(),
+            "Graph ID should match.",
+        )
+
+        self.assertEqual(
+            graph_lm.evidence["new_object0"].shape[0],
+            graph_lm.channel_hypothesis_mapping["new_object0"].total_size,
+            "Channel mapper should have the total number of hypotheses in evidence",
+        )
+
     def _evaluate_target_location(
         self, graph_lm, fake_obs_test, target_object, focus_on_pose=False
     ):


Reviews:
user: login: scottcanoe
body: Nice @ramyamounir, I think this is a nice approach, and it helps quite a bit with the readability of `EvidenceLM._update_evidence`.

My generic feedback is that it'd be really nice to include type annotation for `ChannelMapper`, and if you've got the time, anywhere in `EvidenceLM` while the parameters are fresh in your mind (though type annotation for `EvidenceLM` could be done in a separate PR). I always struggle a bit when reading `EvidenceLM` code since it can be hard to keep track of what the data structures are, so any extra annotation/documentation would be much appreciated.

And just for my understanding, it seems like an `input_channel` refers to a source (either an SM or LM), but `EvidenceLM._update_evidence` seems to get a list of potential input channels from a dictionary of features. Are these features like `rgba`? Or principal curvature vectors? If so, how does that square with the idea that an input channel corresponds to different SMs or LMs?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T21:15:50Z
user: login: tristanls
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T22:35:17Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T23:59:23Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:01:03Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:01:15Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:05:25Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:05:43Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:06:13Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:06:46Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:09:12Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T00:09:40Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T01:05:42Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-15T02:14:17Z
user: login: nielsleadholm
body: Looks great, I think this is a nice and clean approach to this. Just added a few comments on documentation/naming.

One other question - you alluded in the PR description to the changes possibly affecting unsupervised learning, did you have a chance to check whether these benchmark experiments give the same result? 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T15:01:35Z
user: login: vkakerbeck
body: Nice idea! I like how it cleans some of this channel mapping up. Also nice that you added unit tests already. 
I didn't do a super detailed review (waiting for changes re the other comments to be incorporated) but just left 2 high-level comments for now. They are on an aspect of the old implementation that I don't see here anymore now. I am probably just missing something if the benchmark results are still exactly the same but want to make sure.

Also, since there is only one hierarchy unit test and this is probably the condition that is most affected, maybe you could run a before and after comparison of an experiment with `two_stacked_lms_config` (like your `two_lm_distant_heterarchy_sdr` experiment) and make sure the results are still exactly the same.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T09:39:30Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T13:51:27Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:00:38Z
user: login: vkakerbeck
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:30:41Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:36:53Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:37:39Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:38:01Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T14:46:40Z
user: login: tristanls
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T19:03:54Z
user: login: vkakerbeck
body: Thanks for the updates! I just added a few comments to make sure I understand everything you are doing. 
I'm sorry I am asking about so many details and extra tests but I am just a bit anxious about this change causing some unexpected change in functionality without us noticing since hierarchical LMs are not very well tested yet and the unit tests often just test that code runs and not that we still get exactly the same results numerically. It would be great if you could double-check this change on these three things:
- an unsupervised experiment from the benchmarks (https://thousandbrainsproject.readme.io/docs/benchmark-experiments#results-2)
- `two_lm_distant_heterarchy_sdr` (config is here in monty_lab: https://github.com/thousandbrainsproject/monty_lab/blob/main/experiments/configs/evidence_sdr_evaluation.py
- Just having a quick look at the .csv from one of the experiments you already ran to make sure the exact numbers still match up, not just the summary stats (like `highest_evidence`, `rotation_error` and the different number of steps for each episode) + also if there was any significant effect on the runtimes.

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T09:17:08Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T10:55:56Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:02:01Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T11:02:35Z
user: login: nielsleadholm
body: Great! Did you have a chance to do a quick sanity check with one of the benchmark experiments to ensure performance is the same now that you've reverted most of these changes, but still have the channel mapper? Otherwise happy to merge. 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T12:29:04Z
user: login: vkakerbeck
body: Looks great, thanks for adding this channel mapper :)
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T13:32:17Z
user: login: ramyamounir
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-21T15:28:46Z
user: login: tristanls
body: Thank you for the updates @ramyamounir.

I have one **issue** regarding a comment. Aside from that, other things are **non-blocking**.

**note**: My comment labels are intended to be interpreted per https://conventionalcomments.org/
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T21:46:36Z
user: login: jeremyshoemaker
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:19:24Z
user: login: tristanls
body: Thank you for the updates. Looks good!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T22:49:10Z

Review comments:
diff_hunk: @@ -857,33 +858,44 @@ def _add_hypotheses_to_hpspace(
         new_rot_hypotheses,
         new_evidence,
     ):
-        """Add new hypotheses to hypothesis space."""
+        """Add new hypotheses to hypothesis space.
+
+        If the hypothesis space doesn't exist, it will be initialized here.
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: scottcanoe
body: IMO, adding type annotation and google-style docstrings would be really helpful here. Most of `tbp.monty` will (I think/hope) be type-annotated everywhere, and PRs like these seem like an opportunity to move us closer to that goal.
created_at: 2025-03-13T19:39:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: scottcanoe
body: Is this every called to shrink the channel size (i.e., with `value` < 0)?
created_at: 2025-03-13T19:50:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +953,108 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in list(features.keys())
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: scottcanoe
body: Mostly stylistic, but `features.keys()` doesn't need to be converted to a list to be iterated over.
created_at: 2025-03-13T20:14:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +953,108 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in list(features.keys())
+            if ic in self.get_input_channels_in_graph(graph_id)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: scottcanoe
body: I wonder if `self.get_input_channels_in_graph(graph_id)` gets called for every iteration.
created_at: 2025-03-13T20:20:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: scottcanoe
body: We're using Google-style docstrings: https://thousandbrainsproject.readme.io/docs/style-guide#code-docstrings
created_at: 2025-03-13T20:28:24Z
author_association: CONTRIBUTOR
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue**: This file is inside a `utils` folder; it does not need the `_utils` suffix.

**suggestion**: Rename to `evidence_matching.py`.

**note**: I understand there are other files with the `_utils` suffix in this folder, but let's not add any new ones.
created_at: 2025-03-13T20:51:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: scottcanoe
body: My recommendation would be to describe how the class works without referencing how things used to be done.

As a reader, I'd also appreciate a tad more about how this class fits into the `EvidenceLM`. It could be something like what you wrote for the PR: "The `EvidenceLM` stacks hypotheses from all input channels in the same array to perform efficient vector operations on them. Therefore, we need to keep track of which indices in the stacked array correspond to which input channel. This class..."
created_at: 2025-03-13T20:52:09Z
author_association: CONTRIBUTOR
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue (non-blocking)**: Please add types.
created_at: 2025-03-13T21:03:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue**: `get_` is a noise word.

**suggestion**: Rename to `channel_range`.
created_at: 2025-03-13T21:04:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **todo**: Use Google Style docstrings per the [Style Guide](https://thousandbrainsproject.readme.io/docs/style-guide#code-docstrings).
created_at: 2025-03-13T21:24:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue**: This is unused.

**suggestion**: Remove.
created_at: 2025-03-13T21:29:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **question**: I understand that this replaces `num_hypotheses`, but doesn't the existence of `ChannelMapper` obviate the need to track `num_hypotheses` and hence there's no longer a need to retrieve `total_size` of the `ChannelMapper`?

If I understand correctly, knowing and tracking `num_hypotheses` was only used to update indices:
```python
        old_num_hypotheses = self.channel_hypothesis_mapping[graph_id]["num_hypotheses"]
        new_num_hypotheses = old_num_hypotheses + len(new_loc_hypotheses)
        self.channel_hypothesis_mapping[graph_id][input_channel] = [
            old_num_hypotheses,
            new_num_hypotheses,
        ]
        self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = new_num_hypotheses
```
or
```python
            num_hypotheses = 0
            for input_channel in input_channels_to_use:
                (
                    initial_possible_channel_locations,
                    initial_possible_channel_rotations,
                    channel_evidence,
                ) = self._get_initial_hypothesis_space(
                    features, graph_id, input_channel
                )
                initial_possible_locations.append(initial_possible_channel_locations)
                initial_possible_rotations.append(initial_possible_channel_rotations)
                initial_evidence.append(channel_evidence)
                self.channel_hypothesis_mapping[graph_id][input_channel] = [
                    num_hypotheses,
                    num_hypotheses + len(initial_possible_channel_locations),
                ]
                num_hypotheses += len(initial_possible_channel_locations)
            self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = num_hypotheses
```
created_at: 2025-03-13T21:33:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
+        """Increases the size of the specified channel.
+
+        :param value: int, value added or subtracted from channel size.
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **todo**: Use Google Style docstrings per the [Style Guide](https://thousandbrainsproject.readme.io/docs/style-guide#code-docstrings).
created_at: 2025-03-13T21:36:51Z
author_association: CONTRIBUTOR
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue**: Please add unit tests.
created_at: 2025-03-13T21:38:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
+        """Increases the size of the specified channel.
+
+        :param value: int, value added or subtracted from channel size.
+
+        Raises:
+            ValueError: If the channel is not found or requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def set_channel_size(self, channel_name, value):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue**: This is unused.

**suggestion**: Remove.
created_at: 2025-03-13T21:41:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue**: The name of this method and some of the docstring is misleading. This changes the channel size, not increases it.

**suggestion**: `resize_channel_by`

**note**: The reason I'm not suggesting `resize_channel` is in case you want explicit update functionality in the future, then that method would be `resize_channel(val)`.
created_at: 2025-03-13T21:43:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
+        """Increases the size of the specified channel.
+
+        :param value: int, value added or subtracted from channel size.
+
+        Raises:
+            ValueError: If the channel is not found or requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def set_channel_size(self, channel_name, value):
+        """Set the size of the specified channel.
+
+        :param value: int, value to set channel size.
+
+        Raises:
+            ValueError: If the channel is not found or requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] = value
+
+    def add_channel(self, channel_name, size, position=None):
+        """Adds a new channel at a specified position (default is at the end).
+
+        Raises:
+            ValueError: If the channel already exists.
+        """
+        if channel_name in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' already exists.")
+
+        if position is None or position >= len(self.channel_sizes):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **suggestion**: I think that if the caller miscalculated the `position` by going beyond `len(self.channel_sizes)`, then we should let them know by raising `ValueError` instead of accepting out-of-bounds input.
created_at: 2025-03-13T21:51:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
+        """Increases the size of the specified channel.
+
+        :param value: int, value added or subtracted from channel size.
+
+        Raises:
+            ValueError: If the channel is not found or requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def set_channel_size(self, channel_name, value):
+        """Set the size of the specified channel.
+
+        :param value: int, value to set channel size.
+
+        Raises:
+            ValueError: If the channel is not found or requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] = value
+
+    def add_channel(self, channel_name, size, position=None):
+        """Adds a new channel at a specified position (default is at the end).
+
+        Raises:
+            ValueError: If the channel already exists.
+        """
+        if channel_name in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' already exists.")
+
+        if position is None or position >= len(self.channel_sizes):
+            self.channel_sizes[channel_name] = size
+        else:
+            items = list(self.channel_sizes.items())
+            items.insert(position, (channel_name, size))
+            self.channel_sizes = OrderedDict(items)
+
+    def remove_channel(self, channel_name):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: tristanls
body: **issue**: This is unused.

**suggestion**: Remove.
created_at: 2025-03-13T21:56:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,33 +858,44 @@ def _add_hypotheses_to_hpspace(
         new_rot_hypotheses,
         new_evidence,
     ):
-        """Add new hypotheses to hypothesis space."""
+        """Add new hypotheses to hypothesis space.
+
+        If the hypothesis space doesn't exist, it will be initialized here.
+        Otherwise, we will append additional hypotheses to the existing space
+        and initialize its evidence with the mean of existing hypotheses' evidence.
+        """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
-        current_mean_evidence = np.mean(self.evidence[graph_id])
-        new_evidence = new_evidence + current_mean_evidence
-        # Add new hypotheses to hypothesis space
-        self.possible_locations[graph_id] = np.vstack(
-            [
-                self.possible_locations[graph_id],
-                new_loc_hypotheses,
-            ]
-        )
-        self.possible_poses[graph_id] = np.vstack(
-            [
-                self.possible_poses[graph_id],
-                new_rot_hypotheses,
-            ]
-        )
-        self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
-        # Update channel hypothesis mapping
-        old_num_hypotheses = self.channel_hypothesis_mapping[graph_id]["num_hypotheses"]
-        new_num_hypotheses = old_num_hypotheses + len(new_loc_hypotheses)
-        self.channel_hypothesis_mapping[graph_id][input_channel] = [
-            old_num_hypotheses,
-            new_num_hypotheses,
-        ]
-        self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = new_num_hypotheses
+
+        if graph_id in self.evidence.keys():
+            current_mean_evidence = np.mean(self.evidence[graph_id])
+            new_evidence = new_evidence + current_mean_evidence
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: **suggestion**: `+=`?
created_at: 2025-03-13T22:11:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,33 +858,44 @@ def _add_hypotheses_to_hpspace(
         new_rot_hypotheses,
         new_evidence,
     ):
-        """Add new hypotheses to hypothesis space."""
+        """Add new hypotheses to hypothesis space.
+
+        If the hypothesis space doesn't exist, it will be initialized here.
+        Otherwise, we will append additional hypotheses to the existing space
+        and initialize its evidence with the mean of existing hypotheses' evidence.
+        """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
-        current_mean_evidence = np.mean(self.evidence[graph_id])
-        new_evidence = new_evidence + current_mean_evidence
-        # Add new hypotheses to hypothesis space
-        self.possible_locations[graph_id] = np.vstack(
-            [
-                self.possible_locations[graph_id],
-                new_loc_hypotheses,
-            ]
-        )
-        self.possible_poses[graph_id] = np.vstack(
-            [
-                self.possible_poses[graph_id],
-                new_rot_hypotheses,
-            ]
-        )
-        self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
-        # Update channel hypothesis mapping
-        old_num_hypotheses = self.channel_hypothesis_mapping[graph_id]["num_hypotheses"]
-        new_num_hypotheses = old_num_hypotheses + len(new_loc_hypotheses)
-        self.channel_hypothesis_mapping[graph_id][input_channel] = [
-            old_num_hypotheses,
-            new_num_hypotheses,
-        ]
-        self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = new_num_hypotheses
+
+        if graph_id in self.evidence.keys():
+            current_mean_evidence = np.mean(self.evidence[graph_id])
+            new_evidence = new_evidence + current_mean_evidence
+            self.possible_locations[graph_id] = np.vstack(
+                [
+                    self.possible_locations[graph_id],
+                    new_loc_hypotheses,
+                ]
+            )
+            self.possible_poses[graph_id] = np.vstack(
+                [
+                    self.possible_poses[graph_id],
+                    new_rot_hypotheses,
+                ]
+            )
+            self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
+
+            self.channel_hypothesis_mapping[graph_id].increase_channel_size(
+                input_channel, len(new_evidence)
+            )
+
+        else:
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: **issue**: This `else` is surprising to me because the state of `self` is such that `graph_id` is not present in `self.evidence`, nor `self.possible_locations`, nor `self.possible_poses`. However, `graph_id` _is_ present in `self.channel_hypothesis_mapping`. It feels to me like the initialization is split between two methods, here and in `_update_evidence`.

**question**: Would it work to move `ChannelMapper()` initialization here too?
```python
        if graph_id not in self.channel_hypothesis_mapping:
            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
```
created_at: 2025-03-13T22:21:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,33 +858,44 @@ def _add_hypotheses_to_hpspace(
         new_rot_hypotheses,
         new_evidence,
     ):
-        """Add new hypotheses to hypothesis space."""
+        """Add new hypotheses to hypothesis space.
+
+        If the hypothesis space doesn't exist, it will be initialized here.
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: I agree, it would be nice to move towards type annotations everywhere. I've added type annotations and google-style docstrings to `ChannelMapper`, and I'll add those to `EvidenceGraphLM` in the next PR(s) as I dive deeper into the LM code.
created_at: 2025-03-14T23:59:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: ramyamounir
body: Yes, It can shrink channel size if `value` is <0. I've changed the name to `resize_channel_by` as suggested by Tristan. The resizing will be useful for resampling hypotheses.
created_at: 2025-03-15T00:01:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +953,108 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in list(features.keys())
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: Good point, fixed.
created_at: 2025-03-15T00:01:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +953,108 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in list(features.keys())
+            if ic in self.get_input_channels_in_graph(graph_id)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: Yes, good point. It gets called every iteration but it calls `GraphMemory.get_input_channels_in_graph` which just returns the keys of a dictionary (`GraphMemory.models_in_memory[graph_id].keys()`). There is not much compute going on, so it seems better to just call it every iteration.
created_at: 2025-03-15T00:05:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: ramyamounir
body: Fixed.
created_at: 2025-03-15T00:05:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: ramyamounir
body: Nice, yeah I fixed it.
created_at: 2025-03-15T00:06:13Z
author_association: CONTRIBUTOR
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: ramyamounir
body: Fixed, added and added.
created_at: 2025-03-15T00:06:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: ramyamounir
body: Good point. I renamed it to `resize_channel_by` and renamed `set_channel_size` to `resize_channel`. Thanks.
created_at: 2025-03-15T00:09:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
+        """Returns the total number of hypotheses across all channels."""
+        return sum(self.channel_sizes.values())
+
+    def get_channel_range(self, channel_name):
+        """Returns the start and end indices of the given channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def increase_channel_size(self, channel_name, value):
+        """Increases the size of the specified channel.
+
+        :param value: int, value added or subtracted from channel size.
+
+        Raises:
+            ValueError: If the channel is not found or requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def set_channel_size(self, channel_name, value):
+        """Set the size of the specified channel.
+
+        :param value: int, value to set channel size.
+
+        Raises:
+            ValueError: If the channel is not found or requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] = value
+
+    def add_channel(self, channel_name, size, position=None):
+        """Adds a new channel at a specified position (default is at the end).
+
+        Raises:
+            ValueError: If the channel already exists.
+        """
+        if channel_name in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' already exists.")
+
+        if position is None or position >= len(self.channel_sizes):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: ramyamounir
body: Yeah, good point. Fixed.
created_at: 2025-03-15T00:09:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,33 +858,44 @@ def _add_hypotheses_to_hpspace(
         new_rot_hypotheses,
         new_evidence,
     ):
-        """Add new hypotheses to hypothesis space."""
+        """Add new hypotheses to hypothesis space.
+
+        If the hypothesis space doesn't exist, it will be initialized here.
+        Otherwise, we will append additional hypotheses to the existing space
+        and initialize its evidence with the mean of existing hypotheses' evidence.
+        """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
-        current_mean_evidence = np.mean(self.evidence[graph_id])
-        new_evidence = new_evidence + current_mean_evidence
-        # Add new hypotheses to hypothesis space
-        self.possible_locations[graph_id] = np.vstack(
-            [
-                self.possible_locations[graph_id],
-                new_loc_hypotheses,
-            ]
-        )
-        self.possible_poses[graph_id] = np.vstack(
-            [
-                self.possible_poses[graph_id],
-                new_rot_hypotheses,
-            ]
-        )
-        self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
-        # Update channel hypothesis mapping
-        old_num_hypotheses = self.channel_hypothesis_mapping[graph_id]["num_hypotheses"]
-        new_num_hypotheses = old_num_hypotheses + len(new_loc_hypotheses)
-        self.channel_hypothesis_mapping[graph_id][input_channel] = [
-            old_num_hypotheses,
-            new_num_hypotheses,
-        ]
-        self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = new_num_hypotheses
+
+        if graph_id in self.evidence.keys():
+            current_mean_evidence = np.mean(self.evidence[graph_id])
+            new_evidence = new_evidence + current_mean_evidence
+            self.possible_locations[graph_id] = np.vstack(
+                [
+                    self.possible_locations[graph_id],
+                    new_loc_hypotheses,
+                ]
+            )
+            self.possible_poses[graph_id] = np.vstack(
+                [
+                    self.possible_poses[graph_id],
+                    new_rot_hypotheses,
+                ]
+            )
+            self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
+
+            self.channel_hypothesis_mapping[graph_id].increase_channel_size(
+                input_channel, len(new_evidence)
+            )
+
+        else:
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: I see your point. If I move the initialization of `ChannelMapper` to the `_add_hypotheses_to_hpspace` function, it will give an error on new episodes because of this check below:

```python
for input_channel in input_channels_to_use:
            # If the channel doesn't exist, initialize it and add it.
            if input_channel not in self.channel_hypothesis_mapping[graph_id].channels:
```
I check if the input_channel is in the ChannelMapper, so it has to be initialized beforehand. Note that if it's not a new episode, we will already have a ChannelMapper, but we may not have the required input_channel. Does that answer your question?
created_at: 2025-03-15T01:05:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,127 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+
+
+class ChannelMapper:
+    """This class marks the range of hypotheses that correspond to each input channel.
+
+    Instead of storing the actual range as a List or Tuple of indices,
+    we store the number of hypotheses per input channel and calculate
+    the range in a cumulative sum manner as shown in the `get_channel_range`
+    function.
+
+    This allows us to dynamically change the range of one channel without
+    having to recompute the range of all subsequent channels. We also do not
+    need to store "num_hypotheses" as it can be computed with the `total_size`
+    property.
+    """
+
+    def __init__(self, channel_sizes=None):
+        """Initialize the ChannelMapper with an ordered dictionary of channel sizes.
+
+        :param channel_sizes: Dict of {channel_name: size}, maintaining order.
+        """
+        self.channel_sizes = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self):
+        """Returns the existing channel names."""
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self):
+        """Returns the ordered dictionary."""
+        return self.channel_sizes
+
+    @property
+    def total_size(self):
path: src/tbp/monty/frameworks/utils/evidence_matching_utils.py
user: login: ramyamounir
body: Yes, with `ChannelMapper` we won't need to track `num_hypotheses`. I just left the `total_size` property there because it's useful to quickly figure out how many hypotheses over all input channels exist. I've compared this to the number of hypotheses as a check in an integration test. I also expect to use some of these functions in hypotheses resampling.
created_at: 2025-03-15T02:14:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -372,6 +371,8 @@ def reset(self):
             self.graph_memory.initialize_feature_arrays()
         self.symmetry_evidence = 0
         self.last_possible_hypotheses = None
+        self.channel_hypothesis_mapping = {}
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: nielsleadholm
body: Does the description on line 342 of `channel_hypothesis_mapping` need to be updated? Still seems to refer to indices rather than lengths. I think it could also be useful to specify that it is a dictionary of graph IDs mapping to channel hypothesis objects. 
created_at: 2025-03-17T13:09:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,33 +858,43 @@ def _add_hypotheses_to_hpspace(
         new_rot_hypotheses,
         new_evidence,
     ):
-        """Add new hypotheses to hypothesis space."""
+        """Add new hypotheses to hypothesis space.
+
+        If the hypothesis space doesn't exist, it will be initialized here.
+        Otherwise, we will append additional hypotheses to the existing space
+        and initialize its evidence with the mean of existing hypotheses' evidence.
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: nielsleadholm
body: It might be worth re-ordering the text in the doc-string to match the code structure (i.e. "If the hypothesis space already exists... we will append additional hypotheses to the existing space and initialize its evidence with the mean of existing hypotheses' evidence... Otherwise initialize...")
created_at: 2025-03-17T14:39:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,168 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: nielsleadholm
body: Presumably there is still an overhead cost to dynamically resizing them - is this something you've already noticed? Not something for this PR but just worth thinking about this - tweaking the hypothesis space by a handful of hypotheses might incur significant overhead without bringing much computational benefit even if the number of hypotheses is being reduced. Long term it might make sense to just do hypothesis resampling occasionally for this reason. 
created_at: 2025-03-17T14:46:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,168 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self) -> OrderedDictType[str, int]:
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: nielsleadholm
body: I think `channels_dict` might be a more intuitive property name? 
created_at: 2025-03-17T14:49:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,168 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self) -> OrderedDictType[str, int]:
+        """Returns the ordered dictionary of channel sizes.
+
+        Returns:
+            OrderedDict[str, int]: Ordered dictionary mapping channel names to sizes.
+        """
+        return self.channel_sizes
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
+        """Resizes the channel by a specific amount.
+
+        Args:
+            channel_name (str): The name of the channel.
+            value (int): The value used to modify the channel size.
+
+        Raises:
+            ValueError: If the channel is not found or the requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def resize_channel(self, channel_name: str, value: int) -> None:
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: nielsleadholm
body: Maybe name this one `resize_channel_to` to make the distinction with the earlier one clearer?
created_at: 2025-03-17T14:51:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +952,109 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in features.keys()
+            if ic in self.get_input_channels_in_graph(graph_id)
+        ]
+
+        if displacements is None and len(input_channels_to_use) == 0:
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: vkakerbeck
body: These were two separate if statements before. I don't understand how this should work if they are combined. If the displacement is None, we still want to use the observed features to initialize the hypothesis space and determine the initial evidence for it. I think right now you check whether the input_channel is in the hypothesis mapping instead, which may be why the tests still work but I am not sure those are always equivalent. Did you make sure this logic works in all cases? Maybe you can document this a bit more. To me the previous logic was very clearly distinguishing between the first observation before we have move (not the same as resetting!) and observations after a movement. Now this logic seems to be implemented as more of a side effect of whether a channel is stored in the hypothesis space. Maybe I am missing something.
created_at: 2025-03-19T09:23:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +952,109 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in features.keys()
+            if ic in self.get_input_channels_in_graph(graph_id)
+        ]
+
+        if displacements is None and len(input_channels_to_use) == 0:
+            # QUESTION: Do we just want to continue until we get input?
+            raise ValueError(
+                "No input channels found to initializing hypotheses. Make sure"
+                " there is at least one channel that is also stored in the graph."
+            )
+
+        for input_channel in input_channels_to_use:
+            # If the channel doesn't exist, initialize it and add it.
+            if input_channel not in self.channel_hypothesis_mapping[graph_id].channels:
                 (
                     initial_possible_channel_locations,
                     initial_possible_channel_rotations,
                     channel_evidence,
                 ) = self._get_initial_hypothesis_space(
                     features, graph_id, input_channel
                 )
-                initial_possible_locations.append(initial_possible_channel_locations)
-                initial_possible_rotations.append(initial_possible_channel_rotations)
-                initial_evidence.append(channel_evidence)
-                self.channel_hypothesis_mapping[graph_id][input_channel] = [
-                    num_hypotheses,
-                    num_hypotheses + len(initial_possible_channel_locations),
-                ]
-                num_hypotheses += len(initial_possible_channel_locations)
-            self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = num_hypotheses
-            self.possible_locations[graph_id] = np.concatenate(
-                initial_possible_locations, axis=0
-            )
-            self.possible_poses[graph_id] = np.concatenate(
-                initial_possible_rotations, axis=0
-            )
-            self.evidence[graph_id] = (
-                np.concatenate(initial_evidence, axis=0) * self.present_weight
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: vkakerbeck
body: Where did this part go? This is not resetting the evidence, it is using the sensed features to determine the initial evidence. I don't see where this is done in your code now.
created_at: 2025-03-19T09:26:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +952,109 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in features.keys()
+            if ic in self.get_input_channels_in_graph(graph_id)
+        ]
+
+        if displacements is None and len(input_channels_to_use) == 0:
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: The way I think about this is that the `_update_evidence` function had three scenarios:
* `displacements is None`: This happens when the buffer is reset. It corresponds to the `len(self.buffer)<=1` as shown [here](https://github.com/thousandbrainsproject/tbp.monty/blob/e62e1bca981f7ef3a4e2832e11f80056ce189981/src/tbp/monty/frameworks/models/graph_matching.py#L652). When this happens, we reinitialize the hypothesis space for every input channel and concatenate these hypotheses. The new hypothesis space [replaces](https://github.com/thousandbrainsproject/tbp.monty/blob/e62e1bca981f7ef3a4e2832e11f80056ce189981/src/tbp/monty/frameworks/models/evidence_matching.py#L990) the existing one.
* `displacements is not None` and `input_channel not in self.channel_hypothesis_mapping[graph_id].keys()`: This happens when we have a displacement but the incoming channel does not have a hypothesis space associated with it. In this case, we initialize a hypothesis space only for this `input_channel` and add it to the existing hypotheses of this `graph_id`. The evidence here is initialized with the mean of other existing hypotheses evidences from different channels.
* `displacements is not None` and `input_channel in self.channel_hypothesis_mapping[graph_id].keys()`: This is the default case of displacing the hypotheses and updating existing evidence for this channel on this graph_id.

The main thing I observed here is that the second scenario is just a special case of the first scenario. When `displacements is None`, we reinitialize the hypotheses for all input_channels. When `displacements is not None` and `input_channel` doesn't exist, we only initialize the `input_channel` hypotheses.

So I could just delete existing hypotheses for all channels if the `displacement is None`, and then loop over all the `input_channels`. If it can't find the input channel, it will just initialize it and add it. This should cover the first two scenarios in the same loop. Deleting existing hypotheses happens at the beginning of the episode by resetting `channel_hypotheses_mapping` and `evidence`.

Now that I think through all the cases again, there is one case where the results might differ. It's an easy fix, I'll update the PR for it and discuss it more. Otherwise, the logic above makes sense?

Note that the two separate if statements you highlighted above are there just to capture this [ValueError](https://github.com/thousandbrainsproject/tbp.monty/blob/e62e1bca981f7ef3a4e2832e11f80056ce189981/src/tbp/monty/frameworks/models/evidence_matching.py#L956). I wanted to keep all the errors you initially coded. Nothing is under this statement except for a `raise ValueError`
created_at: 2025-03-19T13:51:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +952,109 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in features.keys()
+            if ic in self.get_input_channels_in_graph(graph_id)
+        ]
+
+        if displacements is None and len(input_channels_to_use) == 0:
+            # QUESTION: Do we just want to continue until we get input?
+            raise ValueError(
+                "No input channels found to initializing hypotheses. Make sure"
+                " there is at least one channel that is also stored in the graph."
+            )
+
+        for input_channel in input_channels_to_use:
+            # If the channel doesn't exist, initialize it and add it.
+            if input_channel not in self.channel_hypothesis_mapping[graph_id].channels:
                 (
                     initial_possible_channel_locations,
                     initial_possible_channel_rotations,
                     channel_evidence,
                 ) = self._get_initial_hypothesis_space(
                     features, graph_id, input_channel
                 )
-                initial_possible_locations.append(initial_possible_channel_locations)
-                initial_possible_rotations.append(initial_possible_channel_rotations)
-                initial_evidence.append(channel_evidence)
-                self.channel_hypothesis_mapping[graph_id][input_channel] = [
-                    num_hypotheses,
-                    num_hypotheses + len(initial_possible_channel_locations),
-                ]
-                num_hypotheses += len(initial_possible_channel_locations)
-            self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = num_hypotheses
-            self.possible_locations[graph_id] = np.concatenate(
-                initial_possible_locations, axis=0
-            )
-            self.possible_poses[graph_id] = np.concatenate(
-                initial_possible_rotations, axis=0
-            )
-            self.evidence[graph_id] = (
-                np.concatenate(initial_evidence, axis=0) * self.present_weight
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: This has been moved to [here](https://github.com/thousandbrainsproject/tbp.monty/blob/a5c1fe7b825f05dbd968cc22b55d12fb6e2b365c/src/tbp/monty/frameworks/models/evidence_matching.py#L893). The `add_hypotheses_to_hpspace` now adds any new input_channel hypotheses to a hypotheses space, this also applies to when the `displacement is None`. As mentioned [here](https://github.com/thousandbrainsproject/tbp.monty/pull/210#discussion_r2003384408), I realized there is one case where this might give different results, I'll update the PR to handle it.
created_at: 2025-03-19T14:00:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -941,161 +952,109 @@ def _update_evidence(self, features, displacements, graph_id):
             ValueError: If no input channels are found to initializing hypotheses
         """
         start_time = time.time()
-        all_input_channels = list(features.keys())
-        input_channels_to_use = []
-        for input_channel in all_input_channels:
-            if input_channel in self.get_input_channels_in_graph(graph_id):
-                # NOTE: We might also want to check the confidence in the input channel
-                # features. This information is currently not available here. Once we
-                # pull the observation class into the LM we could add this (TODO S).
-                input_channels_to_use.append(input_channel)
-        # Before moving we initialize the hypothesis space:
-        if displacements is None:
-            if len(input_channels_to_use) == 0:
-                # QUESTION: Do we just want to continue until we get input?
-                raise ValueError(
-                    "No input channels found to initializing hypotheses. Make sure"
-                    " there is at least one channel that is also stored in the graph."
-                )
-            # This is the first observation before we moved -> check where in the
-            # graph the feature can be found and initialize poses & evidence
-            initial_possible_locations = []
-            initial_possible_rotations = []
-            initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
-            for input_channel in input_channels_to_use:
+
+        if graph_id not in self.channel_hypothesis_mapping:
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
+
+        # get all usable input channels
+        input_channels_to_use = [
+            ic
+            for ic in features.keys()
+            if ic in self.get_input_channels_in_graph(graph_id)
+        ]
+
+        if displacements is None and len(input_channels_to_use) == 0:
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: vkakerbeck
body: Okay, thanks for the detailed explanation! That makes sense :) I just wanted to make sure it is still giving the same results in all the scenarios with the now changed if statements. I hadn't seen the line that you linked in the response to my other comment so now that I see that, I think it should be good.
created_at: 2025-03-19T14:30:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,168 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: ramyamounir
body: Yeah, I'll keep an eye on this. There is of course not much overhead to modifying the mapping, but there might be some overhead to the resampling processes and resizing the Numpy arrays where the hypotheses are stored. 
created_at: 2025-03-19T14:36:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,168 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self) -> OrderedDictType[str, int]:
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: ramyamounir
body: I'll remove it anyway since it's one of the unused properties.
created_at: 2025-03-19T14:37:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,168 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self) -> OrderedDictType[str, int]:
+        """Returns the ordered dictionary of channel sizes.
+
+        Returns:
+            OrderedDict[str, int]: Ordered dictionary mapping channel names to sizes.
+        """
+        return self.channel_sizes
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
+        """Resizes the channel by a specific amount.
+
+        Args:
+            channel_name (str): The name of the channel.
+            value (int): The value used to modify the channel size.
+
+        Raises:
+            ValueError: If the channel is not found or the requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def resize_channel(self, channel_name: str, value: int) -> None:
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: ramyamounir
body: Yes, good point! 
created_at: 2025-03-19T14:38:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,168 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def dict(self) -> OrderedDictType[str, int]:
+        """Returns the ordered dictionary of channel sizes.
+
+        Returns:
+            OrderedDict[str, int]: Ordered dictionary mapping channel names to sizes.
+        """
+        return self.channel_sizes
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
+        """Resizes the channel by a specific amount.
+
+        Args:
+            channel_name (str): The name of the channel.
+            value (int): The value used to modify the channel size.
+
+        Raises:
+            ValueError: If the channel is not found or the requested size is negative.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+        if self.channel_sizes[channel_name] + value <= 0:
+            raise ValueError(
+                f"Channel '{channel_name}' size cannot be negative or zero."
+            )
+        self.channel_sizes[channel_name] += value
+
+    def resize_channel(self, channel_name: str, value: int) -> None:
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: ramyamounir
body: This will also be removed because it's unused..
created_at: 2025-03-19T14:46:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,33 +858,44 @@ def _add_hypotheses_to_hpspace(
         new_rot_hypotheses,
         new_evidence,
     ):
-        """Add new hypotheses to hypothesis space."""
+        """Add new hypotheses to hypothesis space.
+
+        If the hypothesis space doesn't exist, it will be initialized here.
+        Otherwise, we will append additional hypotheses to the existing space
+        and initialize its evidence with the mean of existing hypotheses' evidence.
+        """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
-        current_mean_evidence = np.mean(self.evidence[graph_id])
-        new_evidence = new_evidence + current_mean_evidence
-        # Add new hypotheses to hypothesis space
-        self.possible_locations[graph_id] = np.vstack(
-            [
-                self.possible_locations[graph_id],
-                new_loc_hypotheses,
-            ]
-        )
-        self.possible_poses[graph_id] = np.vstack(
-            [
-                self.possible_poses[graph_id],
-                new_rot_hypotheses,
-            ]
-        )
-        self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
-        # Update channel hypothesis mapping
-        old_num_hypotheses = self.channel_hypothesis_mapping[graph_id]["num_hypotheses"]
-        new_num_hypotheses = old_num_hypotheses + len(new_loc_hypotheses)
-        self.channel_hypothesis_mapping[graph_id][input_channel] = [
-            old_num_hypotheses,
-            new_num_hypotheses,
-        ]
-        self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = new_num_hypotheses
+
+        if graph_id in self.evidence.keys():
+            current_mean_evidence = np.mean(self.evidence[graph_id])
+            new_evidence = new_evidence + current_mean_evidence
+            self.possible_locations[graph_id] = np.vstack(
+                [
+                    self.possible_locations[graph_id],
+                    new_loc_hypotheses,
+                ]
+            )
+            self.possible_poses[graph_id] = np.vstack(
+                [
+                    self.possible_poses[graph_id],
+                    new_rot_hypotheses,
+                ]
+            )
+            self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
+
+            self.channel_hypothesis_mapping[graph_id].increase_channel_size(
+                input_channel, len(new_evidence)
+            )
+
+        else:
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: This isn't easy to wrap my head around. I'm going to wait for the next round of reviews to try again to understand this setup.
created_at: 2025-03-19T19:03:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,18 +858,23 @@ def _add_hypotheses_to_hpspace(
         new_loc_hypotheses,
         new_rot_hypotheses,
         new_evidence,
+        add_evidence_mean=True,
     ):
         """Add new hypotheses to hypothesis space.
 
-        If the hypothesis space doesn't exist, it will be initialized here.
-        Otherwise, we will append additional hypotheses to the existing space
-        and initialize its evidence with the mean of existing hypotheses' evidence.
+        If the hypothesis space already exists, we will append new hypotheses to
+        this existing space. Otherwise, we will initialize the space. Note that
+        if `add_evidence_mean` is True, the mean of existing hypotheses evidence
+        will be added to the initial evidence. This is done to give the new input
+        channel a fighting chance.
         """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
+        new_evidence = np.array(new_evidence * self.present_weight)
 
-        if graph_id in self.evidence.keys():
-            new_evidence += np.mean(self.evidence[graph_id])
+        if len(self.channel_hypothesis_mapping[graph_id].channels):
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: vkakerbeck
body: Wouldn't this now raise a key error if `graph_id` is not in the keys yet? I'm not sure I understand the equivalence here. Doesn't the statement you removed check whether there are hypotheses for a specific object, while the new statement checks if there are hypotheses for any channel on the objects?
I guess I am missing something if all the unit tests still pass.
created_at: 2025-03-20T09:05:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,18 +858,23 @@ def _add_hypotheses_to_hpspace(
         new_loc_hypotheses,
         new_rot_hypotheses,
         new_evidence,
+        add_evidence_mean=True,
     ):
         """Add new hypotheses to hypothesis space.
 
-        If the hypothesis space doesn't exist, it will be initialized here.
-        Otherwise, we will append additional hypotheses to the existing space
-        and initialize its evidence with the mean of existing hypotheses' evidence.
+        If the hypothesis space already exists, we will append new hypotheses to
+        this existing space. Otherwise, we will initialize the space. Note that
+        if `add_evidence_mean` is True, the mean of existing hypotheses evidence
+        will be added to the initial evidence. This is done to give the new input
+        channel a fighting chance.
         """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
+        new_evidence = np.array(new_evidence * self.present_weight)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: vkakerbeck
body: This comment doesn't seem to correspond with this line anymore now. Shouldn't that be after `if add_evidence_mean` now? 
created_at: 2025-03-20T09:07:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -890,7 +896,7 @@ def _add_hypotheses_to_hpspace(
         else:
             self.possible_locations[graph_id] = np.array(new_loc_hypotheses)
             self.possible_poses[graph_id] = np.array(new_rot_hypotheses)
-            self.evidence[graph_id] = np.array(new_evidence * self.present_weight)
+            self.evidence[graph_id] = np.array(new_evidence)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: vkakerbeck
body: Why don't you apply `present_weight` here anymore? I know you do it on line 873 now but I am not sure I understand why this has to be a separate line now that is called earlier.
created_at: 2025-03-20T09:09:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,18 +858,23 @@ def _add_hypotheses_to_hpspace(
         new_loc_hypotheses,
         new_rot_hypotheses,
         new_evidence,
+        add_evidence_mean=True,
     ):
         """Add new hypotheses to hypothesis space.
 
-        If the hypothesis space doesn't exist, it will be initialized here.
-        Otherwise, we will append additional hypotheses to the existing space
-        and initialize its evidence with the mean of existing hypotheses' evidence.
+        If the hypothesis space already exists, we will append new hypotheses to
+        this existing space. Otherwise, we will initialize the space. Note that
+        if `add_evidence_mean` is True, the mean of existing hypotheses evidence
+        will be added to the initial evidence. This is done to give the new input
+        channel a fighting chance.
         """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
+        new_evidence = np.array(new_evidence * self.present_weight)
 
-        if graph_id in self.evidence.keys():
-            new_evidence += np.mean(self.evidence[graph_id])
+        if len(self.channel_hypothesis_mapping[graph_id].channels):
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: `graph_id` will always be in the `channel_hypotheses_mapping` when we reach the `_add_hypotheses_to_hpspace` function because it's added here in this [line](https://github.com/thousandbrainsproject/tbp.monty/blob/246aee3e3c0fbdebf93c5e9995d290ed2e7d6741/src/tbp/monty/frameworks/models/evidence_matching.py#L963) of `_update_evidence`

`channel_hypotheses_mapping` is responsible for all `input_channel` related checks now.  In my mind the logic is as follows:
* In `_update_evidence`, we check if a `ChannelMapper` is initialized for this `graph_id`, if not we initialize it [here](https://github.com/thousandbrainsproject/tbp.monty/blob/246aee3e3c0fbdebf93c5e9995d290ed2e7d6741/src/tbp/monty/frameworks/models/evidence_matching.py#L963)
* In `_update_evidence`, we also check if the current input_channel is one of the channels in the mapping. This check is done [here](https://github.com/thousandbrainsproject/tbp.monty/blob/246aee3e3c0fbdebf93c5e9995d290ed2e7d6741/src/tbp/monty/frameworks/models/evidence_matching.py#L981) and would determine if we need to initialize hypotheses for this `input_channel`
* When we reach `_add_hypotheses_to_hpspace`, we already know there exists a `ChannelMapper` (i.e., `graph_id is in channel_hypotheses_mapping.keys()`) and that the `input_channel` is not in the mapping channels. We can now check if there are other channels so that we can use their evidence as mean. This check is done [here](https://github.com/thousandbrainsproject/tbp.monty/blob/246aee3e3c0fbdebf93c5e9995d290ed2e7d6741/src/tbp/monty/frameworks/models/evidence_matching.py#L875)

Hopefully this clarifies things.

created_at: 2025-03-20T10:55:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -890,7 +896,7 @@ def _add_hypotheses_to_hpspace(
         else:
             self.possible_locations[graph_id] = np.array(new_loc_hypotheses)
             self.possible_poses[graph_id] = np.array(new_rot_hypotheses)
-            self.evidence[graph_id] = np.array(new_evidence * self.present_weight)
+            self.evidence[graph_id] = np.array(new_evidence)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: I just thought it should apply to both conditions. Instead of applying it twice ([here](https://github.com/thousandbrainsproject/tbp.monty/blob/246aee3e3c0fbdebf93c5e9995d290ed2e7d6741/src/tbp/monty/frameworks/models/evidence_matching.py#L890) and [here](https://github.com/thousandbrainsproject/tbp.monty/blob/246aee3e3c0fbdebf93c5e9995d290ed2e7d6741/src/tbp/monty/frameworks/models/evidence_matching.py#L899)).

Also, it should be applied before adding the evidence mean from other channels. It just seemed like the right place for it but if it's confusing, we can move back under the `if` and `else` conditions.
created_at: 2025-03-20T11:02:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -857,18 +858,23 @@ def _add_hypotheses_to_hpspace(
         new_loc_hypotheses,
         new_rot_hypotheses,
         new_evidence,
+        add_evidence_mean=True,
     ):
         """Add new hypotheses to hypothesis space.
 
-        If the hypothesis space doesn't exist, it will be initialized here.
-        Otherwise, we will append additional hypotheses to the existing space
-        and initialize its evidence with the mean of existing hypotheses' evidence.
+        If the hypothesis space already exists, we will append new hypotheses to
+        this existing space. Otherwise, we will initialize the space. Note that
+        if `add_evidence_mean` is True, the mean of existing hypotheses evidence
+        will be added to the initial evidence. This is done to give the new input
+        channel a fighting chance.
         """
         # Add current mean evidence to give the new hypotheses a fighting
         # chance. TODO H: Test mean vs. median here.
+        new_evidence = np.array(new_evidence * self.present_weight)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: ramyamounir
body: Yes, I missed that. I'll move it.
created_at: 2025-03-20T11:02:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: vkakerbeck
body: When I read this name it wasn't clear to me if this function set's the channel size to `value` or adds more entries to the existing hypotheses. Maybe you could use a less ambiguous name for the function (like `increase_channel_by`) or for the `value` parameter (like `num_entries_to_add`)
created_at: 2025-03-21T13:31:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: ramyamounir
body: This function was originally named `increase_channel_size`, but Scott and Tristan [pointed out](https://github.com/thousandbrainsproject/tbp.monty/pull/210#discussion_r1994342810) that the function is also used for decreasing the size as well, so it seemed more fitting to rename it to `resize`. Tristan also suggested `resize_channel_by` to highlight that value here would refer to incrementing or decrementing value. I had another [function](https://github.com/thousandbrainsproject/tbp.monty/pull/210#discussion_r1998932415) named `resize_channel_to` but ended up removing it because it's currently unused.
created_at: 2025-03-21T15:28:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -340,10 +339,11 @@ def __init__(
         self.evidence = {}
         self.possible_locations = {}
         self.possible_poses = {}
-        # Stores start and end indices of hypotheses in the above arrays for each graph
-        # corresponding to each input channel. This is used to make sure the right
-        # displacement is applied to the right hypotheses. Channel hypotheses are stored
-        # contiguously so we can just specify ranges here.
+        # Initializes a `ChannelMapper` object for each graph_id. The mapper stores the
+        # length of each input_channel in the hypotheses space defined in the above
+        # arrays. This is used to make sure the right displacement is applied to the
+        # right hypotheses. Channel hypotheses are stored contiguously so we can just
+        # specify the lengths of each channel in an ordered data structure.
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: **issue**: This description is confusing because it describes a `ChannelMapper` and not `self.channel_hypothesis_mapping` dictionary.

**suggestion**: Either remove or update to something that describes this dictionary [as suggested by @nielsleadholm](https://github.com/thousandbrainsproject/tbp.monty/pull/210/files#r1998698279), e.g., `# Dictionary from graph_id to ChannelMapper`.
created_at: 2025-03-24T21:18:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -877,13 +878,9 @@ def _add_hypotheses_to_hpspace(
         )
         self.evidence[graph_id] = np.hstack([self.evidence[graph_id], new_evidence])
         # Update channel hypothesis mapping
-        old_num_hypotheses = self.channel_hypothesis_mapping[graph_id]["num_hypotheses"]
-        new_num_hypotheses = old_num_hypotheses + len(new_loc_hypotheses)
-        self.channel_hypothesis_mapping[graph_id][input_channel] = [
-            old_num_hypotheses,
-            new_num_hypotheses,
-        ]
-        self.channel_hypothesis_mapping[graph_id]["num_hypotheses"] = new_num_hypotheses
+        self.channel_hypothesis_mapping[graph_id].add_channel(
+            input_channel, len(new_loc_hypotheses)
+        )
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: **suggestion (style,  non-blocking)**: There are a lot of chained property accesses here. Maybe something like:
```python
channel_mapper = self.channel_hypothesis_mapping[graph_id]
channel_mapper.add_channel(input_channel, len(new_loc_hypothesis))
```
created_at: 2025-03-24T21:25:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -962,8 +959,7 @@ def _update_evidence(self, features, displacements, graph_id):
             initial_possible_locations = []
             initial_possible_rotations = []
             initial_evidence = []
-            self.channel_hypothesis_mapping[graph_id] = dict()
-            num_hypotheses = 0
+            self.channel_hypothesis_mapping[graph_id] = ChannelMapper()
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: **suggestion (style, non-blocking)**: To avoid chained property accesses in the loop below on line 974, maybe:
```python
channel_mapper = ChannelMapper()
for ...
    ...
    channel_mapper.add_channel(input_channel, len(channel_evidence))

self.channel_hypothesis_mapping[graph_id] = channel_mapper
```
created_at: 2025-03-24T21:28:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1010,7 +1003,7 @@ def _update_evidence(self, features, displacements, graph_id):
                 # hypotheses for them.
                 if (
                     input_channel
-                    not in self.channel_hypothesis_mapping[graph_id].keys()
+                    not in self.channel_hypothesis_mapping[graph_id].channels
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: **suggestion (style, non-blocking)**: Could pull out channel mapper as a variable before
```python
channel_mapper = self.channel_hypothesis_mapping[graph_id]
```
So then this would be:
```python
if (input_channel not in channel_mapper.channels)
```
created_at: 2025-03-24T21:31:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1036,7 +1029,7 @@ def _update_evidence(self, features, displacements, graph_id):
                     # Get the IDs in hypothesis space for this channel
                     channel_start, channel_end = self.channel_hypothesis_mapping[
                         graph_id
-                    ][input_channel]
+                    ].channel_range(input_channel)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: tristanls
body: **suggestion (style, non-blocking)**: With `channel_mapper` in a variable, this could become:
```python
channel_start, channel_end = channel_mapper.channel_range(input_channel)
```

**note**: The above suggestion makes me think that `range(...)` might be a better name instead of `channel_range(...)`.
created_at: 2025-03-24T21:32:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: tristanls
body: **nitpick**: Missing period at the end of the sentence.
created_at: 2025-03-24T21:34:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
+        """Resizes the channel by a specific amount.
+
+        Args:
+            channel_name (str): The name of the channel.
+            value (int): The value used to modify the channel size.
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: tristanls
body: **suggestion (non-blocking)**: Highlight that it can be negative.
created_at: 2025-03-24T21:38:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,128 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+from collections import OrderedDict
+from typing import Dict, List, Optional, Tuple
+from typing import OrderedDict as OrderedDictType
+
+
+class ChannelMapper:
+    """Marks the range of hypotheses that correspond to each input channel.
+
+    The `EvidenceGraphLM` implementation stacks the hypotheses from all input channels
+    in the same array to perform efficient vector operations on them. Therefore, we
+    need to keep track of which indices in the stacked array correspond to which input
+    channel. This class stores only the sizes of the input channels in an ordered data
+    structure (OrderedDict), and computes the range of indices for each channel. Storing
+    the sizes of channels in an ordered dictionary allows us to insert or remove
+    channels, as well as dynamically resize them.
+
+    """
+
+    def __init__(self, channel_sizes: Optional[Dict[str, int]] = None) -> None:
+        """Initializes the ChannelMapper with an ordered dictionary of channel sizes.
+
+        Args:
+            channel_sizes (Optional[Dict[str, int]]): Dictionary of {channel_name: size}
+        """
+        self.channel_sizes: OrderedDictType[str, int] = (
+            OrderedDict(channel_sizes) if channel_sizes else OrderedDict()
+        )
+
+    @property
+    def channels(self) -> List[str]:
+        """Returns the existing channel names.
+
+        Returns:
+            List[str]: List of channel names.
+        """
+        return list(self.channel_sizes.keys())
+
+    @property
+    def total_size(self) -> int:
+        """Returns the total number of hypotheses across all channels.
+
+        Returns:
+            int: Total size across all channels.
+        """
+        return sum(self.channel_sizes.values())
+
+    def channel_range(self, channel_name: str) -> Tuple[int, int]:
+        """Returns the start and end indices of the given channel.
+
+        Args:
+            channel_name (str): The name of the channel.
+
+        Returns:
+            Tuple[int, int]: The start and end indices of the channel.
+
+        Raises:
+            ValueError: If the channel is not found.
+        """
+        if channel_name not in self.channel_sizes:
+            raise ValueError(f"Channel '{channel_name}' not found.")
+
+        start = 0
+        for name, size in self.channel_sizes.items():
+            if name == channel_name:
+                return (start, start + size)
+            start += size
+
+    def resize_channel_by(self, channel_name: str, value: int) -> None:
+        """Resizes the channel by a specific amount.
path: src/tbp/monty/frameworks/utils/evidence_matching.py
user: login: tristanls
body: **suggestion (non-blocking)**: Maybe `Increases or decreases the channel by a specific amount.` to highlight the decrease use case?
created_at: 2025-03-24T21:39:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+import unittest
+
+from tbp.monty.frameworks.utils.evidence_matching import ChannelMapper
+
+
+class ChannelMapperTest(unittest.TestCase):
+    def setUp(self) -> None:
+        """Sets up a default ChannelMapper instance for testing."""
+        self.mapper = ChannelMapper({"A": 5, "B": 10, "C": 15})
+
+    def test_initialization(self):
+        """Test initializing ChannelMapper with predefined sizes."""
+        self.assertEqual(self.mapper.channels, ["A", "B", "C"])
+        self.assertEqual(self.mapper.total_size, 30)
+
+    def test_channel_range(self):
+        """Test retrieving channel ranges and non-existent channels."""
+        self.assertEqual(self.mapper.channel_range("A"), (0, 5))
+        self.assertEqual(self.mapper.channel_range("B"), (5, 15))
+        self.assertEqual(self.mapper.channel_range("C"), (15, 30))
+        with self.assertRaises(ValueError):
+            self.mapper.channel_range("D")
+
+    def test_resize_channel_by(self):
+        """Test increasing and decreasing channel sizes."""
+        self.mapper.resize_channel_by("B", 5)
+        self.assertEqual(self.mapper.channel_range("B"), (5, 20))
+        self.assertEqual(self.mapper.total_size, 35)
path: tests/unit/channel_mapper_test.py
user: login: tristanls
body: **suggestion (non-blocking)**: Add a passing case for resize by a negative value.
created_at: 2025-03-24T21:42:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+# Copyright 2025 Thousand Brains Project
+#
+# Copyright may exist in Contributors' modifications
+# and/or contributions to the work.
+#
+# Use of this source code is governed by the MIT
+# license that can be found in the LICENSE file or at
+# https://opensource.org/licenses/MIT.
+
+import unittest
+
+from tbp.monty.frameworks.utils.evidence_matching import ChannelMapper
+
+
+class ChannelMapperTest(unittest.TestCase):
+    def setUp(self) -> None:
+        """Sets up a default ChannelMapper instance for testing."""
+        self.mapper = ChannelMapper({"A": 5, "B": 10, "C": 15})
+
+    def test_initialization(self):
+        """Test initializing ChannelMapper with predefined sizes."""
+        self.assertEqual(self.mapper.channels, ["A", "B", "C"])
+        self.assertEqual(self.mapper.total_size, 30)
+
+    def test_channel_range(self):
+        """Test retrieving channel ranges and non-existent channels."""
+        self.assertEqual(self.mapper.channel_range("A"), (0, 5))
+        self.assertEqual(self.mapper.channel_range("B"), (5, 15))
+        self.assertEqual(self.mapper.channel_range("C"), (15, 30))
+        with self.assertRaises(ValueError):
+            self.mapper.channel_range("D")
+
+    def test_resize_channel_by(self):
+        """Test increasing and decreasing channel sizes."""
+        self.mapper.resize_channel_by("B", 5)
+        self.assertEqual(self.mapper.channel_range("B"), (5, 20))
+        self.assertEqual(self.mapper.total_size, 35)
+
+        with self.assertRaises(ValueError):
+            self.mapper.resize_channel_by("A", -10)
+
+    def test_add_channel(self):
+        """Test adding a new channel."""
+        self.mapper.add_channel("D", 8)
+        self.assertIn("D", self.mapper.channels)
+        self.assertEqual(self.mapper.channel_range("D"), (30, 38))
+
+        with self.assertRaises(ValueError):
+            self.mapper.add_channel("A", 3)
+
+    def test_add_channel_at_position(self):
+        """Test inserting a channel at a specific position."""
+        self.mapper.add_channel("X", 7, position=1)
+        self.assertEqual(self.mapper.channels, ["A", "X", "B", "C"])
+        self.assertEqual(self.mapper.channel_range("X"), (5, 12))
path: tests/unit/channel_mapper_test.py
user: login: tristanls
body: **suggestion (non-blocking)**: Check ranges of the "moved" channels: "B" and "C".
created_at: 2025-03-24T21:43:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1036,7 +1029,7 @@ def _update_evidence(self, features, displacements, graph_id):
                     # Get the IDs in hypothesis space for this channel
                     channel_start, channel_end = self.channel_hypothesis_mapping[
                         graph_id
-                    ][input_channel]
+                    ].channel_range(input_channel)
path: src/tbp/monty/frameworks/models/evidence_matching.py
user: login: jeremyshoemaker
body: `range` might get confused for the builtin function of the same name. This _is_ a method though, so it might not be as big of a problem.
created_at: 2025-03-25T14:19:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: ramyamounir
created_at: 2025-03-15T01:06:56Z
body: Thanks for the reviews, I've addressed most of the comments.

@scottcanoe the matching step receives the observations in CMP-compliant format, but the function `_select_features_to_use` is used on these observations to extract the "features_to_use" and arranges them with the input_channel as the key. It just extracts the required features based on tolerances defined in configs. So, in a hierarchy, we will want to define different features to be extracted because hsv will not be a higher-level feature. Not sure if I got everything here right, or if @nielsleadholm or @vkakerbeck would like to add or correct something.

@tristanls I think I got all of your comments, except for the ones suggesting to remove unused function in `ChannelMapper`. I've gotten into the habit of defining useful functions and properties for classes so that other users can use those helper functions without trying to understand the logic of the whole class. Is code bloat your concern here?
author_association: CONTRIBUTOR
user: login: tristanls
created_at: 2025-03-17T15:09:04Z
body: > I've gotten into the habit of defining useful functions and properties for classes so that other users can use those helper functions without trying to understand the logic of the whole class. Is code bloat your concern here?

@ramyamounir, adding unused functions to code is problematic for several reasons. As you mentioned, it creates code bloat. It increases the complexity of the code; there are already multiple unused functions in Monty that make understanding the code difficult. Unused code must be maintained so it adds to the maintenance overhead. Changing the implementation requires updating unused code. The presence of unused code confuses analysis in general. You spent time writing, and we now had a few rounds of conversation about code that never runs. This is coding and review time we could have spent on other things. From experience, assumptions of future use often, not always, turn out wrong. In addition to writing the code, there's also the overhead of writing and reviewing tests for code that never runs.
author_association: CONTRIBUTOR
user: login: jeremyshoemaker
created_at: 2025-03-17T15:40:47Z
body: > > I've gotten into the habit of defining useful functions and properties for classes so that other users can use those helper functions without trying to understand the logic of the whole class. Is code bloat your concern here?
> 
> @ramyamounir, adding unused functions to code is problematic for several reasons. As you mentioned, it creates code bloat. It increases the complexity of the code; there are already multiple unused functions in Monty that make understanding the code difficult. Unused code must be maintained so it adds to the maintenance overhead. Changing the implementation requires updating unused code. The presence of unused code confuses analysis in general. You spent time writing, and we now had a few rounds of conversation about code that never runs. This is coding and review time we could have spent on other things. From experience, assumptions of future use often, not always, turn out wrong. In addition to writing the code, there's also the overhead of writing and reviewing tests for code that never runs.

I just want to second everything Tristan said. I have an example from this morning of trying to understand what a method was doing that seemed to not be doing anything, only to realize after half an hour that it was overridden in every subclass, and thus dead code. Sometimes tooling can quickly help with identifying dead code and prevent these kinds of problems, but with a language as dynamic as Python, tooling can only do so much.
author_association: CONTRIBUTOR
user: login: ramyamounir
created_at: 2025-03-19T20:31:39Z
body: Thanks everyone for the reviews. I've made some changes, namely:
* I removed the unused properties and functions from `ChannelMapper` and the unit tests, as suggested by @tristanls and @jeremyshoemaker. I've only kept the `total_size` property as i'm currently using it in an EvidenceLM integration test.
* @nielsleadholm I've addressed your comments on documentation and variable names. It should be much clearer now.
* @vkakerbeck I realized there is a scenario (i.e.,  `len(input_channels_to_use)>1` and `displacement is None`) where we might get different evidence scores after initializing all input_channels one by one. The `_add_hypotheses_to_hpspace` adds the mean of existing evidence from other channels by default if those channels exist. I made sure this shouldn't happen by passing a parameter to control if we want to add evidence mean.
* @vkakerbeck I couldn't find `stacked_lms` experiment configs for testing. However, the unit tests have `test_two_lm_heterarchy_experiment` which runs fine.
* In `_add_hypotheses_to_hpspace` I've changed the main if statement to check if there exists other channels, instead of checking if graph_id is in evidence.keys(). I think this is equivalent but much easier to read and understand.
* I ran all 3 benchmarks again to validate performance doesn't change.
author_association: CONTRIBUTOR
user: login: ramyamounir
created_at: 2025-03-20T11:22:28Z
body: Thanks for the review and suggested experiments @vkakerbeck! These are all very reasonable suggestions considering how tricky these changes are. I want to point out that the next PR on hypotheses resampling will change the proposed code of this PR quite a bit. Specifically, the `_update_evidence` will change again and will be significantly refactored to accommodate the changes below.

![Update Evidence](https://github.com/user-attachments/assets/d498d599-0b14-4c50-8ef3-3f11cb871658)

So, if you're anxious about merging this one because of big changes to updating evidence, maybe we could draft it for now and work towards the next PR directly. The important change in this PR here is the `ChannelMapper` because this will stay for the next PR and you seem to be okay with that change. Alternatively, I can restore some parts of the code that relate to update evidence and only keep the changes related to `ChannelMapper` in this PR. What do you think?
author_association: CONTRIBUTOR
user: login: nielsleadholm
created_at: 2025-03-20T14:52:20Z
body: @vkakerbeck just to say that Ramy and I discussed this and I think it makes sense to simplify this PR to just the ChannelMapper, since there will be more significant changes to the update_evidence logic anyways. @ramyamounir maybe if you simplify to those changes and then tag us, and we can have a final last look. 
author_association: CONTRIBUTOR
user: login: vkakerbeck
created_at: 2025-03-20T15:07:53Z
body: Okay, that sounds good :) I like the addition of the ChannelMapper (and also don't have a problem with the other changes, just want to make sure it preserves the same behaviors).
author_association: CONTRIBUTOR
user: login: ramyamounir
created_at: 2025-03-20T23:39:41Z
body: @nielsleadholm @vkakerbeck I've updated the PR so that only changes related to the `ChannelMapper` are incorporated. As discussed, other simplifications to the `_update_evidence` will be in follow-up PRs.
author_association: CONTRIBUTOR
user: login: ramyamounir
created_at: 2025-03-21T15:35:59Z
body: > Did you have a chance to do a quick sanity check.

@nielsleadholm, Yes. I re-ran all three benchmark experiments listed in the PR summary, all looks good.

@tristanls, please take another look and let me know if you have additional feedback. This PR now only introduces the `ChannelMapper`. Thanks.


author_association: CONTRIBUTOR
user: login: ramyamounir
created_at: 2025-03-26T21:43:36Z
body: Thanks for the Review @tristanls! I've pushed updates to fix the issue and applied your non-blocking suggestions.
author_association: CONTRIBUTOR
user: login: tristanls
created_at: 2025-03-26T22:55:38Z
body: @ramyamounir, if I understand correctly, I think this is now a backward-compatible/non-breaking code refactor (`refactor:`). I don't think there are any breaking changes here, right?
author_association: CONTRIBUTOR
user: login: ramyamounir
created_at: 2025-03-26T23:06:14Z
body: @tristanls Yes, it is should be backward compatible/non-breaking. All the changes are to `channel_hypothesis_mapping` and I do not see it being used or referenced outside of `EvidenceGraphLM` too.
author_association: CONTRIBUTOR