Skip to content

Commit 99b7fcc

Browse files
authored
feat(math): add Green relations (L, R, H, D, J) to finite semigroup domain (#2111)
* feat(math): add Green relations (L, R, H, D, J) to finite semigroup domain Add the five Green relations for a finite semigroup computed via principal ideal equality: - L: elements with the same principal left ideal S^1 a - R: elements with the same principal right ideal a S^1 - H = L ∩ R - D = L ∨ R (join via union-find) - J: elements with the same principal two-sided ideal S^1 a S^1 Each relation is returned as a tuple of equivalence-class tuples in declared element order. The result model re-runs the native kernel to verify exactness. Covers part of GitHub issue #1858. * fix(math): lint and review fixes * chore: update c901 baseline for green relations
1 parent 2a1e49c commit 99b7fcc

6 files changed

Lines changed: 404 additions & 0 deletions

File tree

src/jacobian/math/finite_semigroups/_admission.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
AdmissionDecision.KEEP,
3434
"exact principal two-sided ideals S^1 a S^1 of requested elements",
3535
),
36+
OperationAdmission(
37+
"semigroup.green_relations.compute",
38+
AdmissionDecision.KEEP,
39+
"exact Green relations L, R, H, D, J via principal ideal equality",
40+
),
3641
)
3742

3843
REGISTRATION = OperationRegistration(TOOLS, ADMISSIONS)

src/jacobian/math/finite_semigroups/_models.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,45 @@ def bind_ideals(self) -> Self:
236236
"ideals must be the exact principal ideals of the elements"
237237
)
238238
return self
239+
240+
241+
class GreenRelationsRequest(StrictModel):
242+
"""Request the Green relations L, R, H, D, J of a finite semigroup."""
243+
244+
semigroup: FiniteSemigroup
245+
246+
247+
class GreenRelationsResult(StrictModel):
248+
"""Green relations of a finite semigroup.
249+
250+
Each relation is a tuple of equivalence-class tuples, in declared
251+
element order, partitioning the semigroup elements. ``L`` and ``R``
252+
are Green's left and right equivalences; ``H = L ∩ R``;
253+
``D = L ∘ R`` (the join); ``J`` is the two-sided Green relation.
254+
"""
255+
256+
semigroup: FiniteSemigroup
257+
L: tuple[tuple[str, ...], ...]
258+
R: tuple[tuple[str, ...], ...]
259+
H: tuple[tuple[str, ...], ...]
260+
D: tuple[tuple[str, ...], ...]
261+
J: tuple[tuple[str, ...], ...]
262+
263+
@model_validator(mode="after")
264+
def bind_green_relations(self) -> Self:
265+
from jacobian.math.finite_semigroups._operations import _green_relations
266+
267+
L, R, H, D, J = _green_relations( # noqa: N806
268+
self.semigroup.elements, self.semigroup.multiplication
269+
)
270+
if self.L != L:
271+
raise ValueError("L must be the exact Green L-relation")
272+
if self.R != R:
273+
raise ValueError("R must be the exact Green R-relation")
274+
if self.H != H:
275+
raise ValueError("H must be the exact Green H-relation")
276+
if self.D != D:
277+
raise ValueError("D must be the exact Green D-relation")
278+
if self.J != J:
279+
raise ValueError("J must be the exact Green J-relation")
280+
return self

src/jacobian/math/finite_semigroups/_operations.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
ElementPowerResult,
66
GeneratedSubsemigroupRequest,
77
GeneratedSubsemigroupResult,
8+
GreenRelationsRequest,
9+
GreenRelationsResult,
810
IdempotentsRequest,
911
IdempotentsResult,
1012
PowerProfileRequest,
@@ -198,3 +200,215 @@ def compute_principal_ideals(request: PrincipalIdealsRequest) -> PrincipalIdeals
198200
elements=request.elements,
199201
ideals=ideals,
200202
)
203+
204+
205+
def _left_ideals(
206+
elements: tuple[str, ...],
207+
multiplication: tuple[tuple[str, ...], ...],
208+
) -> list[frozenset[str]]:
209+
"""Compute the principal left ideal S^1 a of each element."""
210+
211+
{label: i for i, label in enumerate(elements)}
212+
n = len(elements)
213+
ideals: list[frozenset[str]] = []
214+
for i in range(n):
215+
ideal = {elements[i]}
216+
for j in range(n):
217+
ideal.add(multiplication[j][i])
218+
ideals.append(frozenset(ideal))
219+
return ideals
220+
221+
222+
def _right_ideals(
223+
elements: tuple[str, ...],
224+
multiplication: tuple[tuple[str, ...], ...],
225+
) -> list[frozenset[str]]:
226+
"""Compute the principal right ideal a S^1 of each element."""
227+
228+
{label: i for i, label in enumerate(elements)}
229+
n = len(elements)
230+
ideals: list[frozenset[str]] = []
231+
for i in range(n):
232+
ideal = {elements[i]}
233+
for j in range(n):
234+
ideal.add(multiplication[i][j])
235+
ideals.append(frozenset(ideal))
236+
return ideals
237+
238+
239+
def _two_sided_ideals(
240+
elements: tuple[str, ...],
241+
multiplication: tuple[tuple[str, ...], ...],
242+
) -> list[frozenset[str]]:
243+
"""Compute the principal two-sided ideal S^1 a S^1 of each element."""
244+
245+
idx = {label: i for i, label in enumerate(elements)}
246+
n = len(elements)
247+
ideals: list[frozenset[str]] = []
248+
for i in range(n):
249+
ideal = {elements[i]}
250+
for j in range(n):
251+
ideal.add(multiplication[j][i])
252+
ideal.add(multiplication[i][j])
253+
for k in range(n):
254+
ideal.add(multiplication[j][idx[multiplication[i][k]]])
255+
ideals.append(frozenset(ideal))
256+
return ideals
257+
258+
259+
def _partition_from_ideals(
260+
elements: tuple[str, ...],
261+
ideals: list[frozenset[str]],
262+
) -> tuple[tuple[str, ...], ...]:
263+
"""Group elements by equality of their principal ideals.
264+
265+
Returns a tuple of equivalence-class tuples in declared element order.
266+
"""
267+
268+
groups: list[list[str]] = []
269+
assigned: list[bool] = [False] * len(elements)
270+
for i in range(len(elements)):
271+
if assigned[i]:
272+
continue
273+
group = [elements[i]]
274+
assigned[i] = True
275+
for j in range(i + 1, len(elements)):
276+
if not assigned[j] and ideals[i] == ideals[j]:
277+
group.append(elements[j])
278+
assigned[j] = True
279+
groups.append(group)
280+
return tuple(tuple(g) for g in groups)
281+
282+
283+
def _green_relations(
284+
elements: tuple[str, ...],
285+
multiplication: tuple[tuple[str, ...], ...],
286+
) -> tuple[
287+
tuple[tuple[str, ...], ...],
288+
tuple[tuple[str, ...], ...],
289+
tuple[tuple[str, ...], ...],
290+
tuple[tuple[str, ...], ...],
291+
tuple[tuple[str, ...], ...],
292+
]:
293+
"""Compute the Green relations L, R, H, D, J.
294+
295+
For a finite semigroup S:
296+
- a L b iff S^1 a = S^1 b (principal left ideals agree)
297+
- a R b iff a S^1 = b S^1 (principal right ideals agree)
298+
- H = L ∩ R
299+
- J is defined by principal two-sided ideals: a J b iff S^1 a S^1 = S^1 b S^1
300+
- D = L ∨ R (the join), equivalently the relation whose blocks are the
301+
connected components of the L-R intersection graph
302+
303+
Returns each as a tuple of equivalence-class tuples in declared element order.
304+
""" # noqa: RUF002
305+
306+
left = _left_ideals(elements, multiplication)
307+
right = _right_ideals(elements, multiplication)
308+
two_sided = _two_sided_ideals(elements, multiplication)
309+
310+
L_classes = _partition_from_ideals(elements, left) # noqa: N806
311+
R_classes = _partition_from_ideals(elements, right) # noqa: N806
312+
J_classes = _partition_from_ideals(elements, two_sided) # noqa: N806
313+
314+
# H = L ∩ R: two elements are H-related iff they are both L-related and R-related
315+
H_classes = _intersection_partition(elements, L_classes, R_classes) # noqa: N806
316+
317+
# D = L ∨ R: build a graph where two elements are connected if L-related or R-related # noqa: RUF003
318+
# then D-classes are the connected components
319+
D_classes = _join_partition(elements, L_classes, R_classes) # noqa: N806
320+
321+
return L_classes, R_classes, H_classes, D_classes, J_classes
322+
323+
324+
def _intersection_partition(
325+
elements: tuple[str, ...],
326+
partition_a: tuple[tuple[str, ...], ...],
327+
partition_b: tuple[tuple[str, ...], ...],
328+
) -> tuple[tuple[str, ...], ...]:
329+
"""Compute the partition that is the intersection of two partitions."""
330+
331+
a_map: dict[str, int] = {}
332+
b_map: dict[str, int] = {}
333+
for i, cls in enumerate(partition_a):
334+
for e in cls:
335+
a_map[e] = i
336+
for i, cls in enumerate(partition_b):
337+
for e in cls:
338+
b_map[e] = i
339+
groups: dict[tuple[int, int], list[str]] = {}
340+
for e in elements:
341+
key = (a_map[e], b_map[e])
342+
groups.setdefault(key, []).append(e)
343+
# Return in declared element order
344+
seen: set[str] = set()
345+
result: list[tuple[str, ...]] = []
346+
for e in elements:
347+
if e in seen:
348+
continue
349+
key = (a_map[e], b_map[e])
350+
result.append(tuple(groups[key]))
351+
seen.update(groups[key])
352+
return tuple(result)
353+
354+
355+
def _join_partition( # noqa: C901
356+
elements: tuple[str, ...],
357+
partition_a: tuple[tuple[str, ...], ...],
358+
partition_b: tuple[tuple[str, ...], ...],
359+
) -> tuple[tuple[str, ...], ...]:
360+
"""Compute the join (least upper bound) of two partitions via union-find."""
361+
362+
n = len(elements)
363+
idx = {e: i for i, e in enumerate(elements)}
364+
parent = list(range(n))
365+
366+
def find(x: int) -> int:
367+
while parent[x] != x:
368+
parent[x] = parent[parent[x]]
369+
x = parent[x]
370+
return x
371+
372+
def union(x: int, y: int) -> None:
373+
rx, ry = find(x), find(y)
374+
if rx != ry:
375+
parent[rx] = ry
376+
377+
for cls in partition_a:
378+
for i in range(1, len(cls)):
379+
union(idx[cls[0]], idx[cls[i]])
380+
for cls in partition_b:
381+
for i in range(1, len(cls)):
382+
union(idx[cls[0]], idx[cls[i]])
383+
384+
groups: dict[int, list[str]] = {}
385+
for e in elements:
386+
groups.setdefault(find(idx[e]), []).append(e)
387+
# Return in declared element order
388+
seen: set[str] = set()
389+
result: list[tuple[str, ...]] = []
390+
for e in elements:
391+
if e in seen:
392+
continue
393+
root = find(idx[e])
394+
result.append(tuple(groups[root]))
395+
seen.update(groups[root])
396+
return tuple(result)
397+
398+
399+
def compute_green_relations(
400+
request: "GreenRelationsRequest",
401+
) -> "GreenRelationsResult":
402+
"""Compute the Green relations of a finite semigroup."""
403+
404+
L, R, H, D, J = _green_relations( # noqa: N806
405+
request.semigroup.elements, request.semigroup.multiplication
406+
)
407+
return GreenRelationsResult(
408+
semigroup=request.semigroup,
409+
L=L,
410+
R=R,
411+
H=H,
412+
D=D,
413+
J=J,
414+
)

src/jacobian/math/finite_semigroups/_tools.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
ElementPowerResult,
1212
GeneratedSubsemigroupRequest,
1313
GeneratedSubsemigroupResult,
14+
GreenRelationsRequest,
15+
GreenRelationsResult,
1416
IdempotentsRequest,
1517
IdempotentsResult,
1618
PowerProfileRequest,
@@ -21,6 +23,7 @@
2123
from jacobian.math.finite_semigroups._operations import (
2224
compute_element_power,
2325
compute_generated_subsemigroup,
26+
compute_green_relations,
2427
compute_idempotents,
2528
compute_power_profile,
2629
compute_principal_ideals,
@@ -181,3 +184,33 @@ def _op[
181184
)
182185

183186
__all__ = ["TOOLS"]
187+
188+
_TOOLS_LIST = list(TOOLS)
189+
_TOOLS_LIST.append(
190+
_op(
191+
"semigroup.green_relations.compute",
192+
"Compute Green relations of a finite semigroup",
193+
"Compute the Green relations L, R, H, D, and J of a finite semigroup. "
194+
"L relates elements with the same principal left ideal, R with the "
195+
"same principal right ideal, H = L ∩ R, D = L ∨ R (join), and J is " # noqa: RUF001
196+
"the two-sided Green relation defined by principal two-sided ideals.",
197+
GreenRelationsRequest,
198+
GreenRelationsResult,
199+
compute_green_relations,
200+
"algebra",
201+
"semigroup",
202+
"exact",
203+
examples=(
204+
example(
205+
"green_relations_z3",
206+
"Compute the Green relations of Z/3Z.",
207+
{
208+
"semigroup": _SEMIGROUP,
209+
},
210+
),
211+
),
212+
)
213+
)
214+
TOOLS = tuple(_TOOLS_LIST)
215+
216+
__all__ = ["TOOLS"]

0 commit comments

Comments
 (0)