Skip to content

Commit 88047fe

Browse files
committed
feat(math): add chip-firing operations for #1741
Add 9 new chip-firing operations to the chip_firing domain: - graph.chip_firing.reduced_laplacian.compute: exact reduced Laplacian (sink row/column deleted) with nonsink vertex labels - graph.chip_firing.fire_vector.compute: integer firing-vector action D' = D - Lf with degree preservation - graph.chip_firing.stabilize.compute: stabilization via least-action BFS queue, returning stable config, odometer, and total firings - graph.chip_firing.parallel_step.compute: one simultaneous legal-firing step on all unstable nonsink vertices - graph.chip_firing.q_reduced.compute: q-reduced normal form via Dhar's algorithm with exact firing vector - graph.chip_firing.degree.compute: typed divisor degree sum - graph.chip_firing.canonical_divisor.compute: K(v) = deg(v) - 2 - graph.chip_firing.critical_group.compute: critical group invariant factors via SNF of reduced Laplacian (SymPy) - graph.chip_firing.abel_jacobi.compute: Abel-Jacobi map to critical-group coordinates All operations use exact integer arithmetic with bounded inputs. 42 tests cover all 17 required fixtures from the issue, including metamorphic identities (degree preservation, firing composition, stabilization idempotence, q-reduced idempotence, critical-group order matching spanning-tree count, sink-change invariance, and vertex-relabelling equivariance). Closes #1741
1 parent 0db39a2 commit 88047fe

5 files changed

Lines changed: 1145 additions & 16 deletions

File tree

src/jacobian/math/chip_firing/_admission.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,56 @@
1313
AdmissionDecision.KEEP,
1414
"exact graph Laplacian with degree vector and labelled axes",
1515
),
16+
OperationAdmission(
17+
"graph.chip_firing.reduced_laplacian.compute",
18+
AdmissionDecision.KEEP,
19+
"exact reduced Laplacian with sink row/column deleted",
20+
),
1621
OperationAdmission(
1722
"graph.chip_firing.fire_vertex.compute",
1823
AdmissionDecision.KEEP,
1924
"exact chip-firing action with vertex degree transfer",
2025
),
26+
OperationAdmission(
27+
"graph.chip_firing.fire_vector.compute",
28+
AdmissionDecision.KEEP,
29+
"exact integer firing-vector action with degree preservation",
30+
),
31+
OperationAdmission(
32+
"graph.chip_firing.stabilize.compute",
33+
AdmissionDecision.KEEP,
34+
"exact stabilization with odometer via least-action algorithm",
35+
),
36+
OperationAdmission(
37+
"graph.chip_firing.parallel_step.compute",
38+
AdmissionDecision.KEEP,
39+
"one simultaneous legal-firing state transform",
40+
),
41+
OperationAdmission(
42+
"graph.chip_firing.q_reduced.compute",
43+
AdmissionDecision.KEEP,
44+
"q-reduced canonical normal form with exact firing vector",
45+
),
46+
OperationAdmission(
47+
"graph.chip_firing.degree.compute",
48+
AdmissionDecision.KEEP,
49+
"exact typed projection of the divisor degree sum",
50+
),
51+
OperationAdmission(
52+
"graph.chip_firing.canonical_divisor.compute",
53+
AdmissionDecision.KEEP,
54+
"exact graph canonical divisor K(v) = deg(v) - 2",
55+
),
56+
OperationAdmission(
57+
"graph.chip_firing.critical_group.compute",
58+
AdmissionDecision.KEEP,
59+
"critical group invariant factors via SNF of reduced Laplacian",
60+
),
61+
OperationAdmission(
62+
"graph.chip_firing.abel_jacobi.compute",
63+
AdmissionDecision.KEEP,
64+
"Abel-Jacobi coordinates in the cokernel of the reduced Laplacian",
65+
),
2166
)
2267

2368
REGISTRATION = OperationRegistration(TOOLS, ADMISSIONS)

src/jacobian/math/chip_firing/_models.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@
1010

1111
MAX_VERTICES = 50
1212
MAX_DEGREE = 100
13+
MAX_COEFFICIENT_DIGITS = 1_000
14+
15+
16+
def _validate_divisor(
17+
vertices: tuple[str, ...],
18+
divisor: tuple[int, ...],
19+
*,
20+
label: str = "divisor",
21+
) -> None:
22+
if len(divisor) != len(vertices):
23+
raise ValueError(f"{label} length must match vertex count")
24+
if any(abs(c) >= 10 ** MAX_COEFFICIENT_DIGITS for c in divisor):
25+
raise ValueError(f"{label} coefficients exceed the digit bound")
26+
27+
28+
def _validate_sink(vertices: tuple[str, ...], sink: str) -> None:
29+
if sink not in set(vertices):
30+
raise ValueError("sink vertex must be in the graph")
1331

1432

1533
class LabelledGraph(StrictModel):
@@ -57,6 +75,26 @@ class LaplacianResult(StrictModel):
5775
degrees: tuple[int, ...]
5876

5977

78+
class ReducedLaplacianRequest(StrictModel):
79+
"""Request the reduced Laplacian (sink row/column deleted)."""
80+
81+
graph: LabelledGraph
82+
sink: str
83+
84+
@model_validator(mode="after")
85+
def require_valid_request(self) -> Self:
86+
_validate_sink(self.graph.vertices, self.sink)
87+
return self
88+
89+
90+
class ReducedLaplacianResult(StrictModel):
91+
"""The reduced Laplacian with nonsink vertex labels."""
92+
93+
vertices: tuple[str, ...]
94+
sink: str
95+
reduced_laplacian: tuple[tuple[int, ...], ...]
96+
97+
6098
class FiringRequest(StrictModel):
6199
"""Fire a vertex: transfer one chip to each neighbor."""
62100

@@ -80,7 +118,168 @@ class FiringResult(StrictModel):
80118
fired_divisor: tuple[int, ...]
81119

82120

121+
class FireVectorRequest(StrictModel):
122+
"""Fire a vector: D' = D - L f."""
123+
124+
graph: LabelledGraph
125+
divisor: tuple[int, ...] = Field(min_length=1)
126+
firing_vector: tuple[int, ...] = Field(min_length=1)
127+
128+
@model_validator(mode="after")
129+
def require_valid_request(self) -> Self:
130+
n = len(self.graph.vertices)
131+
if len(self.divisor) != n:
132+
raise ValueError("divisor length must match vertex count")
133+
if len(self.firing_vector) != n:
134+
raise ValueError("firing vector length must match vertex count")
135+
if any(abs(c) >= 10 ** MAX_COEFFICIENT_DIGITS for c in self.firing_vector):
136+
raise ValueError("firing vector coefficients exceed the digit bound")
137+
return self
138+
139+
140+
class FireVectorResult(StrictModel):
141+
"""Result of firing a vector."""
142+
143+
fired_divisor: tuple[int, ...]
144+
degree_preserved: bool
145+
146+
147+
class SinkConfiguration(StrictModel):
148+
"""A sink configuration for chip-firing stabilization."""
149+
150+
graph: LabelledGraph
151+
sink: str
152+
configuration: tuple[int, ...] = Field(min_length=1)
153+
154+
@model_validator(mode="after")
155+
def require_valid_request(self) -> Self:
156+
vertices = self.graph.vertices
157+
_validate_sink(vertices, self.sink)
158+
if len(self.configuration) != len(vertices):
159+
raise ValueError("configuration length must match vertex count")
160+
nonsink = [i for i, v in enumerate(vertices) if v != self.sink]
161+
if any(self.configuration[i] < 0 for i in nonsink):
162+
raise ValueError("nonsink configuration must be nonnegative")
163+
return self
164+
165+
166+
class StabilizeRequest(StrictModel):
167+
"""Stabilize a sink configuration."""
168+
169+
configuration: SinkConfiguration
170+
171+
172+
class StabilizeResult(StrictModel):
173+
"""The stable configuration and odometer vector."""
174+
175+
stable: tuple[int, ...]
176+
odometer: tuple[int, ...]
177+
total_firings: int
178+
179+
180+
class ParallelStepRequest(StrictModel):
181+
"""One parallel firing step."""
182+
183+
configuration: SinkConfiguration
184+
185+
186+
class ParallelStepResult(StrictModel):
187+
"""The next configuration and the set of vertices that fired."""
188+
189+
next_configuration: tuple[int, ...]
190+
fired_vertices: tuple[str, ...]
191+
192+
193+
class QReducedRequest(StrictModel):
194+
"""Compute the q-reduced normal form of a divisor."""
195+
196+
graph: LabelledGraph
197+
divisor: tuple[int, ...] = Field(min_length=1)
198+
sink: str
199+
200+
@model_validator(mode="after")
201+
def require_valid_request(self) -> Self:
202+
n = len(self.graph.vertices)
203+
_validate_sink(self.graph.vertices, self.sink)
204+
if len(self.divisor) != n:
205+
raise ValueError("divisor length must match vertex count")
206+
return self
207+
208+
209+
class QReducedResult(StrictModel):
210+
"""The q-reduced divisor and the exact firing vector."""
211+
212+
reduced_divisor: tuple[int, ...]
213+
firing_vector: tuple[int, ...]
214+
215+
216+
class DegreeRequest(StrictModel):
217+
"""Compute the degree of a graph divisor."""
218+
219+
divisor: tuple[int, ...] = Field(min_length=1)
220+
221+
222+
class DegreeResult(StrictModel):
223+
"""The degree of the divisor."""
224+
225+
degree: int
226+
227+
228+
class CanonicalDivisorRequest(StrictModel):
229+
"""Compute the graph canonical divisor K(v) = deg(v) - 2."""
230+
231+
graph: LabelledGraph
232+
233+
234+
class CanonicalDivisorResult(StrictModel):
235+
"""The canonical divisor and its degree."""
236+
237+
vertices: tuple[str, ...]
238+
divisor: tuple[int, ...]
239+
degree: int
240+
241+
83242
class CriticalGroupRequest(StrictModel):
84243
"""Request the critical group (sandpile group) of a graph."""
85244

86245
graph: LabelledGraph
246+
sink: str
247+
248+
@model_validator(mode="after")
249+
def require_valid_request(self) -> Self:
250+
_validate_sink(self.graph.vertices, self.sink)
251+
return self
252+
253+
254+
class CriticalGroupResult(StrictModel):
255+
"""The critical group invariant factors and order."""
256+
257+
sink: str
258+
nonsink_vertices: tuple[str, ...]
259+
invariant_factors: tuple[int, ...]
260+
order: int
261+
262+
263+
class AbelJacobiRequest(StrictModel):
264+
"""Map a degree-zero divisor into the critical group."""
265+
266+
graph: LabelledGraph
267+
divisor: tuple[int, ...] = Field(min_length=1)
268+
sink: str
269+
270+
@model_validator(mode="after")
271+
def require_valid_request(self) -> Self:
272+
n = len(self.graph.vertices)
273+
_validate_sink(self.graph.vertices, self.sink)
274+
if len(self.divisor) != n:
275+
raise ValueError("divisor length must match vertex count")
276+
return self
277+
278+
279+
class AbelJacobiResult(StrictModel):
280+
"""The critical-group coordinates of a degree-zero divisor."""
281+
282+
sink: str
283+
nonsink_vertices: tuple[str, ...]
284+
coordinates: tuple[int, ...]
285+
invariant_factors: tuple[int, ...]

0 commit comments

Comments
 (0)