poolsim-core 0.1.0

Core simulation engine for poolsim
Documentation
use std::{fs, path::PathBuf};

use poolsim_core::{
    distribution::LatencyDistribution,
    emit_performance_contract_warning,
    erlang,
    error::PoolsimError,
    monte_carlo,
    optimizer,
    sensitivity,
    simulate,
    sweep,
    sweep_with_options,
    DistributionModel,
    MIN_FULL_SIMULATION_ITERATIONS,
    PERFORMANCE_CONTRACT_WARNING,
    QueueModel,
    RiskLevel,
};
use poolsim_core::types::{
    PoolConfig, SaturationLevel, SimulationOptions, StepLoadPoint, WorkloadConfig,
};
use serde::Deserialize;
use serde_json::json;

#[derive(Debug, Deserialize)]
struct DocsConfig {
    workload: WorkloadConfig,
    pool: PoolConfig,
    #[serde(default)]
    options: SimulationOptions,
}

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .expect("workspace root should resolve")
}

fn fixture(path: &str) -> PathBuf {
    workspace_root().join(path)
}

fn load_docs_config() -> DocsConfig {
    let raw = fs::read_to_string(fixture("docs/fixtures/cli-config.json"))
        .expect("docs CLI fixture should be readable");
    serde_json::from_str(&raw).expect("docs CLI fixture should deserialize")
}

#[test]
fn docs_core_fixture_covers_top_level_and_helper_apis() {
    let docs = load_docs_config();

    assert_eq!(MIN_FULL_SIMULATION_ITERATIONS, 10_000);
    assert_eq!(
        PERFORMANCE_CONTRACT_WARNING,
        "performance contract not met: expected <= 200ms"
    );
    emit_performance_contract_warning(0, 1_000);

    docs.workload.validate().expect("workload fixture should validate");
    docs.pool.validate().expect("pool fixture should validate");
    docs.options.validate().expect("options fixture should validate");
    for point in docs
        .workload
        .step_load_profile
        .as_ref()
        .expect("step profile should exist in fixture")
    {
        point.validate().expect("step point should validate");
    }

    assert_eq!(SaturationLevel::from_rho(0.40), SaturationLevel::Ok);
    assert_eq!(SaturationLevel::from_rho(0.88), SaturationLevel::Warning);
    assert_eq!(SaturationLevel::from_rho(0.96), SaturationLevel::Critical);

    let report = simulate(&docs.workload, &docs.pool, &docs.options)
        .expect("docs simulate example should succeed");
    assert!(report.optimal_pool_size >= docs.pool.min_pool_size);
    assert!(report.optimal_pool_size <= docs.pool.max_pool_size);
    assert_eq!(report.step_load_analysis.len(), 2);

    let evaluation = poolsim_core::evaluate(&docs.workload, report.optimal_pool_size, &docs.options)
        .expect("docs evaluate example should succeed");
    assert_eq!(evaluation.pool_size, report.optimal_pool_size);

    let default_sweep = sweep(&docs.workload, &docs.pool).expect("top-level sweep should succeed");
    let explicit_sweep = sweep_with_options(&docs.workload, &docs.pool, &docs.options)
        .expect("top-level sweep_with_options should succeed");
    assert!(!default_sweep.is_empty());
    assert_eq!(explicit_sweep.len(), default_sweep.len());

    let dist = LatencyDistribution::fit(&docs.workload, DistributionModel::LogNormal)
        .expect("lognormal fit should succeed");
    assert!(dist.mean_ms() > 0.0);
    assert!(dist.percentile_ms(0.99).expect("p99 should compute") > 0.0);
    let mut rng = rand::thread_rng();
    assert!(dist.sample_ms(&mut rng) > 0.0);

    let lambda = docs.workload.requests_per_second;
    let mu = 1_000.0 / dist.mean_ms();
    let rho = erlang::utilisation(lambda, mu, report.optimal_pool_size);
    assert!(rho.is_finite());
    assert!(erlang::erlang_c(lambda, mu, report.optimal_pool_size).is_ok());
    assert!(erlang::mean_queue_wait_ms(lambda, mu, report.optimal_pool_size).is_ok());
    assert!(erlang::queue_wait_percentile_ms(lambda, mu, report.optimal_pool_size, 0.99).is_ok());

    let mc = monte_carlo::run(&docs.workload, report.optimal_pool_size, &dist, &docs.options)
        .expect("Monte Carlo run should succeed");
    assert!(!mc.wait_times_ms.is_empty());

    let optimal = optimizer::find_optimal(&docs.workload, &docs.pool, &dist, &docs.options)
        .expect("optimizer should succeed");
    assert!(optimal.pool_size >= docs.pool.min_pool_size);

    let sensitivity_default =
        sensitivity::sweep(&docs.workload, &docs.pool).expect("module sweep should succeed");
    let sensitivity_target = sensitivity::sweep_with_target(&docs.workload, &docs.pool, 45.0)
        .expect("targeted sweep should succeed");
    let sensitivity_target_model = sensitivity::sweep_with_target_and_model(
        &docs.workload,
        &docs.pool,
        45.0,
        QueueModel::MDC,
    )
    .expect("targeted modeled sweep should succeed");
    let sensitivity_opts = sensitivity::sweep_with_options(&docs.workload, &docs.pool, &docs.options)
        .expect("module sweep_with_options should succeed");
    assert_eq!(sensitivity_default.len(), sensitivity_target.len());
    assert_eq!(sensitivity_target_model.len(), sensitivity_opts.len());

    let error = PoolsimError::invalid_input(
        "INVALID_DOCS_FIXTURE",
        "fixture validation failed",
        Some(json!({"path": "docs/fixtures/cli-config.json"})),
    );
    assert_eq!(error.code(), "INVALID_DOCS_FIXTURE");
    assert_eq!(
        error.details().expect("error details should exist")["path"],
        "docs/fixtures/cli-config.json"
    );

    let risk: RiskLevel = sensitivity_default
        .iter()
        .map(|row| row.risk)
        .min()
        .expect("sensitivity rows should exist");
    assert!(matches!(
        risk,
        RiskLevel::Low | RiskLevel::Medium | RiskLevel::High | RiskLevel::Critical
    ));
}

#[test]
fn docs_empirical_samples_fixture_covers_empirical_distribution_path() {
    let docs = load_docs_config();
    let samples = fs::read_to_string(fixture("docs/fixtures/latencies.txt"))
        .expect("latency sample fixture should be readable");
    let raw_samples_ms = samples
        .lines()
        .filter(|line| !line.trim().is_empty())
        .map(|line| {
            line.trim()
                .parse::<f64>()
                .expect("latency samples should parse as f64")
        })
        .collect::<Vec<_>>();

    let workload = WorkloadConfig {
        raw_samples_ms: Some(raw_samples_ms),
        step_load_profile: Some(vec![
            StepLoadPoint {
                time_s: 0,
                requests_per_second: docs.workload.requests_per_second,
            },
            StepLoadPoint {
                time_s: 30,
                requests_per_second: docs.workload.requests_per_second + 20.0,
            },
        ]),
        ..docs.workload
    };
    workload.validate().expect("empirical workload should validate");

    let dist = LatencyDistribution::fit(&workload, DistributionModel::Empirical)
        .expect("empirical fit should succeed");
    assert!(matches!(dist, LatencyDistribution::Empirical(_)));
    assert!(dist.percentile_ms(0.50).expect("median should compute") > 0.0);
    assert!(dist.mean_ms() > 0.0);
}