Skip to content

Serializer v8: declare IO specs as literal tuples instead of pickling them - #266

Merged
fumitoh merged 6 commits into
mainfrom
serializer-v8-iospec-literals
Jul 26, 2026
Merged

Serializer v8: declare IO specs as literal tuples instead of pickling them#266
fumitoh merged 6 commits into
mainfrom
serializer-v8-iospec-literals

Conversation

@fumitoh

@fumitoh fumitoh commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Summary

Adds 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. 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.pickle was the one binary blob in an otherwise text-first, diffable format, and pickled spec metadata has repeatedly broken (pandas 3 removing pc.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

# modelx: iospecs
# (key, class, version, path, io_args, spec_args)
(1, 'PandasData', 1, 'files/data.csv', {'file_type': 'csv'}, {'sheet': None, 'is_series': False, 'name': None, 'index_nlevels': 1, 'columns_nlevels': 1})
  • Keys are the writer-assigned assign_id(spec) ints — the same ids the data.pickle persistent-id stubs use, so spec identity is explicit and two refs sharing one spec trivially resolve to the same object.
  • Ref sites emit ("IOSpec", key) (the v7 value_id element is dropped; values no longer enter data.pickle through the ref path) plus a regenerated readability comment:
    pdref = ("IOSpec", 2) # PandasData path='files/data.csv' file_type='csv' sheet='SheetA'
  • Class names resolve through the modelx.io.SPEC_CLASSES catalog, never by import path.

Design

  • Spec classes own their literal format through hooks declared on BaseIOSpec (format_version, _on_serialize_args, _on_unserialize_args, _on_comment_args). The serializer owns only the container format. Each entry carries the class's format_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.
  • PandasData persists shape metadata (sheet, is_series, name, index_nlevels, columns_nlevels) derived from the live value and recomputes read_args on load the way _init_spec does, instead of persisting the raw pandas-API dict. numpy-scalar Series names are coerced to plain Python scalars. ExcelRange persists range/sheet/keyids; ModuleData only its path.
  • Unwritable models fail before any output: ModelWriter.validate_model runs ahead of the backup rotation, so a non-literal parameter or an out-of-catalog spec class raises a clean TypeError with the previous save untouched.
  • Per-line tolerance with distinct failure classes (unparsable line / invalid-or-duplicate key / future format version / restore failure): the reader warns, records the key as lost (ref sites and data.pickle stubs degrade to None), deletes orphan IOs, and keeps loading. An unreadable iospecs.py loses the specs but not the model, matching the v4-7 behavior for iospecs.pickle.
  • 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 parser tolerates trailing comments on REFDEFS assignment lines (the shared tokenizer keeps them in the statement token list).
  • _get_serializer now fails with a clean ValueError on unknown (newer) serializer versions instead of a ModuleNotFoundError.

Compatibility

  • Serializers 1-7 are untouched; all existing fixtures (v4-v7) keep loading. The only core change is adding 8 to the Interface.__reduce__ serializer-version gate.
  • Journal/rollback error-path behavior carries over unchanged (verified by the parametrized error-cleanup suites).
  • Dependent packages: _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 frozen model_v8 fixture in the serializer_compat pattern.

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 the DependentPackages.md precedent.

🤖 Generated with Claude Code

fumitoh and others added 6 commits July 25, 2026 19:17
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
fumitoh force-pushed the serializer-v8-iospec-literals branch from b4e1c0d to d4345ec Compare July 26, 2026 13:27
@fumitoh
fumitoh merged commit e15414f into main Jul 26, 2026
18 checks passed
@fumitoh
fumitoh deleted the serializer-v8-iospec-literals branch July 26, 2026 13:41
@fumitoh fumitoh mentioned this pull request Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant