Skip to content

Commit e3afca4

Browse files
fix(generic): do not flatten a nested object that is null in any row (losslessness)
_analyze_flattenable skipped None during shape analysis, so a field that was a dict in some rows and None at an intermediate nesting level in others was flattened; on decode the None row's leaves resolved as absent and unflattened to a missing key, dropping the value ({"meta":{"owner":None}} decoded to {}). Now bails to the attachment path when a nested field (parent_path != "") is None; a top-level None still flattens losslessly via the "-"/all-null rule. Adds test_flatten_roundtrip: aligned nested-object arrays with null at depth — the shape the scalar-only generator never produced, which is why this shipped despite property-based round-trip fuzzing. Enforced cross-SDK by the shared conformance fixtures flatten/017-019. (Prototype pollution does not affect Python; dicts have no mutable prototype.)
1 parent 97865a1 commit e3afca4

5 files changed

Lines changed: 95 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## v2.2.2 (2026-07-10)
4+
5+
### Fixes
6+
7+
- **Losslessness (nested null):** a nested object that is null at an intermediate level (e.g. `{"meta": {"owner": None}}`) is no longer flattened. Previously its leaves encoded as absent (`~`) and unflattened to a missing key, silently dropping the null. Such fields now fall back to the attachment mechanism; a top-level `None` still flattens losslessly (emits `-`, reconstructs via the all-null rule). Enforced by the shared conformance fixtures `flatten/017``019`. Prototype pollution does not affect Python (dicts have no mutable prototype).
8+
9+
### Tests
10+
11+
- `test_flatten_roundtrip`: aligned arrays whose shared fields are fixed-shape nested objects with a field or an intermediate nested level sometimes null/absent — the shape the prior scalar-only generator never produced, leaving the flatten/unflatten path unexercised. Verified to fail on the pre-fix encoder and pass on the fix.
12+
313
## v2.2.1 (2026-06-23)
414

515
### Flatten Opt-Out

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "gcf-python"
7-
version = "2.2.1"
7+
version = "2.2.2"
88
description = "The AI-native wire format for structured data. 50-92% fewer tokens than JSON. 100% comprehension on every frontier model. Zero dependencies."
99
readme = "README.md"
1010
license = {text = "MIT"}

src/gcf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,4 @@
6666
"encode_with_session",
6767
]
6868

69-
__version__ = "1.0.0"
69+
__version__ = "2.2.2"

src/gcf/generic.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,15 @@ def _analyze_flattenable(
171171
canonical_shape: dict[str, str] | None = None # key -> "scalar" | "nested"
172172

173173
for item in arr:
174-
if field_name not in item or item[field_name] is None:
174+
if field_name not in item:
175+
continue
176+
# A nested (non-top-level) null cannot be flattened losslessly: its leaves
177+
# would encode as absent ("~") and unflatten back to a missing key, not None.
178+
# Bail to the attachment path. A top-level None is fine (emits "-" and
179+
# reconstructs via the all-null rule), so just skip the row from shape analysis.
180+
if item[field_name] is None:
181+
if parent_path != "":
182+
return None
175183
continue
176184
v = item[field_name]
177185
if not isinstance(v, dict):

tests/test_roundtrip_v2.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,80 @@ def _structural_equal(a, b):
214214
return a == b
215215

216216

217+
def _gen_flat_shape(r, depth, max_depth):
218+
"""A fixed nested schema: the string "scalar" or a dict of named sub-shapes."""
219+
if depth >= max_depth or r.random() < 0.45:
220+
return "scalar"
221+
shape = {}
222+
for _ in range(1 + r.randint(0, 2)):
223+
shape[_gen_bare_key(r)] = _gen_flat_shape(r, depth + 1, max_depth)
224+
return shape if shape else "scalar"
225+
226+
227+
def _materialize_flat_shape(r, shape):
228+
if shape == "scalar":
229+
return _gen_scalar(r)
230+
obj = {}
231+
for k, s in shape.items():
232+
# A nested sub-object is sometimes None (intermediate null — the case the
233+
# pre-fix encoder dropped) instead of a full object.
234+
obj[k] = None if s != "scalar" and r.random() < 0.3 else _materialize_flat_shape(r, s)
235+
return obj
236+
237+
238+
def _gen_flattenable_array(r):
239+
schema = {"id": "scalar"}
240+
order = ["id"]
241+
has_nested = False
242+
for _ in range(1 + r.randint(0, 2)):
243+
k = _gen_bare_key(r)
244+
if k in schema:
245+
continue
246+
s = _gen_flat_shape(r, 1, 3)
247+
schema[k] = s
248+
order.append(k)
249+
if s != "scalar":
250+
has_nested = True
251+
if not has_nested:
252+
k = _gen_bare_key(r)
253+
schema[k] = {_gen_bare_key(r): {_gen_bare_key(r): "scalar"}}
254+
order.append(k)
255+
arr = []
256+
for _ in range(2 + r.randint(0, 5)):
257+
row = {}
258+
for f in order:
259+
x = r.random()
260+
if x < 0.12:
261+
continue # field absent this row
262+
elif x < 0.24:
263+
row[f] = None # field present-null (top-level null)
264+
else:
265+
row[f] = _materialize_flat_shape(r, schema[f])
266+
arr.append(row)
267+
return arr
268+
269+
270+
def test_flatten_roundtrip():
271+
"""Aligned arrays whose shared fields are fixed-shape nested objects with a
272+
field or an intermediate nested level sometimes null/absent — the v3.2 flatten
273+
path the scalar-only generator never produces, so flatten/unflatten and its
274+
null-at-depth losslessness edge would otherwise be unexercised."""
275+
r = _rng(7)
276+
for i in range(ITERATIONS):
277+
val = _gen_flattenable_array(r)
278+
for no_flatten in (False, True):
279+
gcf = encode_generic(val, GenericOptions(no_flatten=no_flatten))
280+
decoded = decode_generic(gcf)
281+
a = _json_norm(val)
282+
b = _json_norm(decoded)
283+
assert _structural_equal(a, b), (
284+
f"iteration {i} no_flatten={no_flatten}: round-trip mismatch\n"
285+
f" input: {json.dumps(val)}\n"
286+
f" decoded: {json.dumps(decoded)}\n"
287+
f" gcf: {gcf!r}"
288+
)
289+
290+
217291
def test_random_roundtrip():
218292
r = _rng(42)
219293
for i in range(ITERATIONS):

0 commit comments

Comments
 (0)