Commit 45c8ea837 for llama.cpp
commit 45c8ea837489be1304500dd1d298f2a671c985d4
Author: chinmaykamjith001 <chinmaykamjith@gmail.com>
Date: Tue Nov 10 02:57:35 2026 -0500
common/json-schema : fix allOf with plain types (e.g. integer) generating an empty object grammar (#31834)
* common/json-schema : fix allOf with plain types (e.g. integer) generating an empty object grammar
* Fix empty range error and incorporated bot review feedback
diff --git a/common/json-schema-to-grammar.cpp b/common/json-schema-to-grammar.cpp
index 6f3ecc992..b6ea61fc3 100644
--- a/common/json-schema-to-grammar.cpp
+++ b/common/json-schema-to-grammar.cpp
@@ -860,6 +860,7 @@ public:
std::unordered_set<std::string> required;
std::vector<std::pair<std::string, const common_chat_schema *>> properties;
std::map<std::string, size_t> enum_values;
+ std::vector<const common_chat_schema *> leaves;
std::function<void(const common_chat_schema &, bool, int)> add_component = [&](const common_chat_schema & comp, bool is_required, int depth) {
if (depth >= COMMON_RECURSIVE_LIMIT) {
throw std::invalid_argument("JSON schema is nested more than " + std::to_string(COMMON_RECURSIVE_LIMIT) + " levels deep");
@@ -879,6 +880,10 @@ public:
for (const auto & v : as<common_chat_schema_enum>(comp).values) {
enum_values[_generate_constant_rule(v)] += 1;
}
+ } else if (is_required && comp.kind() != common_chat_schema::KIND_ANY) {
+ // Direct non-structural children are used for merging. anyOf alternatives are not merged.
+ // Empty schemas are skipped.
+ leaves.push_back(&comp);
}
};
for (const auto & child : schema.children) {
@@ -901,6 +906,38 @@ public:
return _add_rule(rule_name, "(" + string_join(enum_intersection, " | ") + ")");
}
}
+
+ if (properties.empty() && !leaves.empty()) {
+ if (leaves.size() == 1) {
+ return visit(*leaves[0], name);
+ }
+ bool all_numeric = true;
+ bool any_integer = false;
+ int64_t lo = std::numeric_limits<int64_t>::min();
+ int64_t hi = std::numeric_limits<int64_t>::max();
+ for (const auto * leaf : leaves) {
+ if (leaf->kind() == common_chat_schema::KIND_INTEGER) {
+ const auto & i = as<common_chat_schema_integer>(*leaf);
+ any_integer = true;
+ lo = std::max(lo, i.minimum);
+ hi = std::min(hi, i.maximum);
+ } else if (leaf->kind() != common_chat_schema::KIND_NUMBER) {
+ all_numeric = false;
+ }
+ }
+ if (all_numeric) {
+ if (lo > hi) {
+ throw std::invalid_argument("allOf has an empty numeric range");
+ }
+ if (any_integer) {
+ common_chat_schema_integer merged;
+ merged.minimum = lo;
+ merged.maximum = hi;
+ return visit(merged, name);
+ }
+ return visit(*leaves[0], name);
+ }
+ }
return _add_rule(rule_name, _build_object_rule(properties, required, name, nullptr));
}
diff --git a/tests/test-json-schema-to-grammar.cpp b/tests/test-json-schema-to-grammar.cpp
index c7d971b29..ea10ad7e7 100755
--- a/tests/test-json-schema-to-grammar.cpp
+++ b/tests/test-json-schema-to-grammar.cpp
@@ -1325,6 +1325,66 @@ static void test_all(const std::string & title, std::function<void(const TestCas
)"""
});
+ test({
+ FAILURE,
+ "allOf with an empty integer range",
+ R"""({
+ "allOf": [
+ {"type": "integer", "minimum": 9},
+ {"type": "integer", "maximum": 5}
+ ]
+ })""",
+ ""
+ });
+
+ // NOTE: The number child has a minimum and maximum but the parser does not store bounds for number.
+ // NOTE: So the grammar is an unbounded integer, so the issue's 0-100 isn't enforced. This is an existing limitation of the parser.
+ test({
+ SUCCESS,
+ "allOf with plain integer and number schemas",
+ R"""({
+ "allOf": [
+ {"type": "integer"},
+ {"type": "number", "minimum": 0, "maximum": 100}
+ ]
+ })""",
+ R"""(
+ integral-part ::= [0] | [1-9] [0-9]{0,15}
+ root ::= ("-"? integral-part)
+ space ::= | " " | "\n"{1,2} [ \t]{0,20}
+ )"""
+ });
+
+ test({
+ SUCCESS,
+ "allOf with a single integer schema",
+ R"""({
+ "allOf": [
+ {"type": "integer"}
+ ]
+ })""",
+ R"""(
+ integral-part ::= [0] | [1-9] [0-9]{0,15}
+ root ::= ("-"? integral-part)
+ space ::= | " " | "\n"{1,2} [ \t]{0,20}
+ )"""
+ });
+
+ test({
+ SUCCESS,
+ "allOf with bounded integer and number schemas",
+ R"""({
+ "allOf": [
+ {"type": "integer", "minimum": 0, "maximum": 100},
+ {"type": "number"}
+ ]
+ })""",
+ R"""(
+ root ::= ([0-9] | ([1-8] [0-9] | [9] [0-9]) | "100")
+ space ::= | " " | "\n"{1,2} [ \t]{0,20}
+ )"""
+ });
+
test({
SUCCESS,
"conflicting names",