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 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
+ )