Repository: dfinity/ic
Pull request #4576: feat(sns): Implement topic following index
Author: login: aterga
Labels: name: feat
name: @idx
name: @nns-team
name: CI_OVERRIDE_DIDC_CHECK
description: Skips the backwards compatibility didc check (explain in PR description why)
name: CI_OVERRIDE_BUF_BREAKING
description: Skip buf-breaking (protobuf) check (explain in PR description why)
Created: 2025-03-28T11:37:26Z | Merged: 2025-03-31T21:51:36Z

This PR implements a dynamic index for the topic-based follower index relation. The index will be used to implement the new, topic-based following. Note that the neurons themselves store the "follows" relation which is the inverse of "is-followed-by".

This PR also fixes a small mistake in the new API (switching from `uint64` to `int32` indices). This is safe because the API corresponds to a command that has not yet been enabled in production.


Diff:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 471c855a6896..ffe0e34a39d7 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,4 +9,8 @@
         "editor.formatOnSave": true
     },
     "bazel.buildifierExecutable": "@buildifier",
+    "cSpell.words": [
+        "followee",
+        "followees"
+    ],
 }
\ No newline at end of file
diff --git a/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs b/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs
index 2df5a2d6b043..21932455402e 100644
--- a/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs
+++ b/rs/sns/governance/api/src/ic_sns_governance.pb.v1.rs
@@ -204,7 +204,7 @@ pub mod neuron {
         candid::CandidType, candid::Deserialize, Clone, comparable::Comparable, Debug, PartialEq,
     )]
     pub struct TopicFollowees {
-        pub topic_id_to_followees: BTreeMap<u64, FolloweesForTopic>,
+        pub topic_id_to_followees: BTreeMap<i32, FolloweesForTopic>,
     }
 
     /// The neuron's dissolve state, specifying whether the neuron is dissolving,
diff --git a/rs/sns/governance/canister/governance.did b/rs/sns/governance/canister/governance.did
index a15bfee365b3..e9e6bf1831dc 100644
--- a/rs/sns/governance/canister/governance.did
+++ b/rs/sns/governance/canister/governance.did
@@ -519,7 +519,7 @@ type Neuron = record {
   disburse_maturity_in_progress : vec DisburseMaturityInProgress;
   followees : vec record { nat64; Followees };
   topic_followees : opt record {
-    topic_id_to_followees : vec record { nat64; FolloweesForTopic };
+    topic_id_to_followees : vec record { int32; FolloweesForTopic };
   };
   neuron_fees_e8s : nat64;
 };
diff --git a/rs/sns/governance/canister/governance_test.did b/rs/sns/governance/canister/governance_test.did
index 36f13adcd132..ac97f29d224c 100644
--- a/rs/sns/governance/canister/governance_test.did
+++ b/rs/sns/governance/canister/governance_test.did
@@ -533,7 +533,7 @@ type Neuron = record {
   disburse_maturity_in_progress : vec DisburseMaturityInProgress;
   followees : vec record { nat64; Followees };
   topic_followees : opt record {
-    topic_id_to_followees : vec record { nat64; FolloweesForTopic };
+    topic_id_to_followees : vec record { int32; FolloweesForTopic };
   };
   neuron_fees_e8s : nat64;
 };
diff --git a/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto b/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto
index 6abc8914e50c..5e186bcc87c4 100644
--- a/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto
+++ b/rs/sns/governance/proto/ic_sns_governance/pb/v1/governance.proto
@@ -179,7 +179,7 @@ message Neuron {
 
   // A collection of a neuron's followees (per topic).
   message TopicFollowees {
-    map<uint64, FolloweesForTopic> topic_id_to_followees = 1;
+    map<int32, FolloweesForTopic> topic_id_to_followees = 1;
   }
 
   // The neuron's legacy followees (per proposal type), specified as a map of proposal functions IDs
diff --git a/rs/sns/governance/src/follower_index.rs b/rs/sns/governance/src/follower_index.rs
new file mode 100644
index 000000000000..ed3d08708b70
--- /dev/null
+++ b/rs/sns/governance/src/follower_index.rs
@@ -0,0 +1,214 @@
+use ic_canister_log::log;
+
+use crate::{
+    logs::ERROR,
+    pb::v1::{neuron::FolloweesForTopic, Followee, Neuron, NeuronId, Topic},
+};
+use std::collections::{BTreeMap, BTreeSet};
+
+pub(crate) type FollowerIndex = BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>;
+
+/// This is analogous to the legacy `remove_neuron_from_function_followee_index` function, but for
+/// topic following.
+pub fn remove_neuron_from_follower_index(index: &mut FollowerIndex, neuron: &Neuron) {
+    let Some(follower_id) = &neuron.id else {
+        log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+        return;
+    };
+
+    let Some(topic_followees) = &neuron.topic_followees else {
+        return;
+    };
+
+    for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+        let Ok(topic) = Topic::try_from(*topic) else {
+            log!(
+                ERROR,
+                "Neuron {} has followees for an invalid topic ID: {}",
+                follower_id,
+                topic
+            );
+            continue;
+        };
+
+        if let Some(topic_index) = index.get_mut(&topic) {
+            for Followee {
+                neuron_id: followee_id,
+                alias,
+            } in followees
+            {
+                let Some(followee_id) = followee_id else {
+                    let alias = alias
+                        .as_ref()
+                        .map(|alias| format!(" ({})", alias))
+                        .unwrap_or_default();
+                    log!(
+                        ERROR,
+                        "Neuron with ID {:?} has a followee{} with no ID!",
+                        follower_id,
+                        alias
+                    );
+                    continue;
+                };
+
+                let key = followee_id.to_string();
+                if let Some(followers) = topic_index.get_mut(&key) {
+                    if !followers.remove(follower_id) {
+                        log!(
+                            ERROR,
+                            "Following index was missing an edge from followee with ID {:?} \
+                                to follower with ID {:?} in topic {:?}.",
+                            followee_id,
+                            follower_id,
+                            topic,
+                        );
+                    };
+                    if followers.is_empty() {
+                        topic_index.remove(&key);
+                    }
+                }
+            }
+        }
+    }
+}
+
+/// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+/// topic following.
+pub fn add_neuron_to_follower_index(
+    index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+    follower: &Neuron,
+) {
+    let Some(follower_id) = &follower.id else {
+        log!(ERROR, "Neuron {:?} does not have an ID!", follower);
+        return;
+    };
+
+    let Some(topic_followees) = &follower.topic_followees else {
+        return;
+    };
+
+    for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+        let Ok(topic) = Topic::try_from(*topic) else {
+            log!(
+                ERROR,
+                "Neuron {} has followees for an invalid topic ID: {}",
+                follower_id,
+                topic
+            );
+            continue;
+        };
+        let topic_index = index.entry(topic).or_default();
+
+        for Followee {
+            neuron_id: followee_id,
+            alias,
+        } in followees
+        {
+            let Some(followee_id) = followee_id else {
+                let alias = alias
+                    .as_ref()
+                    .map(|alias| format!(" ({})", alias))
+                    .unwrap_or_default();
+                log!(
+                    ERROR,
+                    "Neuron with ID {:?} has a followee{} with no ID!",
+                    follower_id,
+                    alias
+                );
+                continue;
+            };
+
+            let key = followee_id.to_string();
+            topic_index
+                .entry(key)
+                .or_default()
+                .insert(follower_id.clone());
+        }
+    }
+}
+
+/// This is analogous to the legacy `build_function_followee_index` function, but for topic
+/// following.
+pub fn build_follower_index(
+    neurons: &BTreeMap<String, Neuron>,
+) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+    let mut function_followee_index = BTreeMap::new();
+    for neuron in neurons.values() {
+        add_neuron_to_follower_index(&mut function_followee_index, neuron);
+    }
+    function_followee_index
+}
+
+pub(crate) mod legacy {
+    use crate::{pb::v1::NervousSystemFunction, types::is_registered_function_id};
+
+    use super::*;
+
+    pub(crate) type FollowerIndex = BTreeMap<u64, BTreeMap<String, BTreeSet<NeuronId>>>;
+
+    /// Builds an index that maps proposal sns functions to (followee) neuron IDs to these neuron's
+    /// followers. The resulting index is a map
+    /// Function Id -> (followee's neuron ID) -> set of followers' neuron IDs.
+    ///
+    /// The index is built from the `neurons` in the `Governance` struct, which map followers
+    /// (the neuron ID) to a set of followees per function.
+    pub fn build_function_followee_index(
+        id_to_nervous_system_functions: &BTreeMap<u64, NervousSystemFunction>,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<u64, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            add_neuron_to_function_followee_index(
+                &mut function_followee_index,
+                id_to_nervous_system_functions,
+                neuron,
+            );
+        }
+        function_followee_index
+    }
+
+    /// Adds a neuron to the function_followee_index.
+    pub(crate) fn add_neuron_to_function_followee_index(
+        index: &mut FollowerIndex,
+        id_to_nervous_system_functions: &BTreeMap<u64, NervousSystemFunction>,
+        neuron: &Neuron,
+    ) {
+        for (function_id, followees) in neuron.followees.iter() {
+            if !is_registered_function_id(*function_id, id_to_nervous_system_functions) {
+                continue;
+            }
+
+            let followee_index = index.entry(*function_id).or_default();
+            for followee in followees.followees.iter() {
+                followee_index
+                    .entry(followee.to_string())
+                    .or_default()
+                    .insert(
+                        neuron
+                            .id
+                            .as_ref()
+                            .expect("Neuron must have a NeuronId")
+                            .clone(),
+                    );
+            }
+        }
+    }
+
+    /// Removes a neuron from the function_followee_index.
+    pub fn remove_neuron_from_function_followee_index(index: &mut FollowerIndex, neuron: &Neuron) {
+        for (function, followees) in neuron.followees.iter() {
+            if let Some(followee_index) = index.get_mut(function) {
+                for followee in followees.followees.iter() {
+                    let nid = followee.to_string();
+                    if let Some(followee_set) = followee_index.get_mut(&nid) {
+                        followee_set
+                            .remove(neuron.id.as_ref().expect("Neuron must have a NeuronId"));
+                        if followee_set.is_empty() {
+                            followee_index.remove(&nid);
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs b/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs
index fba8de75589c..9a7117752e2c 100644
--- a/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs
+++ b/rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs
@@ -245,8 +245,8 @@ pub mod neuron {
         ::prost::Message,
     )]
     pub struct TopicFollowees {
-        #[prost(btree_map = "uint64, message", tag = "1")]
-        pub topic_id_to_followees: ::prost::alloc::collections::BTreeMap<u64, FolloweesForTopic>,
+        #[prost(btree_map = "int32, message", tag = "1")]
+        pub topic_id_to_followees: ::prost::alloc::collections::BTreeMap<i32, FolloweesForTopic>,
     }
     /// The neuron's dissolve state, specifying whether the neuron is dissolving,
     /// non-dissolving, or dissolved.
diff --git a/rs/sns/governance/src/governance.rs b/rs/sns/governance/src/governance.rs
index 051e1db7b0e2..0f2230434e17 100644
--- a/rs/sns/governance/src/governance.rs
+++ b/rs/sns/governance/src/governance.rs
@@ -3,6 +3,14 @@ use crate::{
         get_canister_id, perform_execute_generic_nervous_system_function_call,
         upgrade_canister_directly,
     },
+    follower_index::{
+        add_neuron_to_follower_index, build_follower_index,
+        legacy::{
+            self, add_neuron_to_function_followee_index, build_function_followee_index,
+            remove_neuron_from_function_followee_index,
+        },
+        remove_neuron_from_follower_index, FollowerIndex,
+    },
     following::ValidatedSetFollowing,
     logs::{ERROR, INFO},
     neuron::{
@@ -235,75 +243,6 @@ impl NeuronPermission {
 }
 
 impl GovernanceProto {
-    /// Builds an index that maps proposal sns functions to (followee) neuron IDs to these neuron's
-    /// followers. The resulting index is a map
-    /// Function Id -> (followee's neuron ID) -> set of followers' neuron IDs.
-    ///
-    /// The index is built from the `neurons` in the `Governance` struct, which map followers
-    /// (the neuron ID) to a set of followees per function.
-    pub fn build_function_followee_index(
-        &self,
-        neurons: &BTreeMap<String, Neuron>,
-    ) -> BTreeMap<u64, BTreeMap<String, BTreeSet<NeuronId>>> {
-        let mut function_followee_index = BTreeMap::new();
-        for neuron in neurons.values() {
-            GovernanceProto::add_neuron_to_function_followee_index(
-                &mut function_followee_index,
-                &self.id_to_nervous_system_functions,
-                neuron,
-            );
-        }
-        function_followee_index
-    }
-
-    /// Adds a neuron to the function_followee_index.
-    pub fn add_neuron_to_function_followee_index(
-        index: &mut BTreeMap<u64, BTreeMap<String, BTreeSet<NeuronId>>>,
-        registered_functions: &BTreeMap<u64, NervousSystemFunction>,
-        neuron: &Neuron,
-    ) {
-        for (function_id, followees) in neuron.followees.iter() {
-            if !is_registered_function_id(*function_id, registered_functions) {
-                continue;
-            }
-
-            let followee_index = index.entry(*function_id).or_default();
-            for followee in followees.followees.iter() {
-                followee_index
-                    .entry(followee.to_string())
-                    .or_default()
-                    .insert(
-                        neuron
-                            .id
-                            .as_ref()
-                            .expect("Neuron must have a NeuronId")
-                            .clone(),
-                    );
-            }
-        }
-    }
-
-    /// Removes a neuron from the function_followee_index.
-    pub fn remove_neuron_from_function_followee_index(
-        index: &mut BTreeMap<u64, BTreeMap<String, BTreeSet<NeuronId>>>,
-        neuron: &Neuron,
-    ) {
-        for (function, followees) in neuron.followees.iter() {
-            if let Some(followee_index) = index.get_mut(function) {
-                for followee in followees.followees.iter() {
-                    let nid = followee.to_string();
-                    if let Some(followee_set) = followee_index.get_mut(&nid) {
-                        followee_set
-                            .remove(neuron.id.as_ref().expect("Neuron must have a NeuronId"));
-                        if followee_set.is_empty() {
-                            followee_index.remove(&nid);
-                        }
-                    }
-                }
-            }
-        }
-    }
-
     /// Iterate through one neuron and add all the principals that have some permission on this
     /// neuron to the index that maps principalIDs to a set of neurons for which the principal
     /// has some permissions.
@@ -691,7 +630,17 @@ pub struct Governance {
     /// is saved and restored.
     ///
     /// Function ID -> (followee's neuron ID) -> set of followers' neuron IDs.
-    pub function_followee_index: BTreeMap<u64, BTreeMap<String, BTreeSet<NeuronId>>>,
+    pub function_followee_index: legacy::FollowerIndex,
+
+    /// Cached data structure that (for each topic) maps a followee to
+    /// the set of its followers. It is the inverse of the mapping from follower
+    /// to followees that is stored in each (follower) neuron.
+    ///
+    /// This is a cached index and will be removed and recreated when the state
+    /// is saved and restored.
+    ///
+    /// Topic -> (followee's neuron ID) -> set of followers' neuron IDs.
+    pub topic_follower_index: FollowerIndex,
 
     /// Maps Principals to the Neuron IDs of all Neurons for which this principal
     /// has some permissions, i.e., all neurons that have this principal associated
@@ -796,6 +745,7 @@ impl Governance {
             nns_ledger,
             cmc,
             function_followee_index: BTreeMap::new(),
+            topic_follower_index: BTreeMap::new(),
             principal_to_neuron_ids_index: BTreeMap::new(),
             closest_proposal_deadline_timestamp_seconds: 0,
             latest_gc_timestamp_seconds: 0,
@@ -862,9 +812,13 @@ impl Governance {
     /// Must be called after the state has been externally changed (e.g. by
     /// setting a new proto).
     fn initialize_indices(&mut self) {
-        self.function_followee_index = self
-            .proto
-            .build_function_followee_index(&self.proto.neurons);
+        self.function_followee_index = build_function_followee_index(
+            &self.proto.id_to_nervous_system_functions,
+            &self.proto.neurons,
+        );
+
+        self.topic_follower_index = build_follower_index(&self.proto.neurons);
+
         self.principal_to_neuron_ids_index = self
             .proto
             .build_principal_to_neuron_ids_index(&self.proto.neurons);
@@ -1005,12 +959,14 @@ impl Governance {
             &neuron,
         );
 
-        GovernanceProto::add_neuron_to_function_followee_index(
+        add_neuron_to_function_followee_index(
             &mut self.function_followee_index,
             &self.proto.id_to_nervous_system_functions,
             &neuron,
         );
 
+        add_neuron_to_follower_index(&mut self.topic_follower_index, &neuron);
+
         self.proto.neurons.insert(neuron_id.to_string(), neuron);
 
         Ok(())
@@ -1041,10 +997,9 @@ impl Governance {
             &neuron,
         );
 
-        GovernanceProto::remove_neuron_from_function_followee_index(
-            &mut self.function_followee_index,
-            &neuron,
-        );
+        remove_neuron_from_function_followee_index(&mut self.function_followee_index, &neuron);
+
+        remove_neuron_from_follower_index(&mut self.topic_follower_index, &neuron);
 
         self.proto.neurons.remove(&neuron_id.to_string());
 
@@ -3581,7 +3536,7 @@ impl Governance {
         voting_neuron_id: &NeuronId,
         vote_of_neuron: Vote,
         function_id: u64,
-        function_followee_index: &BTreeMap<u64, BTreeMap<String, BTreeSet<NeuronId>>>,
+        function_followee_index: &legacy::FollowerIndex,
         neurons: &BTreeMap<String, Neuron>,
         // As of Dec, 2023 (52eec5c), the next parameter is only used to populate Ballots. In
         // particular, this has no impact on how the implications of following are deduced.
diff --git a/rs/sns/governance/src/lib.rs b/rs/sns/governance/src/lib.rs
index 16c099a6d53f..bc7cf8815f1d 100644
--- a/rs/sns/governance/src/lib.rs
+++ b/rs/sns/governance/src/lib.rs
@@ -3,6 +3,7 @@ use std::convert::TryInto;
 
 mod cached_upgrade_steps;
 pub mod canister_control;
+pub(crate) mod follower_index;
 pub mod following;
 pub mod governance;
 pub mod init;


Reviews:
user: login: github-actions[bot]
body: If this pull request affects the behavior of any canister owned by
the Governance team, remember to update the corresponding
unreleased_changes.md file(s).

To acknowldge this reminder (and unblock the PR), dismiss this
code review by going to the bottom of the pull request page, and
supply one of the following reasons:

1. Done.

2. No canister behavior changes.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:39:28Z
user: login: basvandijk
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-28T11:44:36Z
user: login: daniel-wong-dfinity-org
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T15:17:38Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:01:47Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:03:25Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:09:21Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:10:55Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:11:55Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:14:21Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T16:16:51Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T13:37:31Z
user: login: aterga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T15:25:44Z

Review comments:
diff_hunk: @@ -9,4 +9,8 @@
         "editor.formatOnSave": true
     },
     "bazel.buildifierExecutable": "@buildifier",
+    "cSpell.words": [
+        "followee",
+        "followees"
path: .vscode/settings.json
user: login: daniel-wong-dfinity-org
body: NC

Ha! I always found these words very awkward. The spell checkers seem to agree with me.
created_at: 2025-03-28T14:28:26Z
author_association: CONTRIBUTOR
path: rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs
user: login: daniel-wong-dfinity-org
body: You should maybe mention near the bottom of the PR description switching integer types as a tangential change.
created_at: 2025-03-28T14:29:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: NCR

In Google C++ style guide, output parameters (e.g. `index`) go last. We don't seem to have a consistent [pattern in the ic repo][no pat], which is sad.

[no pat]: https://sourcegraph.com/search?q=repo:%5Egithub%5C.com/dfinity/ic%24+%22:+%26mut+%22+-content:Formatter&patternType=regexp&case=yes&sm=0
created_at: 2025-03-28T14:32:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: I don't think this is used -> I would remove this. Don't ask for tons of stuff that you don't need.
created_at: 2025-03-28T14:39:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: MAIN FEEDBACK.

I would suggest not putting these new things in `impl Governance`. They do not need access to privates -> this could go in a (new) separate sibling module. I'd suggest calling this module `voting` or something like that. This better maintains need-to-know (I have never heard anyone use this term this way, but IMHO, [it is pretty apt][NTK]).

[NTK]: https://www.tumblr.com/magic-bad/763059096547131392/bundling-bad?source=share

PS: If `governance.proto` were its own separate crate, then `voting` could also be a separate crate instead of merely being a separate module within the main sns `governance` crate.
created_at: 2025-03-28T14:39:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: Since we have multiple neurons on this situation with different roles, it is better to name this so that the role is explicit: follower
created_at: 2025-03-28T14:43:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: NCR

Why `..`? I think this should be avoided. Making the compiler explode is admittedly inconvenient, but such explosions are actually often nice reminders about code that likely needs to be updated.

Also, I think "unpacking" FolloweesForTopic should be done separately. Like so:

```suggestion
        for (topic, followees) in &topic_followees.topic_id_to_followees {
            let FolloweesForTopic { followees } = followees;
```

Doing many things in one line is less readable. In this case, it's not extreme (hence NCR), but still.

> Optimize for the reader, **_not the writer_**.
> --[Ancient Proverb]

[Ancient Proverb]: https://google.github.io/styleguide/cppguide.html#Goals
created_at: 2025-03-28T14:50:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: ```suggestion
            let topic_index = index.entry(topic).or_default();
```
created_at: 2025-03-28T14:50:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: I was not expecting this to be here. I would think that an "address book" would be separate from following. Like, suppose I follow the same neuron on multiple topics. It shouldn't be for topic 1, I call that neuron "foo", but on topic 2, I call it "bar", right? Anyway, this is not a new issue to this PR, so no change here, but think about it.
created_at: 2025-03-28T14:53:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
+            } in followees
+            {
+                let Some(followee_id) = followee_id else {
+                    let alias = alias
+                        .as_ref()
+                        .map(|alias| format!(" ({})", alias))
+                        .unwrap_or_default();
+                    log!(
+                        ERROR,
+                        "Neuron with ID {:?} has a followee{} with no ID!",
+                        follower_id,
+                        alias
+                    );
+                    continue;
+                };
+
+                let key = followee_id.to_string();
+                topoc_index
+                    .entry(key)
+                    .or_default()
+                    .insert(follower_id.clone());
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: Hmm. I think in NNS, NeuronId is Copy. I think that makes sense, since IDs should be O(1) in size. Anyway, this is not a new issue, so no change.
created_at: 2025-03-28T14:55:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
+            } in followees
+            {
+                let Some(followee_id) = followee_id else {
+                    let alias = alias
+                        .as_ref()
+                        .map(|alias| format!(" ({})", alias))
+                        .unwrap_or_default();
+                    log!(
+                        ERROR,
+                        "Neuron with ID {:?} has a followee{} with no ID!",
+                        follower_id,
+                        alias
+                    );
+                    continue;
+                };
+
+                let key = followee_id.to_string();
+                topoc_index
+                    .entry(key)
+                    .or_default()
+                    .insert(follower_id.clone());
+            }
+        }
+    }
+
+    /// This is analogous to the legacy `remove_neuron_from_function_followee_index` function, but for
+    /// topic following.
+    pub fn remove_neuron_from_following_index(
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: NC

IIUC, this would be used when a neuron changes its following. In that case, `neuron` would be the old value, right?

Anyway, if updating is the use case for this, maybe instead of providing remove, we should instead provide (approximately!)`update(&old_neuron, &new_neuron, &mut index)` ?
created_at: 2025-03-28T15:01:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
+            } in followees
+            {
+                let Some(followee_id) = followee_id else {
+                    let alias = alias
+                        .as_ref()
+                        .map(|alias| format!(" ({})", alias))
+                        .unwrap_or_default();
+                    log!(
+                        ERROR,
+                        "Neuron with ID {:?} has a followee{} with no ID!",
+                        follower_id,
+                        alias
+                    );
+                    continue;
+                };
+
+                let key = followee_id.to_string();
+                topoc_index
+                    .entry(key)
+                    .or_default()
+                    .insert(follower_id.clone());
+            }
+        }
+    }
+
+    /// This is analogous to the legacy `remove_neuron_from_function_followee_index` function, but for
+    /// topic following.
+    pub fn remove_neuron_from_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+
+            if let Some(topoc_index) = index.get_mut(&topic) {
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: Ditto.
created_at: 2025-03-28T15:09:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
+            } in followees
+            {
+                let Some(followee_id) = followee_id else {
+                    let alias = alias
+                        .as_ref()
+                        .map(|alias| format!(" ({})", alias))
+                        .unwrap_or_default();
+                    log!(
+                        ERROR,
+                        "Neuron with ID {:?} has a followee{} with no ID!",
+                        follower_id,
+                        alias
+                    );
+                    continue;
+                };
+
+                let key = followee_id.to_string();
+                topoc_index
+                    .entry(key)
+                    .or_default()
+                    .insert(follower_id.clone());
+            }
+        }
+    }
+
+    /// This is analogous to the legacy `remove_neuron_from_function_followee_index` function, but for
+    /// topic following.
+    pub fn remove_neuron_from_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+
+            if let Some(topoc_index) = index.get_mut(&topic) {
+                for Followee {
+                    neuron_id: followee_id,
+                    alias,
+                } in followees
+                {
+                    let Some(followee_id) = followee_id else {
+                        let alias = alias
+                            .as_ref()
+                            .map(|alias| format!(" ({})", alias))
+                            .unwrap_or_default();
+                        log!(
+                            ERROR,
+                            "Neuron with ID {:?} has a followee{} with no ID!",
+                            follower_id,
+                            alias
+                        );
+                        continue;
+                    };
+
+                    let key = followee_id.to_string();
+                    if let Some(followee_set) = topoc_index.get_mut(&key) {
path: rs/sns/governance/src/governance.rs
user: login: daniel-wong-dfinity-org
body: ```suggestion
                    if let Some(followers) = topoc_index.get_mut(&key) {
```
created_at: 2025-03-28T15:10:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -9,4 +9,8 @@
         "editor.formatOnSave": true
     },
     "bazel.buildifierExecutable": "@buildifier",
+    "cSpell.words": [
+        "followee",
+        "followees"
path: .vscode/settings.json
user: login: aterga
body: Yep, but now I wanted to avoid seeing the blue squiggly lines in VS Code. :) 
created_at: 2025-03-28T16:01:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
path: rs/sns/governance/src/governance.rs
user: login: aterga
body: The reason this order is more intuitive to me is because I think of this function as a method of the index. If we had a separate type for `BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>`, we'd implement `fn add_neuron_to_following_index(&mut self, ...)`, most likely. 
created_at: 2025-03-28T16:03:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
path: rs/sns/governance/src/governance.rs
user: login: aterga
body: This isn't an address book. This is a followee alias from the p.o.v. of the follower. Aliases are optional and not guaranteed to be the same across different followers.

If you're wondering why we need to store the aliases, it's because users cannot remember Neuron IDs by heart. If I add a following for a neuron, e.g., `1234567890...` today, I might not remember why I added it, who that neuron is controlled by, and whether I still want to follow it in a year from now. Here's a screenshot: 

![image](https://github.com/user-attachments/assets/3549d8fa-3fd1-4e00-ace6-bc6d722ece70)

Note that this is what it looks like in the "CURRENTLY FOLLOWING" section without a alias. An alias could be, e.g., "My friend Bob."
created_at: 2025-03-28T16:09:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
path: rs/sns/governance/src/governance.rs
user: login: aterga
body: > suppose I follow the same neuron on multiple topics. It shouldn't be for topic 1, I call that neuron "foo", but on topic 2, I call it "bar", right?

Correct, this is enforced by the validation code in this PR: https://github.com/dfinity/ic/pull/4563/files#diff-057b49d03b29cd1ea7fab413a3bc7535bc5ea682996ed96c73543b385700f601R536
created_at: 2025-03-28T16:10:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
+            } in followees
+            {
+                let Some(followee_id) = followee_id else {
+                    let alias = alias
+                        .as_ref()
+                        .map(|alias| format!(" ({})", alias))
+                        .unwrap_or_default();
+                    log!(
+                        ERROR,
+                        "Neuron with ID {:?} has a followee{} with no ID!",
+                        follower_id,
+                        alias
+                    );
+                    continue;
+                };
+
+                let key = followee_id.to_string();
+                topoc_index
+                    .entry(key)
+                    .or_default()
+                    .insert(follower_id.clone());
path: rs/sns/governance/src/governance.rs
user: login: aterga
body: In SNS, neuron IDs contain ICRC1 subaccount identifiers, which unfortunately are `Vec<u8>`, which are not `Copy`.
created_at: 2025-03-28T16:11:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
+            let Ok(topic) = Topic::try_from(*topic) else {
+                log!(
+                    ERROR,
+                    "Neuron {} has followees for an invalid topic ID: {}",
+                    follower_id,
+                    topic
+                );
+                continue;
+            };
+            let topoc_index = index.entry(topic).or_default();
+
+            for Followee {
+                neuron_id: followee_id,
+                alias,
+            } in followees
+            {
+                let Some(followee_id) = followee_id else {
+                    let alias = alias
+                        .as_ref()
+                        .map(|alias| format!(" ({})", alias))
+                        .unwrap_or_default();
+                    log!(
+                        ERROR,
+                        "Neuron with ID {:?} has a followee{} with no ID!",
+                        follower_id,
+                        alias
+                    );
+                    continue;
+                };
+
+                let key = followee_id.to_string();
+                topoc_index
+                    .entry(key)
+                    .or_default()
+                    .insert(follower_id.clone());
+            }
+        }
+    }
+
+    /// This is analogous to the legacy `remove_neuron_from_function_followee_index` function, but for
+    /// topic following.
+    pub fn remove_neuron_from_following_index(
path: rs/sns/governance/src/governance.rs
user: login: aterga
body: Note that for now, I'm just following the exact pattern that was there before for the legacy following index. I suppose refactoring them would need to happen side-by-side, so let's consider it separately from this PR.
created_at: 2025-03-28T16:14:21Z
author_association: CONTRIBUTOR
path: rs/sns/governance/src/gen/ic_sns_governance.pb.v1.rs
user: login: aterga
body: Done
created_at: 2025-03-28T16:16:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
+        index: &mut BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>>,
+        neuron: &Neuron,
+    ) {
+        let Some(follower_id) = &neuron.id else {
+            log!(ERROR, "Neuron {:?} does not have an ID!", neuron);
+            return;
+        };
+
+        let Some(topic_followees) = &neuron.topic_followees else {
+            return;
+        };
+
+        for (topic, FolloweesForTopic { followees, .. }) in &topic_followees.topic_id_to_followees {
path: rs/sns/governance/src/governance.rs
user: login: aterga
body: `FolloweesForTopic { followees }` cannot be done since the aliases also exist as a field. 

Whether to write `let FolloweesForTopic { followees } = followees;` or `let followees = followees.followees;` is a matter of taste IMHI. Thus, and since this was marked NCR, I'm keeping it as-is.
created_at: 2025-03-31T13:37:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,141 @@ impl GovernanceProto {
         function_followee_index
     }
 
+    /// This is analogous to the legacy `build_function_followee_index` function, but for topic
+    /// following.
+    pub fn build_following_index(
+        &self,
+        neurons: &BTreeMap<String, Neuron>,
+    ) -> BTreeMap<Topic, BTreeMap<String, BTreeSet<NeuronId>>> {
+        let mut function_followee_index = BTreeMap::new();
+        for neuron in neurons.values() {
+            GovernanceProto::add_neuron_to_following_index(&mut function_followee_index, neuron);
+        }
+        function_followee_index
+    }
+
+    /// This is analogous to the legacy `add_neuron_to_function_followee_index` function, but for
+    /// topic following.
+    pub fn add_neuron_to_following_index(
path: rs/sns/governance/src/governance.rs
user: login: aterga
body: I've moved the legacy and the new follower indices to the respective (newly added) modules in a new file called rs/sns/governance/src/follower_index.rs
created_at: 2025-03-31T15:25:43Z
author_association: CONTRIBUTOR

PR comments:
user: login: aterga
created_at: 2025-03-28T11:43:06Z
body: Note that this PR does not add any tests. The plan is to test the index together with the `cast_vote_and_cascade_follow`, which is be extended to support topic-based following in the next PR.
author_association: CONTRIBUTOR
user: login: daniel-wong-dfinity-org
created_at: 2025-03-28T14:27:13Z
body: NCR

> index for the topic-based following "is-followed-by" relation

I think we usually call this simply "follower index".
author_association: CONTRIBUTOR