|
| 1 | +"""Route harness: every registered shape is tried against every chain route whose admission |
| 2 | +predicate admits it. Three pins per cell: the route SERVES (its lane answers; a lane that |
| 3 | +declines an admitted shape is recorded as an expected failure, the attenuation ledger), the |
| 4 | +answer matches the same engine's general path (all routes off) on node/edge values, and its |
| 5 | +node/edge sets match the pandas general path (the cross-engine oracle). Filed divergences are |
| 6 | +strict expected failures keyed by their tag, so they flip when fixed. |
| 7 | +""" |
| 8 | +import math |
| 9 | +import os |
| 10 | +from typing import Callable, Dict, List, NamedTuple, Tuple |
| 11 | + |
| 12 | +import pandas as pd |
| 13 | +import pytest |
| 14 | + |
| 15 | +import graphistry.compute.chain as chain_mod |
| 16 | +import graphistry.compute.gfql.lazy.engine.polars.chain as pchain |
| 17 | +from graphistry.Engine import Engine |
| 18 | +from graphistry.compute.ast import ASTObject |
| 19 | +from graphistry.compute.chain_specializations.admission import native_fast_path_admits |
| 20 | +from graphistry.compute.gfql.lazy.engine.polars.chain_specializations.admission import ( |
| 21 | + polars_plain_single_hop_admits, polars_seeded_lane_admits, |
| 22 | +) |
| 23 | +from graphistry.tests.compute.gfql.routes.registry import REGISTRY, Shape, graph_for |
| 24 | +from graphistry.tests.compute.gfql.routes.switch import ROUTES as ALL_ROUTES, routes_off |
| 25 | + |
| 26 | +import graphistry.tests.compute.gfql.routes.corpus # noqa: F401 registers routes.corpus |
| 27 | +import graphistry.tests.compute.test_chain # noqa: F401 registers test_chain.* |
| 28 | +import graphistry.tests.compute.test_chain_alias_column_collision # noqa: F401 registers collision.* |
| 29 | + |
| 30 | + |
| 31 | +class Route(NamedTuple): |
| 32 | + name: str |
| 33 | + engines: Tuple[str, ...] |
| 34 | + admits: Callable[[List[ASTObject], str], bool] |
| 35 | + lane: Tuple[object, str] |
| 36 | + indexed: bool |
| 37 | + |
| 38 | + |
| 39 | +ROUTES = [ |
| 40 | + Route("native-fast", ("pandas", "cudf"), |
| 41 | + lambda ops, engine: native_fast_path_admits(ops, Engine(engine), None) is not None, |
| 42 | + (chain_mod, "_try_chain_fast_path"), False), |
| 43 | + Route("polars-plain", ("polars",), |
| 44 | + lambda ops, engine: polars_plain_single_hop_admits(ops, None) is not None, |
| 45 | + (pchain, "_plain_single_hop_polars"), False), |
| 46 | + Route("polars-seeded", ("polars",), |
| 47 | + lambda ops, engine: polars_seeded_lane_admits(ops), |
| 48 | + (pchain, "_try_seeded_chain_polars"), True), |
| 49 | +] |
| 50 | + |
| 51 | +KNOWN: Dict[Tuple[str, str], str] = { # (route, tag) -> issue: strict xfail until it lands |
| 52 | + ("polars-plain", "#2053"): "graphistry/pygraphistry#2053", |
| 53 | +} |
| 54 | + |
| 55 | + |
| 56 | +class Case(NamedTuple): |
| 57 | + route: Route |
| 58 | + engine: str |
| 59 | + shape: Shape |
| 60 | + |
| 61 | + @property |
| 62 | + def id(self) -> str: |
| 63 | + return f"{self.route.name}/{self.engine}/{self.shape.name}" |
| 64 | + |
| 65 | + |
| 66 | +def _cases() -> List[Case]: |
| 67 | + out = [] |
| 68 | + for shape in REGISTRY.values(): |
| 69 | + for route in ROUTES: |
| 70 | + for engine in route.engines: |
| 71 | + try: |
| 72 | + admitted = route.admits(shape.build(), engine) |
| 73 | + except Exception: |
| 74 | + admitted = False |
| 75 | + if admitted: |
| 76 | + out.append(Case(route, engine, shape)) |
| 77 | + return out |
| 78 | + |
| 79 | + |
| 80 | +CASES = _cases() |
| 81 | + |
| 82 | + |
| 83 | +def _topd(df): |
| 84 | + if df is None: |
| 85 | + return None |
| 86 | + if hasattr(df, "to_pandas"): |
| 87 | + return df.to_pandas() |
| 88 | + return df |
| 89 | + |
| 90 | + |
| 91 | +def _canon(df) -> Tuple[Tuple[str, ...], List[Tuple]]: |
| 92 | + df = _topd(df) |
| 93 | + if df is None: |
| 94 | + return ((), []) |
| 95 | + cols = tuple(sorted(df.columns)) |
| 96 | + rows = [] |
| 97 | + for row in df[list(cols)].itertuples(index=False, name=None): |
| 98 | + rows.append(tuple(None if (isinstance(v, float) and math.isnan(v)) or v is pd.NA or v is pd.NaT else v for v in row)) |
| 99 | + rows.sort(key=repr) |
| 100 | + return cols, rows |
| 101 | + |
| 102 | + |
| 103 | +def _sig(res, frames) -> Tuple[List, List]: |
| 104 | + nn, ee = _topd(res._nodes), _topd(res._edges) |
| 105 | + nodes = sorted(nn[frames.node].tolist()) if nn is not None else [] |
| 106 | + edges = sorted(map(tuple, ee[[frames.src, frames.dst]].values.tolist())) if ee is not None and len(ee) else [] |
| 107 | + return nodes, edges |
| 108 | + |
| 109 | + |
| 110 | +def _served(case: Case, monkeypatch): |
| 111 | + mod, name = case.route.lane |
| 112 | + real = getattr(mod, name) |
| 113 | + calls = {"served": 0} |
| 114 | + |
| 115 | + def spy(*a, **k): |
| 116 | + out = real(*a, **k) |
| 117 | + calls["served"] += out is not None |
| 118 | + return out |
| 119 | + monkeypatch.setattr(mod, name, spy) |
| 120 | + return calls |
| 121 | + |
| 122 | + |
| 123 | +def _skip_unavailable(engine: str) -> None: |
| 124 | + if engine == "cudf": |
| 125 | + if os.environ.get("TEST_CUDF") != "1": |
| 126 | + pytest.skip("cuDF lane runs with TEST_CUDF=1") |
| 127 | + pytest.importorskip("cudf") |
| 128 | + if engine == "polars": |
| 129 | + pytest.importorskip("polars") |
| 130 | + |
| 131 | + |
| 132 | +@pytest.mark.parametrize("case", CASES, ids=[c.id for c in CASES]) |
| 133 | +def test_admitted_shape_is_served_and_matches_the_general_path(case: Case, request, monkeypatch): |
| 134 | + _skip_unavailable(case.engine) |
| 135 | + for tag in case.shape.tags: |
| 136 | + if (case.route.name, tag) in KNOWN: |
| 137 | + request.applymarker(pytest.mark.xfail(strict=True, reason=KNOWN[(case.route.name, tag)])) |
| 138 | + g = graph_for(case.shape, case.engine, indexed=case.route.indexed) |
| 139 | + calls = _served(case, monkeypatch) |
| 140 | + try: |
| 141 | + served = g.gfql(case.shape.build(), engine=case.engine) |
| 142 | + except Exception as served_exc: |
| 143 | + with routes_off(ALL_ROUTES): |
| 144 | + with pytest.raises(type(served_exc)): |
| 145 | + g.gfql(case.shape.build(), engine=case.engine) |
| 146 | + return |
| 147 | + with routes_off(ALL_ROUTES): |
| 148 | + general = g.gfql(case.shape.build(), engine=case.engine) |
| 149 | + oracle = _sig(graph_for(case.shape, "pandas").gfql(case.shape.build(), engine="pandas"), case.shape.frames) |
| 150 | + assert _canon(served._nodes) == _canon(general._nodes), f"{case.id}: node rows differ from the general path" |
| 151 | + assert _canon(served._edges) == _canon(general._edges), f"{case.id}: edge rows differ from the general path" |
| 152 | + assert _sig(served, case.shape.frames) == oracle, f"{case.id}: node/edge sets differ from the pandas general path" |
| 153 | + if calls["served"] == 0: |
| 154 | + pytest.xfail(f"{case.id}: admitted by the predicate, declined by the lane body (attenuation ledger)") |
| 155 | + |
| 156 | + |
| 157 | +def test_every_route_serves_most_of_what_it_admits(monkeypatch): |
| 158 | + """A lane that declines most admitted shapes has a predicate that no longer describes it.""" |
| 159 | + per_route: Dict[str, List[int]] = {} |
| 160 | + for case in CASES: |
| 161 | + if case.engine != ("polars" if case.route.name.startswith("polars") else "pandas"): |
| 162 | + continue |
| 163 | + if case.engine == "polars": |
| 164 | + pytest.importorskip("polars") |
| 165 | + g = graph_for(case.shape, case.engine, indexed=case.route.indexed) |
| 166 | + calls = _served(case, monkeypatch) |
| 167 | + try: |
| 168 | + g.gfql(case.shape.build(), engine=case.engine) |
| 169 | + except Exception: |
| 170 | + continue |
| 171 | + per_route.setdefault(case.route.name, []).append(calls["served"] > 0) |
| 172 | + for route, served in per_route.items(): |
| 173 | + assert sum(served) * 2 >= len(served), f"{route}: served {sum(served)} of {len(served)} admitted shapes" |
| 174 | + |
| 175 | + |
| 176 | +def test_every_route_has_admitted_shapes(): |
| 177 | + covered = {(c.route.name, c.engine) for c in CASES} |
| 178 | + for route in ROUTES: |
| 179 | + for engine in route.engines: |
| 180 | + assert (route.name, engine) in covered, f"{route.name}/{engine} admits no registered shape" |
0 commit comments