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.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.