fix(cypher): make expand_from_bound_terminal OPTIONAL fallback lossless - #1378
Conversation
|
Thanks for opening this — it has been seen, and it is queued. This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence. Current review status: working through a backlog. What that means for this PR, concretely:
Things that will genuinely speed it up whenever review does happen:
If this fixes a bug, a reproduction we can run is worth more than a description of the symptom. Thanks for contributing, and sorry in advance for the wait. |
|
Thank you for picking this up so quickly — and the bug you are fixing is real. I verified it on current main: But I have to hold it on one finding, because in the regime it targets it now produces a worse answer than main does. Removing the two guards makes every binding reachable, which is the goal. The problem is that Worked through: three reports That is the trade I cannot take: missing rows become fabricated rows, and on the flagship dead-code query a false positive is the one a user acts on destructively. Under-reporting is bad; confidently telling someone live code is dead is worse. The fix is three lines, and it keeps everything you have already proved. Decouple "did this terminal match" from "was there budget to materialise it" — count the match unconditionally, gate only the write: for (int ei = 0; ei < edge_count; ei++) {
/* ... find node, label filter (unchanged) ... */
match_count++; /* a real neighbour exists, budget or not */
if (new_count < max_new) { /* only the materialisation is capped */
binding_t nb = {0};
binding_copy(&nb, b);
binding_set(&nb, start_var, &found);
if (rel->variable) { binding_set_edge(&nb, rel->variable, &edges[ei]); }
new_bindings[new_count++] = nb;
}
node_fields_free(&found);
}Drop One thing about the test, said carefully because it is a good test. It genuinely binds and it is genuinely RED on main — I traced it: The gap is that it is green through the path the finding condemns. With your fix, by the time A test that discriminates — green on main, RED on this PR as written, green under the fix above — is: insert three Functions in order, and assert no row has Two things that are ours, not yours. This is your fourth fix in this engine after #1173, #1176 and #1177, and the standard has been high in all four. Send the |
Signed-off-by: SEPURI-SAI-KRISHNA <saik20533@gmail.com>
9a37890 to
d3f2a7b
Compare
|
Thanks for the precise diagnosis. Updated so match detection is independent of the write ceiling, detection and materialisation are now decoupled.
New test |
What does this PR do?
Makes the OPTIONAL no-match fallback in
expand_from_bound_terminal(
src/cypher/cypher.c) lossless and free of false positives — the follow-upcalled out in the merge note on #1177.
That function drives a single-relationship
OPTIONAL MATCHfrom its already-boundterminal node — e.g.
MATCH (f:Function) OPTIONAL MATCH (c)-[:CALLS]->(f), wherefis bound and the start
cis not. It sized its hop-output bufferbind_count*10 + 1and gated the no-match fallback write on
new_count < max_new, withmax_newequal tothe whole buffer. Once one terminal's expansion saturated the buffer, every later
terminal's no-match row was silently dropped — exactly the rows
OPTIONAL MATCH ... WHERE c IS NULLis meant to surface. Not a memory-safety bug (theguard kept the write in bounds), but real data loss under saturation, and the same
shape as the fallback issue fixed for the sibling
expand_pattern_relsin #1177.Fix, in two parts:
Lossless sizing. Size the buffer
(size_t)*bind_count * CYP_GROWTH_10 + (size_t)*bind_count. The materialised expansion is capped atmax_new = *bind_count * 10; the OPTIONAL fallback emits at most one row per source(
<= *bind_count). A source either matches (feeds the expansion) or takes thefallback, never both, so the two counts are additive and bounded by
max_new + *bind_count. Computed insize_t; operands<= INT_MAX, so no overflow.Decouple match detection from materialisation (so the fallback never fabricates a
false result). For OPTIONAL the
ti/eiscan loops are no longer gated on the ceiling(
... && (new_count < max_new || opt)); only the write is(
if (new_count < max_new)). Somatch_countis the true neighbour count, andmatch_count == 0at the fallback means the terminal genuinely has none — not "thebuffer filled before we looked." Non-OPTIONAL never reads
match_count, so its guardreduces to
new_count < max_newand keeps the original early-exit.Behaviour: no OPTIONAL no-match row is dropped; no live terminal is ever mislabelled dead;
a saturated-but-matching terminal is truncated at the per-hop ceiling (bounded success,
unchanged).
Tests (reproduce-first, RED→GREEN under ASan+UBSan):
cypher_exec_bound_terminal_optional_fallback_survives— a hub with more incomingCALLSedges thanmax_newsaturates the buffer; a callerless leaf's OPTIONAL no-matchrow must still survive. Fails under the old
+ 1sizing.cypher_exec_bound_terminal_saturation_no_false_deadcode— two hubs, each able tosaturate the buffer alone (so the construction is order-independent; the label-scan query
has no
ORDER BY), plus a genuinely callerless leaf. Asserts no hub with callers everappears as an unbound (
c IS NULL) row while the leaf still does. Fails againstgated detection, passes only once detection is decoupled from the write.
Checklist
git commit -s) — required, CI rejectsunsigned commits (DCO, see CONTRIBUTING.md)
make -f Makefile.cbm test)make -f Makefile.cbm lint-ci)Notes: cypher suite 168 passed under ASan+UBSan. The full suite shows 10 failures that
are pre-existing and unrelated to this change (CLI install/uninstall/binary-publish tests
that need a real install target, a subprocess-timeout timing test, and the vendored
integrity check) — the cypher module is green. clang-format/cppcheck were not available in
my local environment; the change mirrors the sibling
expand_pattern_relsformatting andstays under 100 columns, so I am relying on CI for the formal lint gate.