axiomsync 1.0.0

Core data-processing engine for AxiomSync local retrieval runtime.
Documentation
use chrono::Utc;

use crate::catalog::{benchmark_base_uri, benchmark_fixture_uri, parse_benchmark_fixture_document};
use crate::error::{AxiomError, Result};
use crate::models::{
    BenchmarkFixtureDocument, BenchmarkFixtureSummary, BenchmarkReport, BenchmarkRunOptions,
    BenchmarkTrendReport,
};
use crate::quality::to_benchmark_summary;
use crate::uri::AxiomUri;

use super::AxiomSync;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[expect(
    clippy::struct_excessive_bools,
    reason = "fixture source toggles are independent and map 1:1 to CLI flags"
)]
pub struct BenchmarkFixtureCreateOptions {
    pub query_limit: usize,
    pub include_golden: bool,
    pub include_trace: bool,
    pub include_stress: bool,
    pub trace_expectations: bool,
}

impl AxiomSync {
    pub fn create_benchmark_fixture(
        &self,
        name: &str,
        options: BenchmarkFixtureCreateOptions,
    ) -> Result<BenchmarkFixtureSummary> {
        let trimmed = name.trim();
        if trimmed.is_empty() {
            return Err(AxiomError::Validation(
                "fixture name cannot be empty".to_string(),
            ));
        }
        if !options.include_golden && !options.include_trace {
            return Err(AxiomError::Validation(
                "fixture creation requires at least one source".to_string(),
            ));
        }

        let run_options = BenchmarkRunOptions {
            query_limit: options.query_limit,
            search_limit: 10,
            include_golden: options.include_golden,
            include_trace: options.include_trace,
            include_stress: options.include_stress,
            trace_expectations: options.trace_expectations,
            fixture_name: None,
        };
        let cases = self.collect_benchmark_query_cases(&run_options, options.query_limit.max(1))?;
        let fixture_uri = benchmark_fixture_uri(trimmed)?;
        let document = BenchmarkFixtureDocument {
            version: 1,
            created_at: Utc::now().to_rfc3339(),
            name: trimmed.to_string(),
            cases,
        };
        self.fs.write(
            &fixture_uri,
            &serde_json::to_string_pretty(&document)?,
            true,
        )?;

        Ok(BenchmarkFixtureSummary {
            name: document.name,
            uri: fixture_uri.to_string(),
            case_count: document.cases.len(),
            created_at: document.created_at,
        })
    }

    pub fn list_benchmark_fixtures(&self, limit: usize) -> Result<Vec<BenchmarkFixtureSummary>> {
        let limit = limit.max(1);
        let fixtures_dir = benchmark_base_uri()?.join("fixtures")?;
        if !self.fs.exists(&fixtures_dir) {
            return Ok(Vec::new());
        }

        let entries = self.fs.list(&fixtures_dir, false)?;
        let mut out = Vec::<BenchmarkFixtureSummary>::new();
        for entry in entries {
            if entry.is_dir || !has_json_extension(&entry.name) {
                continue;
            }
            let uri = AxiomUri::parse(&entry.uri)?;
            let raw = self.fs.read(&uri)?;
            let doc = parse_benchmark_fixture_document(&raw)?;
            out.push(BenchmarkFixtureSummary {
                name: doc.name,
                uri: uri.to_string(),
                case_count: doc.cases.len(),
                created_at: doc.created_at,
            });
        }
        out.sort_by(|a, b| {
            b.created_at
                .cmp(&a.created_at)
                .then_with(|| a.name.cmp(&b.name))
        });
        out.truncate(limit);
        Ok(out)
    }

    pub fn list_benchmark_reports(&self, limit: usize) -> Result<Vec<BenchmarkReport>> {
        let limit = limit.max(1);
        let reports_dir = benchmark_base_uri()?.join("reports")?;
        if !self.fs.exists(&reports_dir) {
            return Ok(Vec::new());
        }
        let entries = self.fs.list(&reports_dir, false)?;
        let mut reports = Vec::<BenchmarkReport>::new();
        for entry in entries {
            if entry.is_dir || !has_json_extension(&entry.name) {
                continue;
            }
            let uri = AxiomUri::parse(&entry.uri)?;
            let raw = self.fs.read(&uri)?;
            let Ok(report) = serde_json::from_str::<BenchmarkReport>(&raw) else {
                continue;
            };
            reports.push(report);
        }
        reports.sort_by(|a, b| {
            b.created_at
                .cmp(&a.created_at)
                .then_with(|| b.run_id.cmp(&a.run_id))
        });
        reports.truncate(limit);
        Ok(reports)
    }

    pub fn benchmark_trend(&self, limit: usize) -> Result<BenchmarkTrendReport> {
        let reports = self.list_benchmark_reports(limit.max(2))?;
        if reports.is_empty() {
            return Ok(BenchmarkTrendReport {
                latest: None,
                previous: None,
                delta_p95_latency_ms: None,
                delta_p95_latency_us: None,
                delta_top1_accuracy: None,
                status: "no_data".to_string(),
            });
        }

        let latest = reports.first().cloned().map(to_benchmark_summary);
        let previous = reports.get(1).cloned().map(to_benchmark_summary);
        let delta_p95_latency_ms =
            latest
                .as_ref()
                .zip(previous.as_ref())
                .and_then(|(latest, previous)| {
                    delta_u128_to_i128(latest.p95_latency_ms, previous.p95_latency_ms)
                });
        let delta_p95_latency_us =
            latest
                .as_ref()
                .zip(previous.as_ref())
                .and_then(|(latest, previous)| {
                    let latest = latest
                        .p95_latency_us
                        .unwrap_or(latest.p95_latency_ms.saturating_mul(1_000));
                    let previous = previous
                        .p95_latency_us
                        .unwrap_or(previous.p95_latency_ms.saturating_mul(1_000));
                    delta_u128_to_i128(latest, previous)
                });
        let delta_top1_accuracy = match (latest.as_ref(), previous.as_ref()) {
            (Some(l), Some(p)) => Some(l.top1_accuracy - p.top1_accuracy),
            _ => None,
        };

        let status = match (delta_p95_latency_ms, delta_top1_accuracy) {
            (None, None) => "insufficient_history",
            (Some(dp95), Some(dacc)) if dp95 <= 0 && dacc >= 0.0 => "improved",
            (Some(dp95), Some(dacc)) if dp95 > 0 && dacc < 0.0 => "regressed",
            _ => "mixed",
        }
        .to_string();

        Ok(BenchmarkTrendReport {
            latest,
            previous,
            delta_p95_latency_ms,
            delta_p95_latency_us,
            delta_top1_accuracy,
            status,
        })
    }
}

fn has_json_extension(name: &str) -> bool {
    std::path::Path::new(name)
        .extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
}

fn delta_u128_to_i128(current: u128, previous: u128) -> Option<i128> {
    let current = i128::try_from(current).ok()?;
    let previous = i128::try_from(previous).ok()?;
    Some(current - previous)
}