1use std::collections::{HashMap, HashSet};
34use std::io::{BufRead, Read, Seek};
35use std::path::{Path, PathBuf};
36
37use serde::{Deserialize, Serialize};
38
39use crate::conscience::{self, Challenge};
40
41pub const VERSION: u32 = 1;
43
44#[must_use]
46pub fn path(home: &Path) -> PathBuf {
47 home.join("pools.jsonl")
48}
49
50pub const RRF_K: f64 = 60.0;
53
54pub const Q_STEP: f64 = 0.2;
58pub const Q_CHANGED: f64 = 1.0;
59pub const Q_IGNORED: f64 = -0.5;
60
61pub const SHORTLIST: u8 = 1;
66pub const VECTOR: u8 = 2;
67pub const IN_SCOPE: u8 = 4;
68pub const DELIVERED: u8 = 8;
69pub const PENDING: u8 = 16;
70pub const IN_PLAY: u8 = 32;
71
72pub const NOT_SHOWN: u8 = 0;
74pub const PREFIX: u8 = 1;
75pub const BOUNDARY: u8 = 2;
76pub const TAIL: u8 = 3;
77
78#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
82pub struct Pooled {
83 pub v: u32,
84 pub check: String,
85 pub session: String,
86 pub ms: i64,
87 pub idx: u64,
89 pub line: String,
92 pub model: String,
93 pub prompt_hash: String,
94 pub failed: bool,
95 pub budget: usize,
98 pub prefix: usize,
99 pub s: f64,
100 pub tau: usize,
101 pub k: usize,
102 pub n_rem: usize,
103 pub pt: f64,
104 pub moves_from: Option<String>,
106 pub ids: Vec<String>,
107 pub ver: Vec<String>,
109 pub role: Vec<u8>,
110 pub pb: Vec<Option<f64>>,
111 pub pi: Vec<f64>,
112 pub bm25q: Vec<f64>,
113 pub bm25f: Vec<Option<f64>>,
114 pub cos: Vec<Option<f64>>,
115 pub w: Vec<f64>,
116 pub trust: Vec<f64>,
117 pub act: Vec<f64>,
118 pub q: Vec<f64>,
119 pub cue: Vec<Option<f64>>,
120 pub mv: Vec<Option<f64>>,
121 pub rrf: Vec<Option<f64>>,
122 pub flags: Vec<u8>,
123 pub grade: Vec<u8>,
125}
126
127#[derive(Debug, Clone)]
130pub struct Rows<'a> {
131 pub rows: Vec<&'a Challenge>,
132 pub prefix: usize,
133 pub role: Vec<u8>,
134 pub pb: Vec<Option<f64>>,
135 pub pi: Vec<f64>,
136 pub bm25q: Vec<f64>,
137 pub budget: usize,
138 pub s: f64,
139 pub tau: usize,
140 pub k: usize,
141 pub n_rem: usize,
142 pub pt: f64,
143}
144
145impl<'a> Rows<'a> {
146 #[must_use]
149 pub fn of(e: &crate::whole::Explored<'a>, challenges: &[Challenge], raw: &[f64], budget: usize) -> Rows<'a> {
150 let prefix = e.filled.order.len();
151 let rows: Vec<&'a Challenge> = e.filled.order.iter().chain(&e.filled.dropped).copied().collect();
152 let mut role = vec![NOT_SHOWN; rows.len()];
153 role[..prefix].fill(PREFIX);
154 for (at, _) in &e.boundary {
155 role[*at] = BOUNDARY;
156 }
157 for (at, _) in &e.tail {
158 role[*at] = TAIL;
159 }
160 let (pb_r, pi_r) = e.chances();
161 let pb = std::iter::repeat_n(None, prefix).chain(pb_r).collect();
162 let pi = std::iter::repeat_n(1.0, prefix).chain(pi_r).collect();
163 let by: HashMap<&str, f64> = challenges.iter().zip(raw).map(|(c, r)| (c.id.as_str(), *r)).collect();
164 let bm25q = rows.iter().map(|c| by.get(c.id.as_str()).copied().unwrap_or(0.0)).collect();
165 Rows { rows, prefix, role, pb, pi, bm25q, budget, s: e.s, tau: e.tau, k: e.k, n_rem: e.n, pt: e.pt }
166 }
167}
168
169pub struct Check<'c> {
171 pub session: &'c str,
172 pub check: &'c str,
173 pub idx: u64,
174 pub stop_ms: i64,
176 pub line: &'c str,
177 pub model: &'c str,
178 pub prompt_hash: &'c str,
179 pub failed: bool,
180 pub applied: &'c [(&'c Challenge, u8)],
182 pub pending: &'c [String],
183 pub text: &'c str,
186 pub scope_text: &'c str,
187 pub challenges: &'c [Challenge],
188 pub delivered: &'c HashSet<&'c str>,
190 pub in_play: &'c HashSet<&'c str>,
191 pub lessons: &'c Path,
193 pub outcomes: &'c Path,
194}
195
196pub fn log(home: &Path, rows: &Rows, c: &Check) {
199 if !crate::learn::learning() {
200 return;
201 }
202 if let Ok(line) = serde_json::to_string(&pooled(rows, c)) {
203 let _ = crate::learn::append_line(&path(home), &line);
204 }
205}
206
207fn r4(x: f64) -> f64 {
209 (x * 1e4).round() / 1e4 + 0.0
210}
211
212#[must_use]
214pub fn pooled(rows: &Rows, c: &Check) -> Pooled {
215 let corpus = conscience::load_store_unweighted(c.lessons, Some(c.stop_ms));
218 let cues = crate::cues::load_before(c.lessons, Some(c.stop_ms));
219 let cues_of = |l: &Challenge| cues.get(&crate::cues::key(&l.question, &l.situations, &l.consequence));
220 let (q, moves) = read_outcomes(c.outcomes, c.session, c.stop_ms, &rows.rows.iter().map(|l| l.id.as_str()).collect());
221 let at: HashMap<&str, usize> = corpus.iter().enumerate().map(|(i, l)| (l.id.as_str(), i)).collect();
222 let in_corpus = |scores: &[f64]| -> Vec<Option<f64>> { rows.rows.iter().map(|l| at.get(l.id.as_str()).map(|&i| scores[i])).collect() };
223 let fb_docs: Vec<Vec<String>> =
225 corpus.iter().map(|l| conscience::tokens(&format!("{}\n{}\n{}", l.question, l.situations.join("\n"), l.consequence))).collect();
226 let bm25f = in_corpus(&conscience::bm25(&fb_docs, c.text));
227 let tail = crate::vectors::query_tail(c.text);
229 let (mut cue_docs, mut owner) = (Vec::new(), Vec::new());
230 for (i, l) in corpus.iter().enumerate() {
231 for cue in cues_of(l).into_iter().flatten() {
232 cue_docs.push(conscience::tokens(cue));
233 owner.push(i);
234 }
235 }
236 let mut best: Vec<Option<f64>> = vec![None; corpus.len()];
237 for (s, i) in conscience::bm25(&cue_docs, &tail).into_iter().zip(owner) {
238 best[i] = Some(best[i].map_or(s, |b: f64| b.max(s)));
239 }
240 let cue: Vec<Option<f64>> = rows.rows.iter().map(|l| at.get(l.id.as_str()).and_then(|&i| best[i])).collect();
241 let mv: Vec<Option<f64>> = match &moves {
243 Some((_, said)) => {
244 let docs: Vec<Vec<String>> = corpus
245 .iter()
246 .map(|l| conscience::tokens(&l.situations.iter().chain(cues_of(l).into_iter().flatten()).cloned().collect::<Vec<_>>().join("\n")))
247 .collect();
248 in_corpus(&conscience::bm25(&docs, &said.join("\n")))
249 }
250 None => vec![None; rows.rows.len()],
251 };
252 let qs: Vec<f64> = rows.rows.iter().map(|l| q.get(l.id.as_str()).copied().unwrap_or(0.0)).collect();
253 let n = corpus.len() + c.challenges.len();
255 let hits = crate::vectors::scored(c.lessons, &tail, n);
256 let cos: Vec<Option<f64>> = match &hits {
257 Some(h) => {
258 let by: HashMap<&str, f32> = h.iter().map(|(id, s)| (id.as_str(), *s)).collect();
259 rows.rows.iter().map(|l| by.get(l.id.as_str()).map(|s| f64::from(*s))).collect()
260 }
261 None => vec![None; rows.rows.len()],
262 };
263 let ranked: Vec<String> =
265 hits.iter().flatten().take(crate::vectors::k() + crate::vectors::SHORTLIST + crate::vectors::OVER_FETCH).map(|(id, _)| id.clone()).collect();
266 let (list, added) = crate::vectors::shortlist_from(c.challenges, c.text, c.scope_text, crate::vectors::SHORTLIST, &ranked);
267 let listed: HashSet<&str> = list.iter().map(|l| l.id.as_str()).collect();
268 let vector: HashSet<&str> = list[list.len() - added..].iter().map(|l| l.id.as_str()).collect();
269 let have = conscience::Names::of(c.scope_text);
270 let pending: HashSet<&str> = c.pending.iter().map(String::as_str).collect();
271 let flags = rows
272 .rows
273 .iter()
274 .map(|l| {
275 let id = l.id.as_str();
276 [
277 (listed.contains(id), SHORTLIST),
278 (vector.contains(id), VECTOR),
279 (conscience::in_scope(l, &have), IN_SCOPE),
280 (c.delivered.contains(id), DELIVERED),
281 (pending.contains(id), PENDING),
282 (c.in_play.contains(id), IN_PLAY),
283 ]
284 .iter()
285 .filter(|(on, _)| *on)
286 .fold(0, |f, (_, bit)| f | bit)
287 })
288 .collect();
289 let shown: Vec<usize> = (0..rows.rows.len()).filter(|&i| rows.role[i] != NOT_SHOWN).collect();
291 let pick = |v: &[Option<f64>]| shown.iter().map(|&i| v[i]).collect::<Vec<_>>();
292 let mv_or_fb: Vec<Option<f64>> = if moves.is_some() { mv.clone() } else { bm25f.clone() };
293 let lists = [pick(&bm25f), pick(&cue), pick(&mv_or_fb), shown.iter().map(|&i| Some(qs[i])).collect()];
294 let mut rrf = vec![None; rows.rows.len()];
295 let fused = lists.iter().map(|l| ranks(l)).fold(vec![0.0; shown.len()], |acc, r| acc.iter().zip(&r).map(|(a, r)| a + 1.0 / (RRF_K + r)).collect());
296 for (&i, f) in shown.iter().zip(fused) {
297 rrf[i] = Some(r4(f));
298 }
299 let grade = rows.rows.iter().map(|l| c.applied.iter().find(|(a, _)| a.id == l.id).map_or(0, |(_, g)| *g)).collect();
300 let opt = |v: Vec<Option<f64>>| v.into_iter().map(|x| x.map(r4)).collect::<Vec<_>>();
301 Pooled {
302 v: VERSION,
303 check: c.check.into(),
304 session: c.session.into(),
305 ms: crate::learn::now_ms(),
306 idx: c.idx,
307 line: c.line.into(),
308 model: c.model.into(),
309 prompt_hash: c.prompt_hash.into(),
310 failed: c.failed,
311 budget: rows.budget,
312 prefix: rows.prefix,
313 s: rows.s,
314 tau: rows.tau,
315 k: rows.k,
316 n_rem: rows.n_rem,
317 pt: rows.pt,
318 moves_from: moves.map(|(check, _)| check),
319 ids: rows.rows.iter().map(|l| l.id.clone()).collect(),
320 ver: rows.rows.iter().map(|l| format!("{}@v{}#{}", l.id, l.version, crate::cues::key(&l.question, &l.situations, &l.consequence))).collect(),
321 role: rows.role.clone(),
322 pb: opt(rows.pb.clone()),
323 pi: rows.pi.iter().map(|x| r4(*x)).collect(),
324 bm25q: rows.bm25q.iter().map(|x| r4(*x)).collect(),
325 bm25f: opt(bm25f),
326 cos: opt(cos),
327 w: rows.rows.iter().map(|l| r4(l.weight)).collect(),
328 trust: rows.rows.iter().map(|l| r4(l.trust)).collect(),
329 act: rows.rows.iter().map(|l| r4(l.activation)).collect(),
330 q: qs.into_iter().map(r4).collect(),
331 cue: opt(cue),
332 mv: opt(mv),
333 rrf,
334 flags,
335 grade,
336 }
337}
338
339#[must_use]
342pub fn ranks(v: &[Option<f64>]) -> Vec<f64> {
343 let key = |x: &Option<f64>| x.unwrap_or(f64::NEG_INFINITY);
344 let mut sorted: Vec<f64> = v.iter().map(key).collect();
345 sorted.sort_by(|a, b| b.total_cmp(a));
346 v.iter()
347 .map(|x| {
348 let x = key(x);
349 let lo = sorted.partition_point(|s| *s > x);
350 let hi = sorted.partition_point(|s| *s >= x);
351 (lo + 1 + hi) as f64 / 2.0
352 })
353 .collect()
354}
355
356type Moves = (String, Vec<String>);
358
359fn read_outcomes(log: &Path, session: &str, stop_ms: i64, rows: &HashSet<&str>) -> (HashMap<String, f64>, Option<Moves>) {
363 #[derive(Deserialize)]
364 struct Line {
365 kind: String,
366 #[serde(default)]
367 session: String,
368 #[serde(default)]
369 ms: i64,
370 #[serde(default)]
371 lessons: Vec<String>,
372 #[serde(default)]
373 channel: String,
374 #[serde(default)]
375 check: String,
376 #[serde(default)]
377 moves: Vec<String>,
378 }
379 let mut verdicts: Vec<(i64, f64, Vec<String>)> = Vec::new();
380 let mut moves: Option<(i64, String, Vec<String>)> = None;
381 if let Ok(f) = std::fs::File::open(log) {
382 for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
383 let verdict = line.contains(crate::outcome::CHANGED) || line.contains(crate::outcome::IGNORED);
384 if !verdict && !line.contains("\"moves\"") {
385 continue;
386 }
387 let Ok(o) = serde_json::from_str::<Line>(&line) else { continue };
388 if o.ms >= stop_ms {
389 continue;
390 }
391 let r = match o.kind.as_str() {
392 crate::outcome::CHANGED => Q_CHANGED,
393 crate::outcome::IGNORED => Q_IGNORED,
394 crate::outcome::GRADED if o.session == session && !o.moves.is_empty() => {
395 if moves.as_ref().is_none_or(|(ms, _, _)| o.ms >= *ms) {
396 moves = Some((o.ms, o.check, o.moves));
397 }
398 continue;
399 }
400 _ => continue,
401 };
402 if o.channel == crate::outcome::PLACEBO {
403 continue;
404 }
405 let mine: Vec<String> = o.lessons.into_iter().filter(|l| rows.contains(l.as_str())).collect();
406 if !mine.is_empty() {
407 verdicts.push((o.ms, r, mine));
408 }
409 }
410 }
411 verdicts.sort_by_key(|(ms, _, _)| *ms);
412 let mut q: HashMap<String, f64> = HashMap::new();
413 for (_, r, ls) in verdicts {
414 for l in ls {
415 let v = q.entry(l).or_insert(0.0);
416 *v += Q_STEP * (r - *v);
417 }
418 }
419 (q, moves.map(|(_, check, said)| (check, said)))
420}
421
422#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
426pub struct Misses {
427 pub at: u64,
429 pub checks: u64,
431 pub positives: u64,
432 pub low: u64,
433 pub causes: Vec<(String, u64)>,
435 pub drawn: u64,
437}
438
439pub const CAUSES: [&str; 6] = ["scope", "vector_only", "no_word_match", "demoted", "weak_words", "other"];
444
445fn cache(home: &Path) -> PathBuf {
447 home.join("misses.json")
448}
449
450#[must_use]
453pub fn misses(home: &Path) -> Misses {
454 let log = path(home);
455 let len = std::fs::metadata(&log).map(|m| m.len()).unwrap_or(0);
456 let mut m: Misses = std::fs::read_to_string(cache(home)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
457 if m.at > len || m.causes.len() != CAUSES.len() {
458 m = Misses { causes: CAUSES.iter().map(|c| (c.to_string(), 0)).collect(), ..Misses::default() };
459 }
460 if m.at == len {
461 return m;
462 }
463 let Ok(mut f) = std::fs::File::open(&log) else { return m };
464 if f.seek(std::io::SeekFrom::Start(m.at)).is_err() {
465 return m;
466 }
467 let mut r = std::io::BufReader::new(f.by_ref());
468 let mut buf = String::new();
469 while r.read_line(&mut buf).is_ok_and(|n| n > 0) {
470 if !buf.ends_with('\n') {
472 break;
473 }
474 m.at += buf.len() as u64;
475 if let Ok(p) = serde_json::from_str::<Pooled>(&buf) {
476 fold(&mut m, &p);
477 }
478 buf.clear();
479 }
480 if let Ok(text) = serde_json::to_string(&m) {
481 let tmp = cache(home).with_extension(format!("json.{}", std::process::id()));
482 if std::fs::write(&tmp, text).and_then(|()| std::fs::rename(&tmp, cache(home))).is_err() {
483 let _ = std::fs::remove_file(&tmp);
484 }
485 }
486 m
487}
488
489fn fold(m: &mut Misses, p: &Pooled) {
491 m.checks += 1;
492 for i in 0..p.ids.len() {
493 let (g, f) = (p.grade.get(i).copied().unwrap_or(0), p.flags.get(i).copied().unwrap_or(0));
494 if g < 2 || f & (PENDING | IN_PLAY) != 0 {
495 continue;
496 }
497 m.positives += 1;
498 let role = p.role.get(i).copied().unwrap_or(NOT_SHOWN);
499 if !(role == BOUNDARY || role == TAIL || (role == PREFIX && 2 * i >= p.prefix)) {
500 continue;
501 }
502 m.low += 1;
503 m.drawn += u64::from(role != PREFIX);
504 let why = cause(p, i);
505 if let Some(c) = m.causes.iter_mut().find(|(c, _)| c == why) {
506 c.1 += 1;
507 }
508 }
509}
510
511#[must_use]
514pub fn cause(p: &Pooled, i: usize) -> &'static str {
515 let f = p.flags.get(i).copied().unwrap_or(0);
516 let bm25 = p.bm25q.get(i).copied().unwrap_or(0.0);
517 if f & IN_SCOPE == 0 {
518 return "scope";
519 }
520 if bm25 <= 0.0 {
521 return if f & VECTOR != 0 { "vector_only" } else { "no_word_match" };
522 }
523 let n = p.ids.len().max(i + 1);
525 let share = |v: Vec<Option<f64>>| ranks(&v)[i] / n as f64;
526 let q = |j: usize| p.bm25q.get(j).copied().unwrap_or(0.0);
527 let words = share((0..n).map(|j| Some(q(j))).collect());
528 let score = share((0..n).map(|j| Some(q(j) * p.w.get(j).unwrap_or(&1.0) * p.trust.get(j).unwrap_or(&0.5) / crate::trust::NEUTRAL)).collect());
529 if words <= 0.5 && score > 0.5 {
530 "demoted"
531 } else if words > 0.5 {
532 "weak_words"
533 } else {
534 "other"
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
545 fn a_short_column_never_panics_the_cause() {
546 let p = Pooled { ids: vec!["A".into(), "B".into(), "C".into()], bm25q: vec![1.0], flags: vec![IN_SCOPE; 3], ..Default::default() };
547 assert_eq!(cause(&p, 2), "no_word_match");
548 let p = Pooled { bm25q: vec![1.0, 2.0], ..p };
549 assert!(CAUSES.contains(&cause(&p, 1)));
550 }
551
552 #[test]
553 fn ranks_are_one_based_descending_with_mean_ties_and_missing_last() {
554 assert_eq!(ranks(&[Some(3.0), Some(1.0), Some(3.0), None, Some(2.0), None]), [1.5, 4.0, 1.5, 5.5, 3.0, 5.5]);
555 assert!(ranks(&[]).is_empty());
556 }
557
558 #[test]
561 fn q_and_moves_are_read_as_of_the_stop() {
562 let d = std::env::temp_dir().join(format!("borg-pools-outcomes-{}", std::process::id()));
563 let _ = std::fs::remove_dir_all(&d);
564 std::fs::create_dir_all(&d).unwrap();
565 let log = d.join("outcomes.jsonl");
566 let lines = [
567 r#"{"kind":"asked_ignored","session":"x","ms":30,"lessons":["A"]}"#,
568 r#"{"kind":"asked_then_changed","session":"x","ms":10,"lessons":["A","Z"]}"#,
569 r#"{"kind":"asked_then_changed","session":"x","ms":20,"lessons":["A"],"channel":"placebo"}"#,
570 r#"{"kind":"asked_then_changed","session":"x","ms":99,"lessons":["A"]}"#,
571 r#"{"kind":"graded","session":"s","ms":40,"check":"c2","moves":["later"]}"#,
572 r#"{"kind":"graded","session":"s","ms":35,"check":"c1","moves":["earlier"]}"#,
573 r#"{"kind":"graded","session":"t","ms":45,"check":"c3","moves":["another session"]}"#,
574 r#"{"kind":"graded","session":"s","ms":60,"check":"c4","moves":["after the stop"]}"#,
575 ];
576 for l in lines {
577 crate::learn::append_line(&log, l).unwrap();
578 }
579 let rows: HashSet<&str> = ["A", "B"].into();
580 let (q, moves) = read_outcomes(&log, "s", 50, &rows);
581 assert!((q["A"] - 0.06).abs() < 1e-12, "{q:?}");
584 assert!(!q.contains_key("B") && !q.contains_key("Z"));
585 assert_eq!(moves, Some(("c2".to_string(), vec!["later".to_string()])));
586 assert_eq!(read_outcomes(&log, "s", 36, &rows).1.map(|m| m.0), Some("c1".into()));
587 assert_eq!(read_outcomes(&log, "s", 35, &rows).1, None, "strictly before the stop");
588 let _ = std::fs::remove_dir_all(&d);
589 }
590
591 fn row(p: &mut Pooled, id: &str, role: u8, bm25: f64, w: f64, flags: u8, grade: u8) {
592 p.ids.push(id.into());
593 p.role.push(role);
594 p.bm25q.push(bm25);
595 p.w.push(w);
596 p.trust.push(0.5);
597 p.flags.push(flags);
598 p.grade.push(grade);
599 }
600
601 #[test]
604 fn misses_fold_the_low_first_applies_by_cause() {
605 let mut p = Pooled { prefix: 6, ..Pooled::default() };
606 row(&mut p, "top", PREFIX, 9.0, 1.0, IN_SCOPE, 3); row(&mut p, "a", PREFIX, 8.0, 1.0, IN_SCOPE | PENDING, 3); row(&mut p, "b", PREFIX, 7.0, 1.0, IN_SCOPE | IN_PLAY, 2); row(&mut p, "dem", PREFIX, 6.0, 0.01, IN_SCOPE, 2); row(&mut p, "x", PREFIX, 5.0, 1.0, IN_SCOPE, 0);
611 row(&mut p, "vec", PREFIX, 0.0, 1.0, IN_SCOPE | VECTOR, 2);
612 row(&mut p, "weak", TAIL, 0.5, 1.0, IN_SCOPE, 2);
613 row(&mut p, "none", BOUNDARY, 0.0, 1.0, IN_SCOPE, 3);
614 row(&mut p, "low1", NOT_SHOWN, 0.1, 1.0, IN_SCOPE, 0);
615 let home = std::env::temp_dir().join(format!("borg-pools-misses-{}", std::process::id()));
616 let _ = std::fs::remove_dir_all(&home);
617 std::fs::create_dir_all(&home).unwrap();
618 let line = serde_json::to_string(&p).unwrap();
619 crate::learn::append_line(&path(&home), &line).unwrap();
620 let m = misses(&home);
621 let by = |c: &str| m.causes.iter().find(|(x, _)| x == c).unwrap().1;
622 assert_eq!((m.checks, m.positives, m.low, m.drawn), (1, 5, 4, 2), "{m:?}");
623 assert_eq!((by("demoted"), by("vector_only"), by("weak_words"), by("no_word_match"), by("other"), by("scope")), (1, 1, 1, 1, 0, 0));
624 crate::learn::append_line(&path(&home), &line).unwrap();
626 let m2 = misses(&home);
627 assert_eq!((m2.checks, m2.low, m2.at), (2, 8, std::fs::metadata(path(&home)).unwrap().len()));
628 let _ = std::fs::remove_dir_all(&home);
629 }
630}