Repository: RasaHQ/rasa
Pull request: 3.0 architecture revamp/9340/e2e lookup
**Proposed changes**:
- closes #9340 
- build the lookup table: 
  - lookup table implementation and tests (see `rasa.core.featurizers.precomputation`)
  - components for prep/build of lookup table (start and end of e2e featurization pipeline) (see `rasa.core.featurizers.precomputation`)
- using the lookup table: 
  - adaption of `SingleStateFeaturizer` and `TrackerFeaturizer` (use the lookup table)
  - adaption of unit tests for `SingleStateFeaturizer` and `TrackerFeaturizer`  - and refactoring of unit tests for `SingleStateFeaturizer` 
       - **only** the tests for `SingleStateFeaturizer` really make use of the lookup table 
       - tests for `TrackerFeaturizer` use lookup table set to `None` which corresponds to usage of `RegexInterpreter()` -- which is exactly corresponding to the tests as they were before (i.e. tracker featurizer never included tests with a different interpreter)
  - adaption of `TEDPolicy` (use the lookup table) and its unit tests
     - tests for `TEDPolicy` use lookup table set to `None` which corresponds to usage of `RegexInterpreter()` -- which is exactly corresponding to the tests as they were before 
- not the lookup table 😄 
  -  generalized functionality related to `Features` and added that to `Features` (see `rasa.shared.nlu.training_data.features`)
      * note: this was useful because it made the adaption of `SingleStateFeaturizer`  and the implementation of the lookup table much cleaner than with existing functionalities 

**Ignore**:
- as always, ignore the "new" modules with leading underscore

**Not included (yet)**:
- caching (i.e. we don't want to have to run BERT again and again if only the policies change but not the featurization pipeline)
- tests computing and using a lookup table wrt. full bot example (Will be done with regression tests later on. We could add more specific tests on the "end to end" usage but that requires featurizers etc.) 

**Status (please check what you already did)**:
- [x] added some tests for the functionality
- [ ] ~~updated the documentation~~
- [ ] ~~updated the changelog (please check [changelog](https://github.com/RasaHQ/rasa/tree/main/changelog) for instructions)~~
- [x] reformat files using `black` (please check [Readme](https://github.com/RasaHQ/rasa#code-style) for instructions)


Problem statement:
[Re-implement featurization] Move to integration branch, adapt Policies, and add checks
**Definition of Done**
- [x] moved changes over to `main` branch
- [x] updated graph components (`StoryToTrainingDataConverter` and `MessageE2EFeatureConverter`) and added/adapted unit tests
- [x ] adapted unit tests for `TrackerFeaturizer`
- [x] adpated unit tests for `TEDPolicy` ~~and `UnexpecTEDIntentPolicy`~~
- [ ] add/move unit tests for new graph components

Hints from issue discussion:

Commit message: minor (add some more features check, types, add note,...)

Patch to review:
diff --git a/rasa/core/featurizers/_single_state_featurizer.py b/rasa/core/featurizers/_single_state_featurizer.py
new file mode 100644
index 000000000000..f8a6a08aa2f4
--- /dev/null
+++ b/rasa/core/featurizers/_single_state_featurizer.py
@@ -0,0 +1,409 @@
+# WARNING: This module will be dropped before Rasa Open Source 3.0 is released.
+#          Please don't do any changes in this module and rather adapt SingleStateFeaturizer from
+#          the regular `rasa.core.featurizers.single_state_featurizer` module. This module is a workaround
+#          to defer breaking changes due to the architecture revamp in 3.0.
+# flake8: noqa
+import logging
+import numpy as np
+import scipy.sparse
+from typing import List, Optional, Dict, Text, Set, Any
+from collections import defaultdict
+
+from rasa.nlu.extractors.extractor import EntityTagSpec
+from rasa.nlu.utils import bilou_utils
+from rasa.nlu.utils.bilou_utils import BILOU_PREFIXES
+from rasa.shared.core.domain import SubState, State, Domain
+from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter, RegexInterpreter
+from rasa.shared.core.constants import PREVIOUS_ACTION, ACTIVE_LOOP, USER, SLOTS
+from rasa.shared.core.trackers import is_prev_action_listen_in_state
+from rasa.shared.nlu.constants import (
+    ENTITIES,
+    FEATURE_TYPE_SENTENCE,
+    ACTION_TEXT,
+    ACTION_NAME,
+    INTENT,
+    NO_ENTITY_TAG,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_TAGS,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.utils.tensorflow import model_data_utils
+
+logger = logging.getLogger(__name__)
+
+
+class SingleStateFeaturizer:
+    """Base class to transform the dialogue state into an ML format.
+
+    Subclasses of SingleStateFeaturizer will decide how a bot will
+    transform the dialogue state into a dictionary mapping an attribute
+    to its features. Possible attributes are: INTENT, TEXT, ACTION_NAME,
+    ACTION_TEXT, ENTITIES, SLOTS and ACTIVE_LOOP. Each attribute will be
+    featurized into a list of `rasa.utils.features.Features`.
+    """
+
+    def __init__(self) -> None:
+        """Initialize the single state featurizer."""
+        # rasa core can be trained separately, therefore interpreter during training
+        # will be `RegexInterpreter`. If the model is combined with a rasa nlu model
+        # during prediction the interpreter might be different.
+        # If that is the case, we need to make sure to "reset" the interpreter.
+        self._use_regex_interpreter = False
+        self._default_feature_states = {}
+        self.action_texts = []
+        self.entity_tag_specs = []
+
+    def _create_entity_tag_specs(
+        self, bilou_tagging: bool = False
+    ) -> List[EntityTagSpec]:
+        """Returns the tag to index mapping for entities.
+
+        Returns:
+            Tag to index mapping.
+        """
+        if ENTITIES not in self._default_feature_states:
+            return []
+
+        if bilou_tagging:
+            tag_id_index_mapping = {
+                f"{prefix}{tag}": idx_1 * len(BILOU_PREFIXES) + idx_2 + 1
+                for tag, idx_1 in self._default_feature_states[ENTITIES].items()
+                for idx_2, prefix in enumerate(BILOU_PREFIXES)
+            }
+        else:
+            tag_id_index_mapping = {
+                tag: idx + 1  # +1 to keep 0 for the NO_ENTITY_TAG
+                for tag, idx in self._default_feature_states[ENTITIES].items()
+            }
+
+        # NO_ENTITY_TAG corresponds to non-entity which should correspond to 0 index
+        # needed for correct prediction for padding
+        tag_id_index_mapping[NO_ENTITY_TAG] = 0
+
+        # TODO
+        #  The entity states used to create the tag-idx-mapping contains the
+        #  entities and the concatenated entity and roles/groups. We do not
+        #  distinguish between entities and roles/groups right now.
+        #  we return a list to anticipate that
+        return [
+            EntityTagSpec(
+                tag_name=ENTITY_ATTRIBUTE_TYPE,
+                tags_to_ids=tag_id_index_mapping,
+                ids_to_tags={value: key for key, value in tag_id_index_mapping.items()},
+                num_tags=len(tag_id_index_mapping),
+            )
+        ]
+
+    def prepare_for_training(
+        self,
+        domain: Domain,
+        interpreter: NaturalLanguageInterpreter,
+        bilou_tagging: bool = False,
+    ) -> None:
+        """Gets necessary information for featurization from domain.
+
+        Args:
+            domain: An instance of :class:`rasa.shared.core.domain.Domain`.
+            interpreter: The interpreter used to encode the state
+            bilou_tagging: indicates whether BILOU tagging should be used or not
+        """
+        if isinstance(interpreter, RegexInterpreter):
+            # this method is called during training,
+            # RegexInterpreter means that core was trained separately
+            self._use_regex_interpreter = True
+
+        # store feature states for each attribute in order to create binary features
+        def convert_to_dict(feature_states: List[Text]) -> Dict[Text, int]:
+            return {
+                feature_state: idx for idx, feature_state in enumerate(feature_states)
+            }
+
+        self._default_feature_states[INTENT] = convert_to_dict(domain.intents)
+        self._default_feature_states[ACTION_NAME] = convert_to_dict(
+            domain.action_names_or_texts
+        )
+        self._default_feature_states[ENTITIES] = convert_to_dict(domain.entity_states)
+        self._default_feature_states[SLOTS] = convert_to_dict(domain.slot_states)
+        self._default_feature_states[ACTIVE_LOOP] = convert_to_dict(domain.form_names)
+        self.action_texts = domain.action_texts
+        self.entity_tag_specs = self._create_entity_tag_specs(bilou_tagging)
+
+    def _state_features_for_attribute(
+        self, sub_state: SubState, attribute: Text
+    ) -> Dict[Text, int]:
+        # FIXME: the code below is not type-safe, but fixing it
+        #        would require more refactoring, for instance using
+        #        data classes in our states
+        if attribute in {INTENT, ACTION_NAME}:
+            return {sub_state[attribute]: 1}  # type: ignore[dict-item]
+        elif attribute == ENTITIES:
+            return {entity: 1 for entity in sub_state.get(ENTITIES, [])}
+        elif attribute == ACTIVE_LOOP:
+            return {sub_state["name"]: 1}  # type: ignore[dict-item]
+        elif attribute == SLOTS:
+            return {
+                f"{slot_name}_{i}": value
+                for slot_name, slot_as_feature in sub_state.items()
+                for i, value in enumerate(slot_as_feature)
+            }
+        else:
+            raise ValueError(
+                f"Given attribute '{attribute}' is not supported. "
+                f"It must be one of '{self._default_feature_states.keys()}'."
+            )
+
+    def _create_features(
+        self, sub_state: SubState, attribute: Text, sparse: bool = False
+    ) -> List[Features]:
+        state_features = self._state_features_for_attribute(sub_state, attribute)
+
+        features = np.zeros(len(self._default_feature_states[attribute]), np.float32)
+        for state_feature, value in state_features.items():
+            # check that the value is in default_feature_states to be able to assign
+            # its value
+            if state_feature in self._default_feature_states[attribute]:
+                features[self._default_feature_states[attribute][state_feature]] = value
+        features = np.expand_dims(features, 0)
+
+        if sparse:
+            features = scipy.sparse.coo_matrix(features)
+
+        return [
+            Features(
+                features, FEATURE_TYPE_SENTENCE, attribute, self.__class__.__name__
+            )
+        ]
+
+    @staticmethod
+    def _to_sparse_sentence_features(
+        sparse_sequence_features: List[Features],
+    ) -> List[Features]:
+        return [
+            Features(
+                scipy.sparse.coo_matrix(feature.features.sum(0)),
+                FEATURE_TYPE_SENTENCE,
+                feature.attribute,
+                feature.origin,
+            )
+            for feature in sparse_sequence_features
+        ]
+
+    def _get_features_from_parsed_message(
+        self, parsed_message: Optional[Message], attributes: Set[Text]
+    ) -> Dict[Text, List[Features]]:
+        if parsed_message is None:
+            return {}
+
+        output = defaultdict(list)
+        for attribute in attributes:
+            all_features = parsed_message.get_sparse_features(
+                attribute
+            ) + parsed_message.get_dense_features(attribute)
+
+            for features in all_features:
+                if features is not None:
+                    output[attribute].append(features)
+
+        # if features for INTENT or ACTION_NAME exist,
+        # they are always sparse sequence features;
+        # transform them to sentence sparse features
+        if output.get(INTENT):
+            output[INTENT] = self._to_sparse_sentence_features(output[INTENT])
+        if output.get(ACTION_NAME):
+            output[ACTION_NAME] = self._to_sparse_sentence_features(output[ACTION_NAME])
+
+        return output
+
+    @staticmethod
+    def _get_name_attribute(attributes: Set[Text]) -> Optional[Text]:
+        # there is always either INTENT or ACTION_NAME
+        return next(
+            (
+                attribute
+                for attribute in attributes
+                if attribute in {INTENT, ACTION_NAME}
+            ),
+            None,
+        )
+
+    def _extract_state_features(
+        self,
+        sub_state: SubState,
+        interpreter: NaturalLanguageInterpreter,
+        sparse: bool = False,
+    ) -> Dict[Text, List[Features]]:
+        # this method is called during both prediction and training,
+        # `self._use_regex_interpreter == True` means that core was trained
+        # separately, therefore substitute interpreter based on some trained
+        # nlu model with default RegexInterpreter to make sure
+        # that prediction and train time features are the same
+        if self._use_regex_interpreter and not isinstance(
+            interpreter, RegexInterpreter
+        ):
+            interpreter = RegexInterpreter()
+
+        message = Message(data=sub_state)
+        # remove entities from possible attributes
+        attributes = set(
+            attribute for attribute in sub_state.keys() if attribute != ENTITIES
+        )
+
+        parsed_message = interpreter.featurize_message(message)
+        output = self._get_features_from_parsed_message(parsed_message, attributes)
+
+        # check that name attributes have features
+        name_attribute = self._get_name_attribute(attributes)
+        if name_attribute and name_attribute not in output:
+            # nlu pipeline didn't create features for user or action
+            # this might happen, for example, when we have action_name in the state
+            # but it did not get featurized because only character level
+            # CountVectorsFeaturizer was included in the config.
+            output[name_attribute] = self._create_features(
+                sub_state, name_attribute, sparse
+            )
+
+        return output
+
+    def encode_state(
+        self, state: State, interpreter: NaturalLanguageInterpreter
+    ) -> Dict[Text, List[Features]]:
+        """Encodes the given state with the help of the given interpreter.
+
+        Args:
+            state: The state to encode
+            interpreter: The interpreter used to encode the state
+
+        Returns:
+            A dictionary of state_type to list of features.
+        """
+        state_features = {}
+        for state_type, sub_state in state.items():
+            if state_type == PREVIOUS_ACTION:
+                state_features.update(
+                    self._extract_state_features(sub_state, interpreter, sparse=True)
+                )
+            # featurize user only if it is "real" user input,
+            # i.e. input from a turn after action_listen
+            if state_type == USER and is_prev_action_listen_in_state(state):
+                state_features.update(
+                    self._extract_state_features(sub_state, interpreter, sparse=True)
+                )
+                if sub_state.get(ENTITIES):
+                    state_features[ENTITIES] = self._create_features(
+                        sub_state, ENTITIES, sparse=True
+                    )
+
+            if state_type in {SLOTS, ACTIVE_LOOP}:
+                state_features[state_type] = self._create_features(
+                    sub_state, state_type, sparse=True
+                )
+
+        return state_features
+
+    def encode_entities(
+        self,
+        entity_data: Dict[Text, Any],
+        interpreter: NaturalLanguageInterpreter,
+        bilou_tagging: bool = False,
+    ) -> Dict[Text, List[Features]]:
+        """Encodes the given entity data with the help of the given interpreter.
+
+        Produce numeric entity tags for tokens.
+
+        Args:
+            entity_data: The dict containing the text and entity labels and locations
+            interpreter: The interpreter used to encode the state
+            bilou_tagging: indicates whether BILOU tagging should be used or not
+
+        Returns:
+            A dictionary of entity type to list of features.
+        """
+        # TODO
+        #  The entity states used to create the tag-idx-mapping contains the
+        #  entities and the concatenated entity and roles/groups. We do not
+        #  distinguish between entities and roles/groups right now.
+        if (
+            not entity_data
+            or not self.entity_tag_specs
+            or self.entity_tag_specs[0].num_tags < 2
+        ):
+            # we cannot build a classifier with fewer than 2 classes
+            return {}
+
+        message = interpreter.featurize_message(Message(entity_data))
+
+        if not message:
+            return {}
+
+        if bilou_tagging:
+            bilou_utils.apply_bilou_schema_to_message(message)
+
+        return {
+            ENTITY_TAGS: [
+                model_data_utils.get_tag_ids(
+                    message, self.entity_tag_specs[0], bilou_tagging
+                )
+            ]
+        }
+
+    def _encode_action(
+        self, action: Text, interpreter: NaturalLanguageInterpreter
+    ) -> Dict[Text, List[Features]]:
+        if action in self.action_texts:
+            action_as_sub_state = {ACTION_TEXT: action}
+        else:
+            action_as_sub_state = {ACTION_NAME: action}
+
+        return self._extract_state_features(action_as_sub_state, interpreter)
+
+    def encode_all_labels(
+        self, domain: Domain, interpreter: NaturalLanguageInterpreter
+    ) -> List[Dict[Text, List[Features]]]:
+        """Encodes all labels from the domain using the given interpreter.
+
+        Args:
+            domain: The domain that contains the labels.
+            interpreter: The interpreter used to encode the labels.
+
+        Returns:
+            A list of encoded labels.
+        """
+        return [
+            self._encode_action(action, interpreter)
+            for action in domain.action_names_or_texts
+        ]
+
+
+class IntentTokenizerSingleStateFeaturizer(SingleStateFeaturizer):
+    """A SingleStateFeaturizer for use with policies that predict intent labels."""
+
+    def _encode_intent(
+        self, intent: Text, interpreter: NaturalLanguageInterpreter
+    ) -> Dict[Text, List[Features]]:
+        """Extracts a numeric representation of an intent.
+
+        Args:
+            intent: Intent to be encoded.
+            interpreter: NLU Interpreter to be used for encoding.
+
+        Returns:
+            Encoded representation of intent.
+        """
+        intent_as_sub_state = {INTENT: intent}
+
+        return self._extract_state_features(intent_as_sub_state, interpreter)
+
+    def encode_all_labels(
+        self, domain: Domain, interpreter: NaturalLanguageInterpreter
+    ) -> List[Dict[Text, List[Features]]]:
+        """Encodes all relevant labels from the domain using the given interpreter.
+
+        Args:
+            domain: The domain that contains the labels.
+            interpreter: The interpreter used to encode the labels.
+
+        Returns:
+            A list of encoded labels.
+        """
+        return [self._encode_intent(intent, interpreter) for intent in domain.intents]
diff --git a/rasa/core/featurizers/_tracker_featurizers.py b/rasa/core/featurizers/_tracker_featurizers.py
new file mode 100644
index 000000000000..9a0b741a1f3c
--- /dev/null
+++ b/rasa/core/featurizers/_tracker_featurizers.py
@@ -0,0 +1,1216 @@
+# WARNING: This module will be dropped before Rasa Open Source 3.0 is released.
+#          Please don't do any changes in this module and rather adapt TrackerFeaturizers from
+#          the regular `rasa.core.featurizers.tracker_featurizers` module. This module is a workaround
+#          to defer breaking changes due to the architecture revamp in 3.0.
+# flake8: noqa
+from pathlib import Path
+from collections import defaultdict
+from abc import abstractmethod
+import jsonpickle
+import logging
+
+from tqdm import tqdm
+from typing import Tuple, List, Optional, Dict, Text, Union, Any, Iterator, Set
+import numpy as np
+
+from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer
+from rasa.shared.core.domain import State, Domain
+from rasa.shared.core.events import Event, ActionExecuted, UserUttered
+import rasa.shared.core.trackers
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter
+from rasa.shared.core.constants import (
+    USER,
+    ACTION_UNLIKELY_INTENT_NAME,
+    PREVIOUS_ACTION,
+)
+from rasa.shared.nlu.constants import TEXT, INTENT, ENTITIES
+from rasa.utils.tensorflow.constants import LABEL_PAD_ID
+from rasa.shared.exceptions import RasaException
+import rasa.shared.utils.io
+from rasa.shared.nlu.training_data.features import Features
+from rasa.core.exceptions import InvalidTrackerFeaturizerUsageError
+from rasa.shared.nlu.constants import ACTION_NAME
+
+FEATURIZER_FILE = "featurizer.json"
+
+logger = logging.getLogger(__name__)
+
+
+class InvalidStory(RasaException):
+    """Exception that can be raised if story cannot be featurized."""
+
+    def __init__(self, message: Text) -> None:
+        self.message = message
+        super(InvalidStory, self).__init__()
+
+    def __str__(self) -> Text:
+        return self.message
+
+
+class TrackerFeaturizer:
+    """Base class for actual tracker featurizers."""
+
+    def __init__(
+        self, state_featurizer: Optional[SingleStateFeaturizer] = None
+    ) -> None:
+        """Initializes the tracker featurizer.
+
+        Args:
+            state_featurizer: The state featurizer used to encode tracker states.
+        """
+        self.state_featurizer = state_featurizer
+
+    @staticmethod
+    def _create_states(
+        tracker: DialogueStateTracker,
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_rule_only_turns: bool = False,
+        rule_only_data: Optional[Dict[Text, Any]] = None,
+    ) -> List[State]:
+        """Creates states for the given tracker.
+
+        Args:
+            tracker: The tracker to transform to states.
+            domain: The domain of the tracker.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_rule_only_turns: If `True` ignore dialogue turns that are present
+                only in rules.
+            rule_only_data: Slots and loops,
+                which only occur in rules but not in stories.
+
+        Returns:
+            Trackers as states.
+        """
+        return tracker.past_states(
+            domain,
+            omit_unset_slots=omit_unset_slots,
+            ignore_rule_only_turns=ignore_rule_only_turns,
+            rule_only_data=rule_only_data,
+        )
+
+    def _featurize_states(
+        self,
+        trackers_as_states: List[List[State]],
+        interpreter: NaturalLanguageInterpreter,
+    ) -> List[List[Dict[Text, List[Features]]]]:
+        """Featurizes state histories with `state_featurizer`.
+
+        Args:
+            trackers_as_states: Lists of states produced by a `DialogueStateTracker`
+                instance.
+            interpreter: An interpreter for the `state_featurizer` to use.
+
+        Returns:
+            Featurized tracker states.
+        """
+        return [
+            [
+                self.state_featurizer.encode_state(state, interpreter)
+                for state in tracker_states
+            ]
+            for tracker_states in trackers_as_states
+        ]
+
+    @staticmethod
+    def _convert_labels_to_ids(
+        trackers_as_actions: List[List[Text]], domain: Domain
+    ) -> np.ndarray:
+        """Converts actions to label ids for each tracker.
+
+        Args:
+            trackers_as_actions: A list of tracker labels.
+
+        Returns:
+            Label IDs for each tracker
+        """
+        # store labels in numpy arrays so that it corresponds to np arrays of input
+        # features
+        return np.array(
+            [
+                np.array(
+                    [domain.index_for_action(action) for action in tracker_actions]
+                )
+                for tracker_actions in trackers_as_actions
+            ]
+        )
+
+    def _create_entity_tags(
+        self,
+        trackers_as_entities: List[List[Dict[Text, Any]]],
+        interpreter: NaturalLanguageInterpreter,
+        bilou_tagging: bool = False,
+    ) -> List[List[Dict[Text, List[Features]]]]:
+        """Featurizes extracted entities with `state_featurizer`.
+
+        Args:
+            trackers_as_entities: Extracted entities from trackers.
+            interpreter: An interpreter for the `state_featurizer` to use.
+            bilou_tagging: When `True` use the BILOU tagging scheme.
+
+        Returns:
+            Trackers as entity features.
+        """
+        return [
+            [
+                self.state_featurizer.encode_entities(
+                    entity_data, interpreter, bilou_tagging
+                )
+                for entity_data in trackers_entities
+            ]
+            for trackers_entities in trackers_as_entities
+        ]
+
+    @staticmethod
+    def _entity_data(event: UserUttered) -> Dict[Text, Any]:
+        """Extracts entities from event if not using intents.
+
+        Args:
+            event: The event from which to extract entities.
+
+        Returns:
+            Event text and entities if no intent is present.
+        """
+        # train stories support both text and intent,
+        # but if intent is present, the text is ignored
+        if event.text and not event.intent_name:
+            return {TEXT: event.text, ENTITIES: event.entities}
+
+        # input is not textual, so add empty dict
+        return {}
+
+    @staticmethod
+    def _remove_user_text_if_intent(trackers_as_states: List[List[State]]) -> None:
+        """Deletes user text from state dictionaries if intent is present.
+
+        Only featurizing either the intent or user text is currently supported. When
+        both are present in a state, the user text is removed so that only the intent
+        is featurized.
+
+        `trackers_as_states` is modified in place.
+
+        Args:
+            trackers_as_states: States produced by a `DialogueStateTracker` instance.
+        """
+        for states in trackers_as_states:
+            for state in states:
+                # remove text features to only use intent
+                if state.get(USER, {}).get(INTENT) and state.get(USER, {}).get(TEXT):
+                    del state[USER][TEXT]
+
+    def training_states_actions_and_entities(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]:
+        """Transforms trackers to states, actions, and entity data.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                 from training states.
+
+        Returns:
+            Trackers as states, actions, and entity data.
+        """
+        rasa.shared.utils.io.raise_deprecation_warning(
+            "'training_states_actions_and_entities' is being deprecated in favor of "
+            "'training_states_labels_and_entities'."
+        )
+        return self.training_states_labels_and_entities(
+            trackers,
+            domain,
+            omit_unset_slots=omit_unset_slots,
+            ignore_action_unlikely_intent=ignore_action_unlikely_intent,
+        )
+
+    def training_states_and_actions(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[List[List[State]], List[List[Text]]]:
+        """Transforms trackers to states and actions.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            Trackers as states and actions.
+        """
+        rasa.shared.utils.io.raise_deprecation_warning(
+            "'training_states_and_actions' is being deprecated in favor of "
+            "'training_states_and_labels'."
+        )
+
+        return self.training_states_and_labels(
+            trackers,
+            domain,
+            omit_unset_slots=omit_unset_slots,
+            ignore_action_unlikely_intent=ignore_action_unlikely_intent,
+        )
+
+    def training_states_and_labels(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[List[List[State]], List[List[Text]]]:
+        """Transforms trackers to states and labels.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            Trackers as states and labels.
+        """
+        (
+            trackers_as_states,
+            trackers_as_labels,
+            _,
+        ) = self.training_states_labels_and_entities(
+            trackers,
+            domain,
+            omit_unset_slots=omit_unset_slots,
+            ignore_action_unlikely_intent=ignore_action_unlikely_intent,
+        )
+        return trackers_as_states, trackers_as_labels
+
+    @abstractmethod
+    def training_states_labels_and_entities(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]:
+        """Transforms trackers to states, labels, and entity data.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            Trackers as states, labels, and entity data.
+        """
+        raise NotImplementedError(
+            f"`{self.__class__.__name__}` should implement how to "
+            f"encode trackers as feature vectors"
+        )
+
+    def prepare_for_featurization(
+        self,
+        domain: Domain,
+        interpreter: NaturalLanguageInterpreter,
+        bilou_tagging: bool = False,
+    ) -> None:
+        """Ensures that the featurizer is ready to be called during training.
+
+        State featurizer needs to build its vocabulary from the domain
+        for it to be ready to be used during training.
+
+        Args:
+            domain: Domain of the assistant.
+            interpreter: NLU Interpreter for featurizing states.
+            bilou_tagging: Whether to consider bilou tagging.
+        """
+        if self.state_featurizer is None:
+            raise InvalidTrackerFeaturizerUsageError(
+                f"Instance variable 'state_featurizer' is not set. "
+                f"During initialization set 'state_featurizer' to an instance of "
+                f"'{SingleStateFeaturizer.__class__.__name__}' class "
+                f"to get numerical features for trackers."
+            )
+        self.state_featurizer.prepare_for_training(domain, interpreter, bilou_tagging)
+
+    def featurize_trackers(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        interpreter: NaturalLanguageInterpreter,
+        bilou_tagging: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[
+        List[List[Dict[Text, List[Features]]]],
+        np.ndarray,
+        List[List[Dict[Text, List[Features]]]],
+    ]:
+        """Featurizes the training trackers.
+
+        Args:
+            trackers: list of training trackers
+            domain: the domain
+            interpreter: the interpreter
+            bilou_tagging: indicates whether BILOU tagging should be used or not
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training state features.
+
+        Returns:
+            - a dictionary of state types (INTENT, TEXT, ACTION_NAME, ACTION_TEXT,
+              ENTITIES, SLOTS, ACTIVE_LOOP) to a list of features for all dialogue
+              turns in all training trackers
+            - the label ids (e.g. action ids) for every dialogue turn in all training
+              trackers
+            - A dictionary of entity type (ENTITY_TAGS) to a list of features
+              containing entity tag ids for text user inputs otherwise empty dict
+              for all dialogue turns in all training trackers
+        """
+        self.prepare_for_featurization(domain, interpreter, bilou_tagging)
+        (
+            trackers_as_states,
+            trackers_as_labels,
+            trackers_as_entities,
+        ) = self.training_states_labels_and_entities(
+            trackers,
+            domain,
+            ignore_action_unlikely_intent=ignore_action_unlikely_intent,
+        )
+
+        tracker_state_features = self._featurize_states(trackers_as_states, interpreter)
+
+        if not tracker_state_features and not trackers_as_labels:
+            # If input and output were empty, it means there is
+            # no data on which the policy can be trained
+            # hence return them as it is. They'll be handled
+            # appropriately inside the policy.
+            return tracker_state_features, np.ndarray(trackers_as_labels), []
+
+        label_ids = self._convert_labels_to_ids(trackers_as_labels, domain)
+
+        entity_tags = self._create_entity_tags(
+            trackers_as_entities, interpreter, bilou_tagging
+        )
+
+        return tracker_state_features, label_ids, entity_tags
+
+    def _choose_last_user_input(
+        self, trackers_as_states: List[List[State]], use_text_for_last_user_input: bool
+    ) -> None:
+        for states in trackers_as_states:
+            last_state = states[-1]
+            # only update the state of the real user utterance
+            if not rasa.shared.core.trackers.is_prev_action_listen_in_state(last_state):
+                continue
+
+            if use_text_for_last_user_input:
+                # remove intent features to only use text
+                if last_state.get(USER, {}).get(INTENT):
+                    del last_state[USER][INTENT]
+                # don't add entities if text is used for featurization
+                if last_state.get(USER, {}).get(ENTITIES):
+                    del last_state[USER][ENTITIES]
+            else:
+                # remove text features to only use intent
+                if last_state.get(USER, {}).get(TEXT):
+                    del last_state[USER][TEXT]
+
+        # make sure that all dialogue steps are either intent or text based
+        self._remove_user_text_if_intent(trackers_as_states)
+
+    def prediction_states(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        use_text_for_last_user_input: bool = False,
+        ignore_rule_only_turns: bool = False,
+        rule_only_data: Optional[Dict[Text, Any]] = None,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> List[List[State]]:
+        """Transforms trackers to states for prediction.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            use_text_for_last_user_input: Indicates whether to use text or intent label
+                for featurizing last user input.
+            ignore_rule_only_turns: If True ignore dialogue turns that are present
+                only in rules.
+            rule_only_data: Slots and loops,
+                which only occur in rules but not in stories.
+            ignore_action_unlikely_intent: Whether to remove states containing
+                `action_unlikely_intent` from prediction states.
+
+        Returns:
+            Trackers as states for prediction.
+        """
+        raise NotImplementedError(
+            "Featurizer must have the capacity to create feature vector"
+        )
+
+    def create_state_features(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        interpreter: NaturalLanguageInterpreter,
+        use_text_for_last_user_input: bool = False,
+        ignore_rule_only_turns: bool = False,
+        rule_only_data: Optional[Dict[Text, Any]] = None,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> List[List[Dict[Text, List[Features]]]]:
+        """Creates state features for prediction.
+
+        Args:
+            trackers: A list of state trackers
+            domain: The domain
+            interpreter: The interpreter
+            use_text_for_last_user_input: Indicates whether to use text or intent label
+                for featurizing last user input.
+            ignore_rule_only_turns: If True ignore dialogue turns that are present
+                only in rules.
+            rule_only_data: Slots and loops,
+                which only occur in rules but not in stories.
+            ignore_action_unlikely_intent: Whether to remove any states containing
+                `action_unlikely_intent` from state features.
+
+        Returns:
+            Dictionaries of state type (INTENT, TEXT, ACTION_NAME, ACTION_TEXT,
+            ENTITIES, SLOTS, ACTIVE_LOOP) to a list of features for all dialogue
+            turns in all trackers.
+        """
+        trackers_as_states = self.prediction_states(
+            trackers,
+            domain,
+            use_text_for_last_user_input,
+            ignore_rule_only_turns,
+            rule_only_data,
+            ignore_action_unlikely_intent=ignore_action_unlikely_intent,
+        )
+        return self._featurize_states(trackers_as_states, interpreter)
+
+    def persist(self, path: Union[Text, Path]) -> None:
+        """Persists the tracker featurizer to the given path.
+
+        Args:
+            path: The path to persist the tracker featurizer to.
+        """
+        featurizer_file = Path(path) / FEATURIZER_FILE
+        rasa.shared.utils.io.create_directory_for_file(featurizer_file)
+
+        # entity tags are persisted in TED policy, they are not needed for prediction
+        if self.state_featurizer is not None:
+            self.state_featurizer.entity_tag_specs = None
+
+        # noinspection PyTypeChecker
+        rasa.shared.utils.io.write_text_file(
+            str(jsonpickle.encode(self)), featurizer_file
+        )
+
+    @staticmethod
+    def load(path: Union[Text, Path]) -> Optional["TrackerFeaturizer"]:
+        """Loads the featurizer from file.
+
+        Args:
+            path: The path to load the tracker featurizer from.
+
+        Returns:
+            The loaded tracker featurizer.
+        """
+        featurizer_file = Path(path) / FEATURIZER_FILE
+        if featurizer_file.is_file():
+            return jsonpickle.decode(rasa.shared.utils.io.read_file(featurizer_file))
+
+        logger.error(
+            f"Couldn't load featurizer for policy. "
+            f"File '{featurizer_file}' doesn't exist."
+        )
+        return None
+
+    @staticmethod
+    def _remove_action_unlikely_intent_from_states(states: List[State]) -> List[State]:
+        return [
+            state
+            for state in states
+            if not _is_prev_action_unlikely_intent_in_state(state)
+        ]
+
+    @staticmethod
+    def _remove_action_unlikely_intent_from_events(events: List[Event]) -> List[Event]:
+        return [
+            event
+            for event in events
+            if (
+                not isinstance(event, ActionExecuted)
+                or event.action_name != ACTION_UNLIKELY_INTENT_NAME
+            )
+        ]
+
+
+class FullDialogueTrackerFeaturizer(TrackerFeaturizer):
+    """Creates full dialogue training data for time distributed architectures.
+
+    Creates training data that uses each time output for prediction.
+    """
+
+    def training_states_labels_and_entities(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[
+        List[List[State]], List[List[Optional[Text]]], List[List[Dict[Text, Any]]]
+    ]:
+        """Transforms trackers to states, action labels, and entity data.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            Trackers as states, action labels, and entity data.
+        """
+        trackers_as_states = []
+        trackers_as_actions = []
+        trackers_as_entities = []
+
+        logger.debug(
+            "Creating states and action examples from "
+            "collected trackers (by {}({}))..."
+            "".format(type(self).__name__, type(self.state_featurizer).__name__)
+        )
+        pbar = tqdm(
+            trackers,
+            desc="Processed trackers",
+            disable=rasa.shared.utils.io.is_logging_disabled(),
+        )
+        for tracker in pbar:
+            states = self._create_states(
+                tracker, domain, omit_unset_slots=omit_unset_slots
+            )
+            events = tracker.applied_events()
+
+            if ignore_action_unlikely_intent:
+                states = self._remove_action_unlikely_intent_from_states(states)
+                events = self._remove_action_unlikely_intent_from_events(events)
+
+            delete_first_state = False
+            actions = []
+            entities = []
+            entity_data = {}
+            for event in events:
+                if isinstance(event, UserUttered):
+                    entity_data = self._entity_data(event)
+
+                if not isinstance(event, ActionExecuted):
+                    continue
+
+                if not event.unpredictable:
+                    # only actions which can be
+                    # predicted at a stories start
+                    actions.append(event.action_name or event.action_text)
+                    entities.append(entity_data)
+                else:
+                    # unpredictable actions can be
+                    # only the first in the story
+                    if delete_first_state:
+                        raise InvalidStory(
+                            f"Found two unpredictable actions in one story "
+                            f"'{tracker.sender_id}'. Check your story files."
+                        )
+                    delete_first_state = True
+
+                # reset entity_data for the the next turn
+                entity_data = {}
+
+            if delete_first_state:
+                states = states[1:]
+
+            trackers_as_states.append(states[:-1])
+            trackers_as_actions.append(actions)
+            trackers_as_entities.append(entities)
+
+        self._remove_user_text_if_intent(trackers_as_states)
+
+        return trackers_as_states, trackers_as_actions, trackers_as_entities
+
+    def prediction_states(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        use_text_for_last_user_input: bool = False,
+        ignore_rule_only_turns: bool = False,
+        rule_only_data: Optional[Dict[Text, Any]] = None,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> List[List[State]]:
+        """Transforms trackers to states for prediction.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            use_text_for_last_user_input: Indicates whether to use text or intent label
+                for featurizing last user input.
+            ignore_rule_only_turns: If True ignore dialogue turns that are present
+                only in rules.
+            rule_only_data: Slots and loops,
+                which only occur in rules but not in stories.
+            ignore_action_unlikely_intent: Whether to remove any states containing
+                `action_unlikely_intent` from prediction states.
+
+        Returns:
+            Trackers as states for prediction.
+        """
+        trackers_as_states = [
+            self._create_states(
+                tracker,
+                domain,
+                ignore_rule_only_turns=ignore_rule_only_turns,
+                rule_only_data=rule_only_data,
+            )
+            for tracker in trackers
+        ]
+
+        if ignore_action_unlikely_intent:
+            trackers_as_states = [
+                self._remove_action_unlikely_intent_from_states(states)
+                for states in trackers_as_states
+            ]
+
+        self._choose_last_user_input(trackers_as_states, use_text_for_last_user_input)
+
+        return trackers_as_states
+
+
+class MaxHistoryTrackerFeaturizer(TrackerFeaturizer):
+    """Truncates the tracker history into `max_history` long sequences.
+
+    Creates training data from trackers where actions are the output prediction
+    labels. Tracker state sequences which represent policy input are truncated
+    to not excede `max_history` states.
+    """
+
+    LABEL_NAME = "action"
+
+    def __init__(
+        self,
+        state_featurizer: Optional[SingleStateFeaturizer] = None,
+        max_history: Optional[int] = None,
+        remove_duplicates: bool = True,
+    ) -> None:
+        """Initializes the tracker featurizer.
+
+        Args:
+            state_featurizer: The state featurizer used to encode the states.
+            max_history: The maximum length of an extracted state sequence.
+            remove_duplicates: Keep only unique training state sequence/label pairs.
+        """
+        super().__init__(state_featurizer)
+        self.max_history = max_history
+        self.remove_duplicates = remove_duplicates
+
+    @staticmethod
+    def slice_state_history(
+        states: List[State], slice_length: Optional[int]
+    ) -> List[State]:
+        """Slices states from the trackers history.
+
+        Args:
+            states: The states
+            slice_length: The slice length
+
+        Returns:
+            The sliced states.
+        """
+        if not slice_length:
+            return states
+
+        return states[-slice_length:]
+
+    @staticmethod
+    def _hash_example(states: List[State], labels: Optional[List[Text]] = None) -> int:
+        """Hashes states (and optionally label).
+
+        Produces a hash of the tracker state sequence (and optionally the labels).
+        If `labels` is `None`, labels don't get hashed.
+
+        Args:
+            states: The tracker state sequence to hash.
+            labels: Label strings associated with this state sequence.
+
+        Returns:
+            The hash of the states and (optionally) the label.
+        """
+        frozen_states = tuple(
+            s if s is None else DialogueStateTracker.freeze_current_state(s)
+            for s in states
+        )
+        if labels is not None:
+            frozen_labels = tuple(labels)
+            return hash((frozen_states, frozen_labels))
+        else:
+            return hash(frozen_states)
+
+    def training_states_labels_and_entities(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]:
+        """Transforms trackers to states, action labels, and entity data.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            Trackers as states, labels, and entity data.
+        """
+        example_states = []
+        example_labels = []
+        example_entities = []
+
+        # Store of example hashes for removing duplicate training examples.
+        hashed_examples = set()
+
+        logger.debug(
+            f"Creating states and {self.LABEL_NAME} label examples from "
+            f"collected trackers "
+            f"(by {type(self).__name__}({type(self.state_featurizer).__name__}))..."
+        )
+        pbar = tqdm(
+            trackers,
+            desc="Processed trackers",
+            disable=rasa.shared.utils.io.is_logging_disabled(),
+        )
+        for tracker in pbar:
+
+            for states, label, entities in self._extract_examples(
+                tracker,
+                domain,
+                omit_unset_slots=omit_unset_slots,
+                ignore_action_unlikely_intent=ignore_action_unlikely_intent,
+            ):
+
+                if self.remove_duplicates:
+                    hashed = self._hash_example(states, label)
+                    if hashed in hashed_examples:
+                        continue
+                    hashed_examples.add(hashed)
+
+                example_states.append(states)
+                example_labels.append(label)
+                example_entities.append(entities)
+
+                pbar.set_postfix({f"# {self.LABEL_NAME}": f"{len(example_labels):d}"})
+
+        self._remove_user_text_if_intent(example_states)
+
+        logger.debug(f"Created {len(example_states)} {self.LABEL_NAME} examples.")
+
+        return example_states, example_labels, example_entities
+
+    def _extract_examples(
+        self,
+        tracker: DialogueStateTracker,
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Iterator[Tuple[List[State], List[Text], List[Dict[Text, Any]]]]:
+        """Creates an iterator over training examples from a tracker.
+
+        Args:
+            trackers: The tracker from which to extract training examples.
+            domain: The domain of the training data.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            An iterator over example states, labels, and entity data.
+        """
+        tracker_states = self._create_states(
+            tracker, domain, omit_unset_slots=omit_unset_slots
+        )
+        events = tracker.applied_events()
+
+        if ignore_action_unlikely_intent:
+            tracker_states = self._remove_action_unlikely_intent_from_states(
+                tracker_states
+            )
+            events = self._remove_action_unlikely_intent_from_events(events)
+
+        label_index = 0
+        entity_data = {}
+        for event in events:
+            if isinstance(event, UserUttered):
+                entity_data = self._entity_data(event)
+
+            elif isinstance(event, ActionExecuted):
+
+                label_index += 1
+
+                # use only actions which can be predicted at a stories start
+                if event.unpredictable:
+                    continue
+
+                sliced_states = self.slice_state_history(
+                    tracker_states[:label_index], self.max_history
+                )
+                label = [event.action_name or event.action_text]
+                entities = [entity_data]
+
+                yield sliced_states, label, entities
+
+                # reset entity_data for the the next turn
+                entity_data = {}
+
+    def prediction_states(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        use_text_for_last_user_input: bool = False,
+        ignore_rule_only_turns: bool = False,
+        rule_only_data: Optional[Dict[Text, Any]] = None,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> List[List[State]]:
+        """Transforms trackers to states for prediction.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            use_text_for_last_user_input: Indicates whether to use text or intent label
+                for featurizing last user input.
+            ignore_rule_only_turns: If True ignore dialogue turns that are present
+                only in rules.
+            rule_only_data: Slots and loops,
+                which only occur in rules but not in stories.
+            ignore_action_unlikely_intent: Whether to remove any states containing
+                `action_unlikely_intent` from prediction states.
+
+        Returns:
+            Trackers as states for prediction.
+        """
+        trackers_as_states = [
+            self._create_states(
+                tracker,
+                domain,
+                ignore_rule_only_turns=ignore_rule_only_turns,
+                rule_only_data=rule_only_data,
+            )
+            for tracker in trackers
+        ]
+
+        # Remove `action_unlikely_intent` from `trackers_as_states`.
+        # This must be done before state history slicing to ensure the
+        # max history of the sliced states matches training time.
+        if ignore_action_unlikely_intent:
+            trackers_as_states = [
+                self._remove_action_unlikely_intent_from_states(states)
+                for states in trackers_as_states
+            ]
+
+        trackers_as_states = [
+            self.slice_state_history(states, self.max_history)
+            for states in trackers_as_states
+        ]
+        self._choose_last_user_input(trackers_as_states, use_text_for_last_user_input)
+
+        return trackers_as_states
+
+
+class IntentMaxHistoryTrackerFeaturizer(MaxHistoryTrackerFeaturizer):
+    """Truncates the tracker history into `max_history` long sequences.
+
+    Creates training data from trackers where intents are the output prediction
+    labels. Tracker state sequences which represent policy input are truncated
+    to not excede `max_history` states.
+    """
+
+    LABEL_NAME = "intent"
+
+    @classmethod
+    def _convert_labels_to_ids(
+        cls, trackers_as_intents: List[List[Text]], domain: Domain
+    ) -> np.ndarray:
+        """Converts a list of labels to a matrix of label ids.
+
+        The number of rows is equal to `len(trackers_as_intents)`. The number of
+        columns is equal to the maximum number of positive labels that any training
+        example is associated with. Rows are padded with `LABEL_PAD_ID` if not all rows
+        have the same number of labels.
+
+        Args:
+            trackers_as_intents: Positive example label ids
+                associated with each training example.
+            domain: The domain of the training data.
+
+        Returns:
+           A matrix of label ids.
+        """
+        # store labels in numpy arrays so that it corresponds to np arrays
+        # of input features
+        label_ids = [
+            [domain.intents.index(intent) for intent in tracker_intents]
+            for tracker_intents in trackers_as_intents
+        ]
+
+        return np.array(cls._pad_label_ids(label_ids))
+
+    @staticmethod
+    def _pad_label_ids(label_ids: List[List[int]]) -> List[List[int]]:
+        """Pads label ids so that all are of the same length.
+
+        Args:
+            label_ids: Label ids of varying lengths
+
+        Returns:
+            Label ids padded to be of uniform length.
+        """
+        # If `label_ids` is an empty list, no padding needs to be added.
+        if not label_ids:
+            return label_ids
+
+        # Add `LABEL_PAD_ID` padding to labels array so that
+        # each example has equal number of labels
+        multiple_labels_count = [len(a) for a in label_ids]
+        max_labels_count = max(multiple_labels_count)
+        num_padding_needed = [max_labels_count - len(a) for a in label_ids]
+
+        padded_label_ids = []
+        for ids, num_pads in zip(label_ids, num_padding_needed):
+            padded_row = list(ids) + [LABEL_PAD_ID] * num_pads
+            padded_label_ids.append(padded_row)
+        return padded_label_ids
+
+    def training_states_labels_and_entities(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Tuple[List[List[State]], List[List[Text]], List[List[Dict[Text, Any]]]]:
+        """Transforms trackers to states, intent labels, and entity data.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            Trackers as states, labels, and entity data.
+        """
+        example_states = []
+        example_entities = []
+
+        # Store of example hashes (of both states and labels) for removing
+        # duplicate training examples.
+        hashed_examples = set()
+        # Mapping of example state hash to set of
+        # positive labels associated with the state.
+        state_hash_to_label_set: defaultdict[int, Set[Text]] = defaultdict(set)
+
+        logger.debug(
+            f"Creating states and {self.LABEL_NAME} label examples from "
+            f"collected trackers "
+            f"(by {type(self).__name__}({type(self.state_featurizer).__name__}))..."
+        )
+        pbar = tqdm(
+            trackers,
+            desc="Processed trackers",
+            disable=rasa.shared.utils.io.is_logging_disabled(),
+        )
+        for tracker in pbar:
+
+            for states, label, entities in self._extract_examples(
+                tracker,
+                domain,
+                omit_unset_slots=omit_unset_slots,
+                ignore_action_unlikely_intent=ignore_action_unlikely_intent,
+            ):
+
+                if self.remove_duplicates:
+                    hashed = self._hash_example(states, label)
+                    if hashed in hashed_examples:
+                        continue
+                    hashed_examples.add(hashed)
+
+                # Store all positive labels associated with a training state.
+                state_hash = self._hash_example(states)
+
+                # Only add unique example states unless `remove_duplicates` is `False`.
+                if (
+                    not self.remove_duplicates
+                    or state_hash not in state_hash_to_label_set
+                ):
+                    example_states.append(states)
+                    example_entities.append(entities)
+
+                state_hash_to_label_set[state_hash].add(label[0])
+
+                pbar.set_postfix({f"# {self.LABEL_NAME}": f"{len(example_states):d}"})
+
+        # Collect positive labels for each state example.
+        example_labels = [
+            list(state_hash_to_label_set[self._hash_example(state)])
+            for state in example_states
+        ]
+
+        self._remove_user_text_if_intent(example_states)
+
+        logger.debug(f"Created {len(example_states)} {self.LABEL_NAME} examples.")
+
+        return example_states, example_labels, example_entities
+
+    def _extract_examples(
+        self,
+        tracker: DialogueStateTracker,
+        domain: Domain,
+        omit_unset_slots: bool = False,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> Iterator[Tuple[List[State], List[Text], List[Dict[Text, Any]]]]:
+        """Creates an iterator over training examples from a tracker.
+
+        Args:
+            tracker: The tracker from which to extract training examples.
+            domain: The domain of the training data.
+            omit_unset_slots: If `True` do not include the initial values of slots.
+            ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
+                from training states.
+
+        Returns:
+            An iterator over example states, labels, and entity data.
+        """
+        tracker_states = self._create_states(
+            tracker, domain, omit_unset_slots=omit_unset_slots
+        )
+        events = tracker.applied_events()
+
+        if ignore_action_unlikely_intent:
+            tracker_states = self._remove_action_unlikely_intent_from_states(
+                tracker_states
+            )
+            events = self._remove_action_unlikely_intent_from_events(events)
+
+        label_index = 0
+        for event in events:
+
+            if isinstance(event, ActionExecuted):
+                label_index += 1
+
+            elif isinstance(event, UserUttered):
+
+                sliced_states = self.slice_state_history(
+                    tracker_states[:label_index], self.max_history
+                )
+                label = [event.intent_name or event.text]
+                entities = [{}]
+
+                yield sliced_states, label, entities
+
+    @staticmethod
+    def _cleanup_last_user_state_with_action_listen(
+        trackers_as_states: List[List[State]],
+    ) -> List[List[State]]:
+        """Removes the last tracker state if the previous action is `action_listen`.
+
+        States with the previous action equal to `action_listen` correspond to states
+        with a new user intent. This information is what `UnexpecTEDIntentPolicy` is
+        trying to predict so it needs to be removed before obtaining a prediction.
+
+        Args:
+            trackers_as_states: Trackers converted to states
+
+        Returns:
+            Filtered states with last `action_listen` removed.
+        """
+        for states in trackers_as_states:
+            if not states:
+                continue
+            last_state = states[-1]
+            if rasa.shared.core.trackers.is_prev_action_listen_in_state(last_state):
+                del states[-1]
+
+        return trackers_as_states
+
+    def prediction_states(
+        self,
+        trackers: List[DialogueStateTracker],
+        domain: Domain,
+        use_text_for_last_user_input: bool = False,
+        ignore_rule_only_turns: bool = False,
+        rule_only_data: Optional[Dict[Text, Any]] = None,
+        ignore_action_unlikely_intent: bool = False,
+    ) -> List[List[State]]:
+        """Transforms trackers to states for prediction.
+
+        Args:
+            trackers: The trackers to transform.
+            domain: The domain.
+            use_text_for_last_user_input: Indicates whether to use text or intent label
+                for featurizing last user input.
+            ignore_rule_only_turns: If True ignore dialogue turns that are present
+                only in rules.
+            rule_only_data: Slots and loops,
+                which only occur in rules but not in stories.
+            ignore_action_unlikely_intent: Whether to remove any states containing
+                `action_unlikely_intent` from prediction states.
+
+        Returns:
+            Trackers as states for prediction.
+        """
+        trackers_as_states = [
+            self._create_states(
+                tracker,
+                domain,
+                ignore_rule_only_turns=ignore_rule_only_turns,
+                rule_only_data=rule_only_data,
+            )
+            for tracker in trackers
+        ]
+
+        # Remove `action_unlikely_intent` from `trackers_as_states`.
+        # This must be done before state history slicing to ensure the
+        # max history of the sliced states matches training time.
+        if ignore_action_unlikely_intent:
+            trackers_as_states = [
+                self._remove_action_unlikely_intent_from_states(states)
+                for states in trackers_as_states
+            ]
+
+        self._choose_last_user_input(trackers_as_states, use_text_for_last_user_input)
+
+        # `tracker_as_states` contain a state with intent = last intent
+        # and previous action = action_listen. This state needs to be
+        # removed as it was not present during training as well because
+        # predicting the last intent is what the policies using this
+        # featurizer do. This is specifically done before state history
+        # is sliced so that the number of past states is same as `max_history`.
+        self._cleanup_last_user_state_with_action_listen(trackers_as_states)
+
+        trackers_as_states = [
+            self.slice_state_history(states, self.max_history)
+            for states in trackers_as_states
+        ]
+
+        return trackers_as_states
+
+
+def _is_prev_action_unlikely_intent_in_state(state: State) -> bool:
+    prev_action_name = state.get(PREVIOUS_ACTION, {}).get(ACTION_NAME)
+    return prev_action_name == ACTION_UNLIKELY_INTENT_NAME
diff --git a/rasa/core/featurizers/precomputation.py b/rasa/core/featurizers/precomputation.py
new file mode 100644
index 000000000000..430e852dcd0b
--- /dev/null
+++ b/rasa/core/featurizers/precomputation.py
@@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
+                # ... before changing this flag so we get a complete sub-state
+                event_copy.use_text_for_featurization = None
+                artificial_sub_state = event_copy.as_sub_state()
+                # split it up...
+                sub_states_from_event = []
+                intent = artificial_sub_state.pop(INTENT, None)
+                if intent:
+                    sub_states_from_event.append({INTENT: intent})
+                if len(artificial_sub_state):
+                    sub_states_from_event.append(artificial_sub_state)
+                    # FIXME: seems we can just remove entities here....
+            elif isinstance(event, ActionExecuted):
+                sub_states_from_event = []
+                sub_state = event.as_sub_state()
+                for key in [ACTION_NAME, ACTION_TEXT]:
+                    if key in sub_state:
+                        sub_states_from_event.append({key: sub_state[key]})
+            else:
+                sub_states_from_event = []
+
+            for sub_state in sub_states_from_event:
+                message = Message(data=sub_state)
+                self.add(message)
+
+
+class CoreFeaturizationPreparer(GraphComponent):
+    """Provides training data for core's NLU pipeline as well as lookup table buildup.
+
+    During training as well as during inference, the given data (i.e. story graph or
+    list of messages), will be used to derive a de-duplicated list of all possible
+    "prev_action" and "user" sub-states (with either `TEXT` and possibly `ENTITY`
+    attributes or only `INTENT` attribute) that could be generated from the given data.
+    """
+
+    # NOTE: If we wanted to create the training data for the components of the e2e
+    # featurization pipeline differently from the data that is needed for the lookup
+    # table (currently, the latter is used for training as well), we simply need to
+    # move the functionality of this component to e.g. a `StoryToLookUpDataConverter`,
+    # replace the functionality here with the required training data build up, and
+    # adapt the graph connections so that the `train` and `predict` steps of the e2e
+    # run consecutively and use the respective data.
+
+    @classmethod
+    @abstractmethod
+    def create(
+        cls,
+        config: Dict[Text, Any],
+        model_storage: ModelStorage,
+        resource: Resource,
+        execution_context: ExecutionContext,
+    ) -> GraphComponent:
+        """Creates a new `CoreFeaturizationPreparer`.
+
+        Args:
+            config: unused
+            model_storage: unused
+            resource: unused
+            execution_context: unused
+
+        Returns: An instantiated `CoreFeaturizationPreparer`.
+        """
+        return cls()
+
+    WORKAROUND_TEXT = "hi"
+
+    @staticmethod
+    def prepare_for_training(domain: Domain, story_graph: StoryGraph,) -> TrainingData:
+        """Creates a list of unique (partial) substates from the domain and story graph.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+           domain: the domain
+           story_graph: a story graph
+        Returns:
+           training data for core's NLU pipeline
+        """
+        lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+
+        # collect all action and user (intent-only) substates known from domain
+        lookup_table.derive_messages_from_domain_and_add(domain=domain)
+
+        # collect all substates we see in the given data
+        all_events = (
+            event
+            for step in story_graph.story_steps
+            for event in step.events
+            if not isinstance(event, ActionExecuted)
+            # because all action names and texts are known to the domain
+        )
+        lookup_table.derive_messages_from_events_and_add(events=all_events)
+
+        # make sure that there is at least one user substate with a TEXT to ensure
+        # `CountVectorizer` is trained...
+        lookup_table.add(Message({TEXT: CoreFeaturizationPreparer.WORKAROUND_TEXT}))
+
+        return TrainingData(training_examples=list(lookup_table.values()))
+
+    @staticmethod
+    def prepare_for_inference(tracker: DialogueStateTracker) -> List[Message]:
+        """Creates a list of unique (partial) substates from the events in the tracker.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          tracker: contains the events from which we want to extract the substates
+        Returns:
+          a list of messages that wrap the unique substates
+        """
+        # Note: `tracker.applied_events()` doesn't convert any events to a different
+        # type and hence just iterating over the events is quicker than "applying"
+        # events first and then iterating over results (again).
+        lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+        lookup_table.derive_messages_from_events_and_add(tracker.events)
+        return list(lookup_table.values())
+
+
+class CoreFeaturizationCollector(GraphComponent):
+    """Collects featurised messages for use by a policy."""
+
+    @classmethod
+    @abstractmethod
+    def create(
+        cls,
+        config: Dict[Text, Any],
+        model_storage: ModelStorage,
+        resource: Resource,
+        execution_context: ExecutionContext,
+    ) -> GraphComponent:
+        """Creates a new `CoreFeaturizationCollector`.
+
+        Args:
+            config: unused
+            model_storage: unused
+            resource: unused
+            execution_context: unused
+
+        Returns: An instantiated `CoreFeaturizationCollector`.
+        """
+        return cls()
+
+    @staticmethod
+    def collect(
+        messages: Union[TrainingData, List[Message]]
+    ) -> CoreFeaturizationPrecomputations:
+        """Collects messages created by a MessageContainerForCoreFeaturization."""
+        if isinstance(messages, TrainingData):
+            messages = messages.training_examples
+        # Note that the input messages had been contained in a lookup table in
+        # `StoryToTrainingDataConverter. Hence, we don't need to worry about
+        # collisions here anymore.
+        lookup_table = CoreFeaturizationPrecomputations()
+        for message in messages:
+            lookup_table.add(message)
+        return lookup_table
diff --git a/rasa/core/featurizers/single_state_featurizer.py b/rasa/core/featurizers/single_state_featurizer.py
index 06f54ce7ba27..1272c32e06fc 100644
--- a/rasa/core/featurizers/single_state_featurizer.py
+++ b/rasa/core/featurizers/single_state_featurizer.py
@@ -2,13 +2,12 @@
 import numpy as np
 import scipy.sparse
 from typing import List, Optional, Dict, Text, Set, Any
-from collections import defaultdict
 
+from rasa.core.featurizers.precomputation import CoreFeaturizationPrecomputations
 from rasa.nlu.extractors.extractor import EntityTagSpec
 from rasa.nlu.utils import bilou_utils
 from rasa.nlu.utils.bilou_utils import BILOU_PREFIXES
 from rasa.shared.core.domain import SubState, State, Domain
-from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter, RegexInterpreter
 from rasa.shared.core.constants import PREVIOUS_ACTION, ACTIVE_LOOP, USER, SLOTS
 from rasa.shared.core.trackers import is_prev_action_listen_in_state
 from rasa.shared.nlu.constants import (
@@ -20,15 +19,24 @@
     NO_ENTITY_TAG,
     ENTITY_ATTRIBUTE_TYPE,
     ENTITY_TAGS,
+    TEXT,
 )
 from rasa.shared.nlu.training_data.features import Features
-from rasa.shared.nlu.training_data.message import Message
 from rasa.utils.tensorflow import model_data_utils
+from rasa.core.featurizers import _single_state_featurizer
 
 logger = logging.getLogger(__name__)
 
+# All code outside this module will continue to use the old `tracker_featurizer` module
+# TODO: This is a workaround around until we have all components migrated to
+# `GraphComponent`.
+SingleStateFeaturizer = _single_state_featurizer.SingleStateFeaturizer
+IntentTokenizerSingleStateFeaturizer = (
+    _single_state_featurizer.IntentTokenizerSingleStateFeaturizer
+)
+
 
-class SingleStateFeaturizer:
+class SingleStateFeaturizer2:
     """Base class to transform the dialogue state into an ML format.
 
     Subclasses of SingleStateFeaturizer will decide how a bot will
@@ -40,11 +48,6 @@ class SingleStateFeaturizer:
 
     def __init__(self) -> None:
         """Initialize the single state featurizer."""
-        # rasa core can be trained separately, therefore interpreter during training
-        # will be `RegexInterpreter`. If the model is combined with a rasa nlu model
-        # during prediction the interpreter might be different.
-        # If that is the case, we need to make sure to "reset" the interpreter.
-        self._use_regex_interpreter = False
         self._default_feature_states = {}
         self.action_texts = []
         self.entity_tag_specs = []
@@ -91,23 +94,14 @@ def _create_entity_tag_specs(
         ]
 
     def prepare_for_training(
-        self,
-        domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
-        bilou_tagging: bool = False,
+        self, domain: Domain, bilou_tagging: bool = False,
     ) -> None:
         """Gets necessary information for featurization from domain.
 
         Args:
             domain: An instance of :class:`rasa.shared.core.domain.Domain`.
-            interpreter: The interpreter used to encode the state
             bilou_tagging: indicates whether BILOU tagging should be used or not
         """
-        if isinstance(interpreter, RegexInterpreter):
-            # this method is called during training,
-            # RegexInterpreter means that core was trained separately
-            self._use_regex_interpreter = True
-
         # store feature states for each attribute in order to create binary features
         def convert_to_dict(feature_states: List[Text]) -> Dict[Text, int]:
             return {
@@ -150,7 +144,7 @@ def _state_features_for_attribute(
 
     def _create_features(
         self, sub_state: SubState, attribute: Text, sparse: bool = False
-    ) -> List[Features]:
+    ) -> List["Features"]:
         state_features = self._state_features_for_attribute(sub_state, attribute)
 
         features = np.zeros(len(self._default_feature_states[attribute]), np.float32)
@@ -172,8 +166,8 @@ def _create_features(
 
     @staticmethod
     def _to_sparse_sentence_features(
-        sparse_sequence_features: List[Features],
-    ) -> List[Features]:
+        sparse_sequence_features: List["Features"],
+    ) -> List["Features"]:
         return [
             Features(
                 scipy.sparse.coo_matrix(feature.features.sum(0)),
@@ -184,32 +178,6 @@ def _to_sparse_sentence_features(
             for feature in sparse_sequence_features
         ]
 
-    def _get_features_from_parsed_message(
-        self, parsed_message: Optional[Message], attributes: Set[Text]
-    ) -> Dict[Text, List[Features]]:
-        if parsed_message is None:
-            return {}
-
-        output = defaultdict(list)
-        for attribute in attributes:
-            all_features = parsed_message.get_sparse_features(
-                attribute
-            ) + parsed_message.get_dense_features(attribute)
-
-            for features in all_features:
-                if features is not None:
-                    output[attribute].append(features)
-
-        # if features for INTENT or ACTION_NAME exist,
-        # they are always sparse sequence features;
-        # transform them to sentence sparse features
-        if output.get(INTENT):
-            output[INTENT] = self._to_sparse_sentence_features(output[INTENT])
-        if output.get(ACTION_NAME):
-            output[ACTION_NAME] = self._to_sparse_sentence_features(output[ACTION_NAME])
-
-        return output
-
     @staticmethod
     def _get_name_attribute(attributes: Set[Text]) -> Optional[Text]:
         # there is always either INTENT or ACTION_NAME
@@ -225,29 +193,49 @@ def _get_name_attribute(attributes: Set[Text]) -> Optional[Text]:
     def _extract_state_features(
         self,
         sub_state: SubState,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         sparse: bool = False,
-    ) -> Dict[Text, List[Features]]:
-        # this method is called during both prediction and training,
-        # `self._use_regex_interpreter == True` means that core was trained
-        # separately, therefore substitute interpreter based on some trained
-        # nlu model with default RegexInterpreter to make sure
-        # that prediction and train time features are the same
-        if self._use_regex_interpreter and not isinstance(
-            interpreter, RegexInterpreter
-        ):
-            interpreter = RegexInterpreter()
+    ) -> Dict[Text, List["Features"]]:
 
-        message = Message(data=sub_state)
-        # remove entities from possible attributes
+        # Remove entities from possible attributes
         attributes = set(
             attribute for attribute in sub_state.keys() if attribute != ENTITIES
         )
 
-        parsed_message = interpreter.featurize_message(message)
-        output = self._get_features_from_parsed_message(parsed_message, attributes)
+        # Collect features for all those attributes
+        if precomputations is not None:
+            attributes_to_features = precomputations.lookup_features(
+                sub_state, attributes=attributes
+            )
+            # if features for INTENT or ACTION_NAME exist,
+            # they are always sparse sequence features;
+            # transform them to sentence sparse features
+            if attributes_to_features.get(INTENT):
+                attributes_to_features[INTENT] = self._to_sparse_sentence_features(
+                    attributes_to_features[INTENT]
+                )
+            if attributes_to_features.get(ACTION_NAME):
+                attributes_to_features[ACTION_NAME] = self._to_sparse_sentence_features(
+                    attributes_to_features[ACTION_NAME]
+                )
+        else:
+            attributes_to_features = {}
+
+        # Combine and sort the features:
+        # Per attribute, combine features of same type and level into one Feature,
+        # and (if there are any such features) store the results in a list where
+        # - all the sparse features are listed first and a
+        # - sequence feature is always listed before the sentence feature of the
+        #   same type (sparse/not sparse).
+        output = {
+            attribute: Features.reduce(
+                features_list=features_list, expected_origins=None
+            )
+            for attribute, features_list in attributes_to_features.items()
+            if len(features_list) > 0  # otherwise, following will fail
+        }
 
-        # check that name attributes have features
+        # Check that the name attribute has features
         name_attribute = self._get_name_attribute(attributes)
         if name_attribute and name_attribute not in output:
             # nlu pipeline didn't create features for user or action
@@ -257,17 +245,16 @@ def _extract_state_features(
             output[name_attribute] = self._create_features(
                 sub_state, name_attribute, sparse
             )
-
         return output
 
     def encode_state(
-        self, state: State, interpreter: NaturalLanguageInterpreter
-    ) -> Dict[Text, List[Features]]:
-        """Encodes the given state with the help of the given interpreter.
+        self, state: State, precomputations: Optional[CoreFeaturizationPrecomputations],
+    ) -> Dict[Text, List["Features"]]:
+        """Encode the given state.
 
         Args:
             state: The state to encode
-            interpreter: The interpreter used to encode the state
+            precomputations: Contains precomputed features and attributes.
 
         Returns:
             A dictionary of state_type to list of features.
@@ -276,13 +263,18 @@ def encode_state(
         for state_type, sub_state in state.items():
             if state_type == PREVIOUS_ACTION:
                 state_features.update(
-                    self._extract_state_features(sub_state, interpreter, sparse=True)
+                    self._extract_state_features(
+                        sub_state, precomputations=precomputations, sparse=True,
+                    )
                 )
             # featurize user only if it is "real" user input,
             # i.e. input from a turn after action_listen
             if state_type == USER and is_prev_action_listen_in_state(state):
+
                 state_features.update(
-                    self._extract_state_features(sub_state, interpreter, sparse=True)
+                    self._extract_state_features(
+                        sub_state, precomputations=precomputations, sparse=True,
+                    )
                 )
                 if sub_state.get(ENTITIES):
                     state_features[ENTITIES] = self._create_features(
@@ -299,16 +291,16 @@ def encode_state(
     def encode_entities(
         self,
         entity_data: Dict[Text, Any],
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         bilou_tagging: bool = False,
-    ) -> Dict[Text, List[Features]]:
-        """Encodes the given entity data with the help of the given interpreter.
+    ) -> Dict[Text, List["Features"]]:
+        """Encode the given entity data.
 
         Produce numeric entity tags for tokens.
 
         Args:
             entity_data: The dict containing the text and entity labels and locations
-            interpreter: The interpreter used to encode the state
+            precomputations: Contains precomputed features and attributes.
             bilou_tagging: indicates whether BILOU tagging should be used or not
 
         Returns:
@@ -326,7 +318,7 @@ def encode_entities(
             # we cannot build a classifier with fewer than 2 classes
             return {}
 
-        message = interpreter.featurize_message(Message(entity_data))
+        message = precomputations.lookup_message(user_text=entity_data[TEXT])
 
         if not message:
             return {}
@@ -343,62 +335,70 @@ def encode_entities(
         }
 
     def _encode_action(
-        self, action: Text, interpreter: NaturalLanguageInterpreter
-    ) -> Dict[Text, List[Features]]:
+        self, action: Text, precomputations: Optional[CoreFeaturizationPrecomputations],
+    ) -> Dict[Text, List["Features"]]:
         if action in self.action_texts:
             action_as_sub_state = {ACTION_TEXT: action}
         else:
             action_as_sub_state = {ACTION_NAME: action}
 
-        return self._extract_state_features(action_as_sub_state, interpreter)
+        return self._extract_state_features(
+            action_as_sub_state, precomputations=precomputations
+        )
 
     def encode_all_labels(
-        self, domain: Domain, interpreter: NaturalLanguageInterpreter
-    ) -> List[Dict[Text, List[Features]]]:
-        """Encodes all labels from the domain using the given interpreter.
+        self,
+        domain: Domain,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
+    ) -> List[Dict[Text, List["Features"]]]:
+        """Encode all action from the domain.
 
         Args:
-            domain: The domain that contains the labels.
-            interpreter: The interpreter used to encode the labels.
+            domain: The domain that contains the actions.
+            precomputations: Contains precomputed features and attributes.
 
         Returns:
-            A list of encoded labels.
+            A list of encoded actions.
         """
         return [
-            self._encode_action(action, interpreter)
+            self._encode_action(action, precomputations)
             for action in domain.action_names_or_texts
         ]
 
 
-class IntentTokenizerSingleStateFeaturizer(SingleStateFeaturizer):
+class IntentTokenizerSingleStateFeaturizer2(SingleStateFeaturizer2):
     """A SingleStateFeaturizer for use with policies that predict intent labels."""
 
     def _encode_intent(
-        self, intent: Text, interpreter: NaturalLanguageInterpreter
+        self, intent: Text, precomputations: Optional[CoreFeaturizationPrecomputations],
     ) -> Dict[Text, List[Features]]:
         """Extracts a numeric representation of an intent.
 
         Args:
             intent: Intent to be encoded.
-            interpreter: NLU Interpreter to be used for encoding.
+            precomputations: Contains precomputed features and attributes.
 
         Returns:
             Encoded representation of intent.
         """
         intent_as_sub_state = {INTENT: intent}
 
-        return self._extract_state_features(intent_as_sub_state, interpreter)
+        return self._extract_state_features(intent_as_sub_state, precomputations)
 
     def encode_all_labels(
-        self, domain: Domain, interpreter: NaturalLanguageInterpreter
+        self,
+        domain: Domain,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
     ) -> List[Dict[Text, List[Features]]]:
         """Encodes all relevant labels from the domain using the given interpreter.
 
         Args:
             domain: The domain that contains the labels.
-            interpreter: The interpreter used to encode the labels.
+            precomputations: Contains precomputed features and attributes.
 
         Returns:
             A list of encoded labels.
         """
-        return [self._encode_intent(intent, interpreter) for intent in domain.intents]
+        return [
+            self._encode_intent(intent, precomputations) for intent in domain.intents
+        ]
diff --git a/rasa/core/featurizers/tracker_featurizers.py b/rasa/core/featurizers/tracker_featurizers.py
index 79bb83fbdf94..758010d1418b 100644
--- a/rasa/core/featurizers/tracker_featurizers.py
+++ b/rasa/core/featurizers/tracker_featurizers.py
@@ -1,3 +1,4 @@
+from __future__ import annotations
 from pathlib import Path
 from collections import defaultdict
 from abc import abstractmethod
@@ -8,34 +9,50 @@
 from typing import Tuple, List, Optional, Dict, Text, Union, Any, Iterator, Set
 import numpy as np
 
+
 from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer
-from rasa.shared.core.domain import State, Domain
-from rasa.shared.core.events import Event, ActionExecuted, UserUttered
+from rasa.core.featurizers.precomputation import CoreFeaturizationPrecomputations
+from rasa.core.exceptions import InvalidTrackerFeaturizerUsageError
 import rasa.shared.core.trackers
+import rasa.shared.utils.io
+from rasa.shared.nlu.constants import TEXT, INTENT, ENTITIES, ACTION_NAME
+from rasa.shared.nlu.training_data.features import Features
 from rasa.shared.core.trackers import DialogueStateTracker
-from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter
+from rasa.shared.core.domain import State, Domain
+from rasa.shared.core.events import Event, ActionExecuted, UserUttered
 from rasa.shared.core.constants import (
     USER,
     ACTION_UNLIKELY_INTENT_NAME,
     PREVIOUS_ACTION,
 )
-from rasa.shared.nlu.constants import TEXT, INTENT, ENTITIES
-from rasa.utils.tensorflow.constants import LABEL_PAD_ID
 from rasa.shared.exceptions import RasaException
-import rasa.shared.utils.io
-from rasa.shared.nlu.training_data.features import Features
-from rasa.core.exceptions import InvalidTrackerFeaturizerUsageError
-from rasa.shared.nlu.constants import ACTION_NAME
+from rasa.utils.tensorflow.constants import LABEL_PAD_ID
+from rasa.core.featurizers import _tracker_featurizers
 
 FEATURIZER_FILE = "featurizer.json"
 
 logger = logging.getLogger(__name__)
 
+# All code outside this module will continue to use the old `tracker_featurizer` module
+# TODO: This is a workaround around until we have all components migrated to
+# `GraphComponent`.
+TrackerFeaturizer = _tracker_featurizers.TrackerFeaturizer
+MaxHistoryTrackerFeaturizer = _tracker_featurizers.MaxHistoryTrackerFeaturizer
+IntentMaxHistoryTrackerFeaturizer = (
+    _tracker_featurizers.IntentMaxHistoryTrackerFeaturizer
+)
+FullDialogueTrackerFeaturizer = _tracker_featurizers.FullDialogueTrackerFeaturizer
+
 
 class InvalidStory(RasaException):
     """Exception that can be raised if story cannot be featurized."""
 
     def __init__(self, message: Text) -> None:
+        """Creates an InvalidStory exception.
+
+        Args:
+          message: a custom message
+        """
         self.message = message
         super(InvalidStory, self).__init__()
 
@@ -43,7 +60,7 @@ def __str__(self) -> Text:
         return self.message
 
 
-class TrackerFeaturizer:
+class TrackerFeaturizer2:
     """Base class for actual tracker featurizers."""
 
     def __init__(
@@ -88,21 +105,21 @@ def _create_states(
     def _featurize_states(
         self,
         trackers_as_states: List[List[State]],
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
     ) -> List[List[Dict[Text, List[Features]]]]:
         """Featurizes state histories with `state_featurizer`.
 
         Args:
             trackers_as_states: Lists of states produced by a `DialogueStateTracker`
                 instance.
-            interpreter: An interpreter for the `state_featurizer` to use.
+            precomputations: Contains precomputed features and attributes.
 
         Returns:
             Featurized tracker states.
         """
         return [
             [
-                self.state_featurizer.encode_state(state, interpreter)
+                self.state_featurizer.encode_state(state, precomputations)
                 for state in tracker_states
             ]
             for tracker_states in trackers_as_states
@@ -134,14 +151,14 @@ def _convert_labels_to_ids(
     def _create_entity_tags(
         self,
         trackers_as_entities: List[List[Dict[Text, Any]]],
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         bilou_tagging: bool = False,
     ) -> List[List[Dict[Text, List[Features]]]]:
         """Featurizes extracted entities with `state_featurizer`.
 
         Args:
             trackers_as_entities: Extracted entities from trackers.
-            interpreter: An interpreter for the `state_featurizer` to use.
+            precomputations: Contains precomputed features and attributes.
             bilou_tagging: When `True` use the BILOU tagging scheme.
 
         Returns:
@@ -150,7 +167,7 @@ def _create_entity_tags(
         return [
             [
                 self.state_featurizer.encode_entities(
-                    entity_data, interpreter, bilou_tagging
+                    entity_data, bilou_tagging, precomputations,
                 )
                 for entity_data in trackers_entities
             ]
@@ -312,10 +329,7 @@ def training_states_labels_and_entities(
         )
 
     def prepare_for_featurization(
-        self,
-        domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
-        bilou_tagging: bool = False,
+        self, domain: Domain, bilou_tagging: bool = False,
     ) -> None:
         """Ensures that the featurizer is ready to be called during training.
 
@@ -324,7 +338,6 @@ def prepare_for_featurization(
 
         Args:
             domain: Domain of the assistant.
-            interpreter: NLU Interpreter for featurizing states.
             bilou_tagging: Whether to consider bilou tagging.
         """
         if self.state_featurizer is None:
@@ -334,13 +347,13 @@ def prepare_for_featurization(
                 f"'{SingleStateFeaturizer.__class__.__name__}' class "
                 f"to get numerical features for trackers."
             )
-        self.state_featurizer.prepare_for_training(domain, interpreter, bilou_tagging)
+        self.state_featurizer.prepare_for_training(domain, bilou_tagging)
 
     def featurize_trackers(
         self,
         trackers: List[DialogueStateTracker],
         domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         bilou_tagging: bool = False,
         ignore_action_unlikely_intent: bool = False,
     ) -> Tuple[
@@ -353,7 +366,7 @@ def featurize_trackers(
         Args:
             trackers: list of training trackers
             domain: the domain
-            interpreter: the interpreter
+            precomputations: Contains precomputed features and attributes.
             bilou_tagging: indicates whether BILOU tagging should be used or not
             ignore_action_unlikely_intent: Whether to remove `action_unlikely_intent`
                 from training state features.
@@ -368,7 +381,7 @@ def featurize_trackers(
               containing entity tag ids for text user inputs otherwise empty dict
               for all dialogue turns in all training trackers
         """
-        self.prepare_for_featurization(domain, interpreter, bilou_tagging)
+        self.prepare_for_featurization(domain, bilou_tagging)
         (
             trackers_as_states,
             trackers_as_labels,
@@ -379,7 +392,9 @@ def featurize_trackers(
             ignore_action_unlikely_intent=ignore_action_unlikely_intent,
         )
 
-        tracker_state_features = self._featurize_states(trackers_as_states, interpreter)
+        tracker_state_features = self._featurize_states(
+            trackers_as_states, precomputations
+        )
 
         if not tracker_state_features and not trackers_as_labels:
             # If input and output were empty, it means there is
@@ -391,7 +406,7 @@ def featurize_trackers(
         label_ids = self._convert_labels_to_ids(trackers_as_labels, domain)
 
         entity_tags = self._create_entity_tags(
-            trackers_as_entities, interpreter, bilou_tagging
+            trackers_as_entities, precomputations, bilou_tagging
         )
 
         return tracker_state_features, label_ids, entity_tags
@@ -454,7 +469,7 @@ def create_state_features(
         self,
         trackers: List[DialogueStateTracker],
         domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         use_text_for_last_user_input: bool = False,
         ignore_rule_only_turns: bool = False,
         rule_only_data: Optional[Dict[Text, Any]] = None,
@@ -465,7 +480,7 @@ def create_state_features(
         Args:
             trackers: A list of state trackers
             domain: The domain
-            interpreter: The interpreter
+            precomputations: Contains precomputed features and attributes.
             use_text_for_last_user_input: Indicates whether to use text or intent label
                 for featurizing last user input.
             ignore_rule_only_turns: If True ignore dialogue turns that are present
@@ -488,7 +503,7 @@ def create_state_features(
             rule_only_data,
             ignore_action_unlikely_intent=ignore_action_unlikely_intent,
         )
-        return self._featurize_states(trackers_as_states, interpreter)
+        return self._featurize_states(trackers_as_states, precomputations)
 
     def persist(self, path: Union[Text, Path]) -> None:
         """Persists the tracker featurizer to the given path.
@@ -509,7 +524,7 @@ def persist(self, path: Union[Text, Path]) -> None:
         )
 
     @staticmethod
-    def load(path: Union[Text, Path]) -> Optional["TrackerFeaturizer"]:
+    def load(path: Union[Text, Path]) -> Optional[TrackerFeaturizer2]:
         """Loads the featurizer from file.
 
         Args:
@@ -548,7 +563,7 @@ def _remove_action_unlikely_intent_from_events(events: List[Event]) -> List[Even
         ]
 
 
-class FullDialogueTrackerFeaturizer(TrackerFeaturizer):
+class FullDialogueTrackerFeaturizer2(TrackerFeaturizer2):
     """Creates full dialogue training data for time distributed architectures.
 
     Creates training data that uses each time output for prediction.
@@ -686,7 +701,7 @@ def prediction_states(
         return trackers_as_states
 
 
-class MaxHistoryTrackerFeaturizer(TrackerFeaturizer):
+class MaxHistoryTrackerFeaturizer2(TrackerFeaturizer2):
     """Truncates the tracker history into `max_history` long sequences.
 
     Creates training data from trackers where actions are the output prediction
@@ -927,7 +942,7 @@ def prediction_states(
         return trackers_as_states
 
 
-class IntentMaxHistoryTrackerFeaturizer(MaxHistoryTrackerFeaturizer):
+class IntentMaxHistoryTrackerFeaturizer2(MaxHistoryTrackerFeaturizer2):
     """Truncates the tracker history into `max_history` long sequences.
 
     Creates training data from trackers where intents are the output prediction
diff --git a/rasa/core/policies/_policy.py b/rasa/core/policies/_policy.py
index 0b6da2bdd663..8ed1e1f46b1e 100644
--- a/rasa/core/policies/_policy.py
+++ b/rasa/core/policies/_policy.py
@@ -21,8 +21,8 @@
 
 import numpy as np
 from rasa.core.constants import DEFAULT_POLICY_PRIORITY
-from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer
-from rasa.core.featurizers.tracker_featurizers import (
+from rasa.core.featurizers._single_state_featurizer import SingleStateFeaturizer
+from rasa.core.featurizers._tracker_featurizers import (
     MaxHistoryTrackerFeaturizer,
     TrackerFeaturizer,
     FEATURIZER_FILE,
diff --git a/rasa/core/policies/policy.py b/rasa/core/policies/policy.py
index 3a8b736683a6..2d42a34a15f8 100644
--- a/rasa/core/policies/policy.py
+++ b/rasa/core/policies/policy.py
@@ -17,35 +17,40 @@
     Tuple,
     TYPE_CHECKING,
 )
+
 import numpy as np
+
 from rasa.engine.graph import GraphComponent, ExecutionContext
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
-
-from rasa.shared.core.events import Event
-
-import rasa.shared.utils.common
-import rasa.utils.common
-import rasa.shared.utils.io
-from rasa.shared.core.domain import Domain, State
-from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer
+from rasa.core.featurizers.precomputation import CoreFeaturizationPrecomputations
+from rasa.core.featurizers.tracker_featurizers import (
+    TrackerFeaturizer2 as TrackerFeaturizer,
+)
 from rasa.core.featurizers.tracker_featurizers import (
-    TrackerFeaturizer,
-    MaxHistoryTrackerFeaturizer,
-    FEATURIZER_FILE,
+    MaxHistoryTrackerFeaturizer2 as MaxHistoryTrackerFeaturizer,
 )
+from rasa.core.featurizers.single_state_featurizer import (
+    SingleStateFeaturizer2 as SingleStateFeaturizer,
+)
+from rasa.core.featurizers.tracker_featurizers import FEATURIZER_FILE
+from rasa.core.constants import DEFAULT_POLICY_PRIORITY, POLICY_PRIORITY
+import rasa.utils.common
+import rasa.shared.utils.io
 from rasa.shared.exceptions import RasaException, FileIOException
-from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter
+from rasa.shared.nlu.constants import ENTITIES, INTENT, TEXT, ACTION_TEXT, ACTION_NAME
+from rasa.shared.core.domain import Domain, State
 from rasa.shared.core.trackers import DialogueStateTracker
 from rasa.shared.core.generator import TrackerWithCachedStates
-from rasa.core.constants import DEFAULT_POLICY_PRIORITY, POLICY_PRIORITY
+from rasa.shared.core.events import Event
 from rasa.shared.core.constants import (
     USER,
     SLOTS,
     PREVIOUS_ACTION,
     ACTIVE_LOOP,
 )
-from rasa.shared.nlu.constants import ENTITIES, INTENT, TEXT, ACTION_TEXT, ACTION_NAME
+import rasa.shared.utils.common
+
 
 # All code outside this module will continue to use the old `Policy` interface
 from rasa.core.policies._policy import Policy
@@ -214,7 +219,7 @@ def _featurize_for_training(
         self,
         training_trackers: List[DialogueStateTracker],
         domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         bilou_tagging: bool = False,
         **kwargs: Any,
     ) -> Tuple[
@@ -231,7 +236,7 @@ def _featurize_for_training(
             training_trackers:
                 the list of the :class:`rasa.core.trackers.DialogueStateTracker`
             domain: the :class:`rasa.shared.core.domain.Domain`
-            interpreter: the :class:`rasa.core.interpreter.NaturalLanguageInterpreter`
+            precomputations: Contains precomputed features and attributes.
             bilou_tagging: indicates whether BILOU tagging should be used or not
 
         Returns:
@@ -247,8 +252,8 @@ def _featurize_for_training(
         state_features, label_ids, entity_tags = self.featurizer.featurize_trackers(
             training_trackers,
             domain,
-            interpreter,
-            bilou_tagging,
+            precomputations=precomputations,
+            bilou_tagging=bilou_tagging,
             ignore_action_unlikely_intent=self.supported_data()
             == SupportedData.ML_DATA,
         )
@@ -296,7 +301,7 @@ def _featurize_for_prediction(
         self,
         tracker: DialogueStateTracker,
         domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         use_text_for_last_user_input: bool = False,
     ) -> List[List[Dict[Text, List["Features"]]]]:
         """Transforms training tracker into a vector representation.
@@ -307,7 +312,7 @@ def _featurize_for_prediction(
         Args:
             tracker: The tracker to be featurized.
             domain: The Domain.
-            interpreter: The NLU interpreter.
+            precomputations: Contains precomputed features and attributes.
             use_text_for_last_user_input: Indicates whether to use text or intent label
                 for featurizing last user input.
 
@@ -321,7 +326,7 @@ def _featurize_for_prediction(
         return self.featurizer.create_state_features(
             [tracker],
             domain,
-            interpreter,
+            precomputations=precomputations,
             use_text_for_last_user_input=use_text_for_last_user_input,
             ignore_rule_only_turns=self.supported_data() == SupportedData.ML_DATA,
             rule_only_data=self._rule_only_data,
diff --git a/rasa/core/policies/ted_policy.py b/rasa/core/policies/ted_policy.py
index c9b61c157161..d9f8108f31e6 100644
--- a/rasa/core/policies/ted_policy.py
+++ b/rasa/core/policies/ted_policy.py
@@ -3,29 +3,32 @@
 import shutil
 from pathlib import Path
 from collections import defaultdict
+from typing import Any, List, Optional, Text, Dict, Tuple, Union, Type
 
 import numpy as np
-
-import rasa.shared.utils.io
-import rasa.utils.train_utils
 import tensorflow as tf
 import tensorflow_addons as tfa
-from typing import Any, List, Optional, Text, Dict, Tuple, Union, Type
 
-import rasa.utils.io as io_utils
-import rasa.core.actions.action
 from rasa.engine.graph import ExecutionContext
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
 from rasa.nlu.constants import TOKENS_NAMES
 from rasa.nlu.extractors.extractor import EntityExtractor, EntityTagSpec
-from rasa.shared.core.domain import Domain
+import rasa.core.actions.action
+from rasa.core.featurizers.precomputation import CoreFeaturizationPrecomputations
 from rasa.core.featurizers.tracker_featurizers import (
-    TrackerFeaturizer,
-    MaxHistoryTrackerFeaturizer,
+    TrackerFeaturizer2 as TrackerFeaturizer,
 )
-from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer
+from rasa.core.featurizers.tracker_featurizers import (
+    MaxHistoryTrackerFeaturizer2 as MaxHistoryTrackerFeaturizer,
+)
+from rasa.core.featurizers.single_state_featurizer import (
+    SingleStateFeaturizer2 as SingleStateFeaturizer,
+)
+from rasa.core.policies.policy import PolicyPrediction, PolicyGraphComponent
+from rasa.core.constants import DIALOGUE, POLICY_MAX_HISTORY
 from rasa.shared.exceptions import RasaException
+from rasa.shared.constants import DIAGNOSTIC_DATA
 from rasa.shared.nlu.constants import (
     ACTION_TEXT,
     ACTION_NAME,
@@ -39,13 +42,14 @@
     SPLIT_ENTITIES_BY_COMMA,
     SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE,
 )
-from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter, RegexInterpreter
-from rasa.core.policies.policy import PolicyPrediction, PolicyGraphComponent
-from rasa.core.constants import DIALOGUE, POLICY_MAX_HISTORY
-from rasa.shared.constants import DIAGNOSTIC_DATA
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.core.domain import Domain
 from rasa.shared.core.constants import ACTIVE_LOOP, SLOTS, ACTION_LISTEN_NAME
 from rasa.shared.core.trackers import DialogueStateTracker
 from rasa.shared.core.generator import TrackerWithCachedStates
+from rasa.shared.core.events import EntitiesAdded, Event
+from rasa.shared.utils import io as shared_io_utils
+import rasa.utils.io as io_utils
 import rasa.utils.train_utils
 from rasa.utils.tensorflow.models import RasaModel, TransformerRasaModel
 from rasa.utils.tensorflow import rasa_layers
@@ -114,9 +118,6 @@
     BILOU_FLAG,
     EPOCH_OVERRIDE,
 )
-from rasa.shared.core.events import EntitiesAdded, Event
-from rasa.shared.nlu.training_data.message import Message
-from rasa.shared.utils import io as shared_io_utils
 from rasa.core.policies._ted_policy import TEDPolicy
 
 from rasa.shared.nlu.training_data.features import Features
@@ -415,11 +416,13 @@ def _auto_update_configuration(self) -> None:
         )
 
     def _create_label_data(
-        self, domain: Domain, interpreter: NaturalLanguageInterpreter
+        self,
+        domain: Domain,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
     ) -> Tuple[RasaModelData, List[Dict[Text, List[Features]]]]:
         # encode all label_ids with policies' featurizer
         state_featurizer = self.featurizer.state_featurizer
-        encoded_all_labels = state_featurizer.encode_all_labels(domain, interpreter)
+        encoded_all_labels = state_featurizer.encode_all_labels(domain, precomputations)
 
         attribute_data, _ = convert_to_data_format(
             encoded_all_labels, featurizers=self.config[FEATURIZERS]
@@ -586,7 +589,7 @@ def _prepare_for_training(
         self,
         trackers: List[TrackerWithCachedStates],
         domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: CoreFeaturizationPrecomputations,
         **kwargs: Any,
     ) -> Tuple[RasaModelData, np.ndarray]:
         """Prepares data to be fed into the model.
@@ -594,7 +597,7 @@ def _prepare_for_training(
         Args:
             trackers: List of training trackers to be featurized.
             domain: Domain of the assistant.
-            interpreter: NLU interpreter to be used for featurizing states.
+            precomputations: Contains precomputed features and attributes.
             **kwargs: Any other arguments.
 
         Returns:
@@ -605,7 +608,7 @@ def _prepare_for_training(
         tracker_state_features, label_ids, entity_tags = self._featurize_for_training(
             training_trackers,
             domain,
-            interpreter,
+            precomputations=precomputations,
             bilou_tagging=self.config[BILOU_FLAG],
             **kwargs,
         )
@@ -614,7 +617,7 @@ def _prepare_for_training(
             return RasaModelData(), label_ids
 
         self._label_data, encoded_all_labels = self._create_label_data(
-            domain, interpreter
+            domain, precomputations=precomputations
         )
 
         # extract actual training data to feed to model
@@ -686,9 +689,7 @@ def train(
         self,
         training_trackers: List[TrackerWithCachedStates],
         domain: Domain,
-        # TODO: The default is a workaround until the end-to-end featurization is
-        # implemented for the graph.
-        interpreter: NaturalLanguageInterpreter = RegexInterpreter(),
+        precomputations: Optional[CoreFeaturizationPrecomputations] = None,
     ) -> Resource:
         """Trains the policy (see parent class for full docstring)."""
         if not training_trackers:
@@ -702,7 +703,7 @@ def train(
             return self._resource
 
         model_data, label_ids = self._prepare_for_training(
-            training_trackers, domain, interpreter
+            training_trackers, domain, precomputations,
         )
 
         if model_data.is_empty():
@@ -721,11 +722,11 @@ def train(
 
         return self._resource
 
-    def _featurize_tracker_for_e2e(
+    def _featurize_tracker(
         self,
         tracker: DialogueStateTracker,
         domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations] = None,
     ) -> List[List[Dict[Text, List[Features]]]]:
         # construct two examples in the batch to be fed to the model -
         # one by featurizing last user text
@@ -733,7 +734,10 @@ def _featurize_tracker_for_e2e(
         # the first example in the constructed batch either does not contain user input
         # or uses intent or text based on whether TED is e2e only.
         tracker_state_features = self._featurize_for_prediction(
-            tracker, domain, interpreter, use_text_for_last_user_input=self.only_e2e,
+            tracker,
+            domain,
+            precomputations=precomputations,
+            use_text_for_last_user_input=self.only_e2e,
         )
         # the second - text, but only after user utterance and if not only e2e
         if (
@@ -742,7 +746,10 @@ def _featurize_tracker_for_e2e(
             and not self.only_e2e
         ):
             tracker_state_features += self._featurize_for_prediction(
-                tracker, domain, interpreter, use_text_for_last_user_input=True,
+                tracker,
+                domain,
+                precomputations=precomputations,
+                use_text_for_last_user_input=True,
             )
         return tracker_state_features
 
@@ -794,7 +801,7 @@ def predict_action_probabilities(
         domain: Domain,
         # TODO: The default is a workaround until the end-to-end featurization is
         # implemented for the graph.
-        interpreter: NaturalLanguageInterpreter = RegexInterpreter(),
+        precomputations: Optional[CoreFeaturizationPrecomputations] = None,
         **kwargs: Any,
     ) -> PolicyPrediction:
         """Predicts the next action (see parent class for full docstring)."""
@@ -802,8 +809,8 @@ def predict_action_probabilities(
             return self._prediction(self._default_predictions(domain))
 
         # create model data from tracker
-        tracker_state_features = self._featurize_tracker_for_e2e(
-            tracker, domain, interpreter
+        tracker_state_features = self._featurize_tracker(
+            tracker, domain, precomputations
         )
         model_data = self._create_model_data(tracker_state_features)
         outputs: Dict[Text, np.ndarray] = self.model.run_inference(model_data)
@@ -824,7 +831,7 @@ def predict_action_probabilities(
             )
 
         optional_events = self._create_optional_event_for_entities(
-            outputs, is_e2e_prediction, interpreter, tracker
+            outputs, is_e2e_prediction, precomputations, tracker
         )
 
         return self._prediction(
@@ -838,7 +845,7 @@ def _create_optional_event_for_entities(
         self,
         prediction_output: Dict[Text, tf.Tensor],
         is_e2e_prediction: bool,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         tracker: DialogueStateTracker,
     ) -> Optional[List[Event]]:
         if tracker.latest_action_name != ACTION_LISTEN_NAME or not is_e2e_prediction:
@@ -870,7 +877,10 @@ def _create_optional_event_for_entities(
         # entities belong to the last message of the tracker
         # convert the predicted tags to actual entities
         text = tracker.latest_message.text
-        parsed_message = interpreter.featurize_message(Message(data={TEXT: text}))
+        if precomputations is not None:
+            parsed_message = precomputations.lookup_message(user_text=text)
+        else:
+            parsed_message = Message(data={TEXT: text})
         tokens = parsed_message.get(TOKENS_NAMES[TEXT])
         entities = EntityExtractor.convert_predictions_into_entities(
             text,
@@ -899,7 +909,7 @@ def persist(self) -> None:
             model_filename = self._metadata_filename()
             tf_model_file = model_path / f"{model_filename}.tf_model"
 
-            rasa.shared.utils.io.create_directory_for_file(tf_model_file)
+            shared_io_utils.create_directory_for_file(tf_model_file)
 
             self.featurizer.persist(model_path)
 
@@ -934,7 +944,7 @@ def persist_model_utilities(self, model_path: Path) -> None:
             if self._entity_tag_specs
             else []
         )
-        rasa.shared.utils.io.dump_obj_as_json_to_file(
+        shared_io_utils.dump_obj_as_json_to_file(
             model_path / f"{model_filename}.entity_tag_specs.json", entity_tag_specs,
         )
 
@@ -959,7 +969,7 @@ def _load_model_utilities(cls, model_path: Path) -> Dict[Text, Any]:
         priority = io_utils.json_unpickle(
             model_path / f"{cls._metadata_filename()}.priority.pkl"
         )
-        entity_tag_specs = rasa.shared.utils.io.read_json_file(
+        entity_tag_specs = shared_io_utils.read_json_file(
             model_path / f"{cls._metadata_filename()}.entity_tag_specs.json"
         )
         entity_tag_specs = [
@@ -1064,7 +1074,7 @@ def _load_policy_with_model(
         model_storage: ModelStorage,
         resource: Resource,
         execution_context: ExecutionContext,
-        featurizer: [TrackerFeaturizer],
+        featurizer: TrackerFeaturizer,
         model: TED,
         model_utilities: Dict[Text, Any],
     ) -> TEDPolicyGraphComponent:
diff --git a/rasa/shared/nlu/training_data/features.py b/rasa/shared/nlu/training_data/features.py
index 7f1b05ea9c6b..390aac97ad97 100644
--- a/rasa/shared/nlu/training_data/features.py
+++ b/rasa/shared/nlu/training_data/features.py
@@ -1,10 +1,13 @@
-from typing import Union, Text, Optional, List, Any, Tuple
+from __future__ import annotations
+from typing import Iterable, Union, Text, Optional, List, Any, Tuple, Dict, Set
+import itertools
 
 import numpy as np
 import scipy.sparse
 
 import rasa.shared.utils.io
 import rasa.shared.nlu.training_data.util
+from rasa.shared.nlu.constants import FEATURE_TYPE_SEQUENCE, FEATURE_TYPE_SENTENCE
 
 
 class Features:
@@ -35,6 +38,25 @@ def __init__(
                 "features or a scipy sparse matrix for sparse features."
             )
 
+    def __repr__(self) -> Text:
+        return (
+            f"{self.__class__.__name__}("
+            f"features={self.features}, "
+            f"type={self.type}, "
+            f"origin={self.origin}, "
+            f"attribute={self.attribute})"
+        )
+
+    def __str__(self) -> Text:
+        return (
+            f"{self.__class__.__name__}("
+            f"features.shape={self.features.shape}, "
+            f"is_sparse={self.is_sparse()}, "
+            f"type={self.type}, "
+            f"origin={self.origin}, "
+            f"attribute={self.attribute})"
+        )
+
     def is_sparse(self) -> bool:
         """Checks if features are sparse or not.
 
@@ -49,9 +71,9 @@ def is_dense(self) -> bool:
         Returns:
             True, if features are dense, false otherwise.
         """
-        return isinstance(self.features, np.ndarray)
+        return not self.is_sparse()
 
-    def combine_with_features(self, additional_features: Optional["Features"]) -> None:
+    def combine_with_features(self, additional_features: Optional[Features]) -> None:
         """Combine the incoming features with this instance's features.
 
         Args:
@@ -70,7 +92,7 @@ def combine_with_features(self, additional_features: Optional["Features"]) -> No
         else:
             raise ValueError("Cannot combine sparse and dense features.")
 
-    def _combine_dense_features(self, additional_features: "Features") -> None:
+    def _combine_dense_features(self, additional_features: Features) -> None:
         if self.features.ndim != additional_features.features.ndim:
             raise ValueError(
                 f"Cannot combine dense features as sequence dimensions do not "
@@ -81,7 +103,7 @@ def _combine_dense_features(self, additional_features: "Features") -> None:
             (self.features, additional_features.features), axis=-1
         )
 
-    def _combine_sparse_features(self, additional_features: "Features") -> None:
+    def _combine_sparse_features(self, additional_features: Features) -> None:
         from scipy.sparse import hstack
 
         if self.features.shape[0] != additional_features.features.shape[0]:
@@ -136,3 +158,224 @@ def fingerprint(self) -> Text:
         return rasa.shared.utils.io.deep_container_fingerprint(
             [self.type, self.origin, self.attribute, f_as_text]
         )
+
+    @staticmethod
+    def filter(
+        features_list: List[Features],
+        attributes: Optional[Iterable[Text]] = None,
+        type: Optional[Text] = None,
+        origin: Optional[List[Text]] = None,
+        is_sparse: Optional[bool] = None,
+    ) -> List[Features]:
+        """Filters the given list of features.
+
+        Args:
+          features_list: list of features to be filtered
+          attributes: List of attributes that we're interested in. Set this to `None`
+            to disable this filter.
+          type: The type of feature we're interested in. Set this to `None`
+            to disable this filter.
+          origin: If specified, this method will check that the exact order of origins
+            matches the given list of origins. The reason for this is that if
+            multiple origins are listed for a Feature, this means that this feature
+            has been created by concatenating Features from the listed origins in
+            that particular order.
+          is_sparse: Defines whether all features that we're interested in should be
+            sparse. Set this to `None` to disable this filter.
+
+        Returns:
+            sub-list of features with the desired properties
+        """
+        filtered = features_list
+        if attributes is not None:
+            attributes = set(attributes)
+            filtered = [f for f in filtered if f.attribute in attributes]
+        if origin is not None:
+            filtered = [
+                f
+                for f in filtered
+                if (f.origin if not isinstance(f.origin, Text) else list([f.origin]))
+                == origin
+            ]
+        if type is not None:
+            filtered = [f for f in filtered if f.type == type]
+        if is_sparse is not None:
+            filtered = [f for f in filtered if f.is_sparse() == is_sparse]
+        return filtered
+
+    @staticmethod
+    def groupby_attribute(
+        features_list: List[Features], attributes: Optional[Iterable[Text]] = None,
+    ) -> Dict[Text, List[Features]]:
+        """Groups the given features according to their attribute.
+
+        Args:
+          features_list: list of features to be grouped
+          attributes: If specified, the result will be a grouping with respect to
+            the given attributes. If some specified attribute has no features attached
+            to it, then the resulting dictionary will map it to an empty list.
+            If this is None, the result will be a grouping according to all attributes
+            for which features can be found.
+
+        Returns:
+           a mapping from the requested attributes to the list of correspoding
+           features
+        """
+        # ensure all requested attributes are present in the output - regardless
+        # of whether we find features later
+        extracted: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # extract features for all (requested) attributes
+        for feat in features_list:
+            if attributes is None or feat.attribute in attributes:
+                extracted.setdefault(feat.attribute, []).append(feat)
+        return extracted
+
+    @staticmethod
+    def combine(
+        features_list: List[Features], expected_origins: Optional[List[Text]] = None,
+    ) -> Features:
+        """Combine features of the same type and level that describe the same attribute.
+
+        If sequence features are to be combined, then they must have the same
+        sequence dimension.
+
+        Args:
+          features: Non-empty list of Features  of the same type and level that
+            describe the same attribute.
+          expected_origins: The expected origins of the given features. This method
+            will check that the origin information of each feature is as expected, i.e.
+            the origin of the i-th feature in the given list is the i-th origin
+            in this list of origins.
+
+        Raises:
+          `ValueError` will be raised
+           - if the given list is empty
+           - if there are inconsistencies in the given list of `Features`
+           - if the origins aren't as expected
+        """
+        if len(features_list) == 0:
+            raise ValueError("Expected a non-empty list of Features.")
+        if len(features_list) == 1:
+            # nothing to combine here
+            return features_list[0]
+
+        # Un-Pack the Origin information
+        origin_of_combination = [f.origin for f in features_list]
+        origin_of_combination = [
+            featurizer_name
+            for origin in origin_of_combination
+            for featurizer_name in (origin if isinstance(origin, List) else [origin])
+        ]
+
+        # Sanity Checks
+        # (1) origins must be as expected
+        if expected_origins is not None:
+            if origin_of_combination is not None:
+                for idx, (expected, actual) in enumerate(
+                    itertools.zip_longest(expected_origins, origin_of_combination)
+                ):
+                    if expected != actual:
+                        raise ValueError(
+                            f"Expected {expected} to be the origin of the {idx}-th "
+                            f"feature (because of `origin_of_combination`) but found a "
+                            f"feature from {actual}."
+                        )
+        else:
+            origins: Set[Tuple[Text]] = set(
+                tuple(f.origin) if not isinstance(f.origin, Text) else (f.origin,)
+                for f in features_list
+            )
+            if len(origins) > 1:
+                raise ValueError(
+                    f"Expected all Features to have the same origin "
+                    f"found the following origins: {origins}."
+                )
+        # (2) attributes (is_sparse, type, attribute) must coincide
+        # Note: we could also use `filter` for this check, but then the erorr msgs
+        # aren't as nice.
+        sparseness: Set[bool] = set(f.is_sparse() for f in features_list)
+        if len(sparseness) > 1:
+            raise ValueError(
+                "Expected all Features to have the same sparseness property but "
+                "found both (sparse and dense)."
+            )
+        types: Set[Text] = set(f.type for f in features_list)
+        if len(types) > 1:
+            raise ValueError(
+                f"Expected all Features to have the same type but found the "
+                f"following types {types}."
+            )
+        attributes: Set[Text] = set(f.attribute for f in features_list)
+        if len(attributes) > 1:
+            raise ValueError(
+                f"Expected all Features to describe the same attribute but found "
+                f"attributes: {attributes}."
+            )
+        # (3) dimensions must match
+        # Note: We shouldn't have to check sentence-level features here but it doesn't
+        # hurt either.
+        dimensions = set(f.features.shape[0] for f in features_list)
+        if len(dimensions) > 1:
+            raise ValueError(
+                f"Expected all sequence dimensions to match but found {dimensions}."
+            )
+
+        # Combine the features
+        arbitrary_feature = features_list[0]
+        if not arbitrary_feature.is_sparse():
+            features = np.concatenate([f.features for f in features_list], axis=-1)
+        else:
+            features = scipy.sparse.hstack([f.features for f in features_list])
+        return Features(
+            features=features,
+            feature_type=arbitrary_feature.type,
+            attribute=arbitrary_feature.attribute,
+            origin=origin_of_combination,
+        )
+
+    @staticmethod
+    def reduce(
+        features_list: List[Features], expected_origins: Optional[List[Text]] = None
+    ) -> List[Features]:
+        """Combines features of same type and level into one Feature.
+
+        Args:
+           features_list: list of Features which must all describe the same attribute
+           expected_origins: if specified, this list will be used to validate that
+             the features from the right featurizers are combined in the right order
+             (cf. `Features.combine`)
+
+        Returns:
+            a list of the combined Features, i.e. at most 4 Features, where
+            - all the sparse features are listed before the dense features
+            - sequence feature is always listed before the sentence feature with the
+              same sparseness property
+        """
+        if len(features_list) == 1:
+            return features_list
+        # sanity check
+        different_settings = set(f.attribute for f in features_list)
+        if len(different_settings) > 1:
+            raise ValueError(
+                f"Expected all Features to describe the same attribute but found "
+                f" {different_settings}."
+            )
+        output = []
+        for is_sparse in [True, False]:
+            # all sparse featues before all dense features
+            for type in [FEATURE_TYPE_SEQUENCE, FEATURE_TYPE_SENTENCE]:
+                # sequence feature that is (not) sparse before sentence feature that is
+                #  (not) sparse
+                sublist = Features.filter(
+                    features_list=features_list, type=type, is_sparse=is_sparse,
+                )
+                if sublist:
+                    combined_feature = Features.combine(
+                        sublist, expected_origins=expected_origins,
+                    )
+                    output.append(combined_feature)
+        return output
diff --git a/tests/core/conftest.py b/tests/core/conftest.py
index 06495d89aecf..d225eaf4d9aa 100644
--- a/tests/core/conftest.py
+++ b/tests/core/conftest.py
@@ -170,7 +170,8 @@ def moodbot_features(
     Returns:
       A dict containing dicts for mapping action and intent names to features.
     """
-    origin = getattr(request, "param", "SingleStateFeaturizer")
+    # TODO: remove "2" once migration of policies is done
+    origin = getattr(request, "param", "SingleStateFeaturizer") + "2"
     action_shape = (1, len(moodbot_domain.action_names_or_texts))
     actions = {}
     for index, action in enumerate(moodbot_domain.action_names_or_texts):
diff --git a/tests/core/featurizers/test_precomputation.py b/tests/core/featurizers/test_precomputation.py
new file mode 100644
index 000000000000..ab831b13d154
--- /dev/null
+++ b/tests/core/featurizers/test_precomputation.py
@@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
+
+    # if we handle the collisions:
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 1  # because item with features was added first
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states)
+
+    # if we do not handle the collisions:
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, len(messages) - 1, 2):
+        lookup_table2.add(messages[idx])
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(messages[idx + 1])
+
+
+# TODO: one test where an inconsistency is ignored...
+def test_precomputation_build_ignores_some_inconsistencies():
+    # This is a reminder that not everything inconsistency will be detected.
+    # Similarly, differences in the actual Features (values/origin/...) will be ingored.
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = [
+        {TEXT: "text", ENTITIES: "1"},
+        {TEXT: "text", ENTITIES: "2"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "1"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "2"},
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    assert len(lookup_table) == int(len(sub_states) / 2)
+
+
+def test_precomputation_lookup_features():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={INTENT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={TEXT: "A2"}, features=[_dummy_features(3, TEXT)]),
+        Message(data={INTENT: "B2", OTHER: "C2"}, features=[_dummy_features(4, OTHER)]),
+    ]
+
+    table = CoreFeaturizationPrecomputations()
+    table.add_all(messages)
+
+    # If we don't specify a list of attributes, the resulting features dictionary will
+    # only contain those attributes for which there are features.
+    sub_state = {TEXT: "A", INTENT: "B", OTHER: "C"}
+    features = table.lookup_features(sub_state=sub_state)
+    for attribute, feature_value in [(TEXT, 1), (INTENT, None), (OTHER, 2)]:
+        if feature_value is not None:
+            assert attribute in features
+            assert len(features[attribute]) == 1
+            assert feature_value == features[attribute][0].features[0]
+        else:
+            assert attribute not in features
+
+    # If we query features for `INTENT`, then a key will be there, even if there are
+    # no feaetures
+    features = table.lookup_features(
+        sub_state=sub_state, attributes=list(sub_state.keys())
+    )
+    assert INTENT in features
+    assert len(features[INTENT]) == 0
+
+    # We only get the list of features we want...
+    features = table.lookup_features(sub_state, attributes=[OTHER])
+    assert TEXT not in features
+    assert INTENT not in features
+    assert len(features[OTHER]) == 1
+
+    # ... even if there are no features:
+    YET_ANOTHER = "another"
+    features = table.lookup_features(sub_state, attributes=[YET_ANOTHER])
+    assert len(features[YET_ANOTHER]) == 0
+
+    # And we cannot lookup features for sub states that have no key attribute
+    with pytest.raises(ValueError, match="Unknown key"):
+        table.lookup_features({TEXT: "A-unknwon"})
+
+
+def test_precomputation_lookup_features_if_lookup_table_is_broken():
+
+    broken_table = CoreFeaturizationPrecomputations()
+    broken_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(
+            data={}, features=[_dummy_features(2, TEXT)]
+        ),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data={}, features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    with pytest.raises(
+        RuntimeError, match=f"Feature for attribute {TEXT} has already been"
+    ):
+        broken_table.lookup_features({TEXT: "A", INTENT: "B"})
+
+    # as a reminder that we don't fix every strange edgecase:
+    not_broken_but_strange_table = CoreFeaturizationPrecomputations()
+    not_broken_but_strange_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(data=dict()),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data=dict(), features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    features = not_broken_but_strange_table.lookup_features({TEXT: "A", INTENT: "B"})
+    assert TEXT in features and len(features[TEXT]) == 1
+
+
+def test_precomputation_lookup_message():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={TEXT: "B"}),
+        Message(data={TEXT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={INTENT: "B"}),
+        Message(data={ACTION_TEXT: "B"}),
+        Message(data={ACTION_NAME: "B"}),
+    ]
+
+    table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    table.add_all(messages)
+
+    message = table.lookup_message(user_text="A")
+    assert message
+    message = table.lookup_message(user_text="B")
+    assert OTHER in message.data.keys()
+    with pytest.raises(ValueError, match=f"Expected a message with key \\('{TEXT}"):
+        table.lookup_message(user_text="not included")
+
+
+def test_precomputation_derive_messages_from_events_and_add():
+    events = [
+        UserUttered(intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate and one prev_action substate
+    assert len(lookup_table) == 2
+
+    events = [
+        UserUttered(text="text", intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate, one text-only user substate one
+    # prev_action substate
+    assert len(lookup_table) == 3
+
+
+def test_precomputation_derive_messages_from_domain_and_add():
+    action_names = ["a", "b"]
+    # action texts, response keys, forms, and action_names must be unique or the
+    # domain will complain about it
+    action_texts = ["a2", "b2"]
+    responses = {"a3": "a2", "b3": "b2"}
+    forms = {"a4": "a4"}
+    # however, intent names can be anything
+    intents = ["a", "b"]
+    domain = Domain(
+        intents=intents,
+        action_names=action_names,
+        action_texts=action_texts,
+        responses=responses,
+        entities=["e_a", "e_b", "e_c"],
+        slots=[Slot(name="s")],
+        forms=forms,
+    )
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    lookup_table.derive_messages_from_domain_and_add(domain)
+    # Note that we cannot just sum the above because the `domain` will e.g. combine the
+    # `action_texts` with the `responses` to obtain the `domain.action_texts`
+    assert len(lookup_table) == (
+        len(domain.intent_properties)
+        + len(domain.user_actions)
+        + len(domain.action_texts)
+        + len(DEFAULT_ACTION_NAMES)
+        + len(domain.form_names)
+    )
+
+
+def _create_entity(
+    value: Text, type: Text, role: Optional[Text] = None, group: Optional[Text] = None
+) -> Dict[Text, Text]:
+    entity = {}
+    entity[ENTITY_ATTRIBUTE_VALUE] = value
+    entity[ENTITY_ATTRIBUTE_TYPE] = type
+    entity[ENTITY_ATTRIBUTE_ROLE] = role
+    entity[ENTITY_ATTRIBUTE_GROUP] = group
+    return entity
+
+
+def test_preparation_for_training():
+    """Tests whether story graph and domain information ends up in training data.
+    Note: We do not test for completeness/shape of the result here. These aspects are
+    defined by the same principles as the lookup table. Hence, these aspects should
+    be tested in the lookup table tests, whereas here we just probe whether the lookup
+    table is applied correctly.
+    """
+    # create domain and story graph
+    intent_in_domain_only = "only-appears-in-domain"
+    event_text_prefix = "only-appears-in-events"
+    domain = Domain(
+        intents=["greet", "inform", intent_in_domain_only],
+        entities=["entity_name"],
+        slots=[],
+        responses=dict(),
+        action_names=["action_listen", "utter_greet"],
+        forms=dict(),
+        action_texts=["Hi how are you?"],
+    )
+    text_that_contains_entities = f"{event_text_prefix}-1"
+    events = [
+        ActionExecuted(action_name="action_listen"),
+        UserUttered(
+            text=text_that_contains_entities,
+            intent={"intent_name": "greet"},
+            entities=[_create_entity(value="Bot", type="entity_name")],
+        ),
+        ActionExecuted(action_name="utter_greet", action_text="Hi how are you?"),
+        ActionExecuted(action_name="action_listen"),
+        UserUttered(text=f"{event_text_prefix}-2", intent={"intent_name": "inform"}),
+        ActionExecuted(action_name="action_listen"),
+    ]
+    story_graph = StoryGraph([StoryStep(events=events)])
+
+    # convert!
+    training_data = CoreFeaturizationPreparer.prepare_for_training(
+        domain=domain, story_graph=story_graph
+    )
+
+    # check information from events is contained - by checking the TEXTs
+    training_texts = sorted(
+        message.data.get(TEXT)
+        for message in training_data.training_examples
+        if TEXT in message.data
+    )
+    assert all(
+        [
+            (
+                text.startswith(event_text_prefix)
+                or text == CoreFeaturizationPreparer.WORKAROUND_TEXT
+            )
+            for text in training_texts
+        ]
+    )
+    assert CoreFeaturizationPreparer.WORKAROUND_TEXT in training_texts
+    # check that entities are accessible via the TEXT
+    messages = [
+        message
+        for message in training_data.training_examples
+        if message.data.get(TEXT) == text_that_contains_entities
+    ]
+    assert len(messages) == 1
+    message = messages[0]
+    assert message.data.get(ENTITIES)
+    # check information from domain is contained - by checking the INTENTs
+    training_intents = set(
+        message.get(INTENT) for message in training_data.training_examples
+    )
+    assert intent_in_domain_only in training_intents
+
+
+def test_preparation_for_inference():
+    """Tests whether information from tracker events end up in the messages.
+    Note: We do not test for completeness/shape of the result here. These aspects are
+    defined by the same principles as the lookup table. Hence, these aspects should
+    be tested in the lookup table tests, whereas here we just probe whether the lookup
+    table is applied correctly.
+    """
+    # create tracker
+    event_text_prefix = "this-is-a-text-prefix"
+    text_that_contains_entities = f"{event_text_prefix}-1"
+    events = [
+        UserUttered(
+            text=text_that_contains_entities,
+            intent={"intent_name": "greet"},
+            entities=[_create_entity(value="Bot", type="entity_name")],
+        ),
+        ActionExecuted(action_name="utter_greet", action_text="Hi how are you?"),
+        ActionExecuted(action_name="action_listen"),
+        UserUttered(text=f"{event_text_prefix}-2", intent={"intent_name": "inform"}),
+    ]
+    tracker = DialogueStateTracker.from_events(sender_id="arbitrary", evts=events)
+
+    # convert!
+    messages = CoreFeaturizationPreparer.prepare_for_inference(tracker)
+
+    # check information from events is contained - by checking the TEXTs
+    message_texts = sorted(
+        message.data.get(TEXT) for message in messages if TEXT in message.data
+    )
+    assert all([(text.startswith(event_text_prefix)) for text in message_texts])
+    assert CoreFeaturizationPreparer.WORKAROUND_TEXT not in message_texts
+    # check that ENTITIES are accessible via the TEXT
+    messages_for_chosen_text = [
+        message
+        for message in messages
+        if message.data.get(TEXT) == text_that_contains_entities
+    ]
+    assert len(messages_for_chosen_text) == 1
+    message = messages_for_chosen_text[0]
+    assert message.data.get(ENTITIES)
+
+
+@pytest.mark.parametrize(
+    "messages_with_unique_lookup_key",
+    [
+        [
+            Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+            Message(data={ACTION_TEXT: "B"}),
+        ],
+        [],
+    ],
+)
+def test_collection(messages_with_unique_lookup_key: List[Message]):
+
+    messages = messages_with_unique_lookup_key
+
+    # pass as training data
+    training_data = TrainingData(training_examples=messages)
+    precomputations = CoreFeaturizationCollector.collect(training_data)
+    assert len(precomputations) == len(messages)
+
+    # pass the list of messages directly
+    precomputations = CoreFeaturizationCollector.collect(messages)
+    assert len(precomputations) == len(messages)
+
+
+def test_collection_fails():
+    """The collection expects messages that have a unique lookup key.
+
+    This is because they (should) have been passed through the preparation stage which
+    will have constructed messages with this property.
+    """
+    messages = [
+        Message(data={TEXT: "A", ACTION_TEXT: "B"}, features=[_dummy_features(1, TEXT)])
+    ]
+    training_data = TrainingData(training_examples=messages)
+
+    with pytest.raises(ValueError):
+        CoreFeaturizationCollector.collect(training_data)
+    with pytest.raises(ValueError):
+        CoreFeaturizationCollector.collect(messages)
diff --git a/tests/core/featurizers/test_single_state_featurizers.py b/tests/core/featurizers/test_single_state_featurizers.py
index 522348c332d8..7c6f1bb7e5ee 100644
--- a/tests/core/featurizers/test_single_state_featurizers.py
+++ b/tests/core/featurizers/test_single_state_featurizers.py
@@ -1,17 +1,19 @@
+from rasa.utils.tensorflow.constants import SENTENCE, SEQUENCE
 from typing import Text
 import numpy as np
-
-from rasa.core.agent import Agent
-from rasa.shared.core.constants import ENTITY_LABEL_SEPARATOR
+import re
 import scipy.sparse
-
 import pytest
 
+
+from rasa.core.featurizers.precomputation import CoreFeaturizationPrecomputations
+from rasa.nlu.tokenizers.tokenizer import Token
 from rasa.core.featurizers.single_state_featurizer import (
-    SingleStateFeaturizer,
-    IntentTokenizerSingleStateFeaturizer,
+    SingleStateFeaturizer2 as SingleStateFeaturizer,
 )
-from rasa.shared.core.domain import Domain
+from rasa.nlu.constants import TOKENS_NAMES
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
 from rasa.shared.nlu.constants import (
     ACTION_TEXT,
     ACTION_NAME,
@@ -26,119 +28,65 @@
     ENTITY_ATTRIBUTE_END,
     ENTITY_TAGS,
 )
-from rasa.shared.core.constants import ACTIVE_LOOP, SLOTS
-from rasa.shared.nlu.interpreter import RegexInterpreter
+from rasa.shared.core.domain import Domain
 from rasa.shared.core.slots import Slot
-from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.core.constants import (
+    ACTIVE_LOOP,
+    PREVIOUS_ACTION,
+    SLOTS,
+    ENTITY_LABEL_SEPARATOR,
+    USER,
+)
 
 
-def test_single_state_featurizer_without_interpreter_state_not_with_action_listen():
-    """This test are for encoding state without a trained interpreter.
-    action_name is not action_listen, so, INTENT, TEXT and ENTITIES should not be
-    featurized.
-    """
-    f = SingleStateFeaturizer()
-    f._default_feature_states[INTENT] = {"a": 0, "b": 1}
-    f._default_feature_states[ACTION_NAME] = {"c": 0, "d": 1, "action_listen": 2}
-    f._default_feature_states[SLOTS] = {"e_0": 0, "f_0": 1, "g_0": 2}
-    f._default_feature_states[ACTIVE_LOOP] = {"h": 0, "i": 1, "j": 2, "k": 3}
+#
+# internals
+#
 
-    encoded = f.encode_state(
-        {
-            "user": {"intent": "a", "text": "blah blah blah"},
-            "prev_action": {"action_name": "d", "action_text": "boom"},
-            "active_loop": {"name": "i"},
-            "slots": {"g": (1.0,)},
-        },
-        interpreter=RegexInterpreter(),
-    )
 
-    # user input is ignored as prev action is not action_listen
-    assert list(encoded.keys()) == [ACTION_NAME, ACTIVE_LOOP, SLOTS]
-    assert (
-        encoded[ACTION_NAME][0].features != scipy.sparse.coo_matrix([[0, 1, 0]])
-    ).nnz == 0
-    assert (
-        encoded[ACTIVE_LOOP][0].features != scipy.sparse.coo_matrix([[0, 1, 0, 0]])
-    ).nnz == 0
-    assert (encoded[SLOTS][0].features != scipy.sparse.coo_matrix([[0, 0, 1]])).nnz == 0
-
-
-def test_single_state_featurizer_without_interpreter_state_with_action_listen():
-    """This test are for encoding state without a trained interpreter.
-    action_name is action_listen, so, INTENT and ENTITIES should be featurized
-    while text shouldn't because we don't have an interpreter.
-    """
+def test_state_features_for_attribute__raises_on_not_supported_attribute():
     f = SingleStateFeaturizer()
-    f._default_feature_states[INTENT] = {"a": 0, "b": 1}
-    f._default_feature_states[ACTION_NAME] = {"c": 0, "d": 1, "action_listen": 2}
-    f._default_feature_states[SLOTS] = {"e_0": 0, "f_0": 1, "g_0": 2}
-    f._default_feature_states[ACTIVE_LOOP] = {"h": 0, "i": 1, "j": 2, "k": 3}
 
-    encoded = f.encode_state(
-        {
-            "user": {"intent": "a", "text": "blah blah blah"},
-            "prev_action": {"action_name": "action_listen", "action_text": "boom"},
-            "active_loop": {"name": "k"},
-            "slots": {"e": (1.0,)},
-        },
-        interpreter=RegexInterpreter(),
-    )
-
-    # we featurize all the features except for *_text ones because NLU wasn't trained
-    assert list(encoded.keys()) == [INTENT, ACTION_NAME, ACTIVE_LOOP, SLOTS]
-    assert (encoded[INTENT][0].features != scipy.sparse.coo_matrix([[1, 0]])).nnz == 0
-    assert (
-        encoded[ACTION_NAME][0].features != scipy.sparse.coo_matrix([[0, 0, 1]])
-    ).nnz == 0
-    assert (
-        encoded[ACTIVE_LOOP][0].features != scipy.sparse.coo_matrix([[0, 0, 0, 1]])
-    ).nnz == 0
-    assert (encoded[SLOTS][0].features != scipy.sparse.coo_matrix([[1, 0, 0]])).nnz == 0
+    with pytest.raises(ValueError):
+        f._state_features_for_attribute({}, "not-supported-attribute")
 
 
-def test_single_state_featurizer_without_interpreter_state_no_intent_no_action_name():
-    f = SingleStateFeaturizer()
-    f._default_feature_states[INTENT] = {"a": 0, "b": 1}
-    f._default_feature_states[ACTION_NAME] = {"c": 0, "d": 1, "action_listen": 2}
-    f._default_feature_states[SLOTS] = {"e_0": 0, "f_0": 1, "g_0": 2}
-    f._default_feature_states[ACTIVE_LOOP] = {"h": 0, "i": 1, "j": 2, "k": 3}
+def test_to_sparse_sentence_features():
+    features = [
+        Features(
+            scipy.sparse.csr_matrix(np.random.randint(5, size=(5, 10))),
+            FEATURE_TYPE_SEQUENCE,
+            TEXT,
+            "some-featurizer",
+        )
+    ]
 
-    # check that no intent / action_name features are added when the interpreter
-    # isn't there and
-    # intent / action_name not in input
-    encoded = f.encode_state(
-        {
-            "user": {"text": "blah blah blah"},
-            "prev_action": {"action_text": "boom"},
-            "active_loop": {"name": "k"},
-            "slots": {"e": (1.0,)},
-        },
-        interpreter=RegexInterpreter(),
-    )
+    sentence_features = SingleStateFeaturizer._to_sparse_sentence_features(features)
 
-    assert list(encoded.keys()) == [ACTIVE_LOOP, SLOTS]
-    assert (
-        encoded[ACTIVE_LOOP][0].features != scipy.sparse.coo_matrix([[0, 0, 0, 1]])
-    ).nnz == 0
-    assert (encoded[SLOTS][0].features != scipy.sparse.coo_matrix([[1, 0, 0]])).nnz == 0
+    assert len(sentence_features) == 1
+    assert FEATURE_TYPE_SENTENCE == sentence_features[0].type
+    assert features[0].origin == sentence_features[0].origin
+    assert features[0].attribute == sentence_features[0].attribute
+    assert sentence_features[0].features.shape == (1, 10)
 
 
-def test_single_state_featurizer_correctly_encodes_non_existing_value():
+def test_create_features__dtype_float():
     f = SingleStateFeaturizer()
     f._default_feature_states[INTENT] = {"a": 0, "b": 1}
-    f._default_feature_states[ACTION_NAME] = {"c": 0, "d": 1}
+    f._default_feature_states[ACTION_NAME] = {"e": 0, "d": 1}
+    f._default_feature_states[ENTITIES] = {"c": 0}
 
-    encoded = f.encode_state(
-        {"user": {"intent": "e"}, "prev_action": {"action_name": "action_listen"}},
-        interpreter=RegexInterpreter(),
-    )
+    encoded = f._create_features({"action_name": "d"}, attribute="action_name")
+    assert len(encoded) == 1  # cause for some reason this is a list
+    assert encoded[0].features.dtype == np.float32
 
-    assert list(encoded.keys()) == [INTENT, ACTION_NAME]
-    assert (encoded[INTENT][0].features != scipy.sparse.coo_matrix([[0, 0]])).nnz == 0
 
+#
+# preparation
+#
 
-def test_single_state_featurizer_prepare_for_training():
+
+def test_prepare_for_training():
     domain = Domain(
         intents=["greet"],
         entities=["name"],
@@ -149,7 +97,7 @@ def test_single_state_featurizer_prepare_for_training():
     )
 
     f = SingleStateFeaturizer()
-    f.prepare_for_training(domain, RegexInterpreter())
+    f.prepare_for_training(domain)
 
     assert len(f._default_feature_states[INTENT]) > 1
     assert "greet" in f._default_feature_states[INTENT]
@@ -163,7 +111,14 @@ def test_single_state_featurizer_prepare_for_training():
     assert len(f._default_feature_states[ACTIVE_LOOP]) == 0
 
 
-def test_single_state_featurizer_creates_encoded_all_labels():
+#
+# encode actions
+# (always needs lookup table build from domain)
+#
+
+
+def test_encode_all_labels__encoded_all_action_names_and_texts():
+    # ... where "labels" means actions...
     domain = Domain(
         intents=[],
         entities=[],
@@ -174,178 +129,157 @@ def test_single_state_featurizer_creates_encoded_all_labels():
     )
 
     f = SingleStateFeaturizer()
-    f.prepare_for_training(domain, RegexInterpreter())
-    encoded_labels = f.encode_all_labels(domain, RegexInterpreter())
+    f.prepare_for_training(domain)
 
-    assert len(encoded_labels) == len(domain.action_names_or_texts)
+    precomputations = CoreFeaturizationPrecomputations()
+    precomputations.derive_messages_from_domain_and_add(domain)
+
+    encoded_actions = f.encode_all_labels(domain, precomputations=precomputations)
+
+    assert len(encoded_actions) == len(domain.action_names_or_texts)
     assert all(
         [
-            ACTION_NAME in encoded_label and ACTION_TEXT not in encoded_label
-            for encoded_label in encoded_labels
+            ACTION_NAME in encoded_action and ACTION_TEXT not in encoded_action
+            for encoded_action in encoded_actions
         ]
     )
 
 
-def test_intent_tokenized_single_state_featurizer_creates_encoded_all_labels():
-    domain = Domain(
-        intents=["a", "b", "c", "d"],
-        entities=[],
-        slots=[],
-        responses={},
-        forms={},
-        action_names=[],
-    )
-
-    f = IntentTokenizerSingleStateFeaturizer()
-    f.prepare_for_training(domain, RegexInterpreter())
-    encoded_labels = f.encode_all_labels(domain, RegexInterpreter())
+#
+# encode state withOUT lookup table
+#
 
-    assert len(encoded_labels) == len(domain.intents)
-    assert all(
-        [
-            INTENT in encoded_label and TEXT not in encoded_label
-            for encoded_label in encoded_labels
-        ]
-    )
 
+def sparse_equals_dense(
+    sparse_matrix: scipy.sparse.spmatrix, dense_matrix: np.ndarray
+) -> bool:
+    return (sparse_matrix != scipy.sparse.coo_matrix(dense_matrix)).nnz == 0
 
-@pytest.mark.timeout(
-    300, func_only=True
-)  # these can take a longer time than the default timeout
-def test_single_state_featurizer_with_entity_roles_and_groups(
-    unpacked_trained_spacybot_path: Text,
-):
-    from rasa.core.agent import Agent
 
-    interpreter = Agent.load(unpacked_trained_spacybot_path).interpreter
-    # TODO roles and groups are not supported in e2e yet
-    domain = Domain(
-        intents=[],
-        entities=["city", f"city{ENTITY_LABEL_SEPARATOR}to"],
-        slots=[],
-        responses={},
-        forms={},
-        action_names=[],
-    )
+@pytest.mark.parametrize("action_name", [None, "NOT_action_listen", "action_listen"])
+def test_encode_state__without_lookup(action_name: Text):
+    """Tests that `encode_state` creates features for every attribute.
+    In particular, that this is done even when there is no lookup table.
+    If there is no action_listen in the  state, then no features should be created for
+    the user sub-state.
+    """
     f = SingleStateFeaturizer()
-    f.prepare_for_training(domain, RegexInterpreter())
-    encoded = f.encode_entities(
-        {
-            TEXT: "I am flying from London to Paris",
-            ENTITIES: [
-                {
-                    ENTITY_ATTRIBUTE_TYPE: "city",
-                    ENTITY_ATTRIBUTE_VALUE: "London",
-                    ENTITY_ATTRIBUTE_START: 17,
-                    ENTITY_ATTRIBUTE_END: 23,
-                },
-                {
-                    ENTITY_ATTRIBUTE_TYPE: f"city{ENTITY_LABEL_SEPARATOR}to",
-                    ENTITY_ATTRIBUTE_VALUE: "Paris",
-                    ENTITY_ATTRIBUTE_START: 27,
-                    ENTITY_ATTRIBUTE_END: 32,
-                },
-            ],
-        },
-        interpreter=interpreter,
-    )
-    assert sorted(list(encoded.keys())) == sorted([ENTITY_TAGS])
-    assert np.all(
-        encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [1], [0], [2]]
-    )
+    f._default_feature_states[INTENT] = {"a": 0, "b": 1}
+    f._default_feature_states[ACTION_NAME] = {
+        "c": 0,
+        "d": 1,
+        "NOT_action_listen": 2,
+        "action_listen": 3,
+    }
+    f._default_feature_states[SLOTS] = {"e_0": 0, "f_0": 1, "g_0": 2}
+    f._default_feature_states[ACTIVE_LOOP] = {"h": 0, "i": 1, "j": 2, "k": 3}
 
+    state = {
+        "user": {"intent": "a", "text": "blah blah blah"},
+        "prev_action": {"action_text": "boom"},
+        "active_loop": {"name": "i"},
+        "slots": {"g": (1.0,)},
+    }
+    if action_name is not None:
+        state["prev_action"]["action_name"] = action_name
+
+    encoded = f.encode_state(state, precomputations=None)
+
+    # this differs depending on whether action name is "action_listen" or "d"
+    expected_attributes = [ACTIVE_LOOP, SLOTS]
+    if action_name == "action_listen":
+        expected_attributes += [INTENT]
+    if action_name is not None:
+        expected_attributes += [ACTION_NAME]
+    assert set(encoded.keys()) == set(expected_attributes)
+
+    # the encoding of action_name of course depends on the sub-state
+    if action_name is not None:
+        if action_name == "NOT_action_listen":
+            action_name_encoding = [0, 0, 1, 0]
+        else:
+            action_name_encoding = [0, 0, 0, 1]
+        assert sparse_equals_dense(
+            encoded[ACTION_NAME][0].features, np.array([action_name_encoding])
+        )
 
-@pytest.mark.timeout(
-    300, func_only=True
-)  # these can take a longer time than the default timeout
-def test_single_state_featurizer_with_bilou_entity_roles_and_groups(
-    unpacked_trained_spacybot_path: Text,
-):
-    from rasa.core.agent import Agent
+    # the intent / user substate is only featurized if action_listen is
+    # with_action_listen
+    if action_name == "action_listen":
+        assert sparse_equals_dense(encoded[INTENT][0].features, np.array([[1, 0]]))
 
-    interpreter = Agent.load(unpacked_trained_spacybot_path).interpreter
-    # TODO roles and groups are not supported in e2e yet
-    domain = Domain(
-        intents=[],
-        entities=["city", f"city{ENTITY_LABEL_SEPARATOR}to"],
-        slots=[],
-        responses={},
-        forms={},
-        action_names=[],
+    # this is always the same
+    assert sparse_equals_dense(
+        encoded[ACTIVE_LOOP][0].features, np.array([[0, 1, 0, 0]])
     )
-    f = SingleStateFeaturizer()
-    f.prepare_for_training(domain, RegexInterpreter(), bilou_tagging=True)
+    assert sparse_equals_dense(encoded[SLOTS][0].features, np.array([[0, 0, 1]]))
 
-    encoded = f.encode_entities(
-        {
-            TEXT: "I am flying from London to Paris",
-            ENTITIES: [
-                {
-                    ENTITY_ATTRIBUTE_TYPE: "city",
-                    ENTITY_ATTRIBUTE_VALUE: "London",
-                    ENTITY_ATTRIBUTE_START: 17,
-                    ENTITY_ATTRIBUTE_END: 23,
-                },
-                {
-                    ENTITY_ATTRIBUTE_TYPE: f"city{ENTITY_LABEL_SEPARATOR}to",
-                    ENTITY_ATTRIBUTE_VALUE: "Paris",
-                    ENTITY_ATTRIBUTE_START: 27,
-                    ENTITY_ATTRIBUTE_END: 32,
-                },
-            ],
-        },
-        interpreter=interpreter,
-        bilou_tagging=True,
-    )
-    assert sorted(list(encoded.keys())) == sorted([ENTITY_TAGS])
-    assert np.all(
-        encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [4], [0], [8]]
-    )
 
-    encoded = f.encode_entities(
-        {
-            TEXT: "I am flying to Saint Petersburg",
-            ENTITIES: [
-                {
-                    ENTITY_ATTRIBUTE_TYPE: "city",
-                    ENTITY_ATTRIBUTE_VALUE: "Saint Petersburg",
-                    ENTITY_ATTRIBUTE_START: 15,
-                    ENTITY_ATTRIBUTE_END: 31,
-                },
-            ],
-        },
-        interpreter=interpreter,
-        bilou_tagging=True,
+#
+# encode state WITH lookup table
+#
+
+
+def dummy_features(
+    fill_value: int, units: int, attribute: Text, type: Text, is_sparse: bool
+) -> Features:
+    """Create some dummy `Features` with the desired properties.
+    """
+    matrix = np.full(shape=(1, units), fill_value=fill_value)
+    if is_sparse:
+        matrix = scipy.sparse.coo_matrix(matrix)
+    return Features(
+        features=matrix, attribute=attribute, feature_type=type, origin="doesnt-matter",
     )
-    assert sorted(list(encoded.keys())) == sorted([ENTITY_TAGS])
-    assert np.all(encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [1], [3]])
 
 
-def test_single_state_featurizer_uses_dtype_float():
+@pytest.mark.parametrize("with_action_listen", [True, False])
+def test_encode_state__with_lookup__creates_features_for_intent_and_action_name(
+    with_action_listen: bool,
+):
+    """Tests that features for intent and action name are created if needed.
+    Especially tests that this is the case even though no features are present in the
+    given lookup table for this intent and action_name.
+    However, if no `action_listen` is in the given sub-state, then the user sub-state
+    should not be featurized (hence, no features for intent) should be created.
+    """
+
     f = SingleStateFeaturizer()
     f._default_feature_states[INTENT] = {"a": 0, "b": 1}
-    f._default_feature_states[ACTION_NAME] = {"e": 0, "d": 1}
-    f._default_feature_states[ENTITIES] = {"c": 0}
-
-    encoded = f.encode_state(
-        {
-            "user": {"intent": "a", "entities": ["c"]},
-            "prev_action": {"action_name": "d"},
-        },
-        interpreter=RegexInterpreter(),
-    )
-
-    assert encoded[ACTION_NAME][0].features.dtype == np.float32
+    f._default_feature_states[ACTION_NAME] = {"c": 0, "d": 1, "action_listen": 2}
 
+    # create state
+    action_name = "action_listen" if with_action_listen else "c"
+    state = {USER: {INTENT: "e"}, PREVIOUS_ACTION: {ACTION_NAME: action_name}}
 
-@pytest.mark.timeout(
-    300, func_only=True
-)  # these can take a longer time than the default timeout
-def test_single_state_featurizer_with_interpreter_state_with_action_listen(
-    unpacked_trained_spacybot_path: Text,
-):
-    interpreter = Agent.load(unpacked_trained_spacybot_path).interpreter
+    # create a lookup table with all relevant entries **but no Features**
+    precomputations = CoreFeaturizationPrecomputations()
+    precomputations.add(Message(data={INTENT: state[USER][INTENT]}))
+    precomputations.add(
+        Message(data={ACTION_NAME: state[PREVIOUS_ACTION][ACTION_NAME]})
+    )
 
+    # encode!
+    encoded = f.encode_state(state, precomputations=precomputations)
+
+    if with_action_listen:
+        assert set(encoded.keys()) == set([INTENT, ACTION_NAME])
+        assert (
+            encoded[INTENT][0].features != scipy.sparse.coo_matrix([[0, 0]])
+        ).nnz == 0
+    else:
+        assert set(encoded.keys()) == set([ACTION_NAME])
+
+
+@pytest.mark.parametrize("action_name", [None, "NOT_action_listen", "action_listen"])
+def test_encode_state__with_lookup__looksup_or_creates_features(action_name: Text):
+    """Tests that features from table are combined or created from scratch.
+    If the given action name is ...
+    - "action_listen" then the user substate and the action name are encoded
+    - some "other" action, then the user-substate is not encoed but the action name is
+    - set to "None", then we remove the action name from the user substate and as a
+      result there should be no encoding for the action name and for the user substate
+    """
     f = SingleStateFeaturizer()
     f._default_feature_states[INTENT] = {"greet": 0, "inform": 1}
     f._default_feature_states[ENTITIES] = {
@@ -355,7 +289,7 @@ def test_single_state_featurizer_with_interpreter_state_with_action_listen(
         f"city{ENTITY_LABEL_SEPARATOR}from": 3,
     }
     f._default_feature_states[ACTION_NAME] = {
-        "utter_ask_where_to": 0,
+        "NOT_action_listen": 0,
         "utter_greet": 1,
         "action_listen": 2,
     }
@@ -367,164 +301,261 @@ def test_single_state_featurizer_with_interpreter_state_with_action_listen(
         "active_loop_3": 2,
         "active_loop_4": 3,
     }
-    encoded = f.encode_state(
-        {
-            "user": {
-                "text": "I am flying from London to Paris",
-                "intent": "inform",
-                "entities": ["city", f"city{ENTITY_LABEL_SEPARATOR}to"],
-            },
-            "prev_action": {
-                "action_name": "action_listen",
-                "action_text": "throw a ball",
-            },
-            "active_loop": {"name": "active_loop_4"},
-            "slots": {"slot_1": (1.0,)},
-        },
-        interpreter=interpreter,
+
+    # create state
+    text = "I am flying from London to Paris"
+    tokens = [
+        Token(text=match.group(), start=match.start())
+        for match in re.finditer(r"\S+", text)
+    ]
+    entity_name_list = ["city", f"city{ENTITY_LABEL_SEPARATOR}to"]
+    action_text = "throw a ball"
+    intent = "inform"
+    state = {
+        "user": {"text": text, "intent": intent, "entities": entity_name_list,},
+        "prev_action": {"action_name": action_name, "action_text": action_text,},
+        "active_loop": {"name": "active_loop_4"},
+        "slots": {"slot_1": (1.0,)},
+    }
+    if action_name is None:
+        del state["prev_action"]["action_name"]
+
+    # Build lookup table with all relevant information - and dummy features for all
+    # dense featurizable attributes.
+    # Note that we don't need to add the `ENTITIES` to the message including `TEXT`
+    # here because `encode_state` won't featurize the entities using the lookup table
+    # (only `encode_entities` does that).
+    units = 300
+    precomputations = CoreFeaturizationPrecomputations()
+    precomputations.add_all(
+        [
+            Message(
+                data={TEXT: text, TOKENS_NAMES[TEXT]: tokens},
+                features=[
+                    dummy_features(
+                        fill_value=11,
+                        units=units,
+                        attribute=TEXT,
+                        type=SENTENCE,
+                        is_sparse=True,
+                    ),
+                    dummy_features(
+                        fill_value=12,
+                        units=units,
+                        attribute=TEXT,
+                        type=SEQUENCE,
+                        is_sparse=False,
+                    ),
+                    # Note: sparse sequence feature is last here
+                    dummy_features(
+                        fill_value=13,
+                        units=units,
+                        attribute=TEXT,
+                        type=SEQUENCE,
+                        is_sparse=True,
+                    ),
+                ],
+            ),
+            Message(data={INTENT: intent}),
+            Message(
+                data={ACTION_TEXT: action_text},
+                features=[
+                    dummy_features(
+                        fill_value=1,
+                        units=units,
+                        attribute=ACTION_TEXT,
+                        type=SEQUENCE,
+                        is_sparse=True,
+                    )
+                ],
+            ),
+        ]
     )
+    if action_name is not None:
+        precomputations.add(Message(data={ACTION_NAME: action_name}))
+
+    # encode the state
+    encoded = f.encode_state(state, precomputations=precomputations,)
 
     # check all the features are encoded and *_text features are encoded by a
     # dense featurizer
-    assert sorted(list(encoded.keys())) == sorted(
-        [TEXT, ENTITIES, ACTION_NAME, SLOTS, ACTIVE_LOOP, INTENT, ACTION_TEXT]
+    expected_attributes = [SLOTS, ACTIVE_LOOP, ACTION_TEXT]
+    if action_name is not None:  # i.e. we did not removed it from the state
+        expected_attributes += [ACTION_NAME]
+    if action_name == "action_listen":
+        expected_attributes += [TEXT, ENTITIES, INTENT]
+    assert set(encoded.keys()) == set(expected_attributes)
+
+    # Remember, sparse sequence features come first (and `.features` denotes the matrix
+    # not an `Features` object)
+    if action_name == "action_listen":
+        assert encoded[TEXT][0].features.shape[-1] == units
+        assert encoded[TEXT][0].is_sparse()
+        assert encoded[ENTITIES][0].features.shape[-1] == 4
+        assert sparse_equals_dense(encoded[INTENT][0].features, np.array([[0, 1]]))
+    assert encoded[ACTION_TEXT][0].features.shape[-1] == units
+    assert encoded[ACTION_TEXT][0].is_sparse()
+    if action_name is not None:
+        if action_name == "NOT_action_listen":
+            action_name_encoding = [1, 0, 0]
+        else:  # action_listen
+            action_name_encoding = [0, 0, 1]
+        assert sparse_equals_dense(
+            encoded[ACTION_NAME][0].features, np.array([action_name_encoding])
+        )
+    else:
+        assert ACTION_NAME not in encoded
+    assert sparse_equals_dense(encoded[SLOTS][0].features, np.array([[1, 0, 0]]))
+    assert sparse_equals_dense(
+        encoded[ACTIVE_LOOP][0].features, np.array([[0, 0, 0, 1]])
     )
-    assert encoded[TEXT][0].features.shape[-1] == 300
-    assert encoded[ACTION_TEXT][0].features.shape[-1] == 300
-    assert (encoded[INTENT][0].features != scipy.sparse.coo_matrix([[0, 1]])).nnz == 0
-    assert (
-        encoded[ACTION_NAME][0].features != scipy.sparse.coo_matrix([[0, 0, 1]])
-    ).nnz == 0
-    assert encoded[ENTITIES][0].features.shape[-1] == 4
-    assert (encoded[SLOTS][0].features != scipy.sparse.coo_matrix([[1, 0, 0]])).nnz == 0
-    assert (
-        encoded[ACTIVE_LOOP][0].features != scipy.sparse.coo_matrix([[0, 0, 0, 1]])
-    ).nnz == 0
-
-
-@pytest.mark.timeout(
-    300, func_only=True
-)  # these can take a longer time than the default timeout
-def test_single_state_featurizer_with_interpreter_state_not_with_action_listen(
-    unpacked_trained_spacybot_path: Text,
-):
-    # check that user features are ignored when action_name is not action_listen
-    from rasa.core.agent import Agent
 
-    interpreter = Agent.load(unpacked_trained_spacybot_path).interpreter
-    f = SingleStateFeaturizer()
-    f._default_feature_states[INTENT] = {"a": 0, "b": 1}
-    f._default_feature_states[ENTITIES] = {"c": 0}
-    f._default_feature_states[ACTION_NAME] = {"e": 0, "d": 1, "action_listen": 2}
-    f._default_feature_states[SLOTS] = {"e_0": 0, "f_0": 1, "g_0": 2}
-    f._default_feature_states[ACTIVE_LOOP] = {"h": 0, "i": 1, "j": 2, "k": 3}
 
-    encoded = f.encode_state(
+#
+# encode entities
+# (always needs lookup table build from tokenized messages incl. entities)
+#
+
+
+def test_encode_entities__with_entity_roles_and_groups():
+
+    # create fake message that has been tokenized and entities have been extracted
+    text = "I am flying from London to Paris"
+    tokens = [
+        Token(text=match.group(), start=match.start())
+        for match in re.finditer(r"\S+", text)
+    ]
+    entity_tags = ["city", f"city{ENTITY_LABEL_SEPARATOR}to"]
+    entities = [
         {
-            "user": {"text": "a ball", "intent": "b", "entities": ["c"]},
-            "prev_action": {"action_name": "d", "action_text": "throw a ball"},
-            "active_loop": {"name": "k"},
-            "slots": {"e": (1.0,)},
+            ENTITY_ATTRIBUTE_TYPE: entity_tags[0],
+            ENTITY_ATTRIBUTE_VALUE: "London",
+            ENTITY_ATTRIBUTE_START: 17,
+            ENTITY_ATTRIBUTE_END: 23,
         },
-        interpreter=interpreter,
-    )
-
-    # check user input is ignored when action is not action_listen
-    assert list(encoded.keys()) == [ACTION_TEXT, ACTION_NAME, ACTIVE_LOOP, SLOTS]
-    assert encoded[ACTION_TEXT][0].features.shape[-1] == 300
-    assert (
-        encoded[ACTION_NAME][0].features != scipy.sparse.coo_matrix([[0, 1, 0]])
-    ).nnz == 0
-    assert (encoded[SLOTS][0].features != scipy.sparse.coo_matrix([[1, 0, 0]])).nnz == 0
-    assert (
-        encoded[ACTIVE_LOOP][0].features != scipy.sparse.coo_matrix([[0, 0, 0, 1]])
-    ).nnz == 0
-
-
-@pytest.mark.timeout(
-    300, func_only=True
-)  # these can take a longer time than the default timeout
-def test_single_state_featurizer_with_interpreter_state_with_no_action_name(
-    unpacked_trained_spacybot_path: Text,
-):
-    # check that action name features are not added by the featurizer when not
-    # present in the state and
-    # check user input is ignored when action is not action_listen
-    # and action_name is features are not added
-    from rasa.core.agent import Agent
+        {
+            ENTITY_ATTRIBUTE_TYPE: entity_tags[1],
+            ENTITY_ATTRIBUTE_VALUE: "Paris",
+            ENTITY_ATTRIBUTE_START: 27,
+            ENTITY_ATTRIBUTE_END: 32,
+        },
+    ]
+    message = Message({TEXT: text, TOKENS_NAMES[TEXT]: tokens, ENTITIES: entities})
 
-    interpreter = Agent.load(unpacked_trained_spacybot_path).interpreter
+    # create a lookup table that has seen this message
+    precomputations = CoreFeaturizationPrecomputations()
+    precomputations.add(message)
 
+    # instantiate matching domain and single state featurizer
+    domain = Domain(
+        intents=[],
+        entities=entity_tags,
+        slots=[],
+        responses={},
+        forms={},
+        action_names=[],
+    )
     f = SingleStateFeaturizer()
-    f._default_feature_states[INTENT] = {"a": 0, "b": 1}
-    f._default_feature_states[ENTITIES] = {"c": 0}
-    f._default_feature_states[ACTION_NAME] = {"e": 0, "d": 1, "action_listen": 2}
-    f._default_feature_states[SLOTS] = {"e_0": 0, "f_0": 1, "g_0": 2}
-    f._default_feature_states[ACTIVE_LOOP] = {"h": 0, "i": 1, "j": 2, "k": 3}
+    f.prepare_for_training(domain)
 
-    encoded = f.encode_state(
-        {
-            "user": {"text": "a ball", "intent": "b", "entities": ["c"]},
-            "prev_action": {"action_text": "throw a ball"},
-            "active_loop": {"name": "k"},
-            "slots": {"e": (1.0,)},
-        },
-        interpreter=interpreter,
+    # encode!
+    encoded = f.encode_entities(
+        entity_data={TEXT: text, ENTITIES: entities}, precomputations=precomputations,
     )
 
-    assert list(encoded.keys()) == [ACTION_TEXT, ACTIVE_LOOP, SLOTS]
-    assert encoded[ACTION_TEXT][0].features.shape[-1] == 300
-    assert (encoded[SLOTS][0].features != scipy.sparse.coo_matrix([[1, 0, 0]])).nnz == 0
-    assert (
-        encoded[ACTIVE_LOOP][0].features != scipy.sparse.coo_matrix([[0, 0, 0, 1]])
-    ).nnz == 0
+    # check
+    assert len(f.entity_tag_specs) == 1
+    tags_to_ids = f.entity_tag_specs[0].tags_to_ids
+    for idx, entity_tag in enumerate(entity_tags):
+        tags_to_ids[entity_tag] = idx + 1  # hence, city -> 1, city#to -> 2
+    assert sorted(list(encoded.keys())) == [ENTITY_TAGS]
+    assert np.all(
+        encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [1], [0], [2]]
+    )
 
 
-def test_state_features_for_attribute_raises_on_not_supported_attribute():
-    f = SingleStateFeaturizer()
+def test_encode_entities__with_bilou_entity_roles_and_groups():
 
-    with pytest.raises(ValueError):
-        f._state_features_for_attribute({}, "not-supported-attribute")
+    # Instantiate domain and configure the single state featurizer for this domain.
+    # Note that there are 2 entity tags here.
+    entity_tags = ["city", f"city{ENTITY_LABEL_SEPARATOR}to"]
+    domain = Domain(
+        intents=[],
+        entities=entity_tags,
+        slots=[],
+        responses={},
+        forms={},
+        action_names=[],
+    )
+    f = SingleStateFeaturizer()
+    f.prepare_for_training(domain, bilou_tagging=True)
 
+    # (1) example with both entities
 
-def test_to_sparse_sentence_features():
-    features = [
-        Features(
-            scipy.sparse.csr_matrix(np.random.randint(5, size=(5, 10))),
-            FEATURE_TYPE_SEQUENCE,
-            TEXT,
-            "some-featurizer",
-        )
+    # create message that has been tokenized and where entities have been extracted
+    text = "I am flying from London to Paris"
+    tokens = [
+        Token(text=match.group(), start=match.start())
+        for match in re.finditer(r"\S+", text)
+    ]
+    entities = [
+        {
+            ENTITY_ATTRIBUTE_TYPE: entity_tags[0],
+            ENTITY_ATTRIBUTE_VALUE: "London",
+            ENTITY_ATTRIBUTE_START: 17,
+            ENTITY_ATTRIBUTE_END: 23,
+        },
+        {
+            ENTITY_ATTRIBUTE_TYPE: entity_tags[1],
+            ENTITY_ATTRIBUTE_VALUE: "Paris",
+            ENTITY_ATTRIBUTE_START: 27,
+            ENTITY_ATTRIBUTE_END: 32,
+        },
     ]
+    message = Message({TEXT: text, TOKENS_NAMES[TEXT]: tokens, ENTITIES: entities})
 
-    sentence_features = SingleStateFeaturizer._to_sparse_sentence_features(features)
+    # create a lookup table that has seen this message
+    precomputations = CoreFeaturizationPrecomputations()
+    precomputations.add(message)
 
-    assert len(sentence_features) == 1
-    assert FEATURE_TYPE_SENTENCE == sentence_features[0].type
-    assert features[0].origin == sentence_features[0].origin
-    assert features[0].attribute == sentence_features[0].attribute
-    assert sentence_features[0].features.shape == (1, 10)
+    # encode!
+    encoded = f.encode_entities(
+        {TEXT: text, ENTITIES: entities,},
+        precomputations=precomputations,
+        bilou_tagging=True,
+    )
+    assert sorted(list(encoded.keys())) == sorted([ENTITY_TAGS])
+    assert np.all(
+        encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [4], [0], [8]]
+    )
 
+    # (2) example with only the "city" entity
 
-@pytest.mark.timeout(
-    300, func_only=True
-)  # these can take a longer time than the default timeout
-def test_single_state_featurizer_uses_regex_interpreter(
-    unpacked_trained_spacybot_path: Text,
-):
-    from rasa.core.agent import Agent
+    # create message that has been tokenized and where entities have been extracted
+    text = "I am flying to Saint Petersburg"
+    tokens = [
+        Token(text=match.group(), start=match.start())
+        for match in re.finditer(r"\S+", text)
+    ]
+    entities = [
+        {
+            ENTITY_ATTRIBUTE_TYPE: "city",
+            ENTITY_ATTRIBUTE_VALUE: "Saint Petersburg",
+            ENTITY_ATTRIBUTE_START: 15,
+            ENTITY_ATTRIBUTE_END: 31,
+        },
+    ]
+    message = Message({TEXT: text, TOKENS_NAMES[TEXT]: tokens, ENTITIES: entities})
 
-    domain = Domain(
-        intents=[], entities=[], slots=[], responses={}, forms={}, action_names=[],
+    # create a lookup table that has seen this message
+    precomputations = CoreFeaturizationPrecomputations()
+    precomputations.add(message)
+
+    # encode!
+    encoded = f.encode_entities(
+        {TEXT: text, ENTITIES: entities,},
+        precomputations=precomputations,
+        bilou_tagging=True,
     )
-    f = SingleStateFeaturizer()
-    # simulate that core was trained separately by passing
-    # RegexInterpreter to prepare_for_training
-    f.prepare_for_training(domain, RegexInterpreter())
-    # simulate that nlu and core models were manually combined for prediction
-    # by passing trained interpreter to encode_all_actions
-    interpreter = Agent.load(unpacked_trained_spacybot_path).interpreter
-    features = f._extract_state_features({TEXT: "some text"}, interpreter)
-    # RegexInterpreter cannot create features for text, therefore since featurizer
-    # was trained without nlu, features for text should be empty
-    assert not features
+    assert sorted(list(encoded.keys())) == sorted([ENTITY_TAGS])
+    assert np.all(encoded[ENTITY_TAGS][0].features == [[0], [0], [0], [0], [1], [3]])
diff --git a/tests/core/featurizers/test_tracker_featurizer.py b/tests/core/featurizers/test_tracker_featurizer.py
index e5f58eec803e..25b4b248f256 100644
--- a/tests/core/featurizers/test_tracker_featurizer.py
+++ b/tests/core/featurizers/test_tracker_featurizer.py
@@ -4,17 +4,24 @@
 import pytest
 
 from rasa.core.featurizers.single_state_featurizer import (
-    SingleStateFeaturizer,
-    IntentTokenizerSingleStateFeaturizer,
+    SingleStateFeaturizer2 as SingleStateFeaturizer,
+)
+from rasa.core.featurizers.single_state_featurizer import (
+    IntentTokenizerSingleStateFeaturizer2 as IntentTokenizerSingleStateFeaturizer,
 )
 from rasa.core.featurizers.tracker_featurizers import (
-    TrackerFeaturizer,
-    FullDialogueTrackerFeaturizer,
-    MaxHistoryTrackerFeaturizer,
-    IntentMaxHistoryTrackerFeaturizer,
+    TrackerFeaturizer2 as TrackerFeaturizer,
+)
+from rasa.core.featurizers.tracker_featurizers import (
+    MaxHistoryTrackerFeaturizer2 as MaxHistoryTrackerFeaturizer,
+)
+from rasa.core.featurizers.tracker_featurizers import (
+    IntentMaxHistoryTrackerFeaturizer2 as IntentMaxHistoryTrackerFeaturizer,
+)
+from rasa.core.featurizers.tracker_featurizers import (
+    FullDialogueTrackerFeaturizer2 as FullDialogueTrackerFeaturizer,
 )
 from rasa.shared.core.domain import Domain
-from rasa.shared.nlu.interpreter import RegexInterpreter
 from tests.core.utilities import user_uttered
 from rasa.shared.nlu.training_data.features import Features
 from rasa.shared.nlu.constants import INTENT, ACTION_NAME
@@ -36,7 +43,7 @@ def test_fail_to_load_non_existent_featurizer():
 
 def test_persist_and_load_tracker_featurizer(tmp_path: Text, moodbot_domain: Domain):
     state_featurizer = SingleStateFeaturizer()
-    state_featurizer.prepare_for_training(moodbot_domain, RegexInterpreter())
+    state_featurizer.prepare_for_training(moodbot_domain)
     tracker_featurizer = MaxHistoryTrackerFeaturizer(state_featurizer)
 
     tracker_featurizer.persist(tmp_path)
@@ -118,7 +125,7 @@ def test_featurize_trackers_raises_on_missing_state_featurizer(domain: Domain):
     tracker_featurizer = TrackerFeaturizer()
 
     with pytest.raises(InvalidTrackerFeaturizerUsageError):
-        tracker_featurizer.featurize_trackers([], domain, RegexInterpreter())
+        tracker_featurizer.featurize_trackers([], domain, precomputations=None)
 
 
 def compare_featurized_states(
@@ -157,7 +164,7 @@ def test_featurize_trackers_with_full_dialogue_tracker_featurizer(
     tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer)
 
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [moodbot_tracker], moodbot_domain, RegexInterpreter()
+        [moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -226,7 +233,7 @@ def test_trackers_ignore_action_unlikely_intent_with_full_dialogue_tracker_featu
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
         [tracker],
         moodbot_domain,
-        RegexInterpreter(),
+        precomputations=None,
         ignore_action_unlikely_intent=True,
     )
 
@@ -294,7 +301,7 @@ def test_trackers_keep_action_unlikely_intent_with_full_dialogue_tracker_featuri
     tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer)
 
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [tracker], moodbot_domain, RegexInterpreter(),
+        [tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -344,10 +351,9 @@ def test_create_state_features_full_dialogue_tracker_featurizer(
 ):
     state_featurizer = SingleStateFeaturizer()
     tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer)
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [moodbot_tracker], moodbot_domain, interpreter
+        [moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -401,10 +407,12 @@ def test_state_features_ignore_action_unlikely_intent_full_dialogue_tracker_feat
 
     state_featurizer = SingleStateFeaturizer()
     tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer)
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [tracker], moodbot_domain, interpreter, ignore_action_unlikely_intent=True
+        [tracker],
+        moodbot_domain,
+        precomputations=None,
+        ignore_action_unlikely_intent=True,
     )
 
     expected_features = [
@@ -456,10 +464,9 @@ def test_state_features_keep_action_unlikely_intent_full_dialogue_tracker_featur
 
     state_featurizer = SingleStateFeaturizer()
     tracker_featurizer = FullDialogueTrackerFeaturizer(state_featurizer)
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [tracker], moodbot_domain, interpreter,
+        [tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -751,7 +758,7 @@ def test_featurize_trackers_with_max_history_tracker_featurizer(
     )
 
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [moodbot_tracker], moodbot_domain, RegexInterpreter()
+        [moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -874,7 +881,7 @@ def test_featurize_trackers_ignore_action_unlikely_intent_max_history_featurizer
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
         [tracker],
         moodbot_domain,
-        RegexInterpreter(),
+        precomputations=None,
         ignore_action_unlikely_intent=True,
     )
 
@@ -938,7 +945,7 @@ def test_featurize_trackers_keep_action_unlikely_intent_max_history_featurizer(
     )
 
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [tracker], moodbot_domain, RegexInterpreter(),
+        [tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1005,7 +1012,7 @@ def test_deduplicate_featurize_trackers_with_max_history_tracker_featurizer(
 
     # Add Duplicate moodbot_tracker states should get removed.
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [moodbot_tracker, moodbot_tracker], moodbot_domain, RegexInterpreter()
+        [moodbot_tracker, moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1117,10 +1124,9 @@ def test_create_state_features_with_max_history_tracker_featurizer(
     tracker_featurizer = MaxHistoryTrackerFeaturizer(
         state_featurizer, max_history=max_history
     )
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [moodbot_tracker], moodbot_domain, interpreter
+        [moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1181,10 +1187,12 @@ def test_create_state_features_ignore_action_unlikely_intent_max_history_featuri
     tracker_featurizer = MaxHistoryTrackerFeaturizer(
         state_featurizer, max_history=max_history
     )
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [tracker], moodbot_domain, interpreter, ignore_action_unlikely_intent=True
+        [tracker],
+        moodbot_domain,
+        precomputations=None,
+        ignore_action_unlikely_intent=True,
     )
 
     expected_features = [
@@ -1243,10 +1251,9 @@ def test_create_state_features_keep_action_unlikely_intent_max_history_featurize
     tracker_featurizer = MaxHistoryTrackerFeaturizer(
         state_featurizer, max_history=max_history
     )
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [tracker], moodbot_domain, interpreter,
+        [tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1579,7 +1586,7 @@ def test_featurize_trackers_with_intent_max_history_tracker_featurizer(
     )
 
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [moodbot_tracker], moodbot_domain, RegexInterpreter()
+        [moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1659,7 +1666,7 @@ def test_trackers_ignore_action_unlikely_intent_intent_max_history_featurizer(
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
         [tracker],
         moodbot_domain,
-        RegexInterpreter(),
+        precomputations=None,
         ignore_action_unlikely_intent=True,
     )
 
@@ -1724,7 +1731,7 @@ def test_trackers_keep_action_unlikely_intent_intent_max_history_featurizer(
     )
 
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [tracker], moodbot_domain, RegexInterpreter(),
+        [tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1783,7 +1790,7 @@ def test_deduplicate_featurize_trackers_with_intent_max_history_tracker_featuriz
 
     # Add Duplicate moodbot_tracker states should get removed.
     actual_features, actual_labels, entity_tags = tracker_featurizer.featurize_trackers(
-        [moodbot_tracker, moodbot_tracker], moodbot_domain, RegexInterpreter()
+        [moodbot_tracker, moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1861,10 +1868,9 @@ def test_create_state_features_with_intent_max_history_tracker_featurizer(
     tracker_featurizer = IntentMaxHistoryTrackerFeaturizer(
         state_featurizer, max_history=max_history
     )
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [moodbot_tracker], moodbot_domain, interpreter
+        [moodbot_tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -1927,10 +1933,12 @@ def test_state_features_ignore_action_unlikely_intent_intent_max_history_featuri
     tracker_featurizer = IntentMaxHistoryTrackerFeaturizer(
         state_featurizer, max_history=max_history
     )
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [tracker], moodbot_domain, interpreter, ignore_action_unlikely_intent=True,
+        [tracker],
+        moodbot_domain,
+        precomputations=None,
+        ignore_action_unlikely_intent=True,
     )
 
     expected_features = [
@@ -1992,10 +2000,9 @@ def test_state_features_keep_action_unlikely_intent_intent_max_history_featurize
     tracker_featurizer = IntentMaxHistoryTrackerFeaturizer(
         state_featurizer, max_history=max_history
     )
-    interpreter = RegexInterpreter()
-    state_featurizer.prepare_for_training(moodbot_domain, interpreter)
+    state_featurizer.prepare_for_training(moodbot_domain)
     actual_features = tracker_featurizer.create_state_features(
-        [tracker], moodbot_domain, interpreter,
+        [tracker], moodbot_domain, precomputations=None,
     )
 
     expected_features = [
@@ -2304,7 +2311,7 @@ def test_multilabels_with_intent_max_history_tracker_featurizer(
     )
 
     _1, actual_labels, _2 = tracker_featurizer.featurize_trackers(
-        [tracker1, tracker2], moodbot_domain, RegexInterpreter()
+        [tracker1, tracker2], moodbot_domain, precomputations=None,
     )
 
     greet_index = 5
diff --git a/tests/core/policies/test_ted_policy.py b/tests/core/policies/test_ted_policy.py
index e3bad2b79b1c..0908f8b5065d 100644
--- a/tests/core/policies/test_ted_policy.py
+++ b/tests/core/policies/test_ted_policy.py
@@ -1,22 +1,28 @@
 from pathlib import Path
 from typing import Optional, List, Type, Dict, Text, Any
+
 from unittest.mock import Mock
 import numpy as np
 import pytest
 import tests.core.test_policies
 from _pytest.monkeypatch import MonkeyPatch
 from _pytest.logging import LogCaptureFixture
-from rasa.core.constants import POLICY_PRIORITY, POLICY_MAX_HISTORY
 
-from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer
+
+from rasa.core.constants import POLICY_PRIORITY, POLICY_MAX_HISTORY
+from rasa.core.featurizers.tracker_featurizers import (
+    TrackerFeaturizer2 as TrackerFeaturizer,
+)
 from rasa.core.featurizers.tracker_featurizers import (
-    MaxHistoryTrackerFeaturizer,
-    TrackerFeaturizer,
+    MaxHistoryTrackerFeaturizer2 as MaxHistoryTrackerFeaturizer,
+)
+from rasa.core.featurizers.single_state_featurizer import (
+    SingleStateFeaturizer2 as SingleStateFeaturizer,
 )
 from rasa.core.policies.policy import PolicyGraphComponent as Policy
 from rasa.core.policies.ted_policy import TEDPolicyGraphComponent as TEDPolicy
 from rasa.core.policies.ted_policy import TEDPolicy as Rasa2TEDPolicy
-from rasa.engine.graph import ExecutionContext
+from rasa.engine.graph import ExecutionContext, GraphComponent
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
 from rasa.shared.core.constants import ACTION_LISTEN_NAME, ACTION_UNLIKELY_INTENT_NAME
@@ -29,9 +35,9 @@
     ActiveLoop,
 )
 from rasa.shared.exceptions import RasaException, InvalidConfigException
+from rasa.shared.nlu.interpreter import RegexInterpreter
 from rasa.utils.tensorflow.data_generator import RasaBatchDataGenerator
 from rasa.shared.core.trackers import DialogueStateTracker
-from rasa.shared.nlu.interpreter import RegexInterpreter
 from rasa.model_training import train_core
 from rasa.utils import train_utils
 from rasa.utils.tensorflow.constants import (
@@ -105,9 +111,11 @@ def test_diagnostics(
             ActionExecuted(ACTION_LISTEN_NAME),
         ],
     )
-    policy.train([GREET_RULE], domain, RegexInterpreter())
+    # TODO: drop when policies are migrated
+    precomputations = None if isinstance(policy, GraphComponent) else RegexInterpreter()
+    policy.train([GREET_RULE], domain, precomputations)
     prediction = policy.predict_action_probabilities(
-        GREET_RULE, domain, RegexInterpreter()
+        GREET_RULE, domain, precomputations,
     )
 
     assert prediction.diagnostic_data
@@ -281,8 +289,12 @@ def test_training_with_no_intent(
         training_trackers = tests.core.test_policies.train_trackers(
             default_domain, str(stories), augmentation_factor=20
         )
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(policy, GraphComponent) else RegexInterpreter()
+        )
         with pytest.raises(RasaException) as e:
-            policy.train(training_trackers, default_domain, RegexInterpreter())
+            policy.train(training_trackers, default_domain, precomputations)
 
         assert "No user features specified. Cannot train 'TED' model." == str(e.value)
 
@@ -299,9 +311,13 @@ def test_normalization(
         default_domain: Domain,
         monkeypatch: MonkeyPatch,
     ):
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         # first check the output is what we expect
         prediction = trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
         assert not prediction.is_end_to_end_prediction
         # count number of non-zero confidences
@@ -316,7 +332,7 @@ def test_normalization(
         mock = Mock()
         monkeypatch.setattr(train_utils, "normalize", mock.normalize)
         trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
 
         mock.normalize.assert_called_once()
@@ -324,11 +340,13 @@ def test_normalization(
     def test_label_data_assembly(
         self, trained_policy: TEDPolicy, default_domain: Domain
     ):
-        interpreter = RegexInterpreter()
-
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         state_featurizer = trained_policy.featurizer.state_featurizer
         encoded_all_labels = state_featurizer.encode_all_labels(
-            default_domain, interpreter
+            default_domain, precomputations
         )
 
         attribute_data, _ = model_data_utils.convert_to_data_format(encoded_all_labels)
@@ -357,11 +375,17 @@ def test_gen_batch(
         training_trackers = tests.core.test_policies.train_trackers(
             default_domain, stories_path, augmentation_factor=0
         )
-        interpreter = RegexInterpreter()
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         training_data, label_ids, entity_tags = trained_policy._featurize_for_training(
-            training_trackers, default_domain, interpreter
+            training_trackers, default_domain, precomputations,
+        )
+
+        _, all_labels = trained_policy._create_label_data(
+            default_domain, precomputations
         )
-        _, all_labels = trained_policy._create_label_data(default_domain, interpreter)
         model_data = trained_policy._create_model_data(
             training_data, label_ids, entity_tags, all_labels
         )
@@ -550,7 +574,10 @@ def test_ignore_action_unlikely_intent(
         tracker_events_with_action: List[Event],
         tracker_events_without_action: List[Event],
     ):
-        interpreter = RegexInterpreter()
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         tracker_with_action = DialogueStateTracker.from_events(
             "test 1", evts=tracker_events_with_action
         )
@@ -558,10 +585,10 @@ def test_ignore_action_unlikely_intent(
             "test 2", evts=tracker_events_without_action
         )
         prediction_with_action = trained_policy.predict_action_probabilities(
-            tracker_with_action, default_domain, interpreter
+            tracker_with_action, default_domain, precomputations,
         )
         prediction_without_action = trained_policy.predict_action_probabilities(
-            tracker_without_action, default_domain, interpreter
+            tracker_without_action, default_domain, precomputations,
         )
 
         # If the weights didn't change then both trackers
@@ -614,11 +641,15 @@ def test_normalization(
         default_domain: Domain,
         monkeypatch: MonkeyPatch,
     ):
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         # Mock actual normalization method
         mock = Mock()
         monkeypatch.setattr(train_utils, "normalize", mock.normalize)
         trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
 
         # function should not get called for margin loss_type
@@ -627,9 +658,13 @@ def test_normalization(
     def test_prediction_on_empty_tracker(
         self, trained_policy: Policy, default_domain: Domain
     ):
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         tracker = DialogueStateTracker(DEFAULT_SENDER_ID, default_domain.slots)
         prediction = trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
         assert not prediction.is_end_to_end_prediction
         assert len(prediction.probabilities) == default_domain.num_actions
@@ -707,9 +742,13 @@ def test_normalization(
         default_domain: Domain,
         monkeypatch: MonkeyPatch,
     ):
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         # first check the output is what we expect
         predicted_probabilities = trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         ).probabilities
         # there should be no normalization
         assert all([confidence > 0 for confidence in predicted_probabilities])
@@ -718,7 +757,7 @@ def test_normalization(
         mock = Mock()
         monkeypatch.setattr(train_utils, "normalize", mock.normalize)
         trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
 
         mock.normalize.assert_not_called()
@@ -763,9 +802,13 @@ def test_normalization(
         default_domain: Domain,
         monkeypatch: MonkeyPatch,
     ):
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         # first check the output is what we expect
         predicted_probabilities = trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         ).probabilities
 
         output_sums_to_1 = sum(predicted_probabilities) == pytest.approx(1)
@@ -775,7 +818,7 @@ def test_normalization(
         mock = Mock()
         monkeypatch.setattr(train_utils, "normalize", mock.normalize)
         trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
 
         mock.normalize.assert_not_called()
@@ -783,9 +826,13 @@ def test_normalization(
     def test_prediction_on_empty_tracker(
         self, trained_policy: Policy, default_domain: Domain
     ):
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         tracker = DialogueStateTracker(DEFAULT_SENDER_ID, default_domain.slots)
         prediction = trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
         assert not prediction.is_end_to_end_prediction
         assert len(prediction.probabilities) == default_domain.num_actions
@@ -897,6 +944,7 @@ def test_featurizer(
         tmp_path: Path,
         execution_context: ExecutionContext,
     ):
+
         assert isinstance(trained_policy.featurizer, MaxHistoryTrackerFeaturizer)
         assert isinstance(
             trained_policy.featurizer.state_featurizer, SingleStateFeaturizer
diff --git a/tests/core/test_policies.py b/tests/core/test_policies.py
index b19b52fa4cff..8fd547014f7a 100644
--- a/tests/core/test_policies.py
+++ b/tests/core/test_policies.py
@@ -6,16 +6,19 @@
 import numpy as np
 import pytest
 from _pytest.tmpdir import TempPathFactory
+
+from rasa.core import training
 from rasa.core.constants import DEFAULT_POLICY_PRIORITY, POLICY_MAX_HISTORY
+from rasa.core.policies.policy import SupportedData, Policy, InvalidPolicyConfig
+from rasa.core.policies.rule_policy import RulePolicy
+from rasa.core.policies.ted_policy import TEDPolicy
+from rasa.core.policies.memoization import AugmentedMemoizationPolicy, MemoizationPolicy
 from rasa.engine.graph import ExecutionContext, GraphSchema, GraphComponent
 from rasa.engine.storage.local_model_storage import LocalModelStorage
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
-
-from rasa.shared.core.generator import TrackerWithCachedStates
-
-from rasa.core import training
 from rasa.shared.constants import DEFAULT_SENDER_ID
+from rasa.shared.nlu.interpreter import RegexInterpreter
 from rasa.shared.core.constants import (
     ACTION_LISTEN_NAME,
     ACTION_UNLIKELY_INTENT_NAME,
@@ -28,21 +31,23 @@
     EntitiesAdded,
     SlotSet,
 )
-from rasa.core.featurizers.single_state_featurizer import (
-    SingleStateFeaturizer,
-    IntentTokenizerSingleStateFeaturizer,
+from rasa.core.featurizers.tracker_featurizers import (
+    TrackerFeaturizer2 as TrackerFeaturizer,
 )
 from rasa.core.featurizers.tracker_featurizers import (
-    MaxHistoryTrackerFeaturizer,
-    TrackerFeaturizer,
-    IntentMaxHistoryTrackerFeaturizer,
+    MaxHistoryTrackerFeaturizer2 as MaxHistoryTrackerFeaturizer,
+)
+from rasa.core.featurizers.tracker_featurizers import (
+    IntentMaxHistoryTrackerFeaturizer2 as IntentMaxHistoryTrackerFeaturizer,
+)
+from rasa.core.featurizers.single_state_featurizer import (
+    SingleStateFeaturizer2 as SingleStateFeaturizer,
+)
+from rasa.core.featurizers.single_state_featurizer import (
+    IntentTokenizerSingleStateFeaturizer2 as IntentTokenizerSingleStateFeaturizer,
 )
-from rasa.shared.nlu.interpreter import RegexInterpreter
-from rasa.core.policies.policy import SupportedData, Policy, InvalidPolicyConfig
-from rasa.core.policies.rule_policy import RulePolicy
-from rasa.core.policies.ted_policy import TEDPolicy
-from rasa.core.policies.memoization import AugmentedMemoizationPolicy, MemoizationPolicy
 from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.generator import TrackerWithCachedStates
 from tests.dialogues import TEST_DEFAULT_DIALOGUE
 from tests.core.utilities import get_tracker, tracker_from_dialogue
 
@@ -133,7 +138,12 @@ def trained_policy(
         training_trackers = train_trackers(
             default_domain, stories_path, augmentation_factor=20
         )
-        policy.train(training_trackers, default_domain, RegexInterpreter())
+
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(policy, GraphComponent) else RegexInterpreter()
+        )
+        policy.train(training_trackers, default_domain, precomputations)
         return policy
 
     def test_featurizer(
@@ -144,6 +154,10 @@ def test_featurizer(
         tmp_path: Path,
         execution_context: ExecutionContext,
     ):
+        if not isinstance(trained_policy, GraphComponent):
+            # TODO: Drop after all policies are migrated to `GraphComponent`
+            return
+
         assert isinstance(trained_policy.featurizer, MaxHistoryTrackerFeaturizer)
         assert trained_policy.featurizer.max_history == self.max_history
         assert isinstance(
@@ -195,12 +209,16 @@ def test_persist_and_load(
 
         trackers = train_trackers(default_domain, stories_path, augmentation_factor=20)
 
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(loaded, GraphComponent) else RegexInterpreter()
+        )
         for tracker in trackers:
             predicted_probabilities = loaded.predict_action_probabilities(
-                tracker, default_domain, RegexInterpreter()
+                tracker, default_domain, precomputations,
             )
             actual_probabilities = trained_policy.predict_action_probabilities(
-                tracker, default_domain, RegexInterpreter()
+                tracker, default_domain, precomputations,
             )
             assert predicted_probabilities == actual_probabilities
 
@@ -208,8 +226,12 @@ def test_prediction_on_empty_tracker(
         self, trained_policy: Policy, default_domain: Domain
     ):
         tracker = DialogueStateTracker(DEFAULT_SENDER_ID, default_domain.slots)
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         prediction = trained_policy.predict_action_probabilities(
-            tracker, default_domain, RegexInterpreter()
+            tracker, default_domain, precomputations,
         )
         assert not prediction.is_end_to_end_prediction
         assert len(prediction.probabilities) == default_domain.num_actions
@@ -234,9 +256,10 @@ def test_persist_and_load_empty_policy(
             resource,
             execution_context,
         )
-        empty_policy.train([], default_domain, RegexInterpreter())
 
         if isinstance(empty_policy, GraphComponent):
+            empty_policy.train([], default_domain, precomputations=None)
+            # TODO: add persist step
             loaded = empty_policy.__class__.load(
                 self._config(DEFAULT_POLICY_PRIORITY),
                 default_model_storage,
@@ -254,8 +277,12 @@ def test_persist_and_load_empty_policy(
     def _get_next_action(policy: Policy, events: List[Event], domain: Domain) -> Text:
         tracker = get_tracker(events)
 
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(policy, GraphComponent) else RegexInterpreter()
+        )
         scores = policy.predict_action_probabilities(
-            tracker, domain, RegexInterpreter()
+            tracker, domain, precomputations,
         ).probabilities
         index = scores.index(max(scores))
         return domain.action_names_or_texts[index]
@@ -268,7 +295,8 @@ def _get_next_action(policy: Policy, events: List[Event], domain: Domain) -> Tex
             (
                 [
                     {
-                        "name": "MaxHistoryTrackerFeaturizer",
+                        # TODO: remove "2" when migration of policies is done
+                        "name": "MaxHistoryTrackerFeaturizer2",
                         "max_history": 12,
                         "state_featurizer": [],
                     }
@@ -277,17 +305,19 @@ def _get_next_action(policy: Policy, events: List[Event], domain: Domain) -> Tex
                 type(None),
             ),
             (
-                [{"name": "MaxHistoryTrackerFeaturizer", "max_history": 12}],
+                # TODO: remove "2" when migration of policies is done
+                [{"name": "MaxHistoryTrackerFeaturizer2", "max_history": 12}],
                 MaxHistoryTrackerFeaturizer(max_history=12),
                 type(None),
             ),
             (
                 [
                     {
-                        "name": "IntentMaxHistoryTrackerFeaturizer",
+                        # TODO: remove "2" when migration of policies is done
+                        "name": "IntentMaxHistoryTrackerFeaturizer2",
                         "max_history": 12,
                         "state_featurizer": [
-                            {"name": "IntentTokenizerSingleStateFeaturizer"}
+                            {"name": "IntentTokenizerSingleStateFeaturizer2"}
                         ],
                     }
                 ],
@@ -332,16 +362,18 @@ def test_different_featurizer_configs(
         "featurizer_config",
         [
             [
-                {"name": "MaxHistoryTrackerFeaturizer", "max_history": 12},
-                {"name": "MaxHistoryTrackerFeaturizer", "max_history": 12},
+                # TODO: remove "2" when migration of policies is done
+                {"name": "MaxHistoryTrackerFeaturizer2", "max_history": 12},
+                {"name": "MaxHistoryTrackerFeaturizer2", "max_history": 12},
             ],
             [
                 {
-                    "name": "IntentMaxHistoryTrackerFeaturizer",
+                    # TODO: remove "2" when migration of policies is done
+                    "name": "IntentMaxHistoryTrackerFeaturizer2",
                     "max_history": 12,
                     "state_featurizer": [
-                        {"name": "IntentTokenizerSingleStateFeaturizer"},
-                        {"name": "IntentTokenizerSingleStateFeaturizer"},
+                        {"name": "IntentTokenizerSingleStateFeaturizer2"},
+                        {"name": "IntentTokenizerSingleStateFeaturizer2"},
                     ],
                 }
             ],
@@ -386,6 +418,9 @@ def create_policy(
         return MemoizationPolicy(priority=priority, max_history=max_history)
 
     def test_featurizer(self, trained_policy: Policy, tmp_path: Path):
+        if not isinstance(trained_policy, GraphComponent):
+            # TODO: Drop after all policies are migrated to `GraphComponent`
+            return
         assert isinstance(trained_policy.featurizer, MaxHistoryTrackerFeaturizer)
         assert trained_policy.featurizer.state_featurizer is None
         trained_policy.persist(str(tmp_path))
@@ -400,7 +435,12 @@ def test_memorise(
         stories_path: Text,
     ):
         trackers = train_trackers(default_domain, stories_path, augmentation_factor=20)
-        trained_policy.train(trackers, default_domain, RegexInterpreter())
+
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
+        trained_policy.train(trackers, default_domain, precomputations)
         lookup_with_augmentation = trained_policy.lookup
 
         trackers = [
@@ -426,9 +466,12 @@ def test_memorise(
         trackers_no_augmentation = train_trackers(
             default_domain, stories_path, augmentation_factor=0
         )
-        trained_policy.train(
-            trackers_no_augmentation, default_domain, RegexInterpreter()
+
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
         )
+        trained_policy.train(trackers_no_augmentation, default_domain, precomputations)
         lookup_no_augmentation = trained_policy.lookup
 
         assert lookup_no_augmentation == lookup_with_augmentation
@@ -470,8 +513,13 @@ def test_finetune_after_load(
         original_train_data = train_trackers(
             default_domain, stories_path, augmentation_factor=20
         )
+
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(loaded_policy, GraphComponent) else RegexInterpreter()
+        )
         loaded_policy.train(
-            original_train_data + [new_story], default_domain, RegexInterpreter()
+            original_train_data + [new_story], default_domain, precomputations,
         )
 
         # Get the hash of the tracker state of new story
@@ -522,7 +570,10 @@ def test_ignore_action_unlikely_intent(
         tracker_events_with_action: List[Event],
         tracker_events_without_action: List[Event],
     ):
-        interpreter = RegexInterpreter()
+        # TODO: drop when policies are migrated
+        precomputations = (
+            None if isinstance(trained_policy, GraphComponent) else RegexInterpreter()
+        )
         tracker_with_action = DialogueStateTracker.from_events(
             "test 1", evts=tracker_events_with_action, slots=default_domain.slots
         )
@@ -530,10 +581,10 @@ def test_ignore_action_unlikely_intent(
             "test 2", evts=tracker_events_without_action, slots=default_domain.slots
         )
         prediction_with_action = trained_policy.predict_action_probabilities(
-            tracker_with_action, default_domain, interpreter
+            tracker_with_action, default_domain, precomputations,
         )
         prediction_without_action = trained_policy.predict_action_probabilities(
-            tracker_without_action, default_domain, interpreter
+            tracker_without_action, default_domain, precomputations,
         )
 
         # Memoization shouldn't be affected with the
diff --git a/tests/shared/nlu/training_data/test_features.py b/tests/shared/nlu/training_data/test_features.py
index 674340cd06fb..c6e326cd93dd 100644
--- a/tests/shared/nlu/training_data/test_features.py
+++ b/tests/shared/nlu/training_data/test_features.py
@@ -1,3 +1,6 @@
+import itertools
+from typing import Optional, Text, List, Dict, Tuple, Any
+
 import numpy as np
 import pytest
 import scipy.sparse
@@ -11,6 +14,25 @@
 )
 
 
+@pytest.mark.parametrize(
+    "type,is_sparse,",
+    itertools.product([FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE], [True, False]),
+)
+def test_print(type: Text, is_sparse: bool):
+    first_dim = 1 if type == FEATURE_TYPE_SEQUENCE else 3
+    matrix = np.full(shape=(first_dim, 2), fill_value=1)
+    if is_sparse:
+        matrix = scipy.sparse.coo_matrix(matrix)
+    feat = Features(
+        features=matrix,
+        attribute="fixed-attribute",
+        feature_type=type,
+        origin="origin--doesn't-matter-here",
+    )
+    assert repr(feat)
+    assert str(feat)
+
+
 def test_combine_with_existing_dense_features():
     existing_features = Features(
         np.array([[1, 0, 2, 3], [2, 0, 0, 1]]), FEATURE_TYPE_SEQUENCE, TEXT, "test"
@@ -143,3 +165,289 @@ def test_feature_fingerprints_take_into_account_full_array():
     )
 
     assert f1_sparse.fingerprint() != f2_sparse.fingerprint()
+
+
+def _generate_feature_list_and_modifications(
+    is_sparse: bool, type: Text, number: int
+) -> Tuple[List[Features], List[Dict[Text, Any]]]:
+    """Creates a list of features with the required properties and some modifications.
+    The modifications are given by a list of kwargs dictionaries that can be used to
+    instantiate `Features` that differ from the aforementioned list of features in
+    exactly one property (i.e. type, sequence length (if the given `type` is
+    sequence type only), attribute, origin)
+    Args:
+        is_sparse: whether all features should be sparse
+        type: the type to be used for all features
+        number: the number of features to generate
+    Returns:
+      a tuple containing a list of features with the requested attributes and
+      a list of kwargs dictionaries that can be used to instantiate `Features` that
+      differ from the aforementioned list of features in exactly one property
+    """
+
+    seq_len = 3
+    first_dim = 1 if type == FEATURE_TYPE_SENTENCE else 3
+
+    # create list of features whose properties match - except the shapes and
+    # feature values which are chosen in a specific way
+    features_list = []
+    for idx in range(number):
+        matrix = np.full(shape=(first_dim, idx + 1), fill_value=idx + 1)
+        if is_sparse:
+            matrix = scipy.sparse.coo_matrix(matrix)
+        config = dict(
+            features=matrix,
+            attribute="fixed-attribute",
+            feature_type=type,
+            origin=f"origin-{idx}",
+        )
+        feat = Features(**config)
+        features_list.append(feat)
+
+    # prepare some Features that differ from the features above in certain ways
+    modifications = []
+    # - if we modify one attribute
+    modifications.append({**config, **{"attribute": "OTHER"}})
+    # - if we modify one attribute
+    other_type = (
+        FEATURE_TYPE_SENTENCE
+        if type == FEATURE_TYPE_SEQUENCE
+        else FEATURE_TYPE_SEQUENCE
+    )
+    other_seq_len = 1 if other_type == FEATURE_TYPE_SENTENCE else seq_len
+    other_matrix = np.full(shape=(other_seq_len, number - 1), fill_value=number)
+    if is_sparse:
+        other_matrix = scipy.sparse.coo_matrix(other_matrix)
+    modifications.append(
+        {**config, **{"feature_type": other_type, "features": other_matrix}}
+    )
+    # - if we modify one origin
+    modifications.append({**config, **{"origin": "Other"}})
+    # - if we modify one sequence length
+    if type == FEATURE_TYPE_SEQUENCE:
+        matrix = np.full(shape=(seq_len + 1, idx + 1), fill_value=idx)
+        if is_sparse:
+            matrix = scipy.sparse.coo_matrix(matrix)
+        modifications.append({**config, **{"features": matrix}})
+
+    return features_list, modifications
+
+
+@pytest.mark.parametrize(
+    "is_sparse,type,number",
+    itertools.product(
+        [True, False], [FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE], [1, 2, 5]
+    ),
+)
+def test_combine(is_sparse: bool, type: Text, number: int):
+
+    features_list, modifications = _generate_feature_list_and_modifications(
+        is_sparse=is_sparse, type=type, number=number
+    )
+    modified_features = [Features(**config) for config in modifications]
+    first_dim = features_list[0].features.shape[0]
+
+    expected_origin = [f"origin-{idx}" for idx in range(len(features_list))]
+    if number == 1:
+        # in this case the origin will be same str as before, not a list
+        expected_origin = expected_origin[0]
+
+    # works as expected
+    combination = Features.combine(features_list, expected_origins=expected_origin)
+    assert combination.features.shape[1] == int(number * (number + 1) / 2)
+    assert combination.features.shape[0] == first_dim
+    assert combination.origin == expected_origin
+    assert combination.is_sparse() == is_sparse
+    matrix = combination.features
+    if is_sparse:
+        matrix = combination.features.todense()
+    for idx in range(number):
+        offset = int(idx * (idx + 1) / 2)
+        assert np.all(matrix[:, offset : (offset + idx + 1)] == idx + 1)
+
+    # fails as expected in these cases
+    if number > 1:
+        for modified_feature in modified_features:
+            features_list_copy = features_list.copy()
+            features_list_copy[-1] = modified_feature
+            with pytest.raises(ValueError):
+                Features.combine(features_list_copy, expected_origins=expected_origin)
+
+
+@pytest.mark.parametrize(
+    "is_sparse,type,number",
+    itertools.product(
+        [True, False], [FEATURE_TYPE_SENTENCE, FEATURE_TYPE_SEQUENCE], [1, 2, 5]
+    ),
+)
+def test_filter(is_sparse: bool, type: Text, number: int):
+
+    features_list, modifications = _generate_feature_list_and_modifications(
+        is_sparse=is_sparse, type=type, number=number
+    )
+
+    # fix the filter configuration first (note: we ignore origin on purpose for now)
+    filter_config = dict(attributes=["fixed-attribute"], type=type, is_sparse=is_sparse)
+
+    # we get all features back if all features map...
+    result = Features.filter(features_list, **filter_config)
+    assert len(result) == number
+
+    # ... and less matches if we change the (relevant) properties of some features
+    modified_features = [
+        Features(**config)
+        for config in modifications
+        if set(config.keys()).intersection(filter_config.keys())
+    ]
+    if number > 1:
+        for modified_feature in modified_features:
+            features_list_copy = features_list.copy()
+            features_list_copy[-1] = modified_feature
+            result = Features.filter(features_list_copy, **filter_config)
+            assert len(result) == number - 1
+    if number > 2:
+        for feat_a, feat_b in itertools.combinations(modified_features, 2):
+            features_list_copy = features_list.copy()
+            features_list_copy[-1] = feat_a
+            features_list_copy[-2] = feat_b
+            result = Features.filter(features_list_copy, **filter_config)
+            assert len(result) == number - 2
+
+    # don't forget to check the origin
+    filter_config = dict(
+        attributes=["fixed-attribute"],
+        type=type,
+        origin=["origin-0"],
+        is_sparse=is_sparse,
+    )
+    result = Features.filter(features_list, **filter_config)
+    assert len(result) == 1
+
+
+@pytest.mark.parametrize(
+    "num_features_per_attribute,specified_attributes",
+    itertools.product(
+        [{"a": 3, "b": 1, "c": 0}],
+        [None, ["a", "b", "c", "doesnt-appear"], ["doesnt-appear"]],
+    ),
+)
+def test_groupby(
+    num_features_per_attribute: Dict[Text, int],
+    specified_attributes: Optional[List[Text]],
+):
+
+    features_list = []
+    for attribute, number in num_features_per_attribute.items():
+        for idx in range(number):
+            matrix = np.full(shape=(1, idx + 1), fill_value=idx + 1)
+            config = dict(
+                features=matrix,
+                attribute=attribute,
+                feature_type=FEATURE_TYPE_SEQUENCE,  # doesn't matter
+                origin=f"origin-{idx}",  # doens't matter
+            )
+            feat = Features(**config)
+            features_list.append(feat)
+
+    result = Features.groupby_attribute(features_list, attributes=specified_attributes)
+    if specified_attributes is None:
+        for attribute, number in num_features_per_attribute.items():
+            if number > 0:
+                assert attribute in result
+                assert len(result[attribute]) == number
+            else:
+                assert attribute not in result
+    else:
+        assert set(result.keys()) == set(specified_attributes)
+        for attribute in specified_attributes:
+            assert attribute in result
+            number = num_features_per_attribute.get(attribute, 0)
+            assert len(result[attribute]) == number
+
+
+@pytest.mark.parametrize(
+    "shuffle_mode,num_features_per_combination",
+    itertools.product(
+        ["reversed", "random"], [[1, 0, 0, 0], [1, 1, 1, 1], [2, 3, 4, 5], [0, 1, 2, 2]]
+    ),
+)
+def test_reduce(
+    shuffle_mode: Text, num_features_per_combination: Tuple[int, int, int, int]
+):
+
+    # all combinations - in the expected order
+    # (i.e. all sparse before all dense and sequence before sentence)
+    all_combinations = [
+        (FEATURE_TYPE_SEQUENCE, True),
+        (FEATURE_TYPE_SENTENCE, True),
+        (FEATURE_TYPE_SEQUENCE, False),
+        (FEATURE_TYPE_SENTENCE, False),
+    ]
+
+    # multiply accordingly and mess up the order
+    chosen_combinations = [
+        spec
+        for spec, num in zip(all_combinations, num_features_per_combination)
+        for _ in range(num)
+    ]
+    if shuffle_mode == "reversed":
+        messed_up_order = reversed(chosen_combinations)
+    else:
+        # Note: rng.permutation would mess up the types
+        rng = np.random.default_rng(23452345)
+        permutation = rng.permutation(len(chosen_combinations))
+        messed_up_order = [chosen_combinations[idx] for idx in permutation]
+
+    # create features accordingly
+    features_list = []
+    for idx, (type, is_sparse) in enumerate(messed_up_order):
+        first_dim = 1 if type == FEATURE_TYPE_SEQUENCE else 3
+        matrix = np.full(shape=(first_dim, 1), fill_value=1)
+        if is_sparse:
+            matrix = scipy.sparse.coo_matrix(matrix)
+        config = dict(
+            features=matrix,
+            attribute="fixed-attribute",  # must be the same
+            feature_type=type,
+            origin="origin-does-matter-here",  # must be the same
+        )
+        feat = Features(**config)
+        features_list.append(feat)
+
+    # reduce!
+    reduced_list = Features.reduce(features_list)
+    assert len(reduced_list) == sum(num > 0 for num in num_features_per_combination)
+    idx = 0
+    for num, (type, is_sparse) in zip(num_features_per_combination, all_combinations):
+        if num == 0:
+            # nothing to check here - because we already checked the length above
+            # and check the types and shape of all existing features in this loop
+            pass
+        else:
+            feature = reduced_list[idx]
+            assert feature.is_sparse() == is_sparse
+            assert feature.type == type
+            assert feature.features.shape[-1] == num
+            idx += 1
+
+
+@pytest.mark.parametrize("differ", ["attribute", "origin"])
+def test_reduce_raises_if_combining_different_origins_or_attributes(differ: Text):
+    # create features accordingly
+    arbitrary_fixed_type = FEATURE_TYPE_SENTENCE
+    features_list = []
+    for idx in range(2):
+        first_dim = 1
+        arbitrary_matrix_matching_type = np.full(shape=(first_dim, 1), fill_value=1)
+        config = dict(
+            features=arbitrary_matrix_matching_type,
+            attribute="fixed-attribute" if differ != "attribute" else f"attr-{idx}",
+            feature_type=arbitrary_fixed_type,
+            origin="fixed-origin" if differ != "origin" else f"origin-{idx}",
+        )
+        feat = Features(**config)
+        features_list.append(feat)
+
+    # reduce!
+    with pytest.raises(ValueError, match="Expected all Features"):
+        Features.reduce(features_list)


Code review comments:
diff_hunk: @@ -721,19 +722,22 @@ def train(
 
         return self._resource
 
-    def _featurize_tracker_for_e2e(
+    def _featurize_tracker(
         self,
         tracker: DialogueStateTracker,
         domain: Domain,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations] = None,
path: rasa/core/policies/ted_policy.py
text: @user1:
optional: How about making it a little less vague?
```suggestion
        precomputed_features: Optional[CoreFeaturizationPrecomputations] = None,
```

@author:
As mentioned before, the precomputations must be messages and not just features which is why I went with the more generic name

@user1:
how about `featurized_messages` then? 🤔 

@author:
`tokenized_and_featurized_message` ... :D  Long term there could also be classifications in there from more complex recipes? 🤔  And it is not like all features end up in that dictionary because the `SingleStateFeaturizer` still creates all the multi-hot like features and creates sentence features from sequence features and so on

@user1:
okay, makes sense 👍🏻 Should we still stress that this is specific for end-to-end? We can still rename this once it's no longer specific to end-to-end

@author:
The term "end to end" is used in lots of places. Here it was used to point out that the policies work with text directly, right? But we could have e.g. data with intent names and then someone decides to use bert to convert those intent names to dense features. .... Does that make sense or am I missing something here?

@user1:
That's true. But in that case we can simply rename this component, no?

@author:
How about `MessageContainerForCoreFeaturization` ?

diff_hunk: @@ -225,29 +193,49 @@ def _get_name_attribute(attributes: Set[Text]) -> Optional[Text]:
     def _extract_state_features(
         self,
         sub_state: SubState,
-        interpreter: NaturalLanguageInterpreter,
+        precomputations: Optional[CoreFeaturizationPrecomputations],
         sparse: bool = False,
-    ) -> Dict[Text, List[Features]]:
-        # this method is called during both prediction and training,
-        # `self._use_regex_interpreter == True` means that core was trained
-        # separately, therefore substitute interpreter based on some trained
-        # nlu model with default RegexInterpreter to make sure
-        # that prediction and train time features are the same
-        if self._use_regex_interpreter and not isinstance(
-            interpreter, RegexInterpreter
-        ):
-            interpreter = RegexInterpreter()
+    ) -> Dict[Text, List["Features"]]:
 
-        message = Message(data=sub_state)
-        # remove entities from possible attributes
+        # Remove entities from possible attributes
         attributes = set(
             attribute for attribute in sub_state.keys() if attribute != ENTITIES
         )
 
-        parsed_message = interpreter.featurize_message(message)
-        output = self._get_features_from_parsed_message(parsed_message, attributes)
+        # Collect features for all those attributes
+        if precomputations is not None:
+            attributes_to_features = precomputations.lookup_features(
+                sub_state, attributes=attributes
+            )
+            # if features for INTENT or ACTION_NAME exist,
+            # they are always sparse sequence features;
+            # transform them to sentence sparse features
+            if attributes_to_features.get(INTENT):
+                attributes_to_features[INTENT] = self._to_sparse_sentence_features(
+                    attributes_to_features[INTENT]
+                )
+            if attributes_to_features.get(ACTION_NAME):
+                attributes_to_features[ACTION_NAME] = self._to_sparse_sentence_features(
+                    attributes_to_features[ACTION_NAME]
+                )
+        else:
+            attributes_to_features = {}
+
+        # Combine and sort the features:
+        # Per attribute, combine features of same type and level into one Feature,
+        # and (if there are any such features) store the results in a list where
+        # - all the sparse features are listed first and a
+        # - sequence feature is always listed before the sentence feature of the
+        #   same type (sparse/not sparse).
+        output = {
+            attribute: Features.reduce(
+                features_list=features_list, expected_origins=None
+            )
+            for attribute, features_list in attributes_to_features.items()
+            if len(features_list) > 0  # otherwise, following will fail
+        }
path: rasa/core/featurizers/single_state_featurizer.py
text: @user1:
should we rather do this as part of `CoreFeaturizationPrecomputations` ?

@author:
would be a bit strange if `lookup_features` would do this but I can add `reduce` flag to the `lookup_features` function of `CoreFeaturizationPrecomputations` ... and move this inside the "then" case

@user1:
okay, then let's leave it here (I prefer that over a flag) 👍🏻

diff_hunk: @@ -39,13 +42,14 @@
     SPLIT_ENTITIES_BY_COMMA,
     SPLIT_ENTITIES_BY_COMMA_DEFAULT_VALUE,
 )
-from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter, RegexInterpreter
-from rasa.core.policies.policy import PolicyPrediction, PolicyGraphComponent
-from rasa.core.constants import DIALOGUE, POLICY_MAX_HISTORY
-from rasa.shared.constants import DIAGNOSTIC_DATA
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.core.domain import Domain
 from rasa.shared.core.constants import ACTIVE_LOOP, SLOTS, ACTION_LISTEN_NAME
 from rasa.shared.core.trackers import DialogueStateTracker
 from rasa.shared.core.generator import TrackerWithCachedStates
+from rasa.shared.core.events import EntitiesAdded, Event
+from rasa.shared.utils import io as shared_io_utils
path: rasa/core/policies/ted_policy.py
text: @user1:
optional: How about just using the full module path instead of the alias?
```suggestion
import rasa.shared.utils.io
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
    If advanced collision handling is enabled, then messages that have a strictly
```

diff_hunk: @@ -8,42 +9,58 @@
 from typing import Tuple, List, Optional, Dict, Text, Union, Any, Iterator, Set
 import numpy as np
 
+
 from rasa.core.featurizers.single_state_featurizer import SingleStateFeaturizer
-from rasa.shared.core.domain import State, Domain
-from rasa.shared.core.events import Event, ActionExecuted, UserUttered
+from rasa.core.featurizers.precomputation import CoreFeaturizationPrecomputations
+from rasa.core.exceptions import InvalidTrackerFeaturizerUsageError
 import rasa.shared.core.trackers
+import rasa.shared.utils.io
+from rasa.shared.nlu.constants import TEXT, INTENT, ENTITIES, ACTION_NAME
+from rasa.shared.nlu.training_data.features import Features
 from rasa.shared.core.trackers import DialogueStateTracker
-from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter
+from rasa.shared.core.domain import State, Domain
+from rasa.shared.core.events import Event, ActionExecuted, UserUttered
 from rasa.shared.core.constants import (
     USER,
     ACTION_UNLIKELY_INTENT_NAME,
     PREVIOUS_ACTION,
 )
-from rasa.shared.nlu.constants import TEXT, INTENT, ENTITIES
-from rasa.utils.tensorflow.constants import LABEL_PAD_ID
 from rasa.shared.exceptions import RasaException
-import rasa.shared.utils.io
-from rasa.shared.nlu.training_data.features import Features
-from rasa.core.exceptions import InvalidTrackerFeaturizerUsageError
-from rasa.shared.nlu.constants import ACTION_NAME
+from rasa.utils.tensorflow.constants import LABEL_PAD_ID
+from rasa.core.featurizers import _tracker_featurizers
 
 FEATURIZER_FILE = "featurizer.json"
 
 logger = logging.getLogger(__name__)
 
+# All code outside this module will continue to use the old `tracker_featurizer` module
+# TODO: This is a workaround around until we have all components migrated to
+# `GraphComponent`.
+TrackerFeaturizer = _tracker_featurizers.TrackerFeaturizer
+MaxHistoryTrackerFeaturizer = _tracker_featurizers.MaxHistoryTrackerFeaturizer
+IntentMaxHistoryTrackerFeaturizer = (
+    _tracker_featurizers.IntentMaxHistoryTrackerFeaturizer
+)
+FullDialogueTrackerFeaturizer = _tracker_featurizers.FullDialogueTrackerFeaturizer
+
 
 class InvalidStory(RasaException):
     """Exception that can be raised if story cannot be featurized."""
 
     def __init__(self, message: Text) -> None:
+        """Creates an InvalidStory exception.
+
+        Args:
+          message: a custom message
path: rasa/core/featurizers/tracker_featurizers.py
text: @user1:
```suggestion
          message: a custom exception message.
```

diff_hunk: @@ -150,7 +167,7 @@ def _create_entity_tags(
         return [
             [
                 self.state_featurizer.encode_entities(
-                    entity_data, interpreter, bilou_tagging
+                    entity_data, bilou_tagging, precomputations,
path: rasa/core/featurizers/tracker_featurizers.py
text: @user1:
the params are the wrong way around, aren't they? Might be worth using kwargs for this case.
```suggestion
                    entity_data, precomputations, bilou_tagging,
```

@author:
😬  good catch

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
            handle_collisions: if set to `True`, collisions where one message contains a
                larger or equal number of attributes and of features than the other
                message, then the collision will be resolved automatically.
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
path: rasa/core/featurizers/precomputation.py
text: @user1:
as this is a `GraphComponent` output during training time it has to implement the `Fingerprintable` protocol

@user1:
How about calling this `EndToEndFeatureLookup`? The current name seems quite general to me

@user2:
I agree that the current name doesn't say much. 

@author:
Oh, I see I mixed something up here (there was this one case where a fingerprint run might not return a `FingerprintStatus` but actual output (`Any`)). And I see now that it definitely needs a fingerprint - whether the result is cacheable or not, right?  Or does it have to be cacheable as well? (I mean, it could be that we just skip that and then when checking the cached results for that fingerprint we just find none) .

Regarding the name: The precomputations include attributes which are not "Features" (i.e. the "tokens" at the moment), which is why I wanted to avoid having "Features" in the name there.

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
path: rasa/core/featurizers/precomputation.py
text: @user1:
what do you think? 
```suggestion
                f"{cls.KEY_ATTRIBUTES} but received {len(attributes)} attributes ({attributes})."
```

@author:
👍

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
path: rasa/core/featurizers/precomputation.py
text: @user1:
nit:
```suggestion
        return key_attribute, str(sub_state[key_attribute])
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
          message_with_one_key_attribute: The message we want to add to the lookup
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
path: rasa/core/featurizers/precomputation.py
text: @user1:
can we reduce the indent a bit e.g. by using guards?

@author:
I like the idea - might even be better readable then

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
path: rasa/core/featurizers/precomputation.py
text: @user1:
should we have a `logger.debug` statement here?

@author:
yep, think that's a good idea. And I might/could remove the extra collision handling stuff - if I manage to remove the entities from the lookup - I think I just messed something up last time because they should not be necessary (in the lookup table). And that means that we definitely don't need to resolve anything.

@user1:
that would be great as this would also reduce a lot of complexity / testing effort

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
    def add_all(self, messages_with_one_key_attribute: List[Message]) -> None:
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
          attributes: if not `None`, this specifies the list of the attributes of the
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
path: rasa/core/featurizers/precomputation.py
text: @user1:
the google spec is slightly different: https://google.github.io/styleguide/pyguide.html#doc-function-raises
```suggestion
          ValueError: if any of the lookup table keys that can be generated from
            the given substate is not contained in the lookup table
          RuntimeError: if an inconsistency in the lookup table is detected;
            more precisely, if features for the same attribute are found in two
            looked up messages
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
path: rasa/core/featurizers/precomputation.py
text: @user1:
this seems somewhat fragile. Should we maybe keep the `action_names` separate in the `Domain` in the first place?

@author:
agree (which is why I added the check below - I should switch the order of that) and agree (but I didn't want to touch domain here)

@author:
I've improved the check to check exactly what we assume here and moved it to before these lines. Not sure about the `action_names` because `action_names_or_texts` already looks a bit strange to me (it's merging given action names, form names, default actions). Would rather leave it as is and revisit this during the disentangling tasks.

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
path: rasa/core/featurizers/precomputation.py
text: @user1:
Can this actually happen / can't we just trust the `Domain`? If we raise this, can we add some hints how one could potentially go about resolving this problem?
```suggestion
                "action names followed by the texts. Apparently that changed."
```

@author:
Guess no one has an interest in swapping those lists in the concatenation because I guess that would affect lots of label to index mappings downstream but the above looks so weird that I thought it's good to add this sanity check as explanation.

@user1:
Makes sense 👍🏻 Can still give some hints how to (maybe) resolve it?

@author:
yep, will do

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
+                # ... before changing this flag so we get a complete sub-state
+                event_copy.use_text_for_featurization = None
+                artificial_sub_state = event_copy.as_sub_state()
+                # split it up...
+                sub_states_from_event = []
path: rasa/core/featurizers/precomputation.py
text: @user1:
can we pull this up to avoid redefining it in every `if`?

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
+                # ... before changing this flag so we get a complete sub-state
+                event_copy.use_text_for_featurization = None
+                artificial_sub_state = event_copy.as_sub_state()
+                # split it up...
+                sub_states_from_event = []
+                intent = artificial_sub_state.pop(INTENT, None)
+                if intent:
+                    sub_states_from_event.append({INTENT: intent})
+                if len(artificial_sub_state):
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
                if artificial_sub_state:
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
path: rasa/core/featurizers/precomputation.py
text: @user1:
how about changing it back instead of doing a deepcopy? I guess that would be slightly more efficient 

@author:
That looked so dangerous to me 😬   Could these events be used somewhere else in parallel (potentially in the future)?

@user1:
good point! They couldn't be used in parallel _in the past_ but now with the graph this might actually happen when we are running in threads

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
+                # ... before changing this flag so we get a complete sub-state
+                event_copy.use_text_for_featurization = None
+                artificial_sub_state = event_copy.as_sub_state()
+                # split it up...
+                sub_states_from_event = []
+                intent = artificial_sub_state.pop(INTENT, None)
+                if intent:
+                    sub_states_from_event.append({INTENT: intent})
+                if len(artificial_sub_state):
+                    sub_states_from_event.append(artificial_sub_state)
+                    # FIXME: seems we can just remove entities here....
+            elif isinstance(event, ActionExecuted):
+                sub_states_from_event = []
+                sub_state = event.as_sub_state()
+                for key in [ACTION_NAME, ACTION_TEXT]:
+                    if key in sub_state:
+                        sub_states_from_event.append({key: sub_state[key]})
+            else:
+                sub_states_from_event = []
+
+            for sub_state in sub_states_from_event:
+                message = Message(data=sub_state)
+                self.add(message)
+
+
+class CoreFeaturizationPreparer(GraphComponent):
+    """Provides training data for core's NLU pipeline as well as lookup table buildup.
+
+    During training as well as during inference, the given data (i.e. story graph or
+    list of messages), will be used to derive a de-duplicated list of all possible
+    "prev_action" and "user" sub-states (with either `TEXT` and possibly `ENTITY`
+    attributes or only `INTENT` attribute) that could be generated from the given data.
+    """
+
+    # NOTE: If we wanted to create the training data for the components of the e2e
+    # featurization pipeline differently from the data that is needed for the lookup
+    # table (currently, the latter is used for training as well), we simply need to
+    # move the functionality of this component to e.g. a `StoryToLookUpDataConverter`,
+    # replace the functionality here with the required training data build up, and
+    # adapt the graph connections so that the `train` and `predict` steps of the e2e
+    # run consecutively and use the respective data.
+
+    @classmethod
+    @abstractmethod
+    def create(
+        cls,
+        config: Dict[Text, Any],
+        model_storage: ModelStorage,
+        resource: Resource,
+        execution_context: ExecutionContext,
+    ) -> GraphComponent:
+        """Creates a new `CoreFeaturizationPreparer`.
+
+        Args:
+            config: unused
+            model_storage: unused
+            resource: unused
+            execution_context: unused
+
+        Returns: An instantiated `CoreFeaturizationPreparer`.
path: rasa/core/featurizers/precomputation.py
text: @user1:
I'd just drop this docstring and refer to the parent class docstring

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
+                # ... before changing this flag so we get a complete sub-state
+                event_copy.use_text_for_featurization = None
+                artificial_sub_state = event_copy.as_sub_state()
+                # split it up...
+                sub_states_from_event = []
+                intent = artificial_sub_state.pop(INTENT, None)
+                if intent:
+                    sub_states_from_event.append({INTENT: intent})
+                if len(artificial_sub_state):
+                    sub_states_from_event.append(artificial_sub_state)
+                    # FIXME: seems we can just remove entities here....
+            elif isinstance(event, ActionExecuted):
+                sub_states_from_event = []
+                sub_state = event.as_sub_state()
+                for key in [ACTION_NAME, ACTION_TEXT]:
+                    if key in sub_state:
+                        sub_states_from_event.append({key: sub_state[key]})
+            else:
+                sub_states_from_event = []
+
+            for sub_state in sub_states_from_event:
+                message = Message(data=sub_state)
+                self.add(message)
+
+
+class CoreFeaturizationPreparer(GraphComponent):
path: rasa/core/featurizers/precomputation.py
text: @user1:
can we use the suffix `Converter` to align with some of the other components we have?

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
+                # ... before changing this flag so we get a complete sub-state
+                event_copy.use_text_for_featurization = None
+                artificial_sub_state = event_copy.as_sub_state()
+                # split it up...
+                sub_states_from_event = []
+                intent = artificial_sub_state.pop(INTENT, None)
+                if intent:
+                    sub_states_from_event.append({INTENT: intent})
+                if len(artificial_sub_state):
+                    sub_states_from_event.append(artificial_sub_state)
+                    # FIXME: seems we can just remove entities here....
+            elif isinstance(event, ActionExecuted):
+                sub_states_from_event = []
+                sub_state = event.as_sub_state()
+                for key in [ACTION_NAME, ACTION_TEXT]:
+                    if key in sub_state:
+                        sub_states_from_event.append({key: sub_state[key]})
+            else:
+                sub_states_from_event = []
+
+            for sub_state in sub_states_from_event:
+                message = Message(data=sub_state)
+                self.add(message)
+
+
+class CoreFeaturizationPreparer(GraphComponent):
+    """Provides training data for core's NLU pipeline as well as lookup table buildup.
+
+    During training as well as during inference, the given data (i.e. story graph or
+    list of messages), will be used to derive a de-duplicated list of all possible
+    "prev_action" and "user" sub-states (with either `TEXT` and possibly `ENTITY`
+    attributes or only `INTENT` attribute) that could be generated from the given data.
+    """
+
+    # NOTE: If we wanted to create the training data for the components of the e2e
+    # featurization pipeline differently from the data that is needed for the lookup
+    # table (currently, the latter is used for training as well), we simply need to
+    # move the functionality of this component to e.g. a `StoryToLookUpDataConverter`,
+    # replace the functionality here with the required training data build up, and
+    # adapt the graph connections so that the `train` and `predict` steps of the e2e
+    # run consecutively and use the respective data.
+
+    @classmethod
+    @abstractmethod
+    def create(
+        cls,
+        config: Dict[Text, Any],
+        model_storage: ModelStorage,
+        resource: Resource,
+        execution_context: ExecutionContext,
+    ) -> GraphComponent:
+        """Creates a new `CoreFeaturizationPreparer`.
+
+        Args:
+            config: unused
+            model_storage: unused
+            resource: unused
+            execution_context: unused
+
+        Returns: An instantiated `CoreFeaturizationPreparer`.
+        """
+        return cls()
+
+    WORKAROUND_TEXT = "hi"
+
+    @staticmethod
+    def prepare_for_training(domain: Domain, story_graph: StoryGraph,) -> TrainingData:
+        """Creates a list of unique (partial) substates from the domain and story graph.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+           domain: the domain
+           story_graph: a story graph
+        Returns:
+           training data for core's NLU pipeline
+        """
+        lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+
+        # collect all action and user (intent-only) substates known from domain
+        lookup_table.derive_messages_from_domain_and_add(domain=domain)
+
+        # collect all substates we see in the given data
+        all_events = (
+            event
+            for step in story_graph.story_steps
+            for event in step.events
+            if not isinstance(event, ActionExecuted)
path: rasa/core/featurizers/precomputation.py
text: @user1:
can't we just filter for `UserUttered` in this case?

@author:
oh, yep :D

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
+          adding a message creates a collision that we cannot resolve
+        """
+        for message in messages_with_one_key_attribute:
+            self.add(message)
+
+    def lookup_features(
+        self, sub_state: SubState, attributes: Optional[Iterable[Text]] = None
+    ) -> Dict[Text, List[Features]]:
+        """Looks up all features for the given substate.
+
+        There might be be multiple messages in the lookup table that contain features
+        relevant for the given substate, e.g. this is the case if `TEXT` and
+        `INTENT` are present in the given message. All of those messages will be
+        collected and their features combined.
+
+        Note that here we do **not** impose any restrictions on the attributes that
+        may be included in the *given* `sub_state`.
+
+        Args:
+          sub_state: substate for which we want to lookup the relevent features
+          attributes: if not None, this specifies the list of the attributes of the
+            `Features` that we're interested in (i.e. all other `Features` contained
+            in the relevant messages will be ignored)
+
+        Returns:
+          a dictionary that maps all the (requested) attributes to a list of `Features`
+
+        Raises:
+          - a `ValueError` if any of the lookup table keys that can be generated from
+            the given substate is not contained in the lookup table
+          - a `RuntimeError` if an inconsistency in the lookup table is detected;
+            more precisely, if features for the same attribute are found in two
+            looked up messages
+        """
+        # If we specify a list of attributes, then we want a dict with one entry
+        # for each attribute back - even if the corresponding list of features is empty.
+        features: Dict[Text, List[Features]] = (
+            dict()
+            if attributes is None
+            else {attribute: [] for attribute in attributes}
+        )
+        # get all keys whose values in the lookup table contain features for the
+        # given substate
+        key_attributes = set(sub_state.keys()).intersection(self.KEY_ATTRIBUTES)
+        for key_attribute in key_attributes:
+            key = self._build_key(sub_state={key_attribute: sub_state[key_attribute]})
+            message = self._table.get(key)
+            if not message:
+                raise ValueError(
+                    f"Unknown key {key}. Cannot retrieve features for substate "
+                    f"{sub_state}"
+                )
+            features_from_message = Features.groupby_attribute(
+                message.features, attributes=attributes
+            )
+            for feat_attribute, feat_value in features_from_message.items():
+                existing_values = features.get(feat_attribute)
+                # Note: the follwing if-s are needed because if we specify a list of
+                # attributes then `features_from_message` will contain one entry per
+                # attribute even if the corresponding feature list is empty.
+                if feat_value and existing_values:
+                    raise RuntimeError(
+                        f"Feature for attribute {feat_attribute} has already been "
+                        f"extracted from a different message stored under a key "
+                        f"in {key_attributes} "
+                        f"that is different from {key_attribute}. This means there's a "
+                        f"redundancy in the lookup table."
+                    )
+                if feat_value:
+                    features[feat_attribute] = feat_value
+        return features
+
+    def lookup_message(self, user_text: Text) -> Message:
+        """Returns the lookup table entry identified by the given user text.
+
+        Args:
+          user_text: the text of a user utterance
+        Raises:
+          `ValueError` if there is no message associated with the given user text
+        """
+        key = self._build_key({TEXT: user_text})
+        message = self._table.get(key)
+        if message is None:
+            raise ValueError(f"Expected a message with key {key} in lookup table.")
+        return message
+
+    def derive_messages_from_domain_and_add(self, domain: Domain) -> None:
+        """Adds all lookup table entries that can be derived from the domain.
+
+        Args:
+          domain: the domain from which we extract the substates
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        action_texts = domain.action_texts
+        action_names = domain.action_names_or_texts[
+            slice(0, -len(domain.action_texts) if domain.action_texts else None)
+        ]
+        if set(action_texts).intersection(action_names):
+            raise NotImplementedError(
+                "We assumed that domain's action_names_or_texts contains all "
+                "action names followed by the texts. Apparently that changed. "
+            )
+        for tag, actions in [(ACTION_NAME, action_names), (ACTION_TEXT, action_texts)]:
+            for action in actions:
+                self.add(Message({tag: action}))
+
+        for intent in domain.intent_properties.keys():
+            self.add(Message({INTENT: intent}))
+
+    def derive_messages_from_events_and_add(self, events: Iterable[Event]) -> None:
+        """Creates all possible action and (partial) user substates from the events.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+          events: list of events to extract the substate from
+          lookup_table: lookup table to which the substates will be added (as messages)
+        """
+        for event in events:
+            if isinstance(event, UserUttered):
+                # avoid side effects by making a copy...
+                event_copy = copy.deepcopy(event)
+                # ... before changing this flag so we get a complete sub-state
+                event_copy.use_text_for_featurization = None
+                artificial_sub_state = event_copy.as_sub_state()
+                # split it up...
+                sub_states_from_event = []
+                intent = artificial_sub_state.pop(INTENT, None)
+                if intent:
+                    sub_states_from_event.append({INTENT: intent})
+                if len(artificial_sub_state):
+                    sub_states_from_event.append(artificial_sub_state)
+                    # FIXME: seems we can just remove entities here....
+            elif isinstance(event, ActionExecuted):
+                sub_states_from_event = []
+                sub_state = event.as_sub_state()
+                for key in [ACTION_NAME, ACTION_TEXT]:
+                    if key in sub_state:
+                        sub_states_from_event.append({key: sub_state[key]})
+            else:
+                sub_states_from_event = []
+
+            for sub_state in sub_states_from_event:
+                message = Message(data=sub_state)
+                self.add(message)
+
+
+class CoreFeaturizationPreparer(GraphComponent):
+    """Provides training data for core's NLU pipeline as well as lookup table buildup.
+
+    During training as well as during inference, the given data (i.e. story graph or
+    list of messages), will be used to derive a de-duplicated list of all possible
+    "prev_action" and "user" sub-states (with either `TEXT` and possibly `ENTITY`
+    attributes or only `INTENT` attribute) that could be generated from the given data.
+    """
+
+    # NOTE: If we wanted to create the training data for the components of the e2e
+    # featurization pipeline differently from the data that is needed for the lookup
+    # table (currently, the latter is used for training as well), we simply need to
+    # move the functionality of this component to e.g. a `StoryToLookUpDataConverter`,
+    # replace the functionality here with the required training data build up, and
+    # adapt the graph connections so that the `train` and `predict` steps of the e2e
+    # run consecutively and use the respective data.
+
+    @classmethod
+    @abstractmethod
+    def create(
+        cls,
+        config: Dict[Text, Any],
+        model_storage: ModelStorage,
+        resource: Resource,
+        execution_context: ExecutionContext,
+    ) -> GraphComponent:
+        """Creates a new `CoreFeaturizationPreparer`.
+
+        Args:
+            config: unused
+            model_storage: unused
+            resource: unused
+            execution_context: unused
+
+        Returns: An instantiated `CoreFeaturizationPreparer`.
+        """
+        return cls()
+
+    WORKAROUND_TEXT = "hi"
+
+    @staticmethod
+    def prepare_for_training(domain: Domain, story_graph: StoryGraph,) -> TrainingData:
+        """Creates a list of unique (partial) substates from the domain and story graph.
+
+        Note that partial user substate means user substate with intent only, user
+        substate with all attributes expect intent, "prev_action" substate with
+        action text only or "prev_action" substate with action name only.
+
+        Args:
+           domain: the domain
+           story_graph: a story graph
+        Returns:
+           training data for core's NLU pipeline
+        """
+        lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+
+        # collect all action and user (intent-only) substates known from domain
+        lookup_table.derive_messages_from_domain_and_add(domain=domain)
+
+        # collect all substates we see in the given data
+        all_events = (
+            event
+            for step in story_graph.story_steps
+            for event in step.events
+            if not isinstance(event, ActionExecuted)
+            # because all action names and texts are known to the domain
+        )
+        lookup_table.derive_messages_from_events_and_add(events=all_events)
+
+        # make sure that there is at least one user substate with a TEXT to ensure
+        # `CountVectorizer` is trained...
+        lookup_table.add(Message({TEXT: CoreFeaturizationPreparer.WORKAROUND_TEXT}))
path: rasa/core/featurizers/precomputation.py
text: @user1:
we can actually remove since we are now re-using the `CountVectorsFeaturizer`s from NLU, right?

@author:
will leave the comment in here so we remember we might need that later for complex recipes

diff_hunk: @@ -614,11 +641,15 @@ def test_normalization(
         default_domain: Domain,
         monkeypatch: MonkeyPatch,
     ):
+        # TODO: drop when policies are migrated
path: tests/core/policies/test_ted_policy.py
text: @user1:
I think you can drop any `TODOs` after this line as these tests are only used by `TEDPolicy` which is already adapted

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
    sub_states = unique_sub_states.copy()
```

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
path: tests/core/featurizers/test_precomputation.py
text: @user1:
can this be lowercase?

@author:
wups, yep

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
path: tests/core/featurizers/test_precomputation.py
text: @user1:
can we shorten it?
```suggestion
    assert set(lookup_table.values()) == set(unique_messages) :
```

@author:
If messages are hashable... ?

@user1:
they actually are 👍🏻 

@author:
ooooh, will change this then :)

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
path: tests/core/featurizers/test_precomputation.py
text: @user1:
is this test actually testing two things? Collisions and handling of additional state keys? In that case I'd prefer to separate the unit tests

@author:
nope, that non-key is needed to illustrate that only the ~~first~~ second bunch of messages make it into the lookup table ~~and that the messages which look alike but have the extra non-key can't be looked up~~ which can be identified via the extra key

@user1:
> only the first messages make it into the lookup table 

But doesn't the test add the key to all of the messages?

@author:
sorry that was a poor explanation - should only be added to the second half (there should be a copy and an append in that loop)

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
    """Tests whether the lookup table can deal with not-too messy training data.
```

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
+
+    # if we handle the collisions:
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 1  # because item with features was added first
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states)
+
+    # if we do not handle the collisions:
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, len(messages) - 1, 2):
+        lookup_table2.add(messages[idx])
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(messages[idx + 1])
+
+
+# TODO: one test where an inconsistency is ignored...
+def test_precomputation_build_ignores_some_inconsistencies():
+    # This is a reminder that not everything inconsistency will be detected.
+    # Similarly, differences in the actual Features (values/origin/...) will be ingored.
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
    # Similarly, differences in the actual Features (values/origin/...) will be ignored.
```

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
    That is, we assume that someone missed to annotate `ENTITIES`.
```

@user1:
wait - what does that mean if somebody misses to annotate entities?

@author:
Was thinking of story data where you annotate two instances of the same text differently - that would be lost at the moment - but that a) should/could be handled differently then and b) I think I can remove entities and so this is not really an issue

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
+
+    # if we handle the collisions:
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 1  # because item with features was added first
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states)
+
+    # if we do not handle the collisions:
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, len(messages) - 1, 2):
+        lookup_table2.add(messages[idx])
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(messages[idx + 1])
+
+
+# TODO: one test where an inconsistency is ignored...
+def test_precomputation_build_ignores_some_inconsistencies():
+    # This is a reminder that not everything inconsistency will be detected.
+    # Similarly, differences in the actual Features (values/origin/...) will be ingored.
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = [
+        {TEXT: "text", ENTITIES: "1"},
+        {TEXT: "text", ENTITIES: "2"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "1"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "2"},
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    assert len(lookup_table) == int(len(sub_states) / 2)
+
+
+def test_precomputation_lookup_features():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={INTENT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={TEXT: "A2"}, features=[_dummy_features(3, TEXT)]),
+        Message(data={INTENT: "B2", OTHER: "C2"}, features=[_dummy_features(4, OTHER)]),
+    ]
+
+    table = CoreFeaturizationPrecomputations()
+    table.add_all(messages)
+
+    # If we don't specify a list of attributes, the resulting features dictionary will
+    # only contain those attributes for which there are features.
+    sub_state = {TEXT: "A", INTENT: "B", OTHER: "C"}
+    features = table.lookup_features(sub_state=sub_state)
+    for attribute, feature_value in [(TEXT, 1), (INTENT, None), (OTHER, 2)]:
+        if feature_value is not None:
+            assert attribute in features
+            assert len(features[attribute]) == 1
+            assert feature_value == features[attribute][0].features[0]
+        else:
+            assert attribute not in features
+
+    # If we query features for `INTENT`, then a key will be there, even if there are
+    # no feaetures
+    features = table.lookup_features(
+        sub_state=sub_state, attributes=list(sub_state.keys())
+    )
+    assert INTENT in features
+    assert len(features[INTENT]) == 0
+
+    # We only get the list of features we want...
+    features = table.lookup_features(sub_state, attributes=[OTHER])
+    assert TEXT not in features
+    assert INTENT not in features
+    assert len(features[OTHER]) == 1
+
+    # ... even if there are no features:
+    YET_ANOTHER = "another"
+    features = table.lookup_features(sub_state, attributes=[YET_ANOTHER])
+    assert len(features[YET_ANOTHER]) == 0
+
+    # And we cannot lookup features for sub states that have no key attribute
+    with pytest.raises(ValueError, match="Unknown key"):
+        table.lookup_features({TEXT: "A-unknwon"})
+
+
+def test_precomputation_lookup_features_if_lookup_table_is_broken():
+
+    broken_table = CoreFeaturizationPrecomputations()
+    broken_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(
+            data={}, features=[_dummy_features(2, TEXT)]
+        ),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data={}, features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    with pytest.raises(
+        RuntimeError, match=f"Feature for attribute {TEXT} has already been"
+    ):
+        broken_table.lookup_features({TEXT: "A", INTENT: "B"})
+
+    # as a reminder that we don't fix every strange edgecase:
+    not_broken_but_strange_table = CoreFeaturizationPrecomputations()
+    not_broken_but_strange_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(data=dict()),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data=dict(), features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    features = not_broken_but_strange_table.lookup_features({TEXT: "A", INTENT: "B"})
+    assert TEXT in features and len(features[TEXT]) == 1
+
+
+def test_precomputation_lookup_message():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={TEXT: "B"}),
+        Message(data={TEXT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={INTENT: "B"}),
+        Message(data={ACTION_TEXT: "B"}),
+        Message(data={ACTION_NAME: "B"}),
+    ]
+
+    table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    table.add_all(messages)
+
+    message = table.lookup_message(user_text="A")
+    assert message
+    message = table.lookup_message(user_text="B")
+    assert OTHER in message.data.keys()
+    with pytest.raises(ValueError, match=f"Expected a message with key \\('{TEXT}"):
+        table.lookup_message(user_text="not included")
+
+
+def test_precomputation_derive_messages_from_events_and_add():
+    events = [
+        UserUttered(intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate and one prev_action substate
+    assert len(lookup_table) == 2
+
+    events = [
+        UserUttered(text="text", intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate, one text-only user substate one
+    # prev_action substate
+    assert len(lookup_table) == 3
path: tests/core/featurizers/test_precomputation.py
text: @user1:
how about using `pytest.mark.parametrize` for this?

@author:
good idea, yep :)

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
+
+    # if we handle the collisions:
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 1  # because item with features was added first
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states)
+
+    # if we do not handle the collisions:
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, len(messages) - 1, 2):
+        lookup_table2.add(messages[idx])
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(messages[idx + 1])
+
+
+# TODO: one test where an inconsistency is ignored...
+def test_precomputation_build_ignores_some_inconsistencies():
+    # This is a reminder that not everything inconsistency will be detected.
+    # Similarly, differences in the actual Features (values/origin/...) will be ingored.
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = [
+        {TEXT: "text", ENTITIES: "1"},
+        {TEXT: "text", ENTITIES: "2"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "1"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "2"},
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    assert len(lookup_table) == int(len(sub_states) / 2)
+
+
+def test_precomputation_lookup_features():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={INTENT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={TEXT: "A2"}, features=[_dummy_features(3, TEXT)]),
+        Message(data={INTENT: "B2", OTHER: "C2"}, features=[_dummy_features(4, OTHER)]),
+    ]
+
+    table = CoreFeaturizationPrecomputations()
+    table.add_all(messages)
+
+    # If we don't specify a list of attributes, the resulting features dictionary will
+    # only contain those attributes for which there are features.
+    sub_state = {TEXT: "A", INTENT: "B", OTHER: "C"}
+    features = table.lookup_features(sub_state=sub_state)
+    for attribute, feature_value in [(TEXT, 1), (INTENT, None), (OTHER, 2)]:
+        if feature_value is not None:
+            assert attribute in features
+            assert len(features[attribute]) == 1
+            assert feature_value == features[attribute][0].features[0]
+        else:
+            assert attribute not in features
+
+    # If we query features for `INTENT`, then a key will be there, even if there are
+    # no feaetures
+    features = table.lookup_features(
+        sub_state=sub_state, attributes=list(sub_state.keys())
+    )
+    assert INTENT in features
+    assert len(features[INTENT]) == 0
+
+    # We only get the list of features we want...
+    features = table.lookup_features(sub_state, attributes=[OTHER])
+    assert TEXT not in features
+    assert INTENT not in features
+    assert len(features[OTHER]) == 1
+
+    # ... even if there are no features:
+    YET_ANOTHER = "another"
+    features = table.lookup_features(sub_state, attributes=[YET_ANOTHER])
+    assert len(features[YET_ANOTHER]) == 0
+
+    # And we cannot lookup features for sub states that have no key attribute
+    with pytest.raises(ValueError, match="Unknown key"):
+        table.lookup_features({TEXT: "A-unknwon"})
+
+
+def test_precomputation_lookup_features_if_lookup_table_is_broken():
+
+    broken_table = CoreFeaturizationPrecomputations()
+    broken_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(
+            data={}, features=[_dummy_features(2, TEXT)]
+        ),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data={}, features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    with pytest.raises(
+        RuntimeError, match=f"Feature for attribute {TEXT} has already been"
+    ):
+        broken_table.lookup_features({TEXT: "A", INTENT: "B"})
+
+    # as a reminder that we don't fix every strange edgecase:
+    not_broken_but_strange_table = CoreFeaturizationPrecomputations()
+    not_broken_but_strange_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(data=dict()),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data=dict(), features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    features = not_broken_but_strange_table.lookup_features({TEXT: "A", INTENT: "B"})
+    assert TEXT in features and len(features[TEXT]) == 1
+
+
+def test_precomputation_lookup_message():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={TEXT: "B"}),
+        Message(data={TEXT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={INTENT: "B"}),
+        Message(data={ACTION_TEXT: "B"}),
+        Message(data={ACTION_NAME: "B"}),
+    ]
+
+    table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    table.add_all(messages)
+
+    message = table.lookup_message(user_text="A")
+    assert message
+    message = table.lookup_message(user_text="B")
+    assert OTHER in message.data.keys()
+    with pytest.raises(ValueError, match=f"Expected a message with key \\('{TEXT}"):
+        table.lookup_message(user_text="not included")
+
+
+def test_precomputation_derive_messages_from_events_and_add():
+    events = [
+        UserUttered(intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate and one prev_action substate
+    assert len(lookup_table) == 2
+
+    events = [
+        UserUttered(text="text", intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate, one text-only user substate one
+    # prev_action substate
+    assert len(lookup_table) == 3
+
+
+def test_precomputation_derive_messages_from_domain_and_add():
+    action_names = ["a", "b"]
+    # action texts, response keys, forms, and action_names must be unique or the
+    # domain will complain about it
+    action_texts = ["a2", "b2"]
+    responses = {"a3": "a2", "b3": "b2"}
path: tests/core/featurizers/test_precomputation.py
text: @user1:
responses look actually slightly different (see type warning)

@author:
thanks! will look into that

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
+
+    # if we handle the collisions:
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 1  # because item with features was added first
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states)
+
+    # if we do not handle the collisions:
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, len(messages) - 1, 2):
+        lookup_table2.add(messages[idx])
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(messages[idx + 1])
+
+
+# TODO: one test where an inconsistency is ignored...
+def test_precomputation_build_ignores_some_inconsistencies():
+    # This is a reminder that not everything inconsistency will be detected.
+    # Similarly, differences in the actual Features (values/origin/...) will be ingored.
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = [
+        {TEXT: "text", ENTITIES: "1"},
+        {TEXT: "text", ENTITIES: "2"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "1"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "2"},
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    assert len(lookup_table) == int(len(sub_states) / 2)
+
+
+def test_precomputation_lookup_features():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={INTENT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={TEXT: "A2"}, features=[_dummy_features(3, TEXT)]),
+        Message(data={INTENT: "B2", OTHER: "C2"}, features=[_dummy_features(4, OTHER)]),
+    ]
+
+    table = CoreFeaturizationPrecomputations()
+    table.add_all(messages)
+
+    # If we don't specify a list of attributes, the resulting features dictionary will
+    # only contain those attributes for which there are features.
+    sub_state = {TEXT: "A", INTENT: "B", OTHER: "C"}
+    features = table.lookup_features(sub_state=sub_state)
+    for attribute, feature_value in [(TEXT, 1), (INTENT, None), (OTHER, 2)]:
+        if feature_value is not None:
+            assert attribute in features
+            assert len(features[attribute]) == 1
+            assert feature_value == features[attribute][0].features[0]
+        else:
+            assert attribute not in features
+
+    # If we query features for `INTENT`, then a key will be there, even if there are
+    # no feaetures
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
    # no features
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
    resolve afterwards. For example, an intent name may also appear as text
    and if it is interpreted as text, it will be "dense featurizable" while the intent
    name is not.
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
    a message with `TEXT` (to catch bugs due to wrong usage early on).
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
        """Adds the given messages to the lookup table.
```

diff_hunk: @@ -0,0 +1,462 @@
+from __future__ import annotations
+import typing
+from abc import abstractmethod
+from typing import Optional, Text, Dict, List, Union, Iterable, Tuple, Any
+from collections.abc import ValuesView, KeysView
+import copy
+
+from rasa.engine.graph import GraphComponent
+
+if typing.TYPE_CHECKING:
+    from rasa.engine.storage.storage import ModelStorage
+    from rasa.engine.storage.resource import Resource
+    from rasa.engine.graph import ExecutionContext
+from rasa.shared.core.domain import Domain, SubState
+from rasa.shared.core.events import ActionExecuted, UserUttered, Event
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.core.training_data.structures import StoryGraph
+from rasa.shared.nlu.constants import ACTION_NAME, ACTION_TEXT, ENTITIES, INTENT, TEXT
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.training_data.features import Features
+
+# TODO: cacheable version of `CoreFeaturizationPrecomputations` and new
+# `collect_and_cache` function for `CoreFeaturizationCollector` to be used during
+# train (caveat: we're not able to dynamically switch on/off caching - we could only
+# change the recipe such that the non-caching prediction-time method would be used
+# to collect)
+
+
+class CoreFeaturizationPrecomputations:
+    """A container (key-value store) that stores specific `Messages` only.
+
+    The motivation for this lookup table is that
+    a) our policies only need to pass very specific information through the
+    tokenization and featurization pipeline
+    b) our tokenization and featurization pipeline featurize certain attributes
+    independently of each other.
+
+    Therefore, this key-value store can only store messages where exactly one
+    attribute is one of `ACTION_NAME`, `ACTION_TEXT`, `TEXT`, or `INTENT`.
+    Moreover, it checks that if an `ENTITIES` attribute appears, it is contained in
+    a message with `TEXT` (in order to catch bugs due to wrong usage early on).
+    Keys are constructed by concatenating the key attribute, which is defined by
+    the list of attributes mentioned above, and the actual payload for that
+    attribute in the message. This is to avoid collisions that we otherwise cannot
+    resolve afterwards. For example, an intent name also appears as text and
+    interpreted as text, it will be "dense featurizable" while the intent name is
+    not.
+
+    The store will resolve collisions on it's own to some degree. Messages with the
+    same attributes and the same number of features will be treated as copies (and
+    simply not be added again).
+    If advanced collision handling is enabled, then messages that have a stricly
+    higher number of features and/or strictly more attributes can overwrite the
+    entries (note that attributes must be contained whereas features are not
+    checked for equality).
+    This also means, the table is not capable of resolving all kind of conflicts.
+    If messages contain different attributes and different number of features, the
+    lookup table can only choose to ignore that or raise an error.
+    """
+
+    KEY_ATTRIBUTES = [ACTION_NAME, ACTION_TEXT, TEXT, INTENT]
+
+    def __init__(self, handle_collisions: bool = True):
+        """Creates an empty container for precomputations.
+
+        Args:
+            handle_collisions: if set to True, collisions where one message contains a
+                larger or equal number of attributes and of features than the other
+                message, then the collision will be resolved automatically
+        """
+        self._table: Dict[Tuple[Text, Text], Message] = {}
+        self._handle_collisions = handle_collisions
+        self._num_collisions_ignored = 0
+        self._num_collisions_resolved = 0
+
+    def __repr__(self) -> Text:
+        return f"{self.__class__.__name__}({self._table})"
+
+    def __len__(self) -> int:
+        return len(self._table)
+
+    def values(self) -> ValuesView:
+        """Returns the lookup table values."""
+        return self._table.values()
+
+    def keys(self) -> KeysView:
+        """Returns the lookup table keys."""
+        return self._table.keys()
+
+    @property
+    def num_collisions_ignored(self) -> int:
+        """Returns the number of collisions that have been ignored."""
+        return self._num_collisions_ignored
+
+    @property
+    def num_collisions_resolved(self) -> int:
+        """Returns the number of collisions that have been resolved."""
+        return self._num_collisions_resolved
+
+    @classmethod
+    def _build_key(cls, sub_state: SubState) -> Tuple[Text, Text]:
+        """Builds the lookup table key for the given substate.
+
+        Expects the message to have exactly one of the  `KEY_ATTRIBUTES`.
+        Moreover, if `ENTITIES` is an attribute, then `TEXT` must be present.
+
+        Args:
+          sub_state: substate we want to store in the lookup table
+        Raises:
+          ValueErrors if there is more than one key attribute in the given substate
+          or if the substate contains `ENTITIES` but no `TEXT`
+        """
+        attributes = sub_state.keys()
+        key_attributes = set(attributes).intersection(cls.KEY_ATTRIBUTES)
+        if not key_attributes or len(key_attributes) > 1:
+            raise ValueError(
+                f"Expected exactly one attribute out of "
+                f"{cls.KEY_ATTRIBUTES} but received {attributes}"
+            )
+        if ENTITIES in attributes and TEXT not in attributes:
+            raise ValueError(
+                f"Expected entities information only in conjunction with `TEXT` "
+                f"but received a substate with {sub_state.keys()}."
+            )
+        key_attribute = list(key_attributes)[0]
+        return (key_attribute, str(sub_state[key_attribute]))
+
+    def add(self, message_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          message_with_unique_key_attribute: The message we want to add to the lookup
+            table. It must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for the given message or if this creates
+          a collision that we cannot resolve
+        """
+        key = self._build_key(sub_state=message_with_one_key_attribute.data)
+        existing_message = self._table.get(key)
+        if existing_message is not None:
+            if (
+                len(existing_message.features)
+                != len(message_with_one_key_attribute.features)
+                or existing_message.data.keys()
+                != message_with_one_key_attribute.data.keys()
+            ):
+                if self._handle_collisions:
+                    if len(existing_message.features) <= len(
+                        message_with_one_key_attribute.features
+                    ) and set(message_with_one_key_attribute.data.keys()) >= set(
+                        existing_message.data.keys()
+                    ):
+                        self._table[key] = message_with_one_key_attribute
+                        self._num_collisions_resolved += 1
+                    else:
+                        self._num_collisions_ignored += 1
+
+                else:
+                    raise ValueError(
+                        f"Expected added message to be consistent. {key} already maps "
+                        f"to {existing_message}, but we want to add "
+                        f"{message_with_one_key_attribute} now."
+                    )
+            else:
+                self._num_collisions_ignored += 1
+        else:
+            self._table[key] = message_with_one_key_attribute
+
+    def add_all(self, messages_with_one_key_attribute: Message) -> None:
+        """Adds the given message to the lookup table.
+
+        Args:
+          messages_with_one_key_attribute: The messages that we want to add to the
+            lookup table. Each one must have exactly one key attribute.
+
+        Raises:
+          ValueError if we cannot create a key for one of the given messages or if
path: rasa/core/featurizers/precomputation.py
text: @user1:
```suggestion
          ValueError: if we cannot create a key for one of the given messages or if
```

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
    half_of_added_messages = len(sub_states) // 2
```

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
        messages.append(Message(data=sub_state_copy, features=[]))
```
right?

@author:
yep, good catch :D

diff_hunk: @@ -367,164 +301,261 @@ def test_single_state_featurizer_with_interpreter_state_with_action_listen(
         "active_loop_3": 2,
         "active_loop_4": 3,
     }
-    encoded = f.encode_state(
-        {
-            "user": {
-                "text": "I am flying from London to Paris",
-                "intent": "inform",
-                "entities": ["city", f"city{ENTITY_LABEL_SEPARATOR}to"],
-            },
-            "prev_action": {
-                "action_name": "action_listen",
-                "action_text": "throw a ball",
-            },
-            "active_loop": {"name": "active_loop_4"},
-            "slots": {"slot_1": (1.0,)},
-        },
-        interpreter=interpreter,
+
+    # create state
+    text = "I am flying from London to Paris"
+    tokens = [
+        Token(text=match.group(), start=match.start())
+        for match in re.finditer(r"\S+", text)
+    ]
+    entity_name_list = ["city", f"city{ENTITY_LABEL_SEPARATOR}to"]
+    action_text = "throw a ball"
+    intent = "inform"
+    state = {
+        "user": {"text": text, "intent": intent, "entities": entity_name_list,},
+        "prev_action": {"action_name": action_name, "action_text": action_text,},
+        "active_loop": {"name": "active_loop_4"},
+        "slots": {"slot_1": (1.0,)},
+    }
+    if action_name is None:
+        del state["prev_action"]["action_name"]
+
+    # Build lookup table with all relevant information - and dummy features for all
+    # dense featurizable attributes.
+    # Note that we don't need to add the `ENTITIES` to the message including `TEXT`
+    # here because `encode_state` won't featurize the entities using the lookup table
+    # (only `encode_entities` does that).
+    units = 300
+    precomputations = CoreFeaturizationPrecomputations()
+    precomputations.add_all(
+        [
+            Message(
+                data={TEXT: text, TOKENS_NAMES[TEXT]: tokens},
+                features=[
+                    dummy_features(
+                        fill_value=11,
+                        units=units,
+                        attribute=TEXT,
+                        type=SENTENCE,
+                        is_sparse=True,
+                    ),
+                    dummy_features(
+                        fill_value=12,
+                        units=units,
+                        attribute=TEXT,
+                        type=SEQUENCE,
+                        is_sparse=False,
+                    ),
+                    # Note: sparse sequence feature is last here
+                    dummy_features(
+                        fill_value=13,
+                        units=units,
+                        attribute=TEXT,
+                        type=SEQUENCE,
+                        is_sparse=True,
+                    ),
+                ],
+            ),
+            Message(data={INTENT: intent}),
+            Message(
+                data={ACTION_TEXT: action_text},
+                features=[
+                    dummy_features(
+                        fill_value=1,
+                        units=units,
+                        attribute=ACTION_TEXT,
+                        type=SEQUENCE,
+                        is_sparse=True,
+                    )
+                ],
+            ),
+        ]
     )
+    if action_name is not None:
+        precomputations.add(Message(data={ACTION_NAME: action_name}))
+
+    # encode the state
+    encoded = f.encode_state(state, precomputations=precomputations,)
 
     # check all the features are encoded and *_text features are encoded by a
     # dense featurizer
-    assert sorted(list(encoded.keys())) == sorted(
-        [TEXT, ENTITIES, ACTION_NAME, SLOTS, ACTIVE_LOOP, INTENT, ACTION_TEXT]
+    expected_attributes = [SLOTS, ACTIVE_LOOP, ACTION_TEXT]
+    if action_name is not None:  # i.e. we did not removed it from the state
path: tests/core/featurizers/test_single_state_featurizers.py
text: @user1:
```suggestion
    if action_name is not None:  # i.e. we did not remove it from the state
```

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
+
+    # if we handle the collisions:
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 1  # because item with features was added first
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states)
+
+    # if we do not handle the collisions:
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, len(messages) - 1, 2):
+        lookup_table2.add(messages[idx])
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(messages[idx + 1])
+
+
+# TODO: one test where an inconsistency is ignored...
+def test_precomputation_build_ignores_some_inconsistencies():
+    # This is a reminder that not everything inconsistency will be detected.
+    # Similarly, differences in the actual Features (values/origin/...) will be ingored.
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = [
+        {TEXT: "text", ENTITIES: "1"},
+        {TEXT: "text", ENTITIES: "2"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "1"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "2"},
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    assert len(lookup_table) == int(len(sub_states) / 2)
+
+
+def test_precomputation_lookup_features():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={INTENT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={TEXT: "A2"}, features=[_dummy_features(3, TEXT)]),
+        Message(data={INTENT: "B2", OTHER: "C2"}, features=[_dummy_features(4, OTHER)]),
+    ]
+
+    table = CoreFeaturizationPrecomputations()
+    table.add_all(messages)
+
+    # If we don't specify a list of attributes, the resulting features dictionary will
+    # only contain those attributes for which there are features.
+    sub_state = {TEXT: "A", INTENT: "B", OTHER: "C"}
+    features = table.lookup_features(sub_state=sub_state)
+    for attribute, feature_value in [(TEXT, 1), (INTENT, None), (OTHER, 2)]:
+        if feature_value is not None:
+            assert attribute in features
+            assert len(features[attribute]) == 1
+            assert feature_value == features[attribute][0].features[0]
+        else:
+            assert attribute not in features
+
+    # If we query features for `INTENT`, then a key will be there, even if there are
+    # no feaetures
+    features = table.lookup_features(
+        sub_state=sub_state, attributes=list(sub_state.keys())
+    )
+    assert INTENT in features
+    assert len(features[INTENT]) == 0
+
+    # We only get the list of features we want...
+    features = table.lookup_features(sub_state, attributes=[OTHER])
+    assert TEXT not in features
+    assert INTENT not in features
+    assert len(features[OTHER]) == 1
+
+    # ... even if there are no features:
+    YET_ANOTHER = "another"
+    features = table.lookup_features(sub_state, attributes=[YET_ANOTHER])
+    assert len(features[YET_ANOTHER]) == 0
+
+    # And we cannot lookup features for sub states that have no key attribute
+    with pytest.raises(ValueError, match="Unknown key"):
+        table.lookup_features({TEXT: "A-unknwon"})
+
+
+def test_precomputation_lookup_features_if_lookup_table_is_broken():
+
+    broken_table = CoreFeaturizationPrecomputations()
+    broken_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(
+            data={}, features=[_dummy_features(2, TEXT)]
+        ),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data={}, features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    with pytest.raises(
+        RuntimeError, match=f"Feature for attribute {TEXT} has already been"
+    ):
+        broken_table.lookup_features({TEXT: "A", INTENT: "B"})
+
+    # as a reminder that we don't fix every strange edgecase:
+    not_broken_but_strange_table = CoreFeaturizationPrecomputations()
+    not_broken_but_strange_table._table = {
+        CoreFeaturizationPrecomputations._build_key({TEXT: "A"}): Message(data=dict()),
+        CoreFeaturizationPrecomputations._build_key({INTENT: "B"}): Message(
+            data=dict(), features=[_dummy_features(1, TEXT)]
+        ),
+    }
+    features = not_broken_but_strange_table.lookup_features({TEXT: "A", INTENT: "B"})
+    assert TEXT in features and len(features[TEXT]) == 1
+
+
+def test_precomputation_lookup_message():
+
+    OTHER = "other"
+    messages = [
+        Message(data={TEXT: "A"}, features=[_dummy_features(1, TEXT)]),
+        Message(data={TEXT: "B"}),
+        Message(data={TEXT: "B", OTHER: "C"}, features=[_dummy_features(2, OTHER)]),
+        Message(data={INTENT: "B"}),
+        Message(data={ACTION_TEXT: "B"}),
+        Message(data={ACTION_NAME: "B"}),
+    ]
+
+    table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    table.add_all(messages)
+
+    message = table.lookup_message(user_text="A")
+    assert message
+    message = table.lookup_message(user_text="B")
+    assert OTHER in message.data.keys()
+    with pytest.raises(ValueError, match=f"Expected a message with key \\('{TEXT}"):
+        table.lookup_message(user_text="not included")
+
+
+def test_precomputation_derive_messages_from_events_and_add():
+    events = [
+        UserUttered(intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate and one prev_action substate
+    assert len(lookup_table) == 2
+
+    events = [
+        UserUttered(text="text", intent={INTENT_NAME_KEY: "greet"}),
+        ActionExecuted(action_name="utter_greet"),
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table.derive_messages_from_events_and_add(events)
+    # should create: one intent-only user substate, one text-only user substate one
+    # prev_action substate
+    assert len(lookup_table) == 3
+
+
+def test_precomputation_derive_messages_from_domain_and_add():
+    action_names = ["a", "b"]
+    # action texts, response keys, forms, and action_names must be unique or the
+    # domain will complain about it
+    action_texts = ["a2", "b2"]
+    responses = {"a3": "a2", "b3": "b2"}
+    forms = {"a4": "a4"}
+    # however, intent names can be anything
+    intents = ["a", "b"]
+    domain = Domain(
+        intents=intents,
+        action_names=action_names,
+        action_texts=action_texts,
+        responses=responses,
+        entities=["e_a", "e_b", "e_c"],
+        slots=[Slot(name="s")],
+        forms=forms,
+    )
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    lookup_table.derive_messages_from_domain_and_add(domain)
+    # Note that we cannot just sum the above because the `domain` will e.g. combine the
+    # `action_texts` with the `responses` to obtain the `domain.action_texts`
+    assert len(lookup_table) == (
+        len(domain.intent_properties)
+        + len(domain.user_actions)
+        + len(domain.action_texts)
+        + len(DEFAULT_ACTION_NAMES)
+        + len(domain.form_names)
+    )
+
+
+def _create_entity(
+    value: Text, type: Text, role: Optional[Text] = None, group: Optional[Text] = None
+) -> Dict[Text, Text]:
+    entity = {}
+    entity[ENTITY_ATTRIBUTE_VALUE] = value
+    entity[ENTITY_ATTRIBUTE_TYPE] = type
+    entity[ENTITY_ATTRIBUTE_ROLE] = role
+    entity[ENTITY_ATTRIBUTE_GROUP] = group
+    return entity
+
+
+def test_preparation_for_training():
+    """Tests whether story graph and domain information ends up in training data.
+    Note: We do not test for completeness/shape of the result here. These aspects are
+    defined by the same principles as the lookup table. Hence, these aspects should
+    be tested in the lookup table tests, whereas here we just probe whether the lookup
+    table is applied correctly.
+    """
+    # create domain and story graph
+    intent_in_domain_only = "only-appears-in-domain"
+    event_text_prefix = "only-appears-in-events"
+    domain = Domain(
+        intents=["greet", "inform", intent_in_domain_only],
+        entities=["entity_name"],
+        slots=[],
+        responses=dict(),
+        action_names=["action_listen", "utter_greet"],
+        forms=dict(),
+        action_texts=["Hi how are you?"],
+    )
+    text_that_contains_entities = f"{event_text_prefix}-1"
+    events = [
+        ActionExecuted(action_name="action_listen"),
+        UserUttered(
+            text=text_that_contains_entities,
+            intent={"intent_name": "greet"},
+            entities=[_create_entity(value="Bot", type="entity_name")],
+        ),
+        ActionExecuted(action_name="utter_greet", action_text="Hi how are you?"),
+        ActionExecuted(action_name="action_listen"),
+        UserUttered(text=f"{event_text_prefix}-2", intent={"intent_name": "inform"}),
+        ActionExecuted(action_name="action_listen"),
+    ]
+    story_graph = StoryGraph([StoryStep(events=events)])
+
+    # convert!
+    training_data = CoreFeaturizationPreparer.prepare_for_training(
+        domain=domain, story_graph=story_graph
+    )
+
+    # check information from events is contained - by checking the TEXTs
+    training_texts = sorted(
+        message.data.get(TEXT)
+        for message in training_data.training_examples
+        if TEXT in message.data
+    )
+    assert all(
+        [
+            (
+                text.startswith(event_text_prefix)
+                or text == CoreFeaturizationPreparer.WORKAROUND_TEXT
path: tests/core/featurizers/test_precomputation.py
text: @user1:
If we no longer need this (see Tobi's comment above), we should remove this here, too.

diff_hunk: @@ -0,0 +1,565 @@
+import pytest
+import copy
+import numpy as np
+import itertools
+from typing import List, Text, Optional, Dict
+
+
+from rasa.core.featurizers.precomputation import (
+    CoreFeaturizationCollector,
+    CoreFeaturizationPrecomputations,
+    CoreFeaturizationPreparer,
+)
+from rasa.shared.nlu.training_data.features import Features
+from rasa.shared.nlu.training_data.message import Message
+from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.shared.nlu.constants import (
+    INTENT,
+    TEXT,
+    ENTITIES,
+    ACTION_NAME,
+    ACTION_TEXT,
+    INTENT_NAME_KEY,
+    ENTITY_ATTRIBUTE_VALUE,
+    ENTITY_ATTRIBUTE_TYPE,
+    ENTITY_ATTRIBUTE_ROLE,
+    ENTITY_ATTRIBUTE_GROUP,
+)
+from rasa.shared.core.constants import DEFAULT_ACTION_NAMES
+from rasa.shared.core.slots import Slot
+from rasa.shared.core.domain import Domain
+from rasa.shared.core.events import UserUttered, ActionExecuted
+from rasa.shared.core.training_data.structures import StoryGraph, StoryStep
+from rasa.shared.core.trackers import DialogueStateTracker
+
+
+def _dummy_features(id: int, attribute: Text) -> Features:
+    return Features(
+        np.full(shape=(1), fill_value=id),
+        attribute=attribute,
+        feature_type="really-anything",
+        origin="",
+    )
+
+
+@pytest.mark.parametrize(
+    "wrong_attributes",
+    [list(), ["other"]]
+    + list(itertools.permutations(CoreFeaturizationPrecomputations.KEY_ATTRIBUTES, 2)),
+)
+def test_precomputation_add_with_wrong_key_attributes(wrong_attributes: List[Text]):
+    sub_state = {attribute: "dummy" for attribute in wrong_attributes}
+    with pytest.raises(ValueError, match="Expected exactly one attribute out of"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+@pytest.mark.parametrize(
+    "wrong_combination",
+    [
+        attribute
+        for attribute in CoreFeaturizationPrecomputations.KEY_ATTRIBUTES
+        if attribute != TEXT
+    ],
+)
+def test_precomputation_add_with_unexpected_entity_attribute(wrong_combination: Text):
+    sub_state = {attribute: "dummy" for attribute in [ENTITIES, wrong_combination]}
+    with pytest.raises(ValueError, match="Expected entities information"):
+        CoreFeaturizationPrecomputations().add(Message(sub_state))
+
+
+def test_precomputation_build_with_copies():
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+    unique_messages = [Message(sub_state) for sub_state in unique_sub_states]
+
+    # add some copies
+    num_duplicates = 3
+    messages = unique_messages * num_duplicates
+
+    # build table
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for message in messages:
+        lookup_table.add(message)
+
+    assert len(lookup_table) == len(unique_sub_states)
+    # instead of freezing dicts, building sets, and comparing them, just check ...
+    for message in lookup_table.values():
+        assert message in unique_messages
+    for message in unique_messages:
+        assert message in lookup_table.values()
+
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states) * 2
+    assert lookup_table.num_collisions_resolved == 0
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_annotations():
+
+    # construct a set of unique substates
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add some substats with an additional attribute
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = list(unique_sub_states)  # copy :)
+    for sub_state in unique_sub_states:
+        sub_state_copy = dict(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = "anything"
+        sub_states.append(sub_state_copy)
+
+    # if we handle the collisions...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    half_of_added_messages = int(len(sub_states) / 2)
+    assert len(lookup_table) == half_of_added_messages
+    for message in lookup_table.values():
+        assert ARBITRARY_NON_KEY in message.data
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == half_of_added_messages
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, half_of_added_messages):
+        lookup_table2.add(Message(sub_states[idx]))
+    for idx in range(half_of_added_messages, 2 * half_of_added_messages):
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(Message(sub_states[idx]))
+
+
+def test_precomputation_build_with_inconsistencies_due_to_missing_features():
+    # This really should not happen, but if it did...
+
+    # construct a set of unique substates and messages
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    # add different numbers of features
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    messages = []
+    for sub_state in unique_sub_states:
+        messages.append(Message(data=sub_state, features=[]))
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        messages.append(
+            Message(data=sub_state, features=[dummy_feature, dummy_feature])
+        )
+
+    # if we do collision handling...
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 2
+    assert lookup_table.num_collisions_ignored == 0
+    assert lookup_table.num_collisions_resolved == len(unique_sub_states) * 2
+
+    # ... and if we don't
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    lookup_table2.add(messages[0])
+    with pytest.raises(ValueError, match="Expected added message to be consistent"):
+        lookup_table2.add(messages[1])
+
+
+def test_precomputation_build_with_inconsistent_data():
+    """Tests whether the lookup table can deal not-too messy training data.
+    That is, we assume that someone missed to annotate `ENTITIES`
+    """
+
+    dummy_value = "this-could-be-anything"
+    unique_sub_states = [
+        {INTENT: "greet"},
+        {TEXT: "text", ENTITIES: dummy_value},
+        {ACTION_TEXT: "action_text"},
+        {ACTION_NAME: "action_name"},
+    ]
+
+    messages = []
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    dummy_feature = Features(
+        np.ndarray((1, 2)),
+        attribute="this-is-never-checked-and-can-be-anything",
+        feature_type="really-anything",
+        origin="",
+    )
+    for sub_state in unique_sub_states:
+        # one message with a feature...
+        messages.append(Message(data=sub_state, features=[dummy_feature]))
+        # ... and one with extra attribute instead
+        sub_state_copy = copy.deepcopy(sub_state)
+        sub_state_copy[ARBITRARY_NON_KEY] = dummy_value
+        messages.append(Message(data=sub_state, features=[]))
+
+    # if we handle the collisions:
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=True)
+    for message in messages:
+        lookup_table.add(message)
+    assert len(lookup_table) == len(unique_sub_states)
+    for message in lookup_table.values():
+        assert message.features is not None
+        assert len(message.features) == 1  # because item with features was added first
+    assert lookup_table.num_collisions_ignored == len(unique_sub_states)
+
+    # if we do not handle the collisions:
+    lookup_table2 = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for idx in range(0, len(messages) - 1, 2):
+        lookup_table2.add(messages[idx])
+        with pytest.raises(ValueError, match="Expected added message to be consistent"):
+            lookup_table2.add(messages[idx + 1])
+
+
+# TODO: one test where an inconsistency is ignored...
+def test_precomputation_build_ignores_some_inconsistencies():
+    # This is a reminder that not everything inconsistency will be detected.
+    # Similarly, differences in the actual Features (values/origin/...) will be ingored.
+    ARBITRARY_NON_KEY = "arbitrary_non_key"
+    sub_states = [
+        {TEXT: "text", ENTITIES: "1"},
+        {TEXT: "text", ENTITIES: "2"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "1"},
+        {INTENT: "text", ARBITRARY_NON_KEY: "2"},
+    ]
+    lookup_table = CoreFeaturizationPrecomputations(handle_collisions=False)
+    for sub_state in sub_states:
+        lookup_table.add(Message(sub_state))
+    assert len(lookup_table) == int(len(sub_states) / 2)
path: tests/core/featurizers/test_precomputation.py
text: @user1:
```suggestion
    assert len(lookup_table) == len(sub_states) // 2
```