Skip to content

Commit 4f1e84c

Browse files
committed
fix(contracts): define identifier and opaque label types
1 parent d588263 commit 4f1e84c

12 files changed

Lines changed: 188 additions & 46 deletions

File tree

src/jacobian/math/_labels.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Shared passive value types for opaque mathematical labels."""
2+
3+
from __future__ import annotations
4+
5+
import unicodedata
6+
from typing import Annotated
7+
8+
from pydantic import AfterValidator, Field, StringConstraints
9+
10+
MAX_OPAQUE_LABEL_LENGTH = 64
11+
12+
13+
def _require_opaque_label(value: str) -> str:
14+
if value != value.strip():
15+
raise ValueError("label must not have leading or trailing whitespace")
16+
if any(unicodedata.category(character) == "Cc" for character in value):
17+
raise ValueError("label must not contain control characters")
18+
return value
19+
20+
21+
OpaqueLabel = Annotated[
22+
str,
23+
StringConstraints(
24+
min_length=1,
25+
max_length=MAX_OPAQUE_LABEL_LENGTH,
26+
strict=True,
27+
),
28+
Field(
29+
description=(
30+
"Opaque mathematical label without leading/trailing whitespace or "
31+
"Unicode control characters."
32+
)
33+
),
34+
AfterValidator(_require_opaque_label),
35+
]
36+
37+
__all__ = ["MAX_OPAQUE_LABEL_LENGTH", "OpaqueLabel"]

src/jacobian/math/finite_categories/_models.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pydantic import Field, model_validator
88

99
from jacobian._models import StrictModel
10+
from jacobian.math._labels import OpaqueLabel
1011

1112
MAX_OBJECTS = 20
1213
MAX_MORPHISMS = 100
@@ -16,9 +17,9 @@
1617
class MorphismSpec(StrictModel):
1718
"""One morphism: source and target objects, plus a unique ID."""
1819

19-
morphism_id: str
20-
source: str
21-
target: str
20+
morphism_id: OpaqueLabel
21+
source: OpaqueLabel
22+
target: OpaqueLabel
2223

2324

2425
def _morphism_index(
@@ -152,10 +153,14 @@ class FiniteCategoryRequest(StrictModel):
152153
laws are enforced once at the value boundary.
153154
"""
154155

155-
objects: tuple[str, ...] = Field(min_length=1, max_length=MAX_OBJECTS)
156+
objects: tuple[OpaqueLabel, ...] = Field(min_length=1, max_length=MAX_OBJECTS)
156157
morphisms: tuple[MorphismSpec, ...] = Field(max_length=MAX_MORPHISMS)
157-
identities: tuple[tuple[str, str], ...] = Field(max_length=MAX_OBJECTS)
158-
composition: tuple[tuple[str, str, str], ...] = Field(max_length=MAX_COMPOSITIONS)
158+
identities: tuple[tuple[OpaqueLabel, OpaqueLabel], ...] = Field(
159+
max_length=MAX_OBJECTS
160+
)
161+
composition: tuple[tuple[OpaqueLabel, OpaqueLabel, OpaqueLabel], ...] = Field(
162+
max_length=MAX_COMPOSITIONS
163+
)
159164

160165
@model_validator(mode="after")
161166
def require_valid_category(self) -> Self:
@@ -166,21 +171,21 @@ def require_valid_category(self) -> Self:
166171
class CategoryProfileResult(StrictModel):
167172
"""Profile of a finite category: hom-sets, endomorphisms, identities."""
168173

169-
objects: tuple[str, ...]
174+
objects: tuple[OpaqueLabel, ...]
170175
num_objects: int
171176
num_morphisms: int
172-
hom_sets: tuple[tuple[str, str, int], ...]
173-
endomorphisms: tuple[tuple[str, int], ...]
174-
identity_morphisms: tuple[tuple[str, str], ...]
177+
hom_sets: tuple[tuple[OpaqueLabel, OpaqueLabel, int], ...]
178+
endomorphisms: tuple[tuple[OpaqueLabel, int], ...]
179+
identity_morphisms: tuple[tuple[OpaqueLabel, OpaqueLabel], ...]
175180

176181

177182
class OppositeCategoryResult(StrictModel):
178183
"""The opposite category: reversed morphisms and reversed composition."""
179184

180-
objects: tuple[str, ...]
185+
objects: tuple[OpaqueLabel, ...]
181186
morphisms: tuple[MorphismSpec, ...]
182-
identities: tuple[tuple[str, str], ...]
183-
composition: tuple[tuple[str, str, str], ...]
187+
identities: tuple[tuple[OpaqueLabel, OpaqueLabel], ...]
188+
composition: tuple[tuple[OpaqueLabel, OpaqueLabel, OpaqueLabel], ...]
184189

185190
@model_validator(mode="after")
186191
def require_valid_opposite(self) -> Self:

src/jacobian/math/finite_semigroups/_models.py

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,22 @@
77
from pydantic import Field, model_validator
88

99
from jacobian._models import StrictModel
10+
from jacobian.math._labels import OpaqueLabel
1011

1112
MAX_ELEMENTS = 50
12-
MAX_LABEL_LENGTH = 64
1313

1414

1515
class FiniteSemigroup(StrictModel):
1616
"""A finite semigroup: a finite set with an associative binary operation."""
1717

18-
elements: tuple[str, ...] = Field(min_length=1, max_length=MAX_ELEMENTS)
19-
multiplication: tuple[tuple[str, ...], ...]
18+
elements: tuple[OpaqueLabel, ...] = Field(min_length=1, max_length=MAX_ELEMENTS)
19+
multiplication: tuple[tuple[OpaqueLabel, ...], ...]
2020

2121
@model_validator(mode="after")
2222
def require_valid_semigroup(self) -> Self:
2323
labels = set(self.elements)
2424
if len(labels) != len(self.elements):
2525
raise ValueError("element labels must be distinct")
26-
for label in self.elements:
27-
if len(label) > MAX_LABEL_LENGTH:
28-
raise ValueError("element label exceeds the bounded length budget")
2926
if len(self.multiplication) != len(self.elements):
3027
raise ValueError("multiplication table must have one row per element")
3128
for row in self.multiplication:
@@ -59,7 +56,7 @@ class PowerProfileRequest(StrictModel):
5956
"""Request the power profile of one element in a finite semigroup."""
6057

6158
semigroup: FiniteSemigroup
62-
element: str
59+
element: OpaqueLabel
6360

6461
@model_validator(mode="after")
6562
def require_element_exists(self) -> Self:
@@ -79,12 +76,12 @@ class PowerProfileResult(StrictModel):
7976
"""
8077

8178
semigroup: FiniteSemigroup
82-
element: str
83-
powers: tuple[str, ...]
79+
element: OpaqueLabel
80+
powers: tuple[OpaqueLabel, ...]
8481
index: int = Field(ge=1)
8582
period: int = Field(ge=1)
86-
idempotent: str
87-
cyclic_subsemigroup: tuple[str, ...]
83+
idempotent: OpaqueLabel
84+
cyclic_subsemigroup: tuple[OpaqueLabel, ...]
8885

8986
@model_validator(mode="after")
9087
def bind_power_profile(self) -> Self:
@@ -114,7 +111,7 @@ class GeneratedSubsemigroupRequest(StrictModel):
114111
"""Request the subsemigroup generated by a set of elements."""
115112

116113
semigroup: FiniteSemigroup
117-
generators: tuple[str, ...] = Field(min_length=1)
114+
generators: tuple[OpaqueLabel, ...] = Field(min_length=1)
118115

119116
@model_validator(mode="after")
120117
def require_generators_exist(self) -> Self:
@@ -128,15 +125,15 @@ def require_generators_exist(self) -> Self:
128125
class GeneratedSubsemigroupResult(StrictModel):
129126
"""The subsemigroup generated by a set of elements."""
130127

131-
generators: tuple[str, ...]
132-
elements: tuple[str, ...]
128+
generators: tuple[OpaqueLabel, ...]
129+
elements: tuple[OpaqueLabel, ...]
133130

134131

135132
class ElementPowerRequest(StrictModel):
136133
"""Request the exact power ``element^exponent`` in a finite semigroup."""
137134

138135
semigroup: FiniteSemigroup
139-
element: str
136+
element: OpaqueLabel
140137
exponent: int = Field(ge=1)
141138

142139
@model_validator(mode="after")
@@ -150,9 +147,9 @@ class ElementPowerResult(StrictModel):
150147
"""The exact power ``element^exponent`` in a finite semigroup."""
151148

152149
semigroup: FiniteSemigroup
153-
element: str
150+
element: OpaqueLabel
154151
exponent: int = Field(ge=1)
155-
power: str
152+
power: OpaqueLabel
156153

157154
@model_validator(mode="after")
158155
def bind_power(self) -> Self:
@@ -179,7 +176,7 @@ class IdempotentsResult(StrictModel):
179176
"""All idempotent elements of a finite semigroup."""
180177

181178
semigroup: FiniteSemigroup
182-
idempotents: tuple[str, ...]
179+
idempotents: tuple[OpaqueLabel, ...]
183180

184181
@model_validator(mode="after")
185182
def bind_idempotents(self) -> Self:
@@ -197,7 +194,7 @@ class PrincipalIdealsRequest(StrictModel):
197194
"""Request the principal ideal of each listed element."""
198195

199196
semigroup: FiniteSemigroup
200-
elements: tuple[str, ...] = Field(min_length=1, max_length=MAX_ELEMENTS)
197+
elements: tuple[OpaqueLabel, ...] = Field(min_length=1, max_length=MAX_ELEMENTS)
201198

202199
@model_validator(mode="after")
203200
def require_elements_exist(self) -> Self:
@@ -222,8 +219,8 @@ class PrincipalIdealsResult(StrictModel):
222219
"""
223220

224221
semigroup: FiniteSemigroup
225-
elements: tuple[str, ...]
226-
ideals: tuple[tuple[str, ...], ...]
222+
elements: tuple[OpaqueLabel, ...]
223+
ideals: tuple[tuple[OpaqueLabel, ...], ...]
227224

228225
@model_validator(mode="after")
229226
def bind_ideals(self) -> Self:

src/jacobian/math/finite_topology_spaces/_models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pydantic import Field, model_validator
88

99
from jacobian._models import StrictModel
10+
from jacobian.math._labels import OpaqueLabel
1011
from jacobian.math.finite_topology_spaces.values import (
1112
FiniteTopologicalMap,
1213
FiniteTopologicalSpace,
@@ -55,7 +56,7 @@ class KolmogorovQuotientRequest(StrictModel):
5556

5657

5758
class KolmogorovQuotientResult(StrictModel):
58-
quotient_points: tuple[str, ...]
59+
quotient_points: tuple[OpaqueLabel, ...]
5960
quotient_preorder: tuple[tuple[int, ...], ...]
6061

6162

src/jacobian/math/finite_topology_spaces/values.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@
77
from pydantic import Field, model_validator
88

99
from jacobian._models import StrictModel
10+
from jacobian.math._labels import OpaqueLabel
1011

1112
MAX_POINTS = 64
1213

1314

15+
def _require_distinct_points(points: tuple[str, ...]) -> None:
16+
if len(set(points)) != len(points):
17+
raise ValueError("point labels must be distinct")
18+
19+
1420
class FiniteTopologicalSpace(StrictModel):
1521
"""An immutable finite topological space represented by its specialization
1622
preorder.
@@ -24,11 +30,12 @@ class FiniteTopologicalSpace(StrictModel):
2430
point.
2531
"""
2632

27-
points: tuple[str, ...] = Field(min_length=1, max_length=MAX_POINTS)
33+
points: tuple[OpaqueLabel, ...] = Field(min_length=1, max_length=MAX_POINTS)
2834
preorder: tuple[tuple[int, ...], ...] = Field(min_length=1)
2935

3036
@model_validator(mode="after")
3137
def require_well_formed(self) -> Self:
38+
_require_distinct_points(self.points)
3239
if len(self.preorder) != len(self.points):
3340
raise ValueError("preorder must have one row per point")
3441
for row in self.preorder:

src/jacobian/math/impartial_games/values.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
from pydantic import Field, model_validator
99

1010
from jacobian._models import StrictModel
11+
from jacobian.math._labels import MAX_OPAQUE_LABEL_LENGTH, OpaqueLabel
1112

1213
MAX_POSITIONS = 500
1314
MAX_MOVES = 2_000
14-
MAX_LABEL_LENGTH = 64
15+
MAX_LABEL_LENGTH = MAX_OPAQUE_LABEL_LENGTH
1516
MAX_HEAPS = 50
1617
MAX_HEAP_SIZE = 10_000
1718
MAX_NIM_OPTIONS = 5_000
@@ -21,14 +22,14 @@
2122

2223

2324
class GameMove(StrictModel):
24-
source: str = Field(min_length=1, max_length=MAX_LABEL_LENGTH)
25-
target: str = Field(min_length=1, max_length=MAX_LABEL_LENGTH)
25+
source: OpaqueLabel
26+
target: OpaqueLabel
2627

2728

2829
class ImpartialGame(StrictModel):
2930
"""A complete finite normal-play impartial game DAG."""
3031

31-
positions: tuple[str, ...] = Field(min_length=1, max_length=MAX_POSITIONS)
32+
positions: tuple[OpaqueLabel, ...] = Field(min_length=1, max_length=MAX_POSITIONS)
3233
moves: tuple[GameMove, ...] = Field(max_length=MAX_MOVES)
3334

3435
@model_validator(mode="after")

src/jacobian/math/multiple_testing/_models.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88

99
from jacobian._exact import CanonicalRational
1010
from jacobian._models import StrictModel
11+
from jacobian.math._labels import OpaqueLabel
1112

1213
MAX_HYPOTHESES = 1000
1314

1415

1516
class HypothesisSpec(StrictModel):
1617
"""One labelled p-value."""
1718

18-
hypothesis_id: str = Field(min_length=1, max_length=64)
19+
hypothesis_id: OpaqueLabel
1920
p_value: CanonicalRational
2021

2122
@model_validator(mode="after")
@@ -50,15 +51,15 @@ class BHStepUpResult(StrictModel):
5051

5152
critical_index: int = Field(ge=0)
5253
cutoff_threshold: str
53-
rejected: tuple[str, ...]
54+
rejected: tuple[OpaqueLabel, ...]
5455
total_hypotheses: int = Field(ge=1)
5556

5657

5758
class FDPRequest(StrictModel):
5859
"""False discovery proportion computation."""
5960

60-
rejected_ids: tuple[str, ...] = Field(default=())
61-
true_null_ids: tuple[str, ...] = Field(default=())
61+
rejected_ids: tuple[OpaqueLabel, ...] = Field(default=())
62+
true_null_ids: tuple[OpaqueLabel, ...] = Field(default=())
6263

6364

6465
class FDPResult(StrictModel):

src/jacobian/math/number_field/_models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88

99
from jacobian._models import StrictModel
1010
from jacobian.canonical import parse_canonical_integer
11+
from jacobian.math.polynomials.values import PolynomialVariable
1112

1213

1314
class NumberFieldRequest(StrictModel):
1415
"""A number field Q(alpha) defined by a minimal polynomial."""
1516

1617
coefficients_descending: tuple[str, ...] = Field(min_length=2, max_length=32)
17-
variable: str = Field(min_length=1, max_length=10)
18+
variable: PolynomialVariable
1819

1920
@model_validator(mode="after")
2021
def require_monic_irreducible_integer_polynomial(self) -> Self:

tests/integration/algebra/test_exact_operations.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,20 @@ def test_number_field_discriminant_is_not_power_basis_discriminant() -> None:
144144
assert result.discriminant == "5"
145145

146146

147+
@pytest.mark.parametrize(
148+
"variable",
149+
["", " ", "x y", "x; y", "x\n", "x\x00", "1x", "x" * 33],
150+
)
151+
def test_number_field_variable_uses_polynomial_identifier_grammar(
152+
variable: str,
153+
) -> None:
154+
with pytest.raises(ValidationError):
155+
NumberFieldRequest(
156+
coefficients_descending=("1", "0", "-2"),
157+
variable=variable,
158+
)
159+
160+
147161
def test_integral_basis_is_computed_in_the_defining_power_basis() -> None:
148162
assert ring_of_integers(["1", "0", "-5"], "x") == ["1", "x/2 + 1/2"]
149163

0 commit comments

Comments
 (0)