tenspec 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tenspec/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Tenspec: declare what an array must be, and check it at one boundary.
2
+
3
+ Write the declaration once, beside the value it describes. A checked function, a model
4
+ mixin, or a standalone call then holds it. Import a backend module for the array library
5
+ you use: `tenspec.numpy` or `tenspec.torch`. This module loads neither one.
6
+
7
+ This is the common surface. Everything below it keeps its defining module, including
8
+ `tenspec.errors.TensorMismatch`, `tenspec.errors.BindingError`,
9
+ `tenspec.runtime.validation.validate_array`, `tenspec.runtime.bindings.Bindings`,
10
+ `tenspec.runtime.arrays.ArrayBackend`, and `tenspec.pydantic.annotations.prepare_annotation`.
11
+ """
12
+
13
+ from typing import Literal as Shape
14
+
15
+ from tenspec.errors import AnnotationError, ConstraintEvaluationError
16
+ from tenspec.pydantic.calls import checked
17
+ from tenspec.pydantic.models import TensorContracts
18
+ from tenspec.pydantic.standalone import validate
19
+ from tenspec.runtime.bindings import axis_size
20
+ from tenspec.types.dtypes import DType, Floating
21
+ from tenspec.types.properties import Contiguous, Finite, Materialized, NonEmpty
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ __all__ = [
26
+ "AnnotationError",
27
+ "ConstraintEvaluationError",
28
+ "Contiguous",
29
+ "DType",
30
+ "Finite",
31
+ "Floating",
32
+ "Materialized",
33
+ "NonEmpty",
34
+ "Shape",
35
+ "TensorContracts",
36
+ "axis_size",
37
+ "checked",
38
+ "validate",
39
+ ]
tenspec/errors.py ADDED
@@ -0,0 +1,89 @@
1
+ """The errors Tenspec raises, and what each one reports."""
2
+
3
+ from collections.abc import Mapping
4
+ from enum import StrEnum
5
+
6
+
7
+ class AnnotationError(TypeError):
8
+ """A declaration is invalid, unsupported, or never reached Tenspec's preparation."""
9
+
10
+
11
+ class ConstraintEvaluationError(RuntimeError):
12
+ """A backend cannot inspect a declared property of this value.
13
+
14
+ This never reports a measured mismatch. It reports that the check could not run.
15
+ """
16
+
17
+
18
+ class BindingReason(StrEnum):
19
+ """Why a dimension lookup found no extent."""
20
+
21
+ NO_SCOPE = "no_scope"
22
+ UNBOUND = "unbound"
23
+ NOT_SCALAR = "not_scalar"
24
+
25
+
26
+ class BindingError(ValueError):
27
+ """A required dimension was unavailable, rather than different.
28
+
29
+ The reason says which lookup failed. The name is the dimension the caller asked for.
30
+ """
31
+
32
+ def __init__(self, reason: BindingReason, name: str | None = None) -> None:
33
+ self.reason = reason
34
+ self.name = name
35
+ messages = {
36
+ BindingReason.NO_SCOPE: "no Tenspec validation scope is open",
37
+ BindingReason.UNBOUND: f"dimension {name!r} has no binding in this scope",
38
+ BindingReason.NOT_SCALAR: f"dimension {name!r} holds a group of axes, not one extent",
39
+ }
40
+ super().__init__(messages[reason])
41
+
42
+
43
+ class TensorMismatch(ValueError): # noqa: N818 the accepted contract fixes this name.
44
+ """An array disagrees with its declared requirement.
45
+
46
+ The requirement, the observed fact, and the relevant bindings stay readable on the
47
+ error. The error holds no array.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ requirement: str,
54
+ observed: str,
55
+ bindings: Mapping[str, int | tuple[int, ...]] | None = None,
56
+ ) -> None:
57
+ self.requirement = requirement
58
+ self.observed = observed
59
+ self.bindings: Mapping[str, int | tuple[int, ...]] = dict(bindings or {})
60
+ detail = f", with {self.bindings}" if self.bindings else ""
61
+ super().__init__(f"expected {requirement}, but the array has {observed}{detail}")
62
+
63
+
64
+ def describe_empty(shape: tuple[int, ...]) -> str:
65
+ """What a refusal reports about an array that holds no elements.
66
+
67
+ It reads the shape alone. Axis indices start at 0.
68
+
69
+ describe_empty((2, 0, 0)) returns "shape (2, 0, 0), with no elements at axis 1, axis 2".
70
+
71
+ Returns:
72
+ The observed text for a TensorMismatch.
73
+ """
74
+ empty = ", ".join(f"axis {index}" for index, extent in enumerate(shape) if extent == 0)
75
+ return f"shape {shape}, with no elements at {empty}"
76
+
77
+
78
+ class TransformContractError(RuntimeError):
79
+ """A transform changed a structural fact that it had to preserve.
80
+
81
+ This reports a defect in the transform, not invalid caller input, so it is a
82
+ RuntimeError and never a ValueError. It names the transform and the one fact that
83
+ changed. Every field is text, so the error holds no array.
84
+ """
85
+
86
+ def __init__(self, *, transform: str, fact: str, before: str, after: str) -> None:
87
+ self.transform = transform
88
+ self.fact = fact
89
+ super().__init__(f"{transform} changed the {fact} of its input, from {before} to {after}")
tenspec/numpy.py ADDED
@@ -0,0 +1,343 @@
1
+ """The NumPy backend, and the declarations a NumPy caller writes."""
2
+
3
+ from abc import ABC
4
+ from collections.abc import Hashable, Mapping
5
+ from types import MappingProxyType
6
+ from typing import Annotated, Any, get_args, get_origin
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+ from tenspec.errors import (
12
+ AnnotationError,
13
+ ConstraintEvaluationError,
14
+ TensorMismatch,
15
+ describe_empty,
16
+ )
17
+ from tenspec.runtime.arrays import ArrayBackend, TensorDeclaration
18
+ from tenspec.types import operations, properties
19
+ from tenspec.types.dtypes import DTypeFamily, DTypeId
20
+ from tenspec.types.operations import derives_from
21
+
22
+ NUMPY_SCALARS: Mapping[DTypeId, type[np.generic]] = {
23
+ DTypeId.BOOL: np.bool_,
24
+ DTypeId.INT8: np.int8,
25
+ DTypeId.INT16: np.int16,
26
+ DTypeId.INT32: np.int32,
27
+ DTypeId.INT64: np.int64,
28
+ DTypeId.UINT8: np.uint8,
29
+ DTypeId.UINT16: np.uint16,
30
+ DTypeId.UINT32: np.uint32,
31
+ DTypeId.UINT64: np.uint64,
32
+ DTypeId.FLOAT16: np.float16,
33
+ DTypeId.FLOAT32: np.float32,
34
+ DTypeId.FLOAT64: np.float64,
35
+ DTypeId.COMPLEX64: np.complex64,
36
+ DTypeId.COMPLEX128: np.complex128,
37
+ }
38
+ """The native scalar class of every logical format NumPy represents.
39
+
40
+ NumPy has no bfloat16, so that format is absent here and from `supported_formats`.
41
+ """
42
+
43
+
44
+ class ArrayProperty[D: np.generic](operations.ArrayProperty[NDArray[D]], ABC):
45
+ """The base a NumPy property derives from. D is the scalar domain it accepts.
46
+
47
+ Write `class NonPositive(ArrayProperty[np.floating[Any]])` and implement `validate`.
48
+ A declaration must guarantee D, so a float64-only check refuses a broad Float
49
+ declaration. Write an exact scalar class, or a family with Any.
50
+ """
51
+
52
+
53
+ class Finite(ArrayProperty[np.generic]):
54
+ """Every element of the array is a finite number. An integer array always passes."""
55
+
56
+ @staticmethod
57
+ def validate(values: NDArray[np.generic]) -> None:
58
+ if values.dtype.kind in "fc" and not bool(np.isfinite(values).all()):
59
+ raise TensorMismatch(requirement="Finite", observed="non-finite entries")
60
+
61
+
62
+ class NonEmpty(ArrayProperty[np.generic]):
63
+ """The array holds at least one element."""
64
+
65
+ @staticmethod
66
+ def validate(values: NDArray[np.generic]) -> None:
67
+ if values.size == 0:
68
+ raise TensorMismatch(requirement="NonEmpty", observed=describe_empty(values.shape))
69
+
70
+
71
+ class Contiguous(ArrayProperty[np.generic]):
72
+ """The array uses C-contiguous storage."""
73
+
74
+ @staticmethod
75
+ def validate(values: NDArray[np.generic]) -> None:
76
+ if not values.flags.c_contiguous:
77
+ raise TensorMismatch(
78
+ requirement="Contiguous", observed="storage that is not C-contiguous"
79
+ )
80
+
81
+
82
+ class Materialized(ArrayProperty[np.generic]):
83
+ """The array holds actual data. Every NumPy array does, so this accepts every array."""
84
+
85
+ @staticmethod
86
+ def validate(values: NDArray[np.generic]) -> None:
87
+ """Accept every array."""
88
+
89
+
90
+ class ReadOnly(ArrayProperty[np.generic]):
91
+ """This array's own writeable flag is off.
92
+
93
+ It is a check of that flag. It is not a seal, and it never promises that another
94
+ reference to the same storage cannot change the values.
95
+ """
96
+
97
+ @staticmethod
98
+ def validate(values: NDArray[np.generic]) -> None:
99
+ if values.flags.writeable:
100
+ raise TensorMismatch(requirement="ReadOnly", observed="its writeable flag on")
101
+
102
+
103
+ class ArrayTransform[D: np.generic](operations.ArrayTransform[NDArray[D]], ABC):
104
+ """The base a NumPy transform derives from. D is the scalar domain it accepts.
105
+
106
+ Write `class Owned(ArrayTransform[np.generic])` and implement `transform`. The
107
+ result must keep the concrete class, the shape, the native dtype and the device of
108
+ its input, and must leave the caller's own array unchanged.
109
+ """
110
+
111
+
112
+ class CopyReadOnly(ArrayTransform[np.generic]):
113
+ """Replace the array with a read-only copy of it, on separate storage.
114
+
115
+ The caller's array keeps its own values and its writeable flag. The stored copy
116
+ shares no memory with it, and refuses an ordinary write. This is not immutability:
117
+ a caller who reaches the copy's base array can still change the values.
118
+
119
+ It copies on every validation, so it costs time and memory each time.
120
+ """
121
+
122
+ @staticmethod
123
+ def transform[D: np.generic](values: NDArray[D]) -> NDArray[D]:
124
+ """Copy the array, seal the copy, and return a view of it.
125
+
126
+ Returns:
127
+ A read-only view of a separate copy, with the same class, shape and dtype.
128
+ """
129
+ owned = values.copy()
130
+ owned.setflags(write=False)
131
+ return owned.view()
132
+
133
+
134
+ NUMPY_AUTHOR_BASES = (ArrayProperty, ArrayTransform)
135
+ """The two bases a NumPy operation derives from. Each one fixes a scalar domain."""
136
+
137
+
138
+ def scalar_domain(operation: type[operations.Operation[Any]]) -> Any:
139
+ """The scalar expression one NumPy operation names on its author base.
140
+
141
+ The walk reads each class's own bases, so a subclass of a fixed-domain property
142
+ inherits that domain rather than lose it.
143
+
144
+ Returns:
145
+ The scalar class or family the operation accepts.
146
+
147
+ Raises:
148
+ AnnotationError: when the class is not a NumPy operation, or when it names no
149
+ scalar domain.
150
+ """
151
+ if not derives_from(operation, *NUMPY_AUTHOR_BASES):
152
+ raise AnnotationError(
153
+ f"{operation.__name__} is not a NumPy operation. Derive it from "
154
+ f"tenspec.numpy.ArrayProperty or tenspec.numpy.ArrayTransform."
155
+ )
156
+ for ancestor in operation.__mro__:
157
+ for base in ancestor.__dict__.get("__orig_bases__", ()):
158
+ if get_origin(base) in NUMPY_AUTHOR_BASES:
159
+ return get_args(base)[0]
160
+ raise AnnotationError(
161
+ f"{operation.__name__} names no scalar domain. Write the author base with an "
162
+ f"exact scalar class, such as ArrayProperty[np.float64]."
163
+ )
164
+
165
+
166
+ def scalar_formats(scalar: Any) -> frozenset[DTypeId]:
167
+ """Every logical format one NumPy scalar class or family covers.
168
+
169
+ np.float64 covers float64 alone. np.floating and np.floating[Any] cover every
170
+ floating format NumPy represents. A family whose precision arguments are all Any
171
+ covers the whole family, however many it takes: np.complexfloating takes two, and
172
+ np.complexfloating[Any, Any] is its unrestricted form.
173
+
174
+ A family that restricts any precision argument, such as np.floating[_64Bit], names a
175
+ domain this release cannot read. It refuses, because widening it to the whole family
176
+ would hand float32 values to a check written for float64.
177
+
178
+ Returns:
179
+ The logical formats of that scalar expression.
180
+
181
+ Raises:
182
+ AnnotationError: when the expression is not a NumPy scalar class, or when any
183
+ precision argument is not Any.
184
+ """
185
+ native = scalar
186
+ if get_origin(scalar) is not None:
187
+ if not all(argument is Any for argument in get_args(scalar)):
188
+ raise AnnotationError(
189
+ f"{scalar!r} names a precision Tenspec does not read. Write the exact "
190
+ f"scalar class, such as np.float64, or the family with Any."
191
+ )
192
+ native = get_origin(scalar)
193
+ if not (isinstance(native, type) and issubclass(native, np.generic)):
194
+ raise AnnotationError(f"{scalar!r} is not a NumPy scalar class, so its formats are unknown")
195
+ return frozenset(
196
+ item for item, scalar_type in NUMPY_SCALARS.items() if issubclass(scalar_type, native)
197
+ )
198
+
199
+
200
+ class NumpyArrays(ArrayBackend[np.ndarray[Any, np.dtype[Any]]]):
201
+ """Reads shape, scalar format, and value properties from a NumPy array."""
202
+
203
+ supported_formats = frozenset(NUMPY_SCALARS)
204
+ builtin_properties = MappingProxyType(
205
+ {
206
+ properties.Finite: Finite,
207
+ properties.NonEmpty: NonEmpty,
208
+ properties.Contiguous: Contiguous,
209
+ properties.Materialized: Materialized,
210
+ }
211
+ )
212
+ reports_device = False
213
+
214
+ def shape(self, value: np.ndarray[Any, np.dtype[Any]]) -> tuple[int, ...]:
215
+ """The extent of every axis.
216
+
217
+ Returns:
218
+ One extent per axis, in order.
219
+ """
220
+ return value.shape
221
+
222
+ def dtype(self, value: np.ndarray[Any, np.dtype[Any]]) -> DTypeId:
223
+ """The logical scalar format of the array.
224
+
225
+ Returns:
226
+ The logical scalar format.
227
+
228
+ Raises:
229
+ ConstraintEvaluationError: for a NumPy dtype with no logical format, such
230
+ as a structured or object dtype.
231
+ """
232
+ name = value.dtype.name
233
+ try:
234
+ return DTypeId(name)
235
+ except ValueError as cause:
236
+ raise ConstraintEvaluationError(
237
+ f"NumPy dtype {name!r} has no logical scalar format"
238
+ ) from cause
239
+
240
+ def device(self, value: np.ndarray[Any, np.dtype[Any]]) -> properties.DeviceId | None: # noqa: ARG002 NumPy reports no placement, so it reads nothing.
241
+ """NumPy places no array, so it reports no placement.
242
+
243
+ Returns:
244
+ None, which never stands for the CPU.
245
+ """
246
+ return None
247
+
248
+ def native_dtype(self, value: np.ndarray[Any, np.dtype[Any]]) -> Hashable:
249
+ """NumPy's own dtype object, which carries byte order as well as format.
250
+
251
+ Returns:
252
+ The array's dtype object.
253
+ """
254
+ return value.dtype
255
+
256
+ def operation_formats(self, operation: type[operations.Operation[Any]]) -> frozenset[DTypeId]:
257
+ """Every logical format one NumPy operation accepts, from its declared domain.
258
+
259
+ Returns:
260
+ The logical formats the operation accepts.
261
+
262
+ Raises:
263
+ AnnotationError: when the class is not a NumPy operation, or when its scalar
264
+ domain has no logical format.
265
+ """
266
+ return scalar_formats(scalar_domain(operation))
267
+
268
+
269
+ NUMPY_ARRAYS = NumpyArrays()
270
+ """The one NumPy backend every NumPy declaration carries."""
271
+
272
+
273
+ type Float[S, *C] = Annotated[
274
+ NDArray[np.floating], TensorDeclaration(NUMPY_ARRAYS, DTypeFamily.FLOATING), S, tuple[*C]
275
+ ]
276
+
277
+ type Int[S, *C] = Annotated[
278
+ NDArray[np.signedinteger], TensorDeclaration(NUMPY_ARRAYS, DTypeFamily.INTEGER), S, tuple[*C]
279
+ ]
280
+
281
+ type UInt[S, *C] = Annotated[
282
+ NDArray[np.unsignedinteger], TensorDeclaration(NUMPY_ARRAYS, DTypeFamily.UNSIGNED), S, tuple[*C]
283
+ ]
284
+
285
+ type Complex[S, *C] = Annotated[
286
+ NDArray[np.complexfloating], TensorDeclaration(NUMPY_ARRAYS, DTypeFamily.COMPLEX), S, tuple[*C]
287
+ ]
288
+
289
+ type Bool[S, *C] = Annotated[
290
+ NDArray[np.bool_], TensorDeclaration(NUMPY_ARRAYS, DTypeFamily.BOOLEAN), S, tuple[*C]
291
+ ]
292
+
293
+ type Float16[S, *C] = Annotated[
294
+ NDArray[np.float16], TensorDeclaration(NUMPY_ARRAYS, DTypeId.FLOAT16), S, tuple[*C]
295
+ ]
296
+
297
+ type Float32[S, *C] = Annotated[
298
+ NDArray[np.float32], TensorDeclaration(NUMPY_ARRAYS, DTypeId.FLOAT32), S, tuple[*C]
299
+ ]
300
+
301
+ type Float64[S, *C] = Annotated[
302
+ NDArray[np.float64], TensorDeclaration(NUMPY_ARRAYS, DTypeId.FLOAT64), S, tuple[*C]
303
+ ]
304
+
305
+ type Int8[S, *C] = Annotated[
306
+ NDArray[np.int8], TensorDeclaration(NUMPY_ARRAYS, DTypeId.INT8), S, tuple[*C]
307
+ ]
308
+
309
+ type Int16[S, *C] = Annotated[
310
+ NDArray[np.int16], TensorDeclaration(NUMPY_ARRAYS, DTypeId.INT16), S, tuple[*C]
311
+ ]
312
+
313
+ type Int32[S, *C] = Annotated[
314
+ NDArray[np.int32], TensorDeclaration(NUMPY_ARRAYS, DTypeId.INT32), S, tuple[*C]
315
+ ]
316
+
317
+ type Int64[S, *C] = Annotated[
318
+ NDArray[np.int64], TensorDeclaration(NUMPY_ARRAYS, DTypeId.INT64), S, tuple[*C]
319
+ ]
320
+
321
+ type UInt8[S, *C] = Annotated[
322
+ NDArray[np.uint8], TensorDeclaration(NUMPY_ARRAYS, DTypeId.UINT8), S, tuple[*C]
323
+ ]
324
+
325
+ type UInt16[S, *C] = Annotated[
326
+ NDArray[np.uint16], TensorDeclaration(NUMPY_ARRAYS, DTypeId.UINT16), S, tuple[*C]
327
+ ]
328
+
329
+ type UInt32[S, *C] = Annotated[
330
+ NDArray[np.uint32], TensorDeclaration(NUMPY_ARRAYS, DTypeId.UINT32), S, tuple[*C]
331
+ ]
332
+
333
+ type UInt64[S, *C] = Annotated[
334
+ NDArray[np.uint64], TensorDeclaration(NUMPY_ARRAYS, DTypeId.UINT64), S, tuple[*C]
335
+ ]
336
+
337
+ type Complex64[S, *C] = Annotated[
338
+ NDArray[np.complex64], TensorDeclaration(NUMPY_ARRAYS, DTypeId.COMPLEX64), S, tuple[*C]
339
+ ]
340
+
341
+ type Complex128[S, *C] = Annotated[
342
+ NDArray[np.complex128], TensorDeclaration(NUMPY_ARRAYS, DTypeId.COMPLEX128), S, tuple[*C]
343
+ ]
tenspec/py.typed ADDED
File without changes
@@ -0,0 +1,4 @@
1
+ """The integration that makes a Tenspec declaration an ordinary Pydantic annotation.
2
+
3
+ This package adapts the framework Tenspec composes. It is not a selectable array backend.
4
+ """