Skip to content

Commit 39827af

Browse files
committed
Merge branch 'codex/public-math-contracts' into codex/singular-ideal-operations
2 parents 5f57b77 + 57437ac commit 39827af

4 files changed

Lines changed: 173 additions & 17 deletions

File tree

src/jacobian/math/matrices/symbolic/_models.py

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from itertools import combinations, permutations
56
from typing import Literal, Self
67

78
from pydantic import Field, model_validator
@@ -12,6 +13,87 @@
1213
MAX_SYMBOLIC_MATRIX_DIMENSION = 8
1314
MAX_SYMBOLIC_VARIABLES = 8
1415
MAX_SYMBOLIC_MATRIX_TERMS = 512
16+
MAX_SYMBOLIC_RESULT_TERMS = 256
17+
MAX_SYMBOLIC_RESULT_EXPONENT = 64
18+
MAX_SYMBOLIC_RESULT_COEFFICIENT_DIGITS = 128
19+
20+
21+
def _is_polynomial_entry(value: RationalFunction) -> bool:
22+
terms = value.denominator.terms
23+
return (
24+
len(terms) == 1
25+
and terms[0].coefficient.num == "1"
26+
and terms[0].coefficient.den == "1"
27+
and all(exponent == 0 for exponent in terms[0].exponents)
28+
)
29+
30+
31+
def _principal_minor_term_bounds(
32+
entries: tuple[tuple[RationalFunction, ...], ...],
33+
) -> tuple[int, ...]:
34+
"""Bound raw terms in each characteristic coefficient by Leibniz expansion."""
35+
36+
dimension = len(entries)
37+
bounds = [1]
38+
for size in range(1, dimension + 1):
39+
coefficient_terms = 0
40+
for axes in combinations(range(dimension), size):
41+
for columns in permutations(axes):
42+
product_terms = 1
43+
for row, column in zip(axes, columns, strict=True):
44+
product_terms *= len(entries[row][column].numerator.terms)
45+
coefficient_terms += product_terms
46+
bounds.append(coefficient_terms)
47+
return tuple(bounds)
48+
49+
50+
def _require_determinant_family_result_budget(
51+
matrix: SymbolicMatrix,
52+
*,
53+
characteristic_polynomial: bool,
54+
) -> None:
55+
dimension = len(matrix.entries)
56+
if dimension == 1:
57+
return
58+
values = tuple(value for row in matrix.entries for value in row)
59+
if any(not _is_polynomial_entry(value) for value in values):
60+
raise ValueError(
61+
"multi-dimensional determinant-family requests require polynomial entries"
62+
)
63+
term_bounds = _principal_minor_term_bounds(matrix.entries)
64+
relevant_bounds = term_bounds[1:] if characteristic_polynomial else term_bounds[-1:]
65+
if any(bound > MAX_SYMBOLIC_RESULT_TERMS for bound in relevant_bounds):
66+
raise ValueError("determinant-family expansion exceeds the result term budget")
67+
maximum_exponent = max(
68+
(
69+
exponent
70+
for value in values
71+
for term in value.numerator.terms
72+
for exponent in term.exponents
73+
),
74+
default=0,
75+
)
76+
if dimension * maximum_exponent > MAX_SYMBOLIC_RESULT_EXPONENT:
77+
raise ValueError(
78+
"determinant-family expansion exceeds the result exponent budget"
79+
)
80+
coefficient_digits = max(
81+
(
82+
len(component.lstrip("-"))
83+
for value in values
84+
for term in value.numerator.terms
85+
for component in (term.coefficient.num, term.coefficient.den)
86+
),
87+
default=1,
88+
)
89+
if any(
90+
bound * dimension * coefficient_digits + len(str(max(bound, 1)))
91+
> MAX_SYMBOLIC_RESULT_COEFFICIENT_DIGITS
92+
for bound in relevant_bounds
93+
):
94+
raise ValueError(
95+
"determinant-family expansion exceeds the result coefficient budget"
96+
)
1597

1698

1799
class SymbolicMatrix(StrictModel):
@@ -92,28 +174,41 @@ def require_square(self) -> Self:
92174
class SymbolicDeterminantRequest(SquareSymbolicMatrixRequest):
93175
"""A square matrix whose exact determinant fits the public result type."""
94176

177+
matrix: SymbolicMatrix = Field(
178+
description=(
179+
"A square symbolic matrix. One-dimensional matrices may contain any "
180+
"accepted rational function; larger matrices require polynomial "
181+
"entries whose derived determinant expansion has at most 256 terms, "
182+
"exponent 64, and 128-digit coefficient components."
183+
)
184+
)
185+
95186
@model_validator(mode="after")
96187
def require_representable_determinant(self) -> Self:
97-
# The input sparsity budget bounds backend work, but rational-function
98-
# expansion can still exceed the canonical result representation. Run
99-
# the bounded exact kernel at admission so an accepted request cannot
100-
# fail later while constructing its typed result.
101-
from jacobian.math.matrices.symbolic import symbolic_determinant
102-
103-
symbolic_determinant(self.matrix.entries, self.matrix.variables)
188+
_require_determinant_family_result_budget(
189+
self.matrix,
190+
characteristic_polynomial=False,
191+
)
104192
return self
105193

106194

107195
class SymbolicCharacteristicPolynomialRequest(SquareSymbolicMatrixRequest):
108196
"""A square matrix whose characteristic polynomial fits the result type."""
109197

198+
matrix: SymbolicMatrix = Field(
199+
description=(
200+
"A square symbolic matrix. One-dimensional matrices may contain any "
201+
"accepted rational function; larger matrices require polynomial "
202+
"entries whose derived principal-minor expansions each have at most "
203+
"256 terms, exponent 64, and 128-digit coefficient components."
204+
)
205+
)
206+
110207
@model_validator(mode="after")
111208
def require_representable_characteristic_polynomial(self) -> Self:
112-
from jacobian.math.matrices.symbolic import symbolic_characteristic_polynomial
113-
114-
symbolic_characteristic_polynomial(
115-
self.matrix.entries,
116-
self.matrix.variables,
209+
_require_determinant_family_result_budget(
210+
self.matrix,
211+
characteristic_polynomial=True,
117212
)
118213
return self
119214

src/jacobian/math/projective_coords_ops/_models.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,24 @@
66

77
from pydantic import Field, model_validator
88

9-
from jacobian._exact import CanonicalRational
9+
from jacobian._exact import MAX_CANONICAL_RATIONAL_DIGITS, CanonicalRational
1010
from jacobian._models import StrictModel
1111

1212
MAX_DIM = 16
13+
MAX_PROJECTIVE_COORDINATE_DIGITS = MAX_CANONICAL_RATIONAL_DIGITS // 2
14+
15+
16+
def _require_ratio_result_budget(
17+
coordinates: tuple[CanonicalRational, ...],
18+
) -> None:
19+
if any(
20+
len(component.lstrip("-")) > MAX_PROJECTIVE_COORDINATE_DIGITS
21+
for coordinate in coordinates
22+
for component in (coordinate.num, coordinate.den)
23+
):
24+
raise ValueError(
25+
"projective coordinate components exceed the 16,384-digit ratio budget"
26+
)
1327

1428

1529
class RationalProjectivePoint(StrictModel):
@@ -30,11 +44,17 @@ def require_not_all_zero(self) -> Self:
3044

3145
class RationalPointConstructRequest(StrictModel):
3246
coordinates: tuple[CanonicalRational, ...] = Field(
33-
min_length=1, max_length=MAX_DIM + 1
47+
min_length=1,
48+
max_length=MAX_DIM + 1,
49+
description=(
50+
"Homogeneous coordinates whose rational components are at most "
51+
"16,384 digits so every normalization ratio remains representable."
52+
),
3453
)
3554

3655
@model_validator(mode="after")
3756
def require_valid(self) -> Self:
57+
_require_ratio_result_budget(self.coordinates)
3858
if all(c.as_fraction() == 0 for c in self.coordinates):
3959
raise ValueError(
4060
"projective point must have at least one nonzero coordinate"
@@ -43,11 +63,17 @@ def require_valid(self) -> Self:
4363

4464

4565
class StandardChartRequest(StrictModel):
46-
point: RationalProjectivePoint
66+
point: RationalProjectivePoint = Field(
67+
description=(
68+
"A rational projective point whose coordinate components are at most "
69+
"16,384 digits so every chart ratio remains representable."
70+
)
71+
)
4772
chart_index: int = Field(ge=0)
4873

4974
@model_validator(mode="after")
5075
def require_valid(self) -> Self:
76+
_require_ratio_result_budget(self.point.coordinates)
5177
if self.chart_index >= len(self.point.coordinates):
5278
raise ValueError("chart_index out of range")
5379
if self.point.coordinates[self.chart_index].as_fraction() == 0:
@@ -56,12 +82,18 @@ def require_valid(self) -> Self:
5682

5783

5884
class ChartTransitionRequest(StrictModel):
59-
point: RationalProjectivePoint
85+
point: RationalProjectivePoint = Field(
86+
description=(
87+
"A rational projective point whose coordinate components are at most "
88+
"16,384 digits so every target-chart ratio remains representable."
89+
)
90+
)
6091
chart_i: int = Field(ge=0)
6192
chart_j: int = Field(ge=0)
6293

6394
@model_validator(mode="after")
6495
def require_valid(self) -> Self:
96+
_require_ratio_result_budget(self.point.coordinates)
6597
n = len(self.point.coordinates)
6698
if self.chart_i >= n or self.chart_j >= n:
6799
raise ValueError("chart index out of range")

tests/math/matrices/test_symbolic_matrix.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,25 @@ def test_determinant_request_rejects_unrepresentable_expansion() -> None:
148148
for row in range(8)
149149
)
150150

151-
with pytest.raises(ValidationError, match="term operation budget"):
151+
with pytest.raises(ValidationError, match="result term budget"):
152152
SymbolicDeterminantRequest(
153153
matrix=SymbolicMatrix(variables=variables, entries=entries)
154154
)
155155

156156

157+
def test_determinant_request_admission_does_not_execute_kernel(
158+
monkeypatch: pytest.MonkeyPatch,
159+
) -> None:
160+
import jacobian.math.matrices.symbolic as symbolic
161+
162+
def fail_if_called(*_args: object, **_kwargs: object) -> None:
163+
raise AssertionError("determinant kernel ran during request validation")
164+
165+
monkeypatch.setattr(symbolic, "symbolic_determinant", fail_if_called)
166+
167+
assert isinstance(_generic_two_by_two(), SymbolicDeterminantRequest)
168+
169+
157170
def test_symbolic_rank_of_full_and_singular_matrices() -> None:
158171
full = compute_symbolic_rank(_generic_two_by_two())
159172
assert isinstance(full, SymbolicRankResult)

tests/math/projective_coords_ops/test_projective_coords.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ def test_chart_transition_reports_outside_target_chart() -> None:
100100
assert result.transition is None
101101

102102

103+
def test_chart_transition_rejects_unrepresentable_ratio_growth() -> None:
104+
component = "1" + "0" * 16_384
105+
106+
with pytest.raises(ValidationError, match="ratio budget"):
107+
ChartTransitionRequest(
108+
point=RationalProjectivePoint(
109+
coordinates=(
110+
CanonicalRational(num=component, den="1"),
111+
CanonicalRational(num="1", den=component),
112+
)
113+
),
114+
chart_i=0,
115+
chart_j=1,
116+
)
117+
118+
103119
def test_chart_transition_round_trips_between_defined_charts() -> None:
104120
point = _point(_r("2"), _r("3"), _r("5"))
105121
forward = compute_chart_transition(

0 commit comments

Comments
 (0)