Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,10 @@ source_modules = ["jacobian.dispatch"]
forbidden_modules = ["jacobian.cli", "jacobian.mcp"]

[[tool.importlinter.contracts]]
name = "Finite-field values do not import private backends"
name = "Finite-field values do not import the optional FLINT backend"
type = "forbidden"
source_modules = ["jacobian.math.finite_fields.values"]
forbidden_modules = [
"jacobian.math.finite_fields._flint",
"jacobian.math.finite_fields._sympy",
]
forbidden_modules = ["jacobian.math.finite_fields._flint"]

[tool.coverage.run]
branch = true
Expand Down
105 changes: 7 additions & 98 deletions src/jacobian/_models.py
Original file line number Diff line number Diff line change
@@ -1,106 +1,15 @@
"""Narrow strict-model primitive shared by unrelated wire owners."""

from __future__ import annotations

from dataclasses import fields as dataclass_fields
from dataclasses import is_dataclass
from types import UnionType
from typing import Annotated, Any, Union, get_args, get_origin

from pydantic import BaseModel, ConfigDict, model_validator


def _unwrap_annotation(annotation: Any) -> Any:
origin = get_origin(annotation)
if origin is Annotated:
args = get_args(annotation)
return _unwrap_annotation(args[0]) if args else annotation
return annotation


def _tuple_annotation(annotation: Any) -> Any | None:
annotation = _unwrap_annotation(annotation)
origin = get_origin(annotation)
if origin is tuple:
return annotation
if origin in {Union, UnionType}:
tuple_args = [
arg
for arg in get_args(annotation)
if arg is not type(None) and get_origin(_unwrap_annotation(arg)) is tuple
]
if len(tuple_args) == 1:
return tuple_args[0]
return None


def _lists_to_tuples(value: Any) -> Any:
if isinstance(value, list):
return tuple(_lists_to_tuples(item) for item in value)
return value


def _dataclass_annotation(annotation: Any) -> type[Any] | None:
annotation = _unwrap_annotation(annotation)
origin = get_origin(annotation)
if origin in {Union, UnionType}:
dataclass_args = [
arg
for arg in get_args(annotation)
if arg is not type(None) and _dataclass_annotation(arg) is not None
]
if len(dataclass_args) == 1:
return _dataclass_annotation(dataclass_args[0])
return None
if (
isinstance(annotation, type)
and is_dataclass(annotation)
and not issubclass(annotation, BaseModel)
):
return annotation
return None


def _dataclass_from_json(cls: type[Any], value: Any) -> Any:
if isinstance(value, cls):
return value
if not isinstance(value, dict):
return value
allowed = {field.name for field in dataclass_fields(cls)}
unexpected = sorted(set(value) - allowed)
if unexpected:
raise ValueError(f"unexpected dataclass fields: {', '.join(unexpected)}")
prepared = {
name: _lists_to_tuples(item) if isinstance(item, list) else item
for name, item in value.items()
}
return cls(**prepared)
from pydantic import BaseModel, ConfigDict


class StrictModel(BaseModel):
"""Closed, immutable model with JSON tuple/dataclass decoding."""
"""Closed immutable model; Pydantic owns nested and JSON validation."""

model_config = ConfigDict(extra="forbid", frozen=True)
model_config = ConfigDict(
extra="forbid",
frozen=True,
)
Comment thread
morluto marked this conversation as resolved.

@model_validator(mode="before")
@classmethod
def accept_json_wire_shapes(cls, data: Any) -> Any:
"""Decode JSON arrays and objects into declared tuple and dataclass fields."""

if isinstance(data, cls) or not isinstance(data, dict):
return data
coerced = dict(data)
for name, field in cls.model_fields.items():
if name not in coerced:
continue
value = coerced[name]
dataclass_type = _dataclass_annotation(field.annotation)
if dataclass_type is not None:
coerced[name] = _dataclass_from_json(dataclass_type, value)
continue
if _tuple_annotation(field.annotation) is None or not isinstance(
value, list
):
continue
coerced[name] = _lists_to_tuples(value)
return coerced
__all__ = ["StrictModel"]
126 changes: 67 additions & 59 deletions src/jacobian/math/arithmetic_dynamics/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from pydantic import Field, model_validator

from jacobian._exact import CanonicalRational, require_bounded_rational
from jacobian._models import StrictModel
from jacobian.math._rational_height import RationalHeight, sum_heights

Expand Down Expand Up @@ -146,34 +147,29 @@ def _mobius(value: int) -> int:
return -1 if factors % 2 else 1


def parse_canonical_rational(value: str, *, label: str) -> Fraction:
if len(value) > 2 * MAX_COEFFICIENT_DIGITS + 2:
raise ValueError(f"{label} exceeds the rational digit bound")
try:
parsed = Fraction(value)
except (ValueError, ZeroDivisionError):
raise ValueError(f"{label} must be a canonical rational") from None
if str(parsed) != value:
raise ValueError(f"{label} must be a reduced canonical rational")
if (
len(str(abs(parsed.numerator))) > MAX_COEFFICIENT_DIGITS
or len(str(parsed.denominator)) > MAX_COEFFICIENT_DIGITS
):
raise ValueError(f"{label} exceeds the rational digit bound")
return parsed
def _bounded_fraction(
value: CanonicalRational, *, max_digits: int, label: str
) -> Fraction:
require_bounded_rational(value, max_digits=max_digits, label=label)
return value.as_fraction()


def parse_polynomial_coefficients(values: tuple[str, ...]) -> tuple[Fraction, ...]:
def parse_polynomial_coefficients(
values: tuple[CanonicalRational, ...],
) -> tuple[Fraction, ...]:
coefficients = tuple(
parse_canonical_rational(value, label="coefficient") for value in values
_bounded_fraction(value, max_digits=MAX_COEFFICIENT_DIGITS, label="coefficient")
for value in values
)
if len(coefficients) > 1 and coefficients[-1] == 0:
raise ValueError("polynomial coefficients must omit trailing zeros")
return coefficients


class PolynomialCoefficientRequest(StrictModel):
coefficients: tuple[str, ...] = Field(min_length=1, max_length=MAX_DEGREE + 1)
coefficients: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_DEGREE + 1
)

@model_validator(mode="after")
def require_canonical_coefficients(self) -> Self:
Expand Down Expand Up @@ -207,12 +203,12 @@ def require_bounded_iterate_degree(self) -> Self:
class OrbitPrefixRequest(PolynomialCoefficientRequest):
"""Compute until a first repeat or an explicit finite/output bound."""

start: str
start: CanonicalRational
max_steps: int = Field(ge=0, le=MAX_ORBIT_STEPS)

@model_validator(mode="after")
def require_canonical_start(self) -> Self:
parse_canonical_rational(self.start, label="start")
_bounded_fraction(self.start, max_digits=MAX_COEFFICIENT_DIGITS, label="start")
return self


Expand Down Expand Up @@ -253,12 +249,17 @@ def require_bounded_dynatomic_degree(self) -> Self:
class CycleMultiplierRequest(PolynomialCoefficientRequest):
"""Compute the multiplier of a supplied, validated exact rational cycle."""

cycle: tuple[str, ...] = Field(min_length=1, max_length=MAX_ORBIT_STEPS)
cycle: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_ORBIT_STEPS
)

@model_validator(mode="after")
def require_exact_cycle(self) -> Self:
points = tuple(
parse_canonical_rational(value, label="cycle point") for value in self.cycle
_bounded_fraction(
value, max_digits=MAX_COEFFICIENT_DIGITS, label="cycle point"
)
for value in self.cycle
)
if len(set(points)) != len(points):
raise ValueError("cycle points must be distinct")
Expand Down Expand Up @@ -287,11 +288,11 @@ def require_canonical_prime_field_map(self) -> Self:


class MapIterateResult(StrictModel):
source_coefficients: tuple[str, ...] = Field(
source_coefficients: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_DEGREE + 1
)
n: int = Field(ge=0, le=MAX_ITERATE)
coefficients: tuple[str, ...] = Field(
coefficients: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_ITERATE_DEGREE + 1
)
degree: int = Field(ge=0, le=MAX_ITERATE_DEGREE)
Expand All @@ -301,13 +302,14 @@ class MapIterateResult(StrictModel):
@model_validator(mode="after")
def bind_degree_and_coefficients(self) -> Self:
parse_polynomial_coefficients(self.source_coefficients)
values = tuple(Fraction(value) for value in self.coefficients)
if any(
str(Fraction(value)) != value
or len(value) > MAX_POLYNOMIAL_OUTPUT_DIGITS * 2 + 2
values = tuple(
_bounded_fraction(
value,
max_digits=MAX_POLYNOMIAL_OUTPUT_DIGITS,
label="iterate coefficient",
)
for value in self.coefficients
):
raise ValueError("iterate coefficients must be bounded and canonical")
)
expected_degree = 0 if values == (Fraction(0),) else len(values) - 1
if self.degree != expected_degree:
raise ValueError("degree must match the canonical coefficient tuple")
Expand All @@ -330,11 +332,13 @@ def bind_indices(self) -> Self:


class OrbitPrefixResult(StrictModel):
source_coefficients: tuple[str, ...] = Field(
source_coefficients: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_DEGREE + 1
)
start: str
orbit: tuple[str, ...] = Field(min_length=1, max_length=MAX_ORBIT_STEPS + 1)
start: CanonicalRational
orbit: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_ORBIT_STEPS + 1
)
requested_steps: int = Field(ge=0, le=MAX_ORBIT_STEPS)
computed_steps: int = Field(ge=0, le=MAX_ORBIT_STEPS)
termination: Literal["REPEAT_FOUND", "STEP_BOUND_REACHED", "OUTPUT_BOUND_REACHED"]
Expand Down Expand Up @@ -374,10 +378,10 @@ def bind_termination_evidence(self) -> Self:


class DynatomicPolynomialResult(StrictModel):
source_coefficients: tuple[str, ...] = Field(
source_coefficients: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_DEGREE + 1
)
coefficients: tuple[str, ...] = Field(
coefficients: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_DYNATOMIC_DEGREE + 1
)
degree: int = Field(ge=0, le=MAX_DYNATOMIC_DEGREE)
Expand All @@ -390,49 +394,48 @@ class DynatomicPolynomialResult(StrictModel):
@model_validator(mode="after")
def bind_degree_and_coefficients(self) -> Self:
parse_polynomial_coefficients(self.source_coefficients)
values = tuple(Fraction(value) for value in self.coefficients)
if any(
str(Fraction(value)) != value
or len(value) > MAX_POLYNOMIAL_OUTPUT_DIGITS * 2 + 2
values = tuple(
_bounded_fraction(
value,
max_digits=MAX_POLYNOMIAL_OUTPUT_DIGITS,
label="dynatomic coefficient",
)
for value in self.coefficients
):
raise ValueError("dynatomic coefficients must be bounded and canonical")
)
expected_degree = 0 if values == (Fraction(0),) else len(values) - 1
if self.degree != expected_degree:
raise ValueError("degree must match the canonical coefficient tuple")
return self


class CycleMultiplierResult(StrictModel):
source_coefficients: tuple[str, ...] = Field(
source_coefficients: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_DEGREE + 1
)
multiplier: str
cycle: tuple[str, ...] = Field(min_length=1, max_length=MAX_ORBIT_STEPS)
multiplier: CanonicalRational
cycle: tuple[CanonicalRational, ...] = Field(
min_length=1, max_length=MAX_ORBIT_STEPS
)
period: int = Field(ge=1, le=MAX_ORBIT_STEPS)
validated_cycle: Literal[True] = True
complete: Literal[True] = True

@model_validator(mode="after")
def bind_period_and_multiplier(self) -> Self:
source = parse_polynomial_coefficients(self.source_coefficients)
points = tuple(
parse_canonical_rational(value, label="cycle point") for value in self.cycle
)
points = tuple(value.as_fraction() for value in self.cycle)
if len(set(points)) != len(points) or any(
_evaluate(source, point) != points[(index + 1) % len(points)]
for index, point in enumerate(points)
):
raise ValueError("cycle must be a distinct ordered cycle of the bound map")
if self.period != len(self.cycle):
raise ValueError("period must match cycle length")
value = Fraction(self.multiplier)
if (
str(value) != self.multiplier
or len(str(abs(value.numerator))) > MAX_POLYNOMIAL_OUTPUT_DIGITS
or len(str(value.denominator)) > MAX_POLYNOMIAL_OUTPUT_DIGITS
):
raise ValueError("multiplier must be a bounded canonical rational")
require_bounded_rational(
self.multiplier,
max_digits=MAX_POLYNOMIAL_OUTPUT_DIGITS,
label="multiplier",
)
return self


Expand Down Expand Up @@ -514,14 +517,19 @@ def _evaluate(coefficients: tuple[Fraction, ...], point: Fraction) -> Fraction:


def _require_bound_orbit(
source_coefficients: tuple[str, ...],
start: str,
orbit_values: tuple[str, ...],
source_coefficients: tuple[CanonicalRational, ...],
start: CanonicalRational,
orbit_values: tuple[CanonicalRational, ...],
) -> None:
source = parse_polynomial_coefficients(source_coefficients)
initial = parse_canonical_rational(start, label="start")
initial = _bounded_fraction(start, max_digits=MAX_COEFFICIENT_DIGITS, label="start")
orbit = tuple(
parse_canonical_rational(value, label="orbit value") for value in orbit_values
_bounded_fraction(
value,
max_digits=MAX_ORBIT_VALUE_DIGITS,
label="orbit value",
)
for value in orbit_values
)
if orbit[0] != initial:
raise ValueError("orbit must begin at the bound start point")
Expand Down
Loading