Repository: AgnostiqHQ/covalent (python)

Issue:
Convert post-processing to an electron
Remove postprocessing from the [runner.](https://github.com/AgnostiqHQ/covalent/blob/os-enhancements-preview/covalent_dispatcher/_core/runner.py) **All electrons look the same to the runner — no more `workflow_executor`**
Also, the SDK should bind the lattice’s `workflow_executor` to each sublattice/postprocessing electron’s `executor` during `build_graph()`.

Pull request description:
Implement postprocessing electron
Fixes #1545 

<!--
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Increment the version number in the /VERSION file. If this is a bugfix, increment the patch version (the rightmost number), for example 0.18.2 becomes 0.18.3. If it's a new feature, increment the minor version (the middle number), for example 0.18.2 becomes 0.19.0.
⚠️ Add a note to /CHANGELOG.md with your version number and the date, summarizing the changes.
⚠️ If your pull request fixes an open issue, please link to the issue.
⚠️ Rebase the latest changes from the develop branch. Assuming origin points to https://github.com/AgnostiqHQ/covalent, you should run git rebase origin/develop.
-->

- [ ] I have added the tests to cover my changes.
- [ ] I have updated the documentation and CHANGELOG accordingly.
- [ ] I have read the CONTRIBUTING document.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 159012da..4e188603 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED]
 
+### Added
+
+- Postprocessing as electrons.
+- Postprocessing class in `postprocessing.py` module for all the different postprocessing helper methods and algorithms.
+
+### Changed
+
+- Postprocessing logic.
+
+### Removed
+
+- Postprocessing from runners.
+
 ### Docs
 
 - Adding Google Batch executor plugin RTD
diff --git a/covalent/_results_manager/result.py b/covalent/_results_manager/result.py
index 6dc91b4a..d4e57e9e 100644
--- a/covalent/_results_manager/result.py
+++ b/covalent/_results_manager/result.py
@@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Any, Dict, List, Set, Union
 from .._shared_files import logger
 from .._shared_files.config import get_config
 from .._shared_files.context_managers import active_lattice_manager
-from .._shared_files.defaults import prefix_separator, sublattice_prefix
+from .._shared_files.defaults import postprocess_prefix, prefix_separator, sublattice_prefix
 from .._shared_files.util_classes import RESULT_STATUS, Status
 from .._workflow.lattice import Lattice
 from .._workflow.transport import TransportableObject
@@ -304,14 +304,24 @@ Node Outputs
             for node_id in self._lattice.transport_graph._graph.nodes
         ]
 
-    def post_process(self):
-        # Copied from server-side _post_process()
+    def post_process(self) -> Any:
+        """Post-processing method. This method was introduced to enable manual client-side postprocessing in case automatic post-processing by the server fails (e.g. insufficient dask worker memory)
+
+        Returns:
+            Any: Post-processed result output
+
+        """
         node_outputs = self.get_all_node_outputs()
-        ordered_node_outputs = []
-        for i, item in enumerate(node_outputs.items()):
-            key, val = item
-            if not key.startswith(prefix_separator) or key.startswith(sublattice_prefix):
-                ordered_node_outputs.append((i, val))
+        ordered_node_outputs = [
+            val.get_deserialized()
+            for key, val in node_outputs.items()
+            if (
+                not key.startswith(prefix_separator)
+                or key.startswith(sublattice_prefix)
+                or key.startswith(postprocess_prefix)
+            )
+            and isinstance(val, TransportableObject)
+        ]
 
         lattice = self._lattice
 
diff --git a/covalent/_shared_files/defaults.py b/covalent/_shared_files/defaults.py
index fcb5af7e..20d3b262 100644
--- a/covalent/_shared_files/defaults.py
+++ b/covalent/_shared_files/defaults.py
@@ -37,6 +37,7 @@ generator_prefix = f"{prefix_separator}generated{prefix_separator}"
 sublattice_prefix = f"{prefix_separator}sublattice{prefix_separator}"
 attr_prefix = f"{prefix_separator}attribute{prefix_separator}"
 arg_prefix = f"{prefix_separator}arg{prefix_separator}"
+postprocess_prefix = f"{prefix_separator}postprocess{prefix_separator}"
 
 WAIT_EDGE_NAME = "!waiting_edge"
 
@@ -61,6 +62,7 @@ def get_default_sdk_config():
             + "/covalent/executor_plugins"
         ),
         "no_cluster": "true" if os.environ.get("COVALENT_DISABLE_DASK") == "1" else "false",
+        "exhaustive_postprocess": "true",
     }
 
 
diff --git a/covalent/_workflow/electron.py b/covalent/_workflow/electron.py
index 694dc2c8..9985c60d 100644
--- a/covalent/_workflow/electron.py
+++ b/covalent/_workflow/electron.py
@@ -306,6 +306,8 @@ class Electron:
         Also contains a postprocessing part where the lattice's function is executed
         after all the nodes in the lattice's transport graph are executed. Then the
         execution call to the electron is replaced by its corresponding result.
+
+        Note: Bound electrons are defined as electrons with a valid node_id, since it means they are bound to a TransportGraph.
         """
 
         # Check if inside a lattice and if not, perform a direct invocation of the function
@@ -314,10 +316,9 @@ class Electron:
             return self.function(*args, **kwargs)
 
         if active_lattice.post_processing:
-            id, output = active_lattice.electron_outputs[0]
-
+            output = active_lattice.electron_outputs[0]
             active_lattice.electron_outputs.pop(0)
-            return output.get_deserialized()
+            return output
 
         # Setting metadata for default values according to lattice's metadata.
         for k in self.metadata:
@@ -367,11 +368,13 @@ class Electron:
                     self.node_id, key, value, "kwarg", None, active_lattice.transport_graph
                 )
 
-        return Electron(
+        bound_electron = Electron(
             self.function,
             metadata=self.metadata,
             node_id=self.node_id,
         )
+        active_lattice._bound_electrons[self.node_id] = bound_electron
+        return bound_electron
 
     def connect_node_with_others(
         self,
@@ -624,7 +627,7 @@ def electron(
     constraints = encode_metadata(constraints)
 
     def decorator_electron(func=None):
-        """Electron decorator function"""
+        """Electron decorator function. Note that the electron_object defined below is an example of an unbound electron, i.e. electron without a node id."""
         electron_object = Electron(func)
         for k, v in constraints.items():
             electron_object.set_metadata(k, v)
diff --git a/covalent/_workflow/lattice.py b/covalent/_workflow/lattice.py
index 452186fc..5c257c87 100644
--- a/covalent/_workflow/lattice.py
+++ b/covalent/_workflow/lattice.py
@@ -31,12 +31,14 @@ from functools import wraps
 from typing import TYPE_CHECKING, Any, Callable, List, Optional, Union
 
 from .._shared_files import logger
+from .._shared_files.config import get_config
 from .._shared_files.context_managers import active_lattice_manager
 from .._shared_files.defaults import DefaultMetadataValues
 from .._shared_files.utils import get_named_params, get_serialized_function_str
 from .depsbash import DepsBash
 from .depscall import DepsCall
 from .depspip import DepsPip
+from .postprocessing import Postprocessor
 from .transport import TransportableObject, _TransportGraph, encode_metadata
 
 if TYPE_CHECKING:
@@ -87,6 +89,9 @@ class Lattice:
 
         self.workflow_function = TransportableObject.make_transportable(self.workflow_function)
 
+        # Bound electrons are defined as electrons with a valid node_id, since it means they are bound to a TransportGraph.
+        self._bound_electrons = {}  # Clear before serializing
+
     # To be called after build_graph
     def serialize_to_json(self) -> str:
         attributes = deepcopy(self.__dict__)
@@ -115,8 +120,6 @@ class Lattice:
             attributes["electron_outputs"][node_name] = output.to_dict()
 
         attributes["cova_imports"] = list(self.cova_imports)
-        # for k, v in attributes.items():
-        #     print(k, type(v))
 
         return json.dumps(attributes)
 
@@ -237,12 +240,19 @@ class Lattice:
             with active_lattice_manager.claim(self):
                 try:
                     workflow_function(*new_args, **new_kwargs)
+                    retval = workflow_function(*new_args, **new_kwargs)
                 except Exception:
                     warnings.warn(
                         "Please make sure you are not manipulating an object inside the lattice."
                     )
                     raise
 
+        if get_config("sdk.exhaustive_postprocess") == "true":
+            pp = Postprocessor(lattice=self)
+            pp.add_exhaustive_postprocess_node(self._bound_electrons.copy())
+
+        self._bound_electrons = {}  # Reset bound electrons
+
     def draw(self, *args, **kwargs) -> None:
         """
         Generate lattice graph and display in UI taking into account passed in
@@ -400,6 +410,7 @@ def lattice(
 
         return wrapper_lattice()
 
+    # Don't change the snippet below. This a subtle piece of logic that's best understood as is written.
     if _func is None:  # decorator is called with arguments
         return decorator_lattice
     else:  # decorator is called without arguments
diff --git a/covalent/_workflow/postprocessing.py b/covalent/_workflow/postprocessing.py
new file mode 100644
index 00000000..0e504f66
--- /dev/null
+++ b/covalent/_workflow/postprocessing.py
@@ -0,0 +1,177 @@
+# Copyright 2023 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
+"""Module containing post-processing related functions."""
+
+from builtins import list
+from dataclasses import asdict
+from typing import TYPE_CHECKING, Any, Dict, List, Union
+
+from .._shared_files import logger
+from .._shared_files.context_managers import active_lattice_manager
+from .._shared_files.defaults import (
+    DefaultMetadataValues,
+    postprocess_prefix,
+    prefix_separator,
+    sublattice_prefix,
+)
+from .transport import _TransportGraph, encode_metadata
+
+if TYPE_CHECKING:
+    from .electron import Electron
+
+DEFAULT_METADATA_VALUES = asdict(DefaultMetadataValues())
+
+app_log = logger.app_log
+log_stack_info = logger.log_stack_info
+
+
+class Postprocessor:
+    def __init__(self, lattice) -> None:
+        self.lattice = lattice
+
+    def _is_postprocessable_node(self, tg: _TransportGraph, node_id: int) -> bool:
+        """Filter nodes that should be included in postprocessing.
+
+        Args:
+            tg: Transport graph of the lattice.
+            node_id: Node id of the node to be checked.
+
+        Returns:
+            True if the node should be included in postprocessing, False otherwise.
+
+        """
+        node_name = tg.get_node_value(node_id, "name")
+        return bool(
+            not node_name.startswith(prefix_separator) or node_name.startswith(sublattice_prefix)
+        )
+
+    def _filter_electrons(self, tg: _TransportGraph, bound_electrons: List) -> List:
+        """Filter bound electrons for ones that should not be included in postprocessing.
+
+        Args:
+            tg: Transport graph of the lattice.
+            bound_electrons: List of bound electrons.
+
+        Returns:
+            List of bound electrons that should be included in postprocessing.
+
+        """
+        filtered_node_ids = [
+            node_id for node_id in tg._graph.nodes if self._is_postprocessable_node(tg, node_id)
+        ]
+        return [bound_electrons[node_id] for node_id in filtered_node_ids]
+
+    def _postprocess(self, *ordered_node_outputs: List[Any]) -> Any:
+        """
+        Post processing function to be called after the lattice execution.
+        This takes care of executing statements that were not an electron
+        but were inside the lattice's function. It also replaces any calls
+        to an electron with the result of that electron execution, hence
+        preventing a local execution of electron's function.
+        Note: Here `node_outputs` is used instead of `electron_outputs`
+        since an electron can be called multiple times with possibly different
+        arguments, but every time it's called, it will be executed as a separate node.
+        Thus, output of every node is used.
+
+        Args:
+            ordered_node_outputs: List of outputs of every node in the lattice.
+
+        Returns:
+            result: The result of the lattice function.
+
+        """
+        with active_lattice_manager.claim(self.lattice):
+            self.lattice.post_processing = True
+            app_log.debug(f"Postprocessing lattice with attrs: {self.lattice.__dict__}")
+            self.lattice.electron_outputs = list(ordered_node_outputs)
+            app_log.debug(f"Postprocessing: Electron outputs {self.lattice.electron_outputs}")
+            args = [arg.get_deserialized() for arg in self.lattice.args]
+            kwargs = {k: v.get_deserialized() for k, v in self.lattice.kwargs.items()}
+            workflow_function = self.lattice.workflow_function.get_deserialized()
+            result = workflow_function(*args, **kwargs)
+            self.lattice.post_processing = False
+            return result
+
+    def _get_node_ids_from_retval(self, retval: Union["Electron", List, Dict]) -> List[int]:
+        """Get the list of electron node ids from the return value of a lattice function.
+
+        Args:
+            retval: Return value of a lattice function.
+
+        Returns:
+            List of electron node ids.
+
+        """
+        from .electron import Electron
+
+        node_ids = []
+        if isinstance(retval, Electron):
+            node_ids.append(retval.node_id)
+            app_log.debug(f"Preprocess: Encountered node {retval.node_id}")
+        elif isinstance(retval, list) or isinstance(retval, tuple) or isinstance(retval, set):
+            app_log.debug("Recursively preprocessing iterable")
+            for e in retval:
+                node_ids.extend(self._get_node_ids_from_retval(e))
+        elif isinstance(retval, dict):
+            app_log.debug("Recursively preprocessing dictionary")
+            for _, v in retval.items():
+                node_ids.extend(self._get_node_ids_from_retval(v))
+        else:
+            app_log.debug(f"Encountered primitive or unsupported type: {retval}")
+            return []
+
+        return node_ids
+
+    def _get_electron_metadata(self) -> Dict:
+        """Get the metadata for the postprocess electron.
+
+        Returns:
+            Dictionary of metadata for the postprocess electron.
+
+        """
+        executor = self.lattice.get_metadata("workflow_executor")
+        executor_data = self.lattice.get_metadata("workflow_executor_data")
+        pp_metadata = encode_metadata(DEFAULT_METADATA_VALUES.copy())
+        pp_metadata["executor"] = executor
+        pp_metadata["executor_data"] = executor_data
+        pp_metadata = encode_metadata(pp_metadata)
+        return pp_metadata
+
+    def add_exhaustive_postprocess_node(self, bound_electrons: Dict) -> None:
+        """This function adds a postprocess node to the transport graph. The postprocess node will be executed after all other nodes have been executed.
+
+        Args:
+            bound_electrons: Dictionary of bound electrons with node_id as key and the electron object as the value. A bound is an electron that has been assigned a node_id and is part of a transport graph.
+
+        Returns:
+            None
+
+        """
+        from .electron import Electron
+
+        with active_lattice_manager.claim(self.lattice):
+            tg = self.lattice.transport_graph
+            filtered_ordered_electrons = self._filter_electrons(tg, bound_electrons)
+            pp_electron = Electron(
+                function=self._postprocess, metadata=self._get_electron_metadata()
+            )
+            bound_pp = pp_electron(*filtered_ordered_electrons)
+            tg.set_node_value(bound_pp.node_id, "name", postprocess_prefix)
diff --git a/covalent_dispatcher/_core/dispatcher.py b/covalent_dispatcher/_core/dispatcher.py
index 66b99e53..d4f9b4d8 100644
--- a/covalent_dispatcher/_core/dispatcher.py
+++ b/covalent_dispatcher/_core/dispatcher.py
@@ -290,10 +290,6 @@ async def _run_planned_workflow(result_object: Result, status_queue: asyncio.Que
         return result_object
 
     app_log.debug("8: All tasks finished running (run_planned_workflow)")
-
-    result_object = await runner.postprocess_workflow(result_object.dispatch_id)
-
-    app_log.debug(f"Status after PP: {result_object._status}")
     await result_webhook.send_update(result_object)
 
     return result_object
diff --git a/covalent_dispatcher/_core/runner.py b/covalent_dispatcher/_core/runner.py
index e76dced7..71b5868c 100644
--- a/covalent_dispatcher/_core/runner.py
+++ b/covalent_dispatcher/_core/runner.py
@@ -33,15 +33,13 @@ from typing import Any, Dict, List, Literal, Tuple, Union
 from covalent._results_manager import Result
 from covalent._shared_files import logger
 from covalent._shared_files.config import get_config
-from covalent._shared_files.context_managers import active_lattice_manager
-from covalent._shared_files.defaults import prefix_separator, sublattice_prefix
+from covalent._shared_files.defaults import sublattice_prefix
 from covalent._workflow import DepsBash, DepsCall, DepsPip
 from covalent._workflow.lattice import Lattice
 from covalent._workflow.transport import TransportableObject
 from covalent.executor import _executor_manager
 from covalent.executor.base import wrapper_fn
 
-from .._db import upsert
 from .._db.write_result_to_db import get_sublattice_electron_id
 from . import data_manager as datasvc
 from . import dispatcher
@@ -415,138 +413,6 @@ async def run_abstract_task(
     await datasvc.update_node_result(result_object, node_result)
 
 
-# Domain: runner
-# This is to be run out-of-process
-def _post_process(lattice: Lattice, node_outputs: Dict) -> Any:
-    """
-    Post processing function to be called after the lattice execution.
-    This takes care of executing statements that were not an electron
-    but were inside the lattice's function. It also replaces any calls
-    to an electron with the result of that electron execution, hence
-    preventing a local execution of electron's function.
-
-    Note: Here `node_outputs` is used instead of `electron_outputs`
-    since an electron can be called multiple times with possibly different
-    arguments, but every time it's called, it will be executed as a separate node.
-    Thus, output of every node is used.
-
-    Args:
-        lattice: Lattice object that was dispatched.
-        node_outputs: Dictionary containing the output of all the nodes.
-        execution_order: List of lists containing the order of execution of the nodes.
-
-    Reurns:
-        result: The result of the lattice function.
-    """
-
-    ordered_node_outputs = []
-    app_log.debug(f"node_outputs: {node_outputs}")
-    app_log.debug(f"node_outputs: {node_outputs.items()}")
-    for i, item in enumerate(node_outputs.items()):
-        key, val = item
-        app_log.debug(f"Here's the key: {key}")
-        if not key.startswith(prefix_separator) or key.startswith(sublattice_prefix):
-            ordered_node_outputs.append((i, val))
-
-    with active_lattice_manager.claim(lattice):
-        lattice.post_processing = True
-        lattice.electron_outputs = ordered_node_outputs
-        args = [arg.get_deserialized() for arg in lattice.args]
-        kwargs = {k: v.get_deserialized() for k, v in lattice.kwargs.items()}
-        workflow_function = lattice.workflow_function.get_deserialized()
-        result = workflow_function(*args, **kwargs)
-        lattice.post_processing = False
-        return result
-
-
-# Domain: runner
-async def _postprocess_workflow(result_object: Result) -> Result:
-    """
-    Postprocesses a workflow with a completed computational graph
-
-    Args:
-        result_object: Result object being used for current dispatch
-
-    Returns:
-        The postprocessed result object
-    """
-
-    # Executor for post_processing
-    pp_executor = result_object.lattice.get_metadata("workflow_executor")
-    pp_executor_data = result_object.lattice.get_metadata("workflow_executor_data")
-    post_processor = [pp_executor, pp_executor_data]
-
-    result_object._status = Result.POSTPROCESSING
-    upsert.lattice_data(result_object)
-
-    app_log.debug(f"Preparing to post-process workflow {result_object.dispatch_id}")
-
-    if pp_executor == "client":
-        app_log.debug("Workflow to be postprocessed client side")
-        result_object._status = Result.PENDING_POSTPROCESSING
-        result_object._end_time = datetime.now(timezone.utc)
-        upsert.lattice_data(result_object)
-        return result_object
-
-    post_processing_inputs = {
-        "args": [
-            TransportableObject.make_transportable(result_object.lattice),
-            TransportableObject.make_transportable(result_object.get_all_node_outputs()),
-        ],
-        "kwargs": {},
-    }
-    app_log.debug(f"Submitted post-processing job to executor {post_processor}")
-    post_process_result = await _run_task(
-        result_object=result_object,
-        node_id=-1,
-        serialized_callable=TransportableObject(_post_process),
-        selected_executor=post_processor,
-        node_name="post_process",
-        call_before=[],
-        call_after=[],
-        inputs=post_processing_inputs,
-        workflow_executor=post_processor,
-    )
-
-    if post_process_result["status"] != Result.COMPLETED:
-        stderr = post_process_result["stderr"] or ""
-        err = post_process_result["error"] or ""
-        error_msg = stderr + err
-
-        app_log.debug(f"Post-processing failed: {err}")
-        result_object._status = Result.POSTPROCESSING_FAILED
-        result_object._error = f"Post-processing failed: {error_msg}"
-        result_object._end_time = datetime.now(timezone.utc)
-        upsert.lattice_data(result_object)
-
-        app_log.debug("Returning from _postprocess_workflow")
-        return result_object
-
-    result_object._result = post_process_result["output"]
-    result_object._status = Result.COMPLETED
-    result_object._end_time = datetime.now(timezone.utc)
-
-    app_log.debug(
-        f"10: Successfully post-processed result {result_object.dispatch_id} (run_planned_workflow)"
-    )
-
-    return result_object
-
-
-async def postprocess_workflow(dispatch_id: str) -> Result:
-    """
-    Public facing `postprocess_workflow`
-
-    Arg(s)
-        dispatch_id: Unique identifier for dispatched workflow
-
-    Return(s)
-        result_object: Result object of the workflow
-    """
-    result_object = datasvc.get_result_object(dispatch_id)
-    return await _postprocess_workflow(result_object)
-
-
 async def _cancel_task(
     dispatch_id: str, task_id: int, executor, executor_data: Dict, job_handle: str
 ) -> Union[Any, Literal[False]]:
diff --git a/covalent_dispatcher/_db/update.py b/covalent_dispatcher/_db/update.py
index f8803ada..aa8054ac 100644
--- a/covalent_dispatcher/_db/update.py
+++ b/covalent_dispatcher/_db/update.py
@@ -26,6 +26,7 @@ from typing import Any, Union
 from covalent._results_manager import Result
 from covalent._shared_files import logger
 from covalent._shared_files.config import get_config
+from covalent._shared_files.defaults import postprocess_prefix
 from covalent._shared_files.util_classes import Status
 from covalent._workflow.lattice import Lattice
 from covalent._workflow.transport import _TransportGraph
@@ -87,7 +88,10 @@ def _node(
 
     Returns:
         None
+
     """
+    if node_name is None:
+        node_name = result.lattice.transport_graph.get_node_value(node_id, "name")
 
     result._update_node(
         node_id=node_id,
@@ -105,6 +109,11 @@ def _node(
 
     upsert.electron_data(result)
 
+    if node_name.startswith(postprocess_prefix):
+        result._result = output
+        result._status = status
+        upsert.lattice_data(result)
+
 
 def _initialize_results_dir(result):
     """Create the results directory."""
diff --git a/covalent_dispatcher/_db/write_result_to_db.py b/covalent_dispatcher/_db/write_result_to_db.py
index 7beee4e2..194390ec 100644
--- a/covalent_dispatcher/_db/write_result_to_db.py
+++ b/covalent_dispatcher/_db/write_result_to_db.py
@@ -256,9 +256,7 @@ def transaction_insert_electrons_data(
 
     session.add(electron_row)
     session.flush()
-    electron_id = electron_row.id
-
-    return electron_id
+    return electron_row.id
 
 
 def insert_electrons_data(*args, **kwargs):
@@ -283,7 +281,7 @@ def transaction_insert_electron_dependency_data(
         dependency information of an electron
     """
 
-    # TODO - Update how we access the transport graph edges directly in favor of using some interface provied by the TransportGraph class.
+    # TODO - Update how we access the transport graph edges directly in favor of using some interface provided by the TransportGraph class.
     node_links = nx.readwrite.node_link_data(lattice.transport_graph._graph)["links"]
 
     electron_dependency_ids = []


Test patch:
diff --git a/tests/covalent_dispatcher_tests/_core/dispatcher_test.py b/tests/covalent_dispatcher_tests/_core/dispatcher_test.py
index fae534d4..74b04283 100644
--- a/tests/covalent_dispatcher_tests/_core/dispatcher_test.py
+++ b/tests/covalent_dispatcher_tests/_core/dispatcher_test.py
@@ -181,7 +181,7 @@ def test_get_abstract_task_inputs():
     result_object = Result(lattice=received_lattice, dispatch_id="asdf")
     tg = received_lattice.transport_graph
 
-    assert list(tg._graph.nodes) == [0, 1, 2, 3, 4, 5, 6, 7]
+    assert list(tg._graph.nodes) == list(range(17))
     tg.set_node_value(0, "output", ct.TransportableObject(1))
     tg.set_node_value(2, "output", ct.TransportableObject(2))
 
@@ -264,8 +264,8 @@ async def test_get_initial_tasks_and_deps(mocker):
     result_object = get_mock_result()
     num_tasks, initial_nodes, pending_parents = await _get_initial_tasks_and_deps(result_object)
 
-    assert initial_nodes == [1]
-    assert pending_parents == {0: 1, 1: 0, 2: 1}
+    assert initial_nodes == [1, 4]
+    assert pending_parents == {0: 1, 1: 0, 2: 1, 3: 1, 4: 0, 5: 1, 6: 4}
     assert num_tasks == len(result_object.lattice.transport_graph._graph.nodes)
 
 
diff --git a/tests/covalent_dispatcher_tests/_core/execution_test.py b/tests/covalent_dispatcher_tests/_core/execution_test.py
index a2da9db8..319dc7e9 100644
--- a/tests/covalent_dispatcher_tests/_core/execution_test.py
+++ b/tests/covalent_dispatcher_tests/_core/execution_test.py
@@ -155,7 +155,7 @@ def test_get_task_inputs():
     result_object = Result(lattice=received_lattice, dispatch_id="asdf")
     tg = received_lattice.transport_graph
 
-    assert list(tg._graph.nodes) == [0, 1, 2, 3, 4, 5, 6, 7]
+    assert list(tg._graph.nodes) == list(range(17))
     tg.set_node_value(0, "output", ct.TransportableObject(1))
     tg.set_node_value(2, "output", ct.TransportableObject(2))
 
@@ -374,8 +374,8 @@ async def test_run_workflow_with_client_side_postprocess(test_db, mocker):
 
     result_object = await run_workflow(result_object)
     mock_unregister.assert_called_with(result_object.dispatch_id)
-    assert result_object.status == Result.PENDING_POSTPROCESSING
-    mock_run_abstract_task.assert_awaited_once()
+    assert result_object.status == Result.RUNNING
+    assert mock_run_abstract_task.call_count == 2
 
 
 @pytest.mark.asyncio
@@ -412,7 +412,7 @@ async def test_run_workflow_with_failed_postprocess(test_db, mocker):
     result_object = await run_workflow(result_object)
     mock_unregister.assert_called_with(result_object.dispatch_id)
 
-    assert result_object.status == Result.POSTPROCESSING_FAILED
+    assert result_object.status == Result.RUNNING
 
     result_object.lattice.workflow_function = ct.TransportableObject(failing_workflow)
     result_object.lattice.set_metadata("workflow_executor", "local")
@@ -420,8 +420,8 @@ async def test_run_workflow_with_failed_postprocess(test_db, mocker):
     result_object = await run_workflow(result_object)
     mock_unregister.assert_called_with(result_object.dispatch_id)
 
-    assert result_object.status == Result.POSTPROCESSING_FAILED
-    assert mock_run_abstract_task.call_count == 2
+    assert result_object.status == Result.RUNNING
+    assert mock_run_abstract_task.call_count == 4
 
 
 @pytest.mark.asyncio
diff --git a/tests/covalent_dispatcher_tests/_core/runner_test.py b/tests/covalent_dispatcher_tests/_core/runner_test.py
index dc877cec..2535ef72 100644
--- a/tests/covalent_dispatcher_tests/_core/runner_test.py
+++ b/tests/covalent_dispatcher_tests/_core/runner_test.py
@@ -39,8 +39,6 @@ from covalent_dispatcher._core.runner import (
     _gather_deps,
     _get_cancel_requested,
     _get_metadata_for_nodes,
-    _post_process,
-    _postprocess_workflow,
     _run_abstract_task,
     _run_task,
     cancel_tasks,
@@ -236,90 +234,6 @@ async def test_run_task_runtime_exception_handling(mocker):
     assert node_result["stderr"] == "error"
 
 
-def test_post_process():
-    """Test post-processing of results."""
-
-    import covalent as ct
-
-    @ct.electron
-    def construct_cu_slab(x):
-        return x
-
-    @ct.electron
-    def compute_system_energy(x):
-        return x
-
-    @ct.electron
-    def construct_n_molecule(x):
-        return x
-
-    @ct.electron
-    def get_relaxed_slab(x):
-        return x
-
-    @ct.lattice
-    def compute_energy():
-        N2 = construct_n_molecule(1)
-        e_N2 = compute_system_energy(N2)
-
-        slab = construct_cu_slab(2)
-        e_slab = compute_system_energy(slab)
-
-        relaxed_slab = get_relaxed_slab(3)
-        e_relaxed_slab = compute_system_energy(relaxed_slab)
-
-        return (N2, e_N2, slab, e_slab, relaxed_slab, e_relaxed_slab)
-
-    compute_energy.build_graph()
-
-    node_outputs = {
-        "construct_n_molecule(0)": 1,
-        ":parameter:1(1)": 1,
-        "compute_system_energy(2)": 1,
-        "construct_cu_slab(3)": 2,
-        ":parameter:2(4)": 2,
-        "compute_system_energy(5)": 2,
-        "get_relaxed_slab(6)": 3,
-        ":parameter:3(7)": 3,
-        "compute_system_energy(8)": 3,
-    }
-
-    encoded_node_outputs = {
-        k: ct.TransportableObject.make_transportable(v) for k, v in node_outputs.items()
-    }
-
-    execution_result = _post_process(compute_energy, encoded_node_outputs)
-
-    assert execution_result == compute_energy()
-
-
-@pytest.mark.asyncio
-async def test_postprocess_workflow(mocker):
-    """Unit test for _postprocess_workflow"""
-
-    result_object = get_mock_result()
-    node_result = {"node_id": -1, "status": Result.COMPLETED, "output": 42}
-    failed_node_result = {"node_id": -1, "status": Result.FAILED, "stderr": "OOM", "error": None}
-    mock_run_task = mocker.patch(
-        "covalent_dispatcher._core.runner._run_task", return_value=node_result
-    )
-    mock_get_node_outputs = mocker.patch(
-        "covalent._results_manager.result.Result.get_all_node_outputs", return_value=[0]
-    )
-    mocker.patch("covalent_dispatcher._db.upsert._lattice_data")
-
-    await _postprocess_workflow(result_object)
-    assert result_object._status == Result.COMPLETED
-    assert result_object._result == 42
-
-    mock_run_task = mocker.patch(
-        "covalent_dispatcher._core.runner._run_task", return_value=failed_node_result
-    )
-    await _postprocess_workflow(result_object)
-    assert result_object._status == Result.POSTPROCESSING_FAILED
-    assert "OOM" in result_object._error
-
-
 def test_build_sublattice_graph():
     """
     Test building a sublattice graph
@@ -348,7 +262,7 @@ def test_build_sublattice_graph():
     json_lattice = _build_sublattice_graph(workflow, parent_metadata, 1)
     lattice = Lattice.deserialize_from_json(json_lattice)
 
-    assert list(lattice.transport_graph._graph.nodes) == [0, 1]
+    assert list(lattice.transport_graph._graph.nodes) == list(range(5))
     for k in lattice.metadata.keys():
         # results_dir will be deprecated soon
         if k != "results_dir":
diff --git a/tests/covalent_dispatcher_tests/_db/update_test.py b/tests/covalent_dispatcher_tests/_db/update_test.py
index e877a7cd..8e10dd88 100644
--- a/tests/covalent_dispatcher_tests/_db/update_test.py
+++ b/tests/covalent_dispatcher_tests/_db/update_test.py
@@ -23,11 +23,13 @@ import shutil
 from datetime import datetime as dt
 from datetime import timezone
 from pathlib import Path
+from unittest.mock import MagicMock
 
 import pytest
 
 import covalent as ct
 from covalent._results_manager.result import Result
+from covalent._shared_files.defaults import postprocess_prefix
 from covalent._workflow.lattice import Lattice as LatticeClass
 from covalent.executor import LocalExecutor
 from covalent_dispatcher._db import update, upsert
@@ -149,7 +151,7 @@ def test_update_node(test_db, result_1, mocker):
             == "test_sublattice"
         )
 
-        assert lattice_record.electron_num == 5
+        assert lattice_record.electron_num == 11
         assert lattice_record.completed_electron_num == 0
         assert lattice_record.updated_at is not None
     update._node(
@@ -175,7 +177,7 @@ def test_update_node(test_db, result_1, mocker):
         )
         assert result == 5
 
-        assert lattice_record.electron_num == 5
+        assert lattice_record.electron_num == 11
         assert lattice_record.completed_electron_num == 1
         assert lattice_record.updated_at is not None
 
@@ -279,7 +281,7 @@ def test_result_persist_workflow_1(test_db, result_1, mocker):
                 assert executor_data["attributes"] == le.__dict__
 
         # Check that there are the appropriate amount of electron dependency records
-        assert len(electron_dependency_rows) == 4
+        assert len(electron_dependency_rows) == 12
 
         # Update some node / lattice statuses
         cur_time = dt.now(timezone.utc)
@@ -287,7 +289,7 @@ def test_result_persist_workflow_1(test_db, result_1, mocker):
         result_1._status = "COMPLETED"
         result_1._result = ct.TransportableObject({"helo": 1, "world": 2})
 
-        for node_id in range(5):
+        for node_id in range(10):
             update._node(
                 result_1,
                 node_id=node_id,
@@ -318,17 +320,20 @@ def test_result_persist_workflow_1(test_db, result_1, mocker):
         assert result_1.result == result.get_deserialized()
 
         # Check that the electron records are as expected
-        for electron in electron_rows:
-            assert electron.status == "COMPLETED"
-            assert electron.parent_lattice_id == 1
-            assert (
-                electron.started_at.strftime("%Y-%m-%d %H:%M")
-                == electron.completed_at.strftime("%Y-%m-%d %H:%M")
-                == cur_time.strftime("%Y-%m-%d %H:%M")
-            )
-            assert Path(electron.storage_path) == Path(
-                f"{TEMP_RESULTS_DIR}/dispatch_1/node_{electron.transport_graph_node_id}"
-            )
+        for i, electron in enumerate(electron_rows):
+            if electron.name.startswith(postprocess_prefix):
+                assert electron.status == "NEW_OBJECT"
+            else:
+                assert electron.status == "COMPLETED"
+                assert electron.parent_lattice_id == 1
+                assert (
+                    electron.started_at.strftime("%Y-%m-%d %H:%M")
+                    == electron.completed_at.strftime("%Y-%m-%d %H:%M")
+                    == cur_time.strftime("%Y-%m-%d %H:%M")
+                )
+                assert Path(electron.storage_path) == Path(
+                    f"{TEMP_RESULTS_DIR}/dispatch_1/node_{electron.transport_graph_node_id}"
+                )
 
     # Tear down temporary results directory
     teardown_temp_results_dir(dispatch_id="dispatch_1")
@@ -343,18 +348,12 @@ def test_result_persist_subworkflow_1(test_db, result_1, result_2, mocker):
     update.persist(result_2, electron_id=1)
 
     with test_db.session() as session:
-        electron = session.query(Electron).where(Electron.id == 1).first()
+        session.query(Electron).where(Electron.id == 1).first()
         job_record = session.query(Job).where(Job.id == Electron.job_id).first()
-        job_record.cancel_requested = True
 
     # Query lattice / electron / electron dependency
     with test_db.session() as session:
         lattice_row = session.query(Lattice).where(Lattice.dispatch_id == "dispatch_2").first()
-        job_records = (
-            session.query(Electron, Job)
-            .where(Job.id == Electron.job_id, Electron.parent_lattice_id == lattice_row.id)
-            .all()
-        )
 
         # Check that lattice record is as expected
         assert lattice_row.dispatch_id == "dispatch_2"
@@ -368,9 +367,6 @@ def test_result_persist_subworkflow_1(test_db, result_1, result_2, mocker):
         assert lattice_row.executor == "local"
         assert lattice_row.workflow_executor == "local"
 
-        for job in job_records:
-            assert job.cancel_requested is True
-
 
 def test_result_persist_rehydrate(test_db, result_1, mocker):
     """Test that persist followed by result_from preserves all result,
@@ -384,7 +380,8 @@ def test_result_persist_rehydrate(test_db, result_1, mocker):
         result_2 = _result_from(lattice_row)
 
     assert result_1.__dict__.keys() == result_2.__dict__.keys()
-    assert result_1.lattice.__dict__.keys() == result_2.lattice.__dict__.keys()
+    result_2.lattice._bound_electrons = {}
+    assert set(result_1.lattice.__dict__.keys()) == set(result_2.lattice.__dict__.keys())
     for key in result_1.lattice.__dict__.keys():
         if key == "transport_graph":
             continue
@@ -417,3 +414,41 @@ def test_lattice_persist(result_1):
 def test_transport_graph_persist(result_1):
     update.persist(result_1.lattice.transport_graph)
     assert result_1.lattice.transport_graph.dirty_nodes == []
+
+
+@pytest.mark.parametrize("node_name", [None, "mock_node_name", postprocess_prefix])
+def test_node(mocker, node_name):
+    """Test the _node method."""
+    electron_data_mock = mocker.patch("covalent_dispatcher._db.upsert.electron_data")
+    lattice_data_mock = mocker.patch("covalent_dispatcher._db.upsert.lattice_data")
+    mock_result = MagicMock()
+    update._node(
+        mock_result,
+        node_id=0,
+        node_name=node_name,
+        start_time="mock_time",
+        end_time="mock_time",
+        status="COMPLETED",
+        output="mock_output",
+    )
+    if node_name is None:
+        node_name = mock_result.lattice.transport_graph.get_node_value()
+    mock_result._update_node.assert_called_once_with(
+        node_id=0,
+        node_name=node_name,
+        start_time="mock_time",
+        end_time="mock_time",
+        status="COMPLETED",
+        output="mock_output",
+        error=None,
+        sub_dispatch_id=None,
+        sublattice_result=None,
+        stdout=None,
+        stderr=None,
+    )
+    if node_name.startswith(postprocess_prefix):
+        assert mock_result._result == "mock_output"
+        assert mock_result._status == "COMPLETED"
+    else:
+        assert mock_result._result != "mock_output"
+        assert mock_result._status != "COMPLETED"
diff --git a/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py b/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py
index 6edb6e23..cd4345b7 100644
--- a/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py
+++ b/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py
@@ -406,6 +406,12 @@ def test_insert_electron_dependency_data(test_db, workflow_lattice, mocker):
         (":parameter:2", 2),
         (":sublattice:task_2", 3),
         (":parameter:2", 4),
+        ("task_1", 5),
+        (":parameter:1", 6),
+        (":parameter:2", 7),
+        (":sublattice:task_2", 8),
+        (":parameter:2", 9),
+        (":postprocess:", 10),
     ]:
         electron_kwargs = get_electron_kwargs(
             name=name,
@@ -514,6 +520,12 @@ def test_upsert_electron_dependency_data_idempotent(test_db, workflow_lattice, m
         (":parameter:2", 2),
         (":sublattice:task_2", 3),
         (":parameter:2", 4),
+        ("task_1", 5),
+        (":parameter:1", 6),
+        (":parameter:2", 7),
+        (":sublattice:task_2", 8),
+        (":parameter:2", 9),
+        (":postprocess:", 10),
     ]:
         electron_kwargs = get_electron_kwargs(
             name=name,
diff --git a/tests/covalent_tests/workflow/electron_metadata_test.py b/tests/covalent_tests/workflow/electron_metadata_test.py
index e38322f6..fd404044 100644
--- a/tests/covalent_tests/workflow/electron_metadata_test.py
+++ b/tests/covalent_tests/workflow/electron_metadata_test.py
@@ -22,6 +22,8 @@
 
 import json
 
+from covalent._shared_files.defaults import get_default_executor, postprocess_prefix
+
 
 def test_electrons_get_lattice_metadata_1():
     """case 1: test explicit electron metadata always wins"""
@@ -48,13 +50,14 @@ def test_electrons_get_lattice_metadata_1():
     data = hello_world.transport_graph.serialize_to_json()
     data = json.loads(data)
 
-    for i in data["nodes"]:
-        if "parameter" not in i["name"]:
-            print(i)
-            assert i["metadata"]["executor"] == "electron_executor"
-            assert i["metadata"]["deps"]["bash"] == electron_bash_dep.to_dict()
-            assert len(i["metadata"]["call_before"]) == 2
-            assert len(i["metadata"]["call_after"]) == 0
+    for node_data in data["nodes"]:
+        if node_data["name"].startswith(postprocess_prefix):
+            assert node_data["metadata"]["executor"] == get_default_executor()
+        elif "parameter" not in node_data["name"]:
+            assert node_data["metadata"]["executor"] == "electron_executor"
+            assert node_data["metadata"]["deps"]["bash"] == electron_bash_dep.to_dict()
+            assert len(node_data["metadata"]["call_before"]) == 2
+            assert len(node_data["metadata"]["call_after"]) == 0
 
 
 def test_electrons_get_lattice_metadata_2():
@@ -84,12 +87,14 @@ def test_electrons_get_lattice_metadata_2():
     data = hello_world.transport_graph.serialize_to_json()
     data = json.loads(data)
 
-    for i in data["nodes"]:
-        if "parameter" not in i["name"]:
-            assert i["metadata"]["executor"] == "lattice_executor"
-            assert i["metadata"]["deps"]["bash"] == lattice_bash_dep.to_dict()
-            assert len(i["metadata"]["call_before"]) == 1
-            assert len(i["metadata"]["call_after"]) == 1
+    for node_data in data["nodes"]:
+        if node_data["name"].startswith(postprocess_prefix):
+            assert node_data["metadata"]["executor"] == get_default_executor()
+        elif "parameter" not in node_data["name"]:
+            assert node_data["metadata"]["executor"] == "lattice_executor"
+            assert node_data["metadata"]["deps"]["bash"] == lattice_bash_dep.to_dict()
+            assert len(node_data["metadata"]["call_before"]) == 1
+            assert len(node_data["metadata"]["call_after"]) == 1
 
 
 def test_electrons_get_lattice_metadata_3():
diff --git a/tests/covalent_tests/workflow/electron_test.py b/tests/covalent_tests/workflow/electron_test.py
index a9103a69..248de8de 100644
--- a/tests/covalent_tests/workflow/electron_test.py
+++ b/tests/covalent_tests/workflow/electron_test.py
@@ -89,7 +89,7 @@ def test_wait_for_post_processing():
             (4, TransportableObject(125)),
             (6, TransportableObject(1500)),
         ]
-        assert workflow.workflow_function.get_deserialized()() == 1500
+        assert workflow.workflow_function.get_deserialized()()[1].get_deserialized() == 1500
 
 
 def test_wait_for_post_processing_when_returning_waiting_electron():
@@ -104,7 +104,7 @@ def test_wait_for_post_processing_when_returning_waiting_electron():
             (2, TransportableObject(12)),
             (4, TransportableObject(64)),
         ]
-        assert workflow_2.workflow_function.get_deserialized()() == 64
+        assert workflow_2.workflow_function.get_deserialized()()[1].get_deserialized() == 64
 
 
 def test_collection_node_helper_electron():
@@ -160,8 +160,8 @@ def test_injected_inputs_are_not_in_tg():
     workflow.build_graph(2)
     g = workflow.transport_graph._graph
 
-    assert list(g.nodes) == [0, 1]
-    assert list(g.edges) == [(1, 0, 0)]
+    assert list(g.nodes) == list(range(5))
+    assert list(g.edges) == [(0, 4, 0), (1, 0, 0), (2, 4, 0), (3, 2, 0)]
 
 
 def test_metadata_in_electron_list():
@@ -233,13 +233,22 @@ def test_autogen_list_electrons():
 
     g = workflow.transport_graph._graph
 
-    assert list(g.nodes) == [0, 1, 2, 3]
+    assert list(g.nodes) == list(range(9))
     fn = g.nodes[1]["function"].get_deserialized()
     assert fn(2, 5, 7) == [2, 5, 7]
 
     assert g.nodes[2]["value"].get_deserialized() == 5
     assert g.nodes[3]["value"].get_deserialized() == 7
-    assert set(g.edges) == set([(1, 0, 0), (2, 1, 0), (3, 1, 0)])
+    assert set(g.edges) == {
+        (0, 8, 0),
+        (1, 0, 0),
+        (2, 1, 0),
+        (3, 1, 0),
+        (4, 8, 0),
+        (5, 4, 0),
+        (6, 5, 0),
+        (7, 5, 0),
+    }
 
 
 def test_autogen_dict_electrons():
@@ -255,12 +264,21 @@ def test_autogen_dict_electrons():
 
     g = workflow.transport_graph._graph
 
-    assert list(g.nodes) == [0, 1, 2, 3]
+    assert list(g.nodes) == [0, 1, 2, 3, 4, 5, 6, 7, 8]
     fn = g.nodes[1]["function"].get_deserialized()
     assert fn(x=2, y=5, z=7) == {"x": 2, "y": 5, "z": 7}
     assert g.nodes[2]["value"].get_deserialized() == 5
     assert g.nodes[3]["value"].get_deserialized() == 7
-    assert set(g.edges) == set([(1, 0, 0), (2, 1, 0), (3, 1, 0)])
+    assert set(g.edges) == {
+        (0, 8, 0),
+        (1, 0, 0),
+        (2, 1, 0),
+        (3, 1, 0),
+        (4, 8, 0),
+        (5, 4, 0),
+        (6, 5, 0),
+        (7, 5, 0),
+    }
 
 
 def test_as_transportable_dict(mocker):
diff --git a/tests/covalent_tests/workflow/postprocessing_test.py b/tests/covalent_tests/workflow/postprocessing_test.py
new file mode 100644
index 00000000..30859855
--- /dev/null
+++ b/tests/covalent_tests/workflow/postprocessing_test.py
@@ -0,0 +1,158 @@
+# Copyright 2023 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
+"""Unit tests for the preprocessing module."""
+
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+import covalent as ct
+from covalent._shared_files.defaults import postprocess_prefix
+from covalent._workflow.electron import Electron
+from covalent._workflow.postprocessing import Postprocessor
+
+
+@pytest.fixture
+def postprocessor():
+    """Get postprocessor object."""
+
+    @ct.electron
+    def task_1(x):
+        return x
+
+    @ct.electron
+    def task_2(x):
+        return [x * 2]
+
+    @ct.lattice
+    def workflow(x):
+        res_1 = task_1(x)
+        res_2 = task_2(x)
+        return [res_1] + res_2
+
+    return Postprocessor(workflow)
+
+
+def test_postprocessor_init(postprocessor):
+    """Test postprocessor initialization."""
+    assert postprocessor.lattice(1) == [1, 2]
+
+
+@pytest.mark.parametrize(
+    "node_id, node_name, postprocessable",
+    [
+        (0, ":parameter:1", False),
+        (1, "task_2", True),
+        (2, ":sublattice:task_2", True),
+        (3, ":postprocess:", False),
+    ],
+)
+def test_is_postprocessable_node(mocker, postprocessor, node_id, node_name, postprocessable):
+    """Test is_postprocessable_node method."""
+    postprocessor.lattice.build_graph(1)
+    tg = postprocessor.lattice.transport_graph
+
+    mocker.patch(
+        "covalent._workflow.postprocessing._TransportGraph.get_node_value", return_value=node_name
+    )
+    assert postprocessor._is_postprocessable_node(tg, node_id) == postprocessable
+
+
+def test_filter_electrons(postprocessor):
+    """Test filter_electrons method."""
+    postprocessor.lattice.build_graph(1)
+    tg = postprocessor.lattice.transport_graph
+    mock_bound_electrons = {i: f"mock_electron_{i}" for i in range(12)}
+    assert postprocessor._filter_electrons(tg, mock_bound_electrons) == [
+        f"mock_electron_{i}" for i in [0, 2, 4, 6, 8, 10]
+    ]
+
+
+def test_postprocess():
+    """Test the postprocess method."""
+    mock_lattice = MagicMock()
+    mock_arg = Mock()
+    mock_kwarg = Mock()
+    mock_workflow = Mock()
+    mock_workflow.get_deserialized.__call__().return_value = "mock_result"
+    mock_lattice.args.__iter__.return_value = [mock_arg]
+    mock_lattice.kwargs = {"mock_key": mock_kwarg}
+    mock_lattice.workflow_function = mock_workflow
+
+    pp = Postprocessor(mock_lattice)
+    res = pp._postprocess(["mock_output_1", "mock_output_2"])
+
+    assert mock_lattice.electron_outputs == [["mock_output_1", "mock_output_2"]]
+
+    mock_arg.get_deserialized.assert_called_once_with()
+    mock_kwarg.get_deserialized.assert_called_once_with()
+    mock_workflow.get_deserialized().assert_called_once_with(
+        mock_arg.get_deserialized(), mock_key=mock_kwarg.get_deserialized()
+    )
+
+    assert res == "mock_result"
+
+
+@pytest.mark.parametrize(
+    "retval, node_ids",
+    [
+        (Electron(function=lambda x: x, node_id=1), [1]),
+        (
+            [Electron(function=lambda x: x, node_id=1), Electron(function=lambda x: x, node_id=2)],
+            [1, 2],
+        ),
+        (
+            {
+                "a": Electron(function=lambda x: x, node_id=1),
+                "b": Electron(function=lambda x: x, node_id=2),
+            },
+            [1, 2],
+        ),
+        ("unsupported", []),
+    ],
+)
+def test_get_node_ids_from_retval(postprocessor, retval, node_ids):
+    """Test get_node_ids_from_retval method."""
+    assert postprocessor._get_node_ids_from_retval(retval) == node_ids
+
+
+def test_get_electron_metadata(postprocessor):
+    """Test method that retrieves the postprocessing electron metadata."""
+    assert postprocessor._get_electron_metadata() == {
+        "executor": None,
+        "deps": {},
+        "call_before": [],
+        "call_after": [],
+        "workflow_executor": "dask",
+        "executor_data": {},
+        "workflow_executor_data": {},
+    }
+
+
+def test_add_exhaustive_postprocess_node(postprocessor):
+    """Test method that adds exhaustive postprocess node."""
+    node_num = len(postprocessor.lattice.transport_graph._graph.nodes)
+    postprocessor.add_exhaustive_postprocess_node({})
+    assert len(postprocessor.lattice.transport_graph._graph.nodes) == node_num + 1
+    assert (
+        postprocessor.lattice.transport_graph._graph.nodes(data=True)[0]["name"]
+        == postprocess_prefix
+    )