ferrox_models/tokenizer/wordpiece.rs
1//! WordPiece, for GGUF files whose `tokenizer.ggml.model` is `bert`.
2//!
3//! A transcription of llama.cpp's `LLAMA_VOCAB_TYPE_WPM` path:
4//! `llm_tokenizer_wpm_session::tokenize` and its `preprocess`, in
5//! `.scratch/llama.cpp/src/llama-vocab.cpp`. Its own module rather than
6//! a fourth section of [`super`], which is already past two thousand
7//! lines, and because everything here is reviewed *against that file*.
8//!
9//! # What a WordPiece GGUF actually holds
10//!
11//! Not what the HuggingFace `vocab.txt` holds. llama.cpp's converter
12//! (`conversion/bert.py`) rewrites every vocabulary entry on the way
13//! into the GGUF, and the tokenizer below only makes sense against the
14//! rewritten form:
15//!
16//! * a continuation piece `##ing` is stored as `ing`, with the `##`
17//! stripped;
18//! * a word-initial piece `hello` is stored as `▁hello` (U+2581);
19//! * a `CONTROL` entry such as `[CLS]` is stored unchanged.
20//!
21//! So the `##` prefix does not appear anywhere in this file. Its job,
22//! marking "this piece may only continue a word", is done by the
23//! *absence* of the `▁` that only the first lookup of each word can
24//! match. That is why the loop below prepends `▁` to the word once and
25//! then walks straight through: position 0 can only match a
26//! word-initial piece, and every later position can only match a
27//! continuation.
28//!
29//! # The algorithm, in the order it runs
30//!
31//! 1. **Normalize and split into words** ([`preprocess`]). This is
32//! BertNormalizer plus BertPreTokenizer, not the BPE pre-tokenizer
33//! regex next door in [`super::pretokenize`]: NFD-fold and drop
34//! accents, drop controls, lowercase, break on whitespace, and give
35//! every punctuation, ASCII symbol and CJK character a word of its
36//! own.
37//! 2. **Greedy longest-match-first** per word, from the left, over the
38//! `▁`-prefixed word.
39//! 3. **All-or-nothing fallback.** If any position in a word has no
40//! match at any length, every piece already emitted for that word is
41//! discarded and the word becomes a single unknown token. WordPiece
42//! does not fall back per character, and it does not fall back to
43//! bytes: a word is either fully covered or it is `[UNK]`.
44//!
45//! # Bytes, not characters
46//!
47//! The match loop indexes **bytes**, because llama.cpp's does
48//! (`word1.substr(i, j - i)` over a `std::string`) and because a
49//! vocabulary piece is free to hold one byte of a multi-byte character.
50//! Cutting on `char` boundaries instead would silently skip candidate
51//! lengths and split CJK differently. The lookup table is therefore
52//! keyed by byte string; a slice that is not valid UTF-8 simply matches
53//! nothing, exactly as it matches nothing upstream.
54
55use super::unicode;
56use super::{load_special_tokens, split_on_special_tokens, TextOrSpecial, TokenizerLoadError};
57use std::collections::HashMap;
58
59/// The phantom space llama.cpp's converter puts in front of every
60/// word-initial piece, and that [`GgufWordPieceTokenizer::encode`] puts
61/// in front of every word before matching.
62const PHANTOM_SPACE: &str = "\u{2581}";
63
64/// llama.cpp's `bert` defaults for `special_unk_id`. Applied when the
65/// GGUF carries no `tokenizer.ggml.unknown_token_id`, which is what
66/// upstream does: it seeds the id from the `tokenizer_model == "bert"`
67/// arm and only then lets metadata override it.
68const DEFAULT_UNK_ID: u32 = 100;
69
70/// The `BertNormalizer` switches, and the defaults llama.cpp applies
71/// when a checkpoint does not carry them.
72///
73/// Both default to **true**, and `strip_accents` defaults to whatever
74/// `lowercase` resolved to rather than to a constant. That chain is
75/// upstream's, verbatim: `normalizer_opts.lowercase` is read first,
76/// `strip_accents` is then seeded from it, and only then is
77/// `tokenizer.ggml.normalizer.strip_accents` allowed to override. The
78/// GGUFs people actually have predate both keys, so the defaults are
79/// the live path, not the fallback.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct NormalizerOptions {
82 pub lowercase: bool,
83 pub strip_accents: bool,
84}
85
86impl NormalizerOptions {
87 fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Self {
88 let lowercase = file
89 .metadata_bool("tokenizer.ggml.normalizer.lowercase")
90 .unwrap_or(true);
91 let strip_accents = file
92 .metadata_bool("tokenizer.ggml.normalizer.strip_accents")
93 .unwrap_or(lowercase);
94 NormalizerOptions {
95 lowercase,
96 strip_accents,
97 }
98 }
99}
100
101/// A real WordPiece tokenizer built from a GGUF file's own
102/// `tokenizer.ggml.tokens` metadata array.
103///
104/// See the module docs for the algorithm and for why the vocabulary
105/// looks the way it does. Checked against llama.cpp on the same GGUF by
106/// `crates/ferrox-models/tests/wordpiece_parity.rs`.
107pub struct GgufWordPieceTokenizer {
108 /// Keyed by the piece's **bytes**. See the module docs.
109 token_to_id: HashMap<Vec<u8>, u32>,
110 id_to_token: Vec<String>,
111 /// The longest piece in the vocabulary, in bytes. Upstream's
112 /// `max_token_len`, and the reason the match loop is linear rather
113 /// than quadratic in the length of the word.
114 max_token_len: usize,
115 unk_id: u32,
116 normalizer: NormalizerOptions,
117 /// `CONTROL`/`USER_DEFINED` entries, carved out of raw text before
118 /// normalization runs. For a BERT vocabulary this is `[PAD]`,
119 /// `[UNK]`, `[CLS]`, `[SEP]` and `[MASK]`, which is exactly
120 /// llama.cpp's `cache_special_tokens` for the same file.
121 special_tokens: Vec<(String, u32)>,
122}
123
124impl GgufWordPieceTokenizer {
125 pub fn from_gguf(file: &impl ferrox_gguf::TensorSource) -> Result<Self, TokenizerLoadError> {
126 let tokens_value = file
127 .metadata("tokenizer.ggml.tokens")
128 .ok_or(TokenizerLoadError::MissingTokens)?;
129 let id_to_token: Vec<String> = match tokens_value {
130 ferrox_gguf::GgufValue::Array(items) => items
131 .iter()
132 .map(|v| v.as_str().map(|s| s.to_string()))
133 .collect::<Option<Vec<_>>>()
134 .ok_or(TokenizerLoadError::TokensNotStringArray)?,
135 _ => return Err(TokenizerLoadError::TokensNotStringArray),
136 };
137
138 // First id wins on a duplicate piece, matching upstream's
139 // `token_to_id[word] = i` only ever being written for a word it
140 // has not seen (a GGUF with a repeated piece is malformed, but
141 // it must not decide the answer by hash order).
142 let mut token_to_id: HashMap<Vec<u8>, u32> = HashMap::with_capacity(id_to_token.len());
143 for (i, text) in id_to_token.iter().enumerate() {
144 token_to_id
145 .entry(text.as_bytes().to_vec())
146 .or_insert(i as u32);
147 }
148 let max_token_len = id_to_token.iter().map(|t| t.len()).max().unwrap_or(0);
149
150 let unk_id = file
151 .metadata("tokenizer.ggml.unknown_token_id")
152 .and_then(|v| v.as_u64())
153 .map(|v| v as u32)
154 .unwrap_or(DEFAULT_UNK_ID);
155
156 let special_tokens = load_special_tokens(file, &id_to_token);
157
158 Ok(GgufWordPieceTokenizer {
159 token_to_id,
160 id_to_token,
161 max_token_len,
162 unk_id,
163 normalizer: NormalizerOptions::from_gguf(file),
164 special_tokens,
165 })
166 }
167
168 pub fn vocab_size(&self) -> usize {
169 self.id_to_token.len()
170 }
171
172 pub fn normalizer(&self) -> NormalizerOptions {
173 self.normalizer
174 }
175
176 /// Encodes `text`, with no `[CLS]`/`[SEP]` added.
177 ///
178 /// Upstream's `add_special` wraps the result in BOS and SEP; that
179 /// decision lives with [`super::should_add_bos_token`] and
180 /// [`super::prepend_bos`] for every tokenizer in this crate, and
181 /// baking it in here would double it for callers that already do it.
182 pub fn encode(&self, text: &str) -> Vec<u32> {
183 let mut out = Vec::new();
184 for seg in split_on_special_tokens(text, &self.special_tokens) {
185 match seg {
186 TextOrSpecial::Special(id) => out.push(id),
187 TextOrSpecial::Text(t) => self.encode_normal_run(t, &mut out),
188 }
189 }
190 out
191 }
192
193 fn encode_normal_run(&self, text: &str, out: &mut Vec<u32>) {
194 for word in preprocess(text, self.normalizer) {
195 if word.is_empty() {
196 continue;
197 }
198 let word1 = format!("{PHANTOM_SPACE}{word}");
199 let bytes = word1.as_bytes();
200 let n = bytes.len();
201 let start = out.len();
202
203 let mut i = 0usize;
204 while i < n {
205 // Longest match first, capped at the longest piece the
206 // vocabulary holds. The `+ 1` is upstream's and is
207 // deliberately kept: it lets the first probe be one byte
208 // longer than any piece, which can never match and costs
209 // one lookup.
210 let mut j = n.min(i + self.max_token_len + 1);
211 let mut matched = false;
212 while j > i {
213 if let Some(&id) = self.token_to_id.get(&bytes[i..j]) {
214 out.push(id);
215 i = j;
216 matched = true;
217 break;
218 }
219 j -= 1;
220 }
221 if !matched {
222 // All or nothing: discard the pieces already emitted
223 // for THIS word and stop. The `[UNK]` below covers
224 // the whole word.
225 out.truncate(start);
226 break;
227 }
228 }
229
230 if out.len() == start {
231 out.push(self.unk_id);
232 }
233 }
234 }
235
236 /// The text a token id stands for.
237 ///
238 /// Reverses the converter's phantom space, so `▁hello` decodes to
239 /// `" hello"` and the continuation `ing` decodes to `"ing"`.
240 /// Concatenating a sequence therefore reproduces the normalized
241 /// text with a leading space, which is what llama.cpp's
242 /// `llama_unescape_whitespace` produces before its `clean_spaces`
243 /// pass trims the first one.
244 ///
245 /// A WordPiece round trip is lossy no matter what this does: the
246 /// normalizer lowercased, stripped accents and dropped controls
247 /// before any of these ids existed. Round-tripping is therefore NOT
248 /// evidence that this tokenizer is right, which is why the tests
249 /// that matter compare ids against llama.cpp instead.
250 pub fn decode(&self, ids: &[u32]) -> String {
251 let mut out = String::new();
252 for &id in ids {
253 if let Some(token) = self.id_to_token.get(id as usize) {
254 out.push_str(&token.replace(PHANTOM_SPACE, " "));
255 }
256 }
257 out
258 }
259}
260
261/// The codepoint ranges llama.cpp counts as Chinese, and therefore
262/// splits into single-character words.
263///
264/// Verbatim from `llm_tokenizer_wpm_session::is_chinese_char`, including
265/// the range that upstream's own comment flags as wrong (`0x2B920`
266/// should be `0x2B820`; the HuggingFace Rust implementation has the same
267/// value, so matching it is the point) and including the two ranges it
268/// leaves commented out. Widening this set would split CJK punctuation
269/// differently from the reference.
270fn is_chinese_char(c: char) -> bool {
271 let cpt = c as u32;
272 (0x04E00..=0x09FFF).contains(&cpt)
273 || (0x03400..=0x04DBF).contains(&cpt)
274 || (0x20000..=0x2A6DF).contains(&cpt)
275 || (0x2A700..=0x2B73F).contains(&cpt)
276 || (0x2B740..=0x2B81F).contains(&cpt)
277 || (0x2B920..=0x2CEAF).contains(&cpt)
278 || (0x0F900..=0x0FAFF).contains(&cpt)
279 || (0x2F800..=0x2FA1F).contains(&cpt)
280}
281
282/// BertNormalizer + BertPreTokenizer: normalize `text` and cut it into
283/// words.
284///
285/// A transcription of `llm_tokenizer_wpm_session::preprocess`. The order
286/// of the tests is load-bearing and is upstream's:
287///
288/// 1. whitespace ends the current word and is otherwise dropped;
289/// 2. NUL, U+FFFD and `\p{C}` are dropped, so a soft hyphen or a
290/// zero-width space joins the characters on either side of it into
291/// one word rather than breaking it;
292/// 3. an accent mark is dropped when `strip_accents`;
293/// 4. the character is lowercased when `lowercase`;
294/// 5. punctuation, an **ASCII** symbol and a CJK character each become a
295/// word of their own;
296/// 6. anything else extends the current word.
297///
298/// Two details that a plausible re-derivation gets wrong. The accent
299/// fold in step 3 runs over the NFD-folded text, so `é` has already
300/// become `e` and there is no mark left to drop; the test exists for
301/// marks that were standalone in the input, like the `e` + U+0301 in the
302/// parity corpus. And step 5 tests `is_symbol` only below U+007F, so
303/// `+` starts a new word and `€` does not.
304///
305/// Returns owned `String`s because steps 3 and 4 mean a word is not a
306/// slice of the input.
307fn preprocess(text: &str, opts: NormalizerOptions) -> Vec<String> {
308 let mut words: Vec<String> = vec![String::new()];
309
310 for raw in text.chars() {
311 let c = if opts.strip_accents {
312 unicode::nfd_base(raw)
313 } else {
314 raw
315 };
316
317 if unicode::is_whitespace(c) {
318 if !words.last().is_some_and(String::is_empty) {
319 words.push(String::new());
320 }
321 continue;
322 }
323
324 let flags = unicode::flags(c);
325 debug_assert!(
326 !flags.is_separator(),
327 "every \\p{{Z}} codepoint is also White_Space, so the check above \
328 should have consumed it: {c:?}"
329 );
330
331 if c == '\0' || c == '\u{fffd}' || flags.is_control() {
332 continue;
333 }
334 if opts.strip_accents && flags.is_accent_mark() {
335 continue;
336 }
337
338 let c = if opts.lowercase {
339 unicode::to_lower(c)
340 } else {
341 c
342 };
343
344 if flags.is_punctuation() || ((c as u32) < 0x7F && flags.is_symbol()) || is_chinese_char(c)
345 {
346 if !words.last().is_some_and(String::is_empty) {
347 words.push(String::new());
348 }
349 // A word of exactly this character, then a fresh word for
350 // whatever follows.
351 words.last_mut().expect("just pushed or non-empty").push(c);
352 words.push(String::new());
353 } else {
354 words.last_mut().expect("seeded with one word").push(c);
355 }
356 }
357
358 if words.last().is_some_and(String::is_empty) {
359 words.pop();
360 }
361 words
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 const BOTH: NormalizerOptions = NormalizerOptions {
369 lowercase: true,
370 strip_accents: true,
371 };
372 const NEITHER: NormalizerOptions = NormalizerOptions {
373 lowercase: false,
374 strip_accents: false,
375 };
376
377 fn words(text: &str, opts: NormalizerOptions) -> Vec<String> {
378 preprocess(text, opts)
379 }
380
381 #[test]
382 fn whitespace_splits_words_and_is_dropped() {
383 assert_eq!(words("hello world", BOTH), ["hello", "world"]);
384 assert_eq!(words(" hello world ", BOTH), ["hello", "world"]);
385 assert_eq!(words("", BOTH), Vec::<String>::new());
386 assert_eq!(words(" ", BOTH), Vec::<String>::new());
387 }
388
389 /// The parity corpus's `unicode-space` case, which is the reason
390 /// this cannot be `char::is_ascii_whitespace`: NBSP and the
391 /// ideographic space break words, and ZWSP does not because it is
392 /// `Cf` and gets dropped instead.
393 #[test]
394 fn unicode_spaces_break_words_but_zero_width_ones_vanish() {
395 assert_eq!(
396 words("a\u{a0}b\u{3000}c\u{2009}d\u{200b}e", BOTH),
397 ["a", "b", "c", "de"]
398 );
399 }
400
401 #[test]
402 fn punctuation_and_ascii_symbols_become_single_character_words() {
403 assert_eq!(words("don't.", BOTH), ["don", "'", "t", "."]);
404 assert_eq!(words("a+b", BOTH), ["a", "+", "b"]);
405 assert_eq!(words("1+2=3", BOTH), ["1", "+", "2", "=", "3"]);
406 }
407
408 /// `is_symbol` is consulted only below U+007F. A non-ASCII symbol
409 /// therefore stays welded to its neighbours, which is the opposite
410 /// of what "split on symbols" would do.
411 #[test]
412 fn non_ascii_symbols_do_not_split() {
413 assert_eq!(words("a€b", BOTH), ["a€b"]);
414 assert_eq!(words("a$b", BOTH), ["a", "$", "b"]);
415 }
416
417 #[test]
418 fn cjk_characters_each_get_their_own_word() {
419 assert_eq!(words("日本語", BOTH), ["日", "本", "語"]);
420 assert_eq!(words("a日b", BOTH), ["a", "日", "b"]);
421 }
422
423 /// Hangul is not in the Chinese ranges, so it is never split into
424 /// per-character words. What happens to it instead is worse and is
425 /// the reference's behaviour, not a defect here: every Hangul
426 /// syllable has an NFD decomposition into jamo, so the accent fold
427 /// replaces it with its LEADING jamo and the rest of the syllable is
428 /// gone. `서울` becomes `ᄉᄋ`, one word, and a `[UNK]` in practice.
429 ///
430 /// Asserted rather than left implicit because it is exactly the kind
431 /// of behaviour a future "obvious fix" would break parity by
432 /// improving. The parity corpus's `cjk` case carries `서울` and the
433 /// oracle agrees with this.
434 #[test]
435 fn hangul_is_folded_to_its_leading_jamo_by_the_accent_pass() {
436 assert_eq!(words("서울", BOTH), ["\u{1109}\u{110b}"]);
437 assert_eq!(words("서울", NEITHER), ["서울"], "only the fold does this");
438 }
439
440 #[test]
441 fn controls_are_dropped_without_breaking_the_word() {
442 assert_eq!(words("a\u{1b}b", BOTH), ["ab"], "ESC is Cc");
443 assert_eq!(words("a\u{ad}b", BOTH), ["ab"], "soft hyphen is Cf");
444 assert_eq!(words("a\u{fffd}b", BOTH), ["ab"], "the replacement char");
445 assert_eq!(words("a\0b", BOTH), ["ab"], "NUL");
446 }
447
448 #[test]
449 fn lowercase_and_accent_stripping_follow_their_flags() {
450 assert_eq!(words("Café", BOTH), ["cafe"]);
451 assert_eq!(words("Café", NEITHER), ["Café"]);
452 assert_eq!(
453 words(
454 "Café",
455 NormalizerOptions {
456 lowercase: true,
457 strip_accents: false
458 }
459 ),
460 ["café"]
461 );
462 assert_eq!(
463 words(
464 "Café",
465 NormalizerOptions {
466 lowercase: false,
467 strip_accents: true
468 }
469 ),
470 ["Cafe"]
471 );
472 }
473
474 /// The corpus writes `café` precomposed and `e\u{301}clair`
475 /// decomposed on purpose. Both must land on the same bytes, and they
476 /// do so by two different routes: the NFD fold for the first, the
477 /// `is_accent_mark` drop for the second.
478 #[test]
479 fn precomposed_and_decomposed_accents_normalize_alike() {
480 assert_eq!(words("café", BOTH), words("cafe\u{301}", BOTH));
481 assert_eq!(words("cafe\u{301}", BOTH), ["cafe"]);
482 }
483
484 /// A vocabulary in the form llama.cpp's converter writes: `▁` on
485 /// word-initial pieces, bare continuations, `[...]` specials.
486 fn toy() -> GgufWordPieceTokenizer {
487 let pieces = [
488 "[PAD]", "[UNK]", "[CLS]", "[SEP]", // 0..=3
489 "▁un", "▁hello", "▁.", "▁world", // 4..=7
490 "aff", "able", "ing", // 8..=10
491 "▁unaff", // 11, a word-initial piece that EXTENDS ▁un
492 ];
493 let id_to_token: Vec<String> = pieces.iter().map(|s| s.to_string()).collect();
494 let mut token_to_id = HashMap::new();
495 for (i, t) in id_to_token.iter().enumerate() {
496 token_to_id.entry(t.as_bytes().to_vec()).or_insert(i as u32);
497 }
498 let max_token_len = id_to_token.iter().map(|t| t.len()).max().unwrap();
499 GgufWordPieceTokenizer {
500 token_to_id,
501 id_to_token,
502 max_token_len,
503 unk_id: 1,
504 normalizer: BOTH,
505 special_tokens: vec![("[CLS]".to_string(), 2), ("[SEP]".to_string(), 3)],
506 }
507 }
508
509 #[test]
510 fn a_word_is_covered_word_initial_piece_first_then_continuations() {
511 let t = toy();
512 // ▁un + able, which only works because `able` carries no phantom
513 // space and so cannot match at position 0.
514 assert_eq!(t.encode("unable"), [4, 9]);
515 assert_eq!(t.encode("hello"), [5]);
516 }
517
518 /// Longest match first, not merely *a* match.
519 ///
520 /// `unaffable` has two full covers in this vocabulary: `▁unaff` +
521 /// `able`, and `▁un` + `aff` + `able`. Both reach the end of the
522 /// word, so the all-or-nothing rule does not choose between them and
523 /// a shortest-first loop would be just as "correct" while producing
524 /// different ids for every real checkpoint. Only the probe order
525 /// decides, which is why it is asserted on its own.
526 #[test]
527 fn the_longest_match_wins_where_a_shorter_one_would_also_cover() {
528 let t = toy();
529 assert_eq!(t.encode("unaffable"), [11, 9], "▁unaff + able");
530 assert_ne!(
531 t.encode("unaffable"),
532 [4, 8, 9],
533 "▁un + aff + able is the shortest-first answer"
534 );
535 }
536
537 /// The continuation pieces must not be reachable at the start of a
538 /// word. `aff` alone has no `▁aff` entry, so it is unknown even
539 /// though its bytes are in the vocabulary. This is the `##` rule,
540 /// expressed the way a GGUF expresses it.
541 #[test]
542 fn a_continuation_piece_cannot_start_a_word() {
543 let t = toy();
544 assert_eq!(t.encode("aff"), [1], "no ▁aff, so [UNK]");
545 }
546
547 /// The whole point of the all-or-nothing rule. `worlds` matches
548 /// `▁world` at position 0 and then has nothing for the trailing `s`,
549 /// so the `▁world` already emitted is thrown away and the word
550 /// becomes ONE unknown, not `▁world` plus an unknown.
551 #[test]
552 fn a_partly_covered_word_discards_its_pieces_and_becomes_one_unknown() {
553 let t = toy();
554 assert_eq!(
555 t.encode("worlds"),
556 [1],
557 "a partial cover must be discarded, not kept"
558 );
559 // The same word without the stray byte does cover, which is what
560 // makes the assertion above about the fallback rule rather than
561 // about the vocabulary being too small.
562 assert_eq!(t.encode("world"), [7]);
563 }
564
565 #[test]
566 fn each_word_falls_back_independently() {
567 let t = toy();
568 assert_eq!(t.encode("hello zzz world"), [5, 1, 7]);
569 }
570
571 #[test]
572 fn punctuation_is_its_own_word_and_matches_its_own_piece() {
573 let t = toy();
574 assert_eq!(t.encode("hello."), [5, 6], "▁hello then ▁.");
575 }
576
577 #[test]
578 fn special_tokens_are_carved_out_before_normalization() {
579 let t = toy();
580 // Without the carve-out the brackets would each be their own
581 // punctuation word and `[CLS]` would come back as four unknowns.
582 assert_eq!(t.encode("[CLS]hello[SEP]"), [2, 5, 3]);
583 }
584
585 #[test]
586 fn decode_reverses_the_phantom_space() {
587 let t = toy();
588 assert_eq!(t.decode(&[4, 8, 9]), " unaffable");
589 assert_eq!(t.decode(&[5, 7]), " hello world");
590 assert_eq!(t.decode(&[2]), "[CLS]");
591 }
592
593 /// `max_token_len` caps the probe length, so a vocabulary whose
594 /// longest piece is short must still tokenize correctly rather than
595 /// missing longer matches that do not exist.
596 #[test]
597 fn the_probe_cap_is_the_longest_piece_in_bytes() {
598 let t = toy();
599 assert_eq!(t.max_token_len, "▁hello".len(), "6 bytes, not 6 chars");
600 }
601}