Skip to content

Commit 6e50d31

Browse files
lmeyerovclaude
andcommitted
fix(gfql): show_indexes reports engine usability, not just fingerprint validity
show_indexes() claimed valid=True for an index the resolved query engine cannot use. Indexes are engine-specific, so a fingerprint-fresh index built for another engine (e.g. polars-built, inspected on a graph whose AUTO queries resolve to pandas) silently declines to a scan at query time while the inspection surface said everything was fine. This is the follow-up proposed in #1767's disposition comment ("stop show_indexes() reporting valid=True for an index the resolved query engine cannot use"). #1838 made the decline loud in gfql_explain via _engine_mismatch_reason; this completes the pair on show_indexes by reusing the same mechanism and wording rather than inventing a parallel one. - valid keeps its fingerprint-only meaning (backward compatible) - new columns: query_engine (what engine= resolves to for THIS graph, the same resolution a query makes; default 'auto'), usable (fresh AND engine-matched), reason (shared #1838 mismatch wording + stale reason) - show_indexes(engine=...) previews an explicit engine choice; SHOW GFQL INDEXES respects the gfql(engine=...) of the call; gfql_explain passes its engine through - reporting-only: no query routing changes (#1743 AUTO-routing stays held) Tests: 9 new (polars index vs AUTO/pandas query not-usable with exact reason, matching-engine usable, explicit-engine preview both directions, stale-but-engine-matched not usable, stale+mismatched combined reason, per-kind reasons across all four index kinds, DDL surface, cudf lane with import skip-guard). Index suite: 219 passed, 1 skipped locally (GPU lanes deselected, no GPU on this box). Lint + scoped mypy clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AB4RZpph3uSFUpzKnZJcr
1 parent 54fcba3 commit 6e50d31

7 files changed

Lines changed: 188 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
4242
- **GFQL execution context is declared rather than attached at runtime**: the private `_gfql_*` per-execution fields (index policy and registry, row-pipeline base graph, carried seed nodes, edge aliases, shortest-path backend, and the indexed-bindings handoff) are now declared on `Plottable` with defaults on `PlotterBase`, instead of being set with `setattr` and read back with `getattr(..., default)`. No public API or behaviour change; internal call sites are typed, and hand-rolled `Plottable` stand-ins must now construct the full context.
4343

4444
### Fixed
45+
- **`show_indexes()` reported `valid=True` for an index the resolved query engine cannot use**: GFQL physical indexes are engine-specific, so a fingerprint-fresh index built for another engine (e.g. a polars-built index inspected on a graph whose AUTO queries resolve to pandas) silently declines to a scan at query time — yet `show_indexes()` told the user everything was fine. This is the follow-up proposed in #1767's disposition ("stop `show_indexes()` reporting `valid=True` for an index the resolved query engine cannot use"); #1838 made the decline loud in `gfql_explain`, and this completes the pair on the inspection surface. `valid` keeps its fingerprint-only meaning (backward compatible); three columns are added: `query_engine` (what the `engine` argument — default `'auto'` — resolves to for THIS graph, the same resolution a query makes), `usable` (fresh AND engine-matched), and `reason` (why not, reusing #1838's mismatch wording, e.g. `resident edge_out_adj index engine=polars, requested engine=pandas -> scan`, plus a stale-fingerprint reason). `show_indexes(engine=...)` previews an explicit engine choice, and `SHOW GFQL INDEXES` respects the `gfql(engine=...)` of the call. Reporting-only: no query routing changes (#1743 AUTO-routing remains on hold).
4546
- **The compiled-query cache key omitted the engine, which a shared cache would have turned into a wrong answer.** The key relied on `node_dtypes` to capture engine-dependent compilation, but polars and polars-gpu report *identical* node dtypes and compile differently. While the cache was partitioned per `Plottable` this was masked rather than avoided; sharing plans process-wide exposed it immediately as 16 failures in `test_const_fold_engine_parity.py` on the polars-gpu arm. The key now carries the **resolved** engine -- resolved, because `auto` resolves per graph, so keying on the requested engine would let two graphs that resolve differently share a plan.
4647

4748
- **`master` is green again: the type-hygiene ratchet tripped on merge, and the fix is fewer `cast()`s rather than a raised cap**: `bin/ci_type_hygiene_baseline.json` is a per-file SNAPSHOT, so it goes stale whenever a PR *other than the one that owns the baseline* touches a baselined file. #1830 captured its baseline on a branch whose merge-base is `b6181d355` — before #1800, #1799 and #1816 landed — and each of those three added `cast()` calls to a file #1830 had already pinned. All four were green on their own bases; the combination was not, and nothing in any of the four merges signalled it: `explicit-cast` came out 7 over baseline across `gfql_fast_paths.py` (+5), `gfql_unified.py` (+1) and the polars `row_pipeline.py` (+1), failing `python-lint-types` on 3.11/3.13/3.14. The baseline is **unchanged** here; the findings were removed instead. Three of the seven were never `typing.cast` at all — the guard matches any call whose name is `cast`, including the attribute form, so `pl.Expr.cast`, a polars RUNTIME dtype conversion, counts as a typing finding; those three carry the documented `# hygiene-ok: explicit-cast` escape hatch with the reason. The other four were real, and are gone by DECLARATION rather than by call-site assertion, which is the idiom the guard is asking for: `_two_hop_cached_equal_domain_degree_counts` declares `counts: Tuple[DataFrameT, DataFrameT]` once and both arms assign to it, so four casts collapse to one localized `# type: ignore[assignment]` on the polars arm (`DataFrameT` is pinned to pandas at checking time); `_apply_connected_optional_match` declares `seed_ids: SeriesT` / `node_ids: SeriesT` instead of casting, because selecting one column off a frame is a Series on every engine. (The neighbouring `cast(DataFrameT, df_to_engine(...))` pair is deliberately left alone: `df_to_engine` carries no return annotation, so removing those would mean annotating a helper with 115 call sites, and the polars arm of that branch is not reached by any CI lane — rewriting it would have dragged a pre-existing coverage blind spot into the changed-line gate for no typing gain.) All three files now sit AT or BELOW baseline (18/18, 132/133, 41/42). Typing-only: `typing.cast` is the identity function at runtime, so every removal is provably value-preserving, and the one restructured line binds an unchanged `pl.Expr` list to a name so the per-line suppression fits the 127-column limit. **The hazard is latent, not spent** — any future PR adding a finding to a baselined file reds `master` the same way, and the guard cannot see it from inside either PR.

docs/source/gfql/indexing.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ API):
100100
g = g.gfql_index_edges("forward") # or just one direction: 'forward'|'reverse'|'both'
101101
g = g.create_index("edge_out_adj") # or one kind: 'edge_out_adj'|'edge_in_adj'|'node_id'
102102
g = g.gfql_index_node_props(["id"]) # secondary indexes on node property columns
103-
g.show_indexes() # pandas DataFrame: kind, engine, n_keys, nbytes, valid
103+
g.show_indexes() # pandas DataFrame: kind, engine, ..., valid, usable, reason
104104
g = g.drop_index() # drop all (or drop_index("edge_out_adj"))
105105
106106
Unlike ``gfql_index_all()``, an explicit ``create_index("node_id")`` **raises** on
@@ -165,6 +165,12 @@ time). Consequences:
165165
skipped, never consulted.
166166
- ``g.show_indexes()`` reports liveness in the ``valid`` column, so you can see at a
167167
glance whether a rebind knocked an index out.
168+
- ``valid`` alone is not "this index will serve your query": indexes are also
169+
**engine-specific**. The ``usable`` column is True only when the index is fresh AND
170+
built for the resolved query engine (shown in ``query_engine``); otherwise ``reason``
171+
explains the decline — e.g. a polars-built index on a graph whose default queries
172+
resolve to pandas shows ``usable=False`` with an engine-mismatch reason. Pass
173+
``show_indexes(engine=...)`` to preview an explicit engine choice.
168174
- Rebuild by calling ``gfql_index_all()`` again on the rebound ``g``.
169175

170176
.. doc-test: skip

graphistry/compute/ComputeMixin.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -644,10 +644,16 @@ def drop_index(self, kind: Optional['IndexKind'] = None, *, column: Optional[str
644644
from graphistry.compute.gfql.index import drop_index as _di
645645
return _di(self, kind, column=column)
646646

647-
def show_indexes(self):
648-
"""Return a pandas DataFrame describing resident GFQL indexes (name, kind, column, valid). Empty if none; ``valid=False`` marks a stale index after a frame rebind."""
647+
def show_indexes(self, engine: EngineAbstractType = 'auto'):
648+
"""Return a pandas DataFrame describing resident GFQL indexes (name, kind, column, valid, usable). Empty if none.
649+
650+
``valid=False`` marks a stale index after a frame rebind. ``usable`` further
651+
reports whether the index can serve a query under the resolved engine —
652+
indexes are engine-specific, so a fresh index built for another engine
653+
declines to a scan; ``reason`` explains any decline. Pass ``engine=`` to
654+
preview an explicit engine choice (default ``'auto'`` mirrors query resolution)."""
649655
from graphistry.compute.gfql.index import show_indexes as _si
650-
return _si(self)
656+
return _si(self, engine=engine)
651657

652658
def gfql_index_edges(self, direction='both', engine='auto'):
653659
"""Convenience: build the edge adjacency index(es) — 'forward', 'reverse', or 'both'. Returns a new Plottable."""

graphistry/compute/gfql/index/api.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from __future__ import annotations
99

1010
import copy
11-
from typing import Dict, List, Literal, Optional, Sequence, cast
11+
from typing import Dict, List, Literal, Optional, Sequence, Tuple, cast
1212

1313
import pandas as pd
1414

@@ -81,6 +81,15 @@ def _trace_active() -> bool:
8181
return _get_trace_steps() is not None
8282

8383

84+
def _engine_mismatch_text(kinds: str, index_engines: str, engine: Engine) -> str:
85+
"""Single wording for an engine-mismatched index decline, shared by the
86+
``gfql_explain`` trace diagnostic and the ``show_indexes`` ``reason`` column."""
87+
return (
88+
f"resident {kinds} index engine={index_engines}, "
89+
f"requested engine={engine.value} -> scan"
90+
)
91+
92+
8493
def _engine_mismatch_reason(
8594
registry: GfqlIndexRegistry, direction: HopDirection, engine: Engine
8695
) -> Optional[str]:
@@ -107,10 +116,25 @@ def _engine_mismatch_reason(
107116
return None
108117
kinds = ", ".join(kind for kind, _ in mismatches)
109118
engines = ", ".join(sorted({idx.engine.value for _, idx in mismatches}))
110-
return (
111-
f"resident {kinds} index engine={engines}, requested engine={engine.value} "
112-
"-> scan"
113-
)
119+
return _engine_mismatch_text(kinds, engines, engine)
120+
121+
122+
def _index_usability(
123+
kind: IndexKind, index_engine: Engine, valid: bool, query_engine: Engine
124+
) -> Tuple[bool, Optional[str]]:
125+
"""Usability of ONE resident index under the resolved query engine.
126+
127+
``valid`` (fingerprint freshness) is necessary but not sufficient: indexes are
128+
engine-specific, so a fresh index built for another engine still declines to a
129+
scan at query time (#1767 disposition). Returns ``(usable, reason)`` where
130+
``reason`` uses the same wording as the ``gfql_explain`` decline diagnostic.
131+
"""
132+
reasons: List[str] = []
133+
if index_engine != query_engine:
134+
reasons.append(_engine_mismatch_text(kind, index_engine.value, query_engine))
135+
if not valid:
136+
reasons.append("stale fingerprint (frames rebound since build) -> rebuild")
137+
return (not reasons), ("; ".join(reasons) or None)
114138

115139

116140
def _record_indexed_traversal(
@@ -308,16 +332,27 @@ def drop_index(
308332
return _attach(g, registry.without(kind))
309333

310334

311-
def show_indexes(g: Plottable) -> pd.DataFrame:
335+
def show_indexes(
336+
g: Plottable, engine: EngineAbstractType = EngineAbstract.AUTO
337+
) -> pd.DataFrame:
312338
"""Return a pandas DataFrame describing resident indexes (empty if none).
313339
314340
``valid`` reflects live fingerprint validity against the current frames — a
315341
stale index (after a ``.edges()``/``.nodes()`` rebind) shows ``valid=False`` and
316342
is auto-skipped (scan fallback) until rebuilt. ``nbytes`` is the resident
317343
sidecar-array footprint (the pay-as-you-go memory signal).
344+
345+
``valid`` alone is NOT "this index will serve your query": indexes are
346+
engine-specific, so a fresh index built for another engine silently declines to
347+
a scan at query time (#1767). ``query_engine`` is what ``engine`` resolves to
348+
for THIS graph (the same resolution a query makes — pass ``engine=`` to preview
349+
an explicit choice), ``usable`` is True only when the index is fresh AND
350+
engine-matched, and ``reason`` says why not, with the same wording as the
351+
``gfql_explain`` decline diagnostic.
318352
"""
319353
from .registry import index_nbytes
320354

355+
query_engine = resolve_engine(engine, g)
321356
registry = get_registry(g)
322357
rows: List[Dict[str, object]] = []
323358
for kind in registry.kinds():
@@ -333,6 +368,7 @@ def show_indexes(g: Plottable) -> pd.DataFrame:
333368
valid = g._nodes is not None and registry.get_valid(
334369
NODE_ID, g._nodes, (node_idx.key_col,), node_idx.engine) is not None
335370
n_keys, n_rows = node_idx.n_nodes, 0
371+
usable, reason = _index_usability(kind, idx.engine, valid, query_engine)
336372
rows.append({
337373
"name": idx.name or index_name(kind, idx.key_col),
338374
"kind": kind,
@@ -343,9 +379,14 @@ def show_indexes(g: Plottable) -> pd.DataFrame:
343379
"n_rows": n_rows,
344380
"nbytes": index_nbytes(idx),
345381
"valid": valid,
382+
"query_engine": query_engine.value,
383+
"usable": usable,
384+
"reason": reason,
346385
})
347386
for column in registry.node_prop_cols():
348387
prop = registry.node_props[column]
388+
prop_valid = registry.get_node_prop_valid(column, g._nodes, prop.engine) is not None
389+
usable, reason = _index_usability(NODE_PROP, prop.engine, prop_valid, query_engine)
349390
rows.append({
350391
"name": prop.name or index_name(NODE_PROP, column),
351392
"kind": NODE_PROP,
@@ -355,9 +396,15 @@ def show_indexes(g: Plottable) -> pd.DataFrame:
355396
"n_keys": prop.n_keys,
356397
"n_rows": prop.n_nodes,
357398
"nbytes": index_nbytes(prop),
358-
"valid": registry.get_node_prop_valid(column, g._nodes, prop.engine) is not None,
399+
"valid": prop_valid,
400+
"query_engine": query_engine.value,
401+
"usable": usable,
402+
"reason": reason,
359403
})
360-
cols = ["name", "kind", "key_col", "engine", "backend", "n_keys", "n_rows", "nbytes", "valid"]
404+
cols = [
405+
"name", "kind", "key_col", "engine", "backend", "n_keys", "n_rows", "nbytes",
406+
"valid", "query_engine", "usable", "reason",
407+
]
361408
return pd.DataFrame(rows, columns=cols)
362409

363410

graphistry/compute/gfql/index/explain.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def gfql_explain(
4242
) -> GfqlExplainReport:
4343
resolved_policy: IndexPolicy = validate_index_policy(index_policy) or "use"
4444
eng = resolve_engine(engine, g)
45-
resident = show_indexes(g)
45+
resident = show_indexes(g, engine=engine)
4646
with index_trace() as steps:
4747
try:
4848
g.gfql(query, engine=engine, index_policy=resolved_policy)

graphistry/compute/gfql/index/wire.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,5 +154,5 @@ def apply_index_op(g: Any, op: IndexOp, *, engine: Any = "auto") -> Any:
154154
raise ValueError(f"DROP GFQL INDEX: no resident index of kind {kind!r}")
155155
return drop_index(g, kind, column=op.column)
156156
if isinstance(op, ShowIndexes):
157-
return show_indexes(g)
157+
return show_indexes(g, engine=engine)
158158
raise ValueError(f"Unknown index op: {op!r}")

graphistry/tests/compute/gfql/index/test_index.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,3 +1399,117 @@ def test_engine_mismatch_reason_reports_all_undirected_indexes(
13991399
"resident edge_out_adj, edge_in_adj index "
14001400
f"engine={resident_engine}, requested engine={requested_engine} -> scan"
14011401
), reason
1402+
1403+
1404+
class TestShowIndexesEngineUsability:
1405+
"""#1767 disposition: ``show_indexes`` must stop reporting an index as fine when
1406+
the resolved query engine cannot use it. ``valid`` stays fingerprint-only (BC);
1407+
``query_engine``/``usable``/``reason`` add the engine-usability signal, reusing
1408+
#1838's mismatch-reason wording."""
1409+
1410+
def test_matching_engine_reports_usable(self, graph):
1411+
g = graph.create_index("edge_out_adj", engine="pandas")
1412+
row = g.show_indexes().iloc[0]
1413+
assert bool(row["valid"]) is True
1414+
assert row["query_engine"] == "pandas"
1415+
assert bool(row["usable"]) is True
1416+
assert row["reason"] is None
1417+
1418+
def test_polars_index_auto_query_not_usable(self, graph):
1419+
"""The disposition's silent cliff: polars-built index, AUTO query resolves
1420+
pandas -> fingerprint-fresh but engine-unusable, with the explain wording."""
1421+
pytest.importorskip("polars")
1422+
gi = graph.create_index("edge_out_adj", engine="polars")
1423+
row = gi.show_indexes().iloc[0] # AUTO: polars frames resolve to pandas queries
1424+
assert bool(row["valid"]) is True # fingerprint is fresh — that was never the bug
1425+
assert row["query_engine"] == "pandas"
1426+
assert bool(row["usable"]) is False
1427+
assert row["reason"] == (
1428+
"resident edge_out_adj index engine=polars, requested engine=pandas -> scan"
1429+
)
1430+
1431+
def test_polars_index_explicit_polars_query_usable(self, graph):
1432+
pytest.importorskip("polars")
1433+
gi = graph.create_index("edge_out_adj", engine="polars")
1434+
row = gi.show_indexes(engine="polars").iloc[0]
1435+
assert bool(row["valid"]) is True
1436+
assert row["query_engine"] == "polars"
1437+
assert bool(row["usable"]) is True
1438+
assert row["reason"] is None
1439+
1440+
def test_pandas_index_explicit_polars_query_not_usable(self, graph):
1441+
g = graph.create_index("edge_out_adj", engine="pandas")
1442+
row = g.show_indexes(engine="polars").iloc[0]
1443+
assert bool(row["valid"]) is True
1444+
assert bool(row["usable"]) is False
1445+
assert row["reason"] == (
1446+
"resident edge_out_adj index engine=pandas, requested engine=polars -> scan"
1447+
)
1448+
1449+
def test_stale_index_not_usable_even_when_engine_matches(self, graph):
1450+
g = graph.create_index("edge_out_adj", engine="pandas")
1451+
rng = np.random.default_rng(3)
1452+
g2 = g.edges(
1453+
pd.DataFrame({"src": rng.integers(0, 2000, 50), "dst": rng.integers(0, 2000, 50)}),
1454+
"src", "dst",
1455+
)
1456+
row = g2.show_indexes().iloc[0]
1457+
assert bool(row["valid"]) is False
1458+
assert row["query_engine"] == "pandas"
1459+
assert bool(row["usable"]) is False
1460+
assert row["reason"] == "stale fingerprint (frames rebound since build) -> rebuild"
1461+
1462+
def test_stale_and_engine_mismatched_reports_both(self, graph):
1463+
pytest.importorskip("polars")
1464+
gi = graph.create_index("edge_out_adj", engine="polars")
1465+
rng = np.random.default_rng(4)
1466+
g2 = gi.edges(
1467+
pd.DataFrame({"src": rng.integers(0, 2000, 50), "dst": rng.integers(0, 2000, 50)}),
1468+
"src", "dst",
1469+
)
1470+
row = g2.show_indexes(engine="polars").iloc[0]
1471+
assert bool(row["valid"]) is False
1472+
assert bool(row["usable"]) is False
1473+
assert row["reason"] == "stale fingerprint (frames rebound since build) -> rebuild"
1474+
row_pd = g2.show_indexes(engine="pandas").iloc[0]
1475+
assert bool(row_pd["usable"]) is False
1476+
assert row_pd["reason"] == (
1477+
"resident edge_out_adj index engine=polars, requested engine=pandas -> scan"
1478+
"; stale fingerprint (frames rebound since build) -> rebuild"
1479+
)
1480+
1481+
def test_every_kind_carries_per_row_reason(self, graph):
1482+
pytest.importorskip("polars")
1483+
gi = graph.gfql_index_all(engine="polars")
1484+
gi = gi.create_index("node_prop", column="lab", engine="polars")
1485+
si = gi.show_indexes() # AUTO -> pandas
1486+
assert set(si["kind"]) == {"edge_out_adj", "edge_in_adj", "node_id", "node_prop"}
1487+
assert si["valid"].all()
1488+
assert not si["usable"].any()
1489+
for _, row in si.iterrows():
1490+
assert row["reason"] == (
1491+
f"resident {row['kind']} index engine=polars, "
1492+
"requested engine=pandas -> scan"
1493+
)
1494+
1495+
def test_show_indexes_ddl_surface_respects_engine(self, graph):
1496+
pytest.importorskip("polars")
1497+
gi = graph.create_index("edge_out_adj", engine="polars")
1498+
si_auto = gi.gfql("SHOW GFQL INDEXES")
1499+
assert bool(si_auto.iloc[0]["usable"]) is False
1500+
si_pl = gi.gfql("SHOW GFQL INDEXES", engine="polars")
1501+
assert bool(si_pl.iloc[0]["usable"]) is True
1502+
assert si_pl.iloc[0]["reason"] is None
1503+
1504+
def test_cudf_index_pandas_query_not_usable(self, graph):
1505+
pytest.importorskip("cudf")
1506+
gi = graph.create_index("edge_out_adj", engine="cudf")
1507+
row = gi.show_indexes(engine="pandas").iloc[0]
1508+
assert bool(row["valid"]) is True
1509+
assert bool(row["usable"]) is False
1510+
assert row["reason"] == (
1511+
"resident edge_out_adj index engine=cudf, requested engine=pandas -> scan"
1512+
)
1513+
row_auto = gi.show_indexes().iloc[0] # AUTO on cudf frames resolves cudf
1514+
assert row_auto["query_engine"] == "cudf"
1515+
assert bool(row_auto["usable"]) is True

0 commit comments

Comments
 (0)