Repository: XanaduAI/strawberryfields

Issue:
Add equality comparison for Programs
This is a really cumbersome way of checking the ~equality~ equivalence of two programs --- furthermore, it doesn't take into account programs with the same DAG but different temporal operations list.

A useful feature would be to overload the `Program.__eq__(self, other)` special method, in order to perform the `nx.is_isomorphic` check (similarly to that done in `CircuitSpecs.compile`, but also taking into account parameter values).

That would allow us to simply do `assert prog1 == prog2` in the tests.

It would also help provide useful tracebacks, by indicating which gates and/or parameters differ

_Originally posted by @josh146 in https://github.com/XanaduAI/strawberryfields/pull/127_


Discussion:
This has now been coded up in `tests/frontend/test_circuitspecs_chip0.py` (see below). The next steps are to:

1. Move this function over to `program_utils` and write tests

2. Write an additional function, `program_equality`, which is stricter, and returns `True` if and only if both programs contain the identical commands in the identical order (by looping through `cmd1, cmd2 in zip(prog1.circuit, prog2.circuit)`).

```python

def program_equivalence(prog1, prog2, compare_params=True, atol=1e-6, rtol=0):
    r"""Checks if two programs are equivalent.

    This function converts the program lists into directed acyclic graphs,
    and runs the NetworkX `is_isomorphic` graph function in order
    to determine if the two programs are equivalent.
    Note: when checking for parameter equality between two parameters
    :math:`a` and :math:`b`, we use the following formula:
    .. math:: |a - b| \leq (\texttt{atol} + \texttt{rtol}\times|b|)

    Args:
        prog1 (strawberryfields.program.Program): quantum program
        prog2 (strawberryfields.program.Program): quantum program
        compare_params (bool): Set to ``False`` to turn of comparing
            program parameters; equivalency will only take into
            account the operation order.
        atol (float): the absolute tolerance parameter for checking
            quantum operation parameter equality
        rtol (float): the relative tolerance parameter for checking
            quantum operation parameter equality

    Returns:
        bool: returns ``True`` if two quantum programs are equivalent
    """
    DAG1 = list_to_DAG(prog1.circuit)
    DAG2 = list_to_DAG(prog2.circuit)

    circuit = []
    for G in [DAG1, DAG2]:
        # relabel the DAG nodes to integers
        circuit.append(nx.convert_node_labels_to_integers(G))

        # add node attributes to store the operation name and parameters
        name_mapping = {i: n.op.__class__.__name__ for i, n in enumerate(G.nodes())}
        parameter_mapping = {i: [j.x for j in n.op.p] for i, n in enumerate(G.nodes())}

        # CXgate and BSgate are not symmetric wrt to permuting the order of the two
        # modes it acts on; i.e., the order of the wires matter
        wire_mapping = {}
        for i, n in enumerate(G.nodes()):
            if n.op.__class__.__name__ == "CXgate":
                if np.allclose(n.op.p[0], 0):
                    # if the CXgate parameter is 0, wire order doesn't matter
                    wire_mapping[i] = 0
                else:
                    # if the CXgate parameter is not 0, order matters
                    wire_mapping[i] = [j.ind for j in n.reg]

            elif n.op.__class__.__name__ == "BSgate":
                if np.allclose([j.x % np.pi for j in n.op.p], [np.pi/4, np.pi/2]):
                    # if the beamsplitter is *symmetric*, then the order of the
                    # wires does not matter.
                    wire_mapping[i] = 0
                else:
                    # beamsplitter is not symmetric, order matters
                    wire_mapping[i] = [j.ind for j in n.reg]

            else:
                # not a CXgate or a BSgate, order of wires doesn't matter
                wire_mapping[i] = 0

        # TODO: at the moment, we do not check for whether an empty
        # wire will match an operation with trivial parameters.
        # Maybe we can do this in future, but this is a subgraph
        # isomorphism problem and much harder.

        nx.set_node_attributes(circuit[-1], name_mapping, name="name")
        nx.set_node_attributes(circuit[-1], parameter_mapping, name="p")
        nx.set_node_attributes(circuit[-1], wire_mapping, name="w")

    def node_match(n1, n2):
        """Returns True if both nodes have the same name and
        same parameters, within a certain tolerance"""
        name_match = n1["name"] == n2["name"]
        p_match = np.allclose(n1["p"], n2["p"], atol=atol, rtol=rtol)
        wire_match = n1["w"] == n2["w"]

        if compare_params:
            return name_match and p_match and wire_match

        return name_match and wire_match

    # check if circuits are equivalent
    return nx.is_isomorphic(circuit[0], circuit[1], node_match)
```

Resolving patch:
diff --git a/strawberryfields/program.py b/strawberryfields/program.py
--- a/strawberryfields/program.py
+++ b/strawberryfields/program.py
@@ -62,7 +62,7 @@
 from strawberryfields.compilers import Compiler, compiler_db
 import strawberryfields.program_utils as pu
 
-from .program_utils import Command, RegRef, CircuitError, RegRefError
+from .program_utils import Command, RegRef, CircuitError, RegRefError, program_equivalence
 from .parameters import FreeParameter, ParameterError
 
 
@@ -129,6 +129,13 @@ class Program:
 
     The currently active register references can be accessed using the :meth:`~Program.register` method.
 
+    .. note::
+
+            The ``Program`` equality operation (e.g., ``prog_1 == prog_2``) does not account for
+            logically equivalent programs where the order of operations or modes do not matter. It
+            simply checks that the operations and parameters are identically applied. For a more
+            thorough check, use the ``Program.equivalence()`` method instead.
+
     Args:
         num_subsystems (int, Program): Initial number of modes (subsystems) in the quantum register.
             Alternatively, another Program instance from which to inherit the register state.
@@ -203,6 +210,58 @@ def __len__(self):
         """
         return len(self.circuit)
 
+    def __eq__(self, prog):
+        """Equality operator for programs."""
+        # is the targets differ, the programs are not equal
+        if self.target != prog.target:
+            return False
+
+        # if the registers differ, the programs are not equal
+        if self.register != prog.register:
+            return False
+
+        # NOTE: If two programs have been compiled by different compilers (or only one has been
+        # compiled), they are still considered to be equal, even if their targets differ. Similarly,
+        # two programs with different names can still be considered equal.
+
+        for self_cmd, prog_cmd in zip(self.circuit, prog.circuit):
+            names_eq = self_cmd.op.__class__ == prog_cmd.op.__class__
+            param_eq = all(p1 == p2 for p1, p2 in zip(self_cmd.op.p, prog_cmd.op.p))
+            modes_eq = all(m1 == m2 for m1, m2 in zip(self_cmd.reg, prog_cmd.reg))
+
+            if not all((names_eq, param_eq, modes_eq)):
+                return False
+
+        return True
+
+    def equivalence(self, prog, **kwargs):
+        """Checks if two programs are equivalent.
+
+        This function converts the program lists into directed acyclic graphs,
+        and runs the NetworkX ``is_isomorphic`` graph function in order
+        to determine if the two programs are equivalent.
+
+        .. note::
+
+            This method is a convenience method, wrapping the :func:`.program_equivalence`
+            function in the program utils module.
+
+        Args:
+            prog (strawberryfields.program.Program): quantum program to check equivalence with
+
+        Keyword args:
+            compare_params (bool): Set to ``False`` to turn of comparing program parameters;
+                equivalency will only take into account the operation order.
+            atol (float): the absolute tolerance parameter for checking quantum operation
+                parameter equality
+            rtol (float): the relative tolerance parameter for checking quantum operation
+                parameter equality
+
+        Returns:
+            bool: returns ``True`` if two quantum programs are equivalent
+        """
+        return program_equivalence(self, prog, **kwargs)
+
     def print(self, print_fn=print):
         """Print the program contents using Blackbird syntax.
 
diff --git a/strawberryfields/program_utils.py b/strawberryfields/program_utils.py
--- a/strawberryfields/program_utils.py
+++ b/strawberryfields/program_utils.py
@@ -19,8 +19,9 @@
 from collections.abc import Sequence
 
 import networkx as nx
+import numpy as np
 
-from .parameters import MeasuredParameter
+from .parameters import MeasuredParameter, par_evaluate
 
 
 __all__ = [
@@ -397,3 +398,93 @@ def _print_list(i, q, print_fn=print):
     # convert the circuit back into a list (via a DAG)
     DAG = grid_to_DAG(grid)
     return DAG_to_list(DAG)
+
+
+def program_equivalence(prog1, prog2, compare_params=True, atol=1e-6, rtol=0):
+    r"""Checks if two programs are equivalent.
+
+    This function converts the program lists into directed acyclic graphs,
+    and runs the NetworkX `is_isomorphic` graph function in order
+    to determine if the two programs are equivalent.
+
+    .. note::
+
+        When checking for parameter equality between two parameters
+        :math:`a` and :math:`b`, we use the following formula:
+
+        .. math:: |a - b| \leq (\texttt{atol} + \texttt{rtol}\times|b|)
+
+    Args:
+        prog1 (strawberryfields.program.Program): quantum program
+        prog2 (strawberryfields.program.Program): quantum program
+        compare_params (bool): Set to ``False`` to turn of comparing program parameters;
+            equivalency will only take into account the operation order.
+        atol (float): the absolute tolerance parameter for checking quantum operation
+            parameter equality
+        rtol (float): the relative tolerance parameter for checking quantum operation
+            parameter equality
+
+    Returns:
+        bool: returns ``True`` if two quantum programs are equivalent
+    """
+    # TODO: at the moment, we do not check for whether an empty
+    # wire will match an operation with trivial parameters.
+    # Maybe we can do this in future, but this is a subgraph
+    # isomorphism problem and much harder.
+
+    # if the same program is passed twice, return ``True``
+    if prog1 is prog2:
+        return True
+
+    DAG1 = list_to_DAG(prog1.circuit)
+    DAG2 = list_to_DAG(prog2.circuit)
+
+    circuit = []
+    for G in (DAG1, DAG2):
+        # relabel the DAG nodes to integers
+        circuit.append(nx.convert_node_labels_to_integers(G))
+
+        # ``CXgate`` and ``BSgate`` are not symmetric with respect to permuting the order of the two
+        # modes it acts on; i.e., the order of the wires matter
+        wire_mapping = {}
+        for i, n in enumerate(G.nodes()):
+            # not a ``CXgate`` or a ``BSgate``, order of wires doesn't matter
+            wire_mapping[i] = 0
+
+            if n.op.__class__.__name__ == "CXgate":
+                # if the ``CXgate`` parameter is not 0, order matters
+                if not np.allclose(n.op.p[0], 0):
+                    wire_mapping[i] = [j.ind for j in n.reg]
+
+            elif n.op.__class__.__name__ == "BSgate":
+                # if the beamsplitter is not symmetric, order matters
+                bs_params = [j % np.pi for j in par_evaluate(n.op.p)]
+                if not np.allclose(bs_params, [np.pi / 4, np.pi / 2]):
+                    wire_mapping[i] = [j.ind for j in n.reg]
+
+        # add node attributes to store the operation wires
+        nx.set_node_attributes(circuit[-1], wire_mapping, name="w")
+
+        # add node attributes to store the operation parameters
+        if compare_params:
+            parameter_mapping = {i: par_evaluate(n.op.p) for i, n in enumerate(G.nodes())}
+            nx.set_node_attributes(circuit[-1], parameter_mapping, name="p")
+
+        # add node attributes to store the operation name
+        name_mapping = {i: n.op.__class__.__name__ for i, n in enumerate(G.nodes())}
+        nx.set_node_attributes(circuit[-1], name_mapping, name="name")
+
+    def node_match(n1, n2):
+        """Returns True if both nodes have the same name and
+        same parameters, within a certain tolerance"""
+        name_match = n1["name"] == n2["name"]
+        wire_match = n1["w"] == n2["w"]
+
+        if compare_params:
+            p_match = np.allclose(n1["p"], n2["p"], atol=atol, rtol=rtol)
+            return name_match and p_match and wire_match
+
+        return name_match and wire_match
+
+    # check if circuits are equivalent
+    return nx.is_isomorphic(circuit[0], circuit[1], node_match)


Test patch:
diff --git a/tests/frontend/compilers/conftest.py b/tests/frontend/compilers/conftest.py
--- a/tests/frontend/compilers/conftest.py
+++ b/tests/frontend/compilers/conftest.py
@@ -130,91 +130,3 @@ def generate_X8_params(r, p):
         "final_phase_6": 4.12,
         "final_phase_7": 0,
     }
-
-
-def program_equivalence(prog1, prog2, compare_params=True, atol=1e-6, rtol=0):
-    r"""Checks if two programs are equivalent.
-
-    This function converts the program lists into directed acyclic graphs,
-    and runs the NetworkX `is_isomorphic` graph function in order
-    to determine if the two programs are equivalent.
-
-    Note: when checking for parameter equality between two parameters
-    :math:`a` and :math:`b`, we use the following formula:
-
-    .. math:: |a - b| \leq (\texttt{atol} + \texttt{rtol}\times|b|)
-
-    Args:
-        prog1 (strawberryfields.program.Program): quantum program
-        prog2 (strawberryfields.program.Program): quantum program
-        compare_params (bool): Set to ``False`` to turn of comparing
-            program parameters; equivalency will only take into
-            account the operation order.
-        atol (float): the absolute tolerance parameter for checking
-            quantum operation parameter equality
-        rtol (float): the relative tolerance parameter for checking
-            quantum operation parameter equality
-
-    Returns:
-        bool: returns ``True`` if two quantum programs are equivalent
-    """
-    DAG1 = list_to_DAG(prog1.circuit)
-    DAG2 = list_to_DAG(prog2.circuit)
-
-    circuit = []
-    for G in [DAG1, DAG2]:
-        # relabel the DAG nodes to integers
-        circuit.append(nx.convert_node_labels_to_integers(G))
-
-        # add node attributes to store the operation name and parameters
-        name_mapping = {i: n.op.__class__.__name__ for i, n in enumerate(G.nodes())}
-        parameter_mapping = {i: par_evaluate(n.op.p) for i, n in enumerate(G.nodes())}
-
-        # CXgate and BSgate are not symmetric wrt permuting the order of the two
-        # modes it acts on; i.e., the order of the wires matter
-        wire_mapping = {}
-        for i, n in enumerate(G.nodes()):
-            if n.op.__class__.__name__ == "CXgate":
-                if np.allclose(n.op.p[0], 0):
-                    # if the CXgate parameter is 0, wire order doesn't matter
-                    wire_mapping[i] = 0
-                else:
-                    # if the CXgate parameter is not 0, order matters
-                    wire_mapping[i] = [j.ind for j in n.reg]
-
-            elif n.op.__class__.__name__ == "BSgate":
-                if np.allclose([j % np.pi for j in par_evaluate(n.op.p)], [np.pi / 4, np.pi / 2]):
-                    # if the beamsplitter is *symmetric*, then the order of the
-                    # wires does not matter.
-                    wire_mapping[i] = 0
-                else:
-                    # beamsplitter is not symmetric, order matters
-                    wire_mapping[i] = [j.ind for j in n.reg]
-
-            else:
-                # not a CXgate or a BSgate, order of wires doesn't matter
-                wire_mapping[i] = 0
-
-        # TODO: at the moment, we do not check for whether an empty
-        # wire will match an operation with trivial parameters.
-        # Maybe we can do this in future, but this is a subgraph
-        # isomorphism problem and much harder.
-
-        nx.set_node_attributes(circuit[-1], name_mapping, name="name")
-        nx.set_node_attributes(circuit[-1], parameter_mapping, name="p")
-        nx.set_node_attributes(circuit[-1], wire_mapping, name="w")
-
-    def node_match(n1, n2):
-        """Returns True if both nodes have the same name and
-        same parameters, within a certain tolerance"""
-        name_match = n1["name"] == n2["name"]
-        p_match = np.allclose(n1["p"], n2["p"], atol=atol, rtol=rtol)
-        wire_match = n1["w"] == n2["w"]
-
-        if compare_params:
-            return name_match and p_match and wire_match
-
-        return name_match and wire_match
-
-    # check if circuits are equivalent
-    return nx.is_isomorphic(circuit[0], circuit[1], node_match)
diff --git a/tests/frontend/compilers/test_xcov.py b/tests/frontend/compilers/test_xcov.py
--- a/tests/frontend/compilers/test_xcov.py
+++ b/tests/frontend/compilers/test_xcov.py
@@ -16,15 +16,13 @@
 
 import pytest
 import numpy as np
-import networkx as nx
 
 import blackbird
 
 import strawberryfields as sf
 import strawberryfields.ops as ops
 
-from strawberryfields.parameters import par_evaluate
-from strawberryfields.program_utils import CircuitError, list_to_DAG
+from strawberryfields.program_utils import CircuitError, program_equivalence
 from strawberryfields.io import to_program
 from strawberryfields.utils import random_interferometer
 from strawberryfields.compilers import Compiler
@@ -39,94 +37,6 @@
 """float: the allowed squeezing amplitude"""
 
 
-def program_equivalence(prog1, prog2, compare_params=True, atol=1e-6, rtol=0):
-    r"""Checks if two programs are equivalent.
-
-    This function converts the program lists into directed acyclic graphs,
-    and runs the NetworkX `is_isomorphic` graph function in order
-    to determine if the two programs are equivalent.
-
-    Note: when checking for parameter equality between two parameters
-    :math:`a` and :math:`b`, we use the following formula:
-
-    .. math:: |a - b| \leq (\texttt{atol} + \texttt{rtol}\times|b|)
-
-    Args:
-        prog1 (strawberryfields.program.Program): quantum program
-        prog2 (strawberryfields.program.Program): quantum program
-        compare_params (bool): Set to ``False`` to turn of comparing
-            program parameters; equivalency will only take into
-            account the operation order.
-        atol (float): the absolute tolerance parameter for checking
-            quantum operation parameter equality
-        rtol (float): the relative tolerance parameter for checking
-            quantum operation parameter equality
-
-    Returns:
-        bool: returns ``True`` if two quantum programs are equivalent
-    """
-    DAG1 = list_to_DAG(prog1.circuit)
-    DAG2 = list_to_DAG(prog2.circuit)
-
-    circuit = []
-    for G in [DAG1, DAG2]:
-        # relabel the DAG nodes to integers
-        circuit.append(nx.convert_node_labels_to_integers(G))
-
-        # add node attributes to store the operation name and parameters
-        name_mapping = {i: n.op.__class__.__name__ for i, n in enumerate(G.nodes())}
-        parameter_mapping = {i: par_evaluate(n.op.p) for i, n in enumerate(G.nodes())}
-
-        # CXgate and BSgate are not symmetric wrt permuting the order of the two
-        # modes it acts on; i.e., the order of the wires matter
-        wire_mapping = {}
-        for i, n in enumerate(G.nodes()):
-            if n.op.__class__.__name__ == "CXgate":
-                if np.allclose(n.op.p[0], 0):
-                    # if the CXgate parameter is 0, wire order doesn't matter
-                    wire_mapping[i] = 0
-                else:
-                    # if the CXgate parameter is not 0, order matters
-                    wire_mapping[i] = [j.ind for j in n.reg]
-
-            elif n.op.__class__.__name__ == "BSgate":
-                if np.allclose([j % np.pi for j in par_evaluate(n.op.p)], [np.pi / 4, np.pi / 2]):
-                    # if the beamsplitter is *symmetric*, then the order of the
-                    # wires does not matter.
-                    wire_mapping[i] = 0
-                else:
-                    # beamsplitter is not symmetric, order matters
-                    wire_mapping[i] = [j.ind for j in n.reg]
-
-            else:
-                # not a CXgate or a BSgate, order of wires doesn't matter
-                wire_mapping[i] = 0
-
-        # TODO: at the moment, we do not check for whether an empty
-        # wire will match an operation with trivial parameters.
-        # Maybe we can do this in future, but this is a subgraph
-        # isomorphism problem and much harder.
-
-        nx.set_node_attributes(circuit[-1], name_mapping, name="name")
-        nx.set_node_attributes(circuit[-1], parameter_mapping, name="p")
-        nx.set_node_attributes(circuit[-1], wire_mapping, name="w")
-
-    def node_match(n1, n2):
-        """Returns True if both nodes have the same name and
-        same parameters, within a certain tolerance"""
-        name_match = n1["name"] == n2["name"]
-        p_match = np.allclose(n1["p"], n2["p"], atol=atol, rtol=rtol)
-        wire_match = n1["w"] == n2["w"]
-
-        if compare_params:
-            return name_match and p_match and wire_match
-
-        return name_match and wire_match
-
-    # check if circuits are equivalent
-    return nx.is_isomorphic(circuit[0], circuit[1], node_match)
-
-
 class DummyCircuit(Compiler):
     """Dummy circuit used to instantiate
     the abstract base class"""
diff --git a/tests/frontend/compilers/test_xstrict.py b/tests/frontend/compilers/test_xstrict.py
--- a/tests/frontend/compilers/test_xstrict.py
+++ b/tests/frontend/compilers/test_xstrict.py
@@ -16,8 +16,9 @@
 
 import strawberryfields as sf
 from strawberryfields import ops
+from strawberryfields.program_utils import program_equivalence
 
-from conftest import program_equivalence, X8_device, generate_X8_params
+from conftest import X8_device, generate_X8_params
 
 pytestmark = pytest.mark.frontend
 
diff --git a/tests/frontend/compilers/test_xunitary.py b/tests/frontend/compilers/test_xunitary.py
--- a/tests/frontend/compilers/test_xunitary.py
+++ b/tests/frontend/compilers/test_xunitary.py
@@ -23,8 +23,7 @@
 import strawberryfields as sf
 import strawberryfields.ops as ops
 
-from strawberryfields.parameters import par_evaluate
-from strawberryfields.program_utils import CircuitError, list_to_DAG
+from strawberryfields.program_utils import CircuitError, program_equivalence
 from strawberryfields.io import to_program
 from strawberryfields.utils import random_interferometer
 from strawberryfields.compilers import Compiler
@@ -39,94 +38,6 @@
 """float: the allowed squeezing amplitude"""
 
 
-def program_equivalence(prog1, prog2, compare_params=True, atol=1e-6, rtol=0):
-    r"""Checks if two programs are equivalent.
-
-    This function converts the program lists into directed acyclic graphs,
-    and runs the NetworkX `is_isomorphic` graph function in order
-    to determine if the two programs are equivalent.
-
-    Note: when checking for parameter equality between two parameters
-    :math:`a` and :math:`b`, we use the following formula:
-
-    .. math:: |a - b| \leq (\texttt{atol} + \texttt{rtol}\times|b|)
-
-    Args:
-        prog1 (strawberryfields.program.Program): quantum program
-        prog2 (strawberryfields.program.Program): quantum program
-        compare_params (bool): Set to ``False`` to turn of comparing
-            program parameters; equivalency will only take into
-            account the operation order.
-        atol (float): the absolute tolerance parameter for checking
-            quantum operation parameter equality
-        rtol (float): the relative tolerance parameter for checking
-            quantum operation parameter equality
-
-    Returns:
-        bool: returns ``True`` if two quantum programs are equivalent
-    """
-    DAG1 = list_to_DAG(prog1.circuit)
-    DAG2 = list_to_DAG(prog2.circuit)
-
-    circuit = []
-    for G in [DAG1, DAG2]:
-        # relabel the DAG nodes to integers
-        circuit.append(nx.convert_node_labels_to_integers(G))
-
-        # add node attributes to store the operation name and parameters
-        name_mapping = {i: n.op.__class__.__name__ for i, n in enumerate(G.nodes())}
-        parameter_mapping = {i: par_evaluate(n.op.p) for i, n in enumerate(G.nodes())}
-
-        # CXgate and BSgate are not symmetric wrt permuting the order of the two
-        # modes it acts on; i.e., the order of the wires matter
-        wire_mapping = {}
-        for i, n in enumerate(G.nodes()):
-            if n.op.__class__.__name__ == "CXgate":
-                if np.allclose(n.op.p[0], 0):
-                    # if the CXgate parameter is 0, wire order doesn't matter
-                    wire_mapping[i] = 0
-                else:
-                    # if the CXgate parameter is not 0, order matters
-                    wire_mapping[i] = [j.ind for j in n.reg]
-
-            elif n.op.__class__.__name__ == "BSgate":
-                if np.allclose([j % np.pi for j in par_evaluate(n.op.p)], [np.pi / 4, np.pi / 2]):
-                    # if the beamsplitter is *symmetric*, then the order of the
-                    # wires does not matter.
-                    wire_mapping[i] = 0
-                else:
-                    # beamsplitter is not symmetric, order matters
-                    wire_mapping[i] = [j.ind for j in n.reg]
-
-            else:
-                # not a CXgate or a BSgate, order of wires doesn't matter
-                wire_mapping[i] = 0
-
-        # TODO: at the moment, we do not check for whether an empty
-        # wire will match an operation with trivial parameters.
-        # Maybe we can do this in future, but this is a subgraph
-        # isomorphism problem and much harder.
-
-        nx.set_node_attributes(circuit[-1], name_mapping, name="name")
-        nx.set_node_attributes(circuit[-1], parameter_mapping, name="p")
-        nx.set_node_attributes(circuit[-1], wire_mapping, name="w")
-
-    def node_match(n1, n2):
-        """Returns True if both nodes have the same name and
-        same parameters, within a certain tolerance"""
-        name_match = n1["name"] == n2["name"]
-        p_match = np.allclose(n1["p"], n2["p"], atol=atol, rtol=rtol)
-        wire_match = n1["w"] == n2["w"]
-
-        if compare_params:
-            return name_match and p_match and wire_match
-
-        return name_match and wire_match
-
-    # check if circuits are equivalent
-    return nx.is_isomorphic(circuit[0], circuit[1], node_match)
-
-
 class DummyCircuit(Compiler):
     """Dummy circuit used to instantiate
     the abstract base class"""
diff --git a/tests/frontend/test_program.py b/tests/frontend/test_program.py
--- a/tests/frontend/test_program.py
+++ b/tests/frontend/test_program.py
@@ -62,6 +62,192 @@ def permute_gates():
 class TestProgram:
     """Tests the Program class."""
 
+    def test_eq_operator(self):
+        """Equality operator check."""
+        prog_1 = sf.Program(2)
+        prog_2 = sf.Program(2)
+
+        # should be equivalent
+        assert prog_1 == prog_2
+        assert prog_2 == prog_1
+
+        with prog_1.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        # should be equal
+        assert prog_1 == prog_2
+        assert prog_2 == prog_1
+
+    def test_neq_operator(self):
+        """Not-equal operator check."""
+        prog_1 = sf.Program(2)
+        prog_2 = sf.Program(2)
+
+        with prog_1.context as q:
+            ops.Sgate(0.2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Sgate(4.2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        # should NOT be equal
+        assert prog_1 != prog_2
+        assert prog_2 != prog_1
+
+    def test_neq_operator_equivalent(self):
+        """Not-equal operator check with equivalent circuit."""
+        prog_1 = sf.Program(3)
+        prog_2 = sf.Program(3)
+
+        with prog_1.context as q:
+            ops.Sgate(0.2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.Sgate(0.2) | q[2]
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Sgate(0.2) | q[0]
+            ops.Sgate(0.2) | q[2]  # moved up one step
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        # should NOT be equal
+        assert prog_1 != prog_2
+        assert prog_2 != prog_1
+
+    @pytest.mark.parametrize("compare_params", [True, False])
+    def test_equivalence(self, compare_params):
+        """Identical, but different, programs are equal."""
+        prog_1 = sf.Program(2)
+        prog_2 = sf.Program(2)
+
+        # should be equivalent
+        assert prog_1.equivalence(prog_2)
+        assert prog_2.equivalence(prog_1)
+
+        with prog_1.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        # should be equivalent
+        assert prog_1.equivalence(prog_2, compare_params=compare_params)
+        assert prog_2.equivalence(prog_1, compare_params=compare_params)
+
+    @pytest.mark.parametrize("compare_params", [True, False])
+    def test_equivalence_same_prog(self, prog, compare_params):
+        """Same program equivalence."""
+        assert prog.equivalence(prog)
+
+        with prog.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        # should be equivalent
+        assert prog.equivalence(prog, compare_params=compare_params)
+
+    @pytest.mark.parametrize("compare_params", [True, False])
+    def test_equivalence_different_gates(self, compare_params):
+        """Programs with different circuits differ."""
+        prog_1 = sf.Program(2)
+        prog_2 = sf.Program(2)
+
+        with prog_1.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Fock(2) | q[0]
+            ops.MeasureFock() | q[1]
+
+        # should NOT be equivalent
+        assert not prog_1.equivalence(prog_2, compare_params=compare_params)
+        assert not prog_2.equivalence(prog_1, compare_params=compare_params)
+
+    @pytest.mark.parametrize("compare_params", [True, False])
+    def test_equivalence_different_params(self, compare_params):
+        """Programs with different parameters differ."""
+        prog_1 = sf.Program(2)
+        prog_2 = sf.Program(2)
+
+        with prog_1.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Fock(1) | q[0]  # different parameter
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        if compare_params:
+            # should NOT be equivalent
+            assert not prog_1.equivalence(prog_2, compare_params=compare_params)
+            assert not prog_2.equivalence(prog_1, compare_params=compare_params)
+        else:
+            # should be equivalent
+            assert prog_1.equivalence(prog_2, compare_params=compare_params)
+            assert prog_2.equivalence(prog_1, compare_params=compare_params)
+
+    @pytest.mark.parametrize("compare_params", [True, False])
+    def test_eq_symmetric_bsgate(self, compare_params):
+        """Mode order doesn't matter in programs with symmetric beamsplitter."""
+        prog_1 = sf.Program(2)
+        prog_2 = sf.Program(2)
+
+        with prog_1.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate(np.pi / 4, np.pi / 2) | (q[0], q[1])  # symmetric beamsplitter
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Fock(2) | q[0]
+            ops.BSgate(np.pi / 4, np.pi / 2) | (q[1], q[0])  # symmetric beamsplitter
+            ops.MeasureFock() | q[1]
+
+        # should be equivalent
+        assert prog_1.equivalence(prog_2, compare_params=compare_params)
+        assert prog_2.equivalence(prog_1, compare_params=compare_params)
+
+    @pytest.mark.parametrize("compare_params", [True, False])
+    def test_neq_operator_equivalent(self, compare_params):
+        """Programs with differnet, but equivalent, circuits."""
+        prog_1 = sf.Program(3)
+        prog_2 = sf.Program(3)
+
+        with prog_1.context as q:
+            ops.Sgate(0.2) | q[0]
+            ops.BSgate() | (q[0], q[1])
+            ops.Sgate(0.2) | q[2]
+            ops.MeasureFock() | q[1]
+
+        with prog_2.context as q:
+            ops.Sgate(0.2) | q[0]
+            ops.Sgate(0.2) | q[2]  # moved up one step, but still equivalent
+            ops.BSgate() | (q[0], q[1])
+            ops.MeasureFock() | q[1]
+
+        # should be equivalent
+        assert prog_1.equivalence(prog_2, compare_params=compare_params)
+        assert prog_2.equivalence(prog_1, compare_params=compare_params)
+
     def test_with_block(self, prog):
         """Gate application using a with block."""
         # command queue is empty