ArchytasZX 0.9.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- archytaszx/__init__.py +14 -0
- archytaszx/algebra/__init__.py +14 -0
- archytaszx/algebra/dimension.py +590 -0
- archytaszx/algebra/phase.py +493 -0
- archytaszx/algebra/scalar.py +698 -0
- archytaszx/diagram/__init__.py +14 -0
- archytaszx/diagram/bangbox.py +1086 -0
- archytaszx/diagram/generators.py +255 -0
- archytaszx/diagram/graph.py +668 -0
- archytaszx/diagram/scalable.py +14 -0
- archytaszx/diagram/validate.py +927 -0
- archytaszx/repl/__init__.py +14 -0
- archytaszx/repl/commands.py +15 -0
- archytaszx/repl/parser.py +295 -0
- archytaszx/repl/printer.py +15 -0
- archytaszx/repl/shell.py +15 -0
- archytaszx/rewrite/__init__.py +14 -0
- archytaszx/rewrite/cache.py +14 -0
- archytaszx/rewrite/egraph.py +14 -0
- archytaszx/rewrite/engine.py +596 -0
- archytaszx/rewrite/match.py +1501 -0
- archytaszx/rewrite/normal_form.py +14 -0
- archytaszx/rewrite/rule.py +461 -0
- archytaszx/rewrite/rules_library.py +498 -0
- archytaszx/rewrite/tactics.py +14 -0
- archytaszx/semantics/__init__.py +14 -0
- archytaszx/semantics/certificate.py +776 -0
- archytaszx/semantics/check.py +436 -0
- archytaszx/semantics/contract_numeric.py +293 -0
- archytaszx/semantics/contract_symbolic.py +391 -0
- archytaszx/semantics/denote.py +271 -0
- archytaszx/semantics/induction.py +1012 -0
- archytaszx-0.9.1.dist-info/METADATA +362 -0
- archytaszx-0.9.1.dist-info/RECORD +37 -0
- archytaszx-0.9.1.dist-info/WHEEL +5 -0
- archytaszx-0.9.1.dist-info/licenses/LICENSE +190 -0
- archytaszx-0.9.1.dist-info/top_level.txt +1 -0
archytaszx/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright 2026 Arkhip A. Dmitriev
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
"""ArchytasZX: a qufinite ZX-calculus engine with symbolic qudit count and dimension."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Copyright 2026 Arkhip A. Dmitriev
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
"""Symbolic algebra substrate: dimensions, phases, and exact scalars."""
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
# Copyright 2026 Arkhip A. Dmitriev
|
|
2
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
3
|
+
# you may not use this file except in compliance with the License.
|
|
4
|
+
# You may obtain a copy of the License at
|
|
5
|
+
#
|
|
6
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
|
+
#
|
|
8
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
9
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
10
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
11
|
+
# See the License for the specific language governing permissions and
|
|
12
|
+
# limitations under the License.
|
|
13
|
+
|
|
14
|
+
"""Symbolic dimension expressions: concrete integers, symbols, and products/powers thereof.
|
|
15
|
+
|
|
16
|
+
A :class:`Dim` is normalized and compared through a single canonical
|
|
17
|
+
``sympy`` expression (see :meth:`Dim.to_sympy`). Dimension symbols carry the
|
|
18
|
+
sympy assumptions ``positive=True, integer=True`` and exponent symbols carry
|
|
19
|
+
``integer=True, nonnegative=True``, so sympy's own normalization (e.g.
|
|
20
|
+
folding ``d**n * d**m`` to ``d**(n+m)``) is sound over this domain. This
|
|
21
|
+
supports the spec invariant that dimension is stored per port, never
|
|
22
|
+
as a single global parameter: this module only supplies dimension *values*,
|
|
23
|
+
it holds no ambient "current dimension" state of its own.
|
|
24
|
+
|
|
25
|
+
Phase 10 note: :meth:`Dim.unify` here is a deliberate placeholder. It never
|
|
26
|
+
guesses, factors, or partially solves -- it only recognizes the cases it can
|
|
27
|
+
decide outright (both concrete, syntactically equal, or one side a bare
|
|
28
|
+
symbol) and defers everything else as a residual constraint. The real
|
|
29
|
+
unifier, which must actually solve constraints such as ``d = d1 * d2``,
|
|
30
|
+
arrives in Phase 10 and should replace the body of this method, not its
|
|
31
|
+
contract: callers rely on it never reporting success for something it did
|
|
32
|
+
not verify.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import enum
|
|
38
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from types import MappingProxyType
|
|
41
|
+
from typing import Union, cast
|
|
42
|
+
|
|
43
|
+
import sympy as sp # type: ignore[import-untyped] # sympy ships no py.typed marker
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DimensionError(Exception):
|
|
47
|
+
"""Base class for all errors raised by this module."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DimensionDomainError(DimensionError):
|
|
51
|
+
"""A value is outside the mathematical domain of dimensions or exponents.
|
|
52
|
+
|
|
53
|
+
Raised for concrete dimensions that are not positive integers, exponents
|
|
54
|
+
that are not non-negative integers, and substitution values that violate
|
|
55
|
+
those same domains.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DimensionGrammarError(DimensionError):
|
|
60
|
+
"""An expression falls outside the dimension grammar.
|
|
61
|
+
|
|
62
|
+
The grammar is: concrete positive integers, positive-integer symbols,
|
|
63
|
+
products of these, and powers of these with a non-negative-integer
|
|
64
|
+
exponent. Sums, quotients, roots, floats, and anything else are rejected.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_symbol_name(name: str) -> None:
|
|
69
|
+
"""Reject a symbol name that is not a bare identifier."""
|
|
70
|
+
if not isinstance(name, str) or not name.isidentifier():
|
|
71
|
+
raise DimensionGrammarError(
|
|
72
|
+
f"symbol name must be a bare identifier, got {name!r}; a name like 'd*e' "
|
|
73
|
+
"builds a symbol that renders identically to the product d*e but is a "
|
|
74
|
+
"different Dim"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _dimension_symbol(name: str) -> sp.Symbol:
|
|
79
|
+
_check_symbol_name(name)
|
|
80
|
+
return sp.Symbol(name, positive=True, integer=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _exponent_symbol(name: str) -> sp.Symbol:
|
|
84
|
+
_check_symbol_name(name)
|
|
85
|
+
return sp.Symbol(name, integer=True, nonnegative=True)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_exponent_domain(expr: sp.Expr) -> None:
|
|
89
|
+
if expr.is_Integer:
|
|
90
|
+
if int(expr) < 0:
|
|
91
|
+
raise DimensionDomainError(f"exponent must be a non-negative integer, got {expr}")
|
|
92
|
+
return
|
|
93
|
+
if expr.is_Symbol:
|
|
94
|
+
assumptions = expr.assumptions0
|
|
95
|
+
if not (assumptions.get("integer") and assumptions.get("nonnegative")):
|
|
96
|
+
raise DimensionGrammarError(
|
|
97
|
+
f"symbol {expr} is not a valid exponent symbol "
|
|
98
|
+
"(exponent symbols must be non-negative integers)"
|
|
99
|
+
)
|
|
100
|
+
_check_symbol_name(str(expr.name))
|
|
101
|
+
return
|
|
102
|
+
if expr.is_Add or expr.is_Mul:
|
|
103
|
+
for arg in expr.args:
|
|
104
|
+
_check_exponent_domain(arg)
|
|
105
|
+
return
|
|
106
|
+
raise DimensionGrammarError(f"exponent {expr} is outside the exponent grammar")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_dimension_domain(expr: sp.Expr) -> None:
|
|
110
|
+
if expr.is_Integer:
|
|
111
|
+
if int(expr) < 1:
|
|
112
|
+
raise DimensionDomainError(f"concrete dimension must be >= 1, got {expr}")
|
|
113
|
+
return
|
|
114
|
+
if expr.is_Symbol:
|
|
115
|
+
assumptions = expr.assumptions0
|
|
116
|
+
if not (assumptions.get("integer") and assumptions.get("positive")):
|
|
117
|
+
raise DimensionGrammarError(
|
|
118
|
+
f"symbol {expr} is not a valid dimension symbol "
|
|
119
|
+
"(dimension symbols must be positive integers)"
|
|
120
|
+
)
|
|
121
|
+
_check_symbol_name(str(expr.name))
|
|
122
|
+
return
|
|
123
|
+
if expr.is_Mul:
|
|
124
|
+
for arg in expr.args:
|
|
125
|
+
_check_dimension_domain(arg)
|
|
126
|
+
return
|
|
127
|
+
if expr.is_Pow:
|
|
128
|
+
base, exponent = expr.args
|
|
129
|
+
_check_dimension_domain(base)
|
|
130
|
+
_check_exponent_domain(exponent)
|
|
131
|
+
return
|
|
132
|
+
raise DimensionGrammarError(
|
|
133
|
+
f"expression {expr} is outside the dimension grammar "
|
|
134
|
+
"(sums, quotients, roots, and floats are not allowed)"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
DimSymbolKey = Union[str, "Dim"]
|
|
139
|
+
DimSubstituteValue = Union[int, "Dim"]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Dim:
|
|
143
|
+
"""An immutable, hashable dimension expression.
|
|
144
|
+
|
|
145
|
+
A dimension is a positive integer (>= 1), possibly symbolic. Valid forms
|
|
146
|
+
are a concrete integer, a symbol, a product of dimensions, and a power of
|
|
147
|
+
a dimension by a non-negative integer exponent (itself concrete or
|
|
148
|
+
symbolic). This is the per-port dimension value referenced by the
|
|
149
|
+
"dimension is stored per port" invariant in the spec; ``Dim`` itself
|
|
150
|
+
holds no global or default dimension.
|
|
151
|
+
|
|
152
|
+
Two ``Dim`` values are equal iff their normalized expressions are
|
|
153
|
+
mathematically identical, e.g. ``d1 * d2 == d2 * d1`` and
|
|
154
|
+
``d * d == d ** 2``.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
__slots__ = ("_expr",)
|
|
158
|
+
_expr: sp.Expr
|
|
159
|
+
|
|
160
|
+
def __init__(self, value: int | str | Dim) -> None:
|
|
161
|
+
"""Build a Dim from a concrete positive int, a symbol name, or a copy of another Dim.
|
|
162
|
+
|
|
163
|
+
Raises DimensionDomainError for non-positive integers and
|
|
164
|
+
DimensionGrammarError for any other invalid input.
|
|
165
|
+
"""
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
raise DimensionGrammarError(f"Dim does not accept bool, got {value!r}")
|
|
168
|
+
if isinstance(value, Dim):
|
|
169
|
+
self._expr = value._expr
|
|
170
|
+
elif isinstance(value, int):
|
|
171
|
+
if value < 1:
|
|
172
|
+
raise DimensionDomainError(f"concrete dimension must be >= 1, got {value}")
|
|
173
|
+
self._expr = sp.Integer(value)
|
|
174
|
+
elif isinstance(value, str):
|
|
175
|
+
expr = _dimension_symbol(value)
|
|
176
|
+
_check_dimension_domain(expr)
|
|
177
|
+
self._expr = expr
|
|
178
|
+
else:
|
|
179
|
+
raise TypeError(f"Dim() accepts int, str, or Dim, got {type(value).__name__}")
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def concrete(cls, value: int) -> Dim:
|
|
183
|
+
"""Build a Dim from a concrete positive integer. Alias for Dim(value)."""
|
|
184
|
+
return cls(value)
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def symbol(cls, name: str) -> Dim:
|
|
188
|
+
"""Build a Dim from a fresh positive-integer symbol name. Alias for Dim(name)."""
|
|
189
|
+
return cls(name)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_sympy(cls, expr: sp.Expr) -> Dim:
|
|
193
|
+
"""Build a Dim from a sympy expression, rejecting anything outside the dimension grammar."""
|
|
194
|
+
return cls._from_expr(sp.sympify(expr))
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def _from_expr(cls, expr: sp.Expr) -> Dim:
|
|
198
|
+
normalized = sp.powsimp(expr, force=True)
|
|
199
|
+
_check_dimension_domain(normalized)
|
|
200
|
+
obj = cls.__new__(cls)
|
|
201
|
+
obj._expr = normalized
|
|
202
|
+
return obj
|
|
203
|
+
|
|
204
|
+
def to_sympy(self) -> sp.Expr:
|
|
205
|
+
"""Escape hatch returning the underlying canonical sympy expression.
|
|
206
|
+
|
|
207
|
+
This is the only sanctioned way to reach the sympy backend; every
|
|
208
|
+
other public method keeps sympy as an implementation detail so later
|
|
209
|
+
phases can work entirely in terms of Dim.
|
|
210
|
+
"""
|
|
211
|
+
return self._expr
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def is_concrete(self) -> bool:
|
|
215
|
+
"""True iff this expression has no free symbols, i.e. it denotes a known integer."""
|
|
216
|
+
return not self._expr.free_symbols
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def free_symbols(self) -> frozenset[str]:
|
|
220
|
+
"""The names of all symbols (dimension or exponent) appearing in this expression."""
|
|
221
|
+
return frozenset(str(s.name) for s in self._expr.free_symbols)
|
|
222
|
+
|
|
223
|
+
def to_int(self) -> int:
|
|
224
|
+
"""Return the concrete int value of this dimension.
|
|
225
|
+
|
|
226
|
+
This is the single gate through which a symbolic dimension becomes a
|
|
227
|
+
Python int. Everywhere downstream that needs to build a numpy array
|
|
228
|
+
or dense tensor must go through this method, and it is exactly the
|
|
229
|
+
enforcement point of the invariant "never construct a matrix or
|
|
230
|
+
dense tensor while any dimension or count in scope is symbolic":
|
|
231
|
+
calling it on a symbolic Dim raises rather than silently proceeding.
|
|
232
|
+
"""
|
|
233
|
+
if not self.is_concrete:
|
|
234
|
+
raise DimensionDomainError(
|
|
235
|
+
f"cannot convert symbolic dimension {self} to int; "
|
|
236
|
+
f"free symbols: {sorted(self.free_symbols)}"
|
|
237
|
+
)
|
|
238
|
+
return int(self._expr)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _key_symbol_name(key: DimSymbolKey) -> str:
|
|
242
|
+
if isinstance(key, str):
|
|
243
|
+
return key
|
|
244
|
+
if isinstance(key, Dim):
|
|
245
|
+
if not key._expr.is_Symbol:
|
|
246
|
+
raise DimensionGrammarError(f"substitution key must be a bare symbol, got {key}")
|
|
247
|
+
return str(key._expr.name)
|
|
248
|
+
raise TypeError(f"substitution key must be str or Dim, got {type(key).__name__}")
|
|
249
|
+
|
|
250
|
+
def substitute(self, mapping: Mapping[DimSymbolKey, DimSubstituteValue]) -> Dim:
|
|
251
|
+
"""Return a new Dim with symbols replaced by concrete integers.
|
|
252
|
+
|
|
253
|
+
Keys may be symbol names (str) or bare-symbol Dims; values may be
|
|
254
|
+
int or a concrete Dim. The mapping need not be total: symbols not mentioned are left
|
|
255
|
+
symbolic (e.g. substituting only d in d ** n leaves 2 ** n).
|
|
256
|
+
Substitution reaches into exponents as well as bases. Values are
|
|
257
|
+
validated against the domain of the symbol they replace (positive
|
|
258
|
+
integers for dimension symbols, non-negative integers for exponent
|
|
259
|
+
symbols). This Dim is never mutated; a new Dim is always returned.
|
|
260
|
+
"""
|
|
261
|
+
resolved: dict[str, int] = {}
|
|
262
|
+
for key, value in mapping.items():
|
|
263
|
+
name = self._key_symbol_name(key)
|
|
264
|
+
if isinstance(value, Dim):
|
|
265
|
+
if not value.is_concrete:
|
|
266
|
+
raise DimensionDomainError(
|
|
267
|
+
f"substitution value for {name!r} must be concrete, got {value}"
|
|
268
|
+
)
|
|
269
|
+
resolved[name] = value.to_int()
|
|
270
|
+
elif isinstance(value, int) and not isinstance(value, bool):
|
|
271
|
+
resolved[name] = value
|
|
272
|
+
else:
|
|
273
|
+
raise TypeError(
|
|
274
|
+
f"substitution value for {name!r} must be int or Dim, "
|
|
275
|
+
f"got {type(value).__name__}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
subs_dict: dict[sp.Symbol, sp.Integer] = {}
|
|
279
|
+
# Sorted by name: sympy's free_symbols is a plain set whose Symbol hashing bottoms
|
|
280
|
+
# out in the PYTHONHASHSEED-dependent string hash of the name, so with several
|
|
281
|
+
# out-of-domain values in one call the error below would name a different symbol
|
|
282
|
+
# first across processes.
|
|
283
|
+
for sym in sorted(self._expr.free_symbols, key=lambda s: str(s.name)):
|
|
284
|
+
sym_name = str(sym.name)
|
|
285
|
+
if sym_name not in resolved:
|
|
286
|
+
continue
|
|
287
|
+
value = resolved[sym_name]
|
|
288
|
+
assumptions = sym.assumptions0
|
|
289
|
+
if assumptions.get("positive"):
|
|
290
|
+
if value < 1:
|
|
291
|
+
raise DimensionDomainError(
|
|
292
|
+
f"dimension symbol {sym_name!r} requires a positive integer, got {value}"
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
if value < 0:
|
|
296
|
+
raise DimensionDomainError(
|
|
297
|
+
f"exponent symbol {sym_name!r} requires a non-negative integer, got {value}"
|
|
298
|
+
)
|
|
299
|
+
subs_dict[sym] = sp.Integer(value)
|
|
300
|
+
|
|
301
|
+
new_expr = self._expr.subs(subs_dict)
|
|
302
|
+
return Dim._from_expr(new_expr)
|
|
303
|
+
|
|
304
|
+
def abstract(
|
|
305
|
+
self, *, avoid: Iterable[str] = (), stem: str = "d"
|
|
306
|
+
) -> tuple[Dim, Mapping[str, int]]:
|
|
307
|
+
"""Return a fresh symbol standing for this concrete dimension, and its binding.
|
|
308
|
+
|
|
309
|
+
The inverse direction of :meth:`substitute`: ``symbol.substitute(binding)`` returns
|
|
310
|
+
this Dim again. The name is ``stem``, or ``stem`` suffixed with the lowest positive
|
|
311
|
+
integer not in ``avoid``. Raises DimensionDomainError on a symbolic Dim.
|
|
312
|
+
"""
|
|
313
|
+
if not self.is_concrete:
|
|
314
|
+
raise DimensionDomainError(
|
|
315
|
+
f"cannot abstract symbolic dimension {self}; abstract() takes a concrete "
|
|
316
|
+
f"value, free symbols: {sorted(self.free_symbols)}"
|
|
317
|
+
)
|
|
318
|
+
_check_symbol_name(stem)
|
|
319
|
+
taken = set(avoid)
|
|
320
|
+
name = stem
|
|
321
|
+
suffix = 0
|
|
322
|
+
while name in taken:
|
|
323
|
+
suffix += 1
|
|
324
|
+
name = f"{stem}{suffix}"
|
|
325
|
+
return Dim.symbol(name), MappingProxyType({name: self.to_int()})
|
|
326
|
+
|
|
327
|
+
def __mul__(self, other: Dim | int) -> Dim:
|
|
328
|
+
"""Multiply two dimensions, returning a normalized product."""
|
|
329
|
+
if isinstance(other, bool):
|
|
330
|
+
return NotImplemented
|
|
331
|
+
if isinstance(other, int):
|
|
332
|
+
other = Dim(other)
|
|
333
|
+
if not isinstance(other, Dim):
|
|
334
|
+
return NotImplemented
|
|
335
|
+
return Dim._from_expr(self._expr * other._expr)
|
|
336
|
+
|
|
337
|
+
def __rmul__(self, other: Dim | int) -> Dim:
|
|
338
|
+
"""Support ``2 * d`` by delegating to the commutative __mul__."""
|
|
339
|
+
return self.__mul__(other)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _coerce_exponent(exponent: Dim | int) -> sp.Expr:
|
|
343
|
+
if isinstance(exponent, bool):
|
|
344
|
+
raise DimensionGrammarError(f"exponent does not accept bool, got {exponent!r}")
|
|
345
|
+
if isinstance(exponent, int):
|
|
346
|
+
if exponent < 0:
|
|
347
|
+
raise DimensionDomainError(f"exponent must be >= 0, got {exponent}")
|
|
348
|
+
return sp.Integer(exponent)
|
|
349
|
+
if isinstance(exponent, Dim):
|
|
350
|
+
expr = exponent._expr
|
|
351
|
+
if expr.is_Integer:
|
|
352
|
+
if int(expr) < 0:
|
|
353
|
+
raise DimensionDomainError(f"exponent must be >= 0, got {expr}")
|
|
354
|
+
return expr
|
|
355
|
+
if expr.is_Symbol:
|
|
356
|
+
return _exponent_symbol(str(expr.name))
|
|
357
|
+
raise DimensionGrammarError(
|
|
358
|
+
f"exponent {expr} must be a concrete non-negative integer or a single symbol"
|
|
359
|
+
)
|
|
360
|
+
raise TypeError(f"exponent must be int or Dim, got {type(exponent).__name__}")
|
|
361
|
+
|
|
362
|
+
def __pow__(self, exponent: Dim | int) -> Dim:
|
|
363
|
+
"""Raise this dimension to a non-negative integer (possibly symbolic) power."""
|
|
364
|
+
exponent_expr = self._coerce_exponent(exponent)
|
|
365
|
+
return Dim._from_expr(self._expr**exponent_expr)
|
|
366
|
+
|
|
367
|
+
def __eq__(self, other: object) -> bool:
|
|
368
|
+
"""Exact mathematical equality after normalization.
|
|
369
|
+
|
|
370
|
+
Only Dim-to-Dim comparison is supported: comparing against a
|
|
371
|
+
non-Dim (e.g. ``dim == 2``) returns NotImplemented rather than
|
|
372
|
+
silently coercing, so such a comparison evaluates to False instead
|
|
373
|
+
of implying an int is ever "the same thing" as a Dim.
|
|
374
|
+
"""
|
|
375
|
+
if not isinstance(other, Dim):
|
|
376
|
+
return NotImplemented
|
|
377
|
+
return bool(self._expr == other._expr)
|
|
378
|
+
|
|
379
|
+
def __hash__(self) -> int:
|
|
380
|
+
"""Hash agrees with __eq__: equal Dims (post-normalization) hash equal."""
|
|
381
|
+
return hash(self._expr)
|
|
382
|
+
|
|
383
|
+
def __repr__(self) -> str:
|
|
384
|
+
return f"Dim({self._expr})"
|
|
385
|
+
|
|
386
|
+
def __str__(self) -> str:
|
|
387
|
+
return str(self._expr)
|
|
388
|
+
|
|
389
|
+
def unify(self, other: Dim) -> UnifyResult:
|
|
390
|
+
"""Placeholder unifier (Phase 10 will replace this with a real solver).
|
|
391
|
+
|
|
392
|
+
Resolves only the cases that can be decided without guessing:
|
|
393
|
+
concrete-vs-concrete (definite success or definite failure),
|
|
394
|
+
syntactically-equal-after-normalization (success, no constraints),
|
|
395
|
+
and a bare symbol against anything that does not contain it (a
|
|
396
|
+
binding). If a symbol occurs in the other side as a proper subterm
|
|
397
|
+
(e.g. d against d*e, or d against d**n), it is deferred rather than
|
|
398
|
+
bound: such an equation is satisfiable only in degenerate cases
|
|
399
|
+
(d = d*e holds at e == 1; d = d**n holds at n == 1, or at d == 1 for
|
|
400
|
+
any n), so neither binding it as success nor reporting failure would
|
|
401
|
+
be honest. Everything else is reported as deferred with the pair
|
|
402
|
+
recorded as a residual constraint -- it is never guessed at,
|
|
403
|
+
partially factored, or silently reported as equal.
|
|
404
|
+
"""
|
|
405
|
+
if not isinstance(other, Dim):
|
|
406
|
+
raise TypeError(f"unify() requires a Dim, got {type(other).__name__}")
|
|
407
|
+
if self.is_concrete and other.is_concrete:
|
|
408
|
+
status = UnifyStatus.SUCCESS if self.to_int() == other.to_int() else UnifyStatus.FAILURE
|
|
409
|
+
return UnifyResult(status=status)
|
|
410
|
+
if self._expr == other._expr:
|
|
411
|
+
return UnifyResult(status=UnifyStatus.SUCCESS)
|
|
412
|
+
if self._expr.is_Symbol:
|
|
413
|
+
if str(self._expr.name) in other.free_symbols:
|
|
414
|
+
return UnifyResult(status=UnifyStatus.DEFERRED, constraints=((self, other),))
|
|
415
|
+
return UnifyResult(status=UnifyStatus.SUCCESS, bindings={str(self._expr.name): other})
|
|
416
|
+
if other._expr.is_Symbol:
|
|
417
|
+
if str(other._expr.name) in self.free_symbols:
|
|
418
|
+
return UnifyResult(status=UnifyStatus.DEFERRED, constraints=((self, other),))
|
|
419
|
+
return UnifyResult(status=UnifyStatus.SUCCESS, bindings={str(other._expr.name): self})
|
|
420
|
+
return UnifyResult(status=UnifyStatus.DEFERRED, constraints=((self, other),))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class UnifyStatus(enum.Enum):
|
|
424
|
+
"""The three outcomes the Phase 1 unify placeholder can report."""
|
|
425
|
+
|
|
426
|
+
SUCCESS = "success"
|
|
427
|
+
FAILURE = "failure"
|
|
428
|
+
DEFERRED = "deferred"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
@dataclass(frozen=True)
|
|
432
|
+
class UnifyResult:
|
|
433
|
+
"""The result of Dim.unify: a status plus any bindings or residual constraints found.
|
|
434
|
+
|
|
435
|
+
``bindings`` maps a symbol name to the Dim it was unified with.
|
|
436
|
+
``constraints`` lists pairs of Dims asserted equal that this placeholder
|
|
437
|
+
declined to solve; a real solver (Phase 10) would resolve these further.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
status: UnifyStatus
|
|
441
|
+
bindings: Mapping[str, Dim] = field(default_factory=dict)
|
|
442
|
+
constraints: tuple[tuple[Dim, Dim], ...] = ()
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def is_success(self) -> bool:
|
|
446
|
+
return self.status is UnifyStatus.SUCCESS
|
|
447
|
+
|
|
448
|
+
@property
|
|
449
|
+
def is_failure(self) -> bool:
|
|
450
|
+
return self.status is UnifyStatus.FAILURE
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def is_deferred(self) -> bool:
|
|
454
|
+
return self.status is UnifyStatus.DEFERRED
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
_MAX_UNIFY_ALL_PASSES = 32
|
|
458
|
+
"""Iteration budget for :func:`unify_all`'s bindings fixpoint. Module-level so a test can
|
|
459
|
+
patch it low, mirroring :mod:`archytaszx.rewrite.match`'s ``_MAX_FIXPOINT_PASSES``."""
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@dataclass(frozen=True)
|
|
463
|
+
class UnifyAllResult:
|
|
464
|
+
"""The result of :func:`unify_all`: a status, accumulated bindings, and residual pairs.
|
|
465
|
+
|
|
466
|
+
``residual_pairs`` holds every pair left unresolved once the loop stopped -- either
|
|
467
|
+
with the fixpoint genuinely stabilised on real residual constraints (the ordinary
|
|
468
|
+
``DEFERRED`` case), or with :data:`_MAX_UNIFY_ALL_PASSES` exhausted
|
|
469
|
+
before it could stabilise, in which case ``residual_pairs`` holds whatever was still
|
|
470
|
+
unresolved on that final, non-converged pass -- a snapshot of an interrupted
|
|
471
|
+
computation, not a decided answer.
|
|
472
|
+
|
|
473
|
+
``exhausted`` is the discriminator: ``False`` for an ordinary converged result, ``True``
|
|
474
|
+
only when the pass budget ran out first. Both report ``status=DEFERRED``, and every
|
|
475
|
+
caller must keep them apart -- :mod:`archytaszx.diagram.validate`'s
|
|
476
|
+
``_check_generator_policy`` fails closed on ``exhausted`` with a hard error rather than
|
|
477
|
+
folding it into the ordinary deferred-constraint bookkeeping. Every bounded fixpoint
|
|
478
|
+
here keeps that distinction visible at the call site, not only internally.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
status: UnifyStatus
|
|
482
|
+
bindings: Mapping[str, Dim] = field(default_factory=dict)
|
|
483
|
+
residual_pairs: tuple[tuple[Dim, Dim], ...] = ()
|
|
484
|
+
exhausted: bool = False
|
|
485
|
+
declined_bindings: Mapping[str, Dim] = field(default_factory=dict)
|
|
486
|
+
"""Every binding to a non-concrete ``Dim`` (e.g. ``d := e``) that a pairwise
|
|
487
|
+
:meth:`Dim.unify` call produced but this function declined to fold into ``bindings`` --
|
|
488
|
+
see :func:`unify_all`'s own inline comment for why only concrete bindings are ever
|
|
489
|
+
accumulated for resolution.
|
|
490
|
+
|
|
491
|
+
It exists so a caller can see that an assumption was made even on a ``SUCCESS``: a node
|
|
492
|
+
whose legs unify only via such a binding (legs ``d`` and ``e``) would otherwise report
|
|
493
|
+
``SUCCESS`` with nothing to show for it. Populating it changes no verdict and no
|
|
494
|
+
caller's behavior.
|
|
495
|
+
|
|
496
|
+
:mod:`archytaszx.diagram.validate`'s ``_check_generator_policy`` reports it, together with
|
|
497
|
+
``bindings``, as a deferred
|
|
498
|
+
:class:`~archytaszx.diagram.validate.IssueKind.DIMENSION_BOUND` issue.
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def is_success(self) -> bool:
|
|
503
|
+
return self.status is UnifyStatus.SUCCESS
|
|
504
|
+
|
|
505
|
+
@property
|
|
506
|
+
def is_failure(self) -> bool:
|
|
507
|
+
return self.status is UnifyStatus.FAILURE
|
|
508
|
+
|
|
509
|
+
@property
|
|
510
|
+
def is_deferred(self) -> bool:
|
|
511
|
+
return self.status is UnifyStatus.DEFERRED
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def unify_all(dims: Sequence[Dim]) -> UnifyAllResult:
|
|
515
|
+
"""Resolve a multiset of Dims that must all be pairwise equal to one shared value.
|
|
516
|
+
|
|
517
|
+
Sorts ``dims`` by a canonical key before doing anything else, so the result depends
|
|
518
|
+
only on the multiset of dims given, never their input order. Runs :meth:`Dim.unify`
|
|
519
|
+
to a bounded fixpoint, accumulating concrete bindings monotonically (a name rebound to
|
|
520
|
+
a different concrete value is FAILURE); a binding to a non-concrete Dim is never folded
|
|
521
|
+
into ``bindings`` or substituted through; it is recorded on
|
|
522
|
+
:attr:`UnifyAllResult.declined_bindings` rather than dropped. Returns
|
|
523
|
+
FAILURE on any non-unifiable pair, DEFERRED with every pair still unresolved once the
|
|
524
|
+
fixpoint stabilises, or SUCCESS. A DEFERRED returned when
|
|
525
|
+
:data:`_MAX_UNIFY_ALL_PASSES` ran out first carries ``exhausted=True`` and holds only
|
|
526
|
+
what the final, non-converged pass left unresolved -- see :attr:`UnifyAllResult.exhausted`.
|
|
527
|
+
Raises only :class:`DimensionError` subclasses.
|
|
528
|
+
"""
|
|
529
|
+
ordered = sorted(dims, key=lambda d: sp.srepr(d.to_sympy()))
|
|
530
|
+
if len(ordered) < 2:
|
|
531
|
+
return UnifyAllResult(status=UnifyStatus.SUCCESS)
|
|
532
|
+
|
|
533
|
+
bindings: dict[str, Dim] = {}
|
|
534
|
+
declined: dict[str, Dim] = {}
|
|
535
|
+
residual: dict[str, tuple[Dim, Dim]] = {}
|
|
536
|
+
|
|
537
|
+
for _pass_index in range(_MAX_UNIFY_ALL_PASSES):
|
|
538
|
+
pass_start_bindings = dict(bindings)
|
|
539
|
+
residual = {}
|
|
540
|
+
concrete_bindings = cast(Mapping[DimSymbolKey, DimSubstituteValue], bindings)
|
|
541
|
+
base = ordered[0].substitute(concrete_bindings) if bindings else ordered[0]
|
|
542
|
+
for other in ordered[1:]:
|
|
543
|
+
concrete_bindings = cast(Mapping[DimSymbolKey, DimSubstituteValue], bindings)
|
|
544
|
+
resolved_other = other.substitute(concrete_bindings) if bindings else other
|
|
545
|
+
result = base.unify(resolved_other)
|
|
546
|
+
if result.is_failure:
|
|
547
|
+
return UnifyAllResult(status=UnifyStatus.FAILURE)
|
|
548
|
+
if result.is_deferred:
|
|
549
|
+
key = f"{base!r}|{resolved_other!r}"
|
|
550
|
+
residual[key] = (base, resolved_other)
|
|
551
|
+
continue
|
|
552
|
+
new_concrete = {
|
|
553
|
+
name: value for name, value in result.bindings.items() if value.is_concrete
|
|
554
|
+
}
|
|
555
|
+
declined.update(
|
|
556
|
+
{name: value for name, value in result.bindings.items() if not value.is_concrete}
|
|
557
|
+
)
|
|
558
|
+
for name, value in new_concrete.items():
|
|
559
|
+
existing = bindings.get(name)
|
|
560
|
+
if existing is not None and existing != value:
|
|
561
|
+
return UnifyAllResult(status=UnifyStatus.FAILURE)
|
|
562
|
+
bindings[name] = value
|
|
563
|
+
if new_concrete:
|
|
564
|
+
new_concrete_typed = cast(Mapping[DimSymbolKey, DimSubstituteValue], new_concrete)
|
|
565
|
+
base = base.substitute(new_concrete_typed)
|
|
566
|
+
if bindings == pass_start_bindings:
|
|
567
|
+
if residual:
|
|
568
|
+
return UnifyAllResult(
|
|
569
|
+
status=UnifyStatus.DEFERRED,
|
|
570
|
+
bindings=dict(bindings),
|
|
571
|
+
declined_bindings=dict(declined),
|
|
572
|
+
residual_pairs=tuple(residual.values()),
|
|
573
|
+
)
|
|
574
|
+
return UnifyAllResult(
|
|
575
|
+
status=UnifyStatus.SUCCESS,
|
|
576
|
+
bindings=dict(bindings),
|
|
577
|
+
declined_bindings=dict(declined),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
# Budget exhausted without stabilising: report whatever the final, non-converged pass
|
|
581
|
+
# actually left unresolved, and mark it ``exhausted`` -- see UnifyAllResult's own
|
|
582
|
+
# docstring for why this must not be a bare, contentless DEFERRED indistinguishable from
|
|
583
|
+
# a genuinely converged one.
|
|
584
|
+
return UnifyAllResult(
|
|
585
|
+
status=UnifyStatus.DEFERRED,
|
|
586
|
+
bindings=dict(bindings),
|
|
587
|
+
declined_bindings=dict(declined),
|
|
588
|
+
residual_pairs=tuple(residual.values()),
|
|
589
|
+
exhausted=True,
|
|
590
|
+
)
|