Skip to content

Add golden-fixture regression suite for the serialization format - #285

Closed
mbastian wants to merge 2 commits into
masterfrom
serialization-golden-fixtures
Closed

Add golden-fixture regression suite for the serialization format#285
mbastian wants to merge 2 commits into
masterfrom
serialization-golden-fixtures

Conversation

@mbastian

Copy link
Copy Markdown
Member

Pins the on-disk serialization format against golden files, so the upcoming serializer dispatch refactor cannot silently change the bytes we write or break reading older files.

Fixtures, one directory per minor version

src/test/resources/serialization/

  • 0.4 – 0.7 are real files produced by those released versions, imported from the abandoned graphstore-compatibility-testing repo. 0.6.13 and 0.6.14 were byte-identical so only one copy is kept; 0.7 comes from a 0.7.0-SNAPSHOT build. They're tiny and only cover the format skeleton.
  • 0.8 is generated by the committed SerializationFixtureGenerator and carries the type-surface coverage: a column of every supported type including Character, char[] and the array types, both time representations, timestamp and interval maps of every value type, graph attributes, views, edge types, mixed directedness, dynamic edge weights.

This encodes the semver rule that a minor bump may change the byte format but older formats must stay readable.

Three contracts

  1. Backward compatibility — every fixture from 0.4 up deserializes and holds the expected content.
  2. Format drift — for the current minor only, serializing the generator-built model must reproduce the committed bytes exactly.
  3. Determinism — the same content always serializes to the same bytes regardless of construction order.

Byte mismatches report the first differing offset and surrounding bytes, not just "arrays differ".

Graph attributes are now order-stable

GraphAttributesImpl moves from HashMap to TreeMap so serialized bytes are a function of content rather than insertion history — without this, byte-pinned fixtures would be fragile. TreeMap rejects null keys where HashMap accepted them, so Objects.requireNonNull(key, "key") is added to all nine public methods taking a key.

Read path is unaffected and old files load identically; only the ordering of newly written attribute entries changes. No VERSION bump.

Note on contract 2

It compares generator output to the committed file rather than re-serializing a model read back from one, because deserialization is not byte-idempotent in this codebase — GraphVersion counters and TimeIndexStore.countMap are restored from the stream and then incremented again as elements are re-inserted, so no fixture could ever be a fixed point. The read path is covered by contract 1's content assertions instead.

Pre-existing issues surfaced, not fixed here

The fixtures pin current behaviour rather than papering over it. Found while building them:

  • TimeIndexStore.countMap doubles on every load. A 4-node timestamped graph goes from [4, 2] to [8, 4] across a save/load cycle. Since it's a refcount released at if (--countMap[id] == 0), timestamps are never freed after a load.
  • TextProperties width/height are written and read but then discardedsetTextProperties copies only rgba, size, text, visible.
  • BigInteger[] / BigDecimal[] come back as Object[] (values survive, component type doesn't).
  • GraphViewStore.visibleView isn't serialized at all.

Testing

1627 tests pass, up from 1597. The byte-pin was verified to actually fail, by perturbing both a fixture model value and a serializer write order, then reverting.

🤖 Generated with Claude Code

mbastian and others added 2 commits August 17, 2026 21:03
GraphAttributesImpl backed its map with a java.util.HashMap, and
Serialization.serializeGraphAttributes iterates that map's entrySet()
straight into the byte stream. HashMap iteration order is an
implementation detail, so the serialized bytes were a function of
insertion history rather than of content alone.

Switch the field to a TreeMap so iteration is sorted by key and the
output bytes become a pure function of the content. This is a
prerequisite for byte-pinned serialization fixtures.

TreeMap rejects null keys where HashMap accepted them, and throws NPE
from deep inside the map on get(null)/containsKey(null) where HashMap
returned null/false. Add explicit Objects.requireNonNull(key, "key")
guards to every public method taking a key so the failure is
intentional and well-messaged.

deepHashCode and deepEquals are unchanged and remain correct:
Map.hashCode() is specified as the order-independent sum of entry hash
codes, and deepEquals goes through MapDeepEquals which compares by key
lookup.

The read path is unaffected -- deserializeGraphAttributes just puts
entries into the map -- so previously written files still load
identically. Only the order of newly written graph-attribute entries
changes; the format itself is untouched and Serialization.VERSION is
not bumped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pins the on-disk serialization format against golden files so the
upcoming serializer dispatch refactor cannot silently change the bytes
we write or break reading older files.

Fixtures live in src/test/resources/serialization/, one directory per
MINOR version. 0.4 through 0.7 are real files produced by those
released versions, imported from the abandoned
graphstore-compatibility-testing repo (0.6.13 and 0.6.14 were verified
byte-identical, so only one copy is kept; 0.7 comes from a
0.7.0-SNAPSHOT build). Those are tiny and only cover the format
skeleton. 0.8 is generated by the committed
SerializationFixtureGenerator and carries the type-surface coverage: a
column of every supported type including Character, char[] and the
array types, both time representations, timestamp and interval maps of
every value type, graph attributes, views, edge types, mixed
directedness and dynamic edge weights.

SerializationCompatibilityTest enforces three contracts:

1. Backward compatibility - every fixture, from 0.4 up, deserializes
   and holds the expected content.
2. Format drift - for the current minor only, serializing the model
   built by the generator must reproduce the committed bytes exactly.
   Older minors are exempt: we no longer write those formats.
3. Determinism - the same content always serializes to the same bytes,
   independently of the order it was built in, including graph
   attributes inserted in different orders.

A round-trip-only test covers the surface that cannot be byte-pinned:
generic Map and Set attribute values, which Serialization.serializeMap
and serializeSet iterate in hash order.

Byte mismatches report the first differing offset and the surrounding
bytes rather than just "arrays differ".

Contract 2 compares generator output to the committed file rather than
re-serializing a model read back from it: deserialization is not
byte-idempotent here (GraphVersion counters and TimeIndexStore.countMap
are restored from the stream and then bumped again as elements are
re-inserted), so no fixture could ever be a byte-level fixed point. The
read path is covered by the content assertions of contract 1.

No production code is touched and Serialization.VERSION is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mbastian

Copy link
Copy Markdown
Member Author

Closing to split this up. The graph-attributes ordering change is going out as its own focused PR; the golden-fixture suite will follow separately once that lands.

The fixture work is preserved on the serialization-golden-fixtures branch, which stays pushed.

@mbastian mbastian closed this Aug 18, 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