Serializer v8: declare IO specs as literal tuples instead of pickling them - #266
Merged
Conversation
Add serializer version 8, whose writer declares every BaseIOSpec and its parameters as literal tuples in a central text file (_data/iospecs.py) instead of pickling spec objects into _data/iospecs.pickle. Ref sites emit a compact (IOSpec, key) reference followed by a writer-generated readability comment carrying the spec parameters. The only pickle left in a saved model is _data/data.pickle, which keeps the v7 persistent-id shapes ((DataValue, key) / (BaseIOSpec, key)) resolved against the specs restored from the literal file. - Spec classes are resolved through a name registry, never by import path, decoupling the format from the module layout. - PandasData persists shape metadata (is_series, name, index_nlevels, columns_nlevels, sheet) and recomputes read_args on load the way _init_spec does; ExcelRange persists range/sheet/keyids; ModuleData persists nothing beyond its path. Parameters must be literal-representable (exact types), checked at write time. - The v4-era is_hidden flag is dropped: v8 files carry no such field and a formerly hidden spec reloads as an ordinary one. - The v8 reader loads the literal file per line: a malformed line or a spec whose IO file cannot be read degrades to a lost key (warning, ref restored as None, no orphan IOs), without aborting the load. - The v8 parser tolerates trailing comments on REFDEFS assignment lines, which get_statement_tokens keeps in the token list. - _get_serializer now fails with a clean ValueError on unknown (newer) serializer versions instead of ModuleNotFoundError. - Byte-identical no-op save -> load -> save round trips, including the ref-site comments; the determinism suite is parametrized to v8. - New serializer_compat fixture model_v8 and load gates for it; serializer 1-7 behavior is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Parity with the unreadable-iospecs.pickle behavior of versions 4-7: warn, lose the specs, and keep loading the model instead of aborting the whole read on an OS-level or archive read failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four defects found by review of the initial v8 implementation: - A model whose spec parameters cannot be represented as literals (or whose spec class is outside the registry) used to fail mid-write, after the previous save had been rotated to _BAK1 and a partial tree written that reloads with silent None refs. The writer now validates every spec up front (ModelWriter.validate_model, called by serialize.write_model before the backup rotation and before any output), so such saves fail cleanly with nothing touched. - numpy-scalar Series names (ordinary pandas data, e.g. a column selected from a DataFrame with numpy-typed labels) were rejected, making v8 unable to save models v7 saved. They are now coerced to their plain Python equivalents via .item(). - An out-of-registry spec class referenced by a ref site raised a bare KeyError from comment generation instead of the designed clean TypeError; the comment formatter now raises the same diagnostic error (and validate_model reports it before any write). - In the reader, a line whose key is not an int escaped the per-line tolerance and aborted the whole read, and a failing duplicate-key line clobbered an already-restored spec, leaking it past model close. Non-int and duplicate keys are now skipped per line with a warning, before the restore attempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Serializer 8 no longer hardcodes per-class knowledge: the _PARAM_ENCODERS / _STATE_DECODERS / _COMMENT_FIELDS tables move onto the spec classes as hooks declared on BaseIOSpec, so the serializer depends only on BaseIOSpec/BaseSharedIO surfaces and owns just the container format (grammar, emission order, ref-site shape, per-line tolerance). - BaseIOSpec declares the contract: format_version (a positive int), _on_serialize_args, _on_unserialize_args (classmethod, receives the saved version) and _on_comment_args; PandasData, ExcelRange and ModuleData implement it at format_version 1. The numpy-scalar name coercion moves into pandasio next to the code it serves. - The name -> class catalog moves to modelx/io/__init__.py (SPEC_CLASSES / get_spec_class); the writer requires the spec type to resolve to the identical class through the catalog, keeping the write and read sides symmetric. - Each iospecs.py entry now carries the class format version: (key, class, version, path, io_args, spec_args). A class bumps its version whenever the payload it emits (including the paired IO class's persistent_args) changes shape or meaning, so spec formats evolve without a new serializer version. On read, older versions go through the class's compat handling; a version newer than the running class fails that entry per line, before its IO is created, with a "written by a newer version of modelx" warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review of the hook refactor found the catalog identity check too strict: after importlib.reload(modelx.io.pandasio) (e.g. during interactive development), a live spec's class object differs from the one the catalog resolves, and the save was refused with a misleading "does not support IO spec type 'PandasData'" error. The gate now also accepts a registered class equal in module and qualname, while an out-of-catalog subclass or a foreign class shadowing a cataloged name is still refused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the motivation, the settled design decisions, and the resolutions of the open points (literal-file grammar, ref-site comment format, per-class parameter schemas) behind serializer version 8, following the devnotes precedent of DependentPackages.md and CoreRefactorDesign.md. Line references in the brief cite main at a79b7b2 and will drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fumitoh
force-pushed
the
serializer-v8-iospec-literals
branch
from
July 26, 2026 13:27
b4e1c0d to
d4345ec
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds serializer version 8, whose writer declares every
BaseIOSpecand its parameters as literal tuples in a central text file (_data/iospecs.py) instead of pickling spec objects into_data/iospecs.pickle. The only pickle left in a saved model is_data/data.pickle(user data values), which keeps the v7 persistent-id shapes (("DataValue", key)/("BaseIOSpec", key)) resolved against the specs restored from the literal file.Motivation:
iospecs.picklewas the one binary blob in an otherwise text-first, diffable format, and pickled spec metadata has repeatedly broken (pandas 3 removingpc.load_reduce, two class renames, load-bearing import paths). Spec metadata is a handful of literal-friendly fields — pickle was pure downside for it.The format
assign_id(spec)ints — the same ids thedata.picklepersistent-id stubs use, so spec identity is explicit and two refs sharing one spec trivially resolve to the same object.("IOSpec", key)(the v7value_idelement is dropped; values no longer enterdata.picklethrough the ref path) plus a regenerated readability comment:pdref = ("IOSpec", 2) # PandasData path='files/data.csv' file_type='csv' sheet='SheetA'modelx.io.SPEC_CLASSEScatalog, never by import path.Design
BaseIOSpec(format_version,_on_serialize_args,_on_unserialize_args,_on_comment_args). The serializer owns only the container format. Each entry carries the class'sformat_version, so spec payloads can evolve without a new serializer version: older versions go through the class's compat handling; newer versions fail that entry per line ("written by a newer version of modelx") before its IO is created.sheet,is_series,name,index_nlevels,columns_nlevels) derived from the live value and recomputesread_argson load the way_init_specdoes, instead of persisting the raw pandas-API dict. numpy-scalar Series names are coerced to plain Python scalars. ExcelRange persistsrange/sheet/keyids; ModuleData only its path.ModelWriter.validate_modelruns ahead of the backup rotation, so a non-literal parameter or an out-of-catalog spec class raises a cleanTypeErrorwith the previous save untouched.data.picklestubs degrade to None), deletes orphan IOs, and keeps loading. An unreadableiospecs.pyloses the specs but not the model, matching the v4-7 behavior foriospecs.pickle.is_hiddenflag is dropped: v8 files carry no such field and a formerly hidden spec reloads as an ordinary one._get_serializernow fails with a cleanValueErroron unknown (newer) serializer versions instead of aModuleNotFoundError.Compatibility
Interface.__reduce__serializer-version gate._on_serialize(modelx-cython's exporter) and_get_attrdict/_on_pickle(spyder-modelx) are untouched.Determinism
A no-op save → load → save round trip is byte-identical, including the ref-site comments, across processes with different hash seeds. The determinism suite is parametrized to v8 alongside 6/7.
Testing
Full suite: 1218 passed, 6 skipped. New coverage includes round trips for all three spec types, shared-spec identity (two refs; ref + cells input via the DataValue path), hidden-spec migration, v7→v8 resave, missing IO file (dir + zip), malformed/duplicate/future-version literal lines, unknown spec classes, missing/unreadable
iospecs.py, pre-write validation, ref-comment byte identity, a clean-error test for unknown future versions, and a frozenmodel_v8fixture in theserializer_compatpattern.The implementation went through four adversarial multi-agent review rounds; all confirmed findings were fixed (per-line tolerance escapes, duplicate-key clobbering, write-path validation ordering, numpy-scalar rejection, a too-strict class-identity write gate).
The design brief with the settled decisions is included as
devnotes/IOSpecLiteralTask.md, following theDependentPackages.mdprecedent.🤖 Generated with Claude Code