1use std::path::PathBuf;
4
5use clap::{Parser, Subcommand};
6use serde_json::json;
7
8pub use crate::backup::BackupCommands;
9pub use crate::daemon::DaemonCommands;
10pub use crate::hook::HookCommands;
11use crate::{AppraiseParams, RecallParams, RecordParams, Situation, APPRAISE_ADVISORY};
12
13fn default_db() -> PathBuf {
14 crate::paths::default_db_path()
15}
16
17#[derive(Parser)]
18#[command(name = "innate", version, about = "Self-growing knowledge layer")]
19pub struct Cli {
20 #[arg(long, global = true, env = "INNATE_DB")]
21 pub db: Option<PathBuf>,
22
23 #[command(subcommand)]
24 pub command: Commands,
25}
26
27#[derive(Subcommand)]
28pub enum Commands {
29 Recall {
31 query: String,
32 #[arg(long, default_value = "6000")]
33 budget: usize,
34 #[arg(long)]
35 top: Option<usize>,
36 #[arg(long, default_value = "text")]
37 format: String,
38 #[arg(long)]
39 include_sparks: bool,
40 #[arg(long, default_value = "false")]
42 expand_deps: String,
43 #[arg(long)]
45 allow_trim: bool,
46 #[arg(long, default_value = "off")]
48 refine_mode: String,
49 #[arg(long, default_value = "cli")]
51 source: String,
52 #[arg(long)]
55 min_score: Option<f64>,
56 #[arg(long)]
60 session: bool,
61 #[arg(long)]
64 rerank: bool,
65 },
66 Appraise {
69 #[arg(long, default_value = "")]
71 query: String,
72 #[arg(long)]
74 last_error: Option<String>,
75 #[arg(long)]
77 recent_actions: Option<String>,
78 #[arg(long)]
80 stage: Option<String>,
81 #[arg(long)]
83 file_context: Option<String>,
84 #[arg(long)]
86 candidate: Option<String>,
87 #[arg(long)]
88 top: Option<usize>,
89 #[arg(long)]
90 min_strength: Option<f64>,
91 #[arg(long, default_value = "cli")]
92 source: String,
93 #[arg(long, default_value = "json")]
94 format: String,
95 },
96 Record {
98 trace_id: String,
99 #[arg(long)]
100 query: Option<String>,
101 #[arg(long)]
102 outcome: Option<String>,
103 #[arg(long)]
105 used: Option<String>,
106 #[arg(long, default_value = "explicit")]
107 used_attribution: String,
108 #[arg(long)]
110 used_partial: bool,
111 #[arg(long)]
112 output: Option<String>,
113 #[arg(long)]
114 output_summary: Option<String>,
115 #[arg(long)]
116 nomination: Option<String>,
117 #[arg(long, default_value = "cli")]
118 source: String,
119 #[arg(long)]
121 feedback: Option<String>,
122 #[arg(long, default_value = "user")]
123 feedback_kind: String,
124 #[arg(long)]
125 feedback_actor: Option<String>,
126 #[arg(long)]
127 feedback_reason: Option<String>,
128 #[arg(long)]
129 task_state: Option<String>,
130 #[arg(long, default_value = "0")]
131 priority: i64,
132 #[arg(long)]
136 verdict_heeded: bool,
137 },
138 Add {
140 content: String,
141 #[arg(long, default_value = "note")]
142 kind: String,
143 #[arg(long)]
144 trigger: Option<String>,
145 #[arg(long)]
146 anti_trigger: Option<String>,
147 #[arg(long, default_value = "chat")]
148 source: String,
149 #[arg(long)]
150 skill_name: Option<String>,
151 #[arg(long = "depends-on")]
153 depends_on: Vec<String>,
154 #[arg(long, default_value = "hard")]
156 dep_kind: String,
157 },
158 Spark {
160 content: String,
161 #[arg(long)]
162 trigger: Option<String>,
163 },
164 Evolve {
166 #[arg(long, default_value = "manual")]
167 trigger: String,
168 #[arg(long)]
170 rebuild_embeddings: bool,
171 },
172 Inspect { id: Option<String> },
174 Approve { chunk_id: String },
176 Archive {
178 chunk_id: String,
179 #[arg(long, default_value = "stale")]
180 reason: String,
181 },
182 Invalidate {
184 chunk_id: String,
185 #[arg(long, default_value = "")]
186 reason: String,
187 },
188 Restore { chunk_id: String },
190 MatureSpark { spark_id: String, to: String },
192 PromoteSpark {
194 spark_id: String,
195 #[arg(long, default_value = "note")]
196 to: String,
197 },
198 DropSpark {
200 spark_id: String,
201 #[arg(long, default_value = "")]
202 reason: String,
203 },
204 Backup {
206 #[command(subcommand)]
207 action: BackupCommands,
208 },
209 Install,
211 Uninstall {
213 #[arg(long, short = 'y')]
215 yes: bool,
216 #[arg(long)]
218 purge_data: bool,
219 },
220 Migrate,
222 Metrics {
224 #[command(subcommand)]
225 action: MetricsAction,
226 },
227 Vacuum,
229 RepairTraces {
232 #[arg(long)]
234 dry_run: bool,
235 },
236 RecallEval {
241 labels: PathBuf,
243 #[arg(long, default_value = "10")]
245 k: usize,
246 #[arg(long)]
249 save: bool,
250 },
251 Upgrade {
253 #[arg(long, value_name = "VERSION")]
255 version: Option<String>,
256 #[arg(long)]
258 check: bool,
259 },
260 Daemon {
262 #[command(subcommand)]
263 action: DaemonCommands,
264 },
265 Mcp,
267 Web {
269 #[arg(long, default_value = "127.0.0.1")]
271 bind: String,
272 #[arg(long, default_value_t = 8788)]
274 port: u16,
275 #[arg(long)]
277 no_token: bool,
278 #[arg(long)]
281 allow_remote: bool,
282 },
283 Hook {
285 #[command(subcommand)]
286 action: HookCommands,
287 },
288}
289
290#[derive(clap::Subcommand)]
291pub enum MetricsAction {
292 Snapshot,
294}
295
296pub fn run() -> anyhow::Result<()> {
297 let cli = Cli::parse();
298 crate::paths::ensure_layout();
301 let db_path = cli.db.unwrap_or_else(default_db);
302
303 if let Commands::Mcp = &cli.command {
304 return crate::mcp::run_server(db_path);
305 }
306
307 if let Commands::Install = &cli.command {
308 return crate::install::run_install();
309 }
310
311 if let Commands::Uninstall { yes, purge_data } = &cli.command {
312 return crate::install::run_uninstall(*yes, *purge_data);
313 }
314
315 if let Commands::Migrate = &cli.command {
316 let applied = crate::migrate::run_migrations(&db_path)?;
317 if applied.is_empty() {
318 println!(
319 "already at {} — nothing to do",
320 crate::migrate::target_version()
321 );
322 } else {
323 for step in &applied {
324 println!(" applied: {step}");
325 }
326 println!("migration complete");
327 }
328 return Ok(());
329 }
330
331 if let Commands::Daemon { action } = &cli.command {
332 return crate::daemon::run_command(action, &db_path);
333 }
334
335 if let Commands::Backup { action } = &cli.command {
336 return crate::backup::run_command(action, &db_path);
337 }
338
339 if let Commands::Upgrade { version, check } = &cli.command {
340 return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
341 }
342
343 if let Commands::Hook { action } = &cli.command {
344 return crate::hook::run_command(action, &db_path);
345 }
346
347 let kb = crate::open_kb(&db_path)?;
348
349 match cli.command {
350 Commands::Recall {
351 query,
352 budget,
353 top,
354 format,
355 include_sparks,
356 expand_deps,
357 allow_trim,
358 refine_mode,
359 source,
360 min_score,
361 session,
362 rerank,
363 } => {
364 let result = kb.recall(RecallParams {
365 query: &query,
366 budget,
367 trace: true,
368 include_sparks,
369 top,
370 source: &source,
371 expand_deps: &expand_deps,
372 allow_trim,
373 refine_mode: &refine_mode,
374 min_score,
375 session_only: session,
376 rerank,
377 })?;
378 match format.as_str() {
379 "json" => println!(
380 "{}",
381 serde_json::to_string_pretty(&json!({
382 "trace_id": result.trace_id,
383 "knowledge": result.knowledge,
384 "sparks": result.sparks,
385 "empty": result.empty,
386 }))?
387 ),
388 "prompt" => {
389 for chunk in &result.knowledge {
390 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
391 println!("{content}\n---");
392 }
393 println!("<!-- innate_trace_id: {} -->", result.trace_id);
395 println!(
396 "<!-- innate_selected: {} -->",
397 result
398 .knowledge
399 .iter()
400 .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
401 .collect::<Vec<_>>()
402 .join(",")
403 );
404 }
405 _ => {
406 for chunk in &result.knowledge {
407 let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
408 let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
409 let conf = chunk
410 .get("confidence")
411 .and_then(|v| v.as_f64())
412 .unwrap_or(0.5);
413 println!("[{id}] (conf={conf:.2})\n{content}\n");
414 }
415 if result.empty {
416 println!("(no results)");
417 }
418 }
419 }
420 }
421 Commands::Appraise {
422 query,
423 last_error,
424 recent_actions,
425 stage,
426 file_context,
427 candidate,
428 top,
429 min_strength,
430 source,
431 format,
432 } => {
433 let actions: Vec<String> = recent_actions
434 .as_deref()
435 .map(|raw| {
436 raw.split(',')
437 .map(str::trim)
438 .filter(|a| !a.is_empty())
439 .map(str::to_string)
440 .collect()
441 })
442 .unwrap_or_default();
443 let situation = Situation {
444 query: (!query.is_empty()).then_some(query.as_str()),
445 last_error: last_error.as_deref(),
446 recent_actions: &actions,
447 stage: stage.as_deref(),
448 file_context: file_context.as_deref(),
449 };
450 let verdict = kb.appraise(AppraiseParams {
451 situation,
452 candidate: candidate.as_deref(),
453 min_strength,
454 top,
455 trace: true,
456 source: &source,
457 })?;
458 match format.as_str() {
459 "text" => {
460 println!("ℹ {APPRAISE_ADVISORY}");
461 if verdict.abstained {
462 println!(
463 "ABSTAIN reason={:?} strength={:.3} trace_id={}",
464 verdict.abstain_reason, verdict.strength, verdict.trace_id
465 );
466 } else {
467 println!(
468 "valence={:?} tier={:?} strength={:.3} confidence={:.3} dispersion={:.3} trace_id={}",
469 verdict.valence, verdict.tier, verdict.strength,
470 verdict.confidence, verdict.dispersion, verdict.trace_id
471 );
472 }
473 for fp in &verdict.flagged_points {
474 println!(
475 " ⚠ [{}] {} (s={:.3})",
476 fp.chunk_id, fp.summary, fp.strength
477 );
478 }
479 }
480 _ => println!(
481 "{}",
482 serde_json::to_string_pretty(&json!({
483 "advisory": APPRAISE_ADVISORY,
484 "valence": verdict.valence,
485 "strength": verdict.strength,
486 "tier": verdict.tier,
487 "confidence": verdict.confidence,
488 "dispersion": verdict.dispersion,
489 "abstained": verdict.abstained,
490 "abstain_reason": verdict.abstain_reason,
491 "flagged_points": verdict.flagged_points,
492 "contributors": verdict.contributors,
493 "trace_id": verdict.trace_id,
494 }))?
495 ),
496 }
497 }
498 Commands::Record {
499 trace_id,
500 query,
501 outcome,
502 used,
503 used_attribution,
504 used_partial,
505 output,
506 output_summary,
507 nomination,
508 source,
509 feedback,
510 feedback_kind,
511 feedback_actor,
512 feedback_reason,
513 task_state,
514 priority,
515 verdict_heeded,
516 } => {
517 let used_ids = used.as_deref().map(|raw| {
518 raw.split(',')
519 .map(str::trim)
520 .filter(|id| !id.is_empty())
521 .map(str::to_string)
522 .collect::<Vec<_>>()
523 });
524 let used_ref = used_ids.as_deref();
525 let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
527 match feedback.as_deref() {
528 Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
529 (used_ids.clone(), None)
530 }
531 Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
532 (None, used_ids.clone())
533 }
534 Some("up") => (None, None), Some("down") => (None, None),
536 _ => (None, None),
537 };
538 let fb_up_ref = fb_up.as_deref();
539 let fb_down_ref = fb_down.as_deref();
540 kb.record(RecordParams {
541 trace_id: &trace_id,
542 query: query.as_deref(),
543 output: output.as_deref(),
544 output_summary: output_summary.as_deref(),
545 outcome: outcome.as_deref(),
546 used: used_ref,
547 used_attribution: &used_attribution,
548 used_complete: Some(!used_partial),
549 feedback_up: fb_up_ref,
550 feedback_down: fb_down_ref,
551 feedback_kind: &feedback_kind,
552 feedback_actor: feedback_actor.as_deref(),
553 feedback_reason: feedback_reason.as_deref(),
554 nomination: nomination.as_deref(),
555 priority,
556 task_state: task_state.as_deref(),
557 source: &source,
558 verdict_heeded,
559 })?;
560 println!("recorded");
561 }
562 Commands::Add {
563 content,
564 kind,
565 trigger,
566 anti_trigger,
567 source,
568 skill_name,
569 depends_on,
570 dep_kind,
571 } => {
572 let content = if kind == "skill" {
574 let p = std::path::Path::new(&content);
575 if p.exists() && p.is_file() {
576 std::fs::read_to_string(p).map_err(|e| {
577 anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
578 })?
579 } else {
580 content
581 }
582 } else {
583 content
584 };
585 let deps: Vec<(String, String)> = depends_on
586 .iter()
587 .map(|d| (d.clone(), dep_kind.clone()))
588 .collect();
589 let id = kb.add_with_deps(
590 &content,
591 &kind,
592 trigger.as_deref(),
593 anti_trigger.as_deref(),
594 &source,
595 skill_name.as_deref(),
596 &deps,
597 )?;
598 println!("{id}");
599 }
600 Commands::Spark { content, trigger } => {
601 let id = kb.spark(&content, trigger.as_deref(), None)?;
602 println!("{id}");
603 }
604 Commands::Evolve {
605 trigger,
606 rebuild_embeddings,
607 } => {
608 if rebuild_embeddings {
609 let rebuilt = kb.rebuild_embeddings()?;
610 let report = kb.evolve(&trigger)?;
611 println!(
612 "{}",
613 serde_json::to_string_pretty(&json!({
614 "rebuilt_embeddings": rebuilt,
615 "evolve": report
616 }))?
617 );
618 } else {
619 let report = kb.evolve(&trigger)?;
620 println!("{}", serde_json::to_string_pretty(&report)?);
621 }
622 }
623 Commands::Inspect { id } => match id.as_deref() {
624 None => {
625 let info = kb.inspect()?;
626 println!("{}", serde_json::to_string_pretty(&info)?);
627 }
628 Some(id) => {
629 let detail = kb.inspect_id(id)?;
630 println!("{}", serde_json::to_string_pretty(&detail)?);
631 }
632 },
633 Commands::Approve { chunk_id } => {
634 kb.approve(&chunk_id)?;
635 println!("approved");
636 }
637 Commands::Archive { chunk_id, reason } => {
638 kb.archive(&chunk_id, &reason)?;
639 println!("archived");
640 }
641 Commands::Invalidate { chunk_id, reason } => {
642 kb.invalidate(&chunk_id, &reason)?;
643 println!("invalidated");
644 }
645 Commands::Restore { chunk_id } => {
646 kb.restore(&chunk_id)?;
647 println!("restored");
648 }
649 Commands::MatureSpark { spark_id, to } => {
650 kb.mature_spark(&spark_id, &to)?;
651 println!("matured");
652 }
653 Commands::PromoteSpark { spark_id, to } => {
654 let id = kb.promote_spark(&spark_id, &to)?;
655 println!("{id}");
656 }
657 Commands::DropSpark { spark_id, reason } => {
658 kb.drop_spark(&spark_id, &reason)?;
659 println!("dropped");
660 }
661 Commands::Metrics { action } => match action {
662 MetricsAction::Snapshot => {
663 let kpis = kb.write_metric_snapshot()?;
664 println!("{}", serde_json::to_string_pretty(&kpis)?);
665 }
666 },
667 Commands::Vacuum => {
668 let (before, after) = kb.storage.vacuum()?;
669 let mb = |b: i64| b as f64 / 1_048_576.0;
670 println!(
671 "vacuumed: {:.2} MB → {:.2} MB (reclaimed {:.2} MB)",
672 mb(before),
673 mb(after),
674 mb(before - after)
675 );
676 }
677 Commands::RepairTraces { dry_run } => {
678 let r = kb.repair_traces(dry_run)?;
679 let tag = if dry_run {
680 "[dry-run] would repair"
681 } else {
682 "repaired"
683 };
684 println!(
685 "{tag}: deleted {} false daemon selection events, retired {} orphaned open logs, \
686 selected_count {} → {}",
687 r.daemon_events_deleted, r.open_logs_retired, r.selected_before, r.selected_after
688 );
689 }
690 Commands::RecallEval { labels, k, save } => {
691 let text = std::fs::read_to_string(&labels)
692 .map_err(|e| anyhow::anyhow!("read labels {}: {e}", labels.display()))?;
693 let mut n = 0usize;
694 let (mut sum_p1, mut sum_recall, mut sum_mrr, mut sum_ndcg) = (0.0, 0.0, 0.0, 0.0);
695 let mut misses: Vec<serde_json::Value> = Vec::new();
696 for (lineno, line) in text.lines().enumerate() {
697 let line = line.trim();
698 if line.is_empty() {
699 continue;
700 }
701 let row: serde_json::Value = serde_json::from_str(line)
702 .map_err(|e| anyhow::anyhow!("labels line {}: {e}", lineno + 1))?;
703 let query = row.get("query").and_then(|v| v.as_str()).unwrap_or("");
704 let relevant: std::collections::HashSet<String> = row
705 .get("relevant_ids")
706 .and_then(|v| v.as_array())
707 .map(|a| {
708 a.iter()
709 .filter_map(|v| v.as_str().map(str::to_string))
710 .collect()
711 })
712 .unwrap_or_default();
713 if query.is_empty() || relevant.is_empty() {
714 continue;
715 }
716 let result = kb.recall(RecallParams {
717 query,
718 budget: 100_000,
719 trace: false,
720 top: Some(k),
721 source: "cli",
722 ..Default::default()
723 })?;
724 let ranked: Vec<String> = result
725 .knowledge
726 .iter()
727 .filter_map(|c| c.get("id").and_then(|v| v.as_str()).map(str::to_string))
728 .collect();
729 let (p1, recall_k, mrr, ndcg) = recall_metrics(&ranked, &relevant, k);
730 sum_p1 += p1;
731 sum_recall += recall_k;
732 sum_mrr += mrr;
733 sum_ndcg += ndcg;
734 n += 1;
735 if recall_k == 0.0 {
738 misses.push(json!({
739 "query": query,
740 "relevant_ids": relevant.iter().cloned().collect::<Vec<_>>(),
741 "got_top_k": ranked,
742 }));
743 }
744 }
745 if n == 0 {
746 return Err(anyhow::anyhow!(
747 "no usable labeled queries (need lines with non-empty query + relevant_ids)"
748 ));
749 }
750 let nf = n as f64;
751 let out = json!({
752 "queries": n,
753 "k": k,
754 "p_at_1": (sum_p1 / nf * 1000.0).round() / 1000.0,
755 "recall_at_k": (sum_recall / nf * 1000.0).round() / 1000.0,
756 "mrr": (sum_mrr / nf * 1000.0).round() / 1000.0,
757 "ndcg_at_k": (sum_ndcg / nf * 1000.0).round() / 1000.0,
758 "params": kb.recall_weights(),
761 "misses": misses,
762 });
763 if save {
764 let mut summary = out.clone();
767 if let Some(o) = summary.as_object_mut() {
768 o.remove("misses");
769 o.insert("ts".to_string(), json!(crate::utils::utc_now_iso()));
770 }
771 let path = crate::paths::logs_dir().join("eval_runs.jsonl");
772 if let Some(parent) = path.parent() {
773 let _ = std::fs::create_dir_all(parent);
774 }
775 if let Ok(mut f) = std::fs::OpenOptions::new()
776 .create(true)
777 .append(true)
778 .open(&path)
779 {
780 use std::io::Write;
781 let _ = writeln!(f, "{}", serde_json::to_string(&summary)?);
782 eprintln!("eval run summary appended to {}", path.display());
783 }
784 }
785 println!("{}", serde_json::to_string_pretty(&out)?);
786 }
787 Commands::Web {
788 bind,
789 port,
790 no_token,
791 allow_remote,
792 } => {
793 let loopback = crate::web::is_loopback(&bind);
794 if !loopback && !allow_remote {
795 anyhow::bail!(
796 "refusing to bind non-loopback address {bind} without --allow-remote \
797 (this exposes the knowledge base to the network)"
798 );
799 }
800 if !loopback && no_token {
801 anyhow::bail!(
802 "--no-token cannot be combined with a non-loopback bind: a network-exposed \
803 server must keep the auth token to gate reads and writes"
804 );
805 }
806 crate::web::serve(kb, &bind, port, !no_token)?;
807 }
808 Commands::Mcp
809 | Commands::Install
810 | Commands::Uninstall { .. }
811 | Commands::Migrate
812 | Commands::Upgrade { .. }
813 | Commands::Daemon { .. }
814 | Commands::Backup { .. }
815 | Commands::Hook { .. } => unreachable!(),
816 }
817 Ok(())
818}
819
820pub(crate) fn recall_metrics(
825 ranked: &[String],
826 relevant: &std::collections::HashSet<String>,
827 k: usize,
828) -> (f64, f64, f64, f64) {
829 let topk = &ranked[..ranked.len().min(k)];
830 let p_at_1 = topk
831 .first()
832 .map(|id| relevant.contains(id) as u8 as f64)
833 .unwrap_or(0.0);
834 let hits = topk.iter().filter(|id| relevant.contains(*id)).count();
835 let recall_at_k = hits as f64 / relevant.len() as f64;
836 let mrr = ranked
838 .iter()
839 .position(|id| relevant.contains(id))
840 .map(|pos| 1.0 / (pos as f64 + 1.0))
841 .unwrap_or(0.0);
842 let dcg: f64 = topk
844 .iter()
845 .enumerate()
846 .filter(|(_, id)| relevant.contains(*id))
847 .map(|(i, _)| 1.0 / ((i as f64 + 2.0).log2()))
848 .sum();
849 let ideal_hits = relevant.len().min(k);
850 let idcg: f64 = (0..ideal_hits)
851 .map(|i| 1.0 / ((i as f64 + 2.0).log2()))
852 .sum();
853 let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 };
854 (p_at_1, recall_at_k, mrr, ndcg)
855}
856
857#[cfg(test)]
858mod metric_tests {
859 use super::recall_metrics;
860 use std::collections::HashSet;
861
862 fn rel(ids: &[&str]) -> HashSet<String> {
863 ids.iter().map(|s| s.to_string()).collect()
864 }
865 fn ranked(ids: &[&str]) -> Vec<String> {
866 ids.iter().map(|s| s.to_string()).collect()
867 }
868
869 #[test]
870 fn perfect_ranking_scores_one() {
871 let (p1, r, mrr, ndcg) = recall_metrics(&ranked(&["a", "b", "x"]), &rel(&["a", "b"]), 5);
872 assert!((p1 - 1.0).abs() < 1e-9);
873 assert!((r - 1.0).abs() < 1e-9);
874 assert!((mrr - 1.0).abs() < 1e-9);
875 assert!((ndcg - 1.0).abs() < 1e-9);
876 }
877
878 #[test]
879 fn missed_first_lowers_p1_and_mrr() {
880 let (p1, r, mrr, _ndcg) = recall_metrics(&ranked(&["x", "a"]), &rel(&["a"]), 5);
882 assert_eq!(p1, 0.0);
883 assert!((mrr - 0.5).abs() < 1e-9);
884 assert!((r - 1.0).abs() < 1e-9);
885 }
886
887 #[test]
888 fn k_cutoff_limits_recall() {
889 let (_p1, r, _mrr, ndcg) = recall_metrics(&ranked(&["x", "a"]), &rel(&["a"]), 1);
891 assert_eq!(r, 0.0);
892 assert_eq!(ndcg, 0.0);
893 }
894
895 #[test]
896 fn no_hits_is_all_zero() {
897 let (p1, r, mrr, ndcg) = recall_metrics(&ranked(&["x", "y"]), &rel(&["a"]), 5);
898 assert_eq!((p1, r, mrr, ndcg), (0.0, 0.0, 0.0, 0.0));
899 }
900}