morphis 0.8.0__tar.gz → 0.10.0__tar.gz

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.
Files changed (98) hide show
  1. {morphis-0.8.0 → morphis-0.10.0}/PKG-INFO +1 -1
  2. {morphis-0.8.0 → morphis-0.10.0}/pyproject.toml +7 -1
  3. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/algebra/__init__.py +5 -1
  4. morphis-0.10.0/src/morphis/algebra/contraction.py +271 -0
  5. morphis-0.10.0/src/morphis/algebra/patterns.py +196 -0
  6. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/algebra/solvers.py +35 -36
  7. morphis-0.10.0/src/morphis/algebra/tests/test_contraction.py +224 -0
  8. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_patterns.py +71 -70
  9. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_solvers.py +49 -49
  10. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/__init__.py +7 -1
  11. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/base.py +60 -1
  12. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/frame.py +2 -2
  13. morphis-0.10.0/src/morphis/elements/lot_indexed.py +566 -0
  14. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/multivector.py +44 -0
  15. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/protocols.py +49 -1
  16. morphis-0.10.0/src/morphis/elements/tests/test_maxwell_features.py +337 -0
  17. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/tests/test_model.py +149 -0
  18. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/tests/test_operator.py +82 -74
  19. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/tests/test_operators.py +37 -33
  20. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/vector.py +289 -20
  21. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/exterior.py +8 -8
  22. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/operators.py +45 -39
  23. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/phasors.py +19 -19
  24. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/rotations_3d.py +2 -2
  25. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/rotations_4d.py +3 -3
  26. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/__init__.py +3 -3
  27. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/factorization.py +6 -6
  28. morphis-0.10.0/src/morphis/operations/norms.py +226 -0
  29. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/operator.py +100 -52
  30. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/projections.py +3 -3
  31. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/spectral.py +3 -3
  32. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_matrix_rep.py +11 -10
  33. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_norms.py +46 -46
  34. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_outermorphism.py +50 -45
  35. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/utils/docgen.py +1 -0
  36. morphis-0.8.0/src/morphis/algebra/patterns.py +0 -181
  37. morphis-0.8.0/src/morphis/operations/norms.py +0 -146
  38. {morphis-0.8.0 → morphis-0.10.0}/README.md +0 -0
  39. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/__init__.py +0 -0
  40. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/_legacy/__init__.py +0 -0
  41. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/_legacy/coordinates.py +0 -0
  42. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/_legacy/rotations.py +0 -0
  43. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/_legacy/smoothing.py +0 -0
  44. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/_legacy/vectors.py +0 -0
  45. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/algebra/specs.py +0 -0
  46. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/algebra/tests/__init__.py +0 -0
  47. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_specs.py +0 -0
  48. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/config.py +0 -0
  49. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/metric.py +0 -0
  50. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/operator.py +0 -0
  51. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/tensor.py +0 -0
  52. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/tests/__init__.py +0 -0
  53. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/tests/test_complex_blades.py +0 -0
  54. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/elements/tests/test_tensor.py +0 -0
  55. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/__init__.py +0 -0
  56. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/clifford.py +0 -0
  57. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/duality.py +0 -0
  58. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/examples/transforms_pga.py +0 -0
  59. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/manifold/__init__.py +0 -0
  60. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/_helpers.py +0 -0
  61. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/duality.py +0 -0
  62. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/exponential.py +0 -0
  63. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/matrix_rep.py +0 -0
  64. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/outermorphism.py +0 -0
  65. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/products.py +0 -0
  66. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/structure.py +0 -0
  67. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/subspaces.py +0 -0
  68. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/__init__.py +0 -0
  69. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_complex_operations.py +0 -0
  70. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_duality.py +0 -0
  71. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_exponential.py +0 -0
  72. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_operations.py +0 -0
  73. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_products.py +0 -0
  74. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_spectral.py +0 -0
  75. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/operations/tests/test_structure.py +0 -0
  76. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/topology/__init__.py +0 -0
  77. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/transforms/__init__.py +0 -0
  78. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/transforms/actions.py +0 -0
  79. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/transforms/projective.py +0 -0
  80. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/transforms/rotations.py +0 -0
  81. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/transforms/tests/__init__.py +0 -0
  82. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/transforms/tests/test_projective.py +0 -0
  83. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/utils/__init__.py +0 -0
  84. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/utils/easing.py +0 -0
  85. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/utils/exceptions.py +0 -0
  86. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/utils/observer.py +0 -0
  87. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/utils/pretty.py +0 -0
  88. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/__init__.py +0 -0
  89. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/canvas.py +0 -0
  90. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/contexts.py +0 -0
  91. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/drawing/__init__.py +0 -0
  92. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/drawing/vectors.py +0 -0
  93. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/effects.py +0 -0
  94. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/loop.py +0 -0
  95. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/operations.py +0 -0
  96. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/projection.py +0 -0
  97. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/renderer.py +0 -0
  98. {morphis-0.8.0 → morphis-0.10.0}/src/morphis/visuals/theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphis
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: A unified mathematical framework for geometric computation
5
5
  Keywords: geometric-algebra,mathematics,visualization,multivector,pga
6
6
  Author: ctl-alt-leist
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "morphis"
3
- version = "0.8.0"
3
+ version = "0.10.0"
4
4
  description = "A unified mathematical framework for geometric computation"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -51,3 +51,9 @@ Issues = "https://github.com/ctl-alt-leist/morphis/issues"
51
51
  [build-system]
52
52
  requires = ["uv_build>=0.9.18,<0.10.0"]
53
53
  build-backend = "uv_build"
54
+
55
+ [dependency-groups]
56
+ dev = [
57
+ "pre-commit>=4.5.1",
58
+ "ruff>=0.14.10",
59
+ ]
@@ -2,9 +2,13 @@
2
2
  Linear Algebra Module
3
3
 
4
4
  Provides structured linear algebra utilities for geometric algebra operators.
5
- Includes vector specifications, einsum pattern generation, and solvers.
5
+ Includes vector specifications, einsum pattern generation, solvers, and contraction.
6
6
  """
7
7
 
8
+ from morphis.algebra.contraction import (
9
+ IndexedTensor as IndexedTensor,
10
+ contract as contract,
11
+ )
8
12
  from morphis.algebra.patterns import (
9
13
  INPUT_COLLECTION as INPUT_COLLECTION,
10
14
  INPUT_GEOMETRIC as INPUT_GEOMETRIC,
@@ -0,0 +1,271 @@
1
+ """
2
+ Linear Algebra - Tensor Contraction
3
+
4
+ Provides two contraction APIs for Morphis tensors:
5
+ 1. Index notation: G["mnab"] * q["n"] - bracket syntax with IndexedTensor
6
+ 2. Einsum-style: contract("mnab, n -> mab", G, q) - explicit signature
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from numpy import einsum
14
+
15
+
16
+ if TYPE_CHECKING:
17
+ from morphis.elements.vector import Vector
18
+ from morphis.operations.operator import Operator
19
+
20
+
21
+ # =============================================================================
22
+ # IndexedTensor - Bracket Syntax API
23
+ # =============================================================================
24
+
25
+
26
+ class IndexedTensor:
27
+ """
28
+ Lightweight wrapper that pairs a tensor with index labels for contraction.
29
+
30
+ This class enables einsum-style syntax:
31
+ G["mnab"] * q["n"] # contracts on index 'n'
32
+
33
+ The wrapper holds a reference (not a copy) to the underlying tensor,
34
+ making indexing O(1). Computation only happens when two IndexedTensors
35
+ are multiplied.
36
+
37
+ Attributes:
38
+ tensor: The underlying Vector or Operator (reference, not copy)
39
+ indices: String of index labels (e.g., "mnab")
40
+
41
+ Examples:
42
+ >>> G = Operator(...) # lot=(M, N), grade=2 output
43
+ >>> q = Vector(...) # lot=(N,), grade=0
44
+ >>> b = G["mnab"] * q["n"] # contracts on 'n', result has indices "mab"
45
+ """
46
+
47
+ __slots__ = ("tensor", "indices")
48
+
49
+ def __init__(self, tensor: "Vector | Operator", indices: str):
50
+ """
51
+ Create an indexed tensor wrapper.
52
+
53
+ Args:
54
+ tensor: The underlying Vector or Operator
55
+ indices: String of index labels, one per axis of tensor.data
56
+ """
57
+ self.tensor = tensor
58
+ self.indices = indices
59
+
60
+ # Validate index count matches tensor dimensions
61
+ expected_ndim = tensor.data.ndim
62
+ if len(indices) != expected_ndim:
63
+ raise ValueError(
64
+ f"Index string '{indices}' has {len(indices)} indices, but tensor has {expected_ndim} dimensions"
65
+ )
66
+
67
+ def __mul__(self, other: "IndexedTensor") -> "Vector":
68
+ """
69
+ Contract two indexed tensors on matching indices.
70
+
71
+ Args:
72
+ other: Another IndexedTensor or LotIndexed to contract with
73
+
74
+ Returns:
75
+ Vector with the contracted result
76
+ """
77
+ from morphis.elements.lot_indexed import LotIndexed
78
+
79
+ if isinstance(other, LotIndexed):
80
+ # Convert LotIndexed to IndexedTensor by adding geo indices
81
+ n_geo = other.vector.grade
82
+ geo_labels = "".join(chr(ord("A") + i) for i in range(n_geo))
83
+ other = IndexedTensor(other.vector, other.indices + geo_labels)
84
+
85
+ if not isinstance(other, IndexedTensor):
86
+ return NotImplemented
87
+
88
+ return _contract_indexed(self, other)
89
+
90
+ def __rmul__(self, other: "IndexedTensor") -> "Vector":
91
+ """Right multiplication for contraction."""
92
+ if not isinstance(other, IndexedTensor):
93
+ return NotImplemented
94
+
95
+ return _contract_indexed(other, self)
96
+
97
+ def __repr__(self) -> str:
98
+ tensor_type = type(self.tensor).__name__
99
+ return f"IndexedTensor({tensor_type}, indices='{self.indices}')"
100
+
101
+
102
+ def _contract_indexed(*indexed_tensors: IndexedTensor) -> "Vector":
103
+ """
104
+ Contract multiple IndexedTensor objects.
105
+
106
+ Internal function that performs the actual contraction for bracket syntax.
107
+ """
108
+ from morphis.elements.vector import Vector
109
+
110
+ if len(indexed_tensors) < 2:
111
+ raise ValueError("Contraction requires at least 2 indexed tensors")
112
+
113
+ # Collect all index information
114
+ all_indices = [it.indices for it in indexed_tensors]
115
+ all_data = [it.tensor.data for it in indexed_tensors]
116
+
117
+ # Count index occurrences to determine output indices
118
+ index_counts: dict[str, int] = {}
119
+ for indices in all_indices:
120
+ for idx in indices:
121
+ index_counts[idx] = index_counts.get(idx, 0) + 1
122
+
123
+ # Output indices are those that appear exactly once (not contracted)
124
+ # Preserve order of first appearance
125
+ seen = set()
126
+ output_indices = ""
127
+ for indices in all_indices:
128
+ for idx in indices:
129
+ if idx not in seen:
130
+ seen.add(idx)
131
+ if index_counts[idx] == 1:
132
+ output_indices += idx
133
+
134
+ # Build einsum signature
135
+ input_sig = ",".join(all_indices)
136
+ einsum_sig = f"{input_sig}->{output_indices}"
137
+
138
+ # Perform contraction
139
+ result_data = einsum(einsum_sig, *all_data)
140
+
141
+ # Get metric from first tensor that has one
142
+ metric = None
143
+ for it in indexed_tensors:
144
+ if hasattr(it.tensor, "metric") and it.tensor.metric is not None:
145
+ metric = it.tensor.metric
146
+ break
147
+
148
+ # Infer grade from output
149
+ result_grade = _infer_grade_from_indexed(indexed_tensors, output_indices)
150
+
151
+ return Vector(data=result_data, grade=result_grade, metric=metric)
152
+
153
+
154
+ def _infer_grade_from_indexed(indexed_tensors: tuple[IndexedTensor, ...], output_indices: str) -> int:
155
+ """Infer grade for IndexedTensor contraction result."""
156
+ from morphis.elements.vector import Vector
157
+
158
+ # Track which indices are geometric (vs lot)
159
+ geo_indices = set()
160
+
161
+ for it in indexed_tensors:
162
+ if isinstance(it.tensor, Vector):
163
+ n_lot = len(it.tensor.lot)
164
+ n_geo = it.tensor.grade
165
+ # Geometric indices are the last 'grade' indices
166
+ geo_part = it.indices[n_lot : n_lot + n_geo]
167
+ geo_indices.update(geo_part)
168
+
169
+ # Count geometric indices in output
170
+ result_grade = sum(1 for idx in output_indices if idx in geo_indices)
171
+ return result_grade
172
+
173
+
174
+ # =============================================================================
175
+ # contract() - Einsum-Style API
176
+ # =============================================================================
177
+
178
+
179
+ def contract(signature: str, *tensors: "Vector | Operator") -> "Vector":
180
+ """
181
+ Einsum-style contraction for Morphis tensors.
182
+
183
+ Works exactly like numpy.einsum, but accepts Vector and Operator objects.
184
+ Extracts the underlying data, performs the einsum, and wraps the result
185
+ back into a Vector.
186
+
187
+ Args:
188
+ signature: Einsum signature string (e.g., "mn, n -> m")
189
+ *tensors: Morphis objects (Vector or Operator) to contract
190
+
191
+ Returns:
192
+ Vector containing the contracted result
193
+
194
+ Examples:
195
+ >>> g = euclidean_metric(3)
196
+ >>> u = Vector([1, 2, 3], grade=1, metric=g)
197
+ >>> v = Vector([4, 5, 6], grade=1, metric=g)
198
+
199
+ >>> # Dot product
200
+ >>> s = contract("a, a ->", u, v)
201
+ >>> s.data # 1*4 + 2*5 + 3*6 = 32
202
+
203
+ >>> # Matrix-vector product
204
+ >>> M = Vector(data, grade=2, metric=g) # shape (3, 3)
205
+ >>> w = contract("ab, b -> a", M, v)
206
+
207
+ >>> # Outer product
208
+ >>> outer = contract("a, b -> ab", u, v)
209
+
210
+ >>> # Batch contraction
211
+ >>> G = Vector(data, grade=2, lot=(M, N), metric=g) # shape (M, N, 3, 3)
212
+ >>> q = Vector(data, grade=0, lot=(N,), metric=g) # shape (N,)
213
+ >>> b = contract("mnab, n -> mab", G, q)
214
+ """
215
+ from morphis.elements.vector import Vector
216
+
217
+ if len(tensors) < 1:
218
+ raise ValueError("contract() requires at least 1 tensor")
219
+
220
+ # Extract data arrays from tensors
221
+ data_arrays = [t.data for t in tensors]
222
+
223
+ # Normalize signature: allow spaces around comma and arrow
224
+ sig = signature.replace(" ", "")
225
+
226
+ # Perform einsum
227
+ result_data = einsum(sig, *data_arrays)
228
+
229
+ # Get metric from first tensor that has one
230
+ metric = None
231
+ for t in tensors:
232
+ if hasattr(t, "metric") and t.metric is not None:
233
+ metric = t.metric
234
+ break
235
+
236
+ # Infer grade from output shape
237
+ result_grade = _infer_grade_from_signature(sig, tensors, result_data)
238
+
239
+ return Vector(data=result_data, grade=result_grade, metric=metric)
240
+
241
+
242
+ def _infer_grade_from_signature(signature: str, tensors: tuple, result_data) -> int:
243
+ """Infer grade for einsum-style contraction result."""
244
+ from morphis.elements.vector import Vector
245
+
246
+ # Parse signature to get output indices
247
+ if "->" in signature:
248
+ input_part, output_indices = signature.split("->")
249
+ else:
250
+ # No explicit output - numpy determines it
251
+ return 0 if result_data.ndim == 0 else result_data.ndim
252
+
253
+ input_parts = input_part.split(",")
254
+
255
+ # Track which indices are geometric (vs lot)
256
+ geo_indices = set()
257
+
258
+ for k, t in enumerate(tensors):
259
+ if k < len(input_parts) and isinstance(t, Vector):
260
+ indices = input_parts[k]
261
+ n_lot = len(t.lot)
262
+ n_geo = t.grade
263
+ # Geometric indices are the last 'grade' indices
264
+ if len(indices) >= n_lot + n_geo:
265
+ geo_part = indices[n_lot : n_lot + n_geo]
266
+ geo_indices.update(geo_part)
267
+
268
+ # Count geometric indices in output
269
+ result_grade = sum(1 for idx in output_indices if idx in geo_indices)
270
+
271
+ return result_grade
@@ -0,0 +1,196 @@
1
+ """
2
+ Linear Algebra - Einsum Pattern Generation
3
+
4
+ Generates einsum signatures for linear operator operations. Uses disjoint index
5
+ pools to avoid collisions between input and output indices.
6
+
7
+ Index naming convention:
8
+ - OUTPUT_LOT: "KLMN" (up to 4 output lot dims)
9
+ - INPUT_LOT: "nopq" (up to 4 input lot dims)
10
+ - OUTPUT_GEOMETRIC: "WXYZ" (up to grade-4 output blades)
11
+ - INPUT_GEOMETRIC: "abcd" (up to grade-4 input blades)
12
+
13
+ Storage conventions (lot-first, matching Vector layout):
14
+ - Operator: (*out_lot, *in_lot, *out_geo, *in_geo)
15
+ - Vector: (*lot, *geo)
16
+ """
17
+
18
+ from functools import lru_cache
19
+
20
+ from morphis.algebra.specs import VectorSpec
21
+
22
+
23
+ # Index pools (disjoint to avoid collisions)
24
+ OUTPUT_LOT = "KLMN"
25
+ INPUT_LOT = "nopq"
26
+ OUTPUT_GEOMETRIC = "WXYZ"
27
+ INPUT_GEOMETRIC = "abcd"
28
+
29
+ # Backwards compatibility aliases
30
+ OUTPUT_COLLECTION = OUTPUT_LOT
31
+ INPUT_COLLECTION = INPUT_LOT
32
+
33
+
34
+ @lru_cache(maxsize=128)
35
+ def forward_signature(input_spec: VectorSpec, output_spec: VectorSpec) -> str:
36
+ """
37
+ Generate einsum signature for forward operator application: y = L * x
38
+
39
+ Operator data has shape: (*out_lot, *in_lot, *out_geo, *in_geo)
40
+ Input Vector has shape: (*in_lot, *in_geo)
41
+ Output Vector has shape: (*out_lot, *out_geo)
42
+
43
+ Args:
44
+ input_spec: Specification of input Vector
45
+ output_spec: Specification of output Vector
46
+
47
+ Returns:
48
+ Einsum signature string, e.g., "KnWX,n->KWX" for scalar->bivector
49
+
50
+ Examples:
51
+ >>> # Scalar currents (N,) to bivector fields (M, 3, 3)
52
+ >>> sig = forward_signature(
53
+ ... VectorSpec(grade=0, lot=(1,), dim=3),
54
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
55
+ ... )
56
+ >>> sig
57
+ 'KnWX,n->KWX'
58
+
59
+ >>> # Vector (N, 3) to bivector (M, 3, 3)
60
+ >>> sig = forward_signature(
61
+ ... VectorSpec(grade=1, lot=(1,), dim=3),
62
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
63
+ ... )
64
+ >>> sig
65
+ 'KnWXa,na->KWX'
66
+ """
67
+ _validate_spec_limits(input_spec, output_spec)
68
+
69
+ # Output lot indices
70
+ out_lot = OUTPUT_LOT[: output_spec.collection]
71
+
72
+ # Input lot indices (contracted)
73
+ in_lot = INPUT_LOT[: input_spec.collection]
74
+
75
+ # Output geometric indices
76
+ out_geo = OUTPUT_GEOMETRIC[: output_spec.grade]
77
+
78
+ # Input geometric indices (contracted)
79
+ in_geo = INPUT_GEOMETRIC[: input_spec.grade]
80
+
81
+ # Build operator signature: out_lot + in_lot + out_geo + in_geo (lot-first)
82
+ op_indices = out_lot + in_lot + out_geo + in_geo
83
+
84
+ # Build input signature: in_lot + in_geo (Vector storage order)
85
+ input_indices = in_lot + in_geo
86
+
87
+ # Build output signature: out_lot + out_geo (Vector storage order)
88
+ output_indices = out_lot + out_geo
89
+
90
+ return f"{op_indices},{input_indices}->{output_indices}"
91
+
92
+
93
+ @lru_cache(maxsize=128)
94
+ def adjoint_signature(input_spec: VectorSpec, output_spec: VectorSpec) -> str:
95
+ """
96
+ Generate einsum signature for adjoint operator application: x = L^H * y
97
+
98
+ The adjoint contracts over output indices (what were previously the result).
99
+
100
+ Args:
101
+ input_spec: Specification of original input (becomes adjoint output)
102
+ output_spec: Specification of original output (becomes adjoint input)
103
+
104
+ Returns:
105
+ Einsum signature string for adjoint application
106
+
107
+ Examples:
108
+ >>> # Adjoint of scalar->bivector: bivector->scalar
109
+ >>> sig = adjoint_signature(
110
+ ... VectorSpec(grade=0, lot=(1,), dim=3),
111
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
112
+ ... )
113
+ >>> sig
114
+ 'KnWX,KWX->n'
115
+ """
116
+ _validate_spec_limits(input_spec, output_spec)
117
+
118
+ # Same index allocation as forward
119
+ out_lot = OUTPUT_LOT[: output_spec.collection]
120
+ in_lot = INPUT_LOT[: input_spec.collection]
121
+ out_geo = OUTPUT_GEOMETRIC[: output_spec.grade]
122
+ in_geo = INPUT_GEOMETRIC[: input_spec.grade]
123
+
124
+ # Operator indices: out_lot + in_lot + out_geo + in_geo (lot-first)
125
+ op_indices = out_lot + in_lot + out_geo + in_geo
126
+
127
+ # Adjoint input (original output vec): out_lot + out_geo
128
+ adjoint_input = out_lot + out_geo
129
+
130
+ # Adjoint output (original input space): in_lot + in_geo
131
+ adjoint_output = in_lot + in_geo
132
+
133
+ return f"{op_indices},{adjoint_input}->{adjoint_output}"
134
+
135
+
136
+ def operator_shape(
137
+ input_spec: VectorSpec,
138
+ output_spec: VectorSpec,
139
+ input_lot: tuple[int, ...] | None = None,
140
+ output_lot: tuple[int, ...] | None = None,
141
+ *,
142
+ input_collection: tuple[int, ...] | None = None,
143
+ output_collection: tuple[int, ...] | None = None,
144
+ ) -> tuple[int, ...]:
145
+ """
146
+ Compute the expected shape of operator data given specs and lot shapes.
147
+
148
+ Args:
149
+ input_spec: Specification of input Vector
150
+ output_spec: Specification of output Vector
151
+ input_lot: Shape of input lot dimensions
152
+ output_lot: Shape of output lot dimensions
153
+ input_collection: DEPRECATED alias for input_lot
154
+ output_collection: DEPRECATED alias for output_lot
155
+
156
+ Returns:
157
+ Operator data shape: (*out_lot, *in_lot, *out_geo, *in_geo)
158
+
159
+ Examples:
160
+ >>> # Scalar (N=5) to bivector (M=10) in 3D
161
+ >>> shape = operator_shape(
162
+ ... VectorSpec(grade=0, lot=(1,), dim=3),
163
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
164
+ ... input_lot=(5,),
165
+ ... output_lot=(10,),
166
+ ... )
167
+ >>> shape
168
+ (10, 5, 3, 3)
169
+ """
170
+ # Handle deprecated aliases
171
+ if input_lot is None and input_collection is not None:
172
+ input_lot = input_collection
173
+ if output_lot is None and output_collection is not None:
174
+ output_lot = output_collection
175
+
176
+ if input_lot is None or output_lot is None:
177
+ raise ValueError("input_lot and output_lot are required")
178
+
179
+ if len(input_lot) != input_spec.collection:
180
+ raise ValueError(f"input_lot has {len(input_lot)} dims, but input_spec expects {input_spec.collection}")
181
+ if len(output_lot) != output_spec.collection:
182
+ raise ValueError(f"output_lot has {len(output_lot)} dims, but output_spec expects {output_spec.collection}")
183
+
184
+ return output_lot + input_lot + output_spec.geo + input_spec.geo
185
+
186
+
187
+ def _validate_spec_limits(input_spec: VectorSpec, output_spec: VectorSpec) -> None:
188
+ """Validate that specs are within index pool limits."""
189
+ if input_spec.grade > len(INPUT_GEOMETRIC):
190
+ raise ValueError(f"Input grade {input_spec.grade} exceeds index pool limit {len(INPUT_GEOMETRIC)}")
191
+ if output_spec.grade > len(OUTPUT_GEOMETRIC):
192
+ raise ValueError(f"Output grade {output_spec.grade} exceeds index pool limit {len(OUTPUT_GEOMETRIC)}")
193
+ if input_spec.collection > len(INPUT_LOT):
194
+ raise ValueError(f"Input lot dims {input_spec.collection} exceeds limit {len(INPUT_LOT)}")
195
+ if output_spec.collection > len(OUTPUT_LOT):
196
+ raise ValueError(f"Output lot dims {output_spec.collection} exceeds limit {len(OUTPUT_LOT)}")
@@ -30,7 +30,7 @@ def _to_matrix(op: "Operator") -> NDArray:
30
30
  """
31
31
  Flatten operator to matrix form for linear algebra operations.
32
32
 
33
- Reshapes from (*out_geo, *out_coll, *in_coll, *in_geo) to (out_flat, in_flat).
33
+ Reshapes from (*out_lot, *in_lot, *out_geo, *in_geo) to (out_flat, in_flat).
34
34
 
35
35
  Args:
36
36
  op: Operator to flatten
@@ -41,22 +41,20 @@ def _to_matrix(op: "Operator") -> NDArray:
41
41
  out_flat = int(prod(op.output_shape))
42
42
  in_flat = int(prod(op.input_shape))
43
43
 
44
- # We need to reorder axes to group output together and input together
45
- # Current: (*out_geo, *out_coll, *in_coll, *in_geo)
46
- # Target for reshape: (*out_coll, *out_geo, *in_coll, *in_geo)
47
- # But we want blade order: (*coll, *geo), so:
48
- # Target: (*out_coll, *out_geo, *in_coll, *in_geo)
49
-
50
- out_geo_axes = list(range(op.output_spec.grade))
51
- out_coll_start = op.output_spec.grade
52
- out_coll_axes = list(range(out_coll_start, out_coll_start + op.output_spec.collection))
53
- in_coll_start = out_coll_start + op.output_spec.collection
54
- in_coll_axes = list(range(in_coll_start, in_coll_start + op.input_spec.collection))
55
- in_geo_start = in_coll_start + op.input_spec.collection
44
+ # Current lot-first layout: (*out_lot, *in_lot, *out_geo, *in_geo)
45
+ # Target for reshape: (*out_lot, *out_geo, *in_lot, *in_geo)
46
+ # This groups output dims together (lot, geo) and input dims together (lot, geo)
47
+
48
+ out_lot_axes = list(range(op.output_spec.collection))
49
+ in_lot_start = op.output_spec.collection
50
+ in_lot_axes = list(range(in_lot_start, in_lot_start + op.input_spec.collection))
51
+ out_geo_start = in_lot_start + op.input_spec.collection
52
+ out_geo_axes = list(range(out_geo_start, out_geo_start + op.output_spec.grade))
53
+ in_geo_start = out_geo_start + op.output_spec.grade
56
54
  in_geo_axes = list(range(in_geo_start, in_geo_start + op.input_spec.grade))
57
55
 
58
- # Reorder to: (*out_coll, *out_geo, *in_coll, *in_geo)
59
- perm = out_coll_axes + out_geo_axes + in_coll_axes + in_geo_axes
56
+ # Reorder to: (*out_lot, *out_geo, *in_lot, *in_geo)
57
+ perm = out_lot_axes + out_geo_axes + in_lot_axes + in_geo_axes
60
58
  reordered = op.data.transpose(perm)
61
59
 
62
60
  return reordered.reshape(out_flat, in_flat)
@@ -86,22 +84,22 @@ def _from_matrix(
86
84
  """
87
85
  from morphis.operations.operator import Operator
88
86
 
89
- # Reshape from (out_flat, in_flat) to (*out_coll, *out_geo, *in_coll, *in_geo)
87
+ # Reshape from (out_flat, in_flat) to (*out_lot, *out_geo, *in_lot, *in_geo)
90
88
  intermediate_shape = output_collection + output_spec.geometric_shape + input_collection + input_spec.geometric_shape
91
89
  tensor = matrix.reshape(intermediate_shape)
92
90
 
93
- # Reorder from (*out_coll, *out_geo, *in_coll, *in_geo)
94
- # to (*out_geo, *out_coll, *in_coll, *in_geo)
95
- out_coll_axes = list(range(output_spec.collection))
91
+ # Reorder from (*out_lot, *out_geo, *in_lot, *in_geo)
92
+ # to (*out_lot, *in_lot, *out_geo, *in_geo)
93
+ out_lot_axes = list(range(output_spec.collection))
96
94
  out_geo_start = output_spec.collection
97
95
  out_geo_axes = list(range(out_geo_start, out_geo_start + output_spec.grade))
98
- in_coll_start = out_geo_start + output_spec.grade
99
- in_coll_axes = list(range(in_coll_start, in_coll_start + input_spec.collection))
100
- in_geo_start = in_coll_start + input_spec.collection
96
+ in_lot_start = out_geo_start + output_spec.grade
97
+ in_lot_axes = list(range(in_lot_start, in_lot_start + input_spec.collection))
98
+ in_geo_start = in_lot_start + input_spec.collection
101
99
  in_geo_axes = list(range(in_geo_start, in_geo_start + input_spec.grade))
102
100
 
103
- # Target order: (*out_geo, *out_coll, *in_coll, *in_geo)
104
- perm = out_geo_axes + out_coll_axes + in_coll_axes + in_geo_axes
101
+ # Target order: (*out_lot, *in_lot, *out_geo, *in_geo)
102
+ perm = out_lot_axes + in_lot_axes + out_geo_axes + in_geo_axes
105
103
  data = tensor.transpose(perm)
106
104
 
107
105
  return Operator(
@@ -266,24 +264,25 @@ def structured_svd(
266
264
  dim = op.output_spec.dim
267
265
 
268
266
  # U maps from reduced space (r,) to output space
269
- u_input_spec = VectorSpec(grade=0, collection=1, dim=dim)
267
+ u_input_spec = VectorSpec(grade=0, lot=(1,), dim=dim)
270
268
 
271
269
  # Vt maps from input space to reduced space (r,)
272
- vt_output_spec = VectorSpec(grade=0, collection=1, dim=dim)
270
+ vt_output_spec = VectorSpec(grade=0, lot=(1,), dim=dim)
273
271
 
274
272
  # Wrap U as Operator
275
273
  # U_mat has shape (out_flat, r)
276
- # Need to reshape to (*out_coll, *out_geo, r)
277
- # Then reorder to (*out_geo, *out_coll, r)
274
+ # Reshape to (*out_lot, *out_geo, r) then reorder to lot-first: (*out_lot, r, *out_geo)
275
+ # out_flat = prod(out_lot) * prod(out_geo), input is (r,) which is just lot dims
278
276
  U_intermediate = U_mat.reshape(op.output_collection + op.output_spec.geometric_shape + (r,))
279
277
 
280
- out_coll_axes = list(range(op.output_spec.collection))
278
+ out_lot_axes = list(range(op.output_spec.collection))
281
279
  out_geo_start = op.output_spec.collection
282
280
  out_geo_axes = list(range(out_geo_start, out_geo_start + op.output_spec.grade))
283
281
  r_axis = [out_geo_start + op.output_spec.grade]
284
282
 
285
- # Target: (*out_geo, *out_coll, r)
286
- U_perm = out_geo_axes + out_coll_axes + r_axis
283
+ # Target lot-first: (*out_lot, r, *out_geo, no_in_geo)
284
+ # Since in_spec is grade=0, no in_geo axes
285
+ U_perm = out_lot_axes + r_axis + out_geo_axes
287
286
  U_data = U_intermediate.transpose(U_perm)
288
287
 
289
288
  U_op = Operator(
@@ -295,13 +294,13 @@ def structured_svd(
295
294
 
296
295
  # Wrap Vt as Operator
297
296
  # Vt_mat has shape (r, in_flat)
298
- # Need to reshape to (r, *in_coll, *in_geo)
297
+ # Reshape to (r, *in_lot, *in_geo)
299
298
  Vt_intermediate = Vt_mat.reshape((r,) + op.input_collection + op.input_spec.geometric_shape)
300
299
 
301
- # Current order: (r, *in_coll, *in_geo)
302
- # Target order for operator data: (*out_geo, *out_coll, *in_coll, *in_geo)
303
- # For Vt, out is reduced (grade=0, coll_dims=1) so no out_geo, out_coll = (r,)
304
- # So target: (r, *in_coll, *in_geo) which is already correct!
300
+ # Current order: (r, *in_lot, *in_geo)
301
+ # Target lot-first: (*out_lot, *in_lot, *out_geo, *in_geo)
302
+ # For Vt, out is (r,) so out_lot=(r,) and out_geo=() since grade=0
303
+ # So target: (r, *in_lot, *in_geo) which is already correct!
305
304
  Vt_data = Vt_intermediate
306
305
 
307
306
  Vt_op = Operator(