Skip to content

feat!: Allow specifying link names for lowered functions and declarations#1494

Merged
maximilianruesch merged 26 commits intomainfrom
mr/feat/pass-hugr-names
Mar 13, 2026
Merged

feat!: Allow specifying link names for lowered functions and declarations#1494
maximilianruesch merged 26 commits intomainfrom
mr/feat/pass-hugr-names

Conversation

@maximilianruesch
Copy link
Copy Markdown
Collaborator

@maximilianruesch maximilianruesch commented Feb 12, 2026

Allows annotating function names for definitions and declarations lowered to HUGR. This also changes the default inferral of names to be fully qualified, instead of only partially qualified as in #1452.

See tests for more info on the details of how qualification works.

Closes #1483

BREAKING CHANGE: ParsedFunctionDef and CheckedFunctionDecl have obtained a new required parameter that is set during parsing.

BREAKING CHANGE: The inheritance chain for function declarations (RawFunctionDecl, etc ...) was changed, behaving more similar to the inheritance chain for function definitions.


BEGIN_COMMIT_OVERRIDE
feat: Allow specifying link names for lowered functions and declarations (#1494)
END_COMMIT_OVERRIDE

@hugrbot
Copy link
Copy Markdown
Collaborator

hugrbot commented Feb 12, 2026

This PR contains breaking changes to the public Python API.

Breaking changes summary
guppylang-internals/src/guppylang_internals/definition/common.py:160: MonomorphizableDef.monomorphize(parent_ty):
Parameter was removed

guppylang-internals/src/guppylang_internals/definition/struct.py:0: ParsedStructDef.__init__(link_name_prefix):
Parameter was added as required

guppylang-internals/src/guppylang_internals/definition/function.py:0: ParsedFunctionDef.__init__(link_name):
Parameter was added as required

guppylang-internals/src/guppylang_internals/definition/function.py:206: CheckedFunctionDef.monomorphize(parent_ty):
Parameter was removed

guppylang-internals/src/guppylang_internals/definition/function.py:0: CheckedFunctionDef.__init__(cfg):
Positional parameter was moved
Details: position: from 6 to 7 (+1)

guppylang-internals/src/guppylang_internals/definition/function.py:0: CheckedFunctionDef.__init__(link_name):
Parameter was added as required

guppylang-internals/src/guppylang_internals/definition/function.py:0: CompiledFunctionDef.__init__(cfg):
Positional parameter was moved
Details: position: from 7 to 8 (+1)

guppylang-internals/src/guppylang_internals/definition/function.py:0: CompiledFunctionDef.__init__(func_def):
Positional parameter was moved
Details: position: from 8 to 9 (+1)

guppylang-internals/src/guppylang_internals/definition/function.py:0: CompiledFunctionDef.__init__(link_name):
Parameter was added as required

guppylang-internals/src/guppylang_internals/definition/declaration.py:106: CheckedFunctionDecl:
Base class was removed:
Old: [guppylang_internals.definition.declaration.RawFunctionDecl, guppylang_internals.definition.common.CompilableDef, guppylang_internals.definition.value.CallableDef]
New: [guppylang_internals.definition.common.CompilableDef, guppylang_internals.definition.value.CallableDef]

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CheckedFunctionDecl.parse:
Public object was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CheckedFunctionDecl.python_func:
Public object was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CheckedFunctionDecl.unitary_flags:
Public object was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CheckedFunctionDecl.__init__(python_func):
Parameter was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CheckedFunctionDecl.__init__(docstring):
Positional parameter was moved
Details: position: from 6 to 5 (-1)

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CheckedFunctionDecl.__init__(unitary_flags):
Parameter was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CheckedFunctionDecl.__init__(link_name):
Parameter was added as required

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CompiledFunctionDecl.parse:
Public object was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CompiledFunctionDecl.python_func:
Public object was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CompiledFunctionDecl.unitary_flags:
Public object was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CompiledFunctionDecl.__init__(python_func):
Parameter was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CompiledFunctionDecl.__init__(docstring):
Positional parameter was moved
Details: position: from 6 to 5 (-1)

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CompiledFunctionDecl.__init__(unitary_flags):
Parameter was removed

guppylang-internals/src/guppylang_internals/definition/declaration.py:0: CompiledFunctionDecl.__init__(link_name):
Parameter was added as required


@maximilianruesch maximilianruesch changed the title Mr/feat/pass hugr names feat: Allow specifying hugr names for lowered functions and declarations Feb 12, 2026
@maximilianruesch maximilianruesch changed the title feat: Allow specifying hugr names for lowered functions and declarations feat!: Allow specifying hugr names for lowered functions and declarations Feb 12, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 12, 2026

🐰 Bencher Report

Branchmr/feat/pass-hugr-names
TestbedLinux
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
microseconds (µs)
(Result Δ%)
Upper Boundary
microseconds (µs)
(Limit %)
tests/benchmarks/test_big_array.py::test_big_array_check📈 view plot
🚷 view threshold
697,963.35 µs
(-0.42%)Baseline: 700,885.50 µs
735,929.78 µs
(94.84%)
tests/benchmarks/test_big_array.py::test_big_array_compile📈 view plot
🚷 view threshold
1,899,622.34 µs
(+1.81%)Baseline: 1,865,766.49 µs
1,959,054.81 µs
(96.97%)
tests/benchmarks/test_big_array.py::test_big_array_executable📈 view plot
🚷 view threshold
8,079,924.97 µs
(+0.97%)Baseline: 8,002,520.26 µs
8,402,646.27 µs
(96.16%)
tests/benchmarks/test_ctrl_flow.py::test_many_ctrl_flow_check📈 view plot
🚷 view threshold
48,358.73 µs
(-57.52%)Baseline: 113,828.95 µs
119,520.40 µs
(40.46%)
tests/benchmarks/test_ctrl_flow.py::test_many_ctrl_flow_compile📈 view plot
🚷 view threshold
105,041.25 µs
(-0.33%)Baseline: 105,388.64 µs
110,658.07 µs
(94.92%)
tests/benchmarks/test_ctrl_flow.py::test_many_ctrl_flow_executable📈 view plot
🚷 view threshold
599,717.63 µs
(-0.59%)Baseline: 603,269.57 µs
633,433.05 µs
(94.68%)
tests/benchmarks/test_prelude.py::test_import_guppy📈 view plot
🚷 view threshold
51.78 µs
(-3.44%)Baseline: 53.62 µs
56.30 µs
(91.96%)
🐰 View full continuous benchmarking report in Bencher

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 12, 2026

🐰 Bencher Report

Branchmr/feat/pass-hugr-names
TestbedLinux
Click to view all benchmark results
Benchmarkhugr_bytesBenchmark Result
bytes x 1e3
(Result Δ%)
Upper Boundary
bytes x 1e3
(Limit %)
hugr_nodesBenchmark Result
nodes
(Result Δ%)
Upper Boundary
nodes
(Limit %)
tests/benchmarks/test_big_array.py::test_big_array_compile📈 view plot
🚷 view threshold
141.79 x 1e3
(-0.01%)Baseline: 141.81 x 1e3
143.23 x 1e3
(99.00%)
📈 view plot
🚷 view threshold
6,620.00
(0.00%)Baseline: 6,620.00
6,686.20
(99.01%)
tests/benchmarks/test_ctrl_flow.py::test_many_ctrl_flow_compile📈 view plot
🚷 view threshold
17.57 x 1e3
(-0.09%)Baseline: 17.59 x 1e3
17.76 x 1e3
(98.93%)
📈 view plot
🚷 view threshold
581.00
(0.00%)Baseline: 581.00
586.81
(99.01%)
🐰 View full continuous benchmarking report in Bencher

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Feb 12, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.42%. Comparing base (6ad6c3c) to head (17dbd34).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1494      +/-   ##
==========================================
+ Coverage   93.39%   93.42%   +0.03%     
==========================================
  Files         128      128              
  Lines       11970    12022      +52     
==========================================
+ Hits        11179    11232      +53     
+ Misses        791      790       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@maximilianruesch maximilianruesch marked this pull request as ready for review February 12, 2026 15:17
@maximilianruesch maximilianruesch requested a review from a team as a code owner February 12, 2026 15:17
@maximilianruesch maximilianruesch requested review from acl-cqc and qartik and removed request for qartik February 12, 2026 15:17
Copy link
Copy Markdown
Contributor

@acl-cqc acl-cqc left a comment

Choose a reason for hiding this comment

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

This is cracking stuff, thanks @maximilianruesch. Comments are all small/tiny, not suggesting any major changes :)....except possibly:

Should we rename (qualified_)hugr_name to link_name? I.e. if these declarations will also be used for binary linking?

The counterargument is that guppy puts these names only into hugr (it doesn't generate anything else) so whatever happens to hugr-names downstream from there isn't in guppy's domain....we might wanna check with @doug-q here

Comment thread guppylang-internals/src/guppylang_internals/definition/function.py Outdated
object.__setattr__(self, "_user_set_hugr_name", hugr_name)

@cached_property
def qualified_hugr_name(self) -> str:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice, I like the use of cached_property too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In hugr the field we are filling in is just name so this is ok (hugr_qualified_name would not be ok!!). I'd consider calling this just hugr_name as that might be more obvious (and avoid question of, is there a method returning the unqualified name), but this looks ok.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Moreover, the same value is called hugr_name in the other classes

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

There is a problem with clashing names when we have the hugr_name init variable. We could consider renaming that to sth like hugr_name_override, and then rename this property to hugr_name. I would actually like this. What do you think?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I kinda like that, but it is a lot of typing for users, and I think "setting the hugr_name or leaving the default" (rather than "setting an override") is a reasonable mental model for them (i.e. they don't need to be concerned with quite how complex it is to calculate the default....)

I tried:

>>> @dataclass
... class Foo:
...     xx: InitVar[str | None] = None
...     def __post_init__(self, xx: str | None):
...         print("Post init called with xx=",xx)
...     @property
...     def xx(self):
...         return "world"
...         
>>> Foo().xx
Post init called with xx= <property object at 0x105e4e020>
'world'
>>> Foo(xx="hello").xx
Post init called with xx= hello
'world'

(same with xx: InitVar[str | None] = field(default = None))

Not sure what that property object is but I don't see a problem with having two xxs...have you tried?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I have not actually tried, but some type checker did complain. I will give it a shot and try to identify the root cause 👍

Copy link
Copy Markdown
Collaborator Author

@maximilianruesch maximilianruesch Feb 18, 2026

Choose a reason for hiding this comment

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

We can do this, but we will have to disable ruffs F811 rule for "redefined while unused" checks, since it does not seem to pick up on initvars yet.

Mypy also complains with "already defined", and later with "has no attribute" when trying to access it. I do not think this pattern is widely compatible with the tools that we use.

func_ast,
ty,
docstring,
self.qualified_hugr_name,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's a lot of parameters, we want might to start using more kwargs at some point

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

At some point is a good point. I did have some issues with kwargs and type checkers, so I just avoided them for the moment.

Comment thread guppylang-internals/src/guppylang_internals/definition/function.py Outdated
.. code-block:: python
from guppylang import guppy

@guppy.struct
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe give example of passing parameter? (@guppy.struct(....) \\ class MyStruct2)

Comment thread guppylang/src/guppylang/decorator.py Outdated
Comment thread tests/integration/test_hugr_name.py Outdated


def test_func_hugr_name_annotated():
"""Asserts that annotated HUGR func names are correctly passed to the HUGR nodes."""
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
"""Asserts that annotated HUGR func names are correctly passed to the HUGR nodes."""
"""Asserts that annotated function `hugr_name`s are correctly passed to the HUGR nodes."""

Comment thread tests/integration/test_hugr_name.py Outdated
def crazy_dec() -> None: ...

assert _func_names(crazy_def.compile()) == {
"tests.integration.test_hugr_name.test_func_hugr_name_inferred.<locals>.crazy_def"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would be lovely to remove some of the tests.integration.test_hugr_name.test_func_hugr_name_inferred.<locals> stuff from the test even if not the hugr_name.

Is there anything we can do like test_func_hugr_name_inferred.__qual_name__ + ".<locals>.crazy_def"? Or maybe make a toplevel file_name = .... and then use file_name + "test_func_hugr_name_inferred.<locals>.crazy_def"?

Or def some_local(): pass then sthg like prefix = some_local.__full_name__.remove_suffix("some_local") (suspect that no such full-name exists but mentioning just in case it does)

E.g. to make the tests less fragile if we rename the file

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

For removing fragility it would be sufficient to add some top level MODULE string that dynamically determines the module, and prepending that to every expected name.

For shortening the expected strings, we can use the request fixture from pytest to react to the test name.

I will cook up a custom fixture for this file 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the MODULE would probably have 90% of the benefit and be significantly simpler/more obvious. There is probably something to be said for making the <locals> explicit so people are less surprised when they see it in practice...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Then I would propose a middle ground test_qualifier fixture (which can be defined just as a simple function at the top of the file) that covers everything up to locals, and can be used to test a name as:

<my_expr> == f"{test_qualifier}.<locals>.main"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

sounds fine although IIUC you'll be defining that once per function....at least MODULE is only once per file

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The trick is (using the fixture) I do not. See updated code, I can define

@pytest.fixture
def qualifier(request) -> str:
    """Provides the common qualifier for functions defined in the current test."""

    def tmp() -> None:
        pass

    return f"{tmp.__module__}.{request.node.originalname}"

which processes the test name I receive from pytest.

from guppylang import guppy


def _func_names(package: Package) -> set[str]:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider defining func_names_excluding_main that checks-existence+removes exactly one name ending in main

Copy link
Copy Markdown
Contributor

@acl-cqc acl-cqc left a comment

Choose a reason for hiding this comment

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

This looks pretty good for me, I'll hold back approval only until I've heard from Doug about hugr_name vs link_name vs other

func_def,
func_ty,
None,
hugr_name=func_def.name,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

perhaps worth a comment that since nested functions are always private this doesn't really matter

if self._user_set_hugr_name is not None:
hugr_name = self._user_set_hugr_name
else:
parent_ty_id = DEF_STORE.impl_parents.get(self.id)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

style: I think if (parent_ty_id := DEF_STORE.impl_parents.get(self.id)) is not None: is fine, and allows reducing the indent via elif. Whether elif parent_ty_id := DEF_STORE.impl_parents.get(self.id) (dropping the explicit is not None) is good is a different question...


@dataclass(frozen=True)
class CheckedFunctionDecl(RawFunctionDecl, CompilableDef, CallableDef):
class CheckedFunctionDecl(CompilableDef, CallableDef):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Dropping the RawFunctionDecl as a superclass seems quite a significant change, what happens to fields python_func, description, unitary_flags? Are they multiply-inherited?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I dropped python_func on purpose, since similar to ParsedFunctionDef the original function object is not required anymore, and instead its parsed AST version is passed via defined_at.

unitary_flags is, again similar to ParsedFunctionDef, packaged into the function type passed via ty (and that is inherited from CallableDef).

description was not populated by the parsing process, and never used, so I dropped it without replacement.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A significant (driveby?) improvement/cleanup, SGTM

Comment thread guppylang/src/guppylang/decorator.py Outdated
return self.field2 + self.field2

# Add optional parameters
@guppy.struct(...)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
@guppy.struct(...)
@guppy.struct(hugr_name="my_struct")

@maximilianruesch maximilianruesch changed the title feat!: Allow specifying hugr names for lowered functions and declarations feat!: Allow specifying link names for lowered functions and declarations Feb 26, 2026
Copy link
Copy Markdown
Contributor

@acl-cqc acl-cqc left a comment

Choose a reason for hiding this comment

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

Looks great, thanks @maximilianruesch, just a couple of suggestions. I love that your test function names are even betterly qualified than mine ;-)

Comment thread guppylang-internals/src/guppylang_internals/checker/func_checker.py

@dataclass(frozen=True)
class CheckedFunctionDecl(RawFunctionDecl, CompilableDef, CallableDef):
class CheckedFunctionDecl(CompilableDef, CallableDef):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A significant (driveby?) improvement/cleanup, SGTM

Comment thread guppylang-internals/src/guppylang_internals/definition/function.py Outdated
func_ast, globals, self.id, unitary_flags=self.unitary_flags
)

link_name = f"{self.python_func.__module__}.{self.python_func.__qualname__}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if you made a superclass, you could give it a method calculate_link_name using early return to avoid this set-default-and-override pattern

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

With the fetching of the struct (which should be very specific and explicit for functions) it is imo rather awkward and not straight forward to raise into a superclass.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah....that does reduce the value of raising the superclass. Hmmm, let me have another look

Comment thread guppylang-internals/src/guppylang_internals/definition/function.py Outdated
Comment thread guppylang-internals/src/guppylang_internals/definition/function.py Outdated
Comment thread guppylang-internals/src/guppylang_internals/definition/struct.py Outdated
Comment thread guppylang/src/guppylang/decorator.py Outdated
Comment thread tests/integration/test_basic.py Outdated
@maximilianruesch maximilianruesch added this pull request to the merge queue Mar 13, 2026
Merged via the queue into main with commit fe49819 Mar 13, 2026
8 of 9 checks passed
@maximilianruesch maximilianruesch deleted the mr/feat/pass-hugr-names branch March 13, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Annotating fully qualified names of HUGR functions in Guppy

4 participants