use std::collections::{BTreeMap, BTreeSet, VecDeque};
use ferrox_models::grammar::json_schema::GrammarBuilder;
use crate::ApiError;
pub(super) fn text_excluding(
builder: &mut GrammarBuilder,
prefix: &str,
forbidden: &[&str],
) -> Result<String, ApiError> {
if forbidden.is_empty() || forbidden.iter().any(|literal| literal.is_empty()) {
return Ok(builder.add_rule(prefix, r#""""#));
}
let automaton = Automaton::over(forbidden);
let name_of = |state: usize| {
if state == 0 {
prefix.to_string()
} else {
format!("{prefix}-{state}")
}
};
for state in 0..automaton.nodes.len() {
if automaton.matched[state] {
continue;
}
let mut buckets: BTreeMap<usize, Vec<char>> = BTreeMap::new();
let mut specific: Vec<char> = Vec::new();
for &c in &automaton.alphabet {
let next = automaton.step(state, c);
if automaton.matched[next] {
specific.push(c);
} else if next != 0 {
buckets.entry(next).or_default().push(c);
specific.push(c);
}
}
let mut alternatives = vec![String::new()];
for (next, group) in &buckets {
alternatives.push(format!("{} {}", char_class(group, false), name_of(*next)));
}
alternatives.push(format!("{} {}", char_class(&specific, true), name_of(0)));
let name = name_of(state);
let got = builder.add_rule(&name, &alternatives.join(" | "));
if got != name {
return Err(super::internal(format!(
"tool-call grammar: the rule {name:?} that holds text excluding {forbidden:?} was \
renamed to {got:?}, so its own states would reference the wrong rule"
)));
}
}
Ok(name_of(0))
}
struct Automaton {
nodes: Vec<BTreeMap<char, usize>>,
fail: Vec<usize>,
matched: Vec<bool>,
alphabet: BTreeSet<char>,
}
impl Automaton {
fn over(literals: &[&str]) -> Self {
let mut automaton = Automaton {
nodes: vec![BTreeMap::new()],
fail: vec![0],
matched: vec![false],
alphabet: BTreeSet::new(),
};
for literal in literals {
let mut node = 0usize;
for c in literal.chars() {
automaton.alphabet.insert(c);
node = match automaton.nodes[node].get(&c) {
Some(&next) => next,
None => {
automaton.nodes.push(BTreeMap::new());
automaton.fail.push(0);
automaton.matched.push(false);
let next = automaton.nodes.len() - 1;
automaton.nodes[node].insert(c, next);
next
}
};
}
automaton.matched[node] = true;
}
let mut queue: VecDeque<usize> = automaton.nodes[0].values().copied().collect();
while let Some(node) = queue.pop_front() {
automaton.matched[node] =
automaton.matched[node] || automaton.matched[automaton.fail[node]];
for (&c, &child) in &automaton.nodes[node].clone() {
automaton.fail[child] = automaton.step(automaton.fail[node], c);
automaton.matched[child] = automaton.matched[child] || automaton.matched[node];
queue.push_back(child);
}
}
automaton
}
fn step(&self, state: usize, c: char) -> usize {
let mut state = state;
loop {
if let Some(&next) = self.nodes[state].get(&c) {
return next;
}
if state == 0 {
return 0;
}
state = self.fail[state];
}
}
}
fn char_class(chars: &[char], negated: bool) -> String {
let mut out = String::from("[");
if negated {
out.push('^');
}
for &c in chars {
match c {
'\r' => out.push_str("\\r"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'"' => out.push_str("\\\""),
'-' => out.push_str("\\x2D"),
']' => out.push_str("\\]"),
'[' => out.push_str("\\["),
'\\' => out.push_str("\\\\"),
'^' => out.push_str("\\x5E"),
c => out.push(c),
}
}
out.push(']');
out
}
#[cfg(test)]
mod tests {
use super::*;
use ferrox_models::grammar::Grammar;
fn accepts(forbidden: &str, text: &str) -> bool {
accepts_any(&[forbidden], text)
}
fn accepts_any(forbidden: &[&str], text: &str) -> bool {
let mut builder = GrammarBuilder::new();
let body = text_excluding(&mut builder, "not", forbidden).expect("a rule");
builder.add_rule("root", &format!("{body} \"END\""));
let grammar = Grammar::from_str_with_root(&builder.finish().expect("grammar"), "root")
.expect("compiles");
let mut g = grammar.clone();
let whole = format!("{text}END");
if g.accept_token(0, whole.as_bytes()).is_err() {
return false;
}
g.allows_eog()
}
#[test]
fn only_the_forbidden_literal_is_refused() {
assert!(accepts("</parameter>", "plain text"));
assert!(accepts("</parameter>", "<html><body>a < b</body></html>"));
assert!(accepts(
"</parameter>",
"</param> </parameters> <parameter>"
));
assert!(accepts("</parameter>", ""));
assert!(!accepts("</parameter>", "before</parameter>after"));
assert!(!accepts("</parameter>", "</parameter>"));
}
#[test]
fn a_restarted_partial_match_still_completes_the_literal() {
assert!(!accepts("ab", "aab"));
assert!(accepts("ab", "aa"));
assert!(!accepts("aa", "baaa"));
assert!(accepts("aa", "aba"));
assert!(!accepts("aba", "xxababa"));
assert!(accepts("aba", "xxabb"));
}
#[test]
fn a_multi_byte_literal_is_excluded_by_codepoint() {
assert!(accepts("</|DSML|parameter>", "値 with a | in it"));
assert!(accepts("</|DSML|parameter>", "</|DSML|invoke>"));
assert!(!accepts("</|DSML|parameter>", "x</|DSML|parameter>y"));
}
#[test]
fn a_set_of_literals_excludes_every_member() {
const QUOTE: &str = "<|\"|>";
const BLOCK_CLOSE: &str = "<tool_call|>";
let gemma = [QUOTE, BLOCK_CLOSE];
assert!(accepts_any(&gemma, "plain text"));
assert!(accepts_any(&gemma, "<| <|\" <tool_call| </tool_call>"));
assert!(!accepts_any(&gemma, "before<|\"|>after"));
assert!(!accepts_any(&gemma, "before<tool_call|>after"));
assert!(!accepts_any(&gemma, "<tool_call<|\"|>"));
assert!(!accepts_any(&gemma, "<|\"<tool_call|>"));
}
#[test]
fn a_literal_that_is_a_suffix_of_another_is_still_excluded() {
assert!(!accepts_any(&["abc", "bc"], "xbcx"));
assert!(!accepts_any(&["abc", "bc"], "xabcx"));
assert!(accepts_any(&["abc", "bc"], "xacx"));
assert!(!accepts_any(&["bc", "abcd"], "xbcx"));
assert!(accepts_any(&["bc", "abcd"], "xabd"));
}
#[test]
fn a_state_behind_a_matched_one_gets_no_rule() {
let mut builder = GrammarBuilder::new();
let body = text_excluding(&mut builder, "not", &["bc", "abcde"]).expect("a rule");
builder.add_rule("root", &body);
let text = builder.finish().expect("grammar");
let mut defined: Vec<&str> = Vec::new();
for line in text.lines() {
if let Some((name, _)) = line.split_once("::=") {
let name = name.trim();
if name.starts_with("not") {
defined.push(name);
}
}
}
assert!(
defined.len() > 1,
"the automaton should have states of its own: {text}"
);
for name in &defined {
if *name == "not" {
continue;
}
let referenced = text
.lines()
.filter(|line| !line.trim_start().starts_with(&format!("{name} ")))
.any(|line| {
line.split_once("::=")
.is_some_and(|(_, body)| body.split_whitespace().any(|word| word == *name))
});
assert!(
referenced,
"{name} is a state nothing can enter, so it should not have been written: {text}"
);
}
}
#[test]
fn a_literal_of_class_metacharacters_still_compiles() {
for forbidden in ["]-^", "[\\]", "\"a\"", "\n\t"] {
assert!(
accepts(forbidden, "harmless"),
"{forbidden:?} should compile and accept text without it"
);
assert!(
!accepts(forbidden, forbidden),
"{forbidden:?} should exclude itself"
);
}
}
}