Skip to content

feat!: Mutable structs#1793

Open
mark-koch wants to merge 6 commits into
mainfrom
mk/mutable-structs
Open

feat!: Mutable structs#1793
mark-koch wants to merge 6 commits into
mainfrom
mk/mutable-structs

Conversation

@mark-koch
Copy link
Copy Markdown
Collaborator

@mark-koch mark-koch commented Jun 2, 2026

Closes #1775

This PR adds the optional frozen kwarg to the @guppy.struct decorator. When set to True, the struct will support mutation of both classical and linear fields.

To make this change non-breaking, structs defaults to frozen=True and mutation of affine fields is allowed even if frozen=True. See #1776 for the issue for changing the default behaviour.

The main difficulty with this feature is linearity checking: If we use a projection of a mutable struct, we need to make sure that the struct value itself has not been moved. In particular, values can now be affine, even if all their projections are copyable individually. This clashes with the previous approach of linearity checking where struct values were tracked by just tracking all leaf projections.

Initially, I was planning to rewrite the entire linearity checker to stop decomposing places into their leaf projections. I still think this would be a good idea, however it felt too risky to get everything right in time for the 1.0 release. Instead, I found an alternative solution: Now, we track uses of values via all their projections, not only the leaf ones. This retains enough information to check if parents have been moved.

I recommend reviewing commit by commit.

Note that this PR is only breaking for guppylang-internals:

BREAKING CHANGE: RawStructDef, ParsedStructDef, and CheckedStructDef have a new required field frozen
BREAKING CHANGE: linearity_checker.Scope.use now requires a Place instead of a PlaceId

@hugrbot
Copy link
Copy Markdown
Collaborator

hugrbot commented Jun 2, 2026

This PR contains breaking changes to the public Python API.

Breaking changes summary
guppylang-internals/src/guppylang_internals/definition/struct.py:0: RawStructDef.__init__(frozen):
Parameter was added as required

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

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

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

guppylang-internals/src/guppylang_internals/checker/linearity_checker.py:189: Scope.use(x):
Parameter was removed

guppylang-internals/src/guppylang_internals/checker/linearity_checker.py:189: Scope.use(place):
Parameter was added as required


Comment on lines -781 to +787
stack += [
FieldAccess(place, field, place.defined_at) for field in place.ty.fields
]
if place.ty.fields:
stack += [
FieldAccess(place, field, place.defined_at)
for field in place.ty.fields
]
else:
yield place
Copy link
Copy Markdown
Collaborator Author

@mark-koch mark-koch Jun 2, 2026

Choose a reason for hiding this comment

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

Empty struct places should be treated as leafs, before we just omitted them

@mark-koch mark-koch force-pushed the mk/mutable-structs branch 2 times, most recently from f02de59 to c0ee238 Compare June 2, 2026 11:07
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 2, 2026

🐰 Bencher Report

Branchmk/mutable-structs
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
158.77 x 1e3
(0.00%)Baseline: 158.77 x 1e3
160.36 x 1e3
(99.01%)
📈 view plot
🚷 view threshold
6,641.00
(0.00%)Baseline: 6,641.00
6,707.41
(99.01%)
tests/benchmarks/test_ctrl_flow.py::test_many_ctrl_flow_compile📈 view plot
🚷 view threshold
27.54 x 1e3
(0.00%)Baseline: 27.54 x 1e3
27.81 x 1e3
(99.01%)
📈 view plot
🚷 view threshold
1,074.00
(0.00%)Baseline: 1,074.00
1,084.74
(99.01%)
tests/benchmarks/test_queue_push_pop.py::test_queue_push_benchmark_compile📈 view plot
🚷 view threshold
10.91 x 1e3
(0.00%)Baseline: 10.91 x 1e3
11.02 x 1e3
(99.01%)
📈 view plot
🚷 view threshold
308.00
(0.00%)Baseline: 308.00
311.08
(99.01%)
tests/benchmarks/test_queue_push_pop.py::test_queue_push_pop_benchmark_compile📈 view plot
🚷 view threshold
14.84 x 1e3
(0.00%)Baseline: 14.84 x 1e3
14.99 x 1e3
(99.01%)
📈 view plot
🚷 view threshold
435.00
(0.00%)Baseline: 435.00
439.35
(99.01%)
🐰 View full continuous benchmarking report in Bencher

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Jun 2, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.02%. Comparing base (07d0ed4) to head (7de7032).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1793      +/-   ##
==========================================
+ Coverage   92.99%   93.02%   +0.03%     
==========================================
  Files         135      135              
  Lines       13068    13138      +70     
==========================================
+ Hits        12152    12222      +70     
  Misses        916      916              

☔ 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.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Jun 2, 2026

Merging this PR will not alter performance

✅ 9 untouched benchmarks


Comparing mk/mutable-structs (7de7032) with main (07d0ed4)

Open in CodSpeed

@mark-koch mark-koch force-pushed the mk/mutable-structs branch from c0ee238 to 7de7032 Compare June 2, 2026 11:33
Comment on lines -317 to +318
# We have to check the item type to determine if we can move out of the
# subscript.
if not is_inout_arg and not subscript.ty.copyable:
# We are only allowed to move copyable data out of a subscript
if not is_inout_arg and not node.place.ty.copyable:
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.

Driveby: Whether we can move out of a subscript depends on the type of the final projection, not the whole array element. For example:

@guppy.struct(frozen=False)
class S:
    q: qubit
    x: int

@guppy
def foo(xs: array[S, 42]) -> None:
    a = xs[0].x  # Ok since the x projection is int, so we can copy it
    b = xs[0].q  # Error: Cannot move affine value out of array 

This came up while writing the array test, so I fixed it here

Copy link
Copy Markdown
Contributor

@nicolaassolini-qntm nicolaassolini-qntm left a comment

Choose a reason for hiding this comment

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

I think is good just little comments!

12 | if b:
13 | t = s
14 | return s
| ^ Field `s.x` cannot be returned ...
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.

should here say "Variable s cannot be returned ..." as in the mutable_affine_struct case?

11 | def foo(s: MyStruct @owned, b: bool) -> None:
12 | while b:
13 | t = s
| ^ Field `s.x` cannot be moved ...
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.

same, why s.x and not s?

14 | t.x += 1
| ^ Mutation of classical fields is not supported
| ^^^ Struct `MyStruct` is immutable

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.

adding a Note that says "Set frozen=False to make the struct mutable"?

@@ -145,42 +146,72 @@ class Scope(Locals[PlaceId, Place]):
"""

parent_scope: "Scope | None"
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.

you commented all the fields a part of this, a small comment here?

Comment on lines 166 to +172
def used(self, x: PlaceId) -> Use | None:
"""Checks whether a place has already been used."""
if x in self.vars:
return self.used_local.get(x, None)
assert self.parent_scope is not None
return self.parent_scope.used(x)
if x in self.used_projections:
return self.used_projections[x]
if self.parent_scope:
return self.parent_scope.used(x)
return None
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.

the new implementation of used only checks for place in used_projections or in the parent_scope, is it fine? before we was checking in used_local

return (subscript, literal_idx)


def check_mutable_parent_already_used(place: Place, use: Use, scope: Scope) -> None:
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
def check_mutable_parent_already_used(place: Place, use: Use, scope: Scope) -> None:
def _check_mutable_parent_already_used(place: Place, use: Use, scope: Scope) -> None:

if it is meant to be just a local helper, (same for the other new helper functions)

Comment on lines +918 to +928
for parent in parent_places(place, include_self=True):
match parent.ty:
case StructType(frozen=False):
mutable = True
case _:
mutable = False
if mutable and (prev_use := scope.used(parent.id)):
err = ParentAlreadyUsedError(use.node, place, use.kind)
sub = ParentAlreadyUsedError.ParentUse(prev_use.node, parent, prev_use.kind)
err.add_sub_diagnostic(sub)
raise GuppyError(err)
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
for parent in parent_places(place, include_self=True):
match parent.ty:
case StructType(frozen=False):
mutable = True
case _:
mutable = False
if mutable and (prev_use := scope.used(parent.id)):
err = ParentAlreadyUsedError(use.node, place, use.kind)
sub = ParentAlreadyUsedError.ParentUse(prev_use.node, parent, prev_use.kind)
err.add_sub_diagnostic(sub)
raise GuppyError(err)
for parent in parent_places(place, include_self=True):
match parent.ty:
case StructType(frozen=False):
if prev_use := scope.used(parent.id):
err = ParentAlreadyUsedError(use.node, place, use.kind)
sub = ParentAlreadyUsedError.ParentUse(prev_use.node, parent, prev_use.kind)
err.add_sub_diagnostic(sub)
raise GuppyError(err)

@nicolaassolini-qntm
Copy link
Copy Markdown
Contributor

I also requested Copilot, sometimes it is useful, at least it is better than me in spotting typos or "ugly" comments

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces mutable (non-frozen) structs by adding an optional frozen kwarg to @guppy.struct (defaulting to True for backwards compatibility). To preserve soundness with mutable/affine structs, it updates linearity checking to track non-leaf projections so that moving a parent prevents later use of its projections (even if the leaf fields are individually copyable), and adds/updates integration + error tests accordingly.

Changes:

  • Add frozen: bool to @guppy.struct (default True) and plumb it through internal struct definitions/types.
  • Make mutable structs intrinsically non-copyable; allow mutation only when permitted (with a temporary compatibility exception for affine fields on frozen structs).
  • Update the linearity checker to track uses via projections and introduce ParentAlreadyUsedError; add extensive regression tests.

Reviewed changes

Copilot reviewed 49 out of 49 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/integration/test_linear.py Adds integration coverage for mutating struct fields across control flow.
tests/integration/test_inout.py Adds a mutating struct method test (self.x += ...).
tests/integration/test_array.py Adds coverage for mutating struct fields inside arrays/subscripts.
tests/error/struct_errors/mutate_classical.err Updates expected error from “Unsupported” to “Immutable” for frozen structs.
tests/error/linear_errors/parent_moved1.py New regression: moved parent prevents later projection use.
tests/error/linear_errors/parent_moved1.err Expected diagnostic for parent/projection move violation.
tests/error/linear_errors/parent_moved2.py New regression for conditional parent move then projection use.
tests/error/linear_errors/parent_moved2.err Expected diagnostic for conditional parent move case.
tests/error/linear_errors/parent_moved3.py New regression: branch assigns vs moves, then projection use.
tests/error/linear_errors/parent_moved3.err Expected diagnostic for branch assign/move mix.
tests/error/linear_errors/parent_moved4.py New regression: move field then later return nested projection.
tests/error/linear_errors/parent_moved4.err Expected diagnostic for moved field then projection return.
tests/error/linear_errors/parent_moved5.py New regression involving walrus/boolean context moving parent.
tests/error/linear_errors/parent_moved5.err Expected diagnostic for walrus-based parent move.
tests/error/linear_errors/parent_moved6.py New regression: move in loop then return projection.
tests/error/linear_errors/parent_moved6.err Expected diagnostic for loop move then projection return.
tests/error/linear_errors/parent_moved7.py New regression: repeated move inside loop for nested mutable field.
tests/error/linear_errors/parent_moved7.err Expected diagnostic for repeated-move-in-loop scenario.
tests/error/linear_errors/mutable_empty_struct1.py New regression: empty mutable structs are affine/non-copyable.
tests/error/linear_errors/mutable_empty_struct1.err Expected diagnostic for empty mutable struct copy violation.
tests/error/linear_errors/mutable_empty_struct2.py Variant regression with conditional move of empty mutable struct.
tests/error/linear_errors/mutable_empty_struct2.err Expected diagnostic for conditional empty-struct move.
tests/error/linear_errors/mutable_affine_struct1.py New regression: mutable structs treated affine across moves.
tests/error/linear_errors/mutable_affine_struct1.err Expected diagnostic for basic mutable-struct move then reuse.
tests/error/linear_errors/mutable_affine_struct2.py Regression: conditional move then return mutable struct.
tests/error/linear_errors/mutable_affine_struct2.err Expected diagnostic for conditional move/return.
tests/error/linear_errors/mutable_affine_struct3.py Regression: else-branch move then return.
tests/error/linear_errors/mutable_affine_struct3.err Expected diagnostic for else-branch move then return.
tests/error/linear_errors/mutable_affine_struct4.py Regression: move then conditional return.
tests/error/linear_errors/mutable_affine_struct4.err Expected diagnostic for move then conditional return.
tests/error/linear_errors/mutable_affine_struct5.py Regression: walrus move then return.
tests/error/linear_errors/mutable_affine_struct5.err Expected diagnostic for walrus move then return.
tests/error/linear_errors/mutable_affine_struct6.py Regression: loop move then return.
tests/error/linear_errors/mutable_affine_struct6.err Expected diagnostic for loop move then return.
tests/error/linear_errors/mutable_affine_struct7.py Regression: repeated move inside loop.
tests/error/linear_errors/mutable_affine_struct7.err Expected diagnostic for repeated-move-in-loop.
tests/error/linear_errors/affine_struct_array.py New regression: array subscript returning affine struct is illegal.
tests/error/linear_errors/affine_struct_array.err Expected diagnostic for subscript-returned non-copyable array element.
tests/error/inout_errors/field_borrow_twice.py New regression: borrowing same mutable field twice is illegal.
tests/error/inout_errors/field_borrow_twice.err Expected diagnostic for duplicate borrow of non-copyable field.
tests/error/inout_errors/field_already_used.py New regression: consume then borrow same mutable field is illegal.
tests/error/inout_errors/field_already_used.err Expected diagnostic for borrow-after-consume of field.
guppylang/src/guppylang/decorator.py Adds frozen kwarg plumbing into RawStructDef creation (default True).
guppylang-internals/src/guppylang_internals/tys/ty.py Makes StructType copyability depend on frozen.
guppylang-internals/src/guppylang_internals/definition/struct.py Threads frozen through Raw/Parsed/Checked struct defs.
guppylang-internals/src/guppylang_internals/checker/stmt_checker.py Enforces immutability on field assignment (new StructImmutableError).
guppylang-internals/src/guppylang_internals/checker/linearity_checker.py Tracks projection uses + parent-moved checks; new helper functions.
guppylang-internals/src/guppylang_internals/checker/errors/type_errors.py Adds StructImmutableError.
guppylang-internals/src/guppylang_internals/checker/errors/linearity.py Adds ParentAlreadyUsedError diagnostic.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 707 to +711
elif not place.ty.copyable:
raise GuppyTypeError(ComprAlreadyUsedError(use.node, place, use.kind))

# Also check if parents have been moved
check_mutable_parent_already_used(place, use, self.scope)
Comment on lines 1000 to 1004
use_scope = scopes[use_bb]
place = use_scope[x]
use = use_scope.used_parent[x]
if not place.ty.copyable and (prev_use := scope.used(x)):
use = use_scope.used_parent[x]
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]: Frozen annotation for structs

5 participants