Changelog¶
The current CHANGELOG.md is maintained at the workspace root.
This page mirrors its content for the docs site.
Changelog¶
All notable changes to this project will be documented in this file.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[0.17.1] - 2026-09-16¶
Fixed¶
- A
str | Nonefield given the text"null"read back asNone. The optional wire form spellsNoneas the textnull, and a present string with that exact encoding collided with it; lairs'Token.textlost any token speltnull, which Hypothesis found in bead's layers round-trip law. A present value whose encoding isnullfollowed by any run of backslashes now gains one backslash on the wire and loses it on read, soNoneand every string stay distinct for any inner type. No other value changes its stored or pickled form.
[0.17.0] - 2026-09-16¶
Added¶
didactic-settingsships the configuration composition engine ported from bead'sbead.config.compose.didactic.settings.compose(path, schema=...)composes a validated model from a primary file and the fragments itsdefaults:list selects, abasemapping, a profile (a name underprofiles/or a mapping), overlay files or mappings, and dotted overrides, in that precedence;compose_tracedreturns the value with the merged tree, the record and the ladder ofLayerrecords as aComposed;compose_layersruns the engine over a ladder a caller assembled;strict_mergemerges one mapping over another under a schema. JSON joins TOML as a format read through the standard library, everywhere the engine reads a file.- Union descent. A tagged-union field is descended rather than treated
as an opaque leaf: the discriminator is read before any sibling key,
the node's keys are checked against the selected variant's fields, and
a key belonging to another variant is refused naming both variants and
the layer that selected the one in force. A node no layer has tagged
merges provisionally and the settle pass, after the last layer,
injects the tag of the variant the field default is an instance of, so
{momentum: 0.5}then{kind: sgd}and the reverse compose to the same tree. A later explicit tag switches the variant and drops the old variant's private fields. Unions nest inside variants,dict[str, T]entries andtuple[T, ...]elements; textual tags are decoded against each variant's literal, so an integer-tagged union is selected from the environment; a discriminator must be a literal, never an interpolation. - Config groups. Rooted at each search root (the primary file's parent,
then
search_path), a group is a directory named by the slot's dotted path with.as/, and a fragment is one document in it holding the slot's value. Three spellings feed one ordered selection table keyed by slot: a one-key{slot: name}entry in the primary file'sdefaults:list (Hydra's slash spelling is accepted;nullrecords no selection), thegroups=mapping (Nonedeselects), and override strings whose key contains/. The table merges abovebaseand below the file body. A missing fragment isMissingFragmentErrorlisting every path tried and the names available. - Per-leaf provenance. Every leaf of the composed model carries an
Originrecord (kind,name,path,expression,label), andProvenanceis an immutable sorted mapping from dotted leaf path to origin withpaths,source_of,under,by_layerandto_dict. The record covers exactly the leaves ofmodel_dump_json(): partial overlays keep per-leaf attribution, subtree overwrites prune, leaves no layer wrote aredefault, and an interpolated leaf keeps its layer and gains the expression. The instance carries it as__provenance__andprovenance_of(instance)reads it back. A list is one leaf, so the record stops at the list boundary. - Interpolation with a resolver registry: OmegaConf's grammar (absolute
and relative references, list indexing, nesting, concatenation,
resolver calls, escapes) resolved once over the settled tree, with
register_resolver,unregister_resolver,list_resolvers,active_root, a per-callresolvers=overlay,resolve_traced, andlookup(path), which evaluates a path inside the active evaluation so a cycle through a resolver is reported as a cycle. The built-in set isoc.env,oc.select,oc.decode,oc.deprecated,oc.create,oc.dict.keysandoc.dict.values. Field defaults may carry${...}expressions; they are materialised before interpolation and resolve against the composed tree. Settings.load(path, *, profile, groups, overlays, overrides, search_path, resolvers, **values)composes the primary file, its groups, the profile, the overlays, the declared sources and the overrides through the same engine;Settings.load_tracedreturns theComposedrecord;__search_path__names directories holding fragments and profiles.Sourceis a public base class implementinglayer(schema).EnvSourceandDotEnvSourceread nested paths (APP_TRAINER__EPOCHS), whole model, union and map slots as JSON object text, and map entries below a map slot;DotEnvSourceacceptsexport KEY=value;FileSourcetakesrequired=;CliSourcetakes dotted and__-joined keys. Text from every textual layer is decoded by the leaf's annotation (str,bool,int,float,Literal,Enum,T | None,Annotated,tuple,frozenset, JSON for slots).- Structured errors:
ConfigErrorcarriespath;UnknownKeyError(allowed,declared_by,set_by),UnknownVariantError(value,registered),MissingFragmentError(group,name,tried,available),CoercionError(expected,text) andOverrideSyntaxError;InterpolationErrorcarries the path of the leaf being resolved. didactic.types.unwrap_annotatedis exported publicly.- Hygiene tests pin that composing from TOML imports neither
panprotonoryaml(nortorchortransformers), that composing from YAML addsyamlonly, that no engine module imports a forbidden name, and that opening a YAML file without PyYAML names thedidactic-settings[yaml]extra. - bead's compose test suite is carried as the conformance spec under
packages/didactic-settings/tests/conformance/, with three textual rewrites (from bead.config.compose importtofrom didactic.settings import,profile_dict=tobase=,extra=[overlay]tooverlays=[overlay]) plus two annotations on arootdict and named resolver functions in place of lambdas so the files pass strict pyright; no assertion changes.
Changed¶
- A scalar field given a value of the wrong Python type (
"5"or2.0orTrueforint,1forbool, a number forstr, and thebytes,Decimal,datetime,date,timeandUUIDadapters alike) is refused as aValidationErrorentry of typetype_errorwith the field'slocand the messageexpected int, got str. The adapters raisedAssertionErrorbefore, which carried no location and vanished underpython -O. - A validation failure inside a nested model, a tagged-union variant or
a container of either is reported by the outer model: each inner entry
is re-located under the field name (
loc == ("trainer", "mode")),ValidationError.modelis the outer class, and the outer model keeps collecting its other fields' errors instead of stopping at the first nested failure. Code matchingerror.model is Innerreads the location fromlocinstead. Settings.__provenance__is aProvenanceofOriginrecords keyed by dotted leaf path, not adict[str, str]keyed by top-level field. Code that compared__provenance__["port"] == "env"reads__provenance__["port"].label == "source:env"(or.kind == "source"and.name == "env"); a default is labelleddefaultand a keyword overrideoverride:port=9999.FileSourceno longer drops unknown top-level keys silently; a key the schema does not declare, at any depth, is refused asUnknownKeyErrorwith its dotted path and(set by source:file).Settingssources implementSource.layer(schema)returning aLayer, replacing the privatefetch;separator,nameandrequiredare keyword-only. Sources sit above the profile and overlays and below the overrides.Settings.load(**values)keys are dotted paths with__as the separator and are typed overrides.Settings.__init_subclass__refuses a field named after one ofload's keywords (path,profile,groups,overlays,overrides,search_path,resolvers) and two sources sharing a name, both withTypeErrorat class creation.- Environment, dotenv and CLI text is decoded by the full leaf
annotation rather than coerced for
int,floatandboolonly, and unreadable text isCoercionErrorrather than a validation failure. - A value that arrives typed (a file, a
baseor overlay mapping, a(key, value)pair,Settings.load(**values), a typed CLI argument, a resolver result) is checked against the leaf annotation when it is written, so a wrong-typed leaf isCoercionErrorwith the leaf's path and the layer that set it (boolnever satisfiesint,intsatisfiesfloat, aLiteralorEnumneeds a member of the same type, aPath,datetime,date,time,UUID,Decimalorbytesleaf takes its JSON text form). Every element of atuple[Model, ...]ortuple[Union, ...]list is checked, so a non-mapping element is refused asitems[0]. The resolved tree is validated throughmodel_validate_json, soPathanddatetimeleaves compose from every layer.Noneat an optional list slot (tuple[int, ...] | None) is accepted from every layer, typed or textual, and refused at a required one asConfigError. - Text holding a
${...}expression is kept as it is from a textual layer whatever the leaf annotation, and an expression may sit at a leaf, a list, a map or a model slot in any layer; the resolved value is checked against the schema, and text a resolver produced at a non-strleaf is decoded by the annotation, sotrainer.epochs=${oc.env:EPOCHS},LOFI_T__N='${seed}'andtags: ${oc.dict.keys:paths}compose. A union slot still needs a literal tag at merge time. A textualnull,~or empty value clears an optional model, union or map slot. - The schema's default map is the lowest layer of a
dict[str, T]slot: a default entry survives a layer that adds another key, and a layer's entry composes over the default entry of the same key, through a default union entry's tag included. - A computed or derived field name is accepted and dropped from document
layers only (a file, a mapping, a profile, an overlay, a file source);
in an override, a
Settings.load(**values)keyword or a textual source it is refused asUnknownKeyErrorsaying the field is computed. - A typed discriminator must match a variant's literal by type as well
as value (
Truedoes not selectLiteral[1],1does not selectLiteral[True]);UnknownVariantError.registeredandUnknownKeyError.declared_bycarry the live tag values rather than their text, messages render them withrepr(Stage registers: [1, 2]), and a variant registered under several literals is named once, by its first. A discriminator given under an alias the variants declare selects the variant. Before any tag is known, a root-declared field a variant shadows with another annotation or optionality is refused until the tag is set. An empty union node names the layer that created it in theselects no variantmessage. - For code moving from
bead.config.compose:ComposeValueisConfigValue;compose(profile_dict=...)iscompose(base=...)andcompose(extra=[...])iscompose(overlays=[...]); the primary file'sdefaults:entries and profiles are resolved against a search path rather than the file's directory alone. Scalar maps (dict[str, str]) merge key by key instead of being overwritten wholesale, anddict[str, Model]entries descend into their models and variants. Overrides andbasedocuments are strict-checked at merge time, so an undeclared key isConfigErrorwith its dotted path rather than a validation error or nothing. Override values are read by the leaf annotation, falling back to a standard-library scalar grammar rather than YAML:items=[a, b]with bare words no longer parses (writeitems='["a", "b"]'oritems=a,bundertuple[str, ...]), andyesandnoare strings understr. Fields of a tagged-union variant, the discriminator included, are settable from every layer.oc.select,oc.dict.keys,oc.dict.values,oc.deprecatedandoc.createare implemented in full (bead's raised or passed through);oc.decodekeeps its base64 reading. Resolver arguments are split on commas outside brackets, braces and quotes, so${oc.decode:[1, 2]}passes one argument. A reference cycle through a resolver that reads the tree withlookupis reported as a cycle instead of overflowing the stack.
Removed¶
- The empty
tomlextra ofdidactic-settings; TOML is read throughtomlliband needs no extra. - The string-valued
Settings.__provenance__and the private_Source.fetchprotocol.
[0.16.0] - 2026-09-14¶
Changed¶
dx.GADTdeclarations take their telescopes as keyword arguments, in order, and express dependence between inputs as lambdas over earlier names.lang.constructor("IntLit", value=El[int_code()], returns=Expr[int_code()])replaces a tuple ofdx.paramcalls, andlang.eliminator("evaluate", t=Ty, expression=lambda t: Expr[t], returns=lambda t: El[t], body=...)declares and defines an eliminator in one call. A family is applied to its indices by subscripting,Expr[t], anddx.Implicit(Nat)marks an input Panproto infers.Operation.definetakes a lambda over the eliminator's inputs. Every reference is a Python object, so a misspelt constructor or variable fails on the line that contains it rather than atcompile(), and editors can complete and rename them.- Case analysis is
dx.match(scrutinee, IntLit=lambda value: value, ...), one keyword per constructor with a lambda over that constructor's binders. Inside an eliminator body the constructor names are checked against the scrutinee's family and each branch's binder count against its constructor, with the valid set named on a miss. An operation may stand in for a lambda that would only apply it, sonil=fallbackreads asnil=lambda: fallback(). dx.let(bound, lambda x: body)binds the lambda's parameter.- The declaration surface is typed for a strict checker: lambdas are
typed as unions over arities up to eight, so each binder is inferred as
dx.Varrather than left unknown. GADTDeclarationErrorandGADTReductionErrorlive indidactic.gadt._errors; their public import paths are unchanged.
Removed¶
dx.var,dx.app,dx.branch,dx.case, anddx.param. Variables are lambda parameters, applications are calls on the operation, branches and cases aredx.match, and parameters are keyword arguments. The term classesVar,App,Branch,Case, andLetremain exported for code that builds terms directly.
[0.15.0] - 2026-09-12¶
Added¶
dx.GADTdeclares arbitrary first-order indexed families, constructors with refined result indices, dependent motives, user-defined eliminators, equations, and directed rewrites. Its immutable term AST covers variables, applications, holes, lets, and cases with capture-avoiding substitution and canonical JSON transport.dx.Universeanddx.indexed_byconnect dependent families toModelfields. Construction, JSON validation, and immutable updates reject a payload whose type or symbolic sort does not match its stored indices.- Indexed models emit dependent Model-sort telescopes and include their GADT declarations in the generated Panproto theory. JSON Schema, Pydantic, and FastAPI preserve known index cases as conditional schemas and cross-field validators.
- The GADT test suite covers dependent heterogeneous telescopes, index-refined case coverage, wrong and unreachable branches, branch-local skolem escape, morphism index preservation, colimits, serialization, capture avoidance, rewrite fuel, and property-based round trips.
Changed¶
- panproto is now required at
>=0.74.2. This preserves historical object identities across schema migrations and permits defining equations for eliminators whose family indices are inferred from explicit arguments. Model.with_()reconstructs through the normal validation path, so field validators, class axioms, and indexed invariants are rechecked atomically.- Compatibility reports classify any changed indexed contract as breaking. Automatic migration synthesis now requires an explicit migration when a family's indices, cases, theory, or Python carriers change.
- Inbound Model synthesis fails closed on an indexed primary sort because a
Panproto Theory does not retain the Python carrier annotations required to
reconstruct
IndexedByvalidation. - All four distributions and runtime version constants are now
0.15.0.
[0.14.0] - 2026-09-11¶
Added¶
didactic.extensionsprovides an exact-version, checked lowering boundary for external typed languages. A lowerer must check its source, lower it, and validate the target; Didactic does not erase or reinterpret extension semantics between those steps.tools/qiec_interop.pyexercises the planned Quivers integration contract with indexedVecand parameterizedStatefixtures, including Panproto transport and schema migration.
Changed¶
- panproto is now required at
>=0.74.1, which supplies index-aware case coverage and dependent case motives needed by the QIEC boundary. CommittedDataset.datais documented and tested as Panproto's validated canonical MessagePack encoding rather than the original input JSON text.
[0.13.1] - 2026-09-09¶
Changed¶
- panproto is now required at
>=0.72.1, up from>=0.72.0, forpanproto.typecheck_theory.
Added¶
- Every Theory didactic emits is typechecked in CI.
panproto.create_theorydeserialises a spec and returns, so it accepts a theory whose declarations do not hang together;typecheck_theoryis the check that does not.test_theory_typechecks.pyruns it over one model per translation path: scalars, containers, optionals,RefandEmbededges, bare Model fields, enums, tagged unions in every spelling, a variant-free union root, and a union variant. This is the gate the 0.13.0 defect needed and did not have. That defect shipped because nothing in didactic could reach the checker before panproto 0.72.1, and the fix was verified against a local panproto build rather than in CI.
Two tests keep the gate honest. One is a negative control: a theory
closed against a constructor that does not exist must be rejected, so a
checker that returned unconditionally cannot leave the file green. The
other rebuilds the pre-0.13.0 shape from a real spec, putting the
Closed closure back on a sum sort whose accessor outputs it, and
asserts it is rejected for the reason the original was.
[0.13.0] - 2026-09-08¶
Fixed¶
- A Theory carrying a union-typed field passes panproto's theory
typechecker. The sum sort a
dx.TaggedUnionroot or a Model-ref recursive alias contributes was declaredClosedagainst its constructors.Closedsays those constructors are the only ways to build a value of the sort, and panproto'stypecheck_theoryenforces it by rejecting any operation outside the list whose output is that sort. A union-typed field emits exactly such an operation:parseris an operation fromRunSpectoParserSpec, so it builds aParserSpectoo, and a match over the sort would not be exhaustive. Every Theory with a union-typed field therefore failed the check, which went unnoticed becausecreate_theorydoes not typecheck and the checker was not reachable from Python before panproto 0.72.1. The sum sort is nowOpenand its arms travel in aconstructorskey, which is the list didactic itself consumes. The alternative, routing the field through an opaque value sort to keepClosed, would have cost the structural edge from the owner to the union that 0.12.0 established, in exchange for an exhaustiveness guarantee nothing in didactic reads.
Changed¶
didactic.synthesisreads a sum sort's arms from the newconstructorskey, falling back to aClosedclosure so a spec persisted by an earlier release still synthesises, and then to the ops table so a sum sort still resolves after apanproto.Theoryround trip drops both didactic-private keys.- The spec dict from
build_theory_specchanges shape for every model carrying adx.TaggedUnionor Model-ref-alias field, so those models' structural fingerprints change. Models without one are unaffected. Regenerate schema URIs and migration-registry entries for the affected models rather than hand-editing them.
[0.12.0] - 2026-09-07¶
Fixed¶
- An optional field points at the sort it targets, rather than at a
sort nothing declares.
TypeTranslation.sortis a didactic-side descriptor, not a bare sort name: the optional wrapper spells itMaybe (Ref Target). The three edge branches ofbuild_theory_specread it as a name, sop: Node | Noneemitted an operation whose output was the stringMaybe Node, andp: Ref[Target] | Noneone whose output wasMaybe (Ref Target), theRefprefix now being unreachable to theremoveprefixthat was meant to strip it. No theory declares those sorts, and none can: a panprotoSortExprapplies a sort to dependent terms, so there is noMaybeformer to apply to a sort. An edge now names the same sort its required counterpart does, and optionality is recorded as anoptionalkey on the operation. - A sort contributed by an element type survives the container that
wraps it. The optional, tuple, frozenset and dict wrappers each
built a fresh
TypeTranslationand dropped whatever auxiliary sorts and ops the inner one carried, so aTaggedUnionbehindtuple[T, ...]ordict[str, V]left its sum sort undeclared, and an optional union field named a target the Theory did not declare. The wrappers now carry that contribution through, which is what makes the edge above resolve. A container field's own sort is unchanged: its encoded form is a JSON string, so it keeps itsVal[Str]constraint sort.
Changed¶
- The spec dict from
build_theory_specchanges shape for two families of field, so the structural fingerprint of a model carrying one changes with it: an optionalRef[T],Embed[T]orTaggedUnionfield, and a container over aTaggedUnion. Every other model fingerprints exactly as it did in 0.11.0, verified by comparing fingerprints across the two versions. A model in the affected families takes a new schema URI and a new migration-registry entry; regenerate those rather than hand-editing, and note thatdocs/project/stability.mdlists the spec dict as stable across minors, which this release breaks under the pre-1.0 clause. The old shape named sorts that no theory declared, so there is no version of it worth preserving behind a flag.
[0.11.0] - 2026-09-07¶
Fixed¶
- A
TaggedUnionroot with no variants registered yet is a usable field type. Classification consultedcls.__variants__and rejected an empty registry, so whetherparser: ParserSpecwas legal depended on whether anything had imported a variant before the enclosing class body ran. The failure surfaced at import time as aTypeNotSupportedError, and it hitParserSpec | None,tuple[ParserSpec, ...]anddict[str, ParserSpec]alike. Nothing else in the machinery needed the registry to be populated: the encode and decode paths already read it live, which is what makes mutually recursive variants work in any declaration order. The layered case is the one this blocked, where the root belongs to a low layer and its variants to higher layers the low layer must not import. (#64) - The union's sum sort in the parent Theory names the variants
registered when the Theory is built, not the ones that happened to
exist when the field was classified. A
TypeTranslationmay now carry anauxiliary_specprovider thatbuild_theory_speccalls, and bothTaggedUnionpaths (single root, andA | Bover two roots) supply one. Read the pair through the newTypeTranslation.resolve_auxiliaryrather than theauxiliary_sorts/auxiliary_opsattributes. A root with nothing registered contributes a closed sum over no constructors;__theory__still caches on first read, so read it after the variants are imported. (#64) - A malformed tagged-union payload reaches the caller as a
ValidationError. The decoders built a message naming the missing or unmatched discriminator and then discarded it, raising a bareKeyErrorcarrying only the tag.KeyErroris neitherTypeErrornorValueError, so it also escaped the wrapper that turns a decoder refusal into a validation entry, andmodel_validate_jsonpropagated it unchanged. The same defect sat on the recursive-alias decoder's unknown-constructor branch. All of them now raiseValueErrorwith the message they had already built, andmodel_validate_jsonreports it against the offending field. Callers matching onKeyErrorshould match ondidactic.ValidationError(orValueErrorat the translation layer). (#64) - Encoding a dict payload whose discriminator matches no variant names
the tag it missed, rather than reporting that a value of type
dictis not a registered variant. (#64)
[0.10.1] - 2026-08-26¶
Changed¶
- panproto is now required at
>=0.72.0, up from>=0.71.0. The 0.72.0 release carries twenty-six breaking API changes across the engine, none of which touch the surface didactic uses: the full suite passes and the type checker reports no errors against it. The floor moves anyway, because the previous open lower bound resolved installations to a version this project's own CI had never exercised.
Documentation¶
- Recorded that
mapandfilterare supported in axioms only in prefix form, as inmap f xs. The pipeline formxs & map fparses to a partial application,App(Builtin('Map', [f]), xs), which the axiom evaluator rejects withNotImplementedError. Pretty-printing renders the two forms identically, so the difference is easy to miss; a test now pins it.
[0.10.0] - 2026-08-20¶
Changed¶
- panproto is now required at
>=0.71.0, up from>=0.56.0. Its morphism search became exact optimisation over a cost function network in 0.71.0, and two of the changes reach didactic's own API.
Removed¶
relax_edge_name_pruningonfind_correspondencesandbest_correspondence. panproto deleted the edge-name domain pruner the flag relaxed, on the grounds that it was a soft heuristic used as a hard filter and that edge-name agreement already enters the search objective. With nothing left to relax the flag had no meaning, and passing it raisedTypeErrorfrom panproto. Drop the argument; there is no replacement and none is needed. Edge-name agreement still carries 0.55 of the default objective weight, split across the edge and prop components, so a candidate sharing no outgoing edge name with the source still scores badly. It is now outscored rather than deleted from the search domain. Removing a filter can only enlarge the feasible set, so the best correspondence a search returns is the same or better than before, and any newly reachable ones sort below it. A longer result list is the only visible difference.
Fixed¶
mapandfilterin axiom expressions evaluate their arguments in the right order. panproto's AST puts the collection first and the function last, whichever order the surface syntax used, somap f xsandxs & map fboth parse toBuiltin('Map', [xs, f]). The evaluator read them the other way round and raisedNotImplementedError: bare lambda outside an applicationon every lambda it met.-
check_lens_laws's bad-lens test catches the bad lens every run. The fixture truncates a field at five characters, and the strategy it drew from could return ten examples all shorter than that, in which case the broken lens round-tripped and the test passed while catching nothing. The strategy now guarantees a value long enough to violate the law. This one predates the panproto bump. -
max_resultsonfind_correspondencesis documented as what it is:0asks for every correspondence the search enumerates, and neither0nor any explicit figure is unbounded, since panproto caps every request at 1024.
[0.9.1] - 2026-07-18¶
Fixed¶
- Constructing a nested recursive
TaggedUnionvalue is now linear in the number of nodes rather than exponential in nesting depth. A sum-sort field keeps its variant's fully expanded wire JSON in storage, somodel_dumpreads and parses that directly instead of decoding it back to aModeland re-encoding it (a round trip that re-walked the whole subtree at every enclosing level). The emitted wire form is unchanged. (#58) derived_field_namesis a pure function of the class, so it is materialised once at class-creation time as__derived_field_names__on the metaclass and read from there, rather than recomputed on everyModel.__init__and everymodel_dump. (#59)
[0.9.0] - 2026-06-17¶
Added¶
CommittedDatasetcarries akey: the identifier recorded for the dataset, surfaced bydata_at.Repository.add_datatakes an optionalkeyso a downstream that versions one record per dataset can tag each with its own identifier (for example an AT-URI) and map a committed dataset back to it; when omitted the key defaults to the source path. This completes the committed-data round trip and lets a downstream build a per-record, revision-to-revision diff fromdata_atalone. (#54)
Changed¶
- Minimum
panprotoversion raised from0.54.0to0.56.0, which records the per-dataset key behindadd_data/data_atand lets a commit carry staged data forward without a schema change (a data-only commit no longer raisesnothing stagedwhilehas_stagedreports data staged).
[0.8.0] - 2026-06-17¶
Added¶
Repository.add_data(path)stages a data file for the next commit, the write-side counterpart todata_at. It binds the file to the staged schema, or to HEAD's schema when none is staged, and the committed bytes are then readable at that revision throughdata_at. A downstream that versions record values can stage and read them entirely through the documented surface instead of reaching the inner panproto handle. (#54)
[0.7.8] - 2026-06-17¶
Added¶
Repository.data_at(ref)reads the datasets committed at a revision without touching HEAD or the working tree, returning oneCommittedDataset(schema_id/data/record_count) per committed dataset. This is the data-side counterpart to panproto's committed-schema lookup, so a downstream can reconstruct the record set at an arbitrary revision (for example to diff two revisions) without checking it out.CommittedDatasetis exported fromdidactic.api. (#50)
Changed¶
- Minimum
panprotoversion raised from0.53.0to0.54.0, which adds thedata_atcommitted-data accessor behind the newRepository.data_atand corrects thecreate_annotated_tagbinding stub (return type andmessage/authorargument order). Repository.create_annotated_tagreturns the created annotated-tag object id (the id the tag ref resolves to). It previously returnedNoneto sidestep the panproto stub, now fixed upstream.
Fixed¶
- The runtime
__version__constant in each distribution (didactic.api,didactic.pydantic,didactic.settings,didactic.fastapi) is bumped in lockstep with the packaging version; the four had drifted to0.7.6. Atest_versionguard now fails if a runtime constant and its packaged version diverge.
[0.7.7] - 2026-06-16¶
Added¶
Repositoryexposes tag management:create_tag(name, target, *, force=False)for a lightweight tag,create_annotated_tag(name, target, *, message, tagger)for an annotated-tag object, anddelete_tag(name). The three are thin pass-throughs to the underlying panproto handle, mirroring howcommit,create_branch, andlist_tagsare already wrapped, so a downstream that tags a revision through the documented surface no longer has to reach the private inner handle. (#49)
[0.7.6] - 2026-06-16¶
Changed¶
- Minimum
panprotoversion raised from0.52.1to0.53.0. The release adds a Python entry point for lexicon parsing (parse_atproto_lexicon/parse_schema_document/Schema.from_atproto_lexicon) and the Schema-to-Theory bridge (theory_of/Schema.theory), and bumps its build to pyo3 0.29 for twoRUSTSECadvisories. The GAT theory vocabulary (ValueKind/Operation/Sort) is unchanged, so didactic's forward and inbound theory paths, including closed-sum-sort synthesis, are unaffected.
[0.7.5] - 2026-06-16¶
Added¶
- The inbound synthesiser (
didactic.synthesis:model_from_spec,models_from_specs,model_from_theory) reconstructs closed sum sorts. Adx.TaggedUnionfield rebuilds into a union root with one variant subclass per constructor (keyed by the discriminator value recovered from the constructor name), and a Model-ref recursive alias rebuilds into an equivalenttypealias. A model carrying either shape now round-trips through the synthesiser at the Theory-spec level. Variant and arm Model payloads resolve through the sharedregistry(so an edge elsewhere binds the same class), andmodels_from_specsorders sum-arm dependencies ahead of their dependents so a forward-referenced arm resolves to the real class rather than a fieldless stub. (#45)
Notes¶
- Three lossiness limitations of the synthesiser (scalar value kinds
collapsing to
str,Refindistinguishable fromEmbed, and per-field defaults / metadata being absent) are inherent to the GAT theory vocabulary rather than the synthesiser: a panprotoOperationcarries no containment marker or metadata slot, andValueKindhas no temporal / decimal / uuid variant. These are tracked upstream for a representation didactic can adopt without smuggling private data through ignored spec keys.
[0.7.4] - 2026-06-10¶
Changed¶
- Minimum
panprotoversion raised from0.52.0to0.52.1. The release resyncs the_native.pyistubs to the runtime (diff_and_classifytakes a thirdprotocolargument;ProtolensChain.instantiatetakes(schema, protocol);Instance.root/node_count/arc_countareintproperties andInstance.validate()returns the error list). It also tightens by-construction source emit for Rust and Julia (line comments no longer absorb the following items, opaque token trees emit verbatim, Julia parenthesised macro calls keep their arguments), which flows throughdidactic.codegen.source.emit_prettyandModel.emit_as.
Removed¶
- The two boundary casts that worked around the pre-0.52.1 stub drift
are gone:
classify_changecallspanproto.diff_and_classifywith its three runtime arguments directly, andDependentLens.instantiatepasses theProtocolthrough without re-casting it toSchema. Behaviour is unchanged; the call sites now type-check against the corrected stubs.
[0.7.3] - 2026-06-07¶
Added¶
find_correspondences,best_correspondence, and theCorrespondencerecord wrap panproto's hom search (find_morphisms/find_best_morphism). Given two panproto Schemas, the search enumerates structure-preserving vertex maps, scored by alignment quality, withanchorsto pin known pairings andmonic/epic/isoto constrain the map's shape. A discoveredCorrespondence.vertex_maphas exactly thedict[str, str]shape thatDependentLens.auto_generate_with_hintstakes ashints, so the two compose into a discover-then-derive pipeline (documented in the lenses guide). The search is informative on multi-vertex schemas (hand-built protocol schemas, parse-recovered source schemas); the single-vertex schemas didactic builds from Model classes degenerate to the root pairing.
Changed¶
-
Minimum
panprotoversion raised from0.48.3to0.52.0. The bump pulls in, with direct effect on didactic's surface: -
The hom-search bindings (
find_morphisms,find_best_morphism,TheoryMorphism,SchemaMorphism,FoundMorphism) that back the new correspondence API. - The
emit_prettyrewrite (grammar-derived token roles, role-pair spacing, structural bracket detection) and the emit-coverage sweep that corpus-verifies the emit fixed-point law (emit(parse(emit(s))) == emit(s)), covering every grammar thepanprotowheel ships. This is the engine behinddidactic.codegen.source.emit_prettyandModel.emit_as. get_builtin_protocolresolving tree-sitter grammar protocols (get_builtin_protocol("python")succeeds instead of raisingKeyError).IdGeneratordisambiguation of repeated names at the same scope, which unblocks parsing any Python source that uses@typing.overloadthroughdidactic.codegen.source.parse.- Resynced
_native.pyistubs. didactic's typing follows:DependentLens.auto_generate_with_hintsdeclareshints: dict[str, str](wasobject), and theTheorySpechandoff topanproto.create_theorynarrows through a documented boundary cast (aTypedDictis assignable toMapping[str, object]and to no narrower mapping, while the resynced stub takesMapping[str, JsonValue]).
Fixed¶
__version__strings match the released distribution version across all four packages. The coredidactic.api.__version__and the three sibling__init__modules lagged behind theirpyproject.tomlversions, sodidactic versionunder-reported.
[0.7.2] - 2026-05-19¶
Changed¶
- Minimum
panprotoversion raised from0.43.1to0.48.3. The bump pulls in thepanproto-parseemit_prettyfixes that affectdidactic.codegen.source.emit_prettydirectly: every iteration ofFIELD(REPEAT(...))andFIELD(SEQ(SYMBOL, REPEAT(SEQ(',', SYMBOL))))is now rendered (the prior wheel dropped all but the first), abstract-schema edge order is preserved throughpretty_with_protocol(children of the same parent no longer re-fuse by kind), and indent-based grammars open and close indent scopes on_indent/_dedentexternal tokens. A regression test intests/test_codegen.pycovers the comma-separated argument case end-to-end.
Added¶
DependentLens.from_dsl_json,DependentLens.from_dsl_yaml,DependentLens.from_dsl_nickel, andDependentLens.from_dsl_pathcompile apanproto-lens-dsldocument into a chain. Each loader takes the document source (or a path;from_dsl_pathdispatches on extension) plus the entry vertex of the source schema the chain is being authored against.
[0.7.1] - 2026-05-07¶
Fixed¶
model_validate_jsonno longer crashes for Models that carry an opaque field. Thenullplaceholdermodel_dump_jsonwrites is dropped during JSON-payload conversion (the opaque translation'sfrom_jsonwould otherwise raise, by design); the field falls back to its declared default. A required opaque field with no default surfaces a cleanmissing_requiredValidationErroron round-trip instead of a bareTypeError.docs/guide/fields.mdupdated to spell out this behaviour precisely.
[0.7.0] - 2026-05-07¶
Added¶
tuple[Model, ...](anddict[str, Model], plaininner: Model) now classifies anydx.Modelsubclass used directly as a field type. The classification routes through the same Embed-shaped translation thatEmbed[T]would have built, so the wire format and runtime semantics are identical and callers no longer need a single-variantTaggedUnionwrapper to collect heterogeneous record tuples. Models that areTaggedUnionroots or variants stay on the discriminated path. (#38)dx.field(opaque=True)declares a field that holds any Python value by reference and skips the type-classification pipeline entirely. The runtime stores the value on a per-instance_opaque_storageside table; attribute access returns it identity-equal;with_(...)updates it without re-classification. JSON output writesnullfor opaque fields, andmodel_validate_jsondoes not reconstruct the value: opaque fields explicitly do not round-trip through serialisation. Useful for fields that carry a runtime-only handle (a typeclass instance, a callback, a foreign object) where panproto schema integration is not the goal. (#39)
Changed¶
- The documentation site uses the cinder theme. The previous
Material configuration is replaced by
theme: name: cinderplus a smalldocs/css/palette covering pygments token colours and mkdocstrings layout. CI no longer needs theNO_MKDOCS_2_WARNINGworkaround; the same env var has been removed fromci.yml/docs.yml/release.ymland from the README's local-build snippet.
[0.6.2] - 2026-05-06¶
Fixed¶
FieldValue's recursive mapping arm usesMapping[str, FieldValue](covariant in its value type) instead of the invariantdict[str, FieldValue].dictis invariant inV, so any concretedict[str, X](whereXis a structural subset ofFieldValue) was rejected by type checkers at everyModel.with_(field=value)call site, forcing callers to insertcast("dict[str, FieldValue]", ...)boilerplate. The runtime contract is unchanged: everydictis also aMapping, and the encoder pipeline'sisinstance(v, dict)checks keep matching realdictpayloads. (#36)
[0.6.1] - 2026-05-06¶
Fixed¶
ModelMeta's@dataclass_transformnow declareskw_only_default=True(it previously declaredkw_only_default=False). Without this, type checkers in strict mode rejected every subclass that added a non-default field after a parent's default-bearing field with"Fields without default values cannot appear after fields with default values"(reportGeneralTypeIssues). The runtimeModel.__init__already accepted only keyword arguments, so the flag flip just aligns the static contract with the runtime behaviour. The natural shape (Basecarries auto-id / timestamps with defaults;Subclassadds domain fields) now passes pyright's strict-mode analysis without per-line suppressions. (#34)
[0.6.0] - 2026-05-06¶
Added¶
- Field annotations of the form
A | Bwhere bothAandBareTaggedUnionroots are now classified as a multi-root discriminated union. The two arms must share the same discriminator field name (checked at class-creation time) and their discriminator-value sets must be disjoint (checked lazily, only on encode/decode of an actually colliding value, so a model whose union-typed field is never encoded with a colliding variant remains usable). Encode and decode both consult the live merged variant registry, so variants registered after the field's parent class is classified participate fully. (#30) @dx.model_validator(mode="after")decorates a class method as a class-level validator that receives the constructed instance and runs after every per-field validator and every__axioms__check.raise ValueError/raise TypeErrorfrom the body surfaces as aValidationErrorentry withtype="validator_error"and emptyloc. Multiple model validators on one class collect into a single error. Subclass inheritance and the silent-shadow override-without-marker policy work the same as for@validates. Use this for cross-field invariants that aren't expressible in the__axioms__surface syntax. The shape mirrors Pydantic v2's@model_validator(mode="after"). (#31)
Fixed¶
- The axiom evaluator now resolves
length xs(panproto's long-form length builtin) the same aslen xs. Both compile to Pythonlen(...). (#31) isNone/isNull/isSome/isJustbuiltins resolve to the obviousvalue is None/value is not Nonepredicates in axiom expressions. The Python-friendlyis null/is not nullpreprocessor rules from v0.5.1 already covered the most common spelling; these add the explicit-call form for axioms that prefer it. (#31)
Changed¶
docs/guide/unions.mddocuments the union-of-TaggedUnion-roots shape with the disjoint-values requirement spelled out.docs/guide/validators.mddocuments@dx.model_validatorand the cross-field-invariants decision tree (axiom for surface-syntax expressible, model_validator for Python-needed).
[0.5.2] - 2026-05-05¶
Fixed¶
Embed[Root]whereRootis aTaggedUnionno longer downcasts variant instances to the root class. The Embed encoder always wrote the variant's full storage dict (including the variant-specific fields), but the decoder reconstructed the value viaRoot.from_storage_dict-- the root has no field specs of its own, so the variant fields became unreachable on the recovered instance. The Embed translation now inspects the stored discriminator value at decode time and dispatches to the matching variant inRoot.__variants__, mirroring the live-registry fix used elsewhere in the TaggedUnion path.tuple[Embed[Root], ...],dict[str, Embed[Root]], and bareEmbed[Root]fields all preserve the variant subclass identity through construction, storage round-trip, and JSON round-trip. PlainEmbed[T](no TaggedUnion) keeps the legacy single-class path unchanged. (#27)
[0.5.1] - 2026-05-05¶
Fixed¶
__axioms__can now reference Optional fields. The previous evaluator only handled bare comparison and arithmetic;a == nullparsed but failed at evaluation, anda != nullfailed even at parse time. The expression-language pipeline now does two things: a Python-friendly preprocessor rewrites!=->/=,and/or->&&/||,null/None->Nothing, andX is null/X is not null-> the correspondingNothingcomparison; the evaluator handles the full panproto Expr surface (Just/Nothing,App-style builtins includingmin/max/abs/elem/len,if/then/else(Match),letbindings, lambdas inmap/filter, list literals[1, 2, 3], field accessa.b, and the++(Concat) operator). The preprocessor respects string literals: substitutions never fire inside"..."or'...'. (#26)
Changed¶
docs/guide/axioms.mddocuments the full surface (operators, Python-friendly synonyms, an Optional-field worked example) and narrows the "what axioms cannot do" section to the truly missing constructs (forall/exists, multi-armcase, graph-traversal builtins).
[0.5.0] - 2026-05-05¶
Added¶
pathlib.Path(and anyPurePathsubclass:PurePosixPath,PureWindowsPath, etc.) is a first-class scalar field type. Wire format isstr(path); decoding restores the samePurePathsubclass. (#21)enum.StrEnumandenum.IntEnumare first-class scalar field types. String-valued and int-valued plainenum.Enumsubclasses also work; mixed-value enums raiseTypeNotSupportedError. The encoder accepts either an enum member or its raw value, soM.model_validate({"color": "red"})works the same asM(color=Color.RED). (#23)
Fixed¶
- TaggedUnion-typed fields now JSON-round-trip correctly when the
variant is itself a TaggedUnion.
model_validate_jsonwalks each nested{"kind": "...", ...}payload through the discriminator registry, instead of handing the dict straight to the variant-encoder (which expected a fully-constructed instance). Direct construction with a dict child works too:BinOp(left={"kind": "lit", "value": 1}, ...)dispatches the same way. (#22) - TaggedUnion-typed fields consult
cls.__variants__live at encode and decode time, not snapshotted at field-classify time. Variants registered after a parent variant's field was classified (the canonical case is mutually recursive AST shapes:BinaryOp->ListLiteralandListLiteral->BinaryOp) participate fully, in either definition order. (#24)
Changed¶
docs/guide/types.mddocuments the Path family and the StrEnum / IntEnum / value-typed Enum branches alongside a workedStrEnumexample.docs/guide/unions.mddocuments recursive and mutually recursive variants, dict-dispatch on construction, and the JSON round-trip contract.
[0.4.3] - 2026-05-05¶
Fixed¶
dx.TaggedUnionvariant discriminator now accepts every spelling ofLiteral[...]: bareLiteral["x"], qualifiedtyping.Literal["x"], and aliased imports (from typing import Literal as L). Underfrom __future__ import annotationsthe discriminator annotation arrives as a string; the variant check now evaluates that string in the class's defining module before applying theget_origin(...) is Literalcheck, instead of pattern-matching on the source text. Useful when the variant Model has a class also namedLiteral(e.g. an AST module exportingLiteral,Variable,BinaryOpfrom a discriminator-taggedASTNodeunion root) so the user can keep the public API name. (#18)
[0.4.2] - 2026-05-05¶
Fixed¶
@dx.validatesis no longer a silent no-op. The metaclass now walkstarget.__mro__for methods carrying the__didactic_validator__marker and stores them on the class as__field_validators__;Model.__init__andModel.with_(...)invoke them in the right order:dx.field(converter=...)first, thenmode="before"validators on the raw value, then the encoder, thenmode="after"validators on the canonical decoded value (re-encoded if the validator returned a different value). Validators mayraise ValueError/raise TypeErrorto reject the input; failures surface asValidationErrorentries withtype="validator_error"andloc=(field_name,). Instance,@classmethod, and@staticmethodshapes all work. Subclasses inherit a parent's validators; a subclass override that re-applies@validatesreplaces the inherited method, and a subclass that shadows the method without@validatesdeliberately disables validation for that field. (#17)
Changed¶
docs/guide/validators.mdwas rewritten to document theraise ValueError/ return-value-replaces-stored-value contract (the previous draft described areturn boolshape that the runtime never implemented), and to covermode="before", multi-field validators, multiple-validator chaining, inheritance semantics, and the three method shapes.
[0.4.1] - 2026-05-05¶
Fixed¶
tuple[T, ...]-typed fields now coerce list input to tuple at the encoder boundary instead of raising a bareAssertionError. Mirrors Pydantic's affordance so call sites migrating across don't have to rewrite everyindices=[0, 1, 2]literal. Non-iterable input still fails, but as adx.ValidationErrorcarrying the field name and atype_errorentry, not as anAssertionErrorfrom inside the encoder. (#15)frozenset[T]-typed fields coerce list, set, and tuple input the same way. Bare strings are still rejected (they would otherwise silently explode intofrozenset({"a", "b", "c"})).
[0.4.0] - 2026-05-05¶
Added¶
ModelConfig.extra="ignore"is honoured: keyword arguments at construction (and dict keys atmodel_validate) that don't match a declared field are silently dropped.with_()stays strict regardless; an unknown kwarg there is always a programming error. (#11)- Generic Models auto-parameterise on subscript. Both PEP 695 syntax
(
class Range[T: int | float](dx.Model): ...) and the legacyGeneric[T]mixin form work.Range[int](min=0, max=10)returns an instance of a synthesised concrete subclass; the subclass is cached per type-arg tuple on the generic parent so repeated subscripts return the same class object and itsTheoryis built once. Substitution walks through nested generic shapes:tuple[T, ...],dict[str, T],T | None,Annotated[T, *meta],Embed[T],Ref[T], and unions of these are all rewritten correctly. Class-level defaults (min: T = 0) anddx.field(...)metadata (default,default_factory,description,alias,examples,deprecated,nominal,usage_mode,extras,converter) propagate from the generic parent onto the synthesised subclass. (#12) read_class_annotationsis part of the public surface (lifted from the underscore-prefixed_read_class_annotations) and the metaclass's annotation-reader return type isdict[str, type | TypeVar | ForwardRef]to reflect what the PEP 695 generic-parameter path produces.
Fixed¶
- Inherited field defaults survive on subclass.
Child(Base)whereBasedeclaresid: str = "default-id"constructs cleanly with the inherited default.ModelMeta.collect_field_specswalks ancestor classes by copying their already-finalisedFieldSpec; it only re-runs_build_field_specfor the target class's own annotations. (Reading__dict__for ancestor classes lost their defaults because the metaclass strips field defaults from the class dict at the end of each Model's class-creation step.) (#13) - The deferred-TypeVar branch in
_build_field_speccarries through everyFieldattribute (default, default_factory, converter, alias, description, examples, deprecated, nominal, usage_mode, extras), so a generic withvalue: T = dx.field(default=42, description="...")keeps that metadata available for parameterisation.
Removed¶
- The leftover
# Tracked in panproto/didactic#1.comment blocks from the v0.3.2 suppression unwind are stripped from every file in the workspace. They documented suppressions that no longer exist.
[0.3.2] - 2026-05-04¶
Changed¶
- panproto pin bumped to
>=0.43.1. panproto 0.43.1 ships corrected_native.pyistubs forcreate_theory(Mapping[str, object]) andcolimit_theories((t1, t2, shared)), so didactic now calls these directly again. Tracking issue panproto/panproto#72 closed upstream.
Fixed¶
- All strategic per-file pyright suppressions tracked under (#1)
are now removed and the underlying issues are fixed structurally.
uv run pyrightreports 0 errors with no per-file# pyright: report*=falsedirectives outside the documentedCONTRIBUTING.mdcarve-out (thefield()overload pattern, which pyright in strict mode cannot reconcile with the ergonomics that drive the carve-out's existence). - The fixes touch every package: typed
castboundaries where panproto returns wider types than didactic's narrower public surface,isinstancenarrowing onmodel_dumpresults in tests,Model.model_validate({...})swaps for negative tests passing wrong-typed kwargs, public re-exports of underscore-prefixed names that tests already reach for, and a handful of small refactors (a typed kwargs dict in_resolve_config, a structured cast at the metaclass annotation boundary,__provenance__gated behindTYPE_CHECKINGso the metaclass does not register it as a Model field). TypeFormwidened to includeTypeAliasTypeandGenericAlias. The static type now matches whatclassify/unwrap_annotatedaccept at runtime.FieldSpec.annotationwidened toTypeForm | TypeVar | ForwardRef. The metaclass walks generic-parameter and forward-string annotations as a real path; the type now reflects it.Repository.resolve_refraisespanproto.VcsErrorwhen the underlying call returnsNoneinstead of silently violating its-> strreturn type.- Removed an unused
_class_axiom_eqhelper fromtheory/_theory.py. The helper was a stub for a future eqs-emission path; it lives in the git history and will return when the panproto-Expr parser hookup lands. Lens[A, B]is nowLens[A, B, C]incheck_lens_lawsso the complement type carries through todx.testing.check_lens_laws; tests parameterise asdx.Lens[User, User, str].- Settings package: replaced
# type: ignoredirectives by routing yaml throughimportlib.import_module, adding a realfetchmethod to the_Sourcebase, casting at theOpaque -> JsonValueloader boundary, splitting theargparse.NamespacevsMappingbranches, and gating__provenance__behindTYPE_CHECKINGso the metaclass does not register it as a Model field.
[0.3.1] - 2026-05-01¶
Fixed¶
- Sum-sort encoders (closed Model-ref recursive aliases and TaggedUnion
field types) now route the chosen variant through
model_dump_jsoninstead of baremodel_dump, so any nestedtuple[Embed[T], ...]/dict[str, Embed[T]]/ arbitrary Model-containing structure inside the variant gets the JSON-safe walk. Previously such payloads raisedObject of type X is not JSON serializablefromjson.dumps. (#7) Embed[Inner]round-trip viamodel_dump_json/model_validate_jsonno longer asserts whenInnerhas atuple[T, ...](or anyfrom_json-coerced) field. The embed translation now routes inner JSON payloads throughmodel_validate_jsonso per-fieldfrom_jsonruns at every level (e.g. JSON list to tuple coercion). (#8)model_dumpevaluatesinner_kind == "sum"before theisinstance(value, Model)branch, so a Model variant of a sum-sort field is dumped with its constructor tag instead of collapsing to the variant's record dict. Previously a recursive alias whose Model arm was the current value lost its dispatch info on the dump side; the JSON round-trip then raisedunknown constructoron decode.embed_schema_uriwalks nested Model-containing fields viamodel_dump_jsonso the returned dict is always serialisable; previously failed withTypeErrorwhen the source instance had atuple[Embed[T], ...]field.
Changed¶
- Recursive Model-ref alias encoders prefer the
tupleconstructor over thelistconstructor when both arms are declared, even for Pythonlistinput. This keeps the encoded storage form canonical: round-tripping a Python list and re-encoding produces the same constructor name and the same storage string, restoringModelequality across the round-trip.
[0.3.0] - 2026-05-01¶
Added¶
- Recursive type aliases whose arms include
dx.Modelsubclasses now translate to a panproto-native closed sum sort. The motivating shape is aComponentalias mixing primitives, Models, and JSON-compatible containers; the alias name becomes the panproto sort, with oneOperationper arm declared as a constructor and the sort'sSortClosureset toClosedagainst that constructor list. Wire format for an arm value is a single-key JSON object whose key is the constructor name (matches panproto's term-of-closed-sort encoding). Lists round-trip as tuples to satisfy the tuple-basedFieldValueinvariant. Cycles in the value graph raiseValueErrorrather than recurse. (#2) dx.TaggedUnionsubclasses are now usable directly as a field value type.dict[str, Parameter],tuple[Parameter, ...], and a bareparam: Parameterannotation all work, with dispatch via the variant's discriminator field. The translation contributes a closed sum sort and per-variant constructor ops to the parent Model's Theory; the on-wire format is the variant's naturalmodel_dump(no envelope, since the discriminator is already in the payload). (#5)TypeTranslationgains optionalauxiliary_sortsandauxiliary_opstuples that let a translation contribute extra panproto sort and operation declarations to the parent Model's Theory. Currently produced by the recursive Model-ref alias and TaggedUnion translations;build_theory_specwalks them and dedupes by name. Empty for every other translation.inner_kind = "sum"joins the documented set ofTypeTranslationinner-kind values.model_dumproutes sum-sort fields through their encoder so the constructor-tag dispatch survives JSON round-trip.
Notes¶
- Recursive aliases that aren't pure JSON-shape and aren't
Model-ref-shape (e.g. one admitting
bytes,Decimal, or a non-Model class) continue to raiseTypeNotSupportedErrorwith a clear message. - Panproto's
Theory.sortsandTheory.opsattributes are list-typed at runtime, contrary to the shipped_native.pyistub which marks them as methods. Tests that introspect a built Theory treat them as data.
[0.2.0] - 2026-05-01¶
Added¶
- Bare PEP 695 type aliases now translate transparently.
type Kind = Literal["a", "b", "c"]is accepted as a Model field annotation; the classifier unwraps the alias before dispatching. (#2) - Union of primitive scalars is a translatable field type.
int | str,float | str,int | float | str, and the same unions insidedict[str, V]andT | Noneare accepted. The synthesised panproto sort name is"Union <a> <b> ..."in canonical order; the encoder JSON-encodes the value, and the decoder dispatches on the resulting Python type. (#2, #3) - JSON-shaped recursive type aliases translate to a single opaque
panproto sort named after the alias. The motivating shape is the
canonical
JsonValuealias (str | int | float | bool | None | list[X] | tuple[X, ...] | dict[str, X]withXself-referential). The encoded form isjson.dumps(value); the decoder parses and recursively coerces lists to tuples to satisfy didactic's tuple-basedFieldValuetype. Recursive aliases that are not JSON-shaped (e.g. one admittingbytes) raiseTypeNotSupportedErrorwith a clear message rather than failing silently. (#2)
[0.1.0] - 2026-05-01¶
Added¶
dx.Modelanddx.BaseModelwith frozen, immutable instances backed by a panproto Theory built lazily on first__theory__access.dx.field(...)descriptor with default, default_factory, alias, description, examples, deprecated flag, nominal-id flag, custom converters (PEP 712), and pass-through extras.dx.ModelConfigfor class-level configuration.dx.Ref[T]non-owning cross-vertex references.dx.Embed[T]owned sub-vertex composition.dx.TaggedUniondiscriminated unions with subclass dispatch.dx.Lens[A, B],dx.Iso[A, B],dx.Mapping[A, B]lens classes with composition, identity, andinverse()forIsochains.@dx.computedderived attributes for serialisation.dx.axiom("...")class-level axioms collected oncls.__class_axioms__.@dx.validates(field_name)Python-side validators.- JSON / pickle serialisation with per-field re-coercion.
dx.register_migration(...)/dx.migrate(...): schema migrations as registered lenses keyed by structural fingerprint of the Theory spec, robust to class renames and re-imports.dx.save_registry(path)/dx.load_registry(path): human-readable registry dumps for diagnostic and audit purposes.dx.Repository.init(...)/Repository.open(...): filesystem-backed panproto repository wrapper.addaccepts either a panprotoSchemaor adx.Modelclass (synthesised viaProtocol.from_theories). Branches, refs, tags, and log all exposed.dx.DependentLens: schema-parametric lens family wrappingpanproto.ProtolensChain(auto-generation, JSON round-trip, composition, fusion, instantiation).dx.resolve_backrefs(target, candidates, *, via)plusdx.ModelPoolfor in-memory backref resolution.- Theory colimit on multiple inheritance:
class D(B, C)builds the panproto pushout over the lowest common Model ancestor. dx.testing.verify_iso(iso, strategy)for property-test law checks.didactic-pydantic.from_pydantic(...): structural conversion of a Pydantic v2BaseModelto adx.Modelsubclass.didactic-pydantic.to_pydantic(...): the inverse direction, for FastAPI / OpenAPI consumers.- mkdocs documentation site with narrative guides and a full API
reference, validated in CI under
--strictmode. - Runnable examples in
examples/.
Known issues¶
- A handful of files carry per-file pyright suppressions for noise that the strict-mode checker cannot resolve without a deeper refactor. Each suppression is inline-commented with the rule list and the reason. Tracked in issue #1; the suppressions will be replaced with the real fixes in a v0.1.x patch release.
Notes¶
- Targets Python 3.14 and panproto 0.40+.
- The structural fingerprint normalises a Model's display name in the spec before hashing, so two structurally identical Models share a registry entry.