Skip to content

Add MissingCode primitive + lossless mapping serialization - #110

Merged
matthewhorridge merged 2 commits into
mainfrom
feat/missing-code-primitive
Jun 3, 2026
Merged

Add MissingCode primitive + lossless mapping serialization#110
matthewhorridge merged 2 commits into
mainfrom
feat/missing-code-primitive

Conversation

@matthewhorridge

Copy link
Copy Markdown
Contributor

Summary

Two related changes to missing-data handling and mapping serialization.

1. MissingCode primitive (+ per-value replay logging)

A new missing_code primitive that turns declared missing-value codes (e.g. numeric -999) into real nulls while passing every other value — including genuine nulls — through unchanged. This is the identity-preserving null map that EnumToEnum cannot express (an unmapped value always returns default, so EnumToEnum can't null one code without nulling the column).

  • Each code carries a label (e.g. "not_measured"). The label isn't written to the output (a null has no room for a reason); instead the harmonize engine reports each nulled cell — value, label, row index — to the replay log. Codes + labels are also recorded in rules.json.
  • transformations.py::replay skips non-rule log events (per-value hits aren't replayable); rule events are tagged "event": "rule", and a missing key defaults to "rule" for older logs.

2. Lossless mapping serialization (fixes a silent int-key bug)

JSON object keys are always strings, so an EnumToEnum keyed by integers (e.g. the index from Reduce(ONEHOT)) round-tripped through rules.json with string keys and then missed every lookup, silently falling through to default. The old defence was a coercion heuristic that only restored int keys when keys and values were both int-like — which failed for the common int-key → string-label case.

Fixed at the root: EnumToEnum.mapping now serializes as a list of {"from","to"} entries, and MissingCode.codes as {"code","label"} entries. Each key sits in a value position and keeps its native JSON type, so no coercion is needed (the is_int_like/is_float_like code is removed). Matches Bin's existing list-of-objects style.

No backward compatibility — only the new entry form is read (framework not widely used yet).

Testing

  • Full suite passes (177 tests), including new JSON round-trip guards that an int-keyed EnumToEnum and a numeric-code MissingCode still match integer inputs after dumps/loads — now guaranteed by the format, not by coercion.

🤖 Generated with Claude Code

matthewhorridge and others added 2 commits June 3, 2026 11:27
Adds a `missing_code` primitive that turns declared missing-value codes
(e.g. numeric -999) into real nulls while passing every other value —
including genuine nulls — through unchanged. This is the identity-preserving
null map that EnumToEnum cannot express: EnumToEnum sends every unmapped
value to its default, so it cannot null one code without nulling the column.

Each code carries a label (e.g. "not_measured"). The label is not written to
the output column (a null has no room for a reason); instead the harmonize
engine reports each nulled cell — value, label, and row index — to the replay
log. Codes and labels are also recorded in rules.json via to_dict.

- missing_code.py: primitive; accepts a list or dict of codes, normalises to a
  dict, coerces int/float keys back from JSON strings on deserialization.
- vocabulary/factory/__init__: register and export the primitive.
- replay_logger.py: add log_missing_code_hits; tag rule events "event":"rule".
- harmonize.py: after apply, re-scan the source column for hits and log them.
- transformations.py replay: skip non-rule log events (missing key => "rule"
  for backward compatibility with older logs).
- tests: serialization round-trip incl. numeric-key JSON guard, null
  pass-through, and end-to-end replay-log hit + replay-skip coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
JSON object keys are always strings, so an EnumToEnum keyed by integers (e.g.
the index from Reduce(ONEHOT)) round-tripped through rules.json with string
keys and then missed every lookup, silently falling through to the default.
The previous defence was a heuristic in from_serialization that only restored
int keys when keys AND values were both int-like — which failed for the common
int-key -> string-label case.

Fix the root cause: serialize the mapping as a list of {"from","to"} entries
(and MissingCode.codes as {"code","label"} entries) instead of a JSON object.
Each key then sits in a value position and keeps its native JSON type, so no
coercion is needed. Removed the is_int_like / is_float_like / key-coercion code
from both primitives. This matches Bin's existing list-of-objects style.

No backward compatibility (framework not widely used yet): only the new entry
form is read. Tests updated to the new shape; added json round-trip guards that
an int-keyed EnumToEnum and a numeric-code MissingCode still match integer
inputs after dumps/loads — now guaranteed by the format, not by coercion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@matthewhorridge
matthewhorridge merged commit 34f968f into main Jun 3, 2026
1 check passed
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