Skip to content

Commit e0f5561

Browse files
authored
Add left recursion check: quit early instead of going into an infinite loop (#7083)
* Add left recursion check: quit early instead of going into an infinite loop * Remove custom enum, rename left recursion check and move to "grammar internal" section, add handling for edge case where a leftmost nonterminal may be empty * Remove unnecessary declaration
1 parent 27f65d6 commit e0f5561

File tree

2 files changed

+114
-0
lines changed

2 files changed

+114
-0
lines changed

llama.cpp

+68
Original file line numberDiff line numberDiff line change
@@ -13182,6 +13182,58 @@ static std::vector<llama_grammar_candidate> llama_grammar_reject_candidates(
1318213182
return rejects;
1318313183
}
1318413184

13185+
static bool llama_grammar_detect_left_recursion(
13186+
const std::vector<std::vector<llama_grammar_element>> & rules,
13187+
size_t rule_index,
13188+
std::vector<bool> * rules_visited,
13189+
std::vector<bool> * rules_in_progress,
13190+
std::vector<bool> * rules_may_be_empty) {
13191+
if ((*rules_in_progress)[rule_index]) {
13192+
return true;
13193+
}
13194+
13195+
(*rules_in_progress)[rule_index] = true;
13196+
13197+
const std::vector<llama_grammar_element> & rule = rules[rule_index];
13198+
13199+
// First check if the rule might produce the empty string. This could be done combined with the second
13200+
// step but it's more readable as two steps.
13201+
bool at_rule_start = true;
13202+
for (size_t i = 0; i < rule.size(); i++) {
13203+
if (llama_grammar_is_end_of_sequence(&rule[i])) {
13204+
if (at_rule_start) {
13205+
(*rules_may_be_empty)[rule_index] = true;
13206+
break;
13207+
}
13208+
at_rule_start = true;
13209+
} else {
13210+
at_rule_start = false;
13211+
}
13212+
}
13213+
13214+
// Second, recurse into leftmost nonterminals (or next-leftmost as long as the previous nonterminal may
13215+
// be empty)
13216+
bool recurse_into_nonterminal = true;
13217+
for (size_t i = 0; i < rule.size(); i++) {
13218+
if (rule[i].type == LLAMA_GRETYPE_RULE_REF && recurse_into_nonterminal) {
13219+
if (llama_grammar_detect_left_recursion(rules, (size_t)rule[i].value, rules_visited, rules_in_progress, rules_may_be_empty)) {
13220+
return true;
13221+
}
13222+
if (!((*rules_may_be_empty)[(size_t)rule[i].value])) {
13223+
recurse_into_nonterminal = false;
13224+
}
13225+
} else if (llama_grammar_is_end_of_sequence(&rule[i])) {
13226+
recurse_into_nonterminal = true;
13227+
} else {
13228+
recurse_into_nonterminal = false;
13229+
}
13230+
}
13231+
13232+
(*rules_in_progress)[rule_index] = false;
13233+
(*rules_visited)[rule_index] = true;
13234+
return false;
13235+
}
13236+
1318513237
//
1318613238
// grammar - external
1318713239
//
@@ -13201,6 +13253,19 @@ struct llama_grammar * llama_grammar_init(
1320113253
vec_rules[i].push_back({LLAMA_GRETYPE_END, 0});
1320213254
}
1320313255

13256+
// Check for left recursion
13257+
std::vector<bool> rules_visited(n_rules);
13258+
std::vector<bool> rules_in_progress(n_rules);
13259+
std::vector<bool> rules_may_be_empty(n_rules);
13260+
for (size_t i = 0; i < n_rules; i++) {
13261+
if (rules_visited[i]) {
13262+
continue;
13263+
}
13264+
if (llama_grammar_detect_left_recursion(vec_rules, i, &rules_visited, &rules_in_progress, &rules_may_be_empty)) {
13265+
throw std::runtime_error(format("unsupported grammar, left recursion detected for nonterminal at index %zu", i));
13266+
}
13267+
}
13268+
1320413269
// loop over alternates of start rule to build initial stacks
1320513270
std::vector<std::vector<const llama_grammar_element *>> stacks;
1320613271
pos = vec_rules[start_rule_index].data();
@@ -13223,6 +13288,9 @@ struct llama_grammar * llama_grammar_init(
1322313288
}
1322413289
} while (true);
1322513290

13291+
// Important: vec_rules has to be moved here, not copied, because stacks contains
13292+
// pointers to elements of vec_rules. If vec_rules were copied into llama_grammar
13293+
// then the pointers would be invalidated when the local vec_rules goes out of scope.
1322613294
return new llama_grammar{ std::move(vec_rules), std::move(stacks), {} };
1322713295
}
1322813296

tests/test-grammar-integration.cpp

+46
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,19 @@ static llama_grammar* build_grammar(const std::string & grammar_str) {
2828
return grammar;
2929
}
3030

31+
static bool test_build_grammar_fails(const std::string & grammar_str) {
32+
fprintf(stderr, "⚫ Testing failure for grammar: %s\n", grammar_str.c_str());
33+
bool grammar_fails = false;
34+
try {
35+
build_grammar(grammar_str);
36+
fprintf(stderr, " ❌ Expected build failure, but succeeded\n");
37+
} catch (const std::exception & err) {
38+
grammar_fails = true;
39+
fprintf(stdout, " ✅︎\n");
40+
}
41+
return grammar_fails;
42+
}
43+
3144
static bool match_string(const std::string & input, llama_grammar* grammar) {
3245
auto decoded = decode_utf8(input, {});
3346

@@ -320,13 +333,46 @@ number ::= [0-9]+)""";
320333
fprintf(stderr, " ✅︎ Passed\n");
321334
}
322335

336+
static void test_failure_left_recursion() {
337+
fprintf(stderr, "⚫ Testing left recursion detection:\n");
338+
339+
// Test simple left recursion detection
340+
const std::string simple_str = R"""(root ::= "a" | root "a")""";
341+
assert(test_build_grammar_fails(simple_str));
342+
343+
// Test more complicated left recursion detection
344+
const std::string medium_str = R"""(
345+
root ::= asdf
346+
asdf ::= "a" | asdf "a"
347+
)""";
348+
assert(test_build_grammar_fails(medium_str));
349+
350+
// Test even more complicated left recursion detection
351+
const std::string hard_str = R"""(
352+
root ::= asdf
353+
asdf ::= "a" | foo "b"
354+
foo ::= "c" | asdf "d" | "e")""";
355+
assert(test_build_grammar_fails(hard_str));
356+
357+
// Test yet even more complicated left recursion detection
358+
const std::string hardest_str = R"""(
359+
root ::= asdf
360+
asdf ::= "a" | foo "b"
361+
foo ::= "c" | empty asdf "d" | "e"
362+
empty ::= "blah" | )""";
363+
assert(test_build_grammar_fails(hardest_str));
364+
365+
fprintf(stderr, " ✅︎ Passed\n");
366+
}
367+
323368
int main() {
324369
fprintf(stdout, "Running grammar integration tests...\n");
325370
test_simple_grammar();
326371
test_complex_grammar();
327372
test_quantifiers();
328373
test_failure_missing_root();
329374
test_failure_missing_reference();
375+
test_failure_left_recursion();
330376
fprintf(stdout, "All tests passed.\n");
331377
return 0;
332378
}

0 commit comments

Comments
 (0)