Repository: DARMA-tasking/LB-analysis-framework

Issue:
Support LB iterations
This is related to [vt#2375](https://github.com/DARMA-tasking/vt/issues/2375)

@lifflander if you want to add something

In the JSON for a phase:

```json
"phases":
  [
     {
       "id": ...,
       "tasks": ...,
       "communications": ...,
       "lb_iterations": [
         {
           "id": ...,
           "tasks": ...,
           "communications": ...
         }
       ]
     }
  ]
```

Discussion:

Patch:
diff --git a/config/conf.yaml b/config/conf.yaml
index 95824c4..1abb23b 100644
--- a/config/conf.yaml
+++ b/config/conf.yaml
@@ -9,11 +9,12 @@ check_schema: false
 work_model:
   name: AffineCombination
   parameters:
-    alpha: 0.0
-    beta: 1.0
+    alpha: 1.0
+    beta: 0.0
     gamma: 0.0
 
 # Specify algorithm
+brute_force_optimization: true
 algorithm:
   name: InformAndTransfer
   phase_id: 0
@@ -35,14 +36,4 @@ write_JSON:
   suffix: json
   communications: true
   offline_LB_compatible: false
-# visualization:
-#   x_ranks: 2
-#   y_ranks: 2
-#   z_ranks: 1
-#   object_jitter: 0.5
-#   rank_qoi: load
-#   object_qoi: load
-#   save_meshes: true
-#   force_continuous_object_qoi: true
-#   output_visualization_dir: ../output
-#   output_visualization_file_stem: output_file
+  lb_iterations: true
diff --git a/config/synthetic-blocks.yaml b/config/synthetic-blocks.yaml
index a82f884..2f66d0c 100644
--- a/config/synthetic-blocks.yaml
+++ b/config/synthetic-blocks.yaml
@@ -14,6 +14,7 @@ work_model:
     gamma: 0.0
 
 # Specify algorithm
+brute_force_optimization: true
 algorithm:
   name: InformAndTransfer
   phase_id: 0
@@ -43,7 +44,7 @@ visualization:
   output_visualization_file_stem: output_file
 
 write_JSON:
-  compressed: False
+  compressed: false
   suffix: json
-  communications: True
-  offline_LB_compatible: True
+  communications: true
+  offline_LB_compatible: true
diff --git a/src/lbaf/Applications/LBAF_app.py b/src/lbaf/Applications/LBAF_app.py
index cb900fa..027a220 100644
--- a/src/lbaf/Applications/LBAF_app.py
+++ b/src/lbaf/Applications/LBAF_app.py
@@ -198,13 +198,12 @@ class InternalParameters:
                 raise SystemExit(1) from e
 
             # Retrieve optional parameters
-            self.json_params[
-                "json_output_suffix"] = wrt_json.get("suffix", "json")
-            self.json_params[
-                "communications"] = wrt_json.get("communications", False)
-            self.json_params[
-                "offline_LB_compatible"] = wrt_json.get(
-                "offline_LB_compatible", False)
+            for k_out, k_wrt, v_def in [
+                    ("json_output_suffix", "suffix", "json"),
+                    ("communications", "communications", False),
+                    ("offline_LB_compatible", "offline_LB_compatible", False),
+                    ("lb_iterations", "lb_iterations", False)]:
+                self.json_params[k_out] = wrt_json.get(k_wrt, v_def)
 
     def check_parameters(self):
         """Checks after initialization."""
@@ -412,7 +411,7 @@ class LBAFApplication:
         # Return rank load and work statistics
         return l_stats, w_stats
 
-    def __print_QOI(self) -> int:  # pylint:disable=C0103:invalid-name
+    def __print_qoi(self) -> int:
         """Print list of implemented QOI based on the '-verbosity' command line argument."""
         verbosity = int(self.__args.verbose)
 
@@ -463,7 +462,7 @@ class LBAFApplication:
             self.__parse_args()
 
             # Print list of implemented QOI (according to verbosity argument)
-            self.__print_QOI()
+            self.__print_qoi()
 
             # Warn if default configuration is used because not set as argument
             if self.__args.configuration is None:
@@ -568,8 +567,7 @@ class LBAFApplication:
             objects = initial_phase.get_objects()
             alpha, beta, gamma = [
                 self.__parameters.work_model.get("parameters", {}).get(k)
-                for k in ("alpha", "beta", "gamma")
-            ]
+                for k in ("alpha", "beta", "gamma")]
             _n_a, _w_min_max, a_min_max = lbstats.compute_min_max_arrangements_work(
                 objects, alpha, beta, gamma, n_ranks, logger=self.__logger)
         else:
@@ -581,16 +579,16 @@ class LBAFApplication:
             self.__parameters.work_model,
             self.__parameters.algorithm,
             a_min_max,
-            self.__logger,
-            self.__parameters.rank_qoi if self.__parameters.rank_qoi is not None else '',
-            self.__parameters.object_qoi if self.__parameters.object_qoi is not None else '')
+            self.__logger)
 
         # Execute runtime for specified phases
-        offline_LB_compatible = self.__parameters.json_params.get(  # pylint:disable=C0103:invalid-name;not lowercase
+        offline_LB_compatible = self.__parameters.json_params.get(
             "offline_LB_compatible", False)
+        lb_iterations = self.__parameters.json_params.get(
+            "lb_iterations", False)
         rebalanced_phase = runtime.execute(
             self.__parameters.algorithm.get("phase_id", 0),
-            offline_LB_compatible)
+            1 if offline_LB_compatible else 0)
 
         # Instantiate phase to VT file writer when requested
         if self.__json_writer:
@@ -619,7 +617,8 @@ class LBAFApplication:
                     f"Writing all ({len(phases)}) phases for offline load-balancing")
                 self.__json_writer.write(phases)
             else:
-                self.__logger.info(f"Writing single phase {phase_id} to JSON files")
+                # Add new phase when load balancing when offline mode not selected
+                self.__logger.info(f"Creating rebalanced phase {phase_id}")
                 self.__json_writer.write(
                     {phase_id: rebalanced_phase})
 
diff --git a/src/lbaf/Execution/lbsAlgorithmBase.py b/src/lbaf/Execution/lbsAlgorithmBase.py
index 28ee5a1..4ec087e 100644
--- a/src/lbaf/Execution/lbsAlgorithmBase.py
+++ b/src/lbaf/Execution/lbsAlgorithmBase.py
@@ -58,13 +58,11 @@ class AlgorithmBase:
     _work_model: WorkModelBase
     _logger: Logger
 
-    def __init__(self, work_model: WorkModelBase, parameters: dict, logger: Logger, rank_qoi: str, object_qoi: str):
+    def __init__(self, work_model: WorkModelBase, parameters: dict, logger: Logger):
         """Class constructor.
 
         :param work_model: a WorkModelBase instance
         :param parameters: a dictionary of parameters
-        :param rank_qoi: rank QOI to track
-        :param object_qoi: object QOI to track.
         """
         # Assert that a logger instance was passed
         if not isinstance(logger, Logger):
@@ -83,40 +81,18 @@ class AlgorithmBase:
             self._logger.error("Could not create an algorithm without a dictionary of parameters")
             raise SystemExit(1)
 
-        # Assert that quantity of interest names are string
-        if rank_qoi and not isinstance(rank_qoi, str):
-            self._logger.error("Could not create an algorithm with non-string rank QOI name")
-            raise SystemExit(1)
-        self.__rank_qoi = rank_qoi
-        if object_qoi and not isinstance(object_qoi, str):
-            self._logger.error("Could not create an algorithm with non-string object QOI name")
-            raise SystemExit(1)
-        self.__object_qoi = object_qoi
-        self._logger.info(
-            f"Created base algorithm tracking rank {rank_qoi} and object {object_qoi}")
-
         # Initially no phase is assigned for processing
         self._rebalanced_phase = None
 
         # Save the initial communications data
         self._initial_communications = {}
 
-        # Map global statistical QOIs to their computation methods
+        # Map rank statistics to their respective computation methods
         self.__statistics = {
             ("ranks", lambda x: x.get_load()): {
-                "minimum load": "minimum",
-                "maximum load": "maximum",
-                "load variance": "variance",
-                "load imbalance": "imbalance"},
-            ("largest_volumes", lambda x: x): {
-                "number of communication edges": "cardinality",
-                "maximum largest directed volume": "maximum",
-                "total largest directed volume": "sum"},
-            ("ranks", lambda x: self._work_model.compute(x)): { #pylint:disable=W0108
-                "minimum work": "minimum",
-                "maximum work": "maximum",
-                "total work": "sum",
-                "work variance": "variance"}}
+                "maximum load": "maximum"},
+            ("ranks", lambda x: self._work_model.compute(x)): {
+                "total work": "sum"}}
 
     def get_rebalanced_phase(self):
         """Return phased assigned for processing by algoritm."""
@@ -131,9 +107,7 @@ class AlgorithmBase:
         algorithm_name:str,
         parameters: dict,
         work_model: WorkModelBase,
-        logger: Logger,
-        rank_qoi: str,
-        object_qoi:str):
+        logger: Logger):
         """Instantiate the necessary concrete algorithm."""
         # Load up available algorithms
         # pylint:disable=W0641:possibly-unused-variable,C0415:import-outside-toplevel
@@ -148,90 +122,22 @@ class AlgorithmBase:
         try:
             # Instantiate and return object
             algorithm = locals()[algorithm_name + "Algorithm"]
-            return algorithm(work_model, parameters, logger, rank_qoi, object_qoi)
+            return algorithm(work_model, parameters, logger)
         except Exception as e:
             # Otherwise, error out
             logger.error(f"Could not create an algorithm with name {algorithm_name}")
             raise SystemExit(1) from e
 
-    def _update_distributions_and_statistics(self, distributions: dict, statistics: dict):
-        """Compute and update run distributions and statistics."""
-        # Create or update distributions of object quantities of interest
-        for object_qoi_name in tuple({"load", self.__object_qoi}):
-            if not object_qoi_name:
-                continue
-            try:
-                distributions.setdefault(f"object {object_qoi_name}", []).append(
-                    {o.get_id(): getattr(o, f"get_{object_qoi_name}")()
-                    for o in self._rebalanced_phase.get_objects()})
-            except AttributeError as err:
-                self.__print_QOI("obj")
-                self._logger.error(f"Invalid object_qoi name '{object_qoi_name}'")
-                raise SystemExit(1) from err
-
-        # Create or update distributions of rank quantities of interest
-        for rank_qoi_name in tuple({"objects", "load", self.__rank_qoi}):
-            if not rank_qoi_name or rank_qoi_name == "work":
-                continue
-            try:
-                distributions.setdefault(f"rank {rank_qoi_name}", []).append(
-                    [getattr(p, f"get_{rank_qoi_name}")()
-                    for p in self._rebalanced_phase.get_ranks()])
-            except AttributeError as err:
-                self.__print_QOI("rank")
-                self._logger.error(f"Invalid rank_qoi name '{rank_qoi_name}'")
-                raise SystemExit(1) from err
-        distributions.setdefault("rank work", []).append(
-            [self._work_model.compute(p) for p in self._rebalanced_phase.get_ranks()])
-
-        # Create or update distributions of edge quantities of interest
-        distributions.setdefault("sent", []).append(dict(
-            self._rebalanced_phase.get_edge_maxima().items()))
-
+    def _update_statistics(self, statistics: dict):
+        """Compute and update run statistics."""
         # Create or update statistics dictionary entries
         for (support, getter), stat_names in self.__statistics.items():
             for k, v in stat_names.items():
+                self._logger.debug(f"Updating {k} statistics for {support}")
                 stats = compute_function_statistics(
                     getattr(self._rebalanced_phase, f"get_{support}")(), getter)
                 statistics.setdefault(k, []).append(getattr(stats, f"get_{v}")())
 
-    def __print_QOI(self,rank_or_obj): # pylint:disable=invalid-name
-        """Print list of implemented QOI when invalid QOI is given."""
-        # Initialize file paths
-        current_path = os.path.abspath(__file__)
-        target_dir = os.path.join(
-            os.path.dirname(os.path.dirname(current_path)), "Model")
-        rank_script_name = "lbsRank.py"
-        object_script_name = "lbsObject.py"
-
-        if rank_or_obj == "rank":
-            # Create list of all Rank QOI (lbsRank.get_*)
-            r_qoi_list = ["work"]
-            with open(os.path.join(target_dir, rank_script_name), 'r', encoding="utf-8") as f:
-                lines = f.readlines()
-                for line in lines:
-                    if line[8:12] == "get_":
-                        r_qoi_list.append(line[12:line.find("(")])
-
-            # Print QOI based on verbosity level
-            self._logger.error("List of all possible Rank QOI:")
-            for r_qoi in r_qoi_list:
-                self._logger.error("\t" + r_qoi)
-
-        if rank_or_obj == "obj":
-            # Create list of all Object QOI (lbsObject.get_*)
-            o_qoi_list = []
-            with open(os.path.join(target_dir, object_script_name), 'r', encoding="utf-8") as f:
-                lines = f.readlines()
-                for line in lines:
-                    if line[8:12] == "get_":
-                        o_qoi_list.append(line[12:line.find("(")])
-
-            # Print QOI based on verbosity level
-            self._logger.error("List of all possible Object QOI:")
-            for o_qoi in o_qoi_list:
-                self._logger.error("\t" + o_qoi)
-
     def _report_final_mapping(self, logger):
         """Report final rank object mapping in debug mode."""
         for rank in self._rebalanced_phase.get_ranks():
@@ -253,7 +159,7 @@ class AlgorithmBase:
                             logger.debug(
                                 f"object {k.get_id()} on rank {k.get_rank_id()}: {v}")
 
-    def _initialize(self, p_id, phases, distributions, statistics):
+    def _initialize(self, p_id, phases, statistics):
         """Factor out pre-execution checks and initalizations."""
         # Ensure that a list with at least one phase was provided
         if not isinstance(phases, dict) or not all(
@@ -286,16 +192,15 @@ class AlgorithmBase:
             f"across {self._rebalanced_phase.get_number_of_ranks()} ranks "
             f"into phase {self._rebalanced_phase.get_id()}")
 
-        # Initialize run distributions and statistics
-        self._update_distributions_and_statistics(distributions, statistics)
+        # Initialize run statistics
+        self._update_statistics(statistics)
 
     @abc.abstractmethod
-    def execute(self, p_id, phases, distributions, statistics, a_min_max):
+    def execute(self, p_id, phases, statistics, a_min_max):
         """Execute balancing algorithm on Phase instance.
 
         :param: p_id: index of phase to be rebalanced (all if equal to _)
         :param: phases: list of Phase instances
-        :param: distributions: dictionary of load-varying variables
         :param: statistics: dictionary of  statistics
         :param: a_min_max: possibly empty list of optimal arrangements.
         """
diff --git a/src/lbaf/Execution/lbsBruteForceAlgorithm.py b/src/lbaf/Execution/lbsBruteForceAlgorithm.py
index 918a614..e9cfa77 100644
--- a/src/lbaf/Execution/lbsBruteForceAlgorithm.py
+++ b/src/lbaf/Execution/lbsBruteForceAlgorithm.py
@@ -51,26 +51,24 @@ from ..IO.lbsStatistics import compute_min_max_arrangements_work
 class BruteForceAlgorithm(AlgorithmBase):
     """A concrete class for the brute force optimization algorithm"""
 
-    def __init__(self, work_model, parameters: dict, lgr: Logger, rank_qoi: str, object_qoi: str):
+    def __init__(self, work_model, parameters: dict, lgr: Logger):
         """Class constructor.
 
         :param work_model: a WorkModelBase instance
         :param parameters: a dictionary of parameters
-        :param rank_qoi: rank QOI to track
-        :param object_qoi: object QOI to track.
         """
         # Call superclass init
-        super().__init__(work_model, parameters, lgr, rank_qoi, object_qoi)
+        super().__init__(work_model, parameters, lgr)
 
         # Assign optional parameters
         self.__skip_transfer = parameters.get("skip_transfer", False)
         self._logger.info(
             f"Instantiated {'with' if self.__skip_transfer else 'without'} transfer stage skipping")
 
-    def execute(self, p_id: int, phases: list, distributions: dict, statistics: dict, _):
+    def execute(self, p_id: int, phases: list, statistics: dict, _):
         """Execute brute force optimization algorithm on phase with index p_id."""
         # Perform pre-execution checks and initializations
-        self._initialize(p_id, phases, distributions, statistics)
+        self._initialize(p_id, phases, statistics)
         self._logger.info("Starting brute force optimization")
         initial_phase = phases[min(phases.keys())]
         phase_ranks = initial_phase.get_ranks()
@@ -113,8 +111,8 @@ class BruteForceAlgorithm(AlgorithmBase):
         # Report on object transfers
         self._logger.info(f"{n_transfers} transfers occurred")
 
-        # Update run distributions and statistics
-        self._update_distributions_and_statistics(distributions, statistics)
+        # Update run statistics
+        self._update_statistics(statistics)
 
         # Report final mapping in debug mode
         self._report_final_mapping(self._logger)
diff --git a/src/lbaf/Execution/lbsCentralizedPrefixOptimizerAlgorithm.py b/src/lbaf/Execution/lbsCentralizedPrefixOptimizerAlgorithm.py
index 9e6e993..c6bd475 100644
--- a/src/lbaf/Execution/lbsCentralizedPrefixOptimizerAlgorithm.py
+++ b/src/lbaf/Execution/lbsCentralizedPrefixOptimizerAlgorithm.py
@@ -50,31 +50,30 @@ from ..IO.lbsStatistics import print_function_statistics
 class CentralizedPrefixOptimizerAlgorithm(AlgorithmBase):
     """ A concrete class for the centralized prefix memory-constrained optimizer"""
 
-    def __init__(self, work_model, parameters: dict, lgr: Logger, qoi_name: str, obj_qoi : str):
+    def __init__(self, work_model, parameters: dict, lgr: Logger):
         """ Class constructor
             work_model: a WorkModelBase instance
-            parameters: a dictionary of parameters
-            qoi_name: a quantity of interest."""
+            parameters: a dictionary of parameters."""
 
         # Call superclass init
-        super().__init__(work_model, parameters, lgr, qoi_name, obj_qoi)
+        super().__init__(work_model, parameters, lgr)
 
         self._do_second_stage = parameters.get("do_second_stage", False)
         self._phase = None
         self._max_shared_ids = None
 
-    def execute(self, p_id: int, phases: list, distributions: dict, statistics: dict, _):
+    def execute(self, p_id: int, phases: list, statistics: dict, _):
         """ Execute centralized prefix memory-constrained optimizer"""
 
         p_id = 0
 
         # Ensure that a list with at least one phase was provided
-        self._initialize(p_id, phases, distributions, statistics)
+        self._initialize(p_id, phases, statistics)
 
         self._phase = self._rebalanced_phase
 
-        # Initialize run distributions and statistics
-        self._update_distributions_and_statistics(distributions, statistics)
+        # Initialize run statistics
+        self._update_statistics(statistics)
 
         # Prepare input data for rank order enumerator
         self._logger.info("Starting optimizer")
@@ -192,8 +191,8 @@ class CentralizedPrefixOptimizerAlgorithm(AlgorithmBase):
                     f"iteration {i + 1} rank work",
                     self._logger)
 
-                # Update run distributions and statistics
-                self._update_distributions_and_statistics(distributions, statistics)
+                # Update run statistics
+                self._update_statistics(statistics)
 
         # Report final mapping in debug mode
         self._report_final_mapping(self._logger)
diff --git a/src/lbaf/Execution/lbsInformAndTransferAlgorithm.py b/src/lbaf/Execution/lbsInformAndTransferAlgorithm.py
index 0b7acfe..14d32a8 100644
--- a/src/lbaf/Execution/lbsInformAndTransferAlgorithm.py
+++ b/src/lbaf/Execution/lbsInformAndTransferAlgorithm.py
@@ -59,18 +59,14 @@ class InformAndTransferAlgorithm(AlgorithmBase):
         self,
         work_model,
         parameters: dict,
-        lgr: Logger,
-        rank_qoi: str,
-        object_qoi: str):
+        lgr: Logger):
         """Class constructor.
 
         :param work_model: a WorkModelBase instance
         :param parameters: a dictionary of parameters
-        :param rank_qoi: rank QOI to track
-        :param object_qoi: object QOI to track.
         """
         # Call superclass init
-        super().__init__(work_model, parameters, lgr, rank_qoi, object_qoi)
+        super().__init__(work_model, parameters, lgr)
 
         # Retrieve mandatory integer parameters
         self.__n_iterations = parameters.get("n_iterations")
@@ -128,7 +124,7 @@ class InformAndTransferAlgorithm(AlgorithmBase):
         # Process the message
         self.__known_peers[r_rcv].update(m.get_support())
 
-    def __forward_message(self, i: int, r_snd: Rank, f:int):
+    def __forward_message(self, i: int, r_snd: Rank, f: int):
         """Forward information message to rank peers sampled from known ones."""
         # Make rank aware of itself
         if r_snd not in self.__known_peers:
@@ -218,10 +214,10 @@ class InformAndTransferAlgorithm(AlgorithmBase):
         self._logger.info(
             f"Average number of peers known to ranks: {n_k} ({100 * n_k / n_r:.2f}% of {n_r})")
 
-    def execute(self, p_id: int, phases: list, distributions: dict, statistics: dict, a_min_max):
+    def execute(self, p_id: int, phases: list, statistics: dict, a_min_max):
         """ Execute 2-phase information+transfer algorithm on Phase with index p_id."""
         # Perform pre-execution checks and initializations
-        self._initialize(p_id, phases, distributions, statistics)
+        self._initialize(p_id, phases, statistics)
         print_function_statistics(
             self._rebalanced_phase.get_ranks(),
             self._work_model.compute,
@@ -244,8 +240,6 @@ class InformAndTransferAlgorithm(AlgorithmBase):
             # Start with information stage
             self.__execute_information_stage()
 
-            print(f"statistics: {statistics}")
-
             # Execute transfer stage
             n_ignored, n_transfers, n_rejects = self.__transfer_strategy.execute(
                 self.__known_peers, self._rebalanced_phase, statistics["average load"], statistics["maximum load"][-1])
@@ -267,15 +261,15 @@ class InformAndTransferAlgorithm(AlgorithmBase):
                 f"iteration {i + 1} rank work",
                 self._logger)
 
-            # Update run distributions and statistics
-            self._update_distributions_and_statistics(distributions, statistics)
+            # Update run statistics
+            self._update_statistics(statistics)
 
             # Compute current arrangement
-            arrangement = tuple(sorted(
+            arrangement = dict(sorted(
                 {o.get_id(): p.get_id()
-                for p in self._rebalanced_phase.get_ranks()
-                for o in p.get_objects()}.values()))
-            self._logger.debug(f"Iteration {i + 1} arrangement: {arrangement}")
+                 for p in self._rebalanced_phase.get_ranks()
+                 for o in p.get_objects()}.items())).values()
+            self._logger.debug(f"Iteration {i + 1} arrangement: {tuple(arrangement)}")
 
             # Report minimum Hamming distance when minimax optimum is available
             if a_min_max:
diff --git a/src/lbaf/Execution/lbsPhaseStepperAlgorithm.py b/src/lbaf/Execution/lbsPhaseStepperAlgorithm.py
index da0a933..ae0e7dc 100644
--- a/src/lbaf/Execution/lbsPhaseStepperAlgorithm.py
+++ b/src/lbaf/Execution/lbsPhaseStepperAlgorithm.py
@@ -48,19 +48,17 @@ from ..IO.lbsStatistics import print_function_statistics
 class PhaseStepperAlgorithm(AlgorithmBase):
     """A concrete class for the phase stepper non-optimzing algorithm."""
 
-    def __init__(self, work_model, parameters: dict, lgr: Logger, rank_qoi: str, object_qoi: str):
+    def __init__(self, work_model, parameters: dict, lgr: Logger):
         """Class constructor
 
         :param work_model: a WorkModelBase instance
         :param parameters: a dictionary of parameters
         :param lgr: logger
-        :param rank_qoi: rank QOI to track
-        :param object_qoi: object QOI to track
         """
         # Call superclass init
-        super().__init__(work_model, parameters, lgr, rank_qoi, object_qoi)
+        super().__init__(work_model, parameters, lgr)
 
-    def execute(self, _, phases: list, distributions: dict, statistics: dict, __):
+    def execute(self, _, phases: list, statistics: dict, __):
         """Steps through all phases."""
 
         # Ensure that a list with at least one phase was provided
@@ -81,9 +79,8 @@ class PhaseStepperAlgorithm(AlgorithmBase):
                 f"phase {p_id} rank works",
                 self._logger)
 
-            # Update run distributions and statistics
-            self._update_distributions_and_statistics(
-                distributions, statistics)
+            # Update run statistics
+            self._update_statistics(statistics)
 
             # Report current mapping in debug mode
             self._report_final_mapping(self._logger)
diff --git a/src/lbaf/Execution/lbsPrescribedPermutationAlgorithm.py b/src/lbaf/Execution/lbsPrescribedPermutationAlgorithm.py
index d32f369..bf95721 100644
--- a/src/lbaf/Execution/lbsPrescribedPermutationAlgorithm.py
+++ b/src/lbaf/Execution/lbsPrescribedPermutationAlgorithm.py
@@ -11,18 +11,14 @@ class PrescribedPermutationAlgorithm(AlgorithmBase):
         self,
         work_model,
         parameters: dict,
-        lgr: Logger,
-        rank_qoi: str,
-        object_qoi: str):
+        lgr: Logger):
         """Class constructor.
 
         :param work_model: a WorkModelBase instance
         :param parameters: a dictionary of parameters
-        :param rank_qoi: rank QOI to track
-        :param object_qoi: object QOI to track.
         """
         # Call superclass init
-        super().__init__(work_model, parameters, lgr, rank_qoi, object_qoi)
+        super().__init__(work_model, parameters, lgr)
 
         # Retrieve mandatory parameters
         self.__permutation = parameters.get("permutation")
@@ -31,10 +27,10 @@ class PrescribedPermutationAlgorithm(AlgorithmBase):
             self._logger.error(f"Incorrect prescribed permutation: {self.__permutation}")
             raise SystemExit(1)
 
-    def execute(self, p_id: int, phases: list, distributions: dict, statistics: dict, a_min_max):
+    def execute(self, p_id: int, phases: list, statistics: dict, a_min_max):
         """ Apply prescribed permutation to phase objects."""
         # Perform pre-execution checks and initializations
-        self._initialize(p_id, phases, distributions, statistics)
+        self._initialize(p_id, phases, statistics)
         objects = self._rebalanced_phase.get_objects()
         if (l_p := len(self.__permutation)) != len(objects):
             self._logger.error(
@@ -75,8 +71,8 @@ class PrescribedPermutationAlgorithm(AlgorithmBase):
             "post-permutation rank work",
             self._logger)
 
-        # Update run distributions and statistics
-        self._update_distributions_and_statistics(distributions, statistics)
+        # Update run statistics
+        self._update_statistics(statistics)
 
         # Report final mapping in debug mode
         self._report_final_mapping(self._logger)
diff --git a/src/lbaf/Execution/lbsRuntime.py b/src/lbaf/Execution/lbsRuntime.py
index 6c0c736..9dfff5a 100644
--- a/src/lbaf/Execution/lbsRuntime.py
+++ b/src/lbaf/Execution/lbsRuntime.py
@@ -50,8 +50,13 @@ from ..IO.lbsStatistics import compute_function_statistics, min_Hamming_distance
 class Runtime:
     """A class to handle the execution of the LBS."""
 
-    def __init__(self, phases: dict, work_model: dict, algorithm: dict, arrangements: list, logger: Logger,
-                rank_qoi: str, object_qoi: str):
+    def __init__(
+            self,
+            phases: dict,
+            work_model: dict,
+            algorithm: dict,
+            arrangements: list,
+            logger: Logger):
         """Class constructor.
 
         :param phases: dictionary of Phase instances
@@ -59,8 +64,6 @@ class Runtime:
         :param algorithm: dictionary with algorithm name and parameters
         :param arrangements: arrangements that minimize maximum work
         :param logger: logger for output messages
-        :param rank_qoi: rank QOI name whose distributions are to be tracked
-        :param object_qoi: object QOI name whose distributions are to be tracked.
         """
         # Assign logger to instance variable
         self.__logger = logger
@@ -88,29 +91,25 @@ class Runtime:
             algorithm.get("name"),
             algorithm.get("parameters", {}),
             self.__work_model,
-            self.__logger,
-            rank_qoi,
-            object_qoi)
+            self.__logger)
         if not self.__algorithm:
             self.__logger.error(
                 f"Could not instantiate an algorithm of type {self.__algorithm}")
             raise SystemExit(1)
 
-        # Initialize run distributions and statistics
+        # Initialize run statistics
         phase_0 = self.__phases[min(self.__phases.keys())]
-        self.__distributions = {}
         l_stats = compute_function_statistics(
             phase_0.get_ranks(),
             lambda x: x.get_load())
         self.__statistics = {"average load": l_stats.get_average()}
 
         # Compute initial arrangement
-        arrangement = tuple(
-            v for _, v in sorted({
-                o.get_id(): p.get_id()
-                for p in phase_0.get_ranks()
-                for o in p.get_objects()}.items()))
-        self.__logger.debug(f"Phase 0 arrangement: {arrangement}")
+        arrangement = dict(sorted(
+            {o.get_id(): p.get_id()
+             for p in phase_0.get_ranks()
+             for o in p.get_objects()}.items())).values()
+        self.__logger.debug(f"Initial arrangement: {tuple(arrangement)}")
 
         # Report minimum Hamming distance when minimax optimum is available
         if self.__a_min_max:
@@ -122,34 +121,27 @@ class Runtime:
         """Return runtime work model."""
         return self.__work_model
 
-    def get_distributions(self):
-        """Return runtime distributions."""
-        return self.__distributions
-
-    def get_statistics(self):
-        """Return runtime statistics."""
-        return self.__statistics
-
-    def execute(self, p_id: int, phase_increment=0):
+    def execute(self, p_id: int, phase_increment: int=0):
         """Execute runtime for single phase with given ID or multiple phases in selected range."""
-        # Execute balancing algorithm
+        # Execute load balancing algorithm
         self.__logger.info(
             f"Executing {type(self.__algorithm).__name__} for "
             + ("all phases" if p_id < 0 else f"phase {p_id}"))
         self.__algorithm.execute(
             p_id,
             self.__phases,
-            self.__distributions,
             self.__statistics,
             self.__a_min_max)
 
         # Retrieve possibly null rebalanced phase and return it
-        if (pp := self.__algorithm.get_rebalanced_phase()):
-            pp.set_id((pp_id := pp.get_id() + phase_increment))
+        if (lbp := self.__algorithm.get_rebalanced_phase()):
+            # Increment rebalanced phase ID as requested
+            lbp.set_id((lbp_id := lbp.get_id() + phase_increment))
 
             # Share communications from original phase with new phase
             initial_communications = self.__algorithm.get_initial_communications()
-            pp.set_communications(initial_communications[p_id])
+            lbp.set_communications(initial_communications[p_id])
+            self.__logger.info(f"Created rebalanced phase {lbp_id}")
 
-            self.__logger.info(f"Created rebalanced phase {pp_id}")
-        return pp
+        # Return rebalanced phase
+        return lbp
diff --git a/src/lbaf/IO/lbsConfigurationValidator.py b/src/lbaf/IO/lbsConfigurationValidator.py
index f5bb555..b78a60e 100644
--- a/src/lbaf/IO/lbsConfigurationValidator.py
+++ b/src/lbaf/IO/lbsConfigurationValidator.py
@@ -136,7 +136,8 @@ class ConfigurationValidator:
                 "compressed": bool,
                 Optional("suffix"): str,
                 Optional("communications"): bool,
-                Optional("offline_LB_compatible"): bool},
+                Optional("offline_LB_compatible"): bool,
+                Optional("lb_iterations"): bool},
         })
         self.__from_data = Schema({
             "data_stem": str,
diff --git a/src/lbaf/IO/lbsStatistics.py b/src/lbaf/IO/lbsStatistics.py
index bd8f859..269f7a7 100644
--- a/src/lbaf/IO/lbsStatistics.py
+++ b/src/lbaf/IO/lbsStatistics.py
@@ -274,16 +274,17 @@ def compute_min_max_arrangements_work(objects: tuple, alpha: float, beta: float,
         if logger is not None:
             logger.info(
                 f"Minimax work: {works_min_max:.4g} for {len(arrangements_min_max)} optimal arrangements"
-                " amongst {n_arrangements}")
+                f" amongst {n_arrangements}")
 
     # Return quantities of interest
     return n_arrangements, works_min_max, arrangements_min_max
 
 
-def compute_pairwise_reachable_arrangements(objects: tuple, arrangement: tuple, alpha: float, beta: float, gamma: float,
-                                            w_max: float, from_id: int, to_id: int, n_ranks: int,
-                                            max_objects: Optional[int] = None, logger: Optional[Logger] = None):
-    """Compute arrangements reachable by moving up to a maximum number of objects from one rank to another."""
+def compute_pairwise_reachable_arrangements(
+        objects: tuple, arrangement: tuple, alpha: float, beta: float, gamma: float,
+        w_max: float, from_id: int, to_id: int, n_ranks: int,
+        max_objects: Optional[int] = None, logger: Optional[Logger] = None):
+    """Compute arrangements reachable by moving up to a given maximum number of objects."""
     # Sanity checks regarding rank IDs
     if from_id >= n_ranks:
         if logger is not None:


Test patch:
diff --git a/tests/unit/Execution/test_lbs_brute_force_algorithm.py b/tests/unit/Execution/test_lbs_brute_force_algorithm.py
index 9ab15f2..7679c96 100644
--- a/tests/unit/Execution/test_lbs_brute_force_algorithm.py
+++ b/tests/unit/Execution/test_lbs_brute_force_algorithm.py
@@ -69,9 +69,7 @@ class TestConfig(unittest.TestCase):
             "max_objects_per_transfer": 8,
             "deterministic_transfer": True
         },
-        lgr=self.logger,
-        rank_qoi=None,
-        object_qoi=None)
+        lgr=self.logger)
 
     self.brute_force = BruteForceAlgorithm(
         work_model=WorkModelBase(),
@@ -84,9 +82,7 @@ class TestConfig(unittest.TestCase):
             "max_objects_per_transfer": 8,
             "deterministic_transfer": True
         },
-        lgr=self.logger,
-        rank_qoi=None,
-        object_qoi=None)
+        lgr=self.logger)
 
   def test_lbs_brute_force_skip_transfer(self):
     assert self.brute_force_skip_transfer._BruteForceAlgorithm__skip_transfer is True
diff --git a/tests/unit/Execution/test_lbs_centralized_prefix_optimizer_algorithm.py b/tests/unit/Execution/test_lbs_centralized_prefix_optimizer_algorithm.py
index aded11d..0cd127c 100644
--- a/tests/unit/Execution/test_lbs_centralized_prefix_optimizer_algorithm.py
+++ b/tests/unit/Execution/test_lbs_centralized_prefix_optimizer_algorithm.py
@@ -66,16 +66,12 @@ class TestConfig(unittest.TestCase):
             parameters={},
             lgr=self.logger)
         parameters = {"do_second_stage": True}
-        qoi_name = "load"
 
         # Create CPOA instance
         self.cpoa = CentralizedPrefixOptimizerAlgorithm(
-                        work_model=work_model,
-                        parameters=parameters,
-                        lgr=self.logger,
-                        qoi_name=qoi_name,
-                        obj_qoi=qoi_name
-        )
+            work_model=work_model,
+            parameters=parameters,
+            lgr=self.logger)
 
         # Set up phase
         self.sentinel_objects = {Object(seq_id=15, load=4.5), Object(seq_id=18, load=2.5)}
@@ -92,9 +88,6 @@ class TestConfig(unittest.TestCase):
         # Create dict of phase(s)
         self.phases = {self.phase.get_id(): self.phase}
 
-        # Set up distributions
-        self.distributions = {}
-
         # Set up statistics
         l_stats = compute_function_statistics(
             self.phase.get_ranks(),
@@ -105,7 +98,6 @@ class TestConfig(unittest.TestCase):
         self.cpoa.execute(
             self.phase.get_id(),
             self.phases,
-            self.distributions,
             self.statistics,
             1
         )
diff --git a/tests/unit/Execution/test_lbs_inform_and_transfer_algorithm.py b/tests/unit/Execution/test_lbs_inform_and_transfer_algorithm.py
index 3b237ec..e250475 100644
--- a/tests/unit/Execution/test_lbs_inform_and_transfer_algorithm.py
+++ b/tests/unit/Execution/test_lbs_inform_and_transfer_algorithm.py
@@ -74,9 +74,7 @@ class TestConfig(unittest.TestCase):
                 "max_objects_per_transfer": 8,
                 "deterministic_transfer": True
             },
-            lgr=self.logger,
-            rank_qoi=None,
-            object_qoi=None)
+            lgr=self.logger)
 
     @patch.object(random, "sample")
     def test_lbs_inform_and_transfer_forward_message(self, random_mock):
@@ -114,4 +112,4 @@ class TestConfig(unittest.TestCase):
         self.assertEqual(known_peers, {self.rank: {self.rank, temp_rank_1}})
 
 if __name__ == "__main__":
-    unittest.main()
\ No newline at end of file
+    unittest.main()
diff --git a/tests/unit/Execution/test_lbs_runtime.py b/tests/unit/Execution/test_lbs_runtime.py
index 3d73b33..a540bf8 100644
--- a/tests/unit/Execution/test_lbs_runtime.py
+++ b/tests/unit/Execution/test_lbs_runtime.py
@@ -93,26 +93,27 @@ class TestConfig(unittest.TestCase):
         n_ranks = 4
         self.arrangements = compute_min_max_arrangements_work(objects, alpha, beta, gamma,
                                                               n_ranks, logger=self.logger)[2]
-        self.rank_qoi = None
-        self.object_qoi = None
         # Initialize the Runtime instances
-        self.runtime = Runtime(self.phases, self.work_model, self.algorithm, self.arrangements, self.logger, self.rank_qoi, self.object_qoi)
+        self.runtime = Runtime(
+            self.phases,
+            self.work_model,
+            self.algorithm,
+            self.arrangements,
+            self.logger)
 
     def test_lbs_runtime_get_work_model(self):
         self.assertEqual(self.runtime.get_work_model().__class__, AffineCombinationWorkModel)
 
     def test_lbs_runtime_no_phases(self):
         with self.assertRaises(SystemExit) as context:
-            runtime = Runtime(None, self.work_model, self.algorithm, self.arrangements, self.logger, self.rank_qoi, self.object_qoi)
+            runtime = Runtime(
+                None,
+                self.work_model,
+                self.algorithm,
+                self.arrangements,
+                self.logger)
         self.assertEqual(context.exception.code, 1)
 
-    def test_lbs_runtime_get_distributions(self):
-        assert isinstance(self.runtime.get_distributions(), dict)
-
-    def test_lbs_runtime_get_statistics(self):
-        # Testing lbsStats in a separate unit test; just make sure it returns a dict
-        assert isinstance(self.runtime.get_statistics(), dict)
-
     def test_lbs_runtime_execute(self):
         # Ensure execute method works as expected
         p_id = 0  # Provide a valid phase ID