Skip to content

refactor(cypher): single scalar/aggregate dispatch — unify the 3-way evaluator split#653

Merged
coseto6125 merged 3 commits into
mainfrom
chore/cypher-eval-unify
Jul 22, 2026
Merged

refactor(cypher): single scalar/aggregate dispatch — unify the 3-way evaluator split#653
coseto6125 merged 3 commits into
mainfrom
chore/cypher-eval-unify

Conversation

@coseto6125

Copy link
Copy Markdown
Owner

What

Collapses the cypher executor's split extension point for adding a scalar function or aggregate:

  • One AggregateKind enum, parsed once via AggregateKind::parse, replaces the two independently-maintained aggregate name tables (is_aggregate_fn's matches! list and Accumulator::new's match). Accumulator::new now matches exhaustively on the enum, so a new aggregate without a matching accumulator arm is a compile error instead of the old _ => Counter(0) silent misroute.
  • One scalar dispatch (eval_scalar_funcall) is now used by all three evaluators. eval_return_expr and eval_return_item_rich — previously near-duplicates differing only in whether a bound Var collapses to Str or stays NodeRef/EdgeRef — are now one eval_return_expr_with parameterized by a VarCollapse enum.
  • WHERE-clause function calls are enabled. eval_expr's FunCall arm previously rejected all function calls in WHERE outright; it now routes non-aggregate calls through the shared eval_scalar_funcall, so WHERE TYPE(r) = 'Calls', WHERE id(n) = 5, 'Function' IN labels(n) etc. work in WHERE the same as in RETURN. Aggregates in WHERE (WHERE count(n) > 1) still error explicitly — no single-row meaning, matching OpenCypher's own restriction there.

Why

Adding a function previously meant remembering three eval sites plus two aggregate string tables; missing one silently misrouted to Counter(0) or hit the unreachable! at the old with_agg_specs construction. From the 2026-07-23 architecture review (C3).

Compatibility

Zero change to the public cypher::execute/cypher::parse interface. Zero output-format change for any existing query shape — VarCollapse::ToStr/ToRef reproduce the old eval_return_expr/eval_return_item_rich behavior exactly, including the pre-existing quirk where the plain-projection path never checked edge_vars for an unbound-node var (preserved via the collapse == ToRef gate).

Test plan

  • All 174 pre-existing #[test] fns in the file are unmodified and green.
  • 7 new tests added:
    • exec_where_type_of_edge_filters_correctly / _mismatch_returns_empty, exec_where_labels_of_node_filters_correctly — WHERE fn-calls now work.
    • exec_where_aggregate_funcall_is_rejected — aggregate-in-WHERE still errors explicitly.
    • scalar_funcall_identical_across_where_return_and_with — same TYPE(r) call through WHERE, RETURN, and WITH group-key paths asserts identical result rows.
    • aggregate_kind_every_variant_builds_a_working_accumulator — every AggregateKind variant produces a non-Null accumulator after one fed row (pins exhaustiveness).
    • aggregate_kind_parse_matches_is_aggregate_fn_for_all_known_names — classification consistency.
  • cargo test -p ecp-core --lib cypher: 181 passed, 0 failed.
  • cargo test -p egent-code-plexus --tests: 1249 passed, 0 failed (full CLI suite, cypher round-trips through it).
  • cargo clippy -p egent-code-plexus --tests and cargo clippy -p ecp-analyzer: clean (also re-verified by the pre-push hook).
  • rustfmt --edition 2021 on the touched file.

…evaluator split

Adding a scalar function or aggregate previously meant remembering three
evaluator sites (eval_expr for WHERE, eval_return_expr, eval_return_item_rich)
and two independently-maintained aggregate name lists (is_aggregate_fn's
match!, Accumulator::new's match) — missing one meant a silent Null misroute
or an unreachable panic.

- AggregateKind enum, parsed once via AggregateKind::parse, replaces the two
  string tables. Accumulator::new now matches on the enum exhaustively, so a
  new aggregate without a matching arm is a compile error instead of the old
  `_ => Counter(0)` silent fallback.
- eval_return_expr and eval_return_item_rich collapsed into one
  eval_return_expr_with, parameterized by VarCollapse (their only point of
  divergence: whether a bound Var resolves to Str or stays NodeRef/EdgeRef).
- WHERE-clause function calls now route through the same eval_scalar_funcall
  used by RETURN and WITH, so `WHERE TYPE(r) = 'Calls'` etc. work; aggregates
  in WHERE still error explicitly (no single-row meaning, matching OpenCypher).

From the 2026-07-23 architecture review (C3).
@coseto6125
coseto6125 enabled auto-merge (squash) July 22, 2026 17:47
@coseto6125 coseto6125 added the merge-queue Opt-in to Mergify merge queue label Jul 22, 2026
…gate computed bindings

eval_scalar_funcall only looked up node_vars/edge_vars, which WITH
rebinding and aggregation both clear in favor of `computed` (holding a
NodeRef/EdgeRef instead). A WHERE funcall on a WITH-aliased var or an
aggregate-WITH grouping key therefore silently resolved to Null and
filtered out rows that should have survived — e.g.
`WITH a AS x WHERE ID(x) IS NOT NULL` dropped every row.

TYPE/ID/LABELS now fall back to the matching NodeRef/EdgeRef in
`computed` when the var isn't in node_vars/edge_vars, mirroring the
existing computed-NodeRef recovery already used by the decorator-IN
pushdown path a few hundred lines up. No change to non-FunCall WHERE
behavior or to the RETURN paths (they were already unaffected, since
eval_return_expr_with checks `computed` first regardless).

Follow-up to the C3 cypher evaluator unification (PR #653), caught by
Codex review.
…rs/edge_vars

The prior computed-fallback fix (8d4c5c6) checked node_vars/edge_vars
first and only consulted `computed` when the var was entirely absent
from them. That order is wrong whenever a WITH clause shadows a
surviving variable name, e.g. `WITH b AS a` — exec_with's plain-rebind
branch deliberately preserves node_vars/edge_vars unchanged (so a
later MATCH can still traverse from the original bindings), so the OLD
node_vars["a"] stays present alongside the NEW computed["a"]. TYPE/ID/
LABELS on `a` after such a WITH resolved against the stale pre-WITH
entity instead of the one every other evaluator (prop_value,
eval_return_expr_with) already reads.

Flip the order: check `computed` first whenever the var is present
there at all, matching prop_value's existing precedence, and only fall
back to node_vars/edge_vars when the var isn't in `computed`.

Follow-up to the C3 cypher evaluator unification (PR #653), caught by
Codex review.
@github-actions

Copy link
Copy Markdown
Contributor
ecp impact cache (34 symbols) — internal, used by ecp dev pr-analyze

["anonymous:90:17","literal_coherence_candidates","build_literal_coherence_payload","coherence_finds_session_meta_split_brain_pair","run","run_bfs","impact_with_baseline","coverage_bfs_for_symbol","impact_by_name","anonymous:171:13","build_payload_with_hints","run_batch","build_payload","run_for_symbol","cmd_plan","classify_symbol","merged_node_meta","anonymous:218:17","anonymous:293:13","anonymous:259:26","anonymous:261:31","build_coverage_json","literal_similarity","group","coherence_rejects_unknown_access_mixed_with_read","direct_symbol_uids","direct_count","as_json","access","collect_literal_groups","anonymous:82:13","resolve_min_conf","anonymous:249:37","normalized_levenshtein"]

@github-actions github-actions Bot added the ecp:risk-high ecp signal label Jul 22, 2026
@coseto6125
coseto6125 merged commit 93cf94c into main Jul 22, 2026
24 of 26 checks passed
@coseto6125
coseto6125 deleted the chore/cypher-eval-unify branch July 22, 2026 18:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ecp:risk-high ecp signal merge-queue Opt-in to Mergify merge queue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant