Skip to content

Commit 3170597

Browse files
authored
Merge pull request #895 from graphistry/split/letref-list-bindings
GFQL let: list bindings + letrec ref
2 parents 8d717c1 + 79c2d3a commit 3170597

13 files changed

Lines changed: 137 additions & 44 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1010

1111
### Fixed
1212
- **Tests / GFQL**: Fixed cuDF compatibility in test files by using `to_set()` helper instead of `.tolist()` (cuDF doesn't support `tolist()`)
13+
- **GFQL / let-ref**: Documented and tested existing letrec semantics for ref; list bindings now accept implicit Chains.
1314
- **GFQL / schema**: Apply call() schema effects during validation so enrichments like `get_degrees` are recognized by downstream filters, and prioritize boundary-call validation before schema errors.
1415
- **GFQL / filters**: Treat boolean literal filters on object-typed columns as booleans instead of numeric mismatches.
1516

docs/source/gfql/about.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -440,17 +440,18 @@ GFQL's Let bindings enable you to sequence complex graph programs as directed ac
440440

441441
::
442442

443-
from graphistry import let, ref, call, Chain
443+
from graphistry import let, ref, call, n, e_forward, e, gt
444444

445445
result = g.gfql(let({
446446
# Stage 1: Find suspicious accounts
447-
'suspicious_accounts': [n({'risk_score': gt(80), 'created_recent': True})],
447+
'suspicious_accounts': n({'risk_score': gt(80), 'created_recent': True}),
448448

449449
# Stage 2: Trace money flows from suspicious accounts
450-
'money_flows': ref('suspicious_accounts', [
450+
'money_flows': [
451+
n({'risk_score': gt(80), 'created_recent': True}),
451452
e_forward({'type': 'transfer', 'amount': gt(10000)}, hops=3),
452453
n()
453-
]),
454+
],
454455

455456
# Stage 3: Compute PageRank to find central nodes
456457
'ranked': ref('money_flows', [

docs/source/gfql/builtin_calls.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Call operations are invoked using the ``call()`` function within GFQL chains or
2929
'persons': n({'type': 'person'}),
3030
'with_degrees': ref('persons', [call('get_degrees', {'col': 'degree'})]),
3131
'high_degree': ref('with_degrees', [n({'degree': gt(10)})]),
32-
'connected': ref('high_degree', [e_forward(), n()])
32+
'connected': ref('with_degrees', [n({'degree': gt(10)}), e_forward(), n()])
3333
}))
3434
3535
All Call operations:

docs/source/gfql/overview.rst

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,16 +181,17 @@ GFQL Let approach (declarative DAG with named bindings):
181181

182182
.. code-block:: python
183183
184-
from graphistry import let, ref, Chain
184+
from graphistry import let, ref, n, e_forward, ge
185185
186186
# GFQL Let: Define a DAG of named operations
187187
result = g.gfql(let({
188-
'persons': [n({'type': 'person'})],
188+
'persons': n({'type': 'person'}),
189189
'adults': ref('persons', [n({'age': ge(18)})]), # Reference and filter persons
190-
'connections': ref('adults', [
190+
'connections': [
191+
n({'type': 'person', 'age': ge(18)}),
191192
e_forward({'type': 'knows'}),
192193
n() # Find connections from adults
193-
])
194+
]
194195
}))
195196
196197
# Access any named result from the DAG

docs/source/gfql/quick.rst

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -339,20 +339,21 @@ Remote Mode
339339
Let Bindings and DAG Patterns
340340
-----------------------------
341341

342-
Use Let bindings to create directed acyclic graph (DAG) patterns with named operations:
342+
Use Let bindings to create directed acyclic graph (DAG) patterns with named operations. Lists are treated as implicit Chains.
343343

344344
- **Basic Let with named bindings:**
345345

346346
.. code-block:: python
347347
348-
from graphistry import let, ref, Chain
348+
from graphistry import let, ref, n, e_forward, gt
349349
350350
result = g.gfql(let({
351-
'suspects': [n({'risk_score': gt(80)})],
352-
'connections': ref('suspects', [
351+
'suspects': n({'risk_score': gt(80)}),
352+
'connections': [
353+
n({'risk_score': gt(80)}),
353354
e_forward({'type': 'transaction'}),
354355
n()
355-
])
356+
]
356357
}))
357358
358359
# Access results by name
@@ -363,14 +364,15 @@ Use Let bindings to create directed acyclic graph (DAG) patterns with named oper
363364

364365
.. code-block:: python
365366
366-
from graphistry import Chain
367+
from graphistry import let, ref, n, e_forward, gt
367368
368369
result = g.gfql(let({
369-
'high_value': [n({'balance': gt(100000)})],
370-
'large_transfers': ref('high_value', [
370+
'high_value': n({'balance': gt(100000)}),
371+
'large_transfers': [
372+
n({'balance': gt(100000)}),
371373
e_forward({'type': 'transfer', 'amount': gt(10000)}),
372374
n()
373-
]),
375+
],
374376
'suspicious': ref('large_transfers', [
375377
n({'created_recent': True, 'verified': False})
376378
])
@@ -469,7 +471,8 @@ Reference graphs on remote servers for distributed computing:
469471
'high_risk': ref('remote_data', [
470472
n({'risk_score': gt(95)})
471473
]),
472-
'connections': ref('high_risk', [
474+
'connections': ref('remote_data', [
475+
n({'risk_score': gt(95)}),
473476
e_forward({'type': 'transaction'}),
474477
n()
475478
])

docs/source/gfql/spec/llm_guide.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939

4040
## Quick Example: Fraud Detection
4141

42-
**Dense:** `let({'suspicious': n({'risk_score': gt(80)}), 'flows': ref('suspicious', [e_forward(min_hops=1, max_hops=3), n()]), 'ranked': ref('flows', [call('compute_cugraph', {'alg': 'pagerank'})]), 'viz': ref('ranked', [call('encode_point_color', {...}), call('encode_point_icon', {...})])})`
42+
**Dense:** `let({'suspicious': n({'risk_score': gt(80)}), 'flows': [n({'risk_score': gt(80)}), e_forward(min_hops=1, max_hops=3), n()], 'ranked': ref('flows', [call('compute_cugraph', {'alg': 'pagerank'})]), 'viz': ref('ranked', [call('encode_point_color', {...}), call('encode_point_icon', {...})])})`
4343

4444
**JSON:**
4545
```json
@@ -51,9 +51,9 @@
5151
"chain": [{"type": "Node", "filter_dict": {"risk_score": {"type": "GT", "val": 80}}}]
5252
},
5353
"flows": {
54-
"type": "ChainRef",
55-
"ref": "suspicious",
54+
"type": "Chain",
5655
"chain": [
56+
{"type": "Node", "filter_dict": {"risk_score": {"type": "GT", "val": 80}}},
5757
{"type": "Edge", "direction": "forward", "min_hops": 1, "max_hops": 3, "to_fixed_point": false,
5858
"edge_match": {"amount": {"type": "GT", "val": 10000}}},
5959
{"type": "Node", "filter_dict": {}}

docs/source/gfql/spec/python_embedding.md

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -332,16 +332,17 @@ GFQL supports directed acyclic graph (DAG) patterns using Let bindings, which al
332332
### Let Bindings
333333

334334
```python
335-
from graphistry import let, ref, n, e_forward
335+
from graphistry import let, ref, n, e_forward, ge
336336

337337
# Define DAG patterns with named bindings
338338
result = g.gfql(let({
339339
'persons': n({'type': 'person'}),
340340
'adults': ref('persons', [n({'age': ge(18)})]),
341-
'connections': ref('adults', [
341+
'connections': [
342+
n({'type': 'person', 'age': ge(18)}),
342343
e_forward({'type': 'knows'}),
343-
ref('adults') # Find connections between adults
344-
])
344+
n({'type': 'person', 'age': ge(18)})
345+
]
345346
}))
346347

347348
# Access individual binding results
@@ -352,7 +353,9 @@ connection_edges = result._edges[result._edges['connections']]
352353

353354
### Ref (Reference to Named Bindings)
354355

355-
The `ref()` function creates references to named bindings within a Let:
356+
The `ref()` function creates references to named bindings within a Let.
357+
Ref chains run on the referenced graph; bindings created by `n()` contain nodes only,
358+
so edge traversals need a binding that preserves edges (for example, via a list or `Chain([...])`).
356359

357360
```python
358361
# Basic reference - just the binding result
@@ -361,13 +364,21 @@ result = g.gfql(let({
361364
'extended': ref('base') # Just references 'base'
362365
}))
363366

364-
# Reference with additional operations
367+
# Reference with additional operations (node-only refinements)
365368
result = g.gfql(let({
366369
'suspects': n({'risk_score': gt(80)}),
367-
'lateral_movement': ref('suspects', [
370+
'verified': ref('suspects', [
371+
n({'verified': True})
372+
])
373+
}))
374+
375+
# For traversals, inline the seed filter into a list or Chain binding
376+
result = g.gfql(let({
377+
'lateral_movement': [
378+
n({'risk_score': gt(80)}),
368379
e_forward({'type': 'ssh', 'failed_attempts': gt(5)}),
369380
n({'type': 'server'})
370-
])
381+
]
371382
}))
372383
```
373384

@@ -380,10 +391,11 @@ result = g.gfql(let({
380391
'high_value': n({'balance': gt(100000)}),
381392

382393
# Find transactions from high-value accounts
383-
'large_transfers': ref('high_value', [
394+
'large_transfers': [
395+
n({'balance': gt(100000)}),
384396
e_forward({'type': 'transfer', 'amount': gt(10000)}),
385397
n()
386-
]),
398+
],
387399

388400
# Find suspicious patterns
389401
'suspicious': ref('large_transfers', [
@@ -413,8 +425,12 @@ Call operations can be used within Let bindings for complex workflows:
413425

414426
```python
415427
result = g.gfql(let({
416-
# Initial filtering
417-
'suspects': n({'flagged': True}),
428+
# Initial filtering with edges preserved for graph algorithms
429+
'suspects': Chain([
430+
n({'flagged': True}),
431+
e_undirected(),
432+
n({'flagged': True})
433+
]),
418434

419435
# Compute PageRank on subgraph
420436
'ranked': ref('suspects', [

docs/source/gfql/spec/wire_protocol.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,12 @@ let({
183183

184184
### ChainRef Operation
185185

186+
ChainRef executes on the referenced graph; bindings used for edge traversal should retain edges
187+
(for example, from an ``Edge`` or ``Chain`` binding).
188+
186189
**Python**:
187190
```python
188-
ref('base_nodes', [
191+
ref('base_graph', [
189192
e_forward({'weight': gt(0.5)}),
190193
n({'status': 'active'})
191194
])
@@ -195,7 +198,7 @@ ref('base_nodes', [
195198
```json
196199
{
197200
"type": "ChainRef",
198-
"ref": "base_nodes",
201+
"ref": "base_graph",
199202
"chain": [
200203
{
201204
"type": "Edge",

graphistry/compute/ast.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,7 @@ class ASTLet(ASTObject):
885885
886886
Parameters
887887
----------
888-
bindings : Dict[str, Union[ASTObject, Chain, Plottable]]
888+
bindings : Dict[str, Union[ASTObject, Chain, List[ASTObject], Plottable]]
889889
Mapping from binding names to graph operations (AST objects or Plottables).
890890
891891
Raises
@@ -902,12 +902,12 @@ class ASTLet(ASTObject):
902902
"""
903903
bindings: Dict[str, Union['ASTObject', 'Chain', Plottable]]
904904

905-
def __init__(self, bindings: Dict[str, Union['ASTObject', 'Chain', Plottable, Dict[str, Any]]], validate: bool = True) -> None:
905+
def __init__(self, bindings: Dict[str, Union['ASTObject', 'Chain', List['ASTObject'], Plottable, Dict[str, Any]]], validate: bool = True) -> None:
906906
"""Initialize Let with named bindings.
907907
908-
:param bindings: Dictionary mapping names to GraphOperation instances or JSON dicts.
908+
:param bindings: Dictionary mapping names to GraphOperation instances, lists (implicit Chains), or JSON dicts.
909909
JSON dicts must have a 'type' field indicating the AST object type.
910-
:type bindings: Dict[str, Union[ASTObject, Chain, Plottable, Dict[str, Any]]]
910+
:type bindings: Dict[str, Union[ASTObject, Chain, List[ASTObject], Plottable, Dict[str, Any]]]
911911
:param validate: Whether to validate the bindings immediately
912912
:type validate: bool
913913
"""
@@ -916,7 +916,24 @@ def __init__(self, bindings: Dict[str, Union['ASTObject', 'Chain', Plottable, Di
916916
# Process mixed JSON/native objects
917917
processed_bindings: Dict[str, Any] = {}
918918
for name, value in bindings.items():
919-
if isinstance(value, dict):
919+
if isinstance(value, list):
920+
# Treat list bindings as implicit Chain operations
921+
from graphistry.compute.chain import Chain # noqa: F401, F811
922+
chain_ops: List[ASTObject] = []
923+
for op in value:
924+
if isinstance(op, dict):
925+
if 'type' not in op:
926+
raise ValueError(f"JSON binding '{name}' missing 'type' field")
927+
obj_type = op.get('type')
928+
if obj_type == 'Chain':
929+
raise ValueError(
930+
f"Binding '{name}' contains nested Chain in list; use Chain(...) directly"
931+
)
932+
chain_ops.append(from_json(op, validate=False))
933+
else:
934+
chain_ops.append(op)
935+
processed_bindings[name] = Chain(chain_ops, validate=False) # type: ignore
936+
elif isinstance(value, dict):
920937
# JSON dict - check type and convert if valid
921938
if 'type' not in value:
922939
raise ValueError(f"JSON binding '{name}' missing 'type' field")

graphistry/compute/chain_let.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,13 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g:
254254
if ast_obj.chain:
255255
# Import chain function to execute the operations
256256
from .chain import chain as chain_impl
257-
chain_result = chain_impl(referenced_result, ast_obj.chain, EngineAbstract(engine.value), policy=policy, context=context)
257+
chain_result = chain_impl(
258+
referenced_result,
259+
ast_obj.chain,
260+
EngineAbstract(engine.value),
261+
policy=policy,
262+
context=context
263+
)
258264
# ASTRef with chain should return the filtered result directly
259265
result = chain_result
260266
else:

0 commit comments

Comments
 (0)