tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

tangle_benchmark_report.rs (13718B)


      1 #![forbid(unsafe_code)]
      2 
      3 use std::env;
      4 use std::fs;
      5 use std::path::{Path, PathBuf};
      6 use std::process::Command;
      7 use std::time::{SystemTime, UNIX_EPOCH};
      8 use tangle_bench::{
      9     BenchDatasetConfig, BenchmarkProfile, BenchmarkProfileName, BenchmarkRunReport,
     10     BenchmarkThresholds,
     11 };
     12 use tangle_runtime::nip11::supported_nips_for_group_capability;
     13 
     14 #[derive(Debug)]
     15 struct BenchmarkReportArgs {
     16     output_root: PathBuf,
     17     run_id: String,
     18     profile: BenchmarkProfile,
     19 }
     20 
     21 fn main() {
     22     match run() {
     23         Ok(Some(artifact_dir)) => println!("{}", path_string(&artifact_dir)),
     24         Ok(None) => {}
     25         Err(error) => {
     26             eprintln!("{error}");
     27             std::process::exit(1);
     28         }
     29     }
     30 }
     31 
     32 fn run() -> Result<Option<PathBuf>, String> {
     33     let Some(args) = BenchmarkReportArgs::parse(env::args().skip(1))? else {
     34         println!("{}", help_text());
     35         return Ok(None);
     36     };
     37     let artifact_dir = args.output_root.join(&args.run_id);
     38     fs::create_dir_all(&artifact_dir).map_err(|error| error.to_string())?;
     39 
     40     let report = BenchmarkRunReport::run(args.profile)?;
     41     let dataset_path = artifact_dir.join("dataset-events.jsonl");
     42     fs::write(
     43         &dataset_path,
     44         report
     45             .dataset()
     46             .source_events_jsonl()
     47             .map_err(|error| error.to_string())?,
     48     )
     49     .map_err(|error| error.to_string())?;
     50 
     51     let mut summary = report.summary_json(&args.run_id, &artifact_dir);
     52     let supported_nips = supported_nips_for_group_capability(true);
     53     let supported_nips_count = supported_nips.len();
     54     summary["supported_nips_audit"] = serde_json::json!({
     55         "groups_enabled": true,
     56         "supported_nips": supported_nips,
     57         "count": supported_nips_count
     58     });
     59     summary["run_identity"] = serde_json::json!({
     60         "git_commit": git_full_commit(),
     61         "git_commit_short": git_short_commit(),
     62         "rust_toolchain": rust_toolchain(),
     63         "host_profile": host_profile(),
     64         "os": env::consts::OS,
     65         "arch": env::consts::ARCH
     66     });
     67     summary["host_hardware"] = serde_json::json!({
     68         "cpu_model": cpu_model(),
     69         "cpu_parallelism": cpu_parallelism(),
     70         "memory_bytes": memory_bytes()
     71     });
     72 
     73     let summary_path = artifact_dir.join("summary.json");
     74     let raw = serde_json::to_string_pretty(&summary).map_err(|error| error.to_string())?;
     75     fs::write(&summary_path, format!("{raw}\n")).map_err(|error| error.to_string())?;
     76     Ok(Some(artifact_dir))
     77 }
     78 
     79 impl BenchmarkReportArgs {
     80     fn parse(args: impl IntoIterator<Item = String>) -> Result<Option<Self>, String> {
     81         let mut output_root = PathBuf::from(".local/tangle/benchmarks");
     82         let mut run_id = None;
     83         let mut profile_name = BenchmarkProfileName::VirtualRelayTenancy;
     84         let mut config = BenchDatasetConfig::smoke();
     85         let mut dataset_overridden = false;
     86         let mut thresholds_json = None;
     87         let mut target_hardware_evidence = None;
     88         let mut args = args.into_iter();
     89         while let Some(arg) = args.next() {
     90             match arg.as_str() {
     91                 "--output-root" => {
     92                     output_root = PathBuf::from(require_value("--output-root", args.next())?);
     93                 }
     94                 "--run-id" => {
     95                     run_id = Some(require_value("--run-id", args.next())?);
     96                 }
     97                 "--profile" => {
     98                     profile_name =
     99                         BenchmarkProfileName::parse(&require_value("--profile", args.next())?)?;
    100                 }
    101                 "--thresholds-json" => {
    102                     thresholds_json = Some(PathBuf::from(require_value(
    103                         "--thresholds-json",
    104                         args.next(),
    105                     )?));
    106                 }
    107                 "--target-hardware-evidence" => {
    108                     target_hardware_evidence =
    109                         Some(require_value("--target-hardware-evidence", args.next())?);
    110                 }
    111                 "--group-count" => {
    112                     config.group_count = parse_count("--group-count", args.next())?;
    113                     dataset_overridden = true;
    114                 }
    115                 "--public-events-per-group" => {
    116                     config.public_events_per_group =
    117                         parse_count("--public-events-per-group", args.next())?;
    118                     dataset_overridden = true;
    119                 }
    120                 "--private-events-per-group" => {
    121                     config.private_events_per_group =
    122                         parse_count("--private-events-per-group", args.next())?;
    123                     dataset_overridden = true;
    124                 }
    125                 "--public-note-count" => {
    126                     config.public_note_count = parse_count("--public-note-count", args.next())?;
    127                     dataset_overridden = true;
    128                 }
    129                 "--member-count" => {
    130                     config.member_count = parse_count("--member-count", args.next())?;
    131                     dataset_overridden = true;
    132                 }
    133                 "--help" => return Ok(None),
    134                 other => return Err(format!("unsupported argument `{other}`")),
    135             }
    136         }
    137         let run_id = run_id.unwrap_or_else(default_run_id);
    138         validate_run_id(&run_id)?;
    139         if dataset_overridden && profile_name != BenchmarkProfileName::Smoke {
    140             return Err(
    141                 "dataset size overrides are only supported with the smoke profile".to_owned(),
    142             );
    143         }
    144         let mut profile = BenchmarkProfile::from_name(profile_name);
    145         if dataset_overridden {
    146             profile = profile.with_dataset_config(config)?;
    147         }
    148         if let Some(path) = thresholds_json {
    149             let raw = fs::read_to_string(&path)
    150                 .map_err(|error| format!("failed to read thresholds JSON: {error}"))?;
    151             let thresholds = BenchmarkThresholds::from_json_str(&raw)?;
    152             profile =
    153                 profile.with_thresholds(thresholds, format!("file:{}", path_string(&path)))?;
    154         }
    155         if let Some(evidence) = target_hardware_evidence {
    156             profile = profile.with_target_hardware_evidence(evidence)?;
    157         }
    158         profile.validate_for_run()?;
    159         Ok(Some(Self {
    160             output_root,
    161             run_id,
    162             profile,
    163         }))
    164     }
    165 }
    166 
    167 fn require_value(name: &'static str, value: Option<String>) -> Result<String, String> {
    168     value.ok_or_else(|| format!("{name} requires a value"))
    169 }
    170 
    171 fn parse_count(name: &'static str, value: Option<String>) -> Result<usize, String> {
    172     let raw = require_value(name, value)?;
    173     raw.parse::<usize>()
    174         .map_err(|error| format!("{name} must be a non-negative integer: {error}"))
    175 }
    176 
    177 fn validate_run_id(run_id: &str) -> Result<(), String> {
    178     if run_id.is_empty() || run_id.contains('/') || run_id.contains('\\') || run_id.contains("..") {
    179         return Err("run id must be a single relative path segment".to_owned());
    180     }
    181     Ok(())
    182 }
    183 
    184 fn default_run_id() -> String {
    185     format!("local-{}-{}", unix_seconds(), git_short_commit())
    186 }
    187 
    188 fn unix_seconds() -> u64 {
    189     SystemTime::now()
    190         .duration_since(UNIX_EPOCH)
    191         .map(|duration| duration.as_secs())
    192         .unwrap_or(0)
    193 }
    194 
    195 fn git_short_commit() -> String {
    196     command_text("git", &["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| "unknown".to_owned())
    197 }
    198 
    199 fn git_full_commit() -> String {
    200     command_text("git", &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned())
    201 }
    202 
    203 fn rust_toolchain() -> String {
    204     command_text("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_owned())
    205 }
    206 
    207 fn host_profile() -> String {
    208     let os = env::consts::OS;
    209     let arch = env::consts::ARCH;
    210     format!("{os}-{arch}")
    211 }
    212 
    213 fn cpu_model() -> String {
    214     command_text("sysctl", &["-n", "machdep.cpu.brand_string"])
    215         .or_else(cpu_model_from_proc)
    216         .unwrap_or_else(|| "unknown".to_owned())
    217 }
    218 
    219 fn cpu_parallelism() -> u64 {
    220     std::thread::available_parallelism()
    221         .map(|value| value.get().try_into().expect("parallelism fits in u64"))
    222         .unwrap_or(0)
    223 }
    224 
    225 fn memory_bytes() -> Option<u64> {
    226     command_text("sysctl", &["-n", "hw.memsize"])
    227         .and_then(|value| value.parse::<u64>().ok())
    228         .or_else(memory_bytes_from_proc)
    229 }
    230 
    231 fn cpu_model_from_proc() -> Option<String> {
    232     let raw = fs::read_to_string("/proc/cpuinfo").ok()?;
    233     raw.lines()
    234         .find_map(|line| line.strip_prefix("model name"))
    235         .and_then(|value| {
    236             value
    237                 .split_once(':')
    238                 .map(|(_, model)| model.trim().to_owned())
    239         })
    240         .filter(|value| !value.is_empty())
    241 }
    242 
    243 fn memory_bytes_from_proc() -> Option<u64> {
    244     let raw = fs::read_to_string("/proc/meminfo").ok()?;
    245     raw.lines()
    246         .find_map(|line| line.strip_prefix("MemTotal:"))
    247         .and_then(|value| value.split_whitespace().next())
    248         .and_then(|value| value.parse::<u64>().ok())
    249         .and_then(|kib| kib.checked_mul(1024))
    250 }
    251 
    252 fn command_text(command: &str, args: &[&str]) -> Option<String> {
    253     Command::new(command)
    254         .args(args)
    255         .output()
    256         .ok()
    257         .filter(|output| output.status.success())
    258         .and_then(|output| String::from_utf8(output.stdout).ok())
    259         .map(|value| value.trim().to_owned())
    260         .filter(|value| !value.is_empty())
    261 }
    262 
    263 fn path_string(path: &Path) -> String {
    264     path.to_string_lossy().into_owned()
    265 }
    266 
    267 fn help_text() -> String {
    268     [
    269         "usage: tangle-benchmark-report [--output-root PATH] [--run-id ID] [--profile smoke|virtual-relay-tenancy|medium|large-smoke|proof-10m|proof-large-group|proof-join-storm|proof-slow-client]",
    270         "       [--thresholds-json PATH] [--target-hardware-evidence TEXT]",
    271         "       [--group-count COUNT] [--public-events-per-group COUNT]",
    272         "       [--private-events-per-group COUNT] [--public-note-count COUNT]",
    273         "       [--member-count COUNT]",
    274     ]
    275     .join("\n")
    276 }
    277 
    278 #[cfg(test)]
    279 mod tests {
    280     use super::BenchmarkReportArgs;
    281     use tangle_bench::{BenchDatasetConfig, BenchmarkProfileName};
    282 
    283     #[test]
    284     fn benchmark_report_args_default_to_virtual_relay_tenancy_profile() {
    285         let args = BenchmarkReportArgs::parse(["--run-id".to_owned(), "unit".to_owned()])
    286             .expect("parse")
    287             .expect("args");
    288 
    289         assert_eq!(
    290             args.profile.name(),
    291             BenchmarkProfileName::VirtualRelayTenancy
    292         );
    293         assert_eq!(args.profile.dataset_config(), BenchDatasetConfig::smoke());
    294         assert_eq!(
    295             args.profile.threshold_source(),
    296             "builtin:virtual-relay-tenancy"
    297         );
    298     }
    299 
    300     #[test]
    301     fn benchmark_report_args_reject_unknown_profile() {
    302         let error = BenchmarkReportArgs::parse([
    303             "--profile".to_owned(),
    304             "tiny".to_owned(),
    305             "--run-id".to_owned(),
    306             "unit".to_owned(),
    307         ])
    308         .expect_err("unknown profile");
    309 
    310         assert!(error.contains("unknown benchmark profile"));
    311     }
    312 
    313     #[test]
    314     fn benchmark_report_args_reject_dataset_overrides_for_non_smoke_profiles() {
    315         let error = BenchmarkReportArgs::parse([
    316             "--profile".to_owned(),
    317             "medium".to_owned(),
    318             "--group-count".to_owned(),
    319             "3".to_owned(),
    320             "--run-id".to_owned(),
    321             "unit".to_owned(),
    322         ])
    323         .expect_err("non-smoke override");
    324 
    325         assert!(error.contains("dataset size overrides"));
    326     }
    327 
    328     #[test]
    329     fn benchmark_report_args_accept_large_smoke_target_hardware_evidence_without_proof_claim() {
    330         let args = BenchmarkReportArgs::parse([
    331             "--profile".to_owned(),
    332             "large-smoke".to_owned(),
    333             "--target-hardware-evidence".to_owned(),
    334             "target-hardware:bench-node-001".to_owned(),
    335             "--run-id".to_owned(),
    336             "unit".to_owned(),
    337         ])
    338         .expect("parse")
    339         .expect("args");
    340 
    341         assert_eq!(args.profile.name(), BenchmarkProfileName::LargeSmoke);
    342         assert!(!args.profile.proof_claim_eligible());
    343     }
    344 
    345     #[test]
    346     fn benchmark_report_args_require_hardware_evidence_for_proof_profiles() {
    347         for profile in [
    348             "proof-10m",
    349             "proof-large-group",
    350             "proof-join-storm",
    351             "proof-slow-client",
    352         ] {
    353             let error = BenchmarkReportArgs::parse([
    354                 "--profile".to_owned(),
    355                 profile.to_owned(),
    356                 "--run-id".to_owned(),
    357                 "unit".to_owned(),
    358             ])
    359             .expect_err("proof profile requires evidence");
    360 
    361             assert!(error.contains("target hardware evidence is required"));
    362         }
    363     }
    364 
    365     #[test]
    366     fn benchmark_report_args_accept_proof_profile_with_hardware_evidence() {
    367         let args = BenchmarkReportArgs::parse([
    368             "--profile".to_owned(),
    369             "proof-10m".to_owned(),
    370             "--target-hardware-evidence".to_owned(),
    371             "target-hardware:proof-node-001".to_owned(),
    372             "--run-id".to_owned(),
    373             "unit".to_owned(),
    374         ])
    375         .expect("parse")
    376         .expect("args");
    377 
    378         assert_eq!(args.profile.name(), BenchmarkProfileName::Proof10m);
    379         assert!(args.profile.proof_claim_eligible());
    380     }
    381 
    382     #[test]
    383     fn benchmark_report_args_reject_production_profile_alias() {
    384         let error = BenchmarkReportArgs::parse([
    385             "--profile".to_owned(),
    386             "production".to_owned(),
    387             "--run-id".to_owned(),
    388             "unit".to_owned(),
    389         ])
    390         .expect_err("production profile removed");
    391 
    392         assert!(error.contains("unknown benchmark profile"));
    393     }
    394 }