Versioning, and what counts as a break¶
The version follows Semantic Versioning,
and while the line is 0.x the minor number is the breaking one: 0.1.0 to
0.2.0 may break, 0.1.0 to 0.1.1 may not.
What a break means here is specific, because a validator has two surfaces and only one of them is the API:
- A break. A value that validated and no longer does, or a value that did
not and now does. An error
codeor a violationpaththat changes for a failure that was already reported. A name removed or renamed, a parameter that stops accepting what it accepted, an exception type that changes. - Not a break. A relation that answered
False("not proven") and now answersTrue. Every relation is sound in both lines, and the conservative answer is documented as "no, or not proven" (decidability) -- so code that treatsFalseas a proof of the negative was reading a guarantee that was never given. Widenings are listed in the notes below, and they are the commonest entry. - Not a break, and worth reading anyway. A
reprthat changes, anexpectedstring reworded, a message improved. They are described in the notes because tests pin them, and pinning one is a choice to be told.
Deprecation¶
A name on its way out keeps working for one minor release and warns:
calling it raises a DeprecationWarning naming what replaces it, and the notes
below carry an entry saying when it goes. It is then removed in the next minor
release.
Nothing is removed without that window, and nothing that warns is left warning indefinitely -- a warning that never resolves is a cost with no end, and a removal with no warning is a break a reader had no way to see coming.
Changelog¶
All notable changes to valgebra are recorded here. The format follows Keep a Changelog, and the project follows Semantic Versioning.
Unreleased¶
0.0.13 - 2026-09-24¶
A support and correctness release: ten entries, one addition and nine fixes.
Python 3.15 is supported, and every platform a wheel ships for gets one for every interpreter the classifiers name: 0.0.12 left Apple silicon without 3.10 and every platform but Linux without free-threaded 3.14.
Two fixes are about a process rather than an answer. A recursive meet of
records overflowed the native stack from is_empty() and is_subtype_of() and
ended the interpreter, and two threads reaching a validator's first call
together could stop each other on a free-threaded build.
The decision procedure decides more, and one wrong answer goes. A fixed-length
sequence of unions against the union of its corners, a fixpoint's unfolding
below the fixpoint, and a union of complements that covers every value are
decided where they were undecided; a literal-keyed clause is read as the clause
it is, where it refuted an inclusion no value refutes. On 3.15 a frozendict
literal is refused as a frozenset literal is, rather than read as a constant.
Added¶
- Python 3.15 is supported. The package names it in its classifiers, every
push runs the suite on 3.15 as a merge gate, and a release ships
cp315and free-threadedcp315twheels for Linux, macOS and Windows x64, andcp315for Windows arm64. Until 3.15.0 is published the wheels build against its release candidate, whose ABI the final release keeps. 3.10 stays the floor.
Fixed¶
-
Every platform gets a wheel for every interpreter the classifiers name. 0.0.12 shipped no
cp310wheel for Apple silicon and no free-threaded 3.14 wheel off Linux, though its classifiers name 3.10 and stable free threading: the macOS and Windows builds ran on the host and built for the interpreters the image carried. The release installs 3.10 through 3.14 and 3.14t there first, and Windows builds its free-threaded wheel in a job of its own. The smoke imports each wheel set on the floor, the newest release and the free-threaded build, and the free-threaded import must leave the GIL off. Windows arm64 keeps 3.12 and later, which its image carries. -
Threads that reach a validator's first call together wait detached. The first call builds the validator's indexes, and a second thread arriving during the build waited attached to the interpreter. On a free-threaded build the builder interns strings, which can wait on a lock and let a stop-the-world pause begin that an attached waiter never reaches, and both threads stop. The waiter detaches, which is the wait PyO3 provides for this shape; the hang is reasoned from that contract rather than reproduced.
-
A recursive meet of records is decided rather than overflowing the stack.
is_empty()andis_subtype_of(int)onValidator(recursive(lambda t: intersection({"a": union(t, int)}, {"a": union(t, str)})))overflowed the native stack and ended the process; both answer. Emptiness of a meet of records asks the meet of the types a required key carries, and it read that meet with a fresh cycle check, so each unfolding of the fixpoint reached the same meet again. The meet is read under the cycle check of the node it belongs to, as a field of a single record is. -
A fixed-length sequence of unions is decided against the union of its corners.
Validator(tuple[K, K]).relation_to(union(*(tuple[a, b] for a, b in product(K, K))))forK = int | str | bytes | float | Noneanswered"undecided"and answers"subset", in a few milliseconds; with one corner left out it answered"undecided"and answers"not_subset". The product rule narrowed by every branch at every position and ran out of its work budget; it drops a branch that shares no value with what is left, which is exact, and it refutes where every union member it sets aside holds none of the subject's values. A widening: no answer that was a proof or a refutation changes. -
A
frozendictliteral is refused rather than read as a constant. On Python 3.15,Validator(frozendict(a=int))builtLiteral[frozendict({'a': <class 'int'>})], a schema admitting that one mapping and refusingfrozendict(a=1)and{"a": 1}: afrozendictis not adict, so it missed the dict literal's arm and fell to the constant reading. It raisesNotImplementedErrornaming the dict literal anddict[K, V], as afrozensetliteral does.frozendict[K, V]stays refused, and afrozendictvalue is admitted bycollections.abc.Mappingand not bydict[K, V]or a record. -
A relation between a builtin kind and a class with declared attributes is decided. The published decidability boundary listed an attribute record on either side as a conservative case, and
int ≤ CandC ≤ intas "not proven" for a class with declared attributes. Both are refuted, with a value that decides each. A declared attribute is not a source of conservatism: the same relation declines for a class with no fields and decides for a class laid out as a kind, so the declines beside such a class are the open class order's, which the page assumes elsewhere. Thirty-two ordered pairs of schema nodes move from "not proven" to decided in the page's account. No answer the library gives changes; what changes is the account of it, and the ledger behind it, which drove aProtocolwhere it said it drove a record. -
A fixpoint's unfolding is decided below the fixpoint however its body is spelled.
Validator(union(m, list[t])).relation_to(t)fort = recursive(lambda x: union(m, list[x]))andm = intersection(int, union(float, bool))answered"undecided"and answers"subset". A meet on the left of a reference was placed only where a member of the meet was below the reference, and where none was the pair ended undecided without the reference being unfolded; every other subject reached the unfolding. The meet rule runs first as before, and a meet it does not place is asked against the definition the reference names, as a union member always was. A widening: no answer that was a proof or a refutation changes. -
A literal-keyed clause is a clause, and a relation reads it as one.
Validator({"k?": str | int, str: int}).relation_to({Literal["k"]: str, str: int})answered"not_subset"and answers"subset"; the two are one set, andis_equivalentsays so. The walk reads a literal-keyed clause as the page states -- a key belongs when some clause admits it and its value, so{"k": 1}is a member through thestrclause -- and the set representation read it as a field, which takes precedence, so it refuted an inclusion no value refutes. The lowering joins the clause's type with every clause that covers the key; a field of the same name is read instead of them, as before. -
A union of complements whose inners share no value is the universe, and every subject is below it.
Validator(complement(Annotated[int, pred])) .relation_to(union(complement(None), complement(set))), for a predicatepred, answered"undecided"and answers"subset": no value is bothNoneand a set, so the union holds every value. The universe bound read a union's members by their regions and passed over the one the partition cannot read, which here is the member covering what the other leaves out. Where every rule declines, the union is now also read one De Morgan step in, as the meet of its members' complements, whose emptiness the rules decide. A widening: no answer that was a proof or a refutation changes.
0.0.12 - 2026-09-19¶
A correctness release: thirty entries, and twenty-nine of them are fixes.
The decision procedure decides more. A record whose fields each take several types is the union of the records that fix them; a subject sharing no value with any branch of a union is outside the union rather than undecided; a branch the subject cannot meet is dropped before the rest is asked; and divisibility between two moduli is settled by the steps themselves, which is the one addition.
The frontend refuses what it cannot build, rather than building a schema that denotes the wrong set. Four annotation forms, a frozen set literal, a character class carrying an operator the engine reads differently, an arity, and an order bound whose base and bound do not compare: each raised nothing and admitted the wrong values, and each is a refusal naming what it is about.
The walk says what it found. A union summary names its branches and keeps what stopped the walk, a report keeps the promises the error model makes, a refutation stands on a value, and a render that gave up says so.
Behind several of them is the descriptor's own reading of a kind: a string kind
holds the characters no pattern matches, a set is at most as long as the values
its element denotes, a dict has one entry for 1 and for True, and an
integer bound at the end of the carrier keeps the integers past it. Each was a
set the representation named wrongly, so each was a relation answered wrongly.
Added¶
- Divisibility between two moduli is decided by the steps themselves.
Validator(Annotated[int, MultipleOf(5000)]).is_subtype_of(Annotated[int, MultipleOf(2500)])wasFalseand isTrue. Every multiple ofais a multiple ofbexactly whenbdividesa, so the two steps settle the inclusion between them and the size of either is beside the point; the question is%, asked of the steps, so a divisor of any numeric type answers by its own rules. The refutation and the meet still want the residue representation, and the decidability page says which is which.
Fixed¶
-
A subject outside every branch of a union is refuted, not left open.
Validator({"a": chain, "b": int}).relation_to(union({"a": chain}, {"a": chain, "b": str}))answered"undecided"and answers"not_subset". A subject sharing no value with any branch is outside the union, so where it holds a value that value refutes the inclusion; reading the case as "proved, or not proven" kept the proof for an empty subject and dropped the refutation for every other. -
A union branch sharing no value with the subject decides nothing.
Validator(chain).relation_to(union(None, {"next": int}))answered"undecided"and answers"not_subset", naming the link the supertype refuses. A branch the subject cannot meet is dropped before the rest is asked, so an inclusion refuted by one branch is refuted rather than left open: the rules refute against a single supertype and have no arm that refutes against a union. -
An order bound is refused where the base and the bound do not compare.
Validator(Annotated[None, Ge(0)]), and the same over adictor aset, built a schema that admitted no value at all and reported itself inhabited; each raisesNotImplementedErrornaming the order the base cannot be asked. A bound is a question about two values, so the kind of the bound decides the answer with the kind of the base:Annotated[set[int], Ge(0)]is refused andAnnotated[set[int], Ge({1})]admits the supersets of{1}. A list, a tuple and a set take a bound of their own kind, which they already ordered against. -
A record is decided against the union of records it splits across.
Validator({"a": int | str, "b": int | str}).is_subtype_of(...)over the four records that fix both keys wasFalseand isTrue, andrelation_toanswers"subset"where it answered"undecided". The four records are that record, the way a fixed-length tuple is the union of the tuples it splits across.The same reading settles a difference written as one complemented union.
a & ~(b | c)anda & ~b & ~care one set, and both are decided where either is: a meet against a complemented union removes one part at a time rather than expanding the whole complement first, so the width the set representation's bound sees is the width of the answer rather than of the widest intermediate. The work one build may spend rises with it, to the figure a record of three fields against its eight corners costs.That difference is built under the allowance, which is what puts a ceiling on what asking costs: a record over sixteen corners spends the whole of it and answers
"undecided", whichever way its fields and types divide them. An"undecided"is "not proven", never a claim that the relation fails. -
A mapping opened frees the keys no clause claims.
Validator(dict[str, int]).open().is_valid({1: "x"})wasFalseand isTrue. Openness is the default of the key-type region a schema's clauses leave over, so opening a mapping keeps what astrkey maps to and frees every other key-type, and closing it refuses them again. A record is the case where the clauses claim nothing at all, which is why opening one frees every key -- that is the special case, not the rule.The same reading ends a clause being read two ways according to an unrelated field:
Validator({"a": int, str: int}).close()dropped thestr: intclause because a field was declared beside it, and keeps it now, asValidator({str: int}).close()always did.openon a mapping writes a clause keyed by a complement, which is a shape the set representation declines, so relations about such a schema fall back to the rules (docs/15-decidability.mdrecords the decline). Membership is unaffected. Opening a record is unchanged in both answer and decidability. -
A pattern prints the way Python spells it. The
Regexmarker inside a rendered schema carried the pattern in Rust's spelling: double quotes where Python's own repr picks single, and\u{7}for a control character, which is a truncated escape wherever Python reads it. A repr is an expression that rebuilds the schema, and the marker beside it has a repr of its own, so the two now agree character for character. -
A render that gave up says so. A repr deeper than the renderer's own bound prints
<...>where it stops. It printed..., which is valid Python inside a subscript:evalon such a repr parsed it, built a validator, and handed back one that was not the schema printed -- a lossy rendering with nothing in it to say so. The mark is a syntax error wherever it lands, so a truncated render cannot be read back as a whole one. The bound is reachable: no single annotation can be written deep enough, but a chain of recursive definitions composes, and the documentation saying otherwise is corrected.tuple[T, ...]is unaffected -- that ellipsis is the annotation's own spelling. -
A constraint is put to the kind a literal's constant belongs to.
Annotated[int, MinLen(1)]is refused, because reading a length off an integer raises and the walk reads a raise as a non-member, so the schema would admit nothing and say nothing about why.Annotated[Literal[1], MinLen(1)]is the same schema one value narrower and compiled: it admitted no value and reported itself inhabited, a set that exists according to the library and holds nothing according to the walk. Every constraint family is affected -- a length or a pattern over a number, an order or a divisor over text -- and each is refused with the sentence its bare kind gets. A literal whose constant can be asked the constraint narrows exactly as its kind does, soAnnotated[Literal["ab"], MinLen(1)]is unchanged. -
A frozen set literal is refused, as its set sibling is.
{int}is refused with a sentence namingset[T];frozenset({int})fell past every arm to the literal fallback and compiled to a schema admitting one frozen set holding theinttype object, and no value a caller has. Afrozensetis not aset, so the arm that refuses the one never saw the other. Both are now refused, each naming the parametrised form to write instead. -
A
NamedTupleprints as its name, as every other class does.repr(Validator(Point))gaveintersection(tuple[int, str], Point)where a dataclass givesDCand a plain class givesPlain, anddocs/03-schema-language.mdstates one rule for all of them. The schema is a meet either way -- anisinstancebeside a deep check of what the class declares -- and the reading that names the class looked only for a record of named fields, which aNamedTupledoes not have: its fields are positions. A union naming such a branch said the same thing twice over for the same reason, and now names the class. -
An arity refusal names the annotation it is about.
list[int, str],set[int, str]andfrozenset[int, str]were refused with "expected exactly one type argument" -- a count, naming neither the annotation it was about nor what to write. A caller with one long annotation had nothing to search for. Each now names the spelling, says how many arguments were written, and says what to write instead, which differs by kind: a list of fixed length is the list literal[A, B], and a set schema is homogeneous, so several element types are their unionset[A | B]. -
A walk reports what it found, at the edges a corpus reaches last. A dict holding a key whose
__eq__raises was reported as not being a dict, which sends a reader to the wrong value: it is a dict, it is not a member by the comparison-raises rule, and the field it cannot be shown to hold is reported missing. AndMultipleOfover a step that is not a number is refused at build rather than compiled: the constraint isvalue % n == 0, atimedeltaremainder equals no integer zero, and the validator that came out refused every value without saying why.int,float,DecimalandFractionsteps are unchanged. -
A union summary names its branches, and keeps what stopped the walk. The summary names each branch "as each would name itself alone", and two branch kinds had no name of their own: a
complementread as the wordcomplementwhere alone it saysnot str, and arecursivebranch as the wordvaluewhere alone it names what it admits. Both name themselves. And a branch whose failure isrecursion_limit,recursion_loop,mutated_during_validationorpredicate_errorkeeps that report: each says the walk stopped rather than that the value is outside a set, each fails at the union's own location, and the summary counted that as no progress -- so a value nested past the walk's ceiling, or holding itself, came back as "matched no branch" with the reason dropped. -
A report keeps the promises the error model makes.
fail_fast=Truestops at the first failure, and two sites reported two: a union aggregated the whole of its closest branch, and a mapping clause reported the key and the value together. Both report one, on the object path and the JSON path alike; the branch is still walked whole, because which branch is closest is measured by how far each descended. An undeclared integer key is named in the path as an integer, soerr.path[-1]indexes back down to the entry -- one reading of a record gave the string of its digits and the other the integer, for the same value. A predicate's raised error is summarised like every other value a message carries rather than copied whole. An undeclared key's value reads as a Python repr, as every other value does. And a__repr__raising a fatal signal while a message is built propagates it rather than folding it into<unrepresentable>, which is the rule at every other site a value answers a question. -
An annotation builds the schema it names, or is refused. Four forms were read as a different schema, with no message saying so.
typing.Tuple[()]is the empty tuple and built every tuple, admitting(1,), because a bare legacy alias and an empty parametrisation are told apart by whether a type-argument list is present rather than by whether it is empty. A marker standing for the constraints it yields -- the grouping protocolannotated_typesdocuments, whichIntervalandLenare written against -- was read by attribute alone, so a marker of your own left the schema admitting exactly what it excludes.{"a": int, "a?": str}names one field twice and built a record admitting nothing; it is refused.Literal[int]names no constant and built theintschema; it is refused. And aRegexmarker carrying something that is not text names what it carries rather than reporting every one as bytes. -
A string kind holds the characters no pattern matches. A
stris a sequence of code points and a lone surrogate is one of them:"\ud800"is one character long and is a member ofstr. No codec encodes it, and aRegexmatches the text of a string, so no pattern matches it -- which the walk has always said. The kind's universe stopped where the codecs do, so the difference betweenstrand a catch-all pattern came out empty:Validator(str).relation_to(Annotated[str, Regex("(?s).*")])answered"subset"against a string a caller can write in one line. It answers"not_subset", and the difference between the two reports the character that refutes it. A length bound counts that character as the one character it is, and inclusion between two patterns is unmoved. -
A literal counts as one value only where its constant is one.
Literal[c]denotes the values ofc's type equal toc, which is one value where that type's equality is Python's own or compares by identity, more than one where__eq__answersTruefor its siblings, and none where the constant does not equal itself. Counting every literal as one decidedAnnotated[set[Literal[E.A]], at.MinLen(2)]empty for an enumeration whose__eq__lies, while{E.A, E.B}validates against it, and reported"not_subset"forAnnotated[set[Literal[float("nan")]], at.MinLen(1)]againstint, which asserts a value the schema has none of. The count asks the oracle, so an unreadable constant leaves the bound unread and the relation undecided.set[None],set[bool]and an ordinary enumeration are counted as before. -
An integer bound at the end of the carrier keeps the integers past it. The integer component spells a set as intervals, and the bounds a schema names are 64-bit while Python's integers are not. Complementing a half-line that begins at the smallest such bound needs the integer just below it, which had nowhere to go, so the complement came out empty:
Validator(int).relation_to( Annotated[int, at.Ge(-2**63)])answered"subset"while-2**63 - 1is anintthat bound refuses, andintersection(int, complement(...)).is_empty()answeredTrueover a difference holding that value. The answers are"not_subset"andFalse, andLe(2**63 - 1)answers the same way at the other end. A bound the carrier spells is decided as before, soAnnotated[int, at.Ge(-2**63), at.Le(-2**63)]is proved belowat.MultipleOf(2). -
A container subclass does not talk its way into a schema. A schema over a container denotes the values it holds, and a subclass may override
__len__or__iter__and answer anything. The override was believed, soValidator(set[int]).is_valid(s)wasTruefor asetsubclass whose__iter__yields integers over storage holding"a", andAnnotated[str, MinLen(3)]admitted astrsubclass reporting nine over one character. Both answerFalsenow, onis_validandvalidatealike. Every container the walk reads answers this way:str,bytes,list,tuple,set,frozensetanddictare counted through their base type's__len__, andsetandfrozensetare walked through its__iter__. A subclass that overrides neither is read where it lies, so aNamedTupleand an ordinary container subclass pay nothing. -
A set is at most as long as the values its element denotes. A sequence takes any length by repeating one element and a set does not: it holds each member once, so
Annotated[set[None], MinLen(2)]denotes no set at all. The refinement was read as inhabited, and an inhabited subject is what lets a kind mismatch refute an inclusion -- sorelation_toanswered"not_subset"forAnnotated[set[None], MinLen(2)]againstNone, asserting a value that does not exist, andis_emptyansweredFalse. Both answer for the set now:is_emptyisTrueand the inclusion holds vacuously.set[bool]decides the same way at three members,set[int]at any bound, and a list is unchanged at every one. - A dict has one entry for
1and forTrue.Truehashes as1and equals it, so{1: "a", True: "b"}is a dict of one key -- while the descriptor held the two as separate slots and read a schema requiring both as inhabited.is_emptyansweredFalseforintersection(complement(Validator(dict[Literal[1], str])), complement(Validator(dict[Literal[True], str])), Validator(dict[Literal[1, True], str])), which admits no dict at all. The same shape over two integers is inhabited and still says so.
Literal[1] and Literal[True] stay disjoint: a key is an int or a
bool and the walk tells them apart. What changes is that no dict carries
both of them at once.
- A character class carrying --, &&, ~~ or a nested [ is refused.
Those combine classes in this engine and are literal characters to re, so
one pattern denoted two sets and compiling it said nothing about which:
Annotated[str, Regex(r"[\w--\d]")] admitted the non-digit word characters
here and the word characters plus - there. re gives three different
answers to the four forms -- it raises on the doubled hyphen, warns that it
reserves the two symbol operators, and says nothing at all about a nested set
-- so a reader porting a pattern found out at once, eventually, or never. Each
now raises a ValueError naming the operator this engine would have read.
Escape the characters to mean them literally, or write the classes out. A
POSIX class ([[:alpha:]]) is read rather than refused: it is the divergence
docs/05-refinements.md already names, with both readings shown.
- A pattern in extended mode may end in a comment. (?x) makes # run to
the end of the line, and the anchor a whole-string match needs was appended
after it -- so the closing half was swallowed and a pattern re accepts
raised here. It is the form a long pattern is written in.
- A relation answers for the set a schema denotes, at the edge of every
kind. Eight readings named a set the schema does not have, and each is one
wrong answer a caller could see. is_empty reported an inhabited schema empty
and is_subtype_of reported an inclusion a value refutes, for:
a length bound over a str or bytes, which counted every symbol but the
newline; a str complement, whose universe was every byte string rather than
the valid UTF-8 ones, so a difference holding only the rest read as inhabited; a set whose element kind is unhashable, whose members were cut
although a subclass defining __hash__ is a legal member; an integer bound or
step at the end of the 64-bit range, whose residue class was read as another;
and a float bound written with an integer past 2^53, which no float equals
and which was rounded to one that does.
- A refutation stands on a value. relation_to answered "not_subset" --
which asserts a value of the subject lies outside the other schema -- where no
such value exists: through a recursive reference the lowering had cut, for a
dict[bool, V] against a clause listing both booleans, for a dict[int, V]
against the bool keys it admits, for a clause whose key cannot spell a field
name, for a union of literals whose only missing member is float("nan"), for
a container of a meet of two unrelated classes, and for a class whose
metaclass answers issubclass by running code or by raising. Each answers
"undecided" or "subset" now, and is_subtype_of is unchanged on all of
them.
- is_valid and validate are one answer at the walk's depth bound. A
homogeneous list, tuple or set of a scalar kind was admitted by the first and
refused by the second at the deepest level a walk reaches.
- A record resolves a key the way the dict does. A str subclass carrying a
field's text was read as that field where a catch-all clause sat beside it,
and as an undeclared key where none did, so one value had two answers.
- A Regex marker is immutable. It is hashable and a schema holds it, so a
pattern rebound after the fact changed the hash of a value already in use.
- MultipleOf is a remainder equal to zero, which is what the constraint
documents. The check read the remainder's truthiness, which differs for a type
whose __bool__ and __eq__ disagree.
0.0.11 - 2026-09-15¶
A performance release. Nothing a caller writes changes: every entry below is the same answer arriving for less, or a platform the project states it supports and now proves. The frontend is the release's subject -- compiling an annotation got cheaper three separate ways, and the walk over a record got cheaper where a caller's dict already holds interned keys.
The one fix is PyPy's: a tuple subclass that overrides __len__ could send
the walk past the end of its storage, which is a crash rather than a wrong
answer, and it reached MinLen/MaxLen over such a subclass too.
Changed¶
- Compiling a refinement is about three times cheaper. The frontend read a
marker's optional attributes by trying them: a marker carries one of
ge,gt,le,lt,min_length,max_length,multiple_of,pattern,flagsandfuncand not the other nine, and each absence answered by raising an exception that was built, caught and dropped -- four hundred of them to compile fifty fields.
Two changes remove them. The absences that could be asked for are asked,
through PyObject_GetOptionalAttr where the runtime has it, which is 3.13
onward. And which names a marker can carry is a property of its type --
every annotated_types marker is a slots dataclass, so Ge.ge is the
descriptor that reads the slot and Ge.gt does not exist -- so the type is
asked once and the answer kept, which removes the rest on every interpreter:
below 3.13 there is no non-raising getattr, and func was asked with a bare
one even above it. A marker that keeps its values in a dictionary of its own
is read from that dictionary, which answers for a name it does not hold
without raising; a type with a __getattr__ hook answers for names no
dictionary holds, and is asked for everything exactly as before.
Fifty Annotated[int, Ge(0)] fields compile in 48 us where they took 153
on 3.14 and 45 where they took 144 on 3.12, and as a TypedDict in 100
where they took 240 and 86 where they took 238 (release build, idle
machine, best of five runs of three hundred, twice).
-
A validator no longer imports
dataclassesto ask whether a class is one. Every class node imported the module and called through it; the function is held after the first class that asks, and only after one asks -- importingdataclassespullsinspect,copyandfunctoolsin with it, and the tracked objects they leave behind are walked by every later garbage collection, which costs a program that never compiles a dataclass 6.45% of its compile -- which is an instruction count, taken twice. A fifty-field dataclass compiles in 32.7--34.2 us against 34.4--35.8 (release build, idle machine, three runs each); a program that compiles none imports nothing. -
Building a validator is about four times cheaper. The frontend asked the interpreter to import
typingand resolveget_origin,get_argsand the qualifier forms once per node of the annotation it was reading, for a modulesys.moduleshas held since the first one. It holds them, as the cache beside them always said it did: a fifty-field record compiles in 222,939,220 instructions where it took 939,032,142. -
Validating a record is cheaper when the dict's keys are interned, which is every dict written as a literal, every
**kwargsand every__dict__. A validator's declared keys are interned too, so the probe compares pointers rather than bytes: a fifty-field record walk reads 29% cheaper with both sides interned, and about a percent cheaper with only ours. -
PyPy 3.11 is a stated target. The
Implementation :: PyPyclassifier anddocs/00-installation.mdname the four wheels published for it from 0.0.10 onward -- manylinux and musllinux, x86_64 and aarch64 -- so a consumer there can tell a supported platform from an accident of the build matrix. Every push builds the extension against PyPy and imports it: the C API PyPy offers is not CPython's, and the difference shows at import rather than in any answer a validator gives.
Fixed¶
- A
tuplesubclass that overrides__len__no longer takes PyPy down. The tuple walk read its element count fromPyTuple_Size, which is the storage on CPython and the object's own__len__on PyPy'scpyext: a subclass reporting ten over one element made the walk read nine slots past the end of the allocation, andValidator(tuple[int, ...]).is_valid(...)segfaulted PyPy 3.11 rather than answering. A subclass is read through the base type's own slot, which means the same thing on every interpreter, and is walked over the elements it holds -- the answer CPython gave all along. An exact tuple is read where it lies, and so is a subclass that inheritstuple.__len__rather than overriding it — everyNamedTuple— which the first form of this repair copied along with the liars. Telling them apart costs one type lookup per validation: a three-fieldNamedTuplevalidates in 69 ns against 57 at 0.0.10, where copying it cost 100.
The same reading fixes MinLen/MaxLen over a list or tuple subclass on
PyPy, which had believed the overridden __len__ while the shape beside it
counted the storage.
0.0.10 - 2026-09-13¶
A second representation decides the three relations. Where the structural rules
declined a pair, a set built per kind is asked instead, so is_subtype_of,
is_equivalent and is_empty answer over a wider fragment without any of them
answering differently: every relation this release adds moves a pair from not
proven to decided, and a True still means what it meant. relation_to is the
surface that tells those two apart, in three answers where the boolean has two.
typing.Any is the lattice top, a schema is built in the lattice normal form,
and Validator.simplify is deprecated because the reduction it promised is the
schema a caller already holds. Every compiled entry point takes its arguments
positionally. This is a 0.0.x pre-release and those are breaking changes;
each is written out below.
Added¶
Validator.relation_to(other)reports the inclusion in three answers whereis_subtype_ofreports two. AFalsefrom the latter folds together a value of this schema the other rejects and a question the procedure declines;"subset","not_subset"and"undecided"keep them apart:
from valgebra import Validator
assert Validator(bool).relation_to(int) == "subset"
assert Validator(str).relation_to(int) == "not_subset"
assert Validator(bool).is_subtype_of(int) is True
"subset" is exactly what is_subtype_of answers True for, so no existing
answer moves. A "not_subset" is a statement about a value: some member of
this schema is outside the other, which the completeness probe holds by asking
its universe for that value.
Which pairs earn one has grown since this entry was written, and keeps
growing: a refinement against another of a different kind, two sequences whose
elements share no value where a length bound rules the empty one out, a
sequence against a bound it cannot meet, a class met with its attributes
against a union or a recursive record, and a complement against a class. Each
moves a pair from "undecided" to "not_subset", which is the direction the
procedure may move in. docs/15-decidability.md is
where that list lives rather than here; this entry is the answer, not its
reach.
- A
NamedTupledenotes the tuple its fields lay out, so a relation between one and that tuple is decided rather than declined:
from typing import NamedTuple
from valgebra import Validator
class Pair(NamedTuple):
x: int
y: int
assert Validator(Pair).is_subtype_of(tuple[int, int])
A named tuple's positions are its attributes -- the class lays both down at once -- and the schema says so instead of describing the attributes alone, which denoted a wider set than the class has. A failing field reports its position rather than its name, because the shape carries positions; a passing instance is read once rather than twice, and checks about a third faster.
- A union of literals is decided as the finite set it denotes, at any width and in both directions. A literal denotes one value, so a union of them denotes a set of values, and inclusion between two such sets is membership of every value of one in the other -- found is a proof, and one value found nowhere is a refutation naming what stands against the inclusion:
from typing import Literal
from valgebra import Validator
codes = Validator(Literal[tuple(range(10_000))])
shifted = Validator(Literal[tuple(range(1, 10_001))])
assert codes.relation_to(shifted) == "not_subset"
That pair read "undecided" at a thousand members, because the rules
distributed one table against the other and spent the decision budget on the
product. Membership is a walk of the two tables instead: ten thousand codes
against ten thousand decide in about 9 ms, and the containment they do
prove falls from 237 ms to 6 ms. Literal[float("nan")] is the empty set --
no value equals nan -- and the empty set is below every schema, which the
core could not say before because a literal had no emptiness of its own.
Two tables written in different orders are the same case: relating two
validators renumbers one pool into the other, and a member set that is
renumbered is put back in canonical order rather than left as the other
validator happened to number it.
-
Relations over a bound on a float are decided. A bound was lowered into the descriptor only over whole numbers, so anything needing the descriptor and mentioning
Annotated[float, Gt(0)]stayed undecided:float > 0was not known to be belowfloat, nor disjoint fromint, nor fromfloat < 0. A float bound is now a set of floats, with the side chosen by the base rather than by the operand's type —Gt(0)carries the integer zero and orders the floats all the same — andnansits outside every interval, as Python's own comparisons put it. The completeness probe's random sweep reports fewer undecided relations in both directions, with none refuted by a value. -
Schemas compare as sets. Each kind of value carries a representation closed under union, intersection and complement — integers as interval sets per residue class, floats as intervals over the ordered line with a bit for
nan, strings and bytes as regular languages, sequences as automata over value sets, sets as powerset lines, dicts as map atoms with a default per key kind, classes as an order, objects as records over their attributes — soa <= bis asked asa & ~badmitting no value, which is what the relation means. Where the structural rules decline, that question is asked and answered:
from typing import Annotated, Literal
import annotated_types as at
from valgebra import Regex, Validator, complement, intersection
assert Validator(Annotated[str, Regex("a")]).is_subtype_of(Annotated[str, Regex("ab?")])
assert Validator(Annotated[int, at.MultipleOf(4)]).is_subtype_of(
Annotated[int, at.MultipleOf(2)]
)
assert Validator(bool).is_subtype_of(Literal[True, False])
assert Validator(bool).is_subtype_of(Annotated[int, at.Ge(0)])
assert Validator({"a": int}).is_subtype_of(dict[Literal["a"], int])
assert intersection(list[int], list[str]).is_subtype_of([])
assert Validator(tuple[int]).is_subtype_of(complement(tuple[str]))
Container meets, double complements, one regular language inside another, a
kind against its own literals, one step dividing another, and the emptiness of
a dict schema are decided this way. Twenty-five relations that answered False
answer True, and the same shape written any other way is decided alike: a
respelling such as union(A, complement(union(A, nothing))) is the universe.
Building a set representation costs about two orders of magnitude more than a rule that already answers, so it is asked only where the rules decline, and only for a schema it can build within a bound on the nodes it reads, the nesting it descends and the work it spends. Past any of those the answer is the conservative one, as before. What stays conservative is recursion, a length bound over a shape that is not text, an attribute record beside a builtin kind, and a predicate.
- An integer key stays an integer in an error path. The
patha failure reports is what a caller walks back down to the offending value, and every mapping key arrived as text -- so a dict keyed by numbers reported a location that indexed nothing, andd[2]andd["2"]were indistinguishable. A key that is a string or an integer is now itself; anything else has no spelling in a path made of those two and appears as its repr, as before.
from valgebra import ValidationError, Validator
try:
Validator(dict[int, int]).validate({1: 1, 2: "x"})
except ValidationError as error:
assert error.errors[0]["path"] == (2,)
-
A validator cannot be pickled, and says what to send instead. It holds the classes and callables its schema names, so the schema is what travels; rebuilding is about as cheap as unpickling would be.
ValidationErrorpickles as before. -
A length bound on a list or a tuple is decided. A length is a regular property of a sequence -- "any element, that many times" -- so the representation that holds sequences holds a bound on them, and a bound that used to be opaque to everything but a string is now part of the algebra.
from typing import Annotated
import annotated_types as at
from valgebra import Validator
assert Validator(Annotated[tuple[int, int], at.MinLen(3)]).is_empty()
assert Validator(Annotated[list[int], at.MinLen(3), at.MaxLen(2)]).is_empty()
assert Validator(Annotated[list[int], at.MaxLen(0)]).is_equivalent([])
assert Validator(Annotated[list[int], at.MinLen(2)]).is_equivalent([int, int, int, ...])
A set and a dict have a length their representations do not count, so a bound over one of those still stands.
- A recursive schema is decided against the kinds its body admits. A
reference is a cycle and a set representation has no room for one, so every
relation over a fixpoint used to fall to the structural rules -- which read
shapes, and cannot tell that a JSON value and a
bytesshare no value. The body is now unfolded once before the sets are built, with a bound standing where the reference was: the top where the schema is used positively, the bottom under a complement, which is what keeps a difference sound.
from valgebra import Validator, anything, complement, intersection, recursive, union
json = recursive(lambda j: union(None, bool, int, float, str, [j], {str: j}))
assert intersection(bytes, json).is_empty()
assert intersection(tuple, json).is_empty()
assert Validator(bytes).is_subtype_of(complement(json))
assert json.is_subtype_of(anything)
- A recursive schema is one schema wherever it is combined. Merging a
compiled validator used to copy its definitions, so two occurrences of one
fixpoint became two definitions and every law that compares terms failed on
it:
intersection(json, complement(json))was not empty andunion(json, complement(json))was not the top, for the same schema object on both sides. A merge now reuses definitions it already holds, and a fold that leaves one unreachable drops it -- so the top built that way is the top built any other way.
from valgebra import (
Validator,
anything,
complement,
intersection,
nothing,
recursive,
union,
)
json = recursive(lambda j: union(None, bool, int, float, str, [j], {str: j}))
assert intersection(json, complement(json)) == Validator(nothing)
assert union(json, complement(json)) == Validator(anything)
- Two spellings of one schema are one schema. A record's fields, a map's
clauses, a refinement's markers and a union's members are sets, so the order
they were written in is no longer part of the term:
==says so,hashagrees, a union of two spellings folds to one member, and a validator is a usable dictionary key whatever order its schema was written in. A repeated marker is dropped, since a constraint written twice narrows once.
from typing import Annotated, Literal
import annotated_types as at
from valgebra import Validator, union
assert Validator({"a": int, "b": str}) == Validator({"b": str, "a": int})
assert Validator(Literal[1, 2]) == Validator(Literal[2, 1])
assert Validator(Annotated[int, at.Ge(0), at.Le(9)]) == Validator(
Annotated[int, at.Le(9), at.Ge(0)]
)
assert union({"a": int}, {"a": int}) == Validator({"a": int})
repr prints the canonical order rather than the written one, which is
the visible half: {"name": str, "age?": int} renders as {'age?': int,
'name': str}. It still rebuilds the schema. A test pinning the written order
of a record's fields, a map's clauses or a refinement's markers needs
updating; one pinning a union's or a literal's does not, since those keep the
order they were built in.
- A PEP 695
typealias that names itself builds the fixpoint it writes. The alias is the binder: it is reached again while its own body is read, and there is no lambda to carry the fixpoint, so the alias carries it. What it builds is what the explicit call builds, and mutual recursion is two aliases naming each other. An alias naming itself outside a structural constructor denotes no set and is refused when the validator is built, as an unguardedrecursivealready was. Before, such an alias was refused as a schema too deep to compile.
from valgebra import Validator, recursive, union
type Json = None | bool | int | float | str | list[Json] | dict[str, Json]
assert Validator(Json).is_equivalent(
recursive(lambda j: union(None, bool, int, float, str, [j], {str: j}))
)
-
The three construction bounds are importable:
MAX_SCHEMA_DEPTH,MAX_DEFINITIONSandMAX_SCHEMA_NODESare exported fromvalgebraand listed in__all__, so code sizing a schema against a bound reads the number rather than repeating it. -
A class with declared attributes is the meet of its
isinstanceatom and a record of its attributes, and each half is a set the algebra relates on its own: an object schema is below its own class, an attribute record relates to another by width and depth whatever class it came from, and a record whose required attribute admits nothing is decided empty and one whose attributes are inhabited is decided inhabited — which the class half, being opaque, used to take away. The surface is unchanged:repr(Validator(Point))isPoint, a union names the class in its branch list, and a value of the wrong class reports oneinstance_typeviolation. -
An
intersectionstops collecting violations once a member rejects the value itself rather than something inside it, the ruleAnnotated[...]already applied between a base and its constraints. A member that fails inside the value leaves the others meaningful and they are still collected. -
Widening a literal union is decided by containment rather than by the product of the two member counts, so a table whose members are the same constants as the wider one's relates at any size. Constants pool by value, so two tables written independently share theirs and relate the same way.
-
~~AisA, a union carrying a schema together with its complement is the top, and a meet of that pair is the bottom, all settled where the schema is built. The decision procedure has no rule for these shapes and does not meet one built through the constructors; a shape built another way — a recursive definition, a respelling, constants equal but not identical — reaches the procedure and is not decided.repr,==, and the code a violation reports follow the cancelled form:complement(complement(int))reportsint_typewhere it reportedunexpected_match, andintersection(int, complement(int))reportsno_matchnamingnothing. A predicate and a class with anisinstancehook are exempt from the two cancelling folds: the law is about sets, and an atom that answers by running code is not one. -
A meet of two record schemas is decided empty when a key one side requires cannot hold — because the types the two give it share no value, or because the other side is closed and does not declare it.
{"a": int} & {"a": str}is empty, and so{"a": int}is below~{"a": str}. Only a required key empties a meet: two mappings, or two optional fields, always admit the empty dict. -
A fixed-length sequence is decided against a union of fixed-length sequences it splits across, where no single branch contains it:
tuple[int | str, int]is belowtuple[int, int] | tuple[str, int]. The rule needs a fixed component count, so a homogeneous or variadic sequence is not decomposed, and branches of another container or arity drop out rather than blocking it. -
A literal carries the kind of its constant, so it is decided against another kind:
Literal["a"]is below~int, andLiteral["a"] & Literal["b"]is empty.Literal[1]andLiteral[True]are disjoint although1 == True, because a literal pinstype(x)exactly. The rule applies to the builtin scalars, whose equality is Python's own; a meet of twoEnummembers stays conservative, since user-defined equality can admit one value for two constants. -
A recursive schema is decided below its own body written out, and a refinement of a union below that union:
recursive(lambda t: union(None, {"next": t}))is a subtype ofunion(None, {"next": <that schema>}), andAnnotated[int | str, Ge(0)]ofint | str. Trying a union's branches one by one commits to a branch, and a subject that lands in the union only once a reference is unfolded or a refinement drops to its base got no answer from it. Both rules are sound alone, so where both apply both are asked. -
Every
ValidationErrorcarries the six attributes the error model documents, however it was made. The model describes failures and an error built by hand reports none, so it reads as empty — empty strings and empty tuples — rather than raisingAttributeErrorfor an attribute the type declares.
Changed¶
-
A length bound over a container that repeats one element decides whether the schema has a value, where it was left unknown. A value of any length is as many copies of one element, so the element decides it: a bound of zero is met by the empty container, a longer one by repeating an element. Three things follow. A recursive schema every unfolding of which needs one more element is reported empty, which the completeness ledger carried as a relation it could not decide. A refutation about such a schema is believed, so
list[int]with a length bound is decided not below a tuple, by a rule rather than by the set representation. And the refuting half of the decision workload, whose hardest case was a fixed sequence with an unfillable position, falls by two orders of magnitude; the instruction gate holds the figure. -
A relation between two classes is decided by a rule. A class whose metaclass leaves
isinstancealone is read as holding an object -- the open world the set representation already works in, and the assumption every refutation this library makes about a class already rested on -- so the rules stop deferring to the sets for an answer the library had committed to: two unrelated classes read 1.2 us against 100, adictsubclass against a list 1.4 against 37, and a plain class against a dataclass 2.2 against 472. The answers are unchanged. A class whose metaclass answers with code of its own is read as before, which is not at all, and the decidability page states the assumption and where it is wrong. -
A length bound over a string or a bytes decides whether the schema has a value, where it was left unknown. A string takes any length, so a bound its own lengths admit is met --
Annotated[str, MinLen(1)]is the non-empty string and it has one. Every refutation about such a schema is believed rather than left to the set representation: a non-empty string against another kind reads 0.9 us against 195, and against a mapping 0.9 against -
A bound over a base whose values have no length still says nothing, and the reading declines.
-
A rule that answers for a shape and then declines hands the pair on, where it used to end the question. A refinement takes its base's supertypes and nothing else; a union supertype takes a subject that lands in one branch. A pair either leaves unproven now reaches the readings that decide what a pair with no rule is worth -- two sets that share no value, then the oracle, then the supertype's own shape. A bounded list against a mapping or a union of scalars reads 1.2 us against 395.
-
A subject outside a base is outside every refinement of that base. The value that refutes the one refutes the other, and it is the same value, so the refutation carries where the proof cannot: being inside the base says nothing about the constraints. A tuple against a length-bounded list reads 0.8 us against 460.
-
A class laid out as a builtin decides a relation by a rule. A class deriving from
dictholds mappings and nothing else -- a subclass inherits the layout and cannot lay down a second -- so a list is not below it and the pair is refuted where it was left to the set representation:list[int]against such a class reads 1.2 us against 35. A class deriving from no builtin is not read this way and stays undecided, because a subclass of it may derive from one: an instance of a class built on that one and onstris a string and an instance of the first. -
A pair whose kinds cannot overlap decides by a rule. Two distinct container kinds share no value, so every value of the subject is outside the supertype and the inclusion is refuted -- where the relation was left unproven and settled by lowering both sides into the set representation.
list[int]againsttuple[int, int]reads 0.6 us against 75 us, a mapping against a list 0.7 against 37, with the same answers. -
A key one record requires and the other does not declare decides the pair by a rule. A clause governs the keys a value carries and requires none, so a record open to undeclared keys still holds a value without that key, and that value is one the supertype rejects. The relation was left to the set representation, which lowers both records to answer it -- and since a
TypedDictis open by the typing spec, that was every relation between two of them: 1.0 us against 268 us for two eight-fieldTypedDicts, on the machine the performance page names. -
A
TypedDictvalue is read by its declared keys, as a closed record's is. ATypedDictis open -- the typing spec admits keys it does not declare -- and an open record was scanned key by key where a closed one was read by its keys, for a clause that admits any string. The keys settle it either way: every declared field is probed, and a key to spare is admitted when it is a string and refuses the record when it is not, which no key need be resolved to say. The same fifty-field value reads about a quarter faster as aTypedDictand its error report about a sixth, and neither answer moves. -
Two schemas built alike share their nodes, so a question that reaches both is answered by identity rather than by walking two trees. A relation between a record schema and an equal one built separately reads 1.07M instructions against 4.97M under the core decision workload; the whole of that workload reads 21% down and the schema-transformation workload 4.5% down (
scripts/perf_gate.py --decision --core). Building a validator pays 0.6% for the sharing, and the membership walk is unmoved. Nothing about what a schema denotes changes: the test that decides sharing is stricter than equality -- two spellings of the top stay two nodes, andreprgives back the one that was written. -
Explaining a failure over a record costs a third less. The accepting walk scans a dict once and resolves each key it finds through a map built with the validator; the explaining walk asked the dict for each declared key by name, and a Rust string handed to a dict lookup is decoded into a fresh Python string and hashed before the probe can start -- once per field, per call. The declared keys are interned with the validator, as the attribute names already were, so the lookup is the probe alone. A fifty-field record reporting one bad field moves from 2.38 to 1.53 of pydantic-core's time, and the shape's ceiling comes down from 3.5 to 2.5. No answer changes.
-
The membership walk costs a third less. Every scalar arm of the walk ended in a helper that passed a boolean through and recorded a violation when it was false, and every element of a sequence went through a second helper between the loop and the walk. Both were out-of-line calls doing almost nothing: on a list of integers they were fifty of the hundred and fifty instructions spent per element. The two are inlined and the violation-recording half is marked cold, so the accepting path is a test where the answer already is. Measured on the fixed binding workload, 661.6M instructions against 968.9M, and against pydantic-core a 1,000-element array moves 0.88 to 0.57 of its time, a fifty-field record 0.58 to 0.49, and a JSON document 0.86 to 0.81. No answer changes.
-
Building and composing a schema costs less. A field name was owned outright by the field that declared it, so every pass that rebuilds a schema — opening or closing its records, reindexing it onto another validator's pools, simplifying it — copied the name of every field it carried across. On the core's fixed workload those copies were 38% of every heap allocation the crate made. A name is now shared: carrying it is a refcount bump and no allocation. The workload runs in 161.0M instructions against 233.5M, and compiling a fifty-field record costs 13.7 µs against 14.2. No answer changes.
-
A schema is built in the lattice normal form. The constructors folded two laws and left the rest standing, so
union(int, int)renderedint | intand compared unequal tointwhileunion(int, complement(int))renderedanything—==was equality of nothing in particular, andreprshowed a shape no rule was written for. Members of a join or a meet are flattened, ordered and deduplicated; the identities and the absorbing elements apply;~anythingisnothingand~nothingisanything; and a join of one member is that member. Sounion(str, int) == union(int, str),intersection(int, anything) == Validator(int), and==is equality of a canonical form.
Three things a caller may see. repr shows the normal form, so a join renders
its members in that order — a kind before a literal, for instance, while
literals and classes keep the order they were written in, their pool slots
being what orders them. A union's expected message lists its branches the
same way. And Literal["x"], a join of one member, is that literal: it reports
literal_error where it reported union_error.
Absorption is the one law left standing: A | (A & B) is A only when A
contains A & B, and containment is the decision procedure — running it
wherever a schema is built is the cost this design refuses everywhere else.
is_equivalent decides it.
- A bare container class is its kind.
listandlist[object]admit the same values — every list, a subclass instance included — and were different sorts of thing: a sequence node in one case, anisinstanceatom in no kind at all in the other, so neither spelling was decided below the other andrepr(Validator(list))saidlistwhere the schema said otherwise.list,tuple,set,frozensetanddictname their kind's whole set, which is what the typing spec assigns an unparameterised generic and what the membership check always performed; the two spellings are one schema and compare equal.str,bytes,intandfloatalways read this way.reprfollows the schema, soValidator(list)renderslist[anything].
A class built on a builtin narrows that kind rather than standing beside it, so
it relates to it: Validator(MyInt) is below Validator(int) and
Validator(MyStr) below Validator(str), and a meet of the two is empty. A
class built on no builtin narrows nothing and relates to a kind in neither
direction — class Both(Plain, MyStr) builds and its instances are strings, so
a plain class's instances are not confined to any kind.
-
Every argument the compiled surface takes is positional.
Validator, the combinators, and every method on a validator declare their parameters positional-only, matching the stub that already wrote them that way: a call naming one —v.is_valid(obj=x),Validator(schema=int),complement(schema=int)— raisesTypeError.fail_fastis the one keyword the surface takes and it is keyword-only, as before. A stub that permits a keyword the extension refuses type-checks code that cannot run, and the two disagreed in nine places. -
A
TypedDictis open, which is the set the typing spec assigns it: a dict carrying keys the class does not name is admitted, and the keys it does name are checked and required exactly as before.closed=Trueandextra_items=T(PEP 728) are obeyed where the runtime provides them. The dict-literal form{"name": str}stays closed — it is this library's own spelling, and a schema written as a shape means that shape. Code relying onValidator(TD)to reject an extra key should write theTypedDictclosed=True, or use the dict literal. -
A map key schema narrowed by a constraint is refused where it is written. A clause's key says which keys it governs, and that must be a type —
str,int, a union of them — or aLiteral, which names the keys one by one.{Annotated[str, MinLen(2)]: int}anddict[Annotated[int, Ge(0)], str]now raise at construction. A narrowed key names part of a type, and two such clauses can overlap without either containing the other, which is a question this map model does not answer the same way twice. To constrain the keys themselves, check them beside the mapping rather than inside it. Keys that name a whole type are unaffected,dict[tuple[int, int], V]included. -
typing.Anyis the lattice top. It denotes what it always admitted — every value — and it is now the same schema asanything, so every law and every relation reaches it:Validator(Any) == Validator(anything),complement(Any).simplify()isnothing,intersection(Any, complement(Any))is decided empty, andintis decided belowAny. A gradual type is held apart from the top for a second question, consistency at the boundary between typed and untyped code, which a validator has no site for and never asked. What is kept is the spelling:repr(Validator(Any))is stillAny, andrepr(Validator(anything))is stillanything. The spelling is not part of the set, so two schemas differing only in it are equal and nothing decides anything by it.
Code that read intersection(Any, complement(Any)).is_empty() as False, or
Validator(Any).is_equivalent(anything) as False, now sees the opposite.
Membership is unchanged: Any admitted every value before and admits every
value now.
-
A constant is a value rather than an object. Two equal builtin scalars built separately — two
"code_00042"strings read from different files — pool into one constant, where pooling by object identity made them two and left a schema mentioning both with two nodes no rule could see as one. The rule is a literal's own: same exact type, and equal.Literal[1]andLiteral[True]stay two constants because the types differ;Literal[0.0]andLiteral[-0.0]are one, because the values are equal and the sign of zero is not part of either; ananand an integer too wide for the key pool by identity, sincenanequals nothing at all and a wide integer has no key. Two independently written thousand-member tables relate at any size as a result. -
reprrenders an expression that rebuilds the schema. Three forms rendered something that either was not Python or was Python that builds a different schema: a recursive schema showed its back edge as..., an open record showed its catch-all as..., and both read back asLiteral[Ellipsis], an ordinary dict key; the nullary product printedtuple[], which is not an expression. They render asrecursive(lambda X: {'v': int, 'n?': X}),{'name': str, anything: anything}andtuple[()]. Two forms remain a rendering rather than a round trip, because neither is syntax: a class prints its name and a predicate printsPredicate(...). -
A validator names the package it is imported from.
Validator.__module__isvalgebrarather than the private extension underneath, which the API reference reserves the right to rename and tells callers not to import; the class isfinal, which it was already at runtime, and the annotation says so to a type checker. -
A list or tuple counts one level of the construction depth bound, not two, so a schema can nest twice as deep before the bound refuses it: a chain of 128 nested lists now builds where 64 was the limit, and a chain that pins a length on each list reaches 64 where 43 was the limit. The sequence node carries its elements directly rather than as a regular expression over them, and the two levels a list spent were the expression's own constructors — levels a walk descended and a reader had no way to see. The bound itself is unchanged at 128 levels, and no schema that built before is refused now.
-
A schema refused for depth says so by name. The frontend's own descent limit stood at the same 128 levels as the construction bound, and once a list cost one level rather than two the two limits met: a too-deep chain of lists reported
NotImplementedErrorabout a type that never reaches a leaf instead ofValueErrorabout the depth. The frontend now descends one level further than the deepest schema construction accepts, so the bound that tripped is the bound that speaks. -
A union's
expectednames each branch the way that branch names itself when it fails alone, in place of the branch's node kind. A set of permitted strings — the commonest shape a field has — reportedone of: literal, literaland now reportsone of: the literal 'torch', the literal 'jax'; anEnumbranch names its class.Literal[...]builds a union of its constants, so its constants are what the message lists. The list is bounded at the same number of branches the closest-branch search reads and ends in...beyond it, so a wide union reports a readable prefix. -
The completeness probe searches refinements. Its schema universe crossed every other kind the decision procedure treats differently and held no refinement, so that whole fragment was outside the reach of the gate
docs/15-decidability.mdcites as the reason an unlisted conservative answer cannot go unnoticed. Four atoms — two order bounds and two regexes — put it in reach, and the four suspected gaps they surface are on the ledger with the route to deciding each. No answer changed; nothing was found unsound.
Deprecated¶
Validator.simplifyis deprecated and is removed in the next minor version. Calling it raises aDeprecationWarning. A schema is built in the lattice normal form, so the reduction it promises is the schema a caller already holds:reprshows it and==compares it. What it does beyond that is not a law but a decision — a meet of two provably disjoint kinds is the bottom, a join covering every region is the top — andis_empty,is_subtype_ofandis_equivalentdecide those and more without rewriting a term. Writeintersection(int, str).is_empty()rather thanrepr(intersection(int, str).simplify()) == "nothing".
open, close and ensure stay. The first two rewrite every record a schema
declares at any depth, inside its recursive definitions — a traversal that is
not spellable one set at a time, which is what a whole-schema operation is.
Fixed¶
- The extension imports on PyPy again. Telling a bare legacy alias
(
typing.List) from a parametrization with no arguments reached fortypes.GenericAliasthrough a binding whose type object is a CPython C-API static. That symbol is not part of the limited API and PyPy'scpyextdoes not export it, so the wheel built for PyPy failed to load — anundefined symbolat import, before any schema was built. The class is read from thetypesmodule instead, which is where every other special form this frontend recognises is already read from, and which PyPy carries.
Only the PyPy wheels were affected; every CPython wheel imported and behaved the same throughout. A CI job now builds the extension for PyPy and imports it on every push, so the next such symbol fails there rather than in a release build.
- A relation is refuted only where the subject of that comparison has a value, at every level of it. A container's rule carries its element's refutation up, and the reading that says whether a refutation is a claim was taken once, about the whole subject -- so a list of an element with no value, which is the empty list and below a list of anything, was reported outside it. The reading is taken where the refutation is made:
from typing import Annotated, Never
import annotated_types as at
from valgebra import Validator
no_value = Annotated[list[Never], at.MinLen(1)]
small = Validator({"f": no_value})
large = Validator({"f": no_value, "g": int})
assert Validator(list[small]).is_subtype_of(Validator(list[large]))
The same held for a set, a repeated tuple, a record whose field is optional, and any nesting of those. Shapes whose own form names a value -- a scalar, a container that admits an empty one, a union with such a member -- are read without a descent, which is what keeps the reading's cost where it was.
- A sequence whose repeated element the rules cannot read is not refuted
against a fixed length.
tuple[X, ...] <= tuple[()]was refuted on the ground that a repeating tail cannot fit a fixed length, which stands only whereXhas a value: anXthe rules cannot decide may admit none, andtuple[X, ...]is then the empty tuple, which fits. The refutation is believed where the element is proven inhabited and declined where it is not, and the descriptor -- which reads the element -- decides the pair:
from typing import Annotated, Never
import annotated_types as at
from valgebra import Validator
no_value = Annotated[list[Never], at.MinLen(1)]
assert Validator(tuple[no_value, ...]).is_equivalent(Validator(tuple[()]))
Found by the law that holds the two deciders to one answer: the rules refuted an inclusion the sets prove.
- A schema disjoint from a meet is below that meet's complement.
A <= ~Basks whetherAandBshare a value, and the meet it built for that question heldBas a nested intersection where the rule that decides a meet empty compares the members of one intersection pairwise. Built through the meet constructor the members flatten, the pair meets, and the relation decides:
from valgebra import Validator, complement, intersection
small = Validator(int)
meet = intersection(Validator(str), Validator(bytes))
assert small.is_subtype_of(complement(meet))
The two deciders answered one question differently, which is what a disagreement between them looks like from outside: a relation that holds, reported as not proven.
-
Two wide literal unions are decided as sets. The core compares a union with a union member by member, so
intersection(Literal[*range(20_000)], Literal[*range(20_000, 40_000)]).is_empty()made one call into the bindings per pair of members. The bindings answer the whole disjointness question in one pass where they can hash the constants, and decline — leaving the member walk — where they cannot. -
A raised
ValidationErrorbuilds its structured model when something asks for it. Every failure became a dict, a path tuple and six attribute writes at raise time, so a report over 10,000 failing rows cost 26 ms whether or not the caller read a row; it is now 9 ms for a caller that logsstr(error), and still faster than before for one that readserrors. The attributes, their values andstr()are unchanged, and pickling builds the model first so what crosses a process boundary is the same plain data. -
import valgebracosts 0.9 ms, down from 32.__version__was read withimportlib.metadata.version(), which pullsemail,zipfile,inspectand the compression modules to read a file that says whatCargo.tomlalready said — 20 of those 32 milliseconds, and a dozen modules dragged into any process that imports valgebra. The extension carries the crate's version instead;tests/test_version.pyholds it to the installed distribution's. -
A bound against
nanis refused. Every comparison withnanis false, soAnnotated[float, Ge(nan)]admitted no value at all — the empty set written as a bound, which no caller means and which neither decider proves empty.MultipleOf(nan)goes the same way. A bound that is empty because the order says so, such asGt(inf), is kept: emptiness is then an answer. -
A container is refused as a
Literalargument, where it used to be read as a schema. The typing spec'sLiteraltakesNone, an enum member, or anint,bool,strorbytesvalue; Python does not reject the subscription, soLiteral[[1]]reached the constant fallthrough and came out aslist[Literal[1]], andLiteral[{}]as the empty record — sets the caller did not ask for, with no message saying so. A float is still accepted: it is not a spelling the spec allows either, but it is a constant, which this library pools like any other. A bareForwardRefis refused too, with the message a forward reference in a type argument already gave. -
A bare legacy typing alias is the class it aliases.
typing.Tuplewas read astuple[()]— the empty tuple, admitting()and nothing else — because a bare alias and a parametrization both carry no type arguments;typing.List,typing.Dictandtyping.Setwere refused for wanting one. Each is now its origin, soValidator(typing.Tuple) == Validator(tuple).tuple[()]keeps meaning the empty tuple, and a parametrized alias is unaffected. -
NotRequiredandRequiredare read underfrom __future__ import annotations. CPython computes aTypedDict's__required_keys__when the class is created, from the annotations as written — under PEP 563 those are strings, so the qualifier was invisible to it and every optional key in every module using the future import compiled as required, failing correct data withmissing_key. The resolved hint carries the qualifier and is now what answers, so a class means the same thing with the future import as without it. -
Two spellings of one schema print the same way. A union's members are ordered by the IR's own order and a literal sorts there by its pool slot -- the order the constants were first seen -- so
Literal[1, 2]andLiteral[2, 1]were one schema by==and byhashand two byrepr, againstdocs/04-algebra.md's "reprshows it and==compares it". The literals in a union are now ordered by what they print; members that are not literals keep the place the normal form gives them. -
Two validators that differ only in a constant no longer share a hash.
__hash__skipped every pool slot, soLiteral[1]throughLiteral[1000]were one hash and a dictionary keyed by validators -- the reason the method exists -- degenerated into a linear scan, at 98 microseconds per lookup over ten thousand entries. The constant behind a slot is now folded in, which is 10,000 distinct hashes and 0.065 microseconds for the same registry. A constant with no hash contributes nothing, so a validator stays usable as a key whatever it pools. -
An integer key of any size reaches an error path as an integer, and so does a
bool.docs/08-error-model.mdpromises a path a caller can walk back down to the value, and a key outside a machine word's range was rendered as itsrepr—'1180591620717411303424'— whileTruewas excluded outright and arrived as'True'. Both index nothing.d[True]andd[1]are one entry in Python, so aboolarrives as the integer it is. -
Explaining a failure over a large value no longer costs the size of the value. Every violation summarised the value it was about by building that value's whole
reprand keeping eighty characters of it, so a 20,000-deep list was rendered in full once per level of the walk: twelve seconds for a single error, against twenty microseconds foris_validon the same value. A container is now rendered under a bound instead, which is 1.1 ms for the same case and flat in the depth. A value small enough to print is unchanged. -
A class that holds its own validator is collected. A validator keeps the classes, enum members and callables its schema names, so
Model.validator = Validator(Model)is a reference cycle -- and the type was not tracked by the cycle collector, so the collector never saw the edge from the validator back to the class and every such class leaked. A validator now traverses the objects it owns, and can be weakly referenced, so a registry keyed by schema can be aWeakValueDictionaryand let its entries go. -
A relation over a pattern whose determinisation is exponential refuses instead of exhausting memory.
Annotated[str, Regex("(a|b)*a(a|b){20}")]againstRegex("(a|b)*")spent six seconds and 668 MB, and two more repetitions aborted the process on a four-gigabyte allocation: the automaton bound was checked after the regex engine had built the whole dense table. The engine now carries the size limit, so the family answers in under 100 ms at any repetition count. Membership is unaffected — the walk runs the pattern, not the automaton — and a pattern that stays small is still decided. -
A
Flag, anIntFlagand anEnumwith no members are no longer read as the union of the members they list. A flag's|builds instances the class never listed, soValidator(Permission)was decided a subtype ofLiteral[Permission.READ, Permission.WRITE]althoughPermission.READ | Permission.WRITEis in the class and not in the literal; an enumeration with no members can still be subclassed, soValidator(Base)was decided a subtype ofnothingalthough a subclass's member is an instance of it. Each is now theisinstanceatom it was before the union reading existed, which leaves membership unchanged and the relations undecided. -
A list that changes size while it is being checked is reported as
mutated_during_validation, as a dict, a set and a record already were. A sequence is walked by position against a length read once, so a list grown by a predicate — or, on a free-threaded interpreter, by another thread — hid its new items from the walk, and one that shrank left the walk answering about items that were gone. In both directionsis_validreturnedTruefor a value that is not a member, andensurehanded that value back as checked. A tuple cannot be resized and keeps the plain iterator. -
A dict schema is not decided below the complement of a record it shares values with. Negating "no key of this part, other than the ones this atom names, maps anywhere" tightened that part's default, and a default governs every key the atom does not name — the excluded ones included — so a record complemented twice came back forbidding the key it is about, and
dict[str, int]was decided a subtype of~{"a": int}although{"a": 1}is in both. -
A numeric bound orders the booleans as well as the integers.
boolis a kind of its own to the set representation andintdenotes both, so a bound lowered as a set of integers alone denoted less than the schema does — and a smaller set has a larger complement, which is a subtype proof no value supports. A bound over a base that is not whole numbers,Annotated[float, Gt(0), Lt(1)]among them, is left to the structural rules rather than narrowed to integers. -
Two plain classes are not decided disjoint. A class built on no builtin lays down no instance layout of its own, and reading two such classes as laying down different layouts made them share no value — though
class Both(A, B)builds and its instances are in both. A layout conflict, which Python refuses to build a class across, still decides the pair. -
openandcloseare functions on sets.{"a?": nothing}and{}admit exactly the empty dict — the field allows the key to be absent and admits no value for it, which is what a closed record already says of every key it does not name — yet.open()gave them different sets. The redundant field is now read away first, so equal records open to equal records. Two consequences a caller may see:Validator({}).open()now admits every dict, where it used to be left alone (having no field never made{}a mapping — a clause and no field does), and a record carrying a field its own clauses already cover loses that field when opened or closed. -
A set reports its failing elements in an order the value fixes rather than the one the interpreter hands them over in, which moves with the hash seed. A set has no positions, so an element failure carries no index and only what it reports distinguishes it; the report is ordered by that, and
fail_fastkeeps the first of that order. The error model promised this determinism and a set was where it did not hold. -
A dict key that is not a string appears in an error path as its full
reprinstead of a summary cut at forty characters, and a string key appears whole. A path is what a caller walks back down to the value, and a truncated key indexes nothing. -
A refinement marker that would be dropped is refused instead. Four shapes silently produced a schema that admits either everything the marker excludes or nothing at all: a compiled
re.Pattern's flags were discarded, so a case-insensitive pattern refused the strings it matches; abytespattern and a length bound no length can equal were skipped entirely, leaving the base unconstrained; and a marker fromannotated_typesthat valgebra does not check, such asTimezoneorUnit, was ignored as if it were someone else's metadata.re.IGNORECASE,re.MULTILINE,re.DOTALLandre.VERBOSEare written into the pattern;re.ASCII,re.LOCALEandre.DEBUGare refused by name. Metadata from outside that vocabulary is still ignored, as the typing spec asks. -
A constraint no value of the base can answer is refused at build.
Annotated[int, MinLen(1)]asked an integer for its length, which raises, and a raise reads as a non-member — so the schema denoted nothing at all while looking like a narrowing. A constraint some value of the base can answer is unaffected: a union with a text branch, or a class that may define what the constraint asks for, still narrows. -
==on validators reads a pooled constant the wayLiteraldoes: same type and equal. Python's==runs across types, so comparing by equality alone madeValidator(Literal[1])andValidator(Literal[True])the same validator whileis_equivalentreported them disjoint — two answers about one pair. Comparing a validator with something else answersNotImplementedrather thanFalse, so the other operand gets its turn as the data model asks;==still falls back to identity, so the answer a caller sees is unchanged. -
A class is checked for the attributes it declares rather than for every annotation on it. A dataclass carrying an
InitVardenoted the empty set — the marker names a constructor parameter no instance keeps, so every instance was refused for a missing attribute — and one carrying aClassVarfailed to build at all. Both are read as what they are, a field declaredinit=Falseis still checked because the instance carries it, and aTypedDictfield qualifiedReadOnlycompiles to the type it qualifies instead of being refused. -
A string in the argument of a typing form is refused instead of read as a literal.
list["Account"]is a forward reference the typing spec resolves against the namespace the annotation was written in, and a runtime object carries no namespace; reading it as a constant built a list of the word, which refuses what the annotation admits.Validatorstill reads a bare constant as a literal wherever a value belongs — at the top level, in a native list or dict literal, and inLiteral[...]— and still resolves a class's own string annotations. -
An unpacked variadic tuple compiles to the shape it names.
tuple[int, *tuple[str, ...]]is a fixed prefix followed by a repeating tail — the same shapetuple[int, str, ...]spells — and it was read as a two-element tuple whose second element is a tuple, so it refused(1, "a")and admitted(1, ("a",)).Unpack[...]says the same thing and compiles the same way; an unpacked fixed tuple splices its elements in. An element after the repeating tail names a set the sequence node cannot carry and is refused, as is unpacking aTypeVarTuple, which binds no element types at runtime. -
The product-splitting rule builds its narrowed component through the schema constructors instead of writing at an index, so the double negation it used to manufacture — a complement of a branch component that is itself a complement — cancels where the rest of the tree says it does. The verdicts are unchanged; the rule stops producing a shape no other rule is written for.
-
A
MultipleOfdivisor of a different type than the value divides. The check reads the%operator rather than the value's__mod__alone, and half of what%means lives on the divisor: a value that does not know it answersNotImplemented, the divisor's__rmod__is asked next, andNotImplementedis truthy — so it read as a non-zero remainder.Annotated[int, MultipleOf(0.5)]refused every integer, and aFractionorDecimaldivisor refused every value. -
A
recursivedefinition nested inside another resolves its self-reference wherever the build put it. An inner fixpoint whose body names the outer variable compiles to a definition of its own, and the outer marker lands in that definition rather than in the outer body — so resolving the body alone left the marker dangling, and a dangling marker matches no value: the schema silently rejected members. Contractivity is checked over the whole system of definitions for the same reason, which refusesX = ~XandX = X | list[Y]written across a nesting; both built before and denoted no fixpoint. -
A placeholder kept past the
recursivebuilder it was handed to is refused at construction. The placeholder is an ordinary validator, so nothing stops a caller storing it, and what it stands for stops existing when the builder returns; using one afterwards built a validator that admitted no value and said nothing about why. -
A container that changes size while it is being checked is reported as
mutated_during_validationinstead of aborting the interpreter. Membership runs Python at almost every entry of a dict or a set — a predicate, an__eq__, anisinstancehook — and a free-threaded interpreter lets another thread write to a shared value meanwhile; the iterators underneath both containers answer that with a panic, which crosses the boundary as aBaseExceptionno caller catches as a validation failure. The walk reads both containers in a way that survives the change and reports a non-member, because a reading cut short decides nothing about the contents. Only a change in size costs the reading: a value rewritten in place is unaffected. The same code reports a value whose two readings disagree, which is the same failure of the check to have a stable value to decide about. -
The membership walk counts the levels it holds open and refuses past 512 of them with
recursion_limit, so a value inside every published construction bound cannot exhaust the native stack. Counting recursive unfoldings alone does not bound the frames: an unfolding descends the whole definition body, so a body at the schema-depth bound turns the 128 permitted unfoldings into thousands of frames. A recursive schema over a deep body meets the level bound and reports it; a linked list at the unfolding bound is unaffected, because the level ceiling sits above what that shape asks for. -
ValidationErrorcan be pickled, so a validation failure crosses a process boundary with its structured model intact. The exception's__module__was a bare_valgebra, which names no importable module, andpicklelocates a class by that string together with the qualified name — so a worker in a process pool or a task queue delivered aPicklingErrornaming an internal module instead of the validation result. The module is nowvalgebra, the package the name is exported from: it is baked into every serialized error, so it has to be the path that keeps resolving rather than the private extension underneath, whose name this reference reserves the right to change. A traceback and areprreadvalgebra.ValidationErrorfor the same reason.
0.0.9 - 2026-08-26¶
Two Annotated markers were read as something other than what they mean, and
both produced a schema that denoted nothing or its complement. No other decision
changes.
Fixed¶
- A marker that is itself callable is asked, and only one that is not is
taken apart by its
.func.annotated_types.Predicatecarries its callable there and is not callable, which is why the attribute is read at all — butNotandfunctools.partialcarry one too.
Not(f) denotes the values where f is false, and it defines __call__
because calling is what applies the negation. Read by its .func it
constrained by f instead, so every value under it got the opposite verdict:
Annotated[int, Not(is_even)] admitted the even numbers.
partial(eq, 1) lost its bound argument the same way and became eq, which
raises when called with a single value, so the schema admitted nothing at all.
Callability is the discriminator annotated_types itself encodes, so this is
its rule rather than a heuristic.
- A class in
Annotatedmetadata is ignored, as the typing spec asks of metadata a consumer does not recognise. A marker carries its values on an instance and a class carries the descriptors that read them:Ge(0)holdsge = 0, whileGeholds the slot descriptor, and reading that as a bound built a comparison no value is ordered against. A class is also callable, and calling one constructs rather than asks — a unit marker written as a class answered no question and refused every value. Both traps ended in a schema denoting nothing.
Every other callable is still a predicate: a function, a lambda, a bound
method, an object with __call__.
Changed¶
-
The documentation pages are numbered, so a page's URL carries its number:
/03-schema-language/where it was/schema-language/. The site's landing page is unchanged. -
The published benchmark figures are re-measured, and the performance page now records the interpreter build its baseline was measured on rather than only the version: a free-threaded CPython runs this work about twice as slow as a GIL build of the same version. The ratios are unchanged — 7.6x on deep nesting, 2.0x on the wide record, 1.8x on the large array — and no decision or code path moved.
0.0.8 - 2026-08-25¶
Membership costs less on a refined schema, and no decision or message changes.
Changed¶
- A violation's message is built when a violation is recorded rather than on
every check. Naming a bound takes the bound's
repr, and a value that belongs produces no violation to name it in, so accepting a value used to cost the size of the schema's operand rather than the size of the value.
Per-check cost over the bare type, release build, median of eleven runs of fifty thousand: a single comparison bound about 16 ns where it was about 123, and two about 29 where they were about 258. A comparison bound therefore costs less than a call into a Python predicate, which is the ordering the refinements page describes. The absolute figures are one machine's and move by around a tenth between runs; the ratio and the ordering are what travel.
The operand's size no longer enters into it. A passing check against
Annotated[str, Ge(s)] measures about 51 ns for a one-character s and about
52 ns for a two-hundred-thousand-character one, against 143 ns and 303 us.
Every violation carries the message it carried, and every decision is unchanged.
- The pages carry four things a reader could otherwise only find by experiment.
The
Regexdialect is the Rust engine's, notre's, and a pattern both engines accept can match different strings — POSIX bracket expressions, Unicode case folding of the Turkish dotless i, and\p{...}property escapes, so compiling successfully is not a test of which language a pattern is in. The depth budget accounts for refinements: a refinement is a node, so it costs a level on top of whatever it narrows, and the marker it carries makes no difference. A map can constrain some keys and leave the rest free by giving the permissive clause the complement of the claimed keys, whereopenadmits a clause matching every key and so subsumes a narrower one. And the decidability boundary records that a meet of two distinct literals is not decided empty, with no sound rule to close it: a literal's equality is the value's own, so two literals can share a member while neither contains the other.
0.0.7 - 2026-08-25¶
Fixed¶
-
open,close, andsimplifyrewrite a validator's recursive definitions as well as its root. A recursive validator's root is a single back edge and every record, union, and refinement it declares lives in the definitions table, so all three were no-ops on exactly the schemas that carry the most structure:recursive(lambda n: {"a": int, "next": n}).open()admitted no undeclared key, andsimplifyleft a recursive body unreduced. -
The frontend descends as far as the construction bounds publish, so a schema those bounds accept is no longer rejected while being compiled. Sets, dicts, and records nest to the documented limit rather than to 100 levels, and the message a rejection carries reports how far the frontend descended rather than naming a cause it cannot know.
Added¶
-
is_emptydecides an interval that skips every integer however the meet is spelled.intersection(Annotated[int, Gt(0)], Annotated[int, Lt(1)])is empty, asAnnotated[int, Gt(0), Lt(1)]already was: an intersection is a subset of each of its members, so a member bounded to the integers bounds the whole meet. Aboolbase counts integers for the same reason, since it subclassesint. -
is_subtype_ofdecides attribute schemas across a class hierarchy. A dataclass or named tuple is below one over a base class whose every attribute it carries with a narrower schema, and below the bare class it is an instance of.
Both are conservative-to-decided moves: every relation that held in 0.0.6 still holds, and each carries the counter-direction that keeps it from over-firing.
Changed¶
-
openandclosemay raiseValueError. Opening a record adds a catch-all clause, so it grows the schema and the construction bounds apply to what it produces; a validator near the node limit can cross it. -
A union of no members reprs as
nothingand a meet of none asanything, because each constructor owns the identity of its own arity.repr(union())was the empty string, which is not an expression that rebuilds the validator. -
A widening between two eight-member literal unions costs 619 ns where it cost 1.66 us, and a union of opaque members decides up to about eight hundred members where it decided up to four hundred. The lattice bound asking whether a supertype covers the universe reads that supertype's region set instead of building the complement of a deep clone of it, and the emptiness folds stop once no later member can change the verdict.
-
Membership costs what it cost. The competitive baseline in the performance page is re-measured with its spread and the method that produced it: against pydantic in strict mode, 7.6x on a schema nested twenty-five deep, 2.1x on a fifty-field closed record, and 1.8x on a flat array of ten thousand integers.
On upgrade, handle ValueError from open and close where a schema is built
in a loop, and expect a recursive validator to answer as the recursion says it
should rather than as its root alone did. Nothing else changes.
0.0.6 - 2026-08-08¶
Changed¶
- Internal only, with no change to what any schema admits or answers: the schema
IR's purely structural walks share one declaration of each node's child
schemas, and the two index remappings applied when validators compose —
appending a constants pool, or interning one into another — share one walk over
one set of payload sites. Every public method returns what it returned in
0.0.5, and validation,
is_empty, the error model,repr, and every compiled form are unchanged.
Nothing to do on upgrade. The version exists so the reorganisation ships under a release of its own rather than inside one whose entries describe something else.
0.0.5 - 2026-08-08¶
Added¶
tests/test_completeness_probe.py, which searches for relations answeredFalsethat no value in a wide universe refutes, and fails when one appears that is not written down with a reason. Every other instrument could only notice a completeness gap someone had already thought of; this one searches.
Fixed¶
-
is_subtype_ofdecides a closed record against a catch-all mapping:{"x": int}is recognised belowdict[str, int]. The closed record had a dispatch branch of its own that read a field the supertype covers through a catch-all as undecided, though the general keyed-map rule beside it already decided exactly that. One rule serves every keyed-map shape. -
is_subtype_ofdecides inclusion in a complement:Ais below~Bwhen the two share no value, solist[int]is recognised below~intanddict[str, int]below~str. There was no rule for a complement on the right at all, so the relation was decided only when the left side was itself a complement.
Both change a public method's answer from False to True. No relation that
answered True can answer False, and validation, is_empty, compilation and
every rendered form are unchanged.
is_subtype_ofandis_equivalentdecide the lattice bounds by emptiness rather than by the shape of the atom. A schema that denotes the empty set without being spellednothing— a record with an uninhabited required field, a cancelling intersection — is recognised as a subtype of every schema, and one that covers the universe without being spelledanythingis recognised as a supertype of every schema. Both previously answeredFalse, which was sound but incomplete.
This changes the answer of a public method from False to True for those
schemas. Nothing else moves: is_empty, validation, compilation and every
rendered form are unchanged, and no relation that answered True can answer
False. The gap was masked whenever the other side was scalar, because the
region check decides that case correctly, so it was only visible against a
container, a record, an instance or typing.Any.
0.0.4 - 2026-07-13¶
Added¶
- The schema construction bounds are published as module constants —
MAX_SCHEMA_DEPTH,MAX_DEFINITIONS, andMAX_SCHEMA_NODES— so a caller can size a schema against them.
Changed¶
- Schema construction is bounded on every growth path, not only the combinator
operators: the
Validatorconstructor, the|operator,union,intersection,complement,recursive, andsimplifyall reject a schema past a fixed nesting depth, recursive-definition count, or total node count with aValueError.simplifycan therefore raise when negation-normal form expands a schema past the size bound (seedocs/10-limits.md). - The project describes its schema algebra as closed under its operations rather than complete.
Fixed¶
- No sequence of public calls can overflow the native stack or exhaust memory
while building a validator. A schema grown too deep through the
Validatorconstructor orrecursive, or too large by combining a validator with itself in a loop, is rejected at construction instead of crashing the interpreter on a later clone, drop, decision, or render. This extends the 0.0.3 composition bound, which rejected only the combinator operators and left the constructor andrecursivepaths unbounded. Annotated[int, MultipleOf(0)]is rejected when the validator is compiled: no value is a multiple of zero, so the unsatisfiable schema raises aValueErrorat construction instead of rejecting every value through a swallowedZeroDivisionErrorat validation time.
0.0.3 - 2026-07-07¶
Added¶
- Schema composition bounds nesting depth: combining validators with
|,union,intersection, orcomplementpast a fixed depth is rejected at construction with aValueError, so a schema grown in an unbounded loop cannot overflow the native stack on its next check. Arecursiveback edge counts as a leaf, so a recursive schema's depth stays finite (seedocs/10-limits.md). - The type stub declares the
__copy__and__deepcopy__methods a compiled validator exposes.
Fixed¶
- Release builds report a well-formed validation error instead of panicking across the boundary when the error builder is handed no failures, and the per-element sequence walk folds an impossible missing-schema case to a non-member result rather than a panic.
0.0.2 - 2026-06-30¶
Added¶
valgebra.__version__exposes the installed distribution version, read from the package metadata that maturin derives from the Cargo workspace manifest.
0.0.1 - 2026-06-29¶
The first published release. valgebra ships to PyPI as prebuilt wheels across the support matrix.
Added¶
- Compile-once / validate-fast engine:
Validator(schema)builds an immutable validator withvalidate(raises),is_valid(bool fast path), andensure. - Typing-annotation frontend: scalars,
None,Any,list/set/frozenset/dict, fixed, variadic, and prefix-plus-tail tuples (tuple[A, B, ...]), unions andOptional,Literal,TypedDict, dataclasses,NamedTuple, enums, runtime-checkable protocols,NewType, PEP 695 aliases, andAnnotatedrefinements (with bounds, length, and predicate constraints). - Native forms: a list literal as a sequence —
[T], the fixed[A, B], and the prefix-plus-tail[A, B, ...](a fixed prefix then a repeated tail); a dict literal as a closed record ("key?"optional); a single{KeyType: ValueType}entry as a mapping; and any constant as a typed literal. - A closed Boolean algebra:
union,intersection,complement,anything,nothing, and a law-justifiedsimplify, with the lattice laws property-tested. Conditional fields and key cardinality are composed from these (documented recipes), not shipped as combinators. - Set-relation queries on a compiled validator:
is_subtype_of(set inclusion),is_equivalent(mutual inclusion), andis_empty(an unsatisfiable schema, including a recursive schema with no base case). Decided soundly across scalars, containers, records and mappings, sequence forms, class subtyping (issubclass), and literal values (by membership), and conservative on the cases it cannot prove. - Recursive schemas via the
recursivefixpoint, with cycle and depth guards. - A structured, machine-readable error model: aggregated failures, opt-in fail-fast, and closest-branch reporting for unions.
- JSON input on the Rust path:
validate_json,is_valid_json, andload(validate and return the parsed value), consistent with the object path and faster than parse-then-validate. - A stable
reprthat renders a schema back to its annotation form. - Thread-safe, immutable validators.
- A performance program: criterion and pytest-benchmark suites, a recorded baseline against pydantic-core and jsonschema, and a deterministic instruction-count CI regression gate.