metrale_grammar/structural_tag/mod.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//
3// Structural tag — pure-Rust port of `cpp/structural_tag.{cc,h}`.
4//
5// WHAT THIS IS
6// ------------
7// The structural tag is the tool-calling envelope mechanism. A model
8// is constrained to emit a stream where, between *trigger* points,
9// free text is allowed, and once a trigger prefix appears the model
10// must produce a tagged body — `begin <schema-conforming content> end`
11// — i.e. `<tool_call>{json}</tool_call>` style enforcement. Metrale Engine's
12// `grammar/compile_tools.rs` calls into this for every tool request.
13//
14// PIPELINE (mirrors `StructuralTagToGrammar`)
15// -------------------------------------------
16// 1. `parser` — structural-tag JSON document -> `StructuralTag`
17// AST (`Format` tree).
18// 2. `analyzer` — fills analyzer-only fields: detected end strings
19// and the `is_unlimited` flags.
20// 3. `converter` — `Format` tree -> BNF rules via `GrammarBuilder`,
21// reusing `src/schema` for embedded JSON schemas
22// and `src/regex` for regex content.
23// 4. normalize — `GrammarNormalizer` runs as the final step.
24//
25// FAITHFULNESS / SIMPLIFICATIONS vs C++
26// -------------------------------------
27// * C++ returns `Result<Grammar, StructuralTagError>` where the error
28// is a `std::variant`; we return `Result<_, StructuralTagError>`
29// with a two-variant enum carrying the same JSON-vs-tag distinction.
30// * C++ `RecursionGuard` aborts on overflow; we return an error.
31// * The C++ `StructuralTagAnalyzer` keeps a raw-pointer stack to find
32// the enclosing tag's end strings; we thread that list down the
33// recursion explicitly — equivalent, zero `unsafe`.
34// * Embedded `json_schema` sub-documents are re-serialized with an
35// order-preserving serializer (`json_serialize`) so JSON-Schema
36// property order survives, matching picojson `serialize(false)`.
37
38mod analyzer;
39mod converter;
40mod converter_sep;
41mod converter_tags;
42mod error;
43mod format;
44mod json_serialize;
45mod parser;
46mod parser_tags;
47
48#[cfg(test)]
49mod tests;
50
51pub use error::{StructuralTagError, StructuralTagResult};
52pub use format::{Format, SchemaStyle, StructuralTag, StructuralTagItem, TagFormat};
53
54use crate::grammar::GrammarData;
55use crate::grammar::builder::TagDispatchSpec;
56
57/// Build a [`TagDispatchSpec`] from the structural-tag fields. Shared
58/// by the `any_text` and `triggered_tags` converters.
59pub(crate) fn tag_dispatch_spec(
60 tag_rule_pairs: Vec<(String, i32)>,
61 stop_eos: bool,
62 stop_str: Vec<String>,
63 loop_after_dispatch: bool,
64 excluded_str: Vec<String>,
65) -> TagDispatchSpec {
66 TagDispatchSpec {
67 tag_rule_pairs,
68 stop_eos,
69 stop_str,
70 loop_after_dispatch,
71 excluded_str,
72 }
73}
74
75/// Convert a structural-tag JSON document into a grammar.
76///
77/// Port of `StructuralTagToGrammar` / `Grammar::FromStructuralTag(json)`.
78/// The document must be a `{"type": "structural_tag", "format": {...}}`
79/// object (the `type` field is optional but checked when present).
80pub fn structural_tag_to_grammar(structural_tag_json: &str) -> StructuralTagResult<GrammarData> {
81 let mut structural_tag = parser::StructuralTagParser::from_json(structural_tag_json)?;
82 analyzer::analyze(&mut structural_tag)?;
83 converter::StructuralTagConverter::convert(&structural_tag)
84}
85
86/// Build a grammar from a list of [`StructuralTagItem`]s and trigger
87/// prefixes — the legacy `Grammar::FromStructuralTag(tags, triggers)`
88/// entry point.
89///
90/// Each item becomes a `tag` (`begin`, a `json_schema` body, `end`)
91/// inside a single `triggered_tags` format whose triggers are
92/// `triggers`. This is the exact shape Metrale Engine's tool-call compiler
93/// uses: one tag per tool, one trigger per opening marker.
94///
95/// Port of `StructuralTag::from_legacy_structural_tag` followed by
96/// `StructuralTagToGrammar`.
97pub fn structural_tag_from_items(
98 tags: &[StructuralTagItem],
99 triggers: &[String],
100) -> StructuralTagResult<GrammarData> {
101 let json = build_legacy_json(tags, triggers)?;
102 structural_tag_to_grammar(&json)
103}
104
105/// Assemble the structural-tag JSON document for the legacy
106/// `(tags, triggers)` form. Each tag's `schema` string is embedded
107/// verbatim as the `json_schema` value (it must itself be valid JSON).
108fn build_legacy_json(
109 tags: &[StructuralTagItem],
110 triggers: &[String],
111) -> StructuralTagResult<String> {
112 let mut tag_objs = Vec::with_capacity(tags.len());
113 for item in tags {
114 // Validate the schema parses as JSON before embedding it.
115 crate::schema::JsonValue::parse(&item.schema).map_err(|e| {
116 StructuralTagError::json(format!("structural tag item schema is not valid JSON: {e}"))
117 })?;
118 tag_objs.push(format!(
119 r#"{{"type":"tag","begin":{},"content":{{"type":"json_schema","json_schema":{}}},"end":{}}}"#,
120 json_string(&item.begin),
121 item.schema,
122 json_string(&item.end),
123 ));
124 }
125 let trigger_strs: Vec<String> = triggers.iter().map(|t| json_string(t)).collect();
126 Ok(format!(
127 r#"{{"type":"structural_tag","format":{{"type":"triggered_tags","triggers":[{}],"tags":[{}]}}}}"#,
128 trigger_strs.join(","),
129 tag_objs.join(","),
130 ))
131}
132
133/// Render `s` as a JSON string literal.
134fn json_string(s: &str) -> String {
135 let mut out = String::with_capacity(s.len() + 2);
136 out.push('"');
137 for c in s.chars() {
138 match c {
139 '"' => out.push_str("\\\""),
140 '\\' => out.push_str("\\\\"),
141 '\n' => out.push_str("\\n"),
142 '\r' => out.push_str("\\r"),
143 '\t' => out.push_str("\\t"),
144 '\u{08}' => out.push_str("\\b"),
145 '\u{0c}' => out.push_str("\\f"),
146 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
147 c => out.push(c),
148 }
149 }
150 out.push('"');
151 out
152}