open-standards/proof/proof-rs/crates/proof-cascade/tests/rapl_hardware.rs
Transaction Science 1d85821aa8
Some checks failed
smart-byte conformance gate / Validate conformance JSON files (pull_request) Successful in 11s
smart-byte conformance gate / Second implementation reproduces every vector (pull_request) Successful in 3s
smart-byte conformance gate / Spec changes require vector-pack updates (pull_request) Successful in 3s
smart-byte conformance gate / VERSIONS.md and README.md must exist (pull_request) Successful in 3s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 1m26s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 59s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m49s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m20s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m13s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m10s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m6s
wai cross-architecture byte-equality / linux-x86_64 (pull_request) Successful in 23s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 2m20s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 23s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 42s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 39s
wai formal verification (integer core) / kani (pull_request) Successful in 3m56s
WAI JPEG-AI whole decode (pinned inputs, required) / fetch pinned JPEG-AI inputs → whole decode + pixel hash, required (pull_request) Successful in 53s
WAI neural decoders end-to-end / synthetic fixtures → Rust decode → reference (±4) (pull_request) Successful in 1m4s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 25s
WAI mbt2018_mean integer decode (regenerated vectors) / regenerate mbt2018_mean integer vectors → Rust decode, required (pull_request) Successful in 2m33s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 21m57s
standards workspaces build + test / aac-rs (pull_request) Successful in 8m24s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 4m4s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 2m34s
standards workspaces build + test / map-rs (pull_request) Successful in 4m15s
standards workspaces build + test / mesh-rs (pull_request) Successful in 2m34s
standards workspaces build + test / openpay (pull_request) Successful in 15m42s
wai cross-architecture byte-equality / linux-aarch64 (pull_request) Successful in 1m25s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 5m21s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 1m2s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 2m58s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m19s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 5m44s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 7m23s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 9m0s
standards workspaces build + test / sense-rs (pull_request) Successful in 6m7s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 7m49s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 1m0s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 4m51s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 56s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 2m22s
wai cross-architecture byte-equality / windows-x86_64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · windows (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · windows (pull_request) Has been cancelled
energy meters: document the hardware tests' helper functions
jouleclaw's public-surface audit failed #129 on seven helpers in
jouleclaw-energy's rapl_hardware.rs that had no doc comment: the
bracket's zone sums, the masked view, and the watch summary. Document
them, and the same helpers in the other crates' copies of the module, so
the copies stay alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 10:18:29 -04:00

481 lines
21 KiB
Rust

//! Linux RAPL against the hardware: this host's powercap counters, read by
//! the meter and by an oracle the tests declare here.
//!
//! In a test binary of its own, so the RAPL probe behind `rapl_status()`
//! runs in this process, and the tests take one lock: each burns CPU and
//! reads machine-wide counters. The oracle reads `/sys/class/powercap`
//! itself — every zone's `name`, `energy_uj` and `max_energy_range_uj` — and
//! applies the sustained-advance rule to the top-level `package-<n>` zones
//! on its own. None of these tests passes without asserting: where the
//! counters cannot be read (no powercap; a CI container, whose
//! `/sys/devices/virtual/powercap` Docker masks), the status must say so and
//! agree with the oracle; where they are live, both must say live, RAPL must
//! be the meter's source, and the marginal figure of a burn on every spare
//! core must be `HwShunt`, claim the CPU package and not the GPU, and, summed
//! over the calls, fit inside the oracle's package-only bracket around the
//! whole measurement (idle and busy windows), which a figure that added
//! psys or a package's core sub-zone overruns.
#![cfg(target_os = "linux")]
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
use proof_cascade::meter::{self, rapl_status, CounterStatus, MeterSource};
use proof_schema::Provenance;
/// One test at a time: each burns CPU and reads machine-wide counters.
static SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// The tests' own reading of `/sys/class/powercap`: every zone listed, its
/// `name`, `energy_uj` and `max_energy_range_uj`, read here and nowhere else,
/// and the sustained-advance verdict over the package zones by the family's
/// rule. Nothing in it comes from the crate under test.
#[allow(dead_code)]
mod oracle {
use std::os::raw::{c_int, c_long};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// Where the kernel lists its powercap zones.
pub const POWERCAP: &str = "/sys/class/powercap";
#[repr(C)]
struct Timespec {
tv_sec: c_long,
tv_nsec: c_long,
}
unsafe extern "C" {
fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int;
}
/// `CLOCK_THREAD_CPUTIME_ID` in Linux `<linux/time.h>`.
const CLOCK_THREAD_CPUTIME_ID: c_int = 3;
/// This thread's CPU time.
pub fn thread_cpu() -> Duration {
let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 };
assert_eq!(unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }, 0, "clock_gettime");
Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)
}
/// Spin this thread until it has used `d` of its own CPU time, or `wall_cap`
/// of wall time has passed. Returns the CPU it got.
pub fn burn(d: Duration, wall_cap: Duration) -> Duration {
let (started, wall) = (thread_cpu(), Instant::now());
let mut acc: u64 = 0;
while thread_cpu() - started < d && wall.elapsed() < wall_cap {
for _ in 0..1_000 {
acc = std::hint::black_box(acc.wrapping_add(1));
}
}
thread_cpu() - started
}
/// One entry of the powercap directory.
#[derive(Debug, Clone)]
pub struct Zone {
/// The directory entry: `intel-rapl:0`, `intel-rapl:0:0`, `intel-rapl-mmio:0`, ...
pub dir: String,
pub name: Option<String>,
pub energy_uj: Option<u64>,
pub max_uj: Option<u64>,
}
fn text(p: PathBuf) -> Option<String> {
std::fs::read_to_string(p).ok().map(|s| s.trim().to_string())
}
fn number(p: PathBuf) -> Option<u64> {
text(p)?.parse().ok()
}
/// Every entry under `root`, in name order.
pub fn zones(root: &Path) -> Vec<Zone> {
let Ok(entries) = std::fs::read_dir(root) else { return Vec::new() };
let mut zones: Vec<Zone> = entries
.flatten()
.map(|e| {
let dir = e.file_name().to_string_lossy().into_owned();
let p = e.path();
Zone { name: text(p.join("name")), energy_uj: number(p.join("energy_uj")), max_uj: number(p.join("max_energy_range_uj")), dir }
})
.collect();
zones.sort_by(|a, b| a.dir.cmp(&b.dir));
zones
}
fn digits(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
/// A top-level RAPL zone: `intel-rapl:<n>`. Not a sub-zone, not MMIO.
pub fn top_level(z: &Zone) -> bool {
z.dir.strip_prefix("intel-rapl:").is_some_and(digits)
}
/// A package zone: top-level, named `package-<n>`.
pub fn is_package(z: &Zone) -> bool {
top_level(z) && z.name.as_deref().and_then(|n| n.strip_prefix("package-")).is_some_and(digits)
}
/// The platform zone.
pub fn is_psys(z: &Zone) -> bool {
top_level(z) && z.name.as_deref() == Some("psys")
}
/// A package's `core` sub-zone (`intel-rapl:<n>:<m>` named `core`).
pub fn is_core(z: &Zone) -> bool {
z.dir.strip_prefix("intel-rapl:").is_some_and(|r| r.contains(':')) && z.name.as_deref() == Some("core")
}
/// One zone's energy between two reads, modulo its own range.
pub fn delta(e0: u64, e1: u64, max: Option<u64>) -> Option<u64> {
if e1 >= e0 {
Some(e1 - e0)
} else {
max.filter(|&m| m > 0)?.checked_sub(e0)?.checked_add(e1)
}
}
/// `energy_uj` of each of `dirs` under `root`, in order; `None` if any fails.
pub fn read(root: &Path, dirs: &[&Zone]) -> Option<Vec<u64>> {
dirs.iter().map(|z| number(root.join(&z.dir).join("energy_uj"))).collect()
}
/// The summed energy of `which` of `set` between two reads of `set`.
pub fn energy(set: &[&Zone], before: &[u64], after: &[u64], which: impl Fn(&Zone) -> bool) -> u64 {
set.iter()
.zip(before.iter().zip(after))
.filter(|(z, _)| which(z))
.map(|(z, (&b, &a))| delta(b, a, z.max_uj).expect("a zone fell with no readable range"))
.sum()
}
/// What the oracle concluded about the package counters under a root.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
/// No top-level zone's `energy_uj` reads (no powercap, a masked or
/// root-only tree).
Unreadable,
/// Top-level zones read, but none is a package.
NoPackage,
/// Every package read, but not every one kept advancing.
Frozen,
/// Every package advanced in at least five intervals, at least 80% of
/// them, over at least 300 ms.
Live,
}
/// The oracle's sustained-advance run over the package zones under `root`.
#[derive(Debug)]
pub struct Watch {
pub verdict: Verdict,
pub packages: Vec<String>,
pub intervals: u32,
pub advances: Vec<u32>,
pub window: Duration,
}
impl Watch {
/// The verdict and each package's advances, for the test's log line.
pub fn summary(&self) -> String {
let per: Vec<String> =
self.packages.iter().zip(&self.advances).map(|(p, a)| format!("{p} advanced {a}/{}", self.intervals)).collect();
format!("{:?} ({} over {} ms)", self.verdict, per.join(", "), self.window.as_millis())
}
}
/// Sample every package's `energy_uj`, burning 25 ms of this thread's CPU
/// between samples (capped at 500 ms of wall time), until every package
/// has advanced in at least five intervals, at least 80% of those
/// observed, over at least 300 ms; or one second has passed with at least
/// five intervals; or five seconds have.
pub fn watch(root: &Path) -> Watch {
let all = zones(root);
let packages: Vec<&Zone> = all.iter().filter(|z| is_package(z)).collect();
let names: Vec<String> = packages.iter().map(|z| z.dir.clone()).collect();
let none = |verdict| Watch { verdict, packages: names.clone(), intervals: 0, advances: Vec::new(), window: Duration::ZERO };
if packages.is_empty() {
let readable = all.iter().any(|z| top_level(z) && z.energy_uj.is_some());
return none(if readable { Verdict::NoPackage } else { Verdict::Unreadable });
}
let started = Instant::now();
let Some(mut prev) = read(root, &packages) else { return none(Verdict::Unreadable) };
let mut advances = vec![0u32; packages.len()];
let mut intervals = 0u32;
let live = |adv: &[u32], n: u32, w: Duration| {
w >= Duration::from_millis(300) && adv.iter().all(|&a| a >= 5 && u64::from(a) * 5 >= u64::from(n) * 4)
};
loop {
let el = started.elapsed();
if live(&advances, intervals, el) || el >= Duration::from_secs(5) || (el >= Duration::from_secs(1) && intervals >= 5) {
break;
}
burn(Duration::from_millis(25), Duration::from_millis(500));
if let Some(now) = read(root, &packages) {
intervals += 1;
for ((a, n), p) in advances.iter_mut().zip(&now).zip(&prev) {
*a += u32::from(n > p);
}
prev = now;
}
}
let window = started.elapsed();
let verdict = if live(&advances, intervals, window) { Verdict::Live } else { Verdict::Frozen };
Watch { verdict, packages: names, intervals, advances, window }
}
/// The zones under `root`, as one line for the log.
pub fn listing(root: &Path) -> String {
zones(root)
.iter()
.map(|z| {
let kind = if is_package(z) {
"package"
} else if is_psys(z) {
"psys"
} else if is_core(z) {
"core sub-zone"
} else if top_level(z) {
"top-level"
} else {
"not summed"
};
let e = z.energy_uj.map_or("unreadable".to_string(), |e| e.to_string());
let m = z.max_uj.map_or("-".to_string(), |m| m.to_string());
format!("{}={} [{kind}] energy_uj={e} max={m}", z.dir, z.name.as_deref().unwrap_or("?"))
})
.collect::<Vec<_>>()
.join("; ")
}
/// The CI container's view of the host's powercap directory: the same
/// entries, each a symlink into `/sys/devices/virtual/powercap`, which
/// Docker masks with an empty tmpfs, so every target is missing. Built
/// from this host's own listing; removed when dropped.
pub struct MaskedView(pub PathBuf);
impl MaskedView {
/// Build the masked view of this host's powercap entries in a
/// temporary directory named by `tag` and this process.
pub fn of_host(tag: &str) -> MaskedView {
let root = std::env::temp_dir().join(format!("{tag}-masked-powercap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create the masked view");
let masked = root.join("masked-devices-virtual-powercap");
for z in zones(Path::new(POWERCAP)) {
std::os::unix::fs::symlink(masked.join(&z.dir), root.join(&z.dir)).expect("symlink");
}
MaskedView(root)
}
}
impl Drop for MaskedView {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A bracket of reads around the meter's two reads: every package, psys
/// and core zone read at four points, outer and inner on each side.
pub struct Bracket<'a> {
pub set: Vec<&'a Zone>,
pub reads: [Vec<u64>; 4],
}
impl Bracket<'_> {
/// The packages' energy over the outer bracket, in µJ: the most a
/// package-only figure read inside it can be.
pub fn package_outer(&self) -> u64 {
energy(&self.set, &self.reads[0], &self.reads[3], is_package)
}
/// The packages' energy over the inner bracket, in µJ: the least a
/// package-only figure read around it can be.
pub fn package_inner(&self) -> u64 {
energy(&self.set, &self.reads[1], &self.reads[2], is_package)
}
/// The psys zone's energy over the inner bracket, in µJ.
pub fn psys_inner(&self) -> u64 {
energy(&self.set, &self.reads[1], &self.reads[2], is_psys)
}
/// The core sub-zones' energy over the inner bracket, in µJ.
pub fn core_inner(&self) -> u64 {
energy(&self.set, &self.reads[1], &self.reads[2], is_core)
}
/// Whether the bracket read a zone of the kind `which` picks.
pub fn has(&self, which: impl Fn(&Zone) -> bool) -> bool {
self.set.iter().any(|z| which(z))
}
}
/// The family's rule on a figure `uj` the meter read between the
/// bracket's inner and outer reads: no less than the packages' energy
/// over the inner bracket, no more than over the outer one, and below
/// package+psys and package+core where those zones exist. A figure that
/// added a zone reads at least the packages' and that zone's energy over
/// the inner bracket, so `uj < inner + zone` is the double-count test; it
/// can only be put where the zone moved more over the burn than the
/// packages did in the gap between the outer and inner reads (else a
/// correct figure could reach it too), and the line says when it could
/// not. Returns the figures, for the log.
pub fn assert_package_only(b: &Bracket, uj: u64) -> String {
let (inner, outer, psys, core) = (b.package_inner(), b.package_outer(), b.psys_inner(), b.core_inner());
let gap = outer - inner;
let mut line = format!(
"meter {uj} µJ; oracle package-only inner {inner} outer {outer} µJ (gap {gap}); psys inner {psys} µJ; \
core inner {core} µJ"
);
assert!(inner > 0, "the packages did not advance over the burn: {line}");
assert!(inner <= uj && uj <= outer, "the figure is outside the package-only bracket: {line}");
for (zone, present, moved) in [("psys", b.has(is_psys), psys), ("core", b.has(is_core), core)] {
if !present {
continue;
}
assert!(moved > 0, "{zone} did not advance: {line}");
if moved > gap {
assert!(uj < inner + moved, "the figure reaches package+{zone}: {zone} was added: {line}");
line.push_str(&format!("; below package+{zone}"));
} else {
line.push_str(&format!("; {zone} moved less than the gap, so only the outer bound tests it"));
}
}
line
}
/// Every package, psys and core zone of `all` whose counter reads.
pub fn bracket_set(all: &[Zone]) -> Vec<&Zone> {
all.iter().filter(|z| (is_package(z) || is_psys(z) || is_core(z)) && z.energy_uj.is_some()).collect()
}
/// One oracle read of every zone of `set`; panics if one fails.
pub fn read_all(set: &[&Zone]) -> Vec<u64> {
read(Path::new(POWERCAP), set).expect("an oracle read of the bracket zones")
}
/// Read the bracket around `meter_read` (called twice) and `work`:
/// outer, meter, inner, work, inner, meter, outer.
pub fn around<'a, M>(set: Vec<&'a Zone>, mut meter_read: impl FnMut() -> M, work: impl FnOnce()) -> (Bracket<'a>, M, M) {
let o0 = read_all(&set);
let m0 = meter_read();
let o1 = read_all(&set);
work();
let o2 = read_all(&set);
let m1 = meter_read();
let o3 = read_all(&set);
(Bracket { set, reads: [o0, o1, o2, o3] }, m0, m1)
}
}
use oracle::Verdict;
/// The status agrees with the oracle's own verdict, and RAPL is the meter's
/// source exactly when it is live.
#[test]
fn the_rapl_status_agrees_with_the_oracle_and_gates_the_source() {
let _one = serial();
let status = rapl_status();
let watch = oracle::watch(Path::new(oracle::POWERCAP));
let source = meter::meter_source();
eprintln!(
"rapl hardware: {}; oracle {}; status={status:?} source={source:?}",
oracle::listing(Path::new(oracle::POWERCAP)),
watch.summary()
);
assert_eq!(status == CounterStatus::Live, watch.verdict == Verdict::Live, "the status disagrees with the oracle");
match watch.verdict {
Verdict::Unreadable => assert_eq!(status, CounterStatus::Unavailable),
Verdict::NoPackage => assert_eq!(status, CounterStatus::Absent),
Verdict::Frozen => assert_eq!(status, CounterStatus::Frozen),
Verdict::Live => {}
}
assert_eq!(source == Some(MeterSource::Rapl), status == CounterStatus::Live, "selection disagrees with the status");
assert_eq!(meter::hardware_meter_available(), status == CounterStatus::Live);
}
/// Calls of [`ten_ms_of_work`] completed in this process.
static CALLS: AtomicU64 = AtomicU64::new(0);
/// About 10 ms of one thread's CPU work, counted.
fn ten_ms_of_work() {
let t = Instant::now();
let mut acc: u64 = 0;
while t.elapsed() < Duration::from_millis(10) {
for _ in 0..1_000 {
acc = std::hint::black_box(acc.wrapping_add(1));
}
}
CALLS.fetch_add(1, Ordering::SeqCst);
}
/// A burn on every spare core meters as a hardware figure of the CPU
/// package: `HwShunt`, from RAPL, claiming package energy and not the GPU.
/// The figure is a marginal per call (busy power over idle, times the busy
/// window, over the calls), so the calls' total can be no more than the
/// packages' energy over the whole measurement, which the oracle reads
/// around it; on a quiet host a figure that also summed a package's core
/// sub-zone (or psys, where psys tracks load) is about twice the busy margin
/// and overruns it. A figure of 0 is the crate's "inside the idle noise", and
/// is what a host whose other work holds the package at its power limit
/// reads. Where RAPL is not live there is no figure, and `work` never runs.
#[test]
fn an_all_core_burn_meters_inside_the_package_only_bracket() {
let _one = serial();
let all = oracle::zones(Path::new(oracle::POWERCAP));
let set = oracle::bracket_set(&all);
if meter::meter_source() != Some(MeterSource::Rapl) {
let before = CALLS.load(Ordering::SeqCst);
let m = meter::measure_marginal(ten_ms_of_work);
let watch = oracle::watch(Path::new(oracle::POWERCAP));
eprintln!("rapl burn: no RAPL source; figure {m:?}; oracle {}", watch.summary());
assert_eq!(m, None, "a figure with RAPL declined");
assert_eq!(CALLS.load(Ordering::SeqCst), before, "the work ran with RAPL declined");
assert_ne!(watch.verdict, Verdict::Live, "the oracle saw live packages, yet RAPL is not the source");
return;
}
let wall = Instant::now();
let c0 = CALLS.load(Ordering::SeqCst);
let o0 = oracle::read_all(&set);
let m = meter::measure_marginal(ten_ms_of_work).expect("a live RAPL source metered the burn");
let o3 = oracle::read_all(&set);
let calls = CALLS.load(Ordering::SeqCst) - c0;
let outer = oracle::energy(&set, &o0, &o3, oracle::is_package);
let core = oracle::energy(&set, &o0, &o3, oracle::is_core);
let psys = oracle::energy(&set, &o0, &o3, oracle::is_psys);
let total = m.uj * calls;
let energy = m.energy();
eprintln!(
"rapl burn: {calls} calls in {:?}; {} µJ a call, {total} µJ over the calls; oracle package-only {outer} µJ \
around the measurement (idle and busy), core {core} µJ, psys {psys} µJ; energy {energy:?}; domain {:?}",
wall.elapsed(),
m.uj,
m.source.domain()
);
assert_eq!(m.source, MeterSource::Rapl);
assert_eq!(energy.provenance, Provenance::HwShunt);
assert_eq!(energy.measured_uj, Some(m.uj));
let domain = m.source.domain();
assert!(domain.contains("package") && !domain.contains("GPU"), "the RAPL figure claims {domain}");
assert!(calls > 0, "the work never ran");
assert!(outer > 0, "the packages did not advance");
// Each call's figure is rounded to the microjoule: half a microjoule a call.
assert!(total <= outer + calls / 2 + 1, "{total} µJ over the calls overruns the packages' {outer} µJ");
if m.uj == 0 {
// The crate's contract: from a bracket, 0 means the busy window drew no
// more than the idle one. On a runner whose other jobs hold the package
// at its power limit through both windows, that is what it reads.
eprintln!("rapl burn: the busy window drew no more than the idle one (load {:?}): inside the idle noise", loadavg());
}
}
/// The host's load averages, for the log.
fn loadavg() -> Option<String> {
std::fs::read_to_string("/proc/loadavg").ok().map(|s| s.trim().to_string())
}