mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
* claim: #1397 * External STEP corpus: nightly import checks on real-world files (#1397) tests/external-corpus.toml pins 20 real-world NIST PMI STEP files (AP203 geometry-only and AP242 editions from several vendor exporters, public domain) by archive and per-file SHA-256 with the licence recorded. cargo xtask external-corpus downloads each source once into the gitignored corpus/external/, verifies every hash, imports each file headless (file.insert_step + inspect.measure) and checks body count, total volume (0.1 %) and area (0.5 %) within tolerances and time and memory budgets. A file whose upstream hash changed is reported as 'source changed', never as our failure. --bless "why" re-records expectations in the manifest with a dated reason. The External corpus workflow runs it nightly. assets: skip corpus/ when scanning for attribution (downloaded case data is never committed). --------- Co-authored-by: Dahak Builds <dahakbuilds@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Nightly import checks on real-world STEP files (#1397): the external corpus in
|
||||
# tests/external-corpus.toml is downloaded (hash-verified), imported headless, and checked
|
||||
# against tolerance-based expectations — body counts, total volume and area, time and memory
|
||||
# budgets. It is not part of `cargo xtask ci`: it downloads from the internet.
|
||||
#
|
||||
# Import results should improve over time (healing, edge merging, meshing). When a check fails
|
||||
# because the numbers moved for the better (or the source changed upstream), re-record with
|
||||
# `cargo xtask external-corpus --bless "why"` in a pull request, never by editing numbers.
|
||||
name: External corpus
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '40 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
only:
|
||||
description: "Run only this corpus entry name (empty = every entry)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
corpus:
|
||||
name: Import the external STEP corpus
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Build the CLI
|
||||
run: cargo build --profile ci -p solidcraft-cli
|
||||
- name: Import and check every entry
|
||||
env:
|
||||
ONLY: ${{ inputs.only }}
|
||||
run: |
|
||||
if [ -z "$ONLY" ]; then
|
||||
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli
|
||||
else
|
||||
cargo run -q -p xtask -- external-corpus --cli target/ci/solidcraft-cli --only "$ONLY"
|
||||
fi
|
||||
@@ -11,3 +11,7 @@ __pycache__/
|
||||
|
||||
# cargo-mutants output (docs/mutation-testing.md)
|
||||
mutants.out*/
|
||||
|
||||
# external corpus downloads (cargo xtask external-corpus, #1397): fetched at test time,
|
||||
# hash-pinned in tests/external-corpus.toml; never commit case data
|
||||
/corpus/
|
||||
|
||||
Generated
+3
@@ -5987,9 +5987,12 @@ name = "xtask"
|
||||
version = "0.23.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"syn 2.0.119",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# External STEP corpus: nightly import checks on real-world files (#1397).
|
||||
#
|
||||
# Real-world STEP files from many exporters find bugs our own designs never hit.
|
||||
# `cargo xtask external-corpus` downloads each source (cached under corpus/external/,
|
||||
# gitignored), verifies its hash, imports every file headless with solidcraft-cli, and
|
||||
# checks the expected results below within tolerances: import succeeds with no panic,
|
||||
# the body count, total volume and area, and time and memory budgets.
|
||||
#
|
||||
# Import results should improve over time (healing, edge merging, meshing), so
|
||||
# expectations are tolerance-based and re-recorded with a reason:
|
||||
#
|
||||
# cargo xtask external-corpus --bless "why the numbers moved"
|
||||
#
|
||||
# A file whose hash no longer matches upstream is reported as "source changed" and is
|
||||
# never counted as our failure. Everything here is redistributable: never vendor
|
||||
# catalogue models or scraped datasets whose licence forbids redistribution or
|
||||
# automated download.
|
||||
#
|
||||
# Expected values are recorded by --bless; until a file has them the run only records.
|
||||
|
||||
[[source]]
|
||||
name = "nist-pmi"
|
||||
url = "https://www.nist.gov/document/nist-pmi-step-files"
|
||||
archive = "zip"
|
||||
archive_sha256 = "8fa78429e6d8d9b0d7681d223b6aa9ec98c3772185c55b1a0e3679b21c181911"
|
||||
licence = "US Government work (NIST), public domain; acknowledgement appreciated, the NIST logo may not be used in promotion"
|
||||
note = """STEP AP242 and AP203 files of the FTC / CTC / STC test cases of the NIST MBE PMI
|
||||
Validation and Conformance Testing Project, generated by the CAx implementor forums' CAD
|
||||
systems (CATIA, NX, Inventor, Creo, SolidWorks): the same real parts exported by several
|
||||
exporters, some with graphical and semantic PMI.
|
||||
https://www.nist.gov/ctl/smart-connected-systems-division/smart-connected-manufacturing-systems-group/mbe-pmi-0"""
|
||||
|
||||
# AP203, geometry only (no PMI): the plain solid each test case is.
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_01_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ctc_01_asme1_rd.stp"
|
||||
sha256 = "e081d518484d5c708c6729353237f94a7d97da2b74a10d60d4c0d146d67a5855"
|
||||
bodies = 1
|
||||
volume_mm3 = 14643075.947
|
||||
area_mm2 = 807956.848
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_02_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ctc_02_asme1_rc.stp"
|
||||
sha256 = "e6df6d0ed68fd8a91bd3f8360c7047f2af3d61068b89050d84423fe8ddb93bcf"
|
||||
bodies = 2
|
||||
volume_mm3 = 48296408.737
|
||||
area_mm2 = 2486223.950
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_03_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ctc_03_asme1_rc.stp"
|
||||
sha256 = "95a775b1acdbc34de11319cb83b4cf6c6fdd7de8cbfc4af2d0afe5d3565cfdf8"
|
||||
bodies = 1
|
||||
volume_mm3 = 331925.227
|
||||
area_mm2 = 354976.434
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_04_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ctc_04_asme1_rd.stp"
|
||||
sha256 = "cdb59a085a3364941b881f96b511265a7cb5f6e94180c4426bdc3025481f405a"
|
||||
bodies = 4
|
||||
volume_mm3 = 21304707.061
|
||||
area_mm2 = 1072273.103
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_05_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ctc_05_asme1_rd.stp"
|
||||
sha256 = "5140b8f41fa1d0a069ea978e28c92a597f47220d0a11e667b79ff5f7c7243b86"
|
||||
bodies = 2
|
||||
volume_mm3 = 12836438.150
|
||||
area_mm2 = 862844.712
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_06_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ftc_06_asme1_rd.stp"
|
||||
sha256 = "5e30f0903f9470c14a98e70395ca005fe90bcd3fb704c381a80a69958838f169"
|
||||
bodies = 1
|
||||
volume_mm3 = 3323529.788
|
||||
area_mm2 = 283412.727
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_07_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ftc_07_asme1_rd.stp"
|
||||
sha256 = "a0bc551656780b26d7a0ee1beedbc07d43f41efa092f74f3c2d8383417f04481"
|
||||
bodies = 1
|
||||
volume_mm3 = 1726252.351
|
||||
area_mm2 = 391367.944
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_08_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ftc_08_asme1_rc.stp"
|
||||
sha256 = "4b7011b0b86cf6301058aa954b76a9587d23478b6dee2660f4a80caf0dfe8b73"
|
||||
bodies = 1
|
||||
volume_mm3 = 503592.920
|
||||
area_mm2 = 191685.454
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_09_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ftc_09_asme1_rd.stp"
|
||||
sha256 = "e29c09bd2d454601ff1a0d77c4c4ffaf2ed78eee71031ce49f7b7bb5a6d1d4e8"
|
||||
bodies = 1
|
||||
volume_mm3 = 136446.459
|
||||
area_mm2 = 96614.973
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_10_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ftc_10_asme1_rb.stp"
|
||||
sha256 = "2bd726f9fe86aeb96ab517ce876e5696e7951310df5c4fbca36276d65879067b"
|
||||
bodies = 1
|
||||
volume_mm3 = 188340.847
|
||||
area_mm2 = 48526.769
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_11_ap203"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/AP203 geometry only/nist_ftc_11_asme1_rb.stp"
|
||||
sha256 = "a5abfea460499dc31bc812aca68917948a117565fea4f5a080e36eb32f39650d"
|
||||
bodies = 1
|
||||
volume_mm3 = 5127.593
|
||||
area_mm2 = 5473.582
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
# AP242 with PMI, from the different implementors' editions (e1, e2, e3: exports of
|
||||
# different vendor CAD systems): bigger files, PMI entities the reader must skip, and
|
||||
# assemblies (CTC-02 and CTC-04 fit together).
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_01_ap242_e1"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_ctc_01_asme1_ap242-e1.stp"
|
||||
sha256 = "85a5752da05f53c456ca3a9e038c90358e1d5a3141d1f0d6e5f0970f2356e821"
|
||||
bodies = 1
|
||||
volume_mm3 = 14644830.531
|
||||
area_mm2 = 807074.879
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_02_ap242_e2"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_ctc_02_asme1_ap242-e2.stp"
|
||||
sha256 = "99a0a2079ddeb64d05c2432cbe931fa17c89da60d8b0bcb0b11bd0ed94fa2e68"
|
||||
bodies = 3
|
||||
volume_mm3 = 44375748.567
|
||||
area_mm2 = 2490680.661
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ctc_04_ap242_e1"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_ctc_04_asme1_ap242-e1.stp"
|
||||
sha256 = "20b43b54ce25d4ed17cff794084c406e831c687f5b62471d3371eef33669e355"
|
||||
bodies = 2
|
||||
volume_mm3 = 17524327.897
|
||||
area_mm2 = 1023445.779
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_06_ap242_e2"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_ftc_06_asme1_ap242-e2.stp"
|
||||
sha256 = "b87afb7f25adc0d44d12e182209d50f0b085b533c0742fb8ffd58f4c87403447"
|
||||
bodies = 1
|
||||
volume_mm3 = 3323542.680
|
||||
area_mm2 = 283410.930
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_07_ap242_e2"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_ftc_07_asme1_ap242-e2.stp"
|
||||
sha256 = "aa8115c12e12a99cc439477cbfd222916f9ec629fbb3c9002b18e0c264c9a892"
|
||||
bodies = 3
|
||||
volume_mm3 = 1726451.665
|
||||
area_mm2 = 391325.164
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_08_ap242_e2"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_ftc_08_asme1_ap242-e2.stp"
|
||||
sha256 = "7b9283b4288d152fd181d76c39285d7c79fbba34fda1e14a41ac4a983f6d68b3"
|
||||
bodies = 1
|
||||
volume_mm3 = 503633.752
|
||||
area_mm2 = 191703.522
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_ftc_09_ap242_e1"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_ftc_09_asme1_ap242-e1.stp"
|
||||
sha256 = "f1215fe15a78085a9fa78dd81714caf774b59071be92580b04ebfdc19f52a1bd"
|
||||
bodies = 6
|
||||
volume_mm3 = 138057.658
|
||||
area_mm2 = 96761.979
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_stc_06_ap242_e3"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_stc_06_asme1_ap242-e3.stp"
|
||||
sha256 = "71777c28da76da0e8a667e4cbe792d5f72c09b5c56440c9744d3d50ca96ecc8d"
|
||||
bodies = 1
|
||||
volume_mm3 = 1790506.036
|
||||
area_mm2 = 98796.999
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
|
||||
[[file]]
|
||||
name = "nist_stc_07_ap242_e3"
|
||||
source = "nist-pmi"
|
||||
path = "NIST-PMI-STEP-Files/nist_stc_07_asme1_ap242-e3.stp"
|
||||
sha256 = "2dddf9b8894bc241f7ffddf3dc4502baa3d49c374e8784039d14b86473d54832"
|
||||
bodies = 3
|
||||
volume_mm3 = 1726318.102
|
||||
area_mm2 = 391382.797
|
||||
blessed = "2026-10-25: initial recording: NIST PMI corpus, first run on upstream/main 7e24f0e3"
|
||||
@@ -7,6 +7,9 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
syn = { workspace = true, features = ["visit"] }
|
||||
toml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) -> Result<(), String> {
|
||||
for e in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
||||
let e = e.map_err(|e| e.to_string())?;
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') || name.starts_with("target") || matches!(name.as_str(), "plan" | "dist" | "node_modules") {
|
||||
if name.starts_with('.') || name.starts_with("target") || matches!(name.as_str(), "plan" | "dist" | "node_modules" | "corpus") {
|
||||
continue;
|
||||
}
|
||||
let p = e.path();
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
//! `cargo xtask external-corpus [--bless "why"] [--only NAME] [--cli PATH]`: nightly import
|
||||
//! checks on real-world STEP files (#1397).
|
||||
//!
|
||||
//! `tests/external-corpus.toml` lists each source archive (URL, SHA-256, licence) and every
|
||||
//! file inside it (path, SHA-256, expected results). The run downloads each archive once into
|
||||
//! the gitignored `corpus/external/`, verifies every hash, imports each file headless with
|
||||
//! `solidcraft-cli` (`file.insert_step` then `inspect.measure`), and checks the body count,
|
||||
//! total volume and area within tolerances and the time and memory budgets. Import results
|
||||
//! should improve over time, so expectations are re-recorded with a reason:
|
||||
//! `--bless "why"` rewrites the expected values in the manifest. A file whose hash no longer
|
||||
//! matches upstream is reported as "source changed" and never counted as our failure.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const MANIFEST: &str = "tests/external-corpus.toml";
|
||||
const CACHE_DIR: &str = "corpus/external";
|
||||
|
||||
/// Defaults for entries that do not set their own.
|
||||
const DEFAULT_VOLUME_REL_TOL: f64 = 0.001; // the issue: volume within ~0.1 %
|
||||
const DEFAULT_AREA_REL_TOL: f64 = 0.005; // area drifts more than volume as healing improves
|
||||
const DEFAULT_SECONDS_MAX: u64 = 300;
|
||||
const DEFAULT_MEMORY_MB_MAX: u64 = 8192;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Source {
|
||||
name: String,
|
||||
url: String,
|
||||
/// Only "zip" is supported: an archive is unpacked into the cache under the source name.
|
||||
archive: String,
|
||||
archive_sha256: String,
|
||||
licence: String,
|
||||
#[allow(dead_code)] // shown for the reader of the manifest; the runner does not need it
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Entry {
|
||||
name: String,
|
||||
source: String,
|
||||
path: String,
|
||||
sha256: String,
|
||||
/// Expected results; absent until `--bless` records them.
|
||||
bodies: Option<i64>,
|
||||
volume_mm3: Option<f64>,
|
||||
area_mm2: Option<f64>,
|
||||
volume_rel_tol: Option<f64>,
|
||||
area_rel_tol: Option<f64>,
|
||||
seconds_max: Option<u64>,
|
||||
memory_mb_max: Option<u64>,
|
||||
#[allow(dead_code)] // written by --bless so a reviewer can see why the numbers moved
|
||||
blessed: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
source: Vec<Source>,
|
||||
file: Vec<Entry>,
|
||||
}
|
||||
|
||||
/// What one file's import produced, for the report and for --bless.
|
||||
struct Measured {
|
||||
bodies: i64,
|
||||
volume_mm3: f64,
|
||||
area_mm2: f64,
|
||||
seconds: f64,
|
||||
memory_mb: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> {
|
||||
let arg = |name: &str| rest.iter().position(|a| *a == name).and_then(|i| rest.get(i + 1)).copied();
|
||||
let bless_reason = match rest.iter().position(|a| *a == "--bless") {
|
||||
Some(_) => Some(arg("--bless").ok_or("--bless needs a reason: --bless \"why the numbers moved\"")?.to_string()),
|
||||
None => None,
|
||||
};
|
||||
let only = arg("--only");
|
||||
let cli: Vec<String> = match arg("--cli") {
|
||||
Some(path) => vec![path.to_string()],
|
||||
None => {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
vec![cargo, "run".into(), "-q".into(), "--profile".into(), "ci".into(), "-p".into(), "solidcraft-cli".into(), "--".into()]
|
||||
}
|
||||
};
|
||||
|
||||
let manifest_path = root.join(MANIFEST);
|
||||
let text = std::fs::read_to_string(&manifest_path).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
|
||||
let manifest: Manifest = toml::from_str(&text).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
|
||||
let sources: BTreeMap<&str, &Source> = manifest.source.iter().map(|s| (s.name.as_str(), s)).collect();
|
||||
|
||||
let cache = root.join(CACHE_DIR);
|
||||
std::fs::create_dir_all(&cache).map_err(|e| format!("{}: {e}", cache.display()))?;
|
||||
|
||||
let mut pass = 0usize;
|
||||
let mut recorded = 0usize;
|
||||
let mut source_changed = Vec::new();
|
||||
let mut failed: Vec<String> = Vec::new();
|
||||
let mut measured: BTreeMap<String, Measured> = BTreeMap::new();
|
||||
|
||||
for entry in &manifest.file {
|
||||
if only.is_some_and(|o| o != entry.name) {
|
||||
continue;
|
||||
}
|
||||
let source = sources.get(&entry.source.as_str()).ok_or_else(|| format!("{}: unknown source '{}'", entry.name, entry.source))?;
|
||||
let unpacked = ensure_source(&cache, source)?;
|
||||
let file = unpacked.join(&entry.path);
|
||||
let actual = sha256_file(&file)?;
|
||||
if !actual.eq_ignore_ascii_case(&entry.sha256) {
|
||||
source_changed.push(format!("{} (upstream hash is now {actual})", entry.name));
|
||||
continue;
|
||||
}
|
||||
match import_and_measure(&cli, &cache, entry, &file) {
|
||||
Ok(m) => {
|
||||
measured.insert(entry.name.clone(), m);
|
||||
let m = &measured[&entry.name];
|
||||
if entry.volume_mm3.is_none() {
|
||||
recorded += 1;
|
||||
println!(
|
||||
" record {:<27} {:>14.3} mm3, {:>14.3} mm2, {} bodies, {:.1} s (no expected values yet)",
|
||||
entry.name, m.volume_mm3, m.area_mm2, m.bodies, m.seconds
|
||||
);
|
||||
} else {
|
||||
let problems = check(entry, m);
|
||||
if problems.is_empty() {
|
||||
pass += 1;
|
||||
println!(
|
||||
" pass {:<27} {:>14.3} mm3, {:>14.3} mm2, {} bodies, {:.1} s{}",
|
||||
entry.name,
|
||||
m.volume_mm3,
|
||||
m.area_mm2,
|
||||
m.bodies,
|
||||
m.seconds,
|
||||
m.memory_mb.map_or(String::new(), |mb| format!(", {mb:.0} MB"))
|
||||
);
|
||||
} else {
|
||||
for p in problems {
|
||||
failed.push(format!("{}: {p}", entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(format!("{}: {e}", entry.name)),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(reason) = &bless_reason {
|
||||
let stamp = bless_stamp(reason);
|
||||
let rewritten = rewrite_expected(&text, &measured, &stamp);
|
||||
std::fs::write(&manifest_path, rewritten).map_err(|e| format!("{}: {e}", manifest_path.display()))?;
|
||||
println!("external-corpus: re-recorded {} entries ({})", measured.len(), reason);
|
||||
}
|
||||
|
||||
println!("external-corpus: {pass} pass, {recorded} recorded, {} failed, {} source changed", failed.len(), source_changed.len());
|
||||
for f in &failed {
|
||||
println!(" FAIL {f}");
|
||||
}
|
||||
for s in &source_changed {
|
||||
println!(" SOURCE CHANGED {s} — re-download, re-hash and re-record the entry; this is not our failure");
|
||||
}
|
||||
if !failed.is_empty() {
|
||||
return Err(format!("external-corpus: {} file(s) failed", failed.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download and unpack one source into the cache unless it is already there at the pinned
|
||||
/// archive hash. Data only: nothing from the archive is ever executed.
|
||||
fn ensure_source(cache: &Path, source: &Source) -> Result<PathBuf, String> {
|
||||
if source.archive != "zip" {
|
||||
return Err(format!("{}: archive '{}' is not supported (only \"zip\")", source.name, source.archive));
|
||||
}
|
||||
let dir = cache.join(&source.name);
|
||||
let archive = cache.join(format!("{}.download", source.name));
|
||||
let marker = dir.join(".fetched");
|
||||
if marker.is_file() && std::fs::read_to_string(&marker).unwrap_or_default().trim() == source.archive_sha256 {
|
||||
return Ok(dir);
|
||||
}
|
||||
std::fs::create_dir_all(cache).map_err(|e| format!("{}: {e}", cache.display()))?;
|
||||
println!("external-corpus: downloading {} ({})", source.url, source.licence);
|
||||
let status =
|
||||
Command::new("curl").args(["-fsSL", "--retry", "3", "-o"]).arg(&archive).arg(&source.url).status().map_err(|e| format!("curl: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err(format!("{}: download failed with {status}", source.name));
|
||||
}
|
||||
let actual = sha256_file(&archive)?;
|
||||
if !actual.eq_ignore_ascii_case(&source.archive_sha256) {
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
return Err(format!("{}: archive hash is {actual}, manifest says {}", source.name, source.archive_sha256));
|
||||
}
|
||||
// Unpack fresh: a stale tree must not survive a re-pin.
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
unpack_zip(&archive, &dir)?;
|
||||
let _ = std::fs::remove_file(&archive);
|
||||
std::fs::write(&marker, &source.archive_sha256).map_err(|e| format!("{}: {e}", marker.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Unpack a zip, keeping only regular files and refusing entries that would escape the cache
|
||||
/// (absolute paths or `..` components).
|
||||
fn unpack_zip(archive: &Path, dir: &Path) -> Result<(), String> {
|
||||
let f = std::fs::File::open(archive).map_err(|e| format!("{}: {e}", archive.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(f).map_err(|e| format!("{}: {e}", archive.display()))?;
|
||||
for i in 0..zip.len() {
|
||||
let mut item = zip.by_index(i).map_err(|e| format!("entry {i}: {e}"))?;
|
||||
// zip 9 makes the raw name a Result (it may not decode) and `enclosed_name` is None
|
||||
// when the entry would escape the archive root.
|
||||
let raw = item.name().map(|n| n.to_string()).unwrap_or_else(|_| format!("entry {i}"));
|
||||
let Some(rel) = item.enclosed_name() else {
|
||||
return Err(format!("{}: entry {raw} escapes the archive root", archive.display()));
|
||||
};
|
||||
if !item.is_file() {
|
||||
continue;
|
||||
}
|
||||
let out = dir.join(&rel);
|
||||
if let Some(parent) = out.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
let mut dest = std::fs::File::create(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
std::io::copy(&mut item, &mut dest).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, String> {
|
||||
let mut f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = f.read(&mut buf).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// Import the file and measure the result in one headless CLI run; returns what was measured.
|
||||
/// Err means the import itself failed (a crash, a command error, unparsable output).
|
||||
fn import_and_measure(cli: &[String], cache: &Path, entry: &Entry, file: &Path) -> Result<Measured, String> {
|
||||
let work = cache.join("_work");
|
||||
std::fs::create_dir_all(&work).map_err(|e| format!("{}: {e}", work.display()))?;
|
||||
let script = work.join(format!("{}.json", entry.name));
|
||||
let out = work.join(format!("{}.out.json", entry.name));
|
||||
let steps = json!([
|
||||
{"command": "file.insert_step", "params": {"path": file.display().to_string()}},
|
||||
{"command": "inspect.measure", "params": {}}
|
||||
]);
|
||||
std::fs::write(&script, serde_json::to_string(&steps).map_err(|e| e.to_string())?).map_err(|e| format!("{}: {e}", script.display()))?;
|
||||
|
||||
// GNU time, when present, reports the child's peak resident set; without it the memory
|
||||
// budget is not measured (the time budget still is).
|
||||
let gnu_time = gnu_time();
|
||||
let mem_file = work.join(format!("{}.mem", entry.name));
|
||||
let mut cmd = match &gnu_time {
|
||||
Some(t) => {
|
||||
let mut c = Command::new(t);
|
||||
c.args(["-f", "%M", "-o"]).arg(&mem_file);
|
||||
c
|
||||
}
|
||||
None => Command::new(&cli[0]),
|
||||
};
|
||||
if gnu_time.is_some() {
|
||||
cmd.args(&cli[1..]);
|
||||
}
|
||||
let started = Instant::now();
|
||||
cmd.args(["run"]).arg(&script).args(["--results", "--json-out"]).arg(&out).stdout(Stdio::null()).stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| format!("{}: failed to spawn: {e}", cli.join(" ")))?;
|
||||
let limit = Duration::from_secs(entry.seconds_max.unwrap_or(DEFAULT_SECONDS_MAX));
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
|
||||
break status;
|
||||
}
|
||||
if started.elapsed() > limit {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("import took over {} s (budget) — a hang or a blow-up", limit.as_secs()));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let seconds = started.elapsed().as_secs_f64();
|
||||
if !status.success() {
|
||||
let stderr = child.stderr.take().map(|mut e| {
|
||||
let mut t = String::new();
|
||||
let _ = e.read_to_string(&mut t);
|
||||
t.trim().chars().take(400).collect::<String>()
|
||||
});
|
||||
return Err(format!(
|
||||
"import failed ({status}){}",
|
||||
stderr.map(|s| if s.is_empty() { String::new() } else { format!(": {s}") }).unwrap_or_default()
|
||||
));
|
||||
}
|
||||
let raw = std::fs::read_to_string(&out).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
let results: Value = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
let step = |i: usize| -> Result<&Value, String> { results.get(i).ok_or_else(|| format!("no result {i} in {}", out.display())) };
|
||||
let inserted = step(0)?;
|
||||
if let Some(err) = inserted.get("error") {
|
||||
return Err(format!("file.insert_step: {err}"));
|
||||
}
|
||||
let measured = step(1)?;
|
||||
if let Some(err) = measured.get("error") {
|
||||
return Err(format!("inspect.measure: {err}"));
|
||||
}
|
||||
let bodies = measured["body_count"].as_i64().ok_or("inspect.measure: no body_count")?;
|
||||
if bodies == 0 {
|
||||
return Err("import produced no bodies".into());
|
||||
}
|
||||
let volume_mm3 = measured["total"]["volume_mm3"].as_f64().ok_or("inspect.measure: no total volume")?;
|
||||
let area_mm2 = measured["total"]["area_mm2"].as_f64().ok_or("inspect.measure: no total area")?;
|
||||
// A solid measures with positive volume; a non-closed shell measures zero or negative.
|
||||
if volume_mm3 <= 0.0 {
|
||||
return Err(format!("not a closed solid: total volume {volume_mm3} mm3"));
|
||||
}
|
||||
let memory_mb = if gnu_time.is_some() {
|
||||
std::fs::read_to_string(&mem_file).ok().and_then(|t| t.trim().parse::<f64>().ok()).map(|kb| kb / 1024.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Measured { bodies, volume_mm3, area_mm2, seconds, memory_mb })
|
||||
}
|
||||
|
||||
/// The checks against the manifest's expectations; empty means the file passed.
|
||||
fn check(entry: &Entry, m: &Measured) -> Vec<String> {
|
||||
let mut problems = Vec::new();
|
||||
let Some(want_volume) = entry.volume_mm3 else {
|
||||
return problems; // no expected values yet: recorded, nothing to check
|
||||
};
|
||||
if let Some(want) = entry.bodies.filter(|want| m.bodies != *want) {
|
||||
problems.push(format!("body count {} is not the expected {want}", m.bodies));
|
||||
}
|
||||
let rel = (m.volume_mm3 - want_volume).abs() / want_volume.abs();
|
||||
if rel > entry.volume_rel_tol.unwrap_or(DEFAULT_VOLUME_REL_TOL) {
|
||||
problems.push(format!(
|
||||
"volume {:.3} mm3 is {:+.2} % off the expected {want_volume:.3} (tolerance {:.2} %)",
|
||||
m.volume_mm3,
|
||||
(m.volume_mm3 / want_volume - 1.0) * 100.0,
|
||||
entry.volume_rel_tol.unwrap_or(DEFAULT_VOLUME_REL_TOL) * 100.0
|
||||
));
|
||||
}
|
||||
if let Some(want_area) = entry.area_mm2 {
|
||||
let rel = (m.area_mm2 - want_area).abs() / want_area.abs();
|
||||
if rel > entry.area_rel_tol.unwrap_or(DEFAULT_AREA_REL_TOL) {
|
||||
problems.push(format!(
|
||||
"area {:.3} mm2 is {:+.2} % off the expected {want_area:.3} (tolerance {:.2} %)",
|
||||
m.area_mm2,
|
||||
(m.area_mm2 / want_area - 1.0) * 100.0,
|
||||
entry.area_rel_tol.unwrap_or(DEFAULT_AREA_REL_TOL) * 100.0
|
||||
));
|
||||
}
|
||||
}
|
||||
if m.seconds > entry.seconds_max.unwrap_or(DEFAULT_SECONDS_MAX) as f64 {
|
||||
problems.push(format!("import took {:.1} s, over the budget of {} s", m.seconds, entry.seconds_max.unwrap_or(DEFAULT_SECONDS_MAX)));
|
||||
}
|
||||
if let Some(mb) = m.memory_mb {
|
||||
let max = entry.memory_mb_max.unwrap_or(DEFAULT_MEMORY_MB_MAX);
|
||||
if mb > max as f64 {
|
||||
problems.push(format!("peak memory {mb:.0} MB is over the budget of {max} MB"));
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// GNU time's path if `/usr/bin/time` is GNU time (its `%M` is the peak RSS in kB); None
|
||||
/// elsewhere, which skips the memory budget rather than guessing a platform's units.
|
||||
fn gnu_time() -> Option<String> {
|
||||
let out = Command::new("/usr/bin/time").arg("--version").stderr(Stdio::null()).output().ok()?;
|
||||
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
text.contains("GNU time").then(|| "/usr/bin/time".to_string())
|
||||
}
|
||||
|
||||
/// `--bless "why"`: a stamp recorded with each re-recorded entry, so the manifest says when
|
||||
/// and why the numbers last moved.
|
||||
fn bless_stamp(reason: &str) -> String {
|
||||
let days = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs() / 86_400).unwrap_or(0);
|
||||
let (y, m, d) = civil_from_days(days as i64);
|
||||
format!("{y:04}-{m:02}-{d:02}: {reason}")
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch to a civil date (Howard Hinnant's algorithm).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
/// Rewrite the expected-value lines of each blessed entry's `[[file]]` block in place, so
|
||||
/// comments and hand-written keys stay as they are.
|
||||
fn rewrite_expected(text: &str, measured: &BTreeMap<String, Measured>, stamp: &str) -> String {
|
||||
let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
if lines[i].trim() != "[[file]]" {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
let mut end = start + 1;
|
||||
while end < lines.len() && !lines[end].trim_start().starts_with("[[") {
|
||||
end += 1;
|
||||
}
|
||||
let name = lines[start + 1..end]
|
||||
.iter()
|
||||
.find_map(|l| l.trim().strip_prefix("name = \""))
|
||||
.and_then(|r| r.strip_suffix('"'))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let Some(m) = measured.get(&name) else {
|
||||
i = end;
|
||||
continue;
|
||||
};
|
||||
let want: Vec<(&str, String)> = vec![
|
||||
("bodies", m.bodies.to_string()),
|
||||
("volume_mm3", format!("{:.3}", m.volume_mm3)),
|
||||
("area_mm2", format!("{:.3}", m.area_mm2)),
|
||||
("blessed", format!("\"{stamp}\"")),
|
||||
];
|
||||
for (key, value) in want {
|
||||
let prefix = format!("{key} = ");
|
||||
match lines[start + 1..end].iter().position(|l| l.trim_start().starts_with(&prefix)) {
|
||||
Some(at) => lines[start + 1 + at] = format!("{key} = {value}"),
|
||||
None => {
|
||||
// Insert after the last expected-value line we own, or after `sha256`.
|
||||
let anchor = lines[start + 1..end]
|
||||
.iter()
|
||||
.rposition(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with("sha256 = ")
|
||||
|| t.starts_with("bodies = ")
|
||||
|| t.starts_with("volume_mm3 = ")
|
||||
|| t.starts_with("area_mm2 = ")
|
||||
|| t.starts_with("blessed = ")
|
||||
})
|
||||
.unwrap_or(0);
|
||||
lines.insert(start + 1 + anchor + 1, format!("{key} = {value}"));
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
let mut out = lines.join("\n");
|
||||
if text.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::civil_from_days;
|
||||
|
||||
#[test]
|
||||
fn epoch_days_to_civil_dates() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(-1), (1969, 12, 31));
|
||||
assert_eq!(civil_from_days(11_016), (2000, 2, 29)); // a leap day
|
||||
assert_eq!(civil_from_days(20_751), (2026, 10, 25)); // the day this corpus was first recorded
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
mod app_icons;
|
||||
mod assets;
|
||||
mod book;
|
||||
mod external_corpus;
|
||||
mod features;
|
||||
mod i18n;
|
||||
mod icons;
|
||||
@@ -53,6 +54,12 @@ commands:
|
||||
book [--no-build]
|
||||
generate docs/book's command reference and screenshots, then mdbook build
|
||||
into target/book
|
||||
external-corpus [--bless WHY] [--only NAME] [--cli PATH]
|
||||
import the real-world STEP files listed in tests/external-corpus.toml and
|
||||
check body counts, total volumes and areas within tolerances and time and
|
||||
memory budgets (#1397); run nightly by the External corpus workflow, not by
|
||||
ci (it downloads from the internet). --bless re-records the expected values
|
||||
in the manifest with the reason given
|
||||
licences [--check]
|
||||
write THIRD-PARTY-LICENSES.txt (the licences of every shipped crate, embedded
|
||||
in the programs); --check only reports when it is out of date: pull requests
|
||||
@@ -101,6 +108,7 @@ fn main() -> ExitCode {
|
||||
cmd_action_matrix(rest.contains(&"--bless"), arg("--results"), arg("--from"))
|
||||
}
|
||||
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
|
||||
Some("external-corpus") => external_corpus::run(&root(), &rest),
|
||||
Some("licences") => licences::run(&root(), rest.contains(&"--check")),
|
||||
Some("fold-pending") => pending::run(&root()),
|
||||
Some("skills") => skills::run(&root()),
|
||||
|
||||
Reference in New Issue
Block a user