diff --git a/tests/test_wirelog_advanced.c b/tests/test_wirelog_advanced.c index 828aee4e..6e85570d 100644 --- a/tests/test_wirelog_advanced.c +++ b/tests/test_wirelog_advanced.c @@ -1174,6 +1174,70 @@ test_negated_side_compound_body_pattern_rejected(void) return rc; } +/* Parity (#785): mirrors test_unsafe_negation_variable_rejected (issue #920). + * A variable bound only inside a negated atom is unsafe; IR lowering must + * reject it. */ +static int +test_unsafe_negation_variable_rejected(void) +{ + const char *src + = ".decl a(x: symbol)\n" + ".decl b(x: symbol, y: symbol)\n" + ".decl c(x: symbol)\n" + "c(X) :- a(X), !b(X, Y).\n"; + wirelog_error_t parse_err = WIRELOG_OK; + wirelog_program_t *prog = wirelog_parse_string(src, &parse_err); + if (!prog) + return parse_err == WIRELOG_OK ? 1 : 0; + + wirelog_session_t *s = NULL; + wirelog_error_t err + = wirelog_session_create(prog, WIRELOG_BACKEND_DEFAULT, 1, &s); + int rc = 0; + if (err != WIRELOG_ERR_INVALID_IR || s) { + fprintf(stderr, + "expected invalid IR for unsafe negation variable, got %d\n", err); + wirelog_session_destroy(s); + rc = 1; + } + wirelog_program_free(prog); + return rc; +} + +/* Parity (#785): mirrors test_safe_negation_via_projection_accepted (#920). + * The projected-key form and wildcard columns are safe and must be accepted. */ +static int +test_safe_negation_via_projection_accepted(void) +{ + const char *src + = ".decl a(x: symbol)\n" + ".decl b(x: symbol, y: symbol)\n" + ".decl b_key(x: symbol)\n" + ".decl c(x: symbol)\n" + ".decl d(x: symbol)\n" + "b_key(X) :- b(X, Y).\n" + "c(X) :- a(X), !b_key(X).\n" + "d(X) :- a(X), !b(X, _).\n"; + wirelog_error_t parse_err = WIRELOG_OK; + wirelog_program_t *prog = wirelog_parse_string(src, &parse_err); + if (!prog) { + fprintf(stderr, "safe negation: parse failed, got %d\n", parse_err); + return 1; + } + + wirelog_session_t *s = NULL; + wirelog_error_t err + = wirelog_session_create(prog, WIRELOG_BACKEND_DEFAULT, 1, &s); + int rc = 0; + if (err != WIRELOG_OK || !s) { + fprintf(stderr, "safe negation: expected valid IR, got %d\n", err); + rc = 1; + } + wirelog_session_destroy(s); + wirelog_program_free(prog); + return rc; +} + /* Parity (#785): mirrors test_wirelog_easy.c::test_intern_returns_same_id. * Interning the same string twice through wl_intern_put must return * the same int64 id; the advanced API exposes the intern table via @@ -1570,6 +1634,8 @@ main(void) failures += test_side_compound_wrong_functor_handle_no_match(); failures += test_side_compound_nested_child_no_match(); failures += test_negated_side_compound_body_pattern_rejected(); + failures += test_unsafe_negation_variable_rejected(); + failures += test_safe_negation_via_projection_accepted(); failures += test_intern_returns_same_id(); failures += test_snapshot_filter(); failures += test_cleanup_order_no_use_after_free(); diff --git a/tests/test_wirelog_easy.c b/tests/test_wirelog_easy.c index 9d89e7de..c0e71d17 100644 --- a/tests/test_wirelog_easy.c +++ b/tests/test_wirelog_easy.c @@ -1075,6 +1075,55 @@ test_negated_side_compound_body_pattern_rejected(void) PASS(); } +/* Issue #920: a variable that appears only inside a negated body atom has an + * unbounded range and must be rejected (range-restriction / safety). */ +static void +test_unsafe_negation_variable_rejected(void) +{ + TEST("unsafe variable in negated atom is rejected"); + + const char *src + = ".decl a(x: symbol)\n" + ".decl b(x: symbol, y: symbol)\n" + ".decl c(x: symbol)\n" + "c(X) :- a(X), !b(X, Y).\n"; /* Y bound only under negation */ + + wirelog_easy_session_t *s = NULL; + if (wirelog_easy_open(src, &s) == WIRELOG_OK || s) { + wirelog_easy_close(s); + FAIL("unsafe negation rule should fail open"); + return; + } + PASS(); +} + +/* Issue #920: the safe form, where the negated relation is projected to a key + * relation first so every negated variable is positively bound, is accepted. + * A wildcard under negation is likewise safe. */ +static void +test_safe_negation_via_projection_accepted(void) +{ + TEST("safe negation (projected key / wildcard) is accepted"); + + const char *src + = ".decl a(x: symbol)\n" + ".decl b(x: symbol, y: symbol)\n" + ".decl b_key(x: symbol)\n" + ".decl c(x: symbol)\n" + ".decl d(x: symbol)\n" + "b_key(X) :- b(X, Y).\n" /* project away Y */ + "c(X) :- a(X), !b_key(X).\n" /* X positively bound */ + "d(X) :- a(X), !b(X, _).\n"; /* wildcard column is safe */ + + wirelog_easy_session_t *s = NULL; + if (wirelog_easy_open(src, &s) != WIRELOG_OK || !s) { + FAIL("safe negation rule should open"); + return; + } + wirelog_easy_close(s); + PASS(); +} + static void test_side_compound_public_allocation_saturates(void) { @@ -1790,6 +1839,8 @@ main(void) test_side_compound_wrong_functor_handle_no_match(); test_side_compound_nested_child_no_match(); test_negated_side_compound_body_pattern_rejected(); + test_unsafe_negation_variable_rejected(); + test_safe_negation_via_projection_accepted(); test_side_compound_public_allocation_saturates(); test_insert_sym_variadic(); test_remove_sym(); diff --git a/wirelog/ir/program.c b/wirelog/ir/program.c index 70672a35..517b5381 100644 --- a/wirelog/ir/program.c +++ b/wirelog/ir/program.c @@ -1864,6 +1864,46 @@ convert_rule(const wl_parser_ast_node_t *rule_node, wirelog_ir_node_t *neg_scan = build_atom_scan(neg_atom, prog, &neg_vars, &neg_vcount); + /* Safety / range restriction (issue #920): every named variable in + * a negated atom must also be bound by a positive body atom. A + * variable that occurs only under negation has an unbounded range, + * so the rule is unsafe and is rejected. build_atom_scan normalizes + * wildcards and constants to NULL, so only non-NULL entries are + * named variables that require a positive binding. */ + for (uint32_t j = 0; j < neg_vcount; j++) { + if (!neg_vars[j]) + continue; + bool bound = false; + for (uint32_t c = 0; c < cur_vcount; c++) { + if (cur_vars[c] + && strcmp(cur_vars[c], neg_vars[j]) == 0) { + bound = true; + break; + } + } + if (bound) + continue; + + WL_LOG(WL_LOG_SEC_PARSER, WL_LOG_ERROR, + "unsafe variable '%s' appears only in negated atom '%s'; " + "bind it in a positive body atom (e.g. project the negated " + "relation first: key(...) :- %s(...); " + "head :- ..., !key(...))", + neg_vars[j], neg_atom->name ? neg_atom->name : "?", + neg_atom->name ? neg_atom->name : "rel"); + free_var_names(neg_vars, neg_vcount); + wl_ir_node_free(neg_scan); + for (uint32_t s = 0; s < scan_count; s++) + free_var_names(scan_vars[s], scan_vcounts[s]); + if (cur_vars_is_merged) + free_var_names(cur_vars, cur_vcount); + wl_ir_node_free(current); + free(scans); + free(scan_vars); + free(scan_vcounts); + return NULL; + } + if (neg_scan) { wirelog_ir_node_t *aj = wl_ir_node_create(WIRELOG_IR_ANTIJOIN); if (aj) {