Skip to content

gfql: the general chain path upcasts integer node/property columns to float where every fast lane keeps int (route-dependent dtypes) #2058

Description

@lmeyerov

Summary

Found by route-off test amplification (replaying the existing GFQL suites with each hot path forced to decline). Two independent existing pins fail only when the general path serves the shape:

  • With the pandas/cuDF chain fast lane off, test_endpoint_closure_matrix.py::test_chain_surface_keeps_node_attribute_dtypes[pandas] (and test_clean_graph_is_untouched_by_the_gate) get float64 for an int node attribute on [n(), e_forward(), n()].
  • With the hop index path off, test_indexed_bindings.py parity pins (test_indexed_execution_is_pure, test_node_property_index_seeds_without_scanning, test_destination_property_projection_dtype_parity, test_node_property_index_duplicate_values_match_scan, …) show the scan path returning float64 where the indexed path returns int64 for the same columns (b, r, b.public).

chain._try_chain_fast_path documents it: "the full path upcasts int→float via merge — the merge is the artifact, int is the Cypher-conformant type". So the dtype a user gets depends on which route admitted the shape; any variant a lane declines silently comes back as floats.

Expected

One dtype contract on every route: the general path preserves integer dtypes the way the lanes do (or the lanes are made to match, which is the wrong direction per the docstring).

Pins

The existing tests above ARE the pins: run them with GFQL_ROUTES_OFF=native-fast / index-hop (conftest switch from #2054) and they must pass.

Activity

  1. lmeyerov commented on Sep 6, 2026

    @lmeyerov
    ContributorAuthor

    Split into two seams.

    Chain seam (fixed in #2062). The float came from _chain_impl's endpoint-closure backfill: every endpoint id was concatenated onto the node frame as an id-only row and deduplicated afterwards, so the phantom rows widened int64→float64 / bool→object before being dropped. Traced with a wrapper on every safe_merge: all merges kept int64; only the closure concat changed the dtype. #2062 appends only endpoints missing from the node frame (dedup kept), and the existing endpoint-closure matrix now passes with GFQL_ROUTES_OFF=native-fast.

    Rows-pivot seam (open, needs a contract decision). test_indexed_bindings.py::test_destination_property_projection_dtype_parity with the hop index path off compares a lane answer (int64) against the generic answer that goes through the pandas rows pivot (float64/object). That upcast is not an accident today: gfql_fast_paths._pivot_parity_casts makes the Cypher fast lanes reproduce the pivot's dtypes on pandas on purpose (parity with the canonical path), while cuDF keeps source dtypes. So the question is which dtype contract RETURN projections should have on pandas: the pivot's float64/object (current canonical, lanes match it) or the source dtypes (what the chain surface and the index kernel keep). Once decided, the fix is either in the rows pivot (keep source dtypes when no null is introduced, same rule as #2062) with _pivot_parity_casts retired, or a keep_source_dtypes=True parity pin on the kernel side. Leaving this half open on the issue.

  2. added 20 commits that reference this issue on Sep 6, 2026
  3. lmeyerov commented on Oct 3, 2026

    @lmeyerov
    ContributorAuthor

    Closing: #2062 (1a41079, on master) made every chain route keep source dtypes — the id-only stub-row widening artifact is mirrored at the other seams on purpose and pinned. Reopen if an integer node/property column comes back float on any route at current master.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions