hf2q 0.1.1

Pure Rust CLI for converting HuggingFace models to hardware-optimized formats and serving them over an OpenAI-compatible API on Apple Silicon
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
//! GBNF AST → text serializer.
//!
//! Counterpart to `parser.rs` (`parse(text) -> Grammar`):
//! `serialize(grammar) -> text` produces a GBNF source string that the
//! parser re-parses to a semantically equivalent `Grammar` (same rule
//! count, same `GretElement` sequences modulo synthesized rule renames
//! that the parser is free to choose at re-parse time).
//!
//! # Reference
//!
//! Modeled on llama.cpp `llama_grammar_parser::print` +
//! `print_rule` at `/opt/llama.cpp/src/llama-grammar.cpp:296-371` +
//! `:721-736`.  The C++ `print_grammar_char` (`:231-238`) is **lossy** by
//! design ("cop out of encoding UTF-8" — emits `<U+04XX>` literal
//! placeholders that are NOT valid GBNF input).  The Rust port below
//! emits proper escapes so the output round-trips through `parse`.
//!
//! # Why this exists
//!
//! Wave 2.5 audit (cfa-20260427-adr005-wave2.5) caught the
//! `combine_function_grammars` token-scanning rewriter at handlers.rs
//! corrupting GBNF negated character classes such as `[^<\\]` (the
//! Gemma `gemma4-str-char` rule).  The wave-2.6 fix per goalie
//! research §Q4 is "parse-to-AST + serialize-with-renames"; this
//! module is the "serialize" half.  See research-report.md §Q4 for
//! the full design.
//!
//! # Round-trip guarantee
//!
//! `parse(serialize(parse(s))) == parse(s)` (semantic equivalence at
//! the AST level).  Strict byte-identity is NOT guaranteed because:
//!   - Comments are not preserved (llama.cpp's print_rule does not
//!     preserve comments either; the AST does not carry them).
//!   - Whitespace is normalized.
//!   - Rule emission order follows ascending rule-id.

use std::collections::HashMap;
use std::fmt::Write;

use super::parser::{Grammar, GretElement, GretType};

/// Serialize a parsed grammar back to GBNF source text.
///
/// Round-trip property: the returned string re-parses to a `Grammar`
/// whose `rules` are byte-identical to the input grammar's `rules`
/// (same `Vec<Vec<GretElement>>` contents).  Rule names are preserved.
/// Symbol-id assignments may differ if the input had been built by
/// hand (the parser assigns ids in encounter order); for any grammar
/// produced by `parse(...)`, names → ids round-trip exactly.
///
/// Mirrors `llama_grammar_parser::print` at
/// `/opt/llama.cpp/src/llama-grammar.cpp:721-736`.
pub fn serialize(grammar: &Grammar) -> String {
    // Build id → name map (inverse of grammar.symbol_ids).
    let id_to_name: HashMap<u32, String> = grammar
        .symbol_ids
        .iter()
        .map(|(name, id)| (*id, name.clone()))
        .collect();

    let mut out = String::with_capacity(256);
    for (i, rule) in grammar.rules.iter().enumerate() {
        let rule_id = i as u32;
        // Skip empty rule slots (shouldn't happen for a parsed grammar
        // since `parse` validates non-emptiness, but defend against
        // hand-built grammars).
        if rule.is_empty() {
            continue;
        }
        write_rule(&mut out, rule_id, rule, &id_to_name);
    }
    out
}

/// Serialize a single rule.  Mirrors `print_rule` at
/// `/opt/llama.cpp/src/llama-grammar.cpp:296-371`.
///
/// The bracket-close logic (peek at next element to decide whether to
/// emit `]`) is what makes the serializer character-class-aware
/// without scanning text — the AST already encodes the boundary via
/// `GretType::CharAlt` / `CharRngUpper` lookahead.  This is the
/// missing piece in Wave 2.5's token-scanning rewriter.
fn write_rule(
    out: &mut String,
    rule_id: u32,
    rule: &[GretElement],
    id_to_name: &HashMap<u32, String>,
) {
    // Sanity: every rule must terminate with End.  We mirror llama.cpp's
    // `throw runtime_error` by skipping the rule entirely (the parser
    // enforces this invariant on `parse`, so we should never see it).
    if rule.last().map(|e| e.ty) != Some(GretType::End) {
        return;
    }

    // Emit `name ::= `.
    let name = id_to_name
        .get(&rule_id)
        .map(|s| s.as_str())
        .unwrap_or("<anonymous>");
    let _ = write!(out, "{} ::= ", name);

    // Walk elements; for each char-element close the bracket only when
    // the next element is NOT another char-class continuation (CharAlt
    // or CharRngUpper).  CharAny (`.`) is a top-level atom, not a
    // class continuation — it must NOT suppress bracket-close (W-ζ fix).
    //
    // Empty-alternative disambiguation:
    // An alternative whose source form is empty (zero atoms) is
    // semantically valid — it matches the empty string — but emitting
    // it as bare whitespace breaks round-trip parsing because the
    // parser's `parse_alternates` (parser.rs:267-269 / llama.cpp
    // `:443`) calls `parse_space(.., newline_ok=true)` after `|`,
    // greedily eating the newline and the next rule's name as if it
    // were an alternative continuation.  We emit `""` (which the
    // parser consumes as a zero-element literal — see parser.rs:294
    // where `pos += 1` skips the opening `"`, and the inner loop
    // exits immediately on the closing `"`) to ensure every
    // alternative occupies at least one source token.
    //
    // `alt_is_empty` tracks whether we've emitted any atom since the
    // last Alt (or rule start).  Reset on Alt; flipped false on any
    // atom emission; checked when about to emit `| ` or close out
    // the rule.
    let n = rule.len() - 1; // exclude the trailing End
    let mut i = 0;
    let mut alt_is_empty = true;
    while i < n {
        let elem = rule[i];
        match elem.ty {
            GretType::End => {
                // Cannot happen pre-(n-1) for a valid rule; defend by
                // skipping (mirrors llama.cpp throwing an exception).
                return;
            }
            GretType::Alt => {
                if alt_is_empty {
                    // Pad the just-closed (empty) alternative with
                    // `""` so the parser sees it as a non-empty
                    // literal and doesn't merge it with siblings.
                    let _ = write!(out, "\"\" ");
                }
                let _ = write!(out, "| ");
                alt_is_empty = true;
            }
            GretType::RuleRef => {
                let ref_name = id_to_name
                    .get(&elem.value)
                    .map(|s| s.as_str())
                    .unwrap_or("<unknown>");
                let _ = write!(out, "{} ", ref_name);
                alt_is_empty = false;
            }
            GretType::Char => {
                let _ = write!(out, "[");
                write_char(out, elem.value, /* in_class = */ true);
                alt_is_empty = false;
            }
            GretType::CharNot => {
                let _ = write!(out, "[^");
                write_char(out, elem.value, /* in_class = */ true);
                alt_is_empty = false;
            }
            GretType::CharRngUpper => {
                // The parser enforces that this only follows a char
                // element; mirror llama.cpp's invariant assert.
                // CharRngUpper is a continuation of an existing class,
                // so the alt was already non-empty when we got here.
                let _ = write!(out, "-");
                write_char(out, elem.value, /* in_class = */ true);
            }
            GretType::CharAlt => {
                // Same continuation reasoning as CharRngUpper.
                write_char(out, elem.value, /* in_class = */ true);
            }
            GretType::CharAny => {
                let _ = write!(out, ".");
                alt_is_empty = false;
            }
        }

        // Bracket-close lookahead — mirrors llama.cpp lines 359-368.
        //
        // A char-class run (opened by Char or CharNot) continues only
        // while the NEXT element is CharAlt or CharRngUpper — both of
        // which are syntactically INSIDE the `[...]` bracket.
        //
        // CharAny (`.`) is a top-level alternative, NOT a char-class
        // continuation.  Including it in `inside_class_continues` was a
        // bug: `root ::= "a" .` would parse to [Char('a'), CharAny],
        // and when serializing Char('a') the lookahead saw CharAny as
        // "class continues", suppressing `]`, producing `[a.` instead of
        // `[a] .`.  The bracket never closed, breaking round-trip.
        //
        // Fix (W-ζ MED): remove CharAny from inside_class_continues.
        // CharAny is handled separately below (no bracket needed).
        if elem.ty.is_char_element() {
            // rule[i+1] always exists — End is always last.
            let next_ty = rule[i + 1].ty;
            let inside_class_continues =
                matches!(next_ty, GretType::CharAlt | GretType::CharRngUpper);
            if !inside_class_continues {
                // CharAny was emitted as `.` (a standalone atom, no
                // bracket).  Suppress `] ` for that case only.
                if elem.ty != GretType::CharAny {
                    let _ = write!(out, "] ");
                }
            }
        }

        i += 1;
    }
    // If the trailing alternative is empty (e.g. a recursive
    // subrule whose body is `RuleRef Alt End`), pad with `""` so
    // the parser doesn't bleed the next rule's name into this
    // one's tail.  See the "Empty-alternative disambiguation"
    // comment above for the failure mode this prevents.
    if alt_is_empty {
        let _ = write!(out, "\"\" ");
    }
    out.push('\n');
}

/// Emit a single Unicode scalar `cp` as GBNF source.
///
/// `in_class = true` adds escapes for the four characters that are
/// structural inside a character class (`[`, `]`, `\\`, `-`).  The
/// `-` escape isn't strictly necessary at every position (llama.cpp's
/// parser only treats `-` as range-introducer between two chars), but
/// always escaping it is the conservative choice that keeps the
/// serializer position-agnostic — every char-class element can be
/// emitted independently.
///
/// Outside a class (literal-string context — currently unused since
/// the parser stores literal strings as Char-element sequences inside
/// brackets, never as `"..."`), the same escape table applies minus
/// `-` and `]`.  We default `in_class = true` because the AST
/// representation puts every Char element through a `[...]` bracket
/// (literal strings degenerate to single-char `[c]` brackets).
///
/// All non-ASCII (`> 0x7F`) is emitted as `\uHHHH` (BMP) or
/// `\UHHHHHHHH` (supplementary plane).  This is round-trip safe
/// because the parser at `parse_char` decodes both forms into the
/// same `u32` code point.
fn write_char(out: &mut String, cp: u32, in_class: bool) {
    match cp {
        // Standard escape sequences with single-char shorthands.
        0x09 => out.push_str(r"\t"),
        0x0A => out.push_str(r"\n"),
        0x0D => out.push_str(r"\r"),
        // Backslash itself is always escaped.
        0x5C => out.push_str(r"\\"),
        // Inside a class: `]` ends the class, must be escaped.
        // Always escape `[` too for symmetry — the parser accepts
        // `\[` everywhere.
        0x5B if in_class => out.push_str(r"\["),
        0x5D if in_class => out.push_str(r"\]"),
        // `-` could be parsed as a range introducer; always escape
        // inside a class to make every char-element position-safe.
        0x2D if in_class => out.push_str(r"\x2D"),
        // Quote: `"` ends a quoted literal.  Inside a class it's
        // structurally fine but escape for consistency in case the
        // grammar is later moved to a literal context.
        0x22 => {
            // Escape only when not in class (literal context).  Inside
            // a class, `"` is just a regular char.
            if in_class {
                out.push('"');
            } else {
                out.push_str(r#"\""#);
            }
        }
        // Caret has special meaning ONLY immediately after `[` (the
        // negation marker). The serializer never emits Char as the
        // first element of a class — that role belongs to CharNot.
        // Still, escape `^` as a defensive measure if it appears
        // mid-class (parser accepts unescaped `^` mid-class but emit
        // hex for clarity).
        0x5E if in_class => out.push_str(r"\x5E"),
        // ASCII printable: emit verbatim.
        0x20..=0x7E => out.push(cp as u8 as char),
        // Control chars and other non-printable ASCII.
        0x00..=0x1F | 0x7F => {
            let _ = write!(out, r"\x{:02X}", cp);
        }
        // BMP: `\uHHHH`.
        0x80..=0xFFFF => {
            let _ = write!(out, r"\u{:04X}", cp);
        }
        // Supplementary planes: `\UHHHHHHHH`.
        _ => {
            let _ = write!(out, r"\U{:08X}", cp);
        }
    }
}

// ---------------------------------------------------------------------------
// AST manipulation primitives used by the AST-based combiner.
// ---------------------------------------------------------------------------

/// Apply `f` to every rule name in `grammar`, producing a NEW grammar
/// with renamed `symbol_ids` and references rewritten to match.  The
/// `rules` Vec layout (i.e. rule id → element sequence) is preserved
/// untouched — only the name → id map is rewritten, which makes
/// reference rewriting a no-op (RuleRef stores the rule-id, not the
/// name).
///
/// Pure AST operation: NO string scanning, NO body rewriting.  This
/// is the architectural inversion that fixes the wave-2.5 audit.
pub fn rename_rules<F>(grammar: &Grammar, mut f: F) -> Grammar
where
    F: FnMut(&str) -> String,
{
    let mut new_symbol_ids: HashMap<String, u32> = HashMap::with_capacity(grammar.symbol_ids.len());
    for (name, id) in &grammar.symbol_ids {
        let new_name = f(name);
        new_symbol_ids.insert(new_name, *id);
    }
    Grammar {
        rules: grammar.rules.clone(),
        symbol_ids: new_symbol_ids,
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::super::parser::{parse, GretElement, GretType};
    use super::*;

    /// Semantic AST equality: every rule NAME present in `a` must
    /// also be present in `b`, and the element-sequence under each
    /// shared name must match modulo rule-id renumbering.
    ///
    /// Why name-based and not id-based: the parser assigns rule ids
    /// in encounter order (parser.rs:182-190 `get_or_create_symbol`).
    /// `parse(serialize(g))` may visit rule names in a different
    /// order than `g`'s original parse — e.g. if `root` references
    /// `root_2` which references `root_1`, the second parse will
    /// see ids in (root, root_2, root_1) order, while the original
    /// `"a"{0,2}` parse produced (root, root_1, root_2).
    /// Rule bodies stay identical modulo this renumbering.
    ///
    /// This function performs the renumbering check: for every pair
    /// of rule references with matching NAMES on both sides, the
    /// element types and `RuleRef` target NAMES (not raw ids) must
    /// match.
    fn ast_eq(a: &Grammar, b: &Grammar) -> bool {
        if a.rules.len() != b.rules.len() {
            return false;
        }
        if a.symbol_ids.len() != b.symbol_ids.len() {
            return false;
        }
        // Both grammars must define the same set of rule names.
        for name in a.symbol_ids.keys() {
            if !b.symbol_ids.contains_key(name) {
                return false;
            }
        }
        // Build id → name maps for both.
        let a_id_to_name: std::collections::HashMap<u32, &str> = a
            .symbol_ids
            .iter()
            .map(|(n, id)| (*id, n.as_str()))
            .collect();
        let b_id_to_name: std::collections::HashMap<u32, &str> = b
            .symbol_ids
            .iter()
            .map(|(n, id)| (*id, n.as_str()))
            .collect();
        // For each rule by NAME, compare element sequences.
        for (name, &a_id) in &a.symbol_ids {
            let b_id = b.symbol_ids[name];
            let ra = &a.rules[a_id as usize];
            let rb = &b.rules[b_id as usize];
            if ra.len() != rb.len() {
                return false;
            }
            for (ea, eb) in ra.iter().zip(rb.iter()) {
                if ea.ty != eb.ty {
                    return false;
                }
                match ea.ty {
                    GretType::RuleRef => {
                        // Compare via referenced NAMES, not ids.
                        let na = a_id_to_name.get(&ea.value).copied().unwrap_or("?");
                        let nb = b_id_to_name.get(&eb.value).copied().unwrap_or("?");
                        if na != nb {
                            return false;
                        }
                    }
                    _ => {
                        if ea.value != eb.value {
                            return false;
                        }
                    }
                }
            }
        }
        true
    }

    fn roundtrip(src: &str) -> Grammar {
        let g1 = parse(src).expect("first parse");
        let serialized = serialize(&g1);
        let g2 = parse(&serialized).unwrap_or_else(|e| {
            panic!(
                "round-trip serialize → parse failed: {}\nserialized text was:\n{}",
                e, serialized
            )
        });
        assert!(
            ast_eq(&g1, &g2),
            "AST identity broken on round-trip\noriginal:\n{:?}\nserialized:\n{}\nreparsed:\n{:?}",
            g1,
            serialized,
            g2
        );
        g2
    }

    #[test]
    fn round_trip_simple_literal() {
        roundtrip("root ::= \"hello\"\n");
    }

    // -------------------------------------------------------------------
    // Worker-spec round-trip tests (wave2.6 W-γ5a Q4-A acceptance bar).
    // These mirror the names called out in the worker prompt; the
    // shape-equivalent tests above already exercise the same coverage,
    // but the explicit worker-named variants keep the audit trail
    // grep-able from the cfa-20260427 prompt.
    // -------------------------------------------------------------------

    /// Spec test #1: minimal grammar to anchor the scaffold.
    #[test]
    fn parser_round_trip_simple_grammar() {
        roundtrip("root ::= \"hi\"\n");
    }

    /// Spec test #2: the wave-2.5 audit's failing case literally.
    /// Negated char class with backslash → must NOT be corrupted to
    /// `[<\\]` (positive class) by the round-trip.
    #[test]
    fn parser_round_trip_grammar_with_negated_char_class() {
        roundtrip("root ::= [^<\\\\]\n");
    }

    /// Spec test #3: quoted-literal escape coverage. `<|tool_call>`
    /// is the canonical Gemma/Qwen tool-call open token; this is the
    /// shape `combine_function_grammars` will see in production.
    #[test]
    fn parser_round_trip_grammar_with_quoted_literal_escapes() {
        // Embeds the literal `<|tool_call>` as a GBNF quoted string.
        roundtrip("root ::= \"<|tool_call>\"\n");
    }

    /// Spec test #4: alternations + groups, the structural shape from
    /// the worker prompt (`root := A | B (C D)`).  Group expansion
    /// goes through synthesized `_`-named subrules — exercises the
    /// parser/emitter `_`-in-name agreement (parser.rs:600 fix).
    #[test]
    fn parser_round_trip_grammar_with_alternations_and_groups() {
        roundtrip("root ::= a | b ( c d )\na ::= \"a\"\nb ::= \"b\"\nc ::= \"c\"\nd ::= \"d\"\n");
    }

    #[test]
    fn round_trip_alternation() {
        roundtrip("root ::= \"a\" | \"b\" | \"c\"\n");
    }

    #[test]
    fn round_trip_char_class_range() {
        roundtrip("root ::= [a-z]\n");
    }

    #[test]
    fn round_trip_negated_char_class() {
        // The wave-2.5 audit's failing case: negated char class with
        // backslash inside.  Mirrors gemma4-str-char from
        // src/serve/api/registry.rs:1051.
        roundtrip("root ::= [^<\\\\]\n");
    }

    #[test]
    fn round_trip_negated_with_quote_and_backslash() {
        // The canonical JSON `string` rule's interior char.  Same
        // shape as `[^"\\]` in /opt/llama.cpp/grammars/json.gbnf.
        roundtrip("root ::= [^\"\\\\]\n");
    }

    #[test]
    fn round_trip_char_class_multi_alt() {
        roundtrip("root ::= [abc]\n");
    }

    #[test]
    fn round_trip_char_class_range_plus_alt() {
        // [a-zA-Z0-9] — three ranges in one class.
        roundtrip("root ::= [a-zA-Z0-9]\n");
    }

    #[test]
    fn round_trip_quoted_literal_with_escapes() {
        // Backslash + double quote + tab + newline in a literal.
        // Round-trips because every char goes through the AST as
        // a Char element with its decoded code point.
        roundtrip("root ::= \"a\\\\b\\\"c\\nd\\te\"\n");
    }

    #[test]
    fn round_trip_rule_reference() {
        roundtrip("root ::= ws \"x\" ws\nws ::= \" \"?\n");
    }

    #[test]
    fn round_trip_repetition_star() {
        roundtrip("root ::= \"a\"*\n");
    }

    #[test]
    fn round_trip_repetition_plus() {
        roundtrip("root ::= \"a\"+\n");
    }

    #[test]
    fn round_trip_grouping() {
        roundtrip("root ::= ( \"x\" \"y\" ) | \"z\"\n");
    }

    #[test]
    fn round_trip_any_char_dot() {
        roundtrip("root ::= .\n");
    }

    #[test]
    fn round_trip_utf8_literal() {
        // Greek alpha (U+03B1).
        roundtrip("root ::= \"α\"\n");
    }

    #[test]
    fn round_trip_supplementary_plane() {
        // U+1F600 (😀) emits as \U0001F600 — verifies the supplementary
        // plane branch.
        roundtrip("root ::= \"😀\"\n");
    }

    #[test]
    fn round_trip_json_grammar_fixture() {
        // The canonical llama.cpp json grammar.  Stress-tests every
        // GBNF feature we serialize: nested groups, recursion,
        // quoted-literal escapes, negated char class with
        // backslash, ranges, comments (not preserved).
        let src = std::fs::read_to_string("/opt/llama.cpp/grammars/json.gbnf")
            .expect("json.gbnf fixture present");
        roundtrip(&src);
    }

    #[test]
    fn round_trip_arithmetic_grammar_fixture() {
        let src = std::fs::read_to_string("/opt/llama.cpp/grammars/arithmetic.gbnf")
            .expect("arithmetic.gbnf fixture present");
        roundtrip(&src);
    }

    #[test]
    fn round_trip_list_grammar_fixture() {
        let src = std::fs::read_to_string("/opt/llama.cpp/grammars/list.gbnf")
            .expect("list.gbnf fixture present");
        roundtrip(&src);
    }

    #[test]
    fn negated_char_class_semantics_preserved_after_round_trip() {
        // The audit-driving test: build a grammar with `[^<\\]`, run
        // it through the runtime, and confirm that a STRING with `<`
        // is REJECTED (not accepted, as it would be if `[^<\\]` got
        // corrupted to `[<\\]`).
        use super::super::parser::parse;
        use super::super::sampler::GrammarRuntime;

        let src = "root ::= [^<\\\\]+\n";
        let g1 = parse(src).expect("first parse");
        let serialized = serialize(&g1);
        let g2 = parse(&serialized).expect("re-parse");

        // Run the re-parsed grammar through the runtime against `<`.
        let rid = g2.rule_id("root").expect("root rule exists");
        let mut rt = GrammarRuntime::new(g2, rid).expect("runtime init");
        let alive = rt.accept_bytes(b"<");
        assert!(
            !alive,
            "negated char class `[^<\\\\]` was corrupted during round-trip: \
             a `<` byte was ACCEPTED, but the grammar should REJECT it"
        );
    }

    #[test]
    fn rename_rules_preserves_rule_bodies() {
        let src = "root ::= ws \"x\"\nws ::= \" \"?\n";
        let g = parse(src).expect("parse");
        let renamed = rename_rules(&g, |n| format!("fn-7-{}", n));
        // Rule bodies are byte-identical.
        assert_eq!(renamed.rules, g.rules);
        // Names are renamed.
        assert!(renamed.symbol_ids.contains_key("fn-7-root"));
        assert!(renamed.symbol_ids.contains_key("fn-7-ws"));
        // Old names are gone.
        assert!(!renamed.symbol_ids.contains_key("root"));
        assert!(!renamed.symbol_ids.contains_key("ws"));
        // Rule-ids preserved.
        assert_eq!(renamed.rule_id("fn-7-root"), g.rule_id("root"));
        assert_eq!(renamed.rule_id("fn-7-ws"), g.rule_id("ws"));
    }

    #[test]
    fn rename_rules_round_trip_serialize_parse() {
        let src = "root ::= ws \"x\"\nws ::= \" \"?\n";
        let g = parse(src).expect("parse");
        let renamed = rename_rules(&g, |n| format!("fn-3-{}", n));
        let text = serialize(&renamed);
        let reparsed = parse(&text).expect("re-parse renamed");
        // The reparsed grammar must contain the renamed names.
        assert!(reparsed.symbol_ids.contains_key("fn-3-root"));
        assert!(reparsed.symbol_ids.contains_key("fn-3-ws"));
        // And the rule bodies must match what `rename_rules` produced.
        // (Re-parse may reassign rule-ids, so compare via name lookup.)
        for name in renamed.symbol_ids.keys() {
            let rid_a = renamed.rule_id(name).expect("renamed has name");
            let rid_b = reparsed.rule_id(name).expect("reparsed has name");
            // Element sequences must be identical.
            assert_eq!(
                renamed.rules[rid_a as usize], reparsed.rules[rid_b as usize],
                "rule body for {} differs after round trip",
                name
            );
        }
    }

    #[test]
    fn empty_grammar_serializes_to_empty_string() {
        let g = Grammar {
            rules: Vec::new(),
            symbol_ids: HashMap::new(),
        };
        assert_eq!(serialize(&g), "");
    }

    #[test]
    fn write_char_emits_hex_for_control() {
        let mut s = String::new();
        write_char(&mut s, 0x07, true); // BEL
        assert_eq!(s, r"\x07");
    }

    #[test]
    fn write_char_emits_unicode_escape_for_bmp() {
        let mut s = String::new();
        write_char(&mut s, 0x03B1, true); // Greek alpha
                                          // BMP code point above ASCII → emitted as `\uHHHH` so the
                                          // serialized output is round-trip safe through `parse_char`'s
                                          // `\u` branch (parser.rs:698).
        assert_eq!(s, r"\u03B1");
    }

    #[test]
    fn write_char_emits_long_unicode_for_supplementary() {
        let mut s = String::new();
        write_char(&mut s, 0x1F600, true); // 😀
        assert_eq!(s, r"\U0001F600");
    }

    #[test]
    fn write_char_escapes_backslash() {
        let mut s = String::new();
        write_char(&mut s, 0x5C, true);
        assert_eq!(s, r"\\");
    }

    #[test]
    fn write_char_escapes_close_bracket_in_class() {
        let mut s = String::new();
        write_char(&mut s, 0x5D, true);
        assert_eq!(s, r"\]");
    }

    /// Defensive: make sure a synthesized rule (e.g. from `?` expansion)
    /// round-trips. The parser generates rules like `root_1 ::= |  | `
    /// for `"a"?` which contain only Alt+End structures.
    #[test]
    fn round_trip_zero_or_one_repetition() {
        roundtrip("root ::= \"a\"?\n");
    }

    /// Defensive: brace-form repetition expands into multiple
    /// synthesized subrules; round-trip exercises serialization of
    /// chained RuleRef + Alt + End.
    #[test]
    fn round_trip_brace_min_max_repetition() {
        roundtrip("root ::= \"a\"{0,2}\n");
    }

    /// Real-production fixture: the gemma4-str-char rule body literally
    /// from src/serve/api/registry.rs:1051. This is the audit-failing
    /// case lifted into a unit test on the AST serialize.
    #[test]
    fn round_trip_gemma4_str_char_rule() {
        let src = "gemma4-str-char ::= [^<\\\\] | [\\\\] [^\\x00-\\x1F]\n\
                   root ::= gemma4-str-char\n";
        let g1 = parse(src).expect("first parse");
        let serialized = serialize(&g1);
        let g2 = parse(&serialized).expect("re-parse");
        // Semantic equivalence: the gemma4-str-char rule body matches.
        let id1 = g1.rule_id("gemma4-str-char").expect("rule exists");
        let id2 = g2
            .rule_id("gemma4-str-char")
            .expect("rule exists post-roundtrip");
        assert_eq!(
            g1.rules[id1 as usize], g2.rules[id2 as usize],
            "gemma4-str-char rule body differs after round trip"
        );
    }

    /// Negated-char-class with a trailing range: the parse element
    /// sequence is CharNot(0x00) + CharRngUpper(0x1F).  Verifies the
    /// bracket-close lookahead handles the boundary correctly.
    #[test]
    fn round_trip_negated_class_with_range() {
        roundtrip("root ::= [^\\x00-\\x1F]\n");
    }

    /// Two char classes with a literal between them — exercises the
    /// CharAlt → CharRngUpper transition in adjacent classes.
    #[test]
    fn round_trip_adjacent_char_classes() {
        roundtrip("root ::= [a-z] \"x\" [A-Z]\n");
    }

    /// A char class containing a literal close-bracket via escape:
    /// `[\\]]` would be `[` followed by escaped-`]` followed by `]` —
    /// the escape is consumed in the AST, so on round-trip we must
    /// emit `\]` again.  This is the core escape-discipline test.
    #[test]
    fn round_trip_class_with_escaped_close_bracket() {
        let src = "root ::= [\\]a]\n";
        let g1 = parse(src).expect("first parse");
        let serialized = serialize(&g1);
        let g2 = parse(&serialized).expect("re-parse");
        assert!(
            ast_eq(&g1, &g2),
            "escaped close-bracket in class did not round-trip:\n  serialized: {}",
            serialized
        );
    }

    // -----------------------------------------------------------------------
    // W-ζ MED — Q4-A CharAny adjacency round-trip tests
    //
    // These three tests cover the three shapes called out in the wave-2.7
    // audit: CharAny following a quoted literal, a positive class, and a
    // negated class.  In all three cases the previous (broken) lookahead
    // included CharAny in `inside_class_continues`, which suppressed the
    // `] ` bracket-close when the next element was `.`.
    // -----------------------------------------------------------------------

    /// W-ζ MED Q4-A: `root ::= "a" .` — CharAny following a quoted literal.
    /// Pre-fix: serialize emitted `"a" .` (no bracket) — already fine; this
    /// test confirms the literal+dot case was never broken.  Included for
    /// completeness as the audit's canonical example.
    #[test]
    fn parser_round_trip_char_any_after_quoted_literal() {
        roundtrip("root ::= \"a\" .\n");
    }

    /// W-ζ MED Q4-A: `root ::= [a] .` — CharAny following a positive char class.
    /// Pre-fix: serialize emitted `[a.` (bracket never closed).
    /// Post-fix: serialize emits `[a] .` which re-parses correctly.
    #[test]
    fn parser_round_trip_char_any_after_class() {
        roundtrip("root ::= [a] .\n");
    }

    /// W-ζ MED Q4-A: `root ::= [^a] .` — CharAny following a negated char class.
    /// Pre-fix: serialize emitted `[^a.` (bracket never closed).
    /// Post-fix: serialize emits `[^a] .` which re-parses correctly.
    #[test]
    fn parser_round_trip_char_any_after_negated_class() {
        roundtrip("root ::= [^a] .\n");
    }

    /// Used as a vector for the unused-import warnings — keeps
    /// `GretElement` / `GretType` imports active in the test module
    /// even when the body uses only one of them.
    #[allow(dead_code)]
    fn _silence_unused_import_warnings() {
        let _ = GretElement::new(GretType::End, 0);
    }
}