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",