sharpebench 0.17.0

SharpeBench command-line interface.
use std::fs;

use serde_json::Value;
use sharpebench_core::{
    verify_candidate_lineage, CandidateLineageLedger, CandidateLineageReport, CandidateLineageScore,
};

pub(crate) fn run(args: &[String], json: bool) -> i32 {
    let Some(path) = args.get(2).filter(|value| !value.starts_with('-')) else {
        eprintln!("usage: sharpebench lineage <strategy-evidence.json> [--json]");
        return 2;
    };
    let text = match fs::read_to_string(path) {
        Ok(text) => text,
        Err(error) => {
            eprintln!("error: cannot read {path}: {error}");
            return 1;
        }
    };
    let evidence = match parse_evidence(&text) {
        Ok(evidence) => evidence,
        Err(error) => {
            eprintln!("error: {error}");
            return 1;
        }
    };
    let (ledger, scores) = match extract_contract(&evidence) {
        Ok(contract) => contract,
        Err(error) => {
            eprintln!("error: {error}");
            return 1;
        }
    };
    match verify_candidate_lineage(&ledger, &scores) {
        Ok(report) => {
            if json {
                println!(
                    "{}",
                    serde_json::to_string_pretty(&report)
                        .expect("lineage report is JSON serializable")
                );
            } else {
                print_report(&report);
            }
            0
        }
        Err(error) => {
            eprintln!("lineage verification failed: {error}");
            1
        }
    }
}

fn parse_evidence(text: &str) -> Result<Value, String> {
    if let Ok(value) = serde_json::from_str(text) {
        return Ok(value);
    }
    let records: Result<Vec<Value>, _> = text
        .lines()
        .filter(|line| !line.trim().is_empty())
        .map(serde_json::from_str)
        .collect();
    let mut records =
        records.map_err(|error| format!("invalid strategy evidence JSON: {error}"))?;
    match records.len() {
        0 => Err("strategy evidence is empty".to_owned()),
        1 => Ok(records.remove(0)),
        count => Err(format!(
            "strategy evidence contains {count} JSONL records; extract one run so its lineage report is unambiguous"
        )),
    }
}

fn extract_contract(
    evidence: &Value,
) -> Result<(CandidateLineageLedger, Vec<CandidateLineageScore>), String> {
    if evidence.get("schema_version").and_then(Value::as_u64) < Some(2) {
        return Err("strategy evidence schema_version must be 2 or newer".to_owned());
    }
    if evidence.get("evidence_class").and_then(Value::as_str)
        != Some("retrospective_generated_strategy")
    {
        return Err("evidence_class must be retrospective_generated_strategy".to_owned());
    }
    if evidence.get("status").and_then(Value::as_str) != Some("completed") {
        return Err("strategy evidence status must be completed".to_owned());
    }
    let ledger_value = evidence
        .pointer("/generation/edge_manifest_ledger")
        .ok_or_else(|| {
            "generation.edge_manifest_ledger is missing from the strategy evidence".to_owned()
        })?;
    let ledger: CandidateLineageLedger = serde_json::from_value(ledger_value.clone())
        .map_err(|error| format!("invalid generation.edge_manifest_ledger: {error}"))?;
    let observed = evidence
        .pointer("/generation/observed_n_trials")
        .and_then(Value::as_u64)
        .and_then(|value| usize::try_from(value).ok())
        .ok_or_else(|| "generation.observed_n_trials must be an integer".to_owned())?;
    if observed != ledger.summary.observed_trials {
        return Err(format!(
            "generation.observed_n_trials is {observed}, but the ledger summary claims {}",
            ledger.summary.observed_trials
        ));
    }
    if evidence
        .pointer("/generation/n_trials_source")
        .and_then(Value::as_str)
        != Some(ledger.summary.n_trials_source.as_str())
    {
        return Err("generation.n_trials_source does not match the ledger summary".to_owned());
    }
    if evidence
        .pointer("/selection/metric")
        .and_then(Value::as_str)
        != Some("median per-seed deflated_sharpe")
    {
        return Err("selection.metric must be median per-seed deflated_sharpe".to_owned());
    }

    let scores = extract_scores(evidence)?;
    Ok((ledger, scores))
}

fn extract_scores(evidence: &Value) -> Result<Vec<CandidateLineageScore>, String> {
    let score_object = evidence
        .pointer("/selection/scores")
        .and_then(Value::as_object)
        .ok_or_else(|| "selection.scores must be an object".to_owned())?;
    let mut scores = Vec::with_capacity(score_object.len());
    for (candidate_id, rows) in score_object {
        let rows = rows
            .as_array()
            .ok_or_else(|| format!("selection.scores.{candidate_id} must be an array"))?;
        if rows.is_empty() {
            return Err(format!(
                "selection.scores.{candidate_id} must contain at least one seed score"
            ));
        }
        let mut values = Vec::with_capacity(rows.len());
        for (index, row) in rows.iter().enumerate() {
            let value = row
                .pointer("/score/deflated_sharpe")
                .and_then(Value::as_f64)
                .ok_or_else(|| {
                    format!(
                        "selection.scores.{candidate_id}[{index}].score.deflated_sharpe must be a number"
                    )
                })?;
            if !value.is_finite() {
                return Err(format!(
                    "selection.scores.{candidate_id}[{index}].score.deflated_sharpe must be finite"
                ));
            }
            values.push(value);
        }
        values.sort_by(f64::total_cmp);
        scores.push(CandidateLineageScore {
            candidate_id: candidate_id.clone(),
            median_deflated_sharpe: median(&values),
        });
    }
    scores.sort_by(|left, right| left.candidate_id.cmp(&right.candidate_id));
    Ok(scores)
}

fn median(sorted: &[f64]) -> f64 {
    if sorted.len() % 2 == 1 {
        sorted[sorted.len() / 2]
    } else {
        let upper = sorted.len() / 2;
        (sorted[upper - 1] + sorted[upper]) / 2.0
    }
}

fn print_report(report: &CandidateLineageReport) {
    println!("VERIFIED candidate lineage");
    println!("observed trials:       {}", report.observed_trials);
    println!("scored candidates:     {}", report.scored_candidates);
    println!("strategy families:     {}", report.family_count);
    println!("ancestry edges:        {}", report.ancestry_edges);
    println!("cited sources:         {}", report.cited_source_count);
    println!("trial denominator:     {}", report.trial_denominator);
    println!("family grouping:       diagnostic only; never changes rank or trial count");
    println!("\nFAMILIES");
    for family in &report.families {
        let best = format_optional(family.best_median_deflated_sharpe);
        let median = format_optional(family.family_median_deflated_sharpe);
        let gap = format_optional(family.best_to_median_gap);
        println!(
            "  {}  observed={} scored={} best={} median={} gap={}",
            family.family_digest,
            family.observed_trials,
            family.scored_candidates,
            best,
            median,
            gap
        );
    }
    if report
        .ancestry
        .iter()
        .any(|candidate| candidate.lineage_status != "host-derived-unreferenced")
    {
        println!("\nANCESTRY");
        for candidate in &report.ancestry {
            if candidate.lineage_status == "host-derived-unreferenced" {
                continue;
            }
            println!(
                "  trial={} candidate={} status={} parents={} sources={}",
                candidate.trial_ordinal,
                candidate.candidate_id.as_deref().unwrap_or("unparsed"),
                candidate.lineage_status,
                candidate.parent_candidate_digests.len(),
                candidate.idea_source_digests.len()
            );
        }
    }
    if !report.cited_sources.is_empty() {
        println!("\nSOURCES");
        for source in &report.cited_sources {
            println!(
                "  {}  {}  {}",
                source.source_type,
                source.source_digest,
                source.url_or_doi.as_deref().unwrap_or("unlocated")
            );
        }
    }
}

fn format_optional(value: Option<f64>) -> String {
    value.map_or_else(|| "n/a".to_owned(), |number| format!("{number:.6}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_an_empty_candidate_score_array() {
        let evidence = serde_json::json!({
            "selection": {"scores": {"candidate": []}}
        });
        let error = extract_scores(&evidence).unwrap_err();
        assert!(error.contains("must contain at least one seed score"));
    }

    #[test]
    fn median_uses_all_seed_scores() {
        assert_eq!(median(&[0.1, 0.3, 0.9]), 0.3);
        assert_eq!(median(&[0.1, 0.3, 0.7, 0.9]), 0.5);
    }

    #[test]
    fn refuses_to_silently_choose_one_run_from_a_multi_record_journal() {
        let error = parse_evidence("{\"run\":1}\n{\"run\":2}\n").unwrap_err();
        assert!(error.contains("2 JSONL records"));
    }

    #[test]
    fn consumes_the_sharpearena_v2_fixture_end_to_end() {
        let evidence: Value = serde_json::from_str(include_str!(
            "../tests/fixtures/sharpearena-lineage-v2.json"
        ))
        .unwrap();
        let (ledger, scores) = extract_contract(&evidence).unwrap();
        let report = verify_candidate_lineage(&ledger, &scores).unwrap();

        assert_eq!(report.observed_trials, 1);
        assert_eq!(report.trial_denominator, 1);
        assert_eq!(report.families[0].family_median_deflated_sharpe, Some(0.5));
        assert_eq!(report.cited_sources[0].source_type, "repository");
    }
}