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.
- {morphis-0.9.0 → morphis-0.10.0}/PKG-INFO +1 -1
- {morphis-0.9.0 → morphis-0.10.0}/pyproject.toml +7 -1
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/contraction.py +9 -1
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/__init__.py +1 -0
- morphis-0.10.0/src/morphis/elements/lot_indexed.py +566 -0
- morphis-0.10.0/src/morphis/elements/tests/test_maxwell_features.py +337 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/vector.py +109 -8
- {morphis-0.9.0 → morphis-0.10.0}/README.md +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/coordinates.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/rotations.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/smoothing.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/_legacy/vectors.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/patterns.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/solvers.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/specs.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_contraction.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_patterns.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_solvers.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/algebra/tests/test_specs.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/config.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/base.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/frame.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/metric.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/multivector.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/operator.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/protocols.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tensor.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_complex_blades.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_model.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_operator.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_operators.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/elements/tests/test_tensor.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/clifford.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/duality.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/exterior.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/operators.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/phasors.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/rotations_3d.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/rotations_4d.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/examples/transforms_pga.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/manifold/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/_helpers.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/duality.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/exponential.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/factorization.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/matrix_rep.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/norms.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/operator.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/outermorphism.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/products.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/projections.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/spectral.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/structure.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/subspaces.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_complex_operations.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_duality.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_exponential.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_matrix_rep.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_norms.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_operations.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_outermorphism.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_products.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_spectral.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/operations/tests/test_structure.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/topology/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/actions.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/projective.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/rotations.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/tests/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/transforms/tests/test_projective.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/docgen.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/easing.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/exceptions.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/observer.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/utils/pretty.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/canvas.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/contexts.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/drawing/__init__.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/drawing/vectors.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/effects.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/loop.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/operations.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/projection.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/renderer.py +0 -0
- {morphis-0.9.0 → morphis-0.10.0}/src/morphis/visuals/theme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "morphis"
|
|
3
|
-
version = "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
|
|
|
@@ -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)
|