Skip to content

Quantexa arithmetic where#98

Merged
j6k4m8 merged 8 commits into
aplbrain:masterfrom
q-rosiebloxsom:quantexa-arithmetic-where
Jun 26, 2026
Merged

Quantexa arithmetic where#98
j6k4m8 merged 8 commits into
aplbrain:masterfrom
q-rosiebloxsom:quantexa-arithmetic-where

Conversation

@q-rosiebloxsom

@q-rosiebloxsom q-rosiebloxsom commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds support for arithmetic operators (+, -, *, /, %) in WHERE clauses, enabling queries like:

MATCH (A)-[E]->(B)
WHERE A.price * A.qty > 100
  AND (B.revenue - B.costs) / B.headcount >= 50
RETURN A, B

Changes

Grammar (_GrandCypherGrammar)

The condition rule previously accepted only entity_id op entity_id_or_value. It now accepts arith_expr op arith_expr, where arith_expr is a two-level precedence grammar:

  • arith_expr — handles + / - (lower precedence)
  • arith_term — handles * / / / % (higher precedence)
  • arith_atom — leaf nodes: entity_id, scalar_function, value, NULL, TRUE, FALSE, or a parenthesized sub-expression

The IN operator intentionally keeps its old rule (entity_id op_list value_list) — arithmetic on the LHS of IN is not supported by design.

Typed operand classes: EntityRef, AttributeRef, IDRef, ArithmeticExpression

The old code represented all entity references as plain strings ("A", "A.age") and ID lookups as string patterns ("ID(A)"). This made it impossible to distinguish operand types at evaluation time and required fragile string-parsing (e.g. startswith("ID("), split(".")) throughout CompoundCondition.__call__ and the indexer.

Four typed classes replace this:

  • EntityRef(str) — bare node/edge reference, e.g. A. Raises TypeError if used in a WHERE comparison, since bare entities aren't meaningful in comparisons.
  • AttributeRef(str) — node/edge attribute reference, e.g. A.age, with .entity_name and .attribute accessors.
  • IDRef(str)ID(A) reference with .entity_name accessor.
  • ArithmeticExpression — recursive tree node (left, op, right) with a .resolve() method for evaluation.

All str subclasses retain string equality and dict-key compatibility.

Operand resolution: _resolve_operand()

A single recursive function replaces the scattered resolution logic that was previously inlined in CompoundCondition.__call__. It dispatches on operand type:

  • ArithmeticExpression — recursively resolves via .resolve()
  • IDRef — looks up the node ID from the match mapping
  • AttributeRef — looks up the node/edge attribute from the host graph
  • EntityRef — raises TypeError (bare entities are not valid in comparisons)
  • Anything else — returned as a literal value

CompoundCondition refactor

Constructor parameters renamed from entity_id / value to left / right to reflect that either side of a comparison can now be an arbitrary expression, not just an entity reference or a literal.

The __call__ method is simplified from ~30 lines of branching string-inspection to a short delegation to _resolve_operand().

Division semantics

Integer division (//) is used when both operands are integers, matching Neo4j/Cypher semantics. Float division is used otherwise.

Error handling

ArithmeticExpression.resolve() catches TypeError, ZeroDivisionError, and OverflowError, returning None. CompoundCondition treats None as False, so rows with non-numeric attributes or division by zero are silently excluded rather than crashing.

Indexer (to_indexer_ast and Compare)

to_indexer_ast creates IndexerCompare for two patterns:

  • AttributeRef op literal — the standard case, e.g. WHERE A.age > 20. Compare.__call__ uses self._key.attribute and self._key.entity_name directly to query the node attribute index.
  • IDRef == literal — e.g. WHERE ID(A) == 2. Compare.__call__ returns {entity_name: {value}} directly, restricting grandiso to that single node ID. Only equality is supported; inequality operators on IDRef fall through to IndexerUnsupportedOp for full-scan evaluation.

All other combinations fall through to IndexerUnsupportedOp.

motif_to_indexer_ast uses AttributeRef when building indexer keys.

Return path (_return_requests coercion)

The old list(map(str, self._return_requests)) line stripped Lark Token wrappers to plain strings. With typed operand classes now in play, a blanket str() conversion destroys the type information that _lookup relies on (e.g. isinstance(data_path, IDRef)). The coercion is now type-aware: EntityRef, AttributeRef, and IDRef are preserved, everything else is converted to str.

Bugfix: ID() comparisons with inequality operators

The old to_indexer_ast had a dead branch — entity_id.startswith("ID()") (note the empty parens) never matched actual strings like "ID(A)", so ID() conditions always fell through to IndexerUnsupportedOp, triggering a full scan. This happened to produce correct results because the full-scan path evaluated the comparison properly.

The dead branch was intended to strip ID(A) down to A and pass it to the indexer's Compare class, where the single-element key path (key with no dot) would handle it. However, that Compare path also had a latent bug: it ignored the operator entirely and treated every comparison as equality. For example, WHERE ID(A) > 1 on a graph with nodes 1, 2, 3 would pass key "A", op ">", value 1 to Compare. The single-element branch would ignore ">" and return {"A": {1}} — telling the executor to only consider node 1 for entity A. Nodes 2 and 3 would never be evaluated. The WHERE condition would then check 1 > 1 on the sole candidate, which is false, producing an empty result set.

We have removed both the dead branch in to_indexer_ast and the single-element key path in Compare, since neither was ever reached and the latter doesn't work correctly. ID() conditions are now handled by the typed operand approach: to_indexer_ast routes IDRef == literal to IndexerCompare for indexed lookup, preserving the == optimisation that the original code intended. Inequality operators like WHERE ID(A) > 1, which would have produced wrong results if the dead branch had been reached, are routed to IndexerUnsupportedOp for correct full-scan evaluation.

Known issue: bare entity names in WHERE

test_bare_entity_name_in_where_should_error is marked xfail. A query like WHERE A + 0 > 1 (using a bare entity name A rather than an attribute like A.age) should raise a TypeError, but currently silently returns empty results. This happens because the ? prefix on the entity_id grammar rule inlines bare CNAME tokens directly, bypassing the entity_id transformer method. The bare A arrives as a raw Lark Token rather than an EntityRef, so _resolve_operand treats it as a literal string value instead of raising. Fixing this would require either removing the ? prefix (which affects all grammar rules that reference entity_id) or adding a validation pass after transformation. This was an existing issue which is not yet fixed.

Tests

  • test_queries.py — 23 new end-to-end tests covering all operators, precedence, parentheses, edge attributes, ID() equality and inequality, chained expressions, error resilience (non-numeric, division by zero, missing attributes), AND/NOT combinations, and cross-entity arithmetic.
  • test_parser.py — 10 new parser tests verifying parse tree structure (correct arith_* node types, operator nodes, literal values), not just that parsing doesn't crash.
  • test_to_indexer_ast.py — 9 new tests covering positive routing (AttributeRef + literal → IndexerCompare, IDRef + == + literal → IndexerCompare) and negative routing (bare EntityRef, swapped operands, ArithmeticExpression on either side, IDRef with inequality → UnsupportedOp).
  • test_indexer_plumbing.py — 7 new plumbing tests that mock grandiso.find_motifs_iter to verify the indexer produces correct hints end-to-end: attribute equality/inequality, entity scoping, ID() equality producing hints, ID() inequality/arithmetic/edge attributes falling back to no hints.
  • test_indexer.py — updated Compare tests to use AttributeRef keys instead of plain strings.

@codspeed-hq

codspeed-hq Bot commented Jun 25, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 24.9%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

#### 🎉 Hooray! `pytest-codspeed` just leveled up to 5.0.3!

A heads-up, this is a breaking change and it might affect your current performance baseline a bit. But here's the exciting part - it's packed with new, cool features and promises improved result stability 🥳!
Curious about what's new? Visit our releases page to delve into all the awesome details about this new version.

❌ 49 regressed benchmarks
✅ 50 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_nested_nots_in_statements[DiGraph] 33.3 ms 60.7 ms -45.13%
test_nested_nots_in_statements[MultiDiGraph] 33.3 ms 60.7 ms -45.11%
test_doublenot[MultiDiGraph] 12.1 ms 19 ms -36.04%
test_doublenot[DiGraph] 12.1 ms 18.9 ms -36.01%
test_simple_api_multi_node_multi_where[MultiDiGraph] 17.8 ms 27.4 ms -34.77%
test_simple_api_multi_node_multi_where[DiGraph] 17.8 ms 27.3 ms -34.68%
test_simple_api_single_node_multi_where[DiGraph] 18 ms 27.5 ms -34.54%
test_simple_api_single_node_multi_where[MultiDiGraph] 18.1 ms 27.6 ms -34.51%
test_not[MultiDiGraph] 11.4 ms 16.7 ms -31.72%
test_not[DiGraph] 11.4 ms 16.7 ms -31.55%
test_contains[DiGraph] 21.7 ms 30.6 ms -29.23%
test_contains[MultiDiGraph] 21.7 ms 30.6 ms -29.13%
test_ends_with[DiGraph] 21.1 ms 28.4 ms -25.91%
test_ends_with[MultiDiGraph] 21.1 ms 28.4 ms -25.81%
test_starts_with[MultiDiGraph] 10.8 ms 14.5 ms -25.42%
test_starts_with[DiGraph] 10.8 ms 14.5 ms -25.41%
test_simple_api_single_node_where[DiGraph] 14.5 ms 19.3 ms -24.66%
test_simple_api_single_node_where[MultiDiGraph] 14.6 ms 19.3 ms -24.66%
test_simple_api_two_edge_where_clauses_diff_edge[DiGraph] 26.1 ms 34.6 ms -24.53%
test_simple_api_two_edge_where_clauses_diff_edge[MultiDiGraph] 26.2 ms 34.7 ms -24.49%
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing q-rosiebloxsom:quantexa-arithmetic-where (53b12c0) with master (bf30e3e)

Open in CodSpeed

@q-rosiebloxsom
q-rosiebloxsom marked this pull request as ready for review June 25, 2026 14:09
@q-rosiebloxsom

Copy link
Copy Markdown
Contributor Author

The performance changes - I'm not sure how much to worry about these. I think the extra time is all about the creation of the query (the queries can now be more complex and there is type-checking). As it's about the creation of the query, it won't really affect how long it takes to run the query at scale, which I think is what performance tests are usually about measuring?

@j6k4m8 j6k4m8 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so ID() conditions always fell through to IndexerUnsupportedOp, triggering a full scan.

omg!!! Good catch, that is such a great fix!

The performance changes - I'm not sure how much to worry about these.

Agreed, I don't think these numbers are concerning enough to offset the benefits of the new feature support! Unfortunately I suspect enriching the syntax will inevitably lead to performance hits. But my opinion is let's get feature-parity first and performance can come later; this is great!

The coercion is now type-aware: EntityRef, AttributeRef, and IDRef are preserved, everything else is converted to str.

This is great, and will improve return-handling considerably!!

Is this ready to merge?

@q-rosiebloxsom

q-rosiebloxsom commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

The coercion is now type-aware: EntityRef, AttributeRef, and IDRef are preserved, everything else is converted to str.

This is great, and will improve return-handling considerably!!

RETURN and ORDER BY do still use the old string parsing approach for some stuff, but it should now be much easier for us to refactor that if we want to. With so much going on I think types can really help us. I can raise an issue for this refactor if you're interested in that, I decided not to make all those changes in this PR.

Is this ready to merge?

I'm very happy if this code is merged! Looks like the lint check failed for something to do with .venv?

@j6k4m8

j6k4m8 commented Jun 25, 2026

Copy link
Copy Markdown
Member

I'd love to refactor a bit to surface types better — if you are interested, that would be an amazing update! I am working on resolving the .venv issue now... no idea where that came from!

Comment thread grandcypher/__init__.py Outdated
"-": lambda a, b: a - b,
"*": lambda a, b: a * b,
# Integer division to match Neo4j/Cypher semantics
"/": lambda a, b: a // b if isinstance(a, int) and isinstance(b, int) else a / b,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

python // floor-rounds, so for example,

-3 // 2 == -2

but I thiiiiiiiink java follows truncate-toward-zero, iirc:

-3 / 2 == -1

I think this can be fixed with,

Suggested change
"/": lambda a, b: a // b if isinstance(a, int) and isinstance(b, int) else a / b,
"/": lambda a, b: int(a / b) if isinstance(a, int) and isinstance(b, int) else a / b,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooh great catch - I've checked division in Neo4j and you're right, I've updated the code with your suggestion, also with a comment to explain and a test.

@j6k4m8

j6k4m8 commented Jun 26, 2026

Copy link
Copy Markdown
Member

Amazing, @q-rosiebloxsom — merging now! 🎉

@j6k4m8
j6k4m8 merged commit 18a2af5 into aplbrain:master Jun 26, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants