1use crate::types::*;
2use anyhow::Result;
3use rusqlite::{params, Connection};
4use std::{path::Path, sync::Mutex};
5
6pub struct Store {
7 conn: Mutex<Connection>,
8}
9
10
11impl Store {
12 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
13 let conn = Connection::open(path)?;
14 conn.execute_batch("
15 PRAGMA journal_mode=WAL;
16 PRAGMA busy_timeout=5000;
17 CREATE TABLE IF NOT EXISTS sessions (
18 id TEXT PRIMARY KEY, orchestrator_id TEXT,
19 name TEXT NOT NULL, repo TEXT NOT NULL,
20 status TEXT NOT NULL, agent_type TEXT NOT NULL,
21 cost_usd REAL NOT NULL DEFAULT 0, started_at INTEGER NOT NULL,
22 pr_number INTEGER, pr_id INTEGER,
23 workspace_path TEXT, pid INTEGER,
24 model TEXT, context_tokens INTEGER
25 );
26 CREATE TABLE IF NOT EXISTS orchestrators (
27 id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL
28 );
29 CREATE TABLE IF NOT EXISTS prs (
30 id INTEGER PRIMARY KEY, number INTEGER NOT NULL,
31 title TEXT NOT NULL, url TEXT NOT NULL,
32 body TEXT NOT NULL, session_id TEXT NOT NULL
33 );
34 CREATE TABLE IF NOT EXISTS ci_status (
35 pr_id INTEGER PRIMARY KEY, total INTEGER NOT NULL,
36 passing INTEGER NOT NULL, failing INTEGER NOT NULL,
37 pending INTEGER NOT NULL
38 );
39 CREATE TABLE IF NOT EXISTS review_comments (
40 id INTEGER PRIMARY KEY, pr_id INTEGER NOT NULL,
41 author TEXT NOT NULL, body TEXT NOT NULL,
42 path TEXT, line INTEGER, created_at INTEGER NOT NULL
43 );
44 ")?;
45 for (col, ddl) in [
48 ("model", "ALTER TABLE sessions ADD COLUMN model TEXT"),
49 ("context_tokens", "ALTER TABLE sessions ADD COLUMN context_tokens INTEGER"),
50 ("catalogue_path", "ALTER TABLE sessions ADD COLUMN catalogue_path TEXT"),
51 ("context_used_pct", "ALTER TABLE sessions ADD COLUMN context_used_pct REAL"),
52 ("context_total_tokens", "ALTER TABLE sessions ADD COLUMN context_total_tokens INTEGER"),
53 ("context_window_size", "ALTER TABLE sessions ADD COLUMN context_window_size INTEGER"),
54 ("claude_session_id", "ALTER TABLE sessions ADD COLUMN claude_session_id TEXT"),
55 ] {
56 if !Self::column_exists(&conn, "sessions", col)? {
57 conn.execute(ddl, [])?;
58 }
59 }
60 Ok(Self { conn: Mutex::new(conn) })
61 }
62
63 fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
64 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
65 let exists = stmt
66 .query_map([], |r| r.get::<_, String>(1))?
67 .filter_map(|r| r.ok())
68 .any(|c| c == column);
69 Ok(exists)
70 }
71
72 pub fn upsert_session(&self, s: &Session) -> Result<()> {
73 let status = serde_json::to_string(&s.status)?.replace('"', "");
74 let conn = self.conn.lock().unwrap();
75 conn.execute(
76 "INSERT INTO sessions (id,orchestrator_id,name,repo,status,agent_type,
77 cost_usd,started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
78 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
79 claude_session_id)
80 VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19)
81 ON CONFLICT(id) DO UPDATE SET
82 status=excluded.status,cost_usd=excluded.cost_usd,
83 started_at=excluded.started_at,
84 pr_number=excluded.pr_number,pr_id=excluded.pr_id,
85 workspace_path=excluded.workspace_path,pid=excluded.pid,
86 model=excluded.model,context_tokens=excluded.context_tokens,
87 catalogue_path=excluded.catalogue_path,
88 context_used_pct=excluded.context_used_pct,
89 context_total_tokens=excluded.context_total_tokens,
90 context_window_size=excluded.context_window_size,
91 claude_session_id=excluded.claude_session_id",
92 params![
93 s.id, s.orchestrator_id, s.name, s.repo, status, s.agent_type,
94 s.cost_usd, s.started_at, s.pr_number, s.pr_id,
95 s.workspace_path, s.pid, s.model, s.context_tokens,
96 s.catalogue_path, s.context_used_pct, s.context_total_tokens,
97 s.context_window_size, s.claude_session_id
98 ],
99 )?;
100 Ok(())
101 }
102
103 pub fn list_sessions(&self) -> Result<Vec<Session>> {
104 let conn = self.conn.lock().unwrap();
105 let mut stmt = conn.prepare(
106 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
107 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
108 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
109 claude_session_id
110 FROM sessions ORDER BY started_at DESC",
111 )?;
112 let rows = stmt.query_map([], |r| {
113 Ok((
114 r.get::<_, String>(0)?,
115 r.get::<_, Option<String>>(1)?,
116 r.get::<_, String>(2)?,
117 r.get::<_, String>(3)?,
118 r.get::<_, String>(4)?,
119 r.get::<_, String>(5)?,
120 r.get::<_, f64>(6)?,
121 r.get::<_, i64>(7)?,
122 r.get::<_, Option<u64>>(8)?,
123 r.get::<_, Option<i64>>(9)?,
124 r.get::<_, Option<String>>(10)?,
125 r.get::<_, Option<u32>>(11)?,
126 r.get::<_, Option<String>>(12)?,
127 r.get::<_, Option<i64>>(13)?,
128 r.get::<_, Option<String>>(14)?,
129 r.get::<_, Option<f64>>(15)?,
130 r.get::<_, Option<i64>>(16)?,
131 r.get::<_, Option<i64>>(17)?,
132 r.get::<_, Option<String>>(18)?,
133 ))
134 })?;
135 rows.map(|r| {
136 let (id, orchestrator_id, name, repo, status_str, agent_type,
137 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
138 model, context_tokens, catalogue_path, context_used_pct,
139 context_total_tokens, context_window_size, claude_session_id) = r?;
140 let status = serde_json::from_str(&format!("\"{status_str}\""))
141 .unwrap_or(SessionStatus::Working);
142 Ok(Session {
143 id, orchestrator_id, name, repo, status, agent_type,
144 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
145 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
146 catalogue_path,
147 context_used_pct,
148 context_total_tokens: context_total_tokens.map(|v| v.max(0) as u64),
149 context_window_size: context_window_size.map(|v| v.max(0) as u64),
150 claude_session_id,
151 })
152 })
153 .collect()
154 }
155
156 pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
157 let conn = self.conn.lock().unwrap();
158 let mut stmt = conn.prepare(
159 "SELECT id,orchestrator_id,name,repo,status,agent_type,cost_usd,
160 started_at,pr_number,pr_id,workspace_path,pid,model,context_tokens,
161 catalogue_path,context_used_pct,context_total_tokens,context_window_size,
162 claude_session_id
163 FROM sessions WHERE id = ?1",
164 )?;
165 let mut rows = stmt.query_map([id], |r| {
166 Ok((
167 r.get::<_, String>(0)?,
168 r.get::<_, Option<String>>(1)?,
169 r.get::<_, String>(2)?,
170 r.get::<_, String>(3)?,
171 r.get::<_, String>(4)?,
172 r.get::<_, String>(5)?,
173 r.get::<_, f64>(6)?,
174 r.get::<_, i64>(7)?,
175 r.get::<_, Option<u64>>(8)?,
176 r.get::<_, Option<i64>>(9)?,
177 r.get::<_, Option<String>>(10)?,
178 r.get::<_, Option<u32>>(11)?,
179 r.get::<_, Option<String>>(12)?,
180 r.get::<_, Option<i64>>(13)?,
181 r.get::<_, Option<String>>(14)?,
182 r.get::<_, Option<f64>>(15)?,
183 r.get::<_, Option<i64>>(16)?,
184 r.get::<_, Option<i64>>(17)?,
185 r.get::<_, Option<String>>(18)?,
186 ))
187 })?;
188 match rows.next() {
189 None => Ok(None),
190 Some(r) => {
191 let (id, orchestrator_id, name, repo, status_str, agent_type,
192 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
193 model, context_tokens, catalogue_path, context_used_pct,
194 context_total_tokens, context_window_size, claude_session_id) = r?;
195 let status = serde_json::from_str(&format!("\"{status_str}\""))
196 .unwrap_or(SessionStatus::Working);
197 Ok(Some(Session {
198 id, orchestrator_id, name, repo, status, agent_type,
199 cost_usd, started_at, pr_number, pr_id, workspace_path, pid,
200 model, context_tokens: context_tokens.map(|v| v.max(0) as u64),
201 catalogue_path,
202 context_used_pct,
203 context_total_tokens: context_total_tokens.map(|v| v.max(0) as u64),
204 context_window_size: context_window_size.map(|v| v.max(0) as u64),
205 claude_session_id,
206 }))
207 }
208 }
209 }
210
211 pub fn cost_samples(&self, agent_type: &str, model: Option<&str>) -> Result<Vec<f64>> {
217 let sessions = self.list_sessions()?;
218 Ok(sessions
219 .into_iter()
220 .filter(|s| {
221 s.agent_type == agent_type
222 && s.model.as_deref() == model
223 && s.cost_usd > 0.0
224 })
225 .map(|s| s.cost_usd)
226 .collect())
227 }
228
229 pub fn upsert_orchestrator(&self, o: &Orchestrator) -> Result<()> {
230 let conn = self.conn.lock().unwrap();
231 conn.execute(
232 "INSERT INTO orchestrators(id,name,created_at) VALUES(?1,?2,?3)
233 ON CONFLICT(id) DO UPDATE SET name=excluded.name",
234 params![o.id, o.name, o.created_at],
235 )?;
236 Ok(())
237 }
238
239 pub fn sessions_by_orchestrator(&self, orchestrator_id: &str) -> Result<Vec<Session>> {
240 let sessions = self.list_sessions()?;
241 Ok(sessions.into_iter().filter(|s| s.orchestrator_id.as_deref() == Some(orchestrator_id)).collect())
242 }
243
244 pub fn delete_session(&self, id: &str) -> Result<()> {
245 let conn = self.conn.lock().unwrap();
246 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
247 Ok(())
248 }
249
250 pub fn delete_orchestrator(&self, id: &str) -> Result<()> {
251 let conn = self.conn.lock().unwrap();
252 conn.execute("DELETE FROM sessions WHERE orchestrator_id = ?1", [id])?;
253 conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?;
254 conn.execute("DELETE FROM orchestrators WHERE id = ?1", [id])?;
255 Ok(())
256 }
257
258 pub fn upsert_pr(&self, pr: &PR) -> Result<()> {
259 let conn = self.conn.lock().unwrap();
260 conn.execute(
261 "INSERT OR REPLACE INTO prs
262 (id, number, title, url, body, session_id)
263 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
264 params![pr.id, pr.number, pr.title, pr.url, pr.body, pr.session_id],
265 )?;
266 Ok(())
267 }
268
269 pub fn get_pr(&self, id: PrId) -> Result<Option<PR>> {
270 let conn = self.conn.lock().unwrap();
271 let mut stmt = conn.prepare(
272 "SELECT id, number, title, url, body, session_id FROM prs WHERE id = ?1",
273 )?;
274 let mut rows = stmt.query_map([id], |r| {
275 Ok(PR {
276 id: r.get(0)?,
277 number: r.get(1)?,
278 title: r.get(2)?,
279 url: r.get(3)?,
280 body: r.get(4)?,
281 session_id: r.get(5)?,
282 })
283 })?;
284 rows.next().transpose().map_err(Into::into)
285 }
286
287 pub fn upsert_ci_status(&self, ci: &CIStatus) -> Result<()> {
288 let conn = self.conn.lock().unwrap();
289 conn.execute(
290 "INSERT OR REPLACE INTO ci_status
291 (pr_id, total, passing, failing, pending)
292 VALUES (?1, ?2, ?3, ?4, ?5)",
293 params![ci.pr_id, ci.total, ci.passing, ci.failing, ci.pending],
294 )?;
295 Ok(())
296 }
297
298 pub fn upsert_comment(&self, c: &Comment) -> Result<()> {
299 let conn = self.conn.lock().unwrap();
300 conn.execute(
301 "INSERT OR REPLACE INTO review_comments
302 (id, pr_id, author, body, path, line, created_at)
303 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
304 params![c.id, c.pr_id, c.author, c.body, c.path, c.line, c.created_at],
305 )?;
306 Ok(())
307 }
308
309 pub fn list_orchestrators(&self) -> Result<Vec<Orchestrator>> {
310 let conn = self.conn.lock().unwrap();
311 let mut stmt = conn.prepare(
312 "SELECT id,name,created_at FROM orchestrators ORDER BY created_at DESC",
313 )?;
314 let rows = stmt.query_map([], |r| {
315 Ok(Orchestrator {
316 id: r.get(0)?,
317 name: r.get(1)?,
318 created_at: r.get(2)?,
319 })
320 })?;
321 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use tempfile::tempdir;
329
330 fn test_store() -> Store {
331 let dir = tempdir().unwrap();
332 let path = dir.path().join("t.db");
333 std::mem::forget(dir);
335 Store::open(path).unwrap()
336 }
337
338 #[test]
339 fn upsert_and_list_session() {
340 let store = test_store();
341 let session = Session {
342 id: "s1".into(), orchestrator_id: None, name: "worker-1".into(),
343 repo: "slievr/Athene".into(), status: SessionStatus::Working,
344 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
345 pr_number: None, pr_id: None, workspace_path: None, pid: None,
346 model: None, context_tokens: None, catalogue_path: None,
347 context_used_pct: None, context_total_tokens: None, context_window_size: None,
348 claude_session_id: None,
349 };
350 store.upsert_session(&session).unwrap();
351 let list = store.list_sessions().unwrap();
352 assert_eq!(list.len(), 1);
353 assert_eq!(list[0].id, "s1");
354 }
355
356 #[test]
357 fn upsert_updates_status() {
358 let store = test_store();
359 let mut s = Session {
360 id: "s1".into(), orchestrator_id: None, name: "w".into(),
361 repo: "r".into(), status: SessionStatus::Working,
362 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
363 pr_number: None, pr_id: None, workspace_path: None, pid: None,
364 model: None, context_tokens: None, catalogue_path: None,
365 context_used_pct: None, context_total_tokens: None, context_window_size: None,
366 claude_session_id: None,
367 };
368 store.upsert_session(&s).unwrap();
369 s.status = SessionStatus::Done;
370 store.upsert_session(&s).unwrap();
371 let list = store.list_sessions().unwrap();
372 assert_eq!(list.len(), 1);
373 assert!(matches!(list[0].status, SessionStatus::Done));
374 }
375
376 #[test]
377 fn get_session_by_id() {
378 let store = test_store();
379 let s = Session {
380 id: "s1".into(), orchestrator_id: None, name: "w".into(),
381 repo: "r".into(), status: SessionStatus::Working,
382 agent_type: "c".into(), cost_usd: 0.0, started_at: 0,
383 pr_number: None, pr_id: None, workspace_path: None, pid: None,
384 model: None, context_tokens: None, catalogue_path: None,
385 context_used_pct: None, context_total_tokens: None, context_window_size: None,
386 claude_session_id: None,
387 };
388 store.upsert_session(&s).unwrap();
389 let found = store.get_session("s1").unwrap();
390 assert!(found.is_some());
391 assert_eq!(found.unwrap().name, "w");
392 assert!(store.get_session("missing").unwrap().is_none());
393 }
394
395 #[test]
396 fn model_and_context_tokens_round_trip() {
397 let store = test_store();
398 let s = Session {
399 id: "s1".into(), orchestrator_id: None, name: "w".into(),
400 repo: "r".into(), status: SessionStatus::Working,
401 agent_type: "claude-code".into(), cost_usd: 1.5, started_at: 0,
402 pr_number: None, pr_id: None, workspace_path: None, pid: None,
403 model: Some("claude-fable-5".into()), context_tokens: Some(214_000), catalogue_path: None,
404 context_used_pct: None, context_total_tokens: None, context_window_size: None,
405 claude_session_id: None,
406 };
407 store.upsert_session(&s).unwrap();
408 let found = store.get_session("s1").unwrap().unwrap();
409 assert_eq!(found.model.as_deref(), Some("claude-fable-5"));
410 assert_eq!(found.context_tokens, Some(214_000));
411 }
412
413 #[test]
414 fn catalogue_path_round_trips() {
415 let store = test_store();
416 let s = Session {
417 id: "s2".into(), orchestrator_id: None, name: "w".into(),
418 repo: "r".into(), status: SessionStatus::Working,
419 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
420 pr_number: None, pr_id: None, workspace_path: None, pid: None,
421 model: None, context_tokens: None,
422 catalogue_path: Some("/brains/x".into()),
423 context_used_pct: None, context_total_tokens: None, context_window_size: None,
424 claude_session_id: None,
425 };
426 store.upsert_session(&s).unwrap();
427 let found = store.get_session("s2").unwrap().unwrap();
428 assert_eq!(found.catalogue_path.as_deref(), Some("/brains/x"));
429 assert_eq!(store.list_sessions().unwrap()[0].catalogue_path.as_deref(), Some("/brains/x"));
431 }
432
433 #[test]
434 fn claude_session_id_round_trips() {
435 let store = test_store();
436 let s = Session {
437 id: "s3".into(), orchestrator_id: None, name: "w".into(),
438 repo: "r".into(), status: SessionStatus::Working,
439 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
440 pr_number: None, pr_id: None, workspace_path: None, pid: None,
441 model: None, context_tokens: None, catalogue_path: None,
442 context_used_pct: None, context_total_tokens: None, context_window_size: None,
443 claude_session_id: Some("b7e0b3a0-0000-4000-8000-000000000001".into()),
444 };
445 store.upsert_session(&s).unwrap();
446 let found = store.get_session("s3").unwrap().unwrap();
447 assert_eq!(found.claude_session_id.as_deref(), Some("b7e0b3a0-0000-4000-8000-000000000001"));
448 assert_eq!(
450 store.list_sessions().unwrap().iter().find(|x| x.id == "s3").unwrap().claude_session_id.as_deref(),
451 Some("b7e0b3a0-0000-4000-8000-000000000001"),
452 );
453
454 let mut s2 = s.clone();
456 s2.id = "s4".into();
457 s2.claude_session_id = None;
458 store.upsert_session(&s2).unwrap();
459 assert_eq!(store.get_session("s4").unwrap().unwrap().claude_session_id, None);
460 }
461
462 #[test]
466 fn upsert_conflict_updates_started_at() {
467 let store = test_store();
468 let mut s = Session {
469 id: "s3".into(), orchestrator_id: None, name: "w".into(),
470 repo: "r".into(), status: SessionStatus::Working,
471 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 100,
472 pr_number: None, pr_id: None, workspace_path: None, pid: None,
473 model: None, context_tokens: None, catalogue_path: None,
474 context_used_pct: None, context_total_tokens: None, context_window_size: None,
475 claude_session_id: None,
476 };
477 store.upsert_session(&s).unwrap();
478 s.started_at = 200;
479 store.upsert_session(&s).unwrap();
480 assert_eq!(store.get_session("s3").unwrap().unwrap().started_at, 200);
481 }
482
483 #[test]
484 fn get_pr_round_trips_and_misses_cleanly() {
485 let store = test_store();
486 assert!(store.get_pr(9).unwrap().is_none());
487 let pr = PR {
488 id: 9, number: 9, title: "t".into(),
489 url: "https://github.com/org/repo/pull/9".into(),
490 body: String::new(), session_id: "s1".into(),
491 };
492 store.upsert_pr(&pr).unwrap();
493 let found = store.get_pr(9).unwrap().unwrap();
494 assert_eq!(found.number, 9);
495 assert_eq!(found.session_id, "s1");
496 assert_eq!(found.url, pr.url);
497 }
498
499 #[test]
500 fn context_fields_round_trip() {
501 let store = test_store();
502 let s = Session {
503 id: "s1".into(), orchestrator_id: None, name: "w".into(),
504 repo: "r".into(), status: SessionStatus::Working,
505 agent_type: "claude-code".into(), cost_usd: 2.6, started_at: 0,
506 pr_number: None, pr_id: None, workspace_path: None, pid: None,
507 model: None, context_tokens: None, catalogue_path: None,
508 context_used_pct: Some(62.0),
509 context_total_tokens: Some(124_000),
510 context_window_size: Some(200_000),
511 claude_session_id: None,
512 };
513 store.upsert_session(&s).unwrap();
514 let found = store.get_session("s1").unwrap().unwrap();
515 assert_eq!(found.context_used_pct, Some(62.0));
516 assert_eq!(found.context_total_tokens, Some(124_000));
517 assert_eq!(found.context_window_size, Some(200_000));
518 assert_eq!(store.list_sessions().unwrap()[0].context_used_pct, Some(62.0));
520 }
521
522 #[test]
523 fn context_fields_default_to_none() {
524 let store = test_store();
525 let s = Session {
526 id: "s2".into(), orchestrator_id: None, name: "w".into(),
527 repo: "r".into(), status: SessionStatus::Working,
528 agent_type: "claude-code".into(), cost_usd: 0.0, started_at: 0,
529 pr_number: None, pr_id: None, workspace_path: None, pid: None,
530 model: None, context_tokens: None, catalogue_path: None,
531 context_used_pct: None, context_total_tokens: None, context_window_size: None,
532 claude_session_id: None,
533 };
534 store.upsert_session(&s).unwrap();
535 let found = store.get_session("s2").unwrap().unwrap();
536 assert_eq!(found.context_used_pct, None);
537 assert_eq!(found.context_total_tokens, None);
538 assert_eq!(found.context_window_size, None);
539 }
540
541 #[test]
542 fn cost_samples_filters_by_agent_and_model_and_excludes_zero() {
543 let store = test_store();
544 for (id, agent_type, model, cost) in [
545 ("a", "claude-code", Some("claude-fable-5"), 3.0),
546 ("b", "claude-code", Some("claude-fable-5"), 5.0),
547 ("c", "claude-code", Some("claude-fable-5"), 0.0), ("d", "claude-code", Some("claude-opus-4-8"), 2.0), ("e", "codex", Some("claude-fable-5"), 4.0), ] {
551 store.upsert_session(&Session {
552 id: id.into(), orchestrator_id: None, name: id.into(),
553 repo: "r".into(), status: SessionStatus::Working,
554 agent_type: agent_type.into(), cost_usd: cost, started_at: 0,
555 pr_number: None, pr_id: None, workspace_path: None, pid: None,
556 model: model.map(String::from), context_tokens: None, catalogue_path: None,
557 context_used_pct: None, context_total_tokens: None, context_window_size: None,
558 claude_session_id: None,
559 }).unwrap();
560 }
561 let samples = store.cost_samples("claude-code", Some("claude-fable-5")).unwrap();
562 assert_eq!(samples.len(), 2);
563 assert!((samples.iter().sum::<f64>() - 8.0).abs() < f64::EPSILON);
564 }
565}