Skip to content

Commit 9d7a767

Browse files
committed
fix(gfql): expose structured index decisions
1 parent f968768 commit 9d7a767

5 files changed

Lines changed: 52 additions & 14 deletions

File tree

graphistry/compute/gfql/index/api.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from .policy import IndexPolicy, validate_index_policy
2626
from .types import (
2727
AdjacencyIndexKind, EdgeIndexDirection, HopDirection, IndexKind,
28-
IndexTrace, IndexTraceStep,
28+
IndexDecisionCode, IndexTrace, IndexTraceStep,
2929
)
3030

3131
# Private Plottable attachment keys. Keep access behind helpers.
@@ -140,6 +140,7 @@ def _record_indexed_traversal(
140140
"hop_details": [] if hop_details is None else hop_details,
141141
"path": path,
142142
"decision_reason": reason,
143+
"decision_code": "index_selected" if served else "index_path_unavailable",
143144
}))
144145

145146

@@ -514,6 +515,8 @@ def maybe_index_hop(
514515
Cost gate: only route to the index when (a) a valid matching index is resident
515516
(or buildable under auto/force), (b) the query is covered, (c) the frontier is
516517
not so large that a full scan is cheaper. Correctness is identical either way.
518+
"force" bypasses only the cost gate for a covered query. It still falls back to
519+
scan when the index cannot serve the query.
517520
"""
518521
resolved_policy: IndexPolicy = validate_index_policy(policy) or "use"
519522

@@ -533,16 +536,18 @@ def maybe_index_hop(
533536
except (AttributeError, TypeError, ValueError):
534537
pass
535538

536-
def _bail(reason: str) -> Optional[Plottable]:
539+
def _bail(reason: str, decision_code: IndexDecisionCode) -> Optional[Plottable]:
537540
if trace:
538-
_record(cast(IndexTraceStep, {**diag, "path": "scan", "decision_reason": reason}))
541+
_record(cast(IndexTraceStep, {
542+
**diag, "path": "scan", "decision_reason": reason, "decision_code": decision_code,
543+
}))
539544
return None
540545

541546
if resolved_policy == "off":
542-
return _bail("policy=off")
547+
return _bail("policy=off", "policy_off")
543548
registry = get_registry(g)
544549
if registry.is_empty() and resolved_policy not in ("auto", "force"):
545-
return _bail("no resident index (policy=use)")
550+
return _bail("no resident index (policy=use)", "no_resident_index")
546551

547552
min_hops = cast(Optional[int], rest.get("min_hops"))
548553
max_hops = cast(Optional[int], rest.get("max_hops"))
@@ -575,18 +580,18 @@ def _bail(reason: str) -> Optional[Plottable]:
575580
target_wave_front=target_wave_front,
576581
return_as_wave_front=return_as_wave_front,
577582
):
578-
return _bail("query not index-coverable")
583+
return _bail("query not index-coverable", "not_index_coverable")
579584
assert nodes is not None
580585

581586
node_col = g._node
582587
src, dst = g._source, g._destination
583588
if node_col is None or src is None or dst is None or g._edges is None or g._nodes is None:
584-
return _bail("graph missing node/edge columns")
589+
return _bail("graph missing node/edge columns", "missing_graph_columns")
585590

586591
if resolved_policy in ("auto", "force"):
587592
registry = _ensure_indexes(g, registry, direction, engine, resolved_policy, nodes, src, dst, node_col)
588593
if registry.is_empty():
589-
return _bail("no index available (build declined)")
594+
return _bail("no index available (build declined)", "index_build_declined")
590595

591596
# Cost gate: if the frontier covers a large fraction of distinct sources, the
592597
# scan path is competitive — fall back (avoids index overhead on bulk-ish hops).
@@ -612,7 +617,7 @@ def _bail(reason: str) -> Optional[Plottable]:
612617
if idx0.n_keys > 0 and frontier_n >= frac * idx0.n_keys:
613618
return _bail(
614619
f"frontier {frontier_n} >= {frac}*n_keys "
615-
f"({frac * idx0.n_keys:.0f}) -> scan cheaper"
620+
f"({frac * idx0.n_keys:.0f}) -> scan cheaper", "scan_cost"
616621
)
617622
except (AttributeError, TypeError, ValueError):
618623
pass
@@ -629,12 +634,20 @@ def _bail(reason: str) -> Optional[Plottable]:
629634
edge_match=cast(Optional[dict], rest.get("edge_match")),
630635
)
631636
if trace:
637+
engine_mismatch_reason = (
638+
_engine_mismatch_reason(registry, direction, engine) if result is None else None
639+
)
632640
_record(cast(IndexTraceStep, {
633641
**diag, "hops": eff_hops,
634642
"path": "index" if result is not None else "scan",
635643
"decision_reason": (
636644
"frontier below cost gate -> index" if result is not None
637-
else _engine_mismatch_reason(registry, direction, engine) or "index path not applicable -> scan"
645+
else engine_mismatch_reason or "index path not applicable -> scan"
646+
),
647+
"decision_code": (
648+
"index_selected" if result is not None
649+
else "engine_mismatch" if engine_mismatch_reason is not None
650+
else "index_path_unavailable"
638651
),
639652
}))
640653
return result

graphistry/compute/gfql/index/explain.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from graphistry.compute.gfql.query_types import GFQLQuery
1414
from .api import index_trace, show_indexes
1515
from .policy import IndexPolicy, validate_index_policy
16-
from .types import IndexTraceStep
16+
from .types import IndexDecisionCode, IndexTraceStep
1717

1818
if TYPE_CHECKING:
1919
from graphistry.compute.ComputeMixin import ComputeMixin
@@ -29,6 +29,7 @@ class GfqlExplainReport(TypedDict):
2929
est_result_rows: Optional[int]
3030
chosen_direction: Optional[str]
3131
decision_reason: Optional[str]
32+
decision_code: Optional[IndexDecisionCode]
3233
error: Optional[str]
3334

3435

@@ -54,6 +55,8 @@ def gfql_explain(
5455
# of seeds; `est_result_rows` = estimated fanout (Σ seed degree, free from CSR).
5556
ref = [s for s in steps if s.get("path") == "index"] or list(steps)
5657
last = ref[-1] if ref else {}
58+
if not last and resolved_policy == "off":
59+
last = {"decision_reason": "policy=off", "decision_code": "policy_off"}
5760
resident_names = cast(List[str], resident["name"].tolist() if len(resident) else [])
5861
return {
5962
"engine": eng.value,
@@ -65,5 +68,6 @@ def gfql_explain(
6568
"est_result_rows": cast(Optional[int], last.get("est_result_rows")),
6669
"chosen_direction": cast(Optional[str], last.get("direction")),
6770
"decision_reason": cast(Optional[str], last.get("decision_reason")),
71+
"decision_code": last.get("decision_code"),
6872
"error": error,
6973
}

graphistry/compute/gfql/index/policy.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
def validate_index_policy(policy: Optional[str]) -> Optional[IndexPolicy]:
1111
"""Validate a public ``index_policy`` value.
1212
13-
``None`` means the caller did not override the default planner behavior.
13+
``None`` means the caller did not override the default planner behavior. "force"
14+
is a performance and diagnostic opt-in: it builds missing indexes and bypasses the
15+
cost gate for an index-coverable query. It does not require index coverage and never
16+
changes query results. A query that the index cannot serve still uses the scan path.
1417
"""
1518
if policy is None:
1619
return None

graphistry/compute/gfql/index/types.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@
2121

2222

2323
IndexPath = Literal["scan", "index"]
24+
IndexDecisionCode = Literal[
25+
"policy_off",
26+
"no_resident_index",
27+
"not_index_coverable",
28+
"missing_graph_columns",
29+
"index_build_declined",
30+
"scan_cost",
31+
"index_selected",
32+
"engine_mismatch",
33+
"index_path_unavailable",
34+
]
2435

2536

2637
# One column's constraint in an ``edge_match``/filter dict — exactly the runtime shapes
@@ -54,6 +65,7 @@ class IndexTraceStep(TypedDict, total=False):
5465
frontier_n: int
5566
path: IndexPath
5667
decision_reason: str
68+
decision_code: IndexDecisionCode
5769
n_keys: int
5870
seed_deg_sum: Optional[int]
5971
est_result_rows: Optional[int]

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,12 +230,16 @@ def test_index_policy_force_and_explain(graph, engine):
230230
chain = [n({"id": 0}), e_forward(hops=1)]
231231
rep_off = graph.gfql_explain(chain, index_policy="off", engine=engine)
232232
assert rep_off["used_index"] is False
233+
assert rep_off["decision_code"] == "policy_off"
234+
assert rep_off["decision_reason"] == "policy=off"
233235
rep_force = graph.gfql_explain(chain, index_policy="force", engine=engine)
234236
assert rep_force["used_index"] is True
237+
assert rep_force["decision_code"] == "index_selected"
235238
# results identical regardless of policy
236239
r_scan = graph.gfql(chain, engine=engine)
237240
r_force = graph.gfql(chain, index_policy="force", engine=engine)
238241
assert _sig(r_scan) == _sig(r_force)
242+
assert rep_force["error"] is None
239243

240244

241245
@pytest.mark.parametrize("engine", ENGINES)
@@ -261,6 +265,7 @@ def test_explain_exposes_planner_diagnostics(graph, engine):
261265
gi = graph.gfql_index_all(engine=engine)
262266
rep_off = gi.gfql_explain(chain, index_policy="off", engine=engine)
263267
assert rep_off["used_index"] is False
268+
assert rep_off["decision_code"] == "policy_off"
264269
assert rep_off["decision_reason"] == "policy=off", rep_off
265270

266271

@@ -506,7 +511,7 @@ def test_index_min_two_bounded_range_scans_pandas(graph):
506511
indexed = _force(graph, "pandas").hop(nodes=seeds, engine="pandas", **kwargs)
507512
assert _sig(base) == _sig(indexed)
508513
assert not any(step["path"] == "index" for step in steps), steps
509-
assert any(step["decision_reason"] == "query not index-coverable" for step in steps), steps
514+
assert any(step["decision_code"] == "not_index_coverable" for step in steps), steps
510515

511516
@pytest.mark.parametrize("engine", ENGINES)
512517
def test_index_duplicate_node_ids(engine):
@@ -848,7 +853,8 @@ def test_chain_range_with_auto_labels_stays_on_scan(typed_graph):
848853
assert _sig_typed(base) == _sig_typed(indexed)
849854
assert rep["used_index"] is False, (engine, rep)
850855
assert not any(step["path"] == "index" for step in steps), steps
851-
assert any(step["decision_reason"] == "query not index-coverable" for step in steps), steps
856+
assert rep["decision_code"] == "not_index_coverable", rep
857+
assert any(step["decision_code"] == "not_index_coverable" for step in steps), steps
852858

853859

854860
def test_rebind_edges_revalidates_after_shallow_augmentation():

0 commit comments

Comments
 (0)