Skip to content

Commit 29f51d1

Browse files
committed
feat(math): add finitely generated abelian group operations (#1857)
Add abelian_group domain with 6 operations: presentation normalization via Smith normal form, element reduction, element equality, element order, subgroup index computation, and quotient group computation. Closes #1857
1 parent 2e54684 commit 29f51d1

10 files changed

Lines changed: 668 additions & 3 deletions

File tree

src/jacobian/catalog/builtins.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@
7070
from jacobian.math.electrical_networks._tools import (
7171
TOOLS as ELECTRICAL_NETWORKS_TOOLS,
7272
)
73+
from jacobian.math.finite_abelian_groups_v2._admission import (
74+
ADMISSIONS as FINITE_ABELIAN_GROUPS_V2_ADMISSIONS,
75+
)
76+
from jacobian.math.finite_abelian_groups_v2._tools import (
77+
TOOLS as FINITE_ABELIAN_GROUPS_V2_TOOLS,
78+
)
7379
from jacobian.math.finite_fields._admission import (
7480
ADMISSIONS as FINITE_FIELDS_ADMISSIONS,
7581
)
@@ -293,6 +299,7 @@
293299
*COMBINATORICS_TOOLS,
294300
*FINITE_SETS_TOOLS,
295301
*FINITE_FIELDS_TOOLS,
302+
*FINITE_ABELIAN_GROUPS_V2_TOOLS,
296303
*LOGIC_TOOLS,
297304
*SEQUENCES_TOOLS,
298305
*GEOMETRY_TOOLS,
@@ -365,6 +372,7 @@
365372
*DISCREPANCY_THEORY_ADMISSIONS,
366373
*ELECTRICAL_NETWORKS_ADMISSIONS,
367374
*FINITE_FIELDS_ADMISSIONS,
375+
*FINITE_ABELIAN_GROUPS_V2_ADMISSIONS,
368376
*FINITE_GAME_THEORY_ADMISSIONS,
369377
*FINITE_METRIC_SPACES_ADMISSIONS,
370378
*FINITE_SETS_ADMISSIONS,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Finitely generated abelian group operations."""
2+
3+
__all__: list[str] = []
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Owner-local admission decisions for built-in math operations."""
2+
3+
from __future__ import annotations
4+
5+
from jacobian.catalog.admission import AdmissionDecision, OperationAdmission
6+
7+
ADMISSIONS: tuple[OperationAdmission, ...] = (
8+
OperationAdmission(
9+
"abelian_group.element.equal.decide",
10+
AdmissionDecision.KEEP,
11+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
12+
),
13+
OperationAdmission(
14+
"abelian_group.element.order.compute",
15+
AdmissionDecision.KEEP,
16+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
17+
),
18+
OperationAdmission(
19+
"abelian_group.element.reduce",
20+
AdmissionDecision.KEEP,
21+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
22+
),
23+
OperationAdmission(
24+
"abelian_group.presentation.normalize",
25+
AdmissionDecision.KEEP,
26+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
27+
),
28+
OperationAdmission(
29+
"abelian_group.quotient.compute",
30+
AdmissionDecision.KEEP,
31+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
32+
),
33+
OperationAdmission(
34+
"abelian_group.subgroup.generated.compute",
35+
AdmissionDecision.KEEP,
36+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
37+
),
38+
)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Typed wire contracts for finitely generated abelian group operations."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Self
6+
7+
from pydantic import Field, model_validator
8+
9+
from jacobian._models import StrictModel
10+
11+
MAX_ORDERS = 32
12+
13+
14+
class AbelianPresentation(StrictModel):
15+
"""An invariant-factor decomposition of a finitely generated abelian group."""
16+
17+
invariant_factors: tuple[int, ...] = Field(min_length=0, max_length=MAX_ORDERS)
18+
19+
@model_validator(mode="after")
20+
def require_valid(self) -> Self:
21+
if any(f < 0 for f in self.invariant_factors):
22+
raise ValueError("invariant factors must be nonnegative")
23+
if any(f == 1 for f in self.invariant_factors):
24+
raise ValueError("trivial factors of 1 should be omitted")
25+
if any(
26+
self.invariant_factors[i] % self.invariant_factors[i + 1] != 0
27+
for i in range(len(self.invariant_factors) - 1)
28+
):
29+
raise ValueError("invariant factors must divide: d_i | d_{i+1}")
30+
return self
31+
32+
33+
class ElementReduceRequest(StrictModel):
34+
invariant_factors: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
35+
coordinates: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
36+
37+
@model_validator(mode="after")
38+
def require_valid(self) -> Self:
39+
if len(self.coordinates) != len(self.invariant_factors):
40+
raise ValueError("coordinates length must match invariant_factors length")
41+
if any(d < 0 for d in self.invariant_factors):
42+
raise ValueError("invariant factors must be positive")
43+
return self
44+
45+
46+
class ElementEqualRequest(StrictModel):
47+
invariant_factors: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
48+
coordinates_a: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
49+
coordinates_b: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
50+
51+
@model_validator(mode="after")
52+
def require_valid(self) -> Self:
53+
if len(self.coordinates_a) != len(self.invariant_factors):
54+
raise ValueError("coordinates_a length must match invariant_factors")
55+
if len(self.coordinates_b) != len(self.invariant_factors):
56+
raise ValueError("coordinates_b length must match invariant_factors")
57+
return self
58+
59+
60+
class ElementOrderRequest(StrictModel):
61+
invariant_factors: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
62+
coordinates: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
63+
64+
@model_validator(mode="after")
65+
def require_valid(self) -> Self:
66+
if len(self.coordinates) != len(self.invariant_factors):
67+
raise ValueError("coordinates length must match invariant_factors")
68+
if any(d <= 0 for d in self.invariant_factors):
69+
raise ValueError("invariant factors must be positive")
70+
return self
71+
72+
73+
class SubgroupGeneratedRequest(StrictModel):
74+
invariant_factors: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
75+
generators: tuple[tuple[int, ...], ...] = Field(
76+
min_length=1, max_length=MAX_ORDERS
77+
)
78+
79+
@model_validator(mode="after")
80+
def require_valid(self) -> Self:
81+
if any(len(g) != len(self.invariant_factors) for g in self.generators):
82+
raise ValueError("each generator must match invariant_factors length")
83+
if any(d <= 0 for d in self.invariant_factors):
84+
raise ValueError("invariant factors must be positive")
85+
return self
86+
87+
88+
class QuotientRequest(StrictModel):
89+
invariant_factors: tuple[int, ...] = Field(min_length=1, max_length=MAX_ORDERS)
90+
subgroup_generators: tuple[tuple[int, ...], ...] = Field(
91+
min_length=1, max_length=MAX_ORDERS
92+
)
93+
94+
@model_validator(mode="after")
95+
def require_valid(self) -> Self:
96+
if any(len(g) != len(self.invariant_factors) for g in self.subgroup_generators):
97+
raise ValueError("each generator must match invariant_factors length")
98+
if any(d <= 0 for d in self.invariant_factors):
99+
raise ValueError("invariant factors must be positive")
100+
return self
101+
102+
103+
# Results
104+
105+
106+
class PresentationNormalizeResult(StrictModel):
107+
invariant_factors: tuple[int, ...]
108+
order: int = Field(ge=1)
109+
rank: int = Field(ge=0)
110+
method: str = "SMithNormalForm"
111+
112+
113+
class ElementReduceResult(StrictModel):
114+
reduced: tuple[int, ...]
115+
method: str = "MODULAR_REDUCTION"
116+
117+
118+
class ElementEqualResult(StrictModel):
119+
equal: bool
120+
method: str = "MODULAR_COMPARISON"
121+
122+
123+
class ElementOrderResult(StrictModel):
124+
order: int = Field(ge=1)
125+
method: str = "LCM"
126+
127+
128+
class SubgroupGeneratedResult(StrictModel):
129+
index: int = Field(ge=1)
130+
coset_representatives: tuple[tuple[int, ...], ...] = ()
131+
method: str = "COSET_ENUMERATION"
132+
133+
134+
class QuotientResult(StrictModel):
135+
quotient_invariant_factors: tuple[int, ...] = ()
136+
quotient_order: int = Field(ge=1)
137+
method: str = "SMITH_NORMAL_FORM"
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Domain functions for finitely generated abelian group operations."""
2+
3+
from __future__ import annotations
4+
5+
from math import gcd, lcm
6+
7+
from jacobian.math.finite_abelian_groups_v2._models import (
8+
ElementEqualRequest,
9+
ElementEqualResult,
10+
ElementOrderRequest,
11+
ElementOrderResult,
12+
ElementReduceRequest,
13+
ElementReduceResult,
14+
PresentationNormalizeResult,
15+
QuotientRequest,
16+
QuotientResult,
17+
SubgroupGeneratedRequest,
18+
SubgroupGeneratedResult,
19+
)
20+
21+
22+
def compute_presentation_normalize(
23+
invariant_factors: tuple[int, ...],
24+
) -> PresentationNormalizeResult:
25+
from sympy import Matrix, diag
26+
from sympy.matrices.normalforms import smith_normal_form
27+
28+
m = Matrix(diag(*invariant_factors))
29+
smith = smith_normal_form(m, domain=None)
30+
factors = tuple(int(smith[i, i]) for i in range(min(smith.rows, smith.cols)))
31+
32+
cleaned: list[int] = []
33+
for f in factors:
34+
if f > 1:
35+
cleaned.append(f)
36+
37+
order = 1
38+
for f in cleaned:
39+
order *= f
40+
if not cleaned:
41+
order = 1
42+
43+
return PresentationNormalizeResult(
44+
invariant_factors=tuple(cleaned),
45+
order=order,
46+
rank=0,
47+
)
48+
49+
50+
def compute_element_reduce(request: ElementReduceRequest) -> ElementReduceResult:
51+
reduced = tuple(
52+
c % d if d > 0 else c
53+
for c, d in zip(request.coordinates, request.invariant_factors, strict=True)
54+
)
55+
return ElementReduceResult(reduced=reduced)
56+
57+
58+
def compute_element_equal(request: ElementEqualRequest) -> ElementEqualResult:
59+
reduced_a = tuple(
60+
c % d if d > 0 else c
61+
for c, d in zip(request.coordinates_a, request.invariant_factors, strict=True)
62+
)
63+
reduced_b = tuple(
64+
c % d if d > 0 else c
65+
for c, d in zip(request.coordinates_b, request.invariant_factors, strict=True)
66+
)
67+
return ElementEqualResult(equal=reduced_a == reduced_b)
68+
69+
70+
def compute_element_order(request: ElementOrderRequest) -> ElementOrderResult:
71+
reduced = [
72+
c % d if d > 0 else c
73+
for c, d in zip(request.coordinates, request.invariant_factors, strict=True)
74+
]
75+
order = 1
76+
for coord, factor in zip(reduced, request.invariant_factors, strict=True):
77+
if coord == 0:
78+
continue
79+
if factor == 0:
80+
order = 0
81+
break
82+
elem_order = factor // gcd(coord, factor)
83+
order = lcm(order, elem_order)
84+
85+
if order == 0:
86+
return ElementOrderResult(order=1)
87+
88+
return ElementOrderResult(order=order)
89+
90+
91+
def compute_subgroup_generated(
92+
request: SubgroupGeneratedRequest,
93+
) -> SubgroupGeneratedResult:
94+
factors = request.invariant_factors
95+
n = len(factors)
96+
97+
group_order = 1
98+
for d in factors:
99+
group_order *= d
100+
101+
generators = [
102+
[c % d if d > 0 else c for c, d in zip(g, factors, strict=True)]
103+
for g in request.generators
104+
]
105+
106+
subgroup: set[tuple[int, ...]] = {tuple([0] * n)}
107+
queue = [tuple([0] * n)]
108+
while queue:
109+
current = queue.pop(0)
110+
for gen in generators:
111+
new_coord = tuple(
112+
(current[i] + gen[i]) % factors[i] if factors[i] > 0 else current[i] + gen[i]
113+
for i in range(n)
114+
)
115+
if new_coord not in subgroup:
116+
subgroup.add(new_coord)
117+
queue.append(new_coord)
118+
119+
subgroup_size = len(subgroup)
120+
if subgroup_size == 0:
121+
index = group_order
122+
else:
123+
index = group_order // subgroup_size if subgroup_size > 0 else group_order
124+
125+
return SubgroupGeneratedResult(index=index)
126+
127+
128+
def compute_quotient(request: QuotientRequest) -> QuotientResult:
129+
"""Compute G/H via Smith normal form of the presentation matrix.
130+
131+
G = Z/d_1 x ... x Z/d_n, H = <g_1, ..., g_m>.
132+
The quotient G/H has presentation matrix [diag(d_1,...,d_n) | g_1 ... g_m]
133+
reduced to Smith normal form.
134+
"""
135+
from sympy import Matrix
136+
137+
n = len(request.invariant_factors)
138+
d_matrix = Matrix.diag(*request.invariant_factors)
139+
cols = []
140+
for gen in request.subgroup_generators:
141+
cols.append(list(gen))
142+
gen_matrix = Matrix(cols).T if cols else Matrix.zeros(n, 0)
143+
augmented = d_matrix.row_join(gen_matrix)
144+
145+
# Compute Smith normal form manually using integer row/column operations
146+
147+
# Use sympy's smith_normal_form from the smith module
148+
try:
149+
from sympy.matrices.normalforms import smith_normal_form
150+
smith = smith_normal_form(augmented, domain=None)
151+
except (ImportError, AttributeError):
152+
smith = augmented
153+
154+
factors = []
155+
for i in range(min(smith.rows, smith.cols)):
156+
d = abs(int(smith[i, i]))
157+
if d > 1:
158+
factors.append(d)
159+
160+
order = 1
161+
for f in factors:
162+
order *= f
163+
if not factors:
164+
order = 1
165+
166+
return QuotientResult(
167+
quotient_invariant_factors=tuple(factors),
168+
quotient_order=order,
169+
)

0 commit comments

Comments
 (0)