Found by the 2026-08 layouts/viz amplification probe. Repros on master @ 42b4f7f7d, polars 1.42.0 / pandas 2.3.3 / py3.12.
Engine.resolve_input_engine deliberately maps polars → Engine.PANDAS for input-consuming surfaces, and its docstring names layouts as one of them (graphistry/Engine.py:60-73). That policy is fine. The problem is that the layout surface implements it three different ways, and only one of them works.
The matrix
Same graph, polars _nodes and _edges, engine='auto' (the default), one node column of each needed type:
| layout |
result on polars frames |
circle_layout(bounding_box=..) |
silently returns pandas (both _nodes and _edges converted) |
tree_layout() |
silently returns pandas |
modularity_weighted_layout() |
silently returns pandas |
layout_igraph('fr') |
AttributeError: 'Int64' object has no attribute 'name' |
group_in_a_box_layout() |
ValueError: Could not infer engine, please specify |
ring_categorical_layout('cat') |
AttributeError: 'DataFrame' object has no attribute 'reset_index' |
ring_continuous_layout('v') |
AttributeError: 'DataFrame' object has no attribute 'reset_index' |
time_ring_layout('t') |
AttributeError: 'DataFrame' object has no attribute 'reset_index' |
mercator_layout() |
AttributeError: 'Series' object has no attribute 'values' |
Repro
import pandas as pd, polars as pl, graphistry
E = pd.DataFrame({'s':[0,1,2], 'd':[1,2,0]})
N = pd.DataFrame({'id':[0,1,2], 'cat':['a','b','c'], 'v':[1.,2.,3.],
't': pd.to_datetime(['2020-01-01','2021-01-01','2022-01-01']),
'lat':[10.,20.,30.], 'lon':[1.,2.,3.]})
g = (graphistry.edges(pl.from_pandas(E),'s','d').nodes(pl.from_pandas(N),'id')
.bind(point_latitude='lat', point_longitude='lon'))
g.circle_layout(bounding_box=(0,0,10,10))._nodes # -> pandas.DataFrame, no warning
g.ring_continuous_layout('v') # -> AttributeError
Defect 1 (bare-crash): six layouts crash with an untyped AttributeError / generic ValueError
The three ring layouts do call resolve_input_engine and correctly get Engine.PANDAS — they just never convert the frame, and they call reset_index(drop=True) on it before the resolve even runs:
graphistry/layout/ring/continuous.py:183 then :185
graphistry/layout/ring/categorical.py:171 then :173
graphistry/layout/ring/time.py:336 then :338
mercator.py:73 never consults an engine at all — it does is_not_pandas = not isinstance(g._nodes, pd.DataFrame), which classifies a polars frame as "cuDF" and routes it into the cupy branch, where .values does not exist (:93-94).
gib.py:138-149 sniffs self._edges for pandas-or-cudf only and raises ValueError('Could not infer engine, please specify') for polars — but there is no engine= value that helps, since partition.py:56-57 rejects everything that is not Engine.PANDAS/Engine.CUDF.
Explicit engines do not rescue any of it:
ring_continuous_layout('v', engine='pandas') -> AttributeError: 'DataFrame' object has no attribute 'reset_index'
ring_categorical_layout('cat', engine='pandas')-> AttributeError: 'DataFrame' object has no attribute 'reset_index'
ring_continuous_layout('v', engine='polars') -> AttributeError: 'DataFrame' object has no attribute 'reset_index'
circle_layout(bb, engine='polars') -> ValueError: Only engines pandas/cudf supported, got: Engine.POLARS
engine='pandas' on a polars graph is the exact call a user makes after reading the coercion policy, and it still dies. Only circle_layout honours it.
Expected: either convert the frame the way circle_layout/tree_layout do, or decline with a typed error naming polars (the circle_layout(engine='polars') message Only engines pandas/cudf supported, got: Engine.POLARS is the right shape; the AttributeErrors are not).
Defect 2 (surface-divergence, undocumented): the three that do work silently change the caller's frame type
circle_layout, tree_layout and modularity_weighted_layout return a Plottable whose _nodes and _edges are now pandas.DataFrame. Nothing warns, and the layout docstrings say nothing about it.
docs/source/gfql/engines.rst:48-63 documents this coercion carefully for GFQL ("a graph built from polars.DataFrame is silently coerced to pandas … Result frames match the engine") and tells users to pass engine='polars' to stay native. There is no equivalent statement for layouts, and per Defect 1 there is no engine= value that keeps a layout native. So a documented "pass engine='polars' to stay native" workflow silently loses its frame type the moment a layout is composed in:
out = g.gfql(query, engine='polars') # documented: out._nodes is polars
out = out.circle_layout(bounding_box=(0,0,10,10))
type(out._nodes) # pandas.DataFrame -- silently
This is the same shape as #1824 (polars-gpu silently serving CPU) but a different axis — frame-type coercion on the layout surface rather than execution placement — so it is filed separately rather than as a comment there.
Suggested resolution (not a fix request, just scoping)
Pick one policy and make all ten entry points obey it:
- (a) all layouts call
df_to_engine(..., Engine.PANDAS) on entry when resolve_input_engine says pandas, and document "layouts return pandas frames"; or
- (b) all layouts decline polars with a typed error until a native path exists.
Either is defensible. The current split — 3 coerce, 6 bare-crash, and engine='pandas' not honoured — is not.
Category
bare-crash on valid input (6 layouts) + surface-divergence / undocumented silent coercion (3 layouts).
Not exercised
No cuDF arm: cupy in this environment cannot load libnvrtc.so.12 and cugraph is absent, so the GPU column of this matrix is untested. layout_igraph('fr') on cuDF frames was verified working (returns cuDF), so the igraph plugin's polars failure above is polars-specific, not a general non-pandas failure.
Found by the 2026-08 layouts/viz amplification probe. Repros on
master@42b4f7f7d, polars 1.42.0 / pandas 2.3.3 / py3.12.Engine.resolve_input_enginedeliberately maps polars →Engine.PANDASfor input-consuming surfaces, and its docstring names layouts as one of them (graphistry/Engine.py:60-73). That policy is fine. The problem is that the layout surface implements it three different ways, and only one of them works.The matrix
Same graph, polars
_nodesand_edges,engine='auto'(the default), one node column of each needed type:circle_layout(bounding_box=..)_nodesand_edgesconverted)tree_layout()modularity_weighted_layout()layout_igraph('fr')AttributeError: 'Int64' object has no attribute 'name'group_in_a_box_layout()ValueError: Could not infer engine, please specifyring_categorical_layout('cat')AttributeError: 'DataFrame' object has no attribute 'reset_index'ring_continuous_layout('v')AttributeError: 'DataFrame' object has no attribute 'reset_index'time_ring_layout('t')AttributeError: 'DataFrame' object has no attribute 'reset_index'mercator_layout()AttributeError: 'Series' object has no attribute 'values'Repro
Defect 1 (bare-crash): six layouts crash with an untyped
AttributeError/ genericValueErrorThe three ring layouts do call
resolve_input_engineand correctly getEngine.PANDAS— they just never convert the frame, and they callreset_index(drop=True)on it before the resolve even runs:graphistry/layout/ring/continuous.py:183then:185graphistry/layout/ring/categorical.py:171then:173graphistry/layout/ring/time.py:336then:338mercator.py:73never consults an engine at all — it doesis_not_pandas = not isinstance(g._nodes, pd.DataFrame), which classifies a polars frame as "cuDF" and routes it into the cupy branch, where.valuesdoes not exist (:93-94).gib.py:138-149sniffsself._edgesfor pandas-or-cudf only and raisesValueError('Could not infer engine, please specify')for polars — but there is noengine=value that helps, sincepartition.py:56-57rejects everything that is notEngine.PANDAS/Engine.CUDF.Explicit engines do not rescue any of it:
engine='pandas'on a polars graph is the exact call a user makes after reading the coercion policy, and it still dies. Onlycircle_layouthonours it.Expected: either convert the frame the way
circle_layout/tree_layoutdo, or decline with a typed error naming polars (thecircle_layout(engine='polars')messageOnly engines pandas/cudf supported, got: Engine.POLARSis the right shape; theAttributeErrors are not).Defect 2 (surface-divergence, undocumented): the three that do work silently change the caller's frame type
circle_layout,tree_layoutandmodularity_weighted_layoutreturn a Plottable whose_nodesand_edgesare nowpandas.DataFrame. Nothing warns, and the layout docstrings say nothing about it.docs/source/gfql/engines.rst:48-63documents this coercion carefully for GFQL ("a graph built frompolars.DataFrameis silently coerced to pandas … Result frames match the engine") and tells users to passengine='polars'to stay native. There is no equivalent statement for layouts, and per Defect 1 there is noengine=value that keeps a layout native. So a documented "passengine='polars'to stay native" workflow silently loses its frame type the moment a layout is composed in:This is the same shape as #1824 (polars-gpu silently serving CPU) but a different axis — frame-type coercion on the layout surface rather than execution placement — so it is filed separately rather than as a comment there.
Suggested resolution (not a fix request, just scoping)
Pick one policy and make all ten entry points obey it:
df_to_engine(..., Engine.PANDAS)on entry whenresolve_input_enginesays pandas, and document "layouts return pandas frames"; orEither is defensible. The current split — 3 coerce, 6 bare-crash, and
engine='pandas'not honoured — is not.Category
bare-crash on valid input (6 layouts) + surface-divergence / undocumented silent coercion (3 layouts).
Not exercised
No cuDF arm:
cupyin this environment cannot loadlibnvrtc.so.12andcugraphis absent, so the GPU column of this matrix is untested.layout_igraph('fr')on cuDF frames was verified working (returns cuDF), so the igraph plugin's polars failure above is polars-specific, not a general non-pandas failure.