Repository: RasaHQ/rasa
Pull request: 3.0 architecture revamp/9604/train
**Proposed changes**:
- implement training (Rasa models, core / nlu models) with the graph architecture
- implement finetuning with the graph architecture
- only use e2e architecture if we have e2e data
- adapt tests to changed training behavior (as far as possible as some depend on model loading)
- As we have a general fingerprinting mechanism now we can't say which model parts need retraining. Hence, I had do drop some granularity from the `--dry-run` (the alternative would be to be very verbose which I wanted to avoid)

**Status (please check what you already did)**:
- [ ] 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)
- [ ] reformat files using `black` (please check [Readme](https://github.com/RasaHQ/rasa#code-style) for instructions)


Problem statement:
Integrate `rasa train` and `rasa run` with new model architecture
**Description**
It's time for a big step 🔥 After having migrated all components, implementing `rasa.engine`, creating the new components for the graph, and implementing the graph generation using the recipes it is time to integrate everything together.

**Definition of Done**
- [ ] `rasa train` trains using the graph architecture (+ the variants like `rasa train nlu/core`)
- [ ] `rasa run` loads graph architecture models and is able to make predictions with it
- [ ] The test suit passes
- [ ] the model accuracy didn't drop (I think we need to do that as part of this one as it will be a pain to find the issue later on; also not expecting any issues here)

**Things which don't need to be done**
- Making sure all no longer needed code is deleted (we can do that separately)
- Assessing / improving performance will be a separate issue

Hints from issue discussion:
- [ ]  fix caching of empty resource
- [ ] fix recipe tests
- [ ] not load predict thing as part of train
- [ ] rasa train @wochinge
- [ ] rasa predict @joejuzl (maybe: HTTPInterpreter 😁 )
- [ ] fix recipes tests @wochinge 
- [ ] fix check logging @joejuzl 

Ideas: 
* targets for prediction should be in metadata
* put cli + language in execution context for train

Commit message: add schema validation to training

Patch to review:
diff --git a/.gitignore b/.gitignore
index 75b9cfaee43e..c94e5c619220 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,6 +84,7 @@ env
 .history
 rasa/segment_key
 rasa/keys
+.rasa
 /results/
 # Local Netlify folder
 .netlify
diff --git a/changelog/9604.improvement.md b/changelog/9604.improvement.md
new file mode 100644
index 000000000000..811fd450afd7
--- /dev/null
+++ b/changelog/9604.improvement.md
@@ -0,0 +1,8 @@
+Due to changes in the model architecture the behavior of `rasa train --dry-run` changed.
+The exit codes now have the following meaning:
+
+* `0 means that the model does not require an expensive retraining. However, the 
+  responses might still require updating by running `rasa train`
+* `1` means that one or multiple components require to be retrained.
+* `8` means that the `--force` flag was used and hence any cached results are ignored
+  and the entire model is retrained.
diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml
index 891d0662097b..07db0e535556 100644
--- a/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml
+++ b/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml
@@ -1,6 +1,6 @@
 nlu_message_converter:
   needs:
-    message: __input__
+    messages: __message__
   uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter
   constructor_name: load
   fn: convert_user_message
diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml
index 6fb4f4ad0966..7c610f8c6fcf 100644
--- a/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml
+++ b/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: [ ]
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: true
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 nlu_training_data_provider:
   needs:
diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml
index 66ea8c72e9eb..5ba5ff5d5ccd 100644
--- a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml
+++ b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml
@@ -1,6 +1,6 @@
 nlu_message_converter:
   needs:
-    message: __input__
+    messages: __message__
   uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter
   constructor_name: load
   fn: convert_user_message
diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml
index af64c1d8c64c..3d37b4f8c9b1 100644
--- a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml
+++ b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: [ ]
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: true
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 nlu_training_data_provider:
   needs:
diff --git a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml
index 1ea34aa873a7..6d5b353490fb 100644
--- a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml
+++ b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml
@@ -1,6 +1,6 @@
 nlu_message_converter:
   needs:
-    message: __input__
+    messages: __message__
   uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter
   constructor_name: load
   fn: convert_user_message
diff --git a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml
index 4d002409ccae..8ca980a4c22b 100644
--- a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml
+++ b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: []
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: true
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 nlu_training_data_provider:
   needs:
diff --git a/data/graph_schemas/default_config_core_predict_schema.yml b/data/graph_schemas/default_config_core_predict_schema.yml
index 2641a66367f8..277434f4d1cb 100644
--- a/data/graph_schemas/default_config_core_predict_schema.yml
+++ b/data/graph_schemas/default_config_core_predict_schema.yml
@@ -13,7 +13,7 @@ run_MemoizationPolicy0:
   needs:
     domain: domain_provider
     rule_only_data: rule_only_data_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.memoization.MemoizationPolicyGraphComponent
   constructor_name: load
   fn: predict_action_probabilities
@@ -27,7 +27,7 @@ run_RulePolicy1:
   needs:
     domain: domain_provider
     rule_only_data: rule_only_data_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.rule_policy.RulePolicyGraphComponent
   constructor_name: load
   fn: predict_action_probabilities
@@ -41,7 +41,7 @@ run_UnexpecTEDIntentPolicy2:
   needs:
     domain: domain_provider
     rule_only_data: rule_only_data_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicyGraphComponent
   constructor_name: load
   fn: predict_action_probabilities
@@ -57,7 +57,7 @@ run_TEDPolicy3:
   needs:
     domain: domain_provider
     rule_only_data: rule_only_data_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
   constructor_name: load
   fn: predict_action_probabilities
@@ -88,7 +88,7 @@ select_prediction:
     policy2: run_UnexpecTEDIntentPolicy2
     policy3: run_TEDPolicy3
     domain: domain_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble
   constructor_name: load
   fn: combine_predictions_from_kwargs
diff --git a/data/graph_schemas/default_config_core_train_schema.yml b/data/graph_schemas/default_config_core_train_schema.yml
index faa281c81ad0..ffaee1a728a1 100644
--- a/data/graph_schemas/default_config_core_train_schema.yml
+++ b/data/graph_schemas/default_config_core_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: []
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: false
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 domain_provider:
   needs:
@@ -62,7 +50,8 @@ story_graph_provider:
   uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider
   constructor_name: create
   fn: provide
-  config: {}
+  config:
+    exclusion_percentage: null
   eager: false
   is_target: false
   is_input: true
diff --git a/data/graph_schemas/default_config_e2e_predict_schema.yml b/data/graph_schemas/default_config_e2e_predict_schema.yml
new file mode 100644
index 000000000000..b4ed4414f16f
--- /dev/null
+++ b/data/graph_schemas/default_config_e2e_predict_schema.yml
@@ -0,0 +1,337 @@
+nlu_message_converter:
+  needs:
+    messages: __message__
+  uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter
+  constructor_name: load
+  fn: convert_user_message
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+run_WhitespaceTokenizer0:
+  needs:
+    messages: nlu_message_converter
+  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+run_RegexFeaturizer1:
+  needs:
+    messages: run_WhitespaceTokenizer0
+  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_RegexFeaturizer1
+run_LexicalSyntacticFeaturizer2:
+  needs:
+    messages: run_RegexFeaturizer1
+  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_LexicalSyntacticFeaturizer2
+run_CountVectorsFeaturizer3:
+  needs:
+    messages: run_LexicalSyntacticFeaturizer2
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_CountVectorsFeaturizer3
+run_CountVectorsFeaturizer4:
+  needs:
+    messages: run_CountVectorsFeaturizer3
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config:
+    analyzer: char_wb
+    min_ngram: 1
+    max_ngram: 4
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_CountVectorsFeaturizer4
+run_DIETClassifier5:
+  needs:
+    messages: run_CountVectorsFeaturizer4
+  uses: rasa.nlu.classifiers.diet_classifier.DIETClassifierGraphComponent
+  constructor_name: load
+  fn: process
+  config:
+    epochs: 100
+    constrain_similarities: true
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_DIETClassifier5
+run_EntitySynonymMapper6:
+  needs:
+    messages: run_DIETClassifier5
+  uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapperGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_EntitySynonymMapper6
+run_ResponseSelector7:
+  needs:
+    messages: run_EntitySynonymMapper6
+  uses: rasa.nlu.selectors.response_selector.ResponseSelectorGraphComponent
+  constructor_name: load
+  fn: process
+  config:
+    epochs: 100
+    constrain_similarities: true
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_ResponseSelector7
+run_FallbackClassifier8:
+  needs:
+    messages: run_ResponseSelector7
+  uses: rasa.nlu.classifiers.fallback_classifier.FallbackClassifierGraphComponent
+  constructor_name: load
+  fn: process
+  config:
+    threshold: 0.3
+    ambiguity_threshold: 0.1
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+run_RegexMessageHandlerGraphComponent:
+  needs:
+    messages: run_FallbackClassifier8
+    domain: domain_provider
+  uses: rasa.nlu.classifiers.regex_message_handler.RegexMessageHandlerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+nlu_prediction_to_history_adder:
+  needs:
+    domain: domain_provider
+    original_messages: __message__
+    tracker: __tracker__
+    predictions: run_RegexMessageHandlerGraphComponent
+  uses: rasa.graph_components.adders.nlu_prediction_to_history_adder.NLUPredictionToHistoryAdder
+  constructor_name: load
+  fn: add
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+domain_provider:
+  needs: {}
+  uses: rasa.graph_components.providers.domain_provider.DomainProvider
+  constructor_name: load
+  fn: provide_inference
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: domain_provider
+tracker_to_message_converter:
+  needs:
+    tracker: nlu_prediction_to_history_adder
+  uses: rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter
+  constructor_name: load
+  fn: convert_for_inference
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+e2e_run_WhitespaceTokenizer0:
+  needs:
+    messages: tracker_to_message_converter
+  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+e2e_run_RegexFeaturizer1:
+  needs:
+    messages: e2e_run_WhitespaceTokenizer0
+  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_RegexFeaturizer1
+e2e_run_LexicalSyntacticFeaturizer2:
+  needs:
+    messages: e2e_run_RegexFeaturizer1
+  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_LexicalSyntacticFeaturizer2
+e2e_run_CountVectorsFeaturizer3:
+  needs:
+    messages: e2e_run_LexicalSyntacticFeaturizer2
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_CountVectorsFeaturizer3
+e2e_run_CountVectorsFeaturizer4:
+  needs:
+    messages: e2e_run_CountVectorsFeaturizer3
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process
+  config:
+    analyzer: char_wb
+    min_ngram: 1
+    max_ngram: 4
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_CountVectorsFeaturizer4
+end_to_end_features_provider:
+  needs:
+    messages: e2e_run_CountVectorsFeaturizer4
+  uses: rasa.core.featurizers.precomputation.CoreFeaturizationCollector
+  constructor_name: load
+  fn: collect
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
+run_MemoizationPolicy0:
+  needs:
+    tracker: nlu_prediction_to_history_adder
+    domain: domain_provider
+    rule_only_data: rule_only_data_provider
+  uses: rasa.core.policies.memoization.MemoizationPolicyGraphComponent
+  constructor_name: load
+  fn: predict_action_probabilities
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_MemoizationPolicy0
+run_RulePolicy1:
+  needs:
+    tracker: nlu_prediction_to_history_adder
+    domain: domain_provider
+    rule_only_data: rule_only_data_provider
+  uses: rasa.core.policies.rule_policy.RulePolicyGraphComponent
+  constructor_name: load
+  fn: predict_action_probabilities
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_RulePolicy1
+run_UnexpecTEDIntentPolicy2:
+  needs:
+    tracker: nlu_prediction_to_history_adder
+    domain: domain_provider
+    precomputations: end_to_end_features_provider
+    rule_only_data: rule_only_data_provider
+  uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicyGraphComponent
+  constructor_name: load
+  fn: predict_action_probabilities
+  config:
+    epochs: 100
+    max_history: 5
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_UnexpecTEDIntentPolicy2
+run_TEDPolicy3:
+  needs:
+    tracker: nlu_prediction_to_history_adder
+    domain: domain_provider
+    precomputations: end_to_end_features_provider
+    rule_only_data: rule_only_data_provider
+  uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
+  constructor_name: load
+  fn: predict_action_probabilities
+  config:
+    epochs: 100
+    max_history: 5
+    constrain_similarities: true
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_TEDPolicy3
+rule_only_data_provider:
+  needs: {}
+  uses: rasa.graph_components.providers.rule_only_provider.RuleOnlyDataProvider
+  constructor_name: load
+  fn: provide
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource:
+    name: train_RulePolicy1
+select_prediction:
+  needs:
+    policy0: run_MemoizationPolicy0
+    policy1: run_RulePolicy1
+    policy2: run_UnexpecTEDIntentPolicy2
+    policy3: run_TEDPolicy3
+    domain: domain_provider
+    tracker: nlu_prediction_to_history_adder
+  uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble
+  constructor_name: load
+  fn: combine_predictions_from_kwargs
+  config: {}
+  eager: true
+  is_target: false
+  is_input: false
+  resource: null
diff --git a/data/graph_schemas/default_config_e2e_train_schema.yml b/data/graph_schemas/default_config_e2e_train_schema.yml
new file mode 100644
index 000000000000..6fcdef7ccefb
--- /dev/null
+++ b/data/graph_schemas/default_config_e2e_train_schema.yml
@@ -0,0 +1,369 @@
+schema_validator:
+  needs:
+    importer: __importer__
+  uses: rasa.engine.recipes.default_recipe.SchemaValidator
+  constructor_name: create
+  fn: validate
+  config: {}
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+finetuning_validator:
+  needs:
+    importer: schema_validator
+  uses: rasa.engine.recipes.default_recipe.FinetuningValidator
+  constructor_name: create
+  fn: validate
+  config:
+    validate_core: true
+    validate_nlu: true
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+nlu_training_data_provider:
+  needs:
+    importer: finetuning_validator
+  uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider
+  constructor_name: create
+  fn: provide
+  config:
+    language: en
+    persist: false
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+run_WhitespaceTokenizer0:
+  needs:
+    training_data: nlu_training_data_provider
+  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_RegexFeaturizer1:
+  needs:
+    training_data: run_WhitespaceTokenizer0
+  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
+  constructor_name: create
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_RegexFeaturizer1:
+  needs:
+    training_data: run_WhitespaceTokenizer0
+    resource: train_RegexFeaturizer1
+  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_LexicalSyntacticFeaturizer2:
+  needs:
+    training_data: run_RegexFeaturizer1
+  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
+  constructor_name: create
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_LexicalSyntacticFeaturizer2:
+  needs:
+    training_data: run_RegexFeaturizer1
+    resource: train_LexicalSyntacticFeaturizer2
+  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_CountVectorsFeaturizer3:
+  needs:
+    training_data: run_LexicalSyntacticFeaturizer2
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: create
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_CountVectorsFeaturizer3:
+  needs:
+    training_data: run_LexicalSyntacticFeaturizer2
+    resource: train_CountVectorsFeaturizer3
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_CountVectorsFeaturizer4:
+  needs:
+    training_data: run_CountVectorsFeaturizer3
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: create
+  fn: train
+  config:
+    analyzer: char_wb
+    min_ngram: 1
+    max_ngram: 4
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_CountVectorsFeaturizer4:
+  needs:
+    training_data: run_CountVectorsFeaturizer3
+    resource: train_CountVectorsFeaturizer4
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config:
+    analyzer: char_wb
+    min_ngram: 1
+    max_ngram: 4
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_DIETClassifier5:
+  needs:
+    training_data: run_CountVectorsFeaturizer4
+  uses: rasa.nlu.classifiers.diet_classifier.DIETClassifierGraphComponent
+  constructor_name: create
+  fn: train
+  config:
+    epochs: 100
+    constrain_similarities: true
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_EntitySynonymMapper6:
+  needs:
+    training_data: run_CountVectorsFeaturizer4
+  uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapperGraphComponent
+  constructor_name: create
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_ResponseSelector7:
+  needs:
+    training_data: run_CountVectorsFeaturizer4
+  uses: rasa.nlu.selectors.response_selector.ResponseSelectorGraphComponent
+  constructor_name: create
+  fn: train
+  config:
+    epochs: 100
+    constrain_similarities: true
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+domain_provider:
+  needs:
+    importer: finetuning_validator
+  uses: rasa.graph_components.providers.domain_provider.DomainProvider
+  constructor_name: create
+  fn: provide_train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: true
+  resource: null
+domain_without_responses_provider:
+  needs:
+    domain: domain_provider
+  uses: rasa.graph_components.providers.domain_without_response_provider.DomainWithoutResponsesProvider
+  constructor_name: create
+  fn: provide
+  config: {}
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+story_graph_provider:
+  needs:
+    importer: finetuning_validator
+  uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider
+  constructor_name: create
+  fn: provide
+  config:
+    exclusion_percentage: null
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+training_tracker_provider:
+  needs:
+    story_graph: story_graph_provider
+    domain: domain_without_responses_provider
+  uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider
+  constructor_name: create
+  fn: provide
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+story_to_nlu_training_data_converter:
+  needs:
+    story_graph: story_graph_provider
+    domain: domain_without_responses_provider
+  uses: rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter
+  constructor_name: create
+  fn: convert_for_training
+  config: {}
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+e2e_run_WhitespaceTokenizer0:
+  needs:
+    training_data: story_to_nlu_training_data_converter
+  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+e2e_run_RegexFeaturizer1:
+  needs:
+    training_data: e2e_run_WhitespaceTokenizer0
+    resource: train_RegexFeaturizer1
+  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+e2e_run_LexicalSyntacticFeaturizer2:
+  needs:
+    training_data: e2e_run_RegexFeaturizer1
+    resource: train_LexicalSyntacticFeaturizer2
+  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+e2e_run_CountVectorsFeaturizer3:
+  needs:
+    training_data: e2e_run_LexicalSyntacticFeaturizer2
+    resource: train_CountVectorsFeaturizer3
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+e2e_run_CountVectorsFeaturizer4:
+  needs:
+    training_data: e2e_run_CountVectorsFeaturizer3
+    resource: train_CountVectorsFeaturizer4
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config:
+    analyzer: char_wb
+    min_ngram: 1
+    max_ngram: 4
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+end_to_end_features_provider:
+  needs:
+    messages: e2e_run_CountVectorsFeaturizer4
+  uses: rasa.core.featurizers.precomputation.CoreFeaturizationCollector
+  constructor_name: create
+  fn: collect
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_MemoizationPolicy0:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+  uses: rasa.core.policies.memoization.MemoizationPolicyGraphComponent
+  constructor_name: create
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_RulePolicy1:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+  uses: rasa.core.policies.rule_policy.RulePolicyGraphComponent
+  constructor_name: create
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_UnexpecTEDIntentPolicy2:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+    precomputations: end_to_end_features_provider
+  uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicyGraphComponent
+  constructor_name: create
+  fn: train
+  config:
+    max_history: 5
+    epochs: 100
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_TEDPolicy3:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+    precomputations: end_to_end_features_provider
+  uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
+  constructor_name: create
+  fn: train
+  config:
+    max_history: 5
+    epochs: 100
+    constrain_similarities: true
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
diff --git a/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml b/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml
new file mode 100644
index 000000000000..77f56161bfc6
--- /dev/null
+++ b/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml
@@ -0,0 +1,282 @@
+schema_validator:
+  needs:
+    importer: __importer__
+  uses: rasa.engine.recipes.default_recipe.SchemaValidator
+  constructor_name: create
+  fn: validate
+  config: {}
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+finetuning_validator:
+  needs:
+    importer: schema_validator
+  uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator
+  constructor_name: load
+  fn: validate
+  config:
+    validate_core: true
+    validate_nlu: true
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+nlu_training_data_provider:
+  needs:
+    importer: finetuning_validator
+  uses: rasa.graph_components.providers.nlu_training_data_provider.NLUTrainingDataProvider
+  constructor_name: create
+  fn: provide
+  config:
+    language: en
+    persist: false
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+run_WhitespaceTokenizer0:
+  needs:
+    training_data: nlu_training_data_provider
+  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_RegexFeaturizer1:
+  needs:
+    training_data: run_WhitespaceTokenizer0
+  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
+  constructor_name: load
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_RegexFeaturizer1:
+  needs:
+    training_data: run_WhitespaceTokenizer0
+    resource: train_RegexFeaturizer1
+  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_LexicalSyntacticFeaturizer2:
+  needs:
+    training_data: run_RegexFeaturizer1
+  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
+  constructor_name: load
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_LexicalSyntacticFeaturizer2:
+  needs:
+    training_data: run_RegexFeaturizer1
+    resource: train_LexicalSyntacticFeaturizer2
+  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_CountVectorsFeaturizer3:
+  needs:
+    training_data: run_LexicalSyntacticFeaturizer2
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_CountVectorsFeaturizer3:
+  needs:
+    training_data: run_LexicalSyntacticFeaturizer2
+    resource: train_CountVectorsFeaturizer3
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_CountVectorsFeaturizer4:
+  needs:
+    training_data: run_CountVectorsFeaturizer3
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: train
+  config:
+    analyzer: char_wb
+    min_ngram: 1
+    max_ngram: 4
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+run_CountVectorsFeaturizer4:
+  needs:
+    training_data: run_CountVectorsFeaturizer3
+    resource: train_CountVectorsFeaturizer4
+  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
+  constructor_name: load
+  fn: process_training_data
+  config:
+    analyzer: char_wb
+    min_ngram: 1
+    max_ngram: 4
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_DIETClassifier5:
+  needs:
+    training_data: run_CountVectorsFeaturizer4
+  uses: rasa.nlu.classifiers.diet_classifier.DIETClassifierGraphComponent
+  constructor_name: load
+  fn: train
+  config:
+    epochs: 50
+    constrain_similarities: true
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_EntitySynonymMapper6:
+  needs:
+    training_data: run_CountVectorsFeaturizer4
+  uses: rasa.nlu.extractors.entity_synonyms.EntitySynonymMapperGraphComponent
+  constructor_name: load
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_ResponseSelector7:
+  needs:
+    training_data: run_CountVectorsFeaturizer4
+  uses: rasa.nlu.selectors.response_selector.ResponseSelectorGraphComponent
+  constructor_name: load
+  fn: train
+  config:
+    epochs: 50
+    constrain_similarities: true
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+domain_provider:
+  needs:
+    importer: finetuning_validator
+  uses: rasa.graph_components.providers.domain_provider.DomainProvider
+  constructor_name: create
+  fn: provide_train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: true
+  resource: null
+domain_without_responses_provider:
+  needs:
+    domain: domain_provider
+  uses: rasa.graph_components.providers.domain_without_response_provider.DomainWithoutResponsesProvider
+  constructor_name: create
+  fn: provide
+  config: {}
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+story_graph_provider:
+  needs:
+    importer: finetuning_validator
+  uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider
+  constructor_name: create
+  fn: provide
+  config:
+    exclusion_percentage: null
+  eager: false
+  is_target: false
+  is_input: true
+  resource: null
+training_tracker_provider:
+  needs:
+    story_graph: story_graph_provider
+    domain: domain_without_responses_provider
+  uses: rasa.graph_components.providers.training_tracker_provider.TrainingTrackerProvider
+  constructor_name: create
+  fn: provide
+  config: {}
+  eager: false
+  is_target: false
+  is_input: false
+  resource: null
+train_MemoizationPolicy0:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+  uses: rasa.core.policies.memoization.MemoizationPolicyGraphComponent
+  constructor_name: load
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_RulePolicy1:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+  uses: rasa.core.policies.rule_policy.RulePolicyGraphComponent
+  constructor_name: load
+  fn: train
+  config: {}
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_UnexpecTEDIntentPolicy2:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+  uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicyGraphComponent
+  constructor_name: load
+  fn: train
+  config:
+    max_history: 5
+    epochs: 50
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
+train_TEDPolicy3:
+  needs:
+    training_trackers: training_tracker_provider
+    domain: domain_without_responses_provider
+  uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
+  constructor_name: load
+  fn: train
+  config:
+    max_history: 5
+    epochs: 50
+    constrain_similarities: true
+  eager: false
+  is_target: true
+  is_input: false
+  resource: null
diff --git a/data/graph_schemas/default_config_finetune_schema.yml b/data/graph_schemas/default_config_finetune_schema.yml
index 6977c7909eae..8d15185f155d 100644
--- a/data/graph_schemas/default_config_finetune_schema.yml
+++ b/data/graph_schemas/default_config_finetune_schema.yml
@@ -1,38 +1,26 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: []
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
     importer: schema_validator
   uses: rasa.graph_components.validators.finetuning_validator.FinetuningValidator
-  constructor_name: create
+  constructor_name: load
   fn: validate
   config:
     validate_core: true
     validate_nlu: true
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 nlu_training_data_provider:
   needs:
@@ -221,7 +209,8 @@ story_graph_provider:
   uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider
   constructor_name: create
   fn: provide
-  config: {}
+  config:
+    exclusion_percentage: null
   eager: false
   is_target: false
   is_input: true
@@ -238,91 +227,6 @@ training_tracker_provider:
   is_target: false
   is_input: false
   resource: null
-story_to_nlu_training_data_converter:
-  needs:
-    story_graph: story_graph_provider
-    domain: domain_without_responses_provider
-  uses: rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter
-  constructor_name: create
-  fn: convert_for_training
-  config: {}
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
-e2e_run_WhitespaceTokenizer0:
-  needs:
-    training_data: story_to_nlu_training_data_converter
-  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_RegexFeaturizer1:
-  needs:
-    training_data: e2e_run_WhitespaceTokenizer0
-    resource: train_RegexFeaturizer1
-  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_LexicalSyntacticFeaturizer2:
-  needs:
-    training_data: e2e_run_RegexFeaturizer1
-    resource: train_LexicalSyntacticFeaturizer2
-  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_CountVectorsFeaturizer3:
-  needs:
-    training_data: e2e_run_LexicalSyntacticFeaturizer2
-    resource: train_CountVectorsFeaturizer3
-  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_CountVectorsFeaturizer4:
-  needs:
-    training_data: e2e_run_CountVectorsFeaturizer3
-    resource: train_CountVectorsFeaturizer4
-  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config:
-    analyzer: char_wb
-    min_ngram: 1
-    max_ngram: 4
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-end_to_end_features_provider:
-  needs:
-    messages: e2e_run_CountVectorsFeaturizer4
-  uses: rasa.core.featurizers.precomputation.CoreFeaturizationCollector
-  constructor_name: create
-  fn: collect
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
 train_MemoizationPolicy0:
   needs:
     training_trackers: training_tracker_provider
@@ -351,7 +255,6 @@ train_UnexpecTEDIntentPolicy2:
   needs:
     training_trackers: training_tracker_provider
     domain: domain_without_responses_provider
-    end_to_end_features: end_to_end_features_provider
   uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicyGraphComponent
   constructor_name: load
   fn: train
@@ -366,7 +269,6 @@ train_TEDPolicy3:
   needs:
     training_trackers: training_tracker_provider
     domain: domain_without_responses_provider
-    end_to_end_features: end_to_end_features_provider
   uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
   constructor_name: load
   fn: train
diff --git a/data/graph_schemas/default_config_nlu_predict_schema.yml b/data/graph_schemas/default_config_nlu_predict_schema.yml
index 16648748f10f..e33ca100f488 100644
--- a/data/graph_schemas/default_config_nlu_predict_schema.yml
+++ b/data/graph_schemas/default_config_nlu_predict_schema.yml
@@ -1,6 +1,6 @@
 nlu_message_converter:
   needs:
-    message: __input__
+    messages: __message__
   uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter
   constructor_name: load
   fn: convert_user_message
diff --git a/data/graph_schemas/default_config_nlu_train_schema.yml b/data/graph_schemas/default_config_nlu_train_schema.yml
index f474d6474558..a5040c5af9ec 100644
--- a/data/graph_schemas/default_config_nlu_train_schema.yml
+++ b/data/graph_schemas/default_config_nlu_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: []
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: true
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 nlu_training_data_provider:
   needs:
diff --git a/data/graph_schemas/default_config_predict_schema.yml b/data/graph_schemas/default_config_predict_schema.yml
index 9cd210bf94cd..1ff5cf83acb9 100644
--- a/data/graph_schemas/default_config_predict_schema.yml
+++ b/data/graph_schemas/default_config_predict_schema.yml
@@ -1,6 +1,6 @@
 nlu_message_converter:
   needs:
-    message: __input__
+    messages: __message__
   uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter
   constructor_name: load
   fn: convert_user_message
@@ -139,8 +139,8 @@ run_RegexMessageHandlerGraphComponent:
 nlu_prediction_to_history_adder:
   needs:
     domain: domain_provider
-    original_message: __input__
-    tracker: __input__
+    original_messages: __message__
+    tracker: __tracker__
     predictions: run_RegexMessageHandlerGraphComponent
   uses: rasa.graph_components.adders.nlu_prediction_to_history_adder.NLUPredictionToHistoryAdder
   constructor_name: load
@@ -161,90 +161,6 @@ domain_provider:
   is_input: false
   resource:
     name: domain_provider
-tracker_to_message_converter:
-  needs:
-    tracker: nlu_prediction_to_history_adder
-  uses: rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter
-  constructor_name: load
-  fn: convert_for_inference
-  config: {}
-  eager: true
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_WhitespaceTokenizer0:
-  needs:
-    messages: tracker_to_message_converter
-  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
-  constructor_name: load
-  fn: process
-  config: {}
-  eager: true
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_RegexFeaturizer1:
-  needs:
-    messages: e2e_run_WhitespaceTokenizer0
-  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
-  constructor_name: load
-  fn: process
-  config: {}
-  eager: true
-  is_target: false
-  is_input: false
-  resource:
-    name: train_RegexFeaturizer1
-e2e_run_LexicalSyntacticFeaturizer2:
-  needs:
-    messages: e2e_run_RegexFeaturizer1
-  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
-  constructor_name: load
-  fn: process
-  config: {}
-  eager: true
-  is_target: false
-  is_input: false
-  resource:
-    name: train_LexicalSyntacticFeaturizer2
-e2e_run_CountVectorsFeaturizer3:
-  needs:
-    messages: e2e_run_LexicalSyntacticFeaturizer2
-  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
-  constructor_name: load
-  fn: process
-  config: {}
-  eager: true
-  is_target: false
-  is_input: false
-  resource:
-    name: train_CountVectorsFeaturizer3
-e2e_run_CountVectorsFeaturizer4:
-  needs:
-    messages: e2e_run_CountVectorsFeaturizer3
-  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
-  constructor_name: load
-  fn: process
-  config:
-    analyzer: char_wb
-    min_ngram: 1
-    max_ngram: 4
-  eager: true
-  is_target: false
-  is_input: false
-  resource:
-    name: train_CountVectorsFeaturizer4
-end_to_end_features_provider:
-  needs:
-    messages: e2e_run_CountVectorsFeaturizer4
-  uses: rasa.core.featurizers.precomputation.CoreFeaturizationCollector
-  constructor_name: load
-  fn: collect
-  config: {}
-  eager: true
-  is_target: false
-  is_input: false
-  resource: null
 run_MemoizationPolicy0:
   needs:
     tracker: nlu_prediction_to_history_adder
@@ -277,7 +193,6 @@ run_UnexpecTEDIntentPolicy2:
   needs:
     tracker: nlu_prediction_to_history_adder
     domain: domain_provider
-    end_to_end_features: end_to_end_features_provider
     rule_only_data: rule_only_data_provider
   uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicyGraphComponent
   constructor_name: load
@@ -294,7 +209,6 @@ run_TEDPolicy3:
   needs:
     tracker: nlu_prediction_to_history_adder
     domain: domain_provider
-    end_to_end_features: end_to_end_features_provider
     rule_only_data: rule_only_data_provider
   uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
   constructor_name: load
diff --git a/data/graph_schemas/default_config_train_schema.yml b/data/graph_schemas/default_config_train_schema.yml
index e814051695c2..cb6aa5c59fde 100644
--- a/data/graph_schemas/default_config_train_schema.yml
+++ b/data/graph_schemas/default_config_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: []
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: true
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 nlu_training_data_provider:
   needs:
@@ -221,7 +209,8 @@ story_graph_provider:
   uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider
   constructor_name: create
   fn: provide
-  config: {}
+  config:
+    exclusion_percentage: null
   eager: false
   is_target: false
   is_input: true
@@ -238,91 +227,6 @@ training_tracker_provider:
   is_target: false
   is_input: false
   resource: null
-story_to_nlu_training_data_converter:
-  needs:
-    story_graph: story_graph_provider
-    domain: domain_without_responses_provider
-  uses: rasa.core.featurizers.precomputation.CoreFeaturizationInputConverter
-  constructor_name: create
-  fn: convert_for_training
-  config: {}
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
-e2e_run_WhitespaceTokenizer0:
-  needs:
-    training_data: story_to_nlu_training_data_converter
-  uses: rasa.nlu.tokenizers.whitespace_tokenizer.WhitespaceTokenizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_RegexFeaturizer1:
-  needs:
-    training_data: e2e_run_WhitespaceTokenizer0
-    resource: train_RegexFeaturizer1
-  uses: rasa.nlu.featurizers.sparse_featurizer.regex_featurizer.RegexFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_LexicalSyntacticFeaturizer2:
-  needs:
-    training_data: e2e_run_RegexFeaturizer1
-    resource: train_LexicalSyntacticFeaturizer2
-  uses: rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer.LexicalSyntacticFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_CountVectorsFeaturizer3:
-  needs:
-    training_data: e2e_run_LexicalSyntacticFeaturizer2
-    resource: train_CountVectorsFeaturizer3
-  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-e2e_run_CountVectorsFeaturizer4:
-  needs:
-    training_data: e2e_run_CountVectorsFeaturizer3
-    resource: train_CountVectorsFeaturizer4
-  uses: rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer.CountVectorsFeaturizerGraphComponent
-  constructor_name: load
-  fn: process_training_data
-  config:
-    analyzer: char_wb
-    min_ngram: 1
-    max_ngram: 4
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
-end_to_end_features_provider:
-  needs:
-    messages: e2e_run_CountVectorsFeaturizer4
-  uses: rasa.core.featurizers.precomputation.CoreFeaturizationCollector
-  constructor_name: create
-  fn: collect
-  config: {}
-  eager: false
-  is_target: false
-  is_input: false
-  resource: null
 train_MemoizationPolicy0:
   needs:
     training_trackers: training_tracker_provider
@@ -351,7 +255,6 @@ train_UnexpecTEDIntentPolicy2:
   needs:
     training_trackers: training_tracker_provider
     domain: domain_without_responses_provider
-    end_to_end_features: end_to_end_features_provider
   uses: rasa.core.policies.unexpected_intent_policy.UnexpecTEDIntentPolicyGraphComponent
   constructor_name: create
   fn: train
@@ -366,7 +269,6 @@ train_TEDPolicy3:
   needs:
     training_trackers: training_tracker_provider
     domain: domain_without_responses_provider
-    end_to_end_features: end_to_end_features_provider
   uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
   constructor_name: create
   fn: train
diff --git a/data/graph_schemas/keyword_classifier_config_predict_schema.yml b/data/graph_schemas/keyword_classifier_config_predict_schema.yml
index 818029d3e490..dfce9204027a 100644
--- a/data/graph_schemas/keyword_classifier_config_predict_schema.yml
+++ b/data/graph_schemas/keyword_classifier_config_predict_schema.yml
@@ -1,6 +1,6 @@
 nlu_message_converter:
   needs:
-    message: __input__
+    messages: __message__
   uses: rasa.graph_components.converters.nlu_message_converter.NLUMessageConverter
   constructor_name: load
   fn: convert_user_message
diff --git a/data/graph_schemas/keyword_classifier_config_train_schema.yml b/data/graph_schemas/keyword_classifier_config_train_schema.yml
index 9aa004e9fe1d..01a33213c134 100644
--- a/data/graph_schemas/keyword_classifier_config_train_schema.yml
+++ b/data/graph_schemas/keyword_classifier_config_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: [ ]
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: true
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 nlu_training_data_provider:
   needs:
diff --git a/data/graph_schemas/max_hist_config_predict_schema.yml b/data/graph_schemas/max_hist_config_predict_schema.yml
index 6f6ea8a9fb43..4cb5ed14eba1 100644
--- a/data/graph_schemas/max_hist_config_predict_schema.yml
+++ b/data/graph_schemas/max_hist_config_predict_schema.yml
@@ -13,7 +13,7 @@ run_MemoizationPolicy0:
   needs:
     domain: domain_provider
     rule_only_data: rule_only_data_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.memoization.MemoizationPolicyGraphComponent
   constructor_name: load
   fn: predict_action_probabilities
@@ -28,7 +28,7 @@ run_RulePolicy1:
   needs:
     domain: domain_provider
     rule_only_data: rule_only_data_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.rule_policy.RulePolicyGraphComponent
   constructor_name: load
   fn: predict_action_probabilities
@@ -42,7 +42,7 @@ run_TEDPolicy2:
   needs:
     domain: domain_provider
     rule_only_data: rule_only_data_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.ted_policy.TEDPolicyGraphComponent
   constructor_name: load
   fn: predict_action_probabilities
@@ -70,7 +70,7 @@ select_prediction:
     policy1: run_RulePolicy1
     policy2: run_TEDPolicy2
     domain: domain_provider
-    tracker: __input__
+    tracker: __tracker__
   uses: rasa.core.policies.ensemble.DefaultPolicyPredictionEnsemble
   constructor_name: load
   fn: combine_predictions_from_kwargs
diff --git a/data/graph_schemas/max_hist_config_train_schema.yml b/data/graph_schemas/max_hist_config_train_schema.yml
index 15af463a92ab..901a8b375a94 100644
--- a/data/graph_schemas/max_hist_config_train_schema.yml
+++ b/data/graph_schemas/max_hist_config_train_schema.yml
@@ -1,25 +1,13 @@
-project_provider:
-  needs: {}
-  uses: rasa.graph_components.providers.project_provider.ProjectProvider
-  constructor_name: create
-  fn: provide
-  config:
-    domain_path: null
-    training_data_paths: [ ]
-  eager: false
-  is_target: false
-  is_input: true
-  resource: null
 schema_validator:
   needs:
-    importer: project_provider
+    importer: __importer__
   uses: rasa.engine.recipes.default_recipe.SchemaValidator
   constructor_name: create
   fn: validate
   config: {}
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 finetuning_validator:
   needs:
@@ -32,7 +20,7 @@ finetuning_validator:
     validate_nlu: false
   eager: false
   is_target: false
-  is_input: false
+  is_input: true
   resource: null
 domain_provider:
   needs:
@@ -62,7 +50,8 @@ story_graph_provider:
   uses: rasa.graph_components.providers.story_graph_provider.StoryGraphProvider
   constructor_name: create
   fn: provide
-  config: {}
+  config:
+    exclusion_percentage: null
   eager: false
   is_target: false
   is_input: true
diff --git a/data/test_e2ebot/config.yml b/data/test_e2ebot/config.yml
index 28f2d19eb13c..d04a947bf677 100644
--- a/data/test_e2ebot/config.yml
+++ b/data/test_e2ebot/config.yml
@@ -15,3 +15,4 @@ policies:
 - name: TEDPolicy
   random_seed: 42
   epochs: 5
+- name: RulePolicy
diff --git a/data/test_yaml_stories/stories_simple.yml b/data/test_yaml_stories/stories_simple.yml
index 4b5fe55fd88c..e6d3044d09b0 100644
--- a/data/test_yaml_stories/stories_simple.yml
+++ b/data/test_yaml_stories/stories_simple.yml
@@ -1,8 +1,9 @@
+version: "2.0"
+
+stories:
 - story: simple_story
   steps:
   - intent: greet
   - action: utter_greet
-  - intent: default
-  - action: utter_default
   - intent: goodbye
   - action: utter_goodbye
diff --git a/docs/docs/migration-guide.mdx b/docs/docs/migration-guide.mdx
index 062031ba50c1..80b7371fc23e 100644
--- a/docs/docs/migration-guide.mdx
+++ b/docs/docs/migration-guide.mdx
@@ -93,6 +93,17 @@ class TrainingDataImporter:
     # ...
 ```
 
+### Training
+#### `rasa train --dry-run`
+Due to changes in the model architecture the behavior of `rasa train --dry-run` changed.
+The exit codes now have the following meaning:
+
+* `0 means that the model does not require an expensive retraining. However, the
+  responses might still require updating by running `rasa train`
+* `1` means that one or multiple components require to be retrained.
+* `8` means that the `--force` flag was used and hence any cached results are ignored
+  and the entire model is retrained.
+
 ## Rasa 2.7 to 2.8
 
 :::caution
diff --git a/poetry.lock b/poetry.lock
index d2a5802650ce..e2626dd40be0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3939,22 +3939,12 @@ markdown = [
     {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"},
 ]
 markupsafe = [
-    {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
@@ -3963,21 +3953,14 @@ markupsafe = [
     {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
@@ -3987,9 +3970,6 @@ markupsafe = [
     {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
     {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
diff --git a/rasa/api.py b/rasa/api.py
index be57c2b2343d..32b2f97839f2 100644
--- a/rasa/api.py
+++ b/rasa/api.py
@@ -12,7 +12,6 @@
 if typing.TYPE_CHECKING:
     from typing import Any, Text, Dict, Union, List, Optional, NoReturn
     from rasa.model_training import TrainingResult
-    import asyncio
 
 
 def run(
@@ -74,7 +73,6 @@ def train(
     persist_nlu_training_data: bool = False,
     core_additional_arguments: "Optional[Dict]" = None,
     nlu_additional_arguments: "Optional[Dict]" = None,
-    loop: "Optional[asyncio.AbstractEventLoop]" = None,
     model_to_finetune: "Optional[Text]" = None,
     finetuning_epoch_fraction: float = 1.0,
 ) -> "TrainingResult":
@@ -94,7 +92,6 @@ def train(
         core_additional_arguments: Additional training parameters for core training.
         nlu_additional_arguments: Additional training parameters forwarded to training
             method of each NLU component.
-        loop: Optional EventLoop for running coroutines.
         model_to_finetune: Optional path to a model which should be finetuned or
             a directory in case the latest trained model should be used.
         finetuning_epoch_fraction: The fraction currently specified training epochs
@@ -103,25 +100,21 @@ def train(
     Returns:
         An instance of `TrainingResult`.
     """
-    from rasa.model_training import train_async
-    import rasa.utils.common
-
-    return rasa.utils.common.run_in_loop(
-        train_async(
-            domain=domain,
-            config=config,
-            training_files=training_files,
-            output=output,
-            dry_run=dry_run,
-            force_training=force_training,
-            fixed_model_name=fixed_model_name,
-            persist_nlu_training_data=persist_nlu_training_data,
-            core_additional_arguments=core_additional_arguments,
-            nlu_additional_arguments=nlu_additional_arguments,
-            model_to_finetune=model_to_finetune,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
-        ),
-        loop,
+    from rasa.model_training import train
+
+    return train(
+        domain=domain,
+        config=config,
+        training_files=training_files,
+        output=output,
+        dry_run=dry_run,
+        force_training=force_training,
+        fixed_model_name=fixed_model_name,
+        persist_nlu_training_data=persist_nlu_training_data,
+        core_additional_arguments=core_additional_arguments,
+        nlu_additional_arguments=nlu_additional_arguments,
+        model_to_finetune=model_to_finetune,
+        finetuning_epoch_fraction=finetuning_epoch_fraction,
     )
 
 
diff --git a/rasa/cli/arguments/train.py b/rasa/cli/arguments/train.py
index 9344f19720b6..f5fa9dc2f7a6 100644
--- a/rasa/cli/arguments/train.py
+++ b/rasa/cli/arguments/train.py
@@ -133,9 +133,9 @@ def add_dry_run_param(
         "and this information will be printed as the output. The return "
         "code is a 4-bit bitmask that can also be used to determine what exactly needs "
         "to be retrained:\n"
-        "- 1 means Core needs to be retrained\n"
-        "- 2 means NLU needs to be retrained\n"
-        "- 4 means responses in the domain should be updated\n"
+        "- 0 means that no extensive training is required (note that the responses "
+        "still might require updating by running 'rasa train').\n"
+        "- 1 means the model needs to be retrained\n"
         "- 8 means the training was forced (--force argument is specified)",
     )
 
diff --git a/rasa/cli/initial_project/config.yml b/rasa/cli/initial_project/config.yml
index 95221308e444..6fc742c674f9 100644
--- a/rasa/cli/initial_project/config.yml
+++ b/rasa/cli/initial_project/config.yml
@@ -1,3 +1,5 @@
+recipe: default.v1
+
 # Configuration for Rasa NLU.
 # https://rasa.com/docs/rasa/nlu/components/
 language: en
diff --git a/rasa/cli/train.py b/rasa/cli/train.py
index 49ff2c857b08..10d5ddf00c6d 100644
--- a/rasa/cli/train.py
+++ b/rasa/cli/train.py
@@ -118,22 +118,17 @@ def _model_for_finetuning(args: argparse.Namespace) -> Optional[Text]:
         return args.finetune
 
 
-def run_core_training(
-    args: argparse.Namespace, train_path: Optional[Text] = None
-) -> Optional[Text]:
+def run_core_training(args: argparse.Namespace) -> Optional[Text]:
     """Trains a Rasa Core model only.
 
     Args:
         args: Command-line arguments to configure training.
-        train_path: Path where trained model but not unzipped model should be stored.
 
     Returns:
         Path to a trained model or `None` if training was not successful.
     """
     from rasa.model_training import train_core
 
-    output = train_path or args.out
-
     args.domain = rasa.cli.utils.get_validated_path(
         args.domain, "domain", DEFAULT_DOMAIN_PATH, none_is_valid=True
     )
@@ -154,8 +149,7 @@ def run_core_training(
             domain=args.domain,
             config=config,
             stories=story_file,
-            output=output,
-            train_path=train_path,
+            output=args.out,
             fixed_model_name=args.fixed_model_name,
             additional_arguments=additional_arguments,
             model_to_finetune=_model_for_finetuning(args),
@@ -166,22 +160,17 @@ def run_core_training(
         return None
 
 
-def run_nlu_training(
-    args: argparse.Namespace, train_path: Optional[Text] = None
-) -> Optional[Text]:
+def run_nlu_training(args: argparse.Namespace) -> Optional[Text]:
     """Trains an NLU model.
 
     Args:
         args: Namespace arguments.
-        train_path: Directory where models should be stored.
 
     Returns:
         Path to a trained model or `None` if training was not successful.
     """
     from rasa.model_training import train_nlu
 
-    output = train_path or args.out
-
     config = _get_valid_config(args.config, CONFIG_MANDATORY_KEYS_NLU)
     nlu_data = rasa.cli.utils.get_validated_path(
         args.nlu, "nlu", DEFAULT_DATA_PATH, none_is_valid=True
@@ -195,8 +184,7 @@ def run_nlu_training(
     return train_nlu(
         config=config,
         nlu_data=nlu_data,
-        output=output,
-        train_path=train_path,
+        output=args.out,
         fixed_model_name=args.fixed_model_name,
         persist_nlu_training_data=args.persist_nlu_data,
         additional_arguments=extract_nlu_additional_arguments(args),
diff --git a/rasa/core/policies/memoization.py b/rasa/core/policies/memoization.py
index 63cf0c831a46..2afc84ce7f74 100644
--- a/rasa/core/policies/memoization.py
+++ b/rasa/core/policies/memoization.py
@@ -332,6 +332,9 @@ def load(
         )
 
 
+@DefaultV1Recipe.register(
+    DefaultV1Recipe.ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT, is_trainable=True
+)
 class AugmentedMemoizationPolicyGraphComponent(MemoizationPolicyGraphComponent):
     """The policy that remembers examples from training stories for `max_history` turns.
 
diff --git a/rasa/core/policies/ted_policy.py b/rasa/core/policies/ted_policy.py
index bb6881529995..9ae47d1891e5 100644
--- a/rasa/core/policies/ted_policy.py
+++ b/rasa/core/policies/ted_policy.py
@@ -934,6 +934,9 @@ def persist_model_utilities(self, model_path: Path) -> None:
         rasa.utils.io.json_pickle(
             model_path / f"{model_filename}.priority.pkl", self.priority
         )
+        rasa.utils.io.pickle_dump(
+            model_path / f"{model_filename}.meta.pkl", self.config
+        )
         rasa.utils.io.pickle_dump(
             model_path / f"{model_filename}.data_example.pkl", self.data_example,
         )
@@ -990,6 +993,9 @@ def _load_model_utilities(cls, model_path: Path) -> Dict[Text, Any]:
             )
             for tag_spec in entity_tag_specs
         ]
+        model_config = rasa.utils.io.pickle_load(
+            model_path / f"{cls._metadata_filename()}.meta.pkl"
+        )
 
         return {
             "tf_model_file": tf_model_file,
@@ -998,6 +1004,7 @@ def _load_model_utilities(cls, model_path: Path) -> Dict[Text, Any]:
             "label_data": label_data,
             "priority": priority,
             "entity_tag_specs": entity_tag_specs,
+            "model_config": model_config,
         }
 
     @classmethod
@@ -1054,7 +1061,6 @@ def _load(
         ) = cls._construct_model_initialization_data(model_utilities["loaded_data"])
 
         model = cls._load_tf_model(
-            config,
             model_utilities,
             model_data_example,
             predict_data_example,
@@ -1097,7 +1103,6 @@ def _load_policy_with_model(
     @classmethod
     def _load_tf_model(
         cls,
-        config: Dict[Text, Any],
         model_utilities: Dict[Text, Any],
         model_data_example: RasaModelData,
         predict_data_example: RasaModelData,
@@ -1109,7 +1114,7 @@ def _load_tf_model(
             model_data_example,
             predict_data_example,
             data_signature=model_data_example.get_signature(),
-            config=config,
+            config=model_utilities["model_config"],
             max_history_featurizer_is_used=isinstance(
                 featurizer, MaxHistoryTrackerFeaturizer
             ),
diff --git a/rasa/core/train.py b/rasa/core/train.py
index 3dcb9156367d..ce823554df59 100644
--- a/rasa/core/train.py
+++ b/rasa/core/train.py
@@ -1,78 +1,17 @@
 import argparse
 import logging
 import os
-import tempfile
-import typing
-from typing import Dict, Optional, Text, Union, List
+from pathlib import Path
+from typing import Dict, Optional, Text, List
 
 import rasa.shared.utils.io
 import rasa.utils.io
 from rasa.constants import NUMBER_OF_TRAINING_STORIES_FILE, PERCENTAGE_KEY
-from rasa.shared.core.domain import Domain
 from rasa.shared.importers.importer import TrainingDataImporter
-from rasa.utils.common import TempDirectoryPath
-
-if typing.TYPE_CHECKING:
-    from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter
-    from rasa.core.utils import AvailableEndpoints
-    from rasa.core.agent import Agent
 
 logger = logging.getLogger(__name__)
 
 
-def train(
-    domain_file: Union[Domain, Text],
-    training_resource: Union[Text, "TrainingDataImporter"],
-    output_path: Text,
-    interpreter: Optional["NaturalLanguageInterpreter"] = None,
-    endpoints: "AvailableEndpoints" = None,
-    policy_config: Optional[Union[Text, Dict]] = None,
-    exclusion_percentage: Optional[int] = None,
-    additional_arguments: Optional[Dict] = None,
-    model_to_finetune: Optional["Agent"] = None,
-) -> "Agent":
-    """Trains the model."""
-    from rasa.core import config, utils
-    from rasa.core.utils import AvailableEndpoints
-    from rasa.core.agent import Agent
-
-    if not endpoints:
-        endpoints = AvailableEndpoints()
-
-    if not additional_arguments:
-        additional_arguments = {}
-
-    policies = config.load(policy_config)
-
-    agent = Agent(
-        domain_file,
-        generator=endpoints.nlg,
-        action_endpoint=endpoints.action,
-        interpreter=interpreter,
-        policies=policies,
-    )
-
-    data_load_args, additional_arguments = utils.extract_args(
-        additional_arguments,
-        {
-            "use_story_concatenation",
-            "unique_last_num_states",
-            "augmentation_factor",
-            "remove_duplicates",
-            "debug_plots",
-        },
-    )
-    training_data = agent.load_data(
-        training_resource, exclusion_percentage=exclusion_percentage, **data_load_args
-    )
-    if model_to_finetune:
-        agent.policy_ensemble = model_to_finetune.policy_ensemble
-    agent.train(training_data, **additional_arguments)
-    agent.persist(output_path)
-
-    return agent
-
-
 def train_comparison_models(
     story_file: Text,
     domain: Text,
@@ -83,7 +22,7 @@ def train_comparison_models(
     additional_arguments: Optional[Dict] = None,
 ) -> None:
     """Train multiple models for comparison of policies"""
-    from rasa import model
+    import rasa.model_training
 
     exclusion_percentages = exclusion_percentages or []
     policy_configs = policy_configs or []
@@ -93,11 +32,6 @@ def train_comparison_models(
 
         for current_run, percentage in enumerate(exclusion_percentages, 1):
             for policy_config in policy_configs:
-
-                file_importer = TrainingDataImporter.load_core_importer_from_config(
-                    policy_config, domain, [story_file]
-                )
-
                 config_name = os.path.splitext(os.path.basename(policy_config))[0]
                 logging.info(
                     "Starting to train {} round {}/{}"
@@ -107,34 +41,26 @@ def train_comparison_models(
                     )
                 )
 
-                with TempDirectoryPath(tempfile.mkdtemp()) as train_path:
-                    train(
-                        domain,
-                        file_importer,
-                        train_path,
-                        policy_config=policy_config,
-                        exclusion_percentage=percentage,
-                        additional_arguments=additional_arguments,
-                    )
-                    new_fingerprint = model.model_fingerprint(file_importer)
-
-                    output_dir = os.path.join(output_path, "run_" + str(r + 1))
-                    model_name = config_name + PERCENTAGE_KEY + str(percentage)
-                    model.package_model(
-                        fingerprint=new_fingerprint,
-                        output_directory=output_dir,
-                        train_path=train_path,
-                        fixed_model_name=model_name,
-                    )
+                rasa.model_training.train_core(
+                    domain,
+                    policy_config,
+                    stories=story_file,
+                    output=str(Path(output_path, f"run_{r +1}")),
+                    fixed_model_name=config_name + PERCENTAGE_KEY + str(percentage),
+                    additional_arguments={
+                        **additional_arguments,
+                        "exclusion_percentage": percentage,
+                    },
+                )
 
 
 def get_no_of_stories(story_file: Text, domain: Text) -> int:
-    """Get number of stories in a file."""
-    from rasa.shared.core.domain import Domain
-    from rasa.shared.core.training_data import loading
-
-    stories = loading.load_data_from_files([story_file], Domain.load(domain))
-    return len(stories)
+    """Gets number of stories in a file."""
+    importer = TrainingDataImporter.load_from_dict(
+        domain_path=domain, training_data_paths=[story_file]
+    )
+    story_graph = importer.get_stories()
+    return len(story_graph.story_steps)
 
 
 def do_compare_training(
diff --git a/rasa/engine/caching.py b/rasa/engine/caching.py
index 3203368a0476..adbf26f68d16 100644
--- a/rasa/engine/caching.py
+++ b/rasa/engine/caching.py
@@ -162,9 +162,7 @@ def __init__(self) -> None:
 
         The `Cache` setting can be configured via environment variables.
         """
-        self._cache_location = Path(
-            os.environ.get(CACHE_LOCATION_ENV, DEFAULT_CACHE_LOCATION)
-        )
+        self._cache_location = LocalTrainingCache._get_cache_location()
 
         self._max_cache_size = float(
             os.environ.get(CACHE_SIZE_ENV, DEFAULT_CACHE_SIZE_MB)
@@ -184,6 +182,10 @@ def __init__(self) -> None:
 
         self._drop_cache_entries_from_incompatible_versions()
 
+    @staticmethod
+    def _get_cache_location() -> Path:
+        return Path(os.environ.get(CACHE_LOCATION_ENV, DEFAULT_CACHE_LOCATION))
+
     def _create_database(self) -> sqlalchemy.orm.sessionmaker:
         if self._is_disabled():
             # Use in-memory database as mock to avoid having to check `_is_disabled`
diff --git a/rasa/engine/graph.py b/rasa/engine/graph.py
index d6cde941885c..05f2f86eb73b 100644
--- a/rasa/engine/graph.py
+++ b/rasa/engine/graph.py
@@ -6,7 +6,7 @@
 import logging
 from typing import Any, Callable, Dict, List, Optional, Text, Type, Tuple
 
-
+from rasa.core.channels import UserMessage
 from rasa.engine.exceptions import (
     GraphComponentException,
     GraphSchemaException,
@@ -15,10 +15,22 @@
 from rasa.engine.storage.resource import Resource
 
 from rasa.engine.storage.storage import ModelStorage
-
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.exceptions import InvalidConfigException
+from rasa.shared.importers.importer import TrainingDataImporter
 
 logger = logging.getLogger(__name__)
 
+PLACEHOLDER_IMPORTER = "__importer__"
+PLACEHOLDER_MESSAGE = "__message__"
+PLACEHOLDER_TRACKER = "__tracker__"
+
+RESERVED_PLACEHOLDERS = {
+    PLACEHOLDER_IMPORTER: TrainingDataImporter,
+    PLACEHOLDER_MESSAGE: List[UserMessage],
+    PLACEHOLDER_TRACKER: DialogueStateTracker,
+}
+
 
 @dataclass
 class SchemaNode:
@@ -357,6 +369,10 @@ def _load_component(self, **kwargs: Any) -> None:
                 execution_context=self._execution_context,
                 **kwargs,
             )
+        except InvalidConfigException:
+            # Pass through somewhat expected exception to allow more fine granular
+            # handling of exceptions.
+            raise
         except Exception as e:
             raise GraphComponentException(
                 f"Error initializing graph component for node {self._node_name}."
@@ -420,6 +436,10 @@ def __call__(
 
         try:
             output = self._fn(self._component, **run_kwargs)
+        except InvalidConfigException:
+            # Pass through somewhat expected exception to allow more fine granular
+            # handling of exceptions.
+            raise
         except Exception as e:
             raise GraphComponentException(
                 f"Error running graph component for node {self._node_name}."
diff --git a/rasa/engine/recipes/default_recipe.py b/rasa/engine/recipes/default_recipe.py
index c7a0925c5a07..84180ee03d5b 100644
--- a/rasa/engine/recipes/default_recipe.py
+++ b/rasa/engine/recipes/default_recipe.py
@@ -3,6 +3,7 @@
 import copy
 import enum
 import logging
+import math
 from enum import Enum
 from typing import Dict, Text, Any, Tuple, Type, Optional, List, Callable
 
@@ -14,7 +15,15 @@
 )
 from rasa.core.policies.ensemble import DefaultPolicyPredictionEnsemble
 
-from rasa.engine.graph import GraphSchema, GraphComponent, SchemaNode, ExecutionContext
+from rasa.engine.graph import (
+    GraphSchema,
+    GraphComponent,
+    SchemaNode,
+    ExecutionContext,
+    PLACEHOLDER_IMPORTER,
+    PLACEHOLDER_MESSAGE,
+    PLACEHOLDER_TRACKER,
+)
 from rasa.engine.recipes.recipe import Recipe
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
@@ -29,7 +38,6 @@
 from rasa.graph_components.providers.nlu_training_data_provider import (
     NLUTrainingDataProvider,
 )
-from rasa.graph_components.providers.project_provider import ProjectProvider
 from rasa.graph_components.providers.rule_only_provider import RuleOnlyDataProvider
 from rasa.graph_components.providers.story_graph_provider import StoryGraphProvider
 from rasa.graph_components.providers.training_tracker_provider import (
@@ -37,11 +45,12 @@
 )
 from rasa.graph_components.validators.finetuning_validator import FinetuningValidator
 
-from rasa.shared.exceptions import RasaException
+from rasa.shared.exceptions import RasaException, InvalidConfigException
 from rasa.shared.importers.autoconfig import TrainingType
 
-# from rasa.utils.tensorflow.constants import EPOCHS
 from rasa.shared.importers.importer import TrainingDataImporter
+from rasa.utils.tensorflow.constants import EPOCHS
+import rasa.shared.utils.common
 
 logger = logging.getLogger(__name__)
 
@@ -62,7 +71,7 @@ def create(
 
     def validate(self, importer: TrainingDataImporter) -> TrainingDataImporter:
         """Validates component."""
-        pass
+        return importer
 
 
 default_predict_kwargs = dict(constructor_name="load", eager=True, is_target=False,)
@@ -96,10 +105,97 @@ def __init__(self) -> None:
         """Creates recipe."""
         self._use_core = True
         self._use_nlu = True
+        self._use_end_to_end = True
         self._is_finetuning = False
 
+        from rasa.nlu.classifiers.diet_classifier import (  # noqa: F401
+            DIETClassifierGraphComponent,
+        )
+        from rasa.nlu.classifiers.fallback_classifier import (  # noqa: F401
+            FallbackClassifierGraphComponent,
+        )
+        from rasa.nlu.classifiers.keyword_intent_classifier import (  # noqa: F401
+            KeywordIntentClassifierGraphComponent,
+        )
+        from rasa.nlu.classifiers.mitie_intent_classifier import (  # noqa: F401
+            MitieIntentClassifierGraphComponent,
+        )
+        from rasa.nlu.classifiers.sklearn_intent_classifier import (  # noqa: F401
+            SklearnIntentClassifierGraphComponent,
+        )
+        from rasa.nlu.extractors.crf_entity_extractor import (  # noqa: F401
+            CRFEntityExtractorGraphComponent,
+        )
+        from rasa.nlu.extractors.duckling_entity_extractor import (  # noqa: F401
+            DucklingEntityExtractorGraphComponent,
+        )
+        from rasa.nlu.extractors.entity_synonyms import (  # noqa: F401
+            EntitySynonymMapperGraphComponent,
+        )
+        from rasa.nlu.extractors.mitie_entity_extractor import (  # noqa: F401
+            MitieEntityExtractorGraphComponent,
+        )
+        from rasa.nlu.extractors.spacy_entity_extractor import (  # noqa: F401
+            SpacyEntityExtractorGraphComponent,
+        )
+        from rasa.nlu.extractors.regex_entity_extractor import (  # noqa: F401
+            RegexEntityExtractorGraphComponent,
+        )
+        from rasa.nlu.featurizers.sparse_featurizer.lexical_syntactic_featurizer import (  # noqa: F401, E501
+            LexicalSyntacticFeaturizerGraphComponent,
+        )
+        from rasa.nlu.featurizers.dense_featurizer.convert_featurizer import (  # noqa: F401, E501
+            ConveRTFeaturizerGraphComponent,
+        )
+        from rasa.nlu.featurizers.dense_featurizer.mitie_featurizer import (  # noqa: F401, E501
+            MitieFeaturizerGraphComponent,
+        )
+        from rasa.nlu.featurizers.dense_featurizer.spacy_featurizer import (  # noqa: F401, E501
+            SpacyFeaturizerGraphComponent,
+        )
+        from rasa.nlu.featurizers.sparse_featurizer.count_vectors_featurizer import (  # noqa: F401, E501
+            CountVectorsFeaturizerGraphComponent,
+        )
+        from rasa.nlu.featurizers.dense_featurizer.lm_featurizer import (  # noqa: F401
+            LanguageModelFeaturizerGraphComponent,
+        )
+        from rasa.nlu.featurizers.sparse_featurizer.regex_featurizer import (  # noqa: F401, E501
+            RegexFeaturizerGraphComponent,
+        )
+        from rasa.nlu.selectors.response_selector import (  # noqa: F401
+            ResponseSelectorGraphComponent,
+        )
+        from rasa.nlu.tokenizers.jieba_tokenizer import (  # noqa: F401
+            JiebaTokenizerGraphComponent,
+        )
+        from rasa.nlu.tokenizers.mitie_tokenizer import (  # noqa: F401
+            MitieTokenizerGraphComponent,
+        )
+        from rasa.nlu.tokenizers.spacy_tokenizer import (  # noqa: F401
+            SpacyTokenizerGraphComponent,
+        )
+        from rasa.nlu.tokenizers.whitespace_tokenizer import (  # noqa: F401
+            WhitespaceTokenizerGraphComponent,
+        )
+        from rasa.nlu.utils.mitie_utils import MitieNLPGraphComponent  # noqa: F401
+        from rasa.nlu.utils.spacy_utils import SpacyNLPGraphComponent  # noqa: F401
+
+        from rasa.core.policies.ted_policy import TEDPolicyGraphComponent  # noqa: F401
+        from rasa.core.policies.memoization import (  # noqa: F401
+            MemoizationPolicyGraphComponent,
+            AugmentedMemoizationPolicyGraphComponent,
+        )
+        from rasa.core.policies.rule_policy import (  # noqa: F401
+            RulePolicyGraphComponent,
+        )
+        from rasa.core.policies.unexpected_intent_policy import (  # noqa: F401
+            UnexpecTEDIntentPolicyGraphComponent,
+        )
+
     @dataclasses.dataclass()
     class RegisteredComponent:
+        """Describes a graph component which was registered with the decorator."""
+
         clazz: Type[GraphComponent]
         type: DefaultV1Recipe.ComponentType
         is_trainable: bool
@@ -112,7 +208,20 @@ def register(
         is_trainable: bool,
         model_from: Optional[Text] = None,
     ) -> Callable[[Type[GraphComponent]], Type[GraphComponent]]:
-        def f(registered_class: Type[GraphComponent]) -> Type[GraphComponent]:
+        """This decorator can be used to register classes with the recipe.
+
+        Args:
+            component_type: Describes the type of the component which is then used
+                to place the component in the graph.
+            is_trainable: `True` if the component requires training.
+            model_from: Can be used if this component requires a pre-loaded model
+                such as `SpacyNLP` or `MitieNLP`.
+
+        Returns:
+            The registered class.
+        """
+
+        def decorator(registered_class: Type[GraphComponent]) -> Type[GraphComponent]:
             if not issubclass(registered_class, GraphComponent):
                 raise DefaultV1RecipeRegisterException(
                     f"Failed to register class '{registered_class.__name__}' with "
@@ -126,14 +235,27 @@ def f(registered_class: Type[GraphComponent]) -> Type[GraphComponent]:
             )
             return registered_class
 
-        return f
+        return decorator
 
     @classmethod
     def _from_registry(cls, name: Text) -> RegisteredComponent:
+        # TODO: Hack until we've deleted the old components
         if not name.endswith("GraphComponent"):
             name = f"{name}GraphComponent"
-        # TODO: Handle case name is full module path
-        return cls._registered_components[name]
+
+        if name in cls._registered_components:
+            return cls._registered_components[name]
+
+        if "." in name:
+            clazz = rasa.shared.utils.common.class_from_module_path(name)
+            if clazz.__name__ in cls._registered_components:
+                return cls._registered_components[clazz.__name__]
+
+        raise InvalidConfigException(
+            f"Can't load class for name '{name}'. Please make sure to provide "
+            f"a valid name or module path and to register it using the "
+            f"'@DefaultV1Recipe.register' decorator."
+        )
 
     def schemas_for_config(
         self,
@@ -141,7 +263,7 @@ def schemas_for_config(
         cli_parameters: Dict[Text, Any],
         training_type: TrainingType = TrainingType.BOTH,
         is_finetuning: bool = False,
-    ) -> Tuple[GraphSchema, GraphSchema]:
+    ) -> Tuple[GraphSchema, GraphSchema, Optional[Text]]:
         """Converts the default config to graphs (see interface for full docstring)."""
         self._use_core = (
             bool(config.get("policies")) and not training_type == TrainingType.NLU
@@ -149,46 +271,56 @@ def schemas_for_config(
         self._use_nlu = (
             bool(config.get("pipeline")) and not training_type == TrainingType.CORE
         )
+
+        if not self._use_nlu and training_type == TrainingType.NLU:
+            raise InvalidConfigException(
+                "Can't train an NLU model without a specified pipeline. Please make "
+                "sure to specify a valid pipeline in your configuration."
+            )
+
+        if not self._use_core and training_type == TrainingType.CORE:
+            raise InvalidConfigException(
+                "Can't train an Core model without policies. Please make "
+                "sure to specify a valid policy in your configuration."
+            )
+
+        self._use_end_to_end = (
+            self._use_nlu
+            and self._use_core
+            and training_type == TrainingType.END_TO_END
+        )
+
         self._is_finetuning = is_finetuning
+
         train_nodes, preprocessors = self._create_train_nodes(config, cli_parameters)
         predict_nodes = self._create_predict_nodes(config, preprocessors, train_nodes)
 
-        return GraphSchema(train_nodes), GraphSchema(predict_nodes)
+        return (
+            GraphSchema(train_nodes),
+            GraphSchema(predict_nodes),
+            config.get("language"),
+        )
 
     def _create_train_nodes(
         self, config: Dict[Text, Any], cli_parameters: Dict[Text, Any]
     ) -> Tuple[Dict[Text, SchemaNode], List[Text]]:
         train_config = copy.deepcopy(config)
 
-        training_data_paths = cli_parameters.get("data") or []
-        for arg in ["stories", "nlu"]:
-            if cli_parameters.get(arg):
-                training_data_paths.append(cli_parameters[arg])
-
         train_nodes = {
-            "project_provider": SchemaNode(
-                needs={},
-                uses=ProjectProvider,
-                constructor_name="create",
-                fn="provide",
-                config={
-                    "domain_path": cli_parameters.get("domain"),
-                    "training_data_paths": training_data_paths,
-                },
-                is_input=True,
-            ),
             "schema_validator": SchemaNode(
-                needs={"importer": "project_provider"},
+                needs={"importer": PLACEHOLDER_IMPORTER},
                 uses=SchemaValidator,
                 constructor_name="create",
                 fn="validate",
                 config={},
+                is_input=True,
             ),
             "finetuning_validator": SchemaNode(
                 needs={"importer": "schema_validator"},
                 uses=FinetuningValidator,
-                constructor_name="create",
+                constructor_name="load" if self._is_finetuning else "create",
                 fn="validate",
+                is_input=True,
                 config={"validate_core": self._use_core, "validate_nlu": self._use_nlu},
             ),
         }
@@ -213,7 +345,7 @@ def _add_nlu_train_nodes(
         train_nodes: Dict[Text, SchemaNode],
         cli_parameters: Dict[Text, Any],
     ) -> List[Text]:
-        persist_nlu_data = bool(cli_parameters.get("persist_nlu_data"))
+        persist_nlu_data = bool(cli_parameters.get("persist_nlu_training_data"))
         train_nodes["nlu_training_data_provider"] = SchemaNode(
             needs={"importer": "finetuning_validator"},
             uses=NLUTrainingDataProvider,
@@ -245,10 +377,7 @@ def _add_nlu_train_nodes(
                     config=item,
                 )
                 # TODO: Spacy preprocessor
-            elif component.type in [
-                self.ComponentType.MESSAGE_TOKENIZER,
-                self.ComponentType.MESSAGE_FEATURIZER,
-            ]:
+            else:
                 from_resource = None
                 if component.is_trainable:
                     from_resource = self._add_nlu_train_node(
@@ -260,31 +389,22 @@ def _add_nlu_train_nodes(
                         cli_parameters,
                     )
 
-                last_run_node = self._add_nlu_process_node(
-                    train_nodes,
-                    component.clazz,
-                    component_name,
-                    last_run_node,
-                    item,
-                    from_resource=from_resource,
-                )
-
-                # Remember for End-to-End-Featurization
-                preprocessors.append(last_run_node)
-            elif component.type in [
-                self.ComponentType.INTENT_CLASSIFIER,
-                self.ComponentType.ENTITY_EXTRACTOR,
-            ]:
-                if component.is_trainable:
-                    _ = self._add_nlu_train_node(
+                if component.type in [
+                    self.ComponentType.MESSAGE_TOKENIZER,
+                    self.ComponentType.MESSAGE_FEATURIZER,
+                ]:
+                    last_run_node = self._add_nlu_process_node(
                         train_nodes,
                         component.clazz,
                         component_name,
                         last_run_node,
                         item,
-                        cli_parameters,
+                        from_resource=from_resource,
                     )
 
+                    # Remember for End-to-End-Featurization
+                    preprocessors.append(last_run_node)
+
         return preprocessors
 
     def _add_nlu_train_node(
@@ -296,8 +416,7 @@ def _add_nlu_train_node(
         config: Dict[Text, Any],
         cli_parameters: Dict[Text, Any],
     ) -> Text:
-        config_from_cli = self._extra_config_from_cli(cli_parameters, component)
-
+        config_from_cli = self._extra_config_from_cli(cli_parameters, component, config)
         model_provider_needs = self._get_model_provider_needs(train_nodes, component,)
 
         train_node_name = f"train_{component_name}"
@@ -312,7 +431,10 @@ def _add_nlu_train_node(
         return train_node_name
 
     def _extra_config_from_cli(
-        self, cli_parameters: Dict[Text, Any], component: Type[GraphComponent]
+        self,
+        cli_parameters: Dict[Text, Any],
+        component: Type[GraphComponent],
+        component_config: Dict[Text, Any],
     ) -> Dict[Text, Any]:
         from rasa.nlu.classifiers.mitie_intent_classifier import (
             MitieIntentClassifierGraphComponent,
@@ -324,7 +446,7 @@ def _extra_config_from_cli(
             SklearnIntentClassifierGraphComponent,
         )
 
-        cli_args_mapping = {
+        cli_args_mapping: Dict[Type[GraphComponent], List[Text]] = {
             MitieIntentClassifierGraphComponent: ["num_threads"],
             MitieEntityExtractorGraphComponent: ["num_threads"],
             SklearnIntentClassifierGraphComponent: ["num_threads"],
@@ -336,12 +458,17 @@ def _extra_config_from_cli(
             if param in cli_parameters
         }
 
-        # TODO: Comment in once all components are adapted
-        # if (
-        #     "epoch_fraction" in cli_parameters
-        #     and EPOCHS in component.get_default_config()
-        # ):
-        #     config_from_cli["epoch_fraction"] = cli_parameters["epoch_fraction"]
+        if (
+            self._is_finetuning
+            and "finetuning_epoch_fraction" in cli_parameters
+            and EPOCHS in component.get_default_config()
+        ):
+            old_number_epochs = component_config.get(
+                EPOCHS, component.get_default_config()[EPOCHS]
+            )
+            epoch_fraction = float(cli_parameters["finetuning_epoch_fraction"])
+
+            config_from_cli[EPOCHS] = math.ceil(old_number_epochs * epoch_fraction)
 
         return config_from_cli
 
@@ -427,7 +554,7 @@ def _add_core_train_nodes(
             uses=StoryGraphProvider,
             constructor_name="create",
             fn="provide",
-            config={},
+            config={"exclusion_percentage": cli_parameters.get("exclusion_percentage")},
             is_input=True,
         )
         train_nodes["training_tracker_provider"] = SchemaNode(
@@ -448,18 +575,20 @@ def _add_core_train_nodes(
             },
         )
 
-        node_with_e2e_features = None
-        if self._use_nlu:
-            node_with_e2e_features = self._add_end_to_end_features_for_training(
-                preprocessors, train_nodes
-            )
-
+        policy_with_end_to_end_support_used = False
         for idx, item in enumerate(train_config["policies"]):
             component_name = item.pop("name")
             component = self._from_registry(component_name)
 
             extra_config_from_cli = self._extra_config_from_cli(
-                cli_parameters, component.clazz
+                cli_parameters, component.clazz, item
+            )
+
+            requires_end_to_end_data = self._use_end_to_end and (
+                component.type == self.ComponentType.POLICY_WITH_END_TO_END_SUPPORT
+            )
+            policy_with_end_to_end_support_used = (
+                policy_with_end_to_end_support_used or requires_end_to_end_data
             )
 
             train_nodes[f"train_{component_name}{idx}"] = SchemaNode(
@@ -467,10 +596,8 @@ def _add_core_train_nodes(
                     "training_trackers": "training_tracker_provider",
                     "domain": "domain_without_responses_provider",
                     **(
-                        {"end_to_end_features": node_with_e2e_features}
-                        if component.type
-                        == self.ComponentType.POLICY_WITH_END_TO_END_SUPPORT
-                        and node_with_e2e_features
+                        {"precomputations": "end_to_end_features_provider"}
+                        if requires_end_to_end_data
                         else {}
                     ),
                 },
@@ -481,9 +608,12 @@ def _add_core_train_nodes(
                 config={**item, **extra_config_from_cli},
             )
 
+        if self._use_end_to_end and policy_with_end_to_end_support_used:
+            self._add_end_to_end_features_for_training(preprocessors, train_nodes)
+
     def _add_end_to_end_features_for_training(
         self, preprocessors: List[Text], train_nodes: Dict[Text, SchemaNode],
-    ) -> Text:
+    ) -> None:
         train_nodes["story_to_nlu_training_data_converter"] = SchemaNode(
             needs={
                 "story_graph": "story_graph_provider",
@@ -514,8 +644,6 @@ def _add_end_to_end_features_for_training(
             config={},
         )
 
-        return node_with_e2e_features
-
     def _create_predict_nodes(
         self,
         config: Dict[Text, SchemaNode],
@@ -526,10 +654,10 @@ def _create_predict_nodes(
         predict_config = copy.deepcopy(config)
         predict_nodes = {}
 
-        last_run_node = None
+        nlu_output_node = None
 
         if self._use_nlu:
-            last_run_node = self._add_nlu_predict_nodes(
+            nlu_output_node = self._add_nlu_predict_nodes(
                 predict_config, predict_nodes, train_nodes
             )
 
@@ -537,7 +665,7 @@ def _create_predict_nodes(
             self._add_core_predict_nodes(
                 predict_config,
                 predict_nodes,
-                last_run_node,
+                nlu_output_node,
                 train_nodes,
                 preprocessors,
             )
@@ -552,8 +680,7 @@ def _add_nlu_predict_nodes(
     ) -> Text:
         predict_nodes["nlu_message_converter"] = SchemaNode(
             **default_predict_kwargs,
-            # TODO
-            needs={"message": "__input__"},
+            needs={"messages": PLACEHOLDER_MESSAGE},
             uses=NLUMessageConverter,
             fn="convert_user_message",
             config={},
@@ -662,7 +789,6 @@ def _add_nlu_predict_node(
         last_run_node: Text,
     ) -> Text:
         node_name = f"run_{component_name}"
-
         model_provider_needs = self._get_model_provider_needs(predict_nodes, node.uses,)
 
         predict_nodes[node_name] = dataclasses.replace(
@@ -678,20 +804,18 @@ def _add_core_predict_nodes(
         self,
         predict_config: Dict[Text, Any],
         predict_nodes: Dict[Text, SchemaNode],
-        last_run_node: Optional[Text],
+        nlu_output_node: Optional[Text],
         train_nodes: Dict[Text, SchemaNode],
         preprocessors: List[Text],
     ) -> None:
-        if last_run_node:
+        if nlu_output_node:
             predict_nodes["nlu_prediction_to_history_adder"] = SchemaNode(
                 **default_predict_kwargs,
-                # TODO: I think there is a bug in our Dask Runner for this case as
-                # the input will override `messages`
                 needs={
-                    "predictions": last_run_node,
+                    "predictions": nlu_output_node,
                     "domain": "domain_provider",
-                    "original_message": "__input__",
-                    "tracker": "__input__",
+                    "original_messages": PLACEHOLDER_MESSAGE,
+                    "tracker": PLACEHOLDER_TRACKER,
                 },
                 uses=NLUPredictionToHistoryAdder,
                 fn="add",
@@ -706,16 +830,12 @@ def _add_core_predict_nodes(
             resource=Resource("domain_provider"),
         )
 
-        nlu_merge_needs = {}
         node_with_e2e_features = None
 
-        if self._use_nlu and last_run_node:
+        if "end_to_end_features_provider" in train_nodes:
             node_with_e2e_features = self._add_end_to_end_features_for_inference(
                 predict_nodes, preprocessors
             )
-            # The tracker has to be provided via `runner.run(..., inputs=...)` in this
-            # case
-            nlu_merge_needs = {"tracker": "nlu_prediction_to_history_adder"}
 
         rule_only_data_provider_name = "rule_only_data_provider"
         rule_policy_resource = None
@@ -737,10 +857,9 @@ def _add_core_predict_nodes(
                 train_nodes[train_node_name],
                 **default_predict_kwargs,
                 needs={
-                    **nlu_merge_needs,
                     "domain": "domain_provider",
                     **(
-                        {"end_to_end_features": node_with_e2e_features}
+                        {"precomputations": node_with_e2e_features}
                         if component.type
                         == self.ComponentType.POLICY_WITH_END_TO_END_SUPPORT
                         and node_with_e2e_features
@@ -748,7 +867,7 @@ def _add_core_predict_nodes(
                     ),
                     "tracker": "nlu_prediction_to_history_adder"
                     if self._use_nlu
-                    else "__input__",
+                    else PLACEHOLDER_TRACKER,
                     "rule_only_data": rule_only_data_provider_name,
                 },
                 fn="predict_action_probabilities",
@@ -772,7 +891,7 @@ def _add_core_predict_nodes(
                 "domain": "domain_provider",
                 "tracker": "nlu_prediction_to_history_adder"
                 if self._use_nlu
-                else "__input__",
+                else PLACEHOLDER_TRACKER,
             },
             uses=DefaultPolicyPredictionEnsemble,
             fn="combine_predictions_from_kwargs",
diff --git a/rasa/engine/recipes/recipe.py b/rasa/engine/recipes/recipe.py
index 0511d12b0bf2..7788e4e7cb0f 100644
--- a/rasa/engine/recipes/recipe.py
+++ b/rasa/engine/recipes/recipe.py
@@ -14,8 +14,20 @@ class InvalidRecipeException(RasaException):
 
 
 class Recipe(abc.ABC):
+    """Base class for `Recipe`s which convert configs to graph schemas."""
+
     @staticmethod
     def recipe_for_name(name: Optional[Text]) -> Recipe:
+        """Returns `Recipe` based on an optional recipe identifier.
+
+        Args:
+            name: The identifier which is used to select a certain `Recipe`. If `None`
+                the default recipe will be used.
+
+        Returns:
+            A recipe which can be used to convert a given config to train and predict
+            graph schemas.
+        """
         from rasa.engine.recipes.default_recipe import DefaultV1Recipe
 
         if name is None:
@@ -44,7 +56,7 @@ def schemas_for_config(
         cli_parameters: Dict[Text, Any],
         training_type: TrainingType = TrainingType.BOTH,
         is_finetuning: bool = False,
-    ) -> Tuple[GraphSchema, GraphSchema]:
+    ) -> Tuple[GraphSchema, GraphSchema, Optional[Text]]:
         """Converts a config to graph schemas for training and prediction.
 
         Args:
@@ -59,6 +71,7 @@ def schemas_for_config(
 
         Returns:
             A schema for training a model and a schema for making predictions with this
-            trained model.
+            trained model and the language of the model (`None` in case the language
+            was not specified)
         """
         ...
diff --git a/rasa/engine/runner/dask.py b/rasa/engine/runner/dask.py
index bd31cccf5fee..75ee7e5489c3 100644
--- a/rasa/engine/runner/dask.py
+++ b/rasa/engine/runner/dask.py
@@ -118,7 +118,7 @@ def run(
     @staticmethod
     def _add_inputs_to_graph(inputs: Optional[Dict[Text, Any]], graph: Any,) -> None:
         for input_name, input_value in inputs.items():
-            if input_value in graph.keys():
+            if isinstance(input_value, str) and input_value in graph.keys():
                 raise GraphRunError(
                     f"Input value '{input_value}' clashes with a node name. Make sure "
                     f"that none of the input names passed to the `run` method are the "
diff --git a/rasa/engine/storage/local_model_storage.py b/rasa/engine/storage/local_model_storage.py
index 06d2cbfb7efd..060953cd97b5 100644
--- a/rasa/engine/storage/local_model_storage.py
+++ b/rasa/engine/storage/local_model_storage.py
@@ -148,6 +148,9 @@ def create_model_package(
             )
             self._persist_metadata(model_metadata, temporary_directory)
 
+            if not model_archive_path.parent.exists():
+                model_archive_path.parent.mkdir(parents=True)
+
             with tarfile.open(model_archive_path, "w:gz") as tar:
                 tar.add(temporary_directory, arcname="")
 
diff --git a/rasa/engine/storage/resource.py b/rasa/engine/storage/resource.py
index f39ab49510e1..9e0a5d6ed7a0 100644
--- a/rasa/engine/storage/resource.py
+++ b/rasa/engine/storage/resource.py
@@ -6,6 +6,7 @@
 from typing import Text
 
 import rasa.utils.common
+import rasa.utils.io
 
 if typing.TYPE_CHECKING:
     from rasa.engine.storage.storage import ModelStorage
@@ -39,8 +40,16 @@ def from_cache(
         logger.debug(f"Loading resource '{node_name}' from cache.")
 
         resource = Resource(node_name)
-        with model_storage.write_to(resource) as resource_directory:
-            rasa.utils.common.copy_directory(directory, resource_directory)
+        try:
+            with model_storage.write_to(resource) as resource_directory:
+                rasa.utils.common.copy_directory(directory, resource_directory)
+        except ValueError:
+            # This might happen during finetuning as in this case the model storage
+            # is already filled
+            if not rasa.utils.io.are_directories_equal(directory, resource_directory):
+                # We skip caching in case we see the cached output and output
+                # from the model which we want to finetune are not the same
+                raise
 
         logger.debug(f"Successfully initialized resource '{node_name}' from cache.")
 
@@ -54,8 +63,14 @@ def to_cache(self, directory: Path, model_storage: ModelStorage) -> None:
             model_storage: The model storage which currently contains the persisted
                 `Resource`.
         """
-        with model_storage.read_from(self) as resource_directory:
-            rasa.utils.common.copy_directory(resource_directory, directory)
+        try:
+            with model_storage.read_from(self) as resource_directory:
+                rasa.utils.common.copy_directory(resource_directory, directory)
+        except ValueError:
+            logger.debug(
+                f"Skipped caching resource '{self.name}' as no persisted "
+                f"data was found."
+            )
 
     def fingerprint(self) -> Text:
         """Provides fingerprint for `Resource`.
diff --git a/rasa/engine/training/components.py b/rasa/engine/training/components.py
index ae8d52532035..b6064ed9dffe 100644
--- a/rasa/engine/training/components.py
+++ b/rasa/engine/training/components.py
@@ -135,7 +135,10 @@ def run(self, **kwargs: Any) -> FingerprintStatus:
         """
         fingerprint_key = fingerprinting.calculate_fingerprint_key(
             graph_component_class=self._class_of_replaced_component,
-            config=self._config_of_replaced_component,
+            config={
+                **self._class_of_replaced_component.get_default_config(),
+                **self._config_of_replaced_component,
+            },
             inputs=kwargs,
         )
 
diff --git a/rasa/engine/training/graph_trainer.py b/rasa/engine/training/graph_trainer.py
index 1ffafb96f138..1bb96854b4f1 100644
--- a/rasa/engine/training/graph_trainer.py
+++ b/rasa/engine/training/graph_trainer.py
@@ -4,17 +4,16 @@
 from typing import Any, Dict, Text, Type, Union
 
 from rasa.engine.caching import TrainingCache
-from rasa.engine.graph import ExecutionContext, GraphSchema
+from rasa.engine.graph import ExecutionContext, GraphSchema, PLACEHOLDER_IMPORTER
 from rasa.engine.runner.interface import GraphRunner
-from rasa.engine.storage.storage import ModelStorage
+from rasa.engine.storage.storage import ModelStorage, ModelMetadata
 from rasa.engine.training.components import (
     PrecomputedValueProvider,
     FingerprintComponent,
     FingerprintStatus,
 )
 from rasa.engine.training.hooks import TrainingHook
-from rasa.shared.core.domain import Domain
-
+from rasa.shared.importers.importer import TrainingDataImporter
 
 logger = logging.getLogger(__name__)
 
@@ -44,52 +43,68 @@ def train(
         self,
         train_schema: GraphSchema,
         predict_schema: GraphSchema,
-        domain_path: Path,
+        importer: TrainingDataImporter,
         output_filename: Path,
-    ) -> GraphRunner:
+        force_retraining: bool = False,
+        is_finetuning: bool = False,
+    ) -> ModelMetadata:
         """Trains and packages a model and returns the prediction graph runner.
 
         Args:
             train_schema: The train graph schema.
             predict_schema: The predict graph schema.
-            domain_path: The path to the domain file.
+            importer: The importer which provides the training data for the training.
             output_filename: The location to save the packaged model.
+            force_retraining: If `True` then the cache is skipped and all components
+                are retrained.
 
         Returns:
-            A graph runner loaded with the predict schema.
-
+            The metadata describing the trained model.
         """
         logger.debug("Starting training.")
 
-        pruned_training_schema = self._fingerprint_and_prune(train_schema)
+        # Retrieve the domain for the model metadata right at the start.
+        # This avoids that something during the graph runs mutates it.
+        domain = copy.deepcopy(importer.get_domain())
+
+        if force_retraining:
+            logger.debug(
+                "Skip fingerprint run as a full training of the model was enforced."
+            )
+            pruned_training_schema = train_schema
+        else:
+            fingerprint_run_outputs = self.fingerprint(
+                train_schema, importer=importer, is_finetuning=is_finetuning
+            )
+            pruned_training_schema = self._prune_schema(
+                train_schema, fingerprint_run_outputs
+            )
 
         hooks = [TrainingHook(cache=self._cache, model_storage=self._model_storage)]
 
         graph_runner = self._graph_runner_class.create(
             graph_schema=pruned_training_schema,
             model_storage=self._model_storage,
-            execution_context=ExecutionContext(graph_schema=pruned_training_schema),
+            execution_context=ExecutionContext(
+                graph_schema=train_schema, is_finetuning=is_finetuning
+            ),
             hooks=hooks,
         )
 
         logger.debug("Running the pruned train graph with real node execution.")
 
-        graph_runner.run()
+        graph_runner.run(inputs={PLACEHOLDER_IMPORTER: importer})
 
-        domain = Domain.from_path(domain_path)
-        model_metadata = self._model_storage.create_model_package(
+        return self._model_storage.create_model_package(
             output_filename, train_schema, predict_schema, domain
         )
 
-        return self._graph_runner_class.create(
-            graph_schema=predict_schema,
-            model_storage=self._model_storage,
-            execution_context=ExecutionContext(
-                graph_schema=predict_schema, model_id=model_metadata.model_id
-            ),
-        )
-
-    def _fingerprint_and_prune(self, train_schema: GraphSchema) -> GraphSchema:
+    def fingerprint(
+        self,
+        train_schema: GraphSchema,
+        importer: TrainingDataImporter,
+        is_finetuning: bool = False,
+    ) -> Dict[Text, Union[FingerprintStatus, Any]]:
         """Runs the graph using fingerprints to determine which nodes need to re-run.
 
         Nodes which have a matching fingerprint key in the cache can either be removed
@@ -98,25 +113,24 @@ def _fingerprint_and_prune(self, train_schema: GraphSchema) -> GraphSchema:
 
         Args:
             train_schema: The train graph schema that will be run in fingerprint mode.
+            importer: The importer which provides the training data for the training.
+            is_finetuning: `True` if we want to finetune the model.
 
         Returns:
-            A new, potentially smaller and/or cached, graph schema.
+            Mapping of node names to fingerprint results.
         """
         fingerprint_schema = self._create_fingerprint_schema(train_schema)
 
         fingerprint_graph_runner = self._graph_runner_class.create(
             graph_schema=fingerprint_schema,
             model_storage=self._model_storage,
-            execution_context=ExecutionContext(graph_schema=fingerprint_schema),
+            execution_context=ExecutionContext(
+                graph_schema=fingerprint_schema, is_finetuning=is_finetuning
+            ),
         )
 
         logger.debug("Running the train graph in fingerprint mode.")
-        fingerprint_run_outputs = fingerprint_graph_runner.run()
-
-        pruned_training_schema = self._prune_schema(
-            train_schema, fingerprint_run_outputs
-        )
-        return pruned_training_schema
+        return fingerprint_graph_runner.run(inputs={PLACEHOLDER_IMPORTER: importer})
 
     def _create_fingerprint_schema(self, train_schema: GraphSchema) -> GraphSchema:
         fingerprint_schema = copy.deepcopy(train_schema)
diff --git a/rasa/engine/validation.py b/rasa/engine/validation.py
index 22fbda921ede..24157192a4b0 100644
--- a/rasa/engine/validation.py
+++ b/rasa/engine/validation.py
@@ -27,6 +27,7 @@
     GraphComponent,
     SchemaNode,
     ExecutionContext,
+    RESERVED_PLACEHOLDERS,
 )
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
@@ -116,7 +117,14 @@ def _walk_and_check_for_cycles(
 
     parents = schema.nodes[node_name].needs.values()
     for parent_name in parents:
-        _walk_and_check_for_cycles([*visited_so_far, node_name], parent_name, schema)
+        if not _is_placeholder_input(parent_name):
+            _walk_and_check_for_cycles(
+                [*visited_so_far, node_name], parent_name, schema
+            )
+
+
+def _is_placeholder_input(name: Text) -> bool:
+    return name in RESERVED_PLACEHOLDERS
 
 
 def _validate_interface_usage(node_name: Text, node: SchemaNode) -> None:
@@ -281,7 +289,7 @@ def _validate_run_fn_return_type(
 
     # TODO: Handle forward references here
     if typing_utils.issubtype(return_type, typing.List):
-        return_type = typing.get_args(return_type)[0]
+        return_type = typing_utils.get_args(return_type)[0]
 
     if is_training and not isinstance(return_type, Fingerprintable):
         raise GraphSchemaValidationException(
@@ -349,9 +357,6 @@ def _validate_needs(
     )
 
     for param_name, parent_name in node.needs.items():
-        if parent_name == "__input__":
-            continue
-
         if not has_kwargs and param_name not in available_args:
             raise GraphSchemaValidationException(
                 f"Node '{node_name}' is configured to retrieve a value for the "
@@ -361,14 +366,20 @@ def _validate_needs(
                 f"correctly specified."
             )
 
-        parent = graph.nodes[parent_name]
-
         required_type = available_args.get(param_name)
         needs_passed_to_kwargs = has_kwargs and required_type is None
 
         if not needs_passed_to_kwargs:
+            if _is_placeholder_input(parent_name):
+                parent_return_type = RESERVED_PLACEHOLDERS[parent_name]
+            else:
+                parent = graph.nodes[parent_name]
+                _, parent_return_type = _get_parameter_information(
+                    parent_name, parent.uses, parent.fn
+                )
+
             _validate_parent_return_type(
-                node_name, node, parent_name, parent, required_type.type_annotation
+                node_name, node, parent_return_type, required_type.type_annotation
             )
 
 
@@ -392,13 +403,10 @@ def _get_available_args(
 def _validate_parent_return_type(
     node_name: Text,
     node: SchemaNode,
-    parent_name: Text,
-    parent: SchemaNode,
+    parent_return_type: TypeAnnotation,
     required_type: TypeAnnotation,
 ) -> None:
-    _, parent_return_type = _get_parameter_information(
-        parent_name, parent.uses, parent.fn
-    )
+
     if not typing_utils.issubtype(parent_return_type, required_type):
         raise GraphSchemaValidationException(
             f"Parent of node '{node_name}' returns type "
diff --git a/rasa/graph_components/adders/nlu_prediction_to_history_adder.py b/rasa/graph_components/adders/nlu_prediction_to_history_adder.py
index 60984d072603..8b96619cc9c6 100644
--- a/rasa/graph_components/adders/nlu_prediction_to_history_adder.py
+++ b/rasa/graph_components/adders/nlu_prediction_to_history_adder.py
@@ -35,7 +35,7 @@ def add(
         predictions: List[Message],
         tracker: DialogueStateTracker,
         domain: Domain,
-        original_message: UserMessage,
+        original_messages: List[UserMessage],
     ) -> DialogueStateTracker:
         """Adds NLU predictions to the tracker.
 
@@ -43,13 +43,13 @@ def add(
             predictions: A list of NLU predictions wrapped as Messages
             tracker: The tracker the predictions should be attached to
             domain: The domain of the model.
-            original_message: An original message from the user with
+            original_messages: The original messages from the user with
                 extra metadata to annotate the predictions (e.g. channel)
 
         Returns:
             The original tracker updated with events created from the predictions
         """
-        for message in predictions:
+        for message, original_message in zip(predictions, original_messages):
             user_event = UserUttered(
                 message.data.get(TEXT),
                 message.data.get(INTENT),
diff --git a/rasa/graph_components/converters/nlu_message_converter.py b/rasa/graph_components/converters/nlu_message_converter.py
index 067bff864def..a53cf00c19d3 100644
--- a/rasa/graph_components/converters/nlu_message_converter.py
+++ b/rasa/graph_components/converters/nlu_message_converter.py
@@ -1,5 +1,5 @@
 from __future__ import annotations
-from typing import Dict, Text, Any, Optional, List
+from typing import Dict, Text, Any, List
 
 from rasa.core.channels.channel import UserMessage
 
@@ -24,21 +24,25 @@ def create(
         """Creates component (see parent class for full docstring)."""
         return cls()
 
-    @staticmethod
-    def convert_user_message(message: Optional[UserMessage]) -> List[Message]:
+    @classmethod
+    def convert_user_message(cls, messages: List[UserMessage]) -> List[Message]:
         """Converts user message into Message object.
 
+        Args:
+            messages: The user messages which should be converted to be processed by
+                the NLU components.
+
         Returns:
             List containing only one instance of Message.
             Else empty list if user message is None.
         """
-        if message:
-            data = {
-                TEXT: message.text,
-                "message_id": message.message_id,
-                "metadata": message.metadata,
-            }
-
-            return [Message(data=data)]
-
-        return []
+        return [
+            Message(
+                data={
+                    TEXT: message.text,
+                    "message_id": message.message_id,
+                    "metadata": message.metadata,
+                }
+            )
+            for message in messages
+        ]
diff --git a/rasa/graph_components/providers/domain_without_response_provider.py b/rasa/graph_components/providers/domain_without_response_provider.py
index 96da3c950bb9..75a49ff513ee 100644
--- a/rasa/graph_components/providers/domain_without_response_provider.py
+++ b/rasa/graph_components/providers/domain_without_response_provider.py
@@ -1,4 +1,6 @@
 from __future__ import annotations
+
+import copy
 from typing import Dict, Text, Any
 
 from rasa.engine.graph import GraphComponent, ExecutionContext
@@ -31,6 +33,9 @@ def provide(self, domain: Domain) -> Domain:
             Domain that has been created from the same parameters as the given domain
             but with an empty set of responses.
         """
-        serialized_domain = domain.as_dict()
-        del serialized_domain[KEY_RESPONSES]
+        serialized_domain = copy.deepcopy(domain.as_dict())
+
+        for response_name in serialized_domain[KEY_RESPONSES]:
+            serialized_domain[KEY_RESPONSES][response_name] = []
+
         return Domain.from_dict(serialized_domain)
diff --git a/rasa/graph_components/providers/project_provider.py b/rasa/graph_components/providers/project_provider.py
deleted file mode 100644
index 109c326c2e26..000000000000
--- a/rasa/graph_components/providers/project_provider.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from __future__ import annotations
-from typing import Dict, Text, Any
-
-from rasa.engine.graph import GraphComponent, ExecutionContext
-from rasa.engine.storage.resource import Resource
-from rasa.engine.storage.storage import ModelStorage
-from rasa.shared.constants import (
-    DEFAULT_CONFIG_PATH,
-    DEFAULT_DOMAIN_PATH,
-    DEFAULT_DATA_PATH,
-)
-from rasa.shared.importers.importer import TrainingDataImporter
-
-
-class ProjectProvider(GraphComponent):
-    """Provides domain and training data during training and inference time."""
-
-    @staticmethod
-    def get_default_config() -> Dict[Text, Any]:
-        """Default config for ProjectProvider."""
-        return {
-            "config": {},
-            "config_path": DEFAULT_CONFIG_PATH,
-            "domain_path": DEFAULT_DOMAIN_PATH,
-            "training_data_paths": [DEFAULT_DATA_PATH],
-        }
-
-    def __init__(self, config: Dict[Text, Any]) -> None:
-        """Initializes the ProjectProvider."""
-        self._config = config
-
-    @classmethod
-    def create(
-        cls,
-        config: Dict[Text, Any],
-        model_storage: ModelStorage,
-        resource: Resource,
-        execution_context: ExecutionContext,
-    ) -> ProjectProvider:
-        """Creates component (see parent class for full docstring)."""
-        return cls(config)
-
-    def provide(self) -> TrainingDataImporter:
-        """Provides the TrainingDataImporter."""
-        return TrainingDataImporter.load_from_dict(**self._config)
diff --git a/rasa/graph_components/validators/finetuning_validator.py b/rasa/graph_components/validators/finetuning_validator.py
index 1fe82c6d1921..b170cd88990f 100644
--- a/rasa/graph_components/validators/finetuning_validator.py
+++ b/rasa/graph_components/validators/finetuning_validator.py
@@ -132,6 +132,7 @@ def _validate(self, importer: TrainingDataImporter) -> None:
                 )
         self._fingerprints[FINGERPRINT_VERSION] = rasa_version
 
+        # TODO: Go through config in graph schemas
         config = importer.get_config()
         self._compare_or_memorize(
             fingerprint_key=FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY,
diff --git a/rasa/model.py b/rasa/model.py
index d8a5d90b1fd2..9016a642f20f 100644
--- a/rasa/model.py
+++ b/rasa/model.py
@@ -1,36 +1,19 @@
-import copy
 import glob
 import hashlib
 import logging
 import os
 import shutil
-from subprocess import CalledProcessError, DEVNULL, check_output  # skipcq:BAN-B404
 import tempfile
-import typing
 from pathlib import Path
+from subprocess import check_output, CalledProcessError, DEVNULL
 from typing import (
-    Any,
     Text,
     Tuple,
     Union,
     Optional,
-    List,
-    Dict,
-    NamedTuple,
 )
 
-from packaging import version
-
-from rasa.constants import MINIMUM_COMPATIBLE_VERSION
-import rasa.shared.utils.io
-import rasa.utils.io
-from rasa.cli.utils import create_output_path
-from rasa.shared.utils.cli import print_success
 from rasa.shared.constants import (
-    CONFIG_KEYS_CORE,
-    CONFIG_KEYS_NLU,
-    CONFIG_KEYS,
-    DEFAULT_DOMAIN_PATH,
     DEFAULT_MODELS_PATH,
     DEFAULT_CORE_SUBDIRECTORY_NAME,
     DEFAULT_NLU_SUBDIRECTORY_NAME,
@@ -39,104 +22,10 @@
 from rasa.exceptions import ModelNotFound
 from rasa.utils.common import TempDirectoryPath
 
-if typing.TYPE_CHECKING:
-    from rasa.shared.importers.importer import TrainingDataImporter
 
 logger = logging.getLogger(__name__)
 
 
-# Type alias for the fingerprint
-Fingerprint = Dict[Text, Union[Optional[Text], List[Text], int, float]]
-
-FINGERPRINT_FILE_PATH = "fingerprint.json"
-
-FINGERPRINT_CONFIG_KEY = "config"
-FINGERPRINT_CONFIG_CORE_KEY = "core-config"
-FINGERPRINT_CONFIG_NLU_KEY = "nlu-config"
-FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY = "config-without-epochs"
-FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY = "domain"
-FINGERPRINT_NLG_KEY = "nlg"
-FINGERPRINT_RASA_VERSION_KEY = "version"
-FINGERPRINT_STORIES_KEY = "stories"
-FINGERPRINT_NLU_DATA_KEY = "messages"
-FINGERPRINT_NLU_LABELS_KEY = "nlu_labels"
-FINGERPRINT_PROJECT = "project"
-FINGERPRINT_TRAINED_AT_KEY = "trained_at"
-
-
-class Section(NamedTuple):
-    """Specifies which fingerprint keys decide whether this sub-model is retrained."""
-
-    name: Text
-    relevant_keys: List[Text]
-
-
-SECTION_CORE = Section(
-    name="Core model",
-    relevant_keys=[
-        FINGERPRINT_CONFIG_KEY,
-        FINGERPRINT_CONFIG_CORE_KEY,
-        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY,
-        FINGERPRINT_STORIES_KEY,
-        FINGERPRINT_RASA_VERSION_KEY,
-    ],
-)
-SECTION_NLU = Section(
-    name="NLU model",
-    relevant_keys=[
-        FINGERPRINT_CONFIG_KEY,
-        FINGERPRINT_CONFIG_NLU_KEY,
-        FINGERPRINT_NLU_DATA_KEY,
-        FINGERPRINT_RASA_VERSION_KEY,
-    ],
-)
-SECTION_NLG = Section(name="NLG responses", relevant_keys=[FINGERPRINT_NLG_KEY])
-
-
-class FingerprintComparisonResult:
-    """Container for the results of a fingerprint comparison."""
-
-    def __init__(
-        self,
-        nlu: bool = True,
-        core: bool = True,
-        nlg: bool = True,
-        force_training: bool = False,
-    ):
-        """Creates a `FingerprintComparisonResult` instance.
-
-        Args:
-            nlu: `True` if the NLU model should be retrained.
-            core: `True` if the Core model should be retrained.
-            nlg: `True` if the responses in the domain should be updated.
-            force_training: `True` if a training of all parts is forced.
-        """
-        self.nlu = nlu
-        self.core = core
-        self.nlg = nlg
-        self.force_training = force_training
-
-    def is_training_required(self) -> bool:
-        """Check if anything has to be retrained."""
-
-        return any([self.nlg, self.nlu, self.core, self.force_training])
-
-    def should_retrain_core(self) -> bool:
-        """Check if the Core model has to be updated."""
-
-        return self.force_training or self.core
-
-    def should_retrain_nlg(self) -> bool:
-        """Check if the responses have to be updated."""
-
-        return self.should_retrain_core() or self.nlg
-
-    def should_retrain_nlu(self) -> bool:
-        """Check if the NLU model has to be updated."""
-
-        return self.force_training or self.nlu
-
-
 def get_local_model(model_path: Text = DEFAULT_MODELS_PATH) -> Text:
     """Returns verified path to local model archive.
 
@@ -280,40 +169,6 @@ def get_model_subdirectories(
     return core_path, nlu_path
 
 
-def create_package_rasa(
-    training_directory: Text,
-    output_filename: Text,
-    fingerprint: Optional[Fingerprint] = None,
-) -> Text:
-    """Create a zipped Rasa model from trained model files.
-
-    Args:
-        training_directory: Path to the directory which contains the trained
-                            model files.
-        output_filename: Name of the zipped model file to be created.
-        fingerprint: A unique fingerprint to identify the model version.
-
-    Returns:
-        Path to zipped model.
-
-    """
-    import tarfile
-
-    if fingerprint:
-        persist_fingerprint(training_directory, fingerprint)
-
-    output_directory = os.path.dirname(output_filename)
-    if not os.path.exists(output_directory):
-        os.makedirs(output_directory)
-
-    with tarfile.open(output_filename, "w:gz") as tar:
-        for elem in os.scandir(training_directory):
-            tar.add(elem.path, arcname=elem.name)
-
-    shutil.rmtree(training_directory)
-    return output_filename
-
-
 def project_fingerprint() -> Optional[Text]:
     """Create a hash for the project in the current working directory.
 
@@ -329,132 +184,6 @@ def project_fingerprint() -> Optional[Text]:
         return None
 
 
-def model_fingerprint(file_importer: "TrainingDataImporter") -> Fingerprint:
-    """Create a model fingerprint from its used configuration and training data.
-
-    Args:
-        file_importer: File importer which provides the training data and model config.
-
-    Returns:
-        The fingerprint.
-
-    """
-    import time
-
-    config = file_importer.get_config()
-    domain = file_importer.get_domain()
-    stories = file_importer.get_stories()
-    nlu_data = file_importer.get_nlu_data()
-
-    responses = domain.responses
-
-    # Do a copy of the domain to not change the actual domain (shallow is enough)
-    domain = copy.copy(domain)
-    # don't include the response texts in the fingerprint.
-    # Their fingerprint is separate.
-    domain.responses = {}
-
-    return {
-        FINGERPRINT_CONFIG_KEY: _get_fingerprint_of_config(
-            config, exclude_keys=CONFIG_KEYS
-        ),
-        FINGERPRINT_CONFIG_CORE_KEY: _get_fingerprint_of_config(
-            config, include_keys=CONFIG_KEYS_CORE
-        ),
-        FINGERPRINT_CONFIG_NLU_KEY: _get_fingerprint_of_config(
-            config, include_keys=CONFIG_KEYS_NLU
-        ),
-        FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY: (
-            _get_fingerprint_of_config_without_epochs(config)
-        ),
-        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY: domain.fingerprint(),
-        FINGERPRINT_NLG_KEY: rasa.shared.utils.io.deep_container_fingerprint(responses),
-        FINGERPRINT_PROJECT: project_fingerprint(),
-        FINGERPRINT_NLU_DATA_KEY: nlu_data.fingerprint(),
-        FINGERPRINT_NLU_LABELS_KEY: nlu_data.label_fingerprint(),
-        FINGERPRINT_STORIES_KEY: stories.fingerprint(),
-        FINGERPRINT_TRAINED_AT_KEY: time.time(),
-        FINGERPRINT_RASA_VERSION_KEY: rasa.__version__,
-    }
-
-
-def _get_fingerprint_of_config(
-    config: Optional[Dict[Text, Any]],
-    include_keys: Optional[List[Text]] = None,
-    exclude_keys: Optional[List[Text]] = None,
-) -> Text:
-    if not config:
-        return ""
-
-    exclude_keys = exclude_keys or []
-    keys = include_keys or [k for k in config.keys() if k not in exclude_keys]
-
-    sub_config = {k: config[k] for k in keys if k in config}
-
-    return rasa.shared.utils.io.deep_container_fingerprint(sub_config)
-
-
-def _get_fingerprint_of_config_without_epochs(
-    config: Optional[Dict[Text, Any]],
-) -> Text:
-    if not config:
-        return ""
-
-    copied_config = copy.deepcopy(config)
-
-    for key in ["pipeline", "policies"]:
-        if copied_config.get(key):
-            for p in copied_config[key]:
-                if "epochs" in p:
-                    del p["epochs"]
-
-    return rasa.shared.utils.io.deep_container_fingerprint(copied_config)
-
-
-def fingerprint_from_path(model_path: Text) -> Fingerprint:
-    """Load a persisted fingerprint.
-
-    Args:
-        model_path: Path to directory containing the fingerprint.
-
-    Returns:
-        The fingerprint or an empty dict if no fingerprint was found.
-    """
-    if not model_path or not os.path.exists(model_path):
-        return {}
-
-    fingerprint_path = os.path.join(model_path, FINGERPRINT_FILE_PATH)
-
-    if os.path.isfile(fingerprint_path):
-        return rasa.shared.utils.io.read_json_file(fingerprint_path)
-    else:
-        return {}
-
-
-def persist_fingerprint(output_path: Text, fingerprint: Fingerprint) -> None:
-    """Persist a model fingerprint.
-
-    Args:
-        output_path: Directory in which the fingerprint should be saved.
-        fingerprint: The fingerprint to be persisted.
-
-    """
-
-    path = os.path.join(output_path, FINGERPRINT_FILE_PATH)
-    rasa.shared.utils.io.dump_obj_as_json_to_file(path, fingerprint)
-
-
-def did_section_fingerprint_change(
-    fingerprint1: Fingerprint, fingerprint2: Fingerprint, section: Section
-) -> bool:
-    """Check whether the fingerprint of a section has changed."""
-    for k in section.relevant_keys:
-        if fingerprint1.get(k) != fingerprint2.get(k):
-            logger.info(f"Data ({k}) for {section.name} section changed.")
-            return True
-    return False
-
-
 def move_model(source: Text, target: Text) -> bool:
     """Move two model directories.
 
@@ -474,164 +203,7 @@ def move_model(source: Text, target: Text) -> bool:
         return False
 
 
-def should_retrain(
-    new_fingerprint: Fingerprint,
-    old_model: Optional[Text],
-    train_path: Text,
-    has_e2e_examples: bool = False,
-    force_training: bool = False,
-) -> FingerprintComparisonResult:
-    """Check which components of a model should be retrained.
-
-    Args:
-        new_fingerprint: The fingerprint of the new model to be trained.
-        old_model: Path to the old zipped model file.
-        train_path: Path to the directory in which the new model will be trained.
-        has_e2e_examples: Whether the new training data contains e2e examples.
-        force_training: Indicates if the model needs to be retrained even if the data
-            has not changed.
-
-    Returns:
-        A FingerprintComparisonResult object indicating whether Rasa Core and/or Rasa
-        NLU needs to be retrained or not.
-    """
-    fingerprint_comparison = FingerprintComparisonResult()
-
-    if old_model is None or not os.path.exists(old_model):
-        return fingerprint_comparison
-
-    try:
-        with unpack_model(old_model) as unpacked:
-            last_fingerprint = fingerprint_from_path(unpacked)
-            old_core, old_nlu = get_model_subdirectories(unpacked)
-
-            fingerprint_comparison = FingerprintComparisonResult(
-                core=did_section_fingerprint_change(
-                    last_fingerprint, new_fingerprint, SECTION_CORE
-                ),
-                nlu=did_section_fingerprint_change(
-                    last_fingerprint, new_fingerprint, SECTION_NLU
-                ),
-                nlg=did_section_fingerprint_change(
-                    last_fingerprint, new_fingerprint, SECTION_NLG
-                ),
-                force_training=force_training,
-            )
-
-            # We should retrain core if nlu data changes and there are e2e stories.
-            if has_e2e_examples and fingerprint_comparison.should_retrain_nlu():
-                fingerprint_comparison.core = True
-
-            core_merge_failed = False
-            if not fingerprint_comparison.should_retrain_core():
-                target_path = os.path.join(train_path, DEFAULT_CORE_SUBDIRECTORY_NAME)
-                core_merge_failed = not move_model(old_core, target_path)
-                fingerprint_comparison.core = core_merge_failed
-
-            if not fingerprint_comparison.should_retrain_nlg() and core_merge_failed:
-                # If moving the Core model failed, we should also retrain NLG
-                fingerprint_comparison.nlg = True
-
-            if not fingerprint_comparison.should_retrain_nlu():
-                target_path = os.path.join(train_path, "nlu")
-                fingerprint_comparison.nlu = not move_model(old_nlu, target_path)
-
-            return fingerprint_comparison
-    except Exception as e:
-        logger.error(
-            f"Failed to get the fingerprint. Error: {e}.\n"
-            f"Proceeding with running default retrain..."
-        )
-        return fingerprint_comparison
-
-
-def can_finetune(
-    last_fingerprint: Fingerprint,
-    new_fingerprint: Fingerprint,
-    core: bool = False,
-    nlu: bool = False,
-) -> bool:
-    """Checks if components of a model can be finetuned with incremental training.
-
-    Args:
-        last_fingerprint: The fingerprint of the old model to potentially be fine-tuned.
-        new_fingerprint: The fingerprint of the new model.
-        core: Check sections for finetuning a core model.
-        nlu: Check sections for finetuning an nlu model.
-
-    Returns:
-        `True` if the old model can be finetuned, `False` otherwise.
-    """
-    section_keys = [
-        FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY,
-    ]
-    if core:
-        section_keys.append(FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY)
-    if nlu:
-        section_keys.append(FINGERPRINT_NLU_LABELS_KEY)
-
-    fingerprint_changed = did_section_fingerprint_change(
-        last_fingerprint,
-        new_fingerprint,
-        Section(name="finetune", relevant_keys=section_keys),
-    )
-
-    old_model_above_min_version = version.parse(
-        last_fingerprint.get(FINGERPRINT_RASA_VERSION_KEY)
-    ) >= version.parse(MINIMUM_COMPATIBLE_VERSION)
-    return old_model_above_min_version and not fingerprint_changed
-
-
-def package_model(
-    fingerprint: Fingerprint,
-    output_directory: Text,
-    train_path: Text,
-    fixed_model_name: Optional[Text] = None,
-    model_prefix: Text = "",
-) -> Text:
-    """
-    Compress a trained model.
-
-    Args:
-        fingerprint: fingerprint of the model
-        output_directory: path to the directory in which the model should be stored
-        train_path: path to uncompressed model
-        fixed_model_name: name of the compressed model file
-        model_prefix: prefix of the compressed model file
-
-    Returns: path to 'tar.gz' model file
-    """
-    output_directory = create_output_path(
-        output_directory, prefix=model_prefix, fixed_name=fixed_model_name
-    )
-    create_package_rasa(train_path, output_directory, fingerprint)
-
-    print_success(
-        "Your Rasa model is trained and saved at '{}'.".format(
-            os.path.abspath(output_directory)
-        )
-    )
-
-    return output_directory
-
-
-def update_model_with_new_domain(
-    importer: "TrainingDataImporter", unpacked_model_path: Union[Path, Text]
-) -> None:
-    """Overwrites the domain of an unpacked model with a new domain.
-
-    Args:
-        importer: Importer which provides the new domain.
-        unpacked_model_path: Path to the unpacked model.
-    """
-    model_path = Path(unpacked_model_path) / DEFAULT_CORE_SUBDIRECTORY_NAME
-    domain = importer.get_domain()
-    domain.persist(model_path / DEFAULT_DOMAIN_PATH)
-
-
-def get_model_for_finetuning(
-    previous_model_file: Optional[Union[Path, Text]]
-) -> Optional[Union[Path, Text]]:
+def get_model_for_finetuning(previous_model_file: Union[Path, Text]) -> Optional[Path]:
     """Gets validated path for model to finetune.
 
     Args:
@@ -646,10 +218,10 @@ def get_model_for_finetuning(
             f"Trying to load latest model from '{previous_model_file}' for "
             f"finetuning."
         )
-        return get_latest_model(previous_model_file)
+        previous_model_file = get_latest_model(previous_model_file)
 
-    if Path(previous_model_file).is_file():
-        return previous_model_file
+    if previous_model_file and Path(previous_model_file).is_file():
+        return Path(previous_model_file)
 
     logger.debug(
         "No valid model for finetuning found as directory either "
diff --git a/rasa/model_training.py b/rasa/model_training.py
index bf8520f078b9..a4105f262718 100644
--- a/rasa/model_training.py
+++ b/rasa/model_training.py
@@ -1,45 +1,39 @@
-import os
 import tempfile
-from contextlib import ExitStack
+import time
+from pathlib import Path
 from typing import (
     Text,
     NamedTuple,
-    Tuple,
     Optional,
     List,
     Union,
     Dict,
+    Any,
 )
 
 import rasa.core.interpreter
-from rasa.shared.nlu.interpreter import NaturalLanguageInterpreter
+import rasa.engine.validation
+from rasa.engine.caching import LocalTrainingCache
+from rasa.engine.recipes.recipe import Recipe
+from rasa.engine.runner.dask import DaskGraphRunner
+from rasa.engine.storage.local_model_storage import LocalModelStorage
+from rasa.engine.storage.storage import ModelStorage
+from rasa.engine.training.components import FingerprintStatus
+from rasa.engine.training.graph_trainer import GraphTrainer
+from rasa.shared.importers.autoconfig import TrainingType
 from rasa.shared.importers.importer import TrainingDataImporter
-from rasa import model, telemetry
-from rasa.model import FingerprintComparisonResult
+from rasa import telemetry
 from rasa.shared.core.domain import Domain
 import rasa.shared.utils.common
-from rasa.nlu.model import Interpreter
 import rasa.utils.common
 import rasa.shared.utils.common
-from rasa.utils.common import TempDirectoryPath
-
-from rasa.shared.utils.cli import (
-    print_success,
-    print_warning,
-)
+import rasa.shared.utils.cli
 import rasa.shared.exceptions
 import rasa.shared.utils.io
-from rasa.shared.constants import (
-    DEFAULT_MODELS_PATH,
-    DEFAULT_CORE_SUBDIRECTORY_NAME,
-    DEFAULT_NLU_SUBDIRECTORY_NAME,
-)
-
-from rasa.core.agent import Agent
+import rasa.shared.constants
+import rasa.model
 
-CODE_CORE_NEEDS_TO_BE_RETRAINED = 0b0001
-CODE_NLU_NEEDS_TO_BE_RETRAINED = 0b0010
-CODE_NLG_NEEDS_TO_BE_RETRAINED = 0b0100
+CODE_NEEDS_TO_BE_RETRAINED = 0b0001
 CODE_FORCED_TRAINING = 0b1000
 
 
@@ -48,147 +42,68 @@ class TrainingResult(NamedTuple):
 
     model: Optional[Text] = None
     code: int = 0
+    dry_run_results: Optional[Dict[Text, Union[FingerprintStatus, Any]]] = None
 
 
-async def train_async(
-    domain: Union[Domain, Text],
-    config: Text,
-    training_files: Optional[Union[Text, List[Text]]],
-    output: Text = DEFAULT_MODELS_PATH,
-    dry_run: bool = False,
-    force_training: bool = False,
-    fixed_model_name: Optional[Text] = None,
-    persist_nlu_training_data: bool = False,
-    core_additional_arguments: Optional[Dict] = None,
-    nlu_additional_arguments: Optional[Dict] = None,
-    model_to_finetune: Optional[Text] = None,
-    finetuning_epoch_fraction: float = 1.0,
+def _dry_run_result(
+    fingerprint_results: Dict[Text, Union[FingerprintStatus, Any]],
+    force_full_training: bool,
 ) -> TrainingResult:
-    """Trains a Rasa model (Core and NLU).
+    """Returns a dry run result.
 
     Args:
-        domain: Path to the domain file.
-        config: Path to the config for Core and NLU.
-        training_files: Paths to the training data for Core and NLU.
-        output_path: Output path.
-        dry_run: If `True` then no training will be done, and the information about
-            whether the training needs to be done will be printed.
-        force_training: If `True` retrain model even if data has not changed.
-        fixed_model_name: Name of model to be stored.
-        persist_nlu_training_data: `True` if the NLU training data should be persisted
-            with the model.
-        core_additional_arguments: Additional training parameters for core training.
-        nlu_additional_arguments: Additional training parameters forwarded to training
-            method of each NLU component.
-        model_to_finetune: Optional path to a model which should be finetuned or
-            a directory in case the latest trained model should be used.
-        finetuning_epoch_fraction: The fraction currently specified training epochs
-            in the model configuration which should be used for finetuning.
+        fingerprint_results: A result of fingerprint run..
+        force_full_training: Whether the user used the `--force` flag to enforce a
+            full retraining of the model.
 
     Returns:
-        An instance of `TrainingResult`.
+        Result containing the return code and the fingerprint results.
     """
-    file_importer = TrainingDataImporter.load_from_config(
-        config, domain, training_files
-    )
-    with TempDirectoryPath(tempfile.mkdtemp()) as train_path:
-        domain = file_importer.get_domain()
-
-        if domain.is_empty():
-            nlu_model = await handle_domain_if_not_exists(
-                file_importer, output, fixed_model_name
-            )
-            return TrainingResult(model=nlu_model)
-
-        return await _train_async_internal(
-            file_importer,
-            train_path,
-            output,
-            dry_run,
-            force_training,
-            fixed_model_name,
-            persist_nlu_training_data,
-            core_additional_arguments=core_additional_arguments,
-            nlu_additional_arguments=nlu_additional_arguments,
-            model_to_finetune=model_to_finetune,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
+    if force_full_training:
+        rasa.shared.utils.cli.print_warning("The training was forced.")
+        return TrainingResult(
+            code=CODE_FORCED_TRAINING, dry_run_results=fingerprint_results
         )
 
-
-async def handle_domain_if_not_exists(
-    file_importer: TrainingDataImporter,
-    output_path: Text,
-    fixed_model_name: Optional[Text],
-) -> Text:
-    """Trains only the nlu model and prints a warning about missing domain."""
-    nlu_model_only = await _train_nlu_with_validated_data(
-        file_importer, output=output_path, fixed_model_name=fixed_model_name
+    training_required = any(
+        isinstance(result, FingerprintStatus) and not result.is_hit
+        for result in fingerprint_results.values()
     )
-    rasa.shared.utils.cli.print_warning(
-        "Core training was skipped because no valid domain file was found. "
-        "Only an NLU-model was created. Please specify a valid domain using "
-        "the '--domain' argument or check if the provided domain file exists."
-    )
-    return nlu_model_only
-
-
-def dry_run_result(
-    fingerprint_comparison: FingerprintComparisonResult,
-) -> Tuple[int, List[Text]]:
-    """Returns a dry run result.
 
-    Args:
-        fingerprint_comparison: A result of fingerprint comparison operation.
-
-    Returns:
-        A tuple where the first element is the result code and the second
-        is the list of human-readable texts that need to be printed to the end user.
-    """
-    code = 0
-    texts = []
-
-    if fingerprint_comparison.force_training:
-        code = CODE_FORCED_TRAINING
-        texts.append("The training was forced.")
-        return code, texts
-
-    if fingerprint_comparison.core:
-        code += CODE_CORE_NEEDS_TO_BE_RETRAINED
-        texts.append("Core model should be retrained.")
-
-    if fingerprint_comparison.nlu:
-        code += CODE_NLU_NEEDS_TO_BE_RETRAINED
-        texts.append("NLU model should be retrained.")
-
-    if fingerprint_comparison.nlg:
-        code += CODE_NLG_NEEDS_TO_BE_RETRAINED
-        texts.append("Responses in the domain should be updated.")
-
-    if code == 0:
-        texts.append("No training required.")
+    if training_required:
+        rasa.shared.utils.cli.print_warning("The model needs to be retrained.")
+        return TrainingResult(
+            code=CODE_NEEDS_TO_BE_RETRAINED, dry_run_results=fingerprint_results
+        )
 
-    return code, texts
+    rasa.shared.utils.cli.print_success(
+        "No training of components required "
+        "(the responses might still need updating!)."
+    )
+    return TrainingResult(dry_run_results=fingerprint_results)
 
 
-async def _train_async_internal(
-    file_importer: TrainingDataImporter,
-    train_path: Text,
-    output_path: Text,
-    dry_run: bool,
-    force_training: bool,
-    fixed_model_name: Optional[Text],
-    persist_nlu_training_data: bool,
+def train(
+    domain: Text,
+    config: Text,
+    training_files: Optional[Union[Text, List[Text]]],
+    output: Text = rasa.shared.constants.DEFAULT_MODELS_PATH,
+    dry_run: bool = False,
+    force_training: bool = False,
+    fixed_model_name: Optional[Text] = None,
+    persist_nlu_training_data: bool = False,
     core_additional_arguments: Optional[Dict] = None,
     nlu_additional_arguments: Optional[Dict] = None,
     model_to_finetune: Optional[Text] = None,
     finetuning_epoch_fraction: float = 1.0,
 ) -> TrainingResult:
-    """Trains a Rasa model (Core and NLU). Use only from `train_async`.
+    """Trains a Rasa model (Core and NLU).
 
     Args:
-        file_importer: `TrainingDataImporter` which supplies the training data.
-        train_path: Directory in which to train the model.
-        output_path: Output path.
+        domain: Path to the domain file.
+        config: Path to the config file.
+        training_files: List of paths to training data files.
+        output: Output directory for the trained model.
         dry_run: If `True` then no training will be done, and the information about
             whether the training needs to be done will be printed.
         force_training: If `True` retrain model even if data has not changed.
@@ -206,187 +121,161 @@ async def _train_async_internal(
     Returns:
         An instance of `TrainingResult`.
     """
+    file_importer = TrainingDataImporter.load_from_config(
+        config, domain, training_files
+    )
+
     stories = file_importer.get_stories()
     nlu_data = file_importer.get_nlu_data()
 
-    new_fingerprint = model.model_fingerprint(file_importer)
-    old_model = model.get_latest_model(output_path)
-
-    if dry_run:
-        fingerprint_comparison = model.should_retrain(
-            new_fingerprint, old_model, train_path, force_training=force_training
-        )
-        code, texts = dry_run_result(fingerprint_comparison)
-        for text in texts:
-            print_warning(text) if code > 0 else print_success(text)
-        return TrainingResult(code=code)
+    training_type = TrainingType.BOTH
 
     if nlu_data.has_e2e_examples():
         rasa.shared.utils.common.mark_as_experimental_feature("end-to-end training")
+        training_type = TrainingType.END_TO_END
 
     if stories.is_empty() and nlu_data.contains_no_pure_nlu_data():
         rasa.shared.utils.cli.print_error(
             "No training data given. Please provide stories and NLU data in "
             "order to train a Rasa model using the '--data' argument."
         )
-        return TrainingResult()
+        return TrainingResult(code=1)
 
-    if stories.is_empty():
+    domain = file_importer.get_domain()
+    if domain.is_empty():
         rasa.shared.utils.cli.print_warning(
-            "No stories present. Just a Rasa NLU model will be trained."
+            "Core training was skipped because no valid domain file was found. "
+            "Only an NLU-model was created. Please specify a valid domain using "
+            "the '--domain' argument or check if the provided domain file exists."
         )
-        trained_model = await _train_nlu_with_validated_data(
-            file_importer,
-            output=output_path,
-            fixed_model_name=fixed_model_name,
-            persist_nlu_training_data=persist_nlu_training_data,
-            additional_arguments=nlu_additional_arguments,
-            model_to_finetune=model_to_finetune,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
+        training_type = TrainingType.NLU
+
+    elif stories.is_empty():
+        rasa.shared.utils.cli.print_warning(
+            "No stories present. Just a Rasa NLU model will be trained."
         )
-        return TrainingResult(model=trained_model)
+        training_type = TrainingType.NLU
 
     # We will train nlu if there are any nlu example, including from e2e stories.
-    if nlu_data.contains_no_pure_nlu_data() and not nlu_data.has_e2e_examples():
+    elif nlu_data.contains_no_pure_nlu_data() and not nlu_data.has_e2e_examples():
         rasa.shared.utils.cli.print_warning(
             "No NLU data present. Just a Rasa Core model will be trained."
         )
-        trained_model = _train_core_with_validated_data(
+        training_type = TrainingType.CORE
+
+    with telemetry.track_model_training(
+        file_importer, model_type="rasa",
+    ):
+        return _train_graph(
             file_importer,
-            output=output_path,
+            training_type=training_type,
+            output_path=output,
             fixed_model_name=fixed_model_name,
-            additional_arguments=core_additional_arguments,
             model_to_finetune=model_to_finetune,
+            force_full_training=force_training,
+            persist_nlu_training_data=persist_nlu_training_data,
             finetuning_epoch_fraction=finetuning_epoch_fraction,
+            dry_run=dry_run,
+            **(core_additional_arguments or {}),
+            **(nlu_additional_arguments or {}),
         )
 
-        return TrainingResult(model=trained_model)
 
-    new_fingerprint = model.model_fingerprint(file_importer)
-    old_model = model.get_latest_model(output_path)
+def _train_graph(
+    file_importer: TrainingDataImporter,
+    training_type: TrainingType,
+    output_path: Text,
+    fixed_model_name: Text,
+    model_to_finetune: Optional[Text] = None,
+    force_full_training: bool = False,
+    dry_run: bool = False,
+    **kwargs: Any,
+) -> TrainingResult:
+    if model_to_finetune:
+        model_to_finetune = rasa.model.get_model_for_finetuning(model_to_finetune)
+        if not model_to_finetune:
+            rasa.shared.utils.cli.print_error_and_exit(
+                f"No model for finetuning found. Please make sure to either "
+                f"specify a path to a previous model or to have a finetunable "
+                f"model within the directory '{output_path}'."
+            )
 
-    if not force_training:
-        fingerprint_comparison = model.should_retrain(
-            new_fingerprint,
-            old_model,
-            train_path,
-            has_e2e_examples=nlu_data.has_e2e_examples(),
+        rasa.shared.utils.common.mark_as_experimental_feature(
+            "Incremental Training feature"
         )
-    else:
-        fingerprint_comparison = FingerprintComparisonResult(force_training=True)
 
-    if not old_model or fingerprint_comparison.is_training_required():
+    is_finetuning = model_to_finetune is not None
+
+    config = file_importer.get_config()
+    recipe = Recipe.recipe_for_name(config.get("recipe"))
+    train_schema, predict_schema, language = recipe.schemas_for_config(
+        config, kwargs, training_type=training_type, is_finetuning=is_finetuning
+    )
+
+    rasa.engine.validation.validate(train_schema, language, is_train_graph=True)
+    rasa.engine.validation.validate(predict_schema, language, is_train_graph=False)
+
+    with tempfile.TemporaryDirectory() as temp_model_dir:
+        model_storage = _create_model_storage(
+            is_finetuning, model_to_finetune, Path(temp_model_dir)
+        )
+        cache = LocalTrainingCache()
+        trainer = GraphTrainer(model_storage, cache, DaskGraphRunner)
+
+        if dry_run:
+            fingerprint_status = trainer.fingerprint(train_schema, file_importer)
+            return _dry_run_result(fingerprint_status, force_full_training)
+
+        model_name = _determine_model_name(fixed_model_name, training_type)
+        full_model_path = Path(output_path, model_name)
+
         with telemetry.track_model_training(
-            file_importer, model_type="rasa",
+            file_importer, model_type=training_type.model_type,
         ):
-            await _do_training(
+            trainer.train(
+                train_schema,
+                predict_schema,
                 file_importer,
-                output_path=output_path,
-                train_path=train_path,
-                fingerprint_comparison_result=fingerprint_comparison,
-                fixed_model_name=fixed_model_name,
-                persist_nlu_training_data=persist_nlu_training_data,
-                core_additional_arguments=core_additional_arguments,
-                nlu_additional_arguments=nlu_additional_arguments,
-                old_model_zip_path=old_model,
-                model_to_finetune=model_to_finetune,
-                finetuning_epoch_fraction=finetuning_epoch_fraction,
+                full_model_path,
+                force_retraining=force_full_training,
+                is_finetuning=is_finetuning,
+            )
+            rasa.shared.utils.cli.print_success(
+                f"Your Rasa model is trained and saved at '{full_model_path}'."
             )
-        trained_model = model.package_model(
-            fingerprint=new_fingerprint,
-            output_directory=output_path,
-            train_path=train_path,
-            fixed_model_name=fixed_model_name,
-        )
-        return TrainingResult(model=trained_model)
 
-    rasa.shared.utils.cli.print_success(
-        "Nothing changed. You can use the old model stored at '{}'."
-        "".format(os.path.abspath(old_model))
-    )
-    return TrainingResult(model=old_model)
+        return TrainingResult(str(full_model_path), 0)
 
 
-async def _do_training(
-    file_importer: TrainingDataImporter,
-    output_path: Text,
-    train_path: Text,
-    fingerprint_comparison_result: Optional[FingerprintComparisonResult] = None,
-    fixed_model_name: Optional[Text] = None,
-    persist_nlu_training_data: bool = False,
-    core_additional_arguments: Optional[Dict] = None,
-    nlu_additional_arguments: Optional[Dict] = None,
-    old_model_zip_path: Optional[Text] = None,
-    model_to_finetune: Optional["Text"] = None,
-    finetuning_epoch_fraction: float = 1.0,
-) -> None:
-    if not fingerprint_comparison_result:
-        fingerprint_comparison_result = FingerprintComparisonResult()
-
-    interpreter_path = None
-    if fingerprint_comparison_result.should_retrain_nlu():
-        model_path = await _train_nlu_with_validated_data(
-            file_importer,
-            output=output_path,
-            train_path=train_path,
-            fixed_model_name=fixed_model_name,
-            persist_nlu_training_data=persist_nlu_training_data,
-            additional_arguments=nlu_additional_arguments,
-            model_to_finetune=model_to_finetune,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
-        )
-        interpreter_path = os.path.join(model_path, DEFAULT_NLU_SUBDIRECTORY_NAME)
-    else:
-        rasa.shared.utils.cli.print_color(
-            "NLU data/configuration did not change. No need to retrain NLU model.",
-            color=rasa.shared.utils.io.bcolors.OKBLUE,
-        )
-
-    if fingerprint_comparison_result.should_retrain_core():
-        _train_core_with_validated_data(
-            file_importer,
-            output=output_path,
-            train_path=train_path,
-            fixed_model_name=fixed_model_name,
-            additional_arguments=core_additional_arguments,
-            interpreter=_load_interpreter(interpreter_path)
-            or _interpreter_from_previous_model(old_model_zip_path),
-            model_to_finetune=model_to_finetune,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
-        )
-    elif fingerprint_comparison_result.should_retrain_nlg():
-        rasa.shared.utils.cli.print_color(
-            "Core stories/configuration did not change. "
-            "Only the responses section has been changed. A new model with "
-            "the updated responses will be created.",
-            color=rasa.shared.utils.io.bcolors.OKBLUE,
+def _create_model_storage(
+    is_finetuning: bool, model_to_finetune: Optional[Path], temp_model_dir: Path
+) -> ModelStorage:
+    if is_finetuning:
+        model_storage, _ = LocalModelStorage.from_model_archive(
+            temp_model_dir, model_to_finetune
         )
-        model.update_model_with_new_domain(file_importer, train_path)
     else:
-        rasa.shared.utils.cli.print_color(
-            "Core stories/configuration did not change. No need to retrain Core model.",
-            color=rasa.shared.utils.io.bcolors.OKBLUE,
-        )
+        model_storage = LocalModelStorage(temp_model_dir)
 
+    return model_storage
 
-def _load_interpreter(
-    interpreter_path: Optional[Text],
-) -> Optional[NaturalLanguageInterpreter]:
-    if interpreter_path:
-        return rasa.core.interpreter.create_interpreter(interpreter_path)
 
-    return None
+def _determine_model_name(
+    fixed_model_name: Optional[Text], training_type: TrainingType
+) -> Text:
+    if fixed_model_name:
+        model_file = Path(fixed_model_name)
+        if not model_file.name.endswith(".tar.gz"):
+            return model_file.with_suffix(".tar.gz").name
 
+        return fixed_model_name
 
-def _interpreter_from_previous_model(
-    old_model_zip_path: Optional[Text],
-) -> Optional[NaturalLanguageInterpreter]:
-    if not old_model_zip_path:
-        return None
+    prefix = ""
+    if training_type in [TrainingType.CORE, TrainingType.NLU]:
+        prefix = f"{training_type.model_type}-"
 
-    with model.unpack_model(old_model_zip_path) as unpacked:
-        _, old_nlu = model.get_model_subdirectories(unpacked)
-        return rasa.core.interpreter.create_interpreter(old_nlu)
+    time_format = "%Y%m%d-%H%M%S"
+    return f"{prefix}{time.strftime(time_format)}.tar.gz"
 
 
 def train_core(
@@ -394,7 +283,6 @@ def train_core(
     config: Text,
     stories: Text,
     output: Text,
-    train_path: Optional[Text] = None,
     fixed_model_name: Optional[Text] = None,
     additional_arguments: Optional[Dict] = None,
     model_to_finetune: Optional[Text] = None,
@@ -407,8 +295,6 @@ def train_core(
         config: Path to the config file for Core.
         stories: Path to the Core training data.
         output: Output path.
-        train_path: If `None` the model will be trained in a temporary
-            directory, otherwise in the provided directory.
         fixed_model_name: Name of model to be stored.
         additional_arguments: Additional training parameters.
         model_to_finetune: Optional path to a model which should be finetuned or
@@ -417,8 +303,7 @@ def train_core(
             in the model configuration which should be used for finetuning.
 
     Returns:
-        If `train_path` is given it returns the path to the model archive,
-        otherwise the path to the directory with the trained model files.
+        Path to the model archive.
 
     """
     file_importer = TrainingDataImporter.load_core_importer_from_config(
@@ -450,133 +335,21 @@ def train_core(
         )
         return None
 
-    return _train_core_with_validated_data(
+    return _train_graph(
         file_importer,
-        output=output,
-        train_path=train_path,
-        fixed_model_name=fixed_model_name,
-        additional_arguments=additional_arguments,
+        training_type=TrainingType.CORE,
+        output_path=output,
         model_to_finetune=model_to_finetune,
+        fixed_model_name=fixed_model_name,
         finetuning_epoch_fraction=finetuning_epoch_fraction,
-    )
-
-
-def _train_core_with_validated_data(
-    file_importer: TrainingDataImporter,
-    output: Text,
-    train_path: Optional[Text] = None,
-    fixed_model_name: Optional[Text] = None,
-    additional_arguments: Optional[Dict] = None,
-    interpreter: Optional[Interpreter] = None,
-    model_to_finetune: Optional["Text"] = None,
-    finetuning_epoch_fraction: float = 1.0,
-) -> Optional[Text]:
-    """Train Core with validated training and config data."""
-    import rasa.core.train
-
-    with ExitStack() as stack:
-        if train_path:
-            # If the train path was provided, do nothing on exit.
-            _train_path = train_path
-        else:
-            # Otherwise, create a temp train path and clean it up on exit.
-            _train_path = stack.enter_context(TempDirectoryPath(tempfile.mkdtemp()))
-
-        # normal (not compare) training
-        rasa.shared.utils.cli.print_color(
-            "Training Core model...", color=rasa.shared.utils.io.bcolors.OKBLUE
-        )
-        domain = file_importer.get_domain()
-        config = file_importer.get_config()
-
-        if model_to_finetune:
-            rasa.shared.utils.common.mark_as_experimental_feature(
-                "Incremental Training feature"
-            )
-            model_to_finetune = _core_model_for_finetuning(
-                model_to_finetune,
-                file_importer=file_importer,
-                finetuning_epoch_fraction=finetuning_epoch_fraction,
-            )
-
-            if not model_to_finetune:
-                rasa.shared.utils.cli.print_error_and_exit(
-                    f"No Core model for finetuning found. Please make sure to either "
-                    f"specify a path to a previous model or to have a finetunable "
-                    f"model within the directory '{output}'."
-                )
-
-        with telemetry.track_model_training(
-            file_importer,
-            model_type="core",
-            is_finetuning=model_to_finetune is not None,
-        ):
-            rasa.core.train.train(
-                domain_file=domain,
-                training_resource=file_importer,
-                output_path=os.path.join(_train_path, DEFAULT_CORE_SUBDIRECTORY_NAME),
-                policy_config=config,
-                additional_arguments=additional_arguments,
-                interpreter=interpreter,
-                model_to_finetune=model_to_finetune,
-            )
-        rasa.shared.utils.cli.print_color(
-            "Core model training completed.", color=rasa.shared.utils.io.bcolors.OKBLUE
-        )
-
-        if train_path is None:
-            # Only Core was trained.
-            new_fingerprint = model.model_fingerprint(file_importer)
-            return model.package_model(
-                fingerprint=new_fingerprint,
-                output_directory=output,
-                train_path=_train_path,
-                fixed_model_name=fixed_model_name,
-                model_prefix="core-",
-            )
-
-    return _train_path
-
-
-def _core_model_for_finetuning(
-    model_to_finetune: Text,
-    file_importer: TrainingDataImporter,
-    finetuning_epoch_fraction: float = 1.0,
-) -> Optional[Agent]:
-    path_to_archive = model.get_model_for_finetuning(model_to_finetune)
-    if not path_to_archive:
-        return None
-
-    rasa.shared.utils.cli.print_info(
-        f"Loading Core model from {path_to_archive} for finetuning...",
-    )
-
-    with model.unpack_model(path_to_archive) as unpacked:
-        new_fingerprint = model.model_fingerprint(file_importer)
-        old_fingerprint = model.fingerprint_from_path(unpacked)
-        if not model.can_finetune(old_fingerprint, new_fingerprint, core=True):
-            rasa.shared.utils.cli.print_error_and_exit(
-                "Core model can not be finetuned."
-            )
-
-        config = file_importer.get_config()
-        agent = Agent.load(
-            unpacked,
-            new_config=config,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
-        )
-        # Agent might be empty if no underlying Core model was found.
-        if agent.domain is not None and agent.policy_ensemble is not None:
-            return agent
-
-        return None
+        **(additional_arguments or {}),
+    ).model
 
 
 def train_nlu(
     config: Text,
-    nlu_data: Text,
+    nlu_data: Optional[Text],
     output: Text,
-    train_path: Optional[Text] = None,
     fixed_model_name: Optional[Text] = None,
     persist_nlu_training_data: bool = False,
     additional_arguments: Optional[Dict] = None,
@@ -590,8 +363,6 @@ def train_nlu(
         config: Path to the config file for NLU.
         nlu_data: Path to the NLU training data.
         output: Output path.
-        train_path: If `None` the model will be trained in a temporary
-            directory, otherwise in the provided directory.
         fixed_model_name: Name of the model to be stored.
         persist_nlu_training_data: `True` if the NLU training data should be persisted
                                    with the model.
@@ -604,39 +375,8 @@ def train_nlu(
             in the model configuration which should be used for finetuning.
 
     Returns:
-        If `train_path` is given it returns the path to the model archive,
-        otherwise the path to the directory with the trained model files.
-
+        Path to the model archive.
     """
-    return rasa.utils.common.run_in_loop(
-        train_nlu_async(
-            config,
-            nlu_data,
-            output,
-            train_path,
-            fixed_model_name,
-            persist_nlu_training_data,
-            additional_arguments,
-            domain=domain,
-            model_to_finetune=model_to_finetune,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
-        )
-    )
-
-
-async def train_nlu_async(
-    config: Text,
-    nlu_data: Text,
-    output: Text,
-    train_path: Optional[Text] = None,
-    fixed_model_name: Optional[Text] = None,
-    persist_nlu_training_data: bool = False,
-    additional_arguments: Optional[Dict] = None,
-    domain: Optional[Union[Domain, Text]] = None,
-    model_to_finetune: Optional[Text] = None,
-    finetuning_epoch_fraction: float = 1.0,
-) -> Optional[Text]:
-    """Trains an NLU model asynchronously."""
     if not nlu_data:
         rasa.shared.utils.cli.print_error(
             "No NLU data given. Please provide NLU data in order to train "
@@ -658,130 +398,13 @@ async def train_nlu_async(
         )
         return None
 
-    return await _train_nlu_with_validated_data(
+    return _train_graph(
         file_importer,
-        output=output,
-        train_path=train_path,
-        fixed_model_name=fixed_model_name,
-        persist_nlu_training_data=persist_nlu_training_data,
-        additional_arguments=additional_arguments,
+        training_type=TrainingType.NLU,
+        output_path=output,
         model_to_finetune=model_to_finetune,
+        fixed_model_name=fixed_model_name,
         finetuning_epoch_fraction=finetuning_epoch_fraction,
-    )
-
-
-async def _train_nlu_with_validated_data(
-    file_importer: TrainingDataImporter,
-    output: Text,
-    train_path: Optional[Text] = None,
-    fixed_model_name: Optional[Text] = None,
-    persist_nlu_training_data: bool = False,
-    additional_arguments: Optional[Dict] = None,
-    model_to_finetune: Optional["Text"] = None,
-    finetuning_epoch_fraction: float = 1.0,
-) -> Text:
-    """Train NLU with validated training and config data."""
-    import rasa.nlu.train
-
-    if additional_arguments is None:
-        additional_arguments = {}
-
-    with ExitStack() as stack:
-        if train_path:
-            # If the train path was provided, do nothing on exit.
-            _train_path = train_path
-        else:
-            # Otherwise, create a temp train path and clean it up on exit.
-            _train_path = stack.enter_context(TempDirectoryPath(tempfile.mkdtemp()))
-        config = file_importer.get_config()
-        rasa.shared.utils.cli.print_color(
-            "Training NLU model...", color=rasa.shared.utils.io.bcolors.OKBLUE
-        )
-
-        if model_to_finetune:
-            rasa.shared.utils.common.mark_as_experimental_feature(
-                "Incremental Training feature"
-            )
-            model_to_finetune = _nlu_model_for_finetuning(
-                model_to_finetune,
-                file_importer,
-                finetuning_epoch_fraction,
-                called_from_combined_training=train_path is not None,
-            )
-            if not model_to_finetune:
-                rasa.shared.utils.cli.print_error_and_exit(
-                    f"No NLU model for finetuning found. Please make sure to either "
-                    f"specify a path to a previous model or to have a finetunable "
-                    f"model within the directory '{output}'."
-                )
-
-        with telemetry.track_model_training(
-            file_importer,
-            model_type="nlu",
-            is_finetuning=model_to_finetune is not None,
-        ):
-            await rasa.nlu.train.train(
-                config,
-                file_importer,
-                _train_path,
-                fixed_model_name="nlu",
-                persist_nlu_training_data=persist_nlu_training_data,
-                model_to_finetune=model_to_finetune,
-                **additional_arguments,
-            )
-        rasa.shared.utils.cli.print_color(
-            "NLU model training completed.", color=rasa.shared.utils.io.bcolors.OKBLUE
-        )
-
-        if train_path is None:
-            # Only NLU was trained
-            new_fingerprint = model.model_fingerprint(file_importer)
-
-            return model.package_model(
-                fingerprint=new_fingerprint,
-                output_directory=output,
-                train_path=_train_path,
-                fixed_model_name=fixed_model_name,
-                model_prefix="nlu-",
-            )
-
-    return _train_path
-
-
-def _nlu_model_for_finetuning(
-    model_to_finetune: Text,
-    file_importer: TrainingDataImporter,
-    finetuning_epoch_fraction: float = 1.0,
-    called_from_combined_training: bool = False,
-) -> Optional[Interpreter]:
-
-    path_to_archive = model.get_model_for_finetuning(model_to_finetune)
-    if not path_to_archive:
-        return None
-
-    rasa.shared.utils.cli.print_info(
-        f"Loading NLU model from {path_to_archive} for finetuning...",
-    )
-    with model.unpack_model(path_to_archive) as unpacked:
-        _, old_nlu = model.get_model_subdirectories(unpacked)
-        new_fingerprint = model.model_fingerprint(file_importer)
-        old_fingerprint = model.fingerprint_from_path(unpacked)
-        if not model.can_finetune(
-            old_fingerprint,
-            new_fingerprint,
-            nlu=True,
-            core=called_from_combined_training,
-        ):
-            rasa.shared.utils.cli.print_error_and_exit(
-                "NLU model can not be finetuned."
-            )
-
-        config = file_importer.get_config()
-        loaded_model_to_finetune = Interpreter.load(
-            old_nlu,
-            new_config=config,
-            finetuning_epoch_fraction=finetuning_epoch_fraction,
-        )
-        if not loaded_model_to_finetune:
-            return None
-    return loaded_model_to_finetune
+        persist_nlu_training_data=persist_nlu_training_data,
+        **(additional_arguments or {}),
+    ).model
diff --git a/rasa/nlu/classifiers/diet_classifier.py b/rasa/nlu/classifiers/diet_classifier.py
index 58550562aba0..96d9f71acbdc 100644
--- a/rasa/nlu/classifiers/diet_classifier.py
+++ b/rasa/nlu/classifiers/diet_classifier.py
@@ -314,15 +314,6 @@ def __init__(
         self.finetune_mode = self._execution_context.is_finetuning
         self._sparse_feature_sizes = sparse_feature_sizes
 
-        if not self.model and self.finetune_mode:
-            raise rasa.shared.exceptions.InvalidParameterException(
-                f"{self.__class__.__name__} was instantiated "
-                f"with `model=None` and `finetune_mode=True`. "
-                f"This is not a valid combination as the component "
-                f"needs an already instantiated and trained model "
-                f"to continue training in finetune mode."
-            )
-
     # init helpers
     def _check_masked_lm(self) -> None:
         if (
@@ -845,6 +836,16 @@ def train(self, training_data: TrainingData) -> Resource:
                 f"Skipping training of the classifier."
             )
             return self._resource
+
+        if not self.model and self.finetune_mode:
+            raise rasa.shared.exceptions.InvalidParameterException(
+                f"{self.__class__.__name__} was instantiated "
+                f"with `model=None` and `finetune_mode=True`. "
+                f"This is not a valid combination as the component "
+                f"needs an already instantiated and trained model "
+                f"to continue training in finetune mode."
+            )
+
         if self.component_config.get(INTENT_CLASSIFICATION):
             if not self._check_enough_labels(model_data):
                 logger.error(
diff --git a/rasa/nlu/extractors/regex_entity_extractor.py b/rasa/nlu/extractors/regex_entity_extractor.py
index 841ee5d5694a..a6da3b76f807 100644
--- a/rasa/nlu/extractors/regex_entity_extractor.py
+++ b/rasa/nlu/extractors/regex_entity_extractor.py
@@ -57,7 +57,7 @@ def create(
         model_storage: ModelStorage,
         resource: Resource,
         execution_context: ExecutionContext,
-    ) -> GraphComponent:
+    ) -> RegexEntityExtractorGraphComponent:
         """Creates a new `GraphComponent`.
 
         Args:
@@ -201,22 +201,20 @@ def load(
             with model_storage.read_from(resource) as model_path:
                 regex_file = model_path / cls.REGEX_FILE_NAME
                 patterns = rasa.shared.utils.io.read_json_file(regex_file)
-                return RegexEntityExtractorGraphComponent(
+                return cls(
                     config,
                     model_storage=model_storage,
                     resource=resource,
                     patterns=patterns,
                 )
-        except ValueError:
+        except (ValueError, FileNotFoundError):
             rasa.shared.utils.io.raise_warning(
                 f"Failed to load {cls.__name__} from model storage. "
                 f"This can happen if the model could not be trained because regexes "
                 f"could not be extracted from the given training data - and hence "
                 f"could not be persisted."
             )
-            return RegexEntityExtractorGraphComponent(
-                config, model_storage=model_storage, resource=resource,
-            )
+            return cls(config, model_storage=model_storage, resource=resource,)
 
     def persist(self) -> None:
         """Persist this model."""
diff --git a/rasa/nlu/test.py b/rasa/nlu/test.py
index fdd780bf09e6..2e463b19b0e3 100644
--- a/rasa/nlu/test.py
+++ b/rasa/nlu/test.py
@@ -1,6 +1,9 @@
 import itertools
 import os
 import logging
+import tempfile
+from pathlib import Path
+
 import numpy as np
 from collections import defaultdict, namedtuple
 from tqdm import tqdm
@@ -51,13 +54,13 @@
     INTENT_NAME_KEY,
     PREDICTED_CONFIDENCE_KEY,
 )
-from rasa.model import get_model
 from rasa.nlu.components import ComponentBuilder
 from rasa.nlu.config import RasaNLUModelConfig
-from rasa.nlu.model import Interpreter, Trainer, TrainingData
+from rasa.nlu.model import Interpreter, TrainingData
 from rasa.nlu.classifiers import fallback_classifier
 from rasa.nlu.tokenizers.tokenizer import Token
 from rasa.shared.importers.importer import TrainingDataImporter
+from rasa.shared.nlu.training_data.formats.rasa_yaml import RasaYAMLWriter
 
 if TYPE_CHECKING:
     from typing_extensions import TypedDict
@@ -1561,100 +1564,111 @@ def cross_validate(
         dictionary with key, list structure, where each entry in list
               corresponds to the relevant result for one fold
     """
-    import rasa.nlu.config
+    import rasa.model_training
 
-    if isinstance(nlu_config, (str, Dict)):
-        nlu_config = rasa.nlu.config.load(nlu_config)
+    with tempfile.TemporaryDirectory() as temp_dir:
+        tmp_path = Path(temp_dir)
 
-    if output:
-        rasa.shared.utils.io.create_directory(output)
+        if isinstance(nlu_config, Dict):
+            config_path = tmp_path / "config.yml"
+            rasa.shared.utils.io.write_yaml(nlu_config, config_path)
+            nlu_config = str(config_path)
 
-    trainer = Trainer(nlu_config)
+        if output:
+            rasa.shared.utils.io.create_directory(output)
 
-    intent_train_metrics: IntentMetrics = defaultdict(list)
-    intent_test_metrics: IntentMetrics = defaultdict(list)
-    entity_train_metrics: EntityMetrics = defaultdict(lambda: defaultdict(list))
-    entity_test_metrics: EntityMetrics = defaultdict(lambda: defaultdict(list))
-    response_selection_train_metrics: ResponseSelectionMetrics = defaultdict(list)
-    response_selection_test_metrics: ResponseSelectionMetrics = defaultdict(list)
+        intent_train_metrics: IntentMetrics = defaultdict(list)
+        intent_test_metrics: IntentMetrics = defaultdict(list)
+        entity_train_metrics: EntityMetrics = defaultdict(lambda: defaultdict(list))
+        entity_test_metrics: EntityMetrics = defaultdict(lambda: defaultdict(list))
+        response_selection_train_metrics: ResponseSelectionMetrics = defaultdict(list)
+        response_selection_test_metrics: ResponseSelectionMetrics = defaultdict(list)
 
-    intent_test_results: List[IntentEvaluationResult] = []
-    entity_test_results: List[EntityEvaluationResult] = []
-    response_selection_test_results: List[ResponseSelectionEvaluationResult] = []
+        intent_test_results: List[IntentEvaluationResult] = []
+        entity_test_results: List[EntityEvaluationResult] = []
+        response_selection_test_results: List[ResponseSelectionEvaluationResult] = []
 
-    for train, test in generate_folds(n_folds, data):
-        interpreter = trainer.train(train)
+        for train, test in generate_folds(n_folds, data):
+            training_data_file = tmp_path / "training_data.yml"
+            RasaYAMLWriter().dump(training_data_file, train)
 
-        # calculate train accuracy
-        combine_result(
-            intent_train_metrics,
-            entity_train_metrics,
-            response_selection_train_metrics,
-            interpreter,
-            train,
-        )
-        # calculate test accuracy
-        combine_result(
-            intent_test_metrics,
-            entity_test_metrics,
-            response_selection_test_metrics,
-            interpreter,
-            test,
-            intent_test_results,
-            entity_test_results,
-            response_selection_test_results,
-        )
+            model_file = rasa.model_training.train_nlu(
+                nlu_config, str(training_data_file), str(tmp_path)
+            )
 
-    if intent_test_results:
-        logger.info("Accumulated test folds intent evaluation results:")
-        intent_evaluation = evaluate_intents(
-            intent_test_results,
-            output,
-            successes,
-            errors,
-            disable_plotting,
-            report_as_dict=report_as_dict,
-        )
+            # TODO: Load trained model
+            interpreter = None
 
-    entity_evaluation = {}
-    if entity_test_results:
-        logger.info("Accumulated test folds entity evaluation results:")
-        extractors = _get_active_entity_extractors(entity_test_results)
-        entity_evaluation = evaluate_entities(
-            entity_test_results,
-            extractors,
-            output,
-            successes,
-            errors,
-            disable_plotting,
-            report_as_dict=report_as_dict,
-        )
+            # calculate train accuracy
+            combine_result(
+                intent_train_metrics,
+                entity_train_metrics,
+                response_selection_train_metrics,
+                interpreter,
+                train,
+            )
+            # calculate test accuracy
+            combine_result(
+                intent_test_metrics,
+                entity_test_metrics,
+                response_selection_test_metrics,
+                interpreter,
+                test,
+                intent_test_results,
+                entity_test_results,
+                response_selection_test_results,
+            )
 
-    responses_evaluation = {}
-    if response_selection_test_results:
-        logger.info("Accumulated test folds response selection evaluation results:")
-        responses_evaluation = evaluate_response_selections(
-            response_selection_test_results,
-            output,
-            successes,
-            errors,
-            disable_plotting,
-            report_as_dict=report_as_dict,
-        )
+        if intent_test_results:
+            logger.info("Accumulated test folds intent evaluation results:")
+            intent_evaluation = evaluate_intents(
+                intent_test_results,
+                output,
+                successes,
+                errors,
+                disable_plotting,
+                report_as_dict=report_as_dict,
+            )
 
-    return (
-        CVEvaluationResult(
-            dict(intent_train_metrics), dict(intent_test_metrics), intent_evaluation
-        ),
-        CVEvaluationResult(
-            dict(entity_train_metrics), dict(entity_test_metrics), entity_evaluation
-        ),
-        CVEvaluationResult(
-            dict(response_selection_train_metrics),
-            dict(response_selection_test_metrics),
-            responses_evaluation,
-        ),
-    )
+        entity_evaluation = {}
+        if entity_test_results:
+            logger.info("Accumulated test folds entity evaluation results:")
+            extractors = _get_active_entity_extractors(entity_test_results)
+            entity_evaluation = evaluate_entities(
+                entity_test_results,
+                extractors,
+                output,
+                successes,
+                errors,
+                disable_plotting,
+                report_as_dict=report_as_dict,
+            )
+
+        responses_evaluation = {}
+        if response_selection_test_results:
+            logger.info("Accumulated test folds response selection evaluation results:")
+            responses_evaluation = evaluate_response_selections(
+                response_selection_test_results,
+                output,
+                successes,
+                errors,
+                disable_plotting,
+                report_as_dict=report_as_dict,
+            )
+
+        return (
+            CVEvaluationResult(
+                dict(intent_train_metrics), dict(intent_test_metrics), intent_evaluation
+            ),
+            CVEvaluationResult(
+                dict(entity_train_metrics), dict(entity_test_metrics), entity_evaluation
+            ),
+            CVEvaluationResult(
+                dict(response_selection_train_metrics),
+                dict(response_selection_test_metrics),
+                responses_evaluation,
+            ),
+        )
 
 
 def _targets_predictions_from(
@@ -1751,9 +1765,6 @@ async def compare_nlu(
 
     Returns: training examples per run
     """
-
-    from rasa.model_training import train_nlu_async
-
     training_examples_per_run = []
 
     for run in range(runs):
@@ -1797,7 +1808,7 @@ async def compare_nlu(
                 )
 
                 try:
-                    model_path = await train_nlu_async(
+                    model_path = rasa.model_training.train_nlu(
                         nlu_config,
                         train_split_path,
                         model_output_path,
@@ -1810,8 +1821,6 @@ async def compare_nlu(
                     f_score_results[model_name][run].append(0.0)
                     continue
 
-                model_path = os.path.join(get_model(model_path), "nlu")
-
                 output_path = os.path.join(model_output_path, f"{model_name}_report")
                 result = run_evaluation(
                     test_path, model_path, output_directory=output_path, errors=True
diff --git a/rasa/shared/importers/autoconfig.py b/rasa/shared/importers/autoconfig.py
index c0e49f609c9f..fe5cde648c39 100644
--- a/rasa/shared/importers/autoconfig.py
+++ b/rasa/shared/importers/autoconfig.py
@@ -32,6 +32,16 @@ class TrainingType(Enum):
     NLU = 1
     CORE = 2
     BOTH = 3
+    END_TO_END = 4
+
+    @property
+    def model_type(self) -> Text:
+        """Returns the type of model which this training yields."""
+        if self == TrainingType.NLU:
+            return "nlu"
+        if self == TrainingType.CORE:
+            return "core"
+        return "rasa"
 
 
 def get_configuration(
@@ -75,7 +85,7 @@ def _get_unspecified_autoconfigurable_keys(
     else:
         all_keys = rasa.shared.constants.CONFIG_AUTOCONFIGURABLE_KEYS
 
-    return {k for k in all_keys if not config.get(k)}
+    return {k for k in all_keys if config.get(k) is None}
 
 
 def _get_missing_config_keys(
diff --git a/rasa/shared/importers/default_config.yml b/rasa/shared/importers/default_config.yml
index ba16cd48e931..8ddd6c2824d4 100644
--- a/rasa/shared/importers/default_config.yml
+++ b/rasa/shared/importers/default_config.yml
@@ -1,3 +1,5 @@
+recipe: default.v1
+
 # Configuration for the Rasa NLU components.
 # https://rasa.com/docs/rasa/components
 language: en
diff --git a/rasa/shared/nlu/training_data/features.py b/rasa/shared/nlu/training_data/features.py
index 390aac97ad97..ef06525ed33e 100644
--- a/rasa/shared/nlu/training_data/features.py
+++ b/rasa/shared/nlu/training_data/features.py
@@ -289,11 +289,11 @@ def combine(
                 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}."
-                )
+            # 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.
diff --git a/rasa/utils/io.py b/rasa/utils/io.py
index 6198c5aa7d08..53e276850876 100644
--- a/rasa/utils/io.py
+++ b/rasa/utils/io.py
@@ -1,4 +1,5 @@
 import asyncio
+import filecmp
 import logging
 import os
 import pickle
@@ -229,3 +230,38 @@ def get_emoji_regex() -> Pattern:
         "]+",
         flags=re.UNICODE,
     )
+
+
+def are_directories_equal(dir1: Path, dir2: Path) -> bool:
+    """Compares two directories recursively.
+
+    Files in each directory are
+    assumed to be equal if their names and contents are equal.
+
+    Args:
+        dir1: The first directory.
+        dir2: The second directory.
+
+    Returns:
+        `True` if they are equal, `False` otherwise.
+    """
+    dirs_cmp = filecmp.dircmp(dir1, dir2)
+    if dirs_cmp.left_only or dirs_cmp.right_only:
+        return False
+
+    (_, mismatches, errors) = filecmp.cmpfiles(
+        dir1, dir2, dirs_cmp.common_files, shallow=False
+    )
+
+    if mismatches or errors:
+        return False
+
+    for common_dir in dirs_cmp.common_dirs:
+        new_dir1 = Path(dir1, common_dir)
+        new_dir2 = Path(dir2, common_dir)
+
+        is_equal = are_directories_equal(new_dir1, new_dir2)
+        if not is_equal:
+            return False
+
+    return True
diff --git a/tests/cli/test_rasa_test.py b/tests/cli/test_rasa_test.py
index e63f39bf66c3..9f5fab06e8f8 100644
--- a/tests/cli/test_rasa_test.py
+++ b/tests/cli/test_rasa_test.py
@@ -161,7 +161,7 @@ def test_test_nlu_cross_validation_with_autoconfig(
     nlu_path = str(testdir.tmpdir / "nlu.yml")
     shutil.copy(str(moodbot_nlu_data_path), nlu_path)
     write_yaml(
-        {"language": "en", "pipeline": [], "policies": [],}, config_path,
+        {"language": "en", "pipeline": None, "policies": None,}, config_path,
     )
     args = [
         shutil.which("rasa"),
@@ -188,6 +188,7 @@ def test_test_nlu_comparison(run_in_simple_project: Callable[..., RunResult]):
     write_yaml({"pipeline": "KeywordIntentClassifier"}, "config.yml")
     write_yaml({"pipeline": "KeywordIntentClassifier"}, "config2.yml")
 
+    # TODO: Loading still needs fixing
     run_in_simple_project(
         "test",
         "nlu",
diff --git a/tests/cli/test_rasa_train.py b/tests/cli/test_rasa_train.py
index 6bd729cb5c7c..bac0e9fc6a56 100644
--- a/tests/cli/test_rasa_train.py
+++ b/tests/cli/test_rasa_train.py
@@ -6,13 +6,17 @@
 import pytest
 from typing import Callable
 from _pytest.pytester import RunResult
+from _pytest.tmpdir import TempPathFactory
 
 import rasa.shared.utils.io
-from rasa import model
-from rasa.nlu.model import Metadata
-from rasa.shared.nlu.training_data import training_data
+from rasa.constants import NUMBER_OF_TRAINING_STORIES_FILE
+from rasa.core.policies import Policy
+from rasa.engine.storage.local_model_storage import LocalModelStorage
+from rasa.engine.storage.resource import Resource
+from rasa.nlu.components import Component
+from rasa.shared.core.domain import Domain
 from rasa.model_training import (
-    CODE_CORE_NEEDS_TO_BE_RETRAINED,
+    CODE_NEEDS_TO_BE_RETRAINED,
     CODE_FORCED_TRAINING,
 )
 
@@ -24,9 +28,13 @@
     CONFIG_MANDATORY_KEYS,
     LATEST_TRAINING_DATA_FORMAT_VERSION,
 )
+from rasa.shared.nlu.training_data.training_data import (
+    DEFAULT_TRAINING_DATA_OUTPUT_PATH,
+)
+import rasa.utils.io
 
 
-def test_train(run_in_simple_project: Callable[..., RunResult]):
+def test_train(run_in_simple_project: Callable[..., RunResult], tmp_path: Path):
     temp_dir = os.getcwd()
 
     run_in_simple_project(
@@ -43,16 +51,19 @@ def test_train(run_in_simple_project: Callable[..., RunResult]):
         "test-model",
     )
 
-    assert os.path.exists(os.path.join(temp_dir, "train_models"))
-    files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "train_models"))
-    assert len(files) == 1
-    assert os.path.basename(files[0]) == "test-model.tar.gz"
-    model_dir = model.get_model("train_models")
-    assert model_dir is not None
-    metadata = Metadata.load(os.path.join(model_dir, "nlu"))
-    assert metadata.get("training_data") is None
-    assert not os.path.exists(
-        os.path.join(model_dir, "nlu", training_data.DEFAULT_TRAINING_DATA_OUTPUT_PATH)
+    models_dir = Path(temp_dir, "train_models")
+    assert models_dir.is_dir()
+
+    models = list(models_dir.glob("*"))
+    assert len(models) == 1
+
+    model = models[0]
+    assert model.name == "test-model.tar.gz"
+
+    _, metadata = LocalModelStorage.from_model_archive(tmp_path, model)
+    assert metadata.model_id
+    assert (
+        metadata.domain.as_dict() == Domain.load(Path(temp_dir, "domain.yml")).as_dict()
     )
 
 
@@ -64,10 +75,12 @@ def test_train_finetune(
     )
 
     output = capsys.readouterr().out
-    assert "No NLU model for finetuning found" in output
+    assert "No model for finetuning found" in output
 
 
-def test_train_persist_nlu_data(run_in_simple_project: Callable[..., RunResult]):
+def test_train_persist_nlu_data(
+    run_in_simple_project: Callable[..., RunResult], tmp_path: Path
+):
     temp_dir = os.getcwd()
 
     run_in_simple_project(
@@ -85,21 +98,23 @@ def test_train_persist_nlu_data(run_in_simple_project: Callable[..., RunResult])
         "--persist-nlu-data",
     )
 
-    assert os.path.exists(os.path.join(temp_dir, "train_models"))
-    files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "train_models"))
-    assert len(files) == 1
-    assert os.path.basename(files[0]) == "test-model.tar.gz"
-    model_dir = model.get_model("train_models")
-    assert model_dir is not None
-    metadata = Metadata.load(os.path.join(model_dir, "nlu"))
-    assert metadata.get("training_data") is not None
-    assert os.path.exists(
-        os.path.join(model_dir, "nlu", training_data.DEFAULT_TRAINING_DATA_OUTPUT_PATH)
-    )
+    models_dir = Path(temp_dir, "train_models")
+    assert models_dir.is_dir()
+
+    models = list(models_dir.glob("*"))
+    assert len(models) == 1
+
+    model = models[0]
+    assert model.name == "test-model.tar.gz"
+
+    storage, _ = LocalModelStorage.from_model_archive(tmp_path, model)
+
+    with storage.read_from(Resource("nlu_training_data_provider")) as directory:
+        assert (directory / DEFAULT_TRAINING_DATA_OUTPUT_PATH).exists()
 
 
 def test_train_no_domain_exists(
-    run_in_simple_project: Callable[..., RunResult]
+    run_in_simple_project: Callable[..., RunResult], tmp_path: Path
 ) -> None:
 
     os.remove("domain.yml")
@@ -115,36 +130,57 @@ def test_train_no_domain_exists(
         "nlu-model-only",
     )
 
-    assert os.path.exists("train_models_no_domain")
-    files = rasa.shared.utils.io.list_files("train_models_no_domain")
-    assert len(files) == 1
+    model_file = Path("train_models_no_domain", "nlu-model-only.tar.gz")
+    assert model_file.is_file()
 
-    trained_model_path = "train_models_no_domain/nlu-model-only.tar.gz"
-    unpacked = model.unpack_model(trained_model_path)
+    _, metadata = LocalModelStorage.from_model_archive(tmp_path, model_file)
 
-    metadata_path = os.path.join(unpacked, "nlu", "metadata.json")
-    assert os.path.exists(metadata_path)
+    assert not any(
+        issubclass(component.uses, Policy)
+        for component in metadata.train_schema.nodes.values()
+    )
+    assert not any(
+        issubclass(component.uses, Policy)
+        for component in metadata.predict_schema.nodes.values()
+    )
 
 
 def test_train_skip_on_model_not_changed(
-    run_in_simple_project_with_model: Callable[..., RunResult]
+    run_in_simple_project_with_model: Callable[..., RunResult],
+    tmp_path_factory: TempPathFactory,
 ):
     temp_dir = os.getcwd()
 
-    assert os.path.exists(os.path.join(temp_dir, "models"))
-    files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models"))
-    assert len(files) == 1
+    models_dir = Path(temp_dir, "models")
+    model_files = list(models_dir.glob("*"))
+    assert len(model_files) == 1
+    old_model = model_files[0]
 
-    file_name = files[0]
     run_in_simple_project_with_model("train")
 
-    assert os.path.exists(os.path.join(temp_dir, "models"))
-    files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models"))
-    assert len(files) == 1
-    assert file_name == files[0]
+    model_files = list(sorted(models_dir.glob("*")))
+    assert len(model_files) == 2
+
+    new_model = model_files[1]
+    assert old_model != new_model
+
+    old_dir = tmp_path_factory.mktemp("old")
+    _, old_metadata = LocalModelStorage.from_model_archive(old_dir, old_model)
 
+    new_dir = tmp_path_factory.mktemp("new")
+    _, new_metadata = LocalModelStorage.from_model_archive(new_dir, new_model)
 
-def test_train_force(run_in_simple_project_with_model: Callable[..., RunResult]):
+    assert old_metadata.model_id != new_metadata.model_id
+    assert old_metadata.trained_at < new_metadata.trained_at
+    assert old_metadata.domain.as_dict() == new_metadata.domain.as_dict()
+
+    assert rasa.utils.io.are_directories_equal(old_dir, new_dir)
+
+
+def test_train_force(
+    run_in_simple_project_with_model: Callable[..., RunResult],
+    tmp_path_factory: TempPathFactory,
+):
     temp_dir = os.getcwd()
 
     assert os.path.exists(os.path.join(temp_dir, "models"))
@@ -157,6 +193,14 @@ def test_train_force(run_in_simple_project_with_model: Callable[..., RunResult])
     files = rasa.shared.utils.io.list_files(os.path.join(temp_dir, "models"))
     assert len(files) == 2
 
+    old_dir = tmp_path_factory.mktemp("old")
+    _ = LocalModelStorage.from_model_archive(old_dir, files[0])
+
+    new_dir = tmp_path_factory.mktemp("new")
+    _ = LocalModelStorage.from_model_archive(new_dir, files[1])
+
+    assert not rasa.utils.io.are_directories_equal(old_dir, new_dir)
+
 
 def test_train_dry_run(run_in_simple_project_with_model: Callable[..., RunResult]):
     temp_dir = os.getcwd()
@@ -167,7 +211,7 @@ def test_train_dry_run(run_in_simple_project_with_model: Callable[..., RunResult
 
     output = run_in_simple_project_with_model("train", "--dry-run")
 
-    assert [s for s in output.outlines if "No training required." in s]
+    assert [s for s in output.outlines if "No training of components required" in s]
     assert output.ret == 0
 
 
@@ -190,9 +234,9 @@ def test_train_dry_run_failure(run_in_simple_project: Callable[..., RunResult]):
     output = run_in_simple_project("train", "--dry-run")
 
     assert not any([s for s in output.outlines if "No training required." in s])
-    assert (
-        output.ret & CODE_CORE_NEEDS_TO_BE_RETRAINED == CODE_CORE_NEEDS_TO_BE_RETRAINED
-    ) and (output.ret & CODE_FORCED_TRAINING != CODE_FORCED_TRAINING)
+    assert (output.ret & CODE_NEEDS_TO_BE_RETRAINED == CODE_NEEDS_TO_BE_RETRAINED) and (
+        output.ret & CODE_FORCED_TRAINING != CODE_FORCED_TRAINING
+    )
 
 
 def test_train_dry_run_force(
@@ -260,7 +304,6 @@ def test_train_core(run_in_simple_project: Callable[..., RunResult]):
 
 
 def test_train_core_no_domain_exists(run_in_simple_project: Callable[..., RunResult]):
-
     os.remove("domain.yml")
     run_in_simple_project(
         "train",
@@ -277,11 +320,42 @@ def test_train_core_no_domain_exists(run_in_simple_project: Callable[..., RunRes
         "rasa-model",
     )
 
-    assert not os.path.exists("train_rasa_models_no_domain/rasa-model.tar.gz")
-    assert not os.path.isfile("train_rasa_models_no_domain/rasa-model.tar.gz")
+    assert not list(Path("train_rasa_models_no_domain").glob("*"))
+
+
+def test_train_core_compare(
+    run_in_simple_project: Callable[..., RunResult], tmp_path: Path
+):
+    run_in_simple_project(
+        "train",
+        "core",
+        "-c",
+        "config.yml",
+        "config.yml",
+        "-d",
+        "domain.yml",
+        "--stories",
+        "data",
+        "--out",
+        str(tmp_path),
+        "--runs",
+        "2",
+        "--percentages",
+        "50",
+        "100",
+    )
+
+    for run in range(1, 2):
+        assert (tmp_path / f"run_{run}" / "config__percentage__50.tar.gz").exists()
+        assert (tmp_path / f"run_{run}" / "config__percentage__100.tar.gz").exists()
+
+    num_stories = rasa.shared.utils.io.read_yaml_file(
+        tmp_path / NUMBER_OF_TRAINING_STORIES_FILE
+    )
+    assert num_stories == [3, 0]
 
 
-def test_train_nlu(run_in_simple_project: Callable[..., RunResult]):
+def test_train_nlu(run_in_simple_project: Callable[..., RunResult], tmp_path: Path):
     run_in_simple_project(
         "train",
         "nlu",
@@ -293,21 +367,29 @@ def test_train_nlu(run_in_simple_project: Callable[..., RunResult]):
         "train_models",
     )
 
-    assert os.path.exists("train_models")
-    files = rasa.shared.utils.io.list_files("train_models")
-    assert len(files) == 1
-    assert os.path.basename(files[0]).startswith("nlu-")
-    model_dir = model.get_model("train_models")
-    assert model_dir is not None
-    metadata = Metadata.load(os.path.join(model_dir, "nlu"))
-    assert metadata.get("training_data") is None
-    assert not os.path.exists(
-        os.path.join(model_dir, "nlu", training_data.DEFAULT_TRAINING_DATA_OUTPUT_PATH)
+    model_dir = Path("train_models")
+    assert model_dir.is_dir()
+
+    models = list(model_dir.glob("*.tar.gz"))
+    assert len(models) == 1
+
+    model_file = models[0]
+    assert model_file.name.startswith("nlu-")
+
+    _, metadata = LocalModelStorage.from_model_archive(tmp_path, model_file)
+
+    assert not any(
+        issubclass(component.uses, Component)
+        for component in metadata.train_schema.nodes.values()
+    )
+    assert not any(
+        issubclass(component.uses, Component)
+        for component in metadata.predict_schema.nodes.values()
     )
 
 
 def test_train_nlu_persist_nlu_data(
-    run_in_simple_project: Callable[..., RunResult]
+    run_in_simple_project: Callable[..., RunResult], tmp_path: Path
 ) -> None:
     run_in_simple_project(
         "train",
@@ -321,17 +403,19 @@ def test_train_nlu_persist_nlu_data(
         "--persist-nlu-data",
     )
 
-    assert os.path.exists("train_models")
-    files = rasa.shared.utils.io.list_files("train_models")
-    assert len(files) == 1
-    assert os.path.basename(files[0]).startswith("nlu-")
-    model_dir = model.get_model("train_models")
-    assert model_dir is not None
-    metadata = Metadata.load(os.path.join(model_dir, "nlu"))
-    assert metadata.get("training_data") is not None
-    assert os.path.exists(
-        os.path.join(model_dir, "nlu", training_data.DEFAULT_TRAINING_DATA_OUTPUT_PATH)
-    )
+    models_dir = Path("train_models")
+    assert models_dir.is_dir()
+
+    models = list(models_dir.glob("*"))
+    assert len(models) == 1
+
+    model = models[0]
+    assert model.name.startswith("nlu-")
+
+    storage, _ = LocalModelStorage.from_model_archive(tmp_path, model)
+
+    with storage.read_from(Resource("nlu_training_data_provider")) as directory:
+        assert (directory / DEFAULT_TRAINING_DATA_OUTPUT_PATH).exists()
 
 
 def test_train_help(run: Callable[..., RunResult]):
@@ -513,6 +597,7 @@ def test_train_nlu_finetune_with_model(
     assert len(files) == 1
 
     model_name = os.path.relpath(files[0])
-    output = run_in_simple_project_with_model("train", "nlu", "--finetune", model_name)
-
-    assert any(model_name in line for line in output.outlines)
+    output = run_in_simple_project_with_model("train", "--finetune", model_name)
+    assert any(
+        "Your Rasa model is trained and saved at" in line for line in output.outlines
+    )
diff --git a/tests/conftest.py b/tests/conftest.py
index bc91ba8e31b6..1506643e72ff 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -8,9 +8,11 @@
 import sys
 import uuid
 
+from _pytest.monkeypatch import MonkeyPatch
 from _pytest.python import Function
 from spacy import Language
 
+from rasa.engine.caching import LocalTrainingCache
 from rasa.engine.graph import ExecutionContext, GraphSchema
 from rasa.engine.storage.local_model_storage import LocalModelStorage
 from rasa.engine.storage.storage import ModelStorage
@@ -18,7 +20,7 @@
 
 from typing import Iterator, Callable, Generator
 
-from _pytest.tmpdir import TempPathFactory
+from _pytest.tmpdir import TempPathFactory, TempdirFactory
 from pathlib import Path
 from sanic import Sanic
 from typing import Text, List, Optional, Dict, Any
@@ -42,7 +44,7 @@
 import rasa.core.run
 from rasa.core.tracker_store import InMemoryTrackerStore, TrackerStore
 from rasa.model import get_model
-from rasa.model_training import train_async, train_nlu_async
+from rasa.model_training import train, train_nlu
 from rasa.utils.common import TempDirectoryPath
 from rasa.shared.exceptions import RasaException
 
@@ -172,7 +174,7 @@ def event_loop(request: Request) -> Iterator[asyncio.AbstractEventLoop]:
 
 
 @pytest.fixture(scope="session")
-async def _trained_default_agent(
+def _trained_default_agent(
     tmp_path_factory: TempPathFactory, stories_path: Text, trained_async: Callable
 ) -> Agent:
     project_path = tmp_path_factory.mktemp("project")
@@ -188,15 +190,15 @@ async def _trained_default_agent(
     )
     config_path = project_path / "config.yml"
     rasa.shared.utils.io.write_text_file(config, config_path)
-    model_path = await trained_async(
+    model_path = train(
         "data/test_domains/default_with_slots.yml", str(config_path), [stories_path]
-    )
+    ).model
 
     return Agent.load_local_model(model_path)
 
 
 @pytest.fixture()
-async def empty_agent() -> Agent:
+def empty_agent() -> Agent:
     agent = Agent("data/test_domains/default_with_slots.yml",)
     return agent
 
@@ -209,7 +211,7 @@ def reset_conversation_state(agent: Agent) -> Agent:
 
 
 @pytest.fixture
-async def default_agent(_trained_default_agent: Agent) -> Agent:
+def default_agent(_trained_default_agent: Agent) -> Agent:
     return reset_conversation_state(_trained_default_agent)
 
 
@@ -232,8 +234,8 @@ async def trained_unexpected_intent_policy_path(trained_async: Callable) -> Text
 
 
 @pytest.fixture(scope="session")
-async def trained_nlu_moodbot_path(trained_nlu_async: Callable) -> Text:
-    return await trained_nlu_async(
+def trained_nlu_moodbot_path(trained_nlu: Callable) -> Text:
+    return trained_nlu(
         domain="data/test_moodbot/domain.yml",
         config="data/test_moodbot/config.yml",
         nlu_data="data/test_moodbot/data/nlu.yml",
@@ -241,9 +243,7 @@ async def trained_nlu_moodbot_path(trained_nlu_async: Callable) -> Text:
 
 
 @pytest.fixture(scope="session")
-async def unpacked_trained_moodbot_path(
-    trained_moodbot_path: Text,
-) -> TempDirectoryPath:
+def unpacked_trained_moodbot_path(trained_moodbot_path: Text,) -> TempDirectoryPath:
     return get_model(trained_moodbot_path)
 
 
@@ -257,9 +257,7 @@ async def trained_spacybot_path(trained_async: Callable) -> Text:
 
 
 @pytest.fixture(scope="session")
-async def unpacked_trained_spacybot_path(
-    trained_spacybot_path: Text,
-) -> TempDirectoryPath:
+def unpacked_trained_spacybot_path(trained_spacybot_path: Text,) -> TempDirectoryPath:
     return get_model(trained_spacybot_path)
 
 
@@ -303,45 +301,59 @@ def domain(_domain: Domain) -> Domain:
 @pytest.fixture(scope="session")
 def trained_async(tmp_path_factory: TempPathFactory) -> Callable:
     async def _train(
-        *args: Any, output_path: Optional[Text] = None, **kwargs: Any
+        *args: Any,
+        output_path: Optional[Text] = None,
+        cache_dir: Optional[Path] = None,
+        **kwargs: Any,
     ) -> Optional[Text]:
+
+        old_get_cache_location = None
+        if cache_dir:
+            # This allows insert a certain cache directory for the training
+            old_get_cache_location = LocalTrainingCache._get_cache_location
+            LocalTrainingCache._get_cache_location = Mock(return_value=cache_dir)
+
         if output_path is None:
             output_path = str(tmp_path_factory.mktemp("models"))
 
-        result = await train_async(*args, output=output_path, **kwargs)
+        result = train(*args, output=output_path, **kwargs)
+
+        if cache_dir:
+            LocalTrainingCache._get_cache_location = old_get_cache_location
+
         return result.model
 
     return _train
 
 
 @pytest.fixture(scope="session")
-def trained_nlu_async(tmp_path_factory: TempPathFactory) -> Callable:
-    async def _train_nlu(
+def trained_nlu(tmp_path_factory: TempPathFactory) -> Callable:
+    def _train_nlu(
         *args: Any, output_path: Optional[Text] = None, **kwargs: Any
     ) -> Optional[Text]:
         if output_path is None:
             output_path = str(tmp_path_factory.mktemp("models"))
 
-        return await train_nlu_async(*args, output=output_path, **kwargs)
+        return train_nlu(*args, output=output_path, **kwargs)
 
     return _train_nlu
 
 
 @pytest.fixture(scope="session")
-async def trained_rasa_model(
+def trained_rasa_model(
     trained_async: Callable,
     domain_path: Text,
     nlu_data_path: Text,
     stories_path: Text,
     stack_config_path: Text,
 ) -> Text:
-    trained_stack_model_path = await trained_async(
+    trained_stack_model_path = rasa.api.train(
         domain=domain_path,
         config=stack_config_path,
         training_files=[nlu_data_path, stories_path],
     )
 
-    return trained_stack_model_path
+    return trained_stack_model_path.model
 
 
 @pytest.fixture(scope="session")
@@ -362,7 +374,7 @@ async def trained_simple_rasa_model(
 
 
 @pytest.fixture(scope="session")
-async def unpacked_trained_rasa_model(
+def unpacked_trained_rasa_model(
     trained_rasa_model: Text,
 ) -> Generator[Text, None, None]:
     with get_model(trained_rasa_model) as path:
@@ -397,18 +409,41 @@ async def trained_nlu_model(
     return trained_nlu_model_path
 
 
+@pytest.fixture(scope="session")
+def _trained_e2e_model_cache(tmp_path_factory: TempPathFactory) -> Path:
+    return tmp_path_factory.mktemp("cache")
+
+
+@pytest.fixture()
+def trained_e2e_model_cache(
+    _trained_e2e_model_cache: Path,
+    tmp_path_factory: TempPathFactory,
+    monkeypatch: MonkeyPatch,
+) -> Path:
+    copied_cache = tmp_path_factory.mktemp("copy")
+    rasa.utils.common.copy_directory(_trained_e2e_model_cache, copied_cache)
+
+    monkeypatch.setattr(
+        LocalTrainingCache, "_get_cache_location", Mock(return_value=copied_cache)
+    )
+
+    return copied_cache
+
+
 @pytest.fixture(scope="session")
 async def trained_e2e_model(
     trained_async: Callable,
-    domain_path: Text,
-    stack_config_path: Text,
+    moodbot_domain_path: Text,
+    e2e_bot_config_file: Path,
     nlu_data_path: Text,
     e2e_stories_path: Text,
+    _trained_e2e_model_cache: Path,
 ) -> Text:
     return await trained_async(
-        domain=domain_path,
-        config=stack_config_path,
+        domain=moodbot_domain_path,
+        config=str(e2e_bot_config_file),
         training_files=[nlu_data_path, e2e_stories_path],
+        cache_dir=_trained_e2e_model_cache,
     )
 
 
@@ -428,49 +463,49 @@ def moodbot_nlu_data_path() -> Path:
 
 
 @pytest.fixture
-async def rasa_server(stack_agent: Agent) -> Sanic:
+def rasa_server(stack_agent: Agent) -> Sanic:
     app = server.create_app(agent=stack_agent)
     channel.register([RestInput()], app, "/webhooks/")
     return app
 
 
 @pytest.fixture
-async def rasa_non_trained_server(empty_agent: Agent) -> Sanic:
+def rasa_non_trained_server(empty_agent: Agent) -> Sanic:
     app = server.create_app(agent=empty_agent)
     channel.register([RestInput()], app, "/webhooks/")
     return app
 
 
 @pytest.fixture
-async def rasa_core_server(core_agent: Agent) -> Sanic:
+def rasa_core_server(core_agent: Agent) -> Sanic:
     app = server.create_app(agent=core_agent)
     channel.register([RestInput()], app, "/webhooks/")
     return app
 
 
 @pytest.fixture
-async def rasa_nlu_server(nlu_agent: Agent) -> Sanic:
+def rasa_nlu_server(nlu_agent: Agent) -> Sanic:
     app = server.create_app(agent=nlu_agent)
     channel.register([RestInput()], app, "/webhooks/")
     return app
 
 
 @pytest.fixture
-async def rasa_server_secured(default_agent: Agent) -> Sanic:
+def rasa_server_secured(default_agent: Agent) -> Sanic:
     app = server.create_app(agent=default_agent, auth_token="rasa", jwt_secret="core")
     channel.register([RestInput()], app, "/webhooks/")
     return app
 
 
 @pytest.fixture
-async def rasa_non_trained_server_secured(empty_agent: Agent) -> Sanic:
+def rasa_non_trained_server_secured(empty_agent: Agent) -> Sanic:
     app = server.create_app(agent=empty_agent, auth_token="rasa", jwt_secret="core")
     channel.register([RestInput()], app, "/webhooks/")
     return app
 
 
 @pytest.fixture
-async def rasa_server_without_api() -> Sanic:
+def rasa_server_without_api() -> Sanic:
     app = rasa.core.run._create_app_without_api()
     channel.register([RestInput()], app, "/webhooks/")
     return app
@@ -525,17 +560,17 @@ async def trained_response_selector_bot(trained_async: Callable) -> Path:
 
 
 @pytest.fixture(scope="session")
-async def e2e_bot_domain_file() -> Path:
+def e2e_bot_domain_file() -> Path:
     return Path("data/test_e2ebot/domain.yml")
 
 
 @pytest.fixture(scope="session")
-async def e2e_bot_config_file() -> Path:
+def e2e_bot_config_file() -> Path:
     return Path("data/test_e2ebot/config.yml")
 
 
 @pytest.fixture(scope="session")
-async def e2e_bot_training_files() -> List[Path]:
+def e2e_bot_training_files() -> List[Path]:
     return [
         Path("data/test_e2ebot/data/rules.yml"),
         Path("data/test_e2ebot/data/stories.yml"),
@@ -544,7 +579,7 @@ async def e2e_bot_training_files() -> List[Path]:
 
 
 @pytest.fixture(scope="session")
-async def e2e_bot_test_stories_with_unknown_bot_utterances() -> Path:
+def e2e_bot_test_stories_with_unknown_bot_utterances() -> Path:
     return Path("data/test_e2ebot/tests/test_stories_with_unknown_bot_utterances.yml")
 
 
@@ -568,20 +603,18 @@ async def e2e_bot(
 
 
 @pytest.fixture(scope="module")
-async def response_selector_agent(
-    trained_response_selector_bot: Optional[Path],
-) -> Agent:
-    return Agent.load_local_model(trained_response_selector_bot)
+def response_selector_agent(trained_response_selector_bot: Path,) -> Agent:
+    return Agent.load_local_model(str(trained_response_selector_bot))
 
 
 @pytest.fixture(scope="module")
-async def response_selector_interpreter(response_selector_agent: Agent,) -> Interpreter:
+def response_selector_interpreter(response_selector_agent: Agent,) -> Interpreter:
     return response_selector_agent.interpreter.interpreter
 
 
 @pytest.fixture(scope="module")
-async def e2e_bot_agent(e2e_bot: Optional[Path],) -> Agent:
-    return Agent.load_local_model(e2e_bot)
+def e2e_bot_agent(e2e_bot: Path) -> Agent:
+    return Agent.load_local_model(str(e2e_bot))
 
 
 def write_endpoint_config_to_yaml(
@@ -686,3 +719,19 @@ def default_model_storage(tmp_path: Path) -> ModelStorage:
 @pytest.fixture()
 def default_execution_context() -> ExecutionContext:
     return ExecutionContext(GraphSchema({}), uuid.uuid4().hex)
+
+
+@pytest.fixture(autouse=True)
+def use_temp_dir_for_cache(
+    monkeypatch: MonkeyPatch, tmp_path_factory: TempdirFactory
+) -> Path:
+    # This avoids that the unit tests because they share a local cache.
+    # Instead we are creating a new cache for every test.
+    cache_dir = tmp_path_factory.mktemp(uuid.uuid4().hex)
+    monkeypatch.setattr(
+        LocalTrainingCache,
+        LocalTrainingCache._get_cache_location.__name__,
+        Mock(return_value=cache_dir),
+    )
+
+    return cache_dir
diff --git a/tests/core/conftest.py b/tests/core/conftest.py
index 59e875689f37..8f6d30005759 100644
--- a/tests/core/conftest.py
+++ b/tests/core/conftest.py
@@ -153,7 +153,7 @@ async def trained_formbot(trained_async: Callable) -> Text:
 async def form_bot_agent(trained_formbot: Text) -> Agent:
     endpoint = EndpointConfig("https://example.com/webhooks/actions")
 
-    return Agent.load_local_model(trained_formbot, action_endpoint=endpoint)
+    return Agent.load(trained_formbot, action_endpoint=endpoint)
 
 
 @pytest.fixture
diff --git a/tests/core/policies/test_ted_policy.py b/tests/core/policies/test_ted_policy.py
index 79f7a8f62e00..abb51d747275 100644
--- a/tests/core/policies/test_ted_policy.py
+++ b/tests/core/policies/test_ted_policy.py
@@ -4,6 +4,8 @@
 from unittest.mock import Mock
 import numpy as np
 import pytest
+from _pytest.tmpdir import TempPathFactory
+
 import tests.core.test_policies
 from _pytest.monkeypatch import MonkeyPatch
 from _pytest.logging import LogCaptureFixture
@@ -20,8 +22,9 @@
 )
 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.exceptions import GraphComponentException
 from rasa.engine.graph import ExecutionContext
+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.constants import ACTION_LISTEN_NAME, ACTION_UNLIKELY_INTENT_NAME
@@ -54,14 +57,13 @@
     MASK,
     SENTENCE,
     IDS,
-    EVAL_NUM_EPOCHS,
     EPOCHS,
     EPOCH_OVERRIDE,
 )
 from rasa.shared.nlu.constants import ACTION_NAME
 from rasa.utils.tensorflow import model_data_utils
 from tests.core.test_policies import PolicyTestCollection
-from rasa.shared.constants import DEFAULT_SENDER_ID, DEFAULT_CORE_SUBDIRECTORY_NAME
+from rasa.shared.constants import DEFAULT_SENDER_ID
 
 UTTER_GREET_ACTION = "utter_greet"
 GREET_INTENT_NAME = "greet"
@@ -83,8 +85,7 @@ def get_checkpoint_dir_path(train_path: Path, ted_pos: Optional[int] = 0) -> Pat
         train_path: the path passed to model_training.train_core for training output.
         ted_pos: the position of TED in the policies listed in the config.
     """
-    policy_dir_name = Path("policy_{}_{}".format(ted_pos, Rasa2TEDPolicy.__name__))
-    policy_path = train_path / DEFAULT_CORE_SUBDIRECTORY_NAME / policy_dir_name
+    policy_path = train_path / f"train_TEDPolicy{ted_pos}"
     return policy_path / "checkpoints"
 
 
@@ -124,33 +125,47 @@ class TestTEDPolicy(PolicyTestCollection):
     def _policy_class_to_test() -> Type[TEDPolicy]:
         return TEDPolicy
 
-    def test_train_model_checkpointing(self, tmp_path: Path):
-        checkpoint_dir = get_checkpoint_dir_path(tmp_path)
-        assert not checkpoint_dir.is_dir()
-
+    def test_train_model_checkpointing(
+        self, tmp_path: Path, tmp_path_factory: TempPathFactory
+    ):
         train_core(
             domain="data/test_domains/default.yml",
             stories="data/test_yaml_stories/stories_defaultdomain.yml",
-            train_path=str(tmp_path),
             output=str(tmp_path),
+            fixed_model_name="my_model.tar.gz",
             config="data/test_config/config_ted_policy_model_checkpointing.yml",
         )
-        assert checkpoint_dir.is_dir()
 
-    def test_doesnt_checkpoint_with_no_checkpointing(self, tmp_path: Path):
-        checkpoint_dir = get_checkpoint_dir_path(tmp_path)
-        assert not checkpoint_dir.is_dir()
+        storage_dir = tmp_path_factory.mktemp("storage dir")
+        storage, _ = LocalModelStorage.from_model_archive(
+            storage_dir, tmp_path / "my_model.tar.gz"
+        )
 
+        checkpoint_dir = get_checkpoint_dir_path(storage_dir)
+        assert checkpoint_dir.is_dir()
+
+    def test_doesnt_checkpoint_with_no_checkpointing(
+        self, tmp_path: Path, tmp_path_factory: TempPathFactory
+    ):
         train_core(
             domain="data/test_domains/default.yml",
             stories="data/test_yaml_stories/stories_defaultdomain.yml",
-            train_path=str(tmp_path),
             output=str(tmp_path),
+            fixed_model_name="my_model.tar.gz",
             config="data/test_config/config_ted_policy_no_model_checkpointing.yml",
         )
+
+        storage_dir = tmp_path_factory.mktemp("storage dir")
+        storage, _ = LocalModelStorage.from_model_archive(
+            storage_dir, tmp_path / "my_model.tar.gz"
+        )
+
+        checkpoint_dir = get_checkpoint_dir_path(storage_dir)
         assert not checkpoint_dir.is_dir()
 
-    def test_doesnt_checkpoint_with_zero_eval_num_examples(self, tmp_path: Path):
+    def test_doesnt_checkpoint_with_zero_eval_num_examples(
+        self, tmp_path: Path, tmp_path_factory: TempPathFactory
+    ):
         checkpoint_dir = get_checkpoint_dir_path(tmp_path)
         assert not checkpoint_dir.is_dir()
         config_file = "config_ted_policy_model_checkpointing_zero_eval_num_examples.yml"
@@ -158,8 +173,8 @@ def test_doesnt_checkpoint_with_zero_eval_num_examples(self, tmp_path: Path):
             train_core(
                 domain="data/test_domains/default.yml",
                 stories="data/test_yaml_stories/stories_defaultdomain.yml",
-                train_path=str(tmp_path),
                 output=str(tmp_path),
+                fixed_model_name="my_model.tar.gz",
                 config=f"data/test_config/{config_file}",
             )
         warn_text = (
@@ -167,9 +182,17 @@ def test_doesnt_checkpoint_with_zero_eval_num_examples(self, tmp_path: Path):
             f"'{EVAL_NUM_EXAMPLES}' is not greater than 0. No checkpoint model will be "
             f"saved."
         )
-        assert not checkpoint_dir.is_dir()
+
         assert len([w for w in warning if warn_text in str(w.message)]) == 1
 
+        storage_dir = tmp_path_factory.mktemp("storage dir")
+        storage, _ = LocalModelStorage.from_model_archive(
+            storage_dir, tmp_path / "my_model.tar.gz"
+        )
+
+        checkpoint_dir = get_checkpoint_dir_path(storage_dir)
+        assert not checkpoint_dir.is_dir()
+
     @pytest.mark.parametrize(
         "should_finetune, epoch_override, expected_epoch_value",
         [
@@ -206,24 +229,19 @@ def test_epoch_override_when_loaded(
         assert loaded_policy.config[EPOCHS] == expected_epoch_value
 
     def test_train_fails_with_checkpoint_zero_eval_num_epochs(self, tmp_path: Path):
-        checkpoint_dir = get_checkpoint_dir_path(tmp_path)
-        assert not checkpoint_dir.is_dir()
         config_file = "config_ted_policy_model_checkpointing_zero_every_num_epochs.yml"
-        with pytest.raises(InvalidConfigException):
-            with pytest.warns(UserWarning) as warning:
-                train_core(
-                    domain="data/test_domains/default.yml",
-                    stories="data/test_yaml_stories/stories_defaultdomain.yml",
-                    train_path=str(tmp_path),
-                    output=str(tmp_path),
-                    config=f"data/test_config/{config_file}",
-                )
-        warn_text = (
-            f"You have opted to save the best model, but the value of "
-            f"'{EVAL_NUM_EPOCHS}' is not -1 or greater than 0. Training will fail."
-        )
-        assert len([w for w in warning if warn_text in str(w.message)]) == 1
-        assert not checkpoint_dir.is_dir()
+        with pytest.raises(
+            InvalidConfigException,
+            match="Only values either equal to -1 or greater than 0 are allowed for this parameter.",
+        ):
+            train_core(
+                domain="data/test_domains/default.yml",
+                stories="data/test_yaml_stories/stories_defaultdomain.yml",
+                output=str(tmp_path),
+                config=f"data/test_config/{config_file}",
+            )
+
+        assert not (tmp_path / "my_model.tar.gz").is_file()
 
     def test_training_with_no_intent(
         self,
diff --git a/tests/core/test_training.py b/tests/core/test_training.py
index cbe782f26079..075a123b41b5 100644
--- a/tests/core/test_training.py
+++ b/tests/core/test_training.py
@@ -1,22 +1,17 @@
 from pathlib import Path
-from unittest.mock import Mock
 from _pytest.monkeypatch import MonkeyPatch
 from typing import Text
 
 import pytest
 
 from rasa.core import training
-from rasa.core.policies.memoization import MemoizationPolicy
-from rasa.core.constants import DEFAULT_MAX_HISTORY
 from rasa.core.policies.rule_policy import RulePolicy
 from rasa.shared.core.domain import Domain
-from rasa.core.interpreter import RasaNLUInterpreter
-from rasa.shared.nlu.interpreter import RegexInterpreter
-from rasa.core.train import train
-from rasa.core.agent import Agent
 from rasa.core.policies.ted_policy import TEDPolicy
 
 from rasa.shared.core.training_data.visualization import visualize_stories
+import rasa.model_training
+import rasa.shared.utils.io
 
 
 def test_load_training_data_reader_not_found_throws(tmp_path: Path, domain: Domain):
@@ -60,61 +55,16 @@ async def test_story_visualization_with_merging(domain: Domain):
     assert 20 < len(generated_graph.edges()) < 33
 
 
-def test_training_script_without_max_history_set(
-    tmp_path: Path, domain_path: Text, stories_path: Text
-):
-    tmpdir = str(tmp_path)
-
-    train(
-        domain_path,
-        stories_path,
-        tmpdir,
-        interpreter=RegexInterpreter(),
-        policy_config="data/test_config/no_max_hist_config.yml",
-        additional_arguments={},
-    )
-
-    agent = Agent.load(tmpdir)
-    for policy in agent.policy_ensemble.policies:
-        if hasattr(policy.featurizer, "max_history"):
-            if type(policy) == MemoizationPolicy:
-                assert policy.featurizer.max_history == DEFAULT_MAX_HISTORY
-            else:
-                assert policy.featurizer.max_history is None
-
-
-def test_training_script_with_max_history_set(
-    tmp_path: Path, domain_path: Text, stories_path: Text
-):
-    tmpdir = str(tmp_path)
-
-    train(
-        domain_path,
-        stories_path,
-        tmpdir,
-        interpreter=RegexInterpreter(),
-        policy_config="data/test_config/max_hist_config.yml",
-        additional_arguments={},
-    )
-    agent = Agent.load(tmpdir)
-
-    expected_max_history = {RulePolicy: None}
-    for policy in agent.policy_ensemble.policies:
-        if hasattr(policy.featurizer, "max_history"):
-            expected_history = expected_max_history.get(type(policy), 5)
-            assert policy.featurizer.max_history == expected_history
-
-
 def test_training_script_with_restart_stories(tmp_path: Path, domain_path: Text):
-    train(
+    model_file = rasa.model_training.train_core(
         domain_path,
-        "data/test_yaml_stories/stories_restart.yml",
-        str(tmp_path),
-        interpreter=RegexInterpreter(),
-        policy_config="data/test_config/max_hist_config.yml",
+        config="data/test_config/max_hist_config.yml",
+        stories="data/test_yaml_stories/stories_restart.yml",
+        output=str(tmp_path),
         additional_arguments={},
     )
-    assert True
+
+    assert Path(model_file).is_file()
 
 
 @pytest.mark.timeout(120, func_only=True)
@@ -127,56 +77,29 @@ async def test_random_seed(
             {"name": RulePolicy.__name__},
         ]
     }
+    config_file = tmp_path / "config.yml"
+    rasa.shared.utils.io.write_yaml(policies_config, config_file)
 
-    agent_1 = train(
+    model_file_1 = rasa.model_training.train_core(
         domain_path,
-        stories_path,
-        str(tmp_path),
-        interpreter=RegexInterpreter(),
-        policy_config=policies_config,
+        config=str(config_file),
+        stories=stories_path,
+        output=str(tmp_path),
         additional_arguments={},
     )
 
-    agent_2 = train(
+    model_file_2 = rasa.model_training.train_core(
         domain_path,
-        stories_path,
-        str(tmp_path),
-        interpreter=RegexInterpreter(),
-        policy_config=policies_config,
+        config=str(config_file),
+        stories=stories_path,
+        output=str(tmp_path),
         additional_arguments={},
     )
 
+    # TODO: Adapt rest of the test laster
     processor_1 = agent_1.create_processor()
     processor_2 = agent_2.create_processor()
-
-    probs_1 = await processor_1.predict_next("1")
-    probs_2 = await processor_2.predict_next("2")
-    assert probs_1["confidence"] == probs_2["confidence"]
-
-
-def test_trained_interpreter_passed_to_policies(
-    tmp_path: Path, monkeypatch: MonkeyPatch, domain_path: Text, stories_path: Text
-):
-    policies_config = {
-        "policies": [{"name": TEDPolicy.__name__}, {"name": RulePolicy.__name__}]
-    }
-
-    policy_train = Mock()
-    monkeypatch.setattr(TEDPolicy, "train", policy_train)
-
-    interpreter = Mock(spec=RasaNLUInterpreter)
-
-    train(
-        domain_path,
-        stories_path,
-        str(tmp_path),
-        interpreter=interpreter,
-        policy_config=policies_config,
-        additional_arguments={},
-    )
-
-    policy_train.assert_called_once()
-
-    assert policy_train.call_count == 1
-    _, _, kwargs = policy_train.mock_calls[0]
-    assert kwargs["interpreter"] == interpreter
+    #
+    # probs_1 = await processor_1.predict_next("1")
+    # probs_2 = await processor_2.predict_next("2")
+    # assert probs_1["confidence"] == probs_2["confidence"]
diff --git a/tests/engine/recipes/test_default_recipe.py b/tests/engine/recipes/test_default_recipe.py
index e3e29aebf00d..26de2ba51251 100644
--- a/tests/engine/recipes/test_default_recipe.py
+++ b/tests/engine/recipes/test_default_recipe.py
@@ -3,6 +3,7 @@
 import pytest
 
 import rasa.shared.utils.io
+from rasa.core.policies.ted_policy import TEDPolicyGraphComponent
 from rasa.engine.graph import GraphSchema, GraphComponent, ExecutionContext
 from rasa.engine.recipes.default_recipe import (
     DefaultV1Recipe,
@@ -20,6 +21,7 @@
 from rasa.nlu.extractors.mitie_entity_extractor import (
     MitieEntityExtractorGraphComponent,
 )
+from rasa.shared.exceptions import InvalidConfigException
 from rasa.shared.importers.autoconfig import TrainingType
 import rasa.engine.validation
 
@@ -34,6 +36,14 @@ def test_recipe_for_name():
     "training_type, is_finetuning",
     [
         # The default config is the config which most users run
+        (
+            "rasa/shared/importers/default_config.yml",
+            "data/graph_schemas/default_config_e2e_train_schema.yml",
+            "data/graph_schemas/default_config_e2e_predict_schema.yml",
+            TrainingType.END_TO_END,
+            False,
+        ),
+        # The default config without end to end
         (
             "rasa/shared/importers/default_config.yml",
             "data/graph_schemas/default_config_train_schema.yml",
@@ -129,7 +139,7 @@ def test_generate_graphs(
     config = rasa.shared.utils.io.read_yaml_file(config_path)
 
     recipe = Recipe.recipe_for_name(DefaultV1Recipe.name,)
-    train_schema, predict_schema = recipe.schemas_for_config(
+    train_schema, predict_schema, _ = recipe.schemas_for_config(
         config, {}, training_type=training_type, is_finetuning=is_finetuning
     )
 
@@ -152,37 +162,21 @@ def test_generate_graphs(
     )
 
 
-def test_importer_parameter_interpolation():
+def test_language_returning():
     config = rasa.shared.utils.io.read_yaml(
         """
+    language: "xy"
     version: '2.0'
 
     policies:
     - name: RulePolicy
     """
     )
-    data_path = "my_data/dir"
-    path_to_domain = "my_domain.yml"
-    stories_path = "extra_dir/for_stories"
-    nlu_path = "extra_dir/for_nlu_files"
 
     recipe = Recipe.recipe_for_name(DefaultV1Recipe.name)
-    train_schema, _ = recipe.schemas_for_config(
-        config,
-        {
-            "data": [data_path],
-            "domain": path_to_domain,
-            "stories": stories_path,
-            "nlu": nlu_path,
-        },
-    )
+    _, _, language = recipe.schemas_for_config(config, {},)
 
-    node = train_schema.nodes["project_provider"]
-
-    assert node.config == {
-        "domain_path": path_to_domain,
-        "training_data_paths": [data_path, stories_path, nlu_path],
-    }
+    assert language == "xy"
 
 
 def test_tracker_generator_parameter_interpolation():
@@ -199,7 +193,7 @@ def test_tracker_generator_parameter_interpolation():
     debug_plots = True
 
     recipe = Recipe.recipe_for_name(DefaultV1Recipe.name)
-    train_schema, _ = recipe.schemas_for_config(
+    train_schema, _, _ = recipe.schemas_for_config(
         config, {"augmentation": augmentation, "debug_plots": debug_plots},
     )
 
@@ -222,7 +216,9 @@ def test_nlu_training_data_persistence():
     )
 
     recipe = Recipe.recipe_for_name(DefaultV1Recipe.name)
-    train_schema, _ = recipe.schemas_for_config(config, {"persist_nlu_data": True},)
+    train_schema, _, _ = recipe.schemas_for_config(
+        config, {"persist_nlu_training_data": True},
+    )
 
     node = train_schema.nodes["nlu_training_data_provider"]
 
@@ -257,7 +253,7 @@ def test_num_threads_interpolation():
     )
 
     recipe = Recipe.recipe_for_name(DefaultV1Recipe.name)
-    train_schema, predict_schema = recipe.schemas_for_config(
+    train_schema, predict_schema, _ = recipe.schemas_for_config(
         config, {"num_threads": 20}
     )
 
@@ -272,37 +268,25 @@ def test_num_threads_interpolation():
     assert predict_schema == expected_predict_schema
 
 
-# TODO: comment in once a all components were adapted
-# def test_epoch_fraction_cli_param():
-#     expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file(
-#         "data/graph_schemas/default_config_train_schema.yml"
-#     )
-#     expected_train_schema = GraphSchema.from_dict(expected_schema_as_dict)
-#
-#     expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file(
-#         "data/graph_schemas/default_config_predict_schema.yml"
-#     )
-#     expected_predict_schema = GraphSchema.from_dict(expected_schema_as_dict)
-#
-#
-#     config = rasa.shared.utils.io.read_yaml_file(
-#         "rasa/shared/importers/default_config.yml"
-#     )
-#
-#     recipe = Recipe.recipe_for_name(DefaultV1Recipe.name)
-#     train_schema, predict_schema = recipe.schemas_for_config(
-#         config, {"epoch_fraction": 0.5}
-#     )
-#
-#     for node_name, node in expected_train_schema.nodes.items():
-#         assert train_schema.nodes[node_name] == node
-#
-#     assert train_schema == expected_train_schema
-#
-#     for node_name, node in expected_predict_schema.nodes.items():
-#         assert predict_schema.nodes[node_name] == node
-#
-#     assert predict_schema == expected_predict_schema
+def test_epoch_fraction_cli_param():
+    expected_schema_as_dict = rasa.shared.utils.io.read_yaml_file(
+        "data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml"
+    )
+    expected_train_schema = GraphSchema.from_dict(expected_schema_as_dict)
+
+    config = rasa.shared.utils.io.read_yaml_file(
+        "rasa/shared/importers/default_config.yml"
+    )
+
+    recipe = Recipe.recipe_for_name(DefaultV1Recipe.name)
+    train_schema, predict_schema, _ = recipe.schemas_for_config(
+        config, {"finetuning_epoch_fraction": 0.5}, is_finetuning=True
+    )
+
+    for node_name, node in expected_train_schema.nodes.items():
+        assert train_schema.nodes[node_name] == node
+
+    assert train_schema == expected_train_schema
 
 
 def test_register_component():
@@ -347,6 +331,56 @@ def test_retrieve_not_registered_class():
     class NotRegisteredClass:
         pass
 
-    with pytest.raises(KeyError):
+    with pytest.raises(InvalidConfigException):
         # noinspection PyTypeChecker
         DefaultV1Recipe._from_registry(NotRegisteredClass.__name__)
+
+
+def test_retrieve_via_module_path():
+    train_schema, predict_schema, _ = DefaultV1Recipe().schemas_for_config(
+        {
+            "policies": [
+                {"name": "rasa.core.policies.ted_policy.TEDPolicyGraphComponent"}
+            ]
+        },
+        {},
+        TrainingType.CORE,
+    )
+
+    assert any(
+        issubclass(node.uses, TEDPolicyGraphComponent)
+        for node in train_schema.nodes.values()
+    )
+    assert any(
+        issubclass(node.uses, TEDPolicyGraphComponent)
+        for node in predict_schema.nodes.values()
+    )
+
+
+def test_retrieve_via_invalid_module_path():
+    with pytest.raises(ImportError):
+        DefaultV1Recipe().schemas_for_config(
+            {
+                "policies": [
+                    {
+                        "name": "rasa.core.policies.ted_policy.TEDPolicyGraphComponent1000"
+                    }
+                ]
+            },
+            {},
+            TrainingType.CORE,
+        )
+
+
+def test_train_nlu_without_nlu_pipeline():
+    with pytest.raises(InvalidConfigException):
+        DefaultV1Recipe().schemas_for_config(
+            {"pipeline": []}, {}, TrainingType.NLU,
+        )
+
+
+def test_train_core_without_nlu_pipeline():
+    with pytest.raises(InvalidConfigException):
+        DefaultV1Recipe().schemas_for_config(
+            {"policies": []}, {}, TrainingType.CORE,
+        )
diff --git a/tests/engine/storage/test_local_model_storage.py b/tests/engine/storage/test_local_model_storage.py
index 3af822de40f9..03049de3dbf9 100644
--- a/tests/engine/storage/test_local_model_storage.py
+++ b/tests/engine/storage/test_local_model_storage.py
@@ -126,6 +126,17 @@ def test_create_model_package(
     assert list(persisted_resources) == [Path(load_model_storage_dir, "resource1")]
 
 
+def test_create_package_with_non_existing_parent(tmp_path: Path):
+    storage = LocalModelStorage.create(tmp_path)
+    model_file = tmp_path / "new" / "sub" / "dir" / "file.tar.gz"
+
+    storage.create_model_package(
+        model_file, GraphSchema({}), GraphSchema({}), Domain.empty()
+    )
+
+    assert model_file.is_file()
+
+
 def test_create_model_package_with_non_empty_model_storage(tmp_path: Path):
     # Put something in the future model storage directory
     (tmp_path / "somefile.json").touch()
@@ -133,3 +144,14 @@ def test_create_model_package_with_non_empty_model_storage(tmp_path: Path):
     with pytest.raises(ValueError):
         # Unpacking into an already filled `ModelStorage` raises an exception.
         _ = LocalModelStorage.from_model_archive(tmp_path, Path("does not matter"))
+
+
+def test_create_model_package_with_non_existing_dir(
+    tmp_path: Path, default_model_storage: ModelStorage
+):
+    path = tmp_path / "some_dir" / "another" / "model.tar.gz"
+    default_model_storage.create_model_package(
+        path, GraphSchema({}), GraphSchema({}), Domain.empty()
+    )
+
+    assert path.exists()
diff --git a/tests/engine/storage/test_resource.py b/tests/engine/storage/test_resource.py
index cf1d75b89a3c..0011e71f8caa 100644
--- a/tests/engine/storage/test_resource.py
+++ b/tests/engine/storage/test_resource.py
@@ -1,7 +1,12 @@
+from pathlib import Path
+
+import pytest
 from _pytest.tmpdir import TempPathFactory
 
+import rasa.utils.common
 from rasa.engine.storage.local_model_storage import LocalModelStorage
 from rasa.engine.storage.resource import Resource
+from rasa.engine.storage.storage import ModelStorage
 
 
 def test_resource_caching(tmp_path_factory: TempPathFactory):
@@ -35,6 +40,68 @@ def test_resource_caching(tmp_path_factory: TempPathFactory):
         assert (temporary_directory / test_filename).read_text() == test_content
 
 
+def test_resource_caching_if_already_restored(tmp_path_factory: TempPathFactory):
+    initial_storage_dir = tmp_path_factory.mktemp("initial_model_storage")
+    model_storage = LocalModelStorage(initial_storage_dir)
+
+    resource = Resource("my resource")
+
+    # Fill model storage
+    test_filename = "file.txt"
+    test_content = "test_resource_caching"
+    with model_storage.write_to(resource) as temporary_directory:
+        file = temporary_directory / test_filename
+        file.write_text(test_content)
+
+    cache_dir = tmp_path_factory.mktemp("cache_dir")
+
+    # Cache resource
+    resource.to_cache(cache_dir, model_storage)
+
+    new_storage_dir = tmp_path_factory.mktemp("new dir")
+    rasa.utils.common.copy_directory(initial_storage_dir, new_storage_dir)
+
+    reinstantiated_resource = Resource.from_cache(
+        resource.name, cache_dir, LocalModelStorage(new_storage_dir)
+    )
+
+    assert reinstantiated_resource == resource
+
+
+def test_resource_caching_if_already_restored_with_different_state(
+    tmp_path_factory: TempPathFactory,
+):
+    initial_storage_dir = tmp_path_factory.mktemp("initial_model_storage")
+    model_storage = LocalModelStorage(initial_storage_dir)
+
+    resource = Resource("my resource")
+
+    # Fill model storage
+    test_filename = "file.txt"
+    test_content = "test_resource_caching"
+    with model_storage.write_to(resource) as temporary_directory:
+        file = temporary_directory / test_filename
+        file.write_text(test_content)
+
+    cache_dir = tmp_path_factory.mktemp("cache_dir")
+
+    # Cache resource
+    resource.to_cache(cache_dir, model_storage)
+
+    # Pretend there is an additional file which is not in the restored storage.
+    # This makes the directories and causes the `from_cache` part to fail
+    # different
+    (temporary_directory / "another_file").touch()
+
+    new_storage_dir = tmp_path_factory.mktemp("new dir")
+    rasa.utils.common.copy_directory(initial_storage_dir, new_storage_dir)
+
+    with pytest.raises(ValueError):
+        Resource.from_cache(
+            resource.name, cache_dir, LocalModelStorage(new_storage_dir)
+        )
+
+
 def test_resource_fingerprinting():
     resource1 = Resource("resource 1")
     resource2 = Resource("resource 2")
@@ -46,3 +113,10 @@ def test_resource_fingerprinting():
     assert fingerprint2
 
     assert fingerprint1 != fingerprint2
+
+
+def test_caching_empty_resource(default_model_storage: ModelStorage, tmp_path: Path):
+    resource = Resource("my resource")
+
+    # does not raise
+    resource.to_cache(tmp_path, default_model_storage)
diff --git a/tests/engine/test_validation.py b/tests/engine/test_validation.py
index deced3cd60b4..ac0ad6a16381 100644
--- a/tests/engine/test_validation.py
+++ b/tests/engine/test_validation.py
@@ -4,10 +4,18 @@
 
 from rasa.engine import validation
 from rasa.engine.exceptions import GraphSchemaValidationException
-from rasa.engine.graph import GraphComponent, ExecutionContext, GraphSchema, SchemaNode
+from rasa.engine.graph import (
+    GraphComponent,
+    ExecutionContext,
+    GraphSchema,
+    SchemaNode,
+    PLACEHOLDER_IMPORTER,
+    RESERVED_PLACEHOLDERS,
+)
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
 from rasa.shared.core.domain import Domain
+from rasa.shared.importers.importer import TrainingDataImporter
 from rasa.shared.nlu.training_data.training_data import TrainingData
 
 
@@ -609,3 +617,65 @@ def run(self, training_data: TrainingData) -> TrainingData:
 
     with pytest.raises(GraphSchemaValidationException, match="Cycles"):
         validation.validate(schema, language=None, is_train_graph=is_train_graph)
+
+
+def test_validation_with_placeholders():
+    class MyTestComponent(TestComponentWithoutRun):
+        def run(self, training_data: TrainingDataImporter) -> TrainingDataImporter:
+            pass
+
+    schema = GraphSchema(
+        {
+            "A": SchemaNode(
+                needs={"training_data": "B"},
+                uses=MyTestComponent,
+                eager=True,
+                constructor_name="create",
+                fn="run",
+                is_target=True,
+                config={},
+            ),
+            "B": SchemaNode(
+                needs={"training_data": PLACEHOLDER_IMPORTER},
+                uses=MyTestComponent,
+                eager=True,
+                constructor_name="create",
+                fn="run",
+                config={},
+            ),
+        }
+    )
+
+    # Does not raise
+    validation.validate(schema, language=None, is_train_graph=True)
+
+
+def test_validation_with_placeholders():
+    class MyTestComponent(TestComponentWithoutRun):
+        def run(self, training_data: TrainingDataImporter) -> TrainingDataImporter:
+            pass
+
+    schema = GraphSchema(
+        {
+            "A": SchemaNode(
+                needs={"training_data": "B"},
+                uses=MyTestComponent,
+                eager=True,
+                constructor_name="create",
+                fn="run",
+                is_target=True,
+                config={},
+            ),
+            "B": SchemaNode(
+                needs={"training_data": PLACEHOLDER_IMPORTER},
+                uses=MyTestComponent,
+                eager=True,
+                constructor_name="create",
+                fn="run",
+                config={},
+            ),
+        }
+    )
+
+    # Does not raise
+    validation.validate(schema, language=None, is_train_graph=True)
diff --git a/tests/engine/training/test_graph_trainer.py b/tests/engine/training/test_graph_trainer.py
index 364f725b9db5..3d6c4638144d 100644
--- a/tests/engine/training/test_graph_trainer.py
+++ b/tests/engine/training/test_graph_trainer.py
@@ -14,6 +14,8 @@
 from rasa.engine.storage.resource import Resource
 from rasa.engine.storage.storage import ModelStorage
 from rasa.engine.training.graph_trainer import GraphTrainer
+from rasa.shared.core.domain import Domain
+from rasa.shared.importers.importer import TrainingDataImporter
 from tests.engine.graph_components_test_classes import (
     AddInputs,
     AssertComponent,
@@ -24,7 +26,7 @@
 )
 
 
-def test_graph_trainer_returns_prediction_runner(
+def test_graph_trainer_returns_model_metadata(
     default_model_storage: ModelStorage,
     temp_cache: TrainingCache,
     tmp_path: Path,
@@ -72,15 +74,16 @@ def test_graph_trainer_returns_prediction_runner(
     )
 
     output_filename = tmp_path / "model.tar.gz"
-    predict_graph_runner = graph_trainer.train(
+    model_metadata = graph_trainer.train(
         train_schema=train_schema,
         predict_schema=predict_schema,
-        domain_path=domain_path,
+        importer=TrainingDataImporter.load_from_dict(domain_path=str(domain_path)),
         output_filename=output_filename,
     )
-    assert isinstance(predict_graph_runner, DaskGraphRunner)
-    assert output_filename.is_file()
-    assert predict_graph_runner.run() == {"load": test_value}
+    assert model_metadata.model_id
+    assert model_metadata.domain.as_dict() == Domain.from_path(domain_path).as_dict()
+    assert model_metadata.train_schema == train_schema
+    assert model_metadata.predict_schema == predict_schema
 
 
 def test_graph_trainer_fingerprints_and_caches(
@@ -171,6 +174,18 @@ def test_graph_trainer_fingerprints_and_caches(
         "assert_node": 1,
     }
 
+    # We always run everything when the `force_retraining` flag is set to `True`
+    train_schema.nodes["add"].config["something"] = "new"
+    mocks = spy_on_all_components(train_schema)
+    train_with_schema(train_schema, temp_cache, force_retraining=True)
+    assert node_call_counts(mocks) == {
+        "read_file": 1,
+        "train": 1,
+        "process": 1,
+        "add": 1,
+        "assert_node": 1,
+    }
+
 
 def test_graph_trainer_always_reads_input(
     temp_cache: TrainingCache,
@@ -302,6 +317,7 @@ def inner(
         cache: Optional[TrainingCache] = None,
         model_storage: Optional[ModelStorage] = None,
         path: Optional[Path] = None,
+        force_retraining: bool = False,
     ) -> Path:
         if not path:
             path = tmp_path_factory.mktemp("model_storage_path")
@@ -320,8 +336,9 @@ def inner(
         graph_trainer.train(
             train_schema=train_schema,
             predict_schema=GraphSchema({}),
-            domain_path=domain_path,
+            importer=TrainingDataImporter.load_from_dict(domain_path=str(domain_path)),
             output_filename=output_filename,
+            force_retraining=force_retraining,
         )
 
         assert output_filename.is_file()
diff --git a/tests/graph_components/adders/test_nlu_prediction_to_history_adder.py b/tests/graph_components/adders/test_nlu_prediction_to_history_adder.py
index 001abf38447a..075a722d096d 100644
--- a/tests/graph_components/adders/test_nlu_prediction_to_history_adder.py
+++ b/tests/graph_components/adders/test_nlu_prediction_to_history_adder.py
@@ -54,7 +54,7 @@ def test_prediction_adder_add_message(
     original_message = UserMessage(
         text="hello", input_channel=input_channel, metadata={"meta": "meta"}
     )
-    tracker = component.add(messages, tracker, moodbot_domain, original_message)
+    tracker = component.add(messages, tracker, moodbot_domain, [original_message])
 
     assert len(tracker.events) == len(messages)
     for i, _ in enumerate(messages):
diff --git a/tests/graph_components/converters/test_nlu_message_converter.py b/tests/graph_components/converters/test_nlu_message_converter.py
index fed8848570f6..38884b113ffe 100644
--- a/tests/graph_components/converters/test_nlu_message_converter.py
+++ b/tests/graph_components/converters/test_nlu_message_converter.py
@@ -17,7 +17,7 @@ def test_nlu_message_converter_converts_message(
     )
 
     message = UserMessage(text="Hello", metadata=None)
-    nlu_message = component.convert_user_message(message)
+    nlu_message = component.convert_user_message([message])
     assert len(nlu_message) == 1
     assert isinstance(nlu_message[0], Message)
 
@@ -33,7 +33,7 @@ def test_nlu_message_converter_converts_message_with_metadata(
     )
 
     message = UserMessage(text="Hello", metadata={"test_key": "test_value"})
-    nlu_message = component.convert_user_message(message)
+    nlu_message = component.convert_user_message([message])
     assert len(nlu_message) == 1
     assert isinstance(nlu_message[0], Message)
 
@@ -51,6 +51,5 @@ def test_nlu_message_converter_handles_no_user_message(
         default_execution_context,
     )
 
-    message = None
-    nlu_message = component.convert_user_message(message)
+    nlu_message = component.convert_user_message([])
     assert len(nlu_message) == 0
diff --git a/tests/graph_components/providers/test_domain_without_responses_provider.py b/tests/graph_components/providers/test_domain_without_responses_provider.py
index 75ceb9e94e2b..bf6661ca017a 100644
--- a/tests/graph_components/providers/test_domain_without_responses_provider.py
+++ b/tests/graph_components/providers/test_domain_without_responses_provider.py
@@ -57,18 +57,11 @@ def test_provide(
         original_domain.responses
     ), reminder
 
-    # Assert that the recreated copy does not contain any response information
-    assert not modified_domain.responses
-    assert all(
-        action in original_domain._custom_actions
-        for action in modified_domain.user_actions
-    )
-    assert set(modified_domain.action_names_or_texts) < set(
-        original_domain.action_names_or_texts
-    )
-    assert all(
-        response not in modified_domain.action_names_or_texts
-        for response in set(original_domain.responses).difference(
-            original_domain._custom_actions
-        )
+    # The responses are empty
+    for _, responses_for_action in modified_domain.responses.items():
+        assert not responses_for_action
+
+    # We still have all the same labels
+    assert (
+        original_domain.action_names_or_texts == modified_domain.action_names_or_texts
     )
diff --git a/tests/graph_components/providers/test_nlu_training_data_provider.py b/tests/graph_components/providers/test_nlu_training_data_provider.py
index 8610c0e8e6d5..8eb107313d9f 100644
--- a/tests/graph_components/providers/test_nlu_training_data_provider.py
+++ b/tests/graph_components/providers/test_nlu_training_data_provider.py
@@ -28,7 +28,7 @@ def test_nlu_training_data_provider(
 
     # check the default configuration is as expected
     config_1 = NLUTrainingDataProvider.get_default_config()
-    assert "language" not in config_1
+    assert config_1["language"] is None
     assert config_1["persist"] is False
 
     # create a provider with persist == True
diff --git a/tests/graph_components/providers/test_project_provider.py b/tests/graph_components/providers/test_project_provider.py
deleted file mode 100644
index 1cf045f69581..000000000000
--- a/tests/graph_components/providers/test_project_provider.py
+++ /dev/null
@@ -1,64 +0,0 @@
-from typing import Dict, Text, Any
-
-import pytest
-from rasa.graph_components.providers.project_provider import ProjectProvider
-from rasa.engine.graph import ExecutionContext
-from rasa.engine.storage.resource import Resource
-from rasa.engine.storage.storage import ModelStorage
-from rasa.shared.core.domain import Domain
-from rasa.shared.core.training_data.structures import StoryGraph
-from rasa.shared.nlu.training_data.training_data import TrainingData
-
-
-@pytest.mark.parametrize(
-    "config",
-    [
-        {
-            "config_path": "examples/moodbot/config.yml",
-            "domain_path": "examples/moodbot/domain.yml",
-            "training_data_paths": ["examples/moodbot/data/"],
-        }
-    ],
-)
-def test_provide_importer(
-    default_model_storage: ModelStorage,
-    default_execution_context: ExecutionContext,
-    config: Dict[Text, Any],
-) -> None:
-    project_provider = ProjectProvider.create(
-        config, default_model_storage, Resource("xy"), default_execution_context
-    )
-
-    importer = project_provider.provide()
-
-    assert isinstance(importer.get_config(), Dict)
-    assert isinstance(importer.get_domain(), Domain)
-    assert isinstance(importer.get_nlu_data(), TrainingData)
-    assert len(importer.get_nlu_data().training_examples) > 0
-    assert isinstance(importer.get_stories(), StoryGraph)
-
-
-def test_provide_multiproject_importer(
-    default_model_storage: ModelStorage, default_execution_context: ExecutionContext,
-) -> None:
-    config_path = "data/test_multiproject/config.yml"
-    importer_config = {"importers": [{"name": "MultiProjectImporter"}]}
-    config = {
-        "config_path": config_path,
-        "config": importer_config,
-        "domain_path": None,
-        "training_data_paths": None,
-    }
-    project_provider = ProjectProvider.create(
-        config, default_model_storage, Resource("xy"), default_execution_context
-    )
-    importer = project_provider.provide()
-
-    training_data = importer.get_nlu_data()
-    assert len(training_data.intents) == 4
-
-    domain = importer.get_domain()
-    assert len(domain.responses) == 4
-
-    project_config = importer.get_config()
-    assert len(project_config["policies"]) == 3
diff --git a/tests/nlu/classifiers/test_sklearn_classifier.py b/tests/nlu/classifiers/test_sklearn_classifier.py
index 374c66c21a9f..03a4fab2c9d2 100644
--- a/tests/nlu/classifiers/test_sklearn_classifier.py
+++ b/tests/nlu/classifiers/test_sklearn_classifier.py
@@ -10,7 +10,7 @@
     SpacyFeaturizerGraphComponent,
 )
 from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizerGraphComponent
-from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyPreprocessor
+from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyPreprocessorGraphComponent
 import rasa.shared.nlu.training_data.loading
 from rasa.engine.graph import ExecutionContext, GraphComponent
 from rasa.engine.storage.resource import Resource
@@ -47,7 +47,7 @@ def test_persist_and_load(
     spacy_nlp: Language,
 ):
     spacy_model = SpacyModel(model=spacy_nlp, model_name="en_core_web_md")
-    training_data = SpacyPreprocessor({}).process_training_data(
+    training_data = SpacyPreprocessorGraphComponent({}).process_training_data(
         training_data, spacy_model
     )
 
diff --git a/tests/nlu/conftest.py b/tests/nlu/conftest.py
index 10c4573f991b..101aa1cf7964 100644
--- a/tests/nlu/conftest.py
+++ b/tests/nlu/conftest.py
@@ -25,21 +25,19 @@ def blank_config() -> RasaNLUModelConfig:
 
 
 @pytest.fixture()
-def pretrained_embeddings_spacy_config() -> RasaNLUModelConfig:
-    return RasaNLUModelConfig(
-        {
-            "language": "en",
-            "pipeline": [
-                {"name": "SpacyNLP", "model": "en_core_web_md"},
-                {"name": "SpacyTokenizer"},
-                {"name": "SpacyFeaturizer"},
-                {"name": "RegexFeaturizer"},
-                {"name": "CRFEntityExtractor", EPOCHS: 1, RANDOM_SEED: 42},
-                {"name": "EntitySynonymMapper"},
-                {"name": "SklearnIntentClassifier"},
-            ],
-        }
-    )
+def pretrained_embeddings_spacy_config() -> Dict:
+    return {
+        "language": "en",
+        "pipeline": [
+            {"name": "SpacyNLP", "model": "en_core_web_md"},
+            {"name": "SpacyTokenizer"},
+            {"name": "SpacyFeaturizer"},
+            {"name": "RegexFeaturizer"},
+            {"name": "CRFEntityExtractor", EPOCHS: 1, RANDOM_SEED: 42},
+            {"name": "EntitySynonymMapper"},
+            {"name": "SklearnIntentClassifier"},
+        ],
+    }
 
 
 @pytest.fixture()
diff --git a/tests/nlu/extractors/test_crf_entity_extractor.py b/tests/nlu/extractors/test_crf_entity_extractor.py
index 8f51c89dc3c1..b7167a846c9d 100644
--- a/tests/nlu/extractors/test_crf_entity_extractor.py
+++ b/tests/nlu/extractors/test_crf_entity_extractor.py
@@ -14,7 +14,7 @@
 from rasa.nlu.tokenizers.spacy_tokenizer import SpacyTokenizerGraphComponent
 from rasa.nlu.constants import SPACY_DOCS
 from rasa.nlu.tokenizers.whitespace_tokenizer import WhitespaceTokenizerGraphComponent
-from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyPreprocessor
+from rasa.nlu.utils.spacy_utils import SpacyModel, SpacyPreprocessorGraphComponent
 from rasa.shared.importers.rasa import RasaFileImporter
 from rasa.shared.nlu.constants import TEXT, ENTITIES
 from rasa.shared.nlu.training_data.message import Message
@@ -129,7 +129,7 @@ async def test_train_persist_with_different_configurations(
     training_data = importer.get_nlu_data()
 
     spacy_model = SpacyModel(model=spacy_nlp, model_name="en_core_web_md")
-    training_data = SpacyPreprocessor({}).process_training_data(
+    training_data = SpacyPreprocessorGraphComponent({}).process_training_data(
         training_data, spacy_model
     )
     training_data = spacy_tokenizer.process_training_data(training_data)
@@ -137,7 +137,7 @@ async def test_train_persist_with_different_configurations(
     crf_extractor.train(training_data)
 
     message = Message(data={TEXT: "I am looking for an italian restaurant"})
-    messages = SpacyPreprocessor({}).process([message], spacy_model)
+    messages = SpacyPreprocessorGraphComponent({}).process([message], spacy_model)
     message = spacy_tokenizer.process(messages)[0]
 
     message2 = copy.deepcopy(message)
diff --git a/tests/nlu/test_train.py b/tests/nlu/test_train.py
index 9d93593964c0..db768149eb0a 100644
--- a/tests/nlu/test_train.py
+++ b/tests/nlu/test_train.py
@@ -1,13 +1,17 @@
-import os
+from pathlib import Path
+
 import pytest
+from _pytest.tmpdir import TempPathFactory
 
+from rasa.engine.storage.local_model_storage import LocalModelStorage
 from rasa.nlu import registry
 import rasa.nlu.train
-from rasa.nlu.config import RasaNLUModelConfig
-from rasa.nlu.model import Interpreter, Trainer
-from rasa.shared.nlu.training_data.training_data import TrainingData
+from rasa.nlu.model import Interpreter
+from rasa.shared.nlu.training_data.formats import RasaYAMLReader
 from rasa.utils.tensorflow.constants import EPOCHS
-from typing import Any, Dict, List, Tuple, Text, Union
+from typing import Any, Dict, List, Tuple, Text, Union, Optional
+import rasa.model_training
+import rasa.shared.utils.io
 
 COMPONENTS_TEST_PARAMS = {
     "DIETClassifier": {EPOCHS: 1},
@@ -137,67 +141,25 @@ def test_all_components_are_in_at_least_one_test_pipeline():
 
 @pytest.mark.timeout(600, func_only=True)
 @pytest.mark.parametrize("language, pipeline", pipelines_for_tests())
-async def test_train_persist_load_parse(
-    language, pipeline, component_builder, tmpdir, nlu_as_json_path: Text
+def test_train_persist_load_parse(
+    language: Optional[Text],
+    pipeline: List[Dict],
+    tmp_path: Path,
+    nlu_as_json_path: Text,
 ):
-    _config = RasaNLUModelConfig({"pipeline": pipeline, "language": language})
-
-    (trained, _, persisted_path) = await rasa.nlu.train.train(
-        _config,
-        path=tmpdir.strpath,
-        data=nlu_as_json_path,
-        component_builder=component_builder,
+    config_file = tmp_path / "config.yml"
+    rasa.shared.utils.io.dump_obj_as_json_to_file(
+        config_file, {"pipeline": pipeline, "language": language}
     )
 
-    assert trained.pipeline
-
-    loaded = Interpreter.load(persisted_path, component_builder)
-
-    assert loaded.pipeline
-    assert loaded.parse("Rasa is great!") is not None
-
-
-@pytest.mark.timeout(600, func_only=True)
-@pytest.mark.parametrize("language, pipeline", pipelines_for_non_windows_tests())
-@pytest.mark.skip_on_windows
-async def test_train_persist_load_parse_non_windows(
-    language, pipeline, component_builder, tmpdir, nlu_as_json_path: Text
-):
-    await test_train_persist_load_parse(
-        language, pipeline, component_builder, tmpdir, nlu_as_json_path
+    persisted_path = rasa.model_training.train_nlu(
+        str(config_file), nlu_as_json_path, output=str(tmp_path),
     )
 
+    assert Path(persisted_path).is_file()
 
-@pytest.mark.parametrize("language, pipeline", pipelines_for_tests())
-def test_train_model_without_data(language, pipeline, component_builder, tmpdir):
-    _config = RasaNLUModelConfig({"pipeline": pipeline, "language": language})
-
-    trainer = Trainer(_config, component_builder)
-    trainer.train(TrainingData())
-    persisted_path = trainer.persist(tmpdir.strpath)
-
-    loaded = Interpreter.load(persisted_path, component_builder)
-
-    assert loaded.pipeline
-    assert loaded.parse("Rasa is great!") is not None
-
-
-@pytest.mark.timeout(600, func_only=True)
-@pytest.mark.parametrize("language, pipeline", pipelines_for_non_windows_tests())
-@pytest.mark.skip_on_windows
-def test_train_model_without_data_non_windows(
-    language, pipeline, component_builder, tmpdir
-):
-    test_train_model_without_data(language, pipeline, component_builder, tmpdir)
-
-
-@pytest.mark.timeout(600, func_only=True)
-@pytest.mark.parametrize("language, pipeline", pipelines_for_tests())
-def test_load_and_persist_without_train(language, pipeline, component_builder, tmpdir):
-    _config = RasaNLUModelConfig({"pipeline": pipeline, "language": language})
-
-    trainer = Trainer(_config, component_builder)
-    persisted_path = trainer.persist(tmpdir.strpath)
+    # TODO: Fix model loading
+    assert trained.pipeline
 
     loaded = Interpreter.load(persisted_path, component_builder)
 
@@ -208,95 +170,92 @@ def test_load_and_persist_without_train(language, pipeline, component_builder, t
 @pytest.mark.timeout(600, func_only=True)
 @pytest.mark.parametrize("language, pipeline", pipelines_for_non_windows_tests())
 @pytest.mark.skip_on_windows
-def test_load_and_persist_without_train_non_windows(
-    language, pipeline, component_builder, tmpdir
+def test_train_persist_load_parse_non_windows(
+    language, pipeline, component_builder, tmp_path, nlu_as_json_path: Text
 ):
-    test_load_and_persist_without_train(language, pipeline, component_builder, tmpdir)
+    test_train_persist_load_parse(language, pipeline, tmp_path, nlu_as_json_path)
 
 
-async def test_train_model_empty_pipeline(component_builder, nlu_as_json_path: Text):
-    _config = RasaNLUModelConfig({"pipeline": None, "language": "en"})
+def test_train_model_empty_pipeline(nlu_as_json_path: Text, tmp_path: Path):
+    config_file = tmp_path / "config.yml"
+    rasa.shared.utils.io.dump_obj_as_json_to_file(config_file, {"pipeline": [],})
 
     with pytest.raises(ValueError):
-        await rasa.nlu.train.train(
-            _config, data=nlu_as_json_path, component_builder=component_builder
+        rasa.model_training.train_nlu(
+            str(config_file), nlu_as_json_path, output=str(tmp_path),
         )
 
 
-async def test_train_named_model(component_builder, tmpdir, nlu_as_json_path: Text):
-    _config = RasaNLUModelConfig(
-        {"pipeline": [{"name": "KeywordIntentClassifier"}], "language": "en"}
+def test_handles_pipeline_with_non_existing_component(
+    tmp_path: Path, pretrained_embeddings_spacy_config: Dict, nlu_as_json_path: Text
+):
+    pretrained_embeddings_spacy_config["pipeline"].append(
+        {"name": "my_made_up_component"}
     )
 
-    (trained, _, persisted_path) = await rasa.nlu.train.train(
-        _config,
-        path=tmpdir.strpath,
-        data=nlu_as_json_path,
-        component_builder=component_builder,
+    config_file = tmp_path / "config.yml"
+    rasa.shared.utils.io.dump_obj_as_json_to_file(
+        config_file, pretrained_embeddings_spacy_config
     )
 
-    assert trained.pipeline
-
-    normalized_path = os.path.dirname(os.path.normpath(persisted_path))
-    # should be saved in a dir named after a project
-    assert normalized_path == tmpdir.strpath
-
-
-async def test_handles_pipeline_with_non_existing_component(
-    component_builder, pretrained_embeddings_spacy_config, nlu_as_json_path: Text
-):
-    pretrained_embeddings_spacy_config.pipeline.append({"name": "my_made_up_component"})
-
-    with pytest.raises(Exception) as execinfo:
-        await rasa.nlu.train.train(
-            pretrained_embeddings_spacy_config,
-            data=nlu_as_json_path,
-            component_builder=component_builder,
+    with pytest.raises(
+        Exception,
+        match="Can't load class for name 'my_made_up_componentGraphComponent'",
+    ):
+        rasa.model_training.train_nlu(
+            str(config_file), nlu_as_json_path, output=str(tmp_path),
         )
-    assert "Cannot find class" in str(execinfo.value)
 
 
-async def test_train_model_training_data_persisted(
-    component_builder, tmpdir, nlu_as_json_path: Text
+def test_train_model_training_data_persisted(
+    tmp_path: Path, nlu_as_json_path: Text, tmp_path_factory: TempPathFactory
 ):
-    _config = RasaNLUModelConfig(
-        {"pipeline": [{"name": "KeywordIntentClassifier"}], "language": "en"}
+    config_file = tmp_path / "config.yml"
+    rasa.shared.utils.io.dump_obj_as_json_to_file(
+        config_file,
+        {"pipeline": [{"name": "KeywordIntentClassifier"}], "language": "en"},
     )
 
-    (trained, _, persisted_path) = await rasa.nlu.train.train(
-        _config,
-        path=tmpdir.strpath,
-        data=nlu_as_json_path,
-        component_builder=component_builder,
+    persisted_path = rasa.model_training.train_nlu(
+        str(config_file),
+        nlu_as_json_path,
+        output=str(tmp_path),
         persist_nlu_training_data=True,
     )
 
-    assert trained.pipeline
+    assert Path(persisted_path).is_file()
 
-    loaded = Interpreter.load(persisted_path, component_builder)
+    model_dir = tmp_path_factory.mktemp("loaded")
+    storage, _ = LocalModelStorage.from_model_archive(model_dir, Path(persisted_path))
 
-    assert loaded.pipeline
-    assert loaded.model_metadata.get("training_data") is not None
+    nlu_data_dir = model_dir / "nlu_training_data_provider"
 
+    assert nlu_data_dir.is_dir()
 
-async def test_train_model_no_training_data_persisted(
-    component_builder, tmpdir, nlu_as_json_path: Text
+    assert not RasaYAMLReader().read(nlu_data_dir / "training_data.yml").is_empty()
+
+
+def test_train_model_no_training_data_persisted(
+    tmp_path: Path, nlu_as_json_path: Text, tmp_path_factory: TempPathFactory
 ):
-    _config = RasaNLUModelConfig(
-        {"pipeline": [{"name": "KeywordIntentClassifier"}], "language": "en"}
+    config_file = tmp_path / "config.yml"
+    rasa.shared.utils.io.dump_obj_as_json_to_file(
+        config_file,
+        {"pipeline": [{"name": "KeywordIntentClassifier"}], "language": "en"},
     )
 
-    (trained, _, persisted_path) = await rasa.nlu.train.train(
-        _config,
-        path=tmpdir.strpath,
-        data=nlu_as_json_path,
-        component_builder=component_builder,
+    persisted_path = rasa.model_training.train_nlu(
+        str(config_file),
+        nlu_as_json_path,
+        output=str(tmp_path),
         persist_nlu_training_data=False,
     )
 
-    assert trained.pipeline
+    assert Path(persisted_path).is_file()
 
-    loaded = Interpreter.load(persisted_path, component_builder)
+    model_dir = tmp_path_factory.mktemp("loaded")
+    storage, _ = LocalModelStorage.from_model_archive(model_dir, Path(persisted_path))
 
-    assert loaded.pipeline
-    assert loaded.model_metadata.get("training_data") is None
+    nlu_data_dir = model_dir / "nlu_training_data_provider"
+
+    assert not nlu_data_dir.is_dir()
diff --git a/tests/test_model.py b/tests/test_model.py
index 2994ee29d3b0..bfc0416251df 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -1,56 +1,19 @@
 import os
-import tempfile
 import time
-import shutil
 from pathlib import Path
-from typing import Text, Optional, Any
-from unittest import mock
-from unittest.mock import Mock
+from typing import Text, Optional
 
 import pytest
-from _pytest.monkeypatch import MonkeyPatch
 
 import rasa
 import rasa.constants
-from rasa.shared.importers.importer import TrainingDataImporter
-from rasa.shared.importers.rasa import RasaFileImporter
-from rasa.shared.constants import (
-    DEFAULT_CONFIG_PATH,
-    DEFAULT_DOMAIN_PATH,
-    DEFAULT_DATA_PATH,
-    DEFAULT_CORE_SUBDIRECTORY_NAME,
-)
-from rasa.shared.core.domain import KEY_RESPONSES
-from rasa.shared.core.domain import Domain
 import rasa.shared.utils.io
 from rasa import model
+from rasa.exceptions import ModelNotFound
 from rasa.model import (
-    FINGERPRINT_CONFIG_KEY,
-    FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY,
-    FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY,
-    FINGERPRINT_NLG_KEY,
-    FINGERPRINT_FILE_PATH,
-    FINGERPRINT_NLU_DATA_KEY,
-    FINGERPRINT_RASA_VERSION_KEY,
-    FINGERPRINT_STORIES_KEY,
-    FINGERPRINT_TRAINED_AT_KEY,
-    FINGERPRINT_CONFIG_CORE_KEY,
-    FINGERPRINT_CONFIG_NLU_KEY,
-    SECTION_CORE,
-    SECTION_NLU,
-    can_finetune,
-    create_package_rasa,
     get_latest_model,
     get_model,
-    get_model_subdirectories,
-    model_fingerprint,
-    Fingerprint,
-    did_section_fingerprint_change,
-    should_retrain,
-    FingerprintComparisonResult,
 )
-from rasa.exceptions import ModelNotFound
-from rasa.model_training import train_core
 
 
 def test_get_latest_model(tmp_path: Path):
@@ -66,13 +29,6 @@ def test_get_latest_model(tmp_path: Path):
     assert get_latest_model(str(path)) == path_of_latest
 
 
-def test_get_model_from_directory(trained_rasa_model: str):
-    unpacked = get_model(trained_rasa_model)
-
-    assert os.path.exists(os.path.join(unpacked, DEFAULT_CORE_SUBDIRECTORY_NAME))
-    assert os.path.exists(os.path.join(unpacked, "nlu"))
-
-
 def test_get_model_context_manager(trained_rasa_model: str):
     with get_model(trained_rasa_model) as unpacked:
         assert os.path.exists(unpacked)
@@ -94,548 +50,3 @@ def test_get_local_model_exception(model_path: Optional[Text]):
 def test_get_model_exception(model_path: Optional[Text]):
     with pytest.raises(ModelNotFound):
         get_model(model_path)
-
-
-def test_get_model_from_directory_with_subdirectories(
-    trained_rasa_model: Text, tmp_path: Path
-):
-    unpacked = get_model(trained_rasa_model)
-    unpacked_core, unpacked_nlu = get_model_subdirectories(unpacked)
-
-    assert unpacked_core
-    assert unpacked_nlu
-
-    with pytest.raises(ModelNotFound):
-        get_model_subdirectories(str(tmp_path))  # temp path should be empty
-
-
-def test_get_model_from_directory_nlu_only(trained_rasa_model: Text):
-    unpacked = get_model(trained_rasa_model)
-    shutil.rmtree(os.path.join(unpacked, DEFAULT_CORE_SUBDIRECTORY_NAME))
-    unpacked_core, unpacked_nlu = get_model_subdirectories(unpacked)
-
-    assert not unpacked_core
-    assert unpacked_nlu
-
-
-def _fingerprint(
-    config: Optional[Any] = None,
-    config_nlu: Optional[Any] = None,
-    config_core: Optional[Any] = None,
-    config_without_epochs: Optional[Any] = None,
-    domain: Optional[Any] = None,
-    nlg: Optional[Any] = None,
-    stories: Optional[Any] = None,
-    nlu: Optional[Any] = None,
-    rasa_version: Text = "1.0",
-) -> Fingerprint:
-    return {
-        FINGERPRINT_CONFIG_KEY: config if config is not None else ["test"],
-        FINGERPRINT_CONFIG_CORE_KEY: config_core
-        if config_core is not None
-        else ["test"],
-        FINGERPRINT_CONFIG_NLU_KEY: config_nlu if config_nlu is not None else ["test"],
-        FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY: config_without_epochs
-        if config_without_epochs
-        else ["test"],
-        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY: domain if domain is not None else ["test"],
-        FINGERPRINT_NLG_KEY: nlg if nlg is not None else ["test"],
-        FINGERPRINT_TRAINED_AT_KEY: time.time(),
-        FINGERPRINT_RASA_VERSION_KEY: rasa_version,
-        FINGERPRINT_STORIES_KEY: stories if stories is not None else ["test"],
-        FINGERPRINT_NLU_DATA_KEY: nlu if nlu is not None else ["test"],
-    }
-
-
-def test_persist_and_load_fingerprint():
-    from rasa.model import persist_fingerprint, fingerprint_from_path
-
-    fingerprint = _fingerprint()
-    output_directory = tempfile.mkdtemp()
-
-    persist_fingerprint(output_directory, fingerprint)
-    actual = fingerprint_from_path(output_directory)
-
-    assert actual == fingerprint
-
-
-@pytest.mark.parametrize(
-    "fingerprint2, changed",
-    [
-        (_fingerprint(config=["other"]), True),
-        (_fingerprint(config_core=["other"]), True),
-        (_fingerprint(domain=["other"]), True),
-        (_fingerprint(domain=Domain.empty()), True),
-        (_fingerprint(stories=["test", "other"]), True),
-        (_fingerprint(rasa_version="100"), True),
-        (_fingerprint(config=["other"], domain=["other"]), True),
-        (_fingerprint(nlg=["other"]), False),
-        (_fingerprint(nlu=["test", "other"]), False),
-        (_fingerprint(config_nlu=["other"]), False),
-        (_fingerprint(config_without_epochs=["other"]), False),
-    ],
-)
-def test_core_fingerprint_changed(fingerprint2: Fingerprint, changed: bool):
-    fingerprint1 = _fingerprint()
-    assert (
-        did_section_fingerprint_change(fingerprint1, fingerprint2, SECTION_CORE)
-        is changed
-    )
-
-
-@pytest.mark.parametrize(
-    "fingerprint2, changed",
-    [
-        (_fingerprint(config=["other"]), True),
-        (_fingerprint(nlu=["test", "other"]), True),
-        (_fingerprint(rasa_version="100"), True),
-        (_fingerprint(rasa_version="100", config=["other"]), True),
-        (_fingerprint(nlg=["other"]), False),
-        (_fingerprint(config_core=["other"]), False),
-        (_fingerprint(stories=["other"]), False),
-        (_fingerprint(config_without_epochs=["other"]), False),
-    ],
-)
-def test_nlu_fingerprint_changed(fingerprint2: Fingerprint, changed: bool):
-    fingerprint1 = _fingerprint()
-    assert (
-        did_section_fingerprint_change(fingerprint1, fingerprint2, SECTION_NLU)
-        is changed
-    )
-
-
-def _project_files(
-    project: Text,
-    config_file: Text = DEFAULT_CONFIG_PATH,
-    domain: Text = DEFAULT_DOMAIN_PATH,
-    training_files: Text = DEFAULT_DATA_PATH,
-) -> TrainingDataImporter:
-    paths = {
-        "config_file": config_file,
-        "domain_path": domain,
-        "training_data_paths": training_files,
-    }
-    paths = {
-        k: v if v is None or Path(v).is_absolute() else os.path.join(project, v)
-        for k, v in paths.items()
-    }
-    paths["training_data_paths"] = [paths["training_data_paths"]]
-
-    return RasaFileImporter(**paths)
-
-
-@pytest.mark.parametrize(
-    "domain_path",
-    [
-        DEFAULT_DOMAIN_PATH,
-        str((Path(".") / "data/test_domains/default_with_mapping.yml").absolute()),
-    ],
-)
-def test_create_fingerprint_from_paths(project: Text, domain_path: Text):
-    project_files = _project_files(project, domain=domain_path)
-
-    assert model_fingerprint(project_files)
-
-
-def test_fingerprinting_changed_response_text(project: Text):
-    importer = _project_files(project)
-
-    old_fingerprint = model_fingerprint(importer)
-    old_domain = importer.get_domain()
-
-    # Change NLG content but keep actions the same
-    domain_with_changed_nlg = old_domain.as_dict()
-    domain_with_changed_nlg[KEY_RESPONSES]["utter_greet"].append({"text": "hi"})
-    domain_with_changed_nlg = Domain.from_dict(domain_with_changed_nlg)
-
-    importer.get_domain = lambda: domain_with_changed_nlg
-
-    new_fingerprint = model_fingerprint(importer)
-
-    assert (
-        old_fingerprint[FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY]
-        == new_fingerprint[FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY]
-    )
-    assert old_fingerprint[FINGERPRINT_NLG_KEY] != new_fingerprint[FINGERPRINT_NLG_KEY]
-
-
-def test_fingerprinting_changing_config_epochs(project: Text, tmp_path):
-    config1 = {
-        "language": "en",
-        "pipeline": [
-            {"name": "WhitespaceTokenizer"},
-            {"name": "RegexFeaturizer"},
-            {"name": "LexicalSyntacticFeaturizer"},
-            {"name": "CountVectorsFeaturizer"},
-            {
-                "name": "CountVectorsFeaturizer",
-                "analyzer": "char_wb",
-                "min_ngram": 1,
-                "max_ngram": 4,
-            },
-            {"name": "DIETClassifier", "epochs": 100},
-            {"name": "EntitySynonymMapper"},
-            {"name": "ResponseSelector", "epochs": 100},
-            {
-                "name": "FallbackClassifier",
-                "threshold": 0.3,
-                "ambiguity_threshold": 0.1,
-            },
-        ],
-        "policies": [
-            {"name": "MemoizationPolicy"},
-            {"name": "TEDPolicy", "max_history": 5, "epochs": 100},
-            {"name": "RulePolicy"},
-        ],
-    }
-
-    config1_path = tmp_path / "config1.yml"
-    rasa.shared.utils.io.write_yaml(config1, config1_path, True)
-    importer = TrainingDataImporter.load_from_config(str(config1_path))
-    old_fingerprint = model_fingerprint(importer)
-
-    config2 = {
-        "language": "en",
-        "pipeline": [
-            {"name": "WhitespaceTokenizer"},
-            {"name": "RegexFeaturizer"},
-            {"name": "LexicalSyntacticFeaturizer"},
-            {"name": "CountVectorsFeaturizer"},
-            {
-                "name": "CountVectorsFeaturizer",
-                "analyzer": "char_wb",
-                "min_ngram": 1,
-                "max_ngram": 4,
-            },
-            {"name": "DIETClassifier", "epochs": 50},
-            {"name": "EntitySynonymMapper"},
-            {"name": "ResponseSelector", "epochs": 50},
-            {
-                "name": "FallbackClassifier",
-                "threshold": 0.3,
-                "ambiguity_threshold": 0.1,
-            },
-        ],
-        "policies": [
-            {"name": "MemoizationPolicy"},
-            {"name": "TEDPolicy", "max_history": 5, "epochs": 50},
-            {"name": "RulePolicy"},
-        ],
-    }
-
-    config2_path = tmp_path / "config2.yml"
-    rasa.shared.utils.io.write_yaml(config2, config2_path, True)
-    importer = TrainingDataImporter.load_from_config(str(config2_path))
-    new_fingerprint = model_fingerprint(importer)
-
-    assert (
-        old_fingerprint[FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY]
-        == new_fingerprint[FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY]
-    )
-    assert (
-        old_fingerprint[FINGERPRINT_CONFIG_CORE_KEY]
-        != new_fingerprint[FINGERPRINT_CONFIG_CORE_KEY]
-    )
-    assert (
-        old_fingerprint[FINGERPRINT_CONFIG_NLU_KEY]
-        != new_fingerprint[FINGERPRINT_CONFIG_NLU_KEY]
-    )
-
-    config3 = {
-        "language": "en",
-        "pipeline": [{"name": "WhitespaceTokenizer"},],
-        "policies": [
-            {"name": "MemoizationPolicy"},
-            {"name": "TEDPolicy", "max_history": 5, "epochs": 50},
-            {"name": "RulePolicy"},
-        ],
-    }
-
-    config3_path = tmp_path / "config3.yml"
-    rasa.shared.utils.io.write_yaml(config3, config3_path, True)
-    importer = TrainingDataImporter.load_from_config(str(config3_path))
-    new_fingerprint = model_fingerprint(importer)
-
-    assert (
-        old_fingerprint[FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY]
-        != new_fingerprint[FINGERPRINT_CONFIG_WITHOUT_EPOCHS_KEY]
-    )
-
-
-@pytest.mark.parametrize("empty_key", ["pipeline", "policies"])
-def test_fingerprinting_config_epochs_empty_pipeline_or_policies(
-    project: Text, tmp_path: Path, empty_key: Text,
-):
-    config = {
-        "language": "en",
-        "pipeline": [{"name": "WhitespaceTokenizer"},],
-        "policies": [{"name": "MemoizationPolicy"},],
-    }
-
-    config[empty_key] = None
-
-    model._get_fingerprint_of_config_without_epochs(config)
-
-
-def test_fingerprinting_additional_action(project: Text):
-    importer = _project_files(project)
-
-    old_fingerprint = model_fingerprint(importer)
-    old_domain = importer.get_domain()
-
-    domain_with_new_action = old_domain.as_dict()
-    domain_with_new_action[KEY_RESPONSES]["utter_new"] = [{"text": "hi"}]
-    domain_with_new_action = Domain.from_dict(domain_with_new_action)
-
-    importer.get_domain = lambda: domain_with_new_action
-
-    new_fingerprint = model_fingerprint(importer)
-
-    assert (
-        old_fingerprint[FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY]
-        != new_fingerprint[FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY]
-    )
-    assert old_fingerprint[FINGERPRINT_NLG_KEY] != new_fingerprint[FINGERPRINT_NLG_KEY]
-
-
-@pytest.mark.parametrize("use_fingerprint", [True, False])
-def test_rasa_packaging(
-    trained_rasa_model: Text, project: Text, use_fingerprint: bool, tmp_path: Path
-):
-    unpacked_model_path = get_model(trained_rasa_model)
-
-    os.remove(os.path.join(unpacked_model_path, FINGERPRINT_FILE_PATH))
-    if use_fingerprint:
-        fingerprint = model_fingerprint(_project_files(project))
-    else:
-        fingerprint = None
-
-    output_path = str(tmp_path / "test.tar.gz")
-
-    create_package_rasa(unpacked_model_path, output_path, fingerprint)
-
-    unpacked = get_model(output_path)
-
-    assert (
-        os.path.exists(os.path.join(unpacked, FINGERPRINT_FILE_PATH)) == use_fingerprint
-    )
-    assert os.path.exists(os.path.join(unpacked, DEFAULT_CORE_SUBDIRECTORY_NAME))
-    assert os.path.exists(os.path.join(unpacked, "nlu"))
-
-    assert not os.path.exists(unpacked_model_path)
-
-
-@pytest.mark.parametrize(
-    "fingerprint",
-    [
-        {
-            "new": _fingerprint(),
-            "old": _fingerprint(stories=["others"]),
-            "retrain_core": True,
-            "retrain_nlu": False,
-            "retrain_nlg": True,
-        },
-        {
-            "new": _fingerprint(nlu=["others"]),
-            "old": _fingerprint(),
-            "retrain_core": False,
-            "retrain_nlu": True,
-            "retrain_nlg": False,
-        },
-        {
-            "new": _fingerprint(config="others"),
-            "old": _fingerprint(),
-            "retrain_core": True,
-            "retrain_nlu": True,
-            "retrain_nlg": True,
-        },
-        {
-            "new": _fingerprint(config_core="others"),
-            "old": _fingerprint(),
-            "retrain_core": True,
-            "retrain_nlu": False,
-            "retrain_nlg": True,
-        },
-        {
-            "new": _fingerprint(),
-            "old": _fingerprint(config_nlu="others"),
-            "retrain_core": False,
-            "retrain_nlu": True,
-            "retrain_nlg": False,
-        },
-        {
-            "new": _fingerprint(),
-            "old": _fingerprint(),
-            "retrain_core": False,
-            "retrain_nlu": False,
-            "retrain_nlg": False,
-        },
-        {
-            "new": _fingerprint(),
-            "old": _fingerprint(nlg=["others"]),
-            "retrain_core": False,
-            "retrain_nlu": False,
-            "retrain_nlg": True,
-        },
-    ],
-)
-def test_should_retrain(
-    trained_rasa_model: Text, fingerprint: Fingerprint, tmp_path: Path
-):
-    old_model = set_fingerprint(trained_rasa_model, fingerprint["old"], tmp_path)
-
-    retrain = should_retrain(fingerprint["new"], old_model, str(tmp_path))
-
-    assert retrain.should_retrain_core() == fingerprint["retrain_core"]
-    assert retrain.should_retrain_nlg() == fingerprint["retrain_nlg"]
-    assert retrain.should_retrain_nlu() == fingerprint["retrain_nlu"]
-
-
-def test_should_not_retrain_core(
-    domain_path: Text, tmp_path: Path, stack_config_path: Text
-):
-    # Don't use `stories_path` as checkpoints currently break fingerprinting
-    story_file = tmp_path / "simple_story.yml"
-    story_file.write_text(
-        """
-stories:
-- story: test_story
-  steps:
-  - intent: greet
-  - action: utter_greet
-    """
-    )
-    trained_model = train_core(
-        domain_path, stack_config_path, str(story_file), str(tmp_path)
-    )
-
-    importer = TrainingDataImporter.load_from_config(
-        stack_config_path, domain_path, training_data_paths=[str(story_file)]
-    )
-
-    new_fingerprint = model.model_fingerprint(importer)
-
-    result = model.should_retrain(new_fingerprint, trained_model, tmp_path)
-
-    assert not result.should_retrain_core()
-
-
-def set_fingerprint(
-    trained_rasa_model: Text, fingerprint: Fingerprint, tmp_path: Path
-) -> Text:
-    unpacked_model_path = get_model(trained_rasa_model)
-
-    os.remove(os.path.join(unpacked_model_path, FINGERPRINT_FILE_PATH))
-
-    output_path = str(tmp_path / "test.tar.gz")
-
-    create_package_rasa(unpacked_model_path, output_path, fingerprint)
-
-    return output_path
-
-
-@pytest.mark.parametrize(
-    "comparison_result,retrain_all,retrain_core,retrain_nlg,retrain_nlu",
-    [
-        (FingerprintComparisonResult(force_training=True), True, True, True, True),
-        (
-            FingerprintComparisonResult(core=True, nlu=False, nlg=False),
-            True,
-            True,
-            True,
-            False,
-        ),
-        (
-            FingerprintComparisonResult(core=False, nlu=True, nlg=False),
-            True,
-            False,
-            False,
-            True,
-        ),
-        (
-            FingerprintComparisonResult(core=True, nlu=True, nlg=False),
-            True,
-            True,
-            True,
-            True,
-        ),
-    ],
-)
-def test_fingerprint_comparison_result(
-    comparison_result: FingerprintComparisonResult,
-    retrain_all: bool,
-    retrain_core: bool,
-    retrain_nlg: bool,
-    retrain_nlu: bool,
-):
-    assert comparison_result.is_training_required() == retrain_all
-    assert comparison_result.should_retrain_core() == retrain_core
-    assert comparison_result.should_retrain_nlg() == retrain_nlg
-    assert comparison_result.should_retrain_nlu() == retrain_nlu
-
-
-def test_update_with_new_domain(trained_rasa_model: Text, tmpdir: Path):
-    _ = model.unpack_model(trained_rasa_model, tmpdir)
-
-    new_domain = Domain.empty()
-
-    mocked_importer = Mock()
-
-    def get_domain() -> Domain:
-        return new_domain
-
-    mocked_importer.get_domain = get_domain
-
-    model.update_model_with_new_domain(mocked_importer, tmpdir)
-
-    actual = Domain.load(tmpdir / DEFAULT_CORE_SUBDIRECTORY_NAME / DEFAULT_DOMAIN_PATH)
-
-    assert actual.is_empty()
-
-
-def test_update_with_new_domain_preserves_domain(
-    tmpdir: Path, domain_with_categorical_slot_path
-):
-    domain = Domain.load(domain_with_categorical_slot_path)
-
-    core_directory = tmpdir / DEFAULT_CORE_SUBDIRECTORY_NAME
-    core_directory.mkdir()
-
-    domain.persist(str(core_directory / DEFAULT_DOMAIN_PATH))
-    domain.persist_specification(core_directory)
-
-    mocked_importer = Mock()
-
-    def get_domain() -> Domain:
-        return Domain.load(domain_with_categorical_slot_path)
-
-    mocked_importer.get_domain = get_domain
-
-    model.update_model_with_new_domain(mocked_importer, tmpdir)
-
-    new_persisted = Domain.load(core_directory / DEFAULT_DOMAIN_PATH)
-    new_persisted.compare_with_specification(str(core_directory))
-
-
-@pytest.mark.parametrize(
-    "min_compatible_version, old_model_version, can_tune",
-    [("2.1.0", "2.1.0", True), ("2.0.0", "2.1.0", True), ("2.1.0", "2.0.0", False),],
-)
-def test_can_finetune_min_version(
-    project: Text,
-    monkeypatch: MonkeyPatch,
-    old_model_version: Text,
-    min_compatible_version: Text,
-    can_tune: bool,
-):
-    importer = _project_files(project)
-
-    monkeypatch.setattr(
-        rasa.constants, "MINIMUM_COMPATIBLE_VERSION", min_compatible_version
-    )
-    monkeypatch.setattr(rasa, "__version__", old_model_version)
-    old_fingerprint = model_fingerprint(importer)
-    new_fingerprint = model_fingerprint(importer)
-
-    with mock.patch("rasa.model.MINIMUM_COMPATIBLE_VERSION", min_compatible_version):
-        assert can_finetune(old_fingerprint, new_fingerprint) == can_tune
diff --git a/tests/test_model_training.py b/tests/test_model_training.py
index c73f98701b5b..a1d89a0f7f91 100644
--- a/tests/test_model_training.py
+++ b/tests/test_model_training.py
@@ -2,66 +2,35 @@
 import secrets
 import tempfile
 import os
+import textwrap
 from pathlib import Path
-from typing import Text, Dict, Any
+from typing import Text
 from unittest.mock import Mock
 
 import pytest
 from _pytest.capture import CaptureFixture
 from _pytest.logging import LogCaptureFixture
 from _pytest.monkeypatch import MonkeyPatch
+from _pytest.tmpdir import TempPathFactory
 
 import rasa
 
-from rasa.core.policies.ted_policy import TEDPolicy
+from rasa.core.policies.ted_policy import TEDPolicyGraphComponent
 import rasa.model
 import rasa.model_training
 import rasa.core
 import rasa.core.train
 import rasa.nlu
+from rasa.engine.exceptions import GraphSchemaValidationException
+from rasa.engine.storage.local_model_storage import LocalModelStorage
+from rasa.engine.training.graph_trainer import GraphTrainer
 
-from rasa.nlu.classifiers.diet_classifier import DIETClassifier
+from rasa.nlu.classifiers.diet_classifier import DIETClassifierGraphComponent
 import rasa.shared.importers.autoconfig as autoconfig
 import rasa.shared.utils.io
-from rasa.core.agent import Agent
-from rasa.core.interpreter import RasaNLUInterpreter
-from rasa.nlu.model import Interpreter
+from rasa.shared.core.domain import Domain
+from rasa.shared.exceptions import InvalidConfigException
 from rasa.utils.tensorflow.constants import EPOCHS
-from tests.conftest import AsyncMock
-from tests.test_model import _fingerprint
-
-
-@pytest.mark.parametrize(
-    "parameters",
-    [
-        {"model_name": "test-1234", "prefix": None},
-        {"model_name": None, "prefix": "core-"},
-        {"model_name": None, "prefix": None},
-    ],
-)
-def test_package_model(trained_rasa_model: Text, parameters: Dict):
-    output_path = tempfile.mkdtemp()
-    train_path = rasa.model.unpack_model(trained_rasa_model)
-
-    model_path = rasa.model.package_model(
-        _fingerprint(),
-        output_path,
-        train_path,
-        parameters["model_name"],
-        parameters["prefix"],
-    )
-
-    assert os.path.exists(model_path)
-
-    file_name = os.path.basename(model_path)
-
-    if parameters["model_name"]:
-        assert parameters["model_name"] in file_name
-
-    if parameters["prefix"]:
-        assert parameters["prefix"] in file_name
-
-    assert file_name.endswith(".tar.gz")
 
 
 def count_temp_rasa_files(directory: Text) -> int:
@@ -172,6 +141,7 @@ def test_train_nlu_wrong_format_error_message(
     assert "Please verify the data format" in captured.out
 
 
+# TODO: Will be fixed by ConfigValidator
 def test_train_nlu_with_responses_no_domain_warns(tmp_path: Path):
     data_path = "data/test_nlu_no_responses/nlu_no_responses.yml"
 
@@ -227,81 +197,6 @@ def test_train_nlu_no_nlu_file_error_message(
     assert "No NLU data given" in captured.out
 
 
-def test_trained_interpreter_passed_to_core_training(
-    monkeypatch: MonkeyPatch,
-    tmp_path: Path,
-    unpacked_trained_rasa_model: Text,
-    nlu_data_path: Text,
-    stories_path: Text,
-    config_path: Text,
-    domain_path: Text,
-):
-    # Skip actual NLU training and return trained interpreter path from fixture
-    # Patching is bit more complicated as we have a module `train` and function
-    # with the same name 😬
-    monkeypatch.setattr(
-        rasa.model_training,
-        "_train_nlu_with_validated_data",
-        AsyncMock(return_value=unpacked_trained_rasa_model),
-    )
-
-    # Mock the actual Core training
-    _train_core = mock_core_training(monkeypatch)
-
-    rasa.train(
-        domain_path, config_path, [stories_path, nlu_data_path], str(tmp_path),
-    )
-
-    _train_core.assert_called_once()
-    _, _, kwargs = _train_core.mock_calls[0]
-    assert isinstance(kwargs["interpreter"], RasaNLUInterpreter)
-
-
-def test_interpreter_of_old_model_passed_to_core_training(
-    monkeypatch: MonkeyPatch,
-    tmp_path: Path,
-    trained_rasa_model: Text,
-    domain_path: Text,
-    config_path: Text,
-    stories_path: Text,
-    nlu_data_path: Text,
-):
-    # NLU isn't retrained
-    monkeypatch.setattr(
-        rasa.model.FingerprintComparisonResult,
-        rasa.model.FingerprintComparisonResult.should_retrain_nlu.__name__,
-        lambda _: False,
-    )
-
-    # An old model with an interpreter exists
-    monkeypatch.setattr(
-        rasa.model, rasa.model.get_latest_model.__name__, lambda _: trained_rasa_model
-    )
-
-    # Mock the actual Core training
-    _train_core = mock_core_training(monkeypatch)
-
-    rasa.train(
-        domain_path, config_path, [stories_path, nlu_data_path], str(tmp_path),
-    )
-
-    _train_core.assert_called_once()
-    _, _, kwargs = _train_core.mock_calls[0]
-    assert isinstance(kwargs["interpreter"], RasaNLUInterpreter)
-
-
-def test_load_interpreter_returns_none_for_none():
-    from rasa.model_training import _load_interpreter
-
-    assert _load_interpreter(None) is None
-
-
-def test_interpreter_from_previous_model_returns_none_for_none():
-    from rasa.model_training import _interpreter_from_previous_model
-
-    assert _interpreter_from_previous_model(None) is None
-
-
 def test_train_core_autoconfig(
     tmp_path: Path,
     monkeypatch: MonkeyPatch,
@@ -312,13 +207,11 @@ def test_train_core_autoconfig(
     monkeypatch.setattr(tempfile, "tempdir", tmp_path)
 
     # mock function that returns configuration
-    mocked_get_configuration = Mock()
+    mocked_get_configuration = Mock(wraps=autoconfig.get_configuration)
     monkeypatch.setattr(autoconfig, "get_configuration", mocked_get_configuration)
 
     # skip actual core training
-    monkeypatch.setattr(
-        rasa.model_training, "_train_core_with_validated_data", AsyncMock()
-    )
+    monkeypatch.setattr(GraphTrainer, GraphTrainer.train.__name__, Mock())
 
     # do training
     rasa.model_training.train_core(
@@ -342,13 +235,10 @@ def test_train_nlu_autoconfig(
     monkeypatch.setattr(tempfile, "tempdir", tmp_path)
 
     # mock function that returns configuration
-    mocked_get_configuration = Mock()
+    mocked_get_configuration = Mock(wraps=autoconfig.get_configuration)
     monkeypatch.setattr(autoconfig, "get_configuration", mocked_get_configuration)
 
-    monkeypatch.setattr(
-        rasa.model_training, "_train_nlu_with_validated_data", AsyncMock()
-    )
-
+    monkeypatch.setattr(GraphTrainer, GraphTrainer.train.__name__, Mock())
     # do training
     rasa.model_training.train_nlu(
         stack_config_path, nlu_data_path, output="test_train_nlu_temp_files_models",
@@ -359,26 +249,6 @@ def test_train_nlu_autoconfig(
     assert args[1] == autoconfig.TrainingType.NLU
 
 
-def mock_async(monkeypatch: MonkeyPatch, target: Any, name: Text) -> Mock:
-    mock = Mock()
-
-    async def mock_async_func(*args: Any, **kwargs: Any) -> None:
-        mock(*args, **kwargs)
-
-    monkeypatch.setattr(target, name, mock_async_func)
-    return mock
-
-
-def mock_core_training(monkeypatch: MonkeyPatch) -> Mock:
-    mock = Mock()
-    monkeypatch.setattr(rasa.core.train, rasa.core.train.train.__name__, mock)
-    return mock
-
-
-def mock_nlu_training(monkeypatch: MonkeyPatch) -> Mock:
-    return mock_async(monkeypatch, rasa.nlu.train, rasa.nlu.train.train.__name__)
-
-
 def new_model_path_in_same_dir(old_model_path: Text) -> Text:
     return str(Path(old_model_path).parent / (secrets.token_hex(8) + ".tar.gz"))
 
@@ -386,23 +256,20 @@ def new_model_path_in_same_dir(old_model_path: Text) -> Text:
 class TestE2e:
     def test_e2e_gives_experimental_warning(
         self,
-        monkeypatch: MonkeyPatch,
-        trained_e2e_model: Text,
-        domain_path: Text,
-        stack_config_path: Text,
+        moodbot_domain_path: Path,
+        e2e_bot_config_file: Path,
         e2e_stories_path: Text,
         nlu_data_path: Text,
         caplog: LogCaptureFixture,
+        tmp_path: Path,
     ):
-        mock_nlu_training(monkeypatch)
-        mock_core_training(monkeypatch)
-
         with caplog.at_level(logging.WARNING):
             rasa.train(
-                domain_path,
-                stack_config_path,
+                str(moodbot_domain_path),
+                str(e2e_bot_config_file),
                 [e2e_stories_path, nlu_data_path],
-                output=new_model_path_in_same_dir(trained_e2e_model),
+                output=str(tmp_path),
+                dry_run=True,
             )
 
         assert any(
@@ -414,35 +281,32 @@ def test_e2e_gives_experimental_warning(
 
     def test_models_not_retrained_if_no_new_data(
         self,
-        monkeypatch: MonkeyPatch,
         trained_e2e_model: Text,
-        domain_path: Text,
-        stack_config_path: Text,
+        moodbot_domain_path: Path,
+        e2e_bot_config_file: Path,
         e2e_stories_path: Text,
         nlu_data_path: Text,
+        trained_e2e_model_cache: Path,
     ):
-        mocked_nlu_training = mock_nlu_training(monkeypatch)
-        mocked_core_training = mock_core_training(monkeypatch)
-
-        rasa.train(
-            domain_path,
-            stack_config_path,
+        result = rasa.train(
+            str(moodbot_domain_path),
+            str(e2e_bot_config_file),
             [e2e_stories_path, nlu_data_path],
             output=new_model_path_in_same_dir(trained_e2e_model),
+            dry_run=True,
         )
 
-        mocked_core_training.assert_not_called()
-        mocked_nlu_training.assert_not_called()
+        assert result.code == 0
 
     def test_retrains_nlu_and_core_if_new_e2e_example(
         self,
-        monkeypatch: MonkeyPatch,
         trained_e2e_model: Text,
-        domain_path: Text,
-        stack_config_path: Text,
+        moodbot_domain_path: Path,
+        e2e_bot_config_file: Path,
         e2e_stories_path: Text,
         nlu_data_path: Text,
         tmp_path: Path,
+        trained_e2e_model_cache: Path,
     ):
         stories_yaml = rasa.shared.utils.io.read_yaml_file(e2e_stories_path)
         stories_yaml["stories"][1]["steps"].append({"user": "new message!"})
@@ -450,84 +314,96 @@ def test_retrains_nlu_and_core_if_new_e2e_example(
         new_stories_file = tmp_path / "new_stories.yml"
         rasa.shared.utils.io.write_yaml(stories_yaml, new_stories_file)
 
-        mocked_nlu_training = mock_nlu_training(monkeypatch)
-        mocked_core_training = mock_core_training(monkeypatch)
-
-        new_model_path = rasa.train(
-            domain_path,
-            stack_config_path,
+        result = rasa.train(
+            str(moodbot_domain_path),
+            str(e2e_bot_config_file),
             [new_stories_file, nlu_data_path],
             output=new_model_path_in_same_dir(trained_e2e_model),
-        ).model
-        os.remove(new_model_path)
+            dry_run=True,
+        )
+
+        assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED
 
-        mocked_core_training.assert_called_once()
-        mocked_nlu_training.assert_called_once()
+        fingerprints = result.dry_run_results
+        assert not fingerprints["train_CountVectorsFeaturizer3"].is_hit
+        assert not fingerprints["train_DIETClassifier5"].is_hit
+        assert not fingerprints["end_to_end_features_provider"].is_hit
+        assert not fingerprints["train_TEDPolicy0"].is_hit
+        assert not fingerprints["train_RulePolicy1"].is_hit
 
     def test_retrains_only_core_if_new_e2e_example_seen_before(
         self,
-        monkeypatch: MonkeyPatch,
         trained_e2e_model: Text,
-        domain_path: Text,
-        stack_config_path: Text,
+        moodbot_domain_path: Path,
+        e2e_bot_config_file: Path,
         e2e_stories_path: Text,
         nlu_data_path: Text,
         tmp_path: Path,
+        trained_e2e_model_cache: Path,
     ):
         stories_yaml = rasa.shared.utils.io.read_yaml_file(e2e_stories_path)
         stories_yaml["stories"][1]["steps"].append({"user": "Yes"})
 
-        new_stories_file = new_stories_file = tmp_path / "new_stories.yml"
+        new_stories_file = tmp_path / "new_stories.yml"
         rasa.shared.utils.io.write_yaml(stories_yaml, new_stories_file)
 
-        mocked_nlu_training = mock_nlu_training(monkeypatch)
-        mocked_core_training = mock_core_training(monkeypatch)
-
-        new_model_path = rasa.train(
-            domain_path,
-            stack_config_path,
+        result = rasa.train(
+            str(moodbot_domain_path),
+            str(e2e_bot_config_file),
             [new_stories_file, nlu_data_path],
             output=new_model_path_in_same_dir(trained_e2e_model),
-        ).model
-        os.remove(new_model_path)
+            dry_run=True,
+        )
+
+        assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED
+
+        fingerprints = result.dry_run_results
 
-        mocked_core_training.assert_called_once()
-        mocked_nlu_training.assert_not_called()
+        assert fingerprints["train_CountVectorsFeaturizer3"].is_hit
+        assert fingerprints["train_DIETClassifier5"].is_hit
+        assert fingerprints["end_to_end_features_provider"].is_hit
+        assert not fingerprints["train_TEDPolicy0"].is_hit
+        assert not fingerprints["train_RulePolicy1"].is_hit
 
     def test_nlu_and_core_trained_if_no_nlu_data_but_e2e_stories(
         self,
-        monkeypatch: MonkeyPatch,
-        domain_path: Text,
-        stack_config_path: Text,
+        moodbot_domain_path: Path,
+        e2e_bot_config_file: Path,
         e2e_stories_path: Text,
         tmp_path: Path,
+        monkeypatch: MonkeyPatch,
     ):
-        mocked_nlu_training = mock_nlu_training(monkeypatch)
-        mocked_core_training = mock_core_training(monkeypatch)
+        train_mock = Mock()
+        monkeypatch.setattr(GraphTrainer, GraphTrainer.train.__name__, train_mock)
 
-        output = self.make_tmp_model_dir(tmp_path)
         rasa.train(
-            domain_path, stack_config_path, [e2e_stories_path], output=output,
+            str(moodbot_domain_path),
+            str(e2e_bot_config_file),
+            [e2e_stories_path],
+            output=str(tmp_path),
         )
 
-        mocked_core_training.assert_called_once()
-        mocked_nlu_training.assert_called_once()
+        args, _ = train_mock.call_args
 
-    @staticmethod
-    def make_tmp_model_dir(tmp_path: Path) -> Text:
-        (tmp_path / "models").mkdir()
-        output = str(tmp_path / "models")
-        return output
+        for schema in args[:2]:
+            assert any(
+                issubclass(node.uses, DIETClassifierGraphComponent)
+                for node in schema.nodes.values()
+            )
+            assert any(
+                issubclass(node.uses, TEDPolicyGraphComponent)
+                for node in schema.nodes.values()
+            )
 
     def test_new_nlu_data_retrains_core_if_there_are_e2e_stories(
         self,
-        monkeypatch: MonkeyPatch,
         trained_e2e_model: Text,
-        domain_path: Text,
-        stack_config_path: Text,
+        moodbot_domain_path: Path,
+        e2e_bot_config_file: Path,
         e2e_stories_path: Text,
         nlu_data_path: Text,
         tmp_path: Path,
+        trained_e2e_model_cache: Path,
     ):
         nlu_yaml = rasa.shared.utils.io.read_yaml_file(nlu_data_path)
         nlu_yaml["nlu"][0]["examples"] += "- surprise!\n"
@@ -535,70 +411,75 @@ def test_new_nlu_data_retrains_core_if_there_are_e2e_stories(
         new_nlu_file = tmp_path / "new_nlu.yml"
         rasa.shared.utils.io.write_yaml(nlu_yaml, new_nlu_file)
 
-        mocked_nlu_training = mock_nlu_training(monkeypatch)
-        mocked_core_training = mock_core_training(monkeypatch)
-
-        new_model_path = rasa.train(
-            domain_path,
-            stack_config_path,
+        result = rasa.train(
+            str(moodbot_domain_path),
+            str(e2e_bot_config_file),
             [e2e_stories_path, new_nlu_file],
             output=new_model_path_in_same_dir(trained_e2e_model),
-        ).model
-        os.remove(new_model_path)
+            dry_run=True,
+        )
+
+        assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED
 
-        mocked_core_training.assert_called_once()
-        mocked_nlu_training.assert_called_once()
+        fingerprints = result.dry_run_results
+        assert not fingerprints["train_CountVectorsFeaturizer3"].is_hit
+        assert not fingerprints["train_DIETClassifier5"].is_hit
+        assert not fingerprints["end_to_end_features_provider"].is_hit
+        assert not fingerprints["train_TEDPolicy0"].is_hit
+        assert fingerprints["train_RulePolicy1"].is_hit
 
     def test_new_nlu_data_does_not_retrain_core_if_there_are_no_e2e_stories(
         self,
-        monkeypatch: MonkeyPatch,
-        trained_simple_rasa_model: Text,
-        domain_path: Text,
-        stack_config_path: Text,
+        moodbot_domain_path: Path,
+        e2e_bot_config_file: Path,
         simple_stories_path: Text,
         nlu_data_path: Text,
         tmp_path: Path,
     ):
+        rasa.train(
+            str(moodbot_domain_path),
+            str(e2e_bot_config_file),
+            [simple_stories_path, nlu_data_path],
+            output=str(tmp_path),
+        )
+
         nlu_yaml = rasa.shared.utils.io.read_yaml_file(nlu_data_path)
         nlu_yaml["nlu"][0]["examples"] += "- surprise!\n"
 
         new_nlu_file = tmp_path / "new_nlu.yml"
         rasa.shared.utils.io.write_yaml(nlu_yaml, new_nlu_file)
 
-        mocked_nlu_training = mock_nlu_training(monkeypatch)
-        mocked_core_training = mock_core_training(monkeypatch)
-
-        new_model_path = rasa.train(
-            domain_path,
-            stack_config_path,
+        result = rasa.train(
+            str(moodbot_domain_path),
+            str(e2e_bot_config_file),
             [simple_stories_path, new_nlu_file],
-            output=new_model_path_in_same_dir(trained_simple_rasa_model),
-        ).model
-        os.remove(new_model_path)
+            output=str(tmp_path),
+            dry_run=True,
+        )
 
-        mocked_core_training.assert_not_called()
-        mocked_nlu_training.assert_called_once()
+        assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED
+
+        fingerprints = result.dry_run_results
+
+        assert not fingerprints["train_CountVectorsFeaturizer3"].is_hit
+        assert not fingerprints["train_DIETClassifier5"].is_hit
+        assert "end_to_end_features_provider" not in fingerprints
+        assert fingerprints["train_TEDPolicy0"].is_hit
+        assert fingerprints["train_RulePolicy1"].is_hit
 
     def test_training_core_with_e2e_fails_gracefully(
         self,
         capsys: CaptureFixture,
-        monkeypatch: MonkeyPatch,
         tmp_path: Path,
         domain_path: Text,
         stack_config_path: Text,
         e2e_stories_path: Text,
     ):
-
-        mocked_nlu_training = mock_nlu_training(monkeypatch)
-        mocked_core_training = mock_core_training(monkeypatch)
-
-        output = self.make_tmp_model_dir(tmp_path)
         rasa.model_training.train_core(
-            domain_path, stack_config_path, e2e_stories_path, output=output,
+            domain_path, stack_config_path, e2e_stories_path, output=str(tmp_path),
         )
 
-        mocked_core_training.assert_not_called()
-        mocked_nlu_training.assert_not_called()
+        assert not list(tmp_path.glob("*"))
 
         captured = capsys.readouterr()
         assert (
@@ -611,7 +492,6 @@ def test_training_core_with_e2e_fails_gracefully(
 @pytest.mark.parametrize("use_latest_model", [True, False])
 def test_model_finetuning(
     tmp_path: Path,
-    monkeypatch: MonkeyPatch,
     domain_path: Text,
     stories_path: Text,
     stack_config_path: Text,
@@ -619,16 +499,13 @@ def test_model_finetuning(
     trained_rasa_model: Text,
     use_latest_model: bool,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-    mocked_core_training = mock_core_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
     if use_latest_model:
         trained_rasa_model = str(Path(trained_rasa_model).parent)
 
-    rasa.train(
+    result = rasa.train(
         domain_path,
         stack_config_path,
         [stories_path, nlu_data_path],
@@ -638,29 +515,19 @@ def test_model_finetuning(
         finetuning_epoch_fraction=0.1,
     )
 
-    mocked_core_training.assert_called_once()
-    _, kwargs = mocked_core_training.call_args
-    assert isinstance(kwargs["model_to_finetune"], Agent)
-
-    mocked_nlu_training.assert_called_once()
-    _, kwargs = mocked_nlu_training.call_args
-    assert isinstance(kwargs["model_to_finetune"], Interpreter)
+    assert Path(result.model).is_file()
 
 
 @pytest.mark.timeout(300, func_only=True)
 @pytest.mark.parametrize("use_latest_model", [True, False])
 def test_model_finetuning_core(
     tmp_path: Path,
-    monkeypatch: MonkeyPatch,
     trained_moodbot_path: Text,
     use_latest_model: bool,
+    tmp_path_factory: TempPathFactory,
 ):
-    mocked_core_training = mock_core_training(monkeypatch)
-    mock_agent_load = Mock(wraps=Agent.load)
-    monkeypatch.setattr(Agent, "load", mock_agent_load)
-
     (tmp_path / "models").mkdir()
-    output = str(tmp_path / "models")
+    output = tmp_path / "models"
 
     if use_latest_model:
         trained_moodbot_path = str(Path(trained_moodbot_path).parent)
@@ -682,29 +549,27 @@ def test_model_finetuning_core(
     new_stories_path = tmp_path / "new_stories.yml"
     rasa.shared.utils.io.write_yaml(old_stories, new_stories_path)
 
-    rasa.model_training.train_core(
+    result = rasa.model_training.train_core(
         "data/test_moodbot/domain.yml",
         str(new_config_path),
         str(new_stories_path),
-        output=output,
+        output=str(output),
         model_to_finetune=trained_moodbot_path,
         finetuning_epoch_fraction=0.2,
     )
 
-    mocked_core_training.assert_called_once()
-    _, kwargs = mocked_core_training.call_args
-    model_to_finetune = kwargs["model_to_finetune"]
-    assert isinstance(model_to_finetune, Agent)
+    storage_dir = tmp_path_factory.mktemp("finetuned model")
+    _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(result))
 
-    ted = model_to_finetune.policy_ensemble.policies[0]
-    assert ted.config[EPOCHS] == 2
-    assert ted.finetune_mode
+    assert metadata.train_schema.nodes["train_TEDPolicy0"].config[EPOCHS] == 2
 
 
 def test_model_finetuning_core_with_default_epochs(
-    tmp_path: Path, monkeypatch: MonkeyPatch, trained_moodbot_path: Text,
+    tmp_path: Path,
+    monkeypatch: MonkeyPatch,
+    trained_moodbot_path: Text,
+    tmp_path_factory: TempPathFactory,
 ):
-    mocked_core_training = mock_core_training(monkeypatch)
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -715,7 +580,7 @@ def test_model_finetuning_core_with_default_epochs(
     new_config_path = tmp_path / "new_config.yml"
     rasa.shared.utils.io.write_yaml(old_config, new_config_path)
 
-    rasa.model_training.train_core(
+    model_name = rasa.model_training.train_core(
         "data/test_moodbot/domain.yml",
         str(new_config_path),
         "data/test_moodbot/data/stories.yml",
@@ -724,19 +589,15 @@ def test_model_finetuning_core_with_default_epochs(
         finetuning_epoch_fraction=2,
     )
 
-    mocked_core_training.assert_called_once()
-    _, kwargs = mocked_core_training.call_args
-    model_to_finetune = kwargs["model_to_finetune"]
+    storage_dir = tmp_path_factory.mktemp("finetuned model")
+    _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(model_name))
 
-    ted = model_to_finetune.policy_ensemble.policies[0]
-    assert ted.config[EPOCHS] == TEDPolicy.defaults[EPOCHS] * 2
+    assert metadata.train_schema.nodes["train_TEDPolicy0"].config[EPOCHS] == 2
 
 
 def test_model_finetuning_core_new_domain_label(
     tmp_path: Path, monkeypatch: MonkeyPatch, trained_moodbot_path: Text,
 ):
-    mocked_core_training = mock_core_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -746,7 +607,7 @@ def test_model_finetuning_core_new_domain_label(
     new_domain_path = tmp_path / "new_domain.yml"
     rasa.shared.utils.io.write_yaml(old_domain, new_domain_path)
 
-    with pytest.raises(SystemExit):
+    with pytest.raises(InvalidConfigException):
         rasa.model_training.train_core(
             domain=str(new_domain_path),
             config="data/test_moodbot/config.yml",
@@ -755,15 +616,10 @@ def test_model_finetuning_core_new_domain_label(
             model_to_finetune=trained_moodbot_path,
         )
 
-    mocked_core_training.assert_not_called()
-
 
 def test_model_finetuning_new_domain_label_stops_all_training(
-    tmp_path: Path, monkeypatch: MonkeyPatch, trained_moodbot_path: Text,
+    tmp_path: Path, trained_moodbot_path: Text,
 ):
-    mocked_core_training = mock_core_training(monkeypatch)
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -772,7 +628,7 @@ def test_model_finetuning_new_domain_label_stops_all_training(
     new_domain_path = tmp_path / "new_domain.yml"
     rasa.shared.utils.io.write_yaml(old_domain, new_domain_path)
 
-    with pytest.raises(SystemExit):
+    with pytest.raises(InvalidConfigException):
         rasa.train(
             domain=str(new_domain_path),
             config="data/test_moodbot/config.yml",
@@ -784,26 +640,15 @@ def test_model_finetuning_new_domain_label_stops_all_training(
             model_to_finetune=trained_moodbot_path,
         )
 
-    mocked_core_training.assert_not_called()
-    mocked_nlu_training.assert_not_called()
-
 
 @pytest.mark.timeout(300, func_only=True)
 @pytest.mark.parametrize("use_latest_model", [True, False])
 def test_model_finetuning_nlu(
     tmp_path: Path,
-    monkeypatch: MonkeyPatch,
     trained_nlu_moodbot_path: Text,
     use_latest_model: bool,
+    tmp_path_factory: TempPathFactory,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
-    mock_interpreter_create = Mock(wraps=Interpreter.create)
-    monkeypatch.setattr(Interpreter, "create", mock_interpreter_create)
-
-    mock_DIET_load = Mock(wraps=DIETClassifier.load)
-    monkeypatch.setattr(DIETClassifier, "load", mock_DIET_load)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -819,11 +664,11 @@ def test_model_finetuning_nlu(
     rasa.shared.utils.io.write_yaml(old_config, new_config_path)
 
     old_nlu = rasa.shared.utils.io.read_yaml_file("data/test_moodbot/data/nlu.yml")
-    old_nlu["nlu"][-1]["examples"] = "-something else"
+    old_nlu["nlu"][-1]["examples"] += "- perfect\n"
     new_nlu_path = tmp_path / "new_nlu.yml"
     rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path)
 
-    rasa.model_training.train_nlu(
+    model_name = rasa.model_training.train_nlu(
         str(new_config_path),
         str(new_nlu_path),
         domain="data/test_moodbot/domain.yml",
@@ -832,26 +677,15 @@ def test_model_finetuning_nlu(
         finetuning_epoch_fraction=0.2,
     )
 
-    assert mock_interpreter_create.call_args[1]["should_finetune"]
-
-    mocked_nlu_training.assert_called_once()
-    _, nlu_train_kwargs = mocked_nlu_training.call_args
-    model_to_finetune = nlu_train_kwargs["model_to_finetune"]
-    assert isinstance(model_to_finetune, Interpreter)
-
-    _, diet_kwargs = mock_DIET_load.call_args
-    assert diet_kwargs["should_finetune"] is True
+    storage_dir = tmp_path_factory.mktemp("finetuned model")
+    _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(model_name))
 
-    new_diet_metadata = model_to_finetune.model_metadata.metadata["pipeline"][-1]
-    assert new_diet_metadata["name"] == "DIETClassifier"
-    assert new_diet_metadata[EPOCHS] == 2
+    assert metadata.train_schema.nodes["train_DIETClassifier5"].config[EPOCHS] == 2
 
 
 def test_model_finetuning_nlu_new_label(
-    tmp_path: Path, monkeypatch: MonkeyPatch, trained_nlu_moodbot_path: Text,
+    tmp_path: Path, trained_nlu_moodbot_path: Text,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -860,7 +694,7 @@ def test_model_finetuning_nlu_new_label(
     new_nlu_path = tmp_path / "new_nlu.yml"
     rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path)
 
-    with pytest.raises(SystemExit):
+    with pytest.raises(InvalidConfigException):
         rasa.model_training.train_nlu(
             "data/test_moodbot/config.yml",
             str(new_nlu_path),
@@ -869,14 +703,10 @@ def test_model_finetuning_nlu_new_label(
             model_to_finetune=trained_nlu_moodbot_path,
         )
 
-    mocked_nlu_training.assert_not_called()
-
 
 def test_model_finetuning_nlu_new_entity(
-    tmp_path: Path, monkeypatch: MonkeyPatch, trained_nlu_moodbot_path: Text,
+    tmp_path: Path, trained_nlu_moodbot_path: Text,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -885,7 +715,7 @@ def test_model_finetuning_nlu_new_entity(
     new_nlu_path = tmp_path / "new_nlu.yml"
     rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path)
 
-    with pytest.raises(SystemExit):
+    with pytest.raises(InvalidConfigException):
         rasa.model_training.train_nlu(
             "data/test_moodbot/config.yml",
             str(new_nlu_path),
@@ -894,19 +724,14 @@ def test_model_finetuning_nlu_new_entity(
             model_to_finetune=trained_nlu_moodbot_path,
         )
 
-    mocked_nlu_training.assert_not_called()
-
 
 def test_model_finetuning_nlu_new_label_already_in_domain(
     tmp_path: Path,
-    monkeypatch: MonkeyPatch,
     trained_rasa_model: Text,
     nlu_data_path: Text,
     config_path: Text,
     domain_path: Text,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -916,7 +741,7 @@ def test_model_finetuning_nlu_new_label_already_in_domain(
     new_nlu_path = tmp_path / "new_nlu.yml"
     rasa.shared.utils.io.write_yaml(old_nlu, new_nlu_path)
 
-    with pytest.raises(SystemExit):
+    with pytest.raises(InvalidConfigException):
         rasa.model_training.train_nlu(
             config_path,
             str(new_nlu_path),
@@ -925,14 +750,10 @@ def test_model_finetuning_nlu_new_label_already_in_domain(
             model_to_finetune=trained_rasa_model,
         )
 
-    mocked_nlu_training.assert_not_called()
-
 
 def test_model_finetuning_nlu_new_label_to_domain_only(
-    tmp_path: Path, monkeypatch: MonkeyPatch, trained_nlu_moodbot_path: Text,
+    tmp_path: Path, trained_nlu_moodbot_path: Text,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -941,7 +762,7 @@ def test_model_finetuning_nlu_new_label_to_domain_only(
     new_domain_path = tmp_path / "new_domain.yml"
     rasa.shared.utils.io.write_yaml(old_domain, new_domain_path)
 
-    rasa.model_training.train_nlu(
+    result = rasa.model_training.train_nlu(
         "data/test_moodbot/config.yml",
         "data/test_moodbot/data/nlu.yml",
         domain=str(new_domain_path),
@@ -949,15 +770,16 @@ def test_model_finetuning_nlu_new_label_to_domain_only(
         model_to_finetune=trained_nlu_moodbot_path,
     )
 
-    mocked_nlu_training.assert_called()
+    assert Path(result).is_file()
 
 
 @pytest.mark.timeout(200, func_only=True)
 def test_model_finetuning_nlu_with_default_epochs(
-    tmp_path: Path, monkeypatch: MonkeyPatch, trained_nlu_moodbot_path: Text,
+    tmp_path: Path,
+    monkeypatch: MonkeyPatch,
+    trained_nlu_moodbot_path: Text,
+    tmp_path_factory: TempPathFactory,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -968,20 +790,18 @@ def test_model_finetuning_nlu_with_default_epochs(
     new_config_path = tmp_path / "new_config.yml"
     rasa.shared.utils.io.write_yaml(old_config, new_config_path)
 
-    rasa.model_training.train_nlu(
+    model_name = rasa.model_training.train_nlu(
         str(new_config_path),
         "data/test_moodbot/data/nlu.yml",
         output=output,
         model_to_finetune=trained_nlu_moodbot_path,
-        finetuning_epoch_fraction=0.1,
+        finetuning_epoch_fraction=0.01,
     )
 
-    mocked_nlu_training.assert_called_once()
-    _, nlu_train_kwargs = mocked_nlu_training.call_args
-    model_to_finetune = nlu_train_kwargs["model_to_finetune"]
-    new_diet_metadata = model_to_finetune.model_metadata.metadata["pipeline"][-1]
-    assert new_diet_metadata["name"] == "DIETClassifier"
-    assert new_diet_metadata[EPOCHS] == DIETClassifier.defaults[EPOCHS] * 0.1
+    storage_dir = tmp_path_factory.mktemp("finetuned model")
+    _, metadata = LocalModelStorage.from_model_archive(storage_dir, Path(model_name))
+
+    assert metadata.train_schema.nodes["train_DIETClassifier5"].config[EPOCHS] == 3
 
 
 @pytest.mark.parametrize("model_to_fine_tune", ["invalid-path-to-model", "."])
@@ -995,9 +815,6 @@ def test_model_finetuning_with_invalid_model(
     model_to_fine_tune: Text,
     capsys: CaptureFixture,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
-    mocked_core_training = mock_core_training(monkeypatch)
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -1012,23 +829,19 @@ def test_model_finetuning_with_invalid_model(
             finetuning_epoch_fraction=1,
         )
 
-    mocked_core_training.assert_not_called()
-    mocked_nlu_training.assert_not_called()
     output = capsys.readouterr().out
-    assert "No NLU model for finetuning found" in output
+    assert "No model for finetuning found" in output
 
 
 @pytest.mark.parametrize("model_to_fine_tune", ["invalid-path-to-model", "."])
 def test_model_finetuning_with_invalid_model_core(
     tmp_path: Path,
-    monkeypatch: MonkeyPatch,
     domain_path: Text,
     stories_path: Text,
     stack_config_path: Text,
     model_to_fine_tune: Text,
     capsys: CaptureFixture,
 ):
-    mocked_core_training = mock_core_training(monkeypatch)
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -1042,9 +855,7 @@ def test_model_finetuning_with_invalid_model_core(
             finetuning_epoch_fraction=1,
         )
 
-    mocked_core_training.assert_not_called()
-
-    assert "No Core model for finetuning found" in capsys.readouterr().out
+    assert "No model for finetuning found" in capsys.readouterr().out
 
 
 @pytest.mark.parametrize("model_to_fine_tune", ["invalid-path-to-model", "."])
@@ -1057,8 +868,6 @@ def test_model_finetuning_with_invalid_model_nlu(
     model_to_fine_tune: Text,
     capsys: CaptureFixture,
 ):
-    mocked_nlu_training = mock_nlu_training(monkeypatch)
-
     (tmp_path / "models").mkdir()
     output = str(tmp_path / "models")
 
@@ -1072,54 +881,96 @@ def test_model_finetuning_with_invalid_model_nlu(
             finetuning_epoch_fraction=1,
         )
 
-    mocked_nlu_training.assert_not_called()
-
-    assert "No NLU model for finetuning found" in capsys.readouterr().out
-
-
-@pytest.mark.parametrize(
-    "result, code, texts_count",
-    [
-        (
-            rasa.model.FingerprintComparisonResult(
-                core=False, nlu=False, nlg=False, force_training=True
-            ),
-            0b1000,
-            1,
-        ),
-        (
-            rasa.model.FingerprintComparisonResult(
-                core=True, nlu=True, nlg=True, force_training=True
-            ),
-            0b1000,
-            1,
-        ),
-        (
-            rasa.model.FingerprintComparisonResult(
-                core=False, nlu=False, nlg=True, force_training=False
-            ),
-            0b0100,
-            1,
-        ),
-        (
-            rasa.model.FingerprintComparisonResult(
-                core=True, nlu=True, nlg=True, force_training=False
-            ),
-            0b0111,
-            3,
-        ),
-        (
-            rasa.model.FingerprintComparisonResult(
-                core=False, nlu=False, nlg=False, force_training=False
-            ),
-            0,
-            1,
-        ),
-    ],
-)
-def test_dry_run_result(
-    result: rasa.model.FingerprintComparisonResult, code: int, texts_count: int,
+    assert "No model for finetuning found" in capsys.readouterr().out
+
+
+def test_models_not_retrained_if_only_new_responses(
+    trained_e2e_model: Text,
+    moodbot_domain_path: Path,
+    e2e_bot_config_file: Path,
+    e2e_stories_path: Text,
+    nlu_data_path: Text,
+    trained_e2e_model_cache: Path,
+    tmp_path: Path,
+):
+    domain = Domain.load(moodbot_domain_path)
+    domain_with_extra_response = """
+    version: '2.0'
+    responses:
+      utter_greet:
+      - text: "Hi from Rasa"
+    """
+
+    new_domain = domain.merge(Domain.from_yaml(domain_with_extra_response))
+    new_domain_path = tmp_path / "domain.yml"
+    rasa.shared.utils.io.write_yaml(new_domain.as_dict(), new_domain_path)
+
+    result = rasa.train(
+        str(new_domain_path),
+        str(e2e_bot_config_file),
+        [e2e_stories_path, nlu_data_path],
+        output=str(tmp_path),
+        dry_run=True,
+    )
+
+    assert result.code == 0
+
+
+def test_models_not_retrained_if_only_new_action(
+    trained_e2e_model: Text,
+    moodbot_domain_path: Path,
+    e2e_bot_config_file: Path,
+    e2e_stories_path: Text,
+    nlu_data_path: Text,
+    trained_e2e_model_cache: Path,
+    tmp_path: Path,
 ):
-    result_code, texts = rasa.model_training.dry_run_result(result)
-    assert result_code == code
-    assert len(texts) == texts_count
+    domain = Domain.load(moodbot_domain_path)
+    domain_with_extra_response = """
+    version: '2.0'
+    responses:
+      utter_greet_new:
+      - text: "Hi from Rasa"
+    """
+
+    new_domain = domain.merge(Domain.from_yaml(domain_with_extra_response))
+    new_domain_path = tmp_path / "domain.yml"
+    rasa.shared.utils.io.write_yaml(new_domain.as_dict(), new_domain_path)
+
+    result = rasa.train(
+        str(new_domain_path),
+        str(e2e_bot_config_file),
+        [e2e_stories_path, nlu_data_path],
+        output=str(tmp_path),
+        dry_run=True,
+    )
+
+    assert result.code == rasa.model_training.CODE_NEEDS_TO_BE_RETRAINED
+
+
+def test_invalid_graph_schema(
+    tmp_path: Path, domain_path: Text, stories_path: Text, nlu_data_path: Text,
+):
+    config = textwrap.dedent(
+        """
+    version: "2.0"
+    recipe: "default.v1"
+    
+    pipeline:
+    - name: WhitespaceTokenizer
+    - name: TEDPolicy
+    """
+    )
+
+    new_config_path = tmp_path / "config.yml"
+    rasa.shared.utils.io.write_yaml(
+        rasa.shared.utils.io.read_yaml(config), new_config_path
+    )
+
+    with pytest.raises(GraphSchemaValidationException):
+        rasa.train(
+            domain_path,
+            str(new_config_path),
+            [stories_path, nlu_data_path],
+            output=str(tmp_path),
+        )
diff --git a/tests/test_server.py b/tests/test_server.py
index 8639b8b5788f..1fa1ebaba931 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -216,7 +216,7 @@ def background_server(
     # Fake training function which blocks until we tell it to stop blocking
     # If we can send a status request while this is blocking, we can be sure that the
     # actual training is also not blocking
-    async def mocked_training_function(*_, **__) -> TrainingResult:
+    def mocked_training_function(*_, **__) -> TrainingResult:
         # Tell the others that we are now blocking
         shared_statuses["started_training"] = True
         # Block until somebody tells us to not block anymore
@@ -229,7 +229,7 @@ def run_server(monkeypatch: MonkeyPatch) -> NoReturn:
         import sys
 
         monkeypatch.setattr(
-            sys.modules["rasa.model_training"], "train_async", mocked_training_function,
+            sys.modules["rasa.model_training"], "train", mocked_training_function,
         )
 
         from rasa import __main__
@@ -613,10 +613,8 @@ async def test_train_with_yaml_with_params(
     fake_model = Path(tmp_path) / "fake_model.tar.gz"
     fake_model.touch()
     fake_model_path = str(fake_model)
-    future = asyncio.Future()
-    future.set_result(TrainingResult(model=fake_model_path))
-    mock_train = Mock(return_value=future)
-    monkeypatch.setattr(rasa.model_training, "train_async", mock_train)
+    mock_train = Mock(return_value=TrainingResult(model=fake_model_path))
+    monkeypatch.setattr(rasa.model_training, "train", mock_train)
 
     training_data = """
 stories: []
diff --git a/tests/utilities.py b/tests/utilities.py
index 6c334f75905e..4b0cc50647c2 100644
--- a/tests/utilities.py
+++ b/tests/utilities.py
@@ -1,3 +1,6 @@
+import filecmp
+from pathlib import Path
+
 from yarl import URL
 
 
diff --git a/tests/utils/test_io.py b/tests/utils/test_io.py
index 28adeab81195..a0b3ff172b8a 100644
--- a/tests/utils/test_io.py
+++ b/tests/utils/test_io.py
@@ -1,6 +1,7 @@
 from pathlib import Path
 from typing import Dict, Text
 import pytest
+from _pytest.tmpdir import TempPathFactory
 from prompt_toolkit.document import Document
 from prompt_toolkit.validation import ValidationError
 
@@ -84,3 +85,69 @@ def test_write_and_load_dict_via_jsonpickle(
     rasa.utils.io.json_pickle(file_name=file_name, obj=input, **kwargs)
     loaded = rasa.utils.io.json_unpickle(file_name=file_name, **kwargs)
     assert loaded == expected
+
+
+def test_empty_directories_are_equal(tmp_path_factory: TempPathFactory):
+    dir1 = tmp_path_factory.mktemp("dir1")
+    dir2 = tmp_path_factory.mktemp("dir2")
+
+    assert rasa.utils.io.are_directories_equal(dir1, dir2)
+
+
+def test_directories_are_equal(tmp_path_factory: TempPathFactory):
+    dir1 = tmp_path_factory.mktemp("dir1")
+    (dir1 / "file.txt").write_text("Hello!")
+
+    dir2 = tmp_path_factory.mktemp("dir2")
+    (dir2 / "file.txt").write_text("Hello!")
+
+    assert rasa.utils.io.are_directories_equal(dir1, dir2)
+
+
+def test_directories_are_equal_sub_dir(tmp_path_factory: TempPathFactory,):
+    dir1 = tmp_path_factory.mktemp("dir1")
+    (dir1 / "dir").mkdir()
+    (dir1 / "dir" / "file.txt").write_text("Hello!")
+
+    dir2 = tmp_path_factory.mktemp("dir2")
+    (dir2 / "dir").mkdir()
+    (dir2 / "dir" / "file.txt").write_text("Hello!")
+
+    assert rasa.utils.io.are_directories_equal(dir1, dir2)
+
+
+def test_directories_are_equal_different_file_content(
+    tmp_path_factory: TempPathFactory,
+):
+    dir1 = tmp_path_factory.mktemp("dir1")
+    (dir1 / "file.txt").write_text("Hello!")
+
+    dir2 = tmp_path_factory.mktemp("dir2")
+    (dir2 / "file.txt").write_text("Bye!")
+
+    assert not rasa.utils.io.are_directories_equal(dir1, dir2)
+
+
+def test_directories_are_equal_extra_file(tmp_path_factory: TempPathFactory,):
+    dir1 = tmp_path_factory.mktemp("dir1")
+    (dir1 / "file.txt").write_text("Hello!")
+
+    dir2 = tmp_path_factory.mktemp("dir2")
+    (dir2 / "file.txt").write_text("Hello!")
+    (dir2 / "file2.txt").touch()
+
+    assert not rasa.utils.io.are_directories_equal(dir1, dir2)
+
+
+def test_directories_are_equal_different_file_content_sub_dir(
+    tmp_path_factory: TempPathFactory,
+):
+    dir1 = tmp_path_factory.mktemp("dir1")
+    (dir1 / "dir").mkdir()
+    (dir1 / "dir" / "file.txt").write_text("Hello!")
+
+    dir2 = tmp_path_factory.mktemp("dir2")
+    (dir2 / "dir").mkdir()
+    (dir2 / "dir" / "file.txt").write_text("Bye!")
+
+    assert not rasa.utils.io.are_directories_equal(dir1, dir2)


Code review comments:
diff_hunk: @@ -96,10 +105,97 @@ def __init__(self) -> None:
         """Creates recipe."""
         self._use_core = True
         self._use_nlu = True
+        self._use_end_to_end = True
         self._is_finetuning = False
 
+        from rasa.nlu.classifiers.diet_classifier import (  # noqa: F401
path: rasa/engine/recipes/default_recipe.py
text: @user1:
Is this how we want to do this? Can we group them somehow in a different file?

@author:
Can move them to a registry file in the recipes package 👍🏻 

@author:
done

diff_hunk: @@ -15,10 +15,22 @@
 from rasa.engine.storage.resource import Resource
 
 from rasa.engine.storage.storage import ModelStorage
-
+from rasa.shared.core.trackers import DialogueStateTracker
+from rasa.shared.exceptions import InvalidConfigException
+from rasa.shared.importers.importer import TrainingDataImporter
 
 logger = logging.getLogger(__name__)
 
+PLACEHOLDER_IMPORTER = "__importer__"
path: rasa/engine/graph.py
text: @user1:
I don't think this should be in  `graph.py` as it's to do with the model etc.. Maybe recipe?

@author:
It's not recipe specific as it's used by the graph trainer / predictions. How about a `constants` file in `rasa.engine`? 

@author:
done