lantern 0.3.0

Local-first, provenance-aware semantic search for agent activity
Documentation
//! Positive-only query-success signal for chunks.
//!
//! A chunk's `query_success_count` is a non-negative tally of "this chunk
//! helped answer a query successfully" events. It is intentionally separate
//! from `feedback_score` so an explicit thumbs-up/-down vote stays distinct
//! from an implicit, observed-success signal: a caller can record a query
//! success without claiming the user rated the chunk. Scoring lives in
//! [`crate::search::compute_confidence`]; this module is just the write path
//! and a thin read helper for tests and library callers.
//!
//! `0` is the neutral default and produces identical confidence to a store
//! that never records any query successes, so the v11 migration is a ranking
//! no-op for existing data.

use anyhow::{Context, Result};
use rusqlite::OptionalExtension;
use serde::Serialize;

use crate::inspect::now_unix;
use crate::search::{
    ConfidenceBreakdown, compute_confidence_breakdown, format_confidence_breakdown_token,
};
use crate::store::Store;

/// Increment `chunk_id`'s query-success counter by 1 and return the new count.
///
/// Errors if the chunk does not exist. The update is a single UPDATE statement
/// so concurrent successes from multiple writers compose additively.
pub fn record_query_success(store: &Store, chunk_id: &str) -> Result<i64> {
    let conn = store.conn();
    let rows = conn
        .execute(
            "UPDATE chunks SET query_success_count = query_success_count + 1 WHERE id = ?1",
            rusqlite::params![chunk_id],
        )
        .with_context(|| format!("recording query success for chunk {chunk_id}"))?;
    if rows == 0 {
        anyhow::bail!("no chunk with id {chunk_id}");
    }
    get_query_success_count(store, chunk_id)?
        .ok_or_else(|| anyhow::anyhow!("chunk {chunk_id} disappeared after query-success update"))
}

/// Read the current query-success count for `chunk_id`, or `None` if the chunk
/// is not in the store.
pub fn get_query_success_count(store: &Store, chunk_id: &str) -> Result<Option<i64>> {
    let conn = store.conn();
    let count = conn
        .query_row(
            "SELECT query_success_count FROM chunks WHERE id = ?1",
            rusqlite::params![chunk_id],
            |row| row.get::<_, i64>(0),
        )
        .optional()?;
    Ok(count)
}

/// Result of a query-success write, shaped for the `lantern query-success`
/// CLI output. Mirrors [`crate::feedback::FeedbackReport`] so the two
/// confidence-input write paths render symmetrically.
#[derive(Debug, Clone, Serialize)]
pub struct QuerySuccessReport {
    pub chunk_id: String,
    pub count: i64,
    pub confidence: f64,
    pub confidence_breakdown: ConfidenceBreakdown,
    pub access_decay_at: Option<i64>,
}

fn current_confidence(
    store: &Store,
    chunk_id: &str,
    at_unix: i64,
) -> Result<(f64, ConfidenceBreakdown, Option<i64>)> {
    let conn = store.conn();
    let (access_decay_at, last_accessed_at, timestamp_unix, access_count, feedback_score, query_success_count) =
        conn.query_row(
            "SELECT access_decay_at, last_accessed_at, timestamp_unix, access_count, feedback_score, query_success_count FROM chunks WHERE id = ?1",
            rusqlite::params![chunk_id],
            |row| {
                Ok((
                    row.get(0)?,
                    row.get(1)?,
                    row.get(2)?,
                    row.get(3)?,
                    row.get(4)?,
                    row.get(5)?,
                ))
            },
        )?;
    let (confidence, confidence_breakdown) = compute_confidence_breakdown(
        at_unix,
        last_accessed_at,
        timestamp_unix,
        access_count,
        feedback_score,
        query_success_count,
    );
    Ok((confidence, confidence_breakdown, access_decay_at))
}

/// Increment the query-success count for `chunk_id` and package the result
/// for display. Thin wrapper around [`record_query_success`] so the CLI and
/// MCP layers don't each re-derive the report shape.
pub fn apply_query_success(store: &Store, chunk_id: &str) -> Result<QuerySuccessReport> {
    let count = record_query_success(store, chunk_id)?;
    let (confidence, confidence_breakdown, access_decay_at) =
        current_confidence(store, chunk_id, now_unix())?;
    Ok(QuerySuccessReport {
        chunk_id: chunk_id.to_string(),
        count,
        confidence,
        confidence_breakdown,
        access_decay_at,
    })
}

pub(crate) fn format_text(report: &QuerySuccessReport) -> String {
    let mut text = format!(
        "query success recorded: chunk={} count={} confidence={:.3} freshness_source={} {}",
        report.chunk_id,
        report.count,
        report.confidence,
        report.confidence_breakdown.freshness_source.as_str(),
        format_confidence_breakdown_token(&report.confidence_breakdown),
    );
    if let Some(access_decay_at) = report.access_decay_at {
        text.push_str(&format!(" access_decay_at={access_decay_at}"));
    }
    text
}

pub fn print_text(report: &QuerySuccessReport) {
    println!("{}", format_text(report));
}

pub fn print_json(report: &QuerySuccessReport) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(report)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use std::fs;

    use crate::ingest::ingest_path;
    use crate::search::FreshnessSource;
    use crate::store::Store;
    use tempfile::tempdir;

    use super::*;

    fn open_store_with_chunk() -> (tempfile::TempDir, Store, String) {
        let root = tempdir().expect("tempdir");
        let mut store = Store::initialize(&root.path().join("store")).expect("init store");
        let data = root.path().join("data");
        fs::create_dir_all(&data).expect("create data dir");
        fs::write(data.join("a.txt"), "hello world").expect("write fixture");
        ingest_path(&mut store, &data).expect("ingest fixture");
        let chunk_id = store
            .conn()
            .query_row("SELECT id FROM chunks LIMIT 1", [], |row| row.get(0))
            .expect("chunk id");
        (root, store, chunk_id)
    }

    fn sample_report() -> QuerySuccessReport {
        QuerySuccessReport {
            chunk_id: "chunk-1".into(),
            count: 4,
            confidence: 0.812,
            confidence_breakdown: ConfidenceBreakdown {
                freshness: 0.5,
                freshness_source: FreshnessSource::LastAccessedAt,
                access_boost: 0.5,
                base: 0.5,
                feedback_factor: 0.0,
                query_success_factor: 0.0,
            },
            access_decay_at: Some(1_700_000_800),
        }
    }

    #[test]
    fn apply_query_success_increments_and_returns_report() {
        let (_dir, store, chunk_id) = open_store_with_chunk();

        let first = apply_query_success(&store, &chunk_id).expect("first apply");
        assert_eq!(first.chunk_id, chunk_id);
        assert_eq!(first.count, 1);

        let second = apply_query_success(&store, &chunk_id).expect("second apply");
        assert_eq!(second.count, 2);
        assert_eq!(
            get_query_success_count(&store, &chunk_id).unwrap(),
            Some(2),
            "stored count should match returned count"
        );
    }

    #[test]
    fn apply_query_success_errors_for_unknown_chunk() {
        let (_dir, store, _chunk_id) = open_store_with_chunk();
        let err = apply_query_success(&store, "deadbeef").unwrap_err();
        assert!(
            err.to_string().contains("no chunk with id"),
            "unexpected error: {err}"
        );
    }

    #[test]
    fn format_text_surfaces_freshness_source_token() {
        let text = format_text(&sample_report());
        assert!(text.contains("freshness_source=last_accessed_at"), "{text}");
        assert!(text.contains("confidence=0.812"), "{text}");
        assert!(text.contains("access_decay_at=1700000800"), "{text}");
    }
}