Skip to main content

innate_core/
cli.rs

1//! CLI commands — thin wrapper over KnowledgeBase.
2
3use 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    /// Search the knowledge base
30    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        /// Dependency expansion: false (default) | direct | closure
41        #[arg(long, default_value = "false")]
42        expand_deps: String,
43        /// Allow Refiner to trim blocks that don't fit the budget
44        #[arg(long)]
45        allow_trim: bool,
46        /// Refine mode written to usage_trace: off (default) | trim | adapt
47        #[arg(long, default_value = "off")]
48        refine_mode: String,
49        /// Event source written to usage_trace (mcp | sdk | cli | hook | daemon | augmented)
50        #[arg(long, default_value = "cli")]
51        source: String,
52        /// Relevance gate: drop candidates whose fused score is below this value.
53        /// Keeps always-on hooks high-frequency without injecting noise.
54        #[arg(long)]
55        min_score: Option<f64>,
56        /// Session trace: open a trace for later record-correlation but record no
57        /// `selected`/`retrieved` events. For callers (e.g. the daemon) that do
58        /// not inject the recalled knowledge into a model context.
59        #[arg(long)]
60        session: bool,
61        /// Deep recall: rerank the shortlist with the configured LLM (offline,
62        /// latency-tolerant). No-op without an LLM; never used by hooks.
63        #[arg(long)]
64        rerank: bool,
65    },
66    /// Critic: judge how much footing exists for a candidate in a situation.
67    /// Returns {valence, strength, tier, flagged_points} — never an answer.
68    Appraise {
69        /// Explicit question / instruction (optional).
70        #[arg(long, default_value = "")]
71        query: String,
72        /// Current or last error text.
73        #[arg(long)]
74        last_error: Option<String>,
75        /// Recent actions, comma-separated.
76        #[arg(long)]
77        recent_actions: Option<String>,
78        /// Task stage (e.g. merge, implement, review).
79        #[arg(long)]
80        stage: Option<String>,
81        /// File type / path summary in scope.
82        #[arg(long)]
83        file_context: Option<String>,
84        /// Candidate answer under judgement (folded into resonance, sanitized, never echoed).
85        #[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    /// Close a trace with outcome
97    Record {
98        trace_id: String,
99        #[arg(long)]
100        query: Option<String>,
101        #[arg(long)]
102        outcome: Option<String>,
103        /// Comma-separated chunk ids. An explicit empty value means "known none".
104        #[arg(long)]
105        used: Option<String>,
106        #[arg(long, default_value = "explicit")]
107        used_attribution: String,
108        /// Treat --used as partial attribution; omitted selected chunks are not penalized.
109        #[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        /// Explicit feedback: up or down (applied to --used chunks if provided)
120        #[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        /// This trace came from an `appraise` whose caution was heeded — the
133        /// action was avoided, so the outcome is counterfactual and must NOT
134        /// count toward the critic's calibration (provenance=counterfactual_censored).
135        #[arg(long)]
136        verdict_heeded: bool,
137    },
138    /// Add a knowledge chunk
139    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        /// Declare a dependency on another chunk id (repeatable).
152        #[arg(long = "depends-on")]
153        depends_on: Vec<String>,
154        /// Dependency kind for --depends-on: hard (fail-closed) or soft (bonus).
155        #[arg(long, default_value = "hard")]
156        dep_kind: String,
157    },
158    /// Capture a spark (idea)
159    Spark {
160        content: String,
161        #[arg(long)]
162        trigger: Option<String>,
163    },
164    /// Distil logs + curate
165    Evolve {
166        #[arg(long, default_value = "manual")]
167        trigger: String,
168        /// Rebuild embeddings for chunks with embed_version=0 or < meta.embed_version
169        #[arg(long)]
170        rebuild_embeddings: bool,
171    },
172    /// Health check — no arg = library summary; chunk_id or trace_id = detail view
173    Inspect { id: Option<String> },
174    /// Approve a pending chunk
175    Approve { chunk_id: String },
176    /// Archive a chunk
177    Archive {
178        chunk_id: String,
179        #[arg(long, default_value = "stale")]
180        reason: String,
181    },
182    /// Invalidate a chunk
183    Invalidate {
184        chunk_id: String,
185        #[arg(long, default_value = "")]
186        reason: String,
187    },
188    /// Restore an archived chunk
189    Restore { chunk_id: String },
190    /// Mature a spark
191    MatureSpark { spark_id: String, to: String },
192    /// Promote a spark to knowledge
193    PromoteSpark {
194        spark_id: String,
195        #[arg(long, default_value = "note")]
196        to: String,
197    },
198    /// Drop a spark
199    DropSpark {
200        spark_id: String,
201        #[arg(long, default_value = "")]
202        reason: String,
203    },
204    /// Backup the database to Cloudflare R2
205    Backup {
206        #[command(subcommand)]
207        action: BackupCommands,
208    },
209    /// Interactive setup wizard — configure agents to use Innate MCP server
210    Install,
211    /// Remove Innate from all configured agents and PATH
212    Uninstall {
213        /// Skip confirmation prompts
214        #[arg(long, short = 'y')]
215        yes: bool,
216        /// Also delete knowledge data (~/.innate/). Cannot be undone.
217        #[arg(long)]
218        purge_data: bool,
219    },
220    /// Upgrade database schema to current version
221    Migrate,
222    /// Reclaim disk space: checkpoint the WAL and VACUUM the database
223    Vacuum,
224    /// Repair pre-fix trace pollution: drop false daemon `selected` events,
225    /// recompute `selected_count`, and retire orphaned `open` episodic logs.
226    RepairTraces {
227        /// Report what would change without writing.
228        #[arg(long)]
229        dry_run: bool,
230    },
231    /// Measure recall quality on a labeled set using the configured embedding
232    /// provider. Reads JSONL ({"query": "...", "relevant_ids": ["id", ...]}) and
233    /// reports P@1 / Recall@k / MRR / nDCG@k. The honest way to know whether
234    /// retrieval accuracy is actually a problem before tuning weights.
235    RecallEval {
236        /// Path to a JSONL labels file (one {query, relevant_ids} object per line).
237        labels: PathBuf,
238        /// Cutoff k for Recall@k / nDCG@k and the recall `top` (default 10).
239        #[arg(long, default_value = "10")]
240        k: usize,
241    },
242    /// Upgrade the innate binary to the latest (or specified) release
243    Upgrade {
244        /// Install this specific version, e.g. 0.3.0 or v0.3.0 (default: latest)
245        #[arg(long, value_name = "VERSION")]
246        version: Option<String>,
247        /// Only report whether an upgrade is available; do not install
248        #[arg(long)]
249        check: bool,
250    },
251    /// Daemon control (Linux only)
252    Daemon {
253        #[command(subcommand)]
254        action: DaemonCommands,
255    },
256    /// Start MCP stdio server
257    Mcp,
258    /// Start a local web UI to view and govern the knowledge base
259    Web {
260        /// Address to bind (localhost only by default; exposing beyond is unsafe)
261        #[arg(long, default_value = "127.0.0.1")]
262        bind: String,
263        /// Port to listen on
264        #[arg(long, default_value_t = 8788)]
265        port: u16,
266        /// Disable the governance auth token (NOT recommended; leaves writes unauthenticated)
267        #[arg(long)]
268        no_token: bool,
269        /// Required to bind a non-loopback address. Exposes the knowledge base to
270        /// the network; the auth token then gates reads as well as writes.
271        #[arg(long)]
272        allow_remote: bool,
273    },
274    /// Agent hook handlers (called by agent hooks; reads payload from stdin)
275    Hook {
276        #[command(subcommand)]
277        action: HookCommands,
278    },
279}
280
281pub fn run() -> anyhow::Result<()> {
282    let cli = Cli::parse();
283    // Create the ~/.innate subdirectory layout and migrate any legacy flat files
284    // before any path is resolved.
285    crate::paths::ensure_layout();
286    let db_path = cli.db.unwrap_or_else(default_db);
287
288    if let Commands::Mcp = &cli.command {
289        return crate::mcp::run_server(db_path);
290    }
291
292    if let Commands::Install = &cli.command {
293        return crate::install::run_install();
294    }
295
296    if let Commands::Uninstall { yes, purge_data } = &cli.command {
297        return crate::install::run_uninstall(*yes, *purge_data);
298    }
299
300    if let Commands::Migrate = &cli.command {
301        let applied = crate::migrate::run_migrations(&db_path)?;
302        if applied.is_empty() {
303            println!("already at 4.14 — nothing to do");
304        } else {
305            for step in &applied {
306                println!("  applied: {step}");
307            }
308            println!("migration complete");
309        }
310        return Ok(());
311    }
312
313    if let Commands::Daemon { action } = &cli.command {
314        return crate::daemon::run_command(action, &db_path);
315    }
316
317    if let Commands::Backup { action } = &cli.command {
318        return crate::backup::run_command(action, &db_path);
319    }
320
321    if let Commands::Upgrade { version, check } = &cli.command {
322        return crate::upgrade::run_upgrade(version.as_deref(), &db_path, *check);
323    }
324
325    if let Commands::Hook { action } = &cli.command {
326        return crate::hook::run_command(action, &db_path);
327    }
328
329    let kb = crate::open_kb(&db_path)?;
330
331    match cli.command {
332        Commands::Recall {
333            query,
334            budget,
335            top,
336            format,
337            include_sparks,
338            expand_deps,
339            allow_trim,
340            refine_mode,
341            source,
342            min_score,
343            session,
344            rerank,
345        } => {
346            let result = kb.recall(RecallParams {
347                query: &query,
348                budget,
349                trace: true,
350                include_sparks,
351                top,
352                source: &source,
353                expand_deps: &expand_deps,
354                allow_trim,
355                refine_mode: &refine_mode,
356                min_score,
357                session_only: session,
358                rerank,
359            })?;
360            match format.as_str() {
361                "json" => println!(
362                    "{}",
363                    serde_json::to_string_pretty(&json!({
364                        "trace_id": result.trace_id,
365                        "knowledge": result.knowledge,
366                        "sparks": result.sparks,
367                        "empty": result.empty,
368                    }))?
369                ),
370                "prompt" => {
371                    for chunk in &result.knowledge {
372                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
373                        println!("{content}\n---");
374                    }
375                    // metadata at end (§九 CLI contract)
376                    println!("<!-- innate_trace_id: {} -->", result.trace_id);
377                    println!(
378                        "<!-- innate_selected: {} -->",
379                        result
380                            .knowledge
381                            .iter()
382                            .filter_map(|c| c.get("id").and_then(|v| v.as_str()))
383                            .collect::<Vec<_>>()
384                            .join(",")
385                    );
386                }
387                _ => {
388                    for chunk in &result.knowledge {
389                        let id = chunk.get("id").and_then(|v| v.as_str()).unwrap_or("?");
390                        let content = chunk.get("content").and_then(|v| v.as_str()).unwrap_or("");
391                        let conf = chunk
392                            .get("confidence")
393                            .and_then(|v| v.as_f64())
394                            .unwrap_or(0.5);
395                        println!("[{id}] (conf={conf:.2})\n{content}\n");
396                    }
397                    if result.empty {
398                        println!("(no results)");
399                    }
400                }
401            }
402        }
403        Commands::Appraise {
404            query,
405            last_error,
406            recent_actions,
407            stage,
408            file_context,
409            candidate,
410            top,
411            min_strength,
412            source,
413            format,
414        } => {
415            let actions: Vec<String> = recent_actions
416                .as_deref()
417                .map(|raw| {
418                    raw.split(',')
419                        .map(str::trim)
420                        .filter(|a| !a.is_empty())
421                        .map(str::to_string)
422                        .collect()
423                })
424                .unwrap_or_default();
425            let situation = Situation {
426                query: (!query.is_empty()).then_some(query.as_str()),
427                last_error: last_error.as_deref(),
428                recent_actions: &actions,
429                stage: stage.as_deref(),
430                file_context: file_context.as_deref(),
431            };
432            let verdict = kb.appraise(AppraiseParams {
433                situation,
434                candidate: candidate.as_deref(),
435                min_strength,
436                top,
437                trace: true,
438                source: &source,
439            })?;
440            match format.as_str() {
441                "text" => {
442                    println!("ℹ {APPRAISE_ADVISORY}");
443                    if verdict.abstained {
444                        println!(
445                            "ABSTAIN reason={:?} strength={:.3} trace_id={}",
446                            verdict.abstain_reason, verdict.strength, verdict.trace_id
447                        );
448                    } else {
449                        println!(
450                            "valence={:?} tier={:?} strength={:.3} confidence={:.3} dispersion={:.3} trace_id={}",
451                            verdict.valence, verdict.tier, verdict.strength,
452                            verdict.confidence, verdict.dispersion, verdict.trace_id
453                        );
454                    }
455                    for fp in &verdict.flagged_points {
456                        println!(
457                            "  ⚠ [{}] {} (s={:.3})",
458                            fp.chunk_id, fp.summary, fp.strength
459                        );
460                    }
461                }
462                _ => println!(
463                    "{}",
464                    serde_json::to_string_pretty(&json!({
465                        "advisory": APPRAISE_ADVISORY,
466                        "valence": verdict.valence,
467                        "strength": verdict.strength,
468                        "tier": verdict.tier,
469                        "confidence": verdict.confidence,
470                        "dispersion": verdict.dispersion,
471                        "abstained": verdict.abstained,
472                        "abstain_reason": verdict.abstain_reason,
473                        "flagged_points": verdict.flagged_points,
474                        "contributors": verdict.contributors,
475                        "trace_id": verdict.trace_id,
476                    }))?
477                ),
478            }
479        }
480        Commands::Record {
481            trace_id,
482            query,
483            outcome,
484            used,
485            used_attribution,
486            used_partial,
487            output,
488            output_summary,
489            nomination,
490            source,
491            feedback,
492            feedback_kind,
493            feedback_actor,
494            feedback_reason,
495            task_state,
496            priority,
497            verdict_heeded,
498        } => {
499            let used_ids = used.as_deref().map(|raw| {
500                raw.split(',')
501                    .map(str::trim)
502                    .filter(|id| !id.is_empty())
503                    .map(str::to_string)
504                    .collect::<Vec<_>>()
505            });
506            let used_ref = used_ids.as_deref();
507            // Per §二·五B: trace-level "up" applies only to explicitly used chunks.
508            let (fb_up, fb_down): (Option<Vec<String>>, Option<Vec<String>>) =
509                match feedback.as_deref() {
510                    Some("up") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
511                        (used_ids.clone(), None)
512                    }
513                    Some("down") if used_ids.as_ref().is_some_and(|ids| !ids.is_empty()) => {
514                        (None, used_ids.clone())
515                    }
516                    Some("up") => (None, None), // no used chunks — ignore per design
517                    Some("down") => (None, None),
518                    _ => (None, None),
519                };
520            let fb_up_ref = fb_up.as_deref();
521            let fb_down_ref = fb_down.as_deref();
522            kb.record(RecordParams {
523                trace_id: &trace_id,
524                query: query.as_deref(),
525                output: output.as_deref(),
526                output_summary: output_summary.as_deref(),
527                outcome: outcome.as_deref(),
528                used: used_ref,
529                used_attribution: &used_attribution,
530                used_complete: Some(!used_partial),
531                feedback_up: fb_up_ref,
532                feedback_down: fb_down_ref,
533                feedback_kind: &feedback_kind,
534                feedback_actor: feedback_actor.as_deref(),
535                feedback_reason: feedback_reason.as_deref(),
536                nomination: nomination.as_deref(),
537                priority,
538                task_state: task_state.as_deref(),
539                source: &source,
540                verdict_heeded,
541            })?;
542            println!("recorded");
543        }
544        Commands::Add {
545            content,
546            kind,
547            trigger,
548            anti_trigger,
549            source,
550            skill_name,
551            depends_on,
552            dep_kind,
553        } => {
554            // If kind=skill and content is a readable file path, load its content.
555            let content = if kind == "skill" {
556                let p = std::path::Path::new(&content);
557                if p.exists() && p.is_file() {
558                    std::fs::read_to_string(p).map_err(|e| {
559                        anyhow::anyhow!("Failed to read skill file {}: {e}", p.display())
560                    })?
561                } else {
562                    content
563                }
564            } else {
565                content
566            };
567            let deps: Vec<(String, String)> = depends_on
568                .iter()
569                .map(|d| (d.clone(), dep_kind.clone()))
570                .collect();
571            let id = kb.add_with_deps(
572                &content,
573                &kind,
574                trigger.as_deref(),
575                anti_trigger.as_deref(),
576                &source,
577                skill_name.as_deref(),
578                &deps,
579            )?;
580            println!("{id}");
581        }
582        Commands::Spark { content, trigger } => {
583            let id = kb.spark(&content, trigger.as_deref(), None)?;
584            println!("{id}");
585        }
586        Commands::Evolve {
587            trigger,
588            rebuild_embeddings,
589        } => {
590            if rebuild_embeddings {
591                let rebuilt = kb.rebuild_embeddings()?;
592                let report = kb.evolve(&trigger)?;
593                println!(
594                    "{}",
595                    serde_json::to_string_pretty(&json!({
596                        "rebuilt_embeddings": rebuilt,
597                        "evolve": report
598                    }))?
599                );
600            } else {
601                let report = kb.evolve(&trigger)?;
602                println!("{}", serde_json::to_string_pretty(&report)?);
603            }
604        }
605        Commands::Inspect { id } => match id.as_deref() {
606            None => {
607                let info = kb.inspect()?;
608                println!("{}", serde_json::to_string_pretty(&info)?);
609            }
610            Some(id) => {
611                let detail = kb.inspect_id(id)?;
612                println!("{}", serde_json::to_string_pretty(&detail)?);
613            }
614        },
615        Commands::Approve { chunk_id } => {
616            kb.approve(&chunk_id)?;
617            println!("approved");
618        }
619        Commands::Archive { chunk_id, reason } => {
620            kb.archive(&chunk_id, &reason)?;
621            println!("archived");
622        }
623        Commands::Invalidate { chunk_id, reason } => {
624            kb.invalidate(&chunk_id, &reason)?;
625            println!("invalidated");
626        }
627        Commands::Restore { chunk_id } => {
628            kb.restore(&chunk_id)?;
629            println!("restored");
630        }
631        Commands::MatureSpark { spark_id, to } => {
632            kb.mature_spark(&spark_id, &to)?;
633            println!("matured");
634        }
635        Commands::PromoteSpark { spark_id, to } => {
636            let id = kb.promote_spark(&spark_id, &to)?;
637            println!("{id}");
638        }
639        Commands::DropSpark { spark_id, reason } => {
640            kb.drop_spark(&spark_id, &reason)?;
641            println!("dropped");
642        }
643        Commands::Vacuum => {
644            let (before, after) = kb.storage.vacuum()?;
645            let mb = |b: i64| b as f64 / 1_048_576.0;
646            println!(
647                "vacuumed: {:.2} MB → {:.2} MB (reclaimed {:.2} MB)",
648                mb(before),
649                mb(after),
650                mb(before - after)
651            );
652        }
653        Commands::RepairTraces { dry_run } => {
654            let r = kb.repair_traces(dry_run)?;
655            let tag = if dry_run {
656                "[dry-run] would repair"
657            } else {
658                "repaired"
659            };
660            println!(
661                "{tag}: deleted {} false daemon selection events, retired {} orphaned open logs, \
662                 selected_count {} → {}",
663                r.daemon_events_deleted, r.open_logs_retired, r.selected_before, r.selected_after
664            );
665        }
666        Commands::RecallEval { labels, k } => {
667            let text = std::fs::read_to_string(&labels)
668                .map_err(|e| anyhow::anyhow!("read labels {}: {e}", labels.display()))?;
669            let mut n = 0usize;
670            let (mut sum_p1, mut sum_recall, mut sum_mrr, mut sum_ndcg) = (0.0, 0.0, 0.0, 0.0);
671            for (lineno, line) in text.lines().enumerate() {
672                let line = line.trim();
673                if line.is_empty() {
674                    continue;
675                }
676                let row: serde_json::Value = serde_json::from_str(line)
677                    .map_err(|e| anyhow::anyhow!("labels line {}: {e}", lineno + 1))?;
678                let query = row.get("query").and_then(|v| v.as_str()).unwrap_or("");
679                let relevant: std::collections::HashSet<String> = row
680                    .get("relevant_ids")
681                    .and_then(|v| v.as_array())
682                    .map(|a| {
683                        a.iter()
684                            .filter_map(|v| v.as_str().map(str::to_string))
685                            .collect()
686                    })
687                    .unwrap_or_default();
688                if query.is_empty() || relevant.is_empty() {
689                    continue;
690                }
691                let result = kb.recall(RecallParams {
692                    query,
693                    budget: 100_000,
694                    trace: false,
695                    top: Some(k),
696                    source: "cli",
697                    ..Default::default()
698                })?;
699                let ranked: Vec<String> = result
700                    .knowledge
701                    .iter()
702                    .filter_map(|c| c.get("id").and_then(|v| v.as_str()).map(str::to_string))
703                    .collect();
704                let (p1, recall_k, mrr, ndcg) = recall_metrics(&ranked, &relevant, k);
705                sum_p1 += p1;
706                sum_recall += recall_k;
707                sum_mrr += mrr;
708                sum_ndcg += ndcg;
709                n += 1;
710            }
711            if n == 0 {
712                return Err(anyhow::anyhow!(
713                    "no usable labeled queries (need lines with non-empty query + relevant_ids)"
714                ));
715            }
716            let nf = n as f64;
717            let out = json!({
718                "queries": n,
719                "k": k,
720                "p_at_1": (sum_p1 / nf * 1000.0).round() / 1000.0,
721                "recall_at_k": (sum_recall / nf * 1000.0).round() / 1000.0,
722                "mrr": (sum_mrr / nf * 1000.0).round() / 1000.0,
723                "ndcg_at_k": (sum_ndcg / nf * 1000.0).round() / 1000.0,
724            });
725            println!("{}", serde_json::to_string_pretty(&out)?);
726        }
727        Commands::Web {
728            bind,
729            port,
730            no_token,
731            allow_remote,
732        } => {
733            let loopback = crate::web::is_loopback(&bind);
734            if !loopback && !allow_remote {
735                anyhow::bail!(
736                    "refusing to bind non-loopback address {bind} without --allow-remote \
737                     (this exposes the knowledge base to the network)"
738                );
739            }
740            if !loopback && no_token {
741                anyhow::bail!(
742                    "--no-token cannot be combined with a non-loopback bind: a network-exposed \
743                     server must keep the auth token to gate reads and writes"
744                );
745            }
746            crate::web::serve(kb, &bind, port, !no_token)?;
747        }
748        Commands::Mcp
749        | Commands::Install
750        | Commands::Uninstall { .. }
751        | Commands::Migrate
752        | Commands::Upgrade { .. }
753        | Commands::Daemon { .. }
754        | Commands::Backup { .. }
755        | Commands::Hook { .. } => unreachable!(),
756    }
757    Ok(())
758}
759
760/// Pure ranking metrics for a single query (part b — measurable recall quality).
761/// `ranked` is the recalled chunk ids in rank order; `relevant` the labeled
762/// ground-truth set. Returns `(p_at_1, recall_at_k, mrr, ndcg_at_k)`, each in
763/// `[0,1]`. Kept IO-free so it is unit-testable without a database.
764pub(crate) fn recall_metrics(
765    ranked: &[String],
766    relevant: &std::collections::HashSet<String>,
767    k: usize,
768) -> (f64, f64, f64, f64) {
769    let topk = &ranked[..ranked.len().min(k)];
770    let p_at_1 = topk
771        .first()
772        .map(|id| relevant.contains(id) as u8 as f64)
773        .unwrap_or(0.0);
774    let hits = topk.iter().filter(|id| relevant.contains(*id)).count();
775    let recall_at_k = hits as f64 / relevant.len() as f64;
776    // MRR over the full ranking: reciprocal rank of the first relevant hit.
777    let mrr = ranked
778        .iter()
779        .position(|id| relevant.contains(id))
780        .map(|pos| 1.0 / (pos as f64 + 1.0))
781        .unwrap_or(0.0);
782    // nDCG@k with binary relevance. IDCG = ideal placement of min(|rel|, k) hits.
783    let dcg: f64 = topk
784        .iter()
785        .enumerate()
786        .filter(|(_, id)| relevant.contains(*id))
787        .map(|(i, _)| 1.0 / ((i as f64 + 2.0).log2()))
788        .sum();
789    let ideal_hits = relevant.len().min(k);
790    let idcg: f64 = (0..ideal_hits)
791        .map(|i| 1.0 / ((i as f64 + 2.0).log2()))
792        .sum();
793    let ndcg = if idcg > 0.0 { dcg / idcg } else { 0.0 };
794    (p_at_1, recall_at_k, mrr, ndcg)
795}
796
797#[cfg(test)]
798mod metric_tests {
799    use super::recall_metrics;
800    use std::collections::HashSet;
801
802    fn rel(ids: &[&str]) -> HashSet<String> {
803        ids.iter().map(|s| s.to_string()).collect()
804    }
805    fn ranked(ids: &[&str]) -> Vec<String> {
806        ids.iter().map(|s| s.to_string()).collect()
807    }
808
809    #[test]
810    fn perfect_ranking_scores_one() {
811        let (p1, r, mrr, ndcg) = recall_metrics(&ranked(&["a", "b", "x"]), &rel(&["a", "b"]), 5);
812        assert!((p1 - 1.0).abs() < 1e-9);
813        assert!((r - 1.0).abs() < 1e-9);
814        assert!((mrr - 1.0).abs() < 1e-9);
815        assert!((ndcg - 1.0).abs() < 1e-9);
816    }
817
818    #[test]
819    fn missed_first_lowers_p1_and_mrr() {
820        // Relevant item is at rank 2 → P@1=0, MRR=0.5, Recall@5=1.0.
821        let (p1, r, mrr, _ndcg) = recall_metrics(&ranked(&["x", "a"]), &rel(&["a"]), 5);
822        assert_eq!(p1, 0.0);
823        assert!((mrr - 0.5).abs() < 1e-9);
824        assert!((r - 1.0).abs() < 1e-9);
825    }
826
827    #[test]
828    fn k_cutoff_limits_recall() {
829        // Only the first id counts at k=1; the relevant one at rank 2 is excluded.
830        let (_p1, r, _mrr, ndcg) = recall_metrics(&ranked(&["x", "a"]), &rel(&["a"]), 1);
831        assert_eq!(r, 0.0);
832        assert_eq!(ndcg, 0.0);
833    }
834
835    #[test]
836    fn no_hits_is_all_zero() {
837        let (p1, r, mrr, ndcg) = recall_metrics(&ranked(&["x", "y"]), &rel(&["a"]), 5);
838        assert_eq!((p1, r, mrr, ndcg), (0.0, 0.0, 0.0, 0.0));
839    }
840}