diff --git a/.github/workflows/ci-gpu.yml b/.github/workflows/ci-gpu.yml index 9a23e6a923..be288b50dd 100644 --- a/.github/workflows/ci-gpu.yml +++ b/.github/workflows/ci-gpu.yml @@ -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: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07b53cc0d2..26bf5ccbfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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: @@ -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: @@ -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: @@ -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: @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7207297d9f..6325aa9114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 87cb8101c6..b9b066c429 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -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 @@ -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 @@ -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, @@ -774,8 +773,6 @@ 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 @@ -783,7 +780,7 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni stats = extract_graph_stats(graph_for_stats) current_path = context.operation_path - postchain_context: PolicyContext = { + postchain_context: 'PolicyContext' = { 'phase': 'postchain', 'hook': 'postchain', 'query': ops, @@ -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, diff --git a/graphistry/compute/chain_let.py b/graphistry/compute/chain_let.py index 68662c8f95..6b6c1d59f5 100644 --- a/graphistry/compute/chain_let.py +++ b/graphistry/compute/chain_let.py @@ -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 @@ -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 @@ -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 @@ -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, @@ -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, @@ -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, @@ -526,8 +522,6 @@ 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 @@ -535,7 +529,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, stats = extract_graph_stats(graph_for_stats) current_path = context.operation_path - postletbinding_context: PolicyContext = { + postletbinding_context: 'PolicyContext' = { 'phase': 'postletbinding', 'hook': 'postletbinding', 'query': dag, @@ -610,8 +604,6 @@ 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 @@ -619,7 +611,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, stats = extract_graph_stats(graph_for_stats) current_path = context.operation_path - postlet_context: PolicyContext = { + postlet_context: 'PolicyContext' = { 'phase': 'postlet', 'hook': 'postlet', 'query': dag, @@ -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, diff --git a/graphistry/compute/gfql/call_executor.py b/graphistry/compute/gfql/call_executor.py index ace7f1e2c7..b6c0fdb3c8 100644 --- a/graphistry/compute/gfql/call_executor.py +++ b/graphistry/compute/gfql/call_executor.py @@ -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() @@ -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 @@ -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' @@ -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