Skip to content

Commit 663a685

Browse files
committed
feat(math): add algebraic topology operations (#1878)
Add algebraic_topology_ops domain with 2 operations: edge path word computation and edge path concatenation. Uses exact graph traversal over bounded edge paths. Closes #1878
1 parent 2e54684 commit 663a685

10 files changed

Lines changed: 322 additions & 3 deletions

File tree

src/jacobian/catalog/builtins.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
from jacobian.math.algebraic_combinatorics._tools import (
1919
TOOLS as ALGEBRAIC_COMBINATORICS_TOOLS,
2020
)
21+
from jacobian.math.algebraic_topology_ops._admission import (
22+
ADMISSIONS as ALGEBRAIC_TOPOLOGY_OPS_ADMISSIONS,
23+
)
24+
from jacobian.math.algebraic_topology_ops._tools import (
25+
TOOLS as ALGEBRAIC_TOPOLOGY_OPS_TOOLS,
26+
)
2127
from jacobian.math.analysis._admission import ADMISSIONS as ANALYSIS_ADMISSIONS
2228
from jacobian.math.analysis._tools import TOOLS as ANALYSIS_TOOLS
2329
from jacobian.math.arithmetic._admission import ADMISSIONS as ARITHMETIC_ADMISSIONS
@@ -311,6 +317,7 @@
311317
*POLYNOMIAL_TOOLS,
312318
*MULTIVARIATE_POLYNOMIAL_TOOLS,
313319
*ANALYSIS_TOOLS,
320+
*ALGEBRAIC_TOPOLOGY_OPS_TOOLS,
314321
*PROBABILITY_TOOLS,
315322
*OPTIMIZATION_TOOLS,
316323
*TOPOLOGY_TOOLS,
@@ -352,6 +359,7 @@
352359
*ADDITIVE_COMBINATORICS_ADMISSIONS,
353360
*ALGEBRAIC_COMBINATORICS_ADMISSIONS,
354361
*ANALYSIS_ADMISSIONS,
362+
*ALGEBRAIC_TOPOLOGY_OPS_ADMISSIONS,
355363
*ARITHMETIC_ADMISSIONS,
356364
*ARITHMETIC_COUNTING_ADMISSIONS,
357365
*ARITHMETIC_DYNAMICS_ADMISSIONS,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Algebraic topology operations."""
2+
3+
__all__: list[str] = []
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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+
"topology.simplicial.edge_path.word.compute",
10+
AdmissionDecision.KEEP,
11+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
12+
),
13+
OperationAdmission(
14+
"topology.simplicial.edge_path.concatenate.compute",
15+
AdmissionDecision.KEEP,
16+
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
17+
),
18+
)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Typed wire contracts for algebraic topology 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_EDGES = 64
12+
MAX_WORD = 128
13+
14+
15+
class EdgePath(StrictModel):
16+
"""A path in a graph as a sequence of oriented edges."""
17+
18+
vertex_count: int = Field(ge=2)
19+
edges: tuple[tuple[int, int], ...] = Field(min_length=1, max_length=MAX_EDGES)
20+
21+
@model_validator(mode="after")
22+
def require_valid(self) -> Self:
23+
for u, v in self.edges:
24+
if not (0 <= u < self.vertex_count and 0 <= v < self.vertex_count):
25+
raise ValueError("edge vertices must be in 0..vertex_count-1")
26+
return self
27+
28+
29+
class EdgePathWordRequest(StrictModel):
30+
"""Compute the free group word for an edge path."""
31+
32+
vertex_count: int = Field(ge=2)
33+
edges: tuple[tuple[int, int], ...] = Field(min_length=1, max_length=MAX_EDGES)
34+
path: tuple[int, ...] = Field(min_length=2, max_length=MAX_WORD)
35+
36+
@model_validator(mode="after")
37+
def require_valid(self) -> Self:
38+
if any(not 0 <= v < self.vertex_count for v in self.path):
39+
raise ValueError("path vertices must be in 0..vertex_count-1")
40+
for u, v in self.edges:
41+
if not (0 <= u < self.vertex_count and 0 <= v < self.vertex_count):
42+
raise ValueError("edge vertices must be in 0..vertex_count-1")
43+
return self
44+
45+
46+
class EdgePathConcatenateRequest(StrictModel):
47+
"""Concatenate two edge paths."""
48+
49+
vertex_count: int = Field(ge=2)
50+
path_a: tuple[int, ...] = Field(min_length=2, max_length=MAX_WORD)
51+
path_b: tuple[int, ...] = Field(min_length=2, max_length=MAX_WORD)
52+
53+
@model_validator(mode="after")
54+
def require_valid(self) -> Self:
55+
if any(not 0 <= v < self.vertex_count for v in self.path_a):
56+
raise ValueError("path_a vertices must be valid")
57+
if any(not 0 <= v < self.vertex_count for v in self.path_b):
58+
raise ValueError("path_b vertices must be valid")
59+
return self
60+
61+
62+
# Results
63+
64+
65+
class EdgePathWordResult(StrictModel):
66+
word: tuple[str, ...]
67+
length: int = Field(ge=0)
68+
method: str = "EDGE_LABEL_REDUCTION"
69+
70+
71+
class EdgePathConcatenateResult(StrictModel):
72+
path: tuple[int, ...]
73+
length: int = Field(ge=0)
74+
method: str = "PATH_CONCATENATION"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Domain functions for algebraic topology operations."""
2+
3+
from __future__ import annotations
4+
5+
from jacobian.math.algebraic_topology_ops._models import (
6+
EdgePathConcatenateRequest,
7+
EdgePathConcatenateResult,
8+
EdgePathWordRequest,
9+
EdgePathWordResult,
10+
)
11+
12+
13+
def compute_edge_path_word(request: EdgePathWordRequest) -> EdgePathWordResult:
14+
"""Compute the free group word for an edge path.
15+
16+
Each edge in the graph is assigned a generator label e_i.
17+
Traversing edge i forward adds e_i, backward adds e_i^{-1}.
18+
"""
19+
edges = list(request.edges)
20+
path = list(request.path)
21+
word: list[str] = []
22+
for i in range(len(path) - 1):
23+
u, v = path[i], path[i + 1]
24+
found = False
25+
for j, (eu, ev) in enumerate(edges):
26+
if u == eu and v == ev:
27+
word.append(f"e{j + 1}")
28+
found = True
29+
break
30+
if u == ev and v == eu:
31+
word.append(f"e{j + 1}^-1")
32+
found = True
33+
break
34+
if not found:
35+
word.append(f"INVALID({u}->{v})")
36+
return EdgePathWordResult(
37+
word=tuple(word),
38+
length=len(word),
39+
)
40+
41+
42+
def compute_edge_path_concatenate(
43+
request: EdgePathConcatenateRequest,
44+
) -> EdgePathConcatenateResult:
45+
"""Concatenate two edge paths.
46+
47+
If the last vertex of path_a equals the first vertex of path_b,
48+
the concatenation is path_a + path_b[1:], removing the duplicate.
49+
"""
50+
path_a = list(request.path_a)
51+
path_b = list(request.path_b)
52+
if path_a and path_b and path_a[-1] == path_b[0]:
53+
result = path_a + path_b[1:]
54+
else:
55+
result = path_a + path_b
56+
return EdgePathConcatenateResult(
57+
path=tuple(result),
58+
length=len(result),
59+
)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Algebraic topology operation declarations."""
2+
3+
from collections.abc import Callable
4+
from typing import Any
5+
6+
from jacobian._models import StrictModel
7+
from jacobian.catalog._examples import example
8+
from jacobian.catalog.models import MathTool, OperationExample
9+
from jacobian.math.algebraic_topology_ops._models import (
10+
EdgePathConcatenateRequest,
11+
EdgePathConcatenateResult,
12+
EdgePathWordRequest,
13+
EdgePathWordResult,
14+
)
15+
from jacobian.math.algebraic_topology_ops._operations import (
16+
compute_edge_path_concatenate,
17+
compute_edge_path_word,
18+
)
19+
20+
21+
def _op[RequestT: StrictModel, ResultT: StrictModel](
22+
operation_id: str,
23+
title: str,
24+
description: str,
25+
request_model: type[RequestT],
26+
result_model: type[ResultT],
27+
operation: Callable[[RequestT], ResultT],
28+
*tags: str,
29+
examples: tuple[OperationExample, ...] = (),
30+
version: str = "1",
31+
) -> MathTool[RequestT, ResultT]:
32+
return MathTool(
33+
operation_id=operation_id,
34+
version=version,
35+
title=title,
36+
description=description,
37+
request_type=request_model,
38+
result_type=result_model,
39+
run=operation,
40+
tags=tags,
41+
examples=examples,
42+
)
43+
44+
45+
TOOLS: tuple[MathTool[Any, Any], ...] = (
46+
_op(
47+
"topology.simplicial.edge_path.word.compute",
48+
"Compute the free group word for an edge path",
49+
"Compute the free group word representation of an edge path in a "
50+
"graph, where each edge corresponds to a generator and its inverse.",
51+
EdgePathWordRequest,
52+
EdgePathWordResult,
53+
compute_edge_path_word,
54+
"topology",
55+
"edge-path",
56+
"exact",
57+
examples=(
58+
example(
59+
"triangle_path",
60+
"Compute the word for path 0->1->2 in a triangle.",
61+
{
62+
"vertex_count": 3,
63+
"edges": [[0, 1], [1, 2], [2, 0]],
64+
"path": [0, 1, 2],
65+
},
66+
),
67+
),
68+
),
69+
_op(
70+
"topology.simplicial.edge_path.concatenate.compute",
71+
"Concatenate two edge paths",
72+
"Concatenate two edge paths in a graph, removing the shared vertex.",
73+
EdgePathConcatenateRequest,
74+
EdgePathConcatenateResult,
75+
compute_edge_path_concatenate,
76+
"topology",
77+
"edge-path",
78+
"exact",
79+
examples=(
80+
example(
81+
"concatenate_paths",
82+
"Concatenate [0,1] and [1,2] in a 3-vertex graph.",
83+
{
84+
"vertex_count": 3,
85+
"path_a": [0, 1],
86+
"path_b": [1, 2],
87+
},
88+
),
89+
),
90+
),
91+
)
92+
93+
__all__ = ["TOOLS"]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"catalog_version": "1",
3+
"domain": "algebraic_topology_ops",
4+
"operations": {
5+
"topology.simplicial.edge_path.concatenate.compute": {
6+
"input_schema": "sha256:032aa114a51b155f849194e433e7259901f843a3f779d511f28a2930db133cfb",
7+
"output_schema": "sha256:b783b16a3b53a4ffbbfd38b220ef9208cd2c998f8a25d3ebd12c671f00193257"
8+
},
9+
"topology.simplicial.edge_path.word.compute": {
10+
"input_schema": "sha256:d6b5fc61ccb1c894985679dbf3cf6c55d6d3076c6f18bbb408b5e936940bfa8f",
11+
"output_schema": "sha256:1b0449d78502cd66e325226742fb6cb2a3e2684cec8136b12ecdb2fa83ad6cba"
12+
}
13+
}
14+
}

tests/catalog/test_admission.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ def test_every_frozen_candidate_has_exactly_one_admission_decision() -> None:
2727
reviewed_ids = [record.operation_id for record in OPERATION_ADMISSIONS]
2828

2929
assert REVIEWED_BASE_REVISION == "61589543bbbff546edbc51d34a07887982fa4ad6"
30-
assert len(candidate_ids) == len(set(candidate_ids)) == 399
30+
assert len(candidate_ids) == len(set(candidate_ids)) == 401
3131
assert reviewed_ids == sorted(reviewed_ids)
3232
assert set(reviewed_ids) == set(candidate_ids)
3333
assert all(record.rationale.strip() for record in OPERATION_ADMISSIONS)
3434
assert Counter(record.decision for record in OPERATION_ADMISSIONS) == {
35-
AdmissionDecision.KEEP: 239,
35+
AdmissionDecision.KEEP: 241,
3636
AdmissionDecision.NATIVE_ONLY: 56,
3737
AdmissionDecision.DROP: 104,
3838
}
@@ -46,7 +46,7 @@ def test_public_catalog_contains_only_admitted_atomic_operations() -> None:
4646
}
4747

4848
assert {tool.operation_id for tool in BUILTIN_TOOLS} == expected
49-
assert len(BUILTIN_TOOLS) == 239
49+
assert len(BUILTIN_TOOLS) == 241
5050

5151

5252
def test_catalog_construction_fails_closed_on_duplicate_candidates() -> None:

tests/math/algebraic_topology_ops/__init__.py

Whitespace-only changes.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Tests for algebraic topology operations."""
2+
3+
from jacobian.math.algebraic_topology_ops._models import (
4+
EdgePathConcatenateRequest,
5+
EdgePathWordRequest,
6+
)
7+
from jacobian.math.algebraic_topology_ops._operations import (
8+
compute_edge_path_concatenate,
9+
compute_edge_path_word,
10+
)
11+
from jacobian.math.algebraic_topology_ops._tools import TOOLS
12+
13+
14+
def test_catalog_contains_only_audited_operations() -> None:
15+
assert {tool.operation_id for tool in TOOLS} == {
16+
"topology.simplicial.edge_path.word.compute",
17+
"topology.simplicial.edge_path.concatenate.compute",
18+
}
19+
20+
21+
def test_edge_path_word_forward() -> None:
22+
request = EdgePathWordRequest(
23+
vertex_count=3,
24+
edges=((0, 1), (1, 2), (2, 0)),
25+
path=(0, 1, 2),
26+
)
27+
result = compute_edge_path_word(request)
28+
assert result.word == ("e1", "e2")
29+
assert result.length == 2
30+
31+
32+
def test_edge_path_word_backward() -> None:
33+
request = EdgePathWordRequest(
34+
vertex_count=3,
35+
edges=((0, 1), (1, 2), (2, 0)),
36+
path=(1, 0),
37+
)
38+
result = compute_edge_path_word(request)
39+
assert result.word == ("e1^-1",)
40+
41+
42+
def test_edge_path_concatenate() -> None:
43+
request = EdgePathConcatenateRequest(
44+
vertex_count=3,
45+
path_a=(0, 1),
46+
path_b=(1, 2),
47+
)
48+
result = compute_edge_path_concatenate(request)
49+
assert result.path == (0, 1, 2)
50+
assert result.length == 3

0 commit comments

Comments
 (0)