Skip to content

fix: flatten models in tuples and nested mapping values as lists already are - #1960

Closed
shcheklein wants to merge 1 commit into
mainfrom
fix/flatten-tuple-of-models
Closed

fix: flatten models in tuples and nested mapping values as lists already are#1960
shcheklein wants to merge 1 commit into
mainfrom
fix/flatten-tuple-of-models

Conversation

@shcheklein

@shcheklein shcheklein commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Models stored in a tuple are written double-encoded, unlike the same models in a list.

class Box(dc.DataModel):
    label: str
    score: float

class Frame(dc.DataModel):
    boxes: tuple[Box, ...]

dc.read_values(frame=[Frame(boxes=(Box(label="cat", score=0.9),))]).save("frames")

What lands in the column:

before   ["{\"label\":\"cat\",\"score\":0.9}"]     a string holding JSON
after    [{"label":"cat","score":0.9}]             an object, same as list[Box]

Why

_flatten_fields_values decided what to convert from the runtime type of the value and knew only list and dict. A tuple matched neither, so it was passed through with its Box objects intact and the warehouse serialized each one separately. The dict branch had the same gap one level down: it converted a value that was a model, but not one that was a list of them.

It now decides from the declared type instead, and converts elements one at a time.

This changes the stored format

tuple[Model, ...] columns written before this will not compare equal to ones written after — union/distinct, subtract and merge between an old and a new dataset will disagree. Reads are unaffected: both spellings still load as the declared type, verified by writing on main and reading here.

That break is deliberate. The current spelling is a bug, the shape is rare — tuple[Model, ...] as a stored signal appears once in this repository and nowhere in src/ or examples/ — and leaving it costs a permanent gap in #1943 (below).

Deciding from the annotation, not the value

Two things follow from that, both of which an earlier revision got wrong:

  • A collection that cannot hold a model is never walked. list[int] and tuple[int, int] pass through untouched, so embedding vectors cost nothing to write and keep their identity for content hashing.
  • Elements are converted individually rather than from whatever the first one happens to be. A fixed-length tuple needs this: tuple[Item, int] would otherwise call model_dump() on the int.

Not fixed here

tuple[int, Model] is still written as an array of strings, byte-identical to main. The warehouse chooses the array conversion from the first element, so a leading non-model sends the whole array down the per-element path. Independent of this change.

Side effect

This closes the two shapes #1943 carries a strict xfail for. A dict arriving inside a live model has already been through model_dump, which merges keys that serialize to the same JSON name; the same dict inside a list arrives intact. Flattening them alike lets the existing check see both keys, with no new code there.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 28, 2026

Copy link
Copy Markdown

Deploying datachain with  Cloudflare Pages  Cloudflare Pages

Latest commit: eb04914
Status: ✅  Deploy successful!
Preview URL: https://3e34f83a.datachain-2g6.pages.dev
Branch Preview URL: https://fix-flatten-tuple-of-models.datachain-2g6.pages.dev

View logs

@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Aligns tuple and nested mapping model serialization with existing list behavior.

Changes:

  • Flattens models inside tuples and nested mapping values.
  • Adds flattening and persistence round-trip tests.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/datachain/lib/convert/flatten.py Adds tuple and recursive mapping flattening.
tests/unit/lib/test_signal_schema.py Tests collection flattening shapes.
tests/unit/lib/test_datachain.py Tests save/read round trips.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/datachain/lib/convert/flatten.py Outdated
@shcheklein
shcheklein force-pushed the fix/flatten-tuple-of-models branch 2 times, most recently from 1c48020 to 7e397ed Compare August 28, 2026 21:50
@shcheklein shcheklein self-assigned this Aug 28, 2026
@shcheklein
shcheklein requested a review from a team August 28, 2026 22:47
@shcheklein

Copy link
Copy Markdown
Contributor Author

Closing this. The review found that changing the physical Array(JSON) representation makes existing datasets stop comparing equal to newly written ones — union/distinct, subtract and merge between a legacy and a modern dataset all disagree, with no migration path.

That is the same failure I documented in #1943 as the storage-convergence dead end, and I checked the wrong half of it here: I verified old data still reads, not that old and new rows still compare.

Two further findings, both confirmed:

  • Flattening rebuilds every collection, so a list[int] of one million goes from ~0 ms to 231 ms per flatten. That lands on every embedding vector on every write.
  • tuple[int, Model] stays double-encoded regardless, because the warehouse's array path dispatches on the first element — so the change does not fully achieve its goal without also touching that fast path.

The one piece worth keeping is annotation-guided traversal: deciding what to descend into from the declared type rather than isinstance of the value. That fixes the performance problem, removes the first-element guessing, and needs no representation change. Worth doing on its own if anyone picks it up.

The xfail in #1943 stays; it is cheaper than either route around it.

@shcheklein shcheklein closed this Aug 28, 2026
@shcheklein shcheklein reopened this Aug 29, 2026
@shcheklein
shcheklein force-pushed the fix/flatten-tuple-of-models branch from 7e397ed to ed1c877 Compare August 29, 2026 00:35
…ady are

_flatten_fields_values decided what to convert from the runtime type of a
field's value and knew only list and dict. A tuple matched neither, and the dict
branch converted a value that was itself a model but not one that was a list of
them, so those reached the warehouse as live pydantic instances while the same
models in a list arrived as plain dicts. The warehouse then serialized each
instance on its own, writing a tuple of models as an array of JSON strings where
a list of the same models is an array of objects:

    list[Item]        [{"n":1,"label":"a"}]
    tuple[Item, ...]  ["{\"n\":1,\"label\":\"a\"}"]

A dataset written before this still reads; the two now agree on what they write.

Models are converted with model_dump(mode="json"), which is what the warehouse
applies to a model handed to it directly. The list path used python mode, which
converts nothing, so a pathlib.Path field failed to serialize and a serializer
declared with when_used="json" was skipped -- storing the raw value a redacting
serializer existed to hide. Both now behave the same wherever the model sits.

What to walk comes from the declared type rather than the value. A collection
that cannot hold a model is returned untouched, so a vector of numbers is not
copied and keeps its identity for content hashing, and this holds per element:
tuple[Item, list[int]] converts the model and leaves the list alone. An erased
annotation -- Any, object, a bare container -- says nothing about its contents,
so it is walked rather than assumed model-free. Elements are converted one at a
time rather than from whatever the first one happens to be, which a fixed-length
tuple needs: tuple[Item, int] would otherwise call model_dump on the int.

tuple[int, Model] is still written as an array of strings, unchanged. The
warehouse picks the array conversion from the first element, so a leading
non-model sends the whole array down the per-element path.
@shcheklein
shcheklein force-pushed the fix/flatten-tuple-of-models branch from ed1c877 to eb04914 Compare August 29, 2026 01:29
@shcheklein

Copy link
Copy Markdown
Contributor Author

Superseded. The tuple work is being reconsidered on top of #1943 rather than as a format change in its own right, and the diagnosis moved: the double-encoding of tuple[Model, ...] comes from the column type, not from flattening — see #1963.

Findings from this PR worth keeping for whatever replaces it: unwrap Optional before reading collection arguments, decide what to walk from the declared type rather than the runtime value, and normalize UDF outputs through flatten_value/_flatten_row as well as _flatten_fields_values.

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.

2 participants