Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0c4246f
add overwrite_hugr method
CalMacCQ Nov 17, 2025
63e120a
update ComposablePass impl to have a Hugr return type
CalMacCQ Nov 18, 2025
7481efb
Merge branch 'main' into cm/overwrite_hugr_method
CalMacCQ Nov 18, 2025
e83887c
apply some of Agustin's suggestions
CalMacCQ Nov 18, 2025
0e1ffa9
fix ComposedPass __call__ impl
CalMacCQ Nov 18, 2025
fe94fcc
Merge branch 'main' into cm/overwrite_hugr_method
CalMacCQ Nov 19, 2025
71a0a86
Apply suggestions from code review
CalMacCQ Nov 19, 2025
e29aafd
fix name
CalMacCQ Nov 19, 2025
1b6559c
add _apply and _apply_inplace for ComposedPass
CalMacCQ Nov 19, 2025
e56cd7d
Merge branch 'main' into cm/overwrite_hugr_method
CalMacCQ Nov 19, 2025
bfb6f26
apply suggestions from @acl-cqc
CalMacCQ Nov 19, 2025
8037052
docstring
CalMacCQ Nov 19, 2025
859c811
idea: Alternative to multiple ComposablePass apply methods
aborgna-q Nov 20, 2025
d79a031
feat: PassResult definition
aborgna-q Nov 20, 2025
b3eabde
Use pass names rather than objects, so the result is serializable
aborgna-q Nov 20, 2025
97e5406
More tests
aborgna-q Nov 20, 2025
07caa46
Add pass name to error message
aborgna-q Nov 20, 2025
a1eebb0
Update hugr-py/src/hugr/passes/_composable_pass.py
aborgna-q Nov 24, 2025
ad8ed71
if tree
aborgna-q Nov 24, 2025
44f5fa3
Overrite inplace param
aborgna-q Nov 24, 2025
21ee55a
typo
aborgna-q Nov 24, 2025
be1cad4
Merge remote-tracking branch 'origin/main' into ab/composed-pass-result
aborgna-q Nov 24, 2025
8b9b59b
post-merge cleanup
aborgna-q Nov 24, 2025
4c4cdcd
not my dummy
aborgna-q Nov 24, 2025
1e64469
s/impl_pass_run/implement_pass_run/
aborgna-q Nov 24, 2025
1794edc
s/pass_/composable_pass/
aborgna-q Nov 24, 2025
19662f5
typos
aborgna-q Nov 24, 2025
7cf550d
Replace `original_dirty` with `inplace` flag in results, explain flag…
aborgna-q Nov 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 130 additions & 55 deletions hugr-py/src/hugr/passes/_composable_pass.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,81 +6,88 @@
from __future__ import annotations

from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

if TYPE_CHECKING:
from collections.abc import Callable

from hugr.hugr.base import Hugr


# Type alias for a pass name
PassName = str


@runtime_checkable
class ComposablePass(Protocol):
"""A Protocol which represents a composable Hugr transformation."""

def __call__(self, hugr: Hugr, *, inplace: bool = True) -> Hugr:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we have the impl_pass_run function as a helper for implementing ComposablePass.run where is the __call__ method actually used in the pass implementation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a suggestion from Seyon. Most usecases just need the Hugr after the pass, so we provide a simple call method.

When we actually need to inspect the result/pass output we can use the other call.

"""Call the pass to transform a HUGR.
"""Call the pass to transform a HUGR, returning a Hugr."""
return self.run(hugr, inplace=inplace).hugr

def run(self, hugr: Hugr, *, inplace: bool = True) -> PassResult:
"""Run the pass to transform a HUGR, returning a PassResult.

See :func:`_impl_pass_call` for a helper function to implement this method.
See :func:`implement_pass_run` for a helper function to implement this method.
"""

@property
def name(self) -> str:
def name(self) -> PassName:
"""Returns the name of the pass."""
return self.__class__.__name__

def then(self, other: ComposablePass) -> ComposablePass:
"""Perform another composable pass after this pass."""
# Provide a default implementation for composing passes.
pass_list = []
if isinstance(self, ComposedPass):
pass_list.extend(self.passes)
else:
pass_list.append(self)
return ComposedPass(self, other)

if isinstance(other, ComposedPass):
pass_list.extend(other.passes)
else:
pass_list.append(other)

return ComposedPass(pass_list)


def impl_pass_call(
def implement_pass_run(
composable_pass: ComposablePass,
*,
hugr: Hugr,
inplace: bool,
inplace_call: Callable[[Hugr], None] | None = None,
copy_call: Callable[[Hugr], Hugr] | None = None,
) -> Hugr:
"""Helper function to implement a ComposablePass.__call__ method, given an
inplace or copy-returning pass methods.
inplace_call: Callable[[Hugr], PassResult] | None = None,
copy_call: Callable[[Hugr], PassResult] | None = None,
) -> PassResult:
"""Helper function to implement a ComposablePass.run method, given an
inplace or copy-returning pass method.

At least one of the `inplace_call` or `copy_call` arguments must be provided.

:param composable_pass: The pass being run. Used for error messages.
:param hugr: The Hugr to apply the pass to.
:param inplace: Whether to apply the pass inplace.
:param inplace_call: The method to apply the pass inplace.
:param copy_call: The method to apply the pass by copying the Hugr.
:return: The transformed Hugr.
:return: The result of the pass application.
:raises ValueError: If neither `inplace_call` nor `copy_call` is provided.
"""
if inplace and inplace_call is not None:
inplace_call(hugr)
return hugr
elif inplace and copy_call is not None:
new_hugr = copy_call(hugr)
hugr._overwrite_hugr(new_hugr)
return hugr
elif not inplace and copy_call is not None:
return copy_call(hugr)
elif not inplace and inplace_call is not None:
new_hugr = deepcopy(hugr)
inplace_call(new_hugr)
return new_hugr
else:
msg = "Pass must implement at least an inplace or copy run method"
raise ValueError(msg)
if inplace:
if inplace_call is not None:
return inplace_call(hugr)
elif copy_call is not None:
pass_result = copy_call(hugr)
pass_result.hugr = hugr
if pass_result.modified:
hugr._overwrite_hugr(pass_result.hugr)
pass_result.inplace = True
return pass_result
elif not inplace:
if copy_call is not None:
return copy_call(hugr)
elif inplace_call is not None:
new_hugr = deepcopy(hugr)
pass_result = inplace_call(new_hugr)
pass_result.inplace = False
return pass_result

msg = (
f"{composable_pass.name} needs to implement at least "
+ "an inplace or copy run method"
)
raise ValueError(msg)


@dataclass
Expand All @@ -89,24 +96,92 @@ class ComposedPass(ComposablePass):

passes: list[ComposablePass]

def __call__(self, hugr: Hugr, *, inplace: bool = True) -> Hugr:
def apply(hugr: Hugr) -> Hugr:
result_hugr = hugr
def __init__(self, *passes: ComposablePass) -> None:
self.passes = []
for composable_pass in passes:
if isinstance(composable_pass, ComposedPass):
self.passes.extend(composable_pass.passes)
else:
self.passes.append(composable_pass)

def run(self, hugr: Hugr, *, inplace: bool = True) -> PassResult:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think you can just do:

def run(self, hugr: Hugr, *, inplace: bool = True) -> PassResult:
  pass_result = PassResult(hugr=hugr)
  for pass_ in self.passes:
    new_result = pass_.run(pass_result.hugr, inplace=inplace)
    pass_result = pass_result.then(new_result)
  return pass_result

Because pass_.run always returns a PassResult containing the result hugr; we don't care whether that's the input hugr or a fresh one.

Copy link
Collaborator Author

@aborgna-q aborgna-q Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's already the case, or I'm missing something?

def apply(inplace: bool, hugr: Hugr) -> PassResult:
pass_result = PassResult(hugr=hugr, inplace=inplace)
for comp_pass in self.passes:
result_hugr = comp_pass(result_hugr, inplace=False)
return result_hugr
new_result = comp_pass.run(pass_result.hugr, inplace=inplace)
pass_result = pass_result.then(new_result)
return pass_result

def apply_inplace(hugr: Hugr) -> None:
for comp_pass in self.passes:
comp_pass(hugr, inplace=True)

return impl_pass_call(
return implement_pass_run(
self,
hugr=hugr,
inplace=inplace,
inplace_call=apply_inplace,
copy_call=apply,
inplace_call=lambda hugr: apply(True, hugr),
copy_call=lambda hugr: apply(False, hugr),
)

@property
def name(self) -> str:
return f"Composed({ ', '.join(pass_.name for pass_ in self.passes) })"
def name(self) -> PassName:
names = [composable_pass.name for composable_pass in self.passes]
return f"Composed({ ', '.join(names) })"


@dataclass
class PassResult:
"""The result of a series of composed passes applied to a HUGR.

Includes a flag indicating whether the passes modified the HUGR, and an
arbitrary result object for each pass.

:attr hugr: The transformed Hugr.
:attr inplace: Whether the pass was applied inplace.
If this is `True`, `hugr` will be the same object passed as input.
If this is `False`, `hugr` will be an independent copy of the original Hugr.
:attr modified: Whether the pass made changes to the HUGR.
Copy link
Contributor

@acl-cqc acl-cqc Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so if inplace = True,

  • original_dirty == modified, and the transformed Hugr is the input Hugr

whereas if inplace == False,

  • original_dirty should always be False
  • modified indicates whether the output Hugr == the input
  • (probably but not necessarily) the output never is the input. (We might want to allow the latter if modified is False)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modified indicates whether the output Hugr == the input; but (probably, not necessarily) the output never is the input. (We might want to allow the latter if modified is False)

Conditionally aliasing the output is a bug waiting to happen. If we say the output is a copying the object then we should always do that.
Otherwise we may start modifying the result and unintentionally changing the original too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. Ok, so - if inplace:

  • original_dirty == modified
  • output Hugr is input Hugr

if not inplace:

  • original_dirty == False? (Right? We don't allow it to be partially invalidated or anything)
  • (output Hugr is input Hugr) is always False
  • modified indicates whether the output Hugr == the input

If `False`, `hugr` will have the same contents as the original Hugr.
If `True`, no guarantees are made about the contents of `hugr`.
:attr results: The result of each applied pass, as a tuple of the pass name
and the result.
"""

hugr: Hugr
inplace: bool = False
modified: bool = False
results: list[tuple[PassName, Any]] = field(default_factory=list)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why we include the pass name here?

Copy link
Collaborator Author

@aborgna-q aborgna-q Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly for debuggability, otherwise ComposedPass.run would return a List[Any] of arbitrary payloads without much indication of what came from where.

The result may also be serialized, so the original ComposedPass and its list of passes may be lost.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought including the pass name was quite a nice solution!


@classmethod
def for_pass(
cls,
composable_pass: ComposablePass,
hugr: Hugr,
*,
result: Any,
inplace: bool,
modified: bool = True,
) -> PassResult:
"""Create a new PassResult after a pass application.

:param hugr: The Hugr that was transformed.
:param composable_pass: The pass that was applied.
:param result: The result of the pass application.
:param inplace: Whether the pass was applied inplace.
:param modified: Whether the pass modified the HUGR.
"""
return cls(
hugr=hugr,
inplace=inplace,
modified=modified,
results=[(composable_pass.name, result)],
)

def then(self, other: PassResult) -> PassResult:
"""Extend the PassResult with the results of another PassResult.

Keeps the hugr returned by the last pass.
"""
return PassResult(
hugr=other.hugr,
inplace=self.inplace and other.inplace,
modified=self.modified or other.modified,
results=self.results + other.results,
)
109 changes: 92 additions & 17 deletions hugr-py/tests/test_passes.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,105 @@
from copy import deepcopy

import pytest

from hugr.hugr.base import Hugr
from hugr.passes._composable_pass import ComposablePass, ComposedPass, impl_pass_call
from hugr.passes._composable_pass import (
ComposablePass,
ComposedPass,
PassResult,
implement_pass_run,
)


def test_composable_pass() -> None:
class MyDummyPass(ComposablePass):
def __call__(self, hugr: Hugr, inplace: bool = True) -> Hugr:
return impl_pass_call(
class DummyInlinePass(ComposablePass):
def run(self, hugr: Hugr, inplace: bool = True) -> PassResult:
return implement_pass_run(
self,
hugr=hugr,
inplace=inplace,
inplace_call=lambda hugr: None,
inplace_call=lambda hugr: PassResult.for_pass(
self,
hugr,
result=None,
inplace=True,
# Say that we modified the HUGR even though we didn't
modified=True,
),
)

dummy = MyDummyPass()
class DummyCopyPass(ComposablePass):
def run(self, hugr: Hugr, inplace: bool = True) -> PassResult:
return implement_pass_run(
self,
hugr=hugr,
inplace=inplace,
copy_call=lambda hugr: PassResult.for_pass(
self,
deepcopy(hugr),
result=None,
inplace=False,
# Say that we modified the HUGR even though we didn't
modified=True,
),
)

composed_dummies = dummy.then(dummy)
dummy_inline = DummyInlinePass()
dummy_copy = DummyCopyPass()

my_composed_pass = ComposedPass([dummy, dummy])
assert my_composed_pass.passes == [dummy, dummy]
composed_dummies = dummy_inline.then(dummy_copy)
assert isinstance(composed_dummies, ComposedPass)

assert isinstance(composed_dummies, ComposablePass)
assert composed_dummies == my_composed_pass
assert dummy_inline.name == "DummyInlinePass"
assert dummy_copy.name == "DummyCopyPass"
assert composed_dummies.name == "Composed(DummyInlinePass, DummyCopyPass)"
assert composed_dummies.then(dummy_inline).then(composed_dummies).name == (
"Composed("
+ "DummyInlinePass, DummyCopyPass, "
+ "DummyInlinePass, "
+ "DummyInlinePass, DummyCopyPass)"
)

assert dummy.name == "MyDummyPass"
assert composed_dummies.name == "Composed(MyDummyPass, MyDummyPass)"
# Apply the passes
hugr: Hugr = Hugr()
new_hugr = composed_dummies(hugr, inplace=False)
assert hugr == new_hugr
assert new_hugr is not hugr

assert (
composed_dummies.then(my_composed_pass).name
== "Composed(MyDummyPass, MyDummyPass, MyDummyPass, MyDummyPass)"
)
# Verify the pass results
hugr = Hugr()
inplace_result = composed_dummies.run(hugr, inplace=True)
assert inplace_result.modified
assert inplace_result.inplace
assert inplace_result.results == [
("DummyInlinePass", None),
("DummyCopyPass", None),
]
assert inplace_result.hugr is hugr

hugr = Hugr()
copy_result = composed_dummies.run(hugr, inplace=False)
assert copy_result.modified
assert not copy_result.inplace
assert copy_result.results == [
("DummyInlinePass", None),
("DummyCopyPass", None),
]
assert copy_result.hugr is not hugr


def test_invalid_composable_pass() -> None:
class DummyInvalidPass(ComposablePass):
def run(self, hugr: Hugr, inplace: bool = True) -> PassResult:
return implement_pass_run(
self,
hugr=hugr,
inplace=inplace,
)

dummy_invalid = DummyInvalidPass()
with pytest.raises(
ValueError,
match="DummyInvalidPass needs to implement at least an inplace or copy run method", # noqa: E501
):
dummy_invalid.run(Hugr())
Loading