morphis 0.7.0__py3-none-any.whl → 0.9.0__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.
Files changed (38) hide show
  1. morphis/algebra/__init__.py +5 -1
  2. morphis/algebra/contraction.py +263 -0
  3. morphis/algebra/patterns.py +82 -67
  4. morphis/algebra/solvers.py +35 -36
  5. morphis/algebra/specs.py +94 -46
  6. morphis/algebra/tests/test_contraction.py +224 -0
  7. morphis/algebra/tests/test_patterns.py +71 -70
  8. morphis/algebra/tests/test_solvers.py +49 -49
  9. morphis/algebra/tests/test_specs.py +96 -64
  10. morphis/elements/__init__.py +6 -1
  11. morphis/elements/base.py +70 -5
  12. morphis/elements/frame.py +24 -26
  13. morphis/elements/multivector.py +67 -25
  14. morphis/elements/protocols.py +49 -1
  15. morphis/elements/tensor.py +37 -26
  16. morphis/elements/tests/test_model.py +193 -25
  17. morphis/elements/tests/test_operator.py +82 -74
  18. morphis/elements/tests/test_operators.py +37 -33
  19. morphis/elements/vector.py +393 -25
  20. morphis/examples/exterior.py +8 -8
  21. morphis/examples/operators.py +45 -39
  22. morphis/examples/phasors.py +19 -19
  23. morphis/examples/rotations_3d.py +2 -2
  24. morphis/examples/rotations_4d.py +3 -3
  25. morphis/operations/__init__.py +3 -3
  26. morphis/operations/factorization.py +6 -6
  27. morphis/operations/norms.py +133 -53
  28. morphis/operations/operator.py +100 -52
  29. morphis/operations/projections.py +3 -3
  30. morphis/operations/spectral.py +3 -3
  31. morphis/operations/tests/test_matrix_rep.py +11 -10
  32. morphis/operations/tests/test_norms.py +46 -46
  33. morphis/operations/tests/test_outermorphism.py +50 -45
  34. morphis/utils/docgen.py +1 -0
  35. {morphis-0.7.0.dist-info → morphis-0.9.0.dist-info}/METADATA +1 -1
  36. {morphis-0.7.0.dist-info → morphis-0.9.0.dist-info}/RECORD +38 -36
  37. {morphis-0.7.0.dist-info → morphis-0.9.0.dist-info}/WHEEL +1 -1
  38. {morphis-0.7.0.dist-info → morphis-0.9.0.dist-info}/entry_points.txt +0 -0
@@ -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,263 @@
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 to contract with
73
+
74
+ Returns:
75
+ Vector with the contracted result
76
+ """
77
+ if not isinstance(other, IndexedTensor):
78
+ return NotImplemented
79
+
80
+ return _contract_indexed(self, other)
81
+
82
+ def __rmul__(self, other: "IndexedTensor") -> "Vector":
83
+ """Right multiplication for contraction."""
84
+ if not isinstance(other, IndexedTensor):
85
+ return NotImplemented
86
+
87
+ return _contract_indexed(other, self)
88
+
89
+ def __repr__(self) -> str:
90
+ tensor_type = type(self.tensor).__name__
91
+ return f"IndexedTensor({tensor_type}, indices='{self.indices}')"
92
+
93
+
94
+ def _contract_indexed(*indexed_tensors: IndexedTensor) -> "Vector":
95
+ """
96
+ Contract multiple IndexedTensor objects.
97
+
98
+ Internal function that performs the actual contraction for bracket syntax.
99
+ """
100
+ from morphis.elements.vector import Vector
101
+
102
+ if len(indexed_tensors) < 2:
103
+ raise ValueError("Contraction requires at least 2 indexed tensors")
104
+
105
+ # Collect all index information
106
+ all_indices = [it.indices for it in indexed_tensors]
107
+ all_data = [it.tensor.data for it in indexed_tensors]
108
+
109
+ # Count index occurrences to determine output indices
110
+ index_counts: dict[str, int] = {}
111
+ for indices in all_indices:
112
+ for idx in indices:
113
+ index_counts[idx] = index_counts.get(idx, 0) + 1
114
+
115
+ # Output indices are those that appear exactly once (not contracted)
116
+ # Preserve order of first appearance
117
+ seen = set()
118
+ output_indices = ""
119
+ for indices in all_indices:
120
+ for idx in indices:
121
+ if idx not in seen:
122
+ seen.add(idx)
123
+ if index_counts[idx] == 1:
124
+ output_indices += idx
125
+
126
+ # Build einsum signature
127
+ input_sig = ",".join(all_indices)
128
+ einsum_sig = f"{input_sig}->{output_indices}"
129
+
130
+ # Perform contraction
131
+ result_data = einsum(einsum_sig, *all_data)
132
+
133
+ # Get metric from first tensor that has one
134
+ metric = None
135
+ for it in indexed_tensors:
136
+ if hasattr(it.tensor, "metric") and it.tensor.metric is not None:
137
+ metric = it.tensor.metric
138
+ break
139
+
140
+ # Infer grade from output
141
+ result_grade = _infer_grade_from_indexed(indexed_tensors, output_indices)
142
+
143
+ return Vector(data=result_data, grade=result_grade, metric=metric)
144
+
145
+
146
+ def _infer_grade_from_indexed(indexed_tensors: tuple[IndexedTensor, ...], output_indices: str) -> int:
147
+ """Infer grade for IndexedTensor contraction result."""
148
+ from morphis.elements.vector import Vector
149
+
150
+ # Track which indices are geometric (vs lot)
151
+ geo_indices = set()
152
+
153
+ for it in indexed_tensors:
154
+ if isinstance(it.tensor, Vector):
155
+ n_lot = len(it.tensor.lot)
156
+ n_geo = it.tensor.grade
157
+ # Geometric indices are the last 'grade' indices
158
+ geo_part = it.indices[n_lot : n_lot + n_geo]
159
+ geo_indices.update(geo_part)
160
+
161
+ # Count geometric indices in output
162
+ result_grade = sum(1 for idx in output_indices if idx in geo_indices)
163
+ return result_grade
164
+
165
+
166
+ # =============================================================================
167
+ # contract() - Einsum-Style API
168
+ # =============================================================================
169
+
170
+
171
+ def contract(signature: str, *tensors: "Vector | Operator") -> "Vector":
172
+ """
173
+ Einsum-style contraction for Morphis tensors.
174
+
175
+ Works exactly like numpy.einsum, but accepts Vector and Operator objects.
176
+ Extracts the underlying data, performs the einsum, and wraps the result
177
+ back into a Vector.
178
+
179
+ Args:
180
+ signature: Einsum signature string (e.g., "mn, n -> m")
181
+ *tensors: Morphis objects (Vector or Operator) to contract
182
+
183
+ Returns:
184
+ Vector containing the contracted result
185
+
186
+ Examples:
187
+ >>> g = euclidean_metric(3)
188
+ >>> u = Vector([1, 2, 3], grade=1, metric=g)
189
+ >>> v = Vector([4, 5, 6], grade=1, metric=g)
190
+
191
+ >>> # Dot product
192
+ >>> s = contract("a, a ->", u, v)
193
+ >>> s.data # 1*4 + 2*5 + 3*6 = 32
194
+
195
+ >>> # Matrix-vector product
196
+ >>> M = Vector(data, grade=2, metric=g) # shape (3, 3)
197
+ >>> w = contract("ab, b -> a", M, v)
198
+
199
+ >>> # Outer product
200
+ >>> outer = contract("a, b -> ab", u, v)
201
+
202
+ >>> # Batch contraction
203
+ >>> G = Vector(data, grade=2, lot=(M, N), metric=g) # shape (M, N, 3, 3)
204
+ >>> q = Vector(data, grade=0, lot=(N,), metric=g) # shape (N,)
205
+ >>> b = contract("mnab, n -> mab", G, q)
206
+ """
207
+ from morphis.elements.vector import Vector
208
+
209
+ if len(tensors) < 1:
210
+ raise ValueError("contract() requires at least 1 tensor")
211
+
212
+ # Extract data arrays from tensors
213
+ data_arrays = [t.data for t in tensors]
214
+
215
+ # Normalize signature: allow spaces around comma and arrow
216
+ sig = signature.replace(" ", "")
217
+
218
+ # Perform einsum
219
+ result_data = einsum(sig, *data_arrays)
220
+
221
+ # Get metric from first tensor that has one
222
+ metric = None
223
+ for t in tensors:
224
+ if hasattr(t, "metric") and t.metric is not None:
225
+ metric = t.metric
226
+ break
227
+
228
+ # Infer grade from output shape
229
+ result_grade = _infer_grade_from_signature(sig, tensors, result_data)
230
+
231
+ return Vector(data=result_data, grade=result_grade, metric=metric)
232
+
233
+
234
+ def _infer_grade_from_signature(signature: str, tensors: tuple, result_data) -> int:
235
+ """Infer grade for einsum-style contraction result."""
236
+ from morphis.elements.vector import Vector
237
+
238
+ # Parse signature to get output indices
239
+ if "->" in signature:
240
+ input_part, output_indices = signature.split("->")
241
+ else:
242
+ # No explicit output - numpy determines it
243
+ return 0 if result_data.ndim == 0 else result_data.ndim
244
+
245
+ input_parts = input_part.split(",")
246
+
247
+ # Track which indices are geometric (vs lot)
248
+ geo_indices = set()
249
+
250
+ for k, t in enumerate(tensors):
251
+ if k < len(input_parts) and isinstance(t, Vector):
252
+ indices = input_parts[k]
253
+ n_lot = len(t.lot)
254
+ n_geo = t.grade
255
+ # Geometric indices are the last 'grade' indices
256
+ if len(indices) >= n_lot + n_geo:
257
+ geo_part = indices[n_lot : n_lot + n_geo]
258
+ geo_indices.update(geo_part)
259
+
260
+ # Count geometric indices in output
261
+ result_grade = sum(1 for idx in output_indices if idx in geo_indices)
262
+
263
+ return result_grade
@@ -5,13 +5,14 @@ Generates einsum signatures for linear operator operations. Uses disjoint index
5
5
  pools to avoid collisions between input and output indices.
6
6
 
7
7
  Index naming convention:
8
+ - OUTPUT_LOT: "KLMN" (up to 4 output lot dims)
9
+ - INPUT_LOT: "nopq" (up to 4 input lot dims)
8
10
  - OUTPUT_GEOMETRIC: "WXYZ" (up to grade-4 output blades)
9
- - OUTPUT_COLLECTION: "KLMN" (up to 4 output collection dims)
10
- - INPUT_COLLECTION: "nopq" (up to 4 input collection dims)
11
11
  - INPUT_GEOMETRIC: "abcd" (up to grade-4 input blades)
12
12
 
13
- Operator storage convention: (*output_geometric, *output_collection, *input_collection, *input_geometric)
14
- Vector storage convention: (*collection, *geometric)
13
+ Storage conventions (lot-first, matching Vector layout):
14
+ - Operator: (*out_lot, *in_lot, *out_geo, *in_geo)
15
+ - Vector: (*lot, *geo)
15
16
  """
16
17
 
17
18
  from functools import lru_cache
@@ -20,67 +21,71 @@ from morphis.algebra.specs import VectorSpec
20
21
 
21
22
 
22
23
  # Index pools (disjoint to avoid collisions)
24
+ OUTPUT_LOT = "KLMN"
25
+ INPUT_LOT = "nopq"
23
26
  OUTPUT_GEOMETRIC = "WXYZ"
24
- OUTPUT_COLLECTION = "KLMN"
25
- INPUT_COLLECTION = "nopq"
26
27
  INPUT_GEOMETRIC = "abcd"
27
28
 
29
+ # Backwards compatibility aliases
30
+ OUTPUT_COLLECTION = OUTPUT_LOT
31
+ INPUT_COLLECTION = INPUT_LOT
32
+
28
33
 
29
34
  @lru_cache(maxsize=128)
30
35
  def forward_signature(input_spec: VectorSpec, output_spec: VectorSpec) -> str:
31
36
  """
32
37
  Generate einsum signature for forward operator application: y = L * x
33
38
 
34
- Operator data has shape: (*output_geometric, *output_collection, *input_collection, *input_geometric)
35
- Input blade has shape: (*input_collection, *input_geometric)
36
- Output blade has shape: (*output_collection, *output_geometric)
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)
37
42
 
38
43
  Args:
39
- input_spec: Specification of input blade
40
- output_spec: Specification of output blade
44
+ input_spec: Specification of input Vector
45
+ output_spec: Specification of output Vector
41
46
 
42
47
  Returns:
43
- Einsum signature string, e.g., "WXKn,n->KWX" for scalar->bivector
48
+ Einsum signature string, e.g., "KnWX,n->KWX" for scalar->bivector
44
49
 
45
50
  Examples:
46
51
  >>> # Scalar currents (N,) to bivector fields (M, 3, 3)
47
52
  >>> sig = forward_signature(
48
- ... VectorSpec(grade=0, collection=1, dim=3),
49
- ... VectorSpec(grade=2, collection=1, dim=3),
53
+ ... VectorSpec(grade=0, lot=(1,), dim=3),
54
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
50
55
  ... )
51
56
  >>> sig
52
- 'WXKn,n->KWX'
57
+ 'KnWX,n->KWX'
53
58
 
54
59
  >>> # Vector (N, 3) to bivector (M, 3, 3)
55
60
  >>> sig = forward_signature(
56
- ... VectorSpec(grade=1, collection=1, dim=3),
57
- ... VectorSpec(grade=2, collection=1, dim=3),
61
+ ... VectorSpec(grade=1, lot=(1,), dim=3),
62
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
58
63
  ... )
59
64
  >>> sig
60
- 'WXKna,na->KWX'
65
+ 'KnWXa,na->KWX'
61
66
  """
62
67
  _validate_spec_limits(input_spec, output_spec)
63
68
 
64
- # Output geometric indices (stored first in operator)
65
- out_geo = OUTPUT_GEOMETRIC[: output_spec.grade]
69
+ # Output lot indices
70
+ out_lot = OUTPUT_LOT[: output_spec.collection]
66
71
 
67
- # Output collection indices
68
- out_coll = OUTPUT_COLLECTION[: output_spec.collection]
72
+ # Input lot indices (contracted)
73
+ in_lot = INPUT_LOT[: input_spec.collection]
69
74
 
70
- # Input collection indices (contracted)
71
- in_coll = INPUT_COLLECTION[: input_spec.collection]
75
+ # Output geometric indices
76
+ out_geo = OUTPUT_GEOMETRIC[: output_spec.grade]
72
77
 
73
78
  # Input geometric indices (contracted)
74
79
  in_geo = INPUT_GEOMETRIC[: input_spec.grade]
75
80
 
76
- # Build operator signature: out_geo + out_coll + in_coll + in_geo
77
- op_indices = out_geo + out_coll + in_coll + in_geo
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
78
83
 
79
- # Build input signature: in_coll + in_geo (blade storage order)
80
- input_indices = in_coll + in_geo
84
+ # Build input signature: in_lot + in_geo (Vector storage order)
85
+ input_indices = in_lot + in_geo
81
86
 
82
- # Build output signature: out_coll + out_geo (blade storage order)
83
- output_indices = out_coll + out_geo
87
+ # Build output signature: out_lot + out_geo (Vector storage order)
88
+ output_indices = out_lot + out_geo
84
89
 
85
90
  return f"{op_indices},{input_indices}->{output_indices}"
86
91
 
@@ -102,28 +107,28 @@ def adjoint_signature(input_spec: VectorSpec, output_spec: VectorSpec) -> str:
102
107
  Examples:
103
108
  >>> # Adjoint of scalar->bivector: bivector->scalar
104
109
  >>> sig = adjoint_signature(
105
- ... VectorSpec(grade=0, collection=1, dim=3),
106
- ... VectorSpec(grade=2, collection=1, dim=3),
110
+ ... VectorSpec(grade=0, lot=(1,), dim=3),
111
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
107
112
  ... )
108
113
  >>> sig
109
- 'WXKn,KWX->n'
114
+ 'KnWX,KWX->n'
110
115
  """
111
116
  _validate_spec_limits(input_spec, output_spec)
112
117
 
113
118
  # Same index allocation as forward
119
+ out_lot = OUTPUT_LOT[: output_spec.collection]
120
+ in_lot = INPUT_LOT[: input_spec.collection]
114
121
  out_geo = OUTPUT_GEOMETRIC[: output_spec.grade]
115
- out_coll = OUTPUT_COLLECTION[: output_spec.collection]
116
- in_coll = INPUT_COLLECTION[: input_spec.collection]
117
122
  in_geo = INPUT_GEOMETRIC[: input_spec.grade]
118
123
 
119
- # Operator indices unchanged: out_geo + out_coll + in_coll + in_geo
120
- op_indices = out_geo + out_coll + in_coll + in_geo
124
+ # Operator indices: out_lot + in_lot + out_geo + in_geo (lot-first)
125
+ op_indices = out_lot + in_lot + out_geo + in_geo
121
126
 
122
- # Adjoint input (original output vec): out_coll + out_geo
123
- adjoint_input = out_coll + out_geo
127
+ # Adjoint input (original output vec): out_lot + out_geo
128
+ adjoint_input = out_lot + out_geo
124
129
 
125
- # Adjoint output (original input space): in_coll + in_geo
126
- adjoint_output = in_coll + in_geo
130
+ # Adjoint output (original input space): in_lot + in_geo
131
+ adjoint_output = in_lot + in_geo
127
132
 
128
133
  return f"{op_indices},{adjoint_input}->{adjoint_output}"
129
134
 
@@ -131,42 +136,52 @@ def adjoint_signature(input_spec: VectorSpec, output_spec: VectorSpec) -> str:
131
136
  def operator_shape(
132
137
  input_spec: VectorSpec,
133
138
  output_spec: VectorSpec,
134
- input_collection: tuple[int, ...],
135
- output_collection: tuple[int, ...],
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,
136
144
  ) -> tuple[int, ...]:
137
145
  """
138
- Compute the expected shape of operator data given specs and collection shapes.
146
+ Compute the expected shape of operator data given specs and lot shapes.
139
147
 
140
148
  Args:
141
- input_spec: Specification of input blade
142
- output_spec: Specification of output blade
143
- input_collection: Shape of input collection dimensions
144
- output_collection: Shape of output collection dimensions
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
145
155
 
146
156
  Returns:
147
- Operator data shape: (*output_geometric, *output_collection, *input_collection, *input_geometric)
157
+ Operator data shape: (*out_lot, *in_lot, *out_geo, *in_geo)
148
158
 
149
159
  Examples:
150
160
  >>> # Scalar (N=5) to bivector (M=10) in 3D
151
161
  >>> shape = operator_shape(
152
- ... VectorSpec(grade=0, collection=1, dim=3),
153
- ... VectorSpec(grade=2, collection=1, dim=3),
154
- ... input_collection=(5,),
155
- ... output_collection=(10,),
162
+ ... VectorSpec(grade=0, lot=(1,), dim=3),
163
+ ... VectorSpec(grade=2, lot=(1,), dim=3),
164
+ ... input_lot=(5,),
165
+ ... output_lot=(10,),
156
166
  ... )
157
167
  >>> shape
158
- (3, 3, 10, 5)
168
+ (10, 5, 3, 3)
159
169
  """
160
- if len(input_collection) != input_spec.collection:
161
- raise ValueError(
162
- f"input_collection has {len(input_collection)} dims, but input_spec expects {input_spec.collection}"
163
- )
164
- if len(output_collection) != output_spec.collection:
165
- raise ValueError(
166
- f"output_collection has {len(output_collection)} dims, but output_spec expects {output_spec.collection}"
167
- )
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}")
168
183
 
169
- return output_spec.geometric_shape + output_collection + input_collection + input_spec.geometric_shape
184
+ return output_lot + input_lot + output_spec.geo + input_spec.geo
170
185
 
171
186
 
172
187
  def _validate_spec_limits(input_spec: VectorSpec, output_spec: VectorSpec) -> None:
@@ -175,7 +190,7 @@ def _validate_spec_limits(input_spec: VectorSpec, output_spec: VectorSpec) -> No
175
190
  raise ValueError(f"Input grade {input_spec.grade} exceeds index pool limit {len(INPUT_GEOMETRIC)}")
176
191
  if output_spec.grade > len(OUTPUT_GEOMETRIC):
177
192
  raise ValueError(f"Output grade {output_spec.grade} exceeds index pool limit {len(OUTPUT_GEOMETRIC)}")
178
- if input_spec.collection > len(INPUT_COLLECTION):
179
- raise ValueError(f"Input collection {input_spec.collection} exceeds limit {len(INPUT_COLLECTION)}")
180
- if output_spec.collection > len(OUTPUT_COLLECTION):
181
- raise ValueError(f"Output collection {output_spec.collection} exceeds limit {len(OUTPUT_COLLECTION)}")
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(