Skip to content
3 changes: 2 additions & 1 deletion .github/workflows/ci-gpu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ jobs:

strategy:
matrix:
python-version: ['3.10', 3.11, 3.12]
# RAPIDS GPU images currently support <= 3.13
python-version: ['3.10', 3.11, 3.12, '3.13']

steps:

Expand Down
14 changes: 8 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:

strategy:
matrix:
python-version: [3.8, 3.9, '3.10', 3.11, 3.12] # Run lint/types on all versions
python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] # Run lint/types on all versions

steps:
- name: Checkout repo
Expand Down Expand Up @@ -108,7 +108,7 @@ jobs:

strategy:
matrix:
python-version: [3.8, 3.9, '3.10', 3.11, 3.12]
python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14']

steps:

Expand Down Expand Up @@ -153,7 +153,7 @@ jobs:

strategy:
matrix:
python-version: [3.8, 3.9, '3.10', 3.11, 3.12]
python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14']

steps:

Expand Down Expand Up @@ -191,7 +191,7 @@ jobs:

strategy:
matrix:
python-version: [3.8, 3.9, '3.10', 3.11, 3.12]
python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14']

steps:

Expand Down Expand Up @@ -257,7 +257,8 @@ jobs:

strategy:
matrix:
python-version: [3.9, '3.10', 3.11, 3.12]
# RAPIDS (umap-learn/numba) lacks Python 3.14 wheels as of 2025-11, so keep <=3.13 here
python-version: [3.9, '3.10', 3.11, 3.12, '3.13']

steps:

Expand Down Expand Up @@ -300,7 +301,8 @@ jobs:

strategy:
matrix:
python-version: [3.9, '3.10', 3.11, 3.12]
# RAPIDS stack not available on Python 3.14 yet
python-version: [3.9, '3.10', 3.11, 3.12, '3.13']
#include:
# - python-version: 3.12
# continue-on-error: true
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **GFQL / Oracle**: Introduced `graphistry.gfql.ref.enumerator`, a pandas-only reference implementation that enumerates fixed-length chains, enforces local + same-path predicates, applies strict null semantics, enforces safety caps, and emits alias tags/optional path bindings for use as a correctness oracle.

### Tests
- **CI / Python**: Expand GitHub Actions coverage to Python 3.13 + 3.13/3.14 for CPU lint/type/test jobs, while pinning RAPIDS-dependent CPU/GPU suites to <=3.13 until NVIDIA publishes 3.14 wheels (ensures lint/mypy/pytest signal on the latest interpreter without breaking RAPIDS installs).
- **GFQL**: Added deterministic + property-based oracle tests (triangles, alias reuse, cuDF conversions, Hypothesis) plus parity checks ensuring pandas GFQL chains match the oracle outputs.
- **Layouts**: Added comprehensive test coverage for `circle_layout()` and `group_in_a_box_layout()` with partition support (CPU/GPU)

Expand Down
17 changes: 6 additions & 11 deletions graphistry/compute/chain.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import Dict, Union, cast, List, Tuple, Sequence, Optional, TYPE_CHECKING
from typing import Dict, Union, cast, List, Tuple, Optional, TYPE_CHECKING, Callable, Any
from graphistry.Engine import Engine, EngineAbstract, df_concat, df_to_engine, resolve_engine

from graphistry.Plottable import Plottable
Expand All @@ -11,6 +11,8 @@
from .typing import DataFrameT
from .util import generate_safe_column_name
from graphistry.compute.validate.validate_schema import validate_chain_schema
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

if TYPE_CHECKING:
from graphistry.compute.exceptions import GFQLSchemaError, GFQLValidationError
Expand Down Expand Up @@ -627,13 +629,10 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni

# Prechain hook - fires BEFORE chain operations execute
if policy and 'prechain' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

stats = extract_graph_stats(g)
current_path = context.operation_path

prechain_context: PolicyContext = {
prechain_context: 'PolicyContext' = {
'phase': 'prechain',
'hook': 'prechain',
'query': ops,
Expand Down Expand Up @@ -774,16 +773,14 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni
# Postchain hook - fires AFTER chain operations complete (even on error)
postchain_policy_error = None
if policy and 'postchain' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

# Extract stats from result (if success) or input graph (if error)
# Cast: if success=True, g_out is guaranteed to be a Plottable
graph_for_stats = cast(Plottable, g_out) if success else self
stats = extract_graph_stats(graph_for_stats)
current_path = context.operation_path

postchain_context: PolicyContext = {
postchain_context: 'PolicyContext' = {
'phase': 'postchain',
'hook': 'postchain',
'query': ops,
Expand Down Expand Up @@ -812,15 +809,13 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni
# Postload policy phase - ALWAYS fires (even on error)
policy_error = None
if policy and 'postload' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

# Extract stats from result (if success) or input graph (if error)
# Cast: if success=True, g_out is guaranteed to be a Plottable
graph_for_stats = cast(Plottable, g_out) if success else self
stats = extract_graph_stats(graph_for_stats)

policy_context: PolicyContext = {
policy_context: 'PolicyContext' = {
'phase': 'postload',
'hook': 'postload',
'query': ops,
Expand Down
30 changes: 10 additions & 20 deletions graphistry/compute/chain_let.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING
from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING, Callable, Any
from typing_extensions import Literal
import pandas as pd
from graphistry.Engine import Engine, EngineAbstract, resolve_engine
Expand All @@ -7,6 +7,8 @@
from .ast import ASTObject, ASTLet, ASTRef, ASTRemoteGraph, ASTNode, ASTEdge, ASTCall
from .execution_context import ExecutionContext
from .engine_coercion import ensure_engine_match
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

if TYPE_CHECKING:
from graphistry.compute.chain import Chain
Expand Down Expand Up @@ -277,9 +279,8 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g:

# Preload policy phase for remote data loading
if policy and 'preload' in policy:
from .gfql.policy import PolicyContext, PolicyException

preload_context: PolicyContext = {
preload_context: 'PolicyContext' = {
'phase': 'preload',
'hook': 'preload',
'query': global_query if global_query else ast_obj, # Global query if available
Expand Down Expand Up @@ -314,12 +315,10 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g:

# Postload policy phase for remote data
if policy and 'postload' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

stats = extract_graph_stats(result)

postload_context: PolicyContext = {
postload_context: 'PolicyContext' = {
'phase': 'postload',
'hook': 'postload',
'query': global_query if global_query else ast_obj,
Expand Down Expand Up @@ -431,13 +430,11 @@ def chain_let_impl(g: Plottable, dag: ASTLet,
try:
# Prelet hook - fires BEFORE any bindings execute
if policy and 'prelet' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

stats = extract_graph_stats(g)
current_path = context.operation_path

prelet_context: PolicyContext = {
prelet_context: 'PolicyContext' = {
'phase': 'prelet',
'hook': 'prelet',
'query': dag,
Expand Down Expand Up @@ -466,13 +463,12 @@ def chain_let_impl(g: Plottable, dag: ASTLet,

# Preletbinding hook - fires BEFORE binding execution
if policy and 'preletbinding' in policy:
from .gfql.policy import PolicyContext, PolicyException

current_path = context.operation_path
# Build path that includes this binding (even though we haven't pushed yet)
binding_path = f"{current_path}.binding:{node_name}"

preletbinding_context: PolicyContext = {
preletbinding_context: 'PolicyContext' = {
'phase': 'preletbinding',
'hook': 'preletbinding',
'query': dag,
Expand Down Expand Up @@ -526,16 +522,14 @@ def chain_let_impl(g: Plottable, dag: ASTLet,
# Postletbinding hook - fires AFTER binding execution (even on error)
policy_error = None
if policy and 'postletbinding' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

# Extract stats from binding result (if success) or current graph (if error)
# Cast: if binding_success=True, binding_result is guaranteed to be a Plottable
graph_for_stats = cast(Plottable, binding_result) if binding_success else accumulated_result
stats = extract_graph_stats(graph_for_stats)

current_path = context.operation_path
postletbinding_context: PolicyContext = {
postletbinding_context: 'PolicyContext' = {
'phase': 'postletbinding',
'hook': 'postletbinding',
'query': dag,
Expand Down Expand Up @@ -610,16 +604,14 @@ def chain_let_impl(g: Plottable, dag: ASTLet,
# Postlet hook - fires AFTER all bindings complete (even on error)
postlet_policy_error = None
if policy and 'postlet' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

# Extract stats from result (if success) or input graph (if error)
# Cast: if success=True, result is guaranteed to be a Plottable
graph_for_stats = cast(Plottable, result) if success else g
stats = extract_graph_stats(graph_for_stats)
current_path = context.operation_path

postlet_context: PolicyContext = {
postlet_context: 'PolicyContext' = {
'phase': 'postlet',
'hook': 'postlet',
'query': dag,
Expand Down Expand Up @@ -648,15 +640,13 @@ def chain_let_impl(g: Plottable, dag: ASTLet,
# Postload policy phase - ALWAYS fires (even on error)
policy_error = None
if policy and 'postload' in policy:
from .gfql.policy import PolicyContext, PolicyException
from .gfql.policy.stats import extract_graph_stats

# Extract stats from result (if success) or input graph (if error)
# Cast: if success=True, result is guaranteed to be a Plottable
graph_for_stats = cast(Plottable, result) if success else g
stats = extract_graph_stats(graph_for_stats)

context_dict: PolicyContext = {
context_dict: 'PolicyContext' = {
'phase': 'postload',
'hook': 'postload',
'query': dag,
Expand Down
21 changes: 10 additions & 11 deletions graphistry/compute/gfql/call_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
"""

import threading
from typing import Dict, Any, cast, Optional, TYPE_CHECKING
import time
from typing import Dict, Any, cast, Optional, TYPE_CHECKING, Callable, Tuple
from graphistry.Plottable import Plottable
from graphistry.Engine import Engine
from graphistry.compute.gfql.call_safelist import validate_call_params
from graphistry.compute.exceptions import ErrorCode, GFQLTypeError
from graphistry.compute.engine_coercion import ensure_engine_match
from graphistry.compute.gfql.policy import PolicyContext, PolicyException
from graphistry.compute.gfql.policy.stats import extract_graph_stats

if TYPE_CHECKING:
from graphistry.compute.execution_context import ExecutionContext
from graphistry.compute.gfql.policy.stats import GraphStats

# Thread-local storage for policy context
_thread_local = threading.local()
Expand Down Expand Up @@ -49,18 +53,14 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En
# Precall policy phase - before executing call operation
final_params = params

import time

if policy and 'precall' in policy:
from graphistry.compute.gfql.policy import PolicyContext, PolicyException
from graphistry.compute.gfql.policy.stats import extract_graph_stats

stats = extract_graph_stats(g)

current_path = context.operation_path
# Build path that includes this call (even though we haven't pushed yet)
call_path = f"{current_path}.call:{function}"

policy_context: PolicyContext = {
policy_context: 'PolicyContext' = {
'phase': 'precall',
'hook': 'precall',
'query': None, # Not available in call context
Expand Down Expand Up @@ -155,8 +155,7 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En
# Postcall policy phase - ALWAYS fires (even on error)
policy_error = None
if policy and 'postcall' in policy:
from graphistry.compute.gfql.policy import PolicyContext, PolicyException
from graphistry.compute.gfql.policy.stats import extract_graph_stats
result_stats: Optional['GraphStats'] = None

# Extract stats from result (if success) or input graph (if error)
# IMPORTANT: hypergraph can return DataFrame when return_as != 'graph'
Expand All @@ -167,14 +166,14 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En
elif success:
# Result is not a Plottable (e.g., DataFrame from hypergraph) - use input graph for stats
graph_for_stats = g
result_stats = {} # Can't extract stats from DataFrame
result_stats = None # Can't extract stats from DataFrame
else:
# Error case - use input graph
graph_for_stats = g
result_stats = extract_graph_stats(graph_for_stats)

current_path = context.operation_path
postcall_context: PolicyContext = {
postcall_context: 'PolicyContext' = {
'phase': 'postcall',
'hook': 'postcall',
'query': None, # Not available in call context
Expand Down
Loading