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    /// Observability: write a state-KPI snapshot now (for inspect().trends week-over-week)
223    Metrics {
224        #[command(subcommand)]
225        action: MetricsAction,
226    },
227    /// Reclaim disk space: checkpoint the WAL and VACUUM the database
228    Vacuum,
229    /// Repair pre-fix trace pollution: drop false daemon `selected` events,
230    /// recompute `selected_count`, and retire orphaned `open` episodic logs.
231    RepairTraces {
232        /// Report what would change without writing.
233        #[arg(long)]
234        dry_run: bool,
235    },
236    /// Measure recall quality on a labeled set using the configured embedding
237    /// provider. Reads JSONL ({"query": "...", "relevant_ids": ["id", ...]}) and
238    /// reports P@1 / Recall@k / MRR / nDCG@k. The honest way to know whether
239    /// retrieval accuracy is actually a problem before tuning weights.
240    RecallEval {
241        /// Path to a JSONL labels file (one {query, relevant_ids} object per line).
242        labels: PathBuf,
243        /// Cutoff k for Recall@k / nDCG@k and the recall `top` (default 10).
244        #[arg(long, default_value = "10")]
245        k: usize,
246        /// Append the run summary (metrics + params + ts) to ~/.innate/logs/eval_runs.jsonl
247        /// so offline eval can be compared against online metrics over time.
248        #[arg(long)]
249        save: bool,
250    },
251    /// Upgrade the innate binary to the latest (or specified) release
252    Upgrade {
253        /// Install this specific version, e.g. 0.3.0 or v0.3.0 (default: latest)
254        #[arg(long, value_name = "VERSION")]
255        version: Option<String>,
256        /// Only report whether an upgrade is available; do not install
257        #[arg(long)]
258        check: bool,
259    },
260    /// Daemon control (Linux only)
261    Daemon {
262        #[command(subcommand)]
263        action: DaemonCommands,
264    },
265    /// Start MCP stdio server
266    Mcp,
267    /// Start a local web UI to view and govern the knowledge base
268    Web {
269        /// Address to bind (localhost only by default; exposing beyond is unsafe)
270        #[arg(long, default_value = "127.0.0.1")]
271        bind: String,
272        /// Port to listen on
273        #[arg(long, default_value_t = 8788)]
274        port: u16,
275        /// Disable the governance auth token (NOT recommended; leaves writes unauthenticated)
276        #[arg(long)]
277        no_token: bool,
278        /// Required to bind a non-loopback address. Exposes the knowledge base to
279        /// the network; the auth token then gates reads as well as writes.
280        #[arg(long)]
281        allow_remote: bool,
282    },
283    /// Agent hook handlers (called by agent hooks; reads payload from stdin)
284    Hook {
285        #[command(subcommand)]
286        action: HookCommands,
287    },
288}
289
290#[derive(clap::Subcommand)]
291pub enum MetricsAction {
292    /// Write a state-KPI snapshot row now (debt ratio, pending age, success rates …).
293    Snapshot,
294}
295
296pub fn run() -> anyhow::Result<()> {
297    let cli = Cli::parse();
298    // Create the ~/.innate subdirectory layout and migrate any legacy flat files
299    // before any path is resolved.
300    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                    // metadata at end (§九 CLI contract)
394                    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            // Per §二·五B: trace-level "up" applies only to explicitly used chunks.
526            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), // no used chunks — ignore per design
535                    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            // If kind=skill and content is a readable file path, load its content.
573            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                // Per-query miss report (P4): surface queries where no relevant chunk
736                // ranked, with the actual top-k, to debug *why* recall missed.
737                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 snapshot — fused-score weights in effect, so an eval run is
759                // self-describing and comparable against online metrics over time (P4).
760                "params": kb.recall_weights(),
761                "misses": misses,
762            });
763            if save {
764                // Persist a compact run summary (no per-query misses) for trend comparison
765                // against online metrics. Append-only JSONL; best-effort, non-fatal.
766                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
820/// Pure ranking metrics for a single query (part b — measurable recall quality).
821/// `ranked` is the recalled chunk ids in rank order; `relevant` the labeled
822/// ground-truth set. Returns `(p_at_1, recall_at_k, mrr, ndcg_at_k)`, each in
823/// `[0,1]`. Kept IO-free so it is unit-testable without a database.
824pub(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    // MRR over the full ranking: reciprocal rank of the first relevant hit.
837    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    // nDCG@k with binary relevance. IDCG = ideal placement of min(|rel|, k) hits.
843    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        // Relevant item is at rank 2 → P@1=0, MRR=0.5, Recall@5=1.0.
881        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        // Only the first id counts at k=1; the relevant one at rank 2 is excluded.
890        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}