@@ -675,6 +675,154 @@ TEST(cypher_exec_optional_rel_leaf_fallback_survives) {
675675 PASS ();
676676}
677677
678+ /* Sibling of the leaf-fallback test, but for the BOUND-TERMINAL expansion path
679+ * (expand_from_bound_terminal): the OPTIONAL start var is unbound and the
680+ * terminal is bound, e.g. `MATCH (f) OPTIONAL MATCH (c)-[:CALLS]->(f)`. That
681+ * function sized its hop buffer bind_count*10 + 1 and gated the OPTIONAL
682+ * fallback on `new_count < max_new`, so once one terminal's expansion saturated
683+ * the buffer, every LATER terminal's no-match row was silently dropped — the
684+ * rows `WHERE c IS NULL` is meant to surface. Not an overflow (the guard kept
685+ * the write in bounds) but real data loss. Lossless sizing (bind_count*10 +
686+ * bind_count, fallback ungated) preserves them. */
687+ TEST (cypher_exec_bound_terminal_optional_fallback_survives ) {
688+ cbm_store_t * s = cbm_store_open_memory ();
689+ cbm_store_upsert_project (s , "test" , "/tmp/test" );
690+
691+ /* 2 Function nodes (hub + leaf) → bind_count = 2, max_new = 20. The hub has
692+ * 21 incoming CALLS edges (> max_new), so its expansion saturates the write
693+ * buffer; the leaf has none, so it must still yield its OPTIONAL no-match
694+ * row. Under the original "+ SKIP_ONE" sizing that row was dropped once the
695+ * buffer filled; the lossless sizing preserves it regardless of the order in
696+ * which the (unordered) label scan visits the two terminals. */
697+ cbm_node_t hub = {
698+ .project = "test" , .label = "Function" , .name = "hub" , .qualified_name = "test.hub" };
699+ int64_t hub_id = cbm_store_upsert_node (s , & hub );
700+ cbm_node_t leaf = {
701+ .project = "test" , .label = "Function" , .name = "leaf" , .qualified_name = "test.leaf" };
702+ cbm_store_upsert_node (s , & leaf );
703+
704+ /* Callers are non-Function so they do not inflate bind_count. Each CALLS the
705+ * hub (source = caller, target = hub), so from the bound terminal `hub` the
706+ * expansion binds the unbound start `c` to each caller. */
707+ for (int i = 0 ; i < 21 ; i ++ ) {
708+ char nm [32 ];
709+ char qn [48 ];
710+ snprintf (nm , sizeof (nm ), "caller%02d" , i );
711+ snprintf (qn , sizeof (qn ), "test.caller%02d" , i );
712+ cbm_node_t caller = {.project = "test" , .label = "Var" , .name = nm , .qualified_name = qn };
713+ int64_t cid = cbm_store_upsert_node (s , & caller );
714+ cbm_edge_t e = {.project = "test" , .source_id = cid , .target_id = hub_id , .type = "CALLS" };
715+ cbm_store_insert_edge (s , & e );
716+ }
717+
718+ /* max_rows 0 → the 100000 result ceiling, so the output LIMIT does not hide
719+ * the leaf row; we are testing the hop buffer, not the output cap. */
720+ cbm_cypher_result_t r = {0 };
721+ int rc = cbm_cypher_execute (
722+ s , "MATCH (f:Function) OPTIONAL MATCH (c)-[:CALLS]->(f) RETURN f.name, c.name" , "test" , 0 ,
723+ & r );
724+ ASSERT_EQ (rc , 0 );
725+ ASSERT_EQ (r .col_count , 2 );
726+
727+ /* The leaf terminal has no incoming CALLS edge, so it must yield one row with
728+ * the start var `c` unbound (""). Before the fix this row was dropped once the
729+ * hub saturated the buffer. Also confirm the hub still expanded to bound rows. */
730+ bool leaf_fallback = false;
731+ bool hub_expanded = false;
732+ for (int i = 0 ; i < r .row_count ; i ++ ) {
733+ const char * f = r .rows [i ][0 ];
734+ const char * c = r .rows [i ][1 ];
735+ if (strcmp (f , "leaf" ) == 0 && c [0 ] == '\0' ) {
736+ leaf_fallback = true;
737+ }
738+ if (strcmp (f , "hub" ) == 0 && c [0 ] != '\0' ) {
739+ hub_expanded = true;
740+ }
741+ }
742+ ASSERT_TRUE (leaf_fallback ); /* the bound-terminal OPTIONAL no-match row survived */
743+ ASSERT_TRUE (hub_expanded ); /* the expansion still produced bound rows */
744+
745+ cbm_cypher_result_free (& r );
746+ cbm_store_close (s );
747+ PASS ();
748+ }
749+
750+ /* Discriminating companion to the test above: preserving OPTIONAL no-match rows
751+ * under saturation must NOT be bought by fabricating them. If match detection is
752+ * gated on the same ceiling as the write, then once one terminal fills the
753+ * buffer, another terminal that genuinely HAS callers is never scanned, its
754+ * match_count stays 0, and the fallback invents an unbound "dead code" row for it
755+ * — reporting live code as dead, which is worse than dropping a row.
756+ *
757+ * The construction is deliberately order-independent: BOTH hubs have enough
758+ * callers to saturate the buffer on their own, so whichever the scan visits
759+ * second is guaranteed to be processed after saturation. Under the gated variant
760+ * that second hub is fabricated as dead; the assertion "no hub with callers is
761+ * dead" then fails no matter which order `find_nodes_by_label` returns (its query
762+ * has no ORDER BY, so the test must not depend on one). The fix makes it pass. */
763+ TEST (cypher_exec_bound_terminal_saturation_no_false_deadcode ) {
764+ cbm_store_t * s = cbm_store_open_memory ();
765+ cbm_store_upsert_project (s , "test" , "/tmp/test" );
766+
767+ /* 3 Function terminals → bind_count = 3, max_new = 30. */
768+ cbm_node_t hubA = {
769+ .project = "test" , .label = "Function" , .name = "hubA" , .qualified_name = "test.hubA" };
770+ int64_t hubA_id = cbm_store_upsert_node (s , & hubA );
771+ cbm_node_t hubB = {
772+ .project = "test" , .label = "Function" , .name = "hubB" , .qualified_name = "test.hubB" };
773+ int64_t hubB_id = cbm_store_upsert_node (s , & hubB );
774+ cbm_node_t leaf = {
775+ .project = "test" , .label = "Function" , .name = "leaf" , .qualified_name = "test.leaf" };
776+ cbm_store_upsert_node (s , & leaf );
777+
778+ /* BOTH hubs get 35 callers (> max_new = 30), so either one saturates the write
779+ * buffer by itself; leaf gets none. Callers are non-Function so they don't
780+ * inflate bind_count. */
781+ for (int i = 0 ; i < 70 ; i ++ ) {
782+ char nm [32 ];
783+ char qn [48 ];
784+ snprintf (nm , sizeof (nm ), "caller%02d" , i );
785+ snprintf (qn , sizeof (qn ), "test.caller%02d" , i );
786+ cbm_node_t caller = {.project = "test" , .label = "Var" , .name = nm , .qualified_name = qn };
787+ int64_t cid = cbm_store_upsert_node (s , & caller );
788+ int64_t tgt = i < 35 ? hubA_id : hubB_id ; /* 35 -> hubA, 35 -> hubB */
789+ cbm_edge_t e = {.project = "test" , .source_id = cid , .target_id = tgt , .type = "CALLS" };
790+ cbm_store_insert_edge (s , & e );
791+ }
792+
793+ cbm_cypher_result_t r = {0 };
794+ int rc = cbm_cypher_execute (
795+ s , "MATCH (f:Function) OPTIONAL MATCH (c)-[:CALLS]->(f) RETURN f.name, c.name" , "test" , 0 ,
796+ & r );
797+ ASSERT_EQ (rc , 0 );
798+ ASSERT_EQ (r .col_count , 2 );
799+
800+ bool hub_expanded = false; /* sanity: the buffer really did fill from a hub */
801+ bool hub_false_deadcode = false; /* the bug: a hub with callers invented as dead */
802+ bool leaf_deadcode = false; /* the lossless property: genuine dead code kept */
803+ for (int i = 0 ; i < r .row_count ; i ++ ) {
804+ const char * f = r .rows [i ][0 ];
805+ const char * c = r .rows [i ][1 ];
806+ bool is_hub = strcmp (f , "hubA" ) == 0 || strcmp (f , "hubB" ) == 0 ;
807+ if (is_hub && c [0 ] != '\0' ) {
808+ hub_expanded = true;
809+ }
810+ if (is_hub && c [0 ] == '\0' ) {
811+ hub_false_deadcode = true;
812+ }
813+ if (strcmp (f , "leaf" ) == 0 && c [0 ] == '\0' ) {
814+ leaf_deadcode = true;
815+ }
816+ }
817+ ASSERT_TRUE (hub_expanded );
818+ ASSERT_FALSE (hub_false_deadcode ); /* live code with callers must never appear as dead */
819+ ASSERT_TRUE (leaf_deadcode ); /* genuine no-match row still survives saturation */
820+
821+ cbm_cypher_result_free (& r );
822+ cbm_store_close (s );
823+ PASS ();
824+ }
825+
678826TEST (cypher_exec_where_eq ) {
679827 cbm_store_t * s = setup_cypher_store ();
680828 cbm_cypher_result_t r = {0 };
@@ -3425,6 +3573,8 @@ SUITE(cypher) {
34253573 RUN_TEST (cypher_cross_join_alloc_rejects_overflow );
34263574 RUN_TEST (cypher_exec_optional_rel_saturated_no_overflow );
34273575 RUN_TEST (cypher_exec_optional_rel_leaf_fallback_survives );
3576+ RUN_TEST (cypher_exec_bound_terminal_optional_fallback_survives );
3577+ RUN_TEST (cypher_exec_bound_terminal_saturation_no_false_deadcode );
34283578 RUN_TEST (cypher_issue240_labels_function );
34293579 RUN_TEST (cypher_issue237_distinct_order_limit );
34303580 RUN_TEST (cypher_issue873_distinct_order_limit_dedupes_before_limit );
0 commit comments