1use std::{
2 cmp::Ordering,
3 collections::{BTreeMap, HashMap},
4};
5
6use blake3::hash;
7use serde::{Deserialize, Serialize};
8
9use crate::{MemvidError, Result, types::FrameId};
10
11fn lex_config() -> impl bincode::config::Config {
13 bincode::config::standard()
14 .with_fixed_int_encoding()
15 .with_little_endian()
16}
17
18const LEX_DECODE_LIMIT: usize = crate::MAX_INDEX_BYTES as usize;
19const LEX_SECTION_SOFT_CHARS: usize = 900;
20const LEX_SECTION_HARD_CHARS: usize = 1400;
21const LEX_SECTION_MAX_COUNT: usize = 2048;
22
23#[derive(Default)]
25pub struct LexIndexBuilder {
26 documents: Vec<LexDocument>,
27}
28
29impl LexIndexBuilder {
30 pub fn new() -> Self {
31 Self::default()
32 }
33
34 pub fn add_document(
35 &mut self,
36 frame_id: FrameId,
37 uri: &str,
38 title: Option<&str>,
39 content: &str,
40 tags: &HashMap<String, String>,
41 ) {
42 let tokens = tokenize(content);
43 let tags: BTreeMap<_, _> = tags.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
45 let mut sections = chunk_sections(content);
46
47 let (content_owned, content_lower) = if content.is_empty() {
48 (String::new(), String::new())
49 } else if sections.is_empty() {
50 let owned = content.to_string();
51 let lower = owned.to_ascii_lowercase();
52 sections.push(LexSection {
53 offset: 0,
54 content: owned.clone(),
55 content_lower: lower.clone(),
56 });
57 (owned, lower)
58 } else {
59 (String::new(), String::new())
60 };
61 self.documents.push(LexDocument {
62 frame_id,
63 tokens,
64 tags,
65 content: content_owned,
66 content_lower,
67 uri: Some(uri.to_string()),
68 title: title.map(ToString::to_string),
69 sections,
70 });
71 }
72
73 pub fn finish(mut self) -> Result<LexIndexArtifact> {
74 for document in &mut self.documents {
75 document.ensure_sections();
76 }
77 let bytes = bincode::serde::encode_to_vec(&self.documents, lex_config())?;
78 let checksum = *hash(&bytes).as_bytes();
79 Ok(LexIndexArtifact {
80 bytes,
81 doc_count: self.documents.len() as u64,
82 checksum,
83 })
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct LexIndexArtifact {
90 pub bytes: Vec<u8>,
91 pub doc_count: u64,
92 pub checksum: [u8; 32],
93}
94
95#[derive(Debug, Clone)]
97pub struct LexIndex {
98 documents: Vec<LexDocument>,
99}
100
101impl LexIndex {
102 pub fn decode(bytes: &[u8]) -> Result<Self> {
103 let new_config = bincode::config::standard()
104 .with_fixed_int_encoding()
105 .with_little_endian()
106 .with_limit::<LEX_DECODE_LIMIT>();
107 if let Ok((documents, read)) =
108 bincode::serde::decode_from_slice::<Vec<LexDocument>, _>(bytes, new_config)
109 {
110 if read == bytes.len() {
111 return Ok(Self::from_documents(documents));
112 }
113 }
114
115 let legacy_fixed = bincode::config::standard()
116 .with_fixed_int_encoding()
117 .with_little_endian()
118 .with_limit::<LEX_DECODE_LIMIT>();
119 if let Ok((legacy_docs, read)) =
120 bincode::serde::decode_from_slice::<Vec<LegacyLexDocument>, _>(bytes, legacy_fixed)
121 {
122 if read == bytes.len() {
123 let documents = legacy_docs.into_iter().map(legacy_to_current).collect();
124 return Ok(Self::from_documents(documents));
125 }
126 }
127
128 let legacy_config = bincode::config::standard()
129 .with_little_endian()
130 .with_limit::<LEX_DECODE_LIMIT>();
131 if let Ok((legacy_docs, read)) =
132 bincode::serde::decode_from_slice::<Vec<LegacyLexDocument>, _>(bytes, legacy_config)
133 {
134 if read == bytes.len() {
135 let documents = legacy_docs.into_iter().map(legacy_to_current).collect();
136 return Ok(Self::from_documents(documents));
137 }
138 }
139
140 Err(MemvidError::InvalidToc {
141 reason: "unsupported lex index encoding".into(),
142 })
143 }
144
145 fn from_documents(mut documents: Vec<LexDocument>) -> Self {
146 for document in &mut documents {
147 document.ensure_sections();
148 }
149 Self { documents }
150 }
151
152 pub fn search(&self, query: &str, limit: usize) -> Vec<LexSearchHit> {
153 let mut query_tokens = tokenize(query);
154 query_tokens.retain(|token| !token.is_empty());
155 if query_tokens.is_empty() {
156 return Vec::new();
157 }
158 let mut matches = self.compute_matches(&query_tokens, None, None);
159 matches.truncate(limit);
160 matches
161 .into_iter()
162 .map(|m| {
163 let snippets = build_snippets(&m.content, &m.occurrences, 160, 3);
164 LexSearchHit {
165 frame_id: m.frame_id,
166 score: m.score,
167 match_count: m.occurrences.len(),
168 snippets,
169 }
170 })
171 .collect()
172 }
173
174 pub(crate) fn documents_mut(&mut self) -> &mut [LexDocument] {
175 &mut self.documents
176 }
177
178 pub(crate) fn remove_document(&mut self, frame_id: FrameId) {
179 self.documents.retain(|doc| doc.frame_id != frame_id);
180 }
181
182 pub(crate) fn compute_matches(
183 &self,
184 query_tokens: &[String],
185 uri_filter: Option<&str>,
186 scope_filter: Option<&str>,
187 ) -> Vec<LexMatch> {
188 if query_tokens.is_empty() {
189 return Vec::new();
190 }
191
192 let mut hits = Vec::new();
193 let phrase = query_tokens.join(" ");
194 for document in &self.documents {
195 if let Some(uri) = uri_filter {
196 if !uri_matches(document.uri.as_deref(), uri) {
197 continue;
198 }
199 } else if let Some(scope) = scope_filter {
200 match document.uri.as_deref() {
201 Some(candidate) if candidate.starts_with(scope) => {}
202 _ => continue,
203 }
204 }
205
206 if document.sections.is_empty() {
207 continue;
208 }
209
210 for section in &document.sections {
211 let haystack = section.content_lower.as_str();
212 if haystack.is_empty() {
213 continue;
214 }
215
216 let mut occurrences: Vec<(usize, usize)> = Vec::new();
217
218 if query_tokens.len() == 1 {
219 let needle = &query_tokens[0];
220 if needle.is_empty() {
221 continue;
222 }
223 let mut start = 0usize;
224 while let Some(idx) = haystack[start..].find(needle) {
225 let local_start = start + idx;
226 let local_end = local_start + needle.len();
227 occurrences.push((local_start, local_end));
228 start = local_end;
229 }
230 } else {
231 let mut all_occurrences = Vec::new();
232 let mut all_present = true;
233 for needle in query_tokens {
234 if needle.is_empty() {
235 all_present = false;
236 break;
237 }
238 let mut start = 0usize;
239 let mut found_for_token = false;
240 while let Some(idx) = haystack[start..].find(needle) {
241 found_for_token = true;
242 let local_start = start + idx;
243 let local_end = local_start + needle.len();
244 all_occurrences.push((local_start, local_end));
245 start = local_end;
246 }
247 if !found_for_token {
248 all_present = false;
249 break;
250 }
251 }
252 if !all_present {
253 continue;
254 }
255 occurrences = all_occurrences;
256 }
257
258 if occurrences.is_empty() {
259 continue;
260 }
261
262 occurrences.sort_by_key(|(start, _)| *start);
263 let mut score = occurrences.len() as f32;
264 if !phrase.is_empty() && section.content_lower.contains(&phrase) {
265 score += 1000.0;
266 }
267 hits.push(LexMatch {
268 frame_id: document.frame_id,
269 score,
270 occurrences,
271 content: section.content.clone(),
272 uri: document.uri.clone(),
273 title: document.title.clone(),
274 chunk_offset: section.offset,
275 });
276 }
277 }
278
279 hits.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal));
280
281 let mut seen_frames: std::collections::HashSet<FrameId> = std::collections::HashSet::new();
285 let mut deduped = Vec::with_capacity(hits.len());
286 for hit in hits {
287 if seen_frames.insert(hit.frame_id) {
288 deduped.push(hit);
289 }
290 }
291 deduped
292 }
293}
294
295fn uri_matches(candidate: Option<&str>, expected: &str) -> bool {
296 let Some(uri) = candidate else {
297 return false;
298 };
299 if expected.contains('#') {
300 uri.eq_ignore_ascii_case(expected)
301 } else {
302 let expected_lower = expected.to_ascii_lowercase();
303 let candidate_lower = uri.to_ascii_lowercase();
304 candidate_lower.starts_with(&expected_lower)
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub(crate) struct LexDocument {
310 pub(crate) frame_id: FrameId,
311 tokens: Vec<String>,
312 tags: BTreeMap<String, String>,
313 #[serde(default)]
314 content: String,
315 #[serde(default)]
316 pub(crate) content_lower: String,
317 #[serde(default)]
318 pub(crate) uri: Option<String>,
319 #[serde(default)]
320 pub(crate) title: Option<String>,
321 #[serde(default)]
322 sections: Vec<LexSection>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub(crate) struct LexSection {
327 pub(crate) offset: usize,
328 #[serde(default)]
329 pub(crate) content: String,
330 #[serde(default)]
331 pub(crate) content_lower: String,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335struct LegacyLexDocument {
336 frame_id: FrameId,
337 tokens: Vec<String>,
338 tags: BTreeMap<String, String>,
339 #[serde(default)]
340 content: Option<String>,
341 #[serde(default)]
342 uri: Option<String>,
343 #[serde(default)]
344 title: Option<String>,
345}
346
347impl LexDocument {
348 fn ensure_sections(&mut self) {
349 if !self.sections.is_empty() {
350 return;
351 }
352
353 if self.content.is_empty() {
354 return;
355 }
356
357 if self.content_lower.is_empty() {
358 self.content_lower = self.content.to_ascii_lowercase();
359 }
360
361 self.sections.push(LexSection {
362 offset: 0,
363 content: self.content.clone(),
364 content_lower: self.content_lower.clone(),
365 });
366 }
367}
368
369fn legacy_to_current(legacy: LegacyLexDocument) -> LexDocument {
370 let content = legacy.content.unwrap_or_default();
371 let content_lower = content.to_ascii_lowercase();
372 let sections = if content.is_empty() {
373 Vec::new()
374 } else {
375 vec![LexSection {
376 offset: 0,
377 content: content.clone(),
378 content_lower: content_lower.clone(),
379 }]
380 };
381 LexDocument {
382 frame_id: legacy.frame_id,
383 tokens: legacy.tokens,
384 tags: legacy.tags,
385 content,
386 content_lower,
387 uri: legacy.uri,
388 title: legacy.title,
389 sections,
390 }
391}
392
393#[derive(Debug, Clone)]
394pub struct LexSearchHit {
395 pub frame_id: FrameId,
396 pub score: f32,
397 pub match_count: usize,
398 pub snippets: Vec<String>,
399}
400
401#[derive(Debug, Clone)]
402pub(crate) struct LexMatch {
403 pub frame_id: FrameId,
404 pub score: f32,
405 pub occurrences: Vec<(usize, usize)>,
406 pub content: String,
407 pub uri: Option<String>,
408 pub title: Option<String>,
409 pub chunk_offset: usize,
410}
411
412fn tokenize(input: &str) -> Vec<String> {
413 input
414 .split(|c: char| !is_token_char(c))
415 .filter_map(|token| {
416 if token.chars().any(|ch| ch.is_alphanumeric()) {
417 Some(token.to_lowercase())
418 } else {
419 None
420 }
421 })
422 .collect()
423}
424
425fn is_token_char(ch: char) -> bool {
426 ch.is_alphanumeric() || matches!(ch, '&' | '@' | '+' | '/' | '_')
427}
428
429fn build_snippets(
430 content: &str,
431 occurrences: &[(usize, usize)],
432 window: usize,
433 max_snippets: usize,
434) -> Vec<String> {
435 compute_snippet_slices(content, occurrences, window, max_snippets)
436 .into_iter()
437 .map(|(start, end)| content[start..end].replace('\n', " "))
438 .collect()
439}
440
441fn chunk_sections(content: &str) -> Vec<LexSection> {
442 if content.is_empty() {
443 return Vec::new();
444 }
445
446 if content.len() <= LEX_SECTION_HARD_CHARS {
447 return vec![LexSection {
448 offset: 0,
449 content: content.to_string(),
450 content_lower: content.to_ascii_lowercase(),
451 }];
452 }
453
454 let mut sections: Vec<LexSection> = Vec::new();
455 let mut chunk_start = 0usize;
456 let mut last_soft_break = None;
457 let mut iter = content.char_indices().peekable();
458
459 while let Some((idx, ch)) = iter.next() {
460 let char_end = idx + ch.len_utf8();
461 let current_len = char_end.saturating_sub(chunk_start);
462 let next_char = iter.peek().map(|(_, next)| *next);
463
464 if is_soft_boundary(ch, next_char) {
465 last_soft_break = Some(char_end);
466 if current_len < LEX_SECTION_SOFT_CHARS {
467 continue;
468 }
469 }
470
471 if current_len < LEX_SECTION_HARD_CHARS {
472 continue;
473 }
474
475 let mut split_at = last_soft_break.unwrap_or(char_end);
476 if split_at <= chunk_start {
477 split_at = char_end;
478 }
479
480 push_section(&mut sections, content, chunk_start, split_at);
481 chunk_start = split_at;
482 last_soft_break = None;
483
484 if sections.len() >= LEX_SECTION_MAX_COUNT {
485 break;
486 }
487 }
488
489 if chunk_start < content.len() {
490 if sections.len() >= LEX_SECTION_MAX_COUNT {
491 if let Some(last) = sections.last_mut() {
492 let slice = &content[last.offset..];
493 last.content = slice.to_string();
494 last.content_lower = slice.to_ascii_lowercase();
495 }
496 } else {
497 push_section(&mut sections, content, chunk_start, content.len());
498 }
499 }
500
501 if sections.is_empty() {
502 sections.push(LexSection {
503 offset: 0,
504 content: content.to_string(),
505 content_lower: content.to_ascii_lowercase(),
506 });
507 }
508
509 sections
510}
511
512fn push_section(sections: &mut Vec<LexSection>, content: &str, start: usize, end: usize) {
513 if end <= start {
514 return;
515 }
516
517 let slice = &content[start..end];
518 sections.push(LexSection {
519 offset: start,
520 content: slice.to_string(),
521 content_lower: slice.to_ascii_lowercase(),
522 });
523}
524
525fn is_soft_boundary(ch: char, next: Option<char>) -> bool {
526 match ch {
527 '.' | '!' | '?' => next.map_or(true, |n| n.is_whitespace()),
528 '\n' => true,
529 _ => false,
530 }
531}
532
533pub(crate) fn compute_snippet_slices(
534 content: &str,
535 occurrences: &[(usize, usize)],
536 window: usize,
537 max_snippets: usize,
538) -> Vec<(usize, usize)> {
539 if content.is_empty() {
540 return Vec::new();
541 }
542
543 if occurrences.is_empty() {
544 let end = advance_boundary(content, 0, window);
545 return vec![(0, end)];
546 }
547
548 let mut merged: Vec<(usize, usize)> = Vec::new();
549 for &(start, end) in occurrences {
550 let mut snippet_start = start.saturating_sub(window / 2);
551 let mut snippet_end = (end + window / 2).min(content.len());
552
553 if let Some(adj) = sentence_start_before(content, snippet_start) {
554 snippet_start = adj;
555 }
556 if let Some(adj) = sentence_end_after(content, snippet_end) {
557 snippet_end = adj;
558 }
559
560 snippet_start = prev_char_boundary(content, snippet_start);
561 snippet_end = next_char_boundary(content, snippet_end);
562
563 if snippet_end <= snippet_start {
564 continue;
565 }
566
567 if let Some(last) = merged.last_mut() {
568 if snippet_start <= last.1 + 20 {
569 last.1 = last.1.max(snippet_end);
570 continue;
571 }
572 }
573
574 merged.push((
575 snippet_start.min(content.len()),
576 snippet_end.min(content.len()),
577 ));
578 if merged.len() >= max_snippets {
579 break;
580 }
581 }
582
583 if merged.is_empty() {
584 let end = advance_boundary(content, 0, window);
585 merged.push((0, end));
586 }
587
588 merged
589}
590
591fn sentence_start_before(content: &str, idx: usize) -> Option<usize> {
592 if idx == 0 {
593 return Some(0);
594 }
595 let mut idx = idx.min(content.len());
596 idx = prev_char_boundary(content, idx);
597 let mut candidate = None;
598 for (pos, ch) in content[..idx].char_indices() {
599 if matches!(ch, '.' | '!' | '?' | '\n') {
600 candidate = Some(pos + ch.len_utf8());
601 }
602 }
603 candidate.map(|pos| {
604 let mut pos = next_char_boundary(content, pos);
605 while pos < content.len() && content.as_bytes()[pos].is_ascii_whitespace() {
606 pos += 1;
607 }
608 prev_char_boundary(content, pos)
609 })
610}
611
612fn sentence_end_after(content: &str, idx: usize) -> Option<usize> {
613 if idx >= content.len() {
614 return Some(content.len());
615 }
616 let mut idx = idx;
617 idx = prev_char_boundary(content, idx);
618 for (offset, ch) in content[idx..].char_indices() {
619 let global = idx + offset;
620 if matches!(ch, '.' | '!' | '?') {
621 return Some(next_char_boundary(content, global + ch.len_utf8()));
622 }
623 if ch == '\n' {
624 return Some(global);
625 }
626 }
627 None
628}
629
630fn prev_char_boundary(content: &str, mut idx: usize) -> usize {
631 if idx > content.len() {
632 idx = content.len();
633 }
634 while idx > 0 && !content.is_char_boundary(idx) {
635 idx -= 1;
636 }
637 idx
638}
639
640fn next_char_boundary(content: &str, mut idx: usize) -> usize {
641 if idx > content.len() {
642 idx = content.len();
643 }
644 while idx < content.len() && !content.is_char_boundary(idx) {
645 idx += 1;
646 }
647 idx
648}
649
650fn advance_boundary(content: &str, start: usize, mut window: usize) -> usize {
651 if start >= content.len() {
652 return content.len();
653 }
654 let mut last = content.len();
655 for (offset, _) in content[start..].char_indices() {
656 if window == 0 {
657 return start + offset;
658 }
659 last = start + offset;
660 window -= 1;
661 }
662 content.len().max(last)
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 #[test]
670 fn builder_produces_artifact() {
671 let mut builder = LexIndexBuilder::new();
672 let mut tags = HashMap::new();
673 tags.insert("source".into(), "test".into());
674 builder.add_document(0, "mv2://docs/one", Some("Doc One"), "hello world", &tags);
675 builder.add_document(
676 1,
677 "mv2://docs/two",
678 Some("Doc Two"),
679 "rust systems",
680 &HashMap::new(),
681 );
682
683 let artifact = builder.finish().expect("finish");
684 assert_eq!(artifact.doc_count, 2);
685 assert!(artifact.bytes.len() > 0);
686
687 let index = LexIndex::decode(&artifact.bytes).expect("decode");
688 let hits = index.search("rust", 10);
689 assert_eq!(hits.len(), 1);
690 assert_eq!(hits[0].frame_id, 1);
691 assert!(hits[0].match_count >= 1);
692 assert!(!hits[0].snippets.is_empty());
693 }
694
695 #[test]
696 fn tokenizer_lowercases_and_filters() {
697 let tokens = tokenize("Hello, Rust-lang!");
698 assert_eq!(tokens, vec!["hello", "rust", "lang"]);
699 }
700
701 #[test]
702 fn tokenizer_retains_connector_characters() {
703 let tokens = tokenize("N&M EXPRESS LLC @ 2024");
704 assert_eq!(tokens, vec!["n&m", "express", "llc", "2024"]);
705 }
706
707 #[test]
708 fn compute_matches_deduplicates_by_frame_id() {
709 let mut builder = LexIndexBuilder::new();
713
714 let section1 = "Quantum computing represents a revolutionary approach to computation. \
716 The fundamental principles of quantum mechanics enable quantum computers to process \
717 information in ways classical computers cannot. Quantum bits or qubits can exist in \
718 superposition states, allowing quantum algorithms to explore multiple solutions \
719 simultaneously. This quantum parallelism offers exponential speedups for certain \
720 computational problems. Researchers continue to advance quantum hardware and software. \
721 The field of quantum computing is rapidly evolving with new breakthroughs. \
722 Major tech companies invest heavily in quantum research and development. \
723 Quantum error correction remains a significant challenge for practical quantum computers.";
724
725 let section2 = "Applications of quantum computing span many domains including cryptography, \
726 drug discovery, and optimization problems. Quantum cryptography promises unbreakable \
727 encryption through quantum key distribution protocols. In the pharmaceutical industry, \
728 quantum simulations could revolutionize how we discover new medicines. Quantum \
729 algorithms like Shor's algorithm threaten current encryption standards. Financial \
730 institutions explore quantum computing for portfolio optimization. The quantum \
731 advantage may soon be demonstrated for practical real-world applications. Quantum \
732 machine learning combines quantum computing with artificial intelligence techniques. \
733 The future of quantum computing holds immense promise for scientific discovery.";
734
735 let full_content = format!("{} {}", section1, section2);
736 assert!(
737 full_content.len() > 1400,
738 "Content should be long enough to create multiple sections"
739 );
740
741 builder.add_document(
742 42, "mv2://docs/quantum",
744 Some("Quantum Computing Overview"),
745 &full_content,
746 &HashMap::new(),
747 );
748
749 let artifact = builder.finish().expect("finish should succeed");
750 let index = LexIndex::decode(&artifact.bytes).expect("decode should succeed");
751
752 let query_tokens = tokenize("quantum");
754 let matches = index.compute_matches(&query_tokens, None, None);
755
756 let frame_ids: Vec<_> = matches.iter().map(|m| m.frame_id).collect();
758 let unique_frame_ids: std::collections::HashSet<_> = frame_ids.iter().copied().collect();
759
760 assert_eq!(
761 frame_ids.len(),
762 unique_frame_ids.len(),
763 "Results should not contain duplicate frame_ids. Found: {:?}",
764 frame_ids
765 );
766
767 assert_eq!(matches.len(), 1, "Should have exactly one match");
769 assert_eq!(matches[0].frame_id, 42, "Match should be for frame_id 42");
770 assert!(
771 matches[0].score > 0.0,
772 "Match should have a positive score"
773 );
774 }
775
776 #[test]
777 fn compute_matches_keeps_highest_score_per_frame() {
778 let mut builder = LexIndexBuilder::new();
780
781 let section1 = "This is the first section with one target mention. \
783 It contains various other words to pad the content and make it long enough \
784 to be split into multiple sections by the chunking algorithm. We need quite \
785 a bit of text here to ensure the sections are created properly. The content \
786 continues with more filler text about various topics. Keep writing to reach \
787 the section boundary. More text follows to ensure we cross the soft limit. \
788 This should be enough to trigger section creation at the boundary point.";
789
790 let section2 = "The second section has target target target multiple times. \
791 Target appears here repeatedly: target target target target. This section \
792 should score higher because it has more occurrences of the search term target. \
793 We mention target again to boost the score further. Target target target. \
794 The abundance of target keywords makes this section rank higher in relevance.";
795
796 let full_content = format!("{} {}", section1, section2);
797
798 builder.add_document(
799 99,
800 "mv2://docs/multi-section",
801 Some("Multi-Section Document"),
802 &full_content,
803 &HashMap::new(),
804 );
805
806 let artifact = builder.finish().expect("finish");
807 let index = LexIndex::decode(&artifact.bytes).expect("decode");
808
809 let query_tokens = tokenize("target");
810 let matches = index.compute_matches(&query_tokens, None, None);
811
812 assert_eq!(matches.len(), 1, "Should have exactly one deduplicated match");
814
815 assert!(
818 matches[0].score >= 5.0,
819 "Should keep the highest-scoring match, score was: {}",
820 matches[0].score
821 );
822 }
823}