morphis 0.9.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 (96) hide show
  1. {morphis-0.9.0 → morphis-0.10.0}/PKG-INFO +1 -1
  2. {morphis-0.9.0 → morphis-0.10.0}/pyproject.toml +7 -1
  3. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/contraction.py +9 -1
  4. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/__init__.py +1 -0
  5. morphis-0.10.0/src/morphis/elements/lot_indexed.py +566 -0
  6. morphis-0.10.0/src/morphis/elements/tests/test_maxwell_features.py +337 -0
  7. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/vector.py +109 -8
  8. {morphis-0.9.0 → morphis-0.10.0}/README.md +0 -0
  9. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/__init__.py +0 -0
  10. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/__init__.py +0 -0
  11. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/coordinates.py +0 -0
  12. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/rotations.py +0 -0
  13. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/smoothing.py +0 -0
  14. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/vectors.py +0 -0
  15. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/__init__.py +0 -0
  16. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/patterns.py +0 -0
  17. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/solvers.py +0 -0
  18. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/specs.py +0 -0
  19. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/__init__.py +0 -0
  20. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_contraction.py +0 -0
  21. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_patterns.py +0 -0
  22. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_solvers.py +0 -0
  23. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_specs.py +0 -0
  24. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/config.py +0 -0
  25. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/base.py +0 -0
  26. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/frame.py +0 -0
  27. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/metric.py +0 -0
  28. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/multivector.py +0 -0
  29. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/operator.py +0 -0
  30. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/protocols.py +0 -0
  31. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tensor.py +0 -0
  32. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/__init__.py +0 -0
  33. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_complex_blades.py +0 -0
  34. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_model.py +0 -0
  35. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_operator.py +0 -0
  36. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_operators.py +0 -0
  37. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_tensor.py +0 -0
  38. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/__init__.py +0 -0
  39. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/clifford.py +0 -0
  40. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/duality.py +0 -0
  41. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/exterior.py +0 -0
  42. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/operators.py +0 -0
  43. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/phasors.py +0 -0
  44. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/rotations_3d.py +0 -0
  45. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/rotations_4d.py +0 -0
  46. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/transforms_pga.py +0 -0
  47. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/manifold/__init__.py +0 -0
  48. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/__init__.py +0 -0
  49. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/_helpers.py +0 -0
  50. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/duality.py +0 -0
  51. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/exponential.py +0 -0
  52. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/factorization.py +0 -0
  53. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/matrix_rep.py +0 -0
  54. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/norms.py +0 -0
  55. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/operator.py +0 -0
  56. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/outermorphism.py +0 -0
  57. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/products.py +0 -0
  58. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/projections.py +0 -0
  59. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/spectral.py +0 -0
  60. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/structure.py +0 -0
  61. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/subspaces.py +0 -0
  62. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/__init__.py +0 -0
  63. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_complex_operations.py +0 -0
  64. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_duality.py +0 -0
  65. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_exponential.py +0 -0
  66. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_matrix_rep.py +0 -0
  67. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_norms.py +0 -0
  68. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_operations.py +0 -0
  69. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_outermorphism.py +0 -0
  70. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_products.py +0 -0
  71. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_spectral.py +0 -0
  72. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_structure.py +0 -0
  73. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/topology/__init__.py +0 -0
  74. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/__init__.py +0 -0
  75. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/actions.py +0 -0
  76. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/projective.py +0 -0
  77. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/rotations.py +0 -0
  78. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/tests/__init__.py +0 -0
  79. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/tests/test_projective.py +0 -0
  80. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/__init__.py +0 -0
  81. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/docgen.py +0 -0
  82. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/easing.py +0 -0
  83. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/exceptions.py +0 -0
  84. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/observer.py +0 -0
  85. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/pretty.py +0 -0
  86. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/__init__.py +0 -0
  87. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/canvas.py +0 -0
  88. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/contexts.py +0 -0
  89. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/drawing/__init__.py +0 -0
  90. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/drawing/vectors.py +0 -0
  91. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/effects.py +0 -0
  92. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/loop.py +0 -0
  93. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/operations.py +0 -0
  94. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/projection.py +0 -0
  95. {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/renderer.py +0 -0
  96. {morphis-0.9.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.9.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.9.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
+ ]
@@ -69,11 +69,19 @@ class IndexedTensor:
69
69
  Contract two indexed tensors on matching indices.
70
70
 
71
71
  Args:
72
- other: Another IndexedTensor to contract with
72
+ other: Another IndexedTensor or LotIndexed to contract with
73
73
 
74
74
  Returns:
75
75
  Vector with the contracted result
76
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
+
77
85
  if not isinstance(other, IndexedTensor):
78
86
  return NotImplemented
79
87
 
@@ -10,6 +10,7 @@ from morphis.elements.base import (
10
10
  GradedElement as GradedElement,
11
11
  )
12
12
  from morphis.elements.frame import Frame
13
+ from morphis.elements.lot_indexed import LotIndexed as LotIndexed
13
14
  from morphis.elements.metric import (
14
15
  PGA as PGA,
15
16
  STA as STA,
@@ -0,0 +1,566 @@
1
+ """
2
+ Geometric Algebra - Lot Indexed Vectors
3
+
4
+ LotIndexed provides explicit broadcasting semantics for lot dimensions
5
+ using index labels. This enables einsum-style operations over collection
6
+ dimensions while preserving the geometric structure.
7
+
8
+ Semantics:
9
+ - Shared indices: element-wise for +, -, /, ^ | contraction for *
10
+ - Non-shared indices: outer product
11
+
12
+ Examples:
13
+ x = Vector(...) # lot (M,)
14
+ y = Vector(...) # lot (N, K)
15
+
16
+ # Outer product on lot dimensions
17
+ r = y["nk"] - x["m"] # lot (N, K, M)
18
+
19
+ # Reorder to desired lot order
20
+ r = (y["nk"] - x["m"])["mnk"] # lot (M, N, K)
21
+
22
+ # Contraction with *
23
+ b = G["mn"] * q["n"] # n contracts -> lot (M,)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from typing import TYPE_CHECKING
30
+
31
+ from numpy import einsum, ndarray
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from morphis.elements.vector import Vector
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class LotIndexed:
40
+ """
41
+ Lightweight wrapper pairing a Vector with lot index labels.
42
+
43
+ Enables explicit broadcasting over lot dimensions without affecting
44
+ geometric structure. The indices label only lot dimensions, not
45
+ geometric dimensions.
46
+
47
+ Attributes:
48
+ vector: The underlying Vector
49
+ indices: String of index labels, one per lot dimension
50
+
51
+ Examples:
52
+ >>> v = Vector(data, grade=1, metric=g) # lot=(M, N)
53
+ >>> vi = v["mn"] # LotIndexed with lot indices "mn"
54
+ >>> vi.vector is v
55
+ True
56
+ """
57
+
58
+ vector: "Vector"
59
+ indices: str
60
+
61
+ def __post_init__(self):
62
+ """Validate index count matches lot dimensions."""
63
+ n_lot = len(self.vector.lot)
64
+ if len(self.indices) != n_lot:
65
+ raise ValueError(
66
+ f"Index string '{self.indices}' has {len(self.indices)} indices, but vector has {n_lot} lot dimensions"
67
+ )
68
+
69
+ # Check for duplicate indices
70
+ if len(set(self.indices)) != len(self.indices):
71
+ raise ValueError(f"Duplicate indices in '{self.indices}'")
72
+
73
+ def __getitem__(self, new_indices: str) -> "LotIndexed":
74
+ """
75
+ Reorder lot dimensions to match new index order.
76
+
77
+ Args:
78
+ new_indices: New ordering of index labels
79
+
80
+ Returns:
81
+ LotIndexed with reordered lot dimensions
82
+
83
+ Examples:
84
+ >>> r = (y["nk"] - x["m"])["mnk"] # reorder to (M, N, K)
85
+ """
86
+ if set(new_indices) != set(self.indices):
87
+ raise ValueError(f"Index mismatch: '{new_indices}' must be a permutation of '{self.indices}'")
88
+
89
+ if new_indices == self.indices:
90
+ return self
91
+
92
+ # Build axis permutation for lot dimensions only
93
+ # Axes: lot_axes + geo_axes
94
+ n_lot = len(self.indices)
95
+ n_geo = self.vector.grade
96
+
97
+ # Map from current index -> position
98
+ current_pos = {idx: i for i, idx in enumerate(self.indices)}
99
+ # New positions for lot axes
100
+ lot_perm = [current_pos[idx] for idx in new_indices]
101
+ # Geo axes stay in place (after lot axes)
102
+ geo_axes = list(range(n_lot, n_lot + n_geo))
103
+ full_perm = lot_perm + geo_axes
104
+
105
+ # Transpose data
106
+ new_data = self.vector.data.transpose(full_perm)
107
+
108
+ from morphis.elements.vector import Vector
109
+
110
+ new_vector = Vector(
111
+ data=new_data,
112
+ grade=self.vector.grade,
113
+ metric=self.vector.metric,
114
+ )
115
+ return LotIndexed(new_vector, new_indices)
116
+
117
+ # =========================================================================
118
+ # Arithmetic Operations (element-wise on shared, outer on non-shared)
119
+ # =========================================================================
120
+
121
+ def __add__(self, other: "LotIndexed") -> "LotIndexed":
122
+ """Add with lot broadcasting."""
123
+ return _lot_broadcast_binary(self, other, "add")
124
+
125
+ def __radd__(self, other: "LotIndexed") -> "LotIndexed":
126
+ if not isinstance(other, LotIndexed):
127
+ return NotImplemented
128
+ return _lot_broadcast_binary(other, self, "add")
129
+
130
+ def __sub__(self, other: "LotIndexed") -> "LotIndexed":
131
+ """Subtract with lot broadcasting."""
132
+ return _lot_broadcast_binary(self, other, "sub")
133
+
134
+ def __rsub__(self, other: "LotIndexed") -> "LotIndexed":
135
+ if not isinstance(other, LotIndexed):
136
+ return NotImplemented
137
+ return _lot_broadcast_binary(other, self, "sub")
138
+
139
+ def __truediv__(self, other: "LotIndexed | ndarray | float") -> "LotIndexed":
140
+ """Divide with lot broadcasting."""
141
+ if isinstance(other, LotIndexed):
142
+ return _lot_broadcast_binary(self, other, "div")
143
+ # Scalar division
144
+ from morphis.elements.vector import Vector
145
+
146
+ new_vector = Vector(
147
+ data=self.vector.data / other,
148
+ grade=self.vector.grade,
149
+ metric=self.vector.metric,
150
+ )
151
+ return LotIndexed(new_vector, self.indices)
152
+
153
+ def __mul__(self, other: "LotIndexed | ndarray | float") -> "LotIndexed":
154
+ """
155
+ Multiplication: contraction on shared indices (Einstein convention).
156
+
157
+ For scalar/array multiplication, use standard broadcasting.
158
+ """
159
+ if isinstance(other, LotIndexed):
160
+ return _lot_contract(self, other)
161
+ # Scalar multiplication
162
+ from morphis.elements.vector import Vector
163
+
164
+ new_vector = Vector(
165
+ data=self.vector.data * other,
166
+ grade=self.vector.grade,
167
+ metric=self.vector.metric,
168
+ )
169
+ return LotIndexed(new_vector, self.indices)
170
+
171
+ def __rmul__(self, other: "LotIndexed | ndarray | float") -> "LotIndexed":
172
+ if isinstance(other, LotIndexed):
173
+ return _lot_contract(other, self)
174
+ # Check for numeric types (scalar/array)
175
+ if isinstance(other, (int, float, complex, ndarray)):
176
+ from morphis.elements.vector import Vector
177
+
178
+ new_vector = Vector(
179
+ data=other * self.vector.data,
180
+ grade=self.vector.grade,
181
+ metric=self.vector.metric,
182
+ )
183
+ return LotIndexed(new_vector, self.indices)
184
+ # Unknown type - let Python try other options
185
+ return NotImplemented
186
+
187
+ def __and__(self, other: "LotIndexed") -> "LotIndexed":
188
+ """Hadamard (element-wise) multiplication on shared lot indices."""
189
+ return _lot_broadcast_binary(self, other, "mul")
190
+
191
+ def __xor__(self, other: "LotIndexed") -> "LotIndexed":
192
+ """Wedge product with lot broadcasting."""
193
+ return _lot_broadcast_binary(self, other, "wedge")
194
+
195
+ def __pow__(self, exponent: int) -> "LotIndexed":
196
+ """Power operation preserving indices."""
197
+ from morphis.elements.vector import Vector
198
+
199
+ new_vector = Vector(
200
+ data=self.vector.data**exponent,
201
+ grade=self.vector.grade,
202
+ metric=self.vector.metric,
203
+ )
204
+ return LotIndexed(new_vector, self.indices)
205
+
206
+ # =========================================================================
207
+ # Utility
208
+ # =========================================================================
209
+
210
+ def norm(self) -> "LotIndexed":
211
+ """Compute norm, preserving lot indices."""
212
+ # norm() returns NDArray with shape matching lot
213
+ norm_data = self.vector.norm()
214
+
215
+ from morphis.elements.vector import Vector
216
+
217
+ # Wrap as grade-0 vector to preserve lot structure
218
+ new_vector = Vector(
219
+ data=norm_data,
220
+ grade=0,
221
+ metric=self.vector.metric,
222
+ )
223
+ return LotIndexed(new_vector, self.indices)
224
+
225
+ def sum(self, axis: int | None = None) -> "LotIndexed":
226
+ """Sum over lot axis, removing that index."""
227
+ if axis is None:
228
+ # Sum over all -> scalar, no lot indices
229
+ from morphis.elements.vector import Vector
230
+
231
+ new_vector = Vector(
232
+ data=self.vector.data.sum(axis=tuple(range(len(self.indices)))),
233
+ grade=self.vector.grade,
234
+ metric=self.vector.metric,
235
+ )
236
+ return LotIndexed(new_vector, "")
237
+
238
+ # Remove the index at the summed axis
239
+ new_indices = self.indices[:axis] + self.indices[axis + 1 :]
240
+ summed_vector = self.vector.sum(axis=axis)
241
+
242
+ return LotIndexed(summed_vector, new_indices)
243
+
244
+ def __repr__(self) -> str:
245
+ return f"LotIndexed({self.vector!r}, indices='{self.indices}')"
246
+
247
+
248
+ # =============================================================================
249
+ # Internal Operations
250
+ # =============================================================================
251
+
252
+
253
+ def _compute_broadcast_info(left_indices: str, right_indices: str) -> tuple[str, list[int], list[int]]:
254
+ """
255
+ Compute broadcast information for two indexed operands.
256
+
257
+ Returns:
258
+ result_indices: Output index string (non-shared left, non-shared right, shared)
259
+ left_expand: Axes to add to left operand (via np.newaxis)
260
+ right_expand: Axes to add to right operand (via np.newaxis)
261
+ """
262
+ left_set = set(left_indices)
263
+ right_set = set(right_indices)
264
+
265
+ shared = left_set & right_set
266
+ left_only = [i for i in left_indices if i not in shared]
267
+ right_only = [i for i in right_indices if i not in shared]
268
+ shared_list = [i for i in left_indices if i in shared]
269
+
270
+ # Result order: left_only, right_only, shared (as they appear in left)
271
+ result_indices = "".join(left_only + right_only + shared_list)
272
+
273
+ return result_indices, left_only, right_only, shared_list
274
+
275
+
276
+ def _lot_broadcast_binary(left: LotIndexed, right: LotIndexed, op: str) -> LotIndexed:
277
+ """
278
+ Perform binary operation with lot broadcasting.
279
+
280
+ Shared indices: element-wise
281
+ Non-shared indices: outer product
282
+ """
283
+
284
+ from morphis.elements.metric import Metric
285
+ from morphis.elements.vector import Vector
286
+ from morphis.operations.products import wedge
287
+
288
+ if not isinstance(right, LotIndexed):
289
+ raise TypeError(f"Expected LotIndexed, got {type(right)}")
290
+
291
+ left_indices = left.indices
292
+ right_indices = right.indices
293
+
294
+ # Handle empty indices (scalars)
295
+ if not left_indices and not right_indices:
296
+ # Both are scalars, just operate
297
+ if op == "add":
298
+ result_vector = left.vector + right.vector
299
+ elif op == "sub":
300
+ result_vector = left.vector - right.vector
301
+ elif op == "mul":
302
+ result_data = left.vector.data * right.vector.data
303
+ metric = Metric.merge(left.vector.metric, right.vector.metric)
304
+ result_vector = Vector(data=result_data, grade=left.vector.grade, metric=metric)
305
+ elif op == "div":
306
+ result_vector = left.vector / right.vector.data
307
+ elif op == "wedge":
308
+ result_vector = wedge(left.vector, right.vector)
309
+ else:
310
+ raise ValueError(f"Unknown operation: {op}")
311
+ return LotIndexed(result_vector, "")
312
+
313
+ left_set = set(left_indices)
314
+ right_set = set(right_indices)
315
+ shared = left_set & right_set
316
+
317
+ # Build result index order: left_only + right_only + shared
318
+ left_only = [i for i in left_indices if i not in shared]
319
+ right_only = [i for i in right_indices if i not in shared]
320
+ shared_ordered = [i for i in left_indices if i in shared]
321
+ result_indices = "".join(left_only + right_only + shared_ordered)
322
+
323
+ # Determine geo dimensions
324
+ n_left_geo = left.vector.grade
325
+ n_right_geo = right.vector.grade
326
+
327
+ # Create unique letters for geo dimensions (use uppercase to avoid conflicts)
328
+ left_geo_labels = "".join(chr(ord("A") + i) for i in range(n_left_geo))
329
+ right_geo_labels = "".join(chr(ord("A") + n_left_geo + i) for i in range(n_right_geo))
330
+
331
+ # For wedge, geo dimensions combine; for others, they must match (unless one is scalar)
332
+ if op == "wedge":
333
+ result_geo_labels = left_geo_labels + right_geo_labels
334
+ elif op == "div" and n_right_geo == 0:
335
+ # Division by scalar: broadcast scalar over all geo dimensions
336
+ result_geo_labels = left_geo_labels
337
+ right_geo_labels = "" # Scalar has no geo labels
338
+ elif op in ("mul", "div") and n_left_geo == 0:
339
+ # Scalar times/by something: result takes right's geo
340
+ result_geo_labels = right_geo_labels
341
+ left_geo_labels = ""
342
+ else:
343
+ # For add/sub, geo dimensions must match
344
+ if n_left_geo != n_right_geo:
345
+ raise ValueError(f"Grade mismatch for {op}: {left.vector.grade} vs {right.vector.grade}")
346
+ result_geo_labels = left_geo_labels
347
+ right_geo_labels = left_geo_labels # They share the same geo labels
348
+
349
+ # Build einsum signature
350
+ left_sig = left_indices + left_geo_labels
351
+ right_sig = right_indices + right_geo_labels
352
+ result_sig = result_indices + result_geo_labels
353
+
354
+ left_data = left.vector.data
355
+ right_data = right.vector.data
356
+
357
+ # Merge metrics
358
+ metric = Metric.merge(left.vector.metric, right.vector.metric)
359
+
360
+ # Perform the operation
361
+ if op == "add":
362
+ # Use einsum for broadcasting, then add
363
+ result_data = einsum(f"{left_sig},{right_sig}->{result_sig}", left_data, right_data * 0) + einsum(
364
+ f"{left_sig},{right_sig}->{result_sig}", left_data * 0 + 1, right_data
365
+ )
366
+ # Simpler: broadcast manually
367
+ result_data = _broadcast_and_operate(
368
+ left_data,
369
+ right_data,
370
+ left_indices,
371
+ right_indices,
372
+ result_indices,
373
+ n_left_geo,
374
+ n_right_geo,
375
+ lambda a, b: a + b,
376
+ )
377
+ result_grade = left.vector.grade
378
+ elif op == "sub":
379
+ result_data = _broadcast_and_operate(
380
+ left_data,
381
+ right_data,
382
+ left_indices,
383
+ right_indices,
384
+ result_indices,
385
+ n_left_geo,
386
+ n_right_geo,
387
+ lambda a, b: a - b,
388
+ )
389
+ result_grade = left.vector.grade
390
+ elif op == "mul":
391
+ # Hadamard (element-wise)
392
+ result_data = _broadcast_and_operate(
393
+ left_data,
394
+ right_data,
395
+ left_indices,
396
+ right_indices,
397
+ result_indices,
398
+ n_left_geo,
399
+ n_right_geo,
400
+ lambda a, b: a * b,
401
+ )
402
+ result_grade = left.vector.grade
403
+ elif op == "div":
404
+ result_data = _broadcast_and_operate(
405
+ left_data,
406
+ right_data,
407
+ left_indices,
408
+ right_indices,
409
+ result_indices,
410
+ n_left_geo,
411
+ n_right_geo,
412
+ lambda a, b: a / b,
413
+ )
414
+ result_grade = left.vector.grade
415
+ elif op == "wedge":
416
+ # For wedge, we need the actual wedge product, not just data manipulation
417
+ # Expand lot dimensions first, then compute wedge
418
+ left_expanded, right_expanded = _expand_for_broadcast(
419
+ left_data, right_data, left_indices, right_indices, result_indices, n_left_geo, n_right_geo
420
+ )
421
+ # Create expanded vectors and compute wedge
422
+ left_vec = Vector(data=left_expanded, grade=left.vector.grade, metric=metric)
423
+ right_vec = Vector(data=right_expanded, grade=right.vector.grade, metric=metric)
424
+ result_vector = wedge(left_vec, right_vec)
425
+ return LotIndexed(result_vector, result_indices)
426
+ else:
427
+ raise ValueError(f"Unknown operation: {op}")
428
+
429
+ result_vector = Vector(data=result_data, grade=result_grade, metric=metric)
430
+ return LotIndexed(result_vector, result_indices)
431
+
432
+
433
+ def _broadcast_and_operate(
434
+ left_data,
435
+ right_data,
436
+ left_indices: str,
437
+ right_indices: str,
438
+ result_indices: str,
439
+ n_left_geo: int,
440
+ n_right_geo: int,
441
+ op_func,
442
+ ):
443
+ """
444
+ Broadcast two arrays over lot dimensions and apply operation.
445
+ """
446
+ from numpy import newaxis
447
+
448
+ # Build the expanded arrays
449
+ left_expanded, right_expanded = _expand_for_broadcast(
450
+ left_data, right_data, left_indices, right_indices, result_indices, n_left_geo, n_right_geo
451
+ )
452
+
453
+ # If geo dimensions differ, we need to add trailing dimensions for broadcasting
454
+ # This handles scalar division/multiplication: (M, N, K, 3, 3) / (M, N, K) -> need (M, N, K, 1, 1)
455
+ if n_left_geo > n_right_geo:
456
+ for _ in range(n_left_geo - n_right_geo):
457
+ right_expanded = right_expanded[..., newaxis]
458
+ elif n_right_geo > n_left_geo:
459
+ for _ in range(n_right_geo - n_left_geo):
460
+ left_expanded = left_expanded[..., newaxis]
461
+
462
+ return op_func(left_expanded, right_expanded)
463
+
464
+
465
+ def _expand_for_broadcast(
466
+ left_data, right_data, left_indices: str, right_indices: str, result_indices: str, n_left_geo: int, n_right_geo: int
467
+ ):
468
+ """
469
+ Expand arrays to have compatible shapes for broadcasting.
470
+
471
+ Result axes order: result_lot_indices + geo_axes
472
+ """
473
+ from numpy import expand_dims
474
+
475
+ # Expand left: add newaxis for indices in result but not in left
476
+ left_shape_map = {idx: i for i, idx in enumerate(left_indices)}
477
+ left_perm = []
478
+ left_expand_axes = []
479
+
480
+ for i, idx in enumerate(result_indices):
481
+ if idx in left_shape_map:
482
+ left_perm.append(left_shape_map[idx])
483
+ else:
484
+ left_expand_axes.append(i)
485
+
486
+ # Similarly for right
487
+ right_shape_map = {idx: i for i, idx in enumerate(right_indices)}
488
+ right_perm = []
489
+ right_expand_axes = []
490
+
491
+ for i, idx in enumerate(result_indices):
492
+ if idx in right_shape_map:
493
+ right_perm.append(right_shape_map[idx])
494
+ else:
495
+ right_expand_axes.append(i)
496
+
497
+ # Reorder left lot dimensions to match result order (for indices that exist)
498
+ # Then add geo dimensions
499
+ left_lot_perm = left_perm + list(range(len(left_indices), len(left_indices) + n_left_geo))
500
+ left_reordered = (
501
+ left_data.transpose(left_lot_perm) if left_lot_perm != list(range(len(left_lot_perm))) else left_data
502
+ )
503
+
504
+ # Insert newaxis for missing dimensions
505
+ for ax in sorted(left_expand_axes):
506
+ left_reordered = expand_dims(left_reordered, axis=ax)
507
+
508
+ # Same for right
509
+ right_lot_perm = right_perm + list(range(len(right_indices), len(right_indices) + n_right_geo))
510
+ right_reordered = (
511
+ right_data.transpose(right_lot_perm) if right_lot_perm != list(range(len(right_lot_perm))) else right_data
512
+ )
513
+
514
+ for ax in sorted(right_expand_axes):
515
+ right_reordered = expand_dims(right_reordered, axis=ax)
516
+
517
+ return left_reordered, right_reordered
518
+
519
+
520
+ def _lot_contract(left: LotIndexed, right: LotIndexed) -> LotIndexed:
521
+ """
522
+ Contract two LotIndexed tensors on shared lot indices (Einstein convention).
523
+
524
+ Shared indices are summed over (contracted).
525
+ Non-shared indices form the outer product.
526
+ """
527
+ from morphis.elements.metric import Metric
528
+ from morphis.elements.vector import Vector
529
+
530
+ left_indices = left.indices
531
+ right_indices = right.indices
532
+
533
+ left_set = set(left_indices)
534
+ right_set = set(right_indices)
535
+ shared = left_set & right_set
536
+
537
+ # Result indices: non-shared only (shared are contracted away)
538
+ left_only = [i for i in left_indices if i not in shared]
539
+ right_only = [i for i in right_indices if i not in shared]
540
+ result_indices = "".join(left_only + right_only)
541
+
542
+ n_left_geo = left.vector.grade
543
+ n_right_geo = right.vector.grade
544
+
545
+ # For contraction, we treat this as lot-level contraction
546
+ # Geo dimensions must match and stay the same
547
+ if n_left_geo != n_right_geo:
548
+ raise ValueError(f"Grade mismatch for contraction: {left.vector.grade} vs {right.vector.grade}")
549
+
550
+ # Build einsum signature
551
+ # Lot indices are labeled with the index string
552
+ # Geo indices use uppercase letters
553
+ geo_labels = "".join(chr(ord("A") + i) for i in range(n_left_geo))
554
+
555
+ left_sig = left_indices + geo_labels
556
+ right_sig = right_indices + geo_labels
557
+ result_sig = result_indices + geo_labels
558
+
559
+ einsum_sig = f"{left_sig},{right_sig}->{result_sig}"
560
+
561
+ result_data = einsum(einsum_sig, left.vector.data, right.vector.data)
562
+
563
+ metric = Metric.merge(left.vector.metric, right.vector.metric)
564
+ result_vector = Vector(data=result_data, grade=left.vector.grade, metric=metric)
565
+
566
+ return LotIndexed(result_vector, result_indices)