From ff6cf3c8693e1d2f020260c853e7299257b64922 Mon Sep 17 00:00:00 2001 From: Chang Gao <4209517+gaochangw@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:56:04 +0200 Subject: [PATCH 1/9] Converge the shared crates with the suite's timing tool Both tool repositories now vendor the same 209 shared files with the same verification/shared.sha256; `cargo xtask shared diff` finds no difference. From the timing tool's side the shared crates gain: the STA store reader of versions 1 and 2 (opt-timing and resize read the tables the timing tool now commits; the builder writes v2 with unchecked design-rule counts), the Liberty pin attributes min_pulse_width_high/low and min_period with LIBERTY v4 rlib caches, the bounded SPEF reader and `rusteda import --spef`, netlist edit files, the RC waveform models and prepared arc values of the compute crate, typed set_propagated_clock/unset_propagated_clock (SDC-REQ-0070) and the honest design-rule status of `rusteda show` (RCLI-REQ-0023; renumbered from the timing tool's unpublished RCLI-REQ-0021, which here names BLIF/AIGER import). set_propagated_clock now reaches opt-timing, which reports it as S0535 like any other clock propagation it does not model (test extended). Synthesis output is unchanged: against the pinned c5ec23c producer, 283 cases (every fixture design on six libraries and two option sets, SERV and PicoRV32 workspace flows on sky130 and gf180) give byte-identical netlists, netlist tables and normalized reports, and the native checker proves all 282 netlists equivalent. Gates: workspace tests 837 passed, verify synth, verify pdk --require, all-feature clippy, fmt, layout, coverage 619/0, fixtures, deny, reuse, strict docs and the benchmark Python tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 34 + Cargo.lock | 45 ++ Cargo.toml | 4 + crates/apps/rusteda-cli/Cargo.toml | 6 + crates/apps/rusteda-cli/src/import.rs | 56 +- crates/apps/rusteda-cli/src/import_netlist.rs | 13 +- crates/apps/rusteda-cli/src/import_spef.rs | 302 +++++++ crates/apps/rusteda-cli/src/lib.rs | 47 ++ crates/apps/rusteda-cli/src/query.rs | 12 + .../apps/rusteda-cli/tests/import_netlist.rs | 126 +++ crates/apps/rusteda-cli/tests/import_spef.rs | 487 +++++++++++ crates/apps/rusteda-cli/tests/show_drv.rs | 54 ++ .../apps/rustsys-cli/tests/workspace_opt.rs | 11 +- crates/core/rusteda-compute/src/lib.rs | 1 + crates/core/rusteda-compute/src/rc.rs | 446 ++++++++++ crates/core/rusteda-compute/src/timing/abi.rs | 11 + crates/core/rusteda-compute/src/timing/cpu.rs | 28 +- .../core/rusteda-compute/src/timing/cuda.rs | 27 + .../src/timing/kernels/timing.cu | 16 +- crates/core/rusteda-compute/src/timing/mod.rs | 7 +- .../rusteda-compute/src/timing/tests/mod.rs | 1 + .../src/timing/tests/prepared.rs | 109 +++ .../src/timing/tests/random.rs | 1 + .../rusteda-compute/src/timing/validate.rs | 13 + .../rusteda-compute/tests/rc_transient.rs | 155 ++++ crates/core/rusteda-core/src/liberty/model.rs | 7 + crates/core/rusteda-workspace/src/edits.rs | 764 ++++++++++++++++++ .../core/rusteda-workspace/src/edits/apply.rs | 735 +++++++++++++++++ .../core/rusteda-workspace/src/edits/tests.rs | 602 ++++++++++++++ crates/core/rusteda-workspace/src/lib.rs | 2 + crates/core/rusteda-workspace/src/rlib.rs | 18 +- .../core/rusteda-workspace/src/rlib/decode.rs | 6 + crates/core/rusteda-workspace/src/sta.rs | 8 +- .../core/rusteda-workspace/src/sta/build.rs | 39 +- .../core/rusteda-workspace/src/sta/layout.rs | 108 ++- crates/core/rusteda-workspace/src/sta/read.rs | 166 +++- .../core/rusteda-workspace/src/sta/tests.rs | 126 ++- crates/core/rusteda-workspace/tests/rlib.rs | 58 +- crates/formats/rusteda-formats/Cargo.toml | 4 +- crates/formats/rusteda-formats/README.md | 6 + crates/formats/rusteda-formats/src/lib.rs | 6 +- .../rusteda-formats/src/liberty/build.rs | 10 +- .../rusteda-formats/src/liberty/classify.rs | 8 +- .../formats/rusteda-formats/src/sdc/schema.rs | 4 +- .../rusteda-formats/src/sdc/schema/build.rs | 8 + .../rusteda-formats/src/sdc/schema/parse.rs | 2 +- .../formats/rusteda-formats/src/spef/mod.rs | 273 +++++++ .../formats/rusteda-formats/src/spef/parse.rs | 525 ++++++++++++ .../rusteda-formats/src/spef/source.rs | 94 +++ .../formats/rusteda-formats/src/spef/tests.rs | 124 +++ .../tests/liberty_cells_and_pins.rs | 66 +- .../rusteda-formats/tests/sdc_commands.rs | 19 +- docs/adr/0005-sdc-layer.md | 5 +- docs/mkdocs.yml | 1 + docs/specs/compute/timing.md | 11 + docs/specs/core/README.md | 11 +- docs/specs/liberty/liberty.md | 2 +- docs/specs/rusteda-cli/README.md | 5 + docs/specs/rusteda-cli/spef.md | 76 ++ docs/specs/sdc/sdc.md | 6 + docs/specs/workspace/README.md | 4 +- docs/specs/workspace/rlib.md | 16 +- docs/specs/workspace/sta.md | 59 +- docs/verification/requirements.md | 55 +- verification/shared.sha256 | 100 ++- 65 files changed, 5941 insertions(+), 210 deletions(-) create mode 100644 crates/apps/rusteda-cli/src/import_spef.rs create mode 100644 crates/apps/rusteda-cli/tests/import_spef.rs create mode 100644 crates/apps/rusteda-cli/tests/show_drv.rs create mode 100644 crates/core/rusteda-compute/src/rc.rs create mode 100644 crates/core/rusteda-compute/src/timing/tests/prepared.rs create mode 100644 crates/core/rusteda-compute/tests/rc_transient.rs create mode 100644 crates/core/rusteda-workspace/src/edits.rs create mode 100644 crates/core/rusteda-workspace/src/edits/apply.rs create mode 100644 crates/core/rusteda-workspace/src/edits/tests.rs create mode 100644 crates/formats/rusteda-formats/src/spef/mod.rs create mode 100644 crates/formats/rusteda-formats/src/spef/parse.rs create mode 100644 crates/formats/rusteda-formats/src/spef/source.rs create mode 100644 crates/formats/rusteda-formats/src/spef/tests.rs create mode 100644 docs/specs/rusteda-cli/spef.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 272e479..2bae01c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,28 @@ The format follows the "Keep a Changelog" convention; versions follow semantic v where the user's service manager accepts transient scopes, a memory limit; `verify pdk` runs every synthesis and check step in such a scope (`RUSTEDA_VERIFY_MEMORY_MAX`, `RUSTEDA_VERIFY_TIMEOUT_S`, `RUSTEDA_VERIFY_NO_SCOPE`). +- Shared crates converged with those of the suite's timing tool: both repositories vendor the + same 209 shared files with the same `verification/shared.sha256`, and `cargo xtask shared diff` + finds no difference. From the timing tool's side this adds: + - `rusteda import --spef FILE --spef-corner NAME` (repeatable, paired in order) with the budgets + `--max-spef-mib`, `--max-rc-nets`, `--max-rc-nodes`, `--max-rc-elements` and + `--max-rc-name-mib`: bounded detailed RC import into the `parasitics` table through the new + reader `rusteda_formats::spef` (plain or gzip sources, `T0071` to `T0074`), and a warning + `T0140` when a later import drops that table (RCLI-REQ-0070 to RCLI-REQ-0076, + `docs/specs/rusteda-cli/spef.md`); + - `rusteda_workspace::edits`: netlist edit files `rusteda.edits.v1` and their application; + - `rusteda_compute::rc` (effective-capacitance driver and wire waveform models) and prepared + per-arc values (`ArcValues`) in the timing kernel interface, unchanged for callers that pass + none (COMPUTE-REQ-0030); + - the Liberty pin attributes `min_pulse_width_high`, `min_pulse_width_low` and `min_period` + on `Pin` instead of ignored (LIBERTY-REQ-0049), and the compiled library format `LIBERTY` v4 + that carries them (WS-REQ-0117): a cached v3 `.rlib` is compiled again from its `.lib`; + - `set_propagated_clock` and `unset_propagated_clock` are typed constraints kept in + `CONSTR v1` (SDC-REQ-0070) instead of error `D0010`; `rustsys -w opt-timing` reports + `set_propagated_clock` with warning `S0535` as clock propagation that its ideal-clock + estimate does not model; + - `rusteda show` reports `drv_status: "unchecked"` and `null` design-rule counts for a timing + summary without `drv_status` (RCLI-REQ-0023). ### Changed - The README now leads with CLI and agent workflows, recorded runs, structured output, and @@ -87,6 +109,18 @@ The format follows the "Keep a Changelog" convention; versions follow semantic v - The checker rejects `wand`, `wor`, `triand`, `trior`, `tri0`, `tri1`, `trireg` and `interconnect` nets, logic ports declared `supply0` or `supply1`, and driven supply nets (`C0105`); an undriven supply net is a constant. +- `rusteda_workspace::sta` reads timing tables of format versions 1 and 2 (`STA` v2 stores + endpoint rows, corners and path-chain nodes at full width; WS-REQ-0126, WS-REQ-0127, + WS-REQ-0134), so `opt-timing` and `resize` read the tables that the suite's timing tool + commits; `StaTableBuilder` writes version 2 with `null` design-rule counts and + `drv_status: "unchecked"` (WS-REQ-0135, WS-REQ-0137). +- The warning `D0021` of a design-rule constraint now reads "recorded in CONSTR v1; the analysis + that reads it reports whether it is checked". +- Debug builds of `rusteda import --netlist` check the fast flat reader against the full reader + (structural or extended) on small inputs. +- `rusteda-formats` lists SPEF among its formats; the external-simulator oracle tests of + `rusteda-compute` (ignored by default) read `RUSTEDA_SPICE_SIMULATOR`, + `RUSTEDA_RC_ORACLE_WORKSPACE` and `RUSTEDA_RC_WIRE_WORKSPACE`. ### Fixed - The README, the user guide, the synthesis specification (SYNTH-REQ-0516), the roadmap and ADRs diff --git a/Cargo.lock b/Cargo.lock index 367a2f4..6b5a48e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -139,6 +145,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -221,6 +236,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "heck" version = "0.5.0" @@ -291,6 +316,16 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -379,6 +414,8 @@ version = "0.1.0" dependencies = [ "clap", "clap_complete", + "flate2", + "libc", "rayon", "rusteda-core", "rusteda-formats", @@ -409,9 +446,11 @@ dependencies = [ name = "rusteda-formats" version = "0.1.0" dependencies = [ + "flate2", "rayon", "rustc-hash", "rusteda-core", + "serde", "smallvec", "thiserror", ] @@ -551,6 +590,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "smallvec" version = "1.16.2" diff --git a/Cargo.toml b/Cargo.toml index ec220f3..0510169 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ crossterm = { version = "=0.29.0", default-features = false, features = ["window serde = { version = "=1.0.228", features = ["derive"] } # float_roundtrip: the decimal text of a stored f64 parses back to the same value. serde_json = { version = "=1.0.149", features = ["float_roundtrip"] } +# Gzip sources of the SPEF reader (pure-Rust backend). +flate2 = { version = "=1.1.10", default-features = false, features = ["rust_backend"] } +# Unix open flags of the SPEF import. +libc = "=0.2.189" thiserror = "=2.0.18" rayon = "=1.12.0" rustc-hash = "=2.1.2" diff --git a/crates/apps/rusteda-cli/Cargo.toml b/crates/apps/rusteda-cli/Cargo.toml index 51e9a25..d2f8b8f 100644 --- a/crates/apps/rusteda-cli/Cargo.toml +++ b/crates/apps/rusteda-cli/Cargo.toml @@ -29,5 +29,11 @@ rayon.workspace = true serde.workspace = true serde_json.workspace = true +[target.'cfg(unix)'.dependencies] +libc.workspace = true + +[dev-dependencies] +flate2.workspace = true + [lints] workspace = true diff --git a/crates/apps/rusteda-cli/src/import.rs b/crates/apps/rusteda-cli/src/import.rs index 9e0e0ad..63adf4a 100644 --- a/crates/apps/rusteda-cli/src/import.rs +++ b/crates/apps/rusteda-cli/src/import.rs @@ -6,7 +6,9 @@ use std::path::PathBuf; use rusteda_workspace::text::{mutation_header, next_line}; -use rusteda_workspace::{Diagnostic, Envelope, Error, Invocation, JsonMap, JsonObject, Workspace}; +use rusteda_workspace::{ + DiagCode, Diagnostic, Envelope, Error, Invocation, JsonMap, JsonObject, Severity, Workspace, +}; use crate::OutputOutcome; use crate::import_lib::import_libs; @@ -24,6 +26,9 @@ pub(crate) struct ImportCommand { pub netlist: Option, pub sdc_files: Vec, pub sdc_env: Vec, + /// `(--spef, --spef-corner)` pairs in command-line order (RCLI-REQ-0075). + pub spefs: Vec<(PathBuf, String)>, + pub spef_limits: rusteda_formats::spef::Limits, } /// Runs `rusteda import` (RCLI-REQ-0011). @@ -34,9 +39,15 @@ pub(crate) fn run_import( inv: &Invocation, diags: &mut Vec, ) -> Result { - if cmd.libs.is_empty() && cmd.rtl.is_none() && cmd.netlist.is_none() && cmd.sdc_files.is_empty() + if cmd.libs.is_empty() + && cmd.rtl.is_none() + && cmd.netlist.is_none() + && cmd.sdc_files.is_empty() + && cmd.spefs.is_empty() { - let err = Error::usage("import requires at least one of --lib, --rtl, --netlist, or --sdc"); + let err = Error::usage( + "import requires at least one of --lib, --rtl, --netlist, --sdc, or --spef", + ); let _ = ws.log_rejected(inv, None, &err); return Err(err); } @@ -76,6 +87,15 @@ pub(crate) fn run_import( diags, )?; } + if !cmd.spefs.is_empty() { + crate::import_spef::import_spefs( + &mut txn, + &cmd.spefs, + cmd.spef_limits, + &mut metrics, + diags, + )?; + } Ok(()) })(); @@ -86,6 +106,36 @@ pub(crate) fn run_import( let committed = txn.commit(metrics.clone())?; diags.extend(committed.warnings); + // A dropped `parasitics` table is never silent: the next timing run of the revision has no + // extracted parasitics (RCLI-REQ-0076). + if let Some(dropped) = committed + .meta + .dropped + .iter() + .find(|d| d.table == crate::import_spef::TABLE) + { + let corners: Vec = ws + .revision(committed.base) + .ok() + .and_then(|base| crate::import_spef::read_corners(&base).ok()) + .map(|c| c.into_iter().map(|e| e.source.corner).collect()) + .unwrap_or_default(); + let names = if corners.is_empty() { + String::new() + } else { + format!(" of corner(s) {}", corners.join(", ")) + }; + diags.push(Diagnostic::new( + Severity::Warning, + DiagCode::of("T0140"), + format!( + "the extracted parasitics{names} were dropped ({}): timing of {} uses no SPEF \ + until it is imported again; `--require-wire-model spef_rc` makes such a run \ + an error", + dropped.reason, committed.rev + ), + )); + } let mut env = Envelope::new("rusteda import"); env.workspace = Some(ws_arg.to_string()); diff --git a/crates/apps/rusteda-cli/src/import_netlist.rs b/crates/apps/rusteda-cli/src/import_netlist.rs index 055caf3..3c78ed4 100644 --- a/crates/apps/rusteda-cli/src/import_netlist.rs +++ b/crates/apps/rusteda-cli/src/import_netlist.rs @@ -380,13 +380,18 @@ pub(crate) fn import_netlist( let catalog = CellCatalog::from_txn(ws, txn)?; let frozen = if let Some(fast) = fast::try_fast_flat(&text, &catalog) { + // Debug builds check small inputs against the reader that the fast path replaces: the + // structural reader, or the extended elaborator for concatenations, part-selects and + // sized constants. #[cfg(debug_assertions)] if text.len() <= 65_536 - && let Ok(g) = read_structural(&text, &source_str, &|c| { - catalog.cells.get(c).map(|info| info.pins.clone()) - }) + && let Ok(g) = read_netlist_ast(&text, &source_str, &catalog, &mut Vec::new()) { - debug_assert_eq!(fast, g.freeze(), "fast path diverged from read_structural"); + debug_assert_eq!( + fast, + g.freeze(), + "fast path diverged from the netlist reader" + ); } drop(text); fast diff --git a/crates/apps/rusteda-cli/src/import_spef.rs b/crates/apps/rusteda-cli/src/import_spef.rs new file mode 100644 index 0000000..0cdeac1 --- /dev/null +++ b/crates/apps/rusteda-cli/src/import_spef.rs @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Atomic, bounded detailed-parasitic import (RCLI-REQ-0070 to RCLI-REQ-0072, RCLI-REQ-0075). +//! +//! One import binds one or more SPEF files to named corners (`--spef FILE --spef-corner NAME`, +//! repeatable). The `parasitics` table is a `PARASIT` v2 container that holds the decoded source +//! of every corner: the corners of the base revision whose table still describes the netlist of +//! the new revision are kept, and a corner imported again is replaced. +use rusteda_formats::spef::{self, CornerSetMetadata, CornerSource, Limits, Metadata}; +use rusteda_workspace::{ + DiagCode, Diagnostic, Error, Hash64, JsonMap, RbinWriter, Revision, Severity, Txn, +}; +use serde_json::json; +use std::collections::BTreeMap; +use std::fs::{self, OpenOptions}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// The table name and file of the parasitics. +pub(crate) const TABLE: &str = "parasitics"; +const TABLE_PATH: &str = "parasitics.rbin"; +/// Largest metadata section of a container: 4 KiB per corner, for at most 64 corners. +const MAX_METADATA_BYTES: usize = 64 * 4096; +/// The decoded sources of all corners of a table, which an import holds at once, total at most +/// this many per-file byte budgets (RCLI-REQ-0075). +const MAX_TABLE_SOURCES: usize = 8; + +/// One corner of the new table: its metadata and decoded source. +pub(crate) struct CornerEntry { + pub(crate) source: CornerSource, + text: Vec, +} + +fn check_corner(corner: &str) -> Result<(), Error> { + if corner.is_empty() + || corner.len() > 128 + || !corner + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(Error::usage( + "--spef-corner must be a shell-safe corner handle", + )); + } + Ok(()) +} + +/// Reads the raw bytes of one SPEF file, bounded by `limits` (RCLI-REQ-0073). +fn read_source(path: &Path, limits: Limits) -> Result, Error> { + let meta = fs::symlink_metadata(path) + .map_err(|e| Error::input_rejected(format!("{}: {e}", path.display())))?; + if !meta.file_type().is_file() { + return Err(Error::input_rejected( + "SPEF source must be a regular file, not a symlink/FIFO/device", + )); + } + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); + } + let file = options + .open(path) + .map_err(|e| Error::input_rejected(format!("{}: {e}", path.display())))?; + if !file + .metadata() + .map_err(|e| Error::input_rejected(e.to_string()))? + .is_file() + { + return Err(Error::input_rejected("SPEF source is not a regular file")); + } + let mut bytes = Vec::new(); + file.take(limits.max_bytes as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|e| Error::input_rejected(format!("{}: {e}", path.display())))?; + if bytes.len() > limits.max_bytes { + return Err(Error::input_rejected( + "T0072: SPEF source byte budget exceeded", + )); + } + Ok(bytes) +} + +/// The corners of a `parasitics` table of `rev`, read back for carry-forward (RCLI-REQ-0075). +/// A version 1 table holds one corner without a recorded source path, which is taken from the +/// revision's sources of the table. +pub(crate) fn read_corners(rev: &Revision) -> Result, Error> { + let rbin = rev.open_rbin(TABLE, "PARASIT", 1..=2)?; + rbin.verify_hash()?; + let bad = |e: String| Error::input_rejected(format!("invalid PARASIT table: {e}")); + if rbin.format_version() == 1 { + let metadata = rbin.section_bytes(2)?; + if metadata.len() > 4096 { + return Err(bad("metadata budget exceeded".into())); + } + let meta: Metadata = serde_json::from_slice(metadata).map_err(|e| bad(e.to_string()))?; + let (source_path, source_hash) = rev + .meta() + .sources + .iter() + .find(|s| s.table == TABLE) + .map_or_else( + || (String::new(), String::new()), + |s| (s.path.clone(), s.hash.to_string()), + ); + return Ok(vec![CornerEntry { + source: CornerSource { + corner: meta.corner, + limits: meta.limits, + source_path, + source_hash, + }, + text: rbin.section_bytes(1)?.to_vec(), + }]); + } + let metadata = rbin.section_bytes(1)?; + if metadata.len() > MAX_METADATA_BYTES { + return Err(bad("metadata budget exceeded".into())); + } + let set: CornerSetMetadata = + serde_json::from_slice(metadata).map_err(|e| bad(e.to_string()))?; + let mut out = Vec::with_capacity(set.corners.len()); + for (i, source) in set.corners.into_iter().enumerate() { + let text = rbin.section_bytes(i as u32 + 2)?.to_vec(); + out.push(CornerEntry { source, text }); + } + Ok(out) +} + +/// Imports the SPEF files `inputs`, `(path, corner)` in command-line order, into the +/// `parasitics` table of `txn` (RCLI-REQ-0070, RCLI-REQ-0075). +pub(crate) fn import_spefs( + txn: &mut Txn, + inputs: &[(PathBuf, String)], + limits: Limits, + metrics: &mut JsonMap, + diags: &mut Vec, +) -> Result<(), Error> { + limits + .validate() + .map_err(|e| Error::input_rejected(e.to_string()))?; + if inputs.len() > 64 { + return Err(Error::usage("at most 64 --spef corners per import")); + } + for (i, (_, corner)) in inputs.iter().enumerate() { + check_corner(corner)?; + if inputs[..i].iter().any(|(_, c)| c == corner) { + return Err(Error::usage(format!( + "--spef-corner {corner} is given more than once" + ))); + } + if !txn.libraries().iter().any(|l| l.corner == *corner) { + return Err(Error::input_rejected(format!( + "SPEF corner {corner} has no imported library" + ))); + } + } + + // The corners of the base table that still describe the netlist of the new revision. + let mut corners: BTreeMap = BTreeMap::new(); + let base = txn.base(); + if base.has_table(TABLE) { + let base_netlist = base + .open_rbin(TABLE, "PARASIT", 1..=2)? + .upstream_hash("netlist"); + if base_netlist.is_some() && base_netlist == txn.table_hash("netlist") { + for entry in read_corners(base)? { + corners.insert(entry.source.corner.clone(), entry); + } + } + } + let kept: Vec = corners + .keys() + .filter(|c| !inputs.iter().any(|(_, n)| n == *c)) + .cloned() + .collect(); + // The decoded sources held at once: the kept corners and every imported one so far + // (RCLI-REQ-0075). + let table_budget = limits.max_bytes.saturating_mul(MAX_TABLE_SOURCES); + let mut held: usize = corners + .iter() + .filter(|(c, _)| kept.contains(c)) + .map(|(_, e)| e.text.len()) + .sum(); + let mut hold = |n: usize| -> Result<(), Error> { + held = held.saturating_add(n); + if held > table_budget { + return Err(Error::input_rejected(format!( + "T0072: the decoded SPEF sources of the parasitics table exceed {table_budget} \ + bytes ({MAX_TABLE_SOURCES} times the per-file byte budget)" + ))); + } + Ok(()) + }; + hold(0)?; + + let mut per_corner = serde_json::Map::new(); + let mut coupling_records = 0usize; + for (path, corner) in inputs { + let bytes = read_source(path, limits)?; + let mut reject = |e: spef::Error| { + diags.push(Diagnostic::from_parts( + Severity::Error, + DiagCode::of(e.code), + Some((path.display().to_string(), e.line.max(1) as u32, 1)), + e.message.clone(), + None, + )); + Error::input_rejected(e.to_string()) + }; + let decoded = spef::decode_source(&bytes, limits).map_err(&mut reject)?; + hold(decoded.text.len())?; + let parsed = spef::read(&decoded.text, limits).map_err(&mut reject)?; + coupling_records += parsed.coupling_records; + per_corner.insert( + corner.clone(), + json!({ + "nets": parsed.nets.len(), + "nodes": parsed.node_count, + "elements": parsed.element_count, + "compression": decoded.encoding.as_str(), + "source_bytes": bytes.len(), + "decoded_bytes": decoded.text.len(), + }), + ); + if inputs.len() == 1 { + metrics.insert("spef_nets".into(), json!(parsed.nets.len())); + metrics.insert("spef_nodes".into(), json!(parsed.node_count)); + metrics.insert("spef_elements".into(), json!(parsed.element_count)); + metrics.insert("spef_corner".into(), json!(corner)); + metrics.insert("spef_compression".into(), json!(decoded.encoding.as_str())); + metrics.insert("spef_source_bytes".into(), json!(bytes.len())); + metrics.insert("spef_decoded_bytes".into(), json!(decoded.text.len())); + } + corners.insert( + corner.clone(), + CornerEntry { + source: CornerSource { + corner: corner.clone(), + limits, + source_path: path.display().to_string(), + source_hash: Hash64::of(&bytes).to_string(), + }, + text: decoded.text.into_bytes(), + }, + ); + } + + let set = CornerSetMetadata { + corners: corners.values().map(|c| c.source.clone()).collect(), + }; + let metadata = serde_json::to_vec(&set).map_err(|e| Error::internal(e.to_string()))?; + if metadata.len() > MAX_METADATA_BYTES { + return Err(Error::input_rejected( + "T0072: parasitic metadata budget exceeded", + )); + } + let mut writer = RbinWriter::new("PARASIT", 2)?; + writer.section_bytes(1, 1, metadata)?; + for (i, entry) in corners.values().enumerate() { + writer.section_bytes(i as u32 + 2, 1, entry.text.as_slice())?; + } + let upstream = txn.upstream(&["netlist"])?; + txn.add_rbin(TABLE, TABLE_PATH, &writer, upstream)?; + for entry in corners.values() { + let hash = Hash64::parse(&entry.source.source_hash); + if let Some(hash) = hash + && !entry.source.source_path.is_empty() + { + txn.add_source(TABLE, entry.source.source_path.clone(), hash)?; + } + } + // A pre-extraction timing table cannot depend on a table that did not yet exist. + for table in ["sta", "sta_check", "limits"] { + if txn.table_hash(table).is_some() { + txn.drop_table(table, "extracted parasitics imported")?; + } + } + metrics.insert( + "spef_corners".into(), + json!(corners.keys().collect::>()), + ); + metrics.insert( + "spef_imported".into(), + serde_json::Value::Object(per_corner), + ); + metrics.insert("spef_kept_corners".into(), json!(kept)); + if coupling_records > 0 { + diags.push(Diagnostic::new( + Severity::Warning, + DiagCode::of("T0074"), + format!( + "{coupling_records} inter-net coupling records grounded at factor 1; crosstalk \ + not analyzed" + ), + )); + } + Ok(()) +} diff --git a/crates/apps/rusteda-cli/src/lib.rs b/crates/apps/rusteda-cli/src/lib.rs index e0ed54d..636d2b7 100644 --- a/crates/apps/rusteda-cli/src/lib.rs +++ b/crates/apps/rusteda-cli/src/lib.rs @@ -11,6 +11,7 @@ mod import_logic; mod import_netlist; mod import_rtl; mod import_sdc; +mod import_spef; mod init; mod query; mod revert; @@ -136,6 +137,23 @@ pub struct InitArgs {} #[derive(Args, Debug)] pub struct ImportArgs { + /// Detailed RC SPEF (repeatable); each needs an extraction corner, paired in order with + /// `--spef-corner`. + #[arg(long, requires = "spef_corner", value_name = "FILE")] + pub spef: Vec, + /// The library corner of each `--spef`, in the same order (repeatable). + #[arg(long, requires = "spef", value_name = "NAME")] + pub spef_corner: Vec, + #[arg(long, default_value_t = 32, requires = "spef")] + pub max_spef_mib: usize, + #[arg(long, default_value_t = 100_000, requires = "spef")] + pub max_rc_nets: usize, + #[arg(long, default_value_t = 500_000, requires = "spef")] + pub max_rc_nodes: usize, + #[arg(long, default_value_t = 1_000_000, requires = "spef")] + pub max_rc_elements: usize, + #[arg(long, default_value_t = 16, requires = "spef")] + pub max_rc_name_mib: usize, /// Base revision expected to be HEAD (e.g. `rev0`). #[arg(long = "base-rev", value_name = "REV")] pub base_rev: String, @@ -719,6 +737,22 @@ pub fn run(args: Vec) -> ExitCode { None }; + if imp.spef.len() != imp.spef_corner.len() { + let e = Error::usage( + "every --spef FILE needs its own --spef-corner NAME, paired in order", + ); + let _ = ws.log_rejected(&inv, base_rev_parsed, &e); + return finish_error( + sub_name, + Some(&ws_arg), + base_rev_parsed, + e, + Vec::new(), + format, + quiet, + ); + } + if imp.sdc.is_empty() && !imp.sdc_env.is_empty() { let e = Error::usage("--sdc-env requires --sdc ..."); let _ = ws.log_rejected(&inv, base_rev_parsed, &e); @@ -741,6 +775,19 @@ pub fn run(args: Vec) -> ExitCode { netlist: imp.netlist.clone(), sdc_files: imp.sdc.clone(), sdc_env: imp.sdc_env.clone(), + spefs: imp + .spef + .iter() + .cloned() + .zip(imp.spef_corner.iter().cloned()) + .collect(), + spef_limits: rusteda_formats::spef::Limits { + max_bytes: imp.max_spef_mib.saturating_mul(1 << 20), + max_nets: imp.max_rc_nets, + max_nodes: imp.max_rc_nodes, + max_elements: imp.max_rc_elements, + max_name_bytes: imp.max_rc_name_mib.saturating_mul(1 << 20), + }, }; let mut diags = Vec::new(); match run_import(&ws, &ws_arg, &cmd, &inv, &mut diags) { diff --git a/crates/apps/rusteda-cli/src/query.rs b/crates/apps/rusteda-cli/src/query.rs index b26a282..80aca3f 100644 --- a/crates/apps/rusteda-cli/src/query.rs +++ b/crates/apps/rusteda-cli/src/query.rs @@ -155,6 +155,18 @@ pub(crate) fn run_show( metrics.insert(k, v)?; } } + // A timing table that does not state its design-rule status did not check design rules: + // its counts are unknown, never zero (RCLI-REQ-0023, AGENTS rule 4.2). + if let Some(sta) = meta.summary.get("sta") + && !sta.contains_key("drv_status") + { + metrics.insert("drv_status", &"unchecked")?; + for key in ["max_trans_violating", "max_cap_violating"] { + if sta.contains_key(key) { + metrics.insert(key, &Value::Null)?; + } + } + } env.set_metrics(metrics)?; let mut status_obj = JsonObject::new(); diff --git a/crates/apps/rusteda-cli/tests/import_netlist.rs b/crates/apps/rusteda-cli/tests/import_netlist.rs index a7d1578..9c7f52a 100644 --- a/crates/apps/rusteda-cli/tests/import_netlist.rs +++ b/crates/apps/rusteda-cli/tests/import_netlist.rs @@ -316,6 +316,132 @@ endmodule /// RCLI-REQ-0019: Unresolved cell, unresolved pin, and multi-corner pin-direction conflict must /// fail with exit 1 (`INPUT_REJECTED`), a `V0302` diagnostic, and `file:line:col` location. +/// RCLI-REQ-0019: a Yosys-style netlist with concatenations, part-selects in both directions, +/// sized constants and escaped buses in `assign` statements imports bit for bit as the reader +/// specifies (most significant bit first, the bits of a part-select from its left to its right +/// index); a width mismatch is still rejected. Debug builds of `rusteda` also check the fast reader +/// against the full reader on this input. +#[test] +fn import_yosys_style_concatenations_part_selects_and_sized_constants() { + let root = temp_dir("yosys-assigns"); + let ws_path = root.join("ws"); + let ws_s = ws_path.to_str().unwrap(); + let (code, _, _) = run_json(&["init", "-w", ws_s]); + assert_eq!(code, 0); + let lib_file = root.join("synth_pg.lib"); + fs::write(&lib_file, SYNTH_PG_LIB).unwrap(); + + let v_file = root.join("top.v"); + let verilog = r#" +module top(a, b, y, z, q); + input [3:0] a; + input b; + output [3:0] y; + output [1:0] z; + output [0:2] q; + wire [3:0] \u.bus ; + wire [1:0] w; + INVX1 i0 ( + .A(a[0]), + .Y(\u.bus [0]) + ); + INVX1 i1 ( + .A(a[1]), + .Y(\u.bus [1]) + ); + NAND2X1 n0 ( + .A(a[2]), + .B(b), + .Y(w[1]) + ); + assign \u.bus [3:2] = 2'h1; + assign w[0] = 1'b0; + assign y = { \u.bus [1:0], w }; + assign z[1:0] = { a[3], 1'h1 }; + assign q = a[1:3]; +endmodule +"#; + fs::write(&v_file, verilog).unwrap(); + let (code, env, _) = run_json(&[ + "import", + "-w", + ws_s, + "--base-rev", + "rev0", + "--lib", + lib_file.to_str().unwrap(), + "--netlist", + v_file.to_str().unwrap(), + ]); + assert_eq!(code, 0, "import failed: {env}"); + assert_eq!(env["metrics"]["cells"], 3); + assert_eq!(env["metrics"]["assigns"], 12); + + let ws = Workspace::open(&ws_path).unwrap(); + let rev1 = ws.revision(ws.resolve_query("rev1").unwrap()).unwrap(); + let rbin = rusteda_workspace::netlist::open_rev(&rev1).unwrap(); + let n = rusteda_workspace::netlist::decode(&rbin).unwrap(); + let name = |row: u32| -> String { + match row { + rusteda_core::netlist::CONST0 => "0".to_string(), + rusteda_core::netlist::CONST1 => "1".to_string(), + r => { + let rec = &n.nets[r as usize]; + let base = n.string(rec.name).unwrap().to_string(); + if rec.index == rusteda_core::netlist::SCALAR { + base + } else { + format!("{base}[{}]", rec.index) + } + } + } + }; + let got: Vec<(String, String)> = n + .assigns + .iter() + .map(|a| (name(a.lhs), name(a.rhs))) + .collect(); + let want: Vec<(String, String)> = [ + ("u.bus[3]", "0"), + ("u.bus[2]", "1"), + ("w[0]", "0"), + ("y[3]", "u.bus[1]"), + ("y[2]", "u.bus[0]"), + ("y[1]", "w[1]"), + ("y[0]", "w[0]"), + ("z[1]", "a[3]"), + ("z[0]", "1"), + ("q[0]", "a[1]"), + ("q[1]", "a[2]"), + ("q[2]", "a[3]"), + ] + .iter() + .map(|&(l, r)| (l.to_string(), r.to_string())) + .collect(); + assert_eq!(got, want); + + // 3 bits to 4: rejected with a width diagnostic, as by the full reader. + let bad = verilog.replace("assign q = a[1:3];", "assign y = a[2:0];"); + let v_bad = root.join("bad.v"); + fs::write(&v_bad, bad).unwrap(); + let ws2 = root.join("ws2"); + let ws2_s = ws2.to_str().unwrap(); + assert_eq!(run_json(&["init", "-w", ws2_s]).0, 0); + let (code, env, _) = run_json(&[ + "import", + "-w", + ws2_s, + "--base-rev", + "rev0", + "--lib", + lib_file.to_str().unwrap(), + "--netlist", + v_bad.to_str().unwrap(), + ]); + assert_ne!(code, 0, "a width mismatch must be rejected: {env}"); + let _ = fs::remove_dir_all(&root); +} + #[test] fn unresolved_cell_unresolved_pin_and_corner_direction_conflict() { let root = temp_dir("netlist-errors"); diff --git a/crates/apps/rusteda-cli/tests/import_spef.rs b/crates/apps/rusteda-cli/tests/import_spef.rs new file mode 100644 index 0000000..7ed949a --- /dev/null +++ b/crates/apps/rusteda-cli/tests/import_spef.rs @@ -0,0 +1,487 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Atomic import and dependency invalidation (RCLI-REQ-0070, RCLI-REQ-0075, RCLI-REQ-0076, +//! STA-REQ-0700). +use rusteda_workspace::{Invocation, RbinWriter, Upstream, Workspace}; +use serde_json::Value; +use std::{ + fs, + process::{Command, Stdio}, + sync::atomic::{AtomicU64, Ordering}, +}; +fn run(args: &[&str]) -> Value { + let out = Command::new(env!("CARGO_BIN_EXE_rusteda")) + .args(["--format", "json"]) + .args(args) + .stdin(Stdio::null()) + .output() + .unwrap(); + serde_json::from_slice(&out.stdout).unwrap_or_else(|_| panic!("{:?}", out)) +} +#[test] +fn spef_requires_corner_limits_and_invalidates_old_timing() { + static ID: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "spef-import-{}-{}", + std::process::id(), + ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).unwrap(); + let ws_path = dir.join("ws"); + let w = ws_path.to_str().unwrap(); + let lib = dir.join("cells.lib"); + let net = dir.join("top.v"); + let spef = dir.join("top.spef"); + fs::write(&lib, "library (l) { time_unit : \"1ns\"; capacitive_load_unit(1,pf); cell (BUF) { pin(A) {direction: input;} pin(Y) {direction: output; function: \"A\";} } }").unwrap(); + fs::write( + &net, + "module top(input a, output y); BUF u(.A(a),.Y(y)); endmodule\n", + ) + .unwrap(); + fs::write(&spef,"*SPEF \"ieee 1481-1999\"\n*DESIGN top\n*DIVIDER /\n*DELIMITER :\n*BUS_DELIMITER []\n*T_UNIT 1 NS\n*C_UNIT 1 PF\n*R_UNIT 1 OHM\n*D_NET a 0\n*CONN\n*P a I\n*I u:A I\n*RES\n1 a u:A 2\n*END\n").unwrap(); + assert_eq!(run(&["init", "-w", w])["new_rev"], "rev0"); + assert_eq!( + run(&[ + "import", + "-w", + w, + "--base-rev", + "rev0", + "--lib", + lib.to_str().unwrap(), + "--corner", + "tt", + "--netlist", + net.to_str().unwrap() + ])["new_rev"], + "rev1" + ); + // Deliberately stage an old timing table without a parasitic dependency. + { + let ws = Workspace::open(&ws_path).unwrap(); + let base = ws.resolve_base("rev1").unwrap(); + let inv = Invocation::new("test", "0.1.0", "seed", ["seed"]); + let mut tx = ws.begin(base, &inv).unwrap(); + let mut writer = RbinWriter::new("STA", 1).unwrap(); + writer.section_bytes(1, 1, &b"old"[..]).unwrap(); + tx.add_rbin("sta", "sta.rbin", &writer, Upstream::new()) + .unwrap(); + tx.commit(Default::default()).unwrap(); + } + let imported = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev2", + "--spef", + spef.to_str().unwrap(), + "--spef-corner", + "tt", + ]); + assert_eq!(imported["ok"], true, "{imported}"); + assert!(imported["tables"].get("sta").is_none()); + assert!(imported["tables"].get("parasitics").is_some()); + let conflict = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev2", + "--spef", + spef.to_str().unwrap(), + "--spef-corner", + "tt", + ]); + assert_eq!(conflict["error"]["code"], "REVISION_CONFLICT"); + fs::write(&spef, "*NOT_SUPPORTED\n").unwrap(); + let bad = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev3", + "--spef", + spef.to_str().unwrap(), + "--spef-corner", + "tt", + ]); + assert_eq!(bad["ok"], false); + assert_eq!(run(&["show", "-w", w])["rev"], "rev3"); + fs::remove_dir_all(&dir).unwrap(); +} + +// RCLI-REQ-0073: gzip is a bounded transport; canonical table bytes do not depend on it. +#[test] +fn compressed_sources_are_bounded_and_fail_atomically() { + use std::io::Write; + let dir = std::env::temp_dir().join(format!("spef-compressed-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let ws_path = dir.join("ws"); + let w = ws_path.to_str().unwrap(); + let lib = dir.join("cells.lib"); + let net = dir.join("top.v"); + let plain = dir.join("input.spef"); + let compressed = dir.join("input.spef.gz"); + fs::write(&lib,"library (l) { time_unit : \"1ns\"; capacitive_load_unit(1,pf); cell(BUF){pin(A){direction:input;}pin(Y){direction:output;function:\"A\";}}}").unwrap(); + fs::write( + &net, + "module top(input a,output y);BUF u(.A(a),.Y(y));endmodule\n", + ) + .unwrap(); + let source = "*SPEF \"ieee 1481-1999\"\n*DESIGN top\n*DIVIDER /\n*DELIMITER :\n*BUS_DELIMITER []\n*T_UNIT 1 NS\n*C_UNIT 1 PF\n*R_UNIT 1 OHM\n*D_NET a 0\n*CONN\n*P a I\n*I u:A I\n*RES\n1 a u:A 2\n*END\n"; + fs::write(&plain, source).unwrap(); + let gzip = |bytes: &[u8]| { + let mut g = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + g.write_all(bytes).unwrap(); + g.finish().unwrap() + }; + fs::write(&compressed, gzip(source.as_bytes())).unwrap(); + assert_eq!(run(&["init", "-w", w])["new_rev"], "rev0"); + assert_eq!( + run(&[ + "import", + "-w", + w, + "--base-rev", + "rev0", + "--lib", + lib.to_str().unwrap(), + "--corner", + "tt", + "--netlist", + net.to_str().unwrap() + ])["new_rev"], + "rev1" + ); + let a = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev1", + "--spef", + plain.to_str().unwrap(), + "--spef-corner", + "tt", + ]); + assert_eq!(a["ok"], true, "{a}"); + let b = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev2", + "--spef", + compressed.to_str().unwrap(), + "--spef-corner", + "tt", + ]); + assert_eq!(b["ok"], true, "{b}"); + assert_eq!(b["metrics"]["spef_compression"], "gzip"); + let ws = Workspace::open(&ws_path).unwrap(); + let a = ws.revision(ws.resolve_query("rev2").unwrap()).unwrap(); + let b = ws.revision(ws.resolve_query("rev3").unwrap()).unwrap(); + // PARASIT v2 (RCLI-REQ-0075): section 1 names the corners and their sources, section 2 holds + // the decoded source of the only corner, the same for the plain and the gzip file. + let x = a.open_rbin("parasitics", "PARASIT", 2..=2).unwrap(); + let y = b.open_rbin("parasitics", "PARASIT", 2..=2).unwrap(); + assert_eq!(x.section_bytes(2).unwrap(), y.section_bytes(2).unwrap()); + drop((x, y, a, b)); + let mut corrupt = gzip(source.as_bytes()); + let end = corrupt.len() - 8; + corrupt[end] ^= 1; + fs::write(&compressed, corrupt).unwrap(); + let bad = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev3", + "--spef", + compressed.to_str().unwrap(), + "--spef-corner", + "tt", + ]); + assert_eq!(bad["ok"], false); + fs::write(&compressed, gzip(&vec![b'x'; 2 << 20])).unwrap(); + let bad = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev3", + "--spef", + compressed.to_str().unwrap(), + "--spef-corner", + "tt", + "--max-spef-mib", + "1", + ]); + assert_eq!(bad["ok"], false); + assert!(bad["error"]["message"].as_str().unwrap().contains("T0072")); + #[cfg(unix)] + { + let link = dir.join("input-link.spef"); + std::os::unix::fs::symlink(&plain, &link).unwrap(); + let bad = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev3", + "--spef", + link.to_str().unwrap(), + "--spef-corner", + "tt", + ]); + assert_eq!(bad["ok"], false); + } + assert_eq!(run(&["show", "-w", w])["rev"], "rev3"); + fs::remove_dir_all(dir).unwrap(); +} + +// RCLI-REQ-0075: one import binds several SPEF files to named corners in a PARASIT v2 table; a +// later import replaces its corners and keeps the others; flags pair in order; a netlist change +// drops the table. +#[test] +fn several_corners_are_bound_kept_and_replaced() { + use rusteda_formats::spef::CornerSetMetadata; + let dir = std::env::temp_dir().join(format!("spef-corners-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let ws_path = dir.join("ws"); + let w = ws_path.to_str().unwrap(); + let lib = dir.join("cells.lib"); + let net = dir.join("top.v"); + fs::write(&lib, "library (l) { time_unit : \"1ns\"; capacitive_load_unit(1,pf); cell(BUF){pin(A){direction:input;}pin(Y){direction:output;function:\"A\";}}}").unwrap(); + fs::write( + &net, + "module top(input a,output y);BUF u(.A(a),.Y(y));endmodule\n", + ) + .unwrap(); + let spef = |name: &str, res: u32| { + let p = dir.join(format!("{name}.spef")); + fs::write(&p, format!("*SPEF \"ieee 1481-1999\"\n*DESIGN top\n*DIVIDER /\n*DELIMITER :\n*BUS_DELIMITER []\n*T_UNIT 1 NS\n*C_UNIT 1 PF\n*R_UNIT 1 OHM\n*D_NET a 0\n*CONN\n*P a I\n*I u:A I\n*RES\n1 a u:A {res}\n*END\n")).unwrap(); + p.to_str().unwrap().to_string() + }; + let (fast, slow, fast2) = (spef("fast", 1), spef("slow", 3), spef("fast2", 2)); + assert_eq!(run(&["init", "-w", w])["new_rev"], "rev0"); + let l = lib.to_str().unwrap(); + let r = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev0", + "--lib", + l, + "--corner", + "fast", + "--lib", + l, + "--corner", + "slow", + "--netlist", + net.to_str().unwrap(), + ]); + assert_eq!(r["new_rev"], "rev1", "{r}"); + let corners = |rev: &str| -> (Vec, Vec>) { + let ws = Workspace::open(&ws_path).unwrap(); + let r = ws.revision(ws.resolve_query(rev).unwrap()).unwrap(); + let t = r.open_rbin("parasitics", "PARASIT", 2..=2).unwrap(); + let set: CornerSetMetadata = serde_json::from_slice(t.section_bytes(1).unwrap()).unwrap(); + let names = set.corners.iter().map(|c| c.corner.clone()).collect(); + let texts = (0..set.corners.len()) + .map(|i| t.section_bytes(i as u32 + 2).unwrap().to_vec()) + .collect(); + (names, texts) + }; + let both = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev1", + "--spef", + &slow, + "--spef-corner", + "slow", + "--spef", + &fast, + "--spef-corner", + "fast", + ]); + assert_eq!(both["ok"], true, "{both}"); + assert_eq!( + both["metrics"]["spef_corners"], + serde_json::json!(["fast", "slow"]) + ); + let (names, texts) = corners("rev2"); + assert_eq!(names, ["fast", "slow"]); + assert!(String::from_utf8_lossy(&texts[0]).contains("u:A 1")); + assert!(String::from_utf8_lossy(&texts[1]).contains("u:A 3")); + // The netlist and one source per corner. + assert_eq!(both["sources"].as_array().unwrap().len(), 3, "{both}"); + + let again = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev2", + "--spef", + &fast2, + "--spef-corner", + "fast", + ]); + assert_eq!(again["ok"], true, "{again}"); + assert_eq!( + again["metrics"]["spef_kept_corners"], + serde_json::json!(["slow"]) + ); + // A table that is replaced, not dropped, gives no T0140 (RCLI-REQ-0076). + assert!( + !again["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|d| d["code"] == "T0140"), + "{again}" + ); + let (names3, texts3) = corners("rev3"); + assert_eq!(names3, ["fast", "slow"]); + assert!(String::from_utf8_lossy(&texts3[0]).contains("u:A 2")); + assert_eq!(texts3[1], texts[1], "the kept corner keeps its source"); + + for bad in [ + vec![ + "--spef", + fast.as_str(), + "--spef", + slow.as_str(), + "--spef-corner", + "fast", + ], + vec![ + "--spef", + fast.as_str(), + "--spef-corner", + "fast", + "--spef", + slow.as_str(), + "--spef-corner", + "fast", + ], + vec!["--spef", fast.as_str(), "--spef-corner", "typ"], + ] { + let mut args = vec!["import", "-w", w, "--base-rev", "rev3"]; + args.extend(bad.iter().copied()); + let r = run(&args); + assert_eq!(r["ok"], false, "{args:?}: {r}"); + } + assert_eq!(run(&["show", "-w", w])["rev"], "rev3"); + + // A changed netlist drops the parasitics of every corner. + fs::write( + &net, + "module top(input a,output y);wire n;BUF u(.A(a),.Y(n));BUF v(.A(n),.Y(y));endmodule\n", + ) + .unwrap(); + let eco = run(&[ + "import", + "-w", + w, + "--base-rev", + "rev3", + "--netlist", + net.to_str().unwrap(), + ]); + assert_eq!(eco["ok"], true, "{eco}"); + assert!(eco["tables"].get("parasitics").is_none(), "{eco}"); + // RCLI-REQ-0076: the drop is never silent: one warning T0140 names the dropped corners. + let warned: Vec<&Value> = eco["diagnostics"] + .as_array() + .unwrap() + .iter() + .filter(|d| d["code"] == "T0140") + .collect(); + assert_eq!(warned.len(), 1, "{eco}"); + assert_eq!(warned[0]["severity"], "warning", "{eco}"); + let msg = warned[0]["message"].as_str().unwrap(); + assert!(msg.contains("fast, slow") && msg.contains("rev4"), "{msg}"); + fs::remove_dir_all(&dir).unwrap(); +} + +// RCLI-REQ-0075: the decoded sources that one import holds at once total at most eight per-file +// byte budgets: eight corners of almost one MiB each fit a 1 MiB budget, a ninth is rejected with +// T0072 and nothing is committed. +#[test] +fn the_sources_of_a_table_are_bounded_together() { + let dir = std::env::temp_dir().join(format!("spef-budget-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let ws_path = dir.join("ws"); + let w = ws_path.to_str().unwrap(); + let lib = dir.join("cells.lib"); + let net = dir.join("top.v"); + fs::write(&lib, "library (l) { time_unit : \"1ns\"; capacitive_load_unit(1,pf); cell(BUF){pin(A){direction:input;}pin(Y){direction:output;function:\"A\";}}}").unwrap(); + fs::write( + &net, + "module top(input a,output y);BUF u(.A(a),.Y(y));endmodule\n", + ) + .unwrap(); + // A valid SPEF padded with comment lines to just under 1 MiB. + let mut text = String::from( + "*SPEF \"ieee 1481-1999\"\n*DESIGN top\n*DIVIDER /\n*DELIMITER :\n*BUS_DELIMITER []\n*T_UNIT 1 NS\n*C_UNIT 1 PF\n*R_UNIT 1 OHM\n*D_NET a 0\n*CONN\n*P a I\n*I u:A I\n*RES\n1 a u:A 2\n*END\n", + ); + let pad = format!("// {}\n", "x".repeat(1020)); + while text.len() + pad.len() < (1 << 20) - 4096 { + text.push_str(&pad); + } + let spef = dir.join("padded.spef"); + fs::write(&spef, &text).unwrap(); + let s = spef.to_str().unwrap(); + let names: Vec = (0..9).map(|i| format!("c{i}")).collect(); + let l = lib.to_str().unwrap(); + let mut args = vec!["import", "-w", w, "--base-rev", "rev0"]; + for n in &names { + args.extend(["--lib", l, "--corner", n.as_str()]); + } + args.extend(["--netlist", net.to_str().unwrap()]); + assert_eq!(run(&["init", "-w", w])["new_rev"], "rev0"); + assert_eq!(run(&args)["new_rev"], "rev1"); + let import = |count: usize| { + let mut args = vec![ + "import", + "-w", + w, + "--base-rev", + "rev1", + "--max-spef-mib", + "1", + ]; + for n in &names[..count] { + args.extend(["--spef", s, "--spef-corner", n.as_str()]); + } + run(&args) + }; + let nine = import(9); + assert_eq!(nine["ok"], false, "{nine}"); + assert!( + nine["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("T0072"), + "{nine}" + ); + assert_eq!(run(&["show", "-w", w])["rev"], "rev1", "nothing committed"); + let eight = import(8); + assert_eq!(eight["ok"], true, "{eight}"); + assert_eq!( + eight["metrics"]["spef_corners"].as_array().unwrap().len(), + 8 + ); + fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/apps/rusteda-cli/tests/show_drv.rs b/crates/apps/rusteda-cli/tests/show_drv.rs new file mode 100644 index 0000000..56bf30b --- /dev/null +++ b/crates/apps/rusteda-cli/tests/show_drv.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! `rusteda show` reports the design-rule counts of a timing table that does not state its +//! design-rule status as unchecked (RCLI-REQ-0023). +use rusteda_workspace::{Invocation, Upstream, Workspace}; +use serde_json::{Value, json}; +use std::process::{Command, Stdio}; + +fn show(ws: &str, rev: &str) -> Value { + let out = Command::new(env!("CARGO_BIN_EXE_rusteda")) + .args(["--format", "json", "show", "-w", ws, "--rev", rev]) + .stdin(Stdio::null()) + .output() + .unwrap(); + serde_json::from_slice(&out.stdout).unwrap_or_else(|_| panic!("{out:?}")) +} + +// RCLI-REQ-0023: zeros of a table without `drv_status` are not measurements; a stated status +// is reported as recorded. +#[test] +fn design_rule_counts_without_status_are_unchecked() { + let dir = std::env::temp_dir().join(format!("show-drv-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let inv = |c: &str| Invocation::new("test", "0.1.0", c, vec!["test".to_string()]); + let ws = Workspace::init(dir.join("ws"), &inv("init")) + .unwrap() + .workspace; + for (rev, status) in [("rev0", None), ("rev1", Some("met"))] { + let mut txn = ws + .begin(ws.resolve_base(rev).unwrap(), &inv("seed")) + .unwrap(); + txn.add_bytes("sta", "sta.bin", b"timing", Upstream::new()) + .unwrap(); + txn.set_summary("sta", "max_trans_violating", json!(0)) + .unwrap(); + txn.set_summary("sta", "max_cap_violating", json!(0)) + .unwrap(); + if let Some(s) = status { + txn.set_summary("sta", "drv_status", json!(s)).unwrap(); + } + txn.commit(Default::default()).unwrap(); + } + let w = dir.join("ws").to_string_lossy().into_owned(); + let old = show(&w, "rev1"); + assert_eq!(old["metrics"]["drv_status"], "unchecked", "{old}"); + assert_eq!(old["metrics"]["max_trans_violating"], Value::Null); + assert_eq!(old["metrics"]["max_cap_violating"], Value::Null); + let new = show(&w, "rev2"); + assert_eq!(new["metrics"]["drv_status"], "met", "{new}"); + assert_eq!(new["metrics"]["max_trans_violating"], 0); + std::fs::remove_dir_all(&dir).unwrap(); +} diff --git a/crates/apps/rustsys-cli/tests/workspace_opt.rs b/crates/apps/rustsys-cli/tests/workspace_opt.rs index 8985ff7..0c83821 100644 --- a/crates/apps/rustsys-cli/tests/workspace_opt.rs +++ b/crates/apps/rustsys-cli/tests/workspace_opt.rs @@ -657,7 +657,8 @@ fn selections_expand_their_paths() { } // CLI-REQ-0209: the target derived from constraints, with units, overrides and warnings for -// what the estimate does not model; without constraints the target is not derived. +// what the estimate does not model (also `set_propagated_clock`, a typed constraint since +// SDC-REQ-0070); without constraints the target is not derived. #[test] fn constraints_give_the_timing_target() { let sdc = "set_units -time ps -capacitance fF @@ -673,6 +674,7 @@ set_clock_uncertainty -setup 40 [get_clocks clk] set_clock_uncertainty -hold 90 [get_clocks clk] set_false_path -from [get_ports rst_n] set_clock_latency 100 [get_clocks clk] +set_propagated_clock [get_clocks clk] "; let env = WsEnv::new("constraints"); let ws = env.seed("pipeline", Some(sdc)); @@ -698,7 +700,12 @@ set_clock_latency 100 [get_clocks clk] .filter(|d| d["code"] == "S0535") .map(|d| d["message"].as_str().unwrap().to_string()) .collect(); - for want in ["2 clocks", "set_false_path", "set_clock_latency"] { + for want in [ + "2 clocks", + "set_false_path", + "set_clock_latency", + "set_propagated_clock", + ] { assert!( warnings.iter().any(|w| w.contains(want)), "{want}: {warnings:?}" diff --git a/crates/core/rusteda-compute/src/lib.rs b/crates/core/rusteda-compute/src/lib.rs index 87fd8db..9e08f1f 100644 --- a/crates/core/rusteda-compute/src/lib.rs +++ b/crates/core/rusteda-compute/src/lib.rs @@ -18,6 +18,7 @@ #[cfg(feature = "cuda")] pub mod cuda; pub mod cut; +pub mod rc; pub mod timing; use std::panic::{AssertUnwindSafe, catch_unwind}; diff --git a/crates/core/rusteda-compute/src/rc.rs b/crates/core/rusteda-compute/src/rc.rs new file mode 100644 index 0000000..c84035d --- /dev/null +++ b/crates/core/rusteda-compute/src/rc.rs @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Bounded f64 ramp/charge models, independently derived from RC circuit equations. +//! STA-REQ-0721, STA-REQ-0722. All times ns, capacitances pF, resistances kOhm. +use crate::ComputeError; + +/// Progress-normalized trip points: falling voltages are mapped through 1-v. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Thresholds { + pub lower: f64, + pub upper: f64, + pub input: f64, + pub output: f64, + pub derate: f64, +} +impl Thresholds { + pub fn validate(self) -> Result<(), ComputeError> { + let valid = [self.lower, self.upper, self.input, self.output] + .iter() + .all(|x| x.is_finite() && *x > 0.0 && *x < 1.0); + if !valid + || self.lower >= self.upper + || !self.derate.is_finite() + || self.derate <= 0.0 + || self.derate > 1.0 + { + return Err(ComputeError::Invalid("RC trip points/derate".into())); + } + Ok(()) + } +} + +/// Passive moment-matched driving-point pi. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Pi { + pub near: f64, + pub far: f64, + pub resistance: f64, +} +impl Pi { + pub fn total(self) -> f64 { + self.near + self.far + } + pub fn validate(self) -> Result<(), ComputeError> { + if [self.near, self.far, self.resistance, self.total()] + .iter() + .any(|x| !x.is_finite() || *x < 0.0) + { + return Err(ComputeError::Invalid("nonpassive RC pi".into())); + } + Ok(()) + } +} + +/// Fraction of a far capacitance charged during a ramp interval u, for tau=R*C. +pub fn charged_fraction(u: f64, tau: f64) -> f64 { + if tau == 0.0 { + return 1.0; + } + if u == 0.0 { + return 0.0; + } + let x = u / tau; + if x < 1e-3 { + // Cancellation-free series of 1-(1-exp(-x))/x. + x * (0.5 + x * (-1.0 / 6.0 + x * (1.0 / 24.0 + x * (-1.0 / 120.0 + x / 720.0)))) + } else { + 1.0 + (-x).exp_m1() / x + } +} + +/// A fitted finite-ramp Thevenin driver and passive pi response. +#[derive(Debug, Clone, Copy)] +pub struct GateResponse { + pub delay: f64, + pub slew: f64, + pub capacitance: f64, + pub resistance: f64, + pub duration: f64, + pub root_crossing: f64, +} + +fn fit_duration(slew: f64, cap: f64, res: f64, t: Thresholds) -> Result { + let target = slew * t.derate * (t.output - t.lower) / (t.upper - t.lower); + let tau = res * cap; + let minimum = tau * ((1.0 - t.lower) / (1.0 - t.output)).ln(); + if target <= minimum { + return Err(ComputeError::Invalid( + "NLDM transition incompatible with Thevenin resistance".into(), + )); + } + let mut lo = 0.0; + let mut hi = slew * t.derate / (t.upper - t.lower) + 2.0 * tau; + let mut x = (target / (t.output - t.lower)).min(hi); + let tolerance = (hi * 1e-12).max(1e-15); + for _ in 0..56 { + let mid = crossing(x, tau, t.output); + let lower = crossing(x, tau, t.lower); + let residual = mid - lower - target; + if residual.abs() <= tolerance { + return Ok(x); + } + if residual < 0.0 { + lo = x; + } else { + hi = x; + } + let slope = + crossing_gradient(x, tau, t.output, mid) - crossing_gradient(x, tau, t.lower, lower); + let next = x - residual / slope; + x = if next > lo && next < hi { + next + } else { + lo + (hi - lo) * 0.5 + }; + } + Err(ComputeError::Invalid( + "RC source-duration fit did not converge".into(), + )) +} + +#[derive(Debug, Clone, Copy)] +struct Poles { + a: f64, + b: f64, + root_weight: f64, +} +impl Poles { + fn new(pi: Pi, r: f64) -> Self { + let sum = r * pi.total() + pi.resistance * pi.far; + let product = r * pi.resistance * pi.near * pi.far; + let disc = (sum * sum - 4.0 * product).max(0.0).sqrt(); + let a = (sum + disc) * 0.5; + let b = if a > 0.0 { product / a } else { 0.0 }; + let root_weight = if a > b { + (a - pi.resistance * pi.far) / (a - b) + } else { + 1.0 + }; + Self { a, b, root_weight } + } + fn root(self, x: f64, d: f64) -> f64 { + self.root_weight * ramp_voltage(x, d, self.a) + + (1.0 - self.root_weight) * ramp_voltage(x, d, self.b) + } + fn far(self, x: f64, d: f64) -> f64 { + if self.a == self.b { + return self.root(x, d); + } + (self.a * ramp_voltage(x, d, self.a) - self.b * ramp_voltage(x, d, self.b)) + / (self.a - self.b) + } + fn crossing(self, d: f64, p: f64) -> f64 { + let mut lo = 0.0; + let mut hi = d - self.a * (1.0 - p).ln(); + let tolerance = (hi * 1e-13).max(1e-16); + for _ in 0..80 { + if hi - lo <= tolerance { + break; + } + let mid = lo + (hi - lo) * 0.5; + if self.root(mid, d) < p { + lo = mid; + } else { + hi = mid; + } + } + lo + (hi - lo) * 0.5 + } +} + +/// NLDM-compatible Thevenin proxy: the delay sensitivity supplies a tail +/// resistance, with the threshold log converting its time reference. It is an +/// explicit NLDM parameter estimate, not a transistor DC characterization. +/// Match the lower/delay trip points; equate charge over the source's active ramp. +pub fn gate_detail( + pi: Pi, + t: Thresholds, + mut eval: impl FnMut(f64) -> (f64, f64), +) -> Result { + pi.validate()?; + t.validate()?; + if t.output <= t.lower || t.output >= t.upper { + return Err(ComputeError::Invalid( + "RC driver delay threshold must lie within slew trip points".into(), + )); + } + let total = pi.total(); + let (d0, s0) = eval(total); + if !d0.is_finite() || !s0.is_finite() { + return Err(ComputeError::Invalid("RC NLDM nonfinite".into())); + } + if pi.resistance == 0.0 || pi.far == 0.0 || s0 <= 0.0 { + return Ok(GateResponse { + delay: d0, + slew: s0.max(0.0), + capacitance: total, + resistance: 0.0, + duration: s0.max(0.0) * t.derate / (t.upper - t.lower), + root_crossing: s0.max(0.0) * t.derate / (t.upper - t.lower) * t.output, + }); + } + let delta = (total * 0.01).max(1e-12); + let lower = (total - delta).max(0.0); + let derivative = (eval(total + delta).0 - eval(lower).0) / (total + delta - lower); + let res = derivative.max(0.0) * (-(1.0 - t.output).ln()); + if !res.is_finite() || res == 0.0 { + return Err(ComputeError::Invalid( + "NLDM has no positive delay sensitivity for RC driver".into(), + )); + } + let poles = Poles::new(pi, res); + let mut lo = pi.near; + let mut hi = total; + let tolerance = (total * 1e-11).max(1e-16); + for _ in 0..64 { + let effective = lo + (hi - lo) * 0.5; + let (_, s) = eval(effective); + if !s.is_finite() || s <= 0.0 { + return Err(ComputeError::Invalid( + "invalid NLDM transition for RC fitting".into(), + )); + } + let duration = fit_duration(s, effective, res, t)?; + let actual = + pi.near * poles.root(duration, duration) + pi.far * poles.far(duration, duration); + let nominal = effective * ramp_voltage(duration, duration, res * effective); + if nominal > actual { + hi = effective; + } else { + lo = effective; + } + if hi - lo <= tolerance { + break; + } + } + if hi - lo > tolerance { + return Err(ComputeError::Invalid( + "RC charge solve did not converge".into(), + )); + } + let effective = lo + (hi - lo) * 0.5; + let (delay, s) = eval(effective); + let duration = fit_duration(s, effective, res, t)?; + let nominal = effective * ramp_voltage(duration, duration, res * effective); + let actual = pi.near * poles.root(duration, duration) + pi.far * poles.far(duration, duration); + if (nominal - actual).abs() > total.max(1e-16) * 1e-8 { + return Err(ComputeError::Invalid("RC charge residual exceeded".into())); + } + let slew = (poles.crossing(duration, t.upper) - poles.crossing(duration, t.lower)) / t.derate; + let root_crossing = poles.crossing(duration, t.output); + if [delay, slew, duration, root_crossing] + .iter() + .any(|v| !v.is_finite()) + { + return Err(ComputeError::Invalid("RC waveform overflow".into())); + } + Ok(GateResponse { + delay, + slew, + capacitance: effective, + resistance: res, + duration, + root_crossing, + }) +} + +pub fn gate( + pi: Pi, + t: Thresholds, + eval: impl FnMut(f64) -> (f64, f64), +) -> Result<(f64, f64, f64), ComputeError> { + let r = gate_detail(pi, t, eval)?; + Ok((r.delay, r.slew, r.capacitance)) +} + +/// Exact normalized response of a one-pole network to a unit ramp of duration t. +pub fn ramp_voltage(time: f64, duration: f64, tau: f64) -> f64 { + if time <= 0.0 { + return 0.0; + } + if tau == 0.0 { + return if duration == 0.0 { + 1.0 + } else { + (time / duration).min(1.0) + }; + } + if duration == 0.0 { + return -(-time / tau).exp_m1(); + } + if time <= duration { + time / duration * charged_fraction(time, tau) + } else { + 1.0 + tau / duration * (-duration / tau).exp_m1() * (-(time - duration) / tau).exp() + } +} + +fn crossing_gradient(duration: f64, tau: f64, p: f64, time: f64) -> f64 { + if tau == 0.0 { + return p; + } + if time <= duration { + return p / (-(-time / tau).exp_m1()); + } + let x = duration / tau; + if x < 1e-3 { + 0.5 + x * (1.0 / 12.0 - x * x / 720.0) + } else { + 1.0 - 1.0 / x + 1.0 / x.exp_m1() + } +} + +fn crossing(duration: f64, tau: f64, p: f64) -> f64 { + if tau == 0.0 { + return p * duration; + } + if duration == 0.0 { + return -tau * (1.0 - p).ln(); + } + if p >= charged_fraction(duration, tau) { + return duration - tau * ((1.0 - p) * duration / (-tau * (-duration / tau).exp_m1())).ln(); + } + if p * duration / tau > 40.0 { + return p * duration + tau; + } + let mut lo = 0.0; + let mut hi = duration; + let mut x = (p * duration + tau).min(hi); + for _ in 0..48 { + let v = ramp_voltage(x, duration, tau); + if (v - p).abs() <= 4.0 * f64::EPSILON { + return x; + } + if v < p { + lo = x; + } else { + hi = x; + } + let slope = -(-x / tau).exp_m1() / duration; + let next = x - (v - p) / slope; + x = if next > lo && next < hi { + next + } else { + lo + (hi - lo) * 0.5 + }; + } + lo + (hi - lo) * 0.5 +} + +/// Per-sink threshold delay/transition. Primary ports use an exponential envelope; +/// characterized cell outputs use the finite-ramp convolution exactly. +pub fn wire(tau: f64, slew: f64, t: Thresholds, primary: bool) -> Result<(f64, f64), ComputeError> { + t.validate()?; + if !tau.is_finite() || tau < 0.0 || !slew.is_finite() || slew < 0.0 { + return Err(ComputeError::Invalid("invalid RC wire state".into())); + } + let (delay, transition) = if primary { + ( + -tau * (1.0 - t.input).ln(), + slew + tau * ((1.0 - t.lower) / (1.0 - t.upper)).ln() / t.derate, + ) + } else { + let duration = slew * t.derate / (t.upper - t.lower); + ( + crossing(duration, tau, t.input) - t.output * duration, + (crossing(duration, tau, t.upper) - crossing(duration, tau, t.lower)) / t.derate, + ) + }; + if !delay.is_finite() || !transition.is_finite() { + return Err(ComputeError::Invalid("RC threshold solve overflow".into())); + } + Ok((delay, transition.max(0.0))) +} + +#[cfg(test)] +mod tests { + use super::*; + fn trips() -> Thresholds { + Thresholds { + lower: 0.2, + upper: 0.8, + input: 0.5, + output: 0.5, + derate: 1.0, + } + } + // STA-REQ-0721: fixed resource bounds, zero resistance and invalid models. + #[test] + fn limits_and_degenerate_models() { + let t = trips(); + let eval = |c: f64| (0.1 + c, 0.3 + c); + let r = gate_detail( + Pi { + near: 0.01, + far: 0.02, + resistance: 0.0, + }, + t, + eval, + ) + .unwrap(); + assert_eq!(r.capacitance, 0.03); + assert!( + gate_detail( + Pi { + near: -1.0, + ..Pi::default() + }, + t, + eval + ) + .is_err() + ); + assert!( + gate_detail( + Pi { + near: 0.01, + far: 0.02, + resistance: 1.0 + }, + t, + |_| (f64::NAN, 0.1) + ) + .is_err() + ); + for tau in [0.0, 1e-20, 0.01, 1e6] { + let (d, s) = wire(tau, 0.1, t, false).unwrap(); + assert!(d.is_finite() && s.is_finite()); + } + assert!(wire(1.0, 0.1, Thresholds { derate: 0.0, ..t }, false).is_err()); + } + // STA-REQ-0722: normalized polarity/derate, unchanged zero RC, stable small arguments. + #[test] + fn stable_charge_and_zero_rc() { + assert!((charged_fraction(1e-10, 1.0) - 5e-11).abs() < 1e-20); + let t = trips(); + let (d, s) = wire(0.0, 0.2, t, false).unwrap(); + assert_eq!(d, 0.0); + assert!((s - 0.2).abs() < 1e-15); + let (d, s) = wire(0.01, 0.1, t, true).unwrap(); + assert!((d - 0.01 * 2.0_f64.ln()).abs() < 1e-16); + assert!(s > 0.1); + } +} diff --git a/crates/core/rusteda-compute/src/timing/abi.rs b/crates/core/rusteda-compute/src/timing/abi.rs index 142ddc1..800e321 100644 --- a/crates/core/rusteda-compute/src/timing/abi.rs +++ b/crates/core/rusteda-compute/src/timing/abi.rs @@ -182,6 +182,15 @@ impl WireRec { } } +/// Evaluated RC arc values, per (input transition, output transition, bound). +/// CPU waveform preparation is tag-independent; all backends consume identical data. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Pod, Zeroable)] +pub struct ArcValues { + pub delay: [f64; 8], + pub slew: [f64; 8], +} + /// The levelized timing graph (COMPUTE-REQ-0011) with its tag maps (COMPUTE-REQ-0012). #[derive(Debug, Clone, Default, PartialEq)] pub struct LevelizedTimingCsr<'a> { @@ -341,6 +350,8 @@ pub struct TimingProblem<'a> { pub starts: &'a StartRows, /// Empty (ideal wires) or one record per incoming arc in CSR order. pub wires: &'a [WireRec], + /// Empty, or one prepared RC delay/transition record per incoming arc. + pub arc_values: &'a [ArcValues], } impl TimingProblem<'_> { diff --git a/crates/core/rusteda-compute/src/timing/cpu.rs b/crates/core/rusteda-compute/src/timing/cpu.rs index 0760d5a..d952389 100644 --- a/crates/core/rusteda-compute/src/timing/cpu.rs +++ b/crates/core/rusteda-compute/src/timing/cpu.rs @@ -13,8 +13,8 @@ use rayon::prelude::*; use rusteda_core::job::Ctx; use super::abi::{ - ArcClass, InArc, LevelizedTimingCsr, LutDesc, NONE, StartRows, TagMapRec, TimingProblem, - WireRec, + ArcClass, ArcValues, InArc, LevelizedTimingCsr, LutDesc, NONE, StartRows, TagMapRec, + TimingProblem, WireRec, }; use super::lut::lookup; use super::state::{NO_PRED, TimingValues, absent, arr_index, canon, slew_index}; @@ -79,6 +79,7 @@ struct Inputs<'a> { data: &'a [f64], load: &'a [[f64; 4]], wires: &'a [WireRec], + arc_values: &'a [ArcValues], start_row: &'a [u32], starts: &'a StartRows, ntags: usize, @@ -135,7 +136,10 @@ impl Inputs<'_> { continue; } let l = self.load[v][to * 2 + an]; - let (d, q) = if c.is_wire() { + let (d, q) = if let Some(rec) = self.arc_values.get(k) { + let j = (ti * 2 + to) * 2 + an; + (rec.delay[j], rec.slew[j]) + } else if c.is_wire() { match self.wires.get(k) { Some(w) => (w.delay[to * 2 + an], degrade(s, w.slew[to * 2 + an])), None => (0.0, s), @@ -222,6 +226,7 @@ struct CpuSession<'p> { load: Cow<'p, [[f64; 4]]>, starts: Cow<'p, StartRows>, wires: &'p [WireRec], + arc_values: &'p [ArcValues], start_row: Vec, /// The level of every node, built for the first cone. level_of: Vec, @@ -243,6 +248,7 @@ impl<'p> CpuSession<'p> { load: Cow::Borrowed(p.load), starts: Cow::Borrowed(p.starts), wires: p.wires, + arc_values: p.arc_values, start_row: start_index(p.graph.nodes(), p.starts), level_of: Vec::new(), values: TimingValues { @@ -270,6 +276,7 @@ impl<'p> CpuSession<'p> { data: &self.luts.data, load: &self.load, wires: self.wires, + arc_values: self.arc_values, start_row: &self.start_row, starts: &self.starts, ntags: self.ntags as usize, @@ -565,6 +572,11 @@ impl TimingSession for CpuSession<'_> { } fn set_loads(&mut self, edits: &[(u32, [f64; 4])]) -> Result<(), ComputeError> { + if !self.arc_values.is_empty() { + return Err(ComputeError::Invalid( + "re-prepare RC arc values before patching inputs".into(), + )); + } let n = self.nodes(); for &(v, l) in edits { if v as usize >= n { @@ -581,6 +593,11 @@ impl TimingSession for CpuSession<'_> { } fn set_arc_classes(&mut self, edits: &[(u32, u32)]) -> Result<(), ComputeError> { + if !self.arc_values.is_empty() { + return Err(ComputeError::Invalid( + "re-prepare RC arc values before patching inputs".into(), + )); + } let (na, nc) = (self.in_arcs.len(), self.luts.classes.len()); for &(k, c) in edits { if k as usize >= na || c as usize >= nc { @@ -608,6 +625,11 @@ impl TimingSession for CpuSession<'_> { } fn set_starts(&mut self, starts: &StartRows) -> Result<(), ComputeError> { + if !self.arc_values.is_empty() { + return Err(ComputeError::Invalid( + "re-prepare RC arc values before patching inputs".into(), + )); + } let n = self.nodes(); validate_starts(starts, n, self.ntags)?; diff_starts(&self.starts, starts, self.ntags, &mut self.patched); diff --git a/crates/core/rusteda-compute/src/timing/cuda.rs b/crates/core/rusteda-compute/src/timing/cuda.rs index 441bfad..c782e10 100644 --- a/crates/core/rusteda-compute/src/timing/cuda.rs +++ b/crates/core/rusteda-compute/src/timing/cuda.rs @@ -240,6 +240,8 @@ struct CudaSession<'p, R: Real> { d_load: CudaSlice, d_wires: CudaSlice, has_wires: u32, + d_arc_values: CudaSlice, + has_arc_values: u32, d_start_row: CudaSlice, d_start_arr: CudaSlice, d_start_slew: CudaSlice, @@ -276,6 +278,7 @@ fn session_bytes(p: &TimingProblem<'_>) -> usize { + p.luts.luts.len() * 40 + p.luts.data.len() * r + p.wires.len() * 8 * r + + p.arc_values.len() * 16 * r + p.starts.arr.len() * r + p.starts.slew.len() * r; let per_node = n * (4 + 4 * r + w * r + 4 * r + w * 4 + 2); @@ -325,6 +328,9 @@ impl<'p, R: Real> CudaSession<'p, R> { let d_load = upload_slice(&stream, &conv::(load_flat))?; let d_wires = upload_slice(&stream, &conv::(wires_flat))?; let has_wires = u32::from(!p.wires.is_empty()); + let arc_values_flat: &[f64] = bytemuck::cast_slice(p.arc_values); + let d_arc_values = upload_slice(&stream, &conv::(arc_values_flat))?; + let has_arc_values = u32::from(!p.arc_values.is_empty()); let d_start_row = upload_slice(&stream, &start_row_h)?; let d_start_arr = upload_slice(&stream, &conv::(&starts_h.arr))?; let d_start_slew = upload_slice(&stream, &conv::(&starts_h.slew))?; @@ -358,6 +364,8 @@ impl<'p, R: Real> CudaSession<'p, R> { d_load, d_wires, has_wires, + d_arc_values, + has_arc_values, d_start_row, d_start_arr, d_start_slew, @@ -482,6 +490,8 @@ impl<'p, R: Real> CudaSession<'p, R> { .arg(&self.d_load) .arg(&self.d_wires) .arg(&self.has_wires) + .arg(&self.d_arc_values) + .arg(&self.has_arc_values) .arg(&self.d_start_row) .arg(&self.d_start_arr) .arg(&self.d_start_slew) @@ -562,6 +572,8 @@ impl<'p, R: Real> CudaSession<'p, R> { .arg(&self.d_load) .arg(&self.d_wires) .arg(&self.has_wires) + .arg(&self.d_arc_values) + .arg(&self.has_arc_values) .arg(&self.d_start_row) .arg(&self.d_start_arr) .arg(&self.d_start_slew) @@ -796,6 +808,11 @@ impl TimingSession for CudaSession<'_, R> { } fn set_loads(&mut self, edits: &[(u32, [f64; 4])]) -> Result<(), ComputeError> { + if !self.problem.arc_values.is_empty() { + return Err(ComputeError::Invalid( + "re-prepare RC arc values before patching inputs".into(), + )); + } let n = self.nodes(); for &(v, l) in edits { if v as usize >= n { @@ -815,6 +832,11 @@ impl TimingSession for CudaSession<'_, R> { } fn set_arc_classes(&mut self, edits: &[(u32, u32)]) -> Result<(), ComputeError> { + if !self.problem.arc_values.is_empty() { + return Err(ComputeError::Invalid( + "re-prepare RC arc values before patching inputs".into(), + )); + } let (na, nc) = (self.in_arcs_h.len(), self.problem.luts.classes.len()); for &(k, c) in edits { if k as usize >= na || c as usize >= nc { @@ -844,6 +866,11 @@ impl TimingSession for CudaSession<'_, R> { } fn set_starts(&mut self, starts: &StartRows) -> Result<(), ComputeError> { + if !self.problem.arc_values.is_empty() { + return Err(ComputeError::Invalid( + "re-prepare RC arc values before patching inputs".into(), + )); + } let n = self.nodes(); validate_starts(starts, n, self.ntags())?; let t0 = Instant::now(); diff --git a/crates/core/rusteda-compute/src/timing/kernels/timing.cu b/crates/core/rusteda-compute/src/timing/kernels/timing.cu index a428c7d..c2eaa91 100644 --- a/crates/core/rusteda-compute/src/timing/kernels/timing.cu +++ b/crates/core/rusteda-compute/src/timing/kernels/timing.cu @@ -123,6 +123,8 @@ __device__ unsigned long long eval_node( const real* load, const real* wires, unsigned has_wires, + const real* arc_values, + unsigned has_arc_values, const unsigned* start_row, const real* start_arr, const real* start_slew, @@ -175,7 +177,11 @@ __device__ unsigned long long eval_node( } real l = load[v * 4u + to * 2u + an]; real d, q; - if ((flags & WIRE) != 0u) { + if (has_arc_values != 0u) { + const real* av=arc_values+(unsigned long long)k*16ULL; + unsigned j=(ti*2u+to)*2u+an; + d=av[j]; q=av[8u+j]; + } else if ((flags & WIRE) != 0u) { if (has_wires != 0u) { const real* wr = wires + (unsigned long long)k * 8ULL; d = wr[to * 2u + an]; @@ -287,6 +293,8 @@ extern "C" __global__ void propagate_level( const real* load, const real* wires, unsigned has_wires, + const real* arc_values, + unsigned has_arc_values, const unsigned* start_row, const real* start_arr, const real* start_slew, @@ -303,7 +311,7 @@ extern "C" __global__ void propagate_level( unsigned long long w = (unsigned long long)ntags * 4ULL; unsigned long long mx = eval_node( v, ntags, arr, slew, in_ptr, in_arcs, map_ptr, map_tags, map_vals, - classes, luts, data, load, wires, has_wires, start_row, start_arr, start_slew, + classes, luts, data, load, wires, has_wires, arc_values, has_arc_values, start_row, start_arr, start_slew, arr + (unsigned long long)v * w, slew + (unsigned long long)v * 4ULL, pred + (unsigned long long)v * w @@ -332,6 +340,8 @@ extern "C" __global__ void propagate_cone( const real* load, const real* wires, unsigned has_wires, + const real* arc_values, + unsigned has_arc_values, const unsigned* start_row, const real* start_arr, const real* start_slew, @@ -359,7 +369,7 @@ extern "C" __global__ void propagate_cone( real ds[4]; unsigned long long mx = eval_node( v, ntags, arr, slew, in_ptr, in_arcs, map_ptr, map_tags, map_vals, - classes, luts, data, load, wires, has_wires, start_row, start_arr, start_slew, + classes, luts, data, load, wires, has_wires, arc_values, has_arc_values, start_row, start_arr, start_slew, da, ds, dp ); atomicAdd(&stats[0], 1ULL); diff --git a/crates/core/rusteda-compute/src/timing/mod.rs b/crates/core/rusteda-compute/src/timing/mod.rs index 0f8b886..818cebd 100644 --- a/crates/core/rusteda-compute/src/timing/mod.rs +++ b/crates/core/rusteda-compute/src/timing/mod.rs @@ -23,9 +23,9 @@ mod tests; mod validate; pub use abi::{ - ArcClass, CompiledLutBank, FLAG_MASK, InArc, LevelizedTimingCsr, LutDesc, NEGATIVE_UNATE, - NON_UNATE, NONE, POSITIVE_UNATE, StartRows, TagMapRec, TimingProblem, WIRE, WireRec, - out_adjacency, transition_bit, + ArcClass, ArcValues, CompiledLutBank, FLAG_MASK, InArc, LevelizedTimingCsr, LutDesc, + NEGATIVE_UNATE, NON_UNATE, NONE, POSITIVE_UNATE, StartRows, TagMapRec, TimingProblem, WIRE, + WireRec, out_adjacency, transition_bit, }; pub use state::{ EARLY, FALL, LATE, NO_PRED, RISE, TimingStateSoA, TimingValues, absent, arr_index, pred_source, @@ -190,6 +190,7 @@ pub trait TimingPropagationBackend: Send + Sync + std::fmt::Debug { load: &state.load, starts: &state.starts, wires: &[], + arc_values: &[], }; let mut s = self.open(&p)?; let req = Request { diff --git a/crates/core/rusteda-compute/src/timing/tests/mod.rs b/crates/core/rusteda-compute/src/timing/tests/mod.rs index 3370ed6..066d209 100644 --- a/crates/core/rusteda-compute/src/timing/tests/mod.rs +++ b/crates/core/rusteda-compute/src/timing/tests/mod.rs @@ -11,6 +11,7 @@ mod cpu; mod gpu; mod known; pub(crate) mod naive; +mod prepared; pub(crate) mod random; use crate::timing::{CPU, Request, TimingPropagationBackend, TimingValues}; diff --git a/crates/core/rusteda-compute/src/timing/tests/prepared.rs b/crates/core/rusteda-compute/src/timing/tests/prepared.rs new file mode 100644 index 0000000..5cb1cc5 --- /dev/null +++ b/crates/core/rusteda-compute/src/timing/tests/prepared.rs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! COMPUTE-REQ-0030: bounded three-node prepared-arc ABI and frozen-input checks. +//! Additional state <4 KiB/case, two test workers; no path enumeration. +use crate::timing::*; +use crate::{BackendKind, Precision}; +use std::borrow::Cow; + +fn run(kind: BackendKind) -> TimingValues { + let in_ptr = vec![0, 0, 1, 2]; + let arcs = vec![ + InArc { + src: 0, + class: 0, + map: NONE, + }, + InArc { + src: 1, + class: 1, + map: NONE, + }, + ]; + let (out_ptr, out_dst) = out_adjacency(3, &in_ptr, &arcs); + let graph = LevelizedTimingCsr { + level_ptr: Cow::Owned(vec![0, 1, 2, 3]), + in_ptr: Cow::Owned(in_ptr), + in_arcs: Cow::Owned(arcs), + out_ptr: Cow::Owned(out_ptr), + out_dst: Cow::Owned(out_dst), + map_ptr: Cow::Borrowed(&[0]), + maps: Cow::Borrowed(&[]), + }; + let bank = CompiledLutBank { + luts: Cow::Owned(vec![LutDesc::scalar(0), LutDesc::scalar(1)]), + data: Cow::Owned(vec![100.0, 100.0]), + classes: Cow::Owned(vec![ + ArcClass::cell(POSITIVE_UNATE, [0; 2], [1; 2]), + ArcClass::wire(), + ]), + }; + let mut starts = StartRows::default(); + starts.push(0, &[0.0; 4], &[0.1; 4]); + let values = [ + ArcValues { + delay: [0.2; 8], + slew: [0.15; 8], + }, + ArcValues { + delay: [0.03; 8], + slew: [0.17; 8], + }, + ]; + let loads = [[0.0; 4]; 3]; + let problem = TimingProblem { + graph: &graph, + luts: &bank, + ntags: 1, + load: &loads, + starts: &starts, + wires: &[], + arc_values: &values, + }; + problem.validate().unwrap(); + let backend = timing_backend(kind, Precision::F64).unwrap(); + let mut session = backend.open(&problem).unwrap(); + session.propagate(&Request::all()).unwrap(); + assert!(session.set_loads(&[(1, [1.0; 4])]).is_err()); + assert!(session.set_starts(&starts).is_err()); + assert!(session.set_arc_classes(&[(0, 0)]).is_err()); + let result = session.into_values(); + assert!((result.arrival(2, 0, 0, 1) - 0.23).abs() < 1e-15); + assert_eq!(result.transition(2, 0, 1), 0.17); + assert!( + TimingProblem { + arc_values: &values[..1], + ..problem + } + .validate() + .is_err() + ); + let mut invalid = values; + invalid[0].slew[0] = f64::NAN; + assert!( + TimingProblem { + arc_values: &invalid, + ..problem + } + .validate() + .is_err() + ); + result +} + +#[test] +fn prepared_values_and_frozen_inputs() { + run(BackendKind::Cpu); +} + +#[test] +#[cfg(feature = "cuda")] +#[ignore = "requires native CUDA, execute in a physical-memory cgroup"] +fn prepared_cpu_cuda_agree() { + super::assert_same( + &run(BackendKind::Cpu), + &run(BackendKind::Cuda), + "prepared RC arc f64", + ); +} diff --git a/crates/core/rusteda-compute/src/timing/tests/random.rs b/crates/core/rusteda-compute/src/timing/tests/random.rs index 91c1f94..4066eff 100644 --- a/crates/core/rusteda-compute/src/timing/tests/random.rs +++ b/crates/core/rusteda-compute/src/timing/tests/random.rs @@ -107,6 +107,7 @@ impl Owned { load: &self.load, starts: &self.starts, wires: &self.wires, + arc_values: &[], } } diff --git a/crates/core/rusteda-compute/src/timing/validate.rs b/crates/core/rusteda-compute/src/timing/validate.rs index b8ed3d8..6753d46 100644 --- a/crates/core/rusteda-compute/src/timing/validate.rs +++ b/crates/core/rusteda-compute/src/timing/validate.rs @@ -73,6 +73,19 @@ impl TimingProblem<'_> { check_load(v, l)?; } validate_starts(self.starts, n, self.ntags)?; + if !self.arc_values.is_empty() { + if self.arc_values.len() != g.arcs() { + return bad("RC arc value count".into()); + } + for rec in self.arc_values { + if rec.delay.iter().chain(&rec.slew).any(|x| !x.is_finite()) + || rec.slew.iter().any(|x| *x < 0.0) + { + return bad("invalid RC arc values".into()); + } + } + } + if !self.wires.is_empty() { if self.wires.len() != g.arcs() { return bad(format!( diff --git a/crates/core/rusteda-compute/tests/rc_transient.rs b/crates/core/rusteda-compute/tests/rc_transient.rs new file mode 100644 index 0000000..f49a317 --- /dev/null +++ b/crates/core/rusteda-compute/tests/rc_transient.rs @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! External nodal transient validation, not a second RC tree implementation. +//! Test peak estimate: six sequential four-node circuits, <32 MiB each; no paths. +use rusteda_compute::rc::{self, Pi, Thresholds}; +use std::{ + collections::BTreeMap, + fs, + path::PathBuf, + process::{Command, Stdio}, +}; + +fn measurements(text: &str) -> BTreeMap { + text.lines() + .filter_map(|l| { + let (key, value) = l.split_once('=')?; + Some(( + key.trim().to_string(), + value.split_whitespace().next()?.parse().ok()?, + )) + }) + .collect() +} + +// STA-REQ-0724: the external simulator independently solves capacitor current ODEs. +#[test] +#[ignore = "requires RUSTEDA_SPICE_SIMULATOR and RUSTEDA_RC_ORACLE_WORKSPACE, with OS caps"] +fn simulator_charge_and_thresholds() { + let tool = PathBuf::from( + std::env::var("RUSTEDA_SPICE_SIMULATOR").expect("external simulator required"), + ); + let root = PathBuf::from( + std::env::var("RUSTEDA_RC_ORACLE_WORKSPACE").expect("evidence workspace required"), + ); + fs::create_dir(&root).unwrap(); + for (i, (res, derate, low, high)) in [ + (0.01, 1.0, 0.2, 0.8), + (1.0, 1.0, 0.2, 0.8), + (20.0, 1.0, 0.2, 0.8), + (0.1, 0.5, 0.3, 0.7), + (5.0, 0.5, 0.3, 0.7), + ] + .into_iter() + .enumerate() + { + let pi = Pi { + near: 0.01, + far: 0.02, + resistance: res, + }; + let t = Thresholds { + lower: low, + upper: high, + input: 0.5, + output: 0.5, + derate, + }; + // A bounded synthetic NLDM law; the oracle receives the fitted circuit, + // then checks its actual charge and measured voltage crossing times. + let lookup = |c: f64| (0.05 + 2.0 * c, 0.3 + 3.0 * c); + let r = rc::gate_detail(pi, t, lookup).unwrap(); + let stop = 0.1 + r.duration + 30.0 * (r.resistance * pi.total() + pi.resistance * pi.far); + let step = stop / 100000.0; + let path = root.join(format!("case{i}.cir")); + let log = root.join(format!("case{i}.log")); + let circuit = format!( + "RC driver transient\nVsrc src 0 PWL(0 0 0.1n 0 {end}n 1)\nRd src root {rd}\nRpi root far {rp}\nCn root 0 {cn}p\nCf far 0 {cf}p\nRnom src nom {rd}\nCnom nom 0 {ce}p\n.control\nset numdgt=17\ntran {step}n {stop}n\nmeas tran tlo WHEN v(root)={low} RISE=1\nmeas tran tmid WHEN v(root)=0.5 RISE=1\nmeas tran thi WHEN v(root)={high} RISE=1\nmeas tran ntlo WHEN v(nom)={low} RISE=1\nmeas tran ntmid WHEN v(nom)=0.5 RISE=1\nmeas tran vr FIND v(root) AT={end}n\nmeas tran vf FIND v(far) AT={end}n\nmeas tran vn FIND v(nom) AT={end}n\nquit\n.endc\n.end\n", + end = 0.1 + r.duration, + rd = r.resistance * 1000.0, + rp = pi.resistance * 1000.0, + cn = pi.near, + cf = pi.far, + ce = r.capacitance + ); + fs::write(&path, circuit).unwrap(); + let file = fs::File::create(&log).unwrap(); + let status = Command::new("timeout") + .args(["--kill-after=2s", "35s"]) + .arg(&tool) + .args(["-b"]) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::from(file.try_clone().unwrap())) + .stderr(Stdio::from(file)) + .status() + .unwrap(); + assert!(status.success(), "simulator failed: {}", log.display()); + assert!(fs::metadata(&log).unwrap().len() < 1 << 20); + let m = measurements(&fs::read_to_string(log).unwrap()); + let crossing = (m["tmid"] * 1e9 - 0.1 - r.root_crossing).abs(); + let slew = ((m["thi"] - m["tlo"]) * 1e9 / derate - r.slew).abs(); + let fit = ((m["ntmid"] - m["ntlo"]) * 1e9 + - lookup(r.capacitance).1 * derate * (0.5 - low) / (high - low)) + .abs(); + let charge = (pi.near * m["vr"] + pi.far * m["vf"] - r.capacitance * m["vn"]).abs(); + fs::write(root.join(format!("case{i}.metrics")),format!("crossing_error_ns {crossing:e}\nslew_error_ns {slew:e}\nfit_error_ns {fit:e}\ncharge_error_pf {charge:e}\n")).unwrap(); + assert!(crossing < 1e-6 && slew < 1e-6 && fit < 1e-6 && charge < 1e-8); + } +} + +// STA-REQ-0722: ideal-source single-pole step/ramp response, independent transient oracle. +#[test] +#[ignore = "requires RUSTEDA_SPICE_SIMULATOR and a fresh RUSTEDA_RC_WIRE_WORKSPACE, with OS caps"] +fn simulator_wire_thresholds() { + let tool = std::env::var("RUSTEDA_SPICE_SIMULATOR").expect("external simulator required"); + let root = PathBuf::from( + std::env::var("RUSTEDA_RC_WIRE_WORKSPACE").expect("evidence workspace required"), + ); + fs::create_dir(&root).unwrap(); + for (i, (tau, s, primary, derate, low, high)) in [ + (0.02, 0.0, true, 1.0, 0.2, 0.8), + (0.002, 0.3, false, 1.0, 0.2, 0.8), + (0.1, 0.02, false, 1.0, 0.2, 0.8), + (0.2, 0.3, false, 0.5, 0.3, 0.7), + ] + .into_iter() + .enumerate() + { + let t = Thresholds { + lower: low, + upper: high, + input: 0.5, + output: 0.5, + derate, + }; + let (d, q) = rc::wire(tau, s, t, primary).unwrap(); + let duration = s * derate / (high - low); + let end = 0.1 + duration.max(1e-8); + let stop = end + 30.0 * tau; + let path = root.join(format!("wire{i}.cir")); + let log = root.join(format!("wire{i}.log")); + fs::write(&path,format!("RC interconnect transient\nVsrc in 0 PWL(0 0 0.1n 0 {end}n 1)\nR1 in out {r}\nC1 out 0 1p\n.control\nset numdgt=17\ntran {step}n {stop}n\nmeas tran tlo WHEN v(out)={low} RISE=1\nmeas tran tmid WHEN v(out)=0.5 RISE=1\nmeas tran thi WHEN v(out)={high} RISE=1\nquit\n.endc\n.end\n",r=tau*1000.0,step=stop/100000.0)).unwrap(); + let file = fs::File::create(&log).unwrap(); + let status = Command::new("timeout") + .args(["--kill-after=2s", "35s", &tool, "-b"]) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::from(file.try_clone().unwrap())) + .stderr(Stdio::from(file)) + .status() + .unwrap(); + assert!(status.success()); + assert!(fs::metadata(&log).unwrap().len() < 1 << 20); + let m = measurements(&fs::read_to_string(log).unwrap()); + let de = (m["tmid"] * 1e9 - 0.1 - duration * 0.5 - d).abs(); + let se = ((m["thi"] - m["tlo"]) * 1e9 / derate - q).abs(); + fs::write( + root.join(format!("wire{i}.metrics")), + format!("delay_error_ns {de:e}\nslew_error_ns {se:e}\n"), + ) + .unwrap(); + assert!(de < 1e-6 && se < 1e-6); + } +} diff --git a/crates/core/rusteda-core/src/liberty/model.rs b/crates/core/rusteda-core/src/liberty/model.rs index 23d20bb..e9f3f00 100644 --- a/crates/core/rusteda-core/src/liberty/model.rs +++ b/crates/core/rusteda-core/src/liberty/model.rs @@ -101,6 +101,13 @@ pub struct Pin { pub max_fanout: Option, /// Dimensionless input load for the fanout design rule. pub fanout_load: Option, + /// `min_pulse_width_high` and `min_pulse_width_low`: the unconditional minimum widths of the + /// high and the low pulse at the pin, in library units (LIBERTY-REQ-0049). + pub min_pulse_width_high: Option, + pub min_pulse_width_low: Option, + /// `min_period`: the unconditional minimum period of the signal at the pin, in library units + /// (LIBERTY-REQ-0049). + pub min_period: Option, pub is_clock: bool, pub arcs: Vec, /// `internal_power` entries (LIBERTY-REQ-0016). diff --git a/crates/core/rusteda-workspace/src/edits.rs b/crates/core/rusteda-workspace/src/edits.rs new file mode 100644 index 0000000..849a806 --- /dev/null +++ b/crates/core/rusteda-workspace/src/edits.rs @@ -0,0 +1,764 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Netlist edit files `rusteda.edits.v1`: the edits an optimizer proposes to a revision's netlist +//! and the queries it asks about them (WS-REQ-0140 to WS-REQ-0159, `docs/specs/workspace/edits.md`). +//! +//! An edit file holds `base` edits, accepted already, then `candidates`, each a list of edits that +//! is evaluated on its own after the base edits, and a `report` request: endpoints by name or +//! `ep` handle and ranked paths by `path` handle. Edits name netlist objects by name, never +//! by an id of one tool, so that any tool of the suite can write and read them. The five edit +//! kinds are `resize`, `insert_buffer`, `remove_buffer`, `swap_pins` and `clone`. +//! +//! [`parse`] reads and checks a file: its schema, every name against the naming rules of the +//! netlist (CORE-REQ-0123), the candidate ids against the handle rules, and the bounds of +//! [`Limits`]; [`EditFile::to_json`] writes the canonical form back. [`apply`] applies one edit to +//! a [`GateNetlist`](rusteda_core::netlist::GateNetlist) by a fixed sequence of netlist operations, +//! the same in every tool, so that the netlist a tool commits after accepting edits is the one +//! the edits were evaluated on. Whether an edit keeps the logic of the netlist (the function of a +//! cell) is a question for the tool that knows the cell libraries; this module checks structure +//! only. Every error has a `W03nn` code ([`codes`]) and the JSON path of what it is about. + +mod apply; +#[cfg(test)] +mod tests; + +use std::fmt; + +use rusteda_core::names; +use serde_json::{Map, Value}; + +use crate::envelope::{DiagCode, Diagnostic, Severity}; + +pub use apply::{Applied, apply, find_net, port_bit_net}; + +/// The schema id of an edit file (WS-REQ-0140). +pub const SCHEMA: &str = "rusteda.edits.v1"; + +/// The diagnostic codes of edit files and edits (WS-REQ-0144, WS-REQ-0151). +pub mod codes { + use crate::envelope::DiagCode; + + /// The file is not JSON, or a field is missing, of the wrong type or unknown. + pub const INVALID: DiagCode = DiagCode::of("W0300"); + /// `schema` is missing or is not [`super::SCHEMA`]. + pub const SCHEMA: DiagCode = DiagCode::of("W0301"); + /// A name breaks the naming rules of the netlist. + pub const NAME: DiagCode = DiagCode::of("W0302"); + /// A candidate id is not a handle-safe word, or two candidates share it. + pub const CANDIDATE_ID: DiagCode = DiagCode::of("W0303"); + /// A bound of [`super::Limits`] is exceeded. + pub const LIMIT: DiagCode = DiagCode::of("W0304"); + /// No instance has the name. + pub const UNKNOWN_INSTANCE: DiagCode = DiagCode::of("W0305"); + /// No net has the name, or the name is ambiguous. + pub const UNKNOWN_NET: DiagCode = DiagCode::of("W0306"); + /// No pin of the instance, or no port, has the name. + pub const UNKNOWN_PIN: DiagCode = DiagCode::of("W0307"); + /// The netlist has no cell of the name and none is supplied. + pub const UNKNOWN_CELL: DiagCode = DiagCode::of("W0308"); + /// A new instance or net would take a name in use. + pub const NAME_IN_USE: DiagCode = DiagCode::of("W0309"); + /// A sink is not on the net, drives it, is listed twice, or is a port that cannot move. + pub const SINK: DiagCode = DiagCode::of("W0310"); + /// The instance or cell does not have the shape of a buffer, or its nets cannot merge. + pub const BUFFER: DiagCode = DiagCode::of("W0311"); + /// The pins of a pin swap are not two distinct connected input pins. + pub const PIN_SWAP: DiagCode = DiagCode::of("W0312"); + /// The instance of a clone does not drive exactly one net from exactly one output pin. + pub const CLONE: DiagCode = DiagCode::of("W0313"); + /// The cell of a resize is not pin-compatible, or another netlist operation failed. + pub const NETLIST: DiagCode = DiagCode::of("W0314"); +} + +/// Bounds of an edit file (WS-REQ-0144): a file beyond them is rejected before any edit is +/// applied, so that reading it never allocates beyond them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Bytes of the file text. + pub max_bytes: usize, + /// Candidates. + pub max_candidates: usize, + /// Edits of `base` and of each candidate. + pub max_edits: usize, + /// Sinks of one edit. + pub max_sinks: usize, + /// Entries of `report.endpoints` and of `report.paths`. + pub max_report: usize, +} + +impl Default for Limits { + fn default() -> Limits { + Limits { + max_bytes: 64 << 20, + max_candidates: 1024, + max_edits: 4096, + max_sinks: 65_536, + max_report: 4096, + } + } +} + +/// A sink of a net: a pin of an instance or a port (WS-REQ-0142). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Sink { + /// The pin `pin` of the instance `instance`. + Pin { + /// The instance name. + instance: String, + /// The cell pin name. + pin: String, + }, + /// The port `port`: a scalar port, or a bit `name[index]` of a bus port. + Port { + /// The port name. + port: String, + }, +} + +/// One netlist edit (WS-REQ-0142). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Edit { + /// Gives the instance another cell with the same pins and the same function. + Resize { + /// The instance. + instance: String, + /// The new cell. + cell: String, + }, + /// Splits the net with a new buffer that drives the sinks on a new net. + InsertBuffer { + /// The net to split. + net: String, + /// The cell of the buffer. + cell: String, + /// The name of the new buffer instance. + instance: String, + /// The name of the new net. + net_out: String, + /// The sinks of `net` that move behind the buffer. + sinks: Vec, + }, + /// Removes a buffer and merges its output net into its input net. + RemoveBuffer { + /// The buffer instance. + instance: String, + }, + /// Exchanges the connections of two input pins of an instance. + SwapPins { + /// The instance. + instance: String, + /// The first input pin. + pin_a: String, + /// The second input pin. + pin_b: String, + }, + /// Duplicates a driver with the same input connections and moves sinks to the copy. + Clone { + /// The instance to duplicate. + instance: String, + /// The name of the copy. + new_instance: String, + /// The name of the net the copy drives. + new_net: String, + /// The sinks of the instance's output net that move to the copy. + sinks: Vec, + }, +} + +impl Edit { + /// The `op` of the edit: `resize`, `insert_buffer`, `remove_buffer`, `swap_pins` or `clone`. + pub fn op(&self) -> &'static str { + match self { + Edit::Resize { .. } => "resize", + Edit::InsertBuffer { .. } => "insert_buffer", + Edit::RemoveBuffer { .. } => "remove_buffer", + Edit::SwapPins { .. } => "swap_pins", + Edit::Clone { .. } => "clone", + } + } + + /// The edit as a JSON object in canonical form (WS-REQ-0145). + pub fn to_value(&self) -> Value { + let mut o = Map::new(); + o.insert("op".into(), Value::from(self.op())); + let mut put = |k: &str, v: &str| { + o.insert(k.into(), Value::from(v)); + }; + match self { + Edit::Resize { instance, cell } => { + put("instance", instance); + put("cell", cell); + } + Edit::InsertBuffer { + net, + cell, + instance, + net_out, + sinks, + } => { + put("net", net); + put("cell", cell); + put("instance", instance); + put("net_out", net_out); + o.insert("sinks".into(), sinks_value(sinks)); + } + Edit::RemoveBuffer { instance } => put("instance", instance), + Edit::SwapPins { + instance, + pin_a, + pin_b, + } => { + put("instance", instance); + put("pin_a", pin_a); + put("pin_b", pin_b); + } + Edit::Clone { + instance, + new_instance, + new_net, + sinks, + } => { + put("instance", instance); + put("new_instance", new_instance); + put("new_net", new_net); + o.insert("sinks".into(), sinks_value(sinks)); + } + } + Value::Object(o) + } +} + +fn sinks_value(sinks: &[Sink]) -> Value { + Value::Array( + sinks + .iter() + .map(|s| { + let mut o = Map::new(); + match s { + Sink::Pin { instance, pin } => { + o.insert("instance".into(), Value::from(instance.as_str())); + o.insert("pin".into(), Value::from(pin.as_str())); + } + Sink::Port { port } => { + o.insert("port".into(), Value::from(port.as_str())); + } + } + Value::Object(o) + }) + .collect(), + ) +} + +/// A candidate: edits evaluated together, after the base edits (WS-REQ-0143). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// Its id, a handle-safe word unique in the file. + pub id: String, + /// Its edits, in order. + pub edits: Vec, +} + +/// The values a reader of the file reports for the base and for each candidate (WS-REQ-0143). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ReportRequest { + /// Endpoints: a pin `inst/pin`, a port, or an endpoint handle `ep`. + pub endpoints: Vec, + /// Ranked paths: path handles `path`. + pub paths: Vec, +} + +/// An edit file (WS-REQ-0140). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EditFile { + /// Edits applied first, in order. + pub base: Vec, + /// Candidates, each evaluated after `base` and independently of the others. + pub candidates: Vec, + /// What to report. + pub report: ReportRequest, +} + +impl EditFile { + /// The file as canonical JSON text: two-space indentation, the keys of each object in + /// byte order, a final newline (WS-REQ-0145). [`parse`] reads it back unchanged. + pub fn to_json(&self) -> String { + let mut o = Map::new(); + o.insert("schema".into(), Value::from(SCHEMA)); + o.insert( + "base".into(), + Value::Array(self.base.iter().map(Edit::to_value).collect()), + ); + o.insert( + "candidates".into(), + Value::Array( + self.candidates + .iter() + .map(|c| { + let mut co = Map::new(); + co.insert("id".into(), Value::from(c.id.as_str())); + co.insert( + "edits".into(), + Value::Array(c.edits.iter().map(Edit::to_value).collect()), + ); + Value::Object(co) + }) + .collect(), + ), + ); + let mut r = Map::new(); + r.insert( + "endpoints".into(), + Value::from(self.report.endpoints.clone()), + ); + r.insert("paths".into(), Value::from(self.report.paths.clone())); + o.insert("report".into(), Value::Object(r)); + let mut text = + serde_json::to_string_pretty(&canonical(Value::Object(o))).unwrap_or_default(); + text.push('\n'); + text + } +} + +/// `value` with the keys of every object inserted in byte order, so that the text is the same +/// whatever the map type of `serde_json`. +fn canonical(value: Value) -> Value { + match value { + Value::Object(o) => { + let mut entries: Vec<(String, Value)> = o.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + Value::Object( + entries + .into_iter() + .map(|(k, v)| (k, canonical(v))) + .collect(), + ) + } + Value::Array(a) => Value::Array(a.into_iter().map(canonical).collect()), + v => v, + } +} + +/// An error of an edit file or of an edit (WS-REQ-0144, WS-REQ-0151). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EditsError { + /// Its `W03nn` code. + pub code: DiagCode, + /// The JSON path of the value it is about, such as `candidates[2].edits[0].cell`; empty for + /// the whole file. + pub at: String, + /// What is wrong. + pub message: String, +} + +impl EditsError { + /// An error with `code` at `at`. + pub fn new(code: DiagCode, at: impl Into, message: impl Into) -> EditsError { + EditsError { + code, + at: at.into(), + message: message.into(), + } + } + + /// The error at `prefix` followed by its own path. + pub fn within(mut self, prefix: &str) -> EditsError { + self.at = if self.at.is_empty() { + prefix.to_string() + } else if prefix.is_empty() { + self.at + } else { + format!("{prefix}.{}", self.at) + }; + self + } + + /// The error as an error diagnostic of the envelope. + pub fn to_diagnostic(&self) -> Diagnostic { + Diagnostic::new(Severity::Error, self.code, self.to_string()) + } +} + +impl fmt::Display for EditsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.at.is_empty() { + write!(f, "{}", self.message) + } else { + write!(f, "{}: {}", self.at, self.message) + } + } +} + +impl std::error::Error for EditsError {} + +/// Reads and checks an edit file (WS-REQ-0140 to WS-REQ-0144): the JSON structure, the schema, +/// every name, the candidate ids and the bounds of `limits`. Unknown keys are errors, so that a +/// misspelt field is never ignored. +pub fn parse(text: &str, limits: &Limits) -> Result { + if text.len() > limits.max_bytes { + return Err(EditsError::new( + codes::LIMIT, + "", + format!( + "the file has {} bytes, above the limit of {}", + text.len(), + limits.max_bytes + ), + )); + } + let value: Value = serde_json::from_str(text) + .map_err(|e| EditsError::new(codes::INVALID, "", format!("not valid JSON: {e}")))?; + let o = object(&value, "")?; + known_keys(o, "", &["schema", "base", "candidates", "report"])?; + match o.get("schema") { + Some(Value::String(s)) if s == SCHEMA => {} + Some(Value::String(s)) => { + return Err(EditsError::new( + codes::SCHEMA, + "schema", + format!("schema '{s}' is not '{SCHEMA}'"), + )); + } + _ => { + return Err(EditsError::new( + codes::SCHEMA, + "schema", + format!("the file has no schema '{SCHEMA}'"), + )); + } + } + let base = match o.get("base") { + None => Vec::new(), + Some(v) => edit_list(v, "base", limits)?, + }; + let mut candidates = Vec::new(); + if let Some(v) = o.get("candidates") { + let list = array(v, "candidates")?; + if list.len() > limits.max_candidates { + return Err(EditsError::new( + codes::LIMIT, + "candidates", + format!( + "{} candidates, above the limit of {}", + list.len(), + limits.max_candidates + ), + )); + } + for (k, c) in list.iter().enumerate() { + let at = format!("candidates[{k}]"); + let co = object(c, &at)?; + known_keys(co, &at, &["id", "edits"])?; + let id = string(co, &at, "id")?; + if !is_handle_word(&id) { + return Err(EditsError::new( + codes::CANDIDATE_ID, + format!("{at}.id"), + format!( + "candidate id '{}' is not 1 to 64 of [A-Za-z0-9_-]", + names::for_display(&id) + ), + )); + } + if candidates.iter().any(|c: &Candidate| c.id == id) { + return Err(EditsError::new( + codes::CANDIDATE_ID, + format!("{at}.id"), + format!("two candidates have the id '{id}'"), + )); + } + let edits = match co.get("edits") { + Some(v) => edit_list(v, &format!("{at}.edits"), limits)?, + None => { + return Err(EditsError::new( + codes::INVALID, + format!("{at}.edits"), + "a candidate needs its list of edits", + )); + } + }; + candidates.push(Candidate { id, edits }); + } + } + let mut report = ReportRequest::default(); + if let Some(v) = o.get("report") { + let ro = object(v, "report")?; + known_keys(ro, "report", &["endpoints", "paths"])?; + for (key, out) in [ + ("endpoints", &mut report.endpoints), + ("paths", &mut report.paths), + ] { + let Some(list) = ro.get(key) else { + continue; + }; + let at = format!("report.{key}"); + let list = array(list, &at)?; + if list.len() > limits.max_report { + return Err(EditsError::new( + codes::LIMIT, + at, + format!( + "{} entries, above the limit of {}", + list.len(), + limits.max_report + ), + )); + } + for (k, e) in list.iter().enumerate() { + let eat = format!("{at}[{k}]"); + let Value::String(s) = e else { + return Err(EditsError::new(codes::INVALID, eat, "expected a string")); + }; + if key == "paths" { + if !is_numbered_handle(s, "path") { + return Err(EditsError::new( + codes::NAME, + eat, + format!("'{}' is not a path handle path", names::for_display(s)), + )); + } + } else { + hierarchical(s, &eat)?; + } + out.push(s.clone()); + } + } + } + Ok(EditFile { + base, + candidates, + report, + }) +} + +/// Parses one edit from its JSON object (WS-REQ-0142). +pub fn parse_edit(value: &Value, limits: &Limits) -> Result { + let o = object(value, "")?; + let op = string(o, "", "op")?; + let edit = match op.as_str() { + "resize" => { + known_keys(o, "", &["op", "instance", "cell"])?; + Edit::Resize { + instance: name_field(o, "instance")?, + cell: verbatim_field(o, "cell")?, + } + } + "insert_buffer" => { + known_keys( + o, + "", + &["op", "net", "cell", "instance", "net_out", "sinks"], + )?; + Edit::InsertBuffer { + net: name_field(o, "net")?, + cell: verbatim_field(o, "cell")?, + instance: name_field(o, "instance")?, + net_out: name_field(o, "net_out")?, + sinks: sinks_field(o, limits)?, + } + } + "remove_buffer" => { + known_keys(o, "", &["op", "instance"])?; + Edit::RemoveBuffer { + instance: name_field(o, "instance")?, + } + } + "swap_pins" => { + known_keys(o, "", &["op", "instance", "pin_a", "pin_b"])?; + Edit::SwapPins { + instance: name_field(o, "instance")?, + pin_a: verbatim_field(o, "pin_a")?, + pin_b: verbatim_field(o, "pin_b")?, + } + } + "clone" => { + known_keys( + o, + "", + &["op", "instance", "new_instance", "new_net", "sinks"], + )?; + Edit::Clone { + instance: name_field(o, "instance")?, + new_instance: name_field(o, "new_instance")?, + new_net: name_field(o, "new_net")?, + sinks: sinks_field(o, limits)?, + } + } + other => { + return Err(EditsError::new( + codes::INVALID, + "op", + format!( + "unknown edit '{}': expected resize, insert_buffer, remove_buffer, \ + swap_pins or clone", + names::for_display(other) + ), + )); + } + }; + Ok(edit) +} + +fn edit_list(value: &Value, at: &str, limits: &Limits) -> Result, EditsError> { + let list = array(value, at)?; + if list.len() > limits.max_edits { + return Err(EditsError::new( + codes::LIMIT, + at, + format!( + "{} edits, above the limit of {}", + list.len(), + limits.max_edits + ), + )); + } + list.iter() + .enumerate() + .map(|(k, v)| parse_edit(v, limits).map_err(|e| e.within(&format!("{at}[{k}]")))) + .collect() +} + +fn sinks_field(o: &Map, limits: &Limits) -> Result, EditsError> { + let Some(v) = o.get("sinks") else { + return Err(EditsError::new( + codes::INVALID, + "sinks", + "the edit needs its sinks", + )); + }; + let list = array(v, "sinks")?; + if list.is_empty() { + return Err(EditsError::new( + codes::SINK, + "sinks", + "the edit moves no sink", + )); + } + if list.len() > limits.max_sinks { + return Err(EditsError::new( + codes::LIMIT, + "sinks", + format!( + "{} sinks, above the limit of {}", + list.len(), + limits.max_sinks + ), + )); + } + let mut out = Vec::with_capacity(list.len()); + for (k, s) in list.iter().enumerate() { + let at = format!("sinks[{k}]"); + let so = object(s, &at)?; + let sink = if so.contains_key("port") { + known_keys(so, &at, &["port"])?; + Sink::Port { + port: name_field(so, "port").map_err(|e| e.within(&at))?, + } + } else { + known_keys(so, &at, &["instance", "pin"])?; + Sink::Pin { + instance: name_field(so, "instance").map_err(|e| e.within(&at))?, + pin: verbatim_field(so, "pin").map_err(|e| e.within(&at))?, + } + }; + if out.contains(&sink) { + return Err(EditsError::new(codes::SINK, at, "the sink is listed twice")); + } + out.push(sink); + } + Ok(out) +} + +fn object<'v>(value: &'v Value, at: &str) -> Result<&'v Map, EditsError> { + value + .as_object() + .ok_or_else(|| EditsError::new(codes::INVALID, at, "expected an object")) +} + +fn array<'v>(value: &'v Value, at: &str) -> Result<&'v Vec, EditsError> { + value + .as_array() + .ok_or_else(|| EditsError::new(codes::INVALID, at, "expected an array")) +} + +fn join(at: &str, key: &str) -> String { + if at.is_empty() { + key.to_string() + } else { + format!("{at}.{key}") + } +} + +fn known_keys(o: &Map, at: &str, keys: &[&str]) -> Result<(), EditsError> { + match o.keys().find(|k| !keys.contains(&k.as_str())) { + Some(k) => Err(EditsError::new( + codes::INVALID, + join(at, k), + format!("unknown key '{}'", names::for_display(k)), + )), + None => Ok(()), + } +} + +fn string(o: &Map, at: &str, key: &str) -> Result { + match o.get(key) { + Some(Value::String(s)) => Ok(s.clone()), + Some(_) => Err(EditsError::new( + codes::INVALID, + join(at, key), + "expected a string", + )), + None => Err(EditsError::new( + codes::INVALID, + join(at, key), + format!("missing '{key}'"), + )), + } +} + +fn hierarchical(name: &str, at: &str) -> Result<(), EditsError> { + names::check_hierarchical(name).map_err(|e| { + EditsError::new( + codes::NAME, + at, + format!("'{}': {e}", names::for_display(name)), + ) + }) +} + +/// A port, net or instance name (CORE-REQ-0123). +fn name_field(o: &Map, key: &str) -> Result { + let s = string(o, "", key)?; + hierarchical(&s, key)?; + Ok(s) +} + +/// A cell or cell pin name (CORE-REQ-0123). +fn verbatim_field(o: &Map, key: &str) -> Result { + let s = string(o, "", key)?; + names::check_verbatim(&s).map_err(|e| { + EditsError::new( + codes::NAME, + key, + format!("'{}': {e}", names::for_display(&s)), + ) + })?; + Ok(s) +} + +/// A handle-safe word of 1 to 64 characters: `[A-Za-z0-9_-]+` (WS-REQ-0022). +fn is_handle_word(s: &str) -> bool { + (1..=64).contains(&s.len()) + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +/// True for `` with `N >= 1` written without leading zeros. +pub fn is_numbered_handle(s: &str, prefix: &str) -> bool { + s.strip_prefix(prefix).is_some_and(|n| { + !n.is_empty() + && n.len() <= 10 + && !n.starts_with('0') + && n.bytes().all(|b| b.is_ascii_digit()) + }) +} diff --git a/crates/core/rusteda-workspace/src/edits/apply.rs b/crates/core/rusteda-workspace/src/edits/apply.rs new file mode 100644 index 0000000..e1bd2d2 --- /dev/null +++ b/crates/core/rusteda-workspace/src/edits/apply.rs @@ -0,0 +1,735 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Applying an edit to a [`GateNetlist`] by a fixed sequence of netlist operations +//! (WS-REQ-0146 to WS-REQ-0151). +//! +//! Every edit checks its arguments before it changes the netlist, and an edit that fails leaves +//! the netlist unchanged. The sequence of operations is part of the definition of each edit: it +//! fixes the connection order of every pin and net, so that two tools that apply the same edits +//! to the same netlist get equal frozen netlists. + +use rusteda_core::names; +use rusteda_core::netlist::{ + CellId, Conn, GateNetlist, InstId, NetId, NetlistError, PinDir, PinId, PortDir, +}; + +use super::{Edit, EditsError, Sink, codes}; +use crate::envelope::DiagCode; + +/// What an applied edit changed (WS-REQ-0151): the instances whose cell or connections changed +/// and the nets that gained or lost a pin, the new ones included, each once, in the order the edit +/// touched them. Removed instances and nets are not listed. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Applied { + /// Changed and new instances. + pub instances: Vec, + /// Changed and new nets. + pub nets: Vec, +} + +impl Applied { + fn inst(&mut self, i: InstId) { + if !self.instances.contains(&i) { + self.instances.push(i); + } + } + + fn net(&mut self, n: NetId) { + if !self.nets.contains(&n) { + self.nets.push(n); + } + } + + fn pin_owner(&mut self, nl: &GateNetlist, p: PinId) { + if let Ok(pin) = nl.pin(p) { + self.inst(pin.inst); + } + } +} + +/// Applies `edit` to `netlist` (WS-REQ-0146 to WS-REQ-0151). A cell that the netlist does not +/// have yet is added with the pins `cell_pins` gives for it, in library order; `cell_pins` +/// returns `None` for a cell it does not know. On an error the netlist is unchanged. +pub fn apply( + netlist: &mut GateNetlist, + edit: &Edit, + cell_pins: &dyn Fn(&str) -> Option>, +) -> Result { + match edit { + Edit::Resize { instance, cell } => resize(netlist, instance, cell, cell_pins), + Edit::InsertBuffer { + net, + cell, + instance, + net_out, + sinks, + } => insert_buffer(netlist, net, cell, instance, net_out, sinks, cell_pins), + Edit::RemoveBuffer { instance } => remove_buffer(netlist, instance), + Edit::SwapPins { + instance, + pin_a, + pin_b, + } => swap_pins(netlist, instance, pin_a, pin_b), + Edit::Clone { + instance, + new_instance, + new_net, + sinks, + } => clone(netlist, instance, new_instance, new_net, sinks), + } +} + +/// The net named `name` (WS-REQ-0141): the scalar net `name`, or else, when `name` is +/// `base[index]`, the bit `index` of the bus `base`. A name that names both is ambiguous. +pub fn find_net(netlist: &GateNetlist, name: &str) -> Result { + let scalar = netlist.find_net(name, None); + let bit = split_bit(name).and_then(|(base, k)| netlist.find_net(base, Some(k))); + match (scalar, bit) { + (Some(_), Some(_)) => Err(EditsError::new( + codes::UNKNOWN_NET, + "net", + format!( + "'{}' names both a scalar net and a bit of a bus", + names::for_display(name) + ), + )), + (Some(n), None) | (None, Some(n)) => Ok(n), + (None, None) => Err(EditsError::new( + codes::UNKNOWN_NET, + "net", + format!("no net '{}'", names::for_display(name)), + )), + } +} + +/// The net of the port bit `name` and the direction of its port (WS-REQ-0141): a scalar port, or +/// `base[index]` of a bus port. +pub fn port_bit_net(netlist: &GateNetlist, name: &str) -> Option<(NetId, PortDir)> { + if let Some(p) = netlist.find_port(name) + && p.range.is_none() + { + return p.bits.first().map(|&n| (n, p.dir)); + } + let (base, k) = split_bit(name)?; + let p = netlist.find_port(base)?; + p.range?; + let n = netlist.find_net(base, Some(k))?; + p.bits.contains(&n).then_some((n, p.dir)) +} + +/// `base[index]` split into its base and index. +fn split_bit(name: &str) -> Option<(&str, i32)> { + let inner = name.strip_suffix(']')?; + let open = inner.rfind('[')?; + let (base, k) = (&inner[..open], &inner[open + 1..]); + if base.is_empty() { + return None; + } + k.parse::().ok().map(|k| (base, k)) +} + +fn find_instance(nl: &GateNetlist, name: &str, at: &str) -> Result { + nl.find_instance(name).ok_or_else(|| { + EditsError::new( + codes::UNKNOWN_INSTANCE, + at, + format!("no instance '{}'", names::for_display(name)), + ) + }) +} + +fn free_instance_name(nl: &GateNetlist, name: &str, at: &str) -> Result<(), EditsError> { + if nl.find_instance(name).is_some() { + return Err(EditsError::new( + codes::NAME_IN_USE, + at, + format!("an instance '{}' exists", names::for_display(name)), + )); + } + Ok(()) +} + +fn find_pin(nl: &GateNetlist, inst: InstId, pin: &str, at: &str) -> Result { + nl.find_pin(inst, pin) + .map_err(|e| EditsError::new(codes::UNKNOWN_PIN, at, format!("{e}"))) +} + +/// The direction of the cell pin of `pin`. +fn pin_dir(nl: &GateNetlist, pin: PinId) -> PinDir { + nl.pin(pin) + .ok() + .and_then(|p| { + let inst = nl.instance(p.inst).ok()?; + let cell = nl.cell(inst.cell).ok()?; + cell.pins.get(p.cell_pin as usize).map(|cp| cp.dir) + }) + .unwrap_or(PinDir::Other) +} + +/// True when `net` is a bit of a port or an assignment refers to it. +fn net_in_use(nl: &GateNetlist, net: NetId) -> bool { + let port_bit = nl.ports().iter().any(|p| p.bits.contains(&net)); + port_bit + || nl + .assigns() + .iter() + .any(|a| a.lhs == net || a.rhs == Conn::Net(net)) +} + +fn netlist_error(code: DiagCode, at: &str, e: &NetlistError) -> EditsError { + EditsError::new(code, at, format!("{e}")) +} + +/// Checks that a new scalar net may take `name`: no port, scalar net or bus has it. +fn free_net_name(nl: &GateNetlist, name: &str, at: &str) -> Result<(), EditsError> { + let bus = nl.symbols().get(name).is_some_and(|s| { + nl.nets() + .any(|(_, n)| n.name == Some(s) && n.index.is_some()) + }); + if nl.find_port(name).is_some() || nl.find_net(name, None).is_some() || bus { + return Err(EditsError::new( + codes::NAME_IN_USE, + at, + format!("a port or net '{}' exists", names::for_display(name)), + )); + } + Ok(()) +} + +/// The id of cell `name`, added with the pins of `cell_pins` when the netlist does not have it; +/// `check` sees the pins first and may refuse them, before the cell is added. +fn cell_id( + nl: &mut GateNetlist, + name: &str, + cell_pins: &dyn Fn(&str) -> Option>, + check: &dyn Fn(&[(String, PinDir)]) -> Result<(), EditsError>, +) -> Result { + if let Some(c) = nl.find_cell(name) { + let pins: Vec<(String, PinDir)> = nl + .cell(c) + .map(|ct| { + ct.pins + .iter() + .map(|p| (nl.symbols().resolve(p.name).to_string(), p.dir)) + .collect() + }) + .unwrap_or_default(); + check(&pins)?; + return Ok(c); + } + let Some(pins) = cell_pins(name) else { + return Err(EditsError::new( + codes::UNKNOWN_CELL, + "cell", + format!("no cell '{}'", names::for_display(name)), + )); + }; + check(&pins)?; + nl.add_cell_type(name, &pins) + .map_err(|e| netlist_error(codes::UNKNOWN_CELL, "cell", &e)) +} + +/// The input and output cell pin indices of a buffer cell: exactly two pins, one input and one +/// output (WS-REQ-0147). +fn buffer_pins(pins: &[(String, PinDir)]) -> Option<(u32, u32)> { + if pins.len() != 2 { + return None; + } + let i = pins.iter().position(|p| p.1 == PinDir::Input)?; + let o = pins.iter().position(|p| p.1 == PinDir::Output)?; + Some((i as u32, o as u32)) +} + +/// Resolves the sinks of `net` (WS-REQ-0147, WS-REQ-0150): every pin sink is an input pin +/// connected to `net`; a port sink is an output port whose bit is `net` itself. Returns the pins +/// and whether a port is among the sinks. +fn resolve_sinks( + nl: &GateNetlist, + net: NetId, + sinks: &[Sink], +) -> Result<(Vec, bool), EditsError> { + let mut pins = Vec::with_capacity(sinks.len()); + let mut port = false; + for (k, s) in sinks.iter().enumerate() { + let at = format!("sinks[{k}]"); + match s { + Sink::Pin { instance, pin } => { + let inst = find_instance(nl, instance, &at)?; + let p = find_pin(nl, inst, pin, &at)?; + if nl.pin(p).ok().and_then(|x| x.conn) != Some(Conn::Net(net)) { + return Err(EditsError::new( + codes::SINK, + at, + format!( + "pin '{}/{}' is not on the net", + names::for_display(instance), + names::for_display(pin) + ), + )); + } + if pin_dir(nl, p) != PinDir::Input { + return Err(EditsError::new( + codes::SINK, + at, + format!( + "pin '{}/{}' is not an input pin", + names::for_display(instance), + names::for_display(pin) + ), + )); + } + if pins.contains(&p) { + return Err(EditsError::new(codes::SINK, at, "the sink is listed twice")); + } + pins.push(p); + } + Sink::Port { port: name } => { + let Some((pnet, dir)) = port_bit_net(nl, name) else { + return Err(EditsError::new( + codes::UNKNOWN_PIN, + at, + format!("no port bit '{}'", names::for_display(name)), + )); + }; + if pnet != net { + return Err(EditsError::new( + codes::SINK, + at, + format!("port '{}' is not on the net", names::for_display(name)), + )); + } + if dir != PortDir::Output { + return Err(EditsError::new( + codes::SINK, + at, + format!("port '{}' is not an output port", names::for_display(name)), + )); + } + if port { + return Err(EditsError::new(codes::SINK, at, "the sink is listed twice")); + } + port = true; + } + } + } + Ok((pins, port)) +} + +/// Adds the net `name`, or reports why its name cannot be taken. +fn add_named_net(nl: &mut GateNetlist, name: &str, at: &str) -> Result { + nl.add_net(Some(name), None) + .map_err(|e| netlist_error(codes::NAME_IN_USE, at, &e)) +} + +/// `resize` (WS-REQ-0146): `swap_cell` of the instance to the cell. +fn resize( + nl: &mut GateNetlist, + instance: &str, + cell: &str, + cell_pins: &dyn Fn(&str) -> Option>, +) -> Result { + let inst = find_instance(nl, instance, "instance")?; + let old: Vec<(String, PinDir)> = { + let i = nl + .instance(inst) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + let ct = nl + .cell(i.cell) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + ct.pins + .iter() + .map(|p| (nl.symbols().resolve(p.name).to_string(), p.dir)) + .collect() + }; + let compatible = |pins: &[(String, PinDir)]| { + let mut a = old.clone(); + let mut b = pins.to_vec(); + a.sort_by(|x, y| x.0.cmp(&y.0)); + b.sort_by(|x, y| x.0.cmp(&y.0)); + if a == b { + Ok(()) + } else { + Err(EditsError::new( + codes::NETLIST, + "cell", + format!( + "cell '{}' does not have the pins of instance '{}'", + names::for_display(cell), + names::for_display(instance) + ), + )) + } + }; + let c = cell_id(nl, cell, cell_pins, &compatible)?; + nl.swap_cell(inst, c) + .map_err(|e| netlist_error(codes::NETLIST, "cell", &e))?; + let mut applied = Applied::default(); + applied.inst(inst); + Ok(applied) +} + +/// `insert_buffer` (WS-REQ-0147). +#[allow(clippy::too_many_arguments)] +fn insert_buffer( + nl: &mut GateNetlist, + net: &str, + cell: &str, + instance: &str, + net_out: &str, + sinks: &[Sink], + cell_pins: &dyn Fn(&str) -> Option>, +) -> Result { + let n = find_net(nl, net)?; + free_instance_name(nl, instance, "instance")?; + free_net_name(nl, net_out, "net_out")?; + let (pins, port) = resolve_sinks(nl, n, sinks)?; + let is_buffer = |pins: &[(String, PinDir)]| match buffer_pins(pins) { + Some(_) => Ok(()), + None => Err(EditsError::new( + codes::BUFFER, + "cell", + format!( + "cell '{}' does not have exactly one input and one output pin", + names::for_display(cell) + ), + )), + }; + let c = cell_id(nl, cell, cell_pins, &is_buffer)?; + let pin_list: Vec<(String, PinDir)> = nl + .cell(c) + .map(|ct| { + ct.pins + .iter() + .map(|p| (nl.symbols().resolve(p.name).to_string(), p.dir)) + .collect() + }) + .unwrap_or_default(); + let Some((k_in, k_out)) = buffer_pins(&pin_list) else { + return Err(EditsError::new(codes::BUFFER, "cell", "not a buffer cell")); + }; + let mut applied = Applied::default(); + if !port { + let (buf, out) = nl + .insert_buffer(n, &pins, c, k_in, k_out, instance, Some(net_out)) + .map_err(|e| { + let code = match e { + NetlistError::NetConflict { .. } | NetlistError::DuplicateName { .. } => { + codes::NAME_IN_USE + } + NetlistError::InvalidName { .. } | NetlistError::EmptyName { .. } => { + codes::NAME + } + _ => codes::NETLIST, + }; + netlist_error(code, "net_out", &e) + })?; + applied.inst(buf); + for &p in &pins { + applied.pin_owner(nl, p); + } + applied.net(n); + applied.net(out); + return Ok(applied); + } + // A port bit cannot change nets: the buffer drives `net`, and the pins that stay with the + // driver move to the new net (WS-REQ-0147). + let m = add_named_net(nl, net_out, "net_out")?; + let buf = match nl.add_instance(instance, c) { + Ok(b) => b, + Err(e) => { + let _ = nl.remove_net(m); + return Err(netlist_error(codes::NAME_IN_USE, "instance", &e)); + } + }; + let stay: Vec = nl + .net(n) + .map(|x| { + x.pins + .iter() + .copied() + .filter(|p| !pins.contains(p)) + .collect() + }) + .unwrap_or_default(); + let (pin_in, pin_out) = { + let b = nl + .instance(buf) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + (b.pins[k_in as usize], b.pins[k_out as usize]) + }; + for &p in &stay { + nl.move_pin(p, Conn::Net(m)) + .map_err(|e| netlist_error(codes::NETLIST, "net", &e))?; + applied.pin_owner(nl, p); + } + nl.connect_pin(pin_in, Conn::Net(m)) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + nl.connect_pin(pin_out, Conn::Net(n)) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + applied.inst(buf); + applied.net(n); + applied.net(m); + Ok(applied) +} + +/// The connected input and output pins of a buffer instance and their nets. +struct BufferNets { + pin_in: PinId, + pin_out: PinId, + a: NetId, + b: NetId, +} + +fn buffer_nets(nl: &GateNetlist, inst: InstId, instance: &str) -> Result { + let reject = |why: &str| { + EditsError::new( + codes::BUFFER, + "instance", + format!("instance '{}' {why}", names::for_display(instance)), + ) + }; + let i = nl + .instance(inst) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + let ct = nl + .cell(i.cell) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + let pins: Vec<(String, PinDir)> = ct.pins.iter().map(|p| (String::new(), p.dir)).collect(); + let Some((k_in, k_out)) = buffer_pins(&pins) else { + return Err(reject( + "is not an instance of a cell with one input and one output pin", + )); + }; + let (pin_in, pin_out) = (i.pins[k_in as usize], i.pins[k_out as usize]); + let conn = |p: PinId| nl.pin(p).ok().and_then(|x| x.conn); + let (Some(Conn::Net(a)), Some(Conn::Net(b))) = (conn(pin_in), conn(pin_out)) else { + return Err(reject("does not connect its input and its output to nets")); + }; + if a == b { + return Err(reject("connects its input and its output to the same net")); + } + Ok(BufferNets { + pin_in, + pin_out, + a, + b, + }) +} + +/// `remove_buffer` (WS-REQ-0148). +fn remove_buffer(nl: &mut GateNetlist, instance: &str) -> Result { + let inst = find_instance(nl, instance, "instance")?; + let BufferNets { + pin_in, + pin_out, + a, + b, + } = buffer_nets(nl, inst, instance)?; + let mut applied = Applied::default(); + let others: Vec = nl + .net(b) + .map(|x| x.pins.iter().copied().filter(|&p| p != pin_out).collect()) + .unwrap_or_default(); + if others.iter().any(|&p| pin_dir(nl, p) != PinDir::Input) { + return Err(EditsError::new( + codes::BUFFER, + "instance", + format!( + "another pin than the output of '{}' drives its output net", + names::for_display(instance) + ), + )); + } + if !net_in_use(nl, b) { + let kept = nl + .remove_buffer(inst) + .map_err(|e| netlist_error(codes::BUFFER, "instance", &e))?; + for &p in &others { + applied.pin_owner(nl, p); + } + applied.net(kept); + return Ok(applied); + } + if net_in_use(nl, a) { + return Err(EditsError::new( + codes::BUFFER, + "instance", + format!( + "both nets of '{}' are bits of ports or appear in assignments", + names::for_display(instance) + ), + )); + } + // The output net is a port bit, which cannot be removed: the pins of the input net move to + // it, and the input net goes (WS-REQ-0148). + let moved: Vec = nl + .net(a) + .map(|x| x.pins.iter().copied().filter(|&p| p != pin_in).collect()) + .unwrap_or_default(); + for &p in &moved { + nl.move_pin(p, Conn::Net(b)) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + applied.pin_owner(nl, p); + } + nl.remove_instance(inst) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + nl.remove_net(a) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + applied.net(b); + Ok(applied) +} + +/// `swap_pins` (WS-REQ-0149). +fn swap_pins( + nl: &mut GateNetlist, + instance: &str, + pin_a: &str, + pin_b: &str, +) -> Result { + let inst = find_instance(nl, instance, "instance")?; + if pin_a == pin_b { + return Err(EditsError::new( + codes::PIN_SWAP, + "pin_b", + "the two pins of a pin swap are the same pin", + )); + } + let pa = find_pin(nl, inst, pin_a, "pin_a")?; + let pb = find_pin(nl, inst, pin_b, "pin_b")?; + for (p, name, at) in [(pa, pin_a, "pin_a"), (pb, pin_b, "pin_b")] { + if pin_dir(nl, p) != PinDir::Input { + return Err(EditsError::new( + codes::PIN_SWAP, + at, + format!("pin '{}' is not an input pin", names::for_display(name)), + )); + } + } + let conn = |p: PinId| nl.pin(p).ok().and_then(|x| x.conn); + let (Some(ca), Some(cb)) = (conn(pa), conn(pb)) else { + return Err(EditsError::new( + codes::PIN_SWAP, + "instance", + "both pins of a pin swap must be connected", + )); + }; + let mut applied = Applied::default(); + applied.inst(inst); + if ca != cb { + nl.move_pin(pa, cb) + .map_err(|e| netlist_error(codes::NETLIST, "pin_a", &e))?; + nl.move_pin(pb, ca) + .map_err(|e| netlist_error(codes::NETLIST, "pin_b", &e))?; + for c in [ca, cb] { + if let Conn::Net(n) = c { + applied.net(n); + } + } + } + Ok(applied) +} + +/// `clone` (WS-REQ-0150). +fn clone( + nl: &mut GateNetlist, + instance: &str, + new_instance: &str, + new_net: &str, + sinks: &[Sink], +) -> Result { + let inst = find_instance(nl, instance, "instance")?; + free_instance_name(nl, new_instance, "new_instance")?; + free_net_name(nl, new_net, "new_net")?; + let (cell, connected, out_pin) = { + let i = nl + .instance(inst) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + let ct = nl + .cell(i.cell) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + let outs: Vec = ct + .pins + .iter() + .enumerate() + .filter(|(_, p)| p.dir.drives()) + .map(|(k, _)| k) + .collect(); + if outs.len() != 1 || ct.pins[outs[0]].dir != PinDir::Output { + return Err(EditsError::new( + codes::CLONE, + "instance", + format!( + "instance '{}' does not have exactly one output pin and no inout pin", + names::for_display(instance) + ), + )); + } + (i.cell, i.connected.clone(), i.pins[outs[0]]) + }; + let Some(Conn::Net(n)) = nl.pin(out_pin).ok().and_then(|p| p.conn) else { + return Err(EditsError::new( + codes::CLONE, + "instance", + format!( + "the output pin of '{}' is not on a net", + names::for_display(instance) + ), + )); + }; + let (pins, port) = resolve_sinks(nl, n, sinks)?; + let m = add_named_net(nl, new_net, "new_net")?; + let k = match nl.add_instance(new_instance, cell) { + Ok(k) => k, + Err(e) => { + let _ = nl.remove_net(m); + return Err(netlist_error(codes::NAME_IN_USE, "new_instance", &e)); + } + }; + let stay: Vec = if port { + nl.net(n) + .map(|x| { + x.pins + .iter() + .copied() + .filter(|p| !pins.contains(p)) + .collect() + }) + .unwrap_or_default() + } else { + Vec::new() + }; + let mut applied = Applied::default(); + for &p in &connected { + let (cell_pin, conn) = match nl.pin(p) { + Ok(x) => (x.cell_pin, x.conn), + Err(e) => return Err(netlist_error(codes::NETLIST, "instance", &e)), + }; + let target = if p == out_pin { + Conn::Net(if port { n } else { m }) + } else { + match conn { + Some(c) => c, + None => continue, + } + }; + let q = nl + .find_pin(k, cell_pin) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + nl.connect_pin(q, target) + .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; + if let Conn::Net(x) = target { + applied.net(x); + } + } + applied.inst(k); + let moved = if port { stay } else { pins }; + for &p in &moved { + nl.move_pin(p, Conn::Net(m)) + .map_err(|e| netlist_error(codes::NETLIST, "sinks", &e))?; + applied.pin_owner(nl, p); + } + applied.net(n); + applied.net(m); + Ok(applied) +} diff --git a/crates/core/rusteda-workspace/src/edits/tests.rs b/crates/core/rusteda-workspace/src/edits/tests.rs new file mode 100644 index 0000000..cad87b2 --- /dev/null +++ b/crates/core/rusteda-workspace/src/edits/tests.rs @@ -0,0 +1,602 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Tests of edit files and of the application of edits: each edit is compared with the netlist +//! that the operations of its specification give when a test performs them by hand. + +use rusteda_core::netlist::{Conn, GateNetlist, PinDir, PortDir}; + +use super::*; + +const BUF: [(&str, PinDir); 2] = [("A", PinDir::Input), ("Y", PinDir::Output)]; +const NAND: [(&str, PinDir); 3] = [ + ("A", PinDir::Input), + ("B", PinDir::Input), + ("Y", PinDir::Output), +]; + +/// Pins of the cells the tests know, in library order. +fn cells(name: &str) -> Option> { + let pins: &[(&str, PinDir)] = match name { + "buf1" | "buf2" | "inv1" => &BUF, + "nand2" | "nand2b" => &NAND, + _ => return None, + }; + Some(pins.iter().map(|(n, d)| ((*n).to_string(), *d)).collect()) +} + +/// `in0 -> u1 (nand2 A,B=in1) -> n1 -> {u2.A, u3.A, out0}`; `u2 -> n2 -> out1`; `u3 -> n3`. +fn netlist() -> GateNetlist { + let mut g = GateNetlist::new("top").unwrap(); + let nand = g.add_cell_type("nand2", &NAND).unwrap(); + let buf = g.add_cell_type("buf1", &BUF).unwrap(); + for (p, d) in [ + ("in0", PortDir::Input), + ("in1", PortDir::Input), + ("out0", PortDir::Output), + ("out1", PortDir::Output), + ] { + g.add_port(p, d, None).unwrap(); + } + let net = |g: &GateNetlist, n: &str| g.find_net(n, None).unwrap(); + let u1 = g.add_instance("u1", nand).unwrap(); + g.connect(u1, "A", Conn::Net(net(&g, "in0"))).unwrap(); + g.connect(u1, "B", Conn::Net(net(&g, "in1"))).unwrap(); + g.connect(u1, "Y", Conn::Net(net(&g, "out0"))).unwrap(); + let u2 = g.add_instance("u2", buf).unwrap(); + g.connect(u2, "A", Conn::Net(net(&g, "out0"))).unwrap(); + g.connect(u2, "Y", Conn::Net(net(&g, "out1"))).unwrap(); + let n3 = g.add_net(Some("n3"), None).unwrap(); + let u3 = g.add_instance("u3", buf).unwrap(); + g.connect(u3, "A", Conn::Net(net(&g, "out0"))).unwrap(); + g.connect(u3, "Y", Conn::Net(n3)).unwrap(); + g +} + +fn pin_sink(i: &str, p: &str) -> Sink { + Sink::Pin { + instance: i.into(), + pin: p.into(), + } +} + +fn run(g: &mut GateNetlist, e: &Edit) -> Result { + apply(g, e, &cells) +} + +const FILE: &str = r#"{ + "schema": "rusteda.edits.v1", + "base": [{"op": "resize", "instance": "u2", "cell": "buf2"}], + "candidates": [ + {"id": "c-1", "edits": [ + {"op": "insert_buffer", "net": "out0", "cell": "buf1", "instance": "b1", + "net_out": "nb1", "sinks": [{"instance": "u3", "pin": "A"}, {"port": "out0"}]}, + {"op": "swap_pins", "instance": "u1", "pin_a": "A", "pin_b": "B"}]}, + {"id": "c_2", "edits": [ + {"op": "clone", "instance": "u1", "new_instance": "u1c", "new_net": "n1c", + "sinks": [{"instance": "u2", "pin": "A"}]}, + {"op": "remove_buffer", "instance": "u3"}]} + ], + "report": {"endpoints": ["out1", "ep3"], "paths": ["path1"]} +}"#; + +// WS-REQ-0140, WS-REQ-0142, WS-REQ-0143, WS-REQ-0145: a file with every edit kind reads back +// from its canonical text unchanged, and the canonical text is a fixed point. +#[test] +fn edit_file_round_trips_through_canonical_json() { + let f = parse(FILE, &Limits::default()).unwrap(); + assert_eq!(f.base.len(), 1); + assert_eq!(f.candidates.len(), 2); + assert_eq!(f.candidates[0].id, "c-1"); + assert_eq!( + f.candidates[1] + .edits + .iter() + .map(Edit::op) + .collect::>(), + ["clone", "remove_buffer"] + ); + assert_eq!(f.report.endpoints, ["out1", "ep3"]); + assert_eq!(f.report.paths, ["path1"]); + let text = f.to_json(); + assert!(text.starts_with("{\n \"base\"")); + assert!(text.ends_with("}\n")); + let again = parse(&text, &Limits::default()).unwrap(); + assert_eq!(again, f); + assert_eq!(again.to_json(), text); + let empty = parse(r#"{"schema": "rusteda.edits.v1"}"#, &Limits::default()).unwrap(); + assert_eq!(empty, EditFile::default()); +} + +fn code_of(text: &str, limits: &Limits) -> (String, String) { + let e = parse(text, limits).unwrap_err(); + (e.code.as_str().to_string(), e.at) +} + +// WS-REQ-0141, WS-REQ-0144: every malformed file is rejected with its code and the JSON path of +// the value, before any edit is applied; nothing unknown is ignored. +#[test] +fn malformed_files_are_rejected_with_codes() { + let l = Limits::default(); + let wrap = |edits: &str| { + format!( + r#"{{"schema": "rusteda.edits.v1", "candidates": [{{"id": "c", "edits": [{edits}]}}]}}"# + ) + }; + let cases: Vec<(String, &str, &str)> = vec![ + ("not json".into(), "W0300", ""), + (r#"{"base": []}"#.into(), "W0301", "schema"), + (r#"{"schema": "rusteda.edits.v2"}"#.into(), "W0301", "schema"), + ( + r#"{"schema": "rusteda.edits.v1", "extra": 1}"#.into(), + "W0300", + "extra", + ), + ( + wrap(r#"{"op": "resize", "instance": "u1", "cell": "b", "x": 1}"#), + "W0300", + "candidates[0].edits[0].x", + ), + ( + wrap(r#"{"op": "grow", "instance": "u1"}"#), + "W0300", + "candidates[0].edits[0].op", + ), + ( + wrap(r#"{"op": "resize", "instance": "a//b", "cell": "b"}"#), + "W0302", + "candidates[0].edits[0].instance", + ), + ( + wrap(r#"{"op": "resize", "instance": "u1", "cell": "b c"}"#), + "W0302", + "candidates[0].edits[0].cell", + ), + ( + wrap( + r#"{"op": "clone", "instance": "u1", "new_instance": "k", "new_net": "m", "sinks": []}"#, + ), + "W0310", + "candidates[0].edits[0].sinks", + ), + ( + wrap( + r#"{"op": "clone", "instance": "u1", "new_instance": "k", "new_net": "m", "sinks": [{"port": "o"}, {"port": "o"}]}"#, + ), + "W0310", + "candidates[0].edits[0].sinks[1]", + ), + ( + r#"{"schema": "rusteda.edits.v1", "candidates": [{"id": "a b", "edits": []}]}"# + .into(), + "W0303", + "candidates[0].id", + ), + ( + r#"{"schema": "rusteda.edits.v1", "candidates": [{"id": "a", "edits": []}, {"id": "a", "edits": []}]}"# + .into(), + "W0303", + "candidates[1].id", + ), + ( + r#"{"schema": "rusteda.edits.v1", "report": {"paths": ["path0"]}}"#.into(), + "W0302", + "report.paths[0]", + ), + ( + r#"{"schema": "rusteda.edits.v1", "report": {"endpoints": [3]}}"#.into(), + "W0300", + "report.endpoints[0]", + ), + ]; + for (text, code, at) in &cases { + assert_eq!( + code_of(text, &l), + (code.to_string(), at.to_string()), + "{text}" + ); + } + let small = Limits { + max_candidates: 1, + max_edits: 1, + max_report: 1, + max_bytes: 4096, + max_sinks: 1, + }; + let two_candidates = r#"{"schema": "rusteda.edits.v1", "candidates": [{"id": "a", "edits": []}, {"id": "b", "edits": []}]}"#; + assert_eq!(code_of(two_candidates, &small).0, "W0304"); + let two_edits = r#"{"schema": "rusteda.edits.v1", "base": [{"op": "remove_buffer", "instance": "a"}, {"op": "remove_buffer", "instance": "b"}]}"#; + assert_eq!(code_of(two_edits, &small), ("W0304".into(), "base".into())); + let long = format!( + r#"{{"schema": "rusteda.edits.v1", "x": "{}"}}"#, + "y".repeat(5000) + ); + assert_eq!(code_of(&long, &small).0, "W0304"); + assert!(is_numbered_handle("path12", "path") && !is_numbered_handle("path012", "path")); +} + +// WS-REQ-0141, WS-REQ-0146: `resize` is `swap_cell` to a pin-compatible cell, which is added from +// the cell supplier when the netlist lacks it; an unknown or incompatible cell changes nothing. +#[test] +fn resize_is_a_cell_swap() { + let mut g = netlist(); + let applied = run( + &mut g, + &Edit::Resize { + instance: "u2".into(), + cell: "buf2".into(), + }, + ) + .unwrap(); + let mut want = netlist(); + let buf2 = want.add_cell_type("buf2", &BUF).unwrap(); + let u2 = want.find_instance("u2").unwrap(); + want.swap_cell(u2, buf2).unwrap(); + assert_eq!(g.freeze(), want.freeze()); + assert_eq!(applied.instances, [u2]); + let before = g.freeze(); + for (cell, code) in [("nand2", "W0314"), ("nope", "W0308")] { + let e = run( + &mut g, + &Edit::Resize { + instance: "u2".into(), + cell: cell.into(), + }, + ) + .unwrap_err(); + assert_eq!(e.code.as_str(), code); + assert_eq!(g.freeze(), before); + } + let e = run( + &mut g, + &Edit::Resize { + instance: "zz".into(), + cell: "buf1".into(), + }, + ) + .unwrap_err(); + assert_eq!(e.code.as_str(), "W0305"); +} + +// WS-REQ-0147: `insert_buffer` without a port sink is the netlist's buffer insertion; with a port +// sink the port's net keeps its name and its listed sinks, and the driver with the other pins +// moves to the new net, ahead of the buffer input. +#[test] +fn insert_buffer_with_pin_and_port_sinks() { + let mut g = netlist(); + run( + &mut g, + &Edit::InsertBuffer { + net: "out0".into(), + cell: "buf1".into(), + instance: "b1".into(), + net_out: "nb1".into(), + sinks: vec![pin_sink("u3", "A")], + }, + ) + .unwrap(); + let mut want = netlist(); + let buf = want.find_cell("buf1").unwrap(); + let n = want.find_net("out0", None).unwrap(); + let u3 = want.find_instance("u3").unwrap(); + let u3a = want.find_pin(u3, "A").unwrap(); + want.insert_buffer(n, &[u3a], buf, "A", "Y", "b1", Some("nb1")) + .unwrap(); + assert_eq!(g.freeze(), want.freeze()); + + let mut g = netlist(); + run( + &mut g, + &Edit::InsertBuffer { + net: "out0".into(), + cell: "buf1".into(), + instance: "b2".into(), + net_out: "nb2".into(), + sinks: vec![ + Sink::Port { + port: "out0".into(), + }, + pin_sink("u2", "A"), + ], + }, + ) + .unwrap(); + let mut want = netlist(); + let n = want.find_net("out0", None).unwrap(); + let m = want.add_net(Some("nb2"), None).unwrap(); + let b2 = want.add_instance("b2", buf).unwrap(); + let u2a = { + let u2 = want.find_instance("u2").unwrap(); + want.find_pin(u2, "A").unwrap() + }; + let stay: Vec<_> = want + .net(n) + .unwrap() + .pins + .iter() + .copied() + .filter(|&p| p != u2a) + .collect(); + for p in stay { + want.move_pin(p, Conn::Net(m)).unwrap(); + } + want.connect(b2, "A", Conn::Net(m)).unwrap(); + want.connect(b2, "Y", Conn::Net(n)).unwrap(); + assert_eq!(g.freeze(), want.freeze()); + + // A sink off the net, an input port, an inverter-shaped three-pin cell and a taken name are + // all rejected without a change. + let before = g.freeze(); + let bad = [ + ( + Edit::InsertBuffer { + net: "in0".into(), + cell: "buf1".into(), + instance: "b9".into(), + net_out: "nb9".into(), + sinks: vec![pin_sink("u2", "A")], + }, + "W0310", + ), + ( + Edit::InsertBuffer { + net: "in0".into(), + cell: "buf1".into(), + instance: "b9".into(), + net_out: "nb9".into(), + sinks: vec![Sink::Port { port: "in0".into() }], + }, + "W0310", + ), + ( + Edit::InsertBuffer { + net: "nb2".into(), + cell: "nand2".into(), + instance: "b9".into(), + net_out: "nb9".into(), + sinks: vec![pin_sink("b2", "A")], + }, + "W0311", + ), + ( + Edit::InsertBuffer { + net: "nb2".into(), + cell: "buf1".into(), + instance: "b9".into(), + net_out: "n3".into(), + sinks: vec![pin_sink("b2", "A")], + }, + "W0309", + ), + ( + Edit::InsertBuffer { + net: "nb2".into(), + cell: "buf1".into(), + instance: "u1".into(), + net_out: "nb9".into(), + sinks: vec![pin_sink("b2", "A")], + }, + "W0309", + ), + ( + Edit::InsertBuffer { + net: "nope".into(), + cell: "buf1".into(), + instance: "b9".into(), + net_out: "nb9".into(), + sinks: vec![pin_sink("b2", "A")], + }, + "W0306", + ), + ]; + for (e, code) in &bad { + assert_eq!(run(&mut g, e).unwrap_err().code.as_str(), *code, "{e:?}"); + assert_eq!(g.freeze(), before); + } +} + +// WS-REQ-0148: `remove_buffer` merges the output net into the input net, or, when the output net +// is a port bit, the input net into the output net. +#[test] +fn remove_buffer_in_both_directions() { + let mut g = netlist(); + run( + &mut g, + &Edit::RemoveBuffer { + instance: "u3".into(), + }, + ) + .unwrap(); + let mut want = netlist(); + let u3 = want.find_instance("u3").unwrap(); + want.remove_buffer(u3).unwrap(); + assert_eq!(g.freeze(), want.freeze()); + + // `u2` drives the port bit `out1`: the pins of `out0` move to `out1`, which `u1` then drives. + let mut g = netlist(); + let e = run( + &mut g, + &Edit::RemoveBuffer { + instance: "u2".into(), + }, + ) + .unwrap_err(); + // `out0` is a port bit too: neither net can go. + assert_eq!(e.code.as_str(), "W0311"); + let mut g = netlist(); + run( + &mut g, + &Edit::InsertBuffer { + net: "out0".into(), + cell: "buf1".into(), + instance: "b2".into(), + net_out: "nb2".into(), + sinks: vec![Sink::Port { + port: "out0".into(), + }], + }, + ) + .unwrap(); + run( + &mut g, + &Edit::RemoveBuffer { + instance: "b2".into(), + }, + ) + .unwrap(); + let mut want = netlist(); + let n = want.find_net("out0", None).unwrap(); + let a = want.add_net(Some("nb2"), None).unwrap(); + let buf = want.find_cell("buf1").unwrap(); + let b2 = want.add_instance("b2", buf).unwrap(); + let stay: Vec<_> = want.net(n).unwrap().pins.clone(); + for &p in &stay { + want.move_pin(p, Conn::Net(a)).unwrap(); + } + want.connect(b2, "A", Conn::Net(a)).unwrap(); + want.connect(b2, "Y", Conn::Net(n)).unwrap(); + let b2a = want.find_pin(b2, "A").unwrap(); + let moved: Vec<_> = want + .net(a) + .unwrap() + .pins + .iter() + .copied() + .filter(|&p| p != b2a) + .collect(); + for &p in &moved { + want.move_pin(p, Conn::Net(n)).unwrap(); + } + want.remove_instance(b2).unwrap(); + want.remove_net(a).unwrap(); + assert_eq!(g.freeze(), want.freeze()); + let e = run( + &mut g, + &Edit::RemoveBuffer { + instance: "u1".into(), + }, + ) + .unwrap_err(); + assert_eq!(e.code.as_str(), "W0311"); +} + +// WS-REQ-0149: `swap_pins` moves each input pin to the other's connection, `pin_a` first; the +// pins keep their places in the instance's connection order. +#[test] +fn swap_pins_exchanges_connections() { + let mut g = netlist(); + run( + &mut g, + &Edit::SwapPins { + instance: "u1".into(), + pin_a: "A".into(), + pin_b: "B".into(), + }, + ) + .unwrap(); + let mut want = netlist(); + let u1 = want.find_instance("u1").unwrap(); + let (a, b) = ( + want.find_pin(u1, "A").unwrap(), + want.find_pin(u1, "B").unwrap(), + ); + let (in0, in1) = ( + want.find_net("in0", None).unwrap(), + want.find_net("in1", None).unwrap(), + ); + want.move_pin(a, Conn::Net(in1)).unwrap(); + want.move_pin(b, Conn::Net(in0)).unwrap(); + assert_eq!(g.freeze(), want.freeze()); + for (pa, pb, code) in [ + ("A", "A", "W0312"), + ("A", "Y", "W0312"), + ("A", "Q", "W0307"), + ] { + let e = run( + &mut g, + &Edit::SwapPins { + instance: "u1".into(), + pin_a: pa.into(), + pin_b: pb.into(), + }, + ) + .unwrap_err(); + assert_eq!(e.code.as_str(), code); + } +} + +// WS-REQ-0150: `clone` copies the instance with the connections of its pins in their order, its +// output on the new net, and moves the listed sinks there; with a port sink the copy drives the +// port's net and the original with the other pins moves to the new net. +#[test] +fn clone_with_pin_and_port_sinks() { + let mut g = netlist(); + let applied = run( + &mut g, + &Edit::Clone { + instance: "u1".into(), + new_instance: "u1c".into(), + new_net: "n1c".into(), + sinks: vec![pin_sink("u3", "A")], + }, + ) + .unwrap(); + let mut want = netlist(); + let nand = want.find_cell("nand2").unwrap(); + let n = want.find_net("out0", None).unwrap(); + let m = want.add_net(Some("n1c"), None).unwrap(); + let k = want.add_instance("u1c", nand).unwrap(); + for (p, net) in [("A", "in0"), ("B", "in1")] { + let nid = want.find_net(net, None).unwrap(); + want.connect(k, p, Conn::Net(nid)).unwrap(); + } + want.connect(k, "Y", Conn::Net(m)).unwrap(); + let u3a = { + let u3 = want.find_instance("u3").unwrap(); + want.find_pin(u3, "A").unwrap() + }; + want.move_pin(u3a, Conn::Net(m)).unwrap(); + assert_eq!(g.freeze(), want.freeze()); + assert!(applied.instances.contains(&g.find_instance("u1c").unwrap())); + + let mut g = netlist(); + run( + &mut g, + &Edit::Clone { + instance: "u1".into(), + new_instance: "u1c".into(), + new_net: "n1c".into(), + sinks: vec![Sink::Port { + port: "out0".into(), + }], + }, + ) + .unwrap(); + let mut want = netlist(); + let m = want.add_net(Some("n1c"), None).unwrap(); + let k = want.add_instance("u1c", nand).unwrap(); + let stay: Vec<_> = want.net(n).unwrap().pins.clone(); + for (p, net) in [("A", "in0"), ("B", "in1")] { + let nid = want.find_net(net, None).unwrap(); + want.connect(k, p, Conn::Net(nid)).unwrap(); + } + want.connect(k, "Y", Conn::Net(n)).unwrap(); + for p in stay { + want.move_pin(p, Conn::Net(m)).unwrap(); + } + assert_eq!(g.freeze(), want.freeze()); + + let e = run( + &mut g, + &Edit::Clone { + instance: "u1".into(), + new_instance: "u1d".into(), + new_net: "n1d".into(), + sinks: vec![pin_sink("u1", "A")], + }, + ) + .unwrap_err(); + assert_eq!(e.code.as_str(), "W0310"); + assert!(find_net(&g, "n1d").is_err()); + assert!(port_bit_net(&g, "out1").is_some() && port_bit_net(&g, "n3").is_none()); +} diff --git a/crates/core/rusteda-workspace/src/lib.rs b/crates/core/rusteda-workspace/src/lib.rs index 8becac1..8dd85b1 100644 --- a/crates/core/rusteda-workspace/src/lib.rs +++ b/crates/core/rusteda-workspace/src/lib.rs @@ -25,6 +25,7 @@ //! | [`sta`] | the read-only view of the timing table `sta.rbin` (`STA` v1) and a builder of such tables | //! | [`workspace`] | init, open, resolve, transactions and the commit protocol, revert, history, and the locks on `workspace.lock` they take | //! | [`cache`] | the library cache `cache/-.lib` / `.rlib` | +//! | [`edits`] | netlist edit files `rusteda.edits.v1` and their application to a netlist | //! | [`envelope`] | the `rusteda.v1` JSON envelope, diagnostics and ordered JSON objects | //! | [`error`] | the error type, envelope error codes, exit codes and `W` diagnostic codes | //! | [`json`] | the order-preserving JSON reformatter behind the envelope | @@ -41,6 +42,7 @@ compile_error!( ); pub mod cache; +pub mod edits; pub mod envelope; pub mod error; mod fsutil; diff --git a/crates/core/rusteda-workspace/src/rlib.rs b/crates/core/rusteda-workspace/src/rlib.rs index 5cc6b2b..40918e1 100644 --- a/crates/core/rusteda-workspace/src/rlib.rs +++ b/crates/core/rusteda-workspace/src/rlib.rs @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 RustEDA contributors -//! The `LIBERTY` v3 (`.rlib`) compiled library codec and workspace cache loader -//! (contract §5, `WS-REQ-0110` to `WS-REQ-0116`). +//! The `LIBERTY` v4 (`.rlib`) compiled library codec and workspace cache loader +//! (contract §5, `WS-REQ-0110` to `WS-REQ-0116`, `WS-REQ-0117`). //! //! ## Container layout (`WS-REQ-0110`) //! //! A compiled Liberty library (`cache/-.rlib`) is an [`Rbin`] container of kind -//! [`KIND`] (`"LIBERTY"`) and format version [`FORMAT_VERSION`] (`3`, range [`VERSIONS`]). +//! [`KIND`] (`"LIBERTY"`) and format version [`FORMAT_VERSION`] (`4`, range [`VERSIONS`]; an +//! older `.rlib` is compiled again from its cached `.lib`, `WS-REQ-0084`). //! When compiled from a source `.lib` file ([`encode_with_source`], [`put`]), the container //! header records upstream `"lib"` mapped to the `xxh3-64` hash of the `.lib` bytes. //! @@ -98,7 +99,9 @@ //! `state_function_text: opt`, `state_function: opt`, //! `internal_node: opt`, `clock_gate_clock_pin: bool`, //! `clock_gate_enable_pin: bool`, `clock_gate_out_pin: bool`, `clock_gate_test_pin: bool`, -//! `clock_gate_obs_pin: bool`, `line: u32`. +//! `clock_gate_obs_pin: bool`, `line: u32`, `min_pulse_width_high: opt`, +//! `min_pulse_width_low: opt`, `min_period: opt` (the last three since version 4, +//! `WS-REQ-0117`). //! - `Sequential`: `kind: SeqKind`, `state: str_ref`, `state_n: str_ref`, //! `data_text: str_ref`, `data: Func`, `clock_text: str_ref`, `clock: Func`, //! `clocked_on_also: opt`, `clear_text: opt`, `clear: opt`, @@ -140,8 +143,8 @@ use crate::workspace::{Revision, Workspace}; /// The `rbin` container kind for compiled Liberty libraries (`WS-REQ-0110`). pub const KIND: &str = "LIBERTY"; -/// The current `LIBERTY` format version (`WS-REQ-0110`). -pub const FORMAT_VERSION: u32 = 3; +/// The current `LIBERTY` format version (`WS-REQ-0110`, `WS-REQ-0117`). +pub const FORMAT_VERSION: u32 = 4; /// The range of `LIBERTY` format versions supported by this build (`WS-REQ-0110`). pub const VERSIONS: RangeInclusive = FORMAT_VERSION..=FORMAT_VERSION; @@ -580,6 +583,9 @@ impl Encoder { self.write_bool(p.clock_gate_test_pin); self.write_bool(p.clock_gate_obs_pin); self.write_u32(p.line); + self.write_opt_f64(p.min_pulse_width_high); + self.write_opt_f64(p.min_pulse_width_low); + self.write_opt_f64(p.min_period); Ok(()) } diff --git a/crates/core/rusteda-workspace/src/rlib/decode.rs b/crates/core/rusteda-workspace/src/rlib/decode.rs index 54dbd52..66db251 100644 --- a/crates/core/rusteda-workspace/src/rlib/decode.rs +++ b/crates/core/rusteda-workspace/src/rlib/decode.rs @@ -395,6 +395,9 @@ impl<'a> Decoder<'a> { let clock_gate_test_pin = self.read_bool()?; let clock_gate_obs_pin = self.read_bool()?; let line = self.read_u32()?; + let min_pulse_width_high = self.read_opt_f64()?; + let min_pulse_width_low = self.read_opt_f64()?; + let min_period = self.read_opt_f64()?; Ok(Pin { name, direction, @@ -413,6 +416,9 @@ impl<'a> Decoder<'a> { max_transition, max_fanout, fanout_load, + min_pulse_width_high, + min_pulse_width_low, + min_period, is_clock, arcs, internal_power, diff --git a/crates/core/rusteda-workspace/src/sta.rs b/crates/core/rusteda-workspace/src/sta.rs index a5a2839..a471257 100644 --- a/crates/core/rusteda-workspace/src/sta.rs +++ b/crates/core/rusteda-workspace/src/sta.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 RustEDA contributors -//! A validated, read-only, zero-copy view of the timing table `sta.rbin` (`STA` v1) that the +//! A validated, read-only, zero-copy view of the timing table `sta.rbin` (`STA` v2, v1 still read) that the //! static timing analysis tool of the suite commits, and a small builder of such tables //! (WS-REQ-0126 to WS-REQ-0136, `docs/specs/workspace/sta.md`). //! @@ -35,10 +35,10 @@ pub const TABLE_NAME: &str = "sta"; pub const TABLE_PATH: &str = "sta.rbin"; /// The rbin kind of the timing table (WS-REQ-0126). pub const KIND: &str = "STA"; -/// The format version this build reads and writes (WS-REQ-0126). -pub const FORMAT_VERSION: u32 = 1; +/// The format version this build writes (WS-REQ-0126). +pub const FORMAT_VERSION: u32 = 2; /// The format versions this build reads (WS-REQ-0126). -pub const VERSIONS: RangeInclusive = FORMAT_VERSION..=FORMAT_VERSION; +pub const VERSIONS: RangeInclusive = 1..=FORMAT_VERSION; /// An absent index in a record (WS-REQ-0127). pub const NO_INDEX: u32 = u32::MAX; /// The name of the path group that aggregates every endpoint of a corner (WS-REQ-0131). diff --git a/crates/core/rusteda-workspace/src/sta/build.rs b/crates/core/rusteda-workspace/src/sta/build.rs index 0ccbb67..406e7f3 100644 --- a/crates/core/rusteda-workspace/src/sta/build.rs +++ b/crates/core/rusteda-workspace/src/sta/build.rs @@ -214,7 +214,7 @@ struct Built { ep_slack: Vec, ep_order: Vec, path_rank: Vec, - chain_ptr: Vec, + chain_ptr: Vec, chain: Vec, paths: Vec, points: Vec, @@ -402,8 +402,11 @@ impl StaTableBuilder { ("hold_wns_ns", json!(canon(hold_wns.unwrap_or(0.0)))), ("hold_tns_ns", json!(canon(hold_tns))), ("hold_violating_endpoints", json!(hold_viol)), - ("max_trans_violating", json!(0)), - ("max_cap_violating", json!(0)), + // The builder checks no design rule: its counts are unknown, not zero + // (WS-REQ-0137). + ("max_trans_violating", serde_json::Value::Null), + ("max_cap_violating", serde_json::Value::Null), + ("drv_status", json!("unchecked")), ("total_endpoints", json!(built.meta.num_endpoints)), ("unconstrained_endpoints", json!(0)), ("corners", json!(built.meta.num_corners)), @@ -456,11 +459,13 @@ impl StaTableBuilder { return Err(internal("a timing table has at least one corner")); } if self.corners.len() > 16 { - return Err(internal("PATH_RANK addresses at most 16 corners")); + return Err(internal("a timing table addresses at most 16 corners")); } let n_nodes = self.nodes.len(); - if n_nodes > 0x00FF_FFFF { - return Err(internal("path chains address at most 2^24 nodes")); + if n_nodes >= NO_INDEX as usize { + return Err(internal( + "a timing table addresses fewer than 2^32 - 1 nodes", + )); } let (node_id, ranges) = self.number(); let mut by_id = vec![0usize; n_nodes]; @@ -526,8 +531,10 @@ impl StaTableBuilder { // Endpoints in node order (WS-REQ-0132). let mut eps: Vec<&EndpointSpec> = self.endpoints.iter().collect(); eps.sort_by_key(|e| node_id[e.node.0]); - if eps.len() > 0x000F_FFFF { - return Err(internal("PATH_RANK addresses at most 2^20 endpoints")); + if eps.len() >= NO_INDEX as usize { + return Err(internal( + "a timing table addresses fewer than 2^32 - 1 endpoints", + )); } let ep_of: HashMap = eps.iter().enumerate().map(|(i, e)| (e.node, i)).collect(); @@ -594,21 +601,21 @@ impl StaTableBuilder { .map(|e| { let id = node_id[e.node.0]; let bucket = bucket_of[id as usize]; - let named = if bucket < 0x003F_FFFF { bucket + 1 } else { 0 }; EndpointRec { data_node: id, clock_node: NO_INDEX, capture_clock: if self.clocks.is_empty() { NO_INDEX } else { 0 }, path_group_str: group_str, check_arc: NO_INDEX, - kind_flags: endpoint_code(e.kind) | (2 << 8) | (named << 10), + kind_flags: endpoint_code(e.kind) | (2 << 8), worst_setup_rank: NO_INDEX, worst_hold_rank: NO_INDEX, + name_bucket_plus1: if bucket == NO_INDEX { 0 } else { bucket + 1 }, } }) .collect(); let mut path_rank = Vec::with_capacity(ranked.len()); - let mut chain_ptr = vec![0u32]; + let mut chain_ptr = vec![0u64]; let mut chain = Vec::new(); let mut paths = Vec::with_capacity(top_k); let mut points = Vec::new(); @@ -622,12 +629,13 @@ impl StaTableBuilder { let end = p.points[p.points.len() - 1]; path_rank.push(PathRankRec { slack_ns: canon32(p.slack_ns), - endpoint_lo: (*ep & 0xFFFF) as u16, - corner_ep_hi: (p.corner as u8) | (((*ep >> 16) as u8) << 4), + endpoint: *ep as u32, + corner: p.corner as u16, flags: u8::from(p.kind.is_hold_family()) | (tr_bit(end.1) << 1) | (kind_bits << 2) | (u8::from(row < top_k) << 7), + pad: 0, }); let worst = if p.kind.is_hold_family() { &mut endpoints[*ep].worst_hold_rank @@ -641,10 +649,11 @@ impl StaTableBuilder { chain.extend(f64_to_words(0.0)); for &(n, tr, kind, _) in &p.points { let id = node_id[n.0]; - chain.push(id | (u32::from(tr_bit(tr)) << 24) | (point_code(kind) << 25)); + chain.push(id); + chain.push(u32::from(tr_bit(tr)) | (point_code(kind) << 1)); chain.push(bucket_of[id as usize]); } - chain_ptr.push(chain.len() as u32); + chain_ptr.push(chain.len() as u64); if row < top_k { let begin = points.len() as u32; let mut prev = 0.0; diff --git a/crates/core/rusteda-workspace/src/sta/layout.rs b/crates/core/rusteda-workspace/src/sta/layout.rs index f218d84..c86b074 100644 --- a/crates/core/rusteda-workspace/src/sta/layout.rs +++ b/crates/core/rusteda-workspace/src/sta/layout.rs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 RustEDA contributors -//! Section ids, element sizes and the fixed-size records of the `STA` v1 table -//! (WS-REQ-0126, WS-REQ-0127). +//! Section ids, element sizes and the fixed-size records of the `STA` table, format versions 1 +//! and 2 (WS-REQ-0126, WS-REQ-0127). Version 2 widens the endpoint index of `PATH_RANK`, the name +//! bucket of `ENDPOINTS` and the node of a `PATH_CHAIN` step to 32 bits and the word offsets of +//! `PATH_CHAIN_PTR` to 64 bits; version 1 stays readable. //! //! Every record is `#[repr(C)]` plain old data without padding, so a section is viewed in place //! as `&[Record]` from a map or an aligned buffer. The field order is the byte order of the @@ -40,15 +42,18 @@ pub(crate) mod section { /// The number of sections. pub(crate) const COUNT: u32 = 24; - /// The element size of section `id`, `None` outside `1..=24`. - pub(crate) const fn elem_size(id: u32) -> Option { + /// The element size of section `id` in format `version`, `None` outside `1..=24`. + pub(crate) const fn elem_size(id: u32, version: u32) -> Option { Some(match id { STR_BYTES | SCOPE => 1, + PATH_CHAIN_PTR if version >= 2 => 8, STR_OFFS | TAG_EXTRA_PTR | EP_ORDER | PATH_CHAIN_PTR | PATH_CHAIN | NAME_HASH | INST_HASH => 4, + PATH_RANK if version >= 2 => 12, PATH_RANK => 8, UNCONSTRAINED => 12, CORNERS | TAGS | DISABLED_ARCS | EP_SLACK | NOTES => 16, + ENDPOINTS if version >= 2 => 36, ENDPOINTS | PATH_POINTS => 32, CLOCKS => 48, TAG_EXTRA | PATHS => 56, @@ -166,10 +171,27 @@ pub(crate) struct NodeRec { pub pred: [[u32; 2]; 2], } -/// `ENDPOINTS`: one record per checked endpoint. +/// `ENDPOINTS` of format version 2: one record per checked endpoint. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Pod, Zeroable)] pub(crate) struct EndpointRec { + pub data_node: u32, + pub clock_node: u32, + pub capture_clock: u32, + pub path_group_str: u32, + pub check_arc: u32, + /// Bits 0..8 kind, 8..10 capture edge, 10..32 zero. + pub kind_flags: u32, + pub worst_setup_rank: u32, + pub worst_hold_rank: u32, + /// `NAME_HASH` bucket + 1 of `data_node` (0: none). + pub name_bucket_plus1: u32, +} + +/// `ENDPOINTS` of format version 1, which kept the name bucket in bits 10..32 of `kind_flags`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Pod, Zeroable)] +pub(crate) struct EndpointRecV1 { pub data_node: u32, pub clock_node: u32, pub capture_clock: u32, @@ -181,6 +203,23 @@ pub(crate) struct EndpointRec { pub worst_hold_rank: u32, } +impl EndpointRecV1 { + /// The record in the layout of format version 2. + pub(crate) const fn upgrade(&self) -> EndpointRec { + EndpointRec { + data_node: self.data_node, + clock_node: self.clock_node, + capture_clock: self.capture_clock, + path_group_str: self.path_group_str, + check_arc: self.check_arc, + kind_flags: self.kind_flags & 0x3FF, + worst_setup_rank: self.worst_setup_rank, + worst_hold_rank: self.worst_hold_rank, + name_bucket_plus1: self.kind_flags >> 10, + } + } +} + /// `EP_SLACK`: setup and hold slack of one (corner, endpoint); NaN when absent. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] @@ -189,10 +228,22 @@ pub(crate) struct EpSlackRec { pub hold_slack: f64, } -/// `PATH_RANK`: one record per ranked path, in rank order. +/// `PATH_RANK` of format version 2: one record per ranked path, in rank order. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] pub(crate) struct PathRankRec { + pub slack_ns: f32, + pub endpoint: u32, + pub corner: u16, + /// Bit 0 hold family, bit 1 end transition, bits 2..4 check kind, bit 7 materialized. + pub flags: u8, + pub pad: u8, +} + +/// `PATH_RANK` of format version 1, which addressed `2^20` endpoints and 16 corners. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] +pub(crate) struct PathRankRecV1 { pub slack_ns: f32, /// Bits 0..16 of the endpoint index. pub endpoint_lo: u16, @@ -202,6 +253,19 @@ pub(crate) struct PathRankRec { pub flags: u8, } +impl PathRankRecV1 { + /// The record in the layout of format version 2. + pub(crate) fn upgrade(&self) -> PathRankRec { + PathRankRec { + slack_ns: self.slack_ns, + endpoint: u32::from(self.endpoint_lo) | (u32::from(self.corner_ep_hi >> 4) << 16), + corner: u16::from(self.corner_ep_hi & 0x0F), + flags: self.flags, + pad: 0, + } + } +} + /// `PATHS`: the header of one materialized (top-K) path. #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] @@ -259,9 +323,11 @@ const _: () = { assert!(size_of::() == 48); assert!(size_of::() == 16); assert!(size_of::() == 152); - assert!(size_of::() == 32); + assert!(size_of::() == 36); + assert!(size_of::() == 32); assert!(size_of::() == 16); - assert!(size_of::() == 8); + assert!(size_of::() == 12); + assert!(size_of::() == 8); assert!(size_of::() == 56); assert!(size_of::() == 32); assert!(size_of::() == 80); @@ -271,6 +337,32 @@ const _: () = { /// little-endian word pairs. pub(crate) const CHAIN_HEADER_WORDS: usize = 4; +/// Words of one path-chain step in format `version`: `[node, step_word, name_bucket]` in +/// version 2, `[step_word, name_bucket]` with a 24-bit node in version 1. +pub(crate) const fn chain_step_words(version: u32) -> usize { + if version >= 2 { 3 } else { 2 } +} + +/// One decoded path-chain step: `(node, transition bit, point code, name bucket)`; a version-1 +/// step yields only the low 24 bits of its node. +pub(crate) fn decode_chain_step(version: u32, w: &[u32]) -> (u32, u32, u32, u32) { + if version >= 2 { + (w[0], w[1] & 1, (w[1] >> 1) & 0b11, w[2]) + } else { + ( + w[0] & 0x00FF_FFFF, + (w[0] >> 24) & 1, + (w[0] >> 25) & 0b11, + w[1], + ) + } +} + +/// The bits of a node id that a path-chain step of format `version` keeps. +pub(crate) const fn chain_node_mask(version: u32) -> u32 { + if version >= 2 { u32::MAX } else { 0x00FF_FFFF } +} + /// Words per bucket of `NAME_HASH` and `INST_HASH`. pub(crate) const BUCKET_WORDS: usize = 4; diff --git a/crates/core/rusteda-workspace/src/sta/read.rs b/crates/core/rusteda-workspace/src/sta/read.rs index 13835d0..9225126 100644 --- a/crates/core/rusteda-workspace/src/sta/read.rs +++ b/crates/core/rusteda-workspace/src/sta/read.rs @@ -14,9 +14,9 @@ use std::collections::HashMap; use serde_json::{Map, Value}; use super::layout::{ - BUCKET_WORDS, CHAIN_HEADER_WORDS, ClockRec, CornerRec, EndpointRec, EpSlackRec, MetaRec, - NodeRec, PathHeaderRec, PathPointRec, PathRankRec, SummaryRec, f64_from_words, hash_slot, - section, + BUCKET_WORDS, CHAIN_HEADER_WORDS, ClockRec, CornerRec, EndpointRec, EndpointRecV1, EpSlackRec, + MetaRec, NodeRec, PathHeaderRec, PathPointRec, PathRankRec, PathRankRecV1, SummaryRec, + chain_node_mask, chain_step_words, decode_chain_step, f64_from_words, hash_slot, section, }; use super::{ CheckKind, Clock, Counts, Endpoint, EndpointKind, KIND, NO_INDEX, PathPoint, PathRank, @@ -42,11 +42,13 @@ struct View<'a> { corners: &'a [CornerRec], clocks: &'a [ClockRec], node_rec: &'a [NodeRec], - endpoints: &'a [EndpointRec], + endpoints: Endpoints<'a>, ep_slack: &'a [EpSlackRec], ep_order: &'a [u32], - path_rank: &'a [PathRankRec], - chain_ptr: &'a [u32], + path_rank: Ranks<'a>, + /// The format version, which fixes the layouts above and of `chain`. + version: u32, + chain_ptr: ChainPtr<'a>, chain: &'a [u32], paths: &'a [PathHeaderRec], points: &'a [PathPointRec], @@ -55,6 +57,70 @@ struct View<'a> { scope: &'a [u8], } +/// `ENDPOINTS` in the layout of its format version. +#[derive(Clone, Copy)] +enum Endpoints<'a> { + V1(&'a [EndpointRecV1]), + V2(&'a [EndpointRec]), +} + +impl Endpoints<'_> { + fn len(&self) -> usize { + match self { + Self::V1(r) => r.len(), + Self::V2(r) => r.len(), + } + } + + /// Endpoint `i` in the version-2 layout. + fn get(&self, i: usize) -> Option { + match self { + Self::V1(r) => r.get(i).map(EndpointRecV1::upgrade), + Self::V2(r) => r.get(i).copied(), + } + } +} + +/// `PATH_CHAIN_PTR` in the layout of its format version. +#[derive(Clone, Copy)] +enum ChainPtr<'a> { + V1(&'a [u32]), + V2(&'a [u64]), +} + +impl ChainPtr<'_> { + fn get(&self, i: usize) -> Option { + match self { + Self::V1(p) => p.get(i).map(|&x| u64::from(x)), + Self::V2(p) => p.get(i).copied(), + } + } +} + +/// `PATH_RANK` in the layout of its format version. +#[derive(Clone, Copy)] +enum Ranks<'a> { + V1(&'a [PathRankRecV1]), + V2(&'a [PathRankRec]), +} + +impl Ranks<'_> { + fn len(&self) -> usize { + match self { + Self::V1(r) => r.len(), + Self::V2(r) => r.len(), + } + } + + /// Ranked path `i` in the version-2 layout. + fn get(&self, i: usize) -> Option { + match self { + Self::V1(r) => r.get(i).map(PathRankRecV1::upgrade), + Self::V2(r) => r.get(i).copied(), + } + } +} + /// The name of a node as one `NAME_HASH` bucket holds it. struct NodeName<'a> { node: u32, @@ -106,7 +172,13 @@ fn mul(rbin: &Rbin, a: usize, b: usize, what: &str) -> Result { /// Validates a CSR row pointer of `rows + 1` entries into `target` elements: first 0, last /// `target` (WS-REQ-0128). Inner entries are checked where a row is read (WS-REQ-0129). -fn check_csr(rbin: &Rbin, ptr: &[u32], rows: usize, target: usize, what: &str) -> Result<()> { +fn check_csr>( + rbin: &Rbin, + ptr: &[T], + rows: usize, + target: usize, + what: &str, +) -> Result<()> { let Some(want) = rows.checked_add(1) else { return Err(corrupt_at(rbin, format!("{what} row count overflows"))); }; @@ -116,7 +188,9 @@ fn check_csr(rbin: &Rbin, ptr: &[u32], rows: usize, target: usize, what: &str) - format!("{what} has {} entries, META gives {want}", ptr.len()), )); } - if ptr.first() != Some(&0) || ptr.last().map(|&l| l as usize) != Some(target) { + if ptr.first().map(|&f| f.into()) != Some(0) + || ptr.last().map(|&l| l.into()) != u64::try_from(target).ok() + { return Err(corrupt_at( rbin, format!("{what} must start at 0 and end at {target}"), @@ -187,14 +261,14 @@ fn validate(rbin: &Rbin) -> Result<(MetaRec, StoreMode)> { format!("section {} where section {id} belongs", s.id), )); } - if Some(s.elem_size) != section::elem_size(id) { + if Some(s.elem_size) != section::elem_size(id, version) { return Err(corrupt_at( rbin, format!( "section {id} ({}) has {}-byte elements, expected {}", section::name(id), s.elem_size, - section::elem_size(id).unwrap_or(0) + section::elem_size(id, version).unwrap_or(0) ), )); } @@ -291,13 +365,14 @@ fn validate(rbin: &Rbin) -> Result<(MetaRec, StoreMode)> { )); } } - check_csr( - rbin, - rbin.section::(section::PATH_CHAIN_PTR)?, - n(m.num_ranked_paths), - count(rbin, section::PATH_CHAIN)?, - "PATH_CHAIN_PTR", - )?; + let (ranked, chain_words) = (n(m.num_ranked_paths), count(rbin, section::PATH_CHAIN)?); + if rbin.format_version() >= 2 { + let ptr = rbin.section::(section::PATH_CHAIN_PTR)?; + check_csr(rbin, ptr, ranked, chain_words, "PATH_CHAIN_PTR")?; + } else { + let ptr = rbin.section::(section::PATH_CHAIN_PTR)?; + check_csr(rbin, ptr, ranked, chain_words, "PATH_CHAIN_PTR")?; + } let node_values = [ section::NODE_REC, section::TAG_EXTRA_PTR, @@ -396,17 +471,34 @@ impl StaTable { fn view(&self) -> Result> { let r = &self.rbin; + let version = r.format_version(); + let (endpoints, path_rank) = if version >= 2 { + ( + Endpoints::V2(r.section::(section::ENDPOINTS)?), + Ranks::V2(r.section::(section::PATH_RANK)?), + ) + } else { + ( + Endpoints::V1(r.section::(section::ENDPOINTS)?), + Ranks::V1(r.section::(section::PATH_RANK)?), + ) + }; Ok(View { str_bytes: r.section::(section::STR_BYTES)?, str_offs: r.section::(section::STR_OFFS)?, corners: r.section::(section::CORNERS)?, clocks: r.section::(section::CLOCKS)?, node_rec: r.section::(section::NODE_REC)?, - endpoints: r.section::(section::ENDPOINTS)?, + endpoints, ep_slack: r.section::(section::EP_SLACK)?, ep_order: r.section::(section::EP_ORDER)?, - path_rank: r.section::(section::PATH_RANK)?, - chain_ptr: r.section::(section::PATH_CHAIN_PTR)?, + path_rank, + version, + chain_ptr: if version >= 2 { + ChainPtr::V2(r.section::(section::PATH_CHAIN_PTR)?) + } else { + ChainPtr::V1(r.section::(section::PATH_CHAIN_PTR)?) + }, chain: r.section::(section::PATH_CHAIN)?, paths: r.section::(section::PATHS)?, points: r.section::(section::PATH_POINTS)?, @@ -652,7 +744,7 @@ impl StaTable { if (rec.kind_flags >> 8) & 0b11 == 0b11 { return Err(self.corrupt(format!("endpoint {index} has capture edge 3"))); } - let hint = (rec.kind_flags >> 10).checked_sub(1); + let hint = rec.name_bucket_plus1.checked_sub(1); let name = self.node_name(v, rec.data_node, hint, scan)?; let slack = corner .checked_mul(v.endpoints.len()) @@ -739,8 +831,8 @@ impl StaTable { reason: format!("the timing table has {} ranked paths", v.path_rank.len()), }); }; - let endpoint = usize::from(r.endpoint_lo) | (usize::from(r.corner_ep_hi >> 4) << 16); - let corner = usize::from(r.corner_ep_hi & 0x0F); + let endpoint = r.endpoint as usize; + let corner = usize::from(r.corner); if endpoint >= v.endpoints.len() || corner >= v.corners.len() { return Err(self.corrupt(format!( "ranked path {row} names endpoint {endpoint} of corner {corner}" @@ -788,10 +880,16 @@ impl StaTable { fn chain_of<'a>(&self, v: &View<'a>, row: usize) -> Result<&'a [u32]> { let bounds = row .checked_add(1) - .and_then(|next| Some((*v.chain_ptr.get(row)?, *v.chain_ptr.get(next)?))); + .and_then(|next| Some((v.chain_ptr.get(row)?, v.chain_ptr.get(next)?))); let words = bounds.and_then(|(a, b)| v.chain.get(a as usize..b as usize)); match words { - Some(w) if w.len() >= CHAIN_HEADER_WORDS && w.len() % 2 == 0 => Ok(w), + Some(w) + if w.len() >= CHAIN_HEADER_WORDS + && (w.len() - CHAIN_HEADER_WORDS) + .is_multiple_of(chain_step_words(v.version)) => + { + Ok(w) + } _ => Err(self.corrupt(format!( "the chain of ranked path {row} has a bad word range" ))), @@ -806,16 +904,17 @@ impl StaTable { let mut scan = None; let endpoint = self.endpoint_in(&v, rank.endpoint, rank.corner, &mut scan)?; let chain = self.chain_of(&v, row)?; + let node_mask = chain_node_mask(v.version); let steps: Vec<(u32, Transition, PointKind, u32)> = chain[CHAIN_HEADER_WORDS..] - .chunks_exact(2) + .chunks_exact(chain_step_words(v.version)) .map(|w| { - let node = w[0] & 0x00FF_FFFF; - let tr = if (w[0] >> 24) & 1 == 0 { + let (node, tr, kind, bucket) = decode_chain_step(v.version, w); + let tr = if tr == 0 { Transition::Rise } else { Transition::Fall }; - (node, tr, point_kind(w[0] >> 25), w[1]) + (node, tr, point_kind(kind), bucket) }) .collect(); let point = |name: NodeName<'_>, @@ -858,9 +957,10 @@ impl StaTable { p.transition, p.kind ))); } - // A chain step keeps the low 24 bits of its node (WS-REQ-0127). + // A chain step keeps its whole node; in version 1 only the low 24 bits + // (WS-REQ-0127). let hint = match steps.get(k) { - Some(&(node, _, _, bucket)) if node == p.node & 0x00FF_FFFF => Some(bucket), + Some(&(node, _, _, bucket)) if node == p.node & node_mask => Some(bucket), Some(&(node, ..)) => { return Err(self.corrupt(format!( "point {k} of materialized path {row} is node {} but its chain \ @@ -892,9 +992,9 @@ impl StaTable { (h.slack, true, true) } else { for &(node, tr, kind, bucket) in &steps { - // The bucket holds the whole node; the step keeps its low 24 bits. + // The bucket holds the whole node; a version-1 step keeps its low 24 bits. let name = self.bucket(&v, bucket)?; - if name.node & 0x00FF_FFFF != node { + if name.node & node_mask != node { return Err(self.corrupt(format!( "a chain step of ranked path {row} is node {node}, its name bucket {bucket} \ names node {}", diff --git a/crates/core/rusteda-workspace/src/sta/tests.rs b/crates/core/rusteda-workspace/src/sta/tests.rs index dc2f8c5..7b97204 100644 --- a/crates/core/rusteda-workspace/src/sta/tests.rs +++ b/crates/core/rusteda-workspace/src/sta/tests.rs @@ -124,11 +124,16 @@ fn builder_tables_open_in_both_modes() { let raw = bytes_of(&sample(mode)); let rbin = Rbin::from_bytes(raw.clone(), KIND, VERSIONS).unwrap(); assert_eq!(rbin.kind(), "STA"); - assert_eq!(rbin.format_version(), 1); + assert_eq!(rbin.format_version(), FORMAT_VERSION); let ids: Vec = rbin.sections().iter().map(|s| s.id).collect(); assert_eq!(ids, (1..=24).collect::>()); for s in rbin.sections() { - assert_eq!(Some(s.elem_size), section::elem_size(s.id), "{}", s.id); + assert_eq!( + Some(s.elem_size), + section::elem_size(s.id, FORMAT_VERSION), + "{}", + s.id + ); } rbin.verify_hash().unwrap(); let t = StaTable::from_bytes(raw).unwrap(); @@ -324,16 +329,30 @@ fn malformed_structure_is_corrupt() { // The container: kind, version, section directory, element sizes, file length. let secs = sections_of(&bytes_of(&s)); assert_eq!(open_code(assemble("NETLIST", 1, &secs)), CorruptTable); - assert_eq!(open_code(assemble(KIND, 2, &secs)), SchemaTooNew); + assert_eq!( + open_code(assemble(KIND, FORMAT_VERSION + 1, &secs)), + SchemaTooNew + ); let mut fewer = secs.clone(); fewer.remove(§ion::NOTES); - assert_eq!(open_code(assemble(KIND, 1, &fewer)), CorruptTable); + assert_eq!( + open_code(assemble(KIND, FORMAT_VERSION, &fewer)), + CorruptTable + ); let mut more = secs.clone(); more.insert(25, (1, vec![0])); - assert_eq!(open_code(assemble(KIND, 1, &more)), CorruptTable); + assert_eq!( + open_code(assemble(KIND, FORMAT_VERSION, &more)), + CorruptTable + ); let mut resized = secs.clone(); - resized.get_mut(§ion::ENDPOINTS).unwrap().0 = 16; - assert_eq!(open_code(assemble(KIND, 1, &resized)), CorruptTable); + resized.get_mut(§ion::ENDPOINTS).unwrap().0 = 12; + assert_eq!( + open_code(assemble(KIND, FORMAT_VERSION, &resized)), + CorruptTable + ); + // Version-2 sections in a version-1 container have the wrong element sizes. + assert_eq!(open_code(assemble(KIND, 1, &secs)), CorruptTable); let mut tail = bytes_of(&s); tail.extend_from_slice(&[0; 8]); assert_eq!(open_code(tail), CorruptTable); @@ -371,8 +390,9 @@ fn malformed_structure_is_corrupt() { assert_eq!(open_code(raw), CorruptTable, "TAG_EXTRA_PTR past TAG_EXTRA"); // Row pointers, hash tables, strings and scope. let raw = mutated(&s, |x| { + // The last 64-bit pointer: its low word. let b = bytes(x, section::PATH_CHAIN_PTR); - let last = b.len() - 4; + let last = b.len() - 8; let v = get_u32(b, last); set_u32(b, last, v - 2); }); @@ -403,8 +423,8 @@ fn malformed_indices_are_corrupt() { let t = StaTable::from_bytes(raw).unwrap(); assert_eq!(t.path(0).unwrap_err().code(), CorruptTable); assert_eq!(t.path_ranks().unwrap_err().code(), CorruptTable); - // A rank record whose early-check bit disagrees with its check. - let raw = mutated(&s, |x| bytes(x, section::PATH_RANK)[7] ^= 1); + // A rank record whose early-check bit disagrees with its check (flags at byte 10). + let raw = mutated(&s, |x| bytes(x, section::PATH_RANK)[10] ^= 1); assert_eq!( StaTable::from_bytes(raw) .unwrap() @@ -413,8 +433,9 @@ fn malformed_indices_are_corrupt() { .code(), CorruptTable ); - // A chain step naming a bucket out of range, and one naming another node. - let step = (CHAIN_HEADER_WORDS + 1) * 4; + // A chain step naming a bucket out of range, and one naming another node. A version-2 step + // is `[node, step_word, name_bucket]`: the first bucket is word `CHAIN_HEADER_WORDS + 2`. + let step = (CHAIN_HEADER_WORDS + 2) * 4; let raw = mutated(&chained, |x| { set_u32(bytes(x, section::PATH_CHAIN), step, 999) }); @@ -428,7 +449,7 @@ fn malformed_indices_are_corrupt() { ); let raw = mutated(&chained, |x| { let b = bytes(x, section::PATH_CHAIN); - let other = get_u32(b, step + 8); + let other = get_u32(b, step + 12); set_u32(b, step, other); }); assert_eq!( @@ -440,7 +461,7 @@ fn malformed_indices_are_corrupt() { CorruptTable ); // A chain row pointer past its neighbour. - let raw = mutated(&s, |x| set_u32(bytes(x, section::PATH_CHAIN_PTR), 4, 9999)); + let raw = mutated(&s, |x| set_u32(bytes(x, section::PATH_CHAIN_PTR), 8, 9999)); assert_eq!( StaTable::from_bytes(raw) .unwrap() @@ -653,3 +674,80 @@ fn builder_rejects_misuse() { "a path that does not end at an endpoint" ); } + +/// The version-1 bytes of a version-2 table: name buckets back in bits 10..32 of `kind_flags`, +/// 20-bit endpoints in `PATH_RANK`, `[step_word, name_bucket]` chain steps with 24-bit nodes. +fn downgraded_to_v1(raw: &[u8]) -> Vec { + let mut secs = sections_of(raw); + let eps = std::mem::take(bytes(&mut secs, section::ENDPOINTS)); + let mut v1 = Vec::new(); + for r in eps.chunks_exact(36) { + let mut rec = r[..32].to_vec(); + let flags = get_u32(&rec, 20) | (get_u32(r, 32) << 10); + set_u32(&mut rec, 20, flags); + v1.extend(rec); + } + *secs.get_mut(§ion::ENDPOINTS).unwrap() = (32, v1); + let ranks = std::mem::take(bytes(&mut secs, section::PATH_RANK)); + let mut v1 = Vec::new(); + for r in ranks.chunks_exact(12) { + let ep = get_u32(r, 4); + v1.extend_from_slice(&r[..4]); + v1.extend_from_slice(&((ep & 0xFFFF) as u16).to_le_bytes()); + v1.push(r[8] | (((ep >> 16) as u8) << 4)); + v1.push(r[10]); + } + *secs.get_mut(§ion::PATH_RANK).unwrap() = (8, v1); + let ptr = std::mem::take(bytes(&mut secs, section::PATH_CHAIN_PTR)); + let chain = std::mem::take(bytes(&mut secs, section::PATH_CHAIN)); + let words = |b: &[u8]| -> Vec { b.chunks_exact(4).map(|w| get_u32(w, 0)).collect() }; + let ptr: Vec = ptr + .chunks_exact(8) + .map(|w| u32::try_from(u64::from_le_bytes(w.try_into().unwrap())).unwrap()) + .collect(); + let chain = words(&chain); + let (mut new_ptr, mut new_chain) = (vec![0u32], Vec::new()); + for row in ptr.windows(2) { + let w = &chain[row[0] as usize..row[1] as usize]; + new_chain.extend_from_slice(&w[..CHAIN_HEADER_WORDS]); + for st in w[CHAIN_HEADER_WORDS..].chunks_exact(3) { + new_chain + .push((st[0] & 0x00FF_FFFF) | ((st[1] & 1) << 24) | (((st[1] >> 1) & 3) << 25)); + new_chain.push(st[2]); + } + new_ptr.push(new_chain.len() as u32); + } + let bytes_of_words = + |w: &[u32]| -> Vec { w.iter().flat_map(|x| x.to_le_bytes()).collect() }; + *secs.get_mut(§ion::PATH_CHAIN_PTR).unwrap() = (4, bytes_of_words(&new_ptr)); + *secs.get_mut(§ion::PATH_CHAIN).unwrap() = (4, bytes_of_words(&new_chain)); + assemble(KIND, 1, &secs) +} + +// WS-REQ-0126, WS-REQ-0127: a version-1 table stays readable and answers every query as the +// version-2 table it was converted from. +#[test] +fn version_one_tables_read_like_version_two() { + for mode in [StoreMode::Full, StoreMode::Summary] { + for top_k in [None, Some(0)] { + let mut b = sample(mode); + if let Some(k) = top_k { + b.top_k(k); + } + let raw = bytes_of(&b); + let (t2, t1) = ( + StaTable::from_bytes(raw.clone()).unwrap(), + StaTable::from_bytes(downgraded_to_v1(&raw)).unwrap(), + ); + assert_eq!(t1.endpoints(0).unwrap(), t2.endpoints(0).unwrap()); + assert_eq!(t1.path_ranks().unwrap(), t2.path_ranks().unwrap()); + for row in 0..t2.path_ranks().unwrap().len() { + assert_eq!(t1.path(row).unwrap(), t2.path(row).unwrap(), "path {row}"); + assert_eq!( + t1.path_required(row).unwrap(), + t2.path_required(row).unwrap() + ); + } + } + } +} diff --git a/crates/core/rusteda-workspace/tests/rlib.rs b/crates/core/rusteda-workspace/tests/rlib.rs index 68bdbe1..5b7520f 100644 --- a/crates/core/rusteda-workspace/tests/rlib.rs +++ b/crates/core/rusteda-workspace/tests/rlib.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 RustEDA contributors -//! Tests for the `LIBERTY v1` (`.rlib`) codec and cache loader (`WS-REQ-0110` to `WS-REQ-0116`). +//! Tests for the `LIBERTY` (`.rlib`) codec and cache loader (`WS-REQ-0110` to `WS-REQ-0117`). mod common; @@ -98,6 +98,10 @@ fn rich_library() -> Library { max_transition: Some(0.75), max_fanout: None, fanout_load: Some(1.25), + // Pin pulse attributes, carried since format version 4 (WS-REQ-0117). + min_pulse_width_high: Some(0.125), + min_pulse_width_low: Some(0.15), + min_period: Some(0.5), is_clock: true, arcs: Vec::new(), internal_power: Vec::new(), @@ -138,6 +142,9 @@ fn rich_library() -> Library { max_transition: Some(0.5), max_fanout: Some(16.0), fanout_load: None, + min_pulse_width_high: None, + min_pulse_width_low: None, + min_period: None, is_clock: false, arcs: vec![TimingArc { related_pin: "A".to_string(), @@ -609,3 +616,52 @@ fn cache_put_load_corners_and_transparent_recompile() { let _ = fs::remove_dir_all(&dir); } + +/// WS-REQ-0117: format version 4 carries the pin pulse attributes (`min_pulse_width_high`, +/// `min_pulse_width_low`, `min_period`) through encode and decode, and a cached version 3 `.rlib`, +/// which cannot hold them, is compiled again from its `.lib` instead of being read without them. +#[test] +fn version_4_carries_pin_pulse_attributes_and_recompiles_version_3() { + assert_eq!(FORMAT_VERSION, 4); + assert_eq!(VERSIONS, 4..=4); + let lib = rich_library(); + let decoded = rlib::decode(&rlib::encode(&lib).unwrap()).unwrap(); + let pin = |l: &Library| l.cells[0].pins[0].clone(); + assert_eq!( + ( + pin(&decoded).min_pulse_width_high, + pin(&decoded).min_pulse_width_low, + pin(&decoded).min_period + ), + (Some(0.125), Some(0.15), Some(0.5)) + ); + + let dir = temp_dir("rlib-v3"); + let ws = Workspace::init(&dir, &inv("rusteda init")) + .unwrap() + .workspace; + let base = ws.resolve_base("rev0").unwrap(); + let mut txn = ws.begin(base, &inv("rusteda import")).unwrap(); + let lib_bytes = b"library (rich_lib) { /* v3 */ }"; + let cached = rlib::put(&ws, &lib, lib_bytes).unwrap(); + txn.add_library(cached.entry("tt", "rich.lib")).unwrap(); + txn.commit(BTreeMap::new()).unwrap(); + let rev1 = ws.revision(Rev(1)).unwrap(); + let mut v3 = RbinWriter::new(KIND, 3).unwrap(); + v3.section_bytes(SEC_STR, 1, Vec::new()).unwrap(); + fs::write(&cached.rlib_path, v3.to_bytes()).unwrap(); + let (loaded, recompiled) = rlib::load_or_recompile( + &ws, + &rev1, + "rich_lib", + "tt", + |raw| -> Result { + assert_eq!(raw, lib_bytes); + Ok(lib.clone()) + }, + ) + .unwrap(); + assert!(recompiled, "a version 3 .rlib is compiled again"); + assert_eq!(pin(&loaded).min_period, Some(0.5)); + let _ = fs::remove_dir_all(&dir); +} diff --git a/crates/formats/rusteda-formats/Cargo.toml b/crates/formats/rusteda-formats/Cargo.toml index 602117f..a3955f1 100644 --- a/crates/formats/rusteda-formats/Cargo.toml +++ b/crates/formats/rusteda-formats/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rusteda-formats" -description = "Readers and writers of the RustEDA tools: SystemVerilog, Liberty, SDC, BLIF and AIGER" +description = "Readers and writers of the RustEDA tools: SystemVerilog, Liberty, SDC, SPEF, BLIF and AIGER" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -10,8 +10,10 @@ authors = ["RustEDA contributors"] repository.workspace = true [dependencies] +serde.workspace = true rusteda-core.workspace = true thiserror.workspace = true +flate2.workspace = true rayon.workspace = true rustc-hash.workspace = true smallvec.workspace = true diff --git a/crates/formats/rusteda-formats/README.md b/crates/formats/rusteda-formats/README.md index 99c477b..bff3cfc 100644 --- a/crates/formats/rusteda-formats/README.md +++ b/crates/formats/rusteda-formats/README.md @@ -25,6 +25,12 @@ Readers and writers of the RustEDA tools, shared by every tool of the suite. trees with structured `D0001`..`D0040` diagnostics. `write` emits canonical, variable-free SDC text (`read(write(s)) == s`); `Sdc::to_sections` and `Sdc::from_sections` encode and decode the 13 `CONSTR v1` binary sections; `glob_match` and `glob_match_nocase` implement `*`/`?` matching. +- `spef` (`docs/specs/rusteda-cli/spef.md`): bounded IEEE 1481 detailed RC reader. `read` reads + a SPEF text under explicit `Limits` (source bytes, nets, nodes, elements, name bytes) into a + `Spef` of nets with their capacitive nodes, connections and resistors (inter-net coupling + grounded at factor one); `decode_source` decodes plain and gzip sources under the same + byte budget; syntax, budget and unsupported semantic constructs are `T0071`, `T0072` and + `T0073` errors. - `blif` (`docs/specs/blif/`): BLIF reader. `read_files` and `read_texts` read models with `.names` covers, `.latch` latches, `.subckt` hierarchy (flattened with an explicit stack and bounded by `Limits`) and `.gate`/`.mlatch` library cells (recorded for the consumer to resolve diff --git a/crates/formats/rusteda-formats/src/lib.rs b/crates/formats/rusteda-formats/src/lib.rs index 363a985..4b33eca 100644 --- a/crates/formats/rusteda-formats/src/lib.rs +++ b/crates/formats/rusteda-formats/src/lib.rs @@ -2,11 +2,13 @@ // SPDX-FileCopyrightText: 2026 RustEDA contributors //! Readers and writers of the RustEDA tools: SystemVerilog ([`verilog`], `docs/specs/verilog/`), -//! Liberty ([`liberty`], `docs/specs/liberty/`), SDC ([`sdc`], `docs/specs/sdc/`), BLIF -//! ([`blif`], `docs/specs/blif/`) and AIGER ([`aiger`], `docs/specs/aiger/`). +//! Liberty ([`liberty`], `docs/specs/liberty/`), SDC ([`sdc`], `docs/specs/sdc/`), detailed RC +//! SPEF ([`spef`], `docs/specs/rusteda-cli/spef.md`), BLIF ([`blif`], `docs/specs/blif/`) and +//! AIGER ([`aiger`], `docs/specs/aiger/`). pub mod aiger; pub mod blif; pub mod liberty; pub mod sdc; +pub mod spef; pub mod verilog; diff --git a/crates/formats/rusteda-formats/src/liberty/build.rs b/crates/formats/rusteda-formats/src/liberty/build.rs index 0e629c1..317f62c 100644 --- a/crates/formats/rusteda-formats/src/liberty/build.rs +++ b/crates/formats/rusteda-formats/src/liberty/build.rs @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2026 RustEDA contributors //! Library model built from the syntax tree (LIBERTY-REQ-0010 to LIBERTY-REQ-0019, -//! LIBERTY-REQ-0021, LIBERTY-REQ-0023 to LIBERTY-REQ-0026, LIBERTY-REQ-0040 to LIBERTY-REQ-0048), +//! LIBERTY-REQ-0021, LIBERTY-REQ-0023 to LIBERTY-REQ-0026, LIBERTY-REQ-0040 to LIBERTY-REQ-0049), //! with the messages of the reader (LIBERTY-REQ-0050, LIBERTY-REQ-0057 to LIBERTY-REQ-0059). use std::collections::{BTreeMap, HashSet}; @@ -923,6 +923,11 @@ impl Builder<'_> { let max_transition = limit(self, "max_transition"); let max_fanout = limit(self, "max_fanout"); let fanout_load = limit(self, "fanout_load"); + // The pin-level pulse attributes: unconditional pulse constraints of the pin + // (LIBERTY-REQ-0049). + let min_pulse_width_high = limit(self, "min_pulse_width_high"); + let min_pulse_width_low = limit(self, "min_pulse_width_low"); + let min_period = limit(self, "min_period"); let (three_state, three_state_function) = self.function(p, "three_state", cell, name, problems); let capacitance = self.number(p, PIN, "capacitance"); @@ -946,6 +951,9 @@ impl Builder<'_> { max_transition, max_fanout, fanout_load, + min_pulse_width_high, + min_pulse_width_low, + min_period, is_clock: self.flag(p, PIN, "clock"), arcs, internal_power, diff --git a/crates/formats/rusteda-formats/src/liberty/classify.rs b/crates/formats/rusteda-formats/src/liberty/classify.rs index 9a6aeae..5240500 100644 --- a/crates/formats/rusteda-formats/src/liberty/classify.rs +++ b/crates/formats/rusteda-formats/src/liberty/classify.rs @@ -244,7 +244,10 @@ fn attr_class(ctx: Ctx, name: &str) -> AttrClass { | "clock_gate_out_pin" | "clock_gate_obs_pin" | "internal_node" - | "state_function" => C(S), + | "state_function" + | "min_pulse_width_high" + | "min_pulse_width_low" + | "min_period" => C(S), "rise_capacitance_range" | "fall_capacitance_range" => C(X), "driver_waveform" | "driver_waveform_rise" @@ -252,9 +255,6 @@ fn attr_class(ctx: Ctx, name: &str) -> AttrClass { | "input_voltage" | "output_voltage" | "nextstate_type" - | "min_period" - | "min_pulse_width_high" - | "min_pulse_width_low" | "driver_type" | "inverted_output" | "input_signal_level" diff --git a/crates/formats/rusteda-formats/src/sdc/schema.rs b/crates/formats/rusteda-formats/src/sdc/schema.rs index fccc70f..af46bee 100644 --- a/crates/formats/rusteda-formats/src/sdc/schema.rs +++ b/crates/formats/rusteda-formats/src/sdc/schema.rs @@ -711,14 +711,14 @@ pub static SCHEMAS: &[CmdSchema] = &[ }, CmdSchema { name: "set_propagated_clock", - status: CmdStatus::Phase2b, + status: CmdStatus::Interpreted, min_pos: 1, max_pos: 1, options: OPTS_NONE, }, CmdSchema { name: "unset_propagated_clock", - status: CmdStatus::Phase2b, + status: CmdStatus::Interpreted, min_pos: 1, max_pos: 1, options: OPTS_NONE, diff --git a/crates/formats/rusteda-formats/src/sdc/schema/build.rs b/crates/formats/rusteda-formats/src/sdc/schema/build.rs index 40801dc..b01dfaa 100644 --- a/crates/formats/rusteda-formats/src/sdc/schema/build.rs +++ b/crates/formats/rusteda-formats/src/sdc/schema/build.rs @@ -371,6 +371,14 @@ pub(super) fn build_cmd_kind( objects, }) } + "set_propagated_clock" | "unset_propagated_clock" => { + let objects = parse_obj_val(&p.pos[0], name, "clock_list")?; + Ok(if name == "set_propagated_clock" { + CmdKind::SetPropagatedClock { objects } + } else { + CmdKind::UnsetPropagatedClock { objects } + }) + } "set_clock_transition" => { let raw_tr = parse_f64_val(&p.pos[0], name, "transition")?; let clocks = parse_obj_val(&p.pos[1], name, "clock_list")?; diff --git a/crates/formats/rusteda-formats/src/sdc/schema/parse.rs b/crates/formats/rusteda-formats/src/sdc/schema/parse.rs index fabd9fb..a4b83e4 100644 --- a/crates/formats/rusteda-formats/src/sdc/schema/parse.rs +++ b/crates/formats/rusteda-formats/src/sdc/schema/parse.rs @@ -721,7 +721,7 @@ pub fn parse_top_cmd( Severity::Warning, "D0021", format!( - "design-rule command '{}' is recorded in CONSTR v1 but not analyzed by the timing engine", + "design-rule command '{}' is recorded in CONSTR v1; the analysis that reads it reports whether it is checked", schema.name ), ) diff --git a/crates/formats/rusteda-formats/src/spef/mod.rs b/crates/formats/rusteda-formats/src/spef/mod.rs new file mode 100644 index 0000000..bf24e3f --- /dev/null +++ b/crates/formats/rusteda-formats/src/spef/mod.rs @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Bounded IEEE 1481 detailed RC reader. Grounded inter-net coupling is explicit; +//! unsupported semantic constructs fail instead of silently changing timing. + +mod parse; +mod source; + +use serde::{Deserialize, Serialize}; + +pub use parse::read; +pub use source::{DecodedSource, SourceEncoding, decode_source}; + +/// Hard parser budgets, retained with the imported source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Limits { + pub max_bytes: usize, + pub max_nets: usize, + pub max_nodes: usize, + pub max_elements: usize, + pub max_name_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_bytes: 32 << 20, + max_nets: 100_000, + max_nodes: 500_000, + max_elements: 1_000_000, + max_name_bytes: 16 << 20, + } + } +} + +impl Limits { + /// Absolute supported parser ceilings, also used for public typed inputs. + pub fn absolute_max() -> Self { + let d = Self::default(); + Self { + max_bytes: d.max_bytes * 8, + max_nets: d.max_nets * 8, + max_nodes: d.max_nodes * 8, + max_elements: d.max_elements * 8, + max_name_bytes: d.max_name_bytes * 8, + } + } + /// Reject zero or excessive budgets even for an on-disk table. + pub fn validate(&self) -> Result<(), Error> { + let d = Self::default(); + for (value, default) in [ + (self.max_bytes, d.max_bytes), + (self.max_nets, d.max_nets), + (self.max_nodes, d.max_nodes), + (self.max_elements, d.max_elements), + (self.max_name_bytes, d.max_name_bytes), + ] { + if value == 0 || value > default * 8 { + return Err(Error::new( + 0, + "T0072", + "invalid SPEF budget (allowed 1..8x default)", + )); + } + } + Ok(()) + } +} + +/// A structured parse/budget error with a one-based source line. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{code} at SPEF line {line}: {message}")] +pub struct Error { + pub line: usize, + pub code: &'static str, + pub message: String, +} + +impl Error { + pub(crate) fn new(line: usize, code: &'static str, message: impl Into) -> Self { + Self { + line, + code, + message: message.into(), + } + } +} + +/// A local RC node (per-net indices never escape their net). +#[derive(Debug, Clone, PartialEq)] +pub struct Node { + pub name: String, + pub cap_pf: f64, +} + +/// A physical connection, with the source's declared direction. +#[derive(Debug, Clone, PartialEq)] +pub struct Connection { + pub node: u32, + pub port: bool, + pub direction: char, +} + +/// Resistance in kOhm between local node indices. +#[derive(Debug, Clone, PartialEq)] +pub struct Resistor { + pub a: u32, + pub b: u32, + pub kohm: f64, +} + +/// One detailed net, with inter-net coupling grounded at factor one. +#[derive(Debug, Clone, PartialEq)] +pub struct Net { + pub name: String, + pub total_cap_pf: f64, + pub nodes: Vec, + pub connections: Vec, + pub resistors: Vec, +} + +/// Immutable parsed parasitics. The whole object can be shared between corners. +#[derive(Debug, Clone, PartialEq)] +pub struct Spef { + pub design: String, + pub divider: char, + pub delimiter: char, + pub bus_delimiter: (char, char), + pub nets: Vec, + pub coupling_records: usize, + pub metadata_records: usize, + pub node_count: usize, + pub element_count: usize, +} + +impl Spef { + /// Validate public in-memory input before indexing or allocating engine state. + /// Parser counters are not trusted merely because the struct is public. + pub fn validate(&self, limits: Limits) -> Result<(), Error> { + limits.validate()?; + let fail = |message| Error::new(0, "T0071", message); + if self.design.is_empty() || self.design.len() > 4096 { + return Err(fail("invalid RC design name")); + } + if self.nets.is_empty() || self.nets.len() > limits.max_nets { + return Err(Error::new(0, "T0072", "invalid RC net count")); + } + let mut count = 0usize; + let mut resistors = 0usize; + let mut bytes = self.design.len(); + let mut names = std::collections::BTreeSet::new(); + for net in &self.nets { + count = count + .checked_add(net.nodes.len()) + .ok_or_else(|| fail("RC count overflow"))?; + resistors = resistors + .checked_add(net.resistors.len()) + .ok_or_else(|| fail("RC count overflow"))?; + if count > limits.max_nodes || resistors > limits.max_elements { + return Err(Error::new(0, "T0072", "in-memory RC budget exceeded")); + } + if net.name.is_empty() + || net.name.len() > 4096 + || !names.insert(net.name.as_str()) + || net.nodes.is_empty() + || !net.total_cap_pf.is_finite() + || net.total_cap_pf < 0.0 + { + return Err(fail("invalid or duplicate RC net")); + } + bytes = bytes.saturating_add(net.name.len()); + let mut node_names = std::collections::BTreeSet::new(); + let mut sum = 0.0; + for node in &net.nodes { + if node.name.is_empty() + || node.name.len() > 4096 + || !node_names.insert(node.name.as_str()) + || !node.cap_pf.is_finite() + || node.cap_pf < 0.0 + { + return Err(fail("invalid or duplicate RC node")); + } + bytes = bytes.saturating_add(node.name.len()); + sum += node.cap_pf; + } + if !sum.is_finite() + || (sum - net.total_cap_pf).abs() > 1e-8_f64.max(net.total_cap_pf * 1e-4) + { + return Err(fail("RC total capacitance mismatch")); + } + let mut connections = std::collections::BTreeSet::new(); + if net.connections.iter().any(|c| { + c.node as usize >= net.nodes.len() + || !matches!(c.direction, 'I' | 'O' | 'B') + || !connections.insert(c.node) + }) { + return Err(fail("invalid or duplicate RC connection")); + } + if net.resistors.iter().any(|r| { + r.a as usize >= net.nodes.len() + || r.b as usize >= net.nodes.len() + || !r.kohm.is_finite() + || r.kohm < 0.0 + }) { + return Err(fail("invalid RC resistor")); + } + if bytes > limits.max_name_bytes { + return Err(Error::new(0, "T0072", "RC name budget exceeded")); + } + } + if count != self.node_count + || self.element_count < resistors + || self.element_count > limits.max_elements + { + return Err(fail("inconsistent RC allocation counters")); + } + Ok(()) + } + + /// Conservative additional peak state, including parser and RC tree temporaries. + pub fn estimated_state_bytes(&self) -> u64 { + let nodes = self + .nets + .iter() + .fold(0u64, |n, net| n.saturating_add(net.nodes.len() as u64)); + let resistors = self + .nets + .iter() + .fold(0u64, |n, net| n.saturating_add(net.resistors.len() as u64)); + nodes + .saturating_mul(448) + .saturating_add( + (self.element_count as u64) + .max(resistors) + .saturating_mul(64), + ) + .saturating_add((self.nets.len() as u64).saturating_mul(256)) + } +} + +/// Metadata of the PARASIT v1 source container (section 2; source is section 1). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Metadata { + pub corner: String, + pub limits: Limits, +} + +/// One corner of a PARASIT v2 container (RCLI-REQ-0075): its limits and the identity of the +/// file it was imported from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CornerSource { + pub corner: String, + pub limits: Limits, + /// The source path as given at import. + pub source_path: String, + /// The hash of the source file, as the workspace writes source hashes. + pub source_hash: String, +} + +/// Metadata of the PARASIT v2 container (section 1, RCLI-REQ-0075): the corners in ascending +/// name order; the decoded source of entry `i` is section `i + 2`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CornerSetMetadata { + pub corners: Vec, +} + +#[cfg(test)] +mod tests; diff --git a/crates/formats/rusteda-formats/src/spef/parse.rs b/crates/formats/rusteda-formats/src/spef/parse.rs new file mode 100644 index 0000000..4718915 --- /dev/null +++ b/crates/formats/rusteda-formats/src/spef/parse.rs @@ -0,0 +1,525 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +use super::{Connection, Error, Limits, Net, Node, Resistor, Spef}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Copy, PartialEq)] +enum Section { + Header, + Names, + Ports, + Conn, + Cap, + Res, + End, +} + +struct Parser { + limits: Limits, + line: usize, + name_bytes: usize, + names: BTreeMap, + net_names: BTreeSet, + node_names: BTreeMap, + conn_nodes: BTreeSet, + cap_ids: BTreeSet, + cap_terms: Vec<(u32, f64)>, + res_ids: BTreeSet, + current: Option, + section: Section, + cap_scale: Option, + res_scale: Option, + header: BTreeSet, + spef: Spef, +} + +/// Parse within explicit allocation/work budgets. No graph traversal or recursion. +pub fn read(text: &str, limits: Limits) -> Result { + limits.validate()?; + if text.len() > limits.max_bytes { + return Err(Error::new(0, "T0072", "SPEF source byte budget exceeded")); + } + let mut p = Parser { + limits, + line: 0, + name_bytes: 0, + names: BTreeMap::new(), + net_names: BTreeSet::new(), + node_names: BTreeMap::new(), + conn_nodes: BTreeSet::new(), + cap_ids: BTreeSet::new(), + cap_terms: Vec::new(), + res_ids: BTreeSet::new(), + current: None, + section: Section::Header, + cap_scale: None, + res_scale: None, + header: BTreeSet::new(), + spef: Spef { + design: String::new(), + divider: '/', + delimiter: ':', + bus_delimiter: ('[', ']'), + nets: Vec::new(), + coupling_records: 0, + metadata_records: 0, + node_count: 0, + element_count: 0, + }, + }; + for (i, raw) in text.lines().enumerate() { + p.line = i + 1; + if raw.len() > 65536 { + return Err(p.err("line exceeds 65536 bytes")); + } + let t = tokens(raw).map_err(|m| p.err(m))?; + if !t.is_empty() { + p.line(&t)?; + } + } + if p.current.is_some() { + return Err(p.err("missing *END")); + } + if p.spef.nets.is_empty() { + return Err(p.err("no detailed RC nets")); + } + for h in [ + "*SPEF", + "*DESIGN", + "*DIVIDER", + "*DELIMITER", + "*BUS_DELIMITER", + "*T_UNIT", + "*C_UNIT", + "*R_UNIT", + ] { + if !p.header.contains(h) { + return Err(p.err(format!("missing {h}"))); + } + } + p.spef.nets.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(p.spef) +} + +// Keep escapes while tokenizing: an escaped divider is a literal name character. +fn tokens(raw: &str) -> Result, &'static str> { + let mut out = Vec::new(); + let mut token = String::new(); + let mut quoted = false; + let mut escape = false; + let mut chars = raw.chars().peekable(); + while let Some(c) = chars.next() { + if escape { + token.push(c); + escape = false; + continue; + } + if c == '\\' { + token.push(c); + escape = true; + continue; + } + if c == '"' { + quoted = !quoted; + continue; + } + if !quoted && c == '/' && chars.peek() == Some(&'/') { + break; + } + if !quoted && c.is_whitespace() { + if !token.is_empty() { + out.push(std::mem::take(&mut token)); + } + } else { + token.push(c); + } + if token.len() > 4096 || out.len() > 1024 { + return Err("token/name budget exceeded"); + } + } + if quoted || escape { + return Err("unterminated quote or escape"); + } + if !token.is_empty() { + out.push(token); + } + Ok(out) +} + +impl Parser { + fn err(&self, m: impl Into) -> Error { + Error::new(self.line, "T0071", m) + } + fn budget(&self, m: &str) -> Error { + Error::new(self.line, "T0072", m) + } + fn number(&self, s: &str) -> Result { + let v: f64 = s + .parse() + .map_err(|_| self.err(format!("invalid scalar {s}")))?; + if !v.is_finite() || v < 0.0 { + return Err(self.err("expected finite nonnegative scalar")); + } + Ok(v) + } + fn scaled(&self, s: &str, scale: f64) -> Result { + let v = self.number(s)? * scale; + if !v.is_finite() { + return Err(self.err("unit conversion overflow")); + } + Ok(v) + } + fn name(&mut self, raw: &str) -> Result { + let s = if raw.starts_with('*') { + let n = raw.bytes().skip(1).take_while(u8::is_ascii_digit).count() + 1; + let key = &raw[..n]; + let base = self + .names + .get(key) + .ok_or_else(|| self.err(format!("unresolved name {key}")))?; + format!("{base}{}", &raw[n..]) + } else { + raw.to_owned() + }; + if s.len() > 4096 || self.name_bytes.saturating_add(s.len()) > self.limits.max_name_bytes { + return Err(self.budget("SPEF name byte budget exceeded")); + } + self.name_bytes += s.len(); + Ok(s) + } + fn node(&mut self, name: String) -> Result { + if let Some(&id) = self.node_names.get(&name) { + return Ok(id); + } + if self.spef.node_count == self.limits.max_nodes { + return Err(self.budget("RC node budget exceeded")); + } + let net = self.current.as_mut().expect("in net"); + let id = net.nodes.len() as u32; + self.node_names.insert(name.clone(), id); + net.nodes.push(Node { name, cap_pf: 0.0 }); + self.spef.node_count += 1; + Ok(id) + } + fn element(&mut self) -> Result<(), Error> { + if self.spef.element_count == self.limits.max_elements { + return Err(self.budget("RC element budget exceeded")); + } + self.spef.element_count += 1; + Ok(()) + } + fn line(&mut self, t: &[String]) -> Result<(), Error> { + let key = t[0].as_str(); + if key == "*D_NET" { + if self.current.is_some() || t.len() != 3 { + return Err(self.err("invalid *D_NET nesting/arity")); + } + if self.spef.nets.len() == self.limits.max_nets { + return Err(self.budget("RC net budget exceeded")); + } + let scale = self + .cap_scale + .ok_or_else(|| self.err("missing capacitance unit"))?; + if self.res_scale.is_none() { + return Err(self.err("missing resistance unit")); + } + let name = self.name(&t[1])?; + if !self.net_names.insert(name.clone()) { + return Err(self.err("duplicate *D_NET")); + } + let cap = self.scaled(&t[2], scale)?; + self.current = Some(Net { + name, + total_cap_pf: cap, + nodes: Vec::new(), + connections: Vec::new(), + resistors: Vec::new(), + }); + self.section = Section::End; + return Ok(()); + } + if self.current.is_some() { + return self.net_line(t); + } + if ["*CONN", "*CAP", "*RES", "*END"].contains(&key) { + return Err(self.err("net section outside *D_NET")); + } + if key == "*NAME_MAP" && t.len() == 1 && self.spef.nets.is_empty() { + self.section = Section::Names; + return Ok(()); + } + if key == "*PORTS" && t.len() == 1 && self.spef.nets.is_empty() { + self.section = Section::Ports; + return Ok(()); + } + if self.section == Section::Names + && key.starts_with('*') + && key[1..].bytes().all(|b| b.is_ascii_digit()) + { + if key.len() < 2 || t.len() != 2 { + return Err(self.err("invalid name map record")); + } + if self.names.len() >= self.limits.max_nodes { + return Err(self.budget("name map entry budget exceeded")); + } + let name = self.name(&t[1])?; + if self.names.insert(key.into(), name).is_some() { + return Err(self.err("duplicate name map id")); + } + return Ok(()); + } + if self.section == Section::Ports && !key.starts_with('*') + || self.section == Section::Ports + && key + .get(1..) + .is_some_and(|s| s.chars().next().is_some_and(|c| c.is_ascii_digit())) + { + if t.len() < 2 || !["I", "O", "B"].contains(&t[1].as_str()) { + return Err(self.err("invalid port record")); + } + let _ = self.name(key)?; + return self.attributes(&t[2..]); + } + if self.section == Section::Ports && key == "*C" { + return self.attributes(t); + } + if self.section == Section::End { + return Err(self.err("header after detailed nets")); + } + if !self.header.insert(key.into()) { + return Err(self.err("duplicate header field")); + } + match key { + "*DESIGN_FLOW" => { + for item in &t[1..] { + if !["NAME_SCOPE LOCAL", "PIN_CAP NONE"].contains(&item.as_str()) { + return Err(Error::new( + self.line, + "T0073", + format!("unsupported design flow option {item}"), + )); + } + } + } + "*SPEF" | "*DATE" | "*VENDOR" | "*PROGRAM" | "*VERSION" => { + if t.len() < 2 { + return Err(self.err("empty header field")); + } + } + "*DESIGN" if t.len() == 2 => self.spef.design = t[1].clone(), + "*DIVIDER" | "*DELIMITER" if t.len() == 2 && t[1].chars().count() == 1 => { + let c = t[1].chars().next().unwrap(); + if !matches!(c, '/' | ':' | '.' | '|') { + return Err(self.err("unsupported divider/delimiter")); + } + if key == "*DIVIDER" { + self.spef.divider = c; + } else { + self.spef.delimiter = c; + } + } + "*BUS_DELIMITER" => { + let s = t[1..].join(""); + if !["[]", "<>", "()", "{}"].contains(&s.as_str()) { + return Err(self.err("unsupported bus delimiters")); + } + let mut cs = s.chars(); + self.spef.bus_delimiter = (cs.next().unwrap(), cs.next().unwrap()); + } + "*T_UNIT" | "*C_UNIT" | "*R_UNIT" | "*L_UNIT" if t.len() == 3 => { + let mult = self.number(&t[1])?; + if mult == 0.0 { + return Err(self.err("zero unit multiplier")); + } + let scale = match (key, t[2].as_str()) { + ("*T_UNIT", "NS") | ("*C_UNIT", "PF") | ("*R_UNIT", "KOHM") => 1.0, + ("*T_UNIT", "PS") | ("*C_UNIT", "FF") | ("*R_UNIT", "OHM") => 0.001, + ("*L_UNIT", "HENRY" | "MH" | "UH") => 1.0, + _ => return Err(self.err("unsupported unit")), + }; + if !(mult * scale).is_finite() { + return Err(self.err("invalid unit scale")); + } + if key == "*C_UNIT" { + self.cap_scale = Some(mult * scale); + } + if key == "*R_UNIT" { + self.res_scale = Some(mult * scale); + } + } + _ => { + return Err(Error::new( + self.line, + "T0073", + format!("unsupported SPEF field {key}"), + )); + } + } + self.spef.metadata_records += 1; + Ok(()) + } + fn attributes(&mut self, t: &[String]) -> Result<(), Error> { + let mut i = 0; + while i < t.len() { + match t[i].as_str() { + "*C" if i + 2 < t.len() => { + // Physical coordinates may be negative; they do not enter RC timing. + for s in &t[i + 1..i + 3] { + if !s.parse::().is_ok_and(f64::is_finite) { + return Err(self.err("invalid coordinates")); + } + } + i += 3; + } + "*D" if i + 1 < t.len() => i += 2, + _ => { + return Err(Error::new( + self.line, + "T0073", + format!("unsupported connection attribute {}", t[i]), + )); + } + } + self.spef.metadata_records += 1; + } + Ok(()) + } + fn net_line(&mut self, t: &[String]) -> Result<(), Error> { + let key = t[0].as_str(); + match key { + "*CONN" if t.len() == 1 && self.section == Section::End => { + self.section = Section::Conn; + return Ok(()); + } + "*CAP" if t.len() == 1 && self.section == Section::Conn => { + self.section = Section::Cap; + return Ok(()); + } + "*RES" if t.len() == 1 && matches!(self.section, Section::Conn | Section::Cap) => { + self.section = Section::Res; + return Ok(()); + } + "*END" if t.len() == 1 && self.section != Section::End => { + let mut net = self.current.take().unwrap(); + // Canonical reductions and node/edge order, independent of statement order. + self.cap_terms + .sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); + for &(node, cap) in &self.cap_terms { + net.nodes[node as usize].cap_pf += cap; + } + let mut remap = vec![0u32; net.nodes.len()]; + net.nodes.sort_by(|a, b| a.name.cmp(&b.name)); + for (i, node) in net.nodes.iter().enumerate() { + remap[self.node_names[&node.name] as usize] = i as u32; + } + for c in &mut net.connections { + c.node = remap[c.node as usize]; + } + net.connections.sort_by_key(|c| c.node); + for r in &mut net.resistors { + let a = remap[r.a as usize]; + let b = remap[r.b as usize]; + r.a = a.min(b); + r.b = a.max(b); + } + net.resistors + .sort_by(|a, b| (a.a, a.b).cmp(&(b.a, b.b)).then(a.kohm.total_cmp(&b.kohm))); + let sum: f64 = net.nodes.iter().map(|n| n.cap_pf).sum(); + if !sum.is_finite() + || (sum - net.total_cap_pf).abs() > 1e-8_f64.max(net.total_cap_pf * 1e-4) + { + return Err(self.err(format!( + "net {} total capacitance mismatch: declared {}, records {} pF", + net.name, net.total_cap_pf, sum + ))); + } + self.spef.nets.push(net); + self.node_names.clear(); + self.conn_nodes.clear(); + self.cap_ids.clear(); + self.cap_terms.clear(); + self.res_ids.clear(); + self.section = Section::End; + return Ok(()); + } + _ => {} + } + if self.section == Section::Conn { + if ["*C", "*D"].contains(&key) { + return self.attributes(t); + } + if !["*I", "*P"].contains(&key) + || t.len() < 3 + || !["I", "O", "B"].contains(&t[2].as_str()) + { + return Err(self.err("invalid connection record")); + } + let name = self.name(&t[1])?; + let node = self.node(name)?; + if !self.conn_nodes.insert(node) { + return Err(self.err("duplicate connection")); + } + self.current.as_mut().unwrap().connections.push(Connection { + node, + port: key == "*P", + direction: t[2].chars().next().unwrap(), + }); + return self.attributes(&t[3..]); + } + if !matches!(self.section, Section::Cap | Section::Res) { + return Err(self.err("invalid net section order")); + } + self.element()?; + let id: u64 = key.parse().map_err(|_| self.err("invalid RC element id"))?; + let ids = if self.section == Section::Cap { + &mut self.cap_ids + } else { + &mut self.res_ids + }; + if !ids.insert(id) { + return Err(self.err("duplicate RC element id")); + } + if self.section == Section::Cap { + if t.len() != 3 && t.len() != 4 { + return Err(self.err("invalid capacitance record")); + } + let cap = self.scaled(t.last().unwrap(), self.cap_scale.unwrap())?; + let a = self.name(&t[1])?; + let local = if t.len() == 4 { + let b = self.name(&t[2])?; + let net = self.current.as_ref().unwrap(); + let prefix = format!("{}{}", net.name, self.spef.delimiter); + let is_local = |s: &str| { + self.node_names.contains_key(s) || s == net.name || s.starts_with(&prefix) + }; + let local = match (is_local(&a), is_local(&b)) { + (true, false) => a, + (false, true) => b, + _ => return Err(self.err("coupling must have exactly one local endpoint")), + }; + self.spef.coupling_records += 1; + local + } else { + a + }; + let node = self.node(local)?; + self.cap_terms.push((node, cap)); + } else { + if t.len() != 4 { + return Err(self.err("invalid resistance record")); + } + let a = self.name(&t[1])?; + let b = self.name(&t[2])?; + let a = self.node(a)?; + let b = self.node(b)?; + let kohm = self.scaled(&t[3], self.res_scale.unwrap())?; + self.current + .as_mut() + .unwrap() + .resistors + .push(Resistor { a, b, kohm }); + } + Ok(()) + } +} diff --git a/crates/formats/rusteda-formats/src/spef/source.rs b/crates/formats/rusteda-formats/src/spef/source.rs new file mode 100644 index 0000000..4e61963 --- /dev/null +++ b/crates/formats/rusteda-formats/src/spef/source.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +//! Bounded transport decoding (RCLI-REQ-0073). Both compressed and decoded +//! bytes are charged before parsing; no shell, Python or external decompressor. +use super::{Error, Limits}; +use std::io::Read; + +/// Encoding of the original transport, detected from magic bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceEncoding { + Plain, + Gzip, +} + +impl SourceEncoding { + /// Stable telemetry label. + pub fn as_str(self) -> &'static str { + match self { + Self::Plain => "plain", + Self::Gzip => "gzip", + } + } +} + +/// The canonical UTF-8 source stored in the immutable PARASIT container. +#[derive(Debug)] +pub struct DecodedSource { + pub text: String, + pub encoding: SourceEncoding, +} + +/// Decode plain or concatenated gzip members under the same byte cap. +/// CRC/truncation failures are errors, including an invalid trailing member. +pub fn decode_source(bytes: &[u8], limits: Limits) -> Result { + limits.validate()?; + if bytes.len() > limits.max_bytes { + return Err(Error::new(0, "T0072", "SPEF source byte budget exceeded")); + } + let (decoded, encoding) = if bytes.starts_with(&[0x1f, 0x8b]) { + let mut decoder = flate2::bufread::MultiGzDecoder::new(bytes); + let mut out = Vec::new(); + decoder + .by_ref() + .take(limits.max_bytes as u64 + 1) + .read_to_end(&mut out) + .map_err(|e| Error::new(0, "T0071", format!("invalid gzip SPEF: {e}")))?; + if out.len() > limits.max_bytes { + return Err(Error::new(0, "T0072", "decoded SPEF byte budget exceeded")); + } + (out, SourceEncoding::Gzip) + } else { + (bytes.to_vec(), SourceEncoding::Plain) + }; + let text = String::from_utf8(decoded) + .map_err(|_| Error::new(0, "T0071", "SPEF source is not UTF-8"))?; + Ok(DecodedSource { text, encoding }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + // RCLI-REQ-0073: equivalent encodings, multiple members and corrupt transport. + #[test] + fn bounded_gzip_and_crc_validation() { + let limits = Limits::default(); + let plain = b"*SPEF \"ieee 1481-1999\"\n"; + assert_eq!( + decode_source(plain, limits).unwrap().text, + decode_source(&gzip(plain), limits).unwrap().text + ); + let mut multi = gzip(b"first"); + multi.extend(gzip(b"second")); + assert_eq!(decode_source(&multi, limits).unwrap().text, "firstsecond"); + let mut corrupt = gzip(plain); + let last = corrupt.len() - 8; + corrupt[last] ^= 1; + assert!(decode_source(&corrupt, limits).is_err()); + assert!(decode_source(&corrupt[..corrupt.len() - 5], limits).is_err()); + let bomb = gzip(&vec![b'x'; 4096]); + let cap = Limits { + max_bytes: 1024, + ..limits + }; + assert_eq!(decode_source(&bomb, cap).unwrap_err().code, "T0072"); + assert!(decode_source(&[0xff], limits).is_err()); + } +} diff --git a/crates/formats/rusteda-formats/src/spef/tests.rs b/crates/formats/rusteda-formats/src/spef/tests.rs new file mode 100644 index 0000000..1386867 --- /dev/null +++ b/crates/formats/rusteda-formats/src/spef/tests.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +use super::*; +fn source() -> String { + "*SPEF \"ieee 1481-1999\"\n*DESIGN top\n*DIVIDER /\n*DELIMITER :\n*BUS_DELIMITER [ ]\n*T_UNIT 1 PS\n*C_UNIT 2 FF\n*R_UNIT 2 OHM\n*NAME_MAP\n*1 a\n*2 u\n*D_NET *1 3\n*CONN\n*P *1 I\n*I *2:A I *C -1 2 *D BUF\n*CAP\n1 a 1\n2 *2:A 2\n*RES\n1 a *2:A 5\n*END\n".into() +} +// RCLI-REQ-0071, RCLI-REQ-0072: scalar units and name mapping, no nominal-unit assumptions. +#[test] +fn mapped_units_and_metadata() { + let s = read(&source(), Limits::default()).unwrap(); + assert_eq!(s.nets[0].nodes[1].name, "u:A"); + assert_eq!(s.nets[0].total_cap_pf, 0.006); + assert_eq!(s.nets[0].resistors[0].kohm, 0.01); + assert_eq!(s.node_count, 2); +} +// RCLI-REQ-0071: syntax and unsupported semantics fail explicitly. +#[test] +fn reject_corrupt_or_semantic_extensions() { + for (from, to) in [ + ("*END", ""), + ("*D_NET", "*R_NET"), + ("1 a 1", "1 a NaN"), + ("1 a 1", "1 a -1"), + ("1 a 1", "1 a 1:2:3"), + ("*2:A I", "*99:A I"), + ("*CONN", "*INDUC"), + ("*C -1 2", "*L 0.2"), + ("*1 a", "*1 a\n*1 a"), + ("*SPEF", "*UNKNOWN"), + ("*D_NET *1 3", "*D_NET *1 9"), + ( + "*D_NET *1 3", + "*DESIGN_FLOW \"PIN_CAP INCLUDED\"\n*D_NET *1 3", + ), + ] { + assert!( + read(&source().replace(from, to), Limits::default()).is_err(), + "{from} -> {to}" + ); + } +} +// RCLI-REQ-0072: every independent resource budget fails before it can grow without bound. +#[test] +fn enforce_all_budgets() { + for limits in [ + Limits { + max_bytes: 1, + ..Limits::default() + }, + Limits { + max_nodes: 1, + ..Limits::default() + }, + Limits { + max_elements: 1, + ..Limits::default() + }, + Limits { + max_name_bytes: 1, + ..Limits::default() + }, + ] { + assert_eq!(read(&source(), limits).unwrap_err().code, "T0072"); + } + assert!( + read( + &source(), + Limits { + max_nets: 0, + ..Limits::default() + } + ) + .is_err() + ); +} +// RCLI-REQ-0072: only the local endpoint receives an inter-net coupling term. +#[test] +fn explicit_grounding_and_total() { + let text = source() + .replace("*D_NET *1 3", "*D_NET *1 4") + .replace("2 *2:A 2", "2 *2:A 2\n3 *2:A remote:1 1"); + let s = read(&text, Limits::default()).unwrap(); + assert_eq!(s.coupling_records, 1); + assert_eq!(s.nets[0].nodes.len(), 2); + assert_eq!(s.nets[0].nodes[1].cap_pf, 0.006); + assert!(read(&text.replace("remote:1", "a"), Limits::default()).is_err()); +} + +// RCLI-REQ-0071, RCLI-REQ-0072: source statement order cannot change RC reductions. +#[test] +fn canonical_order_is_independent_of_record_order() { + let a = source(); + let b = a + .replace( + "*P *1 I\n*I *2:A I *C -1 2 *D BUF", + "*I *2:A I *C -1 2 *D BUF\n*P *1 I", + ) + .replace("1 a 1\n2 *2:A 2", "2 *2:A 2\n1 a 1"); + assert_eq!( + read(&a, Limits::default()).unwrap(), + read(&b, Limits::default()).unwrap() + ); +} + +// RCLI-REQ-0074: public typed data must not bypass bounds before engine indexing. +#[test] +fn rejects_invalid_public_rc_models() { + let good = read(&source(), Limits::default()).unwrap(); + good.validate(Limits::default()).unwrap(); + let mut bad = good.clone(); + bad.nets[0].connections[0].node = u32::MAX; + assert!(bad.validate(Limits::default()).is_err()); + let mut bad = good.clone(); + bad.nets[0].resistors[0].b = u32::MAX; + assert!(bad.validate(Limits::default()).is_err()); + let mut bad = good.clone(); + bad.node_count = 0; + assert!(bad.validate(Limits::default()).is_err()); + assert_eq!(bad.estimated_state_bytes(), good.estimated_state_bytes()); + let mut bad = good; + bad.nets[0].nodes[0].cap_pf = f64::INFINITY; + assert!(bad.validate(Limits::default()).is_err()); +} diff --git a/crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs b/crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs index 06fa009..296289f 100644 --- a/crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs +++ b/crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 RustEDA contributors -//! Cell and pin attributes of the library model (LIBERTY-REQ-0011, LIBERTY-REQ-0012). +//! Cell and pin attributes of the library model (LIBERTY-REQ-0011, LIBERTY-REQ-0012, +//! LIBERTY-REQ-0049). -use rusteda_formats::liberty::{Direction, Library, read_text}; +use rusteda_formats::liberty::{Direction, Library, TimingType, read_text, read_text_diags}; const LIB: &str = r#" library (attrs) { @@ -76,6 +77,67 @@ fn electrical_limits_and_fanout_weights() { assert_eq!(y.max_fanout, Some(12.0)); } +// LIBERTY-REQ-0049: the pin pulse attributes are kept on their pin, in library units, and add no +// timing arc: the pin keeps exactly the arcs of its timing groups. A bad value is a pin limit +// problem and is not kept. The attributes are consumed: they produce no warning. +#[test] +fn pin_pulse_attributes_are_kept_on_the_pin() { + let text = r#"library(l) { + time_unit : "1ns"; capacitive_load_unit(1,pf); + cell(f) {area:1; + ff(IQ, IQN) { clocked_on : "CK"; next_state : "D"; } + pin(CK) {direction:input; clock:true; min_pulse_width_high:0.3; + min_pulse_width_low:0.25; min_period:0.9; + timing() { related_pin : "CK"; timing_type : min_pulse_width; when : "D"; + rise_constraint(scalar) { values("0.2"); } } + } + pin(D) {direction:input;} + pin(Q) {direction:output; function:"IQ";} + } + cell(g) {area:1; + ff(IQ, IQN) { clocked_on : "CK"; next_state : "D"; } + pin(CK) {direction:input; clock:true; min_pulse_width_low:0.4;} + pin(D) {direction:input;} + pin(Q) {direction:output; function:"IQ";} + } + cell(h) {area:1; + pin(CK) {direction:input; clock:true; min_period:-1;} + } + }"#; + let (l, diags) = read_text_diags(text, "pulse.lib").expect("parses"); + let ck = l.cells[0].pin("CK").unwrap(); + assert_eq!( + ( + ck.min_pulse_width_high, + ck.min_pulse_width_low, + ck.min_period + ), + (Some(0.3), Some(0.25), Some(0.9)) + ); + // Only the timing group is an arc, with its condition. + let kinds: Vec = ck.arcs.iter().map(|a| a.kind()).collect(); + assert_eq!(kinds, vec![TimingType::MinPulseWidth]); + assert_eq!(ck.arcs[0].when_text.as_deref(), Some("D")); + // Only the low width: the other attributes stay absent, and no arc. + let g = l.cells[1].pin("CK").unwrap(); + assert!(g.arcs.is_empty()); + assert_eq!( + (g.min_pulse_width_high, g.min_pulse_width_low, g.min_period), + (None, Some(0.4), None) + ); + // A negative value is a pin limit problem (L0204) and is not kept. + let h = &l.cells[2]; + let hck = h.pin("CK").unwrap(); + assert!(hck.arcs.is_empty() && hck.min_period.is_none()); + assert!(h.problems.iter().any(|p| p.contains("min_period"))); + assert!(diags.iter().any(|d| d.code == "L0204")); + // The attributes are consumed: no unsupported or unknown construct warning. + assert!( + diags.iter().all(|d| d.code != "L0100" && d.code != "L0101"), + "{diags:?}" + ); +} + // LIBERTY-REQ-0011: area and dont_use defaults. #[test] fn cell_defaults() { diff --git a/crates/formats/rusteda-formats/tests/sdc_commands.rs b/crates/formats/rusteda-formats/tests/sdc_commands.rs index 05ac261..37894e5 100644 --- a/crates/formats/rusteda-formats/tests/sdc_commands.rs +++ b/crates/formats/rusteda-formats/tests/sdc_commands.rs @@ -256,8 +256,6 @@ fn schema_and_phase_2b_diagnostics() { "create_generated_clock -name gclk -source [get_ports clk] -divide_by 2 [get_pins reg/Q]", "set_clock_sense -stop_propagation [get_pins mux/Z]", "set_timing_derate -early 0.95", - "set_propagated_clock [all_clocks]", - "unset_propagated_clock [all_clocks]", "set_sense -type clock -stop_propagation [get_pins mux/Z]", "set_data_check -from [get_pins a] -to [get_pins b] -setup 0.1", "set_ideal_network [get_ports rst_n]", @@ -291,3 +289,20 @@ fn schema_and_phase_2b_diagnostics() { ); } } + +// SDC-REQ-0070: propagated-clock selection is typed and no longer a Phase 2b drop. +#[test] +fn propagated_clock_commands_are_typed() { + let (s, diags) = sdc::read_str( + "set_propagated_clock [all_clocks]\nunset_propagated_clock [get_clocks aux]\n", + ); + assert!( + !diags.iter().any(|d| d.severity == Severity::Error), + "{diags:?}" + ); + assert!(matches!(s.cmds[0].kind, CmdKind::SetPropagatedClock { .. })); + assert!(matches!( + s.cmds[1].kind, + CmdKind::UnsetPropagatedClock { .. } + )); +} diff --git a/docs/adr/0005-sdc-layer.md b/docs/adr/0005-sdc-layer.md index 555a9ee..3038658 100644 --- a/docs/adr/0005-sdc-layer.md +++ b/docs/adr/0005-sdc-layer.md @@ -1,7 +1,10 @@ # ADR 0005: Shared SDC layer, bounded Tcl subset, and CONSTR v1 binary sections - Status: accepted -- Date: 2026-10-17 +- Date: 2026-10-17; amended 2026-10-24 (`set_propagated_clock` and `unset_propagated_clock` + are interpreted typed commands kept in `CONSTR v1`, SDC-REQ-0070; the readers of + `rusteda-formats` also use `thiserror`, `serde`, `flate2`, `rayon`, `rustc-hash` and + `smallvec`) ## Context Timing constraints arrive as Synopsys Design Constraints (SDC) files (`[@sdc2016]`) written in a diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 93c502a..28a73a0 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -121,6 +121,7 @@ nav: - Compiled libraries: specs/workspace/rlib.md - Workspace timing: specs/workspace/sta.md - RustEDA CLI: specs/rusteda-cli/README.md + - Detailed parasitic import: specs/rusteda-cli/spef.md - Core data structures: specs/core/README.md - Core netlist: specs/core/netlist.md - Compute backends: specs/compute/README.md diff --git a/docs/specs/compute/timing.md b/docs/specs/compute/timing.md index 2e7cc18..a659d8a 100644 --- a/docs/specs/compute/timing.md +++ b/docs/specs/compute/timing.md @@ -249,3 +249,14 @@ write-combined. (COMPUTE-REQ-0010) and compiles its kernels for the precision, so that an engine can overlap the preparation with other work on a second thread (COMPUTE-REQ-0003); its result is the result the selection would give. + +## Prepared nonlinear arcs + +**COMPUTE-REQ-0030**: TimingProblem may carry one 128-byte ArcValues per incoming +arc, with f64 delay/slew indexed (input transition, output transition, bound). +Prepared values override cell lookup and wire degradation. They are immutable +snapshots of loads, starts and classes: session patch methods must reject edits +until the caller re-prepares the model. Validate size, finite values and +nonnegative transitions. CPU/CUDA use identical data and agree bitwise in f64. +Include these buffers in host/device budgets; test a three-node case plus +extracted-engine propagation without enumerating timing paths. diff --git a/docs/specs/core/README.md b/docs/specs/core/README.md index b155e11..6cce80c 100644 --- a/docs/specs/core/README.md +++ b/docs/specs/core/README.md @@ -12,7 +12,7 @@ The reader that builds the library model from Liberty text is specified in |---|---| | [Engine runs](#engine-runs) | JOB-REQ-0001 to 0006 | | [Messages](#messages) | JOB-REQ-0007 | -| [Liberty library model](#liberty-library-model) | LIBERTY-REQ-0010 to 0019, 0022 to 0026, 0030 to 0034 and 0040 to 0048 | +| [Liberty library model](#liberty-library-model) | LIBERTY-REQ-0010 to 0019, 0022 to 0026, 0030 to 0034 and 0040 to 0049 | | [Symbols, arenas, names and netlist](netlist.md) | CORE-REQ-0100 to 0103, 0110 to 0113, 0120 to 0128, 0130 to 0138 | ## Engine runs @@ -261,3 +261,12 @@ stores `operating_conditions` (`OperatingConditions` with `name`, `process`, `te `voltage` and `tree_type` as `TreeType`: `best_case_tree`, `balanced_tree`, `worst_case_tree` or `Other`), `default_operating_conditions`, and the library nominal attributes `nom_process`, `nom_temperature` and `nom_voltage` [@liberty2017]. + +**LIBERTY-REQ-0049** Pin pulse attributes: the pin attributes `min_pulse_width_high`, +`min_pulse_width_low` and `min_period` are consumed (in addition to the constructs of +LIBERTY-REQ-0054) and kept on their `Pin` as `min_pulse_width_high`, `min_pulse_width_low` and +`min_period`, in library time units: the unconditional minimum widths of the high and the low pulse +and the minimum period at the pin. They add no timing arc, so the arcs of a pin are exactly its +`timing` groups (LIBERTY-REQ-0019). A negative or non-finite value is a pin limit problem (`L0204`, +LIBERTY-REQ-0057) and is not kept. The compiled library carries them from format version 4 +(WS-REQ-0117) [@liberty2017]. diff --git a/docs/specs/liberty/liberty.md b/docs/specs/liberty/liberty.md index 9bcfdd5..4bcdbe0 100644 --- a/docs/specs/liberty/liberty.md +++ b/docs/specs/liberty/liberty.md @@ -5,7 +5,7 @@ Source: the Liberty library format as published by the Liberty Technical Advisor `liberty`: `read_file`, `read_text`, `read_file_diags`, `read_text_diags` and `parse_function`) accepts and reports. The library model it builds is specified with `rusteda-core`: [Liberty library model](../core/README.md#liberty-library-model) (LIBERTY-REQ-0010 to 0019, -0022 to 0026, 0030 to 0034 and 0040 to 0048). +0022 to 0026, 0030 to 0034 and 0040 to 0049). | Section | Requirements | |---|---| diff --git a/docs/specs/rusteda-cli/README.md b/docs/specs/rusteda-cli/README.md index 7351612..35b4de9 100644 --- a/docs/specs/rusteda-cli/README.md +++ b/docs/specs/rusteda-cli/README.md @@ -47,3 +47,8 @@ **RCLI-REQ-0018** `rusteda revert -w --base-rev --to ` moves `HEAD` from `--base-rev` to `--to` under the exclusive workspace lock without creating a new revision directory, logging the revert in `history.jsonl`; `rusteda completions ` outputs completion scripts for `bash`, `elvish`, `fish`, `powershell`, and `zsh`. **RCLI-REQ-0020** `rusteda export -w [--rev ] --netlist ` (default `HEAD`) thaws the revision's `netlist` table and writes a deterministic structural Verilog netlist (with escaped identifiers per IEEE 1364-2005 §3.7.1) to `` without modifying the workspace or appending to `history.jsonl`. + +**RCLI-REQ-0023** `rusteda show` reports the design-rule counts of a timing table as recorded when +its summary states `drv_status`; a timing summary without `drv_status` comes from an analysis that +did not check design rules, so `show` reports `drv_status: "unchecked"` and its +`max_trans_violating` and `max_cap_violating` as `null`, never as zero. diff --git a/docs/specs/rusteda-cli/spef.md b/docs/specs/rusteda-cli/spef.md new file mode 100644 index 0000000..d02f19c --- /dev/null +++ b/docs/specs/rusteda-cli/spef.md @@ -0,0 +1,76 @@ +# Detailed parasitic import + +**RCLI-REQ-0070**: `import --spef FILE --spef-corner NAME` requires a committed + netlist (or one imported in the same transaction), a library for NAME, and the + usual exclusive lock and base revision. A `PARASIT` v1 rbin (written before + RCLI-REQ-0075) stores the immutable source in section 1 and UTF-8 JSON + `{corner, limits}` metadata in section 2; readers keep accepting it. + Record source identity. The table depends on `netlist`; import invalidates + `sta` and `sta_check` even when a previous timing table predates parasitics. +**RCLI-REQ-0071**: The shared SPEF reader supports detailed D_NET, NAME_MAP, + connections, ground and inter-net coupling C, scalar R, explicit NS/PS, PF/FF, + OHM/KOHM units, divider/delimiter/bus delimiters and escaped names. Values must + be finite and nonnegative; errors are T0071 (syntax), T0072 (budget), T0073 + (unsupported semantics). Reject reduced nets, inductance, triplets, pin-load + attributes, included pin capacitances, duplicate identifiers and unresolved + names. Metadata such as coordinates and date do not affect extracted RC. +**RCLI-REQ-0072**: Limits are configurable and stored: defaults 32 MiB source, + 100k nets, 500k nodes/name-map entries, 1M elements and 16 MiB names, with an + absolute ceiling of eight times each default. Names have 4096-byte and lines + 65536-byte caps. No unbounded enumeration or recursion. Ground coupling at + factor one on each net's local endpoint; T0074 warns that crosstalk is absent. + Require declared total C to agree with records within max(1e-8 pF, 1e-4 relative). + +CLI budgets: `--max-spef-mib`, `--max-rc-nets`, `--max-rc-nodes`, +`--max-rc-elements`, `--max-rc-name-mib`. Missing SPEF never causes these budgets +or extraction modes to be implicitly enabled. A timing consumer must bind the +connections and check complete annotation; it must not replace missing RC with +estimated/ideal wires. A consumer asking for a corner without a source must +fail explicitly. + +**RCLI-REQ-0075**: `--spef FILE --spef-corner NAME` is repeatable and pairs in + order; unequal counts, a corner given twice, more than 64 corners or a corner + without a library are rejected before anything is read. The `parasitics` + table is a `PARASIT` v2 rbin: section 1 is UTF-8 JSON `{corners: [{corner, + limits, source_path, source_hash}]}` in ascending corner order (at most + 256 KiB), and section `i + 2` is the canonical decoded source of entry `i`. + The corners of the base revision's table (v1 or v2) whose netlist is the + netlist of the new revision are kept, an imported corner replaces its old + source, and the source identity of every corner is recorded. Metrics name + `spef_corners`, `spef_kept_corners` and per imported corner `spef_imported` + (nets, nodes, elements, compression, byte counts); a one-file import also + keeps the flat `spef_*` metrics. The decoded sources of all corners of the + new table, kept and imported, which the import holds at once, total at most + eight times the per-file byte budget (`--max-spef-mib`); more is rejected + with `T0072` and nothing is committed. The import drops `sta`, `sta_check` + and `limits`. + +**RCLI-REQ-0076**: An import that drops the `parasitics` table of its base + revision (its netlist changed, so the table's upstream no longer holds) warns + with one `T0140` that names the dropped corners, the reason and the new + revision: its timing has no extracted parasitics until SPEF is imported again, + and a timing command that requires them (`--require-wire-model spef_rc`) + rejects it. The `dropped` list of the envelope names the table as before. An + import that replaces or keeps the table does not warn. + +**RCLI-REQ-0073**: Accept plain UTF-8 and gzip SPEF, detected by magic bytes. + Both encoded and decoded inputs obey `--max-spef-mib`; validate gzip CRC and + concatenated members. Store canonical decoded source in `PARASIT` section 1, + while source identity describes the original file. Report compression and + encoded/decoded byte counts. Reject symlinks, FIFOs and devices before reading; + Unix opens are nonblocking and do not follow symlinks. Failures preserve HEAD. +**RCLI-REQ-0074**: Public in-memory parasitic objects must validate actual node + counts, finite nonnegative values, names, indices and totals before a timing + consumer indexes them. A forged serialized counter cannot reduce the memory + estimate below actual stored vectors. + +The parser estimates <512 MiB extra peak state at default limits (two 32 MiB +source copies, <64 MiB names, 80 MiB nodes/index, 64 MiB elements and <80 MiB +CSR/tree temporaries). A consumer adds its own corner/pin/tag state and accounts +for worker concurrency. The source and all parse results are independent of GUI +and Python. IEEE 1481-1999 defines the exchanged detailed parasitic format. + +Compressed input does not increase these caps: retain at most 32 MiB encoded +and 32 MiB decoded data plus a bounded decoder window. Small gzip/CRC/bomb tests +use <4 MiB each and two test workers. A byte cap applies before token and graph +budgets. No external decompressor or background process is required. diff --git a/docs/specs/sdc/sdc.md b/docs/specs/sdc/sdc.md index 84c8dcc..007aa18 100644 --- a/docs/specs/sdc/sdc.md +++ b/docs/specs/sdc/sdc.md @@ -305,3 +305,9 @@ locations, and exits with code `1` (`INPUT_REJECTED`). A stale `--base-rev` exit (both top-level `--sdc` files and files loaded via relative `source`) with `table: "constraints"` and its `xxh3` file hash, and every `--sdc-env NAME=VALUE` binding with `table: "constraints"`, `path: "env:NAME=VALUE"`, and the `xxh3` hash of `VALUE`. + +**SDC-REQ-0070** `set_propagated_clock` and `unset_propagated_clock` are now +interpreted typed commands with one object collection, preserved by `CONSTR v1`. +This supersedes their Phase 2a D0010 classification in SDC-REQ-0034. The consumer +resolves clock collections and reports unsupported object/source/network features +explicitly; it must not silently use an ideal network for selected physical clocks. diff --git a/docs/specs/workspace/README.md b/docs/specs/workspace/README.md index 18f3785..235db6f 100644 --- a/docs/specs/workspace/README.md +++ b/docs/specs/workspace/README.md @@ -16,9 +16,9 @@ protocol, the library cache and the `rusteda.v1` JSON envelope and text-mode ren | [Library cache](#library-cache) | WS-REQ-0080 to 0082 | | [Envelope and text mode](#envelope-and-text-mode) | WS-REQ-0090 to 0097 | | [Determinism](#determinism) | WS-REQ-0100 | -| [Compiled Liberty libraries (`rlib.md`)](rlib.md) | WS-REQ-0110 to 0116 | +| [Compiled Liberty libraries (`rlib.md`)](rlib.md) | WS-REQ-0110 to 0117 | | [Netlist tables (`netlist.md`)](netlist.md) | WS-REQ-0120 to 0125 | -| [Timing table: read-only view and builder (`sta.md`)](sta.md) | WS-REQ-0126 to 0136 | +| [Timing table: read-only view and builder (`sta.md`)](sta.md) | WS-REQ-0126 to 0137 | ## Workspace layout and files diff --git a/docs/specs/workspace/rlib.md b/docs/specs/workspace/rlib.md index e5e79d0..6761d83 100644 --- a/docs/specs/workspace/rlib.md +++ b/docs/specs/workspace/rlib.md @@ -1,8 +1,8 @@ -# The `LIBERTY` v3 (`.rlib`) compiled library codec (`crates/core/rusteda-workspace/src/rlib.rs`) +# The `LIBERTY` v4 (`.rlib`) compiled library codec (`crates/core/rusteda-workspace/src/rlib.rs`) ## Container and sections -**WS-REQ-0110** A compiled Liberty library (`cache/-.rlib`) is an `rbin` container of kind `"LIBERTY"` and `format_version = 3` (`3..=3`). When compiled from a cached `.lib` file, its `rbin` header records upstream `"lib"` mapped to the `xxh3-64` hash of the `.lib` bytes. It contains three sections in ascending ID order: +**WS-REQ-0110** A compiled Liberty library (`cache/-.rlib`) is an `rbin` container of kind `"LIBERTY"` and `format_version = 3` (`3..=3`; version 4 since WS-REQ-0117). When compiled from a cached `.lib` file, its `rbin` header records upstream `"lib"` mapped to the `xxh3-64` hash of the `.lib` bytes. It contains three sections in ascending ID order: - Section `1` (`STR`, `elem_size = 1`): concatenated UTF-8 bytes of all strings in the library, deduplicated in first-appearance order. - Section `2` (`MODEL`, `elem_size = 1`): deterministic little-endian byte stream encoding the `rusteda_core::liberty::Library` model. - Section `3` (`F64`, `elem_size = 8`): pool of little-endian IEEE-754 `f64` bit patterns (`u64` in little-endian byte order) referenced by `(start, len)` slices from `Template` and `Table` records in `MODEL`. @@ -44,9 +44,19 @@ ## Round-trip, validation, and cache loading +**WS-REQ-0117** Format version 4. Writers write `format_version = 4` and readers accept `4..=4`; +version 4 is the version 3 container and grammar of WS-REQ-0110 to WS-REQ-0112 with three fields +appended to every `Pin` record after `line`: `min_pulse_width_high: opt`, +`min_pulse_width_low: opt` and `min_period: opt` (LIBERTY-REQ-0049). The validation of +WS-REQ-0114 applies with `4..=4`. A cached `.rlib` of version 3, which cannot hold the pin pulse +attributes, is an older version and is compiled again from its cached `.lib` (WS-REQ-0116), so no +reader sees a library without them; a version above 4 fails with `SCHEMA_TOO_NEW`. A tool that +reads only version 3 rejects a version 4 cache with `SCHEMA_TOO_NEW` instead of reading it +without the attributes. + **WS-REQ-0113** Encoding and decoding are exact and reproducible: for every `Library` value `lib`, `decode(&encode(&lib)?)? == lib`, and re-encoding a decoded library produces identical `.rlib` bytes (`encode(&decode(&bytes)?)? == bytes`). -**WS-REQ-0114** Decoding validates the `rbin` header (`kind == "LIBERTY"`, `format_version` in `3..=3`), the presence and element sizes of sections `STR`, `MODEL`, and `F64`, UTF-8 validity and character boundaries of `STR`, slice bounds into `STR` and `F64`, strictly ascending template map keys, valid boolean and enumeration tags, bounded `Func` recursion depth, and that `MODEL` has no trailing bytes; any malformed or truncated input fails with `CORRUPT_TABLE` without panicking, and a `format_version > 3` fails with `SCHEMA_TOO_NEW`. +**WS-REQ-0114** Decoding validates the `rbin` header (`kind == "LIBERTY"`, `format_version` in `3..=3`, `4..=4` since WS-REQ-0117), the presence and element sizes of sections `STR`, `MODEL`, and `F64`, UTF-8 validity and character boundaries of `STR`, slice bounds into `STR` and `F64`, strictly ascending template map keys, valid boolean and enumeration tags, bounded `Func` recursion depth, and that `MODEL` has no trailing bytes; any malformed or truncated input fails with `CORRUPT_TABLE` without panicking, and a `format_version > 3` (`> 4` since WS-REQ-0117) fails with `SCHEMA_TOO_NEW`. **WS-REQ-0115** `rusteda import --lib` compiles each validated library with `rlib::put` into `cache/-.rlib` atomically and records its cache entry in `meta.json` `libraries`. diff --git a/docs/specs/workspace/sta.md b/docs/specs/workspace/sta.md index 0d6957d..42a4fef 100644 --- a/docs/specs/workspace/sta.md +++ b/docs/specs/workspace/sta.md @@ -19,9 +19,9 @@ reads and writes them. ## Container and sections **WS-REQ-0126** The timing table is the table `sta` of a revision, the file `sta.rbin`: an rbin -container (WS-REQ-0030) of kind `STA` and format version 1 (the reader accepts `1..=1`; a newer +container (WS-REQ-0030) of kind `STA` and format version 2 (the reader accepts `1..=2`; a newer version is `SCHEMA_TOO_NEW`, WS-REQ-0039) with exactly the 24 sections below, in id order, with -these element sizes: +these element sizes (a version 1 table has the sizes in parentheses): | Id | Section | Element bytes | Content | |---:|---|---:|---| @@ -35,11 +35,11 @@ these element sizes: | 8 | `NODE_REC` | 152 | timing values per (corner, node), full mode only | | 9 | `TAG_EXTRA_PTR` | 4 | row pointer per (corner, node) into `TAG_EXTRA`, full mode only | | 10 | `TAG_EXTRA` | 56 | arrivals of further launch tags, full mode only | -| 11 | `ENDPOINTS` | 32 | one record per checked endpoint | +| 11 | `ENDPOINTS` | 36 (32) | one record per checked endpoint | | 12 | `EP_SLACK` | 16 | setup and hold slack per (corner, endpoint) | | 13 | `EP_ORDER` | 4 | endpoint rows sorted by slack per (corner, setup or hold) | -| 14 | `PATH_RANK` | 8 | one record per ranked path, in rank order | -| 15 | `PATH_CHAIN_PTR` | 4 | row pointer per ranked path into `PATH_CHAIN` | +| 14 | `PATH_RANK` | 12 (8) | one record per ranked path, in rank order | +| 15 | `PATH_CHAIN_PTR` | 8 (4) | `u64` (`u32`) row pointer per ranked path into `PATH_CHAIN` | | 16 | `PATH_CHAIN` | 4 | the steps of every ranked path | | 17 | `PATHS` | 56 | the header of each materialized path | | 18 | `PATH_POINTS` | 32 | the points of the materialized paths | @@ -74,20 +74,25 @@ stated; `NO_INDEX` = `0xFFFF_FFFF` marks an absent index; time in ns): `corner * num_nodes + node`. - `ENDPOINTS`: `data_node`, `clock_node`, `capture_clock`, `path_group_str`, check arc, `kind_flags` (bits 0 to 7: kind 0 flip-flop, 1 latch, 2 output port, 3 recovery/removal pin, - 4 clock gate; bits 8 and 9: capture edge 0 rise, 1 fall, 2 none; bits 10 to 31: the data - node's `NAME_HASH` bucket plus one, or 0), the rank of the endpoint's worst setup-family and - hold-family ranked path (or `NO_INDEX`). `EP_SLACK`: `f64` setup and hold slack, NaN when + 4 clock gate; bits 8 and 9: capture edge 0 rise, 1 fall, 2 none; bits 10 to 31 zero), the + rank of the endpoint's worst setup-family and hold-family ranked path (or `NO_INDEX`), and the + data node's `NAME_HASH` bucket plus one, or 0. `EP_SLACK`: `f64` setup and hold slack, NaN when absent, row `corner * num_endpoints + endpoint`. `EP_ORDER`: bucket `(corner * 2 + hold) * num_endpoints` holds the endpoint rows by ascending slack. -- `PATH_RANK`: `f32` slack, `u16` bits 0 to 15 of the endpoint row, `u8` corner in bits 0 to 3 - and bits 16 to 19 of the endpoint row in bits 4 to 7, `u8` flags (bit 0: hold family, bit 1: - end transition fall, bits 2 and 3: check 0 setup, 1 hold, 2 recovery, 3 removal, bit 7: - materialized). +- `PATH_RANK`: `f32` slack, `u32` endpoint row, `u16` corner, `u8` flags (bit 0: hold family, + bit 1: end transition fall, bits 2 and 3: check 0 setup, 1 hold, 2 recovery, 3 removal, bit 7: + materialized), `u8` padding. - `PATH_CHAIN` slice of a ranked path: required time and check value as `f64` word pairs (low - word first), then per step the step word (bits 0 to 23 node, bit 24 transition fall, bits 25 - and 26 point kind 0 port, 1 net, 2 cell, 3 clock launch, bits 27 to 31 tag slot) and the - node's `NAME_HASH` bucket; the step word keeps the low 24 bits of the node, whose whole value - the bucket holds. + word first), then per step three words: the node, the step word (bit 0 transition fall, bits 1 + and 2 point kind 0 port, 1 net, 2 cell, 3 clock launch, bits 3 to 31 tag slot) and the node's + `NAME_HASH` bucket. +- Format version 1 differs in three records, which the reader converts to the version 2 values: + `ENDPOINTS` has no ninth word and keeps the `NAME_HASH` bucket plus one in bits 10 to 31 of + `kind_flags`; `PATH_RANK` is `f32` slack, `u16` bits 0 to 15 of the endpoint row, `u8` corner + in bits 0 to 3 and bits 16 to 19 of the endpoint row in bits 4 to 7, `u8` flags; a chain step + is two words, the step word (bits 0 to 23 node, bit 24 transition fall, bits 25 and 26 point + kind, bits 27 to 31 tag slot) and the node's `NAME_HASH` bucket, so a step names only the low + 24 bits of its node, whose whole value the bucket holds. - `PATHS`: `f64` slack, arrival, required time and check value; rank, endpoint row, launch clock, capture clock, first and end index into `PATH_POINTS`. `PATH_POINTS`: `f64` arrival, increment and transition time; node, `u8` transition (0 rise, 1 fall), `u8` point kind, @@ -163,10 +168,11 @@ the rank records (check, corner, endpoint row, end transition, `f32` slack) and **WS-REQ-0134** The first `num_top_k_paths` ranked paths are materialized: `PATHS[row]` (whose rank and endpoint must be those of the rank record) gives the exact `f64` slack and its point range gives the points with their arrival times; the chain of the same row, when it has steps, -has one step per point with the same node (the step's 24 bits are the low bits of the point's -node) and gives each point's name bucket (without steps the name is found by node, -WS-REQ-0129). A ranked path beyond those is read from its chain alone: its points have the names -of their buckets (whose node must agree with the step's 24 bits), kinds and transitions but no +has one step per point with the same node (in a version 1 table, the step's 24 bits are the low +bits of the point's node) and gives each point's name bucket (without steps the name is found by +node, WS-REQ-0129). A ranked path beyond those is read from its chain alone: its points have the +names of their buckets (whose node must agree with the step's node, or its 24 bits in version 1), +kinds and transitions but no arrival times, and its slack is the `f32` of the rank record (`slack_exact` false). Both hold in the full and the summary mode. `StaTable::path_required(row)` returns the required time and check value of the chain header. @@ -174,7 +180,7 @@ the full and the summary mode. ## Builder -**WS-REQ-0135** `StaTableBuilder` writes a valid `STA` v1 table from ports, instance pins (with +**WS-REQ-0135** `StaTableBuilder` writes a valid `STA` v2 table from ports, instance pins (with optional cell names), corners, clocks, endpoints with their per-corner slacks and ranked paths with their points; it analyzes nothing, and every slack and arrival is the caller's. It numbers the port bits first, then the pins instance by instance; writes string 0 empty and shares equal @@ -185,11 +191,12 @@ the first `top_k` ranked paths (default all); writes one path group `**`, one la disabled arcs, unconstrained endpoints or notes, a `SCOPE` with `wire_model` `none`, `clock_model` `ideal` and backend `builder`, and in the full mode absent values in `NODE_REC` except the arrivals of path points. A path that does not end at an endpoint, an unknown corner -or node, a non-finite time, no corner, more than 16 corners, 2^20 endpoints, 2^24 nodes or 4095 -pins of one instance is `INTERNAL`. `StaTableBuilder::stage(txn, upstream)` adds the table as +or node, a non-finite time, no corner, more than 16 corners, 2^32 - 1 endpoints or nodes, more +than 4095 pins of one instance or a cell name past string 2^20 - 1 (the fields of `INST_HASH`) +is `INTERNAL`. `StaTableBuilder::stage(txn, upstream)` adds the table as `sta` (`sta.rbin`) with `upstream` in its header (WS-REQ-0038) and sets the summary values `setup_wns_ns`, `setup_tns_ns`, `setup_violating_endpoints`, `hold_wns_ns`, `hold_tns_ns`, -`hold_violating_endpoints`, `max_trans_violating`, `max_cap_violating`, `total_endpoints`, +`hold_violating_endpoints`, the design-rule values of WS-REQ-0137, `total_endpoints`, `unconstrained_endpoints` and `corners`, and the status `timing` (`met` or `violated`), of the timing table; `to_bytes(upstream)` returns the bytes. @@ -197,3 +204,7 @@ timing table; `to_bytes(upstream)` returns the bytes. written as `+0.0` and NaN as the quiet NaN `0x7ff8_0000_0000_0000`, and every table it writes passes the validation of WS-REQ-0128 and WS-REQ-0129 and reads back with the queries of WS-REQ-0131 to WS-REQ-0134. + +**WS-REQ-0137** The builder checks no design rule: it sets the summary values +`max_trans_violating` and `max_cap_violating` to `null` and `drv_status` to `"unchecked"`, never +to zero, and its `SUMMARY` rows hold no design-rule counts. diff --git a/docs/verification/requirements.md b/docs/verification/requirements.md index 2039f4e..8ed9f77 100644 --- a/docs/verification/requirements.md +++ b/docs/verification/requirements.md @@ -148,6 +148,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | COMPUTE-REQ-0027 | docs/specs/compute/timing.md | crates/core/rusteda-compute/src/lib.rs
crates/core/rusteda-compute/src/timing/cpu.rs
crates/core/rusteda-compute/src/timing/mod.rs
crates/core/rusteda-compute/src/timing/tests/cpu.rs | | COMPUTE-REQ-0028 | docs/specs/compute/timing.md | crates/core/rusteda-compute/src/lib.rs
crates/core/rusteda-compute/src/timing/cuda.rs
crates/core/rusteda-compute/src/timing/tests/gpu.rs | | COMPUTE-REQ-0029 | docs/specs/compute/timing.md | crates/core/rusteda-compute/src/lib.rs
crates/core/rusteda-compute/src/timing/cuda.rs
crates/core/rusteda-compute/src/timing/mod.rs
crates/core/rusteda-compute/src/timing/tests/gpu.rs | +| COMPUTE-REQ-0030 | docs/specs/compute/timing.md | crates/core/rusteda-compute/src/timing/tests/prepared.rs | | COMPUTE-REQ-0101 | docs/specs/compute/README.md | crates/core/rusteda-compute/src/cut/mod.rs
crates/core/rusteda-compute/src/cut/tests.rs
crates/core/rusteda-compute/src/lib.rs | | COMPUTE-REQ-0102 | docs/specs/compute/README.md | crates/core/rusteda-compute/src/cut/cuda.rs
crates/core/rusteda-compute/src/cut/mod.rs | | COMPUTE-REQ-0103 | docs/specs/compute/README.md | crates/core/rusteda-compute/src/cut/mod.rs
crates/core/rusteda-compute/src/cut/pack.rs | @@ -165,7 +166,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | CORE-REQ-0120 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist.rs
crates/core/rusteda-core/src/netlist/tests.rs | | CORE-REQ-0121 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist.rs
crates/core/rusteda-core/src/netlist/tests.rs
crates/core/rusteda-core/src/netlist/tests_random.rs | | CORE-REQ-0122 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist.rs
crates/core/rusteda-core/src/netlist/tests.rs
crates/core/rusteda-core/src/netlist/tests_random.rs | -| CORE-REQ-0123 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist.rs
crates/core/rusteda-core/src/netlist/error.rs
crates/core/rusteda-core/src/netlist/tests.rs
crates/core/rusteda-core/src/netlist/tests_random.rs | +| CORE-REQ-0123 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist.rs
crates/core/rusteda-core/src/netlist/error.rs
crates/core/rusteda-core/src/netlist/tests.rs
crates/core/rusteda-core/src/netlist/tests_random.rs
crates/core/rusteda-workspace/src/edits.rs | | CORE-REQ-0124 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/tests.rs | | CORE-REQ-0125 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/tests.rs | | CORE-REQ-0126 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/tests.rs | @@ -178,7 +179,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | CORE-REQ-0134 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/frozen.rs
crates/core/rusteda-core/src/netlist/tests_frozen.rs | | CORE-REQ-0135 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/frozen.rs
crates/core/rusteda-core/src/netlist/tests.rs
crates/core/rusteda-core/src/netlist/tests_frozen.rs
crates/core/rusteda-core/src/netlist/validate.rs | | CORE-REQ-0136 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/frozen.rs
crates/core/rusteda-core/src/netlist/tests.rs
crates/core/rusteda-core/src/netlist/tests_frozen.rs | -| CORE-REQ-0137 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/frozen.rs
crates/core/rusteda-core/src/netlist/tests_frozen.rs
crates/core/rusteda-core/src/netlist/validate.rs | +| CORE-REQ-0137 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/frozen.rs
crates/core/rusteda-core/src/netlist/tests_frozen.rs
crates/core/rusteda-core/src/netlist/validate.rs
crates/formats/rusteda-formats/src/verilog/netlist/freeze_writer.rs | | CORE-REQ-0138 | docs/specs/core/netlist.md | crates/core/rusteda-core/src/netlist/frozen.rs
crates/core/rusteda-core/src/netlist/tests_frozen.rs | | JOB-REQ-0001 | docs/specs/core/README.md | crates/core/rusteda-core/src/job.rs | | JOB-REQ-0002 | docs/specs/core/README.md | crates/core/rusteda-core/src/job.rs | @@ -225,6 +226,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | LIBERTY-REQ-0046 | docs/specs/core/README.md | crates/core/rusteda-core/src/liberty/model.rs
crates/formats/rusteda-formats/src/liberty/build.rs
crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs | | LIBERTY-REQ-0047 | docs/specs/core/README.md | crates/core/rusteda-core/src/liberty/kinds.rs
crates/core/rusteda-core/src/liberty/model.rs
crates/formats/rusteda-formats/src/liberty/build.rs
crates/formats/rusteda-formats/src/liberty/wire_load.rs
crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs | | LIBERTY-REQ-0048 | docs/specs/core/README.md | crates/core/rusteda-core/src/liberty/kinds.rs
crates/core/rusteda-core/src/liberty/model.rs
crates/formats/rusteda-formats/src/liberty/build.rs
crates/formats/rusteda-formats/src/liberty/wire_load.rs
crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs | +| LIBERTY-REQ-0049 | docs/specs/core/README.md | crates/core/rusteda-core/src/liberty/model.rs
crates/formats/rusteda-formats/src/liberty/build.rs
crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs | | LIBERTY-REQ-0050 | docs/specs/liberty/liberty.md | crates/formats/rusteda-formats/src/liberty/build.rs
crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs | | LIBERTY-REQ-0051 | docs/specs/liberty/liberty.md | crates/formats/rusteda-formats/src/liberty/build.rs
crates/formats/rusteda-formats/src/liberty/classify.rs
crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs | | LIBERTY-REQ-0052 | docs/specs/liberty/liberty.md | crates/formats/rusteda-formats/src/liberty/diags.rs
crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs | @@ -278,6 +280,14 @@ more than 200 IDs is not expanded: only its first number counts as cited. | RCLI-REQ-0020 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/export.rs
crates/apps/rusteda-cli/tests/export_netlist.rs | | RCLI-REQ-0021 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/import_logic.rs
crates/apps/rusteda-cli/src/import_rtl.rs
crates/apps/rusteda-cli/tests/import_logic.rs | | RCLI-REQ-0022 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/import_logic.rs
crates/apps/rusteda-cli/src/import_rtl.rs
crates/apps/rusteda-cli/tests/import_logic.rs
crates/apps/rustsys-cli/src/ws/mod.rs
crates/apps/rustsys-cli/tests/netlist_sources.rs | +| RCLI-REQ-0023 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/query.rs
crates/apps/rusteda-cli/tests/show_drv.rs | +| RCLI-REQ-0070 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import_spef.rs
crates/apps/rusteda-cli/tests/import_spef.rs | +| RCLI-REQ-0071 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import_spef.rs
crates/formats/rusteda-formats/src/spef/tests.rs | +| RCLI-REQ-0072 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import_spef.rs
crates/formats/rusteda-formats/src/spef/tests.rs | +| RCLI-REQ-0073 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import_spef.rs
crates/apps/rusteda-cli/tests/import_spef.rs
crates/formats/rusteda-formats/src/spef/source.rs | +| RCLI-REQ-0074 | docs/specs/rusteda-cli/spef.md | crates/formats/rusteda-formats/src/spef/tests.rs | +| RCLI-REQ-0075 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import.rs
crates/apps/rusteda-cli/src/import_spef.rs
crates/apps/rusteda-cli/tests/import_spef.rs
crates/formats/rusteda-formats/src/spef/mod.rs | +| RCLI-REQ-0076 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import.rs
crates/apps/rusteda-cli/tests/import_spef.rs | | SDC-REQ-0001 | docs/specs/sdc/sdc.md | crates/formats/rusteda-formats/src/sdc.rs
crates/formats/rusteda-formats/src/sdc/eval.rs
crates/formats/rusteda-formats/tests/sdc_fuzz.rs
crates/formats/rusteda-formats/tests/sdc_roundtrip.rs
crates/formats/rusteda-formats/tests/sdc_subset.rs | | SDC-REQ-0002 | docs/specs/sdc/sdc.md | crates/formats/rusteda-formats/src/sdc.rs
crates/formats/rusteda-formats/src/sdc/cmd.rs
crates/formats/rusteda-formats/src/sdc/schema.rs
crates/formats/rusteda-formats/src/sdc/schema/parse.rs
crates/formats/rusteda-formats/tests/sdc_commands.rs
crates/formats/rusteda-formats/tests/sdc_subset.rs | | SDC-REQ-0003 | docs/specs/sdc/sdc.md | crates/formats/rusteda-formats/src/sdc.rs
crates/formats/rusteda-formats/src/sdc/write.rs
crates/formats/rusteda-formats/tests/sdc_roundtrip.rs | @@ -333,6 +343,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | SDC-REQ-0053 | docs/specs/sdc/sdc.md | crates/apps/rusteda-cli/src/import_sdc.rs
crates/apps/rusteda-cli/tests/import_sdc.rs
crates/formats/rusteda-formats/tests/sdc_roundtrip.rs | | SDC-REQ-0054 | docs/specs/sdc/sdc.md | crates/apps/rusteda-cli/src/import_sdc.rs
crates/apps/rusteda-cli/tests/import_sdc.rs | | SDC-REQ-0055 | docs/specs/sdc/sdc.md | crates/apps/rusteda-cli/src/import_sdc.rs
crates/apps/rusteda-cli/tests/import_sdc.rs
crates/formats/rusteda-formats/src/sdc.rs | +| SDC-REQ-0070 | docs/specs/sdc/sdc.md | crates/apps/rustsys-cli/tests/workspace_opt.rs
crates/formats/rusteda-formats/tests/sdc_commands.rs | | SYNTH-REQ-0101 | docs/specs/synth/frontend.md | crates/engines/rustsys/src/lib.rs | | SYNTH-REQ-0102 | docs/specs/synth/frontend.md | crates/apps/rustsys-cli/tests/synth_frontend.rs | | SYNTH-REQ-0103 | docs/specs/synth/frontend.md | crates/engines/rustsys/src/elab/module.rs | @@ -397,14 +408,14 @@ more than 200 IDs is not expanded: only its first number counts as cited. | SYNTH-REQ-0407 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/bits.rs
crates/engines/rustsys/src/elab/expr.rs
crates/engines/rustsys/src/elab/mod.rs | | SYNTH-REQ-0408 | docs/specs/synth/optimization.md | crates/engines/rustsys/tests/passes.rs | | SYNTH-REQ-0410 | docs/specs/synth/optimization.md | crates/engines/rustsys/tests/passes.rs | -| SYNTH-REQ-0411 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/balance.rs
crates/engines/rustsys/src/opt/factor.rs
crates/engines/rustsys/src/opt/lib4.rs
crates/engines/rustsys/src/opt/merge.rs
crates/engines/rustsys/src/opt/refactor.rs
crates/engines/rustsys/src/opt/resub.rs
crates/engines/rustsys/src/opt/rewrite.rs
crates/engines/rustsys/tests/passes.rs | +| SYNTH-REQ-0411 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/balance.rs
crates/engines/rustsys/src/opt/factor.rs
crates/engines/rustsys/src/opt/lib4.rs
crates/engines/rustsys/src/opt/merge.rs
crates/engines/rustsys/src/opt/refactor.rs
crates/engines/rustsys/src/opt/resub.rs
crates/engines/rustsys/src/opt/rewrite.rs
crates/engines/rustsys/tests/balance.rs
crates/engines/rustsys/tests/passes.rs | | SYNTH-REQ-0412 | docs/specs/synth/optimization.md | crates/apps/rustsys-cli/src/proto/job.rs
crates/engines/rustsys/src/lib.rs | | SYNTH-REQ-0413 | docs/specs/synth/optimization.md | crates/apps/rustsys-cli/tests/synth_frontend.rs | | SYNTH-REQ-0414 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/muxdecode.rs
crates/engines/rustsys/tests/passes.rs | | SYNTH-REQ-0415 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/refactor.rs | | SYNTH-REQ-0416 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/refactor.rs | | SYNTH-REQ-0417 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/refactor.rs | -| SYNTH-REQ-0418 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/refactor.rs | +| SYNTH-REQ-0418 | docs/specs/synth/optimization.md | crates/engines/rustsys/src/opt/refactor.rs
crates/engines/rustsys/tests/passes.rs | | SYNTH-REQ-0501 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/library.rs
crates/engines/rustsys/tests/map_consistency.rs
xtask/src/pdk/scan.rs | | SYNTH-REQ-0502 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/library.rs | | SYNTH-REQ-0510 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/covers.rs
crates/engines/rustsys/src/map/cuts.rs
crates/engines/rustsys/src/map/mapper.rs | @@ -416,13 +427,13 @@ more than 200 IDs is not expanded: only its first number counts as cited. | SYNTH-REQ-0516 | docs/specs/synth/mapping.md | crates/apps/rustsys-cli/benches/backends.rs
crates/apps/rustsys-cli/src/proto/job.rs
crates/apps/rustsys-cli/tests/synth_backends.rs
crates/engines/rustsys/src/lib.rs
crates/engines/rustsys/src/map/compute.rs
crates/engines/rustsys/src/map/mapper.rs
crates/engines/rustsys/src/map/tests.rs | | SYNTH-REQ-0517 | docs/specs/synth/mapping.md | crates/apps/rustsys-cli/tests/synth_backends.rs
crates/engines/rustsys/src/map/area.rs
crates/engines/rustsys/src/map/tests.rs | | SYNTH-REQ-0518 | docs/specs/synth/mapping.md | crates/apps/rustsys-cli/tests/synth_backends.rs
crates/engines/rustsys/src/lib.rs
crates/engines/rustsys/src/map/mod.rs | -| SYNTH-REQ-0520 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/mod.rs | +| SYNTH-REQ-0520 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/mod.rs
crates/engines/rustsys/src/map/seq_select.rs
crates/engines/rustsys/tests/enabled_seq.rs | | SYNTH-REQ-0521 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/mod.rs | | SYNTH-REQ-0522 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/emit.rs
crates/engines/rustsys/src/map/library.rs
xtask/src/pdk/scan.rs | -| SYNTH-REQ-0523 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/library.rs | +| SYNTH-REQ-0523 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/electrical/tests.rs
crates/engines/rustsys/src/map/library.rs
crates/engines/rustsys/src/map/seq_modes.rs
crates/engines/rustsys/tests/enabled_seq.rs | | SYNTH-REQ-0524 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/tests.rs | | SYNTH-REQ-0525 | docs/specs/synth/mapping.md | crates/engines/rustsys/src/map/tests.rs | -| SYNTH-REQ-0530 | docs/specs/synth/electrical.md | crates/apps/rustsys-cli/tests/synth_frontend.rs
crates/engines/rustsys/src/electrical/mod.rs | +| SYNTH-REQ-0530 | docs/specs/synth/electrical.md | crates/apps/rustsys-cli/tests/synth_frontend.rs
crates/engines/rustsys/src/electrical/mod.rs
crates/engines/rustsys/src/electrical/tests.rs | | SYNTH-REQ-0531 | docs/specs/synth/electrical.md | crates/apps/rustsys-cli/tests/synth_frontend.rs
crates/engines/rustsys/src/electrical/mod.rs
crates/engines/rustsys/src/electrical/tests.rs | | SYNTH-REQ-0532 | docs/specs/synth/electrical.md | crates/apps/rustsys-cli/tests/synth_frontend.rs
crates/engines/rustsys/src/electrical/mod.rs
crates/engines/rustsys/src/electrical/tests.rs | | SYNTH-REQ-0533 | docs/specs/synth/electrical.md | crates/apps/rustsys-cli/tests/synth_frontend.rs
crates/engines/rustsys/src/electrical/mod.rs
crates/engines/rustsys/src/electrical/tests.rs | @@ -441,7 +452,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | SYNTH-REQ-0546 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/electrical/tests.rs | | SYNTH-REQ-0547 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/electrical/state_phase.rs
crates/engines/rustsys/src/electrical/tests_rules.rs | | SYNTH-REQ-0548 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/electrical/tests.rs | -| SYNTH-REQ-0549 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/electrical/tests.rs | +| SYNTH-REQ-0549 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/electrical/tests/pin_groups.rs | | SYNTH-REQ-0550 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/covers.rs | | SYNTH-REQ-0551 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/electrical/repair.rs | | SYNTH-REQ-0552 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/covers.rs
crates/engines/rustsys/src/electrical/mod.rs
crates/engines/rustsys/src/electrical/network.rs
crates/engines/rustsys/src/electrical/repair.rs
crates/engines/rustsys/src/electrical/tests.rs
crates/engines/rustsys/src/electrical/tests_rules.rs | @@ -457,7 +468,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | SYNTH-REQ-0563 | docs/specs/synth/electrical.md | crates/engines/rustsys/src/electrical/incremental.rs
crates/engines/rustsys/src/electrical/mod.rs
crates/engines/rustsys/tests/incremental.rs | | SYNTH-REQ-0564 | docs/specs/synth/electrical.md | crates/apps/rustsys-cli/tests/workspace_opt.rs
crates/engines/rustsys/src/electrical/incremental.rs
crates/engines/rustsys/src/electrical/mod.rs
crates/engines/rustsys/tests/incremental.rs | | SYNTH-REQ-0565 | docs/specs/synth/electrical.md | crates/apps/rustsys-cli/src/ws/opt.rs
crates/engines/rustsys/src/estimate.rs
crates/engines/rustsys/tests/netlist_estimate.rs | -| SYNTH-REQ-0601 | docs/specs/synth/netlist.md | crates/engines/rustsys/src/emit.rs
crates/engines/rustsys/tests/map_consistency.rs
xtask/src/pdk/scan.rs | +| SYNTH-REQ-0601 | docs/specs/synth/netlist.md | crates/engines/rustsys/src/emit.rs
crates/engines/rustsys/tests/enabled_seq.rs
crates/engines/rustsys/tests/map_consistency.rs
xtask/src/pdk/scan.rs | | SYNTH-REQ-0602 | docs/specs/synth/netlist.md | crates/engines/rustsys/src/elab/module.rs
crates/engines/rustsys/src/elab/resolve.rs
crates/engines/rustsys/src/emit.rs | | SYNTH-REQ-0603 | docs/specs/synth/netlist.md | crates/engines/rustsys/src/elab/mod.rs
crates/engines/rustsys/src/elab/resolve.rs
crates/engines/rustsys/src/elab/types.rs
crates/engines/rustsys/src/emit.rs
crates/engines/rustsys/tests/elab_semantics.rs
crates/verify/rustsys-check/src/netlist.rs
crates/verify/rustsys-check/src/rtl/mod.rs | | SYNTH-REQ-0604 | docs/specs/synth/netlist.md | crates/engines/rustsys/src/emit.rs | @@ -465,7 +476,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | SYNTH-REQ-0606 | docs/specs/synth/netlist.md | crates/engines/rustsys/src/emit.rs | | SYNTH-REQ-0701 | docs/specs/synth/cli.md | crates/apps/rustsys-cli/src/args.rs
crates/apps/rustsys-cli/src/lib.rs
crates/apps/rustsys-cli/src/proto/job.rs
crates/engines/rustsys/src/lib.rs | | SYNTH-REQ-0702 | docs/specs/synth/cli.md | crates/apps/rustsys-cli/tests/synth_frontend.rs | -| SYNTH-REQ-0703 | docs/specs/synth/cli.md | crates/apps/rustsys-cli/tests/synth_frontend.rs
crates/engines/rustsys/src/lib.rs
crates/engines/rustsys/src/map/mapper.rs | +| SYNTH-REQ-0703 | docs/specs/synth/cli.md | crates/apps/rustsys-cli/tests/synth_frontend.rs
crates/engines/rustsys/src/lib.rs
crates/engines/rustsys/src/map/mapper/config.rs | | SYNTH-REQ-0704 | docs/specs/synth/cli.md | crates/apps/rustsys-cli/src/proto/text.rs | | SYNTH-REQ-0705 | docs/specs/synth/cli.md | crates/apps/rustsys-cli/tests/cli_contract.rs | | SYNTH-REQ-0706 | docs/specs/synth/cli.md | crates/apps/rustsys-cli/tests/cli_contract.rs | @@ -507,18 +518,18 @@ more than 200 IDs is not expanded: only its first number counts as cited. | VERILOG-REQ-0205 | docs/specs/verilog/verilog.md | crates/formats/rusteda-formats/src/verilog/pp.rs | | VERILOG-REQ-0206 | docs/specs/verilog/verilog.md | crates/formats/rusteda-formats/src/verilog/mod.rs
crates/formats/rusteda-formats/src/verilog/pp.rs | | VERILOG-REQ-0300 | docs/specs/verilog/verilog.md | crates/engines/rustsys/tests/netlist_round_trip.rs
crates/formats/rusteda-formats/src/verilog/netlist.rs
crates/formats/rusteda-formats/src/verilog/netlist/tests.rs
crates/formats/rusteda-formats/src/verilog/write.rs | -| VERILOG-REQ-0301 | docs/specs/verilog/verilog.md | crates/engines/rustsys/tests/netlist_round_trip.rs
crates/formats/rusteda-formats/src/verilog/netlist.rs
crates/formats/rusteda-formats/src/verilog/netlist/tests.rs
crates/formats/rusteda-formats/src/verilog/write.rs | +| VERILOG-REQ-0301 | docs/specs/verilog/verilog.md | crates/engines/rustsys/tests/netlist_round_trip.rs
crates/formats/rusteda-formats/src/verilog/netlist.rs
crates/formats/rusteda-formats/src/verilog/netlist/freeze_writer.rs
crates/formats/rusteda-formats/src/verilog/netlist/tests.rs
crates/formats/rusteda-formats/src/verilog/write.rs | | VERILOG-REQ-0302 | docs/specs/verilog/verilog.md | crates/formats/rusteda-formats/src/verilog/write.rs | | VERILOG-REQ-0310 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | | VERILOG-REQ-0311 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | | VERILOG-REQ-0312 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | | VERILOG-REQ-0313 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | | VERILOG-REQ-0314 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | -| VERILOG-REQ-0315 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | +| VERILOG-REQ-0315 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs
crates/formats/rusteda-formats/src/verilog/netlist/freeze_writer.rs | | VERILOG-REQ-0316 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | -| VERILOG-REQ-0317 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | +| VERILOG-REQ-0317 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs
crates/formats/rusteda-formats/src/verilog/netlist/freeze_writer.rs | | VERILOG-REQ-0318 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | -| VERILOG-REQ-0319 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs | +| VERILOG-REQ-0319 | docs/specs/verilog/netlist.md | crates/formats/rusteda-formats/src/verilog/netlist.rs
crates/formats/rusteda-formats/src/verilog/netlist/freeze_writer.rs | | WS-REQ-0001 | docs/specs/workspace/README.md | crates/apps/rusteda-cli/src/init.rs
crates/core/rusteda-workspace/src/workspace.rs
crates/core/rusteda-workspace/tests/workspace.rs | | WS-REQ-0002 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/workspace.rs
crates/core/rusteda-workspace/tests/workspace.rs | | WS-REQ-0003 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/workspace.rs
crates/core/rusteda-workspace/tests/workspace.rs | @@ -531,7 +542,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | WS-REQ-0014 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/schema.rs
crates/core/rusteda-workspace/src/txn.rs
crates/core/rusteda-workspace/src/workspace_io.rs
crates/core/rusteda-workspace/tests/workspace_ops.rs | | WS-REQ-0020 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/rev.rs | | WS-REQ-0021 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/rev.rs
crates/core/rusteda-workspace/src/workspace.rs
crates/core/rusteda-workspace/tests/workspace.rs | -| WS-REQ-0022 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/rev.rs | +| WS-REQ-0022 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/edits.rs
crates/core/rusteda-workspace/src/rev.rs | | WS-REQ-0023 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/mmap.rs
crates/core/rusteda-workspace/src/workspace.rs
crates/core/rusteda-workspace/tests/workspace.rs | | WS-REQ-0024 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/mmap.rs
crates/core/rusteda-workspace/src/txn.rs
crates/core/rusteda-workspace/src/workspace.rs
crates/core/rusteda-workspace/tests/failures.rs | | WS-REQ-0025 | docs/specs/workspace/README.md | crates/core/rusteda-workspace/src/cache.rs
crates/core/rusteda-workspace/src/error.rs
crates/core/rusteda-workspace/src/fsutil.rs
crates/core/rusteda-workspace/src/mmap.rs
crates/core/rusteda-workspace/src/rbin.rs
crates/core/rusteda-workspace/src/workspace.rs
crates/core/rusteda-workspace/tests/cache.rs
crates/core/rusteda-workspace/tests/failures.rs
crates/core/rusteda-workspace/tests/rbin.rs | @@ -599,6 +610,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | WS-REQ-0114 | docs/specs/workspace/rlib.md | crates/core/rusteda-workspace/src/rlib.rs
crates/core/rusteda-workspace/src/rlib/decode.rs
crates/core/rusteda-workspace/tests/rlib.rs | | WS-REQ-0115 | docs/specs/workspace/rlib.md | crates/apps/rusteda-cli/tests/cli_contract.rs
crates/core/rusteda-workspace/src/rlib.rs
crates/core/rusteda-workspace/tests/rlib.rs | | WS-REQ-0116 | docs/specs/workspace/rlib.md | crates/apps/rusteda-cli/tests/cli_contract.rs
crates/core/rusteda-workspace/src/rlib.rs
crates/core/rusteda-workspace/tests/rlib.rs | +| WS-REQ-0117 | docs/specs/workspace/rlib.md | crates/core/rusteda-workspace/src/rlib.rs
crates/core/rusteda-workspace/tests/rlib.rs | | WS-REQ-0120 | docs/specs/workspace/netlist.md | crates/core/rusteda-workspace/src/netlist.rs
crates/core/rusteda-workspace/tests/netlist.rs | | WS-REQ-0121 | docs/specs/workspace/netlist.md | crates/core/rusteda-workspace/src/netlist.rs
crates/core/rusteda-workspace/tests/netlist.rs | | WS-REQ-0122 | docs/specs/workspace/netlist.md | crates/core/rusteda-workspace/src/netlist.rs
crates/core/rusteda-workspace/tests/netlist.rs | @@ -616,6 +628,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | WS-REQ-0134 | docs/specs/workspace/sta.md | crates/core/rusteda-workspace/src/sta.rs
crates/core/rusteda-workspace/src/sta/build.rs
crates/core/rusteda-workspace/src/sta/read.rs
crates/core/rusteda-workspace/src/sta/tests.rs | | WS-REQ-0135 | docs/specs/workspace/sta.md | crates/core/rusteda-workspace/src/sta.rs
crates/core/rusteda-workspace/src/sta/build.rs
crates/core/rusteda-workspace/src/sta/layout.rs
crates/core/rusteda-workspace/src/sta/tests.rs
crates/core/rusteda-workspace/tests/sta.rs | | WS-REQ-0136 | docs/specs/workspace/sta.md | crates/core/rusteda-workspace/src/sta.rs
crates/core/rusteda-workspace/src/sta/tests.rs | +| WS-REQ-0137 | docs/specs/workspace/sta.md | crates/core/rusteda-workspace/src/sta/build.rs | | WS-REQ-0190 | docs/specs/workspace/README.md | crates/apps/rusteda-cli/tests/import_logic.rs
crates/core/rusteda-workspace/src/envelope.rs
crates/core/rusteda-workspace/src/envelope_tests.rs | | Prefix | Requirements | Without a citing test or source | @@ -624,15 +637,15 @@ more than 200 IDs is not expanded: only its first number counts as cited. | BLIF | 23 | 0 | | CHECK | 40 | 0 | | CLI | 35 | 0 | -| COMPUTE | 31 | 0 | +| COMPUTE | 32 | 0 | | CORE | 26 | 0 | | JOB | 7 | 0 | -| LIBERTY | 48 | 0 | +| LIBERTY | 49 | 0 | | PROTO | 22 | 0 | -| RCLI | 21 | 0 | -| SDC | 55 | 0 | +| RCLI | 29 | 0 | +| SDC | 56 | 0 | | SYNTH | 158 | 0 | | VERILOG | 28 | 0 | -| WS | 98 | 0 | +| WS | 100 | 0 | -606 requirements, 0 without a citing test or source. +619 requirements, 0 without a citing test or source. diff --git a/verification/shared.sha256 b/verification/shared.sha256 index 4f03e01..8a5cfb2 100644 --- a/verification/shared.sha256 +++ b/verification/shared.sha256 @@ -1,24 +1,27 @@ -bdf9f1ee73fca72d31acae031e8cbe9e381e50c82b152004d0f5721d613f0c28 crates/apps/rusteda-cli/Cargo.toml +5756bd94c68cbb105810ebbe3f03ba9c7ad8d55abb0dfd35eda20658f3dd7c0b crates/apps/rusteda-cli/Cargo.toml d0b7792b6445ce305ff02cf71ef038c302c04408cd7aaf188225560ef362b8c3 crates/apps/rusteda-cli/src/export.rs d0deca5b6b96c19c919b2116babbb9eeacf2cb2b17cb052ad18258c633251640 crates/apps/rusteda-cli/src/export/frozen.rs -f337be6b8340fba41fc9a527fd8f8c6d52c5a463a0a158534d7f5b4a5651205e crates/apps/rusteda-cli/src/import.rs +b6b8d968705d95dbaff592bafcfa4c916943e53ad631d9bdcca3e44095b5b9d0 crates/apps/rusteda-cli/src/import.rs 792a5fc01b3a785d5c7c4272088a467e5156b742e86be3e3da412e7ad7c13586 crates/apps/rusteda-cli/src/import_lib.rs 18c0a59531f4ea3e9541378b733b14a514f451e8b11ec9d78e807b354bae50e8 crates/apps/rusteda-cli/src/import_logic.rs -9c14ac3a33faf4698e5c67a2adf61f669ea2014d14292a2f39035c5314c9998c crates/apps/rusteda-cli/src/import_netlist.rs +39863a388c7e8e14a37dff592e8367ff11df67faef7269e0261e0ef9d5806915 crates/apps/rusteda-cli/src/import_netlist.rs 91214f50dbb02a9c23f082ff4c5a46920328645190689f42a9dab648106ca15a crates/apps/rusteda-cli/src/import_netlist/ast.rs 8826e0a48e9b7a810c06d38e9d92ad1dd7e9334a7ef4c9276aed2e40d3b26ab3 crates/apps/rusteda-cli/src/import_netlist/fast.rs 0448834ee05970132181546c3c17734ace146583b0877a0bc1cba3b926150e66 crates/apps/rusteda-cli/src/import_rtl.rs d4b9920a5f32601f4c89c34f6fba20194df2282bad5379f081a1fc8e4933ca1a crates/apps/rusteda-cli/src/import_sdc.rs +0948b8820e18ec8096315f824b54c733caaa823426e0fd79228c1649f33147c9 crates/apps/rusteda-cli/src/import_spef.rs aadc389f6f4d5fb6d4079e24219849f3f921d558048924a010026dc7480f6042 crates/apps/rusteda-cli/src/init.rs -b981388ee936d712daa9f4bf8f2947dda566cabb2e0e361982565b9b0997e52f crates/apps/rusteda-cli/src/lib.rs +f81704f4ed2d27a3341335fb8495de4e9a9c5aeff299d30783f88fc2db02cbf0 crates/apps/rusteda-cli/src/lib.rs 6eeebde00bb9649fa5328ba95a1947360c3aab13739b09724548a953c51b0bc0 crates/apps/rusteda-cli/src/main.rs -8f82e696136f3596a3e9bb32a0a821782c574a50cc4da3914afd853316eaf113 crates/apps/rusteda-cli/src/query.rs +029115bf32b80519341a6d86c0d3c0480152eb85f5357cd0c1814bed68585eca crates/apps/rusteda-cli/src/query.rs 3c9fb8ef9a52ac80e5047b9c0fdd7b1478a584967c570e07f6b8ca2375b86545 crates/apps/rusteda-cli/src/revert.rs ee8f008db17b19b09e7ed89c6abcea2501dd8b151229853b043e80d437a53dd7 crates/apps/rusteda-cli/tests/cli_contract.rs 4fa54437edac9adc0aed14caf75437c284fe4e858550bb679cc82a85e3607b69 crates/apps/rusteda-cli/tests/export_netlist.rs d9ee3c935b48f4d4efbf18855117cc85dd7db130e20c6b92c01787d1455cede2 crates/apps/rusteda-cli/tests/import_logic.rs -282d850a26b30a6003e16c70596597a46bf9822a79dc5c03300a96df0f9aab83 crates/apps/rusteda-cli/tests/import_netlist.rs +9c3d6bcb3c3a0d5206d27a0116eb5ee9c63cbdb3f6e6b43ba4355898b70e1a66 crates/apps/rusteda-cli/tests/import_netlist.rs e85107183ca7a35a1258cec3acf324756fd92530f0e5ee3acc9b1fe80c031e78 crates/apps/rusteda-cli/tests/import_sdc.rs +2207fc735b9a5a9e7b308aeca7ab3fde1a663943839f6ef8cf5b71c9cda7ce6e crates/apps/rusteda-cli/tests/import_spef.rs +627e3cd03ae976806be3ef71a82c81f8f8243b9b5fe4458424eaadf4f9015bde crates/apps/rusteda-cli/tests/show_drv.rs 16e1910d6afbe8e6a90a5805bffca8dec6752382478861a3ec0c03fa27088ca5 crates/core/rusteda-compute/Cargo.toml 3f610a1de312c37bd44eee972830d4f70edfc1c3cc51692759da407808e533d6 crates/core/rusteda-compute/README.md 42243eab12f75b05aca13a2f41a5c90020cec829d23a3ecd110fe5915393171f crates/core/rusteda-compute/src/cuda.rs @@ -27,22 +30,25 @@ cfa2c2ec5b7e22ccaab416627c5d8aa3e6fcf42f0328add053301bb8ec88e6f2 crates/core/ru 6f6ae8e04ef0bb1fbc029df65cb7d1065ea69ea6232f1b9a29bbde1d1ee3fbf7 crates/core/rusteda-compute/src/cut/mod.rs 3914d3d2c2b5a135888d91985a2252477a37037cc5c0ca80c1006065cf2fcc41 crates/core/rusteda-compute/src/cut/pack.rs b5b0e9feb7741639dddf2211819f4cc55e3daf916ee430e6bc32c0218c9fa6b2 crates/core/rusteda-compute/src/cut/tests.rs -335368a4f52096dac090d32551ea1a7e9010821aa641a707d1343331227e3b4d crates/core/rusteda-compute/src/lib.rs -a78ad26547fb97c36a436285eb9b665b1faf8376fee7212d356c422940f32af1 crates/core/rusteda-compute/src/timing/abi.rs -9dc86463821506ffa82c82099b26dcaa39d92691d0dedbd8d3d357980a1181b4 crates/core/rusteda-compute/src/timing/cpu.rs -f0eec38073cc5a72e5ae4d73a6ec454d7c43bb1299076b6fcd6f22c4251e91de crates/core/rusteda-compute/src/timing/cuda.rs -12272c7c2498bdb6329333633131f54e38d21dbcdbd1b30bdf141462563ce9c2 crates/core/rusteda-compute/src/timing/kernels/timing.cu +f651a75a81f6fb438bf64d4baa48f2451ab62f4477c142c7e3c1649ee51d7c70 crates/core/rusteda-compute/src/lib.rs +3cd6ee42428dbe06151ea414684987725ace3efdcb7f192bf18695b8936e4d7d crates/core/rusteda-compute/src/rc.rs +2185b7a81649244c10b6db436ec9604f5284c0eecbe9656e57ce60a2c1bab8b7 crates/core/rusteda-compute/src/timing/abi.rs +5db80c1d475645d75fe4346a3ef678b213cb424ce8ebe6a949e3aac821c38ad9 crates/core/rusteda-compute/src/timing/cpu.rs +c89580e62748bdb78eb46db2ca955a5f64a7f9019e1b78132fbbe43bd41788d6 crates/core/rusteda-compute/src/timing/cuda.rs +ee120b63de24d009ff344b6aed88f5019bf3a1c73a74176a20211a2746f4a55d crates/core/rusteda-compute/src/timing/kernels/timing.cu 78bfd0b976f32335730a4e4a93639b52fbf26b1c27f22cbf804c2b84aeace1d0 crates/core/rusteda-compute/src/timing/lut.rs -5433445e249269ddb60af4c109aebf7e34b36e4396221025ae72170ae4b11d89 crates/core/rusteda-compute/src/timing/mod.rs +5bca94e335365b9d1f5e6cdeac68a39796eb88f2f11ce23cab6759ed7fae1683 crates/core/rusteda-compute/src/timing/mod.rs b5807796a337a8ae421d15347fe83b80b5be6c1978f03253811e428307680628 crates/core/rusteda-compute/src/timing/state.rs b2d093ea51c390a50f947b024a5f7562a227880365eb3ada6954dfe944d70c87 crates/core/rusteda-compute/src/timing/tests/checks.rs 72d4921c63739db1cd25efe92ebf2b3052b3516d521d6c9bb5588af34def85a6 crates/core/rusteda-compute/src/timing/tests/cpu.rs 487e8eac1783cdffa9efaf9b4837188e5f14dac2f90f22dfef0d95be95eb95a6 crates/core/rusteda-compute/src/timing/tests/gpu.rs a6f5b46861ab55c0109bdbd725132bea8cb8e91aecbd7366c77970801219f27e crates/core/rusteda-compute/src/timing/tests/known.rs -9e9b133d5b7d5150ca449b32eb32c8452bb030ef1be5f12a9f3b5e4ad63709ef crates/core/rusteda-compute/src/timing/tests/mod.rs +a0fb8af237941fa1eea97da8e84a085a76fa03fee96034250caa99636545b59d crates/core/rusteda-compute/src/timing/tests/mod.rs b41668f1b1498e42ad63596446e88efa58a4d5207b2b73c2127e77b36f8eef6c crates/core/rusteda-compute/src/timing/tests/naive.rs -21dc31516c32b6c69a36c8e0ecdff2a9109a444ccdf879faa01c1a373f8322e2 crates/core/rusteda-compute/src/timing/tests/random.rs -118cd520774630ce8a0ef628fd67403592072295229af17383eb9470e002cf38 crates/core/rusteda-compute/src/timing/validate.rs +b7f403ba4e69b0e08ba0f3ed31aa0fa9894ae75579068680778eacd2a48285a6 crates/core/rusteda-compute/src/timing/tests/prepared.rs +f1768224d94512b54d2bc6840d0a5a3d2d31d20f80a73b0a00979f3fba5d6143 crates/core/rusteda-compute/src/timing/tests/random.rs +4731a7e516bf3d21d3d4b278ddb2d5ed7c5aca9cd13fe18731ed058dc147adad crates/core/rusteda-compute/src/timing/validate.rs +000f0607b96fcc0aa58e09be6a9d849efe3729bee60c5579fe0b6d45fac9982d crates/core/rusteda-compute/tests/rc_transient.rs d58a054ed771e0ba5395199f140e162c20a05d5638792b2fab5505e77b2a6c4c crates/core/rusteda-core/Cargo.toml 5e10444759e9722d58c930082955bfc503f3b6f519a26318a5603e38b2db45c6 crates/core/rusteda-core/README.md 3b80f158e1bfad27f3bb6580918641f4a38b6dfb174e7ac8dc00dcd7e50cada0 crates/core/rusteda-core/src/arena.rs @@ -52,7 +58,7 @@ d58a054ed771e0ba5395199f140e162c20a05d5638792b2fab5505e77b2a6c4c crates/core/ru 0e88a816f7a30ba941624abf2b1b274213f1e63474a8f95e7dedbedb4a3a9029 crates/core/rusteda-core/src/liberty/func.rs ff32479a29d332f2cc5b0d32e536a5d45599fd378f577642fd29519aa9ec9458 crates/core/rusteda-core/src/liberty/kinds.rs 5391ea2c2fd9a5940d90357d138ee626d5155dfe9eacf1b85c5b39b265f0bd71 crates/core/rusteda-core/src/liberty/mod.rs -44c25eb8662a243ba3c477e522278a8bc63f43487028dd32a8e0d38e11893c9a crates/core/rusteda-core/src/liberty/model.rs +f59b780c03ea5157694592511a0df6a3bf7c1e2ee90ba2ebae31c01c5b63ab43 crates/core/rusteda-core/src/liberty/model.rs ee637905899292b081a934e521629f9923f5a090b0f9a6ef704dc77b00362079 crates/core/rusteda-core/src/liberty/table.rs 86a87315dbbd52501b4a44ffa7c5c1c175feca23f1ce1b1b161575822781f2c7 crates/core/rusteda-core/src/liberty/units.rs 145d544f44490619335f9aaa8f352b08b1116b95bc888b4ed91eae3450f160d8 crates/core/rusteda-core/src/names.rs @@ -68,27 +74,30 @@ c4159ae157df8a77911174491d6ef1c7587a2b9fe40d7dbce7332aa8f13a68ac crates/core/ru 4de87166a85bdb99386024fd86b478b15705ad9ee7ead31ef705913a9a8cd4af crates/core/rusteda-workspace/Cargo.toml a89dd39f468b9f65c6371aeae1b92c8ee524457160dcf03b1b07c950fed3b87f crates/core/rusteda-workspace/README.md c0c6abb60f38f3a8184c668f39ea18e67d23d00aa822b2c28d517b7884ced986 crates/core/rusteda-workspace/src/cache.rs +0c387363bf5d0b165d2d799f9b38b66c88f14876f6cafced8152a2ec51d8a763 crates/core/rusteda-workspace/src/edits.rs +a121cb78f1d48070d899475dc72bf8c02b20ad862f47b211da5643513ed5eeb2 crates/core/rusteda-workspace/src/edits/apply.rs +7f9bbed6690c5c70db5b1eb4ba66c3b9bc3a720914d8bd39e5c3e6e16cfc0bc4 crates/core/rusteda-workspace/src/edits/tests.rs 6aa1bb21812c7b1c2dc17f608694e2d1c6ad72e404099cfb58b1050b025ed1bd crates/core/rusteda-workspace/src/envelope.rs e27143e2533715cbf21069e9bf49ca676baaa136144775dd3ddde13c92508cad crates/core/rusteda-workspace/src/envelope_tests.rs f1846ba8932700c6b0da6ffa46d1c1a9831855a70733eb37e57efafa486df7e2 crates/core/rusteda-workspace/src/error.rs e00551a876b2daf15ebf8b49b2738af5ebf3de68433c5f7923d14aaba7aefdae crates/core/rusteda-workspace/src/fsutil.rs 1a2d983715d1f5acbe726828dd55713e09c86b5c1b7aafe09843c994c0068dae crates/core/rusteda-workspace/src/hash.rs 9726a1bcd64516197c95820db3a5d5ab988d077c7c5e056736c5effced1ed4de crates/core/rusteda-workspace/src/json.rs -328f20351a6d676ff798ae89c8a1ed835ce9a08e9e774cbc72370c6c5dd6a92a crates/core/rusteda-workspace/src/lib.rs +4732f9499715f2aac70bde638f0865ebf4ad7d48781348ba8bc1b29478d06ae9 crates/core/rusteda-workspace/src/lib.rs 9cf02e0f064c62e13df2d1467d7f5d9071e8a34f3c451dd4d3f0139e7c216875 crates/core/rusteda-workspace/src/lock.rs a7ea922ecab4c0a21bf80c21800d83f2f65a10d285a72dada17eb1edb9406fff crates/core/rusteda-workspace/src/mmap.rs 8bce5fdaa013c0c898fd5f1a292a35a962363fd33fa493a7dee41477dc7b3e59 crates/core/rusteda-workspace/src/netlist.rs 5fd40100d7bffc452552c2284ff723924295ced0fc73724120e3d2abaa992bfd crates/core/rusteda-workspace/src/rbin.rs 557e0890294fe7c66f0128021bbd82b08e5c49e5b7e04547e0c7a5fe8600b7d2 crates/core/rusteda-workspace/src/rbin_tests.rs 97f328d53bfb5f9eefb1a6fa2fa7714d866264d17f7fd4f0d6e12ff728f800ed crates/core/rusteda-workspace/src/rev.rs -d129e63c1e2a106439a10f5600c5b27c80afbc1c60b8ad1ee8ddd09d520d8cb7 crates/core/rusteda-workspace/src/rlib.rs -b8c2af2ff3be077fc97c82b6f0a4a5cba9c1b5a41fb5a61acb7a27f219caede1 crates/core/rusteda-workspace/src/rlib/decode.rs +aae8bef409d325682345478a045f45dcf0ec52d42909f11d21e7b450afb9775f crates/core/rusteda-workspace/src/rlib.rs +c466d7676b5fc1e313daa19e5a454ac65a25eb9953fcfb2cb8bf2aac7a46b96b crates/core/rusteda-workspace/src/rlib/decode.rs 24bcb0c830ad753abe41c2b8189d879b41875c5e8b466f5f0b7e1b1e71af3c0c crates/core/rusteda-workspace/src/schema.rs -6e7e410ed75845aa5083f97ee6bbfe40c3d4c0325a7c2ee03e3806249f62e474 crates/core/rusteda-workspace/src/sta.rs -fa3523df54bc074884e0d9efe157729c6dc0fb8c4b4c75b3069b3bf1e63714e0 crates/core/rusteda-workspace/src/sta/build.rs -2774b24e829f1ad907cbc8da25ef96b535414fc09080e46cfdf2422d49bc14db crates/core/rusteda-workspace/src/sta/layout.rs -1a0a3fd8bf05b118459516898432c57638392b3df14ccc524d0ffad0c37b3571 crates/core/rusteda-workspace/src/sta/read.rs -f861b6620b56a36dd79d7c87bb32c5b36b147701258b4b39286faa0675da1f3c crates/core/rusteda-workspace/src/sta/tests.rs +70b907b98c7be5789d02d4e29fd448fa269b7c28355aa0c62883d67b50ac75b6 crates/core/rusteda-workspace/src/sta.rs +4f2fc87e9fc4eb6d7ca76b2c93e73661e0d3d3c0bcbfa5a0499b5b4a64dced39 crates/core/rusteda-workspace/src/sta/build.rs +6d93825bb8a81db1069a86f07752338d90b61daed728b9703f5bf894b5d47350 crates/core/rusteda-workspace/src/sta/layout.rs +ffe7a125bd760bcbf56bb87aaceeb882c442bdb5648dbefdbc7d79f1b2510f9e crates/core/rusteda-workspace/src/sta/read.rs +b592f0fbecc2df56eb66fbbd0c9b1a8206dcb9d9aacf90b5027bccc264573b24 crates/core/rusteda-workspace/src/sta/tests.rs 553a7b8c9977a509a1e8f1b8112958c1413b1aedfc38d75b89e5482fda5398fe crates/core/rusteda-workspace/src/testutil.rs 2b004f1cc37b9f7c211f80a61a858027464ac6db767776c5cd7a3ecf5798dcc9 crates/core/rusteda-workspace/src/text.rs 8f92e7a0d43f500a8a4a5bd8e9a048f6ede75a80055d896d16db31c67252a7ae crates/core/rusteda-workspace/src/txn.rs @@ -100,23 +109,23 @@ d503ea0056d79b0db7ad1e24a4377896f8f1b3aecd670d45ce534d487656fde0 crates/core/ru 1d6723b4f8ec72bd6472e1a94206704909e036f6df7b7ad1a7fac55a1a12f9bf crates/core/rusteda-workspace/tests/failures.rs a754c1b3554b832bd8495ec07d69f9161afeac61122d391dc0f2dd8de6ed0dd2 crates/core/rusteda-workspace/tests/netlist.rs 8b40861d44c89642fb682f1aeeb0b9d7e1cc76e25ebf2aa635504c2d1f0b3a66 crates/core/rusteda-workspace/tests/rbin.rs -19d34e546b62ef22b6371d8625554f578e1f39ae77c002a28d9eb68bd42a0148 crates/core/rusteda-workspace/tests/rlib.rs +3bab0255e8308c0b7d0810b9e8f35049f1d1280c3c3a4a26decfbea80bf1c54c crates/core/rusteda-workspace/tests/rlib.rs d64d336d29ca749350e9775b9899b29ab064e7cea7f0a9858815c32e89c31581 crates/core/rusteda-workspace/tests/source_rules.rs 24c8298bb8433dd098fe41b78b142fda45449a16c55fe2f7c01bb863ccb313df crates/core/rusteda-workspace/tests/sta.rs d06add6705776a0d74651eea27529e134bf88470f07193fb6a1548db9604c542 crates/core/rusteda-workspace/tests/workspace.rs 83a3da240cf54d2be9b46b8bd9e3921e80917f3152052e2f990c38fe98b0fb02 crates/core/rusteda-workspace/tests/workspace_ops.rs -e493c2d5b5789d9617cff1cdf67d1071bfb366cd43239f7c3224225ff9a8ac3f crates/formats/rusteda-formats/Cargo.toml -c8f40177150ddd81c8e82b0452bd9fc3b97b0cdbd04d1f1b17cac6c573782da3 crates/formats/rusteda-formats/README.md +4b40c991593a3c4c4269be507d6c764ee165b5673141de55aca19e7283f23be3 crates/formats/rusteda-formats/Cargo.toml +08059212af715e9d9173d973b88dac077f79c2875d41f3eb6450a403812688da crates/formats/rusteda-formats/README.md e236c14cc2a221adf9709a9f1958d96185c8fe4202b4a26c9320d8b7e7101230 crates/formats/rusteda-formats/src/aiger/mod.rs e413934691b09d2e9070e531501cbc013c35cf91ea85ca043a74b4c6061e044e crates/formats/rusteda-formats/src/aiger/read.rs 88ebb6f4e8472ccb15a510ece234d7140414c497d18cc87214a51c1b45294140 crates/formats/rusteda-formats/src/blif/flatten.rs 8d4e6903e2f716112dbe1f133e1af9c74b1e2d699af84ff1cdc502d0f81875c9 crates/formats/rusteda-formats/src/blif/lex.rs 276a5f22d23e15095a91e977132a35e4db6360af236dcbf850adcb726bd199f0 crates/formats/rusteda-formats/src/blif/mod.rs efd3308e78fb8a073d0feee934f6ed7cd76c523b39a8edf835d68ff03f8f8046 crates/formats/rusteda-formats/src/blif/parse.rs -a3e52ce1bdb9118393db79fd8b348927b87a07a81c111dbc680d47d7f91e9fa2 crates/formats/rusteda-formats/src/lib.rs -95869a8e15f4af9069fee8d32cf02aba781fcd424b720276f1a8e203c03525c4 crates/formats/rusteda-formats/src/liberty/build.rs +769899b0281a009b88f7ede9303dc3914e69df42eb4d5d3deb8c6f332db0c86e crates/formats/rusteda-formats/src/lib.rs +0d4ec176097b885c712d4236c810bf1b050953e94d12c6c55be46beb0dcfbf8b crates/formats/rusteda-formats/src/liberty/build.rs 0eb7b2337d1addf83ae0fc86781a989f1892231ebc1bf33ac9cfe99ba538b21f crates/formats/rusteda-formats/src/liberty/bus.rs -5859e0e5f74e627aa2396a4fda0bb71138741f5a7097f4d6811054a6f211c4cc crates/formats/rusteda-formats/src/liberty/classify.rs +9fd7cd12f563ff4db8f0ae31645e086e4036c1a2f2517e950d313b7cb3703379 crates/formats/rusteda-formats/src/liberty/classify.rs 42e0f8d8a3eb27c199d57a6d33aab0e478ecb537464aa9502174dabd8bef8d29 crates/formats/rusteda-formats/src/liberty/diags.rs b271d898c1261bfc3361de2586cd7d84b8a562af1dde8f73640c4cc4fb457365 crates/formats/rusteda-formats/src/liberty/func.rs 5fec05b88c51544fd42a1da98b26da92b0c7c59a6e1d21a6227623aa9b48e440 crates/formats/rusteda-formats/src/liberty/mod.rs @@ -134,10 +143,14 @@ cf5ad89fc9c0dba20d8a556790416e25e4aaba44b67313008fb02df8ad69516a crates/formats d2bf014687a3f55ca75636ff99fc82d77604e150a819bb1c42483f452434dd8d crates/formats/rusteda-formats/src/sdc/expr.rs ffab5ac7baa3f18dc5535f63469f50d662e15e3185a81faab2b1196bb0fb1ca5 crates/formats/rusteda-formats/src/sdc/filter.rs e898ddec920cbfdd6a0ef87e4bb5eee1a7d8923387b2bd15571795a05c7223d7 crates/formats/rusteda-formats/src/sdc/obj.rs -7796aefc85429572d84b3263fece6e0da5570f1693bc9fc459b217ab55d9f444 crates/formats/rusteda-formats/src/sdc/schema.rs -69b6d5194359214762dff29b1c3aa8512c5e34cc91aadb8729f702317fe5113b crates/formats/rusteda-formats/src/sdc/schema/build.rs -cfafd192b297f45029beef1725aa3924b2c0cf57911917c017c0c9ff042d4881 crates/formats/rusteda-formats/src/sdc/schema/parse.rs +ce08ff6bf08a47a3bd41ad6eb5f0bfc310649e1b5d37c9543151a80ab24c1ca6 crates/formats/rusteda-formats/src/sdc/schema.rs +321f503ccc18a88ef4c0abb364180b66afdb9290618a09b38cae8a3a298edecb crates/formats/rusteda-formats/src/sdc/schema/build.rs +58c0b5c0642cbd5dcd12d0c5b44ae0e8ec823a6d6b3385ea378de37037e30c18 crates/formats/rusteda-formats/src/sdc/schema/parse.rs 74857186412d2d07e254ac3803ecd3c82523a3ec991cb01af60ccee8806b32a6 crates/formats/rusteda-formats/src/sdc/write.rs +6ed705e9cefc6dddb6b9b19f5b4376e07e06428adbcbff660cb8269e3bd75b4c crates/formats/rusteda-formats/src/spef/mod.rs +53f68f80a989d029200d1d4da87043c1fd77eeec3eac5cf743b690493a2abdc9 crates/formats/rusteda-formats/src/spef/parse.rs +b40781307487f19857ee2ce57497c62d2445476d4039b2d947f931f48ff18db9 crates/formats/rusteda-formats/src/spef/source.rs +94366656884cf628606d51404250dc09290cc21e53392d3771f779caca073af1 crates/formats/rusteda-formats/src/spef/tests.rs 1e86f74e5cd3c0593767c1500e90ba87f3fd27bb674de0d4659589178fb9c80f crates/formats/rusteda-formats/src/verilog/ast.rs 1296306f7f5d48fd13493a59b9838a37fbe7bbb5640eba8f3c4e43bf9d9f07c9 crates/formats/rusteda-formats/src/verilog/ast/stmt.rs 8a97031f310caf737d5d32851a21c98af0ef9c3fb609bda66398316e303b74e0 crates/formats/rusteda-formats/src/verilog/diag.rs @@ -163,9 +176,9 @@ f979fe59be188760093f42cb185ed29932c98c10162e43b593ae7e4d1c725cf3 crates/formats 990e16c9a084cc5ca006e863e31c079905bd1098ce44081be801f77a31e5aee2 crates/formats/rusteda-formats/src/verilog/write.rs 0635429a8f3e5af86a95bc8f6a2bc5b2ec83f2ead2f198997c7ac020aa5a50b0 crates/formats/rusteda-formats/tests/aiger_reader.rs 1707192895f2c84dcbb66cb0be5b4346944c73dd7151bcb84e0cf40769f4ac80 crates/formats/rusteda-formats/tests/blif_reader.rs -80dd195f1528341a482fda18e413d158e15443b00afd8d31eb50c473a188f51c crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs +911443cc05851abe067193e758547aad86bd61925329c7803e1e0eb72df4d7fa crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs 5adbf2e11b217a54388dba09405acfc1f9061b8c250fcf09b8acf384656b4b6c crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs -889d27590d171f73539fe48c34d130a4d591a9971d8f0269d8275fa7b59a5f0b crates/formats/rusteda-formats/tests/sdc_commands.rs +8287f0f3a5e4948cefe16a5952813dee1637fa70b3b666f8321ca67901c8cc12 crates/formats/rusteda-formats/tests/sdc_commands.rs 19a2c7cc20ef1cb4487737479c9ce00618c100d4160ea0feb5afe654a6b44288 crates/formats/rusteda-formats/tests/sdc_fuzz.rs 602e8104c784efa08622306342de3bc45af9a5f1997b5e983842a5fbbc67ce57 crates/formats/rusteda-formats/tests/sdc_roundtrip.rs 70a77f6917823fdc3e9f5cc467dc5574ea2ebf8cdc9db29ba80757ac3882de1e crates/formats/rusteda-formats/tests/sdc_subset.rs @@ -174,18 +187,19 @@ f979fe59be188760093f42cb185ed29932c98c10162e43b593ae7e4d1c725cf3 crates/formats 3943f5ee52b162245f2ca816026a185f000fc72e083db03ec161ebdb6e084d9c docs/specs/blif/README.md 511570acbb903cfdea120d032c7ddd392473048b751d0820f1c9dc8c76a66328 docs/specs/compute/README.md 30337fb76f174838625350731b4d445ab693f2a1171fcd2003be96c7d98f5c67 docs/specs/compute/decisions.md -c46de90f41ec96ff00f5366c8b3916cca2090f0ebb082b74743cb899c0551b84 docs/specs/compute/timing.md -b6c465c294191ba850621bc8aece7a291b02ad6dea37ebe1a292afa26cf92aab docs/specs/core/README.md +73e58da3c98ea28123fdb1ca49ca75cd8be0103e489a82f186c48682fae9afc9 docs/specs/compute/timing.md +e4729252fe9b063d213ef331fe71ac376e5ca5a03614a47f5523e380ff57f80f docs/specs/core/README.md c4978424e18f357b945f0b1d95aa78f27ff845041a85a6c51e6bd277e9fa00ed docs/specs/core/netlist.md -3788d980536c44ec0752b68a263972874b43452fff32850904fe121a3b1aa080 docs/specs/liberty/liberty.md -11d299ba1cc26e5e690fdfe4ed8d1f2eb0d6c64682be895fa5480672ae7b549e docs/specs/rusteda-cli/README.md -23196b4d3a18c3acc22f4143ef00ab0efcc7bbffb9dd837084a121576a733893 docs/specs/sdc/sdc.md +e3af311a4294c43c20e6dc05f1bbc626d889e2fede3da1e3167bbbec2063933c docs/specs/liberty/liberty.md +49c3eb1216efba7784fc8cfb91d45f351aae048b4ba66c2edfec6d6bde962d57 docs/specs/rusteda-cli/README.md +26db6bf7b6b7e9ce585637d4332435ecda802a8e20598e90c2f5be60a2254c10 docs/specs/rusteda-cli/spef.md +eabe18692283630b96dcb7356e3fbbeed477d96e1cbf1c980bd5d701829ce05e docs/specs/sdc/sdc.md 87576e9f297700eb47304ebf63785ea69bc447a0257d1625a16e5f85bb460a99 docs/specs/verilog/netlist.md ceb5285526dcbe059fd6eaa4b4e22ba6c7763427b0be43fe391a154df403d9f9 docs/specs/verilog/verilog.md -64604de62b2c851564c98524a9103c6ea62d5e60e231978b8449a7a1f7ddf697 docs/specs/workspace/README.md +2ce39c6a5cec38209959b4368e2cf8e5f1b588f024c41afc2b5db707604a4978 docs/specs/workspace/README.md fc0596bd1723b6f5869e72043bee3be758eb6b0fdbd8f5f497058192b80b6f1c docs/specs/workspace/netlist.md -7e6589a1b998e4dae29fedf3b7c9f8b00a0ab374bc479f2cd52496e9d33df241 docs/specs/workspace/rlib.md -7452fa5d4de8b3a6376bbcb64884e23af72efc3a2080a47ad7e53b51ad061b8a docs/specs/workspace/sta.md +0bba1a15ef3cc168a5bb8112c71ef95b79478d753407b11073bbef50e863ce40 docs/specs/workspace/rlib.md +4f525e719cad0e82abe5a5415785225268d2797fb86a9a81baf9b2dd22c0d100 docs/specs/workspace/sta.md 0bd84015505254f6fe4511730f6c6bf183606391c0585eecfbd612790583384c verification/pdks.toml 927c465c7fa58d77774e143d82acc94dbcb1bb0e4507d36f1e3b7b7047e1e014 xtask/src/pdk/fetch.rs dd1e99e5e5ff4464804dc1ae2887eb898dec7d64b0657e72dc72407d9c987599 xtask/src/pdk/json.rs From 6c82852ba0d24fbfbd13e76bd86a64ad9a38d9a5 Mon Sep 17 00:00:00 2001 From: Chang Gao <4209517+gaochangw@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:58:38 +0200 Subject: [PATCH 2/9] Keep shared text tool-neutral and make the full netlist reader's budget an option The shared netlist import test named a third-party program in its doc comment, function and temporary directory; it now describes the netlist it imports (concatenations, part-selects in both directions, sized constants), and the doc comment of the unresolved-cell test is attached to that test again. Two shared comments that named the timing tool's repository refer to it by its role. The 64 MiB budget of the full Verilog reader, for a netlist that the streaming reader declines, is the option `rusteda import --max-netlist-ast-mib` (default 64, requires --netlist, RCLI-REQ-0024); its V0300 error names the budget and the option. Measured: the full reader peaks at about 42 times the file size (1.07 GiB for a 26 MiB netlist, 95 MiB with the streaming reader), noted in the spec. Default behaviour is unchanged: against the pinned c5ec23c producer and a rusteda built from c5ec23c, 283 synthesis/flow cases are identical (netlists, reports, exit codes, stderr; SERV/PicoRV32 flows on sky130 and gf180 7t) and the native checker finds 282/282 equivalent; `rusteda import --netlist` of the 16 corpus netlists and of a netlist the streaming reader declines gives identical tables. Gates: workspace tests 838/0/19, all-feature clippy, fmt, layout, coverage 620/0, fixtures, shared check/diff (209 files, identical manifest in both tool repositories), verify synth 85/0, verify pdk --require 279/279, deny, reuse, markdown/strict docs/site checks, benchmark Python tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + crates/apps/rusteda-cli/src/import.rs | 11 +- crates/apps/rusteda-cli/src/import_netlist.rs | 20 ++- crates/apps/rusteda-cli/src/lib.rs | 5 + .../apps/rusteda-cli/tests/import_netlist.rs | 120 ++++++++++++++++-- crates/core/rusteda-workspace/src/error.rs | 3 +- crates/core/rusteda-workspace/src/text.rs | 2 +- docs/specs/rusteda-cli/README.md | 2 + docs/verification/requirements.md | 5 +- verification/shared.sha256 | 14 +- 10 files changed, 158 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bae01c..6992283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,9 @@ The format follows the "Keep a Changelog" convention; versions follow semantic v that reads it reports whether it is checked". - Debug builds of `rusteda import --netlist` check the fast flat reader against the full reader (structural or extended) on small inputs. +- The 64 MiB budget of the full netlist reader, for a netlist that the streaming reader cannot + take, is the option `rusteda import --max-netlist-ast-mib` (default 64, RCLI-REQ-0024); its + `V0300` error names the budget and the option. - `rusteda-formats` lists SPEF among its formats; the external-simulator oracle tests of `rusteda-compute` (ignored by default) read `RUSTEDA_SPICE_SIMULATOR`, `RUSTEDA_RC_ORACLE_WORKSPACE` and `RUSTEDA_RC_WIRE_WORKSPACE`. diff --git a/crates/apps/rusteda-cli/src/import.rs b/crates/apps/rusteda-cli/src/import.rs index 63adf4a..00df24b 100644 --- a/crates/apps/rusteda-cli/src/import.rs +++ b/crates/apps/rusteda-cli/src/import.rs @@ -24,6 +24,8 @@ pub(crate) struct ImportCommand { pub libs: Vec<(PathBuf, String)>, pub rtl: Option, pub netlist: Option, + /// Budget of the full netlist reader in MiB (RCLI-REQ-0024). + pub max_netlist_ast_mib: u64, pub sdc_files: Vec, pub sdc_env: Vec, /// `(--spef, --spef-corner)` pairs in command-line order (RCLI-REQ-0075). @@ -75,7 +77,14 @@ pub(crate) fn run_import( import_rtl(&mut txn, rtl_args, &mut metrics, diags)?; } if let Some(netlist_path) = &cmd.netlist { - import_netlist(ws, &mut txn, netlist_path, &mut metrics, diags)?; + import_netlist( + ws, + &mut txn, + netlist_path, + cmd.max_netlist_ast_mib, + &mut metrics, + diags, + )?; } if !cmd.sdc_files.is_empty() { import_sdc( diff --git a/crates/apps/rusteda-cli/src/import_netlist.rs b/crates/apps/rusteda-cli/src/import_netlist.rs index 3c78ed4..ced8416 100644 --- a/crates/apps/rusteda-cli/src/import_netlist.rs +++ b/crates/apps/rusteda-cli/src/import_netlist.rs @@ -342,11 +342,14 @@ fn read_netlist_ast( } } -/// Imports a structural Verilog netlist into `txn` (`RCLI-REQ-0011`, `RCLI-REQ-0019`). +/// Imports a structural Verilog netlist into `txn` (`RCLI-REQ-0011`, `RCLI-REQ-0019`); a file +/// that the streaming reader declines is read by the full reader only up to `max_ast_mib` MiB +/// (`RCLI-REQ-0024`). pub(crate) fn import_netlist( ws: &Workspace, txn: &mut Txn, file: &Path, + max_ast_mib: u64, metrics: &mut JsonMap, diags: &mut Vec, ) -> Result<(), Error> { @@ -397,10 +400,17 @@ pub(crate) fn import_netlist( fast } else { // Whole-file token/AST expansion is unsuitable for multi-million-cell - // flat inputs. Keep unsupported large syntax explicit and bounded. - if text.len() > 64 * 1024 * 1024 { - let msg = "large flat netlist uses syntax unsupported by the bounded streaming importer (64 MiB AST fallback budget)"; - diags.push(Diagnostic::new(Severity::Error, DiagCode::of(C_UNSUP), msg)); + // flat inputs. Keep unsupported large syntax explicit and bounded (RCLI-REQ-0024). + if text.len() as u64 > max_ast_mib.saturating_mul(1 << 20) { + let msg = format!( + "large flat netlist uses syntax unsupported by the bounded streaming importer \ + ({max_ast_mib} MiB AST fallback budget, --max-netlist-ast-mib)" + ); + diags.push(Diagnostic::new( + Severity::Error, + DiagCode::of(C_UNSUP), + msg.clone(), + )); return Err(Error::input_rejected(msg)); } let g = read_netlist_ast(&text, &source_str, &catalog, diags)?; diff --git a/crates/apps/rusteda-cli/src/lib.rs b/crates/apps/rusteda-cli/src/lib.rs index 636d2b7..5403711 100644 --- a/crates/apps/rusteda-cli/src/lib.rs +++ b/crates/apps/rusteda-cli/src/lib.rs @@ -187,6 +187,10 @@ pub struct ImportArgs { /// Structural gate-level Verilog netlist file. #[arg(long = "netlist", value_name = "FILE")] pub netlist: Option, + /// Largest `--netlist` file, in MiB, that the full Verilog reader reads when the streaming + /// reader declines it (RCLI-REQ-0024). + #[arg(long, default_value_t = 64, requires = "netlist", value_name = "MIB")] + pub max_netlist_ast_mib: u64, /// Synopsys Design Constraints (SDC) files. #[arg(long = "sdc", num_args = 1.., action = clap::ArgAction::Append, value_name = "FILE")] pub sdc: Vec, @@ -773,6 +777,7 @@ pub fn run(args: Vec) -> ExitCode { libs, rtl: rtl_args, netlist: imp.netlist.clone(), + max_netlist_ast_mib: imp.max_netlist_ast_mib, sdc_files: imp.sdc.clone(), sdc_env: imp.sdc_env.clone(), spefs: imp diff --git a/crates/apps/rusteda-cli/tests/import_netlist.rs b/crates/apps/rusteda-cli/tests/import_netlist.rs index 9c7f52a..69ba44d 100644 --- a/crates/apps/rusteda-cli/tests/import_netlist.rs +++ b/crates/apps/rusteda-cli/tests/import_netlist.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 RustEDA contributors -//! Integration tests for `rusteda import --netlist` (RCLI-REQ-0011, RCLI-REQ-0019). +//! Integration tests for `rusteda import --netlist` (RCLI-REQ-0011, RCLI-REQ-0019, +//! RCLI-REQ-0024). use std::env; use std::fs; @@ -314,16 +315,13 @@ endmodule let _ = fs::remove_dir_all(&root); } -/// RCLI-REQ-0019: Unresolved cell, unresolved pin, and multi-corner pin-direction conflict must -/// fail with exit 1 (`INPUT_REJECTED`), a `V0302` diagnostic, and `file:line:col` location. -/// RCLI-REQ-0019: a Yosys-style netlist with concatenations, part-selects in both directions, -/// sized constants and escaped buses in `assign` statements imports bit for bit as the reader -/// specifies (most significant bit first, the bits of a part-select from its left to its right -/// index); a width mismatch is still rejected. Debug builds of `rusteda` also check the fast reader -/// against the full reader on this input. +/// RCLI-REQ-0019: a flat netlist with concatenations, part-selects in both directions, sized +/// constants and escaped buses in `assign` statements imports bit for bit as the reader specifies +/// (most significant bit first, the bits of a part-select from its left to its right index); a +/// width mismatch is still rejected. #[test] -fn import_yosys_style_concatenations_part_selects_and_sized_constants() { - let root = temp_dir("yosys-assigns"); +fn import_flat_concatenations_part_selects_and_sized_constants() { + let root = temp_dir("concat-assigns"); let ws_path = root.join("ws"); let ws_s = ws_path.to_str().unwrap(); let (code, _, _) = run_json(&["init", "-w", ws_s]); @@ -442,6 +440,108 @@ endmodule let _ = fs::remove_dir_all(&root); } +/// RCLI-REQ-0024: a netlist that the streaming reader declines (here a part-select against the +/// declared direction) is read by the full reader only within `--max-netlist-ast-mib`; beyond it +/// the import is rejected with a `V0300` error that names the budget and the option, and HEAD +/// does not move. A netlist that the streaming reader takes ignores the budget. +#[test] +fn full_reader_budget_bounds_netlists_the_streaming_reader_declines() { + let root = temp_dir("ast-budget"); + let ws_path = root.join("ws"); + let ws_s = ws_path.to_str().unwrap(); + assert_eq!(run_json(&["init", "-w", ws_s]).0, 0); + let lib_file = root.join("synth_pg.lib"); + fs::write(&lib_file, SYNTH_PG_LIB).unwrap(); + let (code, env, _) = run_json(&[ + "import", + "-w", + ws_s, + "--base-rev", + "rev0", + "--lib", + lib_file.to_str().unwrap(), + ]); + assert_eq!(code, 0, "library import failed: {env}"); + + let netlist = |select: &str| { + format!( + "module top(a, y, q);\n input [3:0] a;\n output y;\n output [2:0] q;\n \ + INVX1 i0 (.A(a[0]), .Y(y));\n assign q = {select};\nendmodule\n" + ) + }; + let declined = root.join("declined.v"); + fs::write(&declined, netlist("a[1:3]")).unwrap(); + let streamed = root.join("streamed.v"); + fs::write(&streamed, netlist("a[3:1]")).unwrap(); + let import = |file: &Path, extra: &[&str]| { + let mut args = vec![ + "import", + "-w", + ws_s, + "--base-rev", + "rev1", + "--netlist", + file.to_str().unwrap(), + ]; + args.extend_from_slice(extra); + run_json(&args) + }; + + let (code, env, _) = import(&declined, &["--max-netlist-ast-mib", "0"]); + assert_eq!(code, 1, "over the budget: {env}"); + assert_eq!(env["error"]["code"], "INPUT_REJECTED"); + let d0 = &env["diagnostics"][0]; + assert_eq!(d0["code"], "V0300"); + let msg = d0["message"].as_str().unwrap(); + assert!( + msg.contains("0 MiB") && msg.contains("--max-netlist-ast-mib"), + "{msg}" + ); + + // The rejection left HEAD at rev1; the streaming reader ignores the budget. + assert_eq!(run_json(&["show", "-w", ws_s]).1["rev"], "rev1"); + let (code, env, _) = import(&streamed, &["--max-netlist-ast-mib", "0"]); + assert_eq!(code, 0, "streamed netlist: {env}"); + assert_eq!(env["metrics"]["assigns"], 3); + assert_eq!(run_json(&["show", "-w", ws_s]).1["rev"], "rev2"); + + // The default budget (64 MiB) reads the declined netlist with the full reader. + let ws2 = root.join("ws2"); + let ws2_s = ws2.to_str().unwrap(); + assert_eq!(run_json(&["init", "-w", ws2_s]).0, 0); + let (code, env, _) = run_json(&[ + "import", + "-w", + ws2_s, + "--base-rev", + "rev0", + "--lib", + lib_file.to_str().unwrap(), + "--netlist", + declined.to_str().unwrap(), + ]); + assert_eq!(code, 0, "within the default budget: {env}"); + assert_eq!(env["metrics"]["assigns"], 3); + + // The budget only applies to a netlist import. + let (code, env, _) = run_json(&[ + "import", + "-w", + ws2_s, + "--base-rev", + "rev1", + "--lib", + lib_file.to_str().unwrap(), + "--max-netlist-ast-mib", + "1", + ]); + assert_eq!(code, 1, "a budget without --netlist: {env}"); + assert_eq!(env["error"]["code"], "USAGE"); + let _ = fs::remove_dir_all(&root); +} + +/// RCLI-REQ-0019: Unresolved cell, unresolved pin, and multi-corner pin-direction conflict must +/// fail with exit 1 (`INPUT_REJECTED`), a `V0302` diagnostic, and `file:line:col` location. #[test] fn unresolved_cell_unresolved_pin_and_corner_direction_conflict() { let root = temp_dir("netlist-errors"); diff --git a/crates/core/rusteda-workspace/src/error.rs b/crates/core/rusteda-workspace/src/error.rs index 628758b..0784903 100644 --- a/crates/core/rusteda-workspace/src/error.rs +++ b/crates/core/rusteda-workspace/src/error.rs @@ -886,7 +886,8 @@ mod tests { ErrorCode::RevisionConflict, "JSON keeps the envelope code" ); - // The application's errors take its code (RustSTA: A0003 command line, A0002 internal). + // The application's errors take its code (the timing tool's: A0003 command line, A0002 + // internal). for (error, code) in [ (Error::usage("unknown flag --x"), "A0003"), (Error::internal("bug"), "A0002"), diff --git a/crates/core/rusteda-workspace/src/text.rs b/crates/core/rusteda-workspace/src/text.rs index 920809b..1764f8f 100644 --- a/crates/core/rusteda-workspace/src/text.rs +++ b/crates/core/rusteda-workspace/src/text.rs @@ -170,7 +170,7 @@ mod tests { ); } - /// The application code of the tests: RustSTA's code of an invalid command line. + /// The application code of the tests: the timing tool's code of an invalid command line. const APP: DiagCode = DiagCode::of("A0003"); /// The first line of a diagnostic as the shared diagnostic reader takes it (JOB-REQ-0007): diff --git a/docs/specs/rusteda-cli/README.md b/docs/specs/rusteda-cli/README.md index 35b4de9..ca84193 100644 --- a/docs/specs/rusteda-cli/README.md +++ b/docs/specs/rusteda-cli/README.md @@ -36,6 +36,8 @@ **RCLI-REQ-0019** `rusteda import --netlist ` reads a structural gate-level Verilog netlist, resolves every instantiated library cell and pin against the revision's libraries across all corners (rejecting unknown cells/pins and cross-corner pin-direction conflicts with `V`-series diagnostics and `file:line:col` locations), drops `pg_pin` connections and supply ports with one aggregated `V`-series warning, encodes the frozen netlist into `netlist.rbin` (`NETLIST` v1) with an empty upstream map, and records the source in `meta.json` `sources`. +**RCLI-REQ-0024** `rusteda import --netlist ` reads a flat netlist with a bounded streaming reader when it can. A netlist that the streaming reader declines is read with the full Verilog reader only when the file is at most `--max-netlist-ast-mib ` mebibytes (default 64); a larger one is rejected before the full reader runs (`INPUT_REJECTED`, a `V0300` error that names the budget and the option), and HEAD does not move. The budget does not apply to a netlist that the streaming reader takes, and the option requires `--netlist`. The full reader holds the whole syntax tree in memory: its peak resident memory was measured at about 42 times the file size (1.07 GiB for a 26 MiB flat netlist, against 95 MiB with the streaming reader), so the default bounds it near 2.6 GiB. Estimate the memory before raising the budget. + ## Queries, revert, export, and completions **RCLI-REQ-0015** `rusteda log -w [--limit ]` lists committed revisions newest-first with `rev`, `parent`, `command`, `message`, `tables`, and `created_unix_ms`. diff --git a/docs/verification/requirements.md b/docs/verification/requirements.md index 8ed9f77..05482ac 100644 --- a/docs/verification/requirements.md +++ b/docs/verification/requirements.md @@ -281,6 +281,7 @@ more than 200 IDs is not expanded: only its first number counts as cited. | RCLI-REQ-0021 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/import_logic.rs
crates/apps/rusteda-cli/src/import_rtl.rs
crates/apps/rusteda-cli/tests/import_logic.rs | | RCLI-REQ-0022 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/import_logic.rs
crates/apps/rusteda-cli/src/import_rtl.rs
crates/apps/rusteda-cli/tests/import_logic.rs
crates/apps/rustsys-cli/src/ws/mod.rs
crates/apps/rustsys-cli/tests/netlist_sources.rs | | RCLI-REQ-0023 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/query.rs
crates/apps/rusteda-cli/tests/show_drv.rs | +| RCLI-REQ-0024 | docs/specs/rusteda-cli/README.md | crates/apps/rusteda-cli/src/import.rs
crates/apps/rusteda-cli/src/import_netlist.rs
crates/apps/rusteda-cli/src/lib.rs
crates/apps/rusteda-cli/tests/import_netlist.rs | | RCLI-REQ-0070 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import_spef.rs
crates/apps/rusteda-cli/tests/import_spef.rs | | RCLI-REQ-0071 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import_spef.rs
crates/formats/rusteda-formats/src/spef/tests.rs | | RCLI-REQ-0072 | docs/specs/rusteda-cli/spef.md | crates/apps/rusteda-cli/src/import_spef.rs
crates/formats/rusteda-formats/src/spef/tests.rs | @@ -642,10 +643,10 @@ more than 200 IDs is not expanded: only its first number counts as cited. | JOB | 7 | 0 | | LIBERTY | 49 | 0 | | PROTO | 22 | 0 | -| RCLI | 29 | 0 | +| RCLI | 30 | 0 | | SDC | 56 | 0 | | SYNTH | 158 | 0 | | VERILOG | 28 | 0 | | WS | 100 | 0 | -619 requirements, 0 without a citing test or source. +620 requirements, 0 without a citing test or source. diff --git a/verification/shared.sha256 b/verification/shared.sha256 index 8a5cfb2..1c8c2cc 100644 --- a/verification/shared.sha256 +++ b/verification/shared.sha256 @@ -1,24 +1,24 @@ 5756bd94c68cbb105810ebbe3f03ba9c7ad8d55abb0dfd35eda20658f3dd7c0b crates/apps/rusteda-cli/Cargo.toml d0b7792b6445ce305ff02cf71ef038c302c04408cd7aaf188225560ef362b8c3 crates/apps/rusteda-cli/src/export.rs d0deca5b6b96c19c919b2116babbb9eeacf2cb2b17cb052ad18258c633251640 crates/apps/rusteda-cli/src/export/frozen.rs -b6b8d968705d95dbaff592bafcfa4c916943e53ad631d9bdcca3e44095b5b9d0 crates/apps/rusteda-cli/src/import.rs +78beb61d7bac11ed7e2e90b90480490aecac768fcec2a9f35929d2df4ddc84e9 crates/apps/rusteda-cli/src/import.rs 792a5fc01b3a785d5c7c4272088a467e5156b742e86be3e3da412e7ad7c13586 crates/apps/rusteda-cli/src/import_lib.rs 18c0a59531f4ea3e9541378b733b14a514f451e8b11ec9d78e807b354bae50e8 crates/apps/rusteda-cli/src/import_logic.rs -39863a388c7e8e14a37dff592e8367ff11df67faef7269e0261e0ef9d5806915 crates/apps/rusteda-cli/src/import_netlist.rs +b31b4dd74090872f4f941699831b7a988602945e66eab82d75af7becb04fb711 crates/apps/rusteda-cli/src/import_netlist.rs 91214f50dbb02a9c23f082ff4c5a46920328645190689f42a9dab648106ca15a crates/apps/rusteda-cli/src/import_netlist/ast.rs 8826e0a48e9b7a810c06d38e9d92ad1dd7e9334a7ef4c9276aed2e40d3b26ab3 crates/apps/rusteda-cli/src/import_netlist/fast.rs 0448834ee05970132181546c3c17734ace146583b0877a0bc1cba3b926150e66 crates/apps/rusteda-cli/src/import_rtl.rs d4b9920a5f32601f4c89c34f6fba20194df2282bad5379f081a1fc8e4933ca1a crates/apps/rusteda-cli/src/import_sdc.rs 0948b8820e18ec8096315f824b54c733caaa823426e0fd79228c1649f33147c9 crates/apps/rusteda-cli/src/import_spef.rs aadc389f6f4d5fb6d4079e24219849f3f921d558048924a010026dc7480f6042 crates/apps/rusteda-cli/src/init.rs -f81704f4ed2d27a3341335fb8495de4e9a9c5aeff299d30783f88fc2db02cbf0 crates/apps/rusteda-cli/src/lib.rs +44a133f5290ac18b2e91c5557a55065b1e70ef86ffd6198122a8f48b0ec8ece2 crates/apps/rusteda-cli/src/lib.rs 6eeebde00bb9649fa5328ba95a1947360c3aab13739b09724548a953c51b0bc0 crates/apps/rusteda-cli/src/main.rs 029115bf32b80519341a6d86c0d3c0480152eb85f5357cd0c1814bed68585eca crates/apps/rusteda-cli/src/query.rs 3c9fb8ef9a52ac80e5047b9c0fdd7b1478a584967c570e07f6b8ca2375b86545 crates/apps/rusteda-cli/src/revert.rs ee8f008db17b19b09e7ed89c6abcea2501dd8b151229853b043e80d437a53dd7 crates/apps/rusteda-cli/tests/cli_contract.rs 4fa54437edac9adc0aed14caf75437c284fe4e858550bb679cc82a85e3607b69 crates/apps/rusteda-cli/tests/export_netlist.rs d9ee3c935b48f4d4efbf18855117cc85dd7db130e20c6b92c01787d1455cede2 crates/apps/rusteda-cli/tests/import_logic.rs -9c3d6bcb3c3a0d5206d27a0116eb5ee9c63cbdb3f6e6b43ba4355898b70e1a66 crates/apps/rusteda-cli/tests/import_netlist.rs +1f7a933e276981252a213381e9cf665973236af4f50f7a3091bca22cbfbb4a5b crates/apps/rusteda-cli/tests/import_netlist.rs e85107183ca7a35a1258cec3acf324756fd92530f0e5ee3acc9b1fe80c031e78 crates/apps/rusteda-cli/tests/import_sdc.rs 2207fc735b9a5a9e7b308aeca7ab3fde1a663943839f6ef8cf5b71c9cda7ce6e crates/apps/rusteda-cli/tests/import_spef.rs 627e3cd03ae976806be3ef71a82c81f8f8243b9b5fe4458424eaadf4f9015bde crates/apps/rusteda-cli/tests/show_drv.rs @@ -79,7 +79,7 @@ a121cb78f1d48070d899475dc72bf8c02b20ad862f47b211da5643513ed5eeb2 crates/core/ru 7f9bbed6690c5c70db5b1eb4ba66c3b9bc3a720914d8bd39e5c3e6e16cfc0bc4 crates/core/rusteda-workspace/src/edits/tests.rs 6aa1bb21812c7b1c2dc17f608694e2d1c6ad72e404099cfb58b1050b025ed1bd crates/core/rusteda-workspace/src/envelope.rs e27143e2533715cbf21069e9bf49ca676baaa136144775dd3ddde13c92508cad crates/core/rusteda-workspace/src/envelope_tests.rs -f1846ba8932700c6b0da6ffa46d1c1a9831855a70733eb37e57efafa486df7e2 crates/core/rusteda-workspace/src/error.rs +5571ce594739657b1709946b54086591773440bc7a316ba7589c7742fb901653 crates/core/rusteda-workspace/src/error.rs e00551a876b2daf15ebf8b49b2738af5ebf3de68433c5f7923d14aaba7aefdae crates/core/rusteda-workspace/src/fsutil.rs 1a2d983715d1f5acbe726828dd55713e09c86b5c1b7aafe09843c994c0068dae crates/core/rusteda-workspace/src/hash.rs 9726a1bcd64516197c95820db3a5d5ab988d077c7c5e056736c5effced1ed4de crates/core/rusteda-workspace/src/json.rs @@ -99,7 +99,7 @@ c466d7676b5fc1e313daa19e5a454ac65a25eb9953fcfb2cb8bf2aac7a46b96b crates/core/ru ffe7a125bd760bcbf56bb87aaceeb882c442bdb5648dbefdbc7d79f1b2510f9e crates/core/rusteda-workspace/src/sta/read.rs b592f0fbecc2df56eb66fbbd0c9b1a8206dcb9d9aacf90b5027bccc264573b24 crates/core/rusteda-workspace/src/sta/tests.rs 553a7b8c9977a509a1e8f1b8112958c1413b1aedfc38d75b89e5482fda5398fe crates/core/rusteda-workspace/src/testutil.rs -2b004f1cc37b9f7c211f80a61a858027464ac6db767776c5cd7a3ecf5798dcc9 crates/core/rusteda-workspace/src/text.rs +c6afd01aaed57a38218b81385dc7910eef688587302c6920887d6ef493fec5d2 crates/core/rusteda-workspace/src/text.rs 8f92e7a0d43f500a8a4a5bd8e9a048f6ede75a80055d896d16db31c67252a7ae crates/core/rusteda-workspace/src/txn.rs a7396d167c70fffaf1ab8e1f18aa9e58274048525b5ed062349978692afb9458 crates/core/rusteda-workspace/src/workspace.rs 80db67adac8fe2aa4dd42431a4283d8c215703a28ccca8eeea5d012a1f67f5ba crates/core/rusteda-workspace/src/workspace_io.rs @@ -191,7 +191,7 @@ f979fe59be188760093f42cb185ed29932c98c10162e43b593ae7e4d1c725cf3 crates/formats e4729252fe9b063d213ef331fe71ac376e5ca5a03614a47f5523e380ff57f80f docs/specs/core/README.md c4978424e18f357b945f0b1d95aa78f27ff845041a85a6c51e6bd277e9fa00ed docs/specs/core/netlist.md e3af311a4294c43c20e6dc05f1bbc626d889e2fede3da1e3167bbbec2063933c docs/specs/liberty/liberty.md -49c3eb1216efba7784fc8cfb91d45f351aae048b4ba66c2edfec6d6bde962d57 docs/specs/rusteda-cli/README.md +4d06ed32fa219b13bf5285019cc51b26fc00849282099c7ce00e7b6e83de123e docs/specs/rusteda-cli/README.md 26db6bf7b6b7e9ce585637d4332435ecda802a8e20598e90c2f5be60a2254c10 docs/specs/rusteda-cli/spef.md eabe18692283630b96dcb7356e3fbbeed477d96e1cbf1c980bd5d701829ce05e docs/specs/sdc/sdc.md 87576e9f297700eb47304ebf63785ea69bc447a0257d1625a16e5f85bb460a99 docs/specs/verilog/netlist.md From 7b53aafcb8c2e2b13050dc0ab7b941c6ab3a360a Mon Sep 17 00:00:00 2001 From: Chang Gao <4209517+gaochangw@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:46:49 +0200 Subject: [PATCH 3/9] Carry the timing tool's driver-resistance entry point in the shared compute crate The timing tool added rusteda_compute::rc::gate_detail_with_resistance: the bounded charge solve and waveform of rc::gate_detail for a caller-supplied driver resistance, with rc::gate_detail refactored onto the same private solver and unchanged bit for bit (unit test included). rc.rs is copied byte for byte and verification/shared.sha256 is regenerated with `cargo xtask shared update`; `cargo xtask shared diff` is clean in both directions (209 files). No synthesis code calls rc, so synthesized netlists are unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- crates/core/rusteda-compute/src/rc.rs | 72 ++++++++++++++++++++++++++- verification/shared.sha256 | 2 +- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6992283..fa3e5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,7 +77,9 @@ The format follows the "Keep a Changelog" convention; versions follow semantic v - `rusteda_workspace::edits`: netlist edit files `rusteda.edits.v1` and their application; - `rusteda_compute::rc` (effective-capacitance driver and wire waveform models) and prepared per-arc values (`ArcValues`) in the timing kernel interface, unchanged for callers that pass - none (COMPUTE-REQ-0030); + none (COMPUTE-REQ-0030), and `rc::gate_detail_with_resistance`: the bounded charge solve + and waveform of `rc::gate_detail` for a caller-supplied driver resistance, with + `rc::gate_detail` unchanged bit for bit; - the Liberty pin attributes `min_pulse_width_high`, `min_pulse_width_low` and `min_period` on `Pin` instead of ignored (LIBERTY-REQ-0049), and the compiled library format `LIBERTY` v4 that carries them (WS-REQ-0117): a cached v3 `.rlib` is compiled again from its `.lib`; diff --git a/crates/core/rusteda-compute/src/rc.rs b/crates/core/rusteda-compute/src/rc.rs index c84035d..e50db16 100644 --- a/crates/core/rusteda-compute/src/rc.rs +++ b/crates/core/rusteda-compute/src/rc.rs @@ -205,7 +205,52 @@ pub fn gate_detail( let lower = (total - delta).max(0.0); let derivative = (eval(total + delta).0 - eval(lower).0) / (total + delta - lower); let res = derivative.max(0.0) * (-(1.0 - t.output).ln()); - if !res.is_finite() || res == 0.0 { + solve_driver(pi, t, res, eval) +} + +/// [`gate_detail`] with the caller's Thevenin resistance `res` (kOhm) instead of the central +/// delay sensitivity over 1 % of the total load: the same validation, degenerate lumped loads, +/// charge solve and waveform. The caller states how `res` derives from the NLDM tables. +pub fn gate_detail_with_resistance( + pi: Pi, + t: Thresholds, + res: f64, + mut eval: impl FnMut(f64) -> (f64, f64), +) -> Result { + pi.validate()?; + t.validate()?; + if t.output <= t.lower || t.output >= t.upper { + return Err(ComputeError::Invalid( + "RC driver delay threshold must lie within slew trip points".into(), + )); + } + let total = pi.total(); + let (d0, s0) = eval(total); + if !d0.is_finite() || !s0.is_finite() { + return Err(ComputeError::Invalid("RC NLDM nonfinite".into())); + } + if pi.resistance == 0.0 || pi.far == 0.0 || s0 <= 0.0 { + return Ok(GateResponse { + delay: d0, + slew: s0.max(0.0), + capacitance: total, + resistance: 0.0, + duration: s0.max(0.0) * t.derate / (t.upper - t.lower), + root_crossing: s0.max(0.0) * t.derate / (t.upper - t.lower) * t.output, + }); + } + solve_driver(pi, t, res, eval) +} + +/// The bounded charge solve and waveform of [`gate_detail`] for a non-degenerate pi. +fn solve_driver( + pi: Pi, + t: Thresholds, + res: f64, + mut eval: impl FnMut(f64) -> (f64, f64), +) -> Result { + let total = pi.total(); + if !res.is_finite() || res <= 0.0 { return Err(ComputeError::Invalid( "NLDM has no positive delay sensitivity for RC driver".into(), )); @@ -431,6 +476,31 @@ mod tests { } assert!(wire(1.0, 0.1, Thresholds { derate: 0.0, ..t }, false).is_err()); } + // STA-REQ-0726: the caller's resistance reproduces the default estimate bit for bit and + // keeps its validation; a nonpositive resistance is rejected. + #[test] + fn caller_resistance_matches_default_estimate() { + let t = trips(); + let eval = |c: f64| (0.05 + 2.0 * c, 0.3 + 3.0 * c); + let pi = Pi { + near: 0.01, + far: 0.02, + resistance: 1.0, + }; + let a = gate_detail(pi, t, eval).unwrap(); + let b = gate_detail_with_resistance(pi, t, a.resistance, eval).unwrap(); + assert_eq!(a.delay.to_bits(), b.delay.to_bits()); + assert_eq!(a.slew.to_bits(), b.slew.to_bits()); + assert_eq!(a.capacitance.to_bits(), b.capacitance.to_bits()); + assert!(gate_detail_with_resistance(pi, t, 0.0, eval).is_err()); + assert!(gate_detail_with_resistance(pi, t, f64::NAN, eval).is_err()); + let lumped = Pi { + resistance: 0.0, + ..pi + }; + let r = gate_detail_with_resistance(lumped, t, 0.0, eval).unwrap(); + assert_eq!(r.capacitance, 0.03); + } // STA-REQ-0722: normalized polarity/derate, unchanged zero RC, stable small arguments. #[test] fn stable_charge_and_zero_rc() { diff --git a/verification/shared.sha256 b/verification/shared.sha256 index 1c8c2cc..0107187 100644 --- a/verification/shared.sha256 +++ b/verification/shared.sha256 @@ -31,7 +31,7 @@ cfa2c2ec5b7e22ccaab416627c5d8aa3e6fcf42f0328add053301bb8ec88e6f2 crates/core/ru 3914d3d2c2b5a135888d91985a2252477a37037cc5c0ca80c1006065cf2fcc41 crates/core/rusteda-compute/src/cut/pack.rs b5b0e9feb7741639dddf2211819f4cc55e3daf916ee430e6bc32c0218c9fa6b2 crates/core/rusteda-compute/src/cut/tests.rs f651a75a81f6fb438bf64d4baa48f2451ab62f4477c142c7e3c1649ee51d7c70 crates/core/rusteda-compute/src/lib.rs -3cd6ee42428dbe06151ea414684987725ace3efdcb7f192bf18695b8936e4d7d crates/core/rusteda-compute/src/rc.rs +d1ba079889308f9048f2d8704a817d5c2bc0eb3d98055b7c80b9c268340fc3a6 crates/core/rusteda-compute/src/rc.rs 2185b7a81649244c10b6db436ec9604f5284c0eecbe9656e57ce60a2c1bab8b7 crates/core/rusteda-compute/src/timing/abi.rs 5db80c1d475645d75fe4346a3ef678b213cb424ce8ebe6a949e3aac821c38ad9 crates/core/rusteda-compute/src/timing/cpu.rs c89580e62748bdb78eb46db2ca955a5f64a7f9019e1b78132fbbe43bd41788d6 crates/core/rusteda-compute/src/timing/cuda.rs From d36d922c0201968745be72bd71aff57ace393c90 Mon Sep 17 00:00:00 2001 From: Chang Gao <4209517+gaochangw@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:30:21 +0200 Subject: [PATCH 4/9] Carry the timing tool's one-step effective capacitance and driver-waveform wire response in the shared compute crate Additive rc.rs entry points copied byte for byte from the timing tool: rc::gate_one_step (one Newton-Raphson correction of the Thevenin driver's effective capacitance from the total load) and rc::wire_from_driver (sink response of a driver's pi-root waveform through one pole of the sink's first moment). Existing functions are unchanged; manifest regenerated with cargo xtask shared update. Co-Authored-By: Claude Opus 5.5 --- crates/core/rusteda-compute/src/rc.rs | 420 +++++++++++++++++++++++++- verification/shared.sha256 | 2 +- 2 files changed, 420 insertions(+), 2 deletions(-) diff --git a/crates/core/rusteda-compute/src/rc.rs b/crates/core/rusteda-compute/src/rc.rs index e50db16..3015307 100644 --- a/crates/core/rusteda-compute/src/rc.rs +++ b/crates/core/rusteda-compute/src/rc.rs @@ -2,7 +2,8 @@ // SPDX-FileCopyrightText: 2026 RustEDA contributors //! Bounded f64 ramp/charge models, independently derived from RC circuit equations. -//! STA-REQ-0721, STA-REQ-0722. All times ns, capacitances pF, resistances kOhm. +//! STA-REQ-0721, STA-REQ-0722, STA-REQ-0726, STA-REQ-0728, STA-REQ-0729. All times ns, +//! capacitances pF, resistances kOhm. use crate::ComputeError; /// Progress-normalized trip points: falling voltages are mapped through 1-v. @@ -311,6 +312,186 @@ fn solve_driver( }) } +/// The relative load step of the forward table slopes in [`gate_one_step`]. +const SLOPE_STEP: f64 = 1e-7; + +/// [`ramp_voltage`] at `time` and its partial derivatives with respect to the time, the ramp +/// duration and the pole time, for a positive duration and pole time. +fn ramp_partials(time: f64, duration: f64, tau: f64) -> [f64; 4] { + if time <= 0.0 { + return [0.0; 4]; + } + if time <= duration { + let u = time / tau; + let v = ramp_voltage(time, duration, tau); + // (1 - e^-u) - u e^-u without cancellation for small u. + let q = if u < 1e-3 { + u * u * (0.5 - u / 3.0 + u * u / 8.0) + } else { + -(-u).exp_m1() - u * (-u).exp() + }; + return [v, -(-u).exp_m1() / duration, -v / duration, -q / duration]; + } + let a = (-(time - duration) / tau).exp(); + let b = (-time / tau).exp(); + [ + 1.0 - tau / duration * (a - b), + (a - b) / duration, + tau / (duration * duration) * (a - b) - a / duration, + -(a - b) / duration - (a * (time - duration) - b * time) / (tau * duration), + ] +} + +/// [`ramp_voltage`] at the end of the ramp and its derivative with respect to the duration. +fn settled(duration: f64, tau: f64) -> (f64, f64) { + if tau == 0.0 { + return (1.0, 0.0); + } + let x = duration / tau; + ( + ramp_voltage(duration, duration, tau), + tau / (duration * duration) * -(-x).exp_m1() - (-x).exp() / duration, + ) +} + +/// Solves `a x = b` by Gaussian elimination with partial pivoting. +fn solve3(mut a: [[f64; 3]; 3], mut b: [f64; 3]) -> Option<[f64; 3]> { + for i in 0..3 { + let p = (i..3).max_by(|&r, &s| a[r][i].abs().total_cmp(&a[s][i].abs()))?; + a.swap(i, p); + b.swap(i, p); + if !a[i][i].is_normal() { + return None; + } + for r in i + 1..3 { + let f = a[r][i] / a[i][i]; + for k in i..3 { + a[r][k] -= f * a[i][k]; + } + b[r] -= f * b[i]; + } + } + let mut x = [0.0; 3]; + for i in (0..3).rev() { + let tail: f64 = (i + 1..3).map(|k| a[i][k] * x[k]).sum(); + x[i] = (b[i] - tail) / a[i][i]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + +/// One Newton-Raphson correction of the effective capacitance of a finite-ramp Thevenin driver +/// with the caller's resistance `res` (kOhm), instead of the converged charge solve of +/// [`gate_detail_with_resistance`]. The unknowns are the source start, the source duration and +/// the capacitance; the equations put the single-capacitance response at the lower and the +/// delay trip points at the NLDM times of that capacitance and match the charge of the pi at the +/// end of the source ramp. The step starts from the total load with the source ramp of the +/// table transition at the total load, uses the exact Jacobian (forward table slopes over a +/// relative load step of 1e-7) and is clamped to `[near, total]`. The delay is the NLDM delay at +/// the corrected capacitance; the transition and the root crossing are those of the pi root for +/// the source duration fitted at that capacitance. Degenerate lumped loads are as in +/// [`gate_detail_with_resistance`]. +pub fn gate_one_step( + pi: Pi, + t: Thresholds, + res: f64, + mut eval: impl FnMut(f64) -> (f64, f64), +) -> Result { + pi.validate()?; + t.validate()?; + if t.output <= t.lower || t.output >= t.upper { + return Err(ComputeError::Invalid( + "RC driver delay threshold must lie within slew trip points".into(), + )); + } + let total = pi.total(); + let (d0, s0) = eval(total); + if !d0.is_finite() || !s0.is_finite() { + return Err(ComputeError::Invalid("RC NLDM nonfinite".into())); + } + let span = t.upper - t.lower; + if pi.resistance == 0.0 || pi.far == 0.0 || s0 <= 0.0 { + return Ok(GateResponse { + delay: d0, + slew: s0.max(0.0), + capacitance: total, + resistance: 0.0, + duration: s0.max(0.0) * t.derate / span, + root_crossing: s0.max(0.0) * t.derate / span * t.output, + }); + } + if !res.is_finite() || res <= 0.0 { + return Err(ComputeError::Invalid( + "NLDM has no positive delay sensitivity for RC driver".into(), + )); + } + let step = total * SLOPE_STEP; + let (d1, s1) = eval(total + step); + let (dd, ds) = ((d1 - d0) / step, (s1 - s0) / step); + let duration = s0 * t.derate / span; + let lead = t.derate * (t.output - t.lower) / span; + let theta = res * total; + // Start: source ramp of the table transition at the total load, its trip time at the delay. + let p1 = ramp_partials(t.output * duration, duration, theta); + let p2 = ramp_partials(t.output * duration - lead * s0, duration, theta); + let poles = Poles::new(pi, res); + let (ga, gpa) = settled(duration, poles.a); + let (gb, gpb) = settled(duration, poles.b); + let w = poles.root_weight; + let (root, root_p) = (w * ga + (1.0 - w) * gb, w * gpa + (1.0 - w) * gpb); + let (far, far_p) = if poles.a == poles.b { + (root, root_p) + } else { + ( + (poles.a * ga - poles.b * gb) / (poles.a - poles.b), + (poles.a * gpa - poles.b * gpb) / (poles.a - poles.b), + ) + }; + let charge = pi.near * root + pi.far * far; + let charge_p = pi.near * root_p + pi.far * far_p; + let e = ramp_partials(duration, duration, theta); + let residual = [ + p1[0] - t.output, + p2[0] - t.lower, + (total * e[0] - charge) / total, + ]; + let jacobian = [ + [-p1[1], p1[2], p1[1] * dd + p1[3] * res], + [-p2[1], p2[2], p2[1] * (dd - lead * ds) + p2[3] * res], + [ + 0.0, + (total * (e[1] + e[2]) - charge_p) / total, + (e[0] + total * res * e[3]) / total, + ], + ]; + let correction = solve3(jacobian, residual.map(|f| -f)).ok_or_else(|| { + ComputeError::Invalid("RC effective-capacitance correction is singular".into()) + })?; + let capacitance = (total + correction[2]).clamp(pi.near, total); + let (delay, s) = eval(capacitance); + if !delay.is_finite() || !s.is_finite() || s <= 0.0 { + return Err(ComputeError::Invalid( + "invalid NLDM transition for RC fitting".into(), + )); + } + let duration = fit_duration(s, capacitance, res, t)?; + let slew = (poles.crossing(duration, t.upper) - poles.crossing(duration, t.lower)) / t.derate; + let root_crossing = poles.crossing(duration, t.output); + if [slew, duration, root_crossing] + .iter() + .any(|v| !v.is_finite()) + { + return Err(ComputeError::Invalid("RC waveform overflow".into())); + } + Ok(GateResponse { + delay, + slew, + capacitance, + resistance: res, + duration, + root_crossing, + }) +} + pub fn gate( pi: Pi, t: Thresholds, @@ -394,6 +575,102 @@ fn crossing(duration: f64, tau: f64, p: f64) -> f64 { lo + (hi - lo) * 0.5 } +/// Normalized response at `x` of two cascaded poles `p` and `q` to a unit ramp of duration `d`. +fn cascade(x: f64, d: f64, p: f64, q: f64) -> f64 { + if (p - q).abs() > 1e-6 * p.max(q) { + return (p * ramp_voltage(x, d, p) - q * ramp_voltage(x, d, q)) / (p - q); + } + let r = 0.5 * (p + q); + if r == 0.0 { + return ramp_voltage(x, d, 0.0); + } + // Equal poles: the integral of the step response 1 - (1 + t/r) e^(-t/r). + let integral = |t: f64| { + if t <= 0.0 { + 0.0 + } else { + let e = (-t / r).exp(); + t - 2.0 * r * -(-t / r).exp_m1() + t * e + } + }; + if d == 0.0 { + if x <= 0.0 { + return 0.0; + } + return 1.0 - (1.0 + x / r) * (-x / r).exp(); + } + (integral(x) - integral(x - d)) / d +} + +/// The first time at which the monotone response `f` reaches `p` in (0, 1): a doubling bracket +/// from `start` (at most 64 doublings) and at most 100 bisections to a relative 1e-13. +fn monotone_crossing(f: impl Fn(f64) -> f64, p: f64, start: f64) -> Result { + let mut hi = start.max(1e-15); + let mut doublings = 0; + while f(hi) < p { + hi *= 2.0; + doublings += 1; + if doublings > 64 || !hi.is_finite() { + return Err(ComputeError::Invalid( + "RC load crossing not bracketed".into(), + )); + } + } + let mut lo = 0.0; + let tolerance = (hi * 1e-13).max(1e-16); + for _ in 0..100 { + if hi - lo <= tolerance { + break; + } + let mid = lo + (hi - lo) * 0.5; + if f(mid) < p { + lo = mid; + } else { + hi = mid; + } + } + Ok(lo + (hi - lo) * 0.5) +} + +/// Per-sink threshold delay and transition when a finite-ramp Thevenin driver of resistance +/// `res` (kOhm) and source ramp `duration` (ns) drives the pi `pi`: the sink voltage is the pi +/// root response through a single pole of the sink's first moment `tau`. The delay runs from the +/// root's output trip point to the sink's input trip point; with `tau = 0` the sink is the root. +pub fn wire_from_driver( + pi: Pi, + res: f64, + duration: f64, + tau: f64, + t: Thresholds, +) -> Result<(f64, f64), ComputeError> { + pi.validate()?; + t.validate()?; + if !res.is_finite() || res <= 0.0 || pi.resistance <= 0.0 || pi.far <= 0.0 { + return Err(ComputeError::Invalid( + "RC driver waveform needs a resistive pi".into(), + )); + } + if !tau.is_finite() || tau < 0.0 || !duration.is_finite() || duration < 0.0 { + return Err(ComputeError::Invalid("invalid RC wire state".into())); + } + let poles = Poles::new(pi, res); + let w = poles.root_weight; + let load = |x: f64| { + w * cascade(x, duration, poles.a, tau) + (1.0 - w) * cascade(x, duration, poles.b, tau) + }; + let start = duration + poles.a + poles.b + tau; + let root_out = poles.crossing(duration, t.output); + let sink_in = monotone_crossing(load, t.input, start)?; + let slew = (monotone_crossing(load, t.upper, start)? + - monotone_crossing(load, t.lower, start)?) + / t.derate; + let delay = sink_in - root_out; + if !delay.is_finite() || !slew.is_finite() { + return Err(ComputeError::Invalid("RC threshold solve overflow".into())); + } + Ok((delay, slew.max(0.0))) +} + /// Per-sink threshold delay/transition. Primary ports use an exponential envelope; /// characterized cell outputs use the finite-ramp convolution exactly. pub fn wire(tau: f64, slew: f64, t: Thresholds, primary: bool) -> Result<(f64, f64), ComputeError> { @@ -501,6 +778,147 @@ mod tests { let r = gate_detail_with_resistance(lumped, t, 0.0, eval).unwrap(); assert_eq!(r.capacitance, 0.03); } + // STA-REQ-0728: the one-step correction is the Newton step of the three residuals (trip + // points of the single-capacitance response, charge at the end of the source ramp), checked + // against central differences of the residuals; it lies between the converged charge + // solution and the total load, and a degenerate pi stays lumped. + #[test] + fn one_step_is_the_newton_correction_of_the_driver_residuals() { + let t = Thresholds { + lower: 0.3, + upper: 0.7, + input: 0.5, + output: 0.5, + derate: 0.5, + }; + let eval = |c: f64| (0.06 + 6.0 * c, 0.09 + 11.0 * c); + let pi = Pi { + near: 0.004, + far: 0.03, + resistance: 0.8, + }; + let res = 6.0 * std::f64::consts::LN_2; + let total = pi.total(); + let span = t.upper - t.lower; + let lead = t.derate * (t.output - t.lower) / span; + let poles = Poles::new(pi, res); + let residual = |x: [f64; 3]| { + let (d, s) = eval(x[2]); + let tau = res * x[2]; + [ + ramp_voltage(d - x[0], x[1], tau) - t.output, + ramp_voltage(d - lead * s - x[0], x[1], tau) - t.lower, + (x[2] * ramp_voltage(x[1], x[1], tau) + - pi.near * poles.root(x[1], x[1]) + - pi.far * poles.far(x[1], x[1])) + / total, + ] + }; + let (d0, s0) = eval(total); + let x0 = [ + d0 - t.output * s0 * t.derate / span, + s0 * t.derate / span, + total, + ]; + let mut jac = [[0.0; 3]; 3]; + for j in 0..3 { + let h = x0[j] * 1e-6; + let (mut a, mut b) = (x0, x0); + a[j] += h; + b[j] -= h; + let (fa, fb) = (residual(a), residual(b)); + for i in 0..3 { + jac[i][j] = (fa[i] - fb[i]) / (2.0 * h); + } + } + let step = solve3(jac, residual(x0).map(|f| -f)).unwrap(); + let r = gate_one_step(pi, t, res, eval).unwrap(); + assert!( + (r.capacitance - (total + step[2])).abs() < 1e-9 * total, + "{} {}", + r.capacitance, + total + step[2] + ); + let converged = gate_detail_with_resistance(pi, t, res, eval).unwrap(); + assert!(converged.capacitance < r.capacitance && r.capacitance < total); + assert_eq!(r.delay, eval(r.capacitance).0); + let lumped = Pi { + resistance: 0.0, + ..pi + }; + assert_eq!( + gate_one_step(lumped, t, res, eval).unwrap().capacitance, + total + ); + assert!(gate_one_step(pi, t, 0.0, eval).is_err()); + assert!(gate_one_step(pi, t, res, |_| (f64::NAN, 0.1)).is_err()); + } + // STA-REQ-0729: the sink response is the pi root response convolved with a single pole of + // the first moment, checked against a direct numerical convolution; with tau = 0 the sink is + // the root. + #[test] + fn wire_follows_the_driver_waveform() { + let t = trips(); + let pi = Pi { + near: 0.003, + far: 0.02, + resistance: 1.2, + }; + let res = 4.0; + let poles = Poles::new(pi, res); + let duration = 0.21; + let slew = + (poles.crossing(duration, t.upper) - poles.crossing(duration, t.lower)) / t.derate; + let (d, s) = wire_from_driver(pi, res, duration, 0.0, t).unwrap(); + assert!(d.abs() < 1e-12 && (s - slew).abs() < 1e-12, "{d} {s}"); + let tau = 0.035; + let (d, s) = wire_from_driver(pi, res, duration, tau, t).unwrap(); + // Trapezoidal convolution of the root waveform with exp(-u/tau)/tau on a fine grid. + let h = 5e-5; + let n = 20_000; + let root: Vec = (0..n).map(|k| poles.root(k as f64 * h, duration)).collect(); + let kernel: Vec = (0..n) + .map(|k| (-(k as f64) * h / tau).exp() / tau) + .collect(); + let sink = |m: usize| { + let mut acc = 0.0; + for k in 0..=m { + let w = if k == 0 || k == m { 0.5 } else { 1.0 }; + acc += w * root[m - k] * kernel[k]; + } + acc * h + }; + let cross = |p: f64| { + let mut prev = 0.0; + for m in 1..n { + let v = sink(m); + if v >= p { + return (m as f64 - 1.0 + (p - prev) / (v - prev)) * h; + } + prev = v; + } + f64::NAN + }; + let delay = cross(t.input) - poles.crossing(duration, t.output); + let transition = (cross(t.upper) - cross(t.lower)) / t.derate; + assert!((delay - d).abs() < 2e-6, "{delay} {d}"); + assert!((transition - s).abs() < 2e-6, "{transition} {s}"); + assert!(d > 0.0 && s > slew); + assert!( + wire_from_driver( + Pi { + resistance: 0.0, + ..pi + }, + res, + duration, + tau, + t + ) + .is_err() + ); + assert!(wire_from_driver(pi, res, -1.0, tau, t).is_err()); + } // STA-REQ-0722: normalized polarity/derate, unchanged zero RC, stable small arguments. #[test] fn stable_charge_and_zero_rc() { diff --git a/verification/shared.sha256 b/verification/shared.sha256 index 0107187..db0faef 100644 --- a/verification/shared.sha256 +++ b/verification/shared.sha256 @@ -31,7 +31,7 @@ cfa2c2ec5b7e22ccaab416627c5d8aa3e6fcf42f0328add053301bb8ec88e6f2 crates/core/ru 3914d3d2c2b5a135888d91985a2252477a37037cc5c0ca80c1006065cf2fcc41 crates/core/rusteda-compute/src/cut/pack.rs b5b0e9feb7741639dddf2211819f4cc55e3daf916ee430e6bc32c0218c9fa6b2 crates/core/rusteda-compute/src/cut/tests.rs f651a75a81f6fb438bf64d4baa48f2451ab62f4477c142c7e3c1649ee51d7c70 crates/core/rusteda-compute/src/lib.rs -d1ba079889308f9048f2d8704a817d5c2bc0eb3d98055b7c80b9c268340fc3a6 crates/core/rusteda-compute/src/rc.rs +8d30631efa1da0cfcb6181709e3e752a4a2016371963aa9004eabaed88c0e620 crates/core/rusteda-compute/src/rc.rs 2185b7a81649244c10b6db436ec9604f5284c0eecbe9656e57ce60a2c1bab8b7 crates/core/rusteda-compute/src/timing/abi.rs 5db80c1d475645d75fe4346a3ef678b213cb424ce8ebe6a949e3aac821c38ad9 crates/core/rusteda-compute/src/timing/cpu.rs c89580e62748bdb78eb46db2ca955a5f64a7f9019e1b78132fbbe43bd41788d6 crates/core/rusteda-compute/src/timing/cuda.rs From 6012cf66e333c0719ba44c13dda33746193e6406 Mon Sep 17 00:00:00 2001 From: Chang Gao Date: Sun, 25 Oct 2026 04:46:21 +0100 Subject: [PATCH 5/9] refactor: use the canonical RustEDA foundation --- .cargo/config.toml | 3 + CONTRIBUTING.md | 18 + Cargo.lock | 21 + Cargo.toml | 9 +- README.md | 4 + crates/apps/rusteda-cli/Cargo.toml | 1 + crates/apps/rusteda-cli/src/export.rs | 283 ---- crates/apps/rusteda-cli/src/export/frozen.rs | 495 ------- crates/apps/rusteda-cli/src/import.rs | 216 --- crates/apps/rusteda-cli/src/import_lib.rs | 231 ---- crates/apps/rusteda-cli/src/import_logic.rs | 162 --- crates/apps/rusteda-cli/src/import_netlist.rs | 455 ------- .../rusteda-cli/src/import_netlist/ast.rs | 1155 ----------------- .../rusteda-cli/src/import_netlist/fast.rs | 1013 --------------- crates/apps/rusteda-cli/src/import_rtl.rs | 494 ------- crates/apps/rusteda-cli/src/import_sdc.rs | 183 --- crates/apps/rusteda-cli/src/import_spef.rs | 302 ----- crates/apps/rusteda-cli/src/init.rs | 35 - crates/apps/rusteda-cli/src/lib.rs | 921 +------------ crates/apps/rusteda-cli/src/query.rs | 467 ------- crates/apps/rusteda-cli/src/revert.rs | 58 - .../apps/rusteda-cli/tests/product_version.rs | 18 + crates/core/rusteda-compute/Cargo.toml | 23 - crates/core/rusteda-compute/README.md | 27 - crates/core/rusteda-compute/src/cuda.rs | 322 ----- crates/core/rusteda-compute/src/cut/cpu.rs | 651 ---------- crates/core/rusteda-compute/src/cut/cuda.rs | 652 ---------- crates/core/rusteda-compute/src/cut/mod.rs | 400 ------ crates/core/rusteda-compute/src/cut/pack.rs | 233 ---- crates/core/rusteda-compute/src/cut/tests.rs | 522 -------- crates/core/rusteda-compute/src/lib.rs | 469 ------- crates/core/rusteda-compute/src/rc.rs | 934 ------------- crates/core/rusteda-compute/src/timing/abi.rs | 367 ------ crates/core/rusteda-compute/src/timing/cpu.rs | 688 ---------- .../core/rusteda-compute/src/timing/cuda.rs | 922 ------------- .../src/timing/kernels/timing.cu | 436 ------- crates/core/rusteda-compute/src/timing/lut.rs | 155 --- crates/core/rusteda-compute/src/timing/mod.rs | 301 ----- .../core/rusteda-compute/src/timing/state.rs | 210 --- .../src/timing/tests/checks.rs | 273 ---- .../rusteda-compute/src/timing/tests/cpu.rs | 463 ------- .../rusteda-compute/src/timing/tests/gpu.rs | 269 ---- .../rusteda-compute/src/timing/tests/known.rs | 483 ------- .../rusteda-compute/src/timing/tests/mod.rs | 42 - .../rusteda-compute/src/timing/tests/naive.rs | 247 ---- .../src/timing/tests/prepared.rs | 109 -- .../src/timing/tests/random.rs | 469 ------- .../rusteda-compute/src/timing/validate.rs | 301 ----- .../rusteda-compute/tests/rc_transient.rs | 155 --- crates/core/rusteda-core/Cargo.toml | 17 - crates/core/rusteda-core/README.md | 26 - crates/core/rusteda-core/src/arena.rs | 417 ------ crates/core/rusteda-core/src/diag.rs | 271 ---- crates/core/rusteda-core/src/job.rs | 261 ---- crates/core/rusteda-core/src/lib.rs | 15 - crates/core/rusteda-core/src/liberty/func.rs | 112 -- crates/core/rusteda-core/src/liberty/kinds.rs | 306 ----- crates/core/rusteda-core/src/liberty/mod.rs | 25 - crates/core/rusteda-core/src/liberty/model.rs | 723 ----------- crates/core/rusteda-core/src/liberty/table.rs | 326 ----- crates/core/rusteda-core/src/liberty/units.rs | 87 -- crates/core/rusteda-core/src/names.rs | 413 ------ crates/core/rusteda-core/src/netlist.rs | 716 ---------- crates/core/rusteda-core/src/netlist/edit.rs | 377 ------ crates/core/rusteda-core/src/netlist/error.rs | 145 --- .../core/rusteda-core/src/netlist/frozen.rs | 713 ---------- crates/core/rusteda-core/src/netlist/tests.rs | 844 ------------ .../rusteda-core/src/netlist/tests_frozen.rs | 783 ----------- .../rusteda-core/src/netlist/tests_random.rs | 256 ---- .../core/rusteda-core/src/netlist/validate.rs | 510 -------- crates/core/rusteda-core/src/symbol.rs | 146 --- crates/core/rusteda-workspace/Cargo.toml | 24 - crates/core/rusteda-workspace/README.md | 286 ---- crates/core/rusteda-workspace/src/cache.rs | 341 ----- crates/core/rusteda-workspace/src/edits.rs | 764 ----------- .../core/rusteda-workspace/src/edits/apply.rs | 735 ----------- .../core/rusteda-workspace/src/edits/tests.rs | 602 --------- crates/core/rusteda-workspace/src/envelope.rs | 752 ----------- .../rusteda-workspace/src/envelope_tests.rs | 522 -------- crates/core/rusteda-workspace/src/error.rs | 947 -------------- crates/core/rusteda-workspace/src/fsutil.rs | 547 -------- crates/core/rusteda-workspace/src/hash.rs | 396 ------ crates/core/rusteda-workspace/src/json.rs | 591 --------- crates/core/rusteda-workspace/src/lib.rs | 124 -- crates/core/rusteda-workspace/src/lock.rs | 228 ---- crates/core/rusteda-workspace/src/mmap.rs | 43 - crates/core/rusteda-workspace/src/netlist.rs | 203 --- crates/core/rusteda-workspace/src/rbin.rs | 1028 --------------- .../core/rusteda-workspace/src/rbin_tests.rs | 324 ----- crates/core/rusteda-workspace/src/rev.rs | 452 ------- crates/core/rusteda-workspace/src/rlib.rs | 836 ------------ .../core/rusteda-workspace/src/rlib/decode.rs | 987 -------------- crates/core/rusteda-workspace/src/schema.rs | 791 ----------- crates/core/rusteda-workspace/src/sta.rs | 318 ----- .../core/rusteda-workspace/src/sta/build.rs | 876 ------------- .../core/rusteda-workspace/src/sta/layout.rs | 407 ------ crates/core/rusteda-workspace/src/sta/read.rs | 1043 --------------- .../core/rusteda-workspace/src/sta/tests.rs | 753 ----------- crates/core/rusteda-workspace/src/testutil.rs | 27 - crates/core/rusteda-workspace/src/text.rs | 229 ---- crates/core/rusteda-workspace/src/txn.rs | 1014 --------------- .../core/rusteda-workspace/src/workspace.rs | 1039 --------------- .../rusteda-workspace/src/workspace_io.rs | 471 ------- crates/core/rusteda-workspace/tests/bench.rs | 224 ---- crates/core/rusteda-workspace/tests/cache.rs | 421 ------ .../rusteda-workspace/tests/common/mod.rs | 80 -- .../core/rusteda-workspace/tests/failures.rs | 454 ------- .../core/rusteda-workspace/tests/netlist.rs | 665 ---------- crates/core/rusteda-workspace/tests/rbin.rs | 1008 -------------- crates/core/rusteda-workspace/tests/rlib.rs | 667 ---------- .../rusteda-workspace/tests/source_rules.rs | 172 --- crates/core/rusteda-workspace/tests/sta.rs | 107 -- .../core/rusteda-workspace/tests/workspace.rs | 1033 --------------- .../rusteda-workspace/tests/workspace_ops.rs | 1020 --------------- crates/formats/rusteda-formats/Cargo.toml | 22 - crates/formats/rusteda-formats/README.md | 42 - .../formats/rusteda-formats/src/aiger/mod.rs | 131 -- .../formats/rusteda-formats/src/aiger/read.rs | 538 -------- .../rusteda-formats/src/blif/flatten.rs | 396 ------ .../formats/rusteda-formats/src/blif/lex.rs | 134 -- .../formats/rusteda-formats/src/blif/mod.rs | 288 ---- .../formats/rusteda-formats/src/blif/parse.rs | 719 ---------- crates/formats/rusteda-formats/src/lib.rs | 14 - .../rusteda-formats/src/liberty/build.rs | 1093 ---------------- .../rusteda-formats/src/liberty/bus.rs | 381 ------ .../rusteda-formats/src/liberty/classify.rs | 652 ---------- .../rusteda-formats/src/liberty/diags.rs | 217 ---- .../rusteda-formats/src/liberty/func.rs | 292 ----- .../rusteda-formats/src/liberty/mod.rs | 36 - .../rusteda-formats/src/liberty/syntax.rs | 515 -------- .../rusteda-formats/src/liberty/tables.rs | 154 --- .../rusteda-formats/src/liberty/units.rs | 169 --- .../rusteda-formats/src/liberty/wire_load.rs | 298 ----- crates/formats/rusteda-formats/src/sdc.rs | 298 ----- crates/formats/rusteda-formats/src/sdc/cmd.rs | 592 --------- .../formats/rusteda-formats/src/sdc/constr.rs | 544 -------- .../rusteda-formats/src/sdc/constr/decode.rs | 887 ------------- .../rusteda-formats/src/sdc/constr/encode.rs | 1107 ---------------- .../formats/rusteda-formats/src/sdc/eval.rs | 947 -------------- .../rusteda-formats/src/sdc/eval/builtins.rs | 1106 ---------------- .../formats/rusteda-formats/src/sdc/expr.rs | 1061 --------------- .../formats/rusteda-formats/src/sdc/filter.rs | 337 ----- crates/formats/rusteda-formats/src/sdc/obj.rs | 183 --- .../formats/rusteda-formats/src/sdc/schema.rs | 900 ------------- .../rusteda-formats/src/sdc/schema/build.rs | 744 ----------- .../rusteda-formats/src/sdc/schema/parse.rs | 753 ----------- .../formats/rusteda-formats/src/sdc/write.rs | 1139 ---------------- .../formats/rusteda-formats/src/spef/mod.rs | 273 ---- .../formats/rusteda-formats/src/spef/parse.rs | 525 -------- .../rusteda-formats/src/spef/source.rs | 94 -- .../formats/rusteda-formats/src/spef/tests.rs | 124 -- .../rusteda-formats/src/verilog/ast.rs | 520 -------- .../rusteda-formats/src/verilog/ast/stmt.rs | 318 ----- .../rusteda-formats/src/verilog/diag.rs | 74 -- .../rusteda-formats/src/verilog/lex.rs | 729 ----------- .../rusteda-formats/src/verilog/mod.rs | 199 --- .../rusteda-formats/src/verilog/netlist.rs | 742 ----------- .../src/verilog/netlist/freeze_writer.rs | 610 --------- .../src/verilog/netlist/read.rs | 748 ----------- .../src/verilog/netlist/scan.rs | 550 -------- .../src/verilog/netlist/tests.rs | 825 ------------ .../src/verilog/netlist/tests_read.rs | 298 ----- .../rusteda-formats/src/verilog/number.rs | 245 ---- .../rusteda-formats/src/verilog/parse/expr.rs | 707 ---------- .../src/verilog/parse/instances.rs | 265 ---- .../src/verilog/parse/items.rs | 762 ----------- .../rusteda-formats/src/verilog/parse/mod.rs | 298 ----- .../rusteda-formats/src/verilog/parse/stmt.rs | 694 ---------- .../src/verilog/parse/subroutines.rs | 172 --- .../src/verilog/parse/types.rs | 483 ------- .../formats/rusteda-formats/src/verilog/pp.rs | 640 --------- .../rusteda-formats/src/verilog/span.rs | 156 --- .../rusteda-formats/src/verilog/token.rs | 333 ----- .../rusteda-formats/src/verilog/write.rs | 288 ---- .../rusteda-formats/tests/aiger_reader.rs | 373 ------ .../rusteda-formats/tests/blif_reader.rs | 507 -------- .../tests/liberty_cells_and_pins.rs | 250 ---- .../tests/liberty_extensions_and_diags.rs | 781 ----------- .../rusteda-formats/tests/sdc_commands.rs | 308 ----- .../formats/rusteda-formats/tests/sdc_fuzz.rs | 134 -- .../rusteda-formats/tests/sdc_roundtrip.rs | 177 --- .../rusteda-formats/tests/sdc_subset.rs | 327 ----- .../tests/verilog_parse_design.rs | 275 ---- deny.toml | 4 +- docs/_tools/check_markdown.py | 10 +- docs/_tools/foundation_paths.py | 48 + docs/_tools/site_hooks.py | 29 + docs/mkdocs.yml | 3 + docs/verification/requirements.md | 728 +++++------ verification/shared.sha256 | 183 +-- xtask/src/coverage.rs | 11 + xtask/src/foundation.rs | 93 ++ xtask/src/main.rs | 1 + xtask/src/pdk/mod.rs | 2 +- xtask/src/shared.rs | 3 +- 195 files changed, 645 insertions(+), 79670 deletions(-) delete mode 100644 crates/apps/rusteda-cli/src/export.rs delete mode 100644 crates/apps/rusteda-cli/src/export/frozen.rs delete mode 100644 crates/apps/rusteda-cli/src/import.rs delete mode 100644 crates/apps/rusteda-cli/src/import_lib.rs delete mode 100644 crates/apps/rusteda-cli/src/import_logic.rs delete mode 100644 crates/apps/rusteda-cli/src/import_netlist.rs delete mode 100644 crates/apps/rusteda-cli/src/import_netlist/ast.rs delete mode 100644 crates/apps/rusteda-cli/src/import_netlist/fast.rs delete mode 100644 crates/apps/rusteda-cli/src/import_rtl.rs delete mode 100644 crates/apps/rusteda-cli/src/import_sdc.rs delete mode 100644 crates/apps/rusteda-cli/src/import_spef.rs delete mode 100644 crates/apps/rusteda-cli/src/init.rs delete mode 100644 crates/apps/rusteda-cli/src/query.rs delete mode 100644 crates/apps/rusteda-cli/src/revert.rs create mode 100644 crates/apps/rusteda-cli/tests/product_version.rs delete mode 100644 crates/core/rusteda-compute/Cargo.toml delete mode 100644 crates/core/rusteda-compute/README.md delete mode 100644 crates/core/rusteda-compute/src/cuda.rs delete mode 100644 crates/core/rusteda-compute/src/cut/cpu.rs delete mode 100644 crates/core/rusteda-compute/src/cut/cuda.rs delete mode 100644 crates/core/rusteda-compute/src/cut/mod.rs delete mode 100644 crates/core/rusteda-compute/src/cut/pack.rs delete mode 100644 crates/core/rusteda-compute/src/cut/tests.rs delete mode 100644 crates/core/rusteda-compute/src/lib.rs delete mode 100644 crates/core/rusteda-compute/src/rc.rs delete mode 100644 crates/core/rusteda-compute/src/timing/abi.rs delete mode 100644 crates/core/rusteda-compute/src/timing/cpu.rs delete mode 100644 crates/core/rusteda-compute/src/timing/cuda.rs delete mode 100644 crates/core/rusteda-compute/src/timing/kernels/timing.cu delete mode 100644 crates/core/rusteda-compute/src/timing/lut.rs delete mode 100644 crates/core/rusteda-compute/src/timing/mod.rs delete mode 100644 crates/core/rusteda-compute/src/timing/state.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/checks.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/cpu.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/gpu.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/known.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/mod.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/naive.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/prepared.rs delete mode 100644 crates/core/rusteda-compute/src/timing/tests/random.rs delete mode 100644 crates/core/rusteda-compute/src/timing/validate.rs delete mode 100644 crates/core/rusteda-compute/tests/rc_transient.rs delete mode 100644 crates/core/rusteda-core/Cargo.toml delete mode 100644 crates/core/rusteda-core/README.md delete mode 100644 crates/core/rusteda-core/src/arena.rs delete mode 100644 crates/core/rusteda-core/src/diag.rs delete mode 100644 crates/core/rusteda-core/src/job.rs delete mode 100644 crates/core/rusteda-core/src/lib.rs delete mode 100644 crates/core/rusteda-core/src/liberty/func.rs delete mode 100644 crates/core/rusteda-core/src/liberty/kinds.rs delete mode 100644 crates/core/rusteda-core/src/liberty/mod.rs delete mode 100644 crates/core/rusteda-core/src/liberty/model.rs delete mode 100644 crates/core/rusteda-core/src/liberty/table.rs delete mode 100644 crates/core/rusteda-core/src/liberty/units.rs delete mode 100644 crates/core/rusteda-core/src/names.rs delete mode 100644 crates/core/rusteda-core/src/netlist.rs delete mode 100644 crates/core/rusteda-core/src/netlist/edit.rs delete mode 100644 crates/core/rusteda-core/src/netlist/error.rs delete mode 100644 crates/core/rusteda-core/src/netlist/frozen.rs delete mode 100644 crates/core/rusteda-core/src/netlist/tests.rs delete mode 100644 crates/core/rusteda-core/src/netlist/tests_frozen.rs delete mode 100644 crates/core/rusteda-core/src/netlist/tests_random.rs delete mode 100644 crates/core/rusteda-core/src/netlist/validate.rs delete mode 100644 crates/core/rusteda-core/src/symbol.rs delete mode 100644 crates/core/rusteda-workspace/Cargo.toml delete mode 100644 crates/core/rusteda-workspace/README.md delete mode 100644 crates/core/rusteda-workspace/src/cache.rs delete mode 100644 crates/core/rusteda-workspace/src/edits.rs delete mode 100644 crates/core/rusteda-workspace/src/edits/apply.rs delete mode 100644 crates/core/rusteda-workspace/src/edits/tests.rs delete mode 100644 crates/core/rusteda-workspace/src/envelope.rs delete mode 100644 crates/core/rusteda-workspace/src/envelope_tests.rs delete mode 100644 crates/core/rusteda-workspace/src/error.rs delete mode 100644 crates/core/rusteda-workspace/src/fsutil.rs delete mode 100644 crates/core/rusteda-workspace/src/hash.rs delete mode 100644 crates/core/rusteda-workspace/src/json.rs delete mode 100644 crates/core/rusteda-workspace/src/lib.rs delete mode 100644 crates/core/rusteda-workspace/src/lock.rs delete mode 100644 crates/core/rusteda-workspace/src/mmap.rs delete mode 100644 crates/core/rusteda-workspace/src/netlist.rs delete mode 100644 crates/core/rusteda-workspace/src/rbin.rs delete mode 100644 crates/core/rusteda-workspace/src/rbin_tests.rs delete mode 100644 crates/core/rusteda-workspace/src/rev.rs delete mode 100644 crates/core/rusteda-workspace/src/rlib.rs delete mode 100644 crates/core/rusteda-workspace/src/rlib/decode.rs delete mode 100644 crates/core/rusteda-workspace/src/schema.rs delete mode 100644 crates/core/rusteda-workspace/src/sta.rs delete mode 100644 crates/core/rusteda-workspace/src/sta/build.rs delete mode 100644 crates/core/rusteda-workspace/src/sta/layout.rs delete mode 100644 crates/core/rusteda-workspace/src/sta/read.rs delete mode 100644 crates/core/rusteda-workspace/src/sta/tests.rs delete mode 100644 crates/core/rusteda-workspace/src/testutil.rs delete mode 100644 crates/core/rusteda-workspace/src/text.rs delete mode 100644 crates/core/rusteda-workspace/src/txn.rs delete mode 100644 crates/core/rusteda-workspace/src/workspace.rs delete mode 100644 crates/core/rusteda-workspace/src/workspace_io.rs delete mode 100644 crates/core/rusteda-workspace/tests/bench.rs delete mode 100644 crates/core/rusteda-workspace/tests/cache.rs delete mode 100644 crates/core/rusteda-workspace/tests/common/mod.rs delete mode 100644 crates/core/rusteda-workspace/tests/failures.rs delete mode 100644 crates/core/rusteda-workspace/tests/netlist.rs delete mode 100644 crates/core/rusteda-workspace/tests/rbin.rs delete mode 100644 crates/core/rusteda-workspace/tests/rlib.rs delete mode 100644 crates/core/rusteda-workspace/tests/source_rules.rs delete mode 100644 crates/core/rusteda-workspace/tests/sta.rs delete mode 100644 crates/core/rusteda-workspace/tests/workspace.rs delete mode 100644 crates/core/rusteda-workspace/tests/workspace_ops.rs delete mode 100644 crates/formats/rusteda-formats/Cargo.toml delete mode 100644 crates/formats/rusteda-formats/README.md delete mode 100644 crates/formats/rusteda-formats/src/aiger/mod.rs delete mode 100644 crates/formats/rusteda-formats/src/aiger/read.rs delete mode 100644 crates/formats/rusteda-formats/src/blif/flatten.rs delete mode 100644 crates/formats/rusteda-formats/src/blif/lex.rs delete mode 100644 crates/formats/rusteda-formats/src/blif/mod.rs delete mode 100644 crates/formats/rusteda-formats/src/blif/parse.rs delete mode 100644 crates/formats/rusteda-formats/src/lib.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/build.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/bus.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/classify.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/diags.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/func.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/mod.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/syntax.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/tables.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/units.rs delete mode 100644 crates/formats/rusteda-formats/src/liberty/wire_load.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/cmd.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/constr.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/constr/decode.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/constr/encode.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/eval.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/eval/builtins.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/expr.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/filter.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/obj.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/schema.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/schema/build.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/schema/parse.rs delete mode 100644 crates/formats/rusteda-formats/src/sdc/write.rs delete mode 100644 crates/formats/rusteda-formats/src/spef/mod.rs delete mode 100644 crates/formats/rusteda-formats/src/spef/parse.rs delete mode 100644 crates/formats/rusteda-formats/src/spef/source.rs delete mode 100644 crates/formats/rusteda-formats/src/spef/tests.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/ast.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/ast/stmt.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/diag.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/lex.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/mod.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/netlist.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/netlist/freeze_writer.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/netlist/read.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/netlist/scan.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/netlist/tests.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/netlist/tests_read.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/number.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/parse/expr.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/parse/instances.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/parse/items.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/parse/mod.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/parse/stmt.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/parse/subroutines.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/parse/types.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/pp.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/span.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/token.rs delete mode 100644 crates/formats/rusteda-formats/src/verilog/write.rs delete mode 100644 crates/formats/rusteda-formats/tests/aiger_reader.rs delete mode 100644 crates/formats/rusteda-formats/tests/blif_reader.rs delete mode 100644 crates/formats/rusteda-formats/tests/liberty_cells_and_pins.rs delete mode 100644 crates/formats/rusteda-formats/tests/liberty_extensions_and_diags.rs delete mode 100644 crates/formats/rusteda-formats/tests/sdc_commands.rs delete mode 100644 crates/formats/rusteda-formats/tests/sdc_fuzz.rs delete mode 100644 crates/formats/rusteda-formats/tests/sdc_roundtrip.rs delete mode 100644 crates/formats/rusteda-formats/tests/sdc_subset.rs delete mode 100644 crates/formats/rusteda-formats/tests/verilog_parse_design.rs create mode 100644 docs/_tools/foundation_paths.py create mode 100644 docs/_tools/site_hooks.py create mode 100644 xtask/src/foundation.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 54d6162..3967552 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ [alias] xtask = "run --package xtask --quiet --" + +[net] +git-fetch-with-cli = true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1daaff5..70ae18e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -235,3 +235,21 @@ cargo xtask verify pdk --require - The body explains what changed and why, in terms of specifications and tests. - Contributions are accepted under the Developer Certificate of Origin: sign off every commit (`git commit -s`). + + +### Canonical first-party foundation (2026-10-25) + +The four shared library implementations and the workspace CLI implementation are owned by +lab-emi/RustEDA-Common, a private first-party repository. Workspace dependencies and Cargo.lock +pin one exact revision; authorized Git access is required. The local rusteda-cli package is a +product adapter and retains its package version in command output and revision history. + +This supersedes the legacy requirement to copy shared implementation files between tool +repositories, and is the sole first-party exception to the crates.io-only source policy. +Third-party package versions, licenses and source restrictions are unchanged. Shared specification +and automation manifests still require deliberate updates. Canonical core tests run in the +foundation repository; product adapters and engines retain their local tests. + +Do not add a private foundation read key to public workflow logs or caches. Cross-repository +validation belongs in an authorized private integration environment; the unchanged public main +branch retains its existing build policy until a reviewed migration is merged. diff --git a/Cargo.lock b/Cargo.lock index 6b5a48e..6521c5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,23 @@ dependencies = [ "flate2", "libc", "rayon", + "rusteda-cli-common", + "rusteda-core", + "rusteda-formats", + "rusteda-workspace", + "serde", + "serde_json", +] + +[[package]] +name = "rusteda-cli-common" +version = "0.1.0" +source = "git+ssh://git@github.com/lab-emi/RustEDA-Common.git?rev=7e78e705908a3475018ab39df94b94423e158a13#7e78e705908a3475018ab39df94b94423e158a13" +dependencies = [ + "clap", + "clap_complete", + "libc", + "rayon", "rusteda-core", "rusteda-formats", "rusteda-workspace", @@ -427,6 +444,7 @@ dependencies = [ [[package]] name = "rusteda-compute" version = "0.1.0" +source = "git+ssh://git@github.com/lab-emi/RustEDA-Common.git?rev=7e78e705908a3475018ab39df94b94423e158a13#7e78e705908a3475018ab39df94b94423e158a13" dependencies = [ "bytemuck", "cudarc", @@ -437,6 +455,7 @@ dependencies = [ [[package]] name = "rusteda-core" version = "0.1.0" +source = "git+ssh://git@github.com/lab-emi/RustEDA-Common.git?rev=7e78e705908a3475018ab39df94b94423e158a13#7e78e705908a3475018ab39df94b94423e158a13" dependencies = [ "bytemuck", "rustc-hash", @@ -445,6 +464,7 @@ dependencies = [ [[package]] name = "rusteda-formats" version = "0.1.0" +source = "git+ssh://git@github.com/lab-emi/RustEDA-Common.git?rev=7e78e705908a3475018ab39df94b94423e158a13#7e78e705908a3475018ab39df94b94423e158a13" dependencies = [ "flate2", "rayon", @@ -458,6 +478,7 @@ dependencies = [ [[package]] name = "rusteda-workspace" version = "0.1.0" +source = "git+ssh://git@github.com/lab-emi/RustEDA-Common.git?rev=7e78e705908a3475018ab39df94b94423e158a13#7e78e705908a3475018ab39df94b94423e158a13" dependencies = [ "bytemuck", "memmap2", diff --git a/Cargo.toml b/Cargo.toml index 0510169..f8c5999 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,15 +12,16 @@ authors = ["RustSys contributors"] repository = "https://github.com/lab-emi/RustSys" [workspace.dependencies] -rusteda-core = { path = "crates/core/rusteda-core" } -rusteda-workspace = { path = "crates/core/rusteda-workspace" } -rusteda-compute = { path = "crates/core/rusteda-compute" } -rusteda-formats = { path = "crates/formats/rusteda-formats" } +rusteda-core = { git = "ssh://git@github.com/lab-emi/RustEDA-Common.git", rev = "7e78e705908a3475018ab39df94b94423e158a13" } +rusteda-workspace = { git = "ssh://git@github.com/lab-emi/RustEDA-Common.git", rev = "7e78e705908a3475018ab39df94b94423e158a13" } +rusteda-compute = { git = "ssh://git@github.com/lab-emi/RustEDA-Common.git", rev = "7e78e705908a3475018ab39df94b94423e158a13" } +rusteda-formats = { git = "ssh://git@github.com/lab-emi/RustEDA-Common.git", rev = "7e78e705908a3475018ab39df94b94423e158a13" } rustsys = { path = "crates/engines/rustsys" } rustsys-check = { path = "crates/verify/rustsys-check" } rustsys-fixtures = { path = "crates/verify/rustsys-fixtures" } rustsys-defects = { path = "crates/verify/rustsys-defects" } rusteda-cli = { path = "crates/apps/rusteda-cli" } +rusteda-cli-shared = { package = "rusteda-cli-common", git = "ssh://git@github.com/lab-emi/RustEDA-Common.git", rev = "7e78e705908a3475018ab39df94b94423e158a13" } clap = { version = "=4.6.7", default-features = false, features = ["std", "derive", "help", "usage", "error-context", "color", "suggestions"] } clap_complete = { version = "=4.6.11", default-features = false } # Terminal size for the progress line of the command line. `windows`: crossterm does not compile diff --git a/README.md b/README.md index 73a0472..027e47a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ RustSys is a high-performance Verilog/SystemVerilog synthesizer written in Rust. Map RTL to standard cells, export gate-level Verilog and JSON reports, and check equivalence from your terminal, CI pipeline, or coding agent. Part of the **RustEDA** family. +This branch consumes a fixed revision of the private [RustEDA-Common](https://github.com/lab-emi/RustEDA-Common) +foundation. Building it requires authorized Git/SSH access. Shared implementation paths now +belong to that repository; see the canonical-foundation section in [CONTRIBUTING.md](CONTRIBUTING.md). + Verilog source flows through RustSys synthesis into connected standard-cell gates. diff --git a/crates/apps/rusteda-cli/Cargo.toml b/crates/apps/rusteda-cli/Cargo.toml index d2f8b8f..1b359dd 100644 --- a/crates/apps/rusteda-cli/Cargo.toml +++ b/crates/apps/rusteda-cli/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" doc = false [dependencies] +rusteda-cli-shared.workspace = true rusteda-core.workspace = true rusteda-formats.workspace = true rusteda-workspace.workspace = true diff --git a/crates/apps/rusteda-cli/src/export.rs b/crates/apps/rusteda-cli/src/export.rs deleted file mode 100644 index 91f36a2..0000000 --- a/crates/apps/rusteda-cli/src/export.rs +++ /dev/null @@ -1,283 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Workspace table export (`rusteda export --netlist`; RCLI-REQ-0001, RCLI-REQ-0008, RCLI-REQ-0020). - -#[cfg(test)] -use std::collections::HashMap; -#[cfg(test)] -use std::fmt::Write as _; -use std::fs::{self, OpenOptions}; -use std::io::{BufWriter, Write as _}; -use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; - -#[cfg(test)] -use rusteda_core::names::unescape; -#[cfg(test)] -use rusteda_core::netlist::{Conn, GateNetlist, NetId, PortDir}; -#[cfg(test)] -use rusteda_formats::verilog::netlist::from_gate_netlist; -#[cfg(test)] -use rusteda_formats::verilog::write::{ident, write_netlist}; -use rusteda_workspace::text::next_line; -use rusteda_workspace::{Envelope, Error, JsonObject, Workspace, netlist}; - -use crate::OutputOutcome; - -mod frozen; - -/// Formats a [`GateNetlist`] as structural Verilog text, using [`from_gate_netlist`] and -/// [`write_netlist`] when the module uses `[w-1:0]` input/output ports and wires, and falling back -/// to direct `ident`-escaped rendering when the netlist has `inout` ports or general `[msb:lsb]` -/// bus ranges (`RCLI-REQ-0020`). -#[cfg(test)] -fn format_gate_netlist(g: &GateNetlist) -> Result { - if let Ok(m) = from_gate_netlist(g) { - return Ok(write_netlist(&m)); - } - - let mut taken: HashMap = HashMap::new(); - let mut declare = |kind: &'static str, stored: &str| -> Result { - let raw = unescape(stored); - match taken.get(&raw) { - None => { - taken.insert(raw.clone(), (kind, stored.to_string())); - Ok(raw) - } - Some((k, s)) if s == stored => Err(Error::input_rejected(format!( - "{kind} '{stored}' has the name of a {k}; Verilog needs distinct names" - ))), - Some((k, s)) => Err(Error::input_rejected(format!( - "{kind} '{stored}' and {k} '{s}' are both written as '{raw}'; Verilog needs distinct names" - ))), - } - }; - - let mut refs: HashMap = HashMap::new(); - let mut s = String::new(); - let _ = writeln!(s, "module {}(", ident(g.name())); - - for (i, p) in g.ports().iter().enumerate() { - let stored = g.resolve(p.name); - let raw = declare("port", stored)?; - let dir = match p.dir { - PortDir::Input => "input", - PortDir::Output => "output", - PortDir::Inout => "inout", - }; - let range = p - .range - .map(|(msb, lsb)| format!(" [{msb}:{lsb}]")) - .unwrap_or_default(); - let sep = if i + 1 == g.ports().len() { "" } else { "," }; - let _ = writeln!(s, " {dir} wire{range} {}{sep}", ident(&raw)); - for &bit in &p.bits { - let net = g - .net(bit) - .map_err(|e| Error::input_rejected(e.to_string()))?; - let r = match net.index { - None => ident(&raw), - Some(k) => format!("{}[{k}]", ident(&raw)), - }; - refs.insert(bit, r); - } - } - let _ = writeln!(s, ");"); - - let mut groups: Vec<(&str, Vec<(NetId, Option)>)> = Vec::new(); - let mut group_of = HashMap::new(); - for (id, n) in g.nets() { - if refs.contains_key(&id) { - continue; - } - let Some(sym) = n.name else { - return Err(Error::input_rejected(format!( - "net #{} has no name; the writer needs a name for every net", - id.index() - ))); - }; - let k = *group_of.entry(sym).or_insert_with(|| { - groups.push((g.resolve(sym), Vec::new())); - groups.len() - 1 - }); - groups[k].1.push((id, n.index)); - } - - for (stored, bits) in groups { - let raw = declare("net", stored)?; - if let [(id, None)] = bits[..] { - refs.insert(id, ident(&raw)); - let _ = writeln!(s, " wire {};", ident(&raw)); - continue; - } - let Some((_, Some(first_idx))) = bits.first().copied() else { - return Err(Error::input_rejected(format!( - "net '{stored}' mixes scalar and bit-indexed declarations" - ))); - }; - let Some((_, Some(last_idx))) = bits.last().copied() else { - return Err(Error::input_rejected(format!( - "net '{stored}' mixes scalar and bit-indexed declarations" - ))); - }; - let expected_len = (i64::from(first_idx) - i64::from(last_idx)).unsigned_abs() as usize + 1; - let step = if first_idx >= last_idx { -1i64 } else { 1i64 }; - let contiguous = bits.len() == expected_len - && bits.iter().enumerate().all(|(pos, &(_, idx))| { - idx.map(i64::from) == Some(i64::from(first_idx) + (pos as i64) * step) - }); - if !contiguous { - return Err(Error::input_rejected(format!( - "the {} bits of net '{stored}' do not form a contiguous bus range", - bits.len() - ))); - } - for &(id, idx) in &bits { - let k = idx.expect("checked Some above"); - refs.insert(id, format!("{}[{k}]", ident(&raw))); - } - let _ = writeln!(s, " wire [{first_idx}:{last_idx}] {};", ident(&raw)); - } - - let fmt_conn = |c: Conn| -> Result { - match c { - Conn::Const(false) => Ok("1'b0".to_string()), - Conn::Const(true) => Ok("1'b1".to_string()), - Conn::Net(n) => refs - .get(&n) - .cloned() - .ok_or_else(|| Error::input_rejected(format!("net #{} is missing", n.index()))), - } - }; - - for (_, inst) in g.instances() { - let stored = g.resolve(inst.name); - let raw = declare("instance", stored)?; - let cell = g - .cell(inst.cell) - .map_err(|e| Error::input_rejected(e.to_string()))?; - let cell_name = g.resolve(cell.name); - if cell_name == g.name() { - return Err(Error::input_rejected(format!( - "instance '{stored}' has the module's name '{cell_name}' as its cell" - ))); - } - let _ = write!(s, " {} {} (", ident(cell_name), ident(&raw)); - let mut connected_pairs = Vec::new(); - for &p in &inst.connected { - let pin = g.pin(p).map_err(|e| Error::input_rejected(e.to_string()))?; - if let Some(c) = pin.conn { - let pin_name = g.resolve(cell.pins[pin.cell_pin as usize].name); - connected_pairs.push((pin_name, fmt_conn(c)?)); - } - } - for (i, (pin_name, target)) in connected_pairs.iter().enumerate() { - let sep = if i + 1 == connected_pairs.len() { - "" - } else { - ", " - }; - let _ = write!(s, ".{}({target}){sep}", ident(pin_name)); - } - let _ = writeln!(s, ");"); - } - - for a in g.assigns() { - let lhs = fmt_conn(Conn::Net(a.lhs))?; - let rhs = fmt_conn(a.rhs)?; - let _ = writeln!(s, " assign {lhs} = {rhs};"); - } - let _ = writeln!(s, "endmodule"); - Ok(s) -} - -/// Exports the committed `netlist` table from `ws` at `rev_spec` to `out_file` (`RCLI-REQ-0020`). -pub(crate) fn run_export( - ws: &Workspace, - ws_arg: &str, - rev_spec: &str, - out_file: &Path, -) -> Result { - let rev = ws.resolve_query(rev_spec)?; - let revision = ws.revision(rev)?; - let limits = frozen::Limits::default(); - let table_path = revision.table_path(netlist::TABLE_NAME)?; - if fs::metadata(&table_path) - .map_err(|e| Error::io(&table_path, e))? - .len() - > limits.max_table_bytes - { - return Err(Error::input_rejected( - "EXPORT_BUDGET: netlist table exceeds the export byte limit", - )); - } - let rbin = netlist::open_rev(&revision)?; - let f = netlist::decode(&rbin)?; - let plan = frozen::Plan::new(&f, limits)?; - - if let Some(parent) = out_file.parent() { - if !parent.as_os_str().is_empty() { - fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; - } - } - let parent = out_file - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let pending = parent.join(format!( - ".rusteda-export-{}-{nonce}.tmp", - std::process::id() - )); - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&pending) - .map_err(|e| Error::io(&pending, e))?; - let mut writer = BufWriter::with_capacity(1024 * 1024, file); - let written = plan - .write(&mut writer) - .and_then(|()| writer.flush()) - .and_then(|()| writer.get_ref().sync_all()); - drop(writer); - if let Err(e) = written { - let _ = fs::remove_file(&pending); - return Err(Error::io(out_file, e)); - } - if let Err(e) = fs::rename(&pending, out_file) { - let _ = fs::remove_file(&pending); - return Err(Error::io(out_file, e)); - } - - let out_str = out_file.display().to_string(); - let mut env = Envelope::new("rusteda export"); - env.workspace = Some(ws_arg.to_string()); - env.rev = Some(rev); - - let mut metrics = JsonObject::new(); - metrics.insert("instances", &f.instances.len())?; - metrics.insert("nets", &f.nets.len())?; - metrics.insert("ports", &f.ports.len())?; - env.set_metrics(metrics)?; - - env.set("table", &netlist::TABLE_NAME)?; - env.set("output", &out_str)?; - - let rev_s = rev.to_string(); - env.push_next_action(&["rusteda", "show", "-w", ws_arg, "--rev", &rev_s]); - - let mut lines = Vec::new(); - lines.push(format!("{rev} rusteda export netlist -> {out_str}")); - for next in &env.next_actions { - lines.push(next_line(next)); - } - - Ok(OutputOutcome { - envelope: env, - text_lines: lines, - }) -} diff --git a/crates/apps/rusteda-cli/src/export/frozen.rs b/crates/apps/rusteda-cli/src/export/frozen.rs deleted file mode 100644 index 7982c60..0000000 --- a/crates/apps/rusteda-cli/src/export/frozen.rs +++ /dev/null @@ -1,495 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Read-only, streaming Verilog export from validated mmap tables. -//! -//! C910 planning: ~720 MiB mapped data + <=200 MiB wire-group records + name -//! flags, a 1 MiB output buffer and small per-cell strings. No mutable netlist, -//! duplicated connection graph, or whole output String is materialized. General -//! escaped-name collision checking has an explicit auxiliary-memory budget. -//! All scans are linear in table size; no graph paths or cones are traversed. - -use rayon::prelude::*; -use std::borrow::Cow; -use std::collections::HashMap; -use std::io::{self, Write}; - -use rusteda_core::names::unescape; -use rusteda_formats::verilog::write::{ident, is_simple_ident}; -use rusteda_workspace::Error; -use rusteda_workspace::netlist::{ANONYMOUS, CONST0, CONST1, FrozenNetlist, SCALAR, module_entry}; - -#[derive(Debug, Clone, Copy)] -pub(super) struct Limits { - pub max_nets: usize, - pub max_instances: usize, - pub max_strings: usize, - pub max_cells: usize, - pub max_table_bytes: u64, - pub max_aux_bytes: u64, - pub max_name_bytes: usize, -} - -impl Default for Limits { - fn default() -> Self { - Self { - max_nets: 20_000_000, - max_instances: 10_000_000, - max_strings: 32_000_000, - max_cells: 65_536, - max_table_bytes: 2 * 1024 * 1024 * 1024, - max_aux_bytes: 1024 * 1024 * 1024, - max_name_bytes: 1024 * 1024, - } - } -} - -#[derive(Clone, Copy)] -struct Group { - first: u32, - last: u32, - count: u32, - min: i32, - max: i32, - order: u32, -} - -impl Default for Group { - fn default() -> Self { - Self { - first: u32::MAX, - last: 0, - count: 0, - min: 0, - max: 0, - order: 3, - } - } -} - -fn raw(name: &str) -> Cow<'_, str> { - if name.contains('\\') { - Cow::Owned(unescape(name)) - } else { - Cow::Borrowed(name) - } -} - -fn input(message: impl Into) -> Error { - Error::input_rejected(message) -} - -fn string<'f>(f: &'f FrozenNetlist<'_>, sym: u32) -> &'f str { - f.string(sym).expect("validated frozen string") -} - -fn kind_name(kind: u8) -> &'static str { - match kind { - 1 => "port", - 2 => "net", - _ => "instance", - } -} - -pub(super) struct Plan<'f, 'a> { - f: &'f FrozenNetlist<'a>, - groups: Vec, - port_names: Vec, - normal_ranges: bool, - parallel: bool, -} - -fn identifier(out: &mut impl Write, name: &str) -> io::Result<()> { - if is_simple_ident(name) { - out.write_all(name.as_bytes()) - } else { - write!(out, "\\{name} ") - } -} - -struct Chunk(Vec); -impl Write for Chunk { - fn write(&mut self, value: &[u8]) -> io::Result { - const LIMIT: usize = 2 * 1024 * 1024; - let needed = self - .0 - .len() - .checked_add(value.len()) - .filter(|n| *n <= LIMIT) - .ok_or_else(|| io::Error::other("EXPORT_CHUNK_BUDGET"))?; - if needed > self.0.capacity() { - self.0 - .reserve_exact(needed.next_power_of_two().min(LIMIT) - self.0.len()); - } - self.0.extend_from_slice(value); - Ok(value.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -impl<'f, 'a> Plan<'f, 'a> { - /// `f` must have passed the workspace decoder's complete table validation. - pub fn new(f: &'f FrozenNetlist<'a>, limits: Limits) -> Result { - let strings = f.str_offs.len().saturating_sub(1); - if f.nets.len() > limits.max_nets - || f.instances.len() > limits.max_instances - || strings > limits.max_strings - || f.cells.len() > limits.max_cells - { - return Err(input( - "EXPORT_BUDGET: frozen netlist exceeds entity-count limits", - )); - } - if f.str_offs - .windows(2) - .any(|w| (w[1] - w[0]) as usize > limits.max_name_bytes) - { - return Err(input("EXPORT_BUDGET: name exceeds the per-name byte limit")); - } - let group_count = f - .nets - .iter() - .filter(|n| n.name != ANONYMOUS) - .map(|n| n.name as usize + 1) - .max() - .unwrap_or(0); - let escaped = f.str_bytes.contains(&b'\\'); - let mut aux = group_count as u128 * (size_of::() + size_of::()) as u128 - + strings as u128; - aux += f.cells.len() as u128 * size_of::<(String, usize)>() as u128 - + f.cells - .iter() - .map(|&name| string(f, name).len() as u128 + 2) - .sum::(); - aux += f.cell_pin_name.len() as u128 * size_of::() as u128 - + f.cell_pin_name - .iter() - .map(|&name| string(f, name).len() as u128 + 2) - .sum::(); - if escaped { - // Cow keys borrow ordinary names. Count all possible owned raw - // names and generous hash-table capacity before allocating it. - let decls = (f.nets.len() + f.instances.len() + f.ports.len()) as u128; - aux += decls - .checked_next_power_of_two() - .unwrap_or(u128::MAX) - .saturating_mul(64) - + f.str_bytes.len() as u128; - } - if aux > limits.max_aux_bytes as u128 { - return Err(input( - "EXPORT_BUDGET: auxiliary name/group storage exceeds its limit", - )); - } - let mut groups = vec![Group::default(); group_count]; - let mut port_names = vec![false; group_count]; - let mut seen = vec![0u8; strings]; - let mut taken: Option, (u32, u8)>> = escaped.then(HashMap::new); - let mut declare = |sym: u32, kind: u8| -> Result<(), Error> { - let stored = string(f, sym); - if seen[sym as usize] != 0 { - return Err(input(format!( - "{} '{stored}' has the name of a {}; Verilog needs distinct names", - kind_name(kind), - kind_name(seen[sym as usize]) - ))); - } - if let Some(taken) = &mut taken { - let name = raw(stored); - if let Some(&(old, old_kind)) = taken.get(name.as_ref()) { - return Err(input(format!( - "{} '{stored}' and {} '{}' are both written as '{name}'; Verilog needs distinct names", - kind_name(kind), - kind_name(old_kind), - string(f, old) - ))); - } - taken.insert(name, (sym, kind)); - } - seen[sym as usize] = kind; - Ok(()) - }; - let mut normal_ranges = true; - for port in f.ports.iter() { - declare(port.name, 1)?; - port_names[port.name as usize] = true; - normal_ranges &= - port.dir != 2 && (port.is_bus == 0 || (port.msb >= 0 && port.lsb == 0)); - } - for (id, net) in f.nets.iter().enumerate() { - if net.name == ANONYMOUS { - return Err(input(format!( - "net{id} has no name; the writer needs a name for every net" - ))); - } - if port_names[net.name as usize] { - continue; - } - let group = &mut groups[net.name as usize]; - if group.count == 0 { - declare(net.name, 2)?; - group.first = id as u32; - group.min = net.index; - group.max = net.index; - } else { - let previous = f.nets[group.last as usize].index as i64; - if net.index as i64 != previous + 1 { - group.order &= !1; - } - if net.index as i64 != previous - 1 { - group.order &= !2; - } - group.min = group.min.min(net.index); - group.max = group.max.max(net.index); - } - group.last = id as u32; - group.count += 1; - } - for group in groups.iter().filter(|g| g.count > 0) { - normal_ranges &= group.min == SCALAR - || (group.min == 0 && group.max as u64 + 1 == group.count as u64); - } - if !normal_ranges { - for group in groups.iter().filter(|g| g.count > 0 && g.min != SCALAR) { - if group.order == 0 || group.max as i64 - group.min as i64 + 1 != group.count as i64 - { - return Err(input("wire bits do not form a contiguous bus range")); - } - } - } - let module = string(f, f.module[module_entry::NAME]); - for instance in f.instances.iter() { - declare(instance.name, 3)?; - if string(f, f.cells[instance.cell as usize]) == module { - return Err(input(format!( - "instance '{}' has the module's name '{module}' as its cell", - string(f, instance.name) - ))); - } - } - Ok(Self { - f, - groups, - port_names, - normal_ranges, - parallel: aux + 32 * 1024 * 1024 <= limits.max_aux_bytes as u128, - }) - } - - fn reference(&self, out: &mut impl Write, conn: u32) -> io::Result<()> { - match conn { - CONST0 => write!(out, "1'b0"), - CONST1 => write!(out, "1'b1"), - id => { - let net = &self.f.nets[id as usize]; - identifier(out, &raw(string(self.f, net.name)))?; - if net.index != SCALAR { - write!(out, "[{}]", net.index)?; - } - Ok(()) - } - } - } - - fn instance( - &self, - out: &mut impl Write, - id: usize, - cells: &[(String, usize)], - pins: &[String], - ) -> io::Result<()> { - let f = self.f; - let instance = &f.instances[id]; - let (cell, pin_start) = &cells[instance.cell as usize]; - write!(out, " {cell} ")?; - identifier(out, &raw(string(f, instance.name)))?; - out.write_all(b" (")?; - let range = f.instance_pins(id as u32); - for at in range.clone() { - if at != range.start { - out.write_all(b", ")?; - } - write!(out, ".{}(", pins[pin_start + f.pin_cellpin[at] as usize])?; - self.reference(out, f.pin_net[at])?; - out.write_all(b")")?; - } - out.write_all(b");\n") - } - - pub fn write(&self, out: &mut impl Write) -> io::Result<()> { - let f = self.f; - writeln!( - out, - "module {}(", - ident(string(f, f.module[module_entry::NAME])) - )?; - for (id, port) in f.ports.iter().enumerate() { - let direction = match port.dir { - 0 => "input", - 1 => "output", - _ => "inout", - }; - write!(out, " {direction} wire")?; - if port.is_bus != 0 { - write!(out, " [{}:{}]", port.msb, port.lsb)?; - } - let sep = if id + 1 == f.ports.len() { "" } else { "," }; - writeln!(out, " {}{sep}", ident(&raw(string(f, port.name))))?; - } - writeln!(out, ");")?; - for (id, net) in f.nets.iter().enumerate() { - if self.port_names[net.name as usize] { - continue; - } - let group = self.groups[net.name as usize]; - if group.first as usize != id { - continue; - } - write!(out, " wire")?; - if net.index != SCALAR { - let (msb, lsb) = if self.normal_ranges { - ((group.count - 1) as i32, 0) - } else { - (net.index, f.nets[group.last as usize].index) - }; - write!(out, " [{msb}:{lsb}]")?; - } - writeln!(out, " {};", ident(&raw(string(f, net.name))))?; - } - let cells: Vec<_> = f - .cells - .iter() - .enumerate() - .map(|(id, &name)| (ident(string(f, name)), f.cell_pin_start[id] as usize)) - .collect(); - let pins: Vec<_> = f - .cell_pin_name - .iter() - .map(|&name| ident(string(f, name))) - .collect(); - for first in (0..f.instances.len()).step_by(8192) { - let end = (first + 8192).min(f.instances.len()); - if self.parallel && end - first >= 1024 && rayon::current_num_threads() > 1 { - let starts: Vec<_> = (first..end).step_by(512).collect(); - let chunks: Vec<_> = starts - .par_iter() - .map(|&from| { - let mut chunk = Chunk(Vec::with_capacity(65_536)); - for id in from..(from + 512).min(end) { - self.instance(&mut chunk, id, &cells, &pins).ok()?; - } - Some(chunk.0) - }) - .collect(); - for (from, chunk) in starts.into_iter().zip(chunks) { - if let Some(bytes) = chunk { - out.write_all(&bytes)?; - } else { - for id in from..(from + 512).min(end) { - self.instance(out, id, &cells, &pins)?; - } - } - } - } else { - for id in first..end { - self.instance(out, id, &cells, &pins)?; - } - } - } - for assign in f.assigns.iter() { - write!(out, " assign ")?; - self.reference(out, assign.lhs)?; - write!(out, " = ")?; - self.reference(out, assign.rhs)?; - writeln!(out, ";")?; - } - writeln!(out, "endmodule") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rusteda_core::netlist::{Conn, GateNetlist, PinDir, PortDir}; - - //2048 cells, O(V+E) storage <16MiB plus <=32MiB temporary text; no path traversal. - #[test] - fn parallel_frozen_export_preserves_text_and_bounded_fallback() { - let mut g = GateNetlist::new("top").unwrap(); - let cell = g - .add_cell_type("inv", &[("A", PinDir::Input), ("Y", PinDir::Output)]) - .unwrap(); - g.add_port("a", PortDir::Input, None).unwrap(); - g.add_port("y", PortDir::Output, None).unwrap(); - let mut previous = g.find_net("a", None).unwrap(); - for i in 0..2048 { - let n = g.add_net(Some(&format!("net.{i}")), None).unwrap(); - let inst = g.add_instance(&format!("unit.{i}"), cell).unwrap(); - g.connect(inst, "A", Conn::Net(previous)).unwrap(); - g.connect(inst, "Y", Conn::Net(n)).unwrap(); - previous = n; - } - g.add_assign(g.find_net("y", None).unwrap(), Conn::Net(previous)) - .unwrap(); - let f = g.freeze(); - f.validate().unwrap(); - let mut plan = Plan::new(&f, Limits::default()).unwrap(); - let render = |plan: &Plan<'_, '_>, threads| { - rayon::ThreadPoolBuilder::new() - .num_threads(threads) - .build() - .unwrap() - .install(|| { - let mut out = Vec::new(); - plan.write(&mut out).unwrap(); - out - }) - }; - let serial = render(&plan, 1); - assert_eq!(serial, render(&plan, 16)); - plan.parallel = false; - assert_eq!(serial, render(&plan, 16)); - let mut chunk = Chunk(Vec::new()); - assert!(chunk.write_all(&vec![0; 2 * 1024 * 1024 + 1]).is_err()); - assert!(chunk.0.is_empty()); - } - - #[test] - fn frozen_output_matches_legacy_without_thawing_or_output_buffering() { - for range in [None, Some((3, 0)), Some((0, 3)), Some((9, 6))] { - let mut g = GateNetlist::new("top").unwrap(); - let cell = g - .add_cell_type("inv", &[("A", PinDir::Input), ("Y", PinDir::Output)]) - .unwrap(); - g.add_port("a", PortDir::Input, range).unwrap(); - g.add_port("y", PortDir::Output, None).unwrap(); - let n = g.add_net(Some(r"u\/wire"), None).unwrap(); - let u = g.add_instance(r"u\/inst", cell).unwrap(); - g.connect(u, "Y", Conn::Net(n)).unwrap(); - g.connect(u, "A", Conn::Const(true)).unwrap(); - g.add_assign(g.find_net("y", None).unwrap(), Conn::Net(n)) - .unwrap(); - let f = g.freeze(); - f.validate().unwrap(); - let plan = Plan::new(&f, Limits::default()).unwrap(); - let mut output = Vec::new(); - plan.write(&mut output).unwrap(); - assert_eq!( - String::from_utf8(output).unwrap(), - super::super::format_gate_netlist(&g).unwrap() - ); - assert!( - Plan::new( - &f, - Limits { - max_aux_bytes: 0, - ..Limits::default() - } - ) - .is_err() - ); - } - } -} diff --git a/crates/apps/rusteda-cli/src/import.rs b/crates/apps/rusteda-cli/src/import.rs deleted file mode 100644 index 00df24b..0000000 --- a/crates/apps/rusteda-cli/src/import.rs +++ /dev/null @@ -1,216 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! `rusteda import` orchestration (RCLI-REQ-0011 to RCLI-REQ-0014). - -use std::path::PathBuf; - -use rusteda_workspace::text::{mutation_header, next_line}; -use rusteda_workspace::{ - DiagCode, Diagnostic, Envelope, Error, Invocation, JsonMap, JsonObject, Severity, Workspace, -}; - -use crate::OutputOutcome; -use crate::import_lib::import_libs; -use crate::import_netlist::import_netlist; -use crate::import_rtl::{RtlImportArgs, import_rtl}; -use crate::import_sdc::import_sdc; - -/// Parsed options for `rusteda import` (RCLI-REQ-0011). -#[derive(Debug, Clone)] -pub(crate) struct ImportCommand { - pub base_rev: String, - pub message: Option, - pub libs: Vec<(PathBuf, String)>, - pub rtl: Option, - pub netlist: Option, - /// Budget of the full netlist reader in MiB (RCLI-REQ-0024). - pub max_netlist_ast_mib: u64, - pub sdc_files: Vec, - pub sdc_env: Vec, - /// `(--spef, --spef-corner)` pairs in command-line order (RCLI-REQ-0075). - pub spefs: Vec<(PathBuf, String)>, - pub spef_limits: rusteda_formats::spef::Limits, -} - -/// Runs `rusteda import` (RCLI-REQ-0011). -pub(crate) fn run_import( - ws: &Workspace, - ws_arg: &str, - cmd: &ImportCommand, - inv: &Invocation, - diags: &mut Vec, -) -> Result { - if cmd.libs.is_empty() - && cmd.rtl.is_none() - && cmd.netlist.is_none() - && cmd.sdc_files.is_empty() - && cmd.spefs.is_empty() - { - let err = Error::usage( - "import requires at least one of --lib, --rtl, --netlist, --sdc, or --spef", - ); - let _ = ws.log_rejected(inv, None, &err); - return Err(err); - } - - let base = match ws.resolve_base(&cmd.base_rev) { - Ok(b) => b, - Err(e) => { - let _ = ws.log_rejected(inv, None, &e); - return Err(e); - } - }; - - // WS-REQ-0060, WS-REQ-0069: `begin` logs its own failure (including REVISION_CONFLICT). - let mut txn = ws.begin(base, inv)?; - if let Some(msg) = &cmd.message { - txn.set_message(msg.clone()); - } - - let mut metrics = JsonMap::new(); - let step_res = (|| -> Result<(), Error> { - if !cmd.libs.is_empty() { - import_libs(ws, &mut txn, &cmd.libs, &mut metrics, diags)?; - } - if let Some(rtl_args) = &cmd.rtl { - import_rtl(&mut txn, rtl_args, &mut metrics, diags)?; - } - if let Some(netlist_path) = &cmd.netlist { - import_netlist( - ws, - &mut txn, - netlist_path, - cmd.max_netlist_ast_mib, - &mut metrics, - diags, - )?; - } - if !cmd.sdc_files.is_empty() { - import_sdc( - ws, - &mut txn, - &cmd.sdc_files, - &cmd.sdc_env, - &mut metrics, - diags, - )?; - } - if !cmd.spefs.is_empty() { - crate::import_spef::import_spefs( - &mut txn, - &cmd.spefs, - cmd.spef_limits, - &mut metrics, - diags, - )?; - } - Ok(()) - })(); - - if let Err(err) = step_res { - let _ = txn.reject(&err); - return Err(err); - } - - let committed = txn.commit(metrics.clone())?; - diags.extend(committed.warnings); - // A dropped `parasitics` table is never silent: the next timing run of the revision has no - // extracted parasitics (RCLI-REQ-0076). - if let Some(dropped) = committed - .meta - .dropped - .iter() - .find(|d| d.table == crate::import_spef::TABLE) - { - let corners: Vec = ws - .revision(committed.base) - .ok() - .and_then(|base| crate::import_spef::read_corners(&base).ok()) - .map(|c| c.into_iter().map(|e| e.source.corner).collect()) - .unwrap_or_default(); - let names = if corners.is_empty() { - String::new() - } else { - format!(" of corner(s) {}", corners.join(", ")) - }; - diags.push(Diagnostic::new( - Severity::Warning, - DiagCode::of("T0140"), - format!( - "the extracted parasitics{names} were dropped ({}): timing of {} uses no SPEF \ - until it is imported again; `--require-wire-model spef_rc` makes such a run \ - an error", - dropped.reason, committed.rev - ), - )); - } - - let mut env = Envelope::new("rusteda import"); - env.workspace = Some(ws_arg.to_string()); - env.base_rev = Some(committed.base); - env.new_rev = Some(committed.rev); - - let mut metrics_obj = JsonObject::new(); - for (k, v) in &metrics { - metrics_obj.insert(k, v)?; - } - env.set_metrics(metrics_obj)?; - - env.set("tables", &committed.meta.tables)?; - env.set("libraries", &committed.meta.libraries)?; - env.set("sources", &committed.meta.sources)?; - env.set("dropped", &committed.meta.dropped)?; - env.diagnostics = diags.clone(); - - let new_rev_s = committed.rev.to_string(); - let base_rev_s = committed.base.to_string(); - env.push_next_action(&["rusteda", "show", "-w", ws_arg, "--rev", &new_rev_s]); - env.push_next_action(&[ - "rusteda", - "diff", - "-w", - ws_arg, - "--from", - &base_rev_s, - "--to", - &new_rev_s, - ]); - - let mut lines = Vec::new(); - lines.push(mutation_header( - committed.rev, - "rusteda import", - committed.base, - )); - if !committed.meta.tables.is_empty() { - let names: Vec<&str> = committed.meta.tables.keys().map(String::as_str).collect(); - lines.push(format!(" tables: {}", names.join(", "))); - } - if !committed.meta.libraries.is_empty() { - let libs_desc: Vec = committed - .meta - .libraries - .iter() - .map(|l| format!("{} ({})", l.name, l.corner)) - .collect(); - lines.push(format!(" libraries: {}", libs_desc.join(", "))); - } - if !committed.meta.dropped.is_empty() { - let dropped_desc: Vec<&str> = committed - .meta - .dropped - .iter() - .map(|d| d.table.as_str()) - .collect(); - lines.push(format!(" dropped: {}", dropped_desc.join(", "))); - } - for next in &env.next_actions { - lines.push(next_line(next)); - } - - Ok(OutputOutcome { - envelope: env, - text_lines: lines, - }) -} diff --git a/crates/apps/rusteda-cli/src/import_lib.rs b/crates/apps/rusteda-cli/src/import_lib.rs deleted file mode 100644 index fa764d5..0000000 --- a/crates/apps/rusteda-cli/src/import_lib.rs +++ /dev/null @@ -1,231 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Liberty library import (`rusteda import --lib`; RCLI-REQ-0012, RCLI-REQ-0013). - -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::PathBuf; - -use rusteda_core::diag as core_diag; -use rusteda_formats::liberty::{self, Library}; -use rusteda_workspace::{DiagCode, Diagnostic, Error, JsonMap, Severity, Txn, Workspace, rlib}; -use serde_json::json; - -/// Converts a `rusteda_core::diag::Diag` from the Liberty reader into an envelope [`Diagnostic`]. -fn convert_diag(d: core_diag::Diag, fallback_file: &str) -> Diagnostic { - let sev = match d.severity { - core_diag::Severity::Note => Severity::Note, - core_diag::Severity::Warning => Severity::Warning, - core_diag::Severity::Error => Severity::Error, - }; - let code = DiagCode::parse(&d.code).unwrap_or(DiagCode::of("L0001")); - let loc = d.location.map(|l| { - let file = if l.file.is_empty() { - fallback_file.to_string() - } else { - l.file - }; - (file, l.line.max(1), l.column.max(1)) - }); - Diagnostic::from_parts(sev, code, loc, d.message, d.help) -} - -/// Validates and caches `--lib` inputs and adds them to `txn` (RCLI-REQ-0012, RCLI-REQ-0013). -pub(crate) fn import_libs( - ws: &Workspace, - txn: &mut Txn, - libs: &[(PathBuf, String)], - metrics: &mut JsonMap, - diags: &mut Vec, -) -> Result<(), Error> { - if libs.is_empty() { - return Ok(()); - } - - // Track (library_name, corner) pairs given in this invocation so duplicates are rejected. - let mut seen_in_invocation: BTreeSet<(String, String)> = BTreeSet::new(); - // Track parsed libraries per corner to check cell uniqueness within each corner. - let mut parsed_new: Vec<(PathBuf, String, String, Vec, Library)> = - Vec::with_capacity(libs.len()); - - for (path, corner) in libs { - let source_str = path.display().to_string(); - let meta = fs::symlink_metadata(path).map_err(|e| Error::io(path, e))?; - if !meta.file_type().is_file() { - let msg = format!("{source_str}: not a regular file"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("L0001"), msg.clone()) - .at(source_str, 1, 1), - ); - return Err(Error::input_rejected(msg)); - } - let bytes = fs::read(path).map_err(|e| Error::io(path, e))?; - let text = std::str::from_utf8(&bytes).map_err(|e| { - let msg = format!("{source_str}:1:1: invalid UTF-8 in Liberty file: {e}"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("L0001"), msg.clone()).at( - source_str.clone(), - 1, - 1, - ), - ); - Error::input_rejected(msg) - })?; - - let (lib, reader_diags) = match liberty::read_text_diags(text, &source_str) { - Ok(pair) => pair, - Err(liberty::Error::Syntax { - file, - line, - col, - msg, - }) => { - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("L0001"), msg.clone()).at( - file.clone(), - line.max(1), - col.max(1), - ), - ); - return Err(Error::input_rejected(format!("{file}:{line}:{col}: {msg}"))); - } - Err(liberty::Error::Model { file, msg }) => { - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("L0002"), msg.clone()).at( - file.clone(), - 1, - 1, - ), - ); - return Err(Error::input_rejected(format!("{file}:1:1: {msg}"))); - } - Err(liberty::Error::Io { path: p, msg }) => { - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("L0001"), msg.clone()).at( - p.clone(), - 1, - 1, - ), - ); - return Err(Error::input_rejected(format!("{p}:1:1: {msg}"))); - } - }; - - let mut first_err: Option = None; - for rd in reader_diags { - let converted = convert_diag(rd, &source_str); - if converted.severity == Severity::Error && first_err.is_none() { - first_err = Some(match &converted.location { - Some(loc) => format!( - "{}:{}:{}: {}", - loc.file, loc.line, loc.col, converted.message - ), - None => format!("{source_str}:1:1: {}", converted.message), - }); - } - diags.push(converted); - } - if let Some(err_msg) = first_err { - return Err(Error::input_rejected(err_msg)); - } - - if !seen_in_invocation.insert((lib.name.clone(), corner.clone())) { - let msg = format!( - "{source_str}:1:1: library '{}' is given twice for corner '{corner}'", - lib.name - ); - diags.push( - Diagnostic::new( - Severity::Error, - DiagCode::of("L0003"), - format!( - "library '{}' is given twice for corner '{corner}'", - lib.name - ), - ) - .at(source_str, 1, 1), - ); - return Err(Error::input_rejected(msg)); - } - - // Check duplicate cell definitions within this library itself (RCLI-REQ-0013). - let mut local_cells = BTreeSet::new(); - for cell in &lib.cells { - if !local_cells.insert(cell.name.as_str()) { - let msg = format!( - "{source_str}:1:1: cell '{}' is defined twice in corner '{corner}' (library '{}')", - cell.name, lib.name - ); - diags.push( - Diagnostic::new( - Severity::Error, - DiagCode::of("L0055"), - format!( - "cell '{}' is defined twice in corner '{corner}' (library '{}')", - cell.name, lib.name - ), - ) - .at(source_str, 1, 1), - ); - return Err(Error::input_rejected(msg)); - } - } - - parsed_new.push((path.clone(), source_str, corner.clone(), bytes, lib)); - } - - // Check cell uniqueness across all libraries in each affected corner (including carried base - // libraries that are not replaced by `(name, corner)`). - let mut corner_cells: BTreeMap> = BTreeMap::new(); - for existing in txn.libraries() { - if seen_in_invocation.contains(&(existing.name.clone(), existing.corner.clone())) { - continue; - } - if let Some(cached) = ws.cache().lookup(&existing.name, existing.hash) { - if let Ok(text) = fs::read_to_string(&cached.lib_path) { - if let Ok(lib) = liberty::read_text(&text, &cached.lib) { - let map = corner_cells.entry(existing.corner.clone()).or_default(); - for cell in lib.cells { - map.insert(cell.name, existing.name.clone()); - } - } - } - } - } - - for (_, source_str, corner, _, lib) in &parsed_new { - let map = corner_cells.entry(corner.clone()).or_default(); - for cell in &lib.cells { - if let Some(prev_lib) = map.insert(cell.name.clone(), lib.name.clone()) { - let msg = format!( - "{source_str}:1:1: cell '{}' in library '{}' is already defined in library '{prev_lib}' for corner '{corner}'", - cell.name, lib.name - ); - diags.push( - Diagnostic::new( - Severity::Error, - DiagCode::of("L0055"), - format!( - "cell '{}' in library '{}' is already defined in library '{prev_lib}' for corner '{corner}'", - cell.name, lib.name - ), - ) - .at(source_str.clone(), 1, 1), - ); - return Err(Error::input_rejected(msg)); - } - } - } - - let mut total_cells = 0usize; - for (_, source_str, corner, bytes, lib) in parsed_new { - total_cells += lib.cells.len(); - let cached = rlib::put(ws, &lib, &bytes)?; - txn.add_library(cached.entry(corner, source_str))?; - } - - metrics.insert("libraries".to_string(), json!(libs.len())); - metrics.insert("library_cells".to_string(), json!(total_cells)); - Ok(()) -} diff --git a/crates/apps/rusteda-cli/src/import_logic.rs b/crates/apps/rusteda-cli/src/import_logic.rs deleted file mode 100644 index 59eeb1b..0000000 --- a/crates/apps/rusteda-cli/src/import_logic.rs +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! BLIF and AIGER snapshots of `rusteda import --rtl` (RCLI-REQ-0021, RCLI-REQ-0022): the files -//! are validated with the shared readers and stored as given, with their format in `rtl.json`. - -use std::collections::BTreeMap; -use std::fs; -use std::path::{Path, PathBuf}; - -use rusteda_core::diag::{Diag, Severity as DSev}; -use rusteda_workspace::{DiagCode, Diagnostic, Error, Hash64, JsonMap, Severity, Txn, Upstream}; -use serde_json::json; - -use crate::import_rtl::{RtlFileEntry, RtlImportArgs, RtlManifest}; - -/// A format of logic-network sources. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LogicFormat { - Blif, - Aiger, -} - -impl LogicFormat { - fn name(self) -> &'static str { - match self { - LogicFormat::Blif => "blif", - LogicFormat::Aiger => "aiger", - } - } -} - -/// The format of one file by its extension, ignoring letter case: `None` for SystemVerilog. -fn of(p: &Path) -> Option { - match p - .extension() - .map(|e| e.to_string_lossy().to_ascii_lowercase()) - .as_deref() - { - Some("blif") => Some(LogicFormat::Blif), - Some("aag" | "aig") => Some(LogicFormat::Aiger), - _ => None, - } -} - -/// The format of the `--rtl` files: `None` for SystemVerilog; a mix of formats, or several AIGER -/// files, is a `USAGE` error (RCLI-REQ-0021). -pub(crate) fn format_of(files: &[PathBuf]) -> Result, Error> { - let Some(first) = files.first().map(|f| of(f)) else { - return Ok(None); - }; - if files.iter().any(|f| of(f) != first) { - return Err(Error::usage( - "--rtl files of several formats; one import takes SystemVerilog, BLIF or AIGER files", - )); - } - if first == Some(LogicFormat::Aiger) && files.len() > 1 { - return Err(Error::usage("--rtl takes one AIGER file")); - } - Ok(first) -} - -/// A reader message as a workspace diagnostic. -fn convert(d: &Diag) -> Diagnostic { - let sev = match d.severity { - DSev::Note => Severity::Note, - DSev::Warning => Severity::Warning, - DSev::Error => Severity::Error, - }; - let code = DiagCode::parse(&d.code).unwrap_or(DiagCode::of("W0002")); - let loc = d - .location - .as_ref() - .map(|l| (l.file.clone(), l.line.max(1), l.column.max(1))); - Diagnostic::from_parts(sev, code, loc, d.message.clone(), d.help.clone()) -} - -/// Validates and snapshots BLIF or AIGER `--rtl` files into `txn` (RCLI-REQ-0021, -/// RCLI-REQ-0022). -pub(crate) fn import_logic( - txn: &mut Txn, - args: &RtlImportArgs, - format: LogicFormat, - metrics: &mut JsonMap, - diags: &mut Vec, -) -> Result<(), Error> { - if !args.defines.is_empty() - || !args.include_dirs.is_empty() - || !args.params.is_empty() - || args.no_predefined_macros - { - return Err(Error::usage( - "-D, -I, -P and --no-predefined-macros apply to SystemVerilog files only", - )); - } - let top = args.top.trim(); - if top.is_empty() { - return Err(Error::usage("--top name must not be empty")); - } - // Validation with the shared readers. - let checked = match format { - LogicFormat::Blif => { - let o = rusteda_formats::blif::Options { - top: Some(top.to_string()), - ..Default::default() - }; - rusteda_formats::blif::read_files(&args.files, &o).map(|r| r.warnings) - } - LogicFormat::Aiger => { - rusteda_formats::aiger::read_file(&args.files[0], &Default::default()).map(|_| vec![]) - } - }; - match checked { - Ok(warnings) => diags.extend(warnings.iter().map(convert)), - Err(errors) => { - diags.extend(errors.iter().map(convert)); - let first = errors - .first() - .map_or_else(|| "invalid source".to_string(), Diag::render_first_line); - return Err(Error::input_rejected(first)); - } - } - // The snapshot, stored as given. - let rtl_dir = txn.staging_path("rtl")?; - fs::create_dir_all(&rtl_dir).map_err(|e| Error::io(&rtl_dir, e))?; - let mut files = BTreeMap::new(); - for (idx, file) in args.files.iter().enumerate() { - let bytes = fs::read(file).map_err(|e| Error::io(file, e))?; - let hash = Hash64::of(&bytes); - txn.add_source("rtl", file.display().to_string(), hash)?; - let fname = file - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("source"); - let rel = format!("src_{idx}_{fname}"); - let dst = rtl_dir.join(&rel); - fs::write(&dst, &bytes).map_err(|e| Error::io(&dst, e))?; - files.insert(rel, hash); - } - let manifest = RtlManifest { - top: top.to_string(), - defines: BTreeMap::new(), - params: BTreeMap::new(), - files: files - .into_iter() - .map(|(path, hash)| RtlFileEntry { path, hash }) - .collect(), - predefined_macros: true, - format: Some(format.name().to_string()), - }; - let mut text = serde_json::to_string_pretty(&manifest) - .map_err(|e| Error::internal(format!("serializing rtl.json: {e}")))?; - text.push('\n'); - let path = rtl_dir.join("rtl.json"); - fs::write(&path, text.as_bytes()).map_err(|e| Error::io(&path, e))?; - txn.add_dir("rtl", "rtl", Upstream::new())?; - let n = manifest.files.len(); - txn.set_summary("rtl", "rtl_files", n)?; - metrics.insert("rtl_files".to_string(), json!(n)); - metrics.insert("rtl_format".to_string(), json!(format.name())); - Ok(()) -} diff --git a/crates/apps/rusteda-cli/src/import_netlist.rs b/crates/apps/rusteda-cli/src/import_netlist.rs deleted file mode 100644 index ced8416..0000000 --- a/crates/apps/rusteda-cli/src/import_netlist.rs +++ /dev/null @@ -1,455 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Structural netlist import (`rusteda import --netlist`; RCLI-REQ-0011, RCLI-REQ-0019). - -mod ast; -mod fast; - -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::Path; - -use rusteda_core::liberty::{Direction as LibDir, Library}; -use rusteda_core::names::escape_segment; -use rusteda_core::netlist::{GateNetlist, MAX_RANGE_WIDTH, PinDir}; -use rusteda_formats::liberty; -use rusteda_formats::verilog::ast::{ - DataType, Expr, ExprKind, Item, Module, ModuleItem, NetKind, PortConn, PortList, -}; -use rusteda_formats::verilog::diag::{Diagnostic as VDiag, Severity as VSev}; -use rusteda_formats::verilog::netlist::read_structural; -use rusteda_formats::verilog::number::{Bit4, Literal}; -use rusteda_formats::verilog::parse_tokens; -use rusteda_formats::verilog::pp::{PpOptions, Preprocessor}; -use rusteda_formats::verilog::span::SourceMap; -use rusteda_workspace::cache::PutError; -use rusteda_workspace::{ - Corruption, DiagCode, Diagnostic, Error, Hash64, JsonMap, Severity, Txn, Upstream, Workspace, - rlib, -}; -use serde_json::json; - -pub(super) const C_UNSUP: &str = "V0300"; -pub(super) const C_DECL: &str = "V0301"; -pub(super) const C_CELL: &str = "V0302"; -pub(super) const C_WIDTH: &str = "V0303"; -pub(super) const C_PG_DROP: &str = "V0310"; - -#[derive(Debug, Clone)] -pub(super) struct PinConflict { - pub(super) pin: String, - pub(super) corner_a: String, - pub(super) dir_a: PinDir, - pub(super) corner_b: String, - pub(super) dir_b: PinDir, -} - -#[derive(Debug, Clone)] -pub(super) struct CellInfo { - pub(super) pins: Vec<(String, PinDir)>, - pub(super) pin_index: HashMap, - pub(super) pg_pins: HashSet, - pub(super) first_corner: String, - pub(super) conflicts: Vec, -} - -#[derive(Debug, Default)] -pub(super) struct CellCatalog { - pub(super) cells: HashMap, -} - -impl CellCatalog { - fn from_txn(ws: &Workspace, txn: &Txn) -> Result { - let mut cat = Self::default(); - let cache = ws.cache(); - for entry in txn.libraries() { - let rbin = cache - .open_rlib_or_recompile( - &entry.name, - entry.hash, - rlib::KIND, - rlib::VERSIONS, - |raw| { - let text = std::str::from_utf8(raw) - .map_err(|_| Error::corrupt(&entry.source, Corruption::NotUtf8))?; - let lib = liberty::read_text(text, &entry.source).map_err(|e| { - Error::corrupt( - &entry.source, - Corruption::Json { - reason: e.to_string(), - }, - ) - })?; - rlib::encode_with_source(&lib, entry.hash) - }, - ) - .map_err(|e| match e { - PutError::Compile(err) | PutError::Workspace(err) => err, - })?; - let lib: Library = rlib::decode_rbin(&rbin)?; - cat.add_library(&lib, &entry.corner); - } - Ok(cat) - } - - fn add_library(&mut self, lib: &Library, corner: &str) { - for cell in &lib.cells { - let mut pins = Vec::with_capacity(cell.pins.len()); - for p in &cell.pins { - let dir = match p.direction { - LibDir::Input => PinDir::Input, - LibDir::Output => PinDir::Output, - LibDir::Inout => PinDir::Inout, - LibDir::Internal => continue, - }; - pins.push((p.name.clone(), dir)); - } - let mut pg_pins: HashSet = cell.pg_pins.iter().cloned().collect(); - pg_pins.extend(cell.pg_pin_groups.iter().map(|pg| pg.name.clone())); - - if let Some(ex) = self.cells.get_mut(&cell.name) { - ex.pg_pins.extend(pg_pins); - for (pname, pdir) in pins { - if let Some(&idx) = ex.pin_index.get(&pname) { - let prev = ex.pins[idx as usize].1; - if prev != pdir && !ex.conflicts.iter().any(|c| c.pin == pname) { - ex.conflicts.push(PinConflict { - pin: pname, - corner_a: ex.first_corner.clone(), - dir_a: prev, - corner_b: corner.to_string(), - dir_b: pdir, - }); - } - } else { - ex.pin_index.insert(pname.clone(), ex.pins.len() as u32); - ex.pins.push((pname, pdir)); - } - } - } else { - let pin_index = pins - .iter() - .enumerate() - .map(|(i, (n, _))| (n.clone(), i as u32)) - .collect(); - self.cells.insert( - cell.name.clone(), - CellInfo { - pins, - pin_index, - pg_pins, - first_corner: corner.to_string(), - conflicts: Vec::new(), - }, - ); - } - } - } -} - -pub(super) fn range_width(msb: i32, lsb: i32) -> u64 { - (i64::from(msb) - i64::from(lsb)).unsigned_abs() + 1 -} - -pub(super) fn qualify_hier(prefix: Option<&str>, raw: &str) -> String { - match prefix { - None => escape_segment(raw), - Some(p) => format!("{p}/{}", escape_segment(raw)), - } -} - -pub(super) fn collect_expr_idents<'a>(e: &'a Expr, out: &mut HashSet<&'a str>) { - match &e.kind { - ExprKind::Ident(p) if p.scope.is_none() => { - out.insert(p.name.name.as_str()); - } - ExprKind::Index(base, _) | ExprKind::Slice { base, .. } => { - if let Some(id) = base.as_ident() { - out.insert(id.name.as_str()); - } - } - ExprKind::Concat(items) | ExprKind::Repl(_, items) => { - for item in items { - collect_expr_idents(item, out); - } - } - _ => {} - } -} - -pub(super) fn literal_i64(l: &Literal) -> Option { - if l.signed && !l.plain { - return None; - } - let bits = l.bits().ok()?; - let mut v = 0i64; - for (k, b) in bits.iter().enumerate() { - match b { - Bit4::Zero => {} - Bit4::One if k < 62 => v |= 1 << k, - _ => return None, - } - } - Some(v) -} - -fn convert_vdiag(sm: &SourceMap, d: &VDiag, fallback_file: &str) -> Diagnostic { - let sev = match d.severity { - VSev::Note => Severity::Note, - VSev::Warning => Severity::Warning, - VSev::Error => Severity::Error, - }; - let code = DiagCode::parse(d.code).unwrap_or(DiagCode::of(C_UNSUP)); - let loc = match sm.get(d.span.file) { - Some(f) => { - let (line, col) = f.line_col(d.span.start); - let fp = f.path.display().to_string(); - let file = if fp.is_empty() { - fallback_file.to_string() - } else { - fp - }; - Some((file, line.max(1), col.max(1))) - } - None => Some((fallback_file.to_string(), 1, 1)), - }; - Diagnostic::from_parts(sev, code, loc, d.message.clone(), None) -} - -fn reject_vdiags(sm: &SourceMap, vdiags: &[VDiag], file: &str, out: &mut Vec) -> Error { - let mut first_err = None; - for vd in vdiags { - let conv = convert_vdiag(sm, vd, file); - if conv.severity == Severity::Error && first_err.is_none() { - first_err = Some(match &conv.location { - Some(l) => format!("{}:{}:{}: {}", l.file, l.line, l.col, conv.message), - None => format!("{file}:1:1: {}", conv.message), - }); - } - out.push(conv); - } - Error::input_rejected(first_err.unwrap_or_else(|| format!("{file}:1:1: invalid netlist"))) -} - -fn expr_uses_extension(e: &Expr) -> bool { - match &e.kind { - ExprKind::Concat(_) - | ExprKind::Repl(..) - | ExprKind::Slice { .. } - | ExprKind::UnbasedUnsized(_) => true, - ExprKind::Number(l) => l.size != Some(1) || l.plain, - _ => false, - } -} - -fn uses_extended_features(items: &[Item], catalog: &CellCatalog) -> bool { - let mods: Vec<&Module> = items - .iter() - .filter_map(|it| { - if let Item::Module(m) = it { - Some(m.as_ref()) - } else { - None - } - }) - .collect(); - if mods.len() > 1 { - return true; - } - let Some(m) = mods.first() else { return false }; - let is_ext_net = |nk: Option, ty: &DataType| { - matches!(nk, Some(NetKind::Supply0 | NetKind::Supply1)) || ty.signing.is_some() - }; - if let PortList::Ansi(ports) = &m.ports - && ports.iter().any(|p| is_ext_net(p.net, &p.ty)) - { - return true; - } - for item in &m.items { - match item { - ModuleItem::PortDecl(d) if is_ext_net(d.net, &d.ty) => return true, - ModuleItem::Data(d) if is_ext_net(d.net, &d.ty) => return true, - ModuleItem::Instance(inst) => { - if let Some(info) = catalog.cells.get(&inst.module.name) { - if !info.conflicts.is_empty() { - return true; - } - for i in &inst.instances { - for c in &i.conns { - if let PortConn::Named(pin, opt_e) = c { - if info.pg_pins.contains(&pin.name) { - return true; - } - if let Some(Some(e)) = opt_e - && expr_uses_extension(e) - { - return true; - } - } - } - } - } - } - ModuleItem::Assign(assigns) - if assigns - .iter() - .any(|a| expr_uses_extension(&a.lhs) || expr_uses_extension(&a.rhs)) => - { - return true; - } - _ => {} - } - } - false -} - -fn read_netlist_ast( - text: &str, - file: &str, - catalog: &CellCatalog, - diags: &mut Vec, -) -> Result { - let mut sources = SourceMap::new(); - let id = sources.add(file, text); - let mut pp = Preprocessor::new(PpOptions::default()); - let toks = pp.run(&mut sources, id); - if pp.diags.iter().any(VDiag::is_error) { - return Err(reject_vdiags(&sources, &pp.diags, file, diags)); - } - let unit = match parse_tokens(&toks) { - Ok(u) => u, - Err(vd) => return Err(reject_vdiags(&sources, &[vd], file, diags)), - }; - if !uses_extended_features(&unit.items, catalog) { - return read_structural(text, file, &|c| { - catalog.cells.get(c).map(|i| i.pins.clone()) - }) - .map_err(|re| reject_vdiags(&re.sources, &re.diagnostics, file, diags)); - } - for vd in &pp.diags { - diags.push(convert_vdiag(&sources, vd, file)); - } - let budget = (text.len() as u64).max(u64::from(MAX_RANGE_WIDTH)); - match ast::elaborate_extended(&sources, &unit.items, catalog, budget) { - Ok((g, ext_diags)) => { - for vd in &ext_diags { - diags.push(convert_vdiag(&sources, vd, file)); - } - Ok(g) - } - Err(ext_diags) => Err(reject_vdiags(&sources, &ext_diags, file, diags)), - } -} - -/// Imports a structural Verilog netlist into `txn` (`RCLI-REQ-0011`, `RCLI-REQ-0019`); a file -/// that the streaming reader declines is read by the full reader only up to `max_ast_mib` MiB -/// (`RCLI-REQ-0024`). -pub(crate) fn import_netlist( - ws: &Workspace, - txn: &mut Txn, - file: &Path, - max_ast_mib: u64, - metrics: &mut JsonMap, - diags: &mut Vec, -) -> Result<(), Error> { - let source_str = file.display().to_string(); - let meta = fs::symlink_metadata(file).map_err(|e| Error::io(file, e))?; - if !meta.file_type().is_file() { - let msg = format!("{source_str}: not a regular file"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("V0001"), msg.clone()) - .at(source_str, 1, 1), - ); - return Err(Error::input_rejected(msg)); - } - - let bytes = fs::read(file).map_err(|e| Error::io(file, e))?; - let source_hash = Hash64::of(&bytes); - let text = match String::from_utf8(bytes) { - Ok(s) => s, - Err(e) => { - let msg = format!("invalid UTF-8 in Verilog netlist: {e}"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("V0001"), msg.clone()).at( - source_str.clone(), - 1, - 1, - ), - ); - return Err(Error::input_rejected(format!("{source_str}:1:1: {msg}"))); - } - }; - - let catalog = CellCatalog::from_txn(ws, txn)?; - let frozen = if let Some(fast) = fast::try_fast_flat(&text, &catalog) { - // Debug builds check small inputs against the reader that the fast path replaces: the - // structural reader, or the extended elaborator for concatenations, part-selects and - // sized constants. - #[cfg(debug_assertions)] - if text.len() <= 65_536 - && let Ok(g) = read_netlist_ast(&text, &source_str, &catalog, &mut Vec::new()) - { - debug_assert_eq!( - fast, - g.freeze(), - "fast path diverged from the netlist reader" - ); - } - drop(text); - fast - } else { - // Whole-file token/AST expansion is unsuitable for multi-million-cell - // flat inputs. Keep unsupported large syntax explicit and bounded (RCLI-REQ-0024). - if text.len() as u64 > max_ast_mib.saturating_mul(1 << 20) { - let msg = format!( - "large flat netlist uses syntax unsupported by the bounded streaming importer \ - ({max_ast_mib} MiB AST fallback budget, --max-netlist-ast-mib)" - ); - diags.push(Diagnostic::new( - Severity::Error, - DiagCode::of(C_UNSUP), - msg.clone(), - )); - return Err(Error::input_rejected(msg)); - } - let g = read_netlist_ast(&text, &source_str, &catalog, diags)?; - drop(text); - g.freeze() - }; - - rusteda_workspace::netlist::add_to_txn(txn, &frozen, Upstream::new())?; - txn.add_source( - rusteda_workspace::netlist::TABLE_NAME, - source_str, - source_hash, - )?; - - let top_id = frozen.module[rusteda_core::netlist::module_entry::NAME] as usize; - let (ts, te) = ( - frozen.str_offs[top_id] as usize, - frozen.str_offs[top_id + 1] as usize, - ); - let top_name = std::str::from_utf8(&frozen.str_bytes[ts..te]) - .unwrap_or("") - .to_string(); - let (n_insts, n_nets, n_ports, n_assigns) = ( - frozen.instances.len(), - frozen.nets.len(), - frozen.ports.len(), - frozen.assigns.len(), - ); - - txn.set_summary("netlist", "top", top_name.clone())?; - txn.set_summary("netlist", "cells", n_insts)?; - txn.set_summary("netlist", "nets", n_nets)?; - txn.set_summary("netlist", "ports", n_ports)?; - txn.set_summary("netlist", "assigns", n_assigns)?; - - metrics.insert("top".to_string(), json!(top_name)); - metrics.insert("cells".to_string(), json!(n_insts)); - metrics.insert("nets".to_string(), json!(n_nets)); - metrics.insert("ports".to_string(), json!(n_ports)); - metrics.insert("assigns".to_string(), json!(n_assigns)); - Ok(()) -} diff --git a/crates/apps/rusteda-cli/src/import_netlist/ast.rs b/crates/apps/rusteda-cli/src/import_netlist/ast.rs deleted file mode 100644 index 4b8a00f..0000000 --- a/crates/apps/rusteda-cli/src/import_netlist/ast.rs +++ /dev/null @@ -1,1155 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Extended structural elaborator (PG/supply dropping, hierarchy flattening, and diagnostics). - -use std::collections::{HashMap, HashSet}; - -use rusteda_core::names::check_hierarchical; -use rusteda_core::netlist::{ - CellId, Conn, GateNetlist, MAX_RANGE_WIDTH, NetId, PinDir, PinRef, PortDir, -}; -use rusteda_formats::verilog::ast::{ - AnsiPort, ContAssign, DataDecl, DataType, Dim, Direction, Expr, ExprKind, Instantiation, Item, - Module, ModuleItem, NetKind, NonAnsiPort, PortConn, PortList, SliceKind, TypeKind, UnOp, - UnitKind, -}; -use rusteda_formats::verilog::diag::Diagnostic as VDiag; -use rusteda_formats::verilog::number::Bit4; -use rusteda_formats::verilog::span::{SourceMap, Span}; - -use super::{ - C_CELL, C_DECL, C_PG_DROP, C_UNSUP, C_WIDTH, CellCatalog, collect_expr_idents, literal_i64, - qualify_hier, range_width, -}; - -fn pin_dir_label(d: PinDir) -> &'static str { - match d { - PinDir::Input => "input", - PinDir::Output => "output", - PinDir::Inout => "inout", - PinDir::Other => "other", - } -} - -#[derive(Clone)] -enum ScopeEntry { - Net { - range: Option<(i32, i32)>, - bits: Vec, - is_port: bool, - typed: bool, - }, - Supply(bool), - DroppedPgNet, - Instance, -} - -#[derive(Clone)] -struct ResolvedPort { - name: String, - dir: PortDir, - range: Option<(i32, i32)>, - typed: bool, - is_supply: Option, - span: Span, -} - -struct ExtElaborator<'a> { - sources: &'a SourceMap, - catalog: &'a CellCatalog, - modules: HashMap<&'a str, &'a Module>, - g: GateNetlist, - cells: HashMap, - diags: Vec, - bits_left: u64, - budget: u64, - pg_conns_dropped: usize, - supply_ports_dropped: usize, - first_pg_span: Option, -} - -pub(super) fn elaborate_extended<'a>( - sources: &'a SourceMap, - items: &'a [Item], - catalog: &'a CellCatalog, - budget: u64, -) -> Result<(GateNetlist, Vec), Vec> { - let mut diags = Vec::new(); - let mut modules: HashMap<&'a str, &'a Module> = HashMap::new(); - let mut mod_order: Vec<&'a Module> = Vec::new(); - - for it in items { - match it { - Item::Module(m) => { - let m = m.as_ref(); - if modules.insert(m.name.name.as_str(), m).is_some() { - diags.push(VDiag::error( - C_DECL, - m.name.span, - format!("module '{}' is defined twice", m.name.name), - )); - } else { - mod_order.push(m); - } - } - Item::Package(p) => { - diags.push(VDiag::error( - C_UNSUP, - p.span, - "a package is not supported in a structural netlist", - )); - } - Item::Decl(_) | Item::Unsupported { .. } => diags.push(VDiag::error( - C_UNSUP, - Span::default(), - "items outside a module are not supported in a structural netlist", - )), - } - } - if !diags.is_empty() { - return Err(diags); - } - if mod_order.is_empty() { - return Err(vec![VDiag::error( - C_DECL, - Span::default(), - "no module found", - )]); - } - - let mut inst_mods: HashSet<&str> = HashSet::new(); - for m in &mod_order { - for it in &m.items { - if let ModuleItem::Instance(inst) = it - && modules.contains_key(inst.module.name.as_str()) - { - inst_mods.insert(inst.module.name.as_str()); - } - } - } - let roots: Vec<&&Module> = mod_order - .iter() - .filter(|m| !inst_mods.contains(m.name.name.as_str())) - .collect(); - let top = match roots[..] { - [r] => *r, - [] => { - return Err(vec![VDiag::error( - C_UNSUP, - mod_order[0].name.span, - "recursive module instantiation cycle in structural netlist", - )]); - } - _ => { - return Err(vec![VDiag::error( - C_UNSUP, - roots[1].name.span, - format!( - "multiple uninstantiated top modules ('{}' and '{}')", - roots[0].name.name, roots[1].name.name - ), - )]); - } - }; - - let g = GateNetlist::new(&top.name.name) - .map_err(|e| vec![VDiag::error(C_DECL, top.name.span, e.to_string())])?; - let mut elab = ExtElaborator { - sources, - catalog, - modules, - g, - cells: HashMap::new(), - diags: Vec::new(), - bits_left: budget, - budget, - pg_conns_dropped: 0, - supply_ports_dropped: 0, - first_pg_span: None, - }; - let mut stack = vec![top.name.name.as_str()]; - elab.elaborate_module(top, None, &mut stack); - if elab.diags.iter().any(VDiag::is_error) { - return Err(elab.diags); - } - if elab.pg_conns_dropped > 0 || elab.supply_ports_dropped > 0 { - let span = elab.first_pg_span.unwrap_or(top.name.span); - elab.diags.push(VDiag::warning( - C_PG_DROP, - span, - format!( - "dropped {} pg_pin connection(s) and {} supply port(s)", - elab.pg_conns_dropped, elab.supply_ports_dropped - ), - )); - } - Ok((elab.g, elab.diags)) -} - -impl<'a> ExtElaborator<'a> { - fn err(&mut self, code: &'static str, span: Span, msg: impl Into) { - self.diags.push(VDiag::error(code, span, msg)); - } - - fn err_r( - &mut self, - code: &'static str, - span: Span, - msg: impl Into, - ) -> Result { - self.err(code, span, msg); - Err(()) - } - - fn map_dir(&mut self, d: Direction, sp: Span) -> PortDir { - match d { - Direction::Input => PortDir::Input, - Direction::Output => PortDir::Output, - Direction::Inout => PortDir::Inout, - Direction::Ref => { - self.err(C_UNSUP, sp, "ref ports are not supported"); - PortDir::Inout - } - } - } - - fn const_int(&self, e: &Expr, what: &str) -> Result { - let val = match &e.kind { - ExprKind::Number(l) => literal_i64(l), - ExprKind::Unary(UnOp::Minus, inner) => match &inner.kind { - ExprKind::Number(l) if l.plain => literal_i64(l).map(|v| -v), - _ => None, - }, - _ => None, - }; - val.ok_or_else(|| VDiag::error(C_UNSUP, e.span, format!("unsupported {what} expression"))) - } - - fn bound(&self, e: &Expr) -> Result { - let v = self.const_int(e, "range bound")?; - i32::try_from(v) - .ok() - .filter(|&v| v != i32::MIN) - .ok_or_else(|| VDiag::error(C_WIDTH, e.span, format!("range bound {v} is too large"))) - } - - fn parse_range(&self, ty: &DataType) -> Result, VDiag> { - if !matches!(ty.kind, TypeKind::Implicit | TypeKind::Logic) { - return Err(VDiag::error( - C_UNSUP, - ty.span, - "unsupported data type in structural netlist", - )); - } - match &ty.packed[..] { - [] => Ok(None), - [Dim::Range(msb, lsb)] => { - let (m, l) = (self.bound(msb)?, self.bound(lsb)?); - if range_width(m, l) > u64::from(MAX_RANGE_WIDTH) { - return Err(VDiag::error( - C_WIDTH, - msb.span.to(lsb.span), - format!("range [{m}:{l}] is wider than {MAX_RANGE_WIDTH} bits"), - )); - } - Ok(Some((m, l))) - } - _ => Err(VDiag::error( - C_UNSUP, - ty.span, - "multi-dimensional or size dimension is not supported", - )), - } - } - - fn charge(&mut self, name: &str, range: Option<(i32, i32)>, span: Span) -> bool { - let Some((msb, lsb)) = range else { return true }; - if let Some(left) = self.bits_left.checked_sub(range_width(msb, lsb)) { - self.bits_left = left; - true - } else { - self.err( - C_WIDTH, - span, - format!( - "range [{msb}:{lsb}] of '{name}' exceeds {} bits budget", - self.budget - ), - ); - false - } - } - - fn resolve_ports(&mut self, m: &Module) -> (Vec, bool) { - match &m.ports { - PortList::None => (Vec::new(), false), - PortList::Ansi(ports) => ( - ports - .iter() - .filter_map(|p| self.resolve_ansi_port(p)) - .collect(), - false, - ), - PortList::NonAnsi(ports) => (self.resolve_non_ansi_ports(m, ports), true), - } - } - - fn resolve_ansi_port(&mut self, p: &AnsiPort) -> Option { - let dir = self.map_dir(p.dir, p.span); - let is_supply = match p.net { - Some(NetKind::Wire) => None, - Some(NetKind::Supply0) => Some(false), - Some(NetKind::Supply1) => Some(true), - None => { - self.err( - C_UNSUP, - p.span, - format!("variable port '{}' is not supported", p.name.name), - ); - None - } - Some(_) => { - self.err( - C_UNSUP, - p.span, - "unsupported net port kind in structural netlist", - ); - None - } - }; - let range = self - .parse_range(&p.ty) - .map_err(|d| self.diags.push(d)) - .ok()?; - Some(ResolvedPort { - name: p.name.name.clone(), - dir, - range, - typed: true, - is_supply, - span: p.name.span, - }) - } - - fn resolve_non_ansi_ports(&mut self, m: &Module, ports: &[NonAnsiPort]) -> Vec { - let mut header = Vec::new(); - for p in ports { - if p.external.is_none() - && let Some(id) = p.expr.as_ref().and_then(Expr::as_ident) - { - header.push(id); - } else { - self.err( - C_UNSUP, - p.span, - "unsupported port expression in module header", - ); - } - } - let mut decls: HashMap<&str, ResolvedPort> = HashMap::new(); - for item in &m.items { - let ModuleItem::PortDecl(d) = item else { - continue; - }; - let dir = self.map_dir(d.dir, d.span); - let is_supply = match d.net { - None | Some(NetKind::Wire) if !d.is_var => None, - Some(NetKind::Supply0) => Some(false), - Some(NetKind::Supply1) => Some(true), - _ => { - self.err( - C_UNSUP, - d.span, - "unsupported port net/var kind in structural netlist", - ); - None - } - }; - let Ok(range) = self - .parse_range(&d.ty) - .map_err(|diag| self.diags.push(diag)) - else { - continue; - }; - let typed = d.net.is_some() || d.is_var; - for (ident, dims, init) in &d.names { - if !dims.is_empty() || init.is_some() { - self.err( - C_UNSUP, - ident.span, - "unpacked dimension or initializer on port is not supported", - ); - } - if !header.iter().any(|h| h.name == ident.name) { - self.err( - C_DECL, - ident.span, - format!("'{}' is not in the port list", ident.name), - ); - } else if decls - .insert( - ident.name.as_str(), - ResolvedPort { - name: ident.name.clone(), - dir, - range, - typed, - is_supply, - span: ident.span, - }, - ) - .is_some() - { - self.err( - C_DECL, - ident.span, - format!("port '{}' is declared twice", ident.name), - ); - } - } - } - let mut out = Vec::with_capacity(header.len()); - for h in header { - if let Some(rp) = decls.remove(h.name.as_str()) { - out.push(rp); - } else { - self.err( - C_DECL, - h.span, - format!("port '{}' has no direction declaration", h.name), - ); - } - } - out - } - - fn scan_pg_only_nets(&self, m: &'a Module) -> HashSet<&'a str> { - let (mut pg_used, mut sig_used) = (HashSet::new(), HashSet::new()); - for item in &m.items { - match item { - ModuleItem::Instance(inst) => { - let cinfo = self.catalog.cells.get(&inst.module.name); - for i in &inst.instances { - for c in &i.conns { - if let PortConn::Named(pin, Some(Some(e))) = c { - if cinfo.is_some_and(|ci| ci.pg_pins.contains(&pin.name)) { - collect_expr_idents(e, &mut pg_used); - } else { - collect_expr_idents(e, &mut sig_used); - } - } - } - } - } - ModuleItem::Assign(assigns) => { - for a in assigns { - collect_expr_idents(&a.lhs, &mut sig_used); - collect_expr_idents(&a.rhs, &mut sig_used); - } - } - _ => {} - } - } - pg_used.retain(|n| !sig_used.contains(n)); - pg_used - } - - fn add_hierarchical_nets( - &mut self, - hname: &str, - range: Option<(i32, i32)>, - span: Span, - ) -> Option> { - if let Err(e) = check_hierarchical(hname) { - self.err(C_DECL, span, e.to_string()); - return None; - } - let indices: Vec> = match range { - None => vec![None], - Some((msb, lsb)) if msb >= lsb => (lsb..=msb).rev().map(Some).collect(), - Some((msb, lsb)) => (msb..=lsb).map(Some).collect(), - }; - let mut bits = Vec::with_capacity(indices.len()); - for idx in indices { - match self.g.add_net(Some(hname), idx) { - Ok(id) => bits.push(id), - Err(e) => { - self.err(C_DECL, span, e.to_string()); - return None; - } - } - } - Some(bits) - } - - fn elaborate_module( - &mut self, - m: &'a Module, - prefix: Option<&str>, - stack: &mut Vec<&'a str>, - ) -> HashMap)> { - if m.kind != UnitKind::Module { - self.err( - C_UNSUP, - m.span, - "interfaces and programs are not supported in a structural netlist", - ); - } - if let Some(p) = m.params.first() { - self.err( - C_UNSUP, - p.span, - "parameters are not supported in a structural netlist", - ); - } - let (ports, non_ansi) = self.resolve_ports(m); - let pg_only = self.scan_pg_only_nets(m); - let mut scope: HashMap = HashMap::new(); - let mut port_conns: HashMap)> = HashMap::new(); - - for rp in ports { - if let Some(val) = rp.is_supply { - self.supply_ports_dropped += 1; - self.first_pg_span.get_or_insert(rp.span); - scope.insert(rp.name, ScopeEntry::Supply(val)); - continue; - } - if pg_only.contains(rp.name.as_str()) { - self.supply_ports_dropped += 1; - self.first_pg_span.get_or_insert(rp.span); - scope.insert(rp.name, ScopeEntry::DroppedPgNet); - continue; - } - if !self.charge(&rp.name, rp.range, rp.span) { - continue; - } - let hname = qualify_hier(prefix, &rp.name); - let bits = if prefix.is_none() { - match self.g.add_port(&hname, rp.dir, rp.range) { - Ok(k) => self.g.ports()[k].bits.clone(), - Err(e) => { - self.err(C_DECL, rp.span, e.to_string()); - continue; - } - } - } else { - let Some(b) = self.add_hierarchical_nets(&hname, rp.range, rp.span) else { - continue; - }; - b - }; - port_conns.insert( - rp.name.clone(), - (rp.dir, bits.iter().map(|&b| Conn::Net(b)).collect()), - ); - scope.insert( - rp.name, - ScopeEntry::Net { - range: rp.range, - bits, - is_port: true, - typed: rp.typed, - }, - ); - } - - for item in &m.items { - match item { - ModuleItem::PortDecl(_) if non_ansi => {} - ModuleItem::PortDecl(d) => { - self.err( - C_UNSUP, - d.span, - "port declaration in a module with an ANSI port list", - ); - } - ModuleItem::Data(d) => { - self.elaborate_data_decl(d, non_ansi, prefix, &pg_only, &mut scope); - } - ModuleItem::Instance(inst) => { - self.elaborate_instantiation(inst, prefix, &mut scope, stack); - } - ModuleItem::Assign(assigns) => { - for a in assigns { - self.elaborate_assign(a, prefix, &m.default_nettype, &mut scope); - } - } - other => { - let span = match other { - ModuleItem::Always(a) => a.span, - ModuleItem::Initial(s) | ModuleItem::Final(s) => s.span, - ModuleItem::Param(v) => v.first().map_or(m.span, |p| p.span), - ModuleItem::Gate(v) => v.first().map_or(m.span, |g| g.span), - _ => m.span, - }; - self.err(C_UNSUP, span, "unsupported construct in structural netlist"); - } - } - } - port_conns - } - - fn elaborate_data_decl( - &mut self, - d: &DataDecl, - non_ansi: bool, - prefix: Option<&str>, - pg_only: &HashSet<&str>, - scope: &mut HashMap, - ) { - let supply_val = match d.net { - Some(NetKind::Wire) => None, - Some(NetKind::Supply0) => Some(false), - Some(NetKind::Supply1) => Some(true), - _ => { - self.err( - C_UNSUP, - d.span, - "only wire, supply0, and supply1 declarations are supported", - ); - return; - } - }; - let Ok(range) = self - .parse_range(&d.ty) - .map_err(|diag| self.diags.push(diag)) - else { - return; - }; - - for decl in &d.decls { - if !decl.dims.is_empty() || decl.init.is_some() { - self.err( - C_UNSUP, - decl.name.span, - "net arrays and net declaration assignments are not supported", - ); - continue; - } - if let Some(val) = supply_val { - scope.insert(decl.name.name.clone(), ScopeEntry::Supply(val)); - continue; - } - if pg_only.contains(decl.name.name.as_str()) { - scope.insert(decl.name.name.clone(), ScopeEntry::DroppedPgNet); - continue; - } - if non_ansi - && let Some(ScopeEntry::Net { - range: pr, - is_port: true, - typed, - .. - }) = scope.get_mut(&decl.name.name) - { - if *typed { - self.err( - C_DECL, - decl.name.span, - format!("port '{}' is already completely declared", decl.name.name), - ); - } else if *pr != range { - self.err( - C_DECL, - decl.name.span, - format!( - "wire '{}' does not match the range of port '{}'", - decl.name.name, decl.name.name - ), - ); - } else { - *typed = true; - } - continue; - } - if scope.contains_key(&decl.name.name) { - self.err( - C_DECL, - decl.name.span, - format!("'{}' is already declared", decl.name.name), - ); - continue; - } - if !self.charge(&decl.name.name, range, decl.name.span) { - continue; - } - let hname = qualify_hier(prefix, &decl.name.name); - if let Some(bits) = self.add_hierarchical_nets(&hname, range, decl.name.span) { - scope.insert( - decl.name.name.clone(), - ScopeEntry::Net { - range, - bits, - is_port: false, - typed: true, - }, - ); - } - } - } - - fn elaborate_instantiation( - &mut self, - inst: &'a Instantiation, - prefix: Option<&str>, - scope: &mut HashMap, - stack: &mut Vec<&'a str>, - ) { - if !inst.params.is_empty() { - self.err( - C_UNSUP, - inst.span, - "parameter value assignment is not supported in a structural netlist", - ); - return; - } - if let Some(&submod) = self.modules.get(inst.module.name.as_str()) { - if stack.contains(&inst.module.name.as_str()) { - self.err( - C_UNSUP, - inst.module.span, - format!( - "recursive instantiation of module '{}' (hierarchy)", - inst.module.name - ), - ); - return; - } - for i in &inst.instances { - if !i.dims.is_empty() { - self.err(C_UNSUP, i.span, "instance arrays are not supported"); - continue; - } - if scope.contains_key(&i.name.name) { - self.err( - C_DECL, - i.name.span, - format!("'{}' is already declared", i.name.name), - ); - continue; - } - scope.insert(i.name.name.clone(), ScopeEntry::Instance); - let child_prefix = qualify_hier(prefix, &i.name.name); - stack.push(inst.module.name.as_str()); - let sub_ports = self.elaborate_module(submod, Some(&child_prefix), stack); - stack.pop(); - - let mut connected = HashSet::new(); - for c in &i.conns { - let PortConn::Named(pin, opt_e) = c else { - self.err(C_UNSUP, i.span, "only named port connections are supported"); - continue; - }; - let Some(opt_e) = opt_e else { - self.err( - C_UNSUP, - pin.span, - format!("implicit connection '.{}' is not supported", pin.name), - ); - continue; - }; - let Some((pdir, inner_bits)) = sub_ports.get(&pin.name) else { - self.err( - C_CELL, - pin.span, - format!("module '{}' has no port '{}'", inst.module.name, pin.name), - ); - continue; - }; - if !connected.insert(pin.name.as_str()) { - self.err( - C_CELL, - pin.span, - format!( - "port '{}' of '{}' is connected twice", - pin.name, i.name.name - ), - ); - continue; - } - let Some(e) = opt_e else { continue }; - if let Ok(outer_bits) = self.eval_expr(e, true, prefix, &None, scope) { - match pdir { - PortDir::Input => self.connect_bus(inner_bits, &outer_bits, e.span), - PortDir::Output | PortDir::Inout => { - self.connect_bus(&outer_bits, inner_bits, e.span); - } - } - } - } - } - return; - } - - let Some(cinfo) = self.catalog.cells.get(&inst.module.name) else { - self.err( - C_CELL, - inst.module.span, - format!("unknown cell '{}'", inst.module.name), - ); - return; - }; - if !cinfo.conflicts.is_empty() { - for c in &cinfo.conflicts { - self.err( - C_CELL, - inst.module.span, - format!( - "cell '{}' pin '{}' has conflicting directions across corners: '{}' in corner '{}' vs '{}' in corner '{}'", - inst.module.name, - c.pin, - pin_dir_label(c.dir_a), - c.corner_a, - pin_dir_label(c.dir_b), - c.corner_b - ), - ); - } - return; - } - let cell_id = if let Some(&id) = self.cells.get(&inst.module.name) { - id - } else { - match self.g.add_cell_type(&inst.module.name, &cinfo.pins) { - Ok(id) => { - self.cells.insert(inst.module.name.clone(), id); - id - } - Err(e) => { - self.err(C_CELL, inst.module.span, e.to_string()); - return; - } - } - }; - - for i in &inst.instances { - if !i.dims.is_empty() { - self.err(C_UNSUP, i.span, "instance arrays are not supported"); - continue; - } - if scope.contains_key(&i.name.name) { - self.err( - C_DECL, - i.name.span, - format!("'{}' is already declared", i.name.name), - ); - continue; - } - let hname = qualify_hier(prefix, &i.name.name); - let inst_id = match self.g.add_instance(&hname, cell_id) { - Ok(id) => id, - Err(e) => { - self.err(C_DECL, i.name.span, e.to_string()); - continue; - } - }; - scope.insert(i.name.name.clone(), ScopeEntry::Instance); - - let mut connected = HashSet::new(); - for c in &i.conns { - let (pin, opt_e) = match c { - PortConn::Named(pin, Some(opt_e)) => (pin, opt_e), - PortConn::Named(pin, None) => { - self.err( - C_UNSUP, - pin.span, - format!("implicit connection '.{}' is not supported", pin.name), - ); - continue; - } - PortConn::Ordered(e) => { - self.err( - C_UNSUP, - e.as_ref().map_or(i.span, |x| x.span), - "positional connections are not supported", - ); - continue; - } - PortConn::Wildcard(sp) => { - self.err(C_UNSUP, *sp, "'.*' connections are not supported"); - continue; - } - }; - if cinfo.pg_pins.contains(&pin.name) { - self.pg_conns_dropped += 1; - self.first_pg_span.get_or_insert(pin.span); - continue; - } - let Some(&pin_idx) = cinfo.pin_index.get(&pin.name) else { - self.err( - C_CELL, - pin.span, - format!("cell '{}' has no pin '{}'", inst.module.name, pin.name), - ); - continue; - }; - if !connected.insert(pin_idx) { - self.err( - C_CELL, - pin.span, - format!("pin '{}' of '{}' is connected twice", pin.name, i.name.name), - ); - continue; - } - let Some(e) = opt_e else { continue }; - if let Ok(bits) = self.eval_expr(e, true, prefix, &None, scope) { - match bits[..] { - [conn] => { - if let Err(err) = self.g.connect(inst_id, PinRef::Index(pin_idx), conn) - { - self.err(C_CELL, pin.span, err.to_string()); - } - } - _ => self.err( - C_WIDTH, - e.span, - format!("pin '{}' takes 1 bit, got {} bits", pin.name, bits.len()), - ), - } - } - } - } - } - - fn elaborate_assign( - &mut self, - a: &ContAssign, - prefix: Option<&str>, - default_nettype: &Option, - scope: &mut HashMap, - ) { - let lhs = self.eval_expr(&a.lhs, true, prefix, default_nettype, scope); - let rhs = self.eval_expr(&a.rhs, false, prefix, default_nettype, scope); - if let (Ok(l), Ok(r)) = (lhs, rhs) { - self.connect_bus(&l, &r, a.span); - } - } - - fn connect_bus(&mut self, lhs: &[Conn], rhs: &[Conn], span: Span) { - if lhs.len() != rhs.len() { - self.err( - C_WIDTH, - span, - format!("cannot assign {} bit(s) to {} bit(s)", rhs.len(), lhs.len()), - ); - return; - } - for (&l, &r) in lhs.iter().zip(rhs.iter()) { - let Conn::Net(lhs_net) = l else { - self.err( - C_UNSUP, - span, - "a constant cannot be on the left-hand side of an assignment", - ); - return; - }; - let _ = self.g.add_assign(lhs_net, r); - } - } - - fn eval_expr( - &mut self, - e: &Expr, - allow_implicit: bool, - prefix: Option<&str>, - default_nettype: &Option, - scope: &mut HashMap, - ) -> Result, ()> { - match &e.kind { - ExprKind::Ident(p) if p.scope.is_none() => { - let name = &p.name.name; - match scope.get(name) { - Some(ScopeEntry::Net { bits, .. }) => { - return Ok(bits.iter().map(|&b| Conn::Net(b)).collect()); - } - Some(ScopeEntry::Supply(val)) => return Ok(vec![Conn::Const(*val)]), - Some(ScopeEntry::DroppedPgNet) => return Ok(vec![Conn::Const(false)]), - Some(ScopeEntry::Instance) => { - return self.err_r( - C_DECL, - e.span, - format!("'{name}' is an instance, not a net"), - ); - } - None if !allow_implicit => { - return self.err_r(C_DECL, e.span, format!("'{name}' is not declared")); - } - None => {} - } - if let Some(nt) = default_nettype.as_deref() - && nt != "wire" - { - return self.err_r( - C_DECL, - e.span, - format!("implicit net '{name}' is not allowed under `default_nettype {nt}"), - ); - } - let hname = qualify_hier(prefix, name); - let bits = self.add_hierarchical_nets(&hname, None, e.span).ok_or(())?; - let conn = Conn::Net(bits[0]); - scope.insert( - name.clone(), - ScopeEntry::Net { - range: None, - bits, - is_port: false, - typed: true, - }, - ); - Ok(vec![conn]) - } - ExprKind::Index(base, idx_expr) => { - let Some(id) = base.as_ident() else { - return self.err_r( - C_UNSUP, - e.span, - "only bit-selects on identifiers are supported", - ); - }; - let k = self - .const_int(idx_expr, "bit index") - .map_err(|d| self.diags.push(d))?; - self.bit_of(&id.name, k, e.span, scope).map(|c| vec![c]) - } - ExprKind::Slice { - base, - left, - right, - kind: SliceKind::Const, - } => { - let Some(id) = base.as_ident() else { - return self.err_r( - C_UNSUP, - e.span, - "only part-selects on identifiers are supported", - ); - }; - let msb = self - .const_int(left, "part-select bound") - .map_err(|d| self.diags.push(d))?; - let lsb = self - .const_int(right, "part-select bound") - .map_err(|d| self.diags.push(d))?; - let indices: Vec = if msb >= lsb { - (lsb..=msb).rev().collect() - } else { - (msb..=lsb).collect() - }; - indices - .into_iter() - .map(|k| self.bit_of(&id.name, k, e.span, scope)) - .collect() - } - ExprKind::Concat(items) => { - let mut out = Vec::new(); - for item in items { - out.extend(self.eval_expr( - item, - allow_implicit, - prefix, - default_nettype, - scope, - )?); - } - if out.is_empty() { - return self.err_r(C_UNSUP, e.span, "empty concatenation"); - } - Ok(out) - } - ExprKind::Repl(count_expr, items) => { - let Ok(v) = self.const_int(count_expr, "replication count") else { - return self.err_r(C_WIDTH, count_expr.span, "invalid replication count"); - }; - if !(1..=i64::from(MAX_RANGE_WIDTH)).contains(&v) { - return self.err_r(C_WIDTH, count_expr.span, "invalid replication count"); - } - let mut body = Vec::new(); - for item in items { - body.extend(self.eval_expr( - item, - allow_implicit, - prefix, - default_nettype, - scope, - )?); - } - let mut out = Vec::with_capacity(body.len() * (v as usize)); - for _ in 0..v { - out.extend_from_slice(&body); - } - Ok(out) - } - ExprKind::Number(l) if !l.plain && !l.is_truncated() => { - let Some(w) = l.size else { - return self.err_r(C_UNSUP, e.span, "unsized constant in structural netlist"); - }; - if w == 0 || w > MAX_RANGE_WIDTH { - return self.err_r(C_WIDTH, e.span, format!("invalid constant width {w}")); - } - let Ok(bits) = l.bits() else { - return self.err_r(C_UNSUP, e.span, "invalid integer literal"); - }; - let mut out = Vec::with_capacity(bits.len()); - for b in bits.into_iter().rev() { - match b { - Bit4::Zero => out.push(Conn::Const(false)), - Bit4::One => out.push(Conn::Const(true)), - Bit4::X | Bit4::Z => { - return self.err_r(C_UNSUP, e.span, "x/z literals are not supported"); - } - } - } - Ok(out) - } - ExprKind::UnbasedUnsized('0') => Ok(vec![Conn::Const(false)]), - ExprKind::UnbasedUnsized('1') => Ok(vec![Conn::Const(true)]), - _ => { - let snippet = self.sources.snippet(e.span).trim(); - self.err_r( - C_UNSUP, - e.span, - format!("unsupported expression '{snippet}' in a structural netlist"), - ) - } - } - } - - fn bit_of( - &mut self, - name: &str, - k: i64, - span: Span, - scope: &HashMap, - ) -> Result { - match scope.get(name) { - Some(ScopeEntry::Net { - range: Some((msb, lsb)), - bits, - .. - }) => { - let (m, l) = (i64::from(*msb), i64::from(*lsb)); - let pos = if m >= l { m - k } else { k - m }; - if (m.min(l)..=m.max(l)).contains(&k) { - Ok(Conn::Net(bits[pos as usize])) - } else { - self.err_r( - C_WIDTH, - span, - format!("bit {k} is outside the range [{m}:{l}] of '{name}'"), - ) - } - } - Some(ScopeEntry::Net { range: None, .. }) => self.err_r( - C_WIDTH, - span, - format!("'{name}' is a scalar and has no bit {k}"), - ), - Some(ScopeEntry::Instance) => { - self.err_r(C_DECL, span, format!("'{name}' is an instance, not a net")) - } - _ => self.err_r(C_DECL, span, format!("'{name}' is not declared")), - } - } -} diff --git a/crates/apps/rusteda-cli/src/import_netlist/fast.rs b/crates/apps/rusteda-cli/src/import_netlist/fast.rs deleted file mode 100644 index 65cf156..0000000 --- a/crates/apps/rusteda-cli/src/import_netlist/fast.rs +++ /dev/null @@ -1,1013 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Fast streaming reader for single-module flat netlists (`RCLI-REQ-0019`). -//! Expression scratch is capped at MAX_RANGE_WIDTH *4 B, nesting at 64. -//! A C910 import keeps ~1.3 GiB source, <2 GiB frozen tables and bounded -//! name/connection indices; no whole-file tokens/AST or graph paths are built. - -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; - -use rusteda_core::names::{check_verbatim, escape_segment}; -use rusteda_core::netlist::{ - AssignRec, CONST0, CONST1, FrozenNetlist, InstRec, MAX_RANGE_WIDTH, NetRec, PinDir, PortDir, - PortRec, SCALAR, -}; -use rusteda_formats::verilog::number::{Bit4, Literal}; -use rusteda_formats::verilog::token::KEYWORDS; - -use super::{CellCatalog, CellInfo, range_width}; - -#[derive(Clone, Copy)] -enum SymSlice { - Text(u32, u32), - Esc(u32, u32), -} - -struct SymStore { - esc_buf: Vec, -} - -impl SymStore { - fn new() -> Self { - Self { - esc_buf: Vec::new(), - } - } - fn verbatim(&self, s: usize, e: usize) -> Option { - Some(SymSlice::Text( - u32::try_from(s).ok()?, - u32::try_from(e).ok()?, - )) - } - fn hierarchical(&mut self, text: &str, s: usize, e: usize) -> Option { - let raw = &text[s..e]; - if !raw.as_bytes().iter().any(|&b| b == b'\\' || b == b'/') { - return self.verbatim(s, e); - } - let esc = escape_segment(raw); - let start = u32::try_from(self.esc_buf.len()).ok()?; - self.esc_buf.extend_from_slice(esc.as_bytes()); - Some(SymSlice::Esc( - start, - u32::try_from(self.esc_buf.len()).ok()?, - )) - } - fn resolve<'a>(&'a self, text: &'a str, sym: SymSlice) -> &'a str { - match sym { - SymSlice::Text(s, e) => &text[s as usize..e as usize], - SymSlice::Esc(s, e) => { - std::str::from_utf8(&self.esc_buf[s as usize..e as usize]).unwrap_or("") - } - } - } -} - -struct StrTable<'a> { - map: HashMap<&'a str, u32>, - bytes: Vec, - offs: Vec, -} - -impl<'a> StrTable<'a> { - fn with_capacity(cap: usize, byte_cap: usize) -> Self { - let mut offs = Vec::with_capacity(cap + 1); - offs.push(0); - Self { - map: HashMap::with_capacity(cap), - bytes: Vec::with_capacity(byte_cap), - offs, - } - } - fn intern(&mut self, s: &'a str) -> u32 { - if let Some(&id) = self.map.get(s) { - return id; - } - let id = (self.offs.len() - 1) as u32; - self.bytes.extend_from_slice(s.as_bytes()); - self.offs.push(self.bytes.len() as u32); - self.map.insert(s, id); - id - } -} - -#[derive(Clone, Copy)] -enum FastDecl { - Net { - range: Option<(i32, i32)>, - first_net: u32, - is_port: bool, - typed: bool, - }, - Instance, -} - -struct FastPort(SymSlice, u8, Option<(i32, i32)>); -struct FastNet(SymSlice, i32); -struct FastInst(SymSlice, u32); - -struct Cur<'a> { - text: &'a str, - bytes: &'a [u8], - pos: usize, -} - -impl<'a> Cur<'a> { - fn new(text: &'a str) -> Self { - Self { - text, - bytes: text.as_bytes(), - pos: 0, - } - } - - fn skip_ws(&mut self) -> Option<()> { - let (b, n) = (self.bytes, self.bytes.len()); - while self.pos < n { - let c = b[self.pos]; - if c.is_ascii_whitespace() { - self.pos += 1; - } else if c == b'/' && self.pos + 1 < n && b[self.pos + 1] == b'/' { - self.pos += 2; - while self.pos < n && b[self.pos] != b'\n' { - self.pos += 1; - } - } else if c == b'/' && self.pos + 1 < n && b[self.pos + 1] == b'*' { - self.pos += 2; - let rel = b[self.pos..].windows(2).position(|w| w == b"*/")?; - self.pos += rel + 2; - } else { - break; - } - } - Some(()) - } - - fn peek(&mut self) -> Option { - self.skip_ws()?; - self.bytes.get(self.pos).copied() - } - - fn eat(&mut self, ch: u8) -> Option { - self.skip_ws()?; - if self.bytes.get(self.pos) == Some(&ch) { - self.pos += 1; - Some(true) - } else { - Some(false) - } - } - - fn kw(&mut self, kw: &[u8]) -> Option { - self.skip_ws()?; - let end = self.pos + kw.len(); - if end <= self.bytes.len() && &self.bytes[self.pos..end] == kw { - let ok = !matches!( - self.bytes.get(end), - Some(c) if c.is_ascii_alphanumeric() || *c == b'_' || *c == b'$' - ); - if ok { - self.pos = end; - return Some(true); - } - } - Some(false) - } - - fn dir_kw(&mut self) -> Option> { - if self.kw(b"input")? { - Some(Some(PortDir::Input)) - } else if self.kw(b"output")? { - Some(Some(PortDir::Output)) - } else if self.kw(b"inout")? { - Some(Some(PortDir::Inout)) - } else { - Some(None) - } - } - - fn ident(&mut self) -> Option<(usize, usize)> { - self.skip_ws()?; - let (b, n) = (self.bytes, self.bytes.len()); - let c = *b.get(self.pos)?; - if c == b'\\' { - self.pos += 1; - let start = self.pos; - while self.pos < n && (33..=126).contains(&b[self.pos]) { - self.pos += 1; - } - let end = self.pos; - if end == start || (self.pos < n && !b[self.pos].is_ascii_whitespace()) { - return None; - } - if self.pos < n { - self.pos += 1; - } - Some((start, end)) - } else if c.is_ascii_alphabetic() || c == b'_' { - let start = self.pos; - self.pos += 1; - while self.pos < n - && (b[self.pos].is_ascii_alphanumeric() - || b[self.pos] == b'_' - || b[self.pos] == b'$') - { - self.pos += 1; - } - let end = self.pos; - if KEYWORDS.binary_search(&&self.text[start..end]).is_ok() { - self.pos = start; - return None; - } - Some((start, end)) - } else { - None - } - } - - fn int_literal(&mut self) -> Option { - self.skip_ws()?; - let neg = self.eat(b'-')?; - if neg { - self.skip_ws()?; - } - let start = self.pos; - let mut val: i64 = 0; - while let Some(&c) = self.bytes.get(self.pos) { - if c.is_ascii_digit() { - val = val.checked_mul(10)?.checked_add(i64::from(c - b'0'))?; - self.pos += 1; - } else if c == b'_' && self.pos > start { - self.pos += 1; - } else { - break; - } - } - if self.pos == start - || matches!( - self.bytes.get(self.pos), - Some(b'\'' | b'.' | b'a'..=b'z' | b'A'..=b'Z' | b'$') - ) - { - return None; - } - i32::try_from(if neg { -val } else { val }) - .ok() - .filter(|&v| v != i32::MIN) - } - - fn opt_range(&mut self) -> Option> { - if !self.eat(b'[')? { - return Some(None); - } - let msb = self.int_literal()?; - if !self.eat(b':')? { - return None; - } - let lsb = self.int_literal()?; - if !self.eat(b']')? || range_width(msb, lsb) > u64::from(MAX_RANGE_WIDTH) { - return None; - } - Some(Some((msb, lsb))) - } -} - -fn port_dir_code(dir: PortDir) -> u8 { - match dir { - PortDir::Input => 0, - PortDir::Output => 1, - PortDir::Inout => 2, - } -} - -fn pin_dir_code(dir: PinDir) -> u8 { - match dir { - PinDir::Input => 0, - PinDir::Output => 1, - PinDir::Inout => 2, - PinDir::Other => 3, - } -} - -struct ExprReader<'a, 'b> { - syms: &'b mut SymStore, - nets: &'b mut Vec, - decls: &'b mut HashMap<&'a str, FastDecl>, - allow_implicit: bool, -} - -impl<'a> ExprReader<'a, '_> { - /// Append bits in Verilog MSB-to-LSB order, as used by frozen bus tables. - fn append(&mut self, cur: &mut Cur<'a>, out: &mut Vec, depth: usize) -> Option<()> { - if depth >= 64 || out.len() >= MAX_RANGE_WIDTH as usize { - return None; - } - if cur.eat(b'{')? { - loop { - self.append(cur, out, depth + 1)?; - if cur.eat(b'}')? { - return Some(()); - } - if !cur.eat(b',')? { - return None; - } - } - } - if cur.peek()?.is_ascii_digit() { - let mut width = 0u32; - while let Some(&ch) = cur.bytes.get(cur.pos) { - if ch.is_ascii_digit() { - width = width.checked_mul(10)?.checked_add(u32::from(ch - b'0'))?; - } else if ch != b'_' { - break; - } - cur.pos += 1; - } - if width == 0 || width > MAX_RANGE_WIDTH || !cur.eat(b'\'')? { - return None; - } - let base = cur.bytes.get(cur.pos)?.to_ascii_lowercase(); - // Signed/unknown expressions use the diagnostic AST path. Decimal - // digits are bounded separately because conversion is quadratic. - if !matches!(base, b'b' | b'o' | b'h' | b'd') { - return None; - } - cur.pos += 1; - let start = cur.pos; - while let Some(ch) = cur.bytes.get(cur.pos) { - if !(ch.is_ascii_hexdigit() || *ch == b'_') { - break; - } - cur.pos += 1; - if cur.pos - start > MAX_RANGE_WIDTH as usize { - return None; - } - } - if base == b'd' && cur.pos - start > 32 { - return None; - } - if cur.pos == start || out.len().checked_add(width as usize)? > MAX_RANGE_WIDTH as usize - { - return None; - } - let literal = Literal { - size: Some(width), - plain: false, - signed: false, - base: char::from(base), - digits: cur.text[start..cur.pos] - .chars() - .filter(|&ch| ch != '_') - .collect(), - }; - for bit in literal.bits().ok()?.into_iter().rev() { - out.push(match bit { - Bit4::Zero => CONST0, - Bit4::One => CONST1, - _ => return None, - }); - } - return Some(()); - } - let (start, end) = cur.ident()?; - let name = &cur.text[start..end]; - let selected = if cur.eat(b'[')? { - let first = cur.int_literal()?; - let last = if cur.eat(b':')? { - cur.int_literal()? - } else { - first - }; - if !cur.eat(b']')? { - return None; - } - Some((first, last)) - } else { - None - }; - match self.decls.get(name).copied() { - Some(FastDecl::Net { - range: Some((msb, lsb)), - first_net, - .. - }) => { - let (first, last) = selected.unwrap_or((msb, lsb)); - let width = range_width(first, last); - if first < msb.min(lsb) - || first > msb.max(lsb) - || last < msb.min(lsb) - || last > msb.max(lsb) - || (first != last && (first > last) != (msb > lsb)) - || width > u64::from(MAX_RANGE_WIDTH) - || out.len().checked_add(width as usize)? > MAX_RANGE_WIDTH as usize - { - return None; - } - for k in 0..width { - let index = - i64::from(first) + if first > last { -(k as i64) } else { k as i64 }; - let offset = (i64::from(msb) - index).unsigned_abs(); - out.push(first_net.checked_add(u32::try_from(offset).ok()?)?); - } - Some(()) - } - Some(FastDecl::Net { - range: None, - first_net, - .. - }) if selected.is_none() => { - out.push(first_net); - Some(()) - } - None if self.allow_implicit && selected.is_none() => { - let symbol = self.syms.hierarchical(cur.text, start, end)?; - let first_net = u32::try_from(self.nets.len()).ok()?; - self.nets.push(FastNet(symbol, SCALAR)); - self.decls.insert( - name, - FastDecl::Net { - range: None, - first_net, - is_port: false, - typed: true, - }, - ); - out.push(first_net); - Some(()) - } - _ => None, - } - } -} - -#[allow(clippy::too_many_lines)] -pub(super) fn try_fast_flat<'a>( - text: &'a str, - catalog: &'a CellCatalog, -) -> Option> { - if text.as_bytes().contains(&b'`') || text.as_bytes().windows(2).any(|w| w == b"(*") { - return None; - } - let mut cur = Cur::new(text); - if !cur.kw(b"module")? { - return None; - } - let (mod_s, mod_e) = cur.ident()?; - let mod_name = &text[mod_s..mod_e]; - check_verbatim(mod_name).ok()?; - - let mut syms = SymStore::new(); - let mod_sym = syms.verbatim(mod_s, mod_e)?; - let mut budget = (text.len() as u64).max(u64::from(MAX_RANGE_WIDTH)); - let mut decls: HashMap<&'a str, FastDecl> = HashMap::new(); - let mut ports: Vec = Vec::new(); - let mut port_bit_start: Vec = vec![0]; - let mut port_bit_net: Vec = Vec::new(); - let mut nets: Vec = Vec::new(); - - let push_nets = |syms: &mut SymStore, - nets: &mut Vec, - budget: &mut u64, - s: usize, - e: usize, - range: Option<(i32, i32)>| - -> Option<(SymSlice, u32, u32)> { - let sym = syms.hierarchical(text, s, e)?; - let first = u32::try_from(nets.len()).ok()?; - if let Some((msb, lsb)) = range { - let w = range_width(msb, lsb); - *budget = budget.checked_sub(w)?; - if msb >= lsb { - for idx in (lsb..=msb).rev() { - nets.push(FastNet(sym, idx)); - } - } else { - for idx in msb..=lsb { - nets.push(FastNet(sym, idx)); - } - } - Some((sym, first, w as u32)) - } else { - nets.push(FastNet(sym, SCALAR)); - Some((sym, first, 1)) - } - }; - - if !cur.eat(b'(')? { - return None; - } - let mut non_ansi_hdr: Vec<(usize, usize)> = Vec::new(); - let is_non_ansi; - if cur.eat(b')')? { - is_non_ansi = false; - } else { - let save = cur.pos; - let has_dir = cur.dir_kw()?.is_some(); - cur.pos = save; - if has_dir { - is_non_ansi = false; - let (mut cdir, mut crange) = (PortDir::Input, None); - loop { - if let Some(d) = cur.dir_kw()? { - cdir = d; - let _ = cur.kw(b"wire")?; - if cur.kw(b"logic")? || cur.kw(b"reg")? || cur.kw(b"signed")? { - return None; - } - crange = cur.opt_range()?; - } - let (ps, pe) = cur.ident()?; - let pname = &text[ps..pe]; - if decls.contains_key(pname) { - return None; - } - let (sym, first_net, len) = - push_nets(&mut syms, &mut nets, &mut budget, ps, pe, crange)?; - ports.push(FastPort(sym, port_dir_code(cdir), crange)); - port_bit_net.extend(first_net..first_net + len); - port_bit_start.push(port_bit_net.len() as u32); - decls.insert( - pname, - FastDecl::Net { - range: crange, - first_net, - is_port: true, - typed: true, - }, - ); - if cur.eat(b',')? { - continue; - } - if cur.eat(b')')? { - break; - } - return None; - } - } else { - is_non_ansi = true; - let mut seen = HashSet::new(); - loop { - let (ps, pe) = cur.ident()?; - if !seen.insert(&text[ps..pe]) { - return None; - } - non_ansi_hdr.push((ps, pe)); - if cur.eat(b',')? { - continue; - } - if cur.eat(b')')? { - break; - } - return None; - } - } - } - if !cur.eat(b';')? { - return None; - } - - if is_non_ansi { - let hdr_set: HashSet<&str> = non_ansi_hdr.iter().map(|&(s, e)| &text[s..e]).collect(); - let mut pdecls: HashMap<&str, (PortDir, Option<(i32, i32)>, bool)> = - HashMap::with_capacity(non_ansi_hdr.len()); - let mut wredecls: HashMap<&str, Option<(i32, i32)>> = HashMap::new(); - loop { - let save = cur.pos; - if let Some(d) = cur.dir_kw()? { - let typed = cur.kw(b"wire")?; - if cur.kw(b"logic")? || cur.kw(b"reg")? || cur.kw(b"signed")? { - return None; - } - let range = cur.opt_range()?; - loop { - let (ps, pe) = cur.ident()?; - let pname = &text[ps..pe]; - if !hdr_set.contains(pname) || pdecls.insert(pname, (d, range, typed)).is_some() - { - return None; - } - if cur.eat(b',')? { - continue; - } - if cur.eat(b';')? { - break; - } - return None; - } - continue; - } - if cur.kw(b"wire")? { - if cur.kw(b"signed")? || cur.kw(b"logic")? { - return None; - } - let range = cur.opt_range()?; - let (ws, we) = cur.ident()?; - let wname = &text[ws..we]; - if hdr_set.contains(wname) && cur.eat(b';')? { - if wredecls.insert(wname, range).is_some() { - return None; - } - continue; - } - } - cur.pos = save; - break; - } - if pdecls.len() != non_ansi_hdr.len() { - return None; - } - for &(ps, pe) in &non_ansi_hdr { - let pname = &text[ps..pe]; - let &(dir, range, mut typed) = pdecls.get(pname)?; - if let Some(&wr) = wredecls.get(pname) { - if typed || wr != range { - return None; - } - typed = true; - } - let (sym, first_net, len) = - push_nets(&mut syms, &mut nets, &mut budget, ps, pe, range)?; - ports.push(FastPort(sym, port_dir_code(dir), range)); - port_bit_net.extend(first_net..first_net + len); - port_bit_start.push(port_bit_net.len() as u32); - decls.insert( - pname, - FastDecl::Net { - range, - first_net, - is_port: true, - typed, - }, - ); - } - } - - let mut used_cells: Vec<(SymSlice, &'a CellInfo)> = Vec::new(); - let mut cell_map: HashMap<&'a str, u32> = HashMap::new(); - let mut instances: Vec = Vec::new(); - let mut inst_pin_start: Vec = vec![0]; - let mut pin_cellpin: Vec = Vec::new(); - let mut pin_net: Vec = Vec::new(); - let mut assigns: Vec = Vec::new(); - let mut seen_body = false; - - let resolve_expr = |cur: &mut Cur<'a>, - syms: &mut SymStore, - nets: &mut Vec, - decls: &mut HashMap<&'a str, FastDecl>, - allow_implicit: bool, - out: &mut Vec| - -> Option<()> { - out.clear(); - ExprReader { - syms, - nets, - decls, - allow_implicit, - } - .append(cur, out, 0) - }; - - let (mut lhs_buf, mut rhs_buf, mut conn_pins) = (Vec::new(), Vec::new(), Vec::new()); - loop { - if cur.kw(b"endmodule")? { - break; - } - if cur.kw(b"wire")? { - if seen_body || cur.kw(b"signed")? || cur.kw(b"logic")? { - return None; - } - let range = cur.opt_range()?; - loop { - let (ws, we) = cur.ident()?; - let wname = &text[ws..we]; - if let Some(ex) = decls.get_mut(wname) { - match ex { - FastDecl::Net { - range: pr, - is_port: true, - typed, - .. - } if is_non_ansi && !*typed && *pr == range => { - *typed = true; - } - _ => return None, - } - } else { - let (_, first_net, _) = - push_nets(&mut syms, &mut nets, &mut budget, ws, we, range)?; - decls.insert( - wname, - FastDecl::Net { - range, - first_net, - is_port: false, - typed: true, - }, - ); - } - if cur.eat(b',')? { - continue; - } - if cur.eat(b';')? { - break; - } - return None; - } - continue; - } - if cur.kw(b"assign")? { - seen_body = true; - loop { - resolve_expr( - &mut cur, - &mut syms, - &mut nets, - &mut decls, - true, - &mut lhs_buf, - )?; - if !cur.eat(b'=')? { - return None; - } - resolve_expr( - &mut cur, - &mut syms, - &mut nets, - &mut decls, - false, - &mut rhs_buf, - )?; - if lhs_buf.len() != rhs_buf.len() { - return None; - } - for (&l, &r) in lhs_buf.iter().zip(rhs_buf.iter()) { - if l == CONST0 || l == CONST1 { - return None; - } - assigns.push(AssignRec { lhs: l, rhs: r }); - } - if cur.eat(b',')? { - continue; - } - if cur.eat(b';')? { - break; - } - return None; - } - continue; - } - - seen_body = true; - let (cs, ce) = cur.ident()?; - let cname = &text[cs..ce]; - if cname == mod_name { - return None; - } - let cinfo = catalog.cells.get(cname)?; - if !cinfo.conflicts.is_empty() { - return None; - } - let cell_idx = if let Some(&idx) = cell_map.get(cname) { - idx - } else { - let idx = u32::try_from(used_cells.len()).ok()?; - used_cells.push((syms.verbatim(cs, ce)?, cinfo)); - cell_map.insert(cname, idx); - idx - }; - - loop { - let (is, ie) = cur.ident()?; - let iname = &text[is..ie]; - if decls.insert(iname, FastDecl::Instance).is_some() { - return None; - } - instances.push(FastInst(syms.hierarchical(text, is, ie)?, cell_idx)); - if !cur.eat(b'(')? { - return None; - } - conn_pins.clear(); - if !cur.eat(b')')? { - loop { - if !cur.eat(b'.')? { - return None; - } - let (ps, pe) = cur.ident()?; - let &pin_idx = cinfo.pin_index.get(&text[ps..pe])?; - if conn_pins.contains(&pin_idx) { - return None; - } - conn_pins.push(pin_idx); - if !cur.eat(b'(')? { - return None; - } - if !cur.eat(b')')? { - resolve_expr( - &mut cur, - &mut syms, - &mut nets, - &mut decls, - true, - &mut rhs_buf, - )?; - if rhs_buf.len() != 1 || !cur.eat(b')')? { - return None; - } - pin_cellpin.push(pin_idx); - pin_net.push(rhs_buf[0]); - } - if cur.eat(b',')? { - continue; - } - if cur.eat(b')')? { - break; - } - return None; - } - } - inst_pin_start.push(u32::try_from(pin_net.len()).ok()?); - if cur.eat(b',')? { - continue; - } - if cur.eat(b';')? { - break; - } - return None; - } - } - if cur.peek().is_some() { - return None; - } - drop(decls); - drop(cell_map); - - let est = 1 + used_cells.len() * 4 + ports.len() + nets.len() + instances.len(); - let mut st = StrTable::with_capacity(est, est * 10); - let module_name = st.intern(syms.resolve(text, mod_sym)); - - let mut cells = Vec::with_capacity(used_cells.len()); - let mut cell_pin_start = Vec::with_capacity(used_cells.len() + 1); - cell_pin_start.push(0); - let (mut cell_pin_name, mut cell_pin_dir) = (Vec::new(), Vec::new()); - for &(csym, info) in &used_cells { - cells.push(st.intern(syms.resolve(text, csym))); - for (pname, pdir) in &info.pins { - cell_pin_name.push(st.intern(pname.as_str())); - cell_pin_dir.push(pin_dir_code(*pdir)); - } - cell_pin_start.push(cell_pin_name.len() as u32); - } - - let port_recs: Vec = ports - .iter() - .map(|FastPort(name, dir, range)| { - let (msb, lsb) = range.unwrap_or((0, 0)); - PortRec { - name: st.intern(syms.resolve(text, *name)), - dir: *dir, - is_bus: u8::from(range.is_some()), - _pad: 0, - msb, - lsb, - } - }) - .collect(); - drop(ports); - - let net_recs: Vec = nets - .iter() - .map(|FastNet(name, index)| NetRec { - name: st.intern(syms.resolve(text, *name)), - index: *index, - }) - .collect(); - let n_nets = net_recs.len(); - drop(nets); - - let inst_recs: Vec = instances - .iter() - .map(|FastInst(name, cell)| InstRec { - name: st.intern(syms.resolve(text, *name)), - cell: *cell, - }) - .collect(); - drop(instances); - - let StrTable { - bytes: str_bytes, - offs: str_offs, - map, - } = st; - drop(map); - drop(syms); - - let mut net_pin_start = vec![0u32; n_nets + 1]; - for &v in &pin_net { - if (v as usize) < n_nets { - net_pin_start[v as usize + 1] += 1; - } - } - for k in 0..n_nets { - net_pin_start[k + 1] += net_pin_start[k]; - } - let mut fill = net_pin_start.clone(); - let mut net_pins = vec![0u32; net_pin_start[n_nets] as usize]; - for (p, &v) in pin_net.iter().enumerate() { - if (v as usize) < n_nets { - net_pins[fill[v as usize] as usize] = p as u32; - fill[v as usize] += 1; - } - } - drop(fill); - - let module = vec![ - module_name, - port_recs.len() as u32, - n_nets as u32, - inst_recs.len() as u32, - pin_net.len() as u32, - assigns.len() as u32, - ]; - Some(FrozenNetlist { - str_bytes: Cow::Owned(str_bytes), - str_offs: Cow::Owned(str_offs), - module: Cow::Owned(module), - cells: Cow::Owned(cells), - cell_pin_start: Cow::Owned(cell_pin_start), - cell_pin_name: Cow::Owned(cell_pin_name), - cell_pin_dir: Cow::Owned(cell_pin_dir), - ports: Cow::Owned(port_recs), - port_bit_start: Cow::Owned(port_bit_start), - port_bit_net: Cow::Owned(port_bit_net), - nets: Cow::Owned(net_recs), - instances: Cow::Owned(inst_recs), - inst_pin_start: Cow::Owned(inst_pin_start), - pin_cellpin: Cow::Owned(pin_cellpin), - pin_net: Cow::Owned(pin_net), - assigns: Cow::Owned(assigns), - net_pin_start: Cow::Owned(net_pin_start), - net_pins: Cow::Owned(net_pins), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn streamed_vectors_preserve_bit_order() { - for (text, first, expected) in [ - ( - "module top(input [7:0] a, output [7:0] y); assign y = {a[3:0], a[7:4]}; endmodule", - 8, - vec![4, 5, 6, 7, 0, 1, 2, 3], - ), - ( - "module top(input [0:7] a, output [7:0] y); assign y = {a[4:7], a[0:3]}; endmodule", - 8, - vec![4, 5, 6, 7, 0, 1, 2, 3], - ), - ( - "module top(input [7:0] a, output [7:0] y); assign {y[7:4], y[3:0]} = {2'h2, {a[3:2], 4'b0101}}; endmodule", - 8, - vec![CONST1, CONST0, 4, 5, CONST0, CONST1, CONST0, CONST1], - ), - ( - "module top(output [3:0] y); assign y = {1'd1, 3'o2}; endmodule", - 0, - vec![CONST1, CONST0, CONST1, CONST0], - ), - ] { - let catalog = CellCatalog::default(); - let fast = try_fast_flat(text, &catalog).expect("streamed vector import"); - fast.validate().unwrap(); - assert_eq!( - fast.assigns.iter().map(|a| a.lhs).collect::>(), - (first..first + expected.len() as u32).collect::>() - ); - assert_eq!( - fast.assigns.iter().map(|a| a.rhs).collect::>(), - expected, - "{text}" - ); - } - } - - #[test] - fn streaming_expression_limits_and_unknowns_fall_back() { - let catalog = CellCatalog::default(); - for rhs in ["a[0:3]", "4'bx001", "4'b000z", "4'b0001 | a", "1048577'b0"] { - let text = - format!("module top(input [3:0] a, output [3:0] y); assign y = {rhs}; endmodule"); - assert!(try_fast_flat(&text, &catalog).is_none(), "{rhs}"); - } - let text = format!( - "module top(input a, output y); assign y = {}a{}; endmodule", - "{".repeat(65), - "}".repeat(65) - ); - assert!(try_fast_flat(&text, &catalog).is_none()); - } -} diff --git a/crates/apps/rusteda-cli/src/import_rtl.rs b/crates/apps/rusteda-cli/src/import_rtl.rs deleted file mode 100644 index 3ef2713..0000000 --- a/crates/apps/rusteda-cli/src/import_rtl.rs +++ /dev/null @@ -1,494 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! RTL snapshot import (`rusteda import --rtl`; RCLI-REQ-0014). - -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Component, Path, PathBuf}; - -use rusteda_formats::verilog::{ - self, Diagnostic as VDiag, PpOptions, Preprocessor, Severity as VSev, SourceMap, Span, -}; -use rusteda_workspace::{DiagCode, Diagnostic, Error, Hash64, JsonMap, Severity, Txn, Upstream}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -/// One file in `rtl/rtl.json` (RCLI-REQ-0014). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RtlFileEntry { - /// Relative path inside the `rtl/` directory snapshot. - pub path: String, - /// File content hash (`xxh3:<16 hex>`). - pub hash: Hash64, -} - -/// The `rtl/rtl.json` manifest consumed by synthesis and verification (RCLI-REQ-0014). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RtlManifest { - /// Top module name (`--top`). - pub top: String, - /// Preprocessor macro definitions (`-D NAME[=VAL]`), sorted by name. - pub defines: BTreeMap, - /// Top-level parameter overrides (`-P NAME=VAL`), sorted by name. - pub params: BTreeMap, - /// Files in the `rtl/` directory snapshot in deterministic order. - pub files: Vec, - /// Whether predefined macros (`SYNTHESIS`) are enabled (`!--no-predefined-macros`). - pub predefined_macros: bool, - /// `blif` or `aiger` for a snapshot of those formats; absent for SystemVerilog - /// (RCLI-REQ-0022). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, -} - -/// Options for `rusteda import --rtl` (RCLI-REQ-0014). -#[derive(Debug, Clone)] -pub(crate) struct RtlImportArgs { - pub files: Vec, - pub top: String, - pub defines: Vec, - pub include_dirs: Vec, - pub params: Vec, - pub no_predefined_macros: bool, -} - -fn convert_vdiag(sm: &SourceMap, d: &VDiag, fallback_file: &str) -> Diagnostic { - let sev = match d.severity { - VSev::Note => Severity::Note, - VSev::Warning => Severity::Warning, - VSev::Error => Severity::Error, - }; - let code = DiagCode::parse(d.code).unwrap_or(DiagCode::of("V0200")); - let loc = match sm.get(d.span.file) { - Some(f) => { - let (line, col) = f.line_col(d.span.start); - Some((f.path.display().to_string(), line.max(1), col.max(1))) - } - None if !fallback_file.is_empty() => Some((fallback_file.to_string(), 1, 1)), - None => None, - }; - Diagnostic::from_parts(sev, code, loc, d.message.clone(), None) -} - -/// Normalizes a relative path without `.` or `..`; returns `None` if it is absolute, empty, or -/// escapes via `..`. -fn clean_rel_path(path: &Path) -> Option { - let mut out = PathBuf::new(); - for comp in path.components() { - match comp { - Component::CurDir => {} - Component::Normal(part) => { - let s = part.to_str()?; - if s.is_empty() || s.starts_with('.') { - return None; - } - out.push(s); - } - Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None, - } - } - (!out.as_os_str().is_empty()).then_some(out) -} - -/// Resolves an `` `include `` target hermetically: only the including file's directory and `-I` -/// directories are searched; no CWD fallback and no absolute/parent-escaping paths outside them -/// (RCLI-REQ-0014). -fn resolve_hermetic_include( - target: &str, - including_file: &Path, - include_dirs: &[PathBuf], -) -> Result<(PathBuf, PathBuf), String> { - let target_path = Path::new(target); - if target_path.is_absolute() { - return Err(format!( - "include file '{target}' uses an absolute path; only relative paths in the including file's directory or -I directories are allowed" - )); - } - let Some(clean_rel) = clean_rel_path(target_path) else { - return Err(format!( - "include file '{target}' escapes the include directory" - )); - }; - - let including_dir = match including_file.parent() { - Some(p) if !p.as_os_str().is_empty() => p, - _ => Path::new("."), - }; - let candidate = including_dir.join(&clean_rel); - if fs::symlink_metadata(&candidate).is_ok_and(|m| m.file_type().is_file()) { - return Ok((candidate, clean_rel)); - } - - for dir in include_dirs { - let candidate = dir.join(&clean_rel); - if fs::symlink_metadata(&candidate).is_ok_and(|m| m.file_type().is_file()) { - return Ok((candidate, clean_rel)); - } - } - - Err(format!("include file '{target}' not found")) -} - -/// Scans `text` for `` `include `` directives outside comments and string literals and returns -/// `(1-based line, 1-based col, raw target)` for each directive. -fn scan_include_directives(text: &str) -> Vec<(u32, u32, String)> { - let bytes = text.as_bytes(); - let mut i = 0usize; - let mut line = 1u32; - let mut line_start = 0usize; - let mut out = Vec::new(); - - while i < bytes.len() { - let b = bytes[i]; - if b == b'\n' { - i += 1; - line += 1; - line_start = i; - continue; - } - // Line comment - if b == b'/' && bytes.get(i + 1) == Some(&b'/') { - i += 2; - while i < bytes.len() && bytes[i] != b'\n' { - i += 1; - } - continue; - } - // Block comment - if b == b'/' && bytes.get(i + 1) == Some(&b'*') { - i += 2; - while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { - if bytes[i] == b'\n' { - line += 1; - line_start = i + 1; - } - i += 1; - } - i = (i + 2).min(bytes.len()); - continue; - } - // String literal - if b == b'"' { - i += 1; - while i < bytes.len() && bytes[i] != b'"' { - if bytes[i] == b'\\' { - i += 1; - } - if i < bytes.len() && bytes[i] == b'\n' { - line += 1; - line_start = i + 1; - } - i += 1; - } - i = (i + 1).min(bytes.len()); - continue; - } - // Directive - if b == b'`' { - let tick_col = (i - line_start) as u32 + 1; - let tick_line = line; - i += 1; - let start = i; - while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { - i += 1; - } - let word = &text[start..i]; - if word == "include" { - while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') { - i += 1; - } - if bytes.get(i) == Some(&b'"') { - i += 1; - let s = i; - while i < bytes.len() && bytes[i] != b'"' && bytes[i] != b'\n' { - i += 1; - } - let target = text[s..i].to_string(); - if bytes.get(i) == Some(&b'"') { - i += 1; - } - out.push((tick_line, tick_col, target)); - } else if bytes.get(i) == Some(&b'<') { - i += 1; - let s = i; - while i < bytes.len() && bytes[i] != b'>' && bytes[i] != b'\n' { - i += 1; - } - let target = text[s..i].to_string(); - if bytes.get(i) == Some(&b'>') { - i += 1; - } - out.push((tick_line, tick_col, target)); - } - } - continue; - } - i += 1; - } - out -} - -/// Validates and snapshots `--rtl` files and their hermetic `` `include `` closure into `txn` -/// (RCLI-REQ-0014). -pub(crate) fn import_rtl( - txn: &mut Txn, - args: &RtlImportArgs, - metrics: &mut JsonMap, - diags: &mut Vec, -) -> Result<(), Error> { - // BLIF and AIGER files (RCLI-REQ-0021). - if let Some(format) = crate::import_logic::format_of(&args.files)? { - return crate::import_logic::import_logic(txn, args, format, metrics, diags); - } - let top = args.top.trim(); - if top.is_empty() { - return Err(Error::usage("--top module name must not be empty")); - } - - let mut defines_map = BTreeMap::new(); - let mut pp_defines = Vec::new(); - if !args.no_predefined_macros { - pp_defines.push(("SYNTHESIS".to_string(), String::new())); - } - for def in &args.defines { - let (name, val) = match def.split_once('=') { - Some((n, v)) => (n.trim(), v), - None => (def.trim(), ""), - }; - if name.is_empty() { - return Err(Error::usage(format!("invalid -D definition '{def}'"))); - } - defines_map.insert(name.to_string(), val.to_string()); - pp_defines.push((name.to_string(), val.to_string())); - } - - let mut sm = SourceMap::new(); - let mut params_map = BTreeMap::new(); - for param in &args.params { - let Some((name, val)) = param.split_once('=') else { - return Err(Error::usage(format!( - "invalid -P parameter '{param}'; expected NAME=EXPR" - ))); - }; - let name = name.trim(); - if name.is_empty() { - return Err(Error::usage(format!( - "invalid -P parameter '{param}'; parameter name must not be empty" - ))); - } - let label = format!("<-P {name}>"); - if let Err(d) = verilog::parse_expression(&mut sm, &label, val) { - let conv = convert_vdiag(&sm, &d, &label); - let msg = format!("{label}: {}", conv.message); - diags.push(conv); - return Err(Error::input_rejected(msg)); - } - params_map.insert(name.to_string(), val.to_string()); - } - - // Collect given files and their hermetic include closure. - // Snapshot entries: (snapshot_rel_path, source_disk_path, bytes, hash) - let mut snapshot_files: BTreeMap, Hash64)> = BTreeMap::new(); - let mut recorded_sources: BTreeSet = BTreeSet::new(); - let mut visited_disk: BTreeSet = BTreeSet::new(); - let mut work_queue: Vec<(PathBuf, PathBuf)> = Vec::new(); - - for (idx, file) in args.files.iter().enumerate() { - let file_str = file.display().to_string(); - let meta = fs::symlink_metadata(file).map_err(|e| Error::io(file, e))?; - if !meta.file_type().is_file() { - let msg = format!("{file_str}: not a regular file"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("V0002"), msg.clone()) - .at(file_str, 1, 1), - ); - return Err(Error::input_rejected(msg)); - } - let snap_rel = clean_rel_path(file).unwrap_or_else(|| { - let fname = file - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("source.v"); - PathBuf::from(format!("src_{idx}_{fname}")) - }); - work_queue.push((file.clone(), snap_rel)); - } - - while let Some((disk_path, snap_rel)) = work_queue.pop() { - let canonical = fs::canonicalize(&disk_path).unwrap_or_else(|_| disk_path.clone()); - let bytes = fs::read(&disk_path).map_err(|e| Error::io(&disk_path, e))?; - let text = std::str::from_utf8(&bytes).map_err(|e| { - let p_str = disk_path.display().to_string(); - let msg = format!("{p_str}:1:1: invalid UTF-8 in Verilog source: {e}"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("V0001"), msg.clone()) - .at(p_str, 1, 1), - ); - Error::input_rejected(msg) - })?; - let hash = Hash64::of(&bytes); - let src_str = disk_path.display().to_string(); - let safe_src = if src_str.starts_with("env:") { - format!("./{src_str}") - } else { - src_str.clone() - }; - if recorded_sources.insert(safe_src.clone()) { - txn.add_source("rtl", safe_src, hash)?; - } - - let snap_key = snap_rel.to_string_lossy().replace('\\', "/"); - snapshot_files - .entry(snap_key) - .or_insert_with(|| (disk_path.clone(), bytes.clone(), hash)); - - if !visited_disk.insert(canonical) { - continue; - } - - for (line, col, inc_target) in scan_include_directives(text) { - match resolve_hermetic_include(&inc_target, &disk_path, &args.include_dirs) { - Ok((inc_disk, inc_rel)) => { - // Store at both the relative include path and relative to the including file's - // snapshot directory when applicable so relative includes inside the snapshot - // resolve hermetically. - if let Some(parent) = snap_rel.parent() { - if !parent.as_os_str().is_empty() { - if let Some(joined) = clean_rel_path(&parent.join(&inc_rel)) { - let inc_bytes = - fs::read(&inc_disk).map_err(|e| Error::io(&inc_disk, e))?; - let inc_hash = Hash64::of(&inc_bytes); - let key = joined.to_string_lossy().replace('\\', "/"); - snapshot_files - .entry(key) - .or_insert_with(|| (inc_disk.clone(), inc_bytes, inc_hash)); - } - } - } - work_queue.push((inc_disk, inc_rel)); - } - Err(reason) => { - let msg = format!("{src_str}:{line}:{col}: {reason}"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("V0200"), reason) - .at(src_str, line, col), - ); - return Err(Error::input_rejected(msg)); - } - } - } - } - - // Run `rusteda_formats::verilog::Preprocessor` across the given files to validate directives, - // macros, and conditional includes. - let pp_opts = PpOptions { - include_dirs: args.include_dirs.clone(), - defines: pp_defines, - }; - let mut pp = Preprocessor::new(pp_opts); - for file in &args.files { - let file_str = file.display().to_string(); - let id = sm.load(file).map_err(|e| Error::io(file, e))?; - let _tokens = pp.run(&mut sm, id); - let mut first_err: Option = None; - for vd in pp.diags.drain(..) { - let conv = convert_vdiag(&sm, &vd, &file_str); - if conv.severity == Severity::Error && first_err.is_none() { - first_err = Some(match &conv.location { - Some(loc) => format!("{}:{}:{}: {}", loc.file, loc.line, loc.col, conv.message), - None => format!("{file_str}:1:1: {}", conv.message), - }); - } - diags.push(conv); - } - if let Some(err_msg) = first_err { - return Err(Error::input_rejected(err_msg)); - } - } - - // Check if `pp.run` loaded any additional files via macro-expanded includes, and verify they - // also satisfy hermetic include resolution. - let mut idx = 0u32; - while let Some(sf) = sm.get(verilog::FileId(idx)) { - idx += 1; - let sf_path = sf.path.clone(); - if sf_path.to_string_lossy().starts_with("<-P ") { - continue; - } - let is_given = args.files.iter().any(|f| f == &sf_path); - if !is_given { - let fname = sf_path.to_string_lossy().to_string(); - let allowed = args.files.iter().any(|f| { - let dir = match f.parent() { - Some(p) if !p.as_os_str().is_empty() => p, - _ => Path::new("."), - }; - sf_path.starts_with(dir) && dir != Path::new("") - }) || args.include_dirs.iter().any(|d| sf_path.starts_with(d)); - if !allowed { - let d = VDiag::error( - "V0200", - Span::new(verilog::FileId(idx - 1), 0, 0), - format!("include file '{fname}' is outside the include directories"), - ); - let conv = convert_vdiag(&sm, &d, &fname); - diags.push(conv); - return Err(Error::input_rejected(format!( - "{fname}:1:1: include file '{fname}' is outside the include directories" - ))); - } - if let Some(clean) = clean_rel_path(&sf_path) { - let bytes = sf.text.as_bytes().to_vec(); - let hash = Hash64::of(&bytes); - let key = clean.to_string_lossy().replace('\\', "/"); - snapshot_files - .entry(key) - .or_insert_with(|| (sf_path.clone(), bytes, hash)); - } - } - } - - // Write the snapshot directory into staging `rtl/`. - let rtl_dir = txn.staging_path("rtl")?; - fs::create_dir_all(&rtl_dir).map_err(|e| Error::io(&rtl_dir, e))?; - - let mut manifest_files = Vec::with_capacity(snapshot_files.len()); - for (rel_path, (_, bytes, hash)) in &snapshot_files { - if rel_path == "rtl.json" { - return Err(Error::input_rejected( - "RTL source file may not be named 'rtl.json'", - )); - } - let dst = rtl_dir.join(rel_path); - if let Some(parent) = dst.parent() { - fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; - } - fs::write(&dst, bytes).map_err(|e| Error::io(&dst, e))?; - manifest_files.push(RtlFileEntry { - path: rel_path.clone(), - hash: *hash, - }); - } - - let rtl_manifest = RtlManifest { - top: top.to_string(), - defines: defines_map, - params: params_map, - files: manifest_files, - predefined_macros: !args.no_predefined_macros, - format: None, - }; - let mut manifest_json = serde_json::to_string_pretty(&rtl_manifest) - .map_err(|e| Error::internal(format!("serializing rtl.json: {e}")))?; - manifest_json.push('\n'); - let manifest_path = rtl_dir.join("rtl.json"); - fs::write(&manifest_path, manifest_json.as_bytes()) - .map_err(|e| Error::io(&manifest_path, e))?; - - txn.add_dir("rtl", "rtl", Upstream::new())?; - let file_count = rtl_manifest.files.len(); - txn.set_summary("rtl", "rtl_files", file_count)?; - metrics.insert("rtl_files".to_string(), json!(file_count)); - Ok(()) -} diff --git a/crates/apps/rusteda-cli/src/import_sdc.rs b/crates/apps/rusteda-cli/src/import_sdc.rs deleted file mode 100644 index c9fd6a6..0000000 --- a/crates/apps/rusteda-cli/src/import_sdc.rs +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! SDC constraint import (`rusteda import --sdc`; RCLI-REQ-0011, SDC-REQ-0051 to SDC-REQ-0055). - -use std::fs; -use std::path::PathBuf; - -use rusteda_core::diag as core_diag; -use rusteda_formats::sdc::{self, Env, Limits, Source}; -use rusteda_workspace::{ - DiagCode, Diagnostic, Error, Hash64, JsonMap, RbinWriter, Severity, Txn, Upstream, Workspace, -}; -use serde_json::json; - -/// Table name in `meta.json` (`SDC-REQ-0051`). -pub const TABLE_NAME: &str = "constraints"; -/// File name of the `CONSTR v1` binary table inside a revision directory (`SDC-REQ-0051`). -pub const TABLE_PATH: &str = "constraints.rbin"; -/// `rbin` header kind for `constraints.rbin` (`SDC-REQ-0040`, `SDC-REQ-0051`). -pub const KIND: &str = "CONSTR"; -/// `rbin` format version for `CONSTR v1` (`SDC-REQ-0040`, `SDC-REQ-0051`). -pub const FORMAT_VERSION: u32 = 1; - -/// Converts a `rusteda_core::diag::Diag` from `rusteda_formats::sdc` into an envelope [`Diagnostic`]. -fn convert_diag(d: core_diag::Diag, fallback_file: &str) -> Diagnostic { - let sev = match d.severity { - core_diag::Severity::Note => Severity::Note, - core_diag::Severity::Warning => Severity::Warning, - core_diag::Severity::Error => Severity::Error, - }; - let code = DiagCode::parse(&d.code).unwrap_or(DiagCode::of("D0001")); - let loc = d.location.map(|l| { - let file = if l.file.is_empty() { - fallback_file.to_string() - } else { - l.file - }; - (file, l.line.max(1), l.column.max(1)) - }); - Diagnostic::from_parts(sev, code, loc, d.message, d.help) -} - -/// Parses `--sdc-env NAME=VALUE` command-line arguments (`SDC-REQ-0009`, `SDC-REQ-0055`). -fn parse_sdc_env_args( - env_vars: &[String], - diags: &mut Vec, -) -> Result, Error> { - let mut pairs = Vec::with_capacity(env_vars.len()); - for raw in env_vars { - let Some((name, val)) = raw.split_once('=') else { - let msg = format!("invalid --sdc-env '{raw}': expected NAME=VALUE"); - diags.push(Diagnostic::new( - Severity::Error, - DiagCode::of("D0004"), - msg.clone(), - )); - return Err(Error::input_rejected(msg)); - }; - if name.is_empty() || name.contains('\0') || val.contains('\0') { - let msg = format!("invalid --sdc-env '{raw}': NAME must be non-empty without NUL"); - diags.push(Diagnostic::new( - Severity::Error, - DiagCode::of("D0004"), - msg.clone(), - )); - return Err(Error::input_rejected(msg)); - } - pairs.push((name.to_string(), val.to_string())); - } - Ok(pairs) -} - -/// Imports SDC constraint files and `--sdc-env` bindings into `txn` -/// (`RCLI-REQ-0011`, `SDC-REQ-0051` to `SDC-REQ-0055`). -pub(crate) fn import_sdc( - _ws: &Workspace, - txn: &mut Txn, - files: &[PathBuf], - env_vars: &[String], - metrics: &mut JsonMap, - diags: &mut Vec, -) -> Result<(), Error> { - if files.is_empty() { - return Ok(()); - } - - let env_pairs = parse_sdc_env_args(env_vars, diags)?; - let sdc_env = Env::from_pairs(env_pairs.iter().cloned()); - - let mut sources = Vec::with_capacity(files.len()); - let mut first_source_name = String::new(); - for (idx, file) in files.iter().enumerate() { - let source_str = file.display().to_string(); - if idx == 0 { - first_source_name = source_str.clone(); - } - let meta = fs::symlink_metadata(file).map_err(|e| Error::io(file, e))?; - if !meta.file_type().is_file() { - let msg = format!("{source_str}: not a regular file"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("D0001"), msg.clone()).at( - source_str.clone(), - 1, - 1, - ), - ); - return Err(Error::input_rejected(format!("{source_str}:1:1: {msg}"))); - } - let bytes = fs::read(file).map_err(|e| Error::io(file, e))?; - let source_hash = Hash64::of(&bytes); - let text = match String::from_utf8(bytes) { - Ok(s) => s, - Err(e) => { - let msg = format!("invalid UTF-8 in SDC file: {e}"); - diags.push( - Diagnostic::new(Severity::Error, DiagCode::of("D0001"), msg.clone()).at( - source_str.clone(), - 1, - 1, - ), - ); - return Err(Error::input_rejected(format!("{source_str}:1:1: {msg}"))); - } - }; - sources.push(Source::with_hash(source_str, text, source_hash.0)); - } - - let (sdc_model, sdc_diags) = sdc::read(&sources, &sdc_env, &Limits::default()); - - let mut first_err: Option = None; - for rd in sdc_diags { - let converted = convert_diag(rd, &first_source_name); - if converted.severity == Severity::Error && first_err.is_none() { - first_err = Some(match &converted.location { - Some(loc) => format!( - "{}:{}:{}: [{}] {}", - loc.file, - loc.line, - loc.col, - converted.code.as_str(), - converted.message - ), - None => format!( - "{first_source_name}:1:1: [{}] {}", - converted.code.as_str(), - converted.message - ), - }); - } - diags.push(converted); - } - - if let Some(err_msg) = first_err { - return Err(Error::input_rejected(err_msg)); - } - - let sections = sdc_model.to_sections(); - let mut writer = RbinWriter::new(KIND, FORMAT_VERSION)?; - for sec in §ions { - writer.section_bytes(u32::from(sec.id), sec.elem_size, sec.bytes.as_slice())?; - } - txn.add_rbin(TABLE_NAME, TABLE_PATH, &writer, Upstream::new())?; - - for src in &sdc_model.sources { - txn.add_source(TABLE_NAME, src.path.clone(), Hash64(src.xxh3))?; - } - for (name, val) in &env_pairs { - txn.add_env_source(TABLE_NAME, name, val)?; - } - - let n_cmds = sdc_model.cmds.len(); - let n_sources = sdc_model.sources.len(); - txn.set_summary(TABLE_NAME, "commands", n_cmds)?; - txn.set_summary(TABLE_NAME, "sources", n_sources)?; - if let Some(design) = &sdc_model.current_design { - txn.set_summary(TABLE_NAME, "current_design", design.clone())?; - } - - metrics.insert("constraints_commands".to_string(), json!(n_cmds)); - metrics.insert("constraints_sources".to_string(), json!(n_sources)); - Ok(()) -} diff --git a/crates/apps/rusteda-cli/src/import_spef.rs b/crates/apps/rusteda-cli/src/import_spef.rs deleted file mode 100644 index 0cdeac1..0000000 --- a/crates/apps/rusteda-cli/src/import_spef.rs +++ /dev/null @@ -1,302 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Atomic, bounded detailed-parasitic import (RCLI-REQ-0070 to RCLI-REQ-0072, RCLI-REQ-0075). -//! -//! One import binds one or more SPEF files to named corners (`--spef FILE --spef-corner NAME`, -//! repeatable). The `parasitics` table is a `PARASIT` v2 container that holds the decoded source -//! of every corner: the corners of the base revision whose table still describes the netlist of -//! the new revision are kept, and a corner imported again is replaced. -use rusteda_formats::spef::{self, CornerSetMetadata, CornerSource, Limits, Metadata}; -use rusteda_workspace::{ - DiagCode, Diagnostic, Error, Hash64, JsonMap, RbinWriter, Revision, Severity, Txn, -}; -use serde_json::json; -use std::collections::BTreeMap; -use std::fs::{self, OpenOptions}; -use std::io::Read; -use std::path::{Path, PathBuf}; - -/// The table name and file of the parasitics. -pub(crate) const TABLE: &str = "parasitics"; -const TABLE_PATH: &str = "parasitics.rbin"; -/// Largest metadata section of a container: 4 KiB per corner, for at most 64 corners. -const MAX_METADATA_BYTES: usize = 64 * 4096; -/// The decoded sources of all corners of a table, which an import holds at once, total at most -/// this many per-file byte budgets (RCLI-REQ-0075). -const MAX_TABLE_SOURCES: usize = 8; - -/// One corner of the new table: its metadata and decoded source. -pub(crate) struct CornerEntry { - pub(crate) source: CornerSource, - text: Vec, -} - -fn check_corner(corner: &str) -> Result<(), Error> { - if corner.is_empty() - || corner.len() > 128 - || !corner - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') - { - return Err(Error::usage( - "--spef-corner must be a shell-safe corner handle", - )); - } - Ok(()) -} - -/// Reads the raw bytes of one SPEF file, bounded by `limits` (RCLI-REQ-0073). -fn read_source(path: &Path, limits: Limits) -> Result, Error> { - let meta = fs::symlink_metadata(path) - .map_err(|e| Error::input_rejected(format!("{}: {e}", path.display())))?; - if !meta.file_type().is_file() { - return Err(Error::input_rejected( - "SPEF source must be a regular file, not a symlink/FIFO/device", - )); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW); - } - let file = options - .open(path) - .map_err(|e| Error::input_rejected(format!("{}: {e}", path.display())))?; - if !file - .metadata() - .map_err(|e| Error::input_rejected(e.to_string()))? - .is_file() - { - return Err(Error::input_rejected("SPEF source is not a regular file")); - } - let mut bytes = Vec::new(); - file.take(limits.max_bytes as u64 + 1) - .read_to_end(&mut bytes) - .map_err(|e| Error::input_rejected(format!("{}: {e}", path.display())))?; - if bytes.len() > limits.max_bytes { - return Err(Error::input_rejected( - "T0072: SPEF source byte budget exceeded", - )); - } - Ok(bytes) -} - -/// The corners of a `parasitics` table of `rev`, read back for carry-forward (RCLI-REQ-0075). -/// A version 1 table holds one corner without a recorded source path, which is taken from the -/// revision's sources of the table. -pub(crate) fn read_corners(rev: &Revision) -> Result, Error> { - let rbin = rev.open_rbin(TABLE, "PARASIT", 1..=2)?; - rbin.verify_hash()?; - let bad = |e: String| Error::input_rejected(format!("invalid PARASIT table: {e}")); - if rbin.format_version() == 1 { - let metadata = rbin.section_bytes(2)?; - if metadata.len() > 4096 { - return Err(bad("metadata budget exceeded".into())); - } - let meta: Metadata = serde_json::from_slice(metadata).map_err(|e| bad(e.to_string()))?; - let (source_path, source_hash) = rev - .meta() - .sources - .iter() - .find(|s| s.table == TABLE) - .map_or_else( - || (String::new(), String::new()), - |s| (s.path.clone(), s.hash.to_string()), - ); - return Ok(vec![CornerEntry { - source: CornerSource { - corner: meta.corner, - limits: meta.limits, - source_path, - source_hash, - }, - text: rbin.section_bytes(1)?.to_vec(), - }]); - } - let metadata = rbin.section_bytes(1)?; - if metadata.len() > MAX_METADATA_BYTES { - return Err(bad("metadata budget exceeded".into())); - } - let set: CornerSetMetadata = - serde_json::from_slice(metadata).map_err(|e| bad(e.to_string()))?; - let mut out = Vec::with_capacity(set.corners.len()); - for (i, source) in set.corners.into_iter().enumerate() { - let text = rbin.section_bytes(i as u32 + 2)?.to_vec(); - out.push(CornerEntry { source, text }); - } - Ok(out) -} - -/// Imports the SPEF files `inputs`, `(path, corner)` in command-line order, into the -/// `parasitics` table of `txn` (RCLI-REQ-0070, RCLI-REQ-0075). -pub(crate) fn import_spefs( - txn: &mut Txn, - inputs: &[(PathBuf, String)], - limits: Limits, - metrics: &mut JsonMap, - diags: &mut Vec, -) -> Result<(), Error> { - limits - .validate() - .map_err(|e| Error::input_rejected(e.to_string()))?; - if inputs.len() > 64 { - return Err(Error::usage("at most 64 --spef corners per import")); - } - for (i, (_, corner)) in inputs.iter().enumerate() { - check_corner(corner)?; - if inputs[..i].iter().any(|(_, c)| c == corner) { - return Err(Error::usage(format!( - "--spef-corner {corner} is given more than once" - ))); - } - if !txn.libraries().iter().any(|l| l.corner == *corner) { - return Err(Error::input_rejected(format!( - "SPEF corner {corner} has no imported library" - ))); - } - } - - // The corners of the base table that still describe the netlist of the new revision. - let mut corners: BTreeMap = BTreeMap::new(); - let base = txn.base(); - if base.has_table(TABLE) { - let base_netlist = base - .open_rbin(TABLE, "PARASIT", 1..=2)? - .upstream_hash("netlist"); - if base_netlist.is_some() && base_netlist == txn.table_hash("netlist") { - for entry in read_corners(base)? { - corners.insert(entry.source.corner.clone(), entry); - } - } - } - let kept: Vec = corners - .keys() - .filter(|c| !inputs.iter().any(|(_, n)| n == *c)) - .cloned() - .collect(); - // The decoded sources held at once: the kept corners and every imported one so far - // (RCLI-REQ-0075). - let table_budget = limits.max_bytes.saturating_mul(MAX_TABLE_SOURCES); - let mut held: usize = corners - .iter() - .filter(|(c, _)| kept.contains(c)) - .map(|(_, e)| e.text.len()) - .sum(); - let mut hold = |n: usize| -> Result<(), Error> { - held = held.saturating_add(n); - if held > table_budget { - return Err(Error::input_rejected(format!( - "T0072: the decoded SPEF sources of the parasitics table exceed {table_budget} \ - bytes ({MAX_TABLE_SOURCES} times the per-file byte budget)" - ))); - } - Ok(()) - }; - hold(0)?; - - let mut per_corner = serde_json::Map::new(); - let mut coupling_records = 0usize; - for (path, corner) in inputs { - let bytes = read_source(path, limits)?; - let mut reject = |e: spef::Error| { - diags.push(Diagnostic::from_parts( - Severity::Error, - DiagCode::of(e.code), - Some((path.display().to_string(), e.line.max(1) as u32, 1)), - e.message.clone(), - None, - )); - Error::input_rejected(e.to_string()) - }; - let decoded = spef::decode_source(&bytes, limits).map_err(&mut reject)?; - hold(decoded.text.len())?; - let parsed = spef::read(&decoded.text, limits).map_err(&mut reject)?; - coupling_records += parsed.coupling_records; - per_corner.insert( - corner.clone(), - json!({ - "nets": parsed.nets.len(), - "nodes": parsed.node_count, - "elements": parsed.element_count, - "compression": decoded.encoding.as_str(), - "source_bytes": bytes.len(), - "decoded_bytes": decoded.text.len(), - }), - ); - if inputs.len() == 1 { - metrics.insert("spef_nets".into(), json!(parsed.nets.len())); - metrics.insert("spef_nodes".into(), json!(parsed.node_count)); - metrics.insert("spef_elements".into(), json!(parsed.element_count)); - metrics.insert("spef_corner".into(), json!(corner)); - metrics.insert("spef_compression".into(), json!(decoded.encoding.as_str())); - metrics.insert("spef_source_bytes".into(), json!(bytes.len())); - metrics.insert("spef_decoded_bytes".into(), json!(decoded.text.len())); - } - corners.insert( - corner.clone(), - CornerEntry { - source: CornerSource { - corner: corner.clone(), - limits, - source_path: path.display().to_string(), - source_hash: Hash64::of(&bytes).to_string(), - }, - text: decoded.text.into_bytes(), - }, - ); - } - - let set = CornerSetMetadata { - corners: corners.values().map(|c| c.source.clone()).collect(), - }; - let metadata = serde_json::to_vec(&set).map_err(|e| Error::internal(e.to_string()))?; - if metadata.len() > MAX_METADATA_BYTES { - return Err(Error::input_rejected( - "T0072: parasitic metadata budget exceeded", - )); - } - let mut writer = RbinWriter::new("PARASIT", 2)?; - writer.section_bytes(1, 1, metadata)?; - for (i, entry) in corners.values().enumerate() { - writer.section_bytes(i as u32 + 2, 1, entry.text.as_slice())?; - } - let upstream = txn.upstream(&["netlist"])?; - txn.add_rbin(TABLE, TABLE_PATH, &writer, upstream)?; - for entry in corners.values() { - let hash = Hash64::parse(&entry.source.source_hash); - if let Some(hash) = hash - && !entry.source.source_path.is_empty() - { - txn.add_source(TABLE, entry.source.source_path.clone(), hash)?; - } - } - // A pre-extraction timing table cannot depend on a table that did not yet exist. - for table in ["sta", "sta_check", "limits"] { - if txn.table_hash(table).is_some() { - txn.drop_table(table, "extracted parasitics imported")?; - } - } - metrics.insert( - "spef_corners".into(), - json!(corners.keys().collect::>()), - ); - metrics.insert( - "spef_imported".into(), - serde_json::Value::Object(per_corner), - ); - metrics.insert("spef_kept_corners".into(), json!(kept)); - if coupling_records > 0 { - diags.push(Diagnostic::new( - Severity::Warning, - DiagCode::of("T0074"), - format!( - "{coupling_records} inter-net coupling records grounded at factor 1; crosstalk \ - not analyzed" - ), - )); - } - Ok(()) -} diff --git a/crates/apps/rusteda-cli/src/init.rs b/crates/apps/rusteda-cli/src/init.rs deleted file mode 100644 index 9537a57..0000000 --- a/crates/apps/rusteda-cli/src/init.rs +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! `rusteda init` implementation (RCLI-REQ-0010, WS-REQ-0001, WS-REQ-0069). - -use std::path::Path; - -use rusteda_workspace::text::{mutation_header, next_line}; -use rusteda_workspace::{Envelope, Error, Invocation, Rev, Workspace}; - -use crate::OutputOutcome; - -/// Runs `rusteda init -w ` (RCLI-REQ-0010). -pub(crate) fn run_init(dir: &Path, ws_arg: &str, inv: &Invocation) -> Result { - let init = Workspace::init(dir, inv)?; - let mut env = Envelope::new("rusteda init"); - env.workspace = Some(ws_arg.to_string()); - let rev = Rev(0); - env.new_rev = Some(rev); - env.diagnostics = init.warnings; - let rev_s = rev.to_string(); - env.push_next_action(&["rusteda", "show", "-w", ws_arg, "--rev", &rev_s]); - env.push_next_action(&["rusteda", "log", "-w", ws_arg]); - - let mut lines = Vec::new(); - lines.push(mutation_header(rev, "rusteda init", rev)); - lines.push(format!(" workspace: {ws_arg}")); - for next in &env.next_actions { - lines.push(next_line(next)); - } - Ok(OutputOutcome { - envelope: env, - text_lines: lines, - }) -} diff --git a/crates/apps/rusteda-cli/src/lib.rs b/crates/apps/rusteda-cli/src/lib.rs index 5403711..f8177cb 100644 --- a/crates/apps/rusteda-cli/src/lib.rs +++ b/crates/apps/rusteda-cli/src/lib.rs @@ -1,922 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: 2026 RustEDA contributors -//! The `rusteda` command-line program (`docs/specs/rusteda-cli/README.md`, RCLI-REQ-0001 to -//! RCLI-REQ-0018). +//! Product adapter for the canonical shared workspace command. +pub use rusteda_cli_shared::*; -mod export; -mod import; -mod import_lib; -mod import_logic; -mod import_netlist; -mod import_rtl; -mod import_sdc; -mod import_spef; -mod init; -mod query; -mod revert; - -pub use import_rtl::{RtlFileEntry, RtlManifest}; -pub use query::{LibraryDiff, LibraryHashChange, LogEntry, TableDiffEntry}; - -use std::ffi::OsString; -use std::io::{self, Write}; -use std::path::PathBuf; -use std::process::ExitCode; - -use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; -use rusteda_workspace::text::render_diagnostic; -use rusteda_workspace::{ - DiagCode, Diagnostic, Envelope, Error, ErrorCode, Invocation, JsonStyle, Rev, Severity, - Workspace, -}; - -use export::run_export; -use import::{ImportCommand, run_import}; -use import_rtl::RtlImportArgs; -use init::run_init; -use query::{run_diff, run_log, run_show}; -use revert::run_revert; - -/// Application diagnostic codes for `rusteda` (WS-REQ-0097, RCLI-REQ-0005). -const CODE_THREAD_POOL: DiagCode = DiagCode::of("A0001"); -const CODE_INTERNAL: DiagCode = DiagCode::of("A0002"); -const CODE_USAGE: DiagCode = DiagCode::of("A0003"); -const CODE_BACKEND: DiagCode = DiagCode::of("A0501"); -const CODE_CANCELLED: DiagCode = DiagCode::of("A0130"); - -/// Successful command output in both JSON and text forms. -#[derive(Debug)] -pub(crate) struct OutputOutcome { - pub envelope: Envelope, - pub text_lines: Vec, -} - -/// Output format (`--format text|json`; RCLI-REQ-0002, RCLI-REQ-0004). -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)] -pub enum FormatArg { - #[default] - Text, - Json, -} - -/// Colour mode (`--color auto|always|never`; RCLI-REQ-0002). -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)] -pub enum ColorArg { - #[default] - Auto, - Always, - Never, -} - -/// Progress mode (`--progress auto|always|never`; RCLI-REQ-0002). -#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum, Default)] -pub enum ProgressArg { - #[default] - Auto, - Always, - Never, -} - -/// Top-level `rusteda` CLI definition (RCLI-REQ-0001, RCLI-REQ-0002). -#[derive(Parser, Debug)] -#[command( - name = "rusteda", - version = env!("CARGO_PKG_VERSION"), - propagate_version = true, - about = "RustEDA: workspace and design revision driver", -)] -pub struct Cli { - /// Workspace directory (required for every command except completions). - #[arg(short = 'w', long = "workspace", global = true, value_name = "DIR")] - pub workspace: Option, - /// Output format: human-readable text or a single rusteda.v1 JSON envelope on stdout. - #[arg(long = "format", global = true, value_enum, default_value_t = FormatArg::Text)] - pub format: FormatArg, - /// Worker threads for parallel operations (0: default). - #[arg(long = "threads", global = true, default_value_t = 0)] - pub threads: usize, - /// Suppress note diagnostics on stderr. - #[arg(short = 'q', long = "quiet", global = true, conflicts_with = "verbose")] - pub quiet: bool, - /// Verbose output. - #[arg(short = 'v', long = "verbose", global = true)] - pub verbose: bool, - /// Colour on standard error. - #[arg(long = "color", global = true, value_enum, default_value_t = ColorArg::Auto)] - pub color: ColorArg, - /// Progress reporting mode. - #[arg(long = "progress", global = true, value_enum, default_value_t = ProgressArg::Auto)] - pub progress: ProgressArg, - #[command(subcommand)] - pub cmd: Cmd, -} - -#[derive(Subcommand, Debug)] -pub enum Cmd { - /// Initialize a new workspace at `rev0`. - Init(InitArgs), - /// Import libraries, RTL sources, structural netlists, or SDC constraints as a new revision. - Import(Box), - /// List committed revisions newest first. - Log(LogArgs), - /// Show metadata, tables, libraries, sources, summary, and status of a revision. - Show(ShowArgs), - /// Compare tables, libraries, and summary metrics between two revisions. - Diff(DiffArgs), - /// Move HEAD from `--base-rev` to an existing revision `--to`. - Revert(RevertArgs), - /// Export a committed table to a file. - Export(ExportArgs), - /// Print a shell completion script. - Completions(CompletionsArgs), -} - -#[derive(Args, Debug)] -pub struct InitArgs {} - -#[derive(Args, Debug)] -pub struct ImportArgs { - /// Detailed RC SPEF (repeatable); each needs an extraction corner, paired in order with - /// `--spef-corner`. - #[arg(long, requires = "spef_corner", value_name = "FILE")] - pub spef: Vec, - /// The library corner of each `--spef`, in the same order (repeatable). - #[arg(long, requires = "spef", value_name = "NAME")] - pub spef_corner: Vec, - #[arg(long, default_value_t = 32, requires = "spef")] - pub max_spef_mib: usize, - #[arg(long, default_value_t = 100_000, requires = "spef")] - pub max_rc_nets: usize, - #[arg(long, default_value_t = 500_000, requires = "spef")] - pub max_rc_nodes: usize, - #[arg(long, default_value_t = 1_000_000, requires = "spef")] - pub max_rc_elements: usize, - #[arg(long, default_value_t = 16, requires = "spef")] - pub max_rc_name_mib: usize, - /// Base revision expected to be HEAD (e.g. `rev0`). - #[arg(long = "base-rev", value_name = "REV")] - pub base_rev: String, - /// Optional commit message. - #[arg(short = 'm', long = "message", value_name = "TEXT")] - pub message: Option, - /// Liberty library file to import (repeatable). - #[arg(long = "lib", action = clap::ArgAction::Append, value_name = "FILE")] - pub libs: Vec, - /// Corner name for the preceding `--lib` (default `"default"`). - #[arg(long = "corner", action = clap::ArgAction::Append, value_name = "NAME")] - pub corners: Vec, - /// RTL source files: Verilog/SystemVerilog, or BLIF (`.blif`) or AIGER (`.aag`, `.aig`). - #[arg(long = "rtl", num_args = 1.., action = clap::ArgAction::Append, value_name = "FILE")] - pub rtl: Vec, - /// Top module name for `--rtl`. - #[arg(long = "top", value_name = "NAME")] - pub top: Option, - /// Preprocessor macro definition `NAME` or `NAME=VAL` for `--rtl`. - #[arg(short = 'D', action = clap::ArgAction::Append, value_name = "NAME[=VAL]")] - pub defines: Vec, - /// Preprocessor include directory for `--rtl`. - #[arg(short = 'I', action = clap::ArgAction::Append, value_name = "DIR")] - pub include_dirs: Vec, - /// Top-level parameter override `NAME=VAL` for `--rtl`. - #[arg(short = 'P', action = clap::ArgAction::Append, value_name = "NAME=VAL")] - pub params: Vec, - /// Do not predefine `SYNTHESIS` when preprocessing `--rtl`. - #[arg(long = "no-predefined-macros")] - pub no_predefined_macros: bool, - /// Structural gate-level Verilog netlist file. - #[arg(long = "netlist", value_name = "FILE")] - pub netlist: Option, - /// Largest `--netlist` file, in MiB, that the full Verilog reader reads when the streaming - /// reader declines it (RCLI-REQ-0024). - #[arg(long, default_value_t = 64, requires = "netlist", value_name = "MIB")] - pub max_netlist_ast_mib: u64, - /// Synopsys Design Constraints (SDC) files. - #[arg(long = "sdc", num_args = 1.., action = clap::ArgAction::Append, value_name = "FILE")] - pub sdc: Vec, - /// Environment variable binding `NAME=VALUE` for `--sdc`. - #[arg(long = "sdc-env", action = clap::ArgAction::Append, value_name = "NAME=VALUE")] - pub sdc_env: Vec, -} - -#[derive(Args, Debug)] -pub struct LogArgs { - /// Maximum number of revisions to list. - #[arg(long = "limit", value_name = "N")] - pub limit: Option, -} - -#[derive(Args, Debug)] -pub struct ShowArgs { - /// Revision to inspect (default `HEAD`). - #[arg(long = "rev", default_value = "HEAD", value_name = "REV")] - pub rev: String, -} - -#[derive(Args, Debug)] -pub struct DiffArgs { - /// Starting revision. - #[arg(long = "from", value_name = "REV")] - pub from: String, - /// Ending revision. - #[arg(long = "to", value_name = "REV")] - pub to: String, -} - -#[derive(Args, Debug)] -pub struct RevertArgs { - /// Base revision expected to be HEAD. - #[arg(long = "base-rev", value_name = "REV")] - pub base_rev: String, - /// Target revision to become HEAD. - #[arg(long = "to", value_name = "REV")] - pub to: String, -} - -#[derive(Args, Debug)] -pub struct ExportArgs { - /// Revision to export from (default `HEAD`). - #[arg(long = "rev", default_value = "HEAD", value_name = "REV")] - pub rev: String, - /// Destination structural Verilog file for the netlist table. - #[arg(long = "netlist", value_name = "FILE")] - pub netlist: PathBuf, -} - -#[derive(Args, Debug)] -pub struct CompletionsArgs { - /// Shell to generate completions for (`bash`, `elvish`, `fish`, `powershell`, `zsh`). - #[arg(value_name = "SHELL")] - pub shell: clap_complete::Shell, -} - -/// Returns the clap [`clap::Command`] definition. +/// Retain this product's command version while sharing the implementation. pub fn command() -> clap::Command { - Cli::command() -} - -/// Pre-scanned options extracted from raw argv before clap runs, so JSON mode and history logging -/// work even when clap rejects the command line (RCLI-REQ-0004, RCLI-REQ-0008). -#[derive(Debug, Default)] -struct Prescan { - format: FormatArg, - workspace: Option, - base_rev: Option, - subcommand: Option, - quiet: bool, + command_with_version(env!("CARGO_PKG_VERSION")) } -fn prescan(args: &[OsString]) -> Prescan { - let mut out = Prescan::default(); - let strs: Vec = args - .iter() - .skip(1) - .map(|s| s.to_string_lossy().into_owned()) - .collect(); - let known_subs = [ - "init", - "import", - "log", - "show", - "diff", - "revert", - "export", - "completions", - ]; - let mut i = 0; - while i < strs.len() { - let a = &strs[i]; - if a == "--" { - break; - } - if let Some(v) = a.strip_prefix("--format=") { - if v == "json" { - out.format = FormatArg::Json; - } else if v == "text" { - out.format = FormatArg::Text; - } - } else if a == "--format" { - if let Some(v) = strs.get(i + 1) { - if v == "json" { - out.format = FormatArg::Json; - } else if v == "text" { - out.format = FormatArg::Text; - } - i += 1; - } - } else if let Some(v) = a.strip_prefix("--workspace=") { - out.workspace = Some(v.to_string()); - } else if let Some(v) = a.strip_prefix("-w=") { - out.workspace = Some(v.to_string()); - } else if a == "-w" || a == "--workspace" { - if let Some(v) = strs.get(i + 1) { - out.workspace = Some(v.clone()); - i += 1; - } - } else if let Some(v) = a.strip_prefix("--base-rev=") { - out.base_rev = Some(v.to_string()); - } else if a == "--base-rev" { - if let Some(v) = strs.get(i + 1) { - out.base_rev = Some(v.clone()); - i += 1; - } - } else if a == "-q" || a == "--quiet" { - out.quiet = true; - } else if out.subcommand.is_none() - && !a.starts_with('-') - && known_subs.contains(&a.as_str()) - { - out.subcommand = Some(a.clone()); - } - i += 1; - } - out -} - -fn app_diag_code(code: ErrorCode) -> DiagCode { - match code { - ErrorCode::Usage => CODE_USAGE, - ErrorCode::Internal => CODE_INTERNAL, - ErrorCode::Cancelled => CODE_CANCELLED, - ErrorCode::BackendUnavailable => CODE_BACKEND, - _ => code.diag_code().unwrap_or(CODE_USAGE), - } -} - -fn write_stdout(text: &str) { - let mut out = io::stdout().lock(); - let _ = out.write_all(text.as_bytes()); - let _ = out.flush(); -} - -fn write_stderr_diags(diags: &[Diagnostic], quiet: bool) { - let mut err = io::stderr().lock(); - for d in diags { - if quiet && d.severity == Severity::Note { - continue; - } - let line = render_diagnostic(d); - let _ = writeln!(err, "{line}"); - } - let _ = err.flush(); -} - -/// Emits the result or error in `--format text|json` and returns the process [`ExitCode`]. -fn finish_error( - cmd_name: &str, - ws_arg: Option<&str>, - base_rev: Option, - err: Error, - mut diags: Vec, - format: FormatArg, - quiet: bool, -) -> ExitCode { - let code = err.exit_code().clamp(1, 255) as u8; - if !diags.iter().any(|d| d.severity == Severity::Error) { - diags.push(err.to_diagnostic(app_diag_code(err.code()))); - } - let mut env = Envelope::failure(cmd_name, &err); - env.workspace = ws_arg.map(String::from); - env.base_rev = base_rev; - env.diagnostics = diags; - if let (Error::RevisionConflict { base, head }, Some(w)) = (&err, ws_arg) { - let base_s = base.to_string(); - let head_s = head.to_string(); - env.push_next_action(&["rusteda", "log", "-w", w]); - env.push_next_action(&[ - "rusteda", "diff", "-w", w, "--from", &base_s, "--to", &head_s, - ]); - env.push_next_action(&["rusteda", "show", "-w", w, "--rev", &head_s]); - } - - match format { - FormatArg::Json => { - let mut text = env.to_json(JsonStyle::Pretty); - text.push('\n'); - write_stdout(&text); - } - FormatArg::Text => { - write_stderr_diags(&env.diagnostics, quiet); - } - } - ExitCode::from(code) -} - -fn finish_ok(outcome: OutputOutcome, format: FormatArg, quiet: bool) -> ExitCode { - match format { - FormatArg::Json => { - let mut text = outcome.envelope.to_json(JsonStyle::Pretty); - text.push('\n'); - write_stdout(&text); - } - FormatArg::Text => { - write_stderr_diags(&outcome.envelope.diagnostics, quiet); - if !outcome.text_lines.is_empty() { - let mut body = outcome.text_lines.join("\n"); - body.push('\n'); - write_stdout(&body); - } - } - } - ExitCode::from(0) -} - -/// Pairs `--lib` and `--corner` arguments using their command-line indices (RCLI-REQ-0012): -/// each `--corner` applies to the `--lib` immediately preceding it; a `--lib` without a -/// following `--corner` uses `"default"`. -fn pair_libs_and_corners( - matches: &ArgMatches, - libs: &[PathBuf], - corners: &[String], -) -> Result, Error> { - if libs.is_empty() { - if !corners.is_empty() { - return Err(Error::usage("--corner requires a preceding --lib ")); - } - return Ok(Vec::new()); - } - let lib_indices: Vec = matches - .indices_of("libs") - .map(|it| it.collect()) - .unwrap_or_default(); - let corner_indices: Vec = matches - .indices_of("corners") - .map(|it| it.collect()) - .unwrap_or_default(); - - let mut assigned: Vec> = vec![None; libs.len()]; - for (c_pos, &c_idx) in corner_indices.iter().enumerate() { - // Find the last --lib whose index is before c_idx. - let Some(lib_pos) = lib_indices.iter().rposition(|&l_idx| l_idx < c_idx) else { - return Err(Error::usage("--corner must follow a --lib option")); - }; - if assigned[lib_pos].is_some() { - return Err(Error::usage( - "at most one --corner may follow each --lib ", - )); - } - assigned[lib_pos] = Some(corners[c_pos].clone()); - } - - Ok(libs - .iter() - .cloned() - .zip(assigned) - .map(|(path, c)| (path, c.unwrap_or_else(|| "default".to_string()))) - .collect()) -} - -/// Maybe logs a usage error to `history.jsonl` if the command was a workspace-changing subcommand -/// (`init`, `import`, `revert`) and `-w` points to an existing workspace (RCLI-REQ-0008). -fn maybe_log_usage_rejection(pre: &Prescan, argv_strings: &[String], err: &Error) { - let Some(sub) = pre.subcommand.as_deref() else { - return; - }; - if !matches!(sub, "init" | "import" | "revert") { - return; - } - let Some(ws_dir) = pre.workspace.as_deref() else { - return; - }; - if let Ok(ws) = Workspace::open(ws_dir) { - let base_rev = pre.base_rev.as_deref().and_then(|s| Rev::parse(s).ok()); - let inv = Invocation::new( - "rusteda", - env!("CARGO_PKG_VERSION"), - format!("rusteda {sub}"), - argv_strings.to_vec(), - ); - let _ = ws.log_rejected(&inv, base_rev, err); - } -} - -/// Runs `rusteda` with `args` (including `argv[0]`) and returns its [`ExitCode`]. -pub fn run(args: Vec) -> ExitCode { - let pre = prescan(&args); - let argv_strings: Vec = args - .iter() - .map(|a| a.to_string_lossy().into_owned()) - .collect(); - let cmd_name = pre - .subcommand - .as_deref() - .map_or_else(|| "rusteda".to_string(), |s| format!("rusteda {s}")); - - let matches = match command().try_get_matches_from(&args) { - Ok(m) => m, - Err(e) => { - use clap::error::ErrorKind; - match e.kind() { - ErrorKind::DisplayVersion => { - let ver = env!("CARGO_PKG_VERSION"); - if pre.format == FormatArg::Json { - let mut env = Envelope::new(&cmd_name); - env.workspace = pre.workspace.clone(); - let _ = env.set("version", ver); - return finish_ok( - OutputOutcome { - envelope: env, - text_lines: Vec::new(), - }, - FormatArg::Json, - pre.quiet, - ); - } - write_stdout(&format!("rusteda {ver}\n")); - return ExitCode::from(0); - } - ErrorKind::DisplayHelp => { - let help_text = e.render().to_string(); - if pre.format == FormatArg::Json { - let mut env = Envelope::new(&cmd_name); - env.workspace = pre.workspace.clone(); - let _ = env.set("help", help_text.trim_end()); - return finish_ok( - OutputOutcome { - envelope: env, - text_lines: Vec::new(), - }, - FormatArg::Json, - pre.quiet, - ); - } - write_stdout(&help_text); - return ExitCode::from(0); - } - _ => { - let rendered = e.render().to_string(); - let first = rendered - .lines() - .next() - .unwrap_or("invalid command line") - .trim(); - let msg = first.strip_prefix("error: ").unwrap_or(first).to_string(); - let err = Error::usage(&msg); - maybe_log_usage_rejection(&pre, &argv_strings, &err); - let diag = Diagnostic::new(Severity::Error, CODE_USAGE, msg) - .with_help(format!("{cmd_name} --help lists every option")); - let base_rev = pre.base_rev.as_deref().and_then(|s| Rev::parse(s).ok()); - return finish_error( - &cmd_name, - pre.workspace.as_deref(), - base_rev, - err, - vec![diag], - pre.format, - pre.quiet, - ); - } - } - } - }; - - let cli = match Cli::from_arg_matches(&matches) { - Ok(c) => c, - Err(e) => { - let msg = e.to_string(); - let err = Error::usage(&msg); - maybe_log_usage_rejection(&pre, &argv_strings, &err); - let base_rev = pre.base_rev.as_deref().and_then(|s| Rev::parse(s).ok()); - return finish_error( - &cmd_name, - pre.workspace.as_deref(), - base_rev, - err, - Vec::new(), - pre.format, - pre.quiet, - ); - } - }; - - let format = cli.format; - let quiet = cli.quiet; - - if cli.threads > 0 { - let _ = rayon::ThreadPoolBuilder::new() - .num_threads(cli.threads) - .build_global() - .map_err(|e| { - Diagnostic::new( - Severity::Warning, - CODE_THREAD_POOL, - format!("thread pool already initialized: {e}"), - ) - }); - } - - // Handle `completions` first since it does not require `-w/--workspace`. - if let Cmd::Completions(comp) = &cli.cmd { - let mut buf = Vec::new(); - let mut cmd_def = command(); - clap_complete::generate(comp.shell, &mut cmd_def, "rusteda", &mut buf); - let script = String::from_utf8_lossy(&buf).into_owned(); - if format == FormatArg::Json { - let mut env = Envelope::new("rusteda completions"); - let _ = env.set("shell", &comp.shell.to_string()); - let _ = env.set("script", &script); - return finish_ok( - OutputOutcome { - envelope: env, - text_lines: Vec::new(), - }, - FormatArg::Json, - quiet, - ); - } - write_stdout(&script); - return ExitCode::from(0); - } - - let sub_name = match &cli.cmd { - Cmd::Init(_) => "rusteda init", - Cmd::Import(_) => "rusteda import", - Cmd::Log(_) => "rusteda log", - Cmd::Show(_) => "rusteda show", - Cmd::Diff(_) => "rusteda diff", - Cmd::Revert(_) => "rusteda revert", - Cmd::Export(_) => "rusteda export", - Cmd::Completions(_) => "rusteda completions", - }; - - let Some(ws_path) = cli.workspace.as_ref() else { - let err = Error::usage(format!("{sub_name} requires -w/--workspace ")); - return finish_error(sub_name, None, None, err, Vec::new(), format, quiet); - }; - let ws_arg = ws_path.display().to_string(); - let inv = Invocation::new( - "rusteda", - env!("CARGO_PKG_VERSION"), - sub_name, - argv_strings.clone(), - ); - - match &cli.cmd { - Cmd::Init(_) => match run_init(ws_path, &ws_arg, &inv) { - Ok(out) => finish_ok(out, format, quiet), - Err(e) => finish_error(sub_name, Some(&ws_arg), None, e, Vec::new(), format, quiet), - }, - Cmd::Import(imp) => { - let sub_matches = matches - .subcommand_matches("import") - .expect("import subcommand matches"); - let base_rev_parsed = Rev::parse(&imp.base_rev).ok(); - - let ws = match Workspace::open(ws_path) { - Ok(w) => w, - Err(e) => { - return finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ); - } - }; - - let libs = match pair_libs_and_corners(sub_matches, &imp.libs, &imp.corners) { - Ok(l) => l, - Err(e) => { - let _ = ws.log_rejected(&inv, base_rev_parsed, &e); - return finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ); - } - }; - - if imp.rtl.is_empty() - && (imp.top.is_some() - || !imp.defines.is_empty() - || !imp.include_dirs.is_empty() - || !imp.params.is_empty() - || imp.no_predefined_macros) - { - let e = Error::usage( - "--top, -D, -I, -P, and --no-predefined-macros require --rtl ...", - ); - let _ = ws.log_rejected(&inv, base_rev_parsed, &e); - return finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ); - } - - let rtl_args = if !imp.rtl.is_empty() { - let Some(top) = imp.top.clone() else { - let e = Error::usage("--rtl requires --top "); - let _ = ws.log_rejected(&inv, base_rev_parsed, &e); - return finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ); - }; - Some(RtlImportArgs { - files: imp.rtl.clone(), - top, - defines: imp.defines.clone(), - include_dirs: imp.include_dirs.clone(), - params: imp.params.clone(), - no_predefined_macros: imp.no_predefined_macros, - }) - } else { - None - }; - - if imp.spef.len() != imp.spef_corner.len() { - let e = Error::usage( - "every --spef FILE needs its own --spef-corner NAME, paired in order", - ); - let _ = ws.log_rejected(&inv, base_rev_parsed, &e); - return finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ); - } - - if imp.sdc.is_empty() && !imp.sdc_env.is_empty() { - let e = Error::usage("--sdc-env requires --sdc ..."); - let _ = ws.log_rejected(&inv, base_rev_parsed, &e); - return finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ); - } - - let cmd = ImportCommand { - base_rev: imp.base_rev.clone(), - message: imp.message.clone(), - libs, - rtl: rtl_args, - netlist: imp.netlist.clone(), - max_netlist_ast_mib: imp.max_netlist_ast_mib, - sdc_files: imp.sdc.clone(), - sdc_env: imp.sdc_env.clone(), - spefs: imp - .spef - .iter() - .cloned() - .zip(imp.spef_corner.iter().cloned()) - .collect(), - spef_limits: rusteda_formats::spef::Limits { - max_bytes: imp.max_spef_mib.saturating_mul(1 << 20), - max_nets: imp.max_rc_nets, - max_nodes: imp.max_rc_nodes, - max_elements: imp.max_rc_elements, - max_name_bytes: imp.max_rc_name_mib.saturating_mul(1 << 20), - }, - }; - let mut diags = Vec::new(); - match run_import(&ws, &ws_arg, &cmd, &inv, &mut diags) { - Ok(out) => finish_ok(out, format, quiet), - Err(e) => finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - diags, - format, - quiet, - ), - } - } - Cmd::Log(log) => { - let ws = match Workspace::open(ws_path) { - Ok(w) => w, - Err(e) => { - return finish_error( - sub_name, - Some(&ws_arg), - None, - e, - Vec::new(), - format, - quiet, - ); - } - }; - match run_log(&ws, &ws_arg, log.limit) { - Ok(out) => finish_ok(out, format, quiet), - Err(e) => finish_error(sub_name, Some(&ws_arg), None, e, Vec::new(), format, quiet), - } - } - Cmd::Show(show) => { - let ws = match Workspace::open(ws_path) { - Ok(w) => w, - Err(e) => { - return finish_error( - sub_name, - Some(&ws_arg), - None, - e, - Vec::new(), - format, - quiet, - ); - } - }; - match run_show(&ws, &ws_arg, &show.rev) { - Ok(out) => finish_ok(out, format, quiet), - Err(e) => finish_error(sub_name, Some(&ws_arg), None, e, Vec::new(), format, quiet), - } - } - Cmd::Diff(diff) => { - let ws = match Workspace::open(ws_path) { - Ok(w) => w, - Err(e) => { - return finish_error( - sub_name, - Some(&ws_arg), - None, - e, - Vec::new(), - format, - quiet, - ); - } - }; - match run_diff(&ws, &ws_arg, &diff.from, &diff.to) { - Ok(out) => finish_ok(out, format, quiet), - Err(e) => finish_error(sub_name, Some(&ws_arg), None, e, Vec::new(), format, quiet), - } - } - Cmd::Revert(rev) => { - let base_rev_parsed = Rev::parse(&rev.base_rev).ok(); - let ws = match Workspace::open(ws_path) { - Ok(w) => w, - Err(e) => { - return finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ); - } - }; - match run_revert(&ws, &ws_arg, &rev.base_rev, &rev.to, &inv) { - Ok(out) => finish_ok(out, format, quiet), - Err(e) => finish_error( - sub_name, - Some(&ws_arg), - base_rev_parsed, - e, - Vec::new(), - format, - quiet, - ), - } - } - Cmd::Export(exp) => { - let ws = match Workspace::open(ws_path) { - Ok(w) => w, - Err(e) => { - return finish_error( - sub_name, - Some(&ws_arg), - None, - e, - Vec::new(), - format, - quiet, - ); - } - }; - match run_export(&ws, &ws_arg, &exp.rev, &exp.netlist) { - Ok(out) => finish_ok(out, format, quiet), - Err(e) => finish_error(sub_name, Some(&ws_arg), None, e, Vec::new(), format, quiet), - } - } - Cmd::Completions(_) => unreachable!(), - } +/// Run the shared CLI using this product's version in output and history. +pub fn run(args: Vec) -> std::process::ExitCode { + run_with_version(args, env!("CARGO_PKG_VERSION")) } diff --git a/crates/apps/rusteda-cli/src/query.rs b/crates/apps/rusteda-cli/src/query.rs deleted file mode 100644 index 80aca3f..0000000 --- a/crates/apps/rusteda-cli/src/query.rs +++ /dev/null @@ -1,467 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Query commands: `rusteda log`, `rusteda show`, and `rusteda diff` (RCLI-REQ-0015 to -//! RCLI-REQ-0017). - -use std::collections::{BTreeMap, BTreeSet}; - -use rusteda_workspace::text::next_line; -use rusteda_workspace::{ - Envelope, Error, Hash64, JsonObject, LibraryEntry, Rev, RevisionMeta, TableValues, Workspace, -}; -use serde::Serialize; -use serde_json::Value; - -use crate::OutputOutcome; - -/// One entry in `rusteda log` output (RCLI-REQ-0015). -#[derive(Debug, Clone, Serialize)] -pub struct LogEntry { - pub rev: Rev, - pub parent: Option, - pub command: String, - pub message: Option, - pub tables: Vec, - pub created_unix_ms: u64, -} - -/// Runs `rusteda log -w [--limit ]` (RCLI-REQ-0015). -pub(crate) fn run_log( - ws: &Workspace, - ws_arg: &str, - limit: Option, -) -> Result { - let head = ws.head()?; - let mut revs = ws.revisions()?; - revs.reverse(); - if let Some(max) = limit { - revs.truncate(max); - } - - let mut entries = Vec::with_capacity(revs.len()); - for rev in revs { - let r = ws.revision(rev)?; - let meta = r.meta(); - entries.push(LogEntry { - rev: meta.rev, - parent: meta.parent, - command: meta.command.clone(), - message: meta.message.clone(), - tables: meta.tables.keys().cloned().collect(), - created_unix_ms: meta.created_unix_ms, - }); - } - - let mut env = Envelope::new("rusteda log"); - env.workspace = Some(ws_arg.to_string()); - env.rev = Some(head); - env.set("revisions", &entries)?; - let head_s = head.to_string(); - env.push_next_action(&["rusteda", "show", "-w", ws_arg, "--rev", &head_s]); - - let mut lines = Vec::new(); - for entry in &entries { - let parent_s = entry - .parent - .map_or_else(|| "none".to_string(), |p| p.to_string()); - let tables_s = if entry.tables.is_empty() { - "-".to_string() - } else { - entry.tables.join(",") - }; - let msg_s = entry.message.as_deref().unwrap_or(""); - if msg_s.is_empty() { - lines.push(format!( - "{} parent={} command={} tables={} created={}", - entry.rev, parent_s, entry.command, tables_s, entry.created_unix_ms - )); - } else { - lines.push(format!( - "{} parent={} command={} tables={} created={} message={}", - entry.rev, parent_s, entry.command, tables_s, entry.created_unix_ms, msg_s - )); - } - } - for next in &env.next_actions { - lines.push(next_line(next)); - } - - Ok(OutputOutcome { - envelope: env, - text_lines: lines, - }) -} - -/// Extracts effective timing and equivalence statuses from `meta`, rendering a missing `sta` -/// status as `"not_analyzed"` and a missing `check` status as `"unchecked"` (CA-STATUS, -/// RCLI-REQ-0016). -fn effective_status(meta: &RevisionMeta) -> (String, String) { - let timing = meta - .status - .get("sta") - .and_then(|m| m.get("timing")) - .and_then(Value::as_str) - .unwrap_or("not_analyzed") - .to_string(); - let equivalence = meta - .status - .get("check") - .and_then(|m| m.get("equivalence")) - .and_then(Value::as_str) - .or_else(|| { - meta.status - .get("synth") - .and_then(|m| m.get("equivalence")) - .and_then(Value::as_str) - }) - .or_else(|| { - meta.status - .get("netlist") - .and_then(|m| m.get("equivalence")) - .and_then(Value::as_str) - }) - .unwrap_or("unchecked") - .to_string(); - (timing, equivalence) -} - -/// Runs `rusteda show -w [--rev ]` (RCLI-REQ-0016). -pub(crate) fn run_show( - ws: &Workspace, - ws_arg: &str, - rev_spec: &str, -) -> Result { - let rev = ws.resolve_query(rev_spec)?; - let revision = ws.revision(rev)?; - let meta = revision.meta(); - let (timing, equivalence) = effective_status(meta); - let corners = LibraryEntry::corners(&meta.libraries); - - let mut env = Envelope::new("rusteda show"); - env.workspace = Some(ws_arg.to_string()); - env.base_rev = meta.parent; - env.rev = Some(rev); - - let mut scope = JsonObject::new(); - scope.insert("corners", &corners)?; - scope.insert("timing", &timing)?; - scope.insert("equivalence", &equivalence)?; - env.set_scope(scope)?; - - let mut metrics = JsonObject::new(); - for table_vals in meta.summary.values() { - for (k, v) in table_vals { - metrics.insert(k, v)?; - } - } - // A timing table that does not state its design-rule status did not check design rules: - // its counts are unknown, never zero (RCLI-REQ-0023, AGENTS rule 4.2). - if let Some(sta) = meta.summary.get("sta") - && !sta.contains_key("drv_status") - { - metrics.insert("drv_status", &"unchecked")?; - for key in ["max_trans_violating", "max_cap_violating"] { - if sta.contains_key(key) { - metrics.insert(key, &Value::Null)?; - } - } - } - env.set_metrics(metrics)?; - - let mut status_obj = JsonObject::new(); - status_obj.insert("timing", &timing)?; - status_obj.insert("equivalence", &equivalence)?; - for (tbl, vals) in &meta.status { - status_obj.insert(tbl, vals)?; - } - - env.set("parent", &meta.parent)?; - env.set("created_unix_ms", &meta.created_unix_ms)?; - env.set("tool", &meta.tool)?; - env.set("tool_version", &meta.tool_version)?; - env.set("revision_command", &meta.command)?; - env.set("argv", &meta.argv)?; - env.set("message", &meta.message)?; - env.set("tables", &meta.tables)?; - env.set("libraries", &meta.libraries)?; - env.set("sources", &meta.sources)?; - env.set("dropped", &meta.dropped)?; - env.set("summary", &meta.summary)?; - env.set_raw("status", status_obj.into())?; - - let rev_s = rev.to_string(); - env.push_next_action(&["rusteda", "log", "-w", ws_arg]); - if let Some(parent) = meta.parent { - let parent_s = parent.to_string(); - env.push_next_action(&[ - "rusteda", "diff", "-w", ws_arg, "--from", &parent_s, "--to", &rev_s, - ]); - } - - let mut lines = Vec::new(); - let parent_s = meta - .parent - .map_or_else(|| "none".to_string(), |p| p.to_string()); - lines.push(format!("{rev} {} (parent {parent_s})", meta.command)); - if let Some(msg) = &meta.message { - lines.push(format!(" message: {msg}")); - } - lines.push(format!( - " status: timing={timing} equivalence={equivalence}" - )); - for (name, tbl) in &meta.tables { - lines.push(format!(" table {name}: {} ({})", tbl.path, tbl.hash)); - } - for lib in &meta.libraries { - lines.push(format!( - " library {} ({}): {}", - lib.name, lib.corner, lib.hash - )); - } - for src in &meta.sources { - lines.push(format!( - " source {} ({}): {}", - src.path, src.table, src.hash - )); - } - for drop in &meta.dropped { - lines.push(format!(" dropped {}: {}", drop.table, drop.reason)); - } - for next in &env.next_actions { - lines.push(next_line(next)); - } - - Ok(OutputOutcome { - envelope: env, - text_lines: lines, - }) -} - -/// Hash comparison of a single table between two revisions (RCLI-REQ-0017). -#[derive(Debug, Clone, Serialize)] -pub struct TableDiffEntry { - pub status: &'static str, - pub from_hash: Option, - pub to_hash: Option, -} - -/// Hash change of a library present in both revisions under the same `(name, corner)`. -#[derive(Debug, Clone, Serialize)] -pub struct LibraryHashChange { - pub name: String, - pub corner: String, - pub from_hash: Hash64, - pub to_hash: Hash64, -} - -/// Library changes between two revisions (RCLI-REQ-0017). -#[derive(Debug, Clone, Serialize)] -pub struct LibraryDiff { - pub added: Vec, - pub removed: Vec, - pub changed: Vec, -} - -/// Runs `rusteda diff -w --from --to ` (RCLI-REQ-0017). -pub(crate) fn run_diff( - ws: &Workspace, - ws_arg: &str, - from_spec: &str, - to_spec: &str, -) -> Result { - let from_rev = ws.resolve_query(from_spec)?; - let to_rev = ws.resolve_query(to_spec)?; - let from = ws.revision(from_rev)?; - let to = ws.revision(to_rev)?; - let from_meta = from.meta(); - let to_meta = to.meta(); - - let mut all_tables: BTreeSet = BTreeSet::new(); - all_tables.extend(from_meta.tables.keys().cloned()); - all_tables.extend(to_meta.tables.keys().cloned()); - - let mut added_tables = Vec::new(); - let mut removed_tables = Vec::new(); - let mut changed_tables = Vec::new(); - let mut unchanged_tables = Vec::new(); - let mut table_entries: BTreeMap = BTreeMap::new(); - - for name in all_tables { - match (from_meta.tables.get(&name), to_meta.tables.get(&name)) { - (None, Some(t)) => { - added_tables.push(name.clone()); - table_entries.insert( - name, - TableDiffEntry { - status: "added", - from_hash: None, - to_hash: Some(t.hash), - }, - ); - } - (Some(f), None) => { - removed_tables.push(name.clone()); - table_entries.insert( - name, - TableDiffEntry { - status: "removed", - from_hash: Some(f.hash), - to_hash: None, - }, - ); - } - (Some(f), Some(t)) if f.hash != t.hash => { - changed_tables.push(name.clone()); - table_entries.insert( - name, - TableDiffEntry { - status: "changed", - from_hash: Some(f.hash), - to_hash: Some(t.hash), - }, - ); - } - (Some(f), Some(t)) => { - unchanged_tables.push(name.clone()); - table_entries.insert( - name, - TableDiffEntry { - status: "unchanged", - from_hash: Some(f.hash), - to_hash: Some(t.hash), - }, - ); - } - (None, None) => {} - } - } - - // Library diff by (name, corner). - let mut added_libs = Vec::new(); - let mut removed_libs = Vec::new(); - let mut changed_libs = Vec::new(); - for to_lib in &to_meta.libraries { - match from_meta - .libraries - .iter() - .find(|l| l.name == to_lib.name && l.corner == to_lib.corner) - { - None => added_libs.push(to_lib.clone()), - Some(from_lib) if from_lib.hash != to_lib.hash => { - changed_libs.push(LibraryHashChange { - name: to_lib.name.clone(), - corner: to_lib.corner.clone(), - from_hash: from_lib.hash, - to_hash: to_lib.hash, - }); - } - Some(_) => {} - } - } - for from_lib in &from_meta.libraries { - if !to_meta - .libraries - .iter() - .any(|l| l.name == from_lib.name && l.corner == from_lib.corner) - { - removed_libs.push(from_lib.clone()); - } - } - let lib_diff = LibraryDiff { - added: added_libs, - removed: removed_libs, - changed: changed_libs, - }; - - // Numeric summary metric deltas for tables present in both revisions. - let mut metric_deltas: TableValues = TableValues::new(); - let mut flat_deltas = JsonObject::new(); - for (tbl, to_vals) in &to_meta.summary { - if let Some(from_vals) = from_meta.summary.get(tbl) { - for (k, to_v) in to_vals { - if let Some(from_v) = from_vals.get(k) { - if let (Some(a), Some(b)) = (from_v.as_i64(), to_v.as_i64()) { - let d = b.saturating_sub(a); - metric_deltas - .entry(tbl.clone()) - .or_default() - .insert(k.clone(), Value::from(d)); - flat_deltas.insert(k, &d)?; - } else if let (Some(a), Some(b)) = (from_v.as_f64(), to_v.as_f64()) { - let d = b - a; - if d.is_finite() { - metric_deltas - .entry(tbl.clone()) - .or_default() - .insert(k.clone(), Value::from(d)); - flat_deltas.insert(k, &d)?; - } - } - } - } - } - } - - let (from_timing, from_eq) = effective_status(from_meta); - let (to_timing, to_eq) = effective_status(to_meta); - let mut status_obj = JsonObject::new(); - status_obj.insert("timing", &to_timing)?; - status_obj.insert("equivalence", &to_eq)?; - status_obj.insert("from_timing", &from_timing)?; - status_obj.insert("from_equivalence", &from_eq)?; - - let mut env = Envelope::new("rusteda diff"); - env.workspace = Some(ws_arg.to_string()); - env.base_rev = Some(from_rev); - env.rev = Some(to_rev); - env.set_metrics(flat_deltas)?; - env.set("from_rev", &from_rev)?; - env.set("to_rev", &to_rev)?; - env.set("added_tables", &added_tables)?; - env.set("removed_tables", &removed_tables)?; - env.set("changed_tables", &changed_tables)?; - env.set("unchanged_tables", &unchanged_tables)?; - env.set("tables", &table_entries)?; - env.set("libraries", &lib_diff)?; - env.set("metric_deltas", &metric_deltas)?; - env.set_raw("status", status_obj.into())?; - - let to_s = to_rev.to_string(); - env.push_next_action(&["rusteda", "show", "-w", ws_arg, "--rev", &to_s]); - - let mut lines = Vec::new(); - lines.push(format!("diff {from_rev} -> {to_rev}")); - for (name, entry) in &table_entries { - lines.push(format!(" table {name}: {}", entry.status)); - } - for lib in &lib_diff.added { - lines.push(format!( - " library +{} ({}): {}", - lib.name, lib.corner, lib.hash - )); - } - for lib in &lib_diff.removed { - lines.push(format!( - " library -{} ({}): {}", - lib.name, lib.corner, lib.hash - )); - } - for lib in &lib_diff.changed { - lines.push(format!( - " library ~{} ({}): {} -> {}", - lib.name, lib.corner, lib.from_hash, lib.to_hash - )); - } - lines.push(format!( - " status: timing={from_timing}->{to_timing} equivalence={from_eq}->{to_eq}" - )); - for next in &env.next_actions { - lines.push(next_line(next)); - } - - Ok(OutputOutcome { - envelope: env, - text_lines: lines, - }) -} diff --git a/crates/apps/rusteda-cli/src/revert.rs b/crates/apps/rusteda-cli/src/revert.rs deleted file mode 100644 index 6de8cc2..0000000 --- a/crates/apps/rusteda-cli/src/revert.rs +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! `rusteda revert` implementation (RCLI-REQ-0018, WS-REQ-0068, WS-REQ-0069). - -use rusteda_workspace::text::{mutation_header, next_line}; -use rusteda_workspace::{Envelope, Error, Invocation, Workspace}; - -use crate::OutputOutcome; - -/// Runs `rusteda revert -w --base-rev --to ` (RCLI-REQ-0018). -pub(crate) fn run_revert( - ws: &Workspace, - ws_arg: &str, - base_arg: &str, - to_arg: &str, - inv: &Invocation, -) -> Result { - let base = match ws.resolve_base(base_arg) { - Ok(b) => b, - Err(e) => { - let _ = ws.log_rejected(inv, None, &e); - return Err(e); - } - }; - let target = match ws.resolve_query(to_arg) { - Ok(r) => r, - Err(e) => { - let _ = ws.log_rejected(inv, Some(base.rev()), &e); - return Err(e); - } - }; - let reverted = ws.revert(base, target, inv)?; - let mut env = Envelope::new("rusteda revert"); - env.workspace = Some(ws_arg.to_string()); - env.base_rev = Some(reverted.previous); - env.rev = Some(reverted.head); - env.set("head_after", &reverted.head)?; - env.diagnostics = reverted.warnings; - let to_s = reverted.head.to_string(); - env.push_next_action(&["rusteda", "show", "-w", ws_arg, "--rev", &to_s]); - env.push_next_action(&["rusteda", "log", "-w", ws_arg]); - - let mut lines = Vec::new(); - lines.push(mutation_header( - reverted.head, - "rusteda revert", - reverted.previous, - )); - lines.push(format!(" head: {}", reverted.head)); - for next in &env.next_actions { - lines.push(next_line(next)); - } - Ok(OutputOutcome { - envelope: env, - text_lines: lines, - }) -} diff --git a/crates/apps/rusteda-cli/tests/product_version.rs b/crates/apps/rusteda-cli/tests/product_version.rs new file mode 100644 index 0000000..a992761 --- /dev/null +++ b/crates/apps/rusteda-cli/tests/product_version.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 RustEDA contributors + +use clap::error::ErrorKind; + +#[test] +fn caller_version_is_used_by_top_level_and_subcommands() { + for args in [ + vec!["rusteda", "--version"], + vec!["rusteda", "import", "--version"], + ] { + let error = rusteda_cli::command_with_version("7.8.9") + .try_get_matches_from(args) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::DisplayVersion); + assert!(error.to_string().contains("7.8.9")); + } +} diff --git a/crates/core/rusteda-compute/Cargo.toml b/crates/core/rusteda-compute/Cargo.toml deleted file mode 100644 index f8ad370..0000000 --- a/crates/core/rusteda-compute/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "rusteda-compute" -description = "Compute tasks of the RustEDA tools: timing propagation on the CPU and CUDA backends, GPU backend selection, device self-test and the CUDA device layer" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -authors = ["RustEDA contributors"] -repository.workspace = true - -[features] -# CUDA backend: CUDA C kernels compiled by NVRTC (COMPUTE-REQ-0002). -cuda = ["dep:cudarc"] - -[dependencies] -rusteda-core.workspace = true -bytemuck.workspace = true -rayon.workspace = true -cudarc = { workspace = true, optional = true } - -[lints] -workspace = true diff --git a/crates/core/rusteda-compute/README.md b/crates/core/rusteda-compute/README.md deleted file mode 100644 index d030ec0..0000000 --- a/crates/core/rusteda-compute/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# rusteda-compute - -Compute tasks of the RustEDA tools (`docs/specs/compute/`, COMPUTE-REQ-0001 to COMPUTE-REQ-0029 and -COMPUTE-REQ-0101 to COMPUTE-REQ-0106; RustEDA shared-crate contract, section 1; development plan, -rule 5). Every task sits behind a coarse-grained trait with a pure Rust CPU backend (always compiled -and authoritative) and native GPU backends checked for consistency with it. All `unsafe` code in -this crate is confined to the GPU glue modules `cuda`, `timing::cuda` and `cut::cuda` (AGENTS -rules 8.4), each block with its `// SAFETY:` justification. There is no Metal backend yet -(development plan, Phase 4; `docs/specs/compute/decisions.md`). - -- `cut` (`docs/specs/compute/README.md`, COMPUTE-REQ-0101 to COMPUTE-REQ-0106): priority-cut - enumeration and selection (`cut::Session`, `CutPassProblem`) on the `cpu` and `cuda` backends - with bit-exact 32-bit integer arithmetic across backends. -- `timing` (`docs/specs/compute/timing.md`, COMPUTE-REQ-0010 to COMPUTE-REQ-0029): levelized - arrival, transition and predecessor propagation (`TimingPropagationBackend`, `TimingSession`, - `timing_backend`, `prewarm`) on the `cpu` (`f64`) and `cuda` (`f64` bit-identical to `cpu`, and - opt-in `f32`) backends. -- `BackendKind` (`cpu`, `cuda`, `gpu`), `Precision` (`f64`, `f32`) and `ComputeError` (mapped to - `rusteda.v1` envelope codes and diagnostics `A0501` to `A0506`; COMPUTE-REQ-0005, - COMPUTE-REQ-0025). -- `GpuRuntime`, `GpuTask`, `dispatch` and `prepare`: GPU runtime selection and per-process device - self-test for engine kernels (COMPUTE-REQ-0001, COMPUTE-REQ-0003). -- `cuda` (feature `cuda`): CUDA device layer opening the default device once per process, compiling - CUDA C kernels with NVRTC (cached per source and option list), allocating device and page-locked - host buffers, and launching kernels through the CUDA driver API (COMPUTE-REQ-0002, - COMPUTE-REQ-0004). The CUDA driver and NVRTC libraries are loaded at run time; builds need no - CUDA toolkit. diff --git a/crates/core/rusteda-compute/src/cuda.rs b/crates/core/rusteda-compute/src/cuda.rs deleted file mode 100644 index 9c78b1f..0000000 --- a/crates/core/rusteda-compute/src/cuda.rs +++ /dev/null @@ -1,322 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The CUDA runtime (feature `cuda`, COMPUTE-REQ-0002, COMPUTE-REQ-0004): kernels written in -//! CUDA C are compiled at run time by NVRTC for the architecture of the device and launched -//! through the CUDA driver API. The CUDA driver and NVRTC libraries are loaded at run time; builds -//! need no CUDA toolkit. - -// Backend glue over the CUDA driver and NVRTC C APIs (AGENTS rules 8.4): every `unsafe` block -// carries its justification. -#![allow(unsafe_code)] - -use std::collections::HashMap; -use std::ffi::CString; -use std::sync::{Arc, Mutex, OnceLock}; - -use cudarc::driver::{CudaContext, CudaModule, DriverError}; -pub use cudarc::driver::{ - CudaFunction, CudaSlice, CudaStream, DeviceRepr, LaunchConfig, PinnedHostSlice, PushKernelArg, - ValidAsZeroBits, -}; -use cudarc::nvrtc::{Ptx, result as nr, sys as ns}; - -use crate::GpuError; - -impl From for GpuError { - fn from(e: DriverError) -> GpuError { - GpuError(format!("CUDA driver: {e}")) - } -} - -/// Releases an NVRTC program when dropped, so that every exit path of compilation destroys its -/// program (COMPUTE-REQ-0004). -struct Program(ns::nvrtcProgram); - -impl Drop for Program { - fn drop(&mut self) { - // SAFETY: `self.0` was created by `nvrtc::result::create_program` and is destroyed only - // here. - let _ = unsafe { nr::destroy_program(self.0) }; - } -} - -/// The default CUDA device with its stream and the modules compiled for it. -pub struct Device { - ctx: Arc, - stream: Arc, - arch: String, - name: String, - /// Compiled modules keyed by their source text and complete NVRTC option list. - modules: Mutex), Arc>>, -} - -impl std::fmt::Debug for Device { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Device") - .field("name", &self.name) - .field("arch", &self.arch) - .finish_non_exhaustive() - } -} - -static DEVICE: OnceLock> = OnceLock::new(); - -/// The default device, opened once per process. -pub fn device() -> Result<&'static Device, GpuError> { - match DEVICE.get_or_init(open) { - Ok(d) => Ok(d), - Err(e) => Err(e.clone()), - } -} - -fn open() -> Result { - // SAFETY: the probes only load the shared libraries; no CUDA function is called. - if !unsafe { cudarc::driver::sys::is_culib_present() } { - return Err(GpuError("CUDA driver library (libcuda) not found".into())); - } - // SAFETY: as above. - if !unsafe { cudarc::nvrtc::sys::is_culib_present() } { - return Err(GpuError( - "NVRTC library (libnvrtc) not found; add the library directory of a CUDA toolkit to \ - LD_LIBRARY_PATH" - .into(), - )); - } - let ctx = CudaContext::new(0)?; - let (maj, min) = ctx.compute_capability()?; - let name = ctx.name()?; - let stream = ctx.new_stream()?; - let suffix = if maj >= 9 { "a" } else { "" }; - Ok(Device { - ctx, - stream, - arch: format!("sm_{maj}{min}{suffix}"), - name, - modules: Mutex::new(HashMap::new()), - }) -} - -impl Device { - /// The device name and architecture (COMPUTE-REQ-0004). - pub fn describe(&self) -> String { - format!("{} ({})", self.name, self.arch) - } - - pub fn stream(&self) -> &Arc { - &self.stream - } - - /// A new stream on this device's context (COMPUTE-REQ-0004). - pub fn new_stream(&self) -> Result, GpuError> { - Ok(self.ctx.new_stream()?) - } - - /// Free and total device memory in bytes (COMPUTE-REQ-0004). - pub fn mem_info(&self) -> Result<(usize, usize), GpuError> { - self.ctx.bind_to_thread()?; - Ok(cudarc::driver::result::mem_get_info()?) - } - - /// Page-locked host memory of at least `len` elements, not write-combined (COMPUTE-REQ-0004), - /// so that the host reads it at full speed after a device-to-host copy. - pub fn pinned(&self, len: usize) -> Result, GpuError> { - self.ctx.bind_to_thread()?; - // SAFETY: callers read the slice only after a device-to-host copy has written it; flag 0 - // selects cached, non-write-combined page-locked memory. - Ok(unsafe { self.ctx.alloc_pinned_with_flags::(len.max(1), 0) }?) - } - - /// Kernel `name` of the CUDA C source `src` with the default options, compiled once per - /// process. - pub fn function(&self, src: &'static str, name: &str) -> Result { - self.function_with(src, &[], name) - } - - /// Kernel `name` of the CUDA C source `src` compiled with extra NVRTC `options` (for example - /// `-DREAL_F64` and `--fmad=false`), which replace the default options they name; cached per - /// process by source and complete option list (COMPUTE-REQ-0004). - pub fn function_with( - &self, - src: &'static str, - options: &[&str], - name: &str, - ) -> Result { - // Module loading does not bind the context to the calling thread by itself. - self.ctx.bind_to_thread()?; - // Default code generation settings (COMPUTE-REQ-0002): architecture-specific target from - // compute capability 9.0 on, NVRTC's default fused multiply-add contraction, correctly - // rounded division and square root, no flush to zero. - let mut opts = vec![ - format!("--gpu-architecture={}", self.arch), - "--std=c++17".into(), - "--fmad=true".into(), - "--prec-div=true".into(), - "--prec-sqrt=true".into(), - "--ftz=false".into(), - ]; - for &opt in options { - if let Some((k, _)) = opt.split_once('=') - && opt.starts_with("--") - && let Some(slot) = opts - .iter_mut() - .find(|o| o.split_once('=').is_some_and(|(ok, _)| ok == k)) - { - *slot = opt.into(); - } else { - opts.push(opt.into()); - } - } - let key = (src, opts); - let module = { - let mut m = self - .modules - .lock() - .map_err(|_| GpuError("module cache poisoned".into()))?; - match m.get(&key) { - Some(x) => x.clone(), - None => { - let x = self.compile(src, &key.1)?; - m.insert(key, x.clone()); - x - } - } - }; - Ok(module.load_function(name)?) - } - - /// Compiles CUDA C to a CUBIN for this device's architecture with `opts` and loads it. - fn compile(&self, src: &str, opts: &[String]) -> Result, GpuError> { - let err = |what: &str, e: &dyn std::fmt::Debug| GpuError(format!("NVRTC {what}: {e:?}")); - let csrc = CString::new(src).map_err(|e| err("source", &e))?; - let prog = Program(nr::create_program(&csrc, None).map_err(|e| err("create", &e))?); - // SAFETY: `prog.0` was created above and `Program`'s `Drop` destroys it on every return - // path below. - let compiled = unsafe { nr::compile_program(prog.0, opts) }; - if let Err(e) = compiled { - // SAFETY: `prog.0` is valid until `prog` drops at return. - let log = unsafe { nr::get_program_log(prog.0) } - .map(|l| l.iter().map(|c| *c as u8 as char).collect::()) - .unwrap_or_default(); - return Err(GpuError(format!( - "NVRTC compile: {e:?}: {}", - log.trim_end_matches('\0') - ))); - } - let mut n = 0usize; - // SAFETY: `prog.0` holds a successful compilation; `n` receives the CUBIN size. - unsafe { ns::nvrtcGetCUBINSize(prog.0, &mut n) } - .result() - .map_err(|e| err("CUBIN size", &e))?; - let mut cubin = vec![0u8; n]; - // SAFETY: `cubin` has exactly the size NVRTC reported. - unsafe { ns::nvrtcGetCUBIN(prog.0, cubin.as_mut_ptr().cast()) } - .result() - .map_err(|e| err("CUBIN", &e))?; - drop(prog); - Ok(self.ctx.load_module(Ptx::from_binary(cubin))?) - } - - /// Copies `data` to a new device array. - pub fn upload(&self, data: &[T]) -> Result, GpuError> { - Ok(self.stream.clone_htod(data)?) - } - - /// A zero-filled device array (at least one element: some kernels bind every argument). - pub fn zeros( - &self, - len: usize, - ) -> Result, GpuError> { - Ok(self.stream.alloc_zeros(len.max(1))?) - } - - /// Copies a device array to the host after all queued work has finished; execution errors - /// of earlier launches are reported here. - pub fn read(&self, s: &CudaSlice) -> Result, GpuError> { - let v = self.stream.clone_dtoh(s)?; - self.stream.synchronize()?; - Ok(v) - } - - /// Waits for all work queued on the stream. - pub fn sync(&self) -> Result<(), GpuError> { - Ok(self.stream.synchronize()?) - } -} - -/// One-dimensional launch shape for `units` work items in blocks of `block` threads. -pub fn launch_1d(units: usize, block: u32) -> LaunchConfig { - LaunchConfig { - grid_dim: ((units as u32).div_ceil(block).max(1), 1, 1), - block_dim: (block, 1, 1), - shared_mem_bytes: 0, - } -} - -/// Source of the device self-test kernel: `y[i] = 2 x[i] + 1` for `i < n`. -const SELF_TEST_SRC: &str = r#" -extern "C" __global__ void self_test(const float* x, float* y, unsigned n) { - unsigned i = blockIdx.x * blockDim.x + threadIdx.x; - if (i < n) { y[i] = 2.0f * x[i] + 1.0f; } -} -"#; - -/// Runs the self-test kernel and compares every result exactly (COMPUTE-REQ-0001). -pub fn self_test(dev: &Device) -> Result<(), GpuError> { - let n = 1000usize; - let x: Vec = (0..n).map(|i| i as f32 * 0.25 - 17.0).collect(); - let xd = dev.upload(&x)?; - let mut yd = dev.zeros::(n)?; - let f = dev.function(SELF_TEST_SRC, "self_test")?; - let n32 = n as u32; - let mut b = dev.stream.launch_builder(&f); - b.arg(&xd).arg(&mut yd).arg(&n32); - // SAFETY: the kernel reads `n` floats from `xd` and writes `n` floats to `yd`, both of which - // hold at least `n` elements; `n32` matches the kernel's `unsigned` parameter. - unsafe { b.launch(launch_1d(n, 128)) }?; - let y = dev.read(&yd)?; - for (i, (a, b)) in x.iter().zip(&y).enumerate() { - if *b != 2.0 * a + 1.0 { - return Err(GpuError(format!( - "device self-test failed at element {i}: {b} instead of {}", - 2.0 * a + 1.0 - ))); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // COMPUTE-REQ-0004: every W0 device item keeps its signature and every additive item is - // callable with its specified signature. - #[test] - fn device_api_is_additive() { - fn _assert_signatures(dev: &Device) -> Result<(), GpuError> { - let _d: &'static Device = device()?; - let _st: &Arc = dev.stream(); - let _f1: CudaFunction = dev.function("extern \"C\" __global__ void k() {}", "k")?; - let _f2: CudaFunction = dev.function_with( - "extern \"C\" __global__ void k() {}", - &["--fmad=false", "-DREAL_F64"], - "k", - )?; - let slice: CudaSlice = dev.upload(&[1.0f32, 2.0f32])?; - let _z: CudaSlice = dev.zeros::(4)?; - let _host: Vec = dev.read(&slice)?; - dev.sync()?; - let _cfg: LaunchConfig = launch_1d(10, 64); - self_test(dev)?; - let _pin: PinnedHostSlice = dev.pinned::(8)?; - let _new_st: Arc = dev.new_stream()?; - let (_free, _total): (usize, usize) = dev.mem_info()?; - let _desc: String = dev.describe(); - Ok(()) - } - let cfg = launch_1d(257, 128); - assert_eq!(cfg.grid_dim, (3, 1, 1)); - assert_eq!(cfg.block_dim, (128, 1, 1)); - } -} diff --git a/crates/core/rusteda-compute/src/cut/cpu.rs b/crates/core/rusteda-compute/src/cut/cpu.rs deleted file mode 100644 index 13df22f..0000000 --- a/crates/core/rusteda-compute/src/cut/cpu.rs +++ /dev/null @@ -1,651 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! CPU backend of the cut-pass task (COMPUTE-REQ-0105): the CUDA kernel `node_cuts` of `cuda.rs` -//! executed on the host, phase by phase, on the same words. One block of the kernel is one call -//! of [`node_cuts`]; its phases run in the kernel's order, with sequential loops in place of the -//! threads of a block. Two phases use other algorithms for the same result: duplicates are found -//! with a hash table instead of comparing every earlier candidate (the first of equal leaf sets -//! stays), and ranks come from sorting by the candidate order instead of counting smaller -//! candidates (the order is total, so a candidate's rank is its sorted position). The nodes of a -//! level are computed in parallel from the state of the earlier levels and committed in level -//! order, so the result does not depend on the thread count. - -use std::collections::HashMap; -use std::hash::{BuildHasherDefault, Hasher}; -use std::time::Instant; - -use rayon::prelude::*; - -use super::pack::{self, CW, Packed, SW}; -use super::{ - CutPassBackend, CutPassProblem, CutPassSession, PassCosts, PassOutput, PassStats, Readback, -}; -use crate::ComputeError; - -pub(super) const INF_B: u32 = 0xFFFF_FFFF; -const ZERO_B: u32 = 0x8000_0000; -const AF_MAX: u32 = super::AF_MAX; -const NONE: u32 = 0xFFFF_FFFF; - -/// The CPU backend. -#[derive(Debug)] -pub(super) struct CpuBackend; - -pub(super) static CPU: CpuBackend = CpuBackend; - -impl CutPassBackend for CpuBackend { - fn name(&self) -> &'static str { - "cpu" - } - - fn open<'s>( - &'s self, - p: &CutPassProblem<'_>, - ) -> Result, ComputeError> { - super::validate(p)?; - let pk = pack::pack(p, pack::MAX_WORDS * 4)?; - Ok(Box::new(CpuSession { pk })) - } -} - -/// The resident state: the kernel words. -#[derive(Debug)] -pub(super) struct CpuSession { - pub(super) pk: Packed, -} - -impl CutPassSession for CpuSession { - fn run( - &mut self, - c: &PassCosts<'_>, - rb: Readback, - out: &mut PassOutput, - ) -> Result { - super::check_costs(self.pk.n, c)?; - let t = Instant::now(); - let (costs, mode) = pack::costs(self.pk.n, c); - let single = rayon::current_num_threads() == 1; - let levels = self.pk.levels.clone(); - for &(start, count) in &levels { - let from = (self.pk.ord_off + start) as usize; - let ids: Vec = self.pk.nodes[from..from + count as usize].to_vec(); - let pk = &self.pk; - let outs: Vec = if single || ids.len() < 16 { - ids.iter() - .map(|&id| node_cuts(pk, &costs, mode, id)) - .collect() - } else { - ids.par_iter() - .with_min_len(4) - .map(|&id| node_cuts(pk, &costs, mode, id)) - .collect() - }; - for o in outs { - commit(&mut self.pk, &o); - } - } - let kernel_s = t.elapsed().as_secs_f64(); - let t = Instant::now(); - let pk = &self.pk; - let n = pk.n; - match rb { - Readback::All => pack::read_into( - n, - pk.slots, - &pk.best, - &pk.cuts[n..], - Some(&pk.cuts[..n]), - out, - ), - Readback::Chosen => { - let rows = chosen_rows(pk); - pack::read_into(n, pk.slots, &pk.best, &rows, None, out); - } - } - Ok(PassStats { - launches: levels.len() as u32, - kernel_s, - readback_s: t.elapsed().as_secs_f64(), - }) - } -} - -/// The kernel `chosen_rows`: the chosen cut row of every (node, phase), zeros unless a cell -/// implements it. -pub(super) fn chosen_rows(pk: &Packed) -> Vec { - let n = pk.n; - let slots = pk.slots as usize; - let mut out = vec![0u32; 2 * n * CW]; - for i in 0..2 * n { - let id = i >> 1; - let code = pk.best[3 * i]; - if code & 7 == 4 { - let c = ((code >> 3) & 0x7F) as usize; - let r = n + (id * slots + c) * CW; - out[i * CW..(i + 1) * CW].copy_from_slice(&pk.cuts[r..r + CW]); - } - } - out -} - -/// What one block writes: the node's cut count, its rows `0..count`, its six choice words. -#[derive(Debug)] -pub(super) struct NodeOut { - node: u32, - rows: Vec<[u32; CW]>, - best: [u32; 6], -} - -fn commit(pk: &mut Packed, o: &NodeOut) { - let n = pk.n; - let slots = pk.slots as usize; - let id = o.node as usize; - pk.cuts[id] = o.rows.len() as u32; - for (s, r) in o.rows.iter().enumerate() { - let at = n + (id * slots + s) * CW; - pk.cuts[at..at + CW].copy_from_slice(r); - } - pk.best[6 * id..6 * id + 6].copy_from_slice(&o.best); -} - -#[inline] -fn swap_adj(lo: &mut u32, hi: &mut u32, k: u32) { - if k < 4 { - let (keep, up) = match k { - 0 => (0x9999_9999u32, 0x2222_2222u32), - 1 => (0xC3C3_C3C3, 0x0C0C_0C0C), - 2 => (0xF00F_F00F, 0x00F0_00F0), - _ => (0xFF00_00FF, 0x0000_FF00), - }; - let s = 1u32 << k; - *lo = (*lo & keep) | ((*lo & up) << s) | ((*lo >> s) & up); - *hi = (*hi & keep) | ((*hi & up) << s) | ((*hi >> s) & up); - } else { - let nlo = (*lo & 0x0000_FFFF) | (*hi << 16); - let nhi = (*lo >> 16) | (*hi & 0xFFFF_0000); - *lo = nlo; - *hi = nhi; - } -} - -#[inline] -fn depends(lo: u32, hi: u32, i: u32) -> bool { - if i == 5 { - return lo != hi; - } - let vm = match i { - 0 => 0xAAAA_AAAAu32, - 1 => 0xCCCC_CCCC, - 2 => 0xF0F0_F0F0, - 3 => 0xFF00_FF00, - _ => 0xFFFF_0000, - }; - let s = 1u32 << i; - ((((lo >> s) ^ lo) & !vm) | (((hi >> s) ^ hi) & !vm)) != 0 -} - -#[inline] -fn add_arr(a: u32, d: u32) -> u32 { - if a == INF_B { - return INF_B; - } - if a > INF_B - 1 - d { INF_B - 1 } else { a + d } -} - -#[inline] -fn af_add(a: u32, b: u32) -> u32 { - let s = a + b; - if s < AF_MAX { s } else { AF_MAX } -} - -#[inline] -fn af_share(af: u32, fo2: u32) -> u32 { - (af * 2) / if fo2 > 2 { fo2 } else { 2 } -} - -#[inline] -fn better(aarr: u32, aaf: u32, barr: u32, baf: u32, limit: u32, area: bool) -> bool { - if barr == INF_B { - return aarr != INF_B; - } - if area { - let (fa, fb) = (aarr <= limit, barr <= limit); - if fa != fb { - return fa; - } - if !fa { - return aarr < barr; - } - if aaf != baf { - return aaf < baf; - } - return aarr < barr; - } - if aarr != barr { - return aarr < barr; - } - aaf < baf -} - -/// The kernel's `merge` of two fanin cut rows into `out`; false when infeasible. -fn merge(a: &[u32], ca: bool, b: &[u32], cb: bool, k: u32, out: &mut [u32; CW]) -> bool { - let (na, nb) = (a[6], b[6]); - if (a[9] | b[9]).count_ones() + (a[10] | b[10]).count_ones() > k { - return false; - } - let mut u = [0u32; 12]; - let (mut i, mut j, mut n) = (0u32, 0u32, 0u32); - while i < na || j < nb { - let x; - if j >= nb || (i < na && a[i as usize] < b[j as usize]) { - x = a[i as usize]; - i += 1; - } else if i >= na || b[j as usize] < a[i as usize] { - x = b[j as usize]; - j += 1; - } else { - x = a[i as usize]; - i += 1; - j += 1; - } - u[n as usize] = x; - n += 1; - if n > k { - return false; - } - } - let (mut alo, mut ahi, mut blo, mut bhi) = (a[7], a[8], b[7], b[8]); - for q in (0..na).rev() { - let mut to = 0u32; - while u[to as usize] != a[q as usize] { - to += 1; - } - for s in q..to { - swap_adj(&mut alo, &mut ahi, s); - } - } - for q in (0..nb).rev() { - let mut to = 0u32; - while u[to as usize] != b[q as usize] { - to += 1; - } - for s in q..to { - swap_adj(&mut blo, &mut bhi, s); - } - } - if ca { - alo = !alo; - ahi = !ahi; - } - if cb { - blo = !blo; - bhi = !bhi; - } - let (mut lo, mut hi) = (alo & blo, ahi & bhi); - // Remove leaves the function does not depend on. - let mut q = 0u32; - while q < n { - if depends(lo, hi, q) { - q += 1; - continue; - } - for s in q..5 { - swap_adj(&mut lo, &mut hi, s); - } - for t in q..n.saturating_sub(1) { - u[t as usize] = u[t as usize + 1]; - } - n -= 1; - } - let (mut slo, mut shi) = (0u32, 0u32); - for t in 0..6u32 { - out[t as usize] = if t < n { u[t as usize] } else { 0 }; - } - for t in 0..n { - let m = u[t as usize] % 64; - if m < 32 { - slo |= 1 << m; - } else { - shi |= 1 << (m - 32); - } - } - out[6] = n; - out[7] = lo; - out[8] = hi; - out[9] = slo; - out[10] = shi; - true -} - -/// A candidate record (`SW` words): pair index, leaf count, six leaves, truth table (low, high), -/// arrival key, area-flow key. -fn record(pk: &Packed, fo2: &[u32], p: u32, leaves: &[u32], n: u32, lo: u32, hi: u32) -> [u32; SW] { - let mut x = [0u32; SW]; - x[0] = p; - x[1] = n; - x[2..8].copy_from_slice(&leaves[..6]); - x[8] = lo; - x[9] = hi; - let mut karr = ZERO_B; - let mut kaf = 0u32; - for t in 0..n as usize { - let l = leaves[t] as usize; - let bl = &pk.best[6 * l..6 * l + 6]; - let m = bl[1].min(bl[4]); - if m > karr { - karr = m; - } - kaf = af_add(kaf, af_share(bl[2].min(bl[5]), fo2[l])); - } - x[10] = karr; - x[11] = kaf; - x -} - -/// Hash of a leaf set (multiplicative, as the leaf ids are dense). -#[derive(Default)] -pub(super) struct LeafHasher(u64); - -impl Hasher for LeafHasher { - fn finish(&self) -> u64 { - self.0 - } - fn write(&mut self, bytes: &[u8]) { - for b in bytes { - self.0 = (self.0.rotate_left(5) ^ u64::from(*b)).wrapping_mul(0x51_7CC1_B727_220A); - } - } - fn write_u32(&mut self, x: u32) { - self.0 = (self.0.rotate_left(5) ^ u64::from(x)).wrapping_mul(0x9E37_79B9_7F4A_7C15); - } -} - -pub(super) type LeafMap = HashMap<[u32; 7], u32, BuildHasherDefault>; - -fn leaf_key(x: &[u32; SW]) -> [u32; 7] { - let mut k = [0u32; 7]; - k[0] = x[1]; - k[1..1 + x[1] as usize].copy_from_slice(&x[2..2 + x[1] as usize]); - k -} - -/// Phases 2 and 3 of the kernel: the unique candidates (first of equal leaf sets, in candidate -/// order) and their ranks in the candidate order. Returns the unique candidate indices in rank -/// order and, per candidate, the index of the first candidate with its leaf set. -pub(super) fn unique_ranked(cand: &[[u32; SW]], area: bool) -> (Vec, LeafMap) { - let mut first: LeafMap = HashMap::with_capacity_and_hasher(cand.len(), Default::default()); - let mut uniq = Vec::with_capacity(cand.len()); - for (i, x) in cand.iter().enumerate() { - if let std::collections::hash_map::Entry::Vacant(e) = first.entry(leaf_key(x)) { - e.insert(i as u32); - uniq.push(i as u32); - } - } - let key = |i: &u32| { - let x = &cand[*i as usize]; - if area { - (x[11], x[1], x[10], x[0]) - } else { - (x[10], x[1], x[11], x[0]) - } - }; - uniq.sort_unstable_by_key(key); - (uniq, first) -} - -/// One block of the kernel `node_cuts`: the cuts and choices of `node`. -pub(super) fn node_cuts(pk: &Packed, costs: &[u32], mode: u32, node: u32) -> NodeOut { - let n = pk.n; - let slots = pk.slots as usize; - let area = mode & 1 != 0; - let has_req = mode & 2 != 0; - let fo2 = &costs[..n]; - let nd = node as usize; - let (l0, l1) = (pk.nodes[2 * nd], pk.nodes[2 * nd + 1]); - let (f0, f1) = ((l0 >> 1) as usize, (l1 >> 1) as usize); - let (na, nb) = (pk.cuts[f0], pk.cuts[f1]); - let pcount = na * nb; - let row = |id: usize, s: usize| &pk.cuts[n + (id * slots + s) * CW..][..CW]; - // Phase 1: the feasible merges in pair order, then the previous choices' cuts. - let mut cand: Vec<[u32; SW]> = Vec::with_capacity(pcount as usize + 2); - let mut tmp = [0u32; CW]; - for p in 0..pcount { - let (ia, ib) = ((p / nb) as usize, (p % nb) as usize); - if !merge( - row(f0, ia), - l0 & 1 != 0, - row(f1, ib), - l1 & 1 != 0, - pk.kmax, - &mut tmp, - ) { - continue; - } - if tmp[6] == 0 { - continue; - } - cand.push(record(pk, fo2, p, &tmp[..6], tmp[6], tmp[7], tmp[8])); - } - let mut sp = [[0u32; 7]; 2]; - for j in 0..2 { - let code = pk.best[6 * nd + 3 * j]; - if has_req && costs[n + 2 * nd + j] != INF_B && code & 7 == 4 { - let pr = row(nd, ((code >> 3) & 0x7F) as usize); - cand.push(record( - pk, - fo2, - pcount + j as u32, - &pr[..6], - pr[6], - pr[7], - pr[8], - )); - sp[j][0] = pr[6]; - sp[j][1..7].copy_from_slice(&pr[..6]); - } - } - // Phases 2 and 3: unique candidates in rank order; ranks of the forced cuts. - let (order, first) = unique_ranked(&cand, area); - let mut rank_of = vec![NONE; cand.len()]; - for (r, &i) in order.iter().enumerate() { - rank_of[i as usize] = r as u32; - } - let lookup = |k: &[u32; 7]| first.get(k).map_or(NONE, |&i| rank_of[i as usize]); - let mut two = [0u32; 7]; - two[0] = 2; - two[1] = f0.min(f1) as u32; - two[2] = f0.max(f1) as u32; - let mut sv = [NONE; 3]; - // A leaf set with repeated leaves never occurs among the candidates. - if f0 != f1 { - sv[0] = lookup(&two); - } - for j in 0..2 { - if sp[j][0] != 0 { - let mut k = [0u32; 7]; - k[0] = sp[j][0]; - k[1..1 + sp[j][0] as usize].copy_from_slice(&sp[j][1..1 + sp[j][0] as usize]); - sv[1 + j] = lookup(&k); - } - } - let k = order.len() as u32; - let max_cuts = pk.max_cuts; - let kept = k.min(max_cuts); - let mut sfr = [0u32; 3]; - let mut nf = 0u32; - for r in sv { - if r == NONE || nf >= max_cuts { - continue; - } - if !sfr[..nf as usize].contains(&r) { - sfr[nf as usize] = r; - nf += 1; - } - } - let mut rows = vec![[0u32; CW]; kept as usize + 1]; - { - let tr = &mut rows[kept as usize]; - tr[0] = node; - tr[6] = 1; - tr[7] = 0xAAAA_AAAA; - tr[8] = 0xAAAA_AAAA; - let m = node % 64; - tr[9] = if m < 32 { 1 << m } else { 0 }; - tr[10] = if m < 32 { 0 } else { 1 << (m - 32) }; - } - // Phase 4: the kept cuts at their slots. - for (r, &i) in order.iter().enumerate() { - let r = r as u32; - let mut below = 0u32; - let mut forced = false; - for &q in &sfr[..nf as usize] { - if q < r { - below += 1; - } - if q == r { - forced = true; - } - } - let room = max_cuts - nf; - let slot = if forced { - let o = r - below; - below + o.min(room) - } else if r - below < room { - r - } else { - continue; - }; - let xi = &cand[i as usize]; - let rw = &mut rows[slot as usize]; - let nn = xi[1]; - rw[..6].copy_from_slice(&xi[2..8]); - let (mut slo, mut shi) = (0u32, 0u32); - for t in 0..nn as usize { - let m = xi[2 + t] % 64; - if m < 32 { - slo |= 1 << m; - } else { - shi |= 1 << (m - 32); - } - } - rw[6] = nn; - rw[7] = xi[8]; - rw[8] = xi[9]; - rw[9] = slo; - rw[10] = shi; - } - // Phase 5: the best match of every (kept cut, phase). - let lib = &pk.lib; - let mut res = vec![[NONE, INF_B, AF_MAX]; 2 * kept as usize]; - for q in 0..2 * kept as usize { - let (ci, ph) = (q >> 1, q & 1); - let rw = &rows[ci]; - let nn = rw[6]; - let (mut lo, mut hi) = (rw[7], rw[8]); - if ph == 1 { - lo = !lo; - hi = !hi; - } - if nn < 6 { - hi = 0; - if nn < 5 { - lo &= (1u32 << (1u32 << nn)) - 1; - } - } - let (mut a, mut b) = (0u32, pk.nkeys); - while a < b { - let mid = (a + b) >> 1; - let kk = &lib[mid as usize * 5..]; - let lt = kk[0] < nn || (kk[0] == nn && (kk[1] < hi || (kk[1] == hi && kk[2] < lo))); - if lt { - a = mid + 1; - } else { - b = mid; - } - } - let (mut barr, mut baf, mut bm) = (INF_B, AF_MAX, NONE); - let limit = if has_req { - costs[n + 2 * nd + ph] - } else { - INF_B - }; - let ka = a as usize * 5; - if a < pk.nkeys && lib[ka] == nn && lib[ka + 1] == hi && lib[ka + 2] == lo { - let (ms, mc) = (lib[ka + 3], lib[ka + 4]); - for mi in ms..ms + mc { - let cell = lib[(pk.moff + 2 * mi) as usize]; - let pp = lib[(pk.moff + 2 * mi + 1) as usize]; - let ce = &lib[(pk.coff + 8 * cell) as usize..][..8]; - let nin = ce[0]; - let mut arr = ZERO_B; - let mut af = ce[1]; - let mut ok = true; - for t in 0..nin { - let leaf = rw[((pp >> (4 * t)) & 15) as usize] as usize; - let lph = ((pp >> (24 + t)) & 1) as usize; - let la = pk.best[6 * leaf + 3 * lph + 1]; - if la == INF_B { - ok = false; - break; - } - let v = add_arr(la, ce[2 + t as usize]); - if v > arr { - arr = v; - } - af = af_add(af, af_share(pk.best[6 * leaf + 3 * lph + 2], fo2[leaf])); - } - if !ok { - continue; - } - if better(arr, af, barr, baf, limit, area) { - barr = arr; - baf = af; - bm = mi; - } - } - } - res[q] = [bm, barr, baf]; - } - // Phase 6: the first best over the cuts per phase, then the inverters. - let mut sarr = [INF_B; 2]; - let mut saf = [AF_MAX; 2]; - let mut simp = [0u32; 2]; - for ph in 0..2 { - let limit = if has_req { - costs[n + 2 * nd + ph] - } else { - INF_B - }; - for ci in 0..kept as usize { - let rr = res[ci * 2 + ph]; - if rr[0] == NONE { - continue; - } - if better(rr[1], rr[2], sarr[ph], saf[ph], limit, area) { - sarr[ph] = rr[1]; - saf[ph] = rr[2]; - simp[ph] = 4 | ((ci as u32) << 3) | (rr[0] << 10); - } - } - } - for p in 0..2 { - let limit = if has_req { - costs[n + 2 * nd + p] - } else { - INF_B - }; - let o = 1 - p; - if sarr[o] != INF_B && simp[o] & 7 != 3 { - let carr = add_arr(sarr[o], pk.d_inv); - let caf = af_add(saf[o], pk.a_inv); - if better(carr, caf, sarr[p], saf[p], limit, area) { - sarr[p] = carr; - saf[p] = caf; - simp[p] = 3; - } - } - } - let best = [simp[0], sarr[0], saf[0], simp[1], sarr[1], saf[1]]; - NodeOut { node, rows, best } -} diff --git a/crates/core/rusteda-compute/src/cut/cuda.rs b/crates/core/rusteda-compute/src/cut/cuda.rs deleted file mode 100644 index 8d04f48..0000000 --- a/crates/core/rusteda-compute/src/cut/cuda.rs +++ /dev/null @@ -1,652 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! CUDA backend of the cut-pass task (COMPUTE-REQ-0106): the kernel `node_cuts` in CUDA C, one -//! block of 128 threads per node of a level, launched level by level in chunks on the device -//! layer of `crate::cuda` (COMPUTE-REQ-0002). The cut table and the choices stay on the device -//! between passes; a pass uploads its costs, launches every level without waiting, and waits once -//! before reading back (see `pack.rs` for the words). - -// Backend glue over the CUDA driver: every `unsafe` block carries its justification. -#![allow(unsafe_code)] - -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::time::Instant; - -use super::pack::{self, BLOCK, CW, Packed}; -use super::{ - CutPassBackend, CutPassProblem, CutPassSession, PassCosts, PassOutput, PassStats, Readback, -}; -use crate::ComputeError; -use crate::cuda::{CudaFunction, CudaSlice, Device, LaunchConfig, PushKernelArg, launch_1d}; - -const SRC: &str = r#" -#define INF_B 0xFFFFFFFFu -#define ZERO_B 0x80000000u -#define AF_MAX 1073741824u -#define NONE 0xFFFFFFFFu -#define CW 11u -#define SW 12u -#define U 128u - -__device__ __forceinline__ void swap_adj(unsigned &lo, unsigned &hi, unsigned k) { - if (k < 4) { - unsigned keep = k == 0 ? 0x99999999u : k == 1 ? 0xC3C3C3C3u : k == 2 ? 0xF00FF00Fu : 0xFF0000FFu; - unsigned up = k == 0 ? 0x22222222u : k == 1 ? 0x0C0C0C0Cu : k == 2 ? 0x00F000F0u : 0x0000FF00u; - unsigned s = 1u << k; - lo = (lo & keep) | ((lo & up) << s) | ((lo >> s) & up); - hi = (hi & keep) | ((hi & up) << s) | ((hi >> s) & up); - } else { - unsigned nlo = (lo & 0x0000FFFFu) | (hi << 16); - unsigned nhi = (lo >> 16) | (hi & 0xFFFF0000u); - lo = nlo; - hi = nhi; - } -} - -__device__ __forceinline__ bool depends(unsigned lo, unsigned hi, unsigned i) { - if (i == 5) return lo != hi; - unsigned vm = i == 0 ? 0xAAAAAAAAu : i == 1 ? 0xCCCCCCCCu : i == 2 ? 0xF0F0F0F0u : i == 3 ? 0xFF00FF00u : 0xFFFF0000u; - unsigned s = 1u << i; - return ((((lo >> s) ^ lo) & ~vm) | (((hi >> s) ^ hi) & ~vm)) != 0; -} - -// Arrivals are biased (order-preserving unsigned); INF_B is unmapped. -__device__ __forceinline__ unsigned add_arr(unsigned a, unsigned d) { - if (a == INF_B) return INF_B; - return a > INF_B - 1u - d ? INF_B - 1u : a + d; -} - -__device__ __forceinline__ unsigned af_add(unsigned a, unsigned b) { - unsigned s = a + b; - return s < AF_MAX ? s : AF_MAX; -} - -__device__ __forceinline__ unsigned af_share(unsigned af, unsigned fo2) { - return (af * 2u) / (fo2 > 2u ? fo2 : 2u); -} - -// a better than b (COMPUTE-REQ-0102). -__device__ __forceinline__ bool better(unsigned aarr, unsigned aaf, unsigned barr, unsigned baf, unsigned limit, bool area) { - if (barr == INF_B) return aarr != INF_B; - if (area) { - bool fa = aarr <= limit, fb = barr <= limit; - if (fa != fb) return fa; - if (!fa) return aarr < barr; - if (aaf != baf) return aaf < baf; - return aarr < barr; - } - if (aarr != barr) return aarr < barr; - return aaf < baf; -} - -// Merges two fanin cuts (rows of CW words) into out (CW words); false when infeasible. -__device__ bool merge(const unsigned* a, bool ca, const unsigned* b, bool cb, unsigned k, unsigned* out) { - unsigned na = a[6], nb = b[6]; - if (__popc(a[9] | b[9]) + __popc(a[10] | b[10]) > k) return false; - unsigned u[12]; - unsigned i = 0, j = 0, n = 0; - while (i < na || j < nb) { - unsigned x; - if (j >= nb || (i < na && a[i] < b[j])) { x = a[i]; i++; } - else if (i >= na || b[j] < a[i]) { x = b[j]; j++; } - else { x = a[i]; i++; j++; } - u[n] = x; - n++; - if (n > k) return false; - } - unsigned alo = a[7], ahi = a[8], blo = b[7], bhi = b[8]; - for (int q = (int)na - 1; q >= 0; q--) { - unsigned to = 0; - while (u[to] != a[q]) to++; - for (unsigned s = (unsigned)q; s < to; s++) swap_adj(alo, ahi, s); - } - for (int q = (int)nb - 1; q >= 0; q--) { - unsigned to = 0; - while (u[to] != b[q]) to++; - for (unsigned s = (unsigned)q; s < to; s++) swap_adj(blo, bhi, s); - } - if (ca) { alo = ~alo; ahi = ~ahi; } - if (cb) { blo = ~blo; bhi = ~bhi; } - unsigned lo = alo & blo, hi = ahi & bhi; - // Remove leaves the function does not depend on. - unsigned q = 0; - while (q < n) { - if (depends(lo, hi, q)) { q++; continue; } - for (unsigned s = q; s < 5; s++) swap_adj(lo, hi, s); - for (unsigned t = q; t + 1 < n; t++) u[t] = u[t + 1]; - n--; - } - unsigned slo = 0, shi = 0; - for (unsigned t = 0; t < 6; t++) out[t] = t < n ? u[t] : 0; - for (unsigned t = 0; t < n; t++) { - unsigned m = u[t] % 64u; - if (m < 32) slo |= 1u << m; else shi |= 1u << (m - 32); - } - out[6] = n; - out[7] = lo; - out[8] = hi; - out[9] = slo; - out[10] = shi; - return true; -} - -// Candidate order (COMPUTE-REQ-0102): pass cost, leaf count, other cost, then pair index. -__device__ __forceinline__ bool less(const unsigned* x, const unsigned* y, bool area) { - // x, y: candidate records [p, n, l0..l5, tt_lo, tt_hi, key_arr, key_af] - unsigned xa = x[10], ya = y[10]; - unsigned xf = x[11], yf = y[11]; - if (area) { - if (xf != yf) return xf < yf; - if (x[1] != y[1]) return x[1] < y[1]; - if (xa != ya) return xa < ya; - } else { - if (xa != ya) return xa < ya; - if (x[1] != y[1]) return x[1] < y[1]; - if (xf != yf) return xf < yf; - } - return x[0] < y[0]; -} - -extern "C" __global__ void node_cuts( - const unsigned* nodes, unsigned* cuts, unsigned* best, const unsigned* lib, - const unsigned* costs, unsigned* scratch, - unsigned ord_off, unsigned chunk_base, unsigned n_nodes, unsigned slots, unsigned max_cuts, - unsigned kmax, unsigned mode, unsigned d_inv, unsigned a_inv, unsigned nkeys, unsigned moff, - unsigned coff, unsigned scr_words) { - __shared__ unsigned cnt[U]; - __shared__ unsigned off[U]; - __shared__ unsigned sv[8]; - __shared__ unsigned sarr[2]; - __shared__ unsigned saf[2]; - __shared__ unsigned simp[2]; - // Leaf count and leaves of the previous choices' cuts per phase (0: none). - __shared__ unsigned sp[14]; - // Ranks of the forced cuts (two-fanin cut, previous cuts), deduplicated. - __shared__ unsigned sfr[3]; - const unsigned tid = threadIdx.x; - const bool area = (mode & 1u) != 0; - const bool has_req = (mode & 2u) != 0; - const unsigned node = nodes[ord_off + chunk_base + blockIdx.x]; - const unsigned l0 = nodes[2 * node], l1 = nodes[2 * node + 1]; - const unsigned f0 = l0 >> 1, f1 = l1 >> 1; - const unsigned na = cuts[f0], nb = cuts[f1]; - const unsigned P = na * nb; - // Candidate capacity: every pair plus the two previous cuts. - const unsigned pcap = slots * slots + 2; - unsigned* scr = scratch + (unsigned long long)blockIdx.x * scr_words; - unsigned* cand = scr; - unsigned* idx = scr + pcap * SW; - unsigned* flag = idx + pcap; - unsigned* rank = flag + pcap; - unsigned* res = rank + pcap; - const unsigned* rows = cuts + n_nodes; - const unsigned* fo2 = costs; - // Phase 1: merge the pairs of a contiguous range per unit; feasible ones in pair order. - const unsigned R = (P + U - 1) / U; - const unsigned beg = tid * R; - const unsigned end = min(P, beg + R); - unsigned c = 0; - unsigned tmp[CW]; - for (unsigned p = beg; p < end; p++) { - unsigned ia = p / nb, ib = p % nb; - const unsigned* ra = rows + ((unsigned long long)f0 * slots + ia) * CW; - const unsigned* rb = rows + ((unsigned long long)f1 * slots + ib) * CW; - if (!merge(ra, (l0 & 1u) != 0, rb, (l1 & 1u) != 0, kmax, tmp)) continue; - if (tmp[6] == 0) continue; - unsigned* x = cand + (beg + c) * SW; - x[0] = p; - x[1] = tmp[6]; - for (unsigned t = 0; t < 6; t++) x[2 + t] = tmp[t]; - x[8] = tmp[7]; - x[9] = tmp[8]; - unsigned karr = ZERO_B; - unsigned kaf = 0; - for (unsigned t = 0; t < tmp[6]; t++) { - unsigned l = tmp[t]; - const unsigned* bl = best + 6 * l; - unsigned a0 = bl[1], a1 = bl[4]; - unsigned m = a0 < a1 ? a0 : a1; - if (m > karr) karr = m; - unsigned af0 = bl[2], af1 = bl[5]; - kaf = af_add(kaf, af_share(af0 < af1 ? af0 : af1, fo2[l])); - } - x[10] = karr; - x[11] = kaf; - c++; - } - cnt[tid] = c; - __syncthreads(); - if (tid == 0) { - unsigned s = 0; - for (unsigned u2 = 0; u2 < U; u2++) { off[u2] = s; s += cnt[u2]; } - // In area passes, the previous choices' cuts of the phases the cover uses follow the - // pairs as candidates (read before this node's rows are rewritten). - for (unsigned j = 0; j < 2; j++) { - sp[7 * j] = 0; - unsigned code = best[6 * node + 3 * j]; - if (has_req && costs[n_nodes + 2 * node + j] != INF_B && (code & 7u) == 4u) { - const unsigned* pr = rows + ((unsigned long long)node * slots + ((code >> 3) & 0x7Fu)) * CW; - unsigned* x = cand + (P + j) * SW; - x[0] = P + j; - x[1] = pr[6]; - for (unsigned t = 0; t < 6; t++) x[2 + t] = pr[t]; - x[8] = pr[7]; - x[9] = pr[8]; - unsigned karr = ZERO_B; - unsigned kaf = 0; - for (unsigned t = 0; t < pr[6]; t++) { - unsigned l = pr[t]; - const unsigned* bl = best + 6 * l; - unsigned a0 = bl[1], a1 = bl[4]; - unsigned m = a0 < a1 ? a0 : a1; - if (m > karr) karr = m; - unsigned af0 = bl[2], af1 = bl[5]; - kaf = af_add(kaf, af_share(af0 < af1 ? af0 : af1, fo2[l])); - } - x[10] = karr; - x[11] = kaf; - idx[s] = P + j; - s++; - sp[7 * j] = pr[6]; - for (unsigned t = 0; t < 6; t++) sp[7 * j + 1 + t] = pr[t]; - } - } - sv[0] = s; - sv[2] = NONE; - sv[3] = NONE; - sv[4] = NONE; - } - __syncthreads(); - for (unsigned j = 0; j < c; j++) idx[off[tid] + j] = beg + j; - __syncthreads(); - const unsigned V = sv[0]; - // Phase 2: duplicates of an earlier candidate (equal leaf sets). - for (unsigned i = tid; i < V; i += U) { - const unsigned* xi = cand + idx[i] * SW; - unsigned dup = 0; - for (unsigned j = 0; j < i && !dup; j++) { - const unsigned* xj = cand + idx[j] * SW; - if (xj[1] != xi[1]) continue; - bool same = true; - for (unsigned t = 0; t < xi[1]; t++) if (xj[2 + t] != xi[2 + t]) { same = false; break; } - if (same) dup = 1; - } - flag[i] = dup; - } - __syncthreads(); - // Phase 3: rank of every unique candidate; the two-fanin cut. - const unsigned two_lo = f0 < f1 ? f0 : f1, two_hi = f0 < f1 ? f1 : f0; - unsigned uniq = 0; - for (unsigned i = tid; i < V; i += U) { - if (flag[i]) continue; - uniq++; - const unsigned* xi = cand + idx[i] * SW; - unsigned r = 0; - for (unsigned j = 0; j < V; j++) { - if (j == i || flag[j]) continue; - if (less(cand + idx[j] * SW, xi, area)) r++; - } - rank[i] = r; - if (xi[1] == 2 && xi[2] == two_lo && xi[3] == two_hi) sv[2] = r; - for (unsigned j = 0; j < 2; j++) { - if (sp[7 * j] == 0 || sp[7 * j] != xi[1]) continue; - bool same = true; - for (unsigned t = 0; t < xi[1]; t++) if (sp[7 * j + 1 + t] != xi[2 + t]) same = false; - if (same) sv[3 + j] = r; - } - } - cnt[tid] = uniq; - __syncthreads(); - if (tid == 0) { - unsigned k = 0; - for (unsigned u2 = 0; u2 < U; u2++) k += cnt[u2]; - unsigned kept = k < max_cuts ? k : max_cuts; - sv[1] = kept; - // Forced cuts in order: two-fanin cut, previous cut of phase 0, of phase 1. - unsigned nf = 0; - for (unsigned j = 0; j < 3; j++) { - unsigned r = sv[2 + j]; - if (r == NONE || nf >= max_cuts) continue; - bool dup = false; - for (unsigned q = 0; q < nf; q++) if (sfr[q] == r) dup = true; - if (!dup) { sfr[nf] = r; nf++; } - } - sv[5] = nf; - cuts[node] = kept + 1; - unsigned* tr = cuts + n_nodes + ((unsigned long long)node * slots + kept) * CW; - tr[0] = node; - for (unsigned t = 1; t < 6; t++) tr[t] = 0; - tr[6] = 1; - tr[7] = 0xAAAAAAAAu; - tr[8] = 0xAAAAAAAAu; - unsigned m = node % 64u; - tr[9] = m < 32 ? (1u << m) : 0; - tr[10] = m < 32 ? 0 : (1u << (m - 32)); - } - __syncthreads(); - const unsigned kept = sv[1]; - const unsigned nf = sv[5]; - // Phase 4: write the kept cuts at their slots: the forced cuts, and the best others up to - // `max_cuts`, in rank order. - for (unsigned i = tid; i < V; i += U) { - if (flag[i]) continue; - unsigned r = rank[i]; - unsigned below = 0; - bool forced = false; - for (unsigned q = 0; q < nf; q++) { - if (sfr[q] < r) below++; - if (sfr[q] == r) forced = true; - } - unsigned slot = NONE; - if (forced) { - unsigned o = r - below; - slot = below + (o < max_cuts - nf ? o : max_cuts - nf); - } else if (r - below < max_cuts - nf) { - slot = r; - } - if (slot == NONE) continue; - const unsigned* xi = cand + idx[i] * SW; - unsigned* row = cuts + n_nodes + ((unsigned long long)node * slots + slot) * CW; - unsigned n = xi[1]; - unsigned slo = 0, shi = 0; - for (unsigned t = 0; t < 6; t++) row[t] = xi[2 + t]; - for (unsigned t = 0; t < n; t++) { - unsigned m = xi[2 + t] % 64u; - if (m < 32) slo |= 1u << m; else shi |= 1u << (m - 32); - } - row[6] = n; - row[7] = xi[8]; - row[8] = xi[9]; - row[9] = slo; - row[10] = shi; - } - __syncthreads(); - // Phase 5: best match of every (kept cut, phase). - for (unsigned q = tid; q < kept * 2; q += U) { - unsigned ci = q >> 1, ph = q & 1u; - const unsigned* row = cuts + n_nodes + ((unsigned long long)node * slots + ci) * CW; - unsigned n = row[6]; - unsigned lo = row[7], hi = row[8]; - if (ph) { lo = ~lo; hi = ~hi; } - if (n < 6) { hi = 0; if (n < 5) lo &= (1u << (1u << n)) - 1u; } - // Binary search of (n, hi, lo) among the sorted keys. - unsigned a = 0, b = nkeys; - while (a < b) { - unsigned mid = (a + b) >> 1; - const unsigned* kk = lib + mid * 5; - bool lt = kk[0] < n || (kk[0] == n && (kk[1] < hi || (kk[1] == hi && kk[2] < lo))); - if (lt) a = mid + 1; else b = mid; - } - unsigned barr = INF_B; - unsigned baf = AF_MAX, bm = NONE; - unsigned limit = has_req ? costs[n_nodes + 2 * node + ph] : INF_B; - if (a < nkeys && lib[a * 5] == n && lib[a * 5 + 1] == hi && lib[a * 5 + 2] == lo) { - unsigned ms = lib[a * 5 + 3], mc = lib[a * 5 + 4]; - for (unsigned mi = ms; mi < ms + mc; mi++) { - unsigned cell = lib[moff + 2 * mi]; - unsigned pp = lib[moff + 2 * mi + 1]; - const unsigned* ce = lib + coff + 8 * cell; - unsigned nin = ce[0]; - unsigned arr = ZERO_B; - unsigned af = ce[1]; - bool ok = true; - for (unsigned t = 0; t < nin; t++) { - unsigned leaf = row[(pp >> (4 * t)) & 15u]; - unsigned lph = (pp >> (24 + t)) & 1u; - unsigned la = best[6 * leaf + 3 * lph + 1]; - if (la == INF_B) { ok = false; break; } - unsigned v = add_arr(la, ce[2 + t]); - if (v > arr) arr = v; - af = af_add(af, af_share(best[6 * leaf + 3 * lph + 2], fo2[leaf])); - } - if (!ok) continue; - if (better(arr, af, barr, baf, limit, area)) { barr = arr; baf = af; bm = mi; } - } - } - unsigned* rr = res + q * 4; - rr[0] = bm; - rr[1] = barr; - rr[2] = baf; - } - __syncthreads(); - // Phase 6: first best over the cuts per phase, then inverters. - if (tid < 2) { - unsigned ph = tid; - unsigned limit = has_req ? costs[n_nodes + 2 * node + ph] : INF_B; - unsigned oarr = INF_B; - unsigned oaf = AF_MAX, oimp = 0; - for (unsigned ci = 0; ci < kept; ci++) { - const unsigned* rr = res + (ci * 2 + ph) * 4; - if (rr[0] == NONE) continue; - if (better(rr[1], rr[2], oarr, oaf, limit, area)) { - oarr = rr[1]; - oaf = rr[2]; - oimp = 4u | (ci << 3) | (rr[0] << 10); - } - } - sarr[ph] = oarr; - saf[ph] = oaf; - simp[ph] = oimp; - } - __syncthreads(); - if (tid == 0) { - for (unsigned p = 0; p < 2; p++) { - unsigned limit = has_req ? costs[n_nodes + 2 * node + p] : INF_B; - unsigned o = 1 - p; - if (sarr[o] != INF_B && (simp[o] & 7u) != 3u) { - unsigned carr = add_arr(sarr[o], d_inv); - unsigned caf = af_add(saf[o], a_inv); - if (better(carr, caf, sarr[p], saf[p], limit, area)) { - sarr[p] = carr; - saf[p] = caf; - simp[p] = 3u; - } - } - } - unsigned* bn = best + 6 * node; - for (unsigned p = 0; p < 2; p++) { - bn[3 * p] = simp[p]; - bn[3 * p + 1] = sarr[p]; - bn[3 * p + 2] = saf[p]; - } - } -} - -extern "C" __global__ void chosen_rows(const unsigned* cuts, const unsigned* best, unsigned* out, - unsigned n_nodes, unsigned slots) { - const unsigned i = blockIdx.x * blockDim.x + threadIdx.x; - if (i < 2 * n_nodes) { - const unsigned id = i >> 1; - const unsigned code = best[3 * i]; - const bool cell = (code & 7u) == 4u; - const unsigned c = (code >> 3) & 0x7Fu; - for (unsigned w = 0; w < CW; ++w) { - out[i * CW + w] = cell ? cuts[n_nodes + (id * slots + c) * CW + w] : 0u; - } - } -} -"#; - -/// Largest device array: the kernels index with 32-bit words. -const MAX_BYTES: u64 = 16 << 30; - -/// The CUDA backend. -#[derive(Debug)] -pub(super) struct CudaBackend; - -pub(super) static CUDA: CudaBackend = CudaBackend; - -/// Runs `f`, turning a panic inside the runtime into a device error (COMPUTE-REQ-0001). -fn guarded(f: impl FnOnce() -> Result) -> Result { - match catch_unwind(AssertUnwindSafe(f)) { - Ok(r) => r, - Err(p) => { - let msg = p - .downcast_ref::() - .map(String::as_str) - .or_else(|| p.downcast_ref::<&str>().copied()) - .unwrap_or("unknown panic"); - Err(ComputeError::Device(format!( - "cuda runtime failed: {}", - msg.trim() - ))) - } - } -} - -impl CutPassBackend for CudaBackend { - fn name(&self) -> &'static str { - "cuda" - } - - fn open<'s>( - &'s self, - p: &CutPassProblem<'_>, - ) -> Result, ComputeError> { - super::validate(p)?; - let pk = pack::pack(p, MAX_BYTES)?; - guarded(move || { - let dev = crate::cuda::device()?; - let node_cuts = dev.function(SRC, "node_cuts")?; - let chosen_rows = dev.function(SRC, "chosen_rows")?; - let nodes = dev.upload(&pk.nodes)?; - let cuts = dev.upload(&pk.cuts)?; - let best = dev.upload(&pk.best)?; - let lib = dev.upload(&pk.lib)?; - let costs = dev.zeros::(pk.n * 3)?; - let scratch = dev.zeros::(pk.scr_words * pk.chunk)?; - // The device holds the arrays; the host keeps the shape only. - let mut pk = pk; - pk.nodes = Vec::new(); - pk.cuts = Vec::new(); - pk.best = Vec::new(); - pk.lib = Vec::new(); - let s = CudaSession { - dev, - node_cuts, - chosen_rows, - nodes, - cuts, - best, - lib, - costs, - scratch, - pk, - }; - Ok(Box::new(s) as Box) - }) - } -} - -/// Device state of a session. -struct CudaSession { - dev: &'static Device, - node_cuts: CudaFunction, - chosen_rows: CudaFunction, - nodes: CudaSlice, - cuts: CudaSlice, - best: CudaSlice, - lib: CudaSlice, - costs: CudaSlice, - scratch: CudaSlice, - pk: Packed, -} - -impl CudaSession { - fn launch_chunk(&mut self, chunk_base: u32, count: u32, mode: u32) -> Result<(), ComputeError> { - let l = &self.pk; - let mut b = self.dev.stream().launch_builder(&self.node_cuts); - b.arg(&self.nodes) - .arg(&mut self.cuts) - .arg(&mut self.best) - .arg(&self.lib) - .arg(&self.costs) - .arg(&mut self.scratch) - .arg(&l.ord_off) - .arg(&chunk_base) - .arg(&l.n32) - .arg(&l.slots) - .arg(&l.max_cuts) - .arg(&l.kmax) - .arg(&mode) - .arg(&l.d_inv) - .arg(&l.a_inv) - .arg(&l.nkeys) - .arg(&l.moff) - .arg(&l.coff) - .arg(&l.scr_words32); - let cfg = LaunchConfig { - grid_dim: (count, 1, 1), - block_dim: (BLOCK, 1, 1), - shared_mem_bytes: 0, - }; - // SAFETY: `pack` sized every array for the indices the kernel computes from a problem - // that passed `validate`: node and fanin ids below `n`, cut rows of `slots` entries per - // node, `scr_words` scratch words per block of the chunk (`count <= chunk`), match and - // cell offsets inside `lib`. The chunk lies inside the level order list. - unsafe { b.launch(cfg) }.map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - Ok(()) - } -} - -impl CutPassSession for CudaSession { - fn run( - &mut self, - c: &PassCosts<'_>, - rb: Readback, - out: &mut PassOutput, - ) -> Result { - super::check_costs(self.pk.n, c)?; - guarded(|| { - let t = Instant::now(); - let (costs, mode) = pack::costs(self.pk.n, c); - self.costs = self.dev.upload(&costs)?; - let mut launches = 0u32; - let levels = self.pk.levels.clone(); - for (start, count) in levels { - let mut done = 0u32; - while done < count { - let k = (count - done).min(self.pk.chunk as u32); - self.launch_chunk(start + done, k, mode)?; - launches += 1; - done += k; - } - } - self.dev.sync()?; - let kernel_s = t.elapsed().as_secs_f64(); - let t = Instant::now(); - let n = self.pk.n; - match rb { - Readback::All => { - let cuts = self.dev.read(&self.cuts)?; - let best = self.dev.read(&self.best)?; - pack::read_into(n, self.pk.slots, &best, &cuts[n..], Some(&cuts[..n]), out); - } - Readback::Chosen => { - let mut rows = self.dev.zeros::(2 * n * CW)?; - let mut b = self.dev.stream().launch_builder(&self.chosen_rows); - b.arg(&self.cuts) - .arg(&self.best) - .arg(&mut rows) - .arg(&self.pk.n32) - .arg(&self.pk.slots); - // SAFETY: one thread per (node, phase) below `2 n`; it reads the three `best` - // words of its phase and one cut row of its node (`slots` rows per node, - // `CW` words each) and writes its own `CW` words of `rows`, which holds - // `2 n CW` words. - unsafe { b.launch(launch_1d(2 * n, BLOCK)) } - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - let rows = self.dev.read(&rows)?; - let best = self.dev.read(&self.best)?; - pack::read_into(n, self.pk.slots, &best, &rows, None, out); - } - } - Ok(PassStats { - launches, - kernel_s, - readback_s: t.elapsed().as_secs_f64(), - }) - }) - } -} diff --git a/crates/core/rusteda-compute/src/cut/mod.rs b/crates/core/rusteda-compute/src/cut/mod.rs deleted file mode 100644 index 6010197..0000000 --- a/crates/core/rusteda-compute/src/cut/mod.rs +++ /dev/null @@ -1,400 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The cut-pass task (COMPUTE-REQ-0101 to COMPUTE-REQ-0106): one priority-cut pass of a -//! dual-phase standard-cell mapper over an and-inverter graph, level by level. For every node of a -//! level, the feasible merges of its fanins' cut pairs (plus, in passes with required times, the -//! cuts chosen by the previous pass) are deduplicated, ranked by the pass cost, the best -//! `max_cuts` are kept, and the best library match of both phases is chosen, with an inverter -//! from the other phase as an alternative. All costs are integers. -//! -//! A [`CutPassBackend`] opens a [`CutPassSession`] on a [`CutPassProblem`]; the session keeps the -//! cut table and the choices resident between passes, so an engine runs its delay pass and its -//! area-flow passes on one session and reads back only what it needs after each pass. Every -//! backend returns word-identical results for the same problem and pass sequence -//! (COMPUTE-REQ-0104). - -mod cpu; -#[cfg(feature = "cuda")] -mod cuda; -mod pack; -#[cfg(test)] -mod tests; - -use crate::{BackendKind, ComputeError, GpuRuntime}; - -/// Largest number of priority cuts per node (the cut slot of an [`Imp`] has 7 bits and the -/// node's trivial cut takes one slot after the kept cuts). -pub const MAX_CUTS: u32 = 126; -/// Largest number of library matches (the match index of an [`Imp`] has 22 bits). -pub const MAX_MATCHES: usize = 1 << 22; -/// Largest number of cut leaves. -pub const MAX_LEAVES: u32 = 6; -/// Arrival of an unmapped (node, phase); finite arrivals stay below it. -pub const ARR_INF: i32 = i32::MAX; -/// Saturation value of area flows, and the area flow of an unmapped (node, phase). -pub const AF_MAX: u32 = 1 << 30; - -/// Implementation code of a (node, phase): the kind in bits 0-2; for a cell, the slot of its cut -/// in bits 3-9 and the index of its match in bits 10-31. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub struct Imp(pub u32); - -impl Imp { - /// Not computed. - pub const NONE: Imp = Imp(0); - /// Combinational input, positive phase. - pub const INPUT: Imp = Imp(1); - /// Free complemented output of an input's driver. - pub const FREE_NEG: Imp = Imp(2); - /// Inverter from the other phase. - pub const INV: Imp = Imp(3); - /// Kind of a cell implementation. - pub const KIND_CELL: u32 = 4; - - /// A cell over cut slot `cut` with match `m`. - pub fn cell(cut: u32, m: u32) -> Imp { - Imp(Imp::KIND_CELL | (cut << 3) | (m << 10)) - } - pub fn kind(self) -> u32 { - self.0 & 7 - } - pub fn cut(self) -> u32 { - (self.0 >> 3) & 0x7F - } - pub fn match_index(self) -> u32 { - self.0 >> 10 - } -} - -/// Implementation, arrival and area flow of one (node, phase). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Choice { - pub imp: Imp, - /// Arrival in fixed-point delay units; [`ARR_INF`] when unmapped. - pub arr: i32, - /// Area flow in fixed-point area units, at most [`AF_MAX`]. - pub af: u32, -} - -impl Choice { - pub const NONE: Choice = Choice { - imp: Imp::NONE, - arr: ARR_INF, - af: AF_MAX, - }; -} - -/// A cut: `n` sorted leaf nodes (unused leaves 0), the root function as a six-variable truth -/// table where variable i is leaf i, and the leaf signature (bit `leaf % 64` of every leaf). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct CutRow { - pub leaves: [u32; 6], - pub n: u32, - pub tt: u64, - pub sign: u64, -} - -/// The library side of the problem: match keys `(vars, tt)` in ascending order without repeats, -/// each with its matches in preference order (CSR), and the cells the matches refer to. -#[derive(Debug, Clone, Copy)] -pub struct MatchTable<'a> { - /// Number of variables of each key, 1 to 6. - pub key_vars: &'a [u8], - /// Truth table of each key over its variables (bits above `2^vars` clear). - pub key_tt: &'a [u64], - /// Matches of key i: `key_first[i]..key_first[i + 1]` (length: keys + 1). - pub key_first: &'a [u32], - /// Cell of each match. - pub m_cell: &'a [u32], - /// Cell input i of a match connects to leaf `perm[i]`. - pub m_perm: &'a [[u8; 6]], - /// Cell input i of a match is inverted when bit i is set. - pub m_phase: &'a [u8], - /// Input count of each cell (equal to the variables of the keys of its matches). - pub c_inputs: &'a [u8], - /// Area of each cell in fixed-point units, at most [`AF_MAX`]. - pub c_area: &'a [u32], - /// Delay from each input of each cell in fixed-point units, not negative. - pub c_delay: &'a [[i32; 6]], -} - -/// A mapping problem: the graph, the initial choices and the library. -#[derive(Debug, Clone, Copy)] -pub struct CutPassProblem<'a> { - /// The fanin literals (`2 * node + complemented`) of every AND node; `[0, 0]` for other nodes. - /// Node 0 is the constant. - pub fanins: &'a [[u32; 2]], - /// The nodes that start with their trivial cut (combinational inputs), ascending. - pub inputs: &'a [u32], - /// The AND nodes to compute, grouped by level: level i is - /// `level_nodes[level_ptr[i]..level_ptr[i + 1]]`; every fanin of a node is an input or a node - /// of an earlier level. - pub level_ptr: &'a [u32], - pub level_nodes: &'a [u32], - /// The choices before the first pass, per node and phase (no cell implementations). - pub init: &'a [[Choice; 2]], - /// Largest cut size `k`, 1 to 6. - pub max_leaves: u32, - /// Priority cuts kept per node, 1 to [`MAX_CUTS`]. - pub max_cuts: u32, - /// Delay and area of the inverter of the inverter alternative. - pub inv_delay: i32, - pub inv_area: u32, - pub lib: MatchTable<'a>, -} - -/// The cost that ranks the candidate cuts of a pass. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Rank { - /// Arrival, then leaf count, then area flow. - Delay, - /// Area flow, then leaf count, then arrival. - AreaFlow, -} - -/// The inputs of one pass. -#[derive(Debug, Clone, Copy)] -pub struct PassCosts<'a> { - pub rank: Rank, - /// Half fanouts per node (the divisor of area-flow shares; values below 2 count as 2). - pub fo2: &'a [u32], - /// Required time per node and phase; [`ARR_INF`] marks a phase outside the cover. With - /// required times, the cuts that the previous pass chose for the used phases stay candidates. - pub required: Option<&'a [[i32; 2]]>, -} - -/// What a pass reads back. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Readback { - /// The choices and the chosen cut of every (node, phase). - Chosen, - /// The choices and the whole cut table. - All, -} - -/// The result of a pass. -#[derive(Debug, Clone, Default)] -pub struct PassOutput { - /// Per node: the choices of both phases. - pub choices: Vec<[Choice; 2]>, - /// [`Readback::Chosen`]: `rows[2 * node + phase]` is the cut of that (node, phase) when a cell - /// implements it, else all zero. [`Readback::All`]: `slots` rows per node, of which the first - /// `cut_count[node]` are the node's cuts (the kept cuts in rank order, then the trivial cut). - pub rows: Vec, - /// [`Readback::All`] only: cuts per node. - pub cut_count: Vec, - /// Rows per node in the cut table: `max_cuts + 1`. - pub slots: u32, -} - -/// What a pass cost (wall clock). -#[derive(Debug, Clone, Copy, Default)] -pub struct PassStats { - /// Kernel launches (GPU) or levels (CPU). - pub launches: u32, - /// Until every node of the pass is computed and every error is collected. - pub kernel_s: f64, - /// Copying the results into the [`PassOutput`]. - pub readback_s: f64, -} - -/// A backend of the cut-pass task. -pub trait CutPassBackend: Send + Sync { - fn name(&self) -> &'static str; - /// Validates the problem (COMPUTE-REQ-0101) and makes it resident. - fn open<'s>( - &'s self, - p: &CutPassProblem<'_>, - ) -> Result, ComputeError>; -} - -/// A problem resident on a backend; passes run in call order. -pub trait CutPassSession { - /// Runs one pass over every level and reads back `rb` into `out`. - fn run( - &mut self, - c: &PassCosts<'_>, - rb: Readback, - out: &mut PassOutput, - ) -> Result; -} - -/// The backend for `kind` (COMPUTE-REQ-0004): `Cpu` always; `Cuda` when compiled and its device -/// passes the self-test; `Gpu` the first compiled GPU backend whose device passes it. A backend -/// that is missing or fails is an error, never a fallback to another backend. -pub fn backend(kind: BackendKind) -> Result<&'static dyn CutPassBackend, ComputeError> { - match kind { - BackendKind::Cpu => Ok(&cpu::CPU), - BackendKind::Cuda => { - if !GpuRuntime::Cuda.compiled() { - return Err(ComputeError::NotCompiled("cuda")); - } - crate::prepare(GpuRuntime::Cuda) - .map_err(|e| ComputeError::Unavailable(format!("cuda: {}", e.0)))?; - gpu_backend(GpuRuntime::Cuda) - } - BackendKind::Gpu => { - let compiled: Vec = GpuRuntime::ALL - .into_iter() - .filter(|r| r.compiled()) - .collect(); - if compiled.is_empty() { - return Err(ComputeError::Unavailable( - "this build has no GPU backend; enable the `cuda` feature".into(), - )); - } - let mut failures = Vec::new(); - for rt in compiled { - match crate::prepare(rt) { - Ok(()) => return gpu_backend(rt), - Err(e) => failures.push(format!("{}: {}", rt.name(), e.0)), - } - } - Err(ComputeError::Unavailable(failures.join("; "))) - } - } -} - -fn gpu_backend(rt: GpuRuntime) -> Result<&'static dyn CutPassBackend, ComputeError> { - match rt { - #[cfg(feature = "cuda")] - GpuRuntime::Cuda => Ok(&cuda::CUDA), - #[allow(unreachable_patterns)] - _ => Err(ComputeError::NotCompiled(rt.name())), - } -} - -/// Truth-table mask of a function of `vars` variables. -fn tt_mask(vars: u32) -> u64 { - if vars >= 6 { - u64::MAX - } else { - (1u64 << (1u32 << vars)) - 1 - } -} - -/// Checks the input contract of COMPUTE-REQ-0101. Kernels index device arrays with the values -/// checked here, so a GPU backend never launches on a problem that fails it. -pub fn validate(p: &CutPassProblem<'_>) -> Result<(), ComputeError> { - let bad = |m: String| Err(ComputeError::Invalid(m)); - let n = p.fanins.len(); - if n == 0 || p.init.len() != n { - return bad(format!("{n} nodes with {} initial choices", p.init.len())); - } - if n > (u32::MAX >> 2) as usize { - return Err(ComputeError::TooLarge(format!("{n} nodes"))); - } - if !(1..=MAX_LEAVES).contains(&p.max_leaves) { - return bad(format!("cut size {}", p.max_leaves)); - } - if p.max_cuts == 0 || p.max_cuts > MAX_CUTS { - return Err(ComputeError::TooLarge(format!( - "{} priority cuts per node: the cut-pass task supports 1 to {MAX_CUTS}", - p.max_cuts - ))); - } - if p.inv_delay < 0 || p.inv_area > AF_MAX { - return bad("inverter delay or area out of range".into()); - } - for c in p.init { - for x in c { - if x.imp.kind() >= Imp::KIND_CELL || x.af > AF_MAX { - return bad("an initial choice is a cell or has an area flow above AF_MAX".into()); - } - } - } - // Topological levels: inputs are ready; a level's nodes read only ready nodes. - let mut ready = vec![false; n]; - for &i in p.inputs { - if i as usize >= n || i == 0 || ready[i as usize] { - return bad(format!("input {i}")); - } - ready[i as usize] = true; - } - if p.level_ptr.first() != Some(&0) - || p.level_ptr.last().map(|x| *x as usize) != Some(p.level_nodes.len()) - || p.level_ptr.windows(2).any(|w| w[0] > w[1]) - { - return bad("level offsets".into()); - } - for w in p.level_ptr.windows(2) { - let level = &p.level_nodes[w[0] as usize..w[1] as usize]; - for &id in level { - if id as usize >= n || id == 0 || ready[id as usize] { - return bad(format!("level node {id}")); - } - for l in p.fanins[id as usize] { - let f = (l >> 1) as usize; - if f == 0 || f >= n || !ready[f] { - return bad(format!("fanin {f} of node {id} is not computed before it")); - } - } - } - for &id in level { - ready[id as usize] = true; - } - } - // Library. - let t = &p.lib; - let keys = t.key_vars.len(); - let matches = t.m_cell.len(); - let cells = t.c_inputs.len(); - if t.key_tt.len() != keys - || t.key_first.len() != keys + 1 - || t.key_first.first() != Some(&0) - || t.key_first.last().map(|x| *x as usize) != Some(matches) - || t.key_first.windows(2).any(|w| w[0] > w[1]) - || t.m_perm.len() != matches - || t.m_phase.len() != matches - || t.c_area.len() != cells - || t.c_delay.len() != cells - { - return bad("match table shape".into()); - } - if matches > MAX_MATCHES { - return Err(ComputeError::TooLarge(format!( - "{matches} library matches: the cut-pass task supports at most {MAX_MATCHES}" - ))); - } - for c in 0..cells { - let k = t.c_inputs[c] as usize; - if !(1..=6).contains(&k) || t.c_area[c] > AF_MAX || t.c_delay[c][..k].iter().any(|d| *d < 0) - { - return bad(format!("cell {c}")); - } - } - for i in 0..keys { - let vars = u32::from(t.key_vars[i]); - if !(1..=6).contains(&vars) || t.key_tt[i] & !tt_mask(vars) != 0 { - return bad(format!("key {i}")); - } - if i > 0 && (t.key_vars[i - 1], t.key_tt[i - 1]) >= (t.key_vars[i], t.key_tt[i]) { - return bad("keys are not strictly ascending".into()); - } - for m in t.key_first[i] as usize..t.key_first[i + 1] as usize { - let cell = t.m_cell[m] as usize; - if cell >= cells - || u32::from(t.c_inputs[cell]) != vars - || t.m_perm[m][..vars as usize] - .iter() - .any(|x| u32::from(*x) >= vars) - || u32::from(t.m_phase[m]) >> vars != 0 - { - return bad(format!("match {m}")); - } - } - } - Ok(()) -} - -/// Checks the inputs of one pass against the problem size. -fn check_costs(n: usize, c: &PassCosts<'_>) -> Result<(), ComputeError> { - if c.fo2.len() != n || c.required.is_some_and(|r| r.len() != n) { - return Err(ComputeError::Invalid(format!( - "pass costs for {} nodes, problem has {n}", - c.fo2.len() - ))); - } - Ok(()) -} diff --git a/crates/core/rusteda-compute/src/cut/pack.rs b/crates/core/rusteda-compute/src/cut/pack.rs deleted file mode 100644 index 18e15f9..0000000 --- a/crates/core/rusteda-compute/src/cut/pack.rs +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The kernel words of a cut-pass problem (COMPUTE-REQ-0103), shared by every backend. All -//! arrays are `u32`: -//! - `nodes`: fanin literals of every node (2 per node), then the AND nodes in level order from -//! `ord_off`; -//! - `cuts`: cut count of every node, then `slots` rows of `CW` words per node: six leaves, leaf -//! count, truth table (low, high word), leaf signature (low, high word); -//! - `best`: per node and phase: implementation code, biased arrival, area flow (3 words); -//! - `lib`: match keys (vars, table high, table low, first match, match count) ascending, matches -//! (cell, packed permutation and phase) from `moff`, cells (inputs, area, six delays) from -//! `coff`; -//! - `costs` (per pass): half fanouts of every node, then biased required times (2 per node). -//! -//! Arrivals and required times are stored biased (`x ^ 0x8000_0000` of the `i32`), which keeps -//! their order under unsigned comparison: kernels use 32-bit unsigned integers only. - -use super::{Choice, CutPassProblem, CutRow, Imp, PassCosts, Rank}; -use crate::ComputeError; - -/// Threads per block of the cut kernel. -#[cfg_attr(not(feature = "cuda"), allow(dead_code))] -pub(super) const BLOCK: u32 = 128; -/// Words per cut row. -pub(super) const CW: usize = 11; -/// Words per candidate record: pair index, leaf count, six leaves, truth table (2), arrival key, -/// area-flow key. -pub(super) const SW: usize = 12; -/// Largest scratch array per launch. -const SCRATCH_BUDGET: u64 = 256 << 20; -/// Kernels index every array with 32-bit words. -pub(super) const MAX_WORDS: u64 = u32::MAX as u64; - -/// Order-preserving map of an arrival or required time to `u32`. -pub(super) fn bias(x: i32) -> u32 { - (x as u32) ^ 0x8000_0000 -} - -pub(super) fn unbias(x: u32) -> i32 { - (x ^ 0x8000_0000) as i32 -} - -/// Everything a backend needs, packed once per session. -#[derive(Debug)] -#[cfg_attr(not(feature = "cuda"), allow(dead_code))] -pub(super) struct Packed { - pub n: usize, - pub n32: u32, - pub nodes: Vec, - pub ord_off: u32, - /// (first index into the level order, node count) of every level. - pub levels: Vec<(u32, u32)>, - pub cuts: Vec, - pub best: Vec, - pub lib: Vec, - pub nkeys: u32, - pub moff: u32, - pub coff: u32, - pub slots: u32, - pub max_cuts: u32, - pub kmax: u32, - pub d_inv: u32, - pub a_inv: u32, - pub scr_words: usize, - pub scr_words32: u32, - /// Largest number of blocks per launch (scratch budget, grid axis, widest level). - pub chunk: usize, -} - -/// Packs a validated problem; `max_bytes` is the largest device array. -pub(super) fn pack(p: &CutPassProblem<'_>, max_bytes: u64) -> Result { - let n = p.fanins.len(); - let slots = p.max_cuts as usize + 1; - let mut nodes = Vec::with_capacity(2 * n + p.level_nodes.len()); - for f in p.fanins { - nodes.extend_from_slice(f); - } - let ord_off = nodes.len() as u32; - nodes.extend_from_slice(p.level_nodes); - let levels: Vec<(u32, u32)> = p - .level_ptr - .windows(2) - .map(|w| (w[0], w[1] - w[0])) - .collect(); - let row_words = (n as u64) * (slots * CW) as u64; - let total = row_words + n as u64; - if total >= MAX_WORDS || total * 4 > max_bytes { - return Err(ComputeError::TooLarge(format!( - "{n} nodes with {slots} cuts each exceed 32-bit device indices or the device buffer limit" - ))); - } - let mut cuts = vec![0u32; total as usize]; - for &id in p.inputs { - cuts[id as usize] = 1; - let r = n + id as usize * slots * CW; - cuts[r] = id; - cuts[r + 6] = 1; - cuts[r + 7] = 0xAAAA_AAAA; - cuts[r + 8] = 0xAAAA_AAAA; - let m = id % 64; - cuts[r + 9] = if m < 32 { 1 << m } else { 0 }; - cuts[r + 10] = if m < 32 { 0 } else { 1 << (m - 32) }; - } - let mut best = Vec::with_capacity(6 * n); - for c in p.init { - for x in c { - best.extend([x.imp.0, bias(x.arr), x.af]); - } - } - let t = &p.lib; - let keys = t.key_vars.len(); - let mut lib = Vec::with_capacity(keys * 5 + t.m_cell.len() * 2 + t.c_inputs.len() * 8); - for i in 0..keys { - let tt = t.key_tt[i]; - lib.extend([ - u32::from(t.key_vars[i]), - (tt >> 32) as u32, - tt as u32, - t.key_first[i], - t.key_first[i + 1] - t.key_first[i], - ]); - } - let moff = lib.len() as u32; - for m in 0..t.m_cell.len() { - let mut pp = 0u32; - for (i, x) in t.m_perm[m].iter().enumerate() { - pp |= u32::from(*x & 15) << (4 * i); - } - lib.extend([t.m_cell[m], pp | (u32::from(t.m_phase[m]) << 24)]); - } - let coff = lib.len() as u32; - for c in 0..t.c_inputs.len() { - let mut rec = [0u32; 8]; - rec[0] = u32::from(t.c_inputs[c]); - rec[1] = t.c_area[c]; - for i in 0..rec[0] as usize { - rec[2 + i] = t.c_delay[c][i] as u32; - } - lib.extend(rec); - } - // Candidates: every pair of fanin cuts plus the two previous cuts. - let pcap = slots * slots + 2; - let scr_words = pcap * (SW + 3) + slots * 8; - let budget = SCRATCH_BUDGET.min(max_bytes); - let widest = levels - .iter() - .map(|l| l.1 as usize) - .max() - .unwrap_or(1) - .max(1); - let chunk = ((budget / (scr_words as u64 * 4)) as usize) - .clamp(1, 65_535) - .min(widest); - Ok(Packed { - n, - n32: n as u32, - nodes, - ord_off, - levels, - cuts, - best, - lib, - nkeys: keys as u32, - moff, - coff, - slots: slots as u32, - max_cuts: p.max_cuts, - kmax: p.max_leaves, - d_inv: p.inv_delay as u32, - a_inv: p.inv_area, - scr_words, - scr_words32: scr_words as u32, - chunk, - }) -} - -/// The cost words of a pass and the kernel mode (bit 0: area ranking, bit 1: required times). -pub(super) fn costs(n: usize, c: &PassCosts<'_>) -> (Vec, u32) { - let mut w = vec![0u32; 3 * n]; - w[..n].copy_from_slice(c.fo2); - if let Some(req) = c.required { - for (id, r) in req.iter().enumerate() { - w[n + 2 * id] = bias(r[0]); - w[n + 2 * id + 1] = bias(r[1]); - } - } - let mode = u32::from(c.rank == Rank::AreaFlow) | (u32::from(c.required.is_some()) << 1); - (w, mode) -} - -/// A choice from its three words. -pub(super) fn choice(w: &[u32]) -> Choice { - Choice { - imp: Imp(w[0]), - arr: unbias(w[1]), - af: w[2], - } -} - -/// A cut from its `CW` words. -pub(super) fn row(r: &[u32]) -> CutRow { - let mut leaves = [0u32; 6]; - leaves.copy_from_slice(&r[..6]); - CutRow { - leaves, - n: r[6], - tt: u64::from(r[7]) | (u64::from(r[8]) << 32), - sign: u64::from(r[9]) | (u64::from(r[10]) << 32), - } -} - -/// Fills `out` from the choice words and either the chosen rows (`2 n` rows of `CW` words) or -/// the whole cut table. -pub(super) fn read_into( - n: usize, - slots: u32, - best: &[u32], - rows: &[u32], - counts: Option<&[u32]>, - out: &mut super::PassOutput, -) { - out.slots = slots; - out.choices.clear(); - out.choices - .extend((0..n).map(|id| [choice(&best[6 * id..]), choice(&best[6 * id + 3..])])); - out.rows.clear(); - out.rows.extend(rows.chunks_exact(CW).map(row)); - out.cut_count.clear(); - if let Some(c) = counts { - out.cut_count.extend_from_slice(c); - } -} diff --git a/crates/core/rusteda-compute/src/cut/tests.rs b/crates/core/rusteda-compute/src/cut/tests.rs deleted file mode 100644 index bcbf55a..0000000 --- a/crates/core/rusteda-compute/src/cut/tests.rs +++ /dev/null @@ -1,522 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Tests of the cut-pass task on random problems: the CPU backend's duplicate and rank phases -//! against the kernel's quadratic loops, independence of the thread count, validation, and -//! word-identical results of every backend (COMPUTE-REQ-0104; the GPU test needs a GPU). - -use std::collections::BTreeMap; - -use super::cpu::unique_ranked; -use super::pack::SW; -use super::*; - -/// SplitMix64. -struct Rng(u64); - -impl Rng { - fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - fn below(&mut self, n: u64) -> u64 { - self.next() % n.max(1) - } - fn coin(&mut self, pct: u64) -> bool { - self.below(100) < pct - } -} - -/// An owned problem. -struct Owned { - fanins: Vec<[u32; 2]>, - inputs: Vec, - level_ptr: Vec, - level_nodes: Vec, - init: Vec<[Choice; 2]>, - key_vars: Vec, - key_tt: Vec, - key_first: Vec, - m_cell: Vec, - m_perm: Vec<[u8; 6]>, - m_phase: Vec, - c_inputs: Vec, - c_area: Vec, - c_delay: Vec<[i32; 6]>, - max_leaves: u32, - max_cuts: u32, - inv_delay: i32, - inv_area: u32, -} - -impl Owned { - fn view(&self) -> CutPassProblem<'_> { - CutPassProblem { - fanins: &self.fanins, - inputs: &self.inputs, - level_ptr: &self.level_ptr, - level_nodes: &self.level_nodes, - init: &self.init, - max_leaves: self.max_leaves, - max_cuts: self.max_cuts, - inv_delay: self.inv_delay, - inv_area: self.inv_area, - lib: MatchTable { - key_vars: &self.key_vars, - key_tt: &self.key_tt, - key_first: &self.key_first, - m_cell: &self.m_cell, - m_perm: &self.m_perm, - m_phase: &self.m_phase, - c_inputs: &self.c_inputs, - c_area: &self.c_area, - c_delay: &self.c_delay, - }, - } - } -} - -fn permutations(n: usize) -> Vec> { - fn rec(k: usize, cur: &mut Vec, out: &mut Vec>) { - if k == cur.len() { - out.push(cur.clone()); - return; - } - for i in k..cur.len() { - cur.swap(k, i); - rec(k + 1, cur, out); - cur.swap(k, i); - } - } - let mut out = Vec::new(); - rec(0, &mut (0..n as u8).collect(), &mut out); - out -} - -/// A random library: common two-input cells and random functions of up to four inputs that -/// depend on every input, expanded into every permutation and input phase. -fn random_lib(rng: &mut Rng, o: &mut Owned) { - let mut cells: Vec<(u8, u64)> = vec![ - (1, 0b01), - (1, 0b10), - (2, 0b1000), - (2, 0b0111), - (2, 0b1110), - (2, 0b0001), - (2, 0b0110), - (2, 0b1001), - ]; - for _ in 0..rng.below(8) { - let k = 3 + rng.below(2) as u8; - let mask = (1u64 << (1u32 << k)) - 1; - let tt = rng.next() & mask; - let depends_all = (0..k as usize).all(|i| { - let s = 1u32 << i; - let vm = [ - 0xAAAA_AAAA_AAAA_AAAAu64, - 0xCCCC_CCCC_CCCC_CCCC, - 0xF0F0_F0F0_F0F0_F0F0, - 0xFF00_FF00_FF00_FF00, - ][i]; - ((tt >> s) ^ tt) & !vm & mask != 0 - }); - if depends_all { - cells.push((k, tt)); - } - } - let mut table: BTreeMap<(u8, u64), Vec<(u32, [u8; 6], u8)>> = BTreeMap::new(); - for (ci, &(k, ctt)) in cells.iter().enumerate() { - o.c_inputs.push(k); - o.c_area.push(100 + rng.below(20_000) as u32); - let mut d = [0i32; 6]; - for x in d.iter_mut().take(k as usize) { - *x = 1 + rng.below(90_000) as i32; - } - o.c_delay.push(d); - for p in permutations(k as usize) { - for phase in 0..(1u32 << k) { - let mut tt = 0u64; - for m in 0..(1u32 << k) { - let mut y = 0u32; - for (i, &leaf) in p.iter().enumerate() { - y |= (((m >> leaf) & 1) ^ ((phase >> i) & 1)) << i; - } - if (ctt >> y) & 1 == 1 { - tt |= 1 << m; - } - } - let mut perm = [0u8; 6]; - perm[..k as usize].copy_from_slice(&p); - table - .entry((k, tt)) - .or_default() - .push((ci as u32, perm, phase as u8)); - } - } - } - o.key_first.push(0); - for ((k, tt), ms) in table { - o.key_vars.push(k); - o.key_tt.push(tt); - // A bounded number of matches per key, in a random but fixed order. - let take = 1 + rng.below(6) as usize; - let start = rng.below(ms.len() as u64) as usize; - for j in 0..take.min(ms.len()) { - let (c, perm, ph) = ms[(start + j) % ms.len()]; - o.m_cell.push(c); - o.m_perm.push(perm); - o.m_phase.push(ph); - } - o.key_first.push(o.m_cell.len() as u32); - } -} - -/// A random graph of `ands` AND nodes over `inputs` inputs with random initial choices. -fn random_problem(seed: u64, inputs: usize, ands: usize, max_cuts: u32, k: u32) -> Owned { - let mut rng = Rng(seed); - let n = 1 + inputs + ands; - let mut o = Owned { - fanins: vec![[0, 0]; n], - inputs: (1..=inputs as u32).collect(), - level_ptr: vec![0], - level_nodes: Vec::new(), - init: vec![[Choice::NONE; 2]; n], - key_vars: Vec::new(), - key_tt: Vec::new(), - key_first: Vec::new(), - m_cell: Vec::new(), - m_perm: Vec::new(), - m_phase: Vec::new(), - c_inputs: Vec::new(), - c_area: Vec::new(), - c_delay: Vec::new(), - max_leaves: k, - max_cuts, - inv_delay: 1 + rng.below(30_000) as i32, - inv_area: 50 + rng.below(1_000) as u32, - }; - random_lib(&mut rng, &mut o); - let zero = Choice { - imp: Imp::INPUT, - arr: 0, - af: 0, - }; - o.init[0] = [zero, zero]; - for i in 1..=inputs { - let arr = rng.below(300_000) as i32 - 50_000; - let neg = if rng.coin(30) { - Choice { - imp: Imp::FREE_NEG, - arr, - af: 0, - } - } else { - Choice { - imp: Imp::INV, - arr: arr + o.inv_delay, - af: o.inv_area, - } - }; - o.init[i] = [ - Choice { - imp: Imp::INPUT, - arr, - af: 0, - }, - neg, - ]; - } - let mut level = vec![0u32; n]; - let mut levels: Vec> = Vec::new(); - for id in inputs + 1..n { - let near = |rng: &mut Rng| id - 1 - rng.below((id - 1).min(40) as u64) as usize; - let a = near(&mut rng); - let mut b = 1 + rng.below(id as u64 - 1) as usize; - if b == a { - b = if a > 1 { a - 1 } else { a + 1 }; - } - o.fanins[id] = [ - 2 * a as u32 + u32::from(rng.coin(50)), - 2 * b as u32 + u32::from(rng.coin(50)), - ]; - let l = 1 + level[a].max(level[b]); - level[id] = l; - if levels.len() < l as usize { - levels.resize(l as usize, Vec::new()); - } - levels[l as usize - 1].push(id as u32); - } - for l in levels { - o.level_nodes.extend(l); - o.level_ptr.push(o.level_nodes.len() as u32); - } - o -} - -/// The costs of the passes of a run: a delay pass, area-flow passes with required times derived -/// from the previous choices (some phases outside the cover, some unconstrained), and a final -/// delay-ranked pass with required times. -fn pass_costs( - rng: &mut Rng, - n: usize, - prev: &[[Choice; 2]], - i: usize, -) -> (Rank, Vec, Option>) { - let fo2: Vec = (0..n).map(|_| rng.below(12) as u32).collect(); - if i == 0 { - return (Rank::Delay, fo2, None); - } - let req = (0..n) - .map(|id| { - let mut r = [ARR_INF; 2]; - for p in 0..2 { - let a = prev.get(id).map_or(ARR_INF, |c| c[p].arr); - r[p] = match rng.below(10) { - 0..=2 => ARR_INF, - 3 => ARR_INF - 1, - _ if a == ARR_INF => ARR_INF - 1, - _ => a.saturating_add(rng.below(40_000) as i32 - 10_000), - }; - } - r - }) - .collect(); - let rank = if i == 4 { Rank::Delay } else { Rank::AreaFlow }; - (rank, fo2, Some(req)) -} - -/// Outputs of every pass of a run on `b`. -fn run_all(b: &dyn CutPassBackend, o: &Owned, seed: u64, passes: usize) -> Vec { - let mut s = b - .open(&o.view()) - .unwrap_or_else(|e| panic!("{}: {e}", b.name())); - let mut rng = Rng(seed ^ 0xC0FF_EE00); - let n = o.fanins.len(); - let mut outs: Vec = Vec::new(); - for i in 0..passes { - let prev = outs.last().map_or(o.init.clone(), |x| x.choices.clone()); - let (rank, fo2, req) = pass_costs(&mut rng, n, &prev, i); - let rb = if i % 2 == 1 { - Readback::All - } else { - Readback::Chosen - }; - let mut out = PassOutput::default(); - s.run( - &PassCosts { - rank, - fo2: &fo2, - required: req.as_deref(), - }, - rb, - &mut out, - ) - .unwrap_or_else(|e| panic!("{} pass {i}: {e}", b.name())); - outs.push(out); - } - outs -} - -fn same(a: &[PassOutput], b: &[PassOutput]) -> Result<(), String> { - for (i, (x, y)) in a.iter().zip(b).enumerate() { - if x.choices != y.choices { - let id = (0..x.choices.len()).find(|&j| x.choices[j] != y.choices[j]); - return Err(format!("pass {i}: choices differ first at node {id:?}")); - } - if x.cut_count != y.cut_count { - return Err(format!("pass {i}: cut counts differ")); - } - if x.rows != y.rows { - let r = (0..x.rows.len()).find(|&j| x.rows[j] != y.rows[j]); - return Err(format!("pass {i}: rows differ first at row {r:?}")); - } - } - Ok(()) -} - -const CASES: [(u64, usize, usize, u32, u32); 8] = [ - (1, 8, 300, 1, 4), - (2, 12, 800, 8, 6), - (3, 20, 1500, 12, 6), - (4, 6, 400, 40, 5), - (5, 30, 2500, 20, 6), - (6, 10, 600, 126, 6), - (7, 16, 1200, 3, 2), - (8, 24, 2000, 40, 6), -]; - -// COMPUTE-REQ-0105: the hash-and-sort phases equal the kernel's quadratic duplicate and rank -// loops. -#[test] -fn unique_and_rank_equal_quadratic_loops() { - let mut rng = Rng(77); - for round in 0..300 { - let v = 1 + rng.below(400) as usize; - let cand: Vec<[u32; SW]> = (0..v) - .map(|p| { - let mut x = [0u32; SW]; - x[0] = p as u32; - let n = 1 + rng.below(3) as u32; - x[1] = n; - let mut l: Vec = (0..n).map(|_| rng.below(6) as u32).collect(); - l.sort_unstable(); - l.dedup(); - x[1] = l.len() as u32; - x[2..2 + l.len()].copy_from_slice(&l); - x[10] = rng.below(4) as u32; - x[11] = rng.below(4) as u32; - x - }) - .collect(); - for area in [false, true] { - let (order, _) = unique_ranked(&cand, area); - // The kernel: flag of an earlier equal leaf set; rank by counting smaller candidates. - let same_leaves = |a: &[u32; SW], b: &[u32; SW]| { - a[1] == b[1] && a[2..2 + a[1] as usize] == b[2..2 + b[1] as usize] - }; - let flag: Vec = (0..v) - .map(|i| (0..i).any(|j| same_leaves(&cand[j], &cand[i]))) - .collect(); - let less = |x: &[u32; SW], y: &[u32; SW]| { - let (xa, ya, xf, yf) = (x[10], y[10], x[11], y[11]); - if area { - if xf != yf { - return xf < yf; - } - if x[1] != y[1] { - return x[1] < y[1]; - } - if xa != ya { - return xa < ya; - } - } else { - if xa != ya { - return xa < ya; - } - if x[1] != y[1] { - return x[1] < y[1]; - } - if xf != yf { - return xf < yf; - } - } - x[0] < y[0] - }; - let uniq: Vec = (0..v).filter(|&i| !flag[i]).collect(); - assert_eq!(order.len(), uniq.len(), "round {round}"); - for &i in &uniq { - let r = uniq - .iter() - .filter(|&&j| j != i && less(&cand[j], &cand[i])) - .count(); - assert_eq!(order[r] as usize, i, "round {round} area {area}"); - } - } - } -} - -fn pool(threads: usize) -> rayon::ThreadPool { - rayon::ThreadPoolBuilder::new() - .num_threads(threads) - .build() - .expect("thread pool") -} - -// COMPUTE-REQ-0104, COMPUTE-REQ-0105: the CPU backend's results do not depend on the thread -// count. -#[test] -fn cpu_backend_is_thread_independent() { - for &(seed, inputs, ands, c, k) in &CASES[..5] { - let o = random_problem(seed, inputs, ands, c, k); - let cpu = backend(BackendKind::Cpu).expect("cpu"); - let one = pool(1).install(|| run_all(cpu, &o, seed, 5)); - let many = pool(8).install(|| run_all(cpu, &o, seed, 5)); - same(&one, &many).unwrap_or_else(|e| panic!("seed {seed}: {e}")); - // Every node of a level is mapped (the library has every two-input function). - let last = &one[4]; - for &id in &o.level_nodes { - assert!(last.choices[id as usize].iter().all(|c| c.arr != ARR_INF)); - } - } -} - -// COMPUTE-REQ-0101: malformed problems are rejected before any backend touches them. -#[test] -fn validation_rejects_malformed_problems() { - let cpu = backend(BackendKind::Cpu).expect("cpu"); - let base = random_problem(9, 6, 50, 4, 4); - assert!(cpu.open(&base.view()).is_ok()); - let check = |f: &dyn Fn(&mut Owned), want_too_large: bool| { - let mut o = random_problem(9, 6, 50, 4, 4); - f(&mut o); - match cpu.open(&o.view()) { - Err(ComputeError::TooLarge(_)) if want_too_large => {} - Err(ComputeError::Invalid(_)) if !want_too_large => {} - Err(e) => panic!("unexpected error {e}"), - Ok(_) => panic!("accepted"), - } - }; - check(&|o| o.max_cuts = 127, true); - check(&|o| o.max_cuts = 0, true); - check(&|o| o.max_leaves = 7, false); - check(&|o| o.fanins[20][0] = 2 * 49, false); - check(&|o| o.fanins[20][1] = 0, false); - check(&|o| o.level_nodes.swap(0, 40), false); - check(&|o| o.init[3][0].imp = Imp::cell(0, 0), false); - check(&|o| o.c_delay[0][0] = -1, false); - check(&|o| o.m_perm[0][0] = 5, false); - check(&|o| o.key_tt.swap(0, 1), false); - let mut o = random_problem(9, 6, 50, 4, 4); - o.inputs.push(70); - assert!(matches!(cpu.open(&o.view()), Err(ComputeError::Invalid(_)))); - let mut s = cpu.open(&base.view()).expect("open"); - let fo2 = vec![2u32; 3]; - assert!(matches!( - s.run( - &PassCosts { - rank: Rank::Delay, - fo2: &fo2, - required: None - }, - Readback::Chosen, - &mut PassOutput::default() - ), - Err(ComputeError::Invalid(_)) - )); -} - -// COMPUTE-REQ-0004: a backend that the build lacks is an error, never a fallback. -#[test] -fn missing_backend_is_an_error() { - if !GpuRuntime::Cuda.compiled() { - assert!(matches!( - backend(BackendKind::Cuda), - Err(ComputeError::NotCompiled("cuda")) - )); - assert!(matches!( - backend(BackendKind::Gpu), - Err(ComputeError::Unavailable(_)) - )); - } -} - -// COMPUTE-REQ-0104, COMPUTE-REQ-0106: the CUDA backend returns the CPU backend's words for every -// pass. Needs a GPU and the NVRTC library; run with `--ignored` on a GPU machine. -#[test] -#[ignore] -fn cuda_equals_cpu_on_random_problems() { - let cuda = backend(BackendKind::Cuda).unwrap_or_else(|e| panic!("{e}")); - let cpu = backend(BackendKind::Cpu).expect("cpu"); - for &(seed, inputs, ands, c, k) in &CASES { - for rep in 0..4u64 { - let s = seed * 100 + rep; - let o = random_problem(s, inputs, ands, c, k); - let a = run_all(cpu, &o, s, 5); - let b = run_all(cuda, &o, s, 5); - same(&a, &b).unwrap_or_else(|e| panic!("seed {s} C {c} k {k}: {e}")); - } - } -} diff --git a/crates/core/rusteda-compute/src/lib.rs b/crates/core/rusteda-compute/src/lib.rs deleted file mode 100644 index 9e08f1f..0000000 --- a/crates/core/rusteda-compute/src/lib.rs +++ /dev/null @@ -1,469 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Compute tasks of the RustEDA tools (`docs/specs/compute/`): task traits with a pure Rust CPU -//! backend, always built and authoritative, and native GPU backends that are checked for -//! consistency with it (RustEDA shared-crate contract, section 1; development plan, rule 5). -//! -//! - [`cut`]: the priority-cut enumeration and selection task (COMPUTE-REQ-0101 to -//! COMPUTE-REQ-0106); -//! - [`timing`]: the timing propagation task (COMPUTE-REQ-0010 to COMPUTE-REQ-0029); -//! - [`BackendKind`], [`Precision`] and [`ComputeError`]: the kinds shared by every task -//! (COMPUTE-REQ-0005); -//! - [`GpuRuntime`], [`GpuTask`], [`dispatch`] and [`prepare`]: selection of the GPU runtime that -//! executes an engine's own kernels, with a per-process device self-test (COMPUTE-REQ-0001, -//! COMPUTE-REQ-0003); -//! - [`cuda`] (feature `cuda`): the CUDA device layer (COMPUTE-REQ-0002, COMPUTE-REQ-0004). - -#[cfg(feature = "cuda")] -pub mod cuda; -pub mod cut; -pub mod rc; -pub mod timing; - -use std::panic::{AssertUnwindSafe, catch_unwind}; - -use rusteda_core::diag::Diag; -use rusteda_core::job::Cancelled; - -/// A backend a caller may ask a task for (COMPUTE-REQ-0005). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum BackendKind { - /// Pure Rust, always compiled; the authoritative implementation of every task. - Cpu, - /// NVIDIA GPUs: CUDA C kernels compiled by NVRTC (feature `cuda`). - Cuda, - /// The first compiled GPU backend whose device passes the per-process self-test. - Gpu, -} - -impl BackendKind { - /// Every kind, in the order of their names in user-facing lists. - pub const ALL: [BackendKind; 3] = [BackendKind::Cpu, BackendKind::Cuda, BackendKind::Gpu]; - - /// The name of the kind: `cpu`, `cuda` or `gpu`. - pub fn name(self) -> &'static str { - match self { - BackendKind::Cpu => "cpu", - BackendKind::Cuda => "cuda", - BackendKind::Gpu => "gpu", - } - } - - /// The kind with the given name. - pub fn parse(name: &str) -> Option { - BackendKind::ALL.into_iter().find(|k| k.name() == name) - } -} - -impl std::fmt::Display for BackendKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.name()) - } -} - -/// The floating-point precision of a backend's arithmetic (COMPUTE-REQ-0005). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum Precision { - /// IEEE 754 double precision: the authoritative precision. - #[default] - F64, - /// IEEE 754 single precision, on request. - F32, -} - -impl Precision { - /// Every precision. - pub const ALL: [Precision; 2] = [Precision::F64, Precision::F32]; - - /// The name of the precision: `f64` or `f32`. - pub fn name(self) -> &'static str { - match self { - Precision::F64 => "f64", - Precision::F32 => "f32", - } - } - - /// The precision with the given name. - pub fn parse(name: &str) -> Option { - Precision::ALL.into_iter().find(|p| p.name() == name) - } -} - -impl std::fmt::Display for Precision { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.name()) - } -} - -/// Where the inputs of a task came from, which decides the envelope code of -/// [`ComputeError::Invalid`] (COMPUTE-REQ-0025). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum InputOrigin { - /// Built by the engine in memory: an invalid input is an internal error. - Lowered, - /// Borrowed from a table mapped from disk: an invalid input is a corrupt table. - Mapped, -} - -/// The failure of a compute task (COMPUTE-REQ-0005, COMPUTE-REQ-0025). A failed task never -/// returns values. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum ComputeError { - /// The build lacks the named backend (its Cargo feature). - NotCompiled(&'static str), - /// The backend is compiled but unusable: no driver or device, or a failed self-test. - Unavailable(String), - /// The problem exceeds the device memory or the index range of the backend. - TooLarge(String), - /// A kernel did not compile. - Compile(String), - /// A driver call, a copy, a launch or a kernel execution failed. - Device(String), - /// The problem or a patch violates the task's interface. - Invalid(String), - /// The job was cancelled at a checkpoint. - Cancelled, -} - -impl ComputeError { - /// The envelope code of the error (`rusteda.v1`, contract section 7) for inputs of `origin`. - pub fn envelope_code(&self, origin: InputOrigin) -> &'static str { - match self { - ComputeError::NotCompiled(_) | ComputeError::Unavailable(_) => "BACKEND_UNAVAILABLE", - ComputeError::TooLarge(_) => "INPUT_REJECTED", - ComputeError::Compile(_) | ComputeError::Device(_) => "INTERNAL", - ComputeError::Invalid(_) => match origin { - InputOrigin::Lowered => "INTERNAL", - InputOrigin::Mapped => "CORRUPT_TABLE", - }, - ComputeError::Cancelled => "CANCELLED", - } - } - - /// The diagnostic code of the error, in the range A0500 to A0599 of this crate. - pub fn diag_code(&self) -> &'static str { - match self { - ComputeError::NotCompiled(_) | ComputeError::Unavailable(_) => "A0501", - ComputeError::TooLarge(_) => "A0502", - ComputeError::Compile(_) => "A0503", - ComputeError::Device(_) => "A0504", - ComputeError::Invalid(_) => "A0505", - ComputeError::Cancelled => "A0506", - } - } - - /// The error as a coded message: an error, or a note for a cancellation. - pub fn diag(&self) -> Diag { - let d = match self { - ComputeError::Cancelled => Diag::note(self.diag_code(), self.to_string()), - _ => Diag::error(self.diag_code(), self.to_string()), - }; - match self { - ComputeError::NotCompiled(_) => { - d.with_help("build with the Cargo feature `cuda`, or choose the backend `cpu`") - } - ComputeError::Unavailable(_) => d.with_help( - "a GPU backend needs an NVIDIA GPU, its driver and the NVRTC library; or choose \ - the backend `cpu`", - ), - ComputeError::TooLarge(_) => d.with_help("choose the backend `cpu`"), - _ => d, - } - } -} - -impl std::fmt::Display for ComputeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ComputeError::NotCompiled(b) => write!(f, "this build has no {b} backend"), - ComputeError::Unavailable(m) => write!(f, "backend unavailable: {m}"), - ComputeError::TooLarge(m) => write!(f, "problem too large for the backend: {m}"), - ComputeError::Compile(m) => write!(f, "kernel compilation failed: {m}"), - ComputeError::Device(m) => write!(f, "device failure: {m}"), - ComputeError::Invalid(m) => write!(f, "invalid problem: {m}"), - ComputeError::Cancelled => f.write_str("cancelled"), - } - } -} - -impl std::error::Error for ComputeError {} - -impl From for ComputeError { - fn from(_: Cancelled) -> ComputeError { - ComputeError::Cancelled - } -} - -impl From for ComputeError { - /// A failure of the device layer after the device was opened. - fn from(e: GpuError) -> ComputeError { - ComputeError::Device(e.0) - } -} - -/// A GPU runtime a build may contain (COMPUTE-REQ-0001). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum GpuRuntime { - /// CUDA C kernels compiled by NVRTC (feature `cuda`, COMPUTE-REQ-0002). - Cuda, -} - -impl GpuRuntime { - /// Every runtime, in the order automatic selection tries them. - pub const ALL: [GpuRuntime; 1] = [GpuRuntime::Cuda]; - - pub fn name(self) -> &'static str { - match self { - GpuRuntime::Cuda => "cuda", - } - } - - /// True when this build contains the runtime. - pub fn compiled(self) -> bool { - match self { - GpuRuntime::Cuda => cfg!(feature = "cuda"), - } - } -} - -/// A failure to set up or run GPU work. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GpuError(pub String); - -impl std::fmt::Display for GpuError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for GpuError {} - -/// GPU work of an engine: one implementation per runtime; a task without an implementation for -/// the runtime it is run on fails (COMPUTE-REQ-0001). -pub trait GpuTask { - type Output; - /// The CUDA C implementation for the `cuda` runtime. - #[cfg(feature = "cuda")] - fn run_cuda(&self, _dev: &cuda::Device) -> Result { - Err(GpuError( - "this task has no implementation for the cuda runtime".into(), - )) - } -} - -/// Runs `task` on runtime `want`, or with `None` on the first compiled runtime (in -/// [`GpuRuntime::ALL`] order) whose default device passes the self-test. Returns the output and -/// the runtime that produced it. -pub fn dispatch( - want: Option, - task: &T, -) -> Result<(T::Output, GpuRuntime), GpuError> { - let candidates: Vec = match want { - Some(r) if !r.compiled() => { - return Err(GpuError(format!( - "this build has no {} runtime (enable the `{}` feature)", - r.name(), - r.name() - ))); - } - Some(r) => vec![r], - None => GpuRuntime::ALL - .into_iter() - .filter(|r| r.compiled()) - .collect(), - }; - if candidates.is_empty() { - return Err(GpuError( - "this build has no GPU runtime (enable the `cuda` feature)".into(), - )); - } - let mut failures = Vec::new(); - for rt in candidates { - match prepare(rt) { - Ok(()) => return run_on(rt, task).map(|o| (o, rt)), - Err(e) => failures.push(format!("{}: {}", rt.name(), e.0)), - } - } - Err(GpuError(format!( - "no usable GPU runtime ({})", - failures.join("; ") - ))) -} - -/// Checks the preconditions of a runtime and runs the self-test on its default device -/// (COMPUTE-REQ-0001), once per process and runtime; later calls return the first result (and -/// wait for it while another thread computes it, COMPUTE-REQ-0003). -pub fn prepare(rt: GpuRuntime) -> Result<(), GpuError> { - static PREPARED: [std::sync::OnceLock>; GpuRuntime::ALL.len()] = - [const { std::sync::OnceLock::new() }; GpuRuntime::ALL.len()]; - let slot = GpuRuntime::ALL - .iter() - .position(|r| *r == rt) - .unwrap_or_default(); - PREPARED[slot].get_or_init(|| run_on(rt, &SelfTest)).clone() -} - -fn run_on(rt: GpuRuntime, task: &T) -> Result { - let out = catch_unwind(AssertUnwindSafe(|| match rt { - #[cfg(feature = "cuda")] - GpuRuntime::Cuda => task.run_cuda(cuda::device()?), - #[allow(unreachable_patterns)] - _ => { - // In a build without a GPU runtime this is the only arm and `task` is otherwise unused. - let _ = task; - Err(GpuError(format!("this build has no {} runtime", rt.name()))) - } - })); - match out { - Ok(r) => r, - Err(p) => { - let msg = p - .downcast_ref::() - .map(String::as_str) - .or_else(|| p.downcast_ref::<&str>().copied()) - .unwrap_or("unknown panic"); - Err(GpuError(format!( - "{} runtime failed: {}", - rt.name(), - msg.trim() - ))) - } - } -} - -/// Runs the self-test kernel of a runtime, which compares every element with the exact expected -/// value, so that a runtime that silently fails to execute kernels is rejected. -struct SelfTest; - -impl GpuTask for SelfTest { - type Output = (); - #[cfg(feature = "cuda")] - fn run_cuda(&self, dev: &cuda::Device) -> Result<(), GpuError> { - cuda::self_test(dev) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // COMPUTE-REQ-0001: a requested runtime that the build lacks, and a build without any GPU - // runtime, are failures. The assertions need a build without the runtime features, such as - // `cargo test -p rusteda-compute --lib`; a workspace build may unify them in. - #[test] - fn requested_runtime_must_be_compiled() { - for rt in GpuRuntime::ALL.into_iter().filter(|r| !r.compiled()) { - assert_eq!( - dispatch(Some(rt), &SelfTest).map(|_| ()), - Err(GpuError(format!( - "this build has no {0} runtime (enable the `{0}` feature)", - rt.name() - ))) - ); - } - if !GpuRuntime::ALL.iter().any(|r| r.compiled()) { - assert_eq!( - dispatch(None, &SelfTest).map(|_| ()), - Err(GpuError( - "this build has no GPU runtime (enable the `cuda` feature)".into() - )) - ); - } - } - - // Needs a GPU and the NVRTC library; run with `--ignored` on a GPU machine. - #[test] - #[ignore] - fn self_test_on_every_compiled_runtime() { - for rt in GpuRuntime::ALL.into_iter().filter(|r| r.compiled()) { - prepare(rt).unwrap_or_else(|e| panic!("{}: {e}", rt.name())); - } - } - - // COMPUTE-REQ-0005: the shared kinds have stable names that parse back. - #[test] - fn kind_and_precision_names() { - for k in BackendKind::ALL { - assert_eq!(BackendKind::parse(k.name()), Some(k)); - assert_eq!(k.to_string(), k.name()); - } - for p in Precision::ALL { - assert_eq!(Precision::parse(p.name()), Some(p)); - } - assert_eq!( - BackendKind::ALL.map(BackendKind::name), - ["cpu", "cuda", "gpu"] - ); - assert_eq!(Precision::ALL.map(Precision::name), ["f64", "f32"]); - assert_eq!(BackendKind::parse("metal"), None); - assert_eq!(Precision::parse("f16"), None); - } - - // COMPUTE-REQ-0025, COMPUTE-REQ-0005: every error has its envelope code and diagnostic; an - // uncompiled or unusable backend is BACKEND_UNAVAILABLE with A0501. - #[test] - fn error_envelope_codes_and_diagnostics() { - use InputOrigin::{Lowered, Mapped}; - let cases = [ - ( - ComputeError::NotCompiled("cuda"), - "BACKEND_UNAVAILABLE", - "BACKEND_UNAVAILABLE", - "A0501", - ), - ( - ComputeError::Unavailable("no device".into()), - "BACKEND_UNAVAILABLE", - "BACKEND_UNAVAILABLE", - "A0501", - ), - ( - ComputeError::TooLarge("x".into()), - "INPUT_REJECTED", - "INPUT_REJECTED", - "A0502", - ), - ( - ComputeError::Compile("x".into()), - "INTERNAL", - "INTERNAL", - "A0503", - ), - ( - ComputeError::Device("x".into()), - "INTERNAL", - "INTERNAL", - "A0504", - ), - ( - ComputeError::Invalid("x".into()), - "INTERNAL", - "CORRUPT_TABLE", - "A0505", - ), - (ComputeError::Cancelled, "CANCELLED", "CANCELLED", "A0506"), - ]; - for (e, lowered, mapped, code) in cases { - assert_eq!(e.envelope_code(Lowered), lowered, "{e}"); - assert_eq!(e.envelope_code(Mapped), mapped, "{e}"); - assert_eq!(e.diag_code(), code); - let d = e.diag(); - assert_eq!(d.code, code); - assert_eq!(d.message, e.to_string()); - let range = d.code[1..].parse::().unwrap_or(0); - assert!(d.code.starts_with('A') && (500..600).contains(&range)); - } - assert_eq!( - ComputeError::NotCompiled("cuda").to_string(), - "this build has no cuda backend" - ); - assert!(ComputeError::NotCompiled("cuda").diag().help.is_some()); - assert_eq!(ComputeError::from(Cancelled), ComputeError::Cancelled); - assert_eq!( - ComputeError::from(GpuError("launch".into())), - ComputeError::Device("launch".into()) - ); - } -} diff --git a/crates/core/rusteda-compute/src/rc.rs b/crates/core/rusteda-compute/src/rc.rs deleted file mode 100644 index 3015307..0000000 --- a/crates/core/rusteda-compute/src/rc.rs +++ /dev/null @@ -1,934 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Bounded f64 ramp/charge models, independently derived from RC circuit equations. -//! STA-REQ-0721, STA-REQ-0722, STA-REQ-0726, STA-REQ-0728, STA-REQ-0729. All times ns, -//! capacitances pF, resistances kOhm. -use crate::ComputeError; - -/// Progress-normalized trip points: falling voltages are mapped through 1-v. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Thresholds { - pub lower: f64, - pub upper: f64, - pub input: f64, - pub output: f64, - pub derate: f64, -} -impl Thresholds { - pub fn validate(self) -> Result<(), ComputeError> { - let valid = [self.lower, self.upper, self.input, self.output] - .iter() - .all(|x| x.is_finite() && *x > 0.0 && *x < 1.0); - if !valid - || self.lower >= self.upper - || !self.derate.is_finite() - || self.derate <= 0.0 - || self.derate > 1.0 - { - return Err(ComputeError::Invalid("RC trip points/derate".into())); - } - Ok(()) - } -} - -/// Passive moment-matched driving-point pi. -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub struct Pi { - pub near: f64, - pub far: f64, - pub resistance: f64, -} -impl Pi { - pub fn total(self) -> f64 { - self.near + self.far - } - pub fn validate(self) -> Result<(), ComputeError> { - if [self.near, self.far, self.resistance, self.total()] - .iter() - .any(|x| !x.is_finite() || *x < 0.0) - { - return Err(ComputeError::Invalid("nonpassive RC pi".into())); - } - Ok(()) - } -} - -/// Fraction of a far capacitance charged during a ramp interval u, for tau=R*C. -pub fn charged_fraction(u: f64, tau: f64) -> f64 { - if tau == 0.0 { - return 1.0; - } - if u == 0.0 { - return 0.0; - } - let x = u / tau; - if x < 1e-3 { - // Cancellation-free series of 1-(1-exp(-x))/x. - x * (0.5 + x * (-1.0 / 6.0 + x * (1.0 / 24.0 + x * (-1.0 / 120.0 + x / 720.0)))) - } else { - 1.0 + (-x).exp_m1() / x - } -} - -/// A fitted finite-ramp Thevenin driver and passive pi response. -#[derive(Debug, Clone, Copy)] -pub struct GateResponse { - pub delay: f64, - pub slew: f64, - pub capacitance: f64, - pub resistance: f64, - pub duration: f64, - pub root_crossing: f64, -} - -fn fit_duration(slew: f64, cap: f64, res: f64, t: Thresholds) -> Result { - let target = slew * t.derate * (t.output - t.lower) / (t.upper - t.lower); - let tau = res * cap; - let minimum = tau * ((1.0 - t.lower) / (1.0 - t.output)).ln(); - if target <= minimum { - return Err(ComputeError::Invalid( - "NLDM transition incompatible with Thevenin resistance".into(), - )); - } - let mut lo = 0.0; - let mut hi = slew * t.derate / (t.upper - t.lower) + 2.0 * tau; - let mut x = (target / (t.output - t.lower)).min(hi); - let tolerance = (hi * 1e-12).max(1e-15); - for _ in 0..56 { - let mid = crossing(x, tau, t.output); - let lower = crossing(x, tau, t.lower); - let residual = mid - lower - target; - if residual.abs() <= tolerance { - return Ok(x); - } - if residual < 0.0 { - lo = x; - } else { - hi = x; - } - let slope = - crossing_gradient(x, tau, t.output, mid) - crossing_gradient(x, tau, t.lower, lower); - let next = x - residual / slope; - x = if next > lo && next < hi { - next - } else { - lo + (hi - lo) * 0.5 - }; - } - Err(ComputeError::Invalid( - "RC source-duration fit did not converge".into(), - )) -} - -#[derive(Debug, Clone, Copy)] -struct Poles { - a: f64, - b: f64, - root_weight: f64, -} -impl Poles { - fn new(pi: Pi, r: f64) -> Self { - let sum = r * pi.total() + pi.resistance * pi.far; - let product = r * pi.resistance * pi.near * pi.far; - let disc = (sum * sum - 4.0 * product).max(0.0).sqrt(); - let a = (sum + disc) * 0.5; - let b = if a > 0.0 { product / a } else { 0.0 }; - let root_weight = if a > b { - (a - pi.resistance * pi.far) / (a - b) - } else { - 1.0 - }; - Self { a, b, root_weight } - } - fn root(self, x: f64, d: f64) -> f64 { - self.root_weight * ramp_voltage(x, d, self.a) - + (1.0 - self.root_weight) * ramp_voltage(x, d, self.b) - } - fn far(self, x: f64, d: f64) -> f64 { - if self.a == self.b { - return self.root(x, d); - } - (self.a * ramp_voltage(x, d, self.a) - self.b * ramp_voltage(x, d, self.b)) - / (self.a - self.b) - } - fn crossing(self, d: f64, p: f64) -> f64 { - let mut lo = 0.0; - let mut hi = d - self.a * (1.0 - p).ln(); - let tolerance = (hi * 1e-13).max(1e-16); - for _ in 0..80 { - if hi - lo <= tolerance { - break; - } - let mid = lo + (hi - lo) * 0.5; - if self.root(mid, d) < p { - lo = mid; - } else { - hi = mid; - } - } - lo + (hi - lo) * 0.5 - } -} - -/// NLDM-compatible Thevenin proxy: the delay sensitivity supplies a tail -/// resistance, with the threshold log converting its time reference. It is an -/// explicit NLDM parameter estimate, not a transistor DC characterization. -/// Match the lower/delay trip points; equate charge over the source's active ramp. -pub fn gate_detail( - pi: Pi, - t: Thresholds, - mut eval: impl FnMut(f64) -> (f64, f64), -) -> Result { - pi.validate()?; - t.validate()?; - if t.output <= t.lower || t.output >= t.upper { - return Err(ComputeError::Invalid( - "RC driver delay threshold must lie within slew trip points".into(), - )); - } - let total = pi.total(); - let (d0, s0) = eval(total); - if !d0.is_finite() || !s0.is_finite() { - return Err(ComputeError::Invalid("RC NLDM nonfinite".into())); - } - if pi.resistance == 0.0 || pi.far == 0.0 || s0 <= 0.0 { - return Ok(GateResponse { - delay: d0, - slew: s0.max(0.0), - capacitance: total, - resistance: 0.0, - duration: s0.max(0.0) * t.derate / (t.upper - t.lower), - root_crossing: s0.max(0.0) * t.derate / (t.upper - t.lower) * t.output, - }); - } - let delta = (total * 0.01).max(1e-12); - let lower = (total - delta).max(0.0); - let derivative = (eval(total + delta).0 - eval(lower).0) / (total + delta - lower); - let res = derivative.max(0.0) * (-(1.0 - t.output).ln()); - solve_driver(pi, t, res, eval) -} - -/// [`gate_detail`] with the caller's Thevenin resistance `res` (kOhm) instead of the central -/// delay sensitivity over 1 % of the total load: the same validation, degenerate lumped loads, -/// charge solve and waveform. The caller states how `res` derives from the NLDM tables. -pub fn gate_detail_with_resistance( - pi: Pi, - t: Thresholds, - res: f64, - mut eval: impl FnMut(f64) -> (f64, f64), -) -> Result { - pi.validate()?; - t.validate()?; - if t.output <= t.lower || t.output >= t.upper { - return Err(ComputeError::Invalid( - "RC driver delay threshold must lie within slew trip points".into(), - )); - } - let total = pi.total(); - let (d0, s0) = eval(total); - if !d0.is_finite() || !s0.is_finite() { - return Err(ComputeError::Invalid("RC NLDM nonfinite".into())); - } - if pi.resistance == 0.0 || pi.far == 0.0 || s0 <= 0.0 { - return Ok(GateResponse { - delay: d0, - slew: s0.max(0.0), - capacitance: total, - resistance: 0.0, - duration: s0.max(0.0) * t.derate / (t.upper - t.lower), - root_crossing: s0.max(0.0) * t.derate / (t.upper - t.lower) * t.output, - }); - } - solve_driver(pi, t, res, eval) -} - -/// The bounded charge solve and waveform of [`gate_detail`] for a non-degenerate pi. -fn solve_driver( - pi: Pi, - t: Thresholds, - res: f64, - mut eval: impl FnMut(f64) -> (f64, f64), -) -> Result { - let total = pi.total(); - if !res.is_finite() || res <= 0.0 { - return Err(ComputeError::Invalid( - "NLDM has no positive delay sensitivity for RC driver".into(), - )); - } - let poles = Poles::new(pi, res); - let mut lo = pi.near; - let mut hi = total; - let tolerance = (total * 1e-11).max(1e-16); - for _ in 0..64 { - let effective = lo + (hi - lo) * 0.5; - let (_, s) = eval(effective); - if !s.is_finite() || s <= 0.0 { - return Err(ComputeError::Invalid( - "invalid NLDM transition for RC fitting".into(), - )); - } - let duration = fit_duration(s, effective, res, t)?; - let actual = - pi.near * poles.root(duration, duration) + pi.far * poles.far(duration, duration); - let nominal = effective * ramp_voltage(duration, duration, res * effective); - if nominal > actual { - hi = effective; - } else { - lo = effective; - } - if hi - lo <= tolerance { - break; - } - } - if hi - lo > tolerance { - return Err(ComputeError::Invalid( - "RC charge solve did not converge".into(), - )); - } - let effective = lo + (hi - lo) * 0.5; - let (delay, s) = eval(effective); - let duration = fit_duration(s, effective, res, t)?; - let nominal = effective * ramp_voltage(duration, duration, res * effective); - let actual = pi.near * poles.root(duration, duration) + pi.far * poles.far(duration, duration); - if (nominal - actual).abs() > total.max(1e-16) * 1e-8 { - return Err(ComputeError::Invalid("RC charge residual exceeded".into())); - } - let slew = (poles.crossing(duration, t.upper) - poles.crossing(duration, t.lower)) / t.derate; - let root_crossing = poles.crossing(duration, t.output); - if [delay, slew, duration, root_crossing] - .iter() - .any(|v| !v.is_finite()) - { - return Err(ComputeError::Invalid("RC waveform overflow".into())); - } - Ok(GateResponse { - delay, - slew, - capacitance: effective, - resistance: res, - duration, - root_crossing, - }) -} - -/// The relative load step of the forward table slopes in [`gate_one_step`]. -const SLOPE_STEP: f64 = 1e-7; - -/// [`ramp_voltage`] at `time` and its partial derivatives with respect to the time, the ramp -/// duration and the pole time, for a positive duration and pole time. -fn ramp_partials(time: f64, duration: f64, tau: f64) -> [f64; 4] { - if time <= 0.0 { - return [0.0; 4]; - } - if time <= duration { - let u = time / tau; - let v = ramp_voltage(time, duration, tau); - // (1 - e^-u) - u e^-u without cancellation for small u. - let q = if u < 1e-3 { - u * u * (0.5 - u / 3.0 + u * u / 8.0) - } else { - -(-u).exp_m1() - u * (-u).exp() - }; - return [v, -(-u).exp_m1() / duration, -v / duration, -q / duration]; - } - let a = (-(time - duration) / tau).exp(); - let b = (-time / tau).exp(); - [ - 1.0 - tau / duration * (a - b), - (a - b) / duration, - tau / (duration * duration) * (a - b) - a / duration, - -(a - b) / duration - (a * (time - duration) - b * time) / (tau * duration), - ] -} - -/// [`ramp_voltage`] at the end of the ramp and its derivative with respect to the duration. -fn settled(duration: f64, tau: f64) -> (f64, f64) { - if tau == 0.0 { - return (1.0, 0.0); - } - let x = duration / tau; - ( - ramp_voltage(duration, duration, tau), - tau / (duration * duration) * -(-x).exp_m1() - (-x).exp() / duration, - ) -} - -/// Solves `a x = b` by Gaussian elimination with partial pivoting. -fn solve3(mut a: [[f64; 3]; 3], mut b: [f64; 3]) -> Option<[f64; 3]> { - for i in 0..3 { - let p = (i..3).max_by(|&r, &s| a[r][i].abs().total_cmp(&a[s][i].abs()))?; - a.swap(i, p); - b.swap(i, p); - if !a[i][i].is_normal() { - return None; - } - for r in i + 1..3 { - let f = a[r][i] / a[i][i]; - for k in i..3 { - a[r][k] -= f * a[i][k]; - } - b[r] -= f * b[i]; - } - } - let mut x = [0.0; 3]; - for i in (0..3).rev() { - let tail: f64 = (i + 1..3).map(|k| a[i][k] * x[k]).sum(); - x[i] = (b[i] - tail) / a[i][i]; - } - x.iter().all(|v| v.is_finite()).then_some(x) -} - -/// One Newton-Raphson correction of the effective capacitance of a finite-ramp Thevenin driver -/// with the caller's resistance `res` (kOhm), instead of the converged charge solve of -/// [`gate_detail_with_resistance`]. The unknowns are the source start, the source duration and -/// the capacitance; the equations put the single-capacitance response at the lower and the -/// delay trip points at the NLDM times of that capacitance and match the charge of the pi at the -/// end of the source ramp. The step starts from the total load with the source ramp of the -/// table transition at the total load, uses the exact Jacobian (forward table slopes over a -/// relative load step of 1e-7) and is clamped to `[near, total]`. The delay is the NLDM delay at -/// the corrected capacitance; the transition and the root crossing are those of the pi root for -/// the source duration fitted at that capacitance. Degenerate lumped loads are as in -/// [`gate_detail_with_resistance`]. -pub fn gate_one_step( - pi: Pi, - t: Thresholds, - res: f64, - mut eval: impl FnMut(f64) -> (f64, f64), -) -> Result { - pi.validate()?; - t.validate()?; - if t.output <= t.lower || t.output >= t.upper { - return Err(ComputeError::Invalid( - "RC driver delay threshold must lie within slew trip points".into(), - )); - } - let total = pi.total(); - let (d0, s0) = eval(total); - if !d0.is_finite() || !s0.is_finite() { - return Err(ComputeError::Invalid("RC NLDM nonfinite".into())); - } - let span = t.upper - t.lower; - if pi.resistance == 0.0 || pi.far == 0.0 || s0 <= 0.0 { - return Ok(GateResponse { - delay: d0, - slew: s0.max(0.0), - capacitance: total, - resistance: 0.0, - duration: s0.max(0.0) * t.derate / span, - root_crossing: s0.max(0.0) * t.derate / span * t.output, - }); - } - if !res.is_finite() || res <= 0.0 { - return Err(ComputeError::Invalid( - "NLDM has no positive delay sensitivity for RC driver".into(), - )); - } - let step = total * SLOPE_STEP; - let (d1, s1) = eval(total + step); - let (dd, ds) = ((d1 - d0) / step, (s1 - s0) / step); - let duration = s0 * t.derate / span; - let lead = t.derate * (t.output - t.lower) / span; - let theta = res * total; - // Start: source ramp of the table transition at the total load, its trip time at the delay. - let p1 = ramp_partials(t.output * duration, duration, theta); - let p2 = ramp_partials(t.output * duration - lead * s0, duration, theta); - let poles = Poles::new(pi, res); - let (ga, gpa) = settled(duration, poles.a); - let (gb, gpb) = settled(duration, poles.b); - let w = poles.root_weight; - let (root, root_p) = (w * ga + (1.0 - w) * gb, w * gpa + (1.0 - w) * gpb); - let (far, far_p) = if poles.a == poles.b { - (root, root_p) - } else { - ( - (poles.a * ga - poles.b * gb) / (poles.a - poles.b), - (poles.a * gpa - poles.b * gpb) / (poles.a - poles.b), - ) - }; - let charge = pi.near * root + pi.far * far; - let charge_p = pi.near * root_p + pi.far * far_p; - let e = ramp_partials(duration, duration, theta); - let residual = [ - p1[0] - t.output, - p2[0] - t.lower, - (total * e[0] - charge) / total, - ]; - let jacobian = [ - [-p1[1], p1[2], p1[1] * dd + p1[3] * res], - [-p2[1], p2[2], p2[1] * (dd - lead * ds) + p2[3] * res], - [ - 0.0, - (total * (e[1] + e[2]) - charge_p) / total, - (e[0] + total * res * e[3]) / total, - ], - ]; - let correction = solve3(jacobian, residual.map(|f| -f)).ok_or_else(|| { - ComputeError::Invalid("RC effective-capacitance correction is singular".into()) - })?; - let capacitance = (total + correction[2]).clamp(pi.near, total); - let (delay, s) = eval(capacitance); - if !delay.is_finite() || !s.is_finite() || s <= 0.0 { - return Err(ComputeError::Invalid( - "invalid NLDM transition for RC fitting".into(), - )); - } - let duration = fit_duration(s, capacitance, res, t)?; - let slew = (poles.crossing(duration, t.upper) - poles.crossing(duration, t.lower)) / t.derate; - let root_crossing = poles.crossing(duration, t.output); - if [slew, duration, root_crossing] - .iter() - .any(|v| !v.is_finite()) - { - return Err(ComputeError::Invalid("RC waveform overflow".into())); - } - Ok(GateResponse { - delay, - slew, - capacitance, - resistance: res, - duration, - root_crossing, - }) -} - -pub fn gate( - pi: Pi, - t: Thresholds, - eval: impl FnMut(f64) -> (f64, f64), -) -> Result<(f64, f64, f64), ComputeError> { - let r = gate_detail(pi, t, eval)?; - Ok((r.delay, r.slew, r.capacitance)) -} - -/// Exact normalized response of a one-pole network to a unit ramp of duration t. -pub fn ramp_voltage(time: f64, duration: f64, tau: f64) -> f64 { - if time <= 0.0 { - return 0.0; - } - if tau == 0.0 { - return if duration == 0.0 { - 1.0 - } else { - (time / duration).min(1.0) - }; - } - if duration == 0.0 { - return -(-time / tau).exp_m1(); - } - if time <= duration { - time / duration * charged_fraction(time, tau) - } else { - 1.0 + tau / duration * (-duration / tau).exp_m1() * (-(time - duration) / tau).exp() - } -} - -fn crossing_gradient(duration: f64, tau: f64, p: f64, time: f64) -> f64 { - if tau == 0.0 { - return p; - } - if time <= duration { - return p / (-(-time / tau).exp_m1()); - } - let x = duration / tau; - if x < 1e-3 { - 0.5 + x * (1.0 / 12.0 - x * x / 720.0) - } else { - 1.0 - 1.0 / x + 1.0 / x.exp_m1() - } -} - -fn crossing(duration: f64, tau: f64, p: f64) -> f64 { - if tau == 0.0 { - return p * duration; - } - if duration == 0.0 { - return -tau * (1.0 - p).ln(); - } - if p >= charged_fraction(duration, tau) { - return duration - tau * ((1.0 - p) * duration / (-tau * (-duration / tau).exp_m1())).ln(); - } - if p * duration / tau > 40.0 { - return p * duration + tau; - } - let mut lo = 0.0; - let mut hi = duration; - let mut x = (p * duration + tau).min(hi); - for _ in 0..48 { - let v = ramp_voltage(x, duration, tau); - if (v - p).abs() <= 4.0 * f64::EPSILON { - return x; - } - if v < p { - lo = x; - } else { - hi = x; - } - let slope = -(-x / tau).exp_m1() / duration; - let next = x - (v - p) / slope; - x = if next > lo && next < hi { - next - } else { - lo + (hi - lo) * 0.5 - }; - } - lo + (hi - lo) * 0.5 -} - -/// Normalized response at `x` of two cascaded poles `p` and `q` to a unit ramp of duration `d`. -fn cascade(x: f64, d: f64, p: f64, q: f64) -> f64 { - if (p - q).abs() > 1e-6 * p.max(q) { - return (p * ramp_voltage(x, d, p) - q * ramp_voltage(x, d, q)) / (p - q); - } - let r = 0.5 * (p + q); - if r == 0.0 { - return ramp_voltage(x, d, 0.0); - } - // Equal poles: the integral of the step response 1 - (1 + t/r) e^(-t/r). - let integral = |t: f64| { - if t <= 0.0 { - 0.0 - } else { - let e = (-t / r).exp(); - t - 2.0 * r * -(-t / r).exp_m1() + t * e - } - }; - if d == 0.0 { - if x <= 0.0 { - return 0.0; - } - return 1.0 - (1.0 + x / r) * (-x / r).exp(); - } - (integral(x) - integral(x - d)) / d -} - -/// The first time at which the monotone response `f` reaches `p` in (0, 1): a doubling bracket -/// from `start` (at most 64 doublings) and at most 100 bisections to a relative 1e-13. -fn monotone_crossing(f: impl Fn(f64) -> f64, p: f64, start: f64) -> Result { - let mut hi = start.max(1e-15); - let mut doublings = 0; - while f(hi) < p { - hi *= 2.0; - doublings += 1; - if doublings > 64 || !hi.is_finite() { - return Err(ComputeError::Invalid( - "RC load crossing not bracketed".into(), - )); - } - } - let mut lo = 0.0; - let tolerance = (hi * 1e-13).max(1e-16); - for _ in 0..100 { - if hi - lo <= tolerance { - break; - } - let mid = lo + (hi - lo) * 0.5; - if f(mid) < p { - lo = mid; - } else { - hi = mid; - } - } - Ok(lo + (hi - lo) * 0.5) -} - -/// Per-sink threshold delay and transition when a finite-ramp Thevenin driver of resistance -/// `res` (kOhm) and source ramp `duration` (ns) drives the pi `pi`: the sink voltage is the pi -/// root response through a single pole of the sink's first moment `tau`. The delay runs from the -/// root's output trip point to the sink's input trip point; with `tau = 0` the sink is the root. -pub fn wire_from_driver( - pi: Pi, - res: f64, - duration: f64, - tau: f64, - t: Thresholds, -) -> Result<(f64, f64), ComputeError> { - pi.validate()?; - t.validate()?; - if !res.is_finite() || res <= 0.0 || pi.resistance <= 0.0 || pi.far <= 0.0 { - return Err(ComputeError::Invalid( - "RC driver waveform needs a resistive pi".into(), - )); - } - if !tau.is_finite() || tau < 0.0 || !duration.is_finite() || duration < 0.0 { - return Err(ComputeError::Invalid("invalid RC wire state".into())); - } - let poles = Poles::new(pi, res); - let w = poles.root_weight; - let load = |x: f64| { - w * cascade(x, duration, poles.a, tau) + (1.0 - w) * cascade(x, duration, poles.b, tau) - }; - let start = duration + poles.a + poles.b + tau; - let root_out = poles.crossing(duration, t.output); - let sink_in = monotone_crossing(load, t.input, start)?; - let slew = (monotone_crossing(load, t.upper, start)? - - monotone_crossing(load, t.lower, start)?) - / t.derate; - let delay = sink_in - root_out; - if !delay.is_finite() || !slew.is_finite() { - return Err(ComputeError::Invalid("RC threshold solve overflow".into())); - } - Ok((delay, slew.max(0.0))) -} - -/// Per-sink threshold delay/transition. Primary ports use an exponential envelope; -/// characterized cell outputs use the finite-ramp convolution exactly. -pub fn wire(tau: f64, slew: f64, t: Thresholds, primary: bool) -> Result<(f64, f64), ComputeError> { - t.validate()?; - if !tau.is_finite() || tau < 0.0 || !slew.is_finite() || slew < 0.0 { - return Err(ComputeError::Invalid("invalid RC wire state".into())); - } - let (delay, transition) = if primary { - ( - -tau * (1.0 - t.input).ln(), - slew + tau * ((1.0 - t.lower) / (1.0 - t.upper)).ln() / t.derate, - ) - } else { - let duration = slew * t.derate / (t.upper - t.lower); - ( - crossing(duration, tau, t.input) - t.output * duration, - (crossing(duration, tau, t.upper) - crossing(duration, tau, t.lower)) / t.derate, - ) - }; - if !delay.is_finite() || !transition.is_finite() { - return Err(ComputeError::Invalid("RC threshold solve overflow".into())); - } - Ok((delay, transition.max(0.0))) -} - -#[cfg(test)] -mod tests { - use super::*; - fn trips() -> Thresholds { - Thresholds { - lower: 0.2, - upper: 0.8, - input: 0.5, - output: 0.5, - derate: 1.0, - } - } - // STA-REQ-0721: fixed resource bounds, zero resistance and invalid models. - #[test] - fn limits_and_degenerate_models() { - let t = trips(); - let eval = |c: f64| (0.1 + c, 0.3 + c); - let r = gate_detail( - Pi { - near: 0.01, - far: 0.02, - resistance: 0.0, - }, - t, - eval, - ) - .unwrap(); - assert_eq!(r.capacitance, 0.03); - assert!( - gate_detail( - Pi { - near: -1.0, - ..Pi::default() - }, - t, - eval - ) - .is_err() - ); - assert!( - gate_detail( - Pi { - near: 0.01, - far: 0.02, - resistance: 1.0 - }, - t, - |_| (f64::NAN, 0.1) - ) - .is_err() - ); - for tau in [0.0, 1e-20, 0.01, 1e6] { - let (d, s) = wire(tau, 0.1, t, false).unwrap(); - assert!(d.is_finite() && s.is_finite()); - } - assert!(wire(1.0, 0.1, Thresholds { derate: 0.0, ..t }, false).is_err()); - } - // STA-REQ-0726: the caller's resistance reproduces the default estimate bit for bit and - // keeps its validation; a nonpositive resistance is rejected. - #[test] - fn caller_resistance_matches_default_estimate() { - let t = trips(); - let eval = |c: f64| (0.05 + 2.0 * c, 0.3 + 3.0 * c); - let pi = Pi { - near: 0.01, - far: 0.02, - resistance: 1.0, - }; - let a = gate_detail(pi, t, eval).unwrap(); - let b = gate_detail_with_resistance(pi, t, a.resistance, eval).unwrap(); - assert_eq!(a.delay.to_bits(), b.delay.to_bits()); - assert_eq!(a.slew.to_bits(), b.slew.to_bits()); - assert_eq!(a.capacitance.to_bits(), b.capacitance.to_bits()); - assert!(gate_detail_with_resistance(pi, t, 0.0, eval).is_err()); - assert!(gate_detail_with_resistance(pi, t, f64::NAN, eval).is_err()); - let lumped = Pi { - resistance: 0.0, - ..pi - }; - let r = gate_detail_with_resistance(lumped, t, 0.0, eval).unwrap(); - assert_eq!(r.capacitance, 0.03); - } - // STA-REQ-0728: the one-step correction is the Newton step of the three residuals (trip - // points of the single-capacitance response, charge at the end of the source ramp), checked - // against central differences of the residuals; it lies between the converged charge - // solution and the total load, and a degenerate pi stays lumped. - #[test] - fn one_step_is_the_newton_correction_of_the_driver_residuals() { - let t = Thresholds { - lower: 0.3, - upper: 0.7, - input: 0.5, - output: 0.5, - derate: 0.5, - }; - let eval = |c: f64| (0.06 + 6.0 * c, 0.09 + 11.0 * c); - let pi = Pi { - near: 0.004, - far: 0.03, - resistance: 0.8, - }; - let res = 6.0 * std::f64::consts::LN_2; - let total = pi.total(); - let span = t.upper - t.lower; - let lead = t.derate * (t.output - t.lower) / span; - let poles = Poles::new(pi, res); - let residual = |x: [f64; 3]| { - let (d, s) = eval(x[2]); - let tau = res * x[2]; - [ - ramp_voltage(d - x[0], x[1], tau) - t.output, - ramp_voltage(d - lead * s - x[0], x[1], tau) - t.lower, - (x[2] * ramp_voltage(x[1], x[1], tau) - - pi.near * poles.root(x[1], x[1]) - - pi.far * poles.far(x[1], x[1])) - / total, - ] - }; - let (d0, s0) = eval(total); - let x0 = [ - d0 - t.output * s0 * t.derate / span, - s0 * t.derate / span, - total, - ]; - let mut jac = [[0.0; 3]; 3]; - for j in 0..3 { - let h = x0[j] * 1e-6; - let (mut a, mut b) = (x0, x0); - a[j] += h; - b[j] -= h; - let (fa, fb) = (residual(a), residual(b)); - for i in 0..3 { - jac[i][j] = (fa[i] - fb[i]) / (2.0 * h); - } - } - let step = solve3(jac, residual(x0).map(|f| -f)).unwrap(); - let r = gate_one_step(pi, t, res, eval).unwrap(); - assert!( - (r.capacitance - (total + step[2])).abs() < 1e-9 * total, - "{} {}", - r.capacitance, - total + step[2] - ); - let converged = gate_detail_with_resistance(pi, t, res, eval).unwrap(); - assert!(converged.capacitance < r.capacitance && r.capacitance < total); - assert_eq!(r.delay, eval(r.capacitance).0); - let lumped = Pi { - resistance: 0.0, - ..pi - }; - assert_eq!( - gate_one_step(lumped, t, res, eval).unwrap().capacitance, - total - ); - assert!(gate_one_step(pi, t, 0.0, eval).is_err()); - assert!(gate_one_step(pi, t, res, |_| (f64::NAN, 0.1)).is_err()); - } - // STA-REQ-0729: the sink response is the pi root response convolved with a single pole of - // the first moment, checked against a direct numerical convolution; with tau = 0 the sink is - // the root. - #[test] - fn wire_follows_the_driver_waveform() { - let t = trips(); - let pi = Pi { - near: 0.003, - far: 0.02, - resistance: 1.2, - }; - let res = 4.0; - let poles = Poles::new(pi, res); - let duration = 0.21; - let slew = - (poles.crossing(duration, t.upper) - poles.crossing(duration, t.lower)) / t.derate; - let (d, s) = wire_from_driver(pi, res, duration, 0.0, t).unwrap(); - assert!(d.abs() < 1e-12 && (s - slew).abs() < 1e-12, "{d} {s}"); - let tau = 0.035; - let (d, s) = wire_from_driver(pi, res, duration, tau, t).unwrap(); - // Trapezoidal convolution of the root waveform with exp(-u/tau)/tau on a fine grid. - let h = 5e-5; - let n = 20_000; - let root: Vec = (0..n).map(|k| poles.root(k as f64 * h, duration)).collect(); - let kernel: Vec = (0..n) - .map(|k| (-(k as f64) * h / tau).exp() / tau) - .collect(); - let sink = |m: usize| { - let mut acc = 0.0; - for k in 0..=m { - let w = if k == 0 || k == m { 0.5 } else { 1.0 }; - acc += w * root[m - k] * kernel[k]; - } - acc * h - }; - let cross = |p: f64| { - let mut prev = 0.0; - for m in 1..n { - let v = sink(m); - if v >= p { - return (m as f64 - 1.0 + (p - prev) / (v - prev)) * h; - } - prev = v; - } - f64::NAN - }; - let delay = cross(t.input) - poles.crossing(duration, t.output); - let transition = (cross(t.upper) - cross(t.lower)) / t.derate; - assert!((delay - d).abs() < 2e-6, "{delay} {d}"); - assert!((transition - s).abs() < 2e-6, "{transition} {s}"); - assert!(d > 0.0 && s > slew); - assert!( - wire_from_driver( - Pi { - resistance: 0.0, - ..pi - }, - res, - duration, - tau, - t - ) - .is_err() - ); - assert!(wire_from_driver(pi, res, -1.0, tau, t).is_err()); - } - // STA-REQ-0722: normalized polarity/derate, unchanged zero RC, stable small arguments. - #[test] - fn stable_charge_and_zero_rc() { - assert!((charged_fraction(1e-10, 1.0) - 5e-11).abs() < 1e-20); - let t = trips(); - let (d, s) = wire(0.0, 0.2, t, false).unwrap(); - assert_eq!(d, 0.0); - assert!((s - 0.2).abs() < 1e-15); - let (d, s) = wire(0.01, 0.1, t, true).unwrap(); - assert!((d - 0.01 * 2.0_f64.ln()).abs() < 1e-16); - assert!(s > 0.1); - } -} diff --git a/crates/core/rusteda-compute/src/timing/abi.rs b/crates/core/rusteda-compute/src/timing/abi.rs deleted file mode 100644 index 800e321..0000000 --- a/crates/core/rusteda-compute/src/timing/abi.rs +++ /dev/null @@ -1,367 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The interface of the timing task (COMPUTE-REQ-0011 to COMPUTE-REQ-0015): plain records in -//! host byte order and `Cow` arrays, so that an engine can lower into owned arrays or borrow -//! tables mapped from disk. - -use std::borrow::Cow; - -use bytemuck::{Pod, Zeroable}; - -/// No table, no tag map (`u32::MAX`). -pub const NONE: u32 = u32::MAX; - -/// [`ArcClass::flags`] bit of a wire arc (COMPUTE-REQ-0013). -pub const WIRE: u32 = 1; - -/// The [`ArcClass::flags`] bit that lets input transition `ti` produce output transition `to` -/// (0 rise, 1 fall; COMPUTE-REQ-0013). -pub const fn transition_bit(ti: usize, to: usize) -> u32 { - 1 << (1 + 2 * ti + to) -} - -/// Rise to rise and fall to fall. -pub const POSITIVE_UNATE: u32 = transition_bit(0, 0) | transition_bit(1, 1); -/// Rise to fall and fall to rise. -pub const NEGATIVE_UNATE: u32 = transition_bit(0, 1) | transition_bit(1, 0); -/// Every input transition to every output transition. -pub const NON_UNATE: u32 = POSITIVE_UNATE | NEGATIVE_UNATE; -/// The defined bits of [`ArcClass::flags`]. -pub const FLAG_MASK: u32 = WIRE | NON_UNATE; - -/// One incoming arc of a node, in CSR order (12 bytes; COMPUTE-REQ-0011). -#[repr(C)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Pod, Zeroable)] -pub struct InArc { - /// The source node, on a lower level than the arc's destination. - pub src: u32, - /// Index into [`CompiledLutBank::classes`]. - pub class: u32, - /// Index of the arc's tag map ([`LevelizedTimingCsr::map_ptr`]), or [`NONE`] for the identity - /// map. - pub map: u32, -} - -/// One record of a tag map (80 bytes; COMPUTE-REQ-0012): arrivals of `src_tag` at the arc's -/// source enter `dst_tag` at its destination through a time shift and a window. The arrays are -/// indexed by analysis (0 early, 1 late). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable)] -pub struct TagMapRec { - pub src_tag: u32, - pub dst_tag: u32, - /// Added to the source arrival: the change of time origin from `src_tag` to `dst_tag`. - pub shift: f64, - /// A shifted arrival below `open` is dropped; `-inf`: no lower bound. - pub open: [f64; 2], - /// The closing edge: the shifted arrival is limited to `close` minus the setup time of the - /// arc's class; `+inf`: no closing edge. - pub close: [f64; 2], - /// The transition at the closing edge, the related-pin argument of the setup table. - pub close_slew: [f64; 2], - /// An upper limit independent of the setup time (for example the largest time a latch may - /// borrow); `+inf`: none. - pub limit: [f64; 2], -} - -impl TagMapRec { - /// A record without shift and window: `src_tag` continues as `dst_tag`. - pub fn through(src_tag: u32, dst_tag: u32) -> TagMapRec { - TagMapRec { - src_tag, - dst_tag, - shift: 0.0, - open: [f64::NEG_INFINITY; 2], - close: [f64::INFINITY; 2], - close_slew: [0.0; 2], - limit: [f64::INFINITY; 2], - } - } -} - -/// A lookup table with up to three axes (40 bytes; COMPUTE-REQ-0013, COMPUTE-REQ-0014). -#[repr(C)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Pod, Zeroable)] -pub struct LutDesc { - /// Points per axis; 0 marks an absent axis, and the axes in use come first. - pub n: [u32; 3], - /// Offset of each axis' points in [`CompiledLutBank::data`]. - pub off: [u32; 3], - /// The canonical argument (0, 1 or 2) that indexes each axis. - pub arg: [u32; 3], - /// Offset of the values: row-major, the last axis varies fastest. - pub voff: u32, -} - -impl LutDesc { - /// A table without axes: the single value at `voff`. - pub fn scalar(voff: u32) -> LutDesc { - LutDesc { - voff, - ..LutDesc::default() - } - } - - /// The number of axes in use (the leading axes with points). - pub fn axes(&self) -> usize { - self.n.iter().take_while(|&&n| n > 0).count() - } - - /// The number of values: the product of the point counts of the axes in use. - pub fn values(&self) -> usize { - self.n[..self.axes()].iter().map(|&n| n as usize).product() - } -} - -/// What an arc does, shared by all arcs of the same library timing arc (28 bytes; -/// COMPUTE-REQ-0013). -#[repr(C)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Pod, Zeroable)] -pub struct ArcClass { - /// Bit 0 [`WIRE`]; bit `1 + 2 ti + to` ([`transition_bit`]): input transition `ti` produces - /// output transition `to`. - pub flags: u32, - /// Delay table per output transition, or [`NONE`]. - pub delay: [u32; 2], - /// Output transition table per output transition, or [`NONE`]. - pub slew: [u32; 2], - /// Setup table of a tag-map window per input (data) transition, or [`NONE`]. - pub setup: [u32; 2], -} - -impl ArcClass { - /// The wire class: no tables, rise to rise and fall to fall. - pub const fn wire() -> ArcClass { - ArcClass { - flags: WIRE | POSITIVE_UNATE, - delay: [NONE; 2], - slew: [NONE; 2], - setup: [NONE; 2], - } - } - - /// A cell class with the given transition bits and delay and output transition tables per - /// output transition, without setup tables. - pub const fn cell(transitions: u32, delay: [u32; 2], slew: [u32; 2]) -> ArcClass { - ArcClass { - flags: transitions & NON_UNATE, - delay, - slew, - setup: [NONE; 2], - } - } - - /// True for a wire class. - pub fn is_wire(&self) -> bool { - self.flags & WIRE != 0 - } - - /// True when input transition `ti` produces output transition `to`. - pub fn allows(&self, ti: usize, to: usize) -> bool { - self.flags & transition_bit(ti, to) != 0 - } -} - -/// The wire record of an incoming arc (64 bytes; COMPUTE-REQ-0015), indexed `to * 2 + an`: the -/// hook of resistance-capacitance wire models, all zero for ideal wires. -#[repr(C)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Pod, Zeroable)] -pub struct WireRec { - /// Added to the arrival. - pub delay: [f64; 4], - /// Degradation term `k`: the transition `s` becomes `sqrt(s * s + k * k)`, or stays `s` when - /// `k` is 0. - pub slew: [f64; 4], -} - -impl WireRec { - /// True when every term is zero (an ideal wire). - pub fn is_zero(&self) -> bool { - self.delay.iter().chain(&self.slew).all(|&x| x == 0.0) - } -} - -/// Evaluated RC arc values, per (input transition, output transition, bound). -/// CPU waveform preparation is tag-independent; all backends consume identical data. -#[repr(C)] -#[derive(Debug, Clone, Copy, Default, PartialEq, Pod, Zeroable)] -pub struct ArcValues { - pub delay: [f64; 8], - pub slew: [f64; 8], -} - -/// The levelized timing graph (COMPUTE-REQ-0011) with its tag maps (COMPUTE-REQ-0012). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct LevelizedTimingCsr<'a> { - /// Level `l` holds the nodes `level_ptr[l] .. level_ptr[l + 1]`. - pub level_ptr: Cow<'a, [u32]>, - /// The incoming arcs of node `v` are `in_arcs[in_ptr[v] .. in_ptr[v + 1]]`. - pub in_ptr: Cow<'a, [u32]>, - pub in_arcs: Cow<'a, [InArc]>, - /// The distinct destinations of the arcs from node `u`, increasing: - /// `out_dst[out_ptr[u] .. out_ptr[u + 1]]`. - pub out_ptr: Cow<'a, [u32]>, - pub out_dst: Cow<'a, [u32]>, - /// Tag map `m` is `maps[map_ptr[m] .. map_ptr[m + 1]]`; empty or `[0]` without maps. - pub map_ptr: Cow<'a, [u32]>, - pub maps: Cow<'a, [TagMapRec]>, -} - -impl LevelizedTimingCsr<'_> { - /// The node count (0 for an empty `in_ptr`). - pub fn nodes(&self) -> usize { - self.in_ptr.len().saturating_sub(1) - } - - /// The level count. - pub fn levels(&self) -> usize { - self.level_ptr.len().saturating_sub(1) - } - - /// The number of incoming arcs. - pub fn arcs(&self) -> usize { - self.in_arcs.len() - } - - /// The number of tag maps. - pub fn map_count(&self) -> usize { - self.map_ptr.len().saturating_sub(1) - } - - /// The incoming arcs of node `v`. - pub fn incoming(&self, v: usize) -> &[InArc] { - &self.in_arcs[self.in_ptr[v] as usize..self.in_ptr[v + 1] as usize] - } - - /// The records of tag map `m`. - pub fn map(&self, m: u32) -> &[TagMapRec] { - let m = m as usize; - &self.maps[self.map_ptr[m] as usize..self.map_ptr[m + 1] as usize] - } - - /// The level of every node. - pub fn level_of(&self) -> Vec { - let mut out = vec![0u32; self.nodes()]; - for (l, w) in self.level_ptr.windows(2).enumerate() { - for x in &mut out[w[0] as usize..w[1] as usize] { - *x = l as u32; - } - } - out - } -} - -/// The outgoing adjacency `(out_ptr, out_dst)` of the incoming arcs of `n` nodes: for each source, -/// the distinct destinations of its arcs, increasing (COMPUTE-REQ-0011). -pub fn out_adjacency(n: usize, in_ptr: &[u32], in_arcs: &[InArc]) -> (Vec, Vec) { - let mut count = vec![0u32; n + 1]; - let mut last = vec![u32::MAX; n]; - for v in 0..n { - for a in &in_arcs[in_ptr[v] as usize..in_ptr[v + 1] as usize] { - let u = a.src as usize; - if last[u] != v as u32 { - last[u] = v as u32; - count[u + 1] += 1; - } - } - } - for u in 0..n { - count[u + 1] += count[u]; - } - let mut cursor = count.clone(); - let mut dst = vec![0u32; count[n] as usize]; - last.fill(u32::MAX); - for v in 0..n { - for a in &in_arcs[in_ptr[v] as usize..in_ptr[v + 1] as usize] { - let u = a.src as usize; - if last[u] != v as u32 { - last[u] = v as u32; - dst[cursor[u] as usize] = v as u32; - cursor[u] += 1; - } - } - } - (count, dst) -} - -/// The timing tables of one corner (COMPUTE-REQ-0013). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct CompiledLutBank<'a> { - pub luts: Cow<'a, [LutDesc]>, - /// Axis points and values. - pub data: Cow<'a, [f64]>, - pub classes: Cow<'a, [ArcClass]>, -} - -/// The start rows of a propagation (COMPUTE-REQ-0015): row `k` of `node[k]` holds the arrivals -/// `arr[(k * T + t) * 4 + tr * 2 + an]` and the transitions `slew[k * 4 + tr * 2 + an]`. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct StartRows { - /// The nodes with start values, strictly increasing. - pub node: Vec, - pub arr: Vec, - pub slew: Vec, -} - -impl StartRows { - /// The number of rows. - pub fn len(&self) -> usize { - self.node.len() - } - - /// True without rows. - pub fn is_empty(&self) -> bool { - self.node.is_empty() - } - - /// Appends the row of `node` (after every row of a smaller node): its `T * 4` arrivals and 4 - /// transitions. - pub fn push(&mut self, node: u32, arr: &[f64], slew: &[f64; 4]) { - self.node.push(node); - self.arr.extend_from_slice(arr); - self.slew.extend_from_slice(slew); - } - - /// The arrivals and transitions of row `k` for `ntags` tags. - pub fn row(&self, k: usize, ntags: u32) -> (&[f64], &[f64]) { - let w = ntags as usize * 4; - ( - &self.arr[k * w..(k + 1) * w], - &self.slew[k * 4..(k + 1) * 4], - ) - } - - /// The row index of `node`, if it has one. - pub fn find(&self, node: u32) -> Option { - self.node.binary_search(&node).ok() - } -} - -/// Everything one propagation reads (COMPUTE-REQ-0011 to COMPUTE-REQ-0015). -#[derive(Debug, Clone, Copy)] -pub struct TimingProblem<'a> { - pub graph: &'a LevelizedTimingCsr<'a>, - pub luts: &'a CompiledLutBank<'a>, - /// The tag count `T >= 1`. - pub ntags: u32, - /// `load[v][tr * 2 + an]`. - pub load: &'a [[f64; 4]], - pub starts: &'a StartRows, - /// Empty (ideal wires) or one record per incoming arc in CSR order. - pub wires: &'a [WireRec], - /// Empty, or one prepared RC delay/transition record per incoming arc. - pub arc_values: &'a [ArcValues], -} - -impl TimingProblem<'_> { - /// The node count. - pub fn nodes(&self) -> usize { - self.graph.nodes() - } - - /// The number of values per node row of arrivals and predecessors: `T * 4`. - pub fn row_width(&self) -> usize { - self.ntags as usize * 4 - } -} diff --git a/crates/core/rusteda-compute/src/timing/cpu.rs b/crates/core/rusteda-compute/src/timing/cpu.rs deleted file mode 100644 index d952389..0000000 --- a/crates/core/rusteda-compute/src/timing/cpu.rs +++ /dev/null @@ -1,688 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The CPU backend (COMPUTE-REQ-0027): `f64`, the nodes of a level on the threads of the calling -//! thread's rayon pool, each node reduced sequentially in CSR order (COMPUTE-REQ-0017), so that -//! the values do not depend on the number of threads (COMPUTE-REQ-0019). Sessions compute in the -//! host view and borrow the problem's arrays until a patch copies the patched array. - -use std::borrow::Cow; -use std::time::Instant; - -use rayon::prelude::*; -use rusteda_core::job::Ctx; - -use super::abi::{ - ArcClass, ArcValues, InArc, LevelizedTimingCsr, LutDesc, NONE, StartRows, TagMapRec, - TimingProblem, WireRec, -}; -use super::lut::lookup; -use super::state::{NO_PRED, TimingValues, absent, arr_index, canon, slew_index}; -use super::validate::{check_load, validate_starts}; -use super::{PropagationStats, Readback, Request, Scope, TimingPropagationBackend, TimingSession}; -use crate::{BackendKind, ComputeError, Precision}; - -/// Levels with fewer nodes run on the calling thread. -const PAR_MIN: usize = 64; - -/// The CPU backend. -#[derive(Debug)] -pub struct CpuBackend; - -impl TimingPropagationBackend for CpuBackend { - fn kind(&self) -> BackendKind { - BackendKind::Cpu - } - - fn precision(&self) -> Precision { - Precision::F64 - } - - fn open<'p>( - &self, - problem: &TimingProblem<'p>, - ) -> Result, ComputeError> { - problem.validate()?; - Ok(Box::new(CpuSession::new(problem))) - } -} - -/// True when `x` is better than `y` in analysis `an`: smaller early, larger late. -#[inline] -fn better(an: usize, x: f64, y: f64) -> bool { - if an == 0 { x < y } else { x > y } -} - -/// The degraded transition of a wire record term `k` (COMPUTE-REQ-0015). -#[inline] -fn degrade(s: f64, k: f64) -> f64 { - if k == 0.0 { s } else { (s * s + k * k).sqrt() } -} - -/// The start row of every node, or [`NONE`]. -fn start_index(n: usize, starts: &StartRows) -> Vec { - let mut out = vec![NONE; n]; - for (k, &v) in starts.node.iter().enumerate() { - out[v as usize] = k as u32; - } - out -} - -/// The inputs that one node evaluation reads. -struct Inputs<'a> { - in_ptr: &'a [u32], - in_arcs: &'a [InArc], - map_ptr: &'a [u32], - maps: &'a [TagMapRec], - classes: &'a [ArcClass], - luts: &'a [LutDesc], - data: &'a [f64], - load: &'a [[f64; 4]], - wires: &'a [WireRec], - arc_values: &'a [ArcValues], - start_row: &'a [u32], - starts: &'a StartRows, - ntags: usize, -} - -impl Inputs<'_> { - /// Computes the row of node `v` (COMPUTE-REQ-0017, COMPUTE-REQ-0018) into `arr` (`T * 4` - /// arrivals), `slew` (4 transitions) and `pred` (`T * 4` predecessors) from the values of the - /// lower levels, `low_arr` and `low_slew`. Returns the largest magnitude of a present value. - fn node( - &self, - v: usize, - low_arr: &[f64], - low_slew: &[f64], - arr: &mut [f64], - slew: &mut [f64], - pred: &mut [u32], - ) -> f64 { - let nt = self.ntags; - match self.start_row[v] { - NONE => { - for (i, x) in arr.iter_mut().enumerate() { - *x = absent(i % 2); - } - for (i, x) in slew.iter_mut().enumerate() { - *x = absent(i % 2); - } - } - r => { - let (a, s) = self.starts.row(r as usize, nt as u32); - arr.copy_from_slice(a); - slew.copy_from_slice(s); - } - } - pred.fill(NO_PRED); - for k in self.in_ptr[v] as usize..self.in_ptr[v + 1] as usize { - let a = self.in_arcs[k]; - let c = &self.classes[a.class as usize]; - let u = a.src as usize; - let recs: &[TagMapRec] = if a.map == NONE { - &[] - } else { - let m = a.map as usize; - &self.maps[self.map_ptr[m] as usize..self.map_ptr[m + 1] as usize] - }; - for ti in 0..2 { - for to in 0..2 { - if !c.allows(ti, to) { - continue; - } - for an in 0..2 { - let s = low_slew[slew_index(u, ti, an)]; - if s == absent(an) { - continue; - } - let l = self.load[v][to * 2 + an]; - let (d, q) = if let Some(rec) = self.arc_values.get(k) { - let j = (ti * 2 + to) * 2 + an; - (rec.delay[j], rec.slew[j]) - } else if c.is_wire() { - match self.wires.get(k) { - Some(w) => (w.delay[to * 2 + an], degrade(s, w.slew[to * 2 + an])), - None => (0.0, s), - } - } else { - let x = [s, l, l]; - let d = lookup(self.luts, self.data, c.delay[to], x); - let q = lookup(self.luts, self.data, c.slew[to], x); - (d, if q < 0.0 { 0.0 } else { q }) - }; - let si = to * 2 + an; - if better(an, q, slew[si]) { - slew[si] = q; - } - let p = u as u32 | ((ti as u32) << 31); - if a.map == NONE { - for t in 0..nt { - let x = low_arr[arr_index(u, t, ti, an, nt)]; - if x == absent(an) { - continue; - } - let cand = x + d; - let i = (t * 2 + to) * 2 + an; - if better(an, cand, arr[i]) { - arr[i] = cand; - pred[i] = p; - } - } - continue; - } - for r in recs { - let x = low_arr[arr_index(u, r.src_tag as usize, ti, an, nt)]; - if x == absent(an) { - continue; - } - let mut m = x + r.shift; - if m < r.open[an] { - continue; - } - let mut h = r.limit[an]; - if r.close[an] < f64::INFINITY { - let x = [r.close_slew[an], s, l]; - let e = r.close[an] - lookup(self.luts, self.data, c.setup[ti], x); - if e < h { - h = e; - } - } - if m > h { - m = h; - } - let cand = m + d; - let i = (r.dst_tag as usize * 2 + to) * 2 + an; - if better(an, cand, arr[i]) { - arr[i] = cand; - pred[i] = p; - } - } - } - } - } - } - let mut mx = 0.0f64; - for x in arr.iter_mut().chain(slew.iter_mut()) { - *x = canon(*x); - if x.is_finite() && x.abs() > mx { - mx = x.abs(); - } - } - mx - } -} - -/// The larger of two magnitudes. -fn max2(a: f64, b: f64) -> f64 { - if b > a { b } else { a } -} - -/// A session of the CPU backend. -struct CpuSession<'p> { - graph: &'p LevelizedTimingCsr<'p>, - luts: &'p crate::timing::CompiledLutBank<'p>, - ntags: u32, - in_arcs: Cow<'p, [InArc]>, - load: Cow<'p, [[f64; 4]]>, - starts: Cow<'p, StartRows>, - wires: &'p [WireRec], - arc_values: &'p [ArcValues], - start_row: Vec, - /// The level of every node, built for the first cone. - level_of: Vec, - values: TimingValues, - /// Whether `values` are complete for the inputs before the pending patches. - resident: bool, - /// Nodes patched since the last propagation. - patched: Vec, - changed: Option>, -} - -impl<'p> CpuSession<'p> { - fn new(p: &TimingProblem<'p>) -> CpuSession<'p> { - CpuSession { - graph: p.graph, - luts: p.luts, - ntags: p.ntags, - in_arcs: Cow::Borrowed(&p.graph.in_arcs), - load: Cow::Borrowed(p.load), - starts: Cow::Borrowed(p.starts), - wires: p.wires, - arc_values: p.arc_values, - start_row: start_index(p.graph.nodes(), p.starts), - level_of: Vec::new(), - values: TimingValues { - ntags: p.ntags, - ..TimingValues::default() - }, - resident: false, - patched: Vec::new(), - changed: None, - } - } - - fn nodes(&self) -> usize { - self.graph.nodes() - } - - fn inputs(&self) -> Inputs<'_> { - Inputs { - in_ptr: &self.graph.in_ptr, - in_arcs: &self.in_arcs, - map_ptr: &self.graph.map_ptr, - maps: &self.graph.maps, - classes: &self.luts.classes, - luts: &self.luts.luts, - data: &self.luts.data, - load: &self.load, - wires: self.wires, - arc_values: self.arc_values, - start_row: &self.start_row, - starts: &self.starts, - ntags: self.ntags as usize, - } - } - - /// Recomputes every node from level `first` on. - fn run_levels( - &mut self, - first: usize, - ctx: Ctx<'_>, - st: &mut PropagationStats, - ) -> Result<(), ComputeError> { - let (n, nt) = (self.nodes(), self.ntags); - if !self.values.fits(n, nt) { - let w = n * nt as usize * 4; - self.values = TimingValues { - ntags: nt, - arr: vec![0.0; w], - slew: vec![0.0; n * 4], - pred: vec![NO_PRED; w], - }; - } - let mut values = std::mem::take(&mut self.values); - let r = levels( - &self.inputs(), - &self.graph.level_ptr, - first, - &mut values, - ctx, - st, - ); - self.values = values; - r - } - - /// Recomputes the cone of `seeds` and the patched nodes. - fn run_cone( - &mut self, - seeds: &[u32], - ctx: Ctx<'_>, - st: &mut PropagationStats, - ) -> Result, ComputeError> { - if self.level_of.is_empty() { - self.level_of = self.graph.level_of(); - } - let mut values = std::mem::take(&mut self.values); - let start: Vec = seeds.iter().chain(&self.patched).copied().collect(); - let r = cone( - &self.inputs(), - self.graph, - &self.level_of, - &start, - &mut values, - ctx, - st, - ); - self.values = values; - r - } - - /// The lowest level of a patched node, if any. - fn lowest_patched_level(&mut self) -> Option { - if self.patched.is_empty() { - return None; - } - if self.level_of.is_empty() { - self.level_of = self.graph.level_of(); - } - self.patched - .iter() - .map(|&v| self.level_of[v as usize] as usize) - .min() - } - - fn check_rows(&self, rb: Readback<'_>) -> Result<(), ComputeError> { - if let Readback::Rows(rows) = rb { - let n = self.nodes(); - if let Some(&r) = rows.iter().find(|&&r| r as usize >= n) { - return Err(ComputeError::Invalid(format!("readback row {r} of {n}"))); - } - } - Ok(()) - } -} - -/// Propagates the levels from `first` on into `values` (COMPUTE-REQ-0017, COMPUTE-REQ-0026). -fn levels( - inp: &Inputs<'_>, - level_ptr: &[u32], - first: usize, - values: &mut TimingValues, - ctx: Ctx<'_>, - st: &mut PropagationStats, -) -> Result<(), ComputeError> { - let w = inp.ntags * 4; - let nlev = level_ptr.len() - 1; - let mut mx = 0.0f64; - for l in first..nlev { - ctx.checkpoint()?; - let (b, e) = (level_ptr[l] as usize, level_ptr[l + 1] as usize); - let count = e - b; - let (low_arr, high_arr) = values.arr.split_at_mut(b * w); - let (low_slew, high_slew) = values.slew.split_at_mut(b * 4); - let high_pred = &mut values.pred[b * w..]; - let (low_arr, low_slew) = (&*low_arr, &*low_slew); - let a = &mut high_arr[..count * w]; - let s = &mut high_slew[..count * 4]; - let p = &mut high_pred[..count * w]; - let m = if count >= PAR_MIN { - a.par_chunks_mut(w) - .zip(s.par_chunks_mut(4)) - .zip(p.par_chunks_mut(w)) - .enumerate() - .with_min_len(32) - .map(|(i, ((a, s), p))| inp.node(b + i, low_arr, low_slew, a, s, p)) - .reduce(|| 0.0, max2) - } else { - a.chunks_mut(w) - .zip(s.chunks_mut(4)) - .zip(p.chunks_mut(w)) - .enumerate() - .map(|(i, ((a, s), p))| inp.node(b + i, low_arr, low_slew, a, s, p)) - .fold(0.0, max2) - }; - mx = max2(mx, m); - st.levels += 1; - st.nodes_evaluated += count as u64; - ctx.progress("propagate", (l + 1 - first) as u64, (nlev - first) as u64); - } - st.nodes_changed = st.nodes_evaluated; - st.max_abs_ns = mx; - Ok(()) -} - -/// Propagates the forward cone of `start` with bitwise change detection (COMPUTE-REQ-0021); -/// returns the nodes whose values changed, increasing. -fn cone( - inp: &Inputs<'_>, - g: &LevelizedTimingCsr, - level_of: &[u32], - start: &[u32], - values: &mut TimingValues, - ctx: Ctx<'_>, - st: &mut PropagationStats, -) -> Result, ComputeError> { - let n = g.nodes(); - let w = inp.ntags * 4; - let nlev = g.levels(); - let mut dirty = vec![false; n]; - let mut bucket: Vec> = vec![Vec::new(); nlev]; - for &v in start { - if !dirty[v as usize] { - dirty[v as usize] = true; - bucket[level_of[v as usize] as usize].push(v); - } - } - let first = bucket.iter().position(|b| !b.is_empty()).unwrap_or(nlev); - let mut changed = Vec::new(); - let (mut ta, mut ts, mut tp) = (Vec::new(), Vec::new(), Vec::new()); - let mut mx = 0.0f64; - for l in first..nlev { - if bucket[l].is_empty() { - continue; - } - ctx.checkpoint()?; - let mut list = std::mem::take(&mut bucket[l]); - list.sort_unstable(); - let b = g.level_ptr[l] as usize; - ta.resize(list.len() * w, 0.0); - ts.resize(list.len() * 4, 0.0); - tp.resize(list.len() * w, NO_PRED); - let (low_arr, low_slew) = (&values.arr[..b * w], &values.slew[..b * 4]); - let rows = ta - .par_chunks_mut(w) - .zip(ts.par_chunks_mut(4)) - .zip(tp.par_chunks_mut(w)) - .zip(list.par_iter()) - .with_min_len(32); - let m = rows - .map(|(((a, s), p), &v)| inp.node(v as usize, low_arr, low_slew, a, s, p)) - .reduce(|| 0.0, max2); - mx = max2(mx, m); - for (i, &v) in list.iter().enumerate() { - let v = v as usize; - let (na, ns, np) = ( - &ta[i * w..(i + 1) * w], - &ts[i * 4..(i + 1) * 4], - &tp[i * w..(i + 1) * w], - ); - let (oa, os, op) = ( - &mut values.arr[v * w..(v + 1) * w], - &mut values.slew[v * 4..(v + 1) * 4], - &mut values.pred[v * w..(v + 1) * w], - ); - let same = na - .iter() - .zip(oa.iter()) - .all(|(x, y)| x.to_bits() == y.to_bits()) - && ns - .iter() - .zip(os.iter()) - .all(|(x, y)| x.to_bits() == y.to_bits()) - && np == &op[..]; - if same { - continue; - } - oa.copy_from_slice(na); - os.copy_from_slice(ns); - op.copy_from_slice(np); - changed.push(v as u32); - for &d in &g.out_dst[g.out_ptr[v] as usize..g.out_ptr[v + 1] as usize] { - if !dirty[d as usize] { - dirty[d as usize] = true; - bucket[level_of[d as usize] as usize].push(d); - } - } - } - st.levels += 1; - st.nodes_evaluated += list.len() as u64; - ctx.progress("propagate", (l + 1 - first) as u64, (nlev - first) as u64); - } - st.nodes_changed = changed.len() as u64; - st.max_abs_ns = mx; - Ok(changed) -} - -impl TimingSession for CpuSession<'_> { - fn kind(&self) -> BackendKind { - BackendKind::Cpu - } - - fn precision(&self) -> Precision { - Precision::F64 - } - - fn propagate(&mut self, req: &Request<'_>) -> Result { - let t0 = Instant::now(); - let device = format!("{} threads", rayon::current_num_threads()); - let mut st = PropagationStats::new(BackendKind::Cpu, device, Precision::F64); - self.check_rows(req.readback)?; - let needs_resident = !matches!(req.scope, Scope::All); - if needs_resident && !self.resident { - return Err(ComputeError::Invalid( - "no resident values: propagate every node first".into(), - )); - } - let n = self.nodes(); - let r = match req.scope { - Scope::All => self.run_levels(0, req.ctx, &mut st).map(|()| None), - Scope::FromLevel(l) => { - let first = (l as usize).min(self.graph.levels()); - let first = self.lowest_patched_level().map_or(first, |p| p.min(first)); - self.run_levels(first, req.ctx, &mut st).map(|()| None) - } - Scope::Cone(seeds) => { - if let Some(&s) = seeds.iter().find(|&&s| s as usize >= n) { - return Err(ComputeError::Invalid(format!("cone seed {s} of {n}"))); - } - self.run_cone(seeds, req.ctx, &mut st).map(Some) - } - }; - match r { - Ok(changed) => { - self.changed = changed; - self.patched.clear(); - self.resident = true; - } - Err(e) => { - self.changed = None; - self.resident = false; - return Err(e); - } - } - st.kernel_s = t0.elapsed().as_secs_f64(); - st.total_s = st.kernel_s; - Ok(st) - } - - fn read(&mut self, readback: Readback<'_>) -> Result<(), ComputeError> { - if !self.resident { - return Err(ComputeError::Invalid("no values to read".into())); - } - self.check_rows(readback) - } - - fn values(&self) -> &TimingValues { - &self.values - } - - fn changed(&self) -> Option<&[u32]> { - self.changed.as_deref() - } - - fn set_loads(&mut self, edits: &[(u32, [f64; 4])]) -> Result<(), ComputeError> { - if !self.arc_values.is_empty() { - return Err(ComputeError::Invalid( - "re-prepare RC arc values before patching inputs".into(), - )); - } - let n = self.nodes(); - for &(v, l) in edits { - if v as usize >= n { - return Err(ComputeError::Invalid(format!("load of node {v} of {n}"))); - } - check_load(v as usize, &l)?; - } - let load = self.load.to_mut(); - for &(v, l) in edits { - load[v as usize] = l; - self.patched.push(v); - } - Ok(()) - } - - fn set_arc_classes(&mut self, edits: &[(u32, u32)]) -> Result<(), ComputeError> { - if !self.arc_values.is_empty() { - return Err(ComputeError::Invalid( - "re-prepare RC arc values before patching inputs".into(), - )); - } - let (na, nc) = (self.in_arcs.len(), self.luts.classes.len()); - for &(k, c) in edits { - if k as usize >= na || c as usize >= nc { - return Err(ComputeError::Invalid(format!( - "class {c} of {nc} for arc {k} of {na}" - ))); - } - if let Some(w) = self.wires.get(k as usize) - && !self.luts.classes[c as usize].is_wire() - && !w.is_zero() - { - return Err(ComputeError::Invalid(format!( - "arc {k} has a wire record but class {c} is no wire class" - ))); - } - } - let in_ptr = &self.graph.in_ptr; - let arcs = self.in_arcs.to_mut(); - for &(k, c) in edits { - arcs[k as usize].class = c; - let v = in_ptr.partition_point(|&p| p <= k) - 1; - self.patched.push(v as u32); - } - Ok(()) - } - - fn set_starts(&mut self, starts: &StartRows) -> Result<(), ComputeError> { - if !self.arc_values.is_empty() { - return Err(ComputeError::Invalid( - "re-prepare RC arc values before patching inputs".into(), - )); - } - let n = self.nodes(); - validate_starts(starts, n, self.ntags)?; - diff_starts(&self.starts, starts, self.ntags, &mut self.patched); - self.start_row = start_index(n, starts); - self.starts = Cow::Owned(starts.clone()); - Ok(()) - } - - fn write_values(&mut self, values: &TimingValues) -> Result<(), ComputeError> { - let (n, nt) = (self.nodes(), self.ntags); - if !values.fits(n, nt) { - return Err(ComputeError::Invalid(format!( - "values do not fit {n} nodes and {nt} tags" - ))); - } - if values.arr.iter().chain(&values.slew).any(|x| x.is_nan()) { - return Err(ComputeError::Invalid("values hold NaN".into())); - } - self.values = TimingValues { - ntags: nt, - arr: values.arr.iter().map(|&x| canon(x)).collect(), - slew: values.slew.iter().map(|&x| canon(x)).collect(), - pred: values.pred.clone(), - }; - self.resident = true; - self.changed = None; - Ok(()) - } - - fn into_values(self: Box) -> TimingValues { - self.values - } -} - -/// Appends to `out` the nodes whose start row differs in any bit between `old` and `new` -/// (including rows only one of them has). -pub(super) fn diff_starts(old: &StartRows, new: &StartRows, ntags: u32, out: &mut Vec) { - let (mut i, mut j) = (0, 0); - let same = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(x, y)| x.to_bits() == y.to_bits()); - while i < old.len() || j < new.len() { - if j == new.len() || (i < old.len() && old.node[i] < new.node[j]) { - out.push(old.node[i]); - i += 1; - } else if i == old.len() || new.node[j] < old.node[i] { - out.push(new.node[j]); - j += 1; - } else { - let ((oa, os), (na, ns)) = (old.row(i, ntags), new.row(j, ntags)); - if !same(oa, na) || !same(os, ns) { - out.push(old.node[i]); - } - i += 1; - j += 1; - } - } -} diff --git a/crates/core/rusteda-compute/src/timing/cuda.rs b/crates/core/rusteda-compute/src/timing/cuda.rs deleted file mode 100644 index c782e10..0000000 --- a/crates/core/rusteda-compute/src/timing/cuda.rs +++ /dev/null @@ -1,922 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The CUDA backend of the timing task (feature `cuda`, COMPUTE-REQ-0028): `f64` (compiled with -//! `-DREAL_F64` and `--fmad=false`, bit-identical to the CPU backend) and `f32` (default NVRTC -//! options), one stream per session, inputs and values resident on the device across levels and -//! across propagations, and full readback through page-locked host memory that is not -//! write-combined. - -// Backend glue: kernel launches and device-to-pinned copies through the CUDA driver API (AGENTS -// rules 8.4); every `unsafe` block carries its justification. -#![allow(unsafe_code)] - -use std::sync::Arc; -use std::time::Instant; - -use rayon::prelude::*; -use rusteda_core::job::Ctx; - -use super::abi::{InArc, NONE, StartRows, TagMapRec, TimingProblem}; -use super::cpu::diff_starts; -use super::state::{NO_PRED, TimingValues, canon}; -use super::validate::{check_load, validate_starts}; -use super::{PropagationStats, Readback, Request, Scope, TimingPropagationBackend, TimingSession}; -use crate::cuda::{ - CudaFunction, CudaSlice, CudaStream, Device, DeviceRepr, PinnedHostSlice, PushKernelArg, - ValidAsZeroBits, device, launch_1d, -}; -use crate::{BackendKind, ComputeError, Precision}; - -const SRC: &str = include_str!("kernels/timing.cu"); -const BLOCK: u32 = 128; - -trait Real: DeviceRepr + ValidAsZeroBits + Copy + Send + Sync + Default + 'static { - const OPTIONS: &'static [&'static str]; - const PREC: Precision; - fn from_f64(x: f64) -> Self; - fn to_f64(self) -> f64; -} - -impl Real for f64 { - // No fused multiply-add contraction (COMPUTE-REQ-0019, COMPUTE-REQ-0028): every operation - // rounds once in IEEE 754 double precision, matching the CPU backend bit for bit. - const OPTIONS: &'static [&'static str] = &["--fmad=false", "-DREAL_F64"]; - const PREC: Precision = Precision::F64; - fn from_f64(x: f64) -> f64 { - x - } - fn to_f64(self) -> f64 { - self - } -} - -impl Real for f32 { - const OPTIONS: &'static [&'static str] = &[]; - const PREC: Precision = Precision::F32; - fn from_f64(x: f64) -> f32 { - x as f32 - } - fn to_f64(self) -> f64 { - self as f64 - } -} - -/// The CUDA backend for one precision. -#[derive(Debug)] -pub struct CudaBackend { - precision: Precision, -} - -pub(super) static CUDA_F64: CudaBackend = CudaBackend { - precision: Precision::F64, -}; -pub(super) static CUDA_F32: CudaBackend = CudaBackend { - precision: Precision::F32, -}; - -pub(super) fn backend(precision: Precision) -> &'static dyn TimingPropagationBackend { - match precision { - Precision::F64 => &CUDA_F64, - Precision::F32 => &CUDA_F32, - } -} - -/// Compiles the timing kernels for `precision` on the default device (COMPUTE-REQ-0029). -pub(super) fn compile(precision: Precision) -> Result<(), ComputeError> { - let dev = device().map_err(|e| ComputeError::Unavailable(e.0))?; - let opts = match precision { - Precision::F64 => f64::OPTIONS, - Precision::F32 => f32::OPTIONS, - }; - for name in ["propagate_level", "propagate_cone", "gather_rows"] { - dev.function_with(SRC, opts, name) - .map_err(|e| ComputeError::Compile(e.0))?; - } - Ok(()) -} - -impl TimingPropagationBackend for CudaBackend { - fn kind(&self) -> BackendKind { - BackendKind::Cuda - } - - fn precision(&self) -> Precision { - self.precision - } - - fn open<'p>( - &self, - problem: &TimingProblem<'p>, - ) -> Result, ComputeError> { - problem.validate()?; - let dev = device().map_err(|e| ComputeError::Unavailable(e.0))?; - match self.precision { - Precision::F64 => Ok(Box::new(CudaSession::::open(problem, dev)?)), - Precision::F32 => Ok(Box::new(CudaSession::::open(problem, dev)?)), - } - } -} - -fn conv(x: &[f64]) -> Vec { - if x.len() >= 4096 { - x.par_iter().map(|&v| R::from_f64(v)).collect() - } else { - x.iter().map(|&v| R::from_f64(v)).collect() - } -} - -fn start_index(n: usize, starts: &StartRows) -> Vec { - let mut out = vec![NONE; n]; - for (k, &v) in starts.node.iter().enumerate() { - out[v as usize] = k as u32; - } - out -} - -fn pack_maps(maps: &[TagMapRec]) -> (Vec, Vec) { - let mut tags = Vec::with_capacity(maps.len() * 2); - let mut vals = Vec::with_capacity(maps.len() * 9); - for r in maps { - tags.push(r.src_tag); - tags.push(r.dst_tag); - vals.push(R::from_f64(r.shift)); - vals.push(R::from_f64(r.open[0])); - vals.push(R::from_f64(r.open[1])); - vals.push(R::from_f64(r.close[0])); - vals.push(R::from_f64(r.close[1])); - vals.push(R::from_f64(r.close_slew[0])); - vals.push(R::from_f64(r.close_slew[1])); - vals.push(R::from_f64(r.limit[0])); - vals.push(R::from_f64(r.limit[1])); - } - (tags, vals) -} - -fn upload_slice( - stream: &Arc, - data: &[T], -) -> Result, ComputeError> { - if data.is_empty() { - stream - .alloc_zeros::(1) - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}"))) - } else { - stream - .clone_htod(data) - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}"))) - } -} - -fn alloc_zeros( - stream: &Arc, - len: usize, -) -> Result, ComputeError> { - stream - .alloc_zeros::(len.max(1)) - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}"))) -} - -fn read_slice( - stream: &Arc, - s: &CudaSlice, -) -> Result, ComputeError> { - let v = stream - .clone_dtoh(s) - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - stream - .synchronize() - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - Ok(v) -} - -/// Which rows the last propagation replaced, for `Readback::Changed` (COMPUTE-REQ-0022). -enum Replaced { - All, - FromNode(usize), - Cone, -} - -/// Page-locked host staging buffers for full readback (COMPUTE-REQ-0004, COMPUTE-REQ-0028). -struct PinnedStaging { - arr: PinnedHostSlice, - slew: PinnedHostSlice, - pred: PinnedHostSlice, -} - -/// Device buffers allocated on first `Cone` propagation. -struct ConeBuffers { - out_ptr: CudaSlice, - out_dst: CudaSlice, - dirty: CudaSlice, - changed_flags: CudaSlice, - stats: CudaSlice, - scratch_arr: CudaSlice, - scratch_pred: CudaSlice, - level_of: Vec, -} - -struct CudaSession<'p, R: Real> { - problem: TimingProblem<'p>, - dev: &'static Device, - stream: Arc, - fn_level: CudaFunction, - fn_cone: CudaFunction, - fn_gather: CudaFunction, - // Host mirrors of patchable inputs. - in_arcs_h: Vec, - load_h: Vec<[f64; 4]>, - starts_h: StartRows, - start_row_h: Vec, - // Resident device inputs and values. - d_in_ptr: CudaSlice, - d_in_arcs: CudaSlice, - d_map_ptr: CudaSlice, - d_map_tags: CudaSlice, - d_map_vals: CudaSlice, - d_classes: CudaSlice, - d_luts: CudaSlice, - d_data: CudaSlice, - d_load: CudaSlice, - d_wires: CudaSlice, - has_wires: u32, - d_arc_values: CudaSlice, - has_arc_values: u32, - d_start_row: CudaSlice, - d_start_arr: CudaSlice, - d_start_slew: CudaSlice, - d_arr: CudaSlice, - d_slew: CudaSlice, - d_pred: CudaSlice, - d_max_bits: CudaSlice, - cone_bufs: Option>, - pinned: Option>, - values: TimingValues, - resident: bool, - patched: Vec, - changed: Option>, - last_replaced: Replaced, - pending_upload_s: f64, -} - -/// Estimates the device memory in bytes that a session of `p` needs (COMPUTE-REQ-0028). -fn session_bytes(p: &TimingProblem<'_>) -> usize { - let r = size_of::(); - let n = p.nodes(); - let w = p.row_width(); - let g = p.graph; - let max_lvl = g - .level_ptr - .windows(2) - .map(|pair| (pair[1] - pair[0]) as usize) - .max() - .unwrap_or(0); - let fixed = (g.in_ptr.len() + g.map_ptr.len() + g.out_ptr.len() + g.out_dst.len()) * 4 - + g.in_arcs.len() * 12 - + g.maps.len() * (8 + 9 * r) - + p.luts.classes.len() * 28 - + p.luts.luts.len() * 40 - + p.luts.data.len() * r - + p.wires.len() * 8 * r - + p.arc_values.len() * 16 * r - + p.starts.arr.len() * r - + p.starts.slew.len() * r; - let per_node = n * (4 + 4 * r + w * r + 4 * r + w * 4 + 2); - let cone_scratch = max_lvl * w * (r + 4); - fixed + per_node + cone_scratch + 4096 -} - -impl<'p, R: Real> CudaSession<'p, R> { - fn open(p: &TimingProblem<'p>, dev: &'static Device) -> Result { - // Compile kernels before measuring upload or checking free device memory. - let fn_level = dev - .function_with(SRC, R::OPTIONS, "propagate_level") - .map_err(|e| ComputeError::Compile(e.0))?; - let fn_cone = dev - .function_with(SRC, R::OPTIONS, "propagate_cone") - .map_err(|e| ComputeError::Compile(e.0))?; - let fn_gather = dev - .function_with(SRC, R::OPTIONS, "gather_rows") - .map_err(|e| ComputeError::Compile(e.0))?; - let needed = session_bytes::(p); - let (free, _) = dev.mem_info()?; - if needed > free { - return Err(ComputeError::TooLarge(format!( - "session needs {needed} bytes, {free} bytes free on device" - ))); - } - let t0 = Instant::now(); - let stream = dev.new_stream()?; - let g = p.graph; - let n = p.nodes(); - let w = p.row_width(); - let in_arcs_h = g.in_arcs.to_vec(); - let load_h = p.load.to_vec(); - let starts_h = p.starts.clone(); - let start_row_h = start_index(n, &starts_h); - let (map_tags, map_vals) = pack_maps::(&g.maps); - let load_flat: &[f64] = bytemuck::cast_slice(&load_h); - let wires_flat: &[f64] = bytemuck::cast_slice(p.wires); - let d_in_ptr = upload_slice(&stream, &g.in_ptr)?; - let d_in_arcs = upload_slice(&stream, bytemuck::cast_slice(&in_arcs_h))?; - let d_map_ptr = upload_slice(&stream, &g.map_ptr)?; - let d_map_tags = upload_slice(&stream, &map_tags)?; - let d_map_vals = upload_slice(&stream, &map_vals)?; - let d_classes = upload_slice(&stream, bytemuck::cast_slice(&p.luts.classes))?; - let d_luts = upload_slice(&stream, bytemuck::cast_slice(&p.luts.luts))?; - let d_data = upload_slice(&stream, &conv::(&p.luts.data))?; - let d_load = upload_slice(&stream, &conv::(load_flat))?; - let d_wires = upload_slice(&stream, &conv::(wires_flat))?; - let has_wires = u32::from(!p.wires.is_empty()); - let arc_values_flat: &[f64] = bytemuck::cast_slice(p.arc_values); - let d_arc_values = upload_slice(&stream, &conv::(arc_values_flat))?; - let has_arc_values = u32::from(!p.arc_values.is_empty()); - let d_start_row = upload_slice(&stream, &start_row_h)?; - let d_start_arr = upload_slice(&stream, &conv::(&starts_h.arr))?; - let d_start_slew = upload_slice(&stream, &conv::(&starts_h.slew))?; - let d_arr = alloc_zeros::(&stream, n * w)?; - let d_slew = alloc_zeros::(&stream, n * 4)?; - let d_pred = alloc_zeros::(&stream, n * w)?; - let d_max_bits = alloc_zeros::(&stream, 1)?; - stream - .synchronize() - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - let pending_upload_s = t0.elapsed().as_secs_f64(); - Ok(CudaSession { - problem: *p, - dev, - stream, - fn_level, - fn_cone, - fn_gather, - in_arcs_h, - load_h, - starts_h, - start_row_h, - d_in_ptr, - d_in_arcs, - d_map_ptr, - d_map_tags, - d_map_vals, - d_classes, - d_luts, - d_data, - d_load, - d_wires, - has_wires, - d_arc_values, - has_arc_values, - d_start_row, - d_start_arr, - d_start_slew, - d_arr, - d_slew, - d_pred, - d_max_bits, - cone_bufs: None, - pinned: None, - values: TimingValues { - ntags: p.ntags, - ..TimingValues::default() - }, - resident: false, - patched: Vec::new(), - changed: None, - last_replaced: Replaced::All, - pending_upload_s, - }) - } - - fn nodes(&self) -> usize { - self.problem.nodes() - } - - fn ntags(&self) -> u32 { - self.problem.ntags - } - - fn row_width(&self) -> usize { - self.problem.row_width() - } - - fn ensure_host_values(&mut self) { - let (n, nt, w) = (self.nodes(), self.ntags(), self.row_width()); - if !self.values.fits(n, nt) { - self.values = TimingValues { - ntags: nt, - arr: vec![0.0; n * w], - slew: vec![0.0; n * 4], - pred: vec![NO_PRED; n * w], - }; - } - } - - fn check_rows(&self, rb: Readback<'_>) -> Result<(), ComputeError> { - if let Readback::Rows(rows) = rb { - let n = self.nodes(); - if let Some(&r) = rows.iter().find(|&&r| r as usize >= n) { - return Err(ComputeError::Invalid(format!("readback row {r} of {n}"))); - } - } - Ok(()) - } - - fn ensure_cone_buffers(&mut self) -> Result<(), ComputeError> { - if self.cone_bufs.is_none() { - let g = self.problem.graph; - let n = self.nodes(); - let w = self.row_width(); - let max_lvl = g - .level_ptr - .windows(2) - .map(|pair| (pair[1] - pair[0]) as usize) - .max() - .unwrap_or(0); - self.cone_bufs = Some(ConeBuffers { - out_ptr: upload_slice(&self.stream, &g.out_ptr)?, - out_dst: upload_slice(&self.stream, &g.out_dst)?, - dirty: alloc_zeros::(&self.stream, n)?, - changed_flags: alloc_zeros::(&self.stream, n)?, - stats: alloc_zeros::(&self.stream, 3)?, - scratch_arr: alloc_zeros::(&self.stream, max_lvl * w)?, - scratch_pred: alloc_zeros::(&self.stream, max_lvl * w)?, - level_of: g.level_of(), - }); - } - Ok(()) - } - - fn lowest_patched_level(&mut self) -> Result, ComputeError> { - if self.patched.is_empty() { - return Ok(None); - } - self.ensure_cone_buffers()?; - let level_of = &self.cone_bufs.as_ref().expect("just built").level_of; - Ok(self - .patched - .iter() - .map(|&v| level_of[v as usize] as usize) - .min()) - } - - fn run_levels( - &mut self, - first: usize, - ctx: Ctx<'_>, - st: &mut PropagationStats, - ) -> Result<(), ComputeError> { - let lp = &self.problem.graph.level_ptr; - let nlev = self.problem.graph.levels(); - let nt = self.ntags(); - self.d_max_bits = upload_slice(&self.stream, &[0u64])?; - for l in first..nlev { - ctx.checkpoint()?; - let (base, end) = (lp[l], lp[l + 1]); - let count = end - base; - st.levels += 1; - if count > 0 { - let mut b = self.stream.launch_builder(&self.fn_level); - b.arg(&mut self.d_arr) - .arg(&mut self.d_slew) - .arg(&mut self.d_pred) - .arg(&self.d_in_ptr) - .arg(&self.d_in_arcs) - .arg(&self.d_map_ptr) - .arg(&self.d_map_tags) - .arg(&self.d_map_vals) - .arg(&self.d_classes) - .arg(&self.d_luts) - .arg(&self.d_data) - .arg(&self.d_load) - .arg(&self.d_wires) - .arg(&self.has_wires) - .arg(&self.d_arc_values) - .arg(&self.has_arc_values) - .arg(&self.d_start_row) - .arg(&self.d_start_arr) - .arg(&self.d_start_slew) - .arg(&mut self.d_max_bits) - .arg(&base) - .arg(&count) - .arg(&nt); - // SAFETY: the problem and all patches passed `validate`: every index, pointer - // range, table offset and start row is in bounds; every incoming arc of level `l` - // has `src < base`, so threads of this launch read only levels `< l` and write - // only their own node `v in base .. base + count`. - unsafe { b.launch(launch_1d(count as usize, BLOCK)) } - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - st.launches += 1; - st.nodes_evaluated += u64::from(count); - } - ctx.progress("propagate", (l + 1 - first) as u64, (nlev - first) as u64); - } - st.nodes_changed = st.nodes_evaluated; - let max_bits = read_slice(&self.stream, &self.d_max_bits)?[0]; - st.max_abs_ns = f64::from_bits(max_bits); - Ok(()) - } - - fn run_cone( - &mut self, - seeds: &[u32], - ctx: Ctx<'_>, - st: &mut PropagationStats, - ) -> Result, ComputeError> { - let n = self.nodes(); - if n == 0 || (seeds.is_empty() && self.patched.is_empty()) { - return Ok(Vec::new()); - } - self.ensure_cone_buffers()?; - let nlev = self.problem.graph.levels(); - let nt = self.ntags(); - let mut dirty_h = vec![0u8; n]; - let mut first = nlev; - { - let level_of = &self.cone_bufs.as_ref().expect("just built").level_of; - for &v in seeds.iter().chain(&self.patched) { - dirty_h[v as usize] = 1; - let l = level_of[v as usize] as usize; - if l < first { - first = l; - } - } - } - if first >= nlev { - return Ok(Vec::new()); - } - let cb = self.cone_bufs.as_mut().expect("just built"); - cb.dirty = upload_slice(&self.stream, &dirty_h)?; - cb.changed_flags = alloc_zeros::(&self.stream, n)?; - cb.stats = upload_slice(&self.stream, &[0u64; 3])?; - let lp = &self.problem.graph.level_ptr; - for l in first..nlev { - ctx.checkpoint()?; - let (base, end) = (lp[l], lp[l + 1]); - let count = end - base; - st.levels += 1; - if count > 0 { - let mut b = self.stream.launch_builder(&self.fn_cone); - b.arg(&mut self.d_arr) - .arg(&mut self.d_slew) - .arg(&mut self.d_pred) - .arg(&mut cb.scratch_arr) - .arg(&mut cb.scratch_pred) - .arg(&self.d_in_ptr) - .arg(&self.d_in_arcs) - .arg(&self.d_map_ptr) - .arg(&self.d_map_tags) - .arg(&self.d_map_vals) - .arg(&self.d_classes) - .arg(&self.d_luts) - .arg(&self.d_data) - .arg(&self.d_load) - .arg(&self.d_wires) - .arg(&self.has_wires) - .arg(&self.d_arc_values) - .arg(&self.has_arc_values) - .arg(&self.d_start_row) - .arg(&self.d_start_arr) - .arg(&self.d_start_slew) - .arg(&cb.out_ptr) - .arg(&cb.out_dst) - .arg(&mut cb.dirty) - .arg(&mut cb.changed_flags) - .arg(&mut cb.stats) - .arg(&base) - .arg(&count) - .arg(&nt); - // SAFETY: `scratch_arr` and `scratch_pred` hold `max_level_width * w` elements; - // each thread `pos < count` uses its own slice `pos * w .. (pos + 1) * w`, reads - // only levels `< l`, and updates only node `v = base + pos` and `dirty` entries of - // outgoing neighbors on levels `> l`. - unsafe { b.launch(launch_1d(count as usize, BLOCK)) } - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - st.launches += 1; - } - ctx.progress("propagate", (l + 1 - first) as u64, (nlev - first) as u64); - } - let stats = read_slice(&self.stream, &cb.stats)?; - st.nodes_evaluated = stats[0]; - st.nodes_changed = stats[1]; - st.max_abs_ns = f64::from_bits(stats[2]); - if stats[1] == 0 { - return Ok(Vec::new()); - } - let flags = read_slice(&self.stream, &cb.changed_flags)?; - let changed: Vec = flags - .iter() - .enumerate() - .filter_map(|(v, &f)| (f != 0).then_some(v as u32)) - .collect(); - Ok(changed) - } - - /// Copies every row from the device to `self.values` through page-locked staging memory - /// (COMPUTE-REQ-0022, COMPUTE-REQ-0028). - fn read_all(&mut self) -> Result<(), ComputeError> { - self.ensure_host_values(); - let n = self.nodes(); - if n == 0 { - return Ok(()); - } - let w = self.row_width(); - if self.pinned.is_none() { - self.pinned = Some(PinnedStaging { - arr: self.dev.pinned::(n * w)?, - slew: self.dev.pinned::(n * 4)?, - pred: self.dev.pinned::(n * w)?, - }); - } - let pin = self.pinned.as_mut().expect("just allocated"); - self.stream - .memcpy_dtoh(&self.d_arr, &mut pin.arr) - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - self.stream - .memcpy_dtoh(&self.d_slew, &mut pin.slew) - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - self.stream - .memcpy_dtoh(&self.d_pred, &mut pin.pred) - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - self.stream - .synchronize() - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - let arr_s = pin - .arr - .as_slice() - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - let slew_s = pin - .slew - .as_slice() - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - let pred_s = pin - .pred - .as_slice() - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - for (dst, &src) in self.values.arr.iter_mut().zip(arr_s) { - *dst = canon(src.to_f64()); - } - for (dst, &src) in self.values.slew.iter_mut().zip(slew_s) { - *dst = canon(src.to_f64()); - } - self.values.pred.copy_from_slice(&pred_s[..n * w]); - Ok(()) - } - - /// Copies the rows of `rows` from the device to `self.values` (COMPUTE-REQ-0022). - fn read_rows(&mut self, rows: &[u32]) -> Result<(), ComputeError> { - self.ensure_host_values(); - if rows.is_empty() || self.nodes() == 0 { - return Ok(()); - } - let k = rows.len(); - let w = self.row_width(); - let d_rows = upload_slice(&self.stream, rows)?; - let mut g_arr = alloc_zeros::(&self.stream, k * w)?; - let mut g_slew = alloc_zeros::(&self.stream, k * 4)?; - let mut g_pred = alloc_zeros::(&self.stream, k * w)?; - let (k32, w32) = (k as u32, w as u32); - let mut b = self.stream.launch_builder(&self.fn_gather); - b.arg(&self.d_arr) - .arg(&self.d_slew) - .arg(&self.d_pred) - .arg(&d_rows) - .arg(&mut g_arr) - .arg(&mut g_slew) - .arg(&mut g_pred) - .arg(&k32) - .arg(&w32); - // SAFETY: every entry of `rows` was checked to be `< nodes`; `g_arr`, `g_slew` and - // `g_pred` hold `k * w`, `k * 4` and `k * w` elements. - unsafe { b.launch(launch_1d(k, BLOCK)) } - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - let h_arr = read_slice(&self.stream, &g_arr)?; - let h_slew = read_slice(&self.stream, &g_slew)?; - let h_pred = read_slice(&self.stream, &g_pred)?; - for (i, &v) in rows.iter().enumerate() { - let v = v as usize; - for j in 0..w { - self.values.arr[v * w + j] = canon(h_arr[i * w + j].to_f64()); - self.values.pred[v * w + j] = h_pred[i * w + j]; - } - for j in 0..4 { - self.values.slew[v * 4 + j] = canon(h_slew[i * 4 + j].to_f64()); - } - } - Ok(()) - } - - fn read_into(&mut self, readback: Readback<'_>) -> Result<(), ComputeError> { - match readback { - Readback::None => { - self.ensure_host_values(); - Ok(()) - } - Readback::All => self.read_all(), - Readback::Rows(rows) => self.read_rows(rows), - Readback::Changed => match self.last_replaced { - Replaced::All => self.read_all(), - Replaced::FromNode(first_node) => { - let rows: Vec = (first_node as u32..self.nodes() as u32).collect(); - self.read_rows(&rows) - } - Replaced::Cone => { - let rows = self.changed.clone().unwrap_or_default(); - self.read_rows(&rows) - } - }, - } - } -} - -impl TimingSession for CudaSession<'_, R> { - fn kind(&self) -> BackendKind { - BackendKind::Cuda - } - - fn precision(&self) -> Precision { - R::PREC - } - - fn propagate(&mut self, req: &Request<'_>) -> Result { - let t_total = Instant::now(); - let mut st = PropagationStats::new(BackendKind::Cuda, self.dev.describe(), R::PREC); - st.upload_s = std::mem::take(&mut self.pending_upload_s); - self.check_rows(req.readback)?; - let needs_resident = !matches!(req.scope, Scope::All); - if needs_resident && !self.resident { - return Err(ComputeError::Invalid( - "no resident values: propagate every node first".into(), - )); - } - let n = self.nodes(); - if let Scope::Cone(seeds) = req.scope - && let Some(&s) = seeds.iter().find(|&&s| s as usize >= n) - { - return Err(ComputeError::Invalid(format!("cone seed {s} of {n}"))); - } - let t_kern = Instant::now(); - let r = match req.scope { - Scope::All => self - .run_levels(0, req.ctx, &mut st) - .map(|()| (None, Replaced::All)), - Scope::FromLevel(l) => { - let first = (l as usize).min(self.problem.graph.levels()); - let first = self.lowest_patched_level()?.map_or(first, |p| p.min(first)); - let first_node = self.problem.graph.level_ptr[first] as usize; - self.run_levels(first, req.ctx, &mut st) - .map(|()| (None, Replaced::FromNode(first_node))) - } - Scope::Cone(seeds) => self - .run_cone(seeds, req.ctx, &mut st) - .map(|ch| (Some(ch), Replaced::Cone)), - }; - st.kernel_s = t_kern.elapsed().as_secs_f64(); - match r { - Ok((changed, replaced)) => { - self.changed = changed; - self.last_replaced = replaced; - self.patched.clear(); - self.resident = true; - } - Err(e) => { - self.changed = None; - self.resident = false; - return Err(e); - } - } - let t_rb = Instant::now(); - self.read_into(req.readback)?; - st.readback_s = t_rb.elapsed().as_secs_f64(); - st.total_s = st.upload_s + t_total.elapsed().as_secs_f64(); - Ok(st) - } - - fn read(&mut self, readback: Readback<'_>) -> Result<(), ComputeError> { - if !self.resident { - return Err(ComputeError::Invalid("no values to read".into())); - } - self.check_rows(readback)?; - self.read_into(readback) - } - - fn values(&self) -> &TimingValues { - &self.values - } - - fn changed(&self) -> Option<&[u32]> { - self.changed.as_deref() - } - - fn set_loads(&mut self, edits: &[(u32, [f64; 4])]) -> Result<(), ComputeError> { - if !self.problem.arc_values.is_empty() { - return Err(ComputeError::Invalid( - "re-prepare RC arc values before patching inputs".into(), - )); - } - let n = self.nodes(); - for &(v, l) in edits { - if v as usize >= n { - return Err(ComputeError::Invalid(format!("load of node {v} of {n}"))); - } - check_load(v as usize, &l)?; - } - let t0 = Instant::now(); - for &(v, l) in edits { - self.load_h[v as usize] = l; - self.patched.push(v); - } - let flat: &[f64] = bytemuck::cast_slice(&self.load_h); - self.d_load = upload_slice(&self.stream, &conv::(flat))?; - self.pending_upload_s += t0.elapsed().as_secs_f64(); - Ok(()) - } - - fn set_arc_classes(&mut self, edits: &[(u32, u32)]) -> Result<(), ComputeError> { - if !self.problem.arc_values.is_empty() { - return Err(ComputeError::Invalid( - "re-prepare RC arc values before patching inputs".into(), - )); - } - let (na, nc) = (self.in_arcs_h.len(), self.problem.luts.classes.len()); - for &(k, c) in edits { - if k as usize >= na || c as usize >= nc { - return Err(ComputeError::Invalid(format!( - "class {c} of {nc} for arc {k} of {na}" - ))); - } - if let Some(w) = self.problem.wires.get(k as usize) - && !self.problem.luts.classes[c as usize].is_wire() - && !w.is_zero() - { - return Err(ComputeError::Invalid(format!( - "arc {k} has a wire record but class {c} is no wire class" - ))); - } - } - let t0 = Instant::now(); - let in_ptr = &self.problem.graph.in_ptr; - for &(k, c) in edits { - self.in_arcs_h[k as usize].class = c; - let v = in_ptr.partition_point(|&p| p <= k) - 1; - self.patched.push(v as u32); - } - self.d_in_arcs = upload_slice(&self.stream, bytemuck::cast_slice(&self.in_arcs_h))?; - self.pending_upload_s += t0.elapsed().as_secs_f64(); - Ok(()) - } - - fn set_starts(&mut self, starts: &StartRows) -> Result<(), ComputeError> { - if !self.problem.arc_values.is_empty() { - return Err(ComputeError::Invalid( - "re-prepare RC arc values before patching inputs".into(), - )); - } - let n = self.nodes(); - validate_starts(starts, n, self.ntags())?; - let t0 = Instant::now(); - diff_starts(&self.starts_h, starts, self.ntags(), &mut self.patched); - self.start_row_h = start_index(n, starts); - self.starts_h = starts.clone(); - self.d_start_row = upload_slice(&self.stream, &self.start_row_h)?; - self.d_start_arr = upload_slice(&self.stream, &conv::(&self.starts_h.arr))?; - self.d_start_slew = upload_slice(&self.stream, &conv::(&self.starts_h.slew))?; - self.pending_upload_s += t0.elapsed().as_secs_f64(); - Ok(()) - } - - fn write_values(&mut self, values: &TimingValues) -> Result<(), ComputeError> { - let (n, nt) = (self.nodes(), self.ntags()); - if !values.fits(n, nt) { - return Err(ComputeError::Invalid(format!( - "values do not fit {n} nodes and {nt} tags" - ))); - } - if values.arr.iter().chain(&values.slew).any(|x| x.is_nan()) { - return Err(ComputeError::Invalid("values hold NaN".into())); - } - let t0 = Instant::now(); - let arr_r: Vec = values.arr.iter().map(|&x| R::from_f64(canon(x))).collect(); - let slew_r: Vec = values.slew.iter().map(|&x| R::from_f64(canon(x))).collect(); - self.d_arr = upload_slice(&self.stream, &arr_r)?; - self.d_slew = upload_slice(&self.stream, &slew_r)?; - self.d_pred = upload_slice(&self.stream, &values.pred)?; - self.stream - .synchronize() - .map_err(|e| ComputeError::Device(format!("CUDA driver: {e}")))?; - self.values = TimingValues { - ntags: nt, - arr: arr_r.iter().map(|&x| canon(x.to_f64())).collect(), - slew: slew_r.iter().map(|&x| canon(x.to_f64())).collect(), - pred: values.pred.clone(), - }; - self.resident = true; - self.changed = None; - self.last_replaced = Replaced::All; - self.pending_upload_s += t0.elapsed().as_secs_f64(); - Ok(()) - } - - fn into_values(self: Box) -> TimingValues { - self.values - } -} diff --git a/crates/core/rusteda-compute/src/timing/kernels/timing.cu b/crates/core/rusteda-compute/src/timing/kernels/timing.cu deleted file mode 100644 index c2eaa91..0000000 --- a/crates/core/rusteda-compute/src/timing/kernels/timing.cu +++ /dev/null @@ -1,436 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -// Level-synchronous arrival, transition and predecessor propagation kernels of -// `rusteda_compute::timing`, compiled at run time by NVRTC (COMPUTE-REQ-0014 to COMPUTE-REQ-0019, -// COMPUTE-REQ-0021, COMPUTE-REQ-0022, COMPUTE-REQ-0028). -// `-DREAL_F64` with `--fmad=false` selects IEEE 754 double precision bit-identical to the CPU; -// the default is single precision. - -#ifdef REAL_F64 -typedef double real; -typedef unsigned long long bits_t; -__device__ __forceinline__ real r_inf() { return __longlong_as_double(0x7ff0000000000000ULL); } -__device__ __forceinline__ bits_t to_bits(real x) { return (bits_t)__double_as_longlong(x); } -__device__ __forceinline__ real r_sqrt(real x) { return sqrt(x); } -#else -typedef float real; -typedef unsigned int bits_t; -__device__ __forceinline__ real r_inf() { return __int_as_float(0x7f800000); } -__device__ __forceinline__ bits_t to_bits(real x) { return __float_as_uint(x); } -__device__ __forceinline__ real r_sqrt(real x) { return sqrtf(x); } -#endif - -#define NONE 0xFFFFFFFFu -#define NO_PRED 0xFFFFFFFFu -#define WIRE 1u - -__device__ __forceinline__ real absent_val(unsigned an) { - return an == 0u ? r_inf() : -r_inf(); -} - -__device__ __forceinline__ bool better(unsigned an, real x, real y) { - return an == 0u ? (x < y) : (x > y); -} - -__device__ __forceinline__ real canon(real x) { - return x == (real)0 ? (real)0 : x; -} - -__device__ __forceinline__ void interval(const real* p, unsigned m, real x, - unsigned* i0, unsigned* i1, real* w) { - if (m <= 1u) { - *i0 = 0u; - *i1 = 0u; - *w = (real)0; - return; - } - unsigned k = 0u; - while (k + 2u < m && x > p[k + 1u]) { - k += 1u; - } - *i0 = k; - *i1 = k + 1u; - *w = (x - p[k]) / (p[k + 1u] - p[k]); -} - -// Multilinear interpolation and linear extrapolation on 0 to 3 axes (COMPUTE-REQ-0014), in the -// exact operation order of `timing::lut::lookup`. -__device__ real lookup(const unsigned* luts, const real* data, unsigned t, - real x0, real x1, real x2) { - if (t == NONE) { - return (real)0; - } - const unsigned* l = luts + t * 10u; - unsigned n0 = l[0], n1 = l[1], n2 = l[2]; - unsigned off0 = l[3], off1 = l[4], off2 = l[5]; - unsigned arg0 = l[6], arg1 = l[7], arg2 = l[8]; - const real* v = data + l[9]; - if (n0 == 0u) { - return v[0]; - } - real args[3] = { x0, x1, x2 }; - unsigned i0, i1; - real w0; - interval(data + off0, n0, args[arg0], &i0, &i1, &w0); - if (n1 == 0u) { - return v[i0] + (v[i1] - v[i0]) * w0; - } - unsigned j0, j1; - real w1; - interval(data + off1, n1, args[arg1], &j0, &j1, &w1); - real c00, c01, c10, c11; - if (n2 == 0u) { - c00 = v[i0 * n1 + j0]; - c01 = v[i0 * n1 + j1]; - c10 = v[i1 * n1 + j0]; - c11 = v[i1 * n1 + j1]; - } else { - unsigned k0, k1; - real w2; - interval(data + off2, n2, args[arg2], &k0, &k1, &w2); - unsigned b00 = (i0 * n1 + j0) * n2; - unsigned b01 = (i0 * n1 + j1) * n2; - unsigned b10 = (i1 * n1 + j0) * n2; - unsigned b11 = (i1 * n1 + j1) * n2; - c00 = v[b00 + k0] + (v[b00 + k1] - v[b00 + k0]) * w2; - c01 = v[b01 + k0] + (v[b01 + k1] - v[b01 + k0]) * w2; - c10 = v[b10 + k0] + (v[b10 + k1] - v[b10 + k0]) * w2; - c11 = v[b11 + k0] + (v[b11 + k1] - v[b11 + k0]) * w2; - } - real lo = c00 + (c01 - c00) * w1; - real hi = c10 + (c11 - c10) * w1; - return lo + (hi - lo) * w0; -} - -// Evaluates node `v` (COMPUTE-REQ-0017, COMPUTE-REQ-0018) into `da` (`ntags * 4` arrivals), -// `ds` (4 transitions) and `dp` (`ntags * 4` predecessors) from the values of lower levels in -// `low_arr` and `low_slew`. Returns the IEEE 754 `f64` bit pattern of the largest magnitude among -// the present values of the row. -__device__ unsigned long long eval_node( - unsigned v, - unsigned ntags, - const real* low_arr, - const real* low_slew, - const unsigned* in_ptr, - const unsigned* in_arcs, - const unsigned* map_ptr, - const unsigned* map_tags, - const real* map_vals, - const unsigned* classes, - const unsigned* luts, - const real* data, - const real* load, - const real* wires, - unsigned has_wires, - const real* arc_values, - unsigned has_arc_values, - const unsigned* start_row, - const real* start_arr, - const real* start_slew, - real* da, - real* ds, - unsigned* dp -) { - unsigned w = ntags * 4u; - unsigned r = start_row[v]; - if (r == NONE) { - for (unsigned i = 0u; i < w; i++) { - da[i] = absent_val(i & 1u); - } - for (unsigned i = 0u; i < 4u; i++) { - ds[i] = absent_val(i & 1u); - } - } else { - const real* sa = start_arr + (unsigned long long)r * w; - const real* ss = start_slew + (unsigned long long)r * 4u; - for (unsigned i = 0u; i < w; i++) { - da[i] = sa[i]; - } - for (unsigned i = 0u; i < 4u; i++) { - ds[i] = ss[i]; - } - } - for (unsigned i = 0u; i < w; i++) { - dp[i] = NO_PRED; - } - for (unsigned k = in_ptr[v]; k < in_ptr[v + 1u]; k++) { - unsigned u = in_arcs[k * 3u]; - unsigned cls_idx = in_arcs[k * 3u + 1u]; - unsigned map_idx = in_arcs[k * 3u + 2u]; - const unsigned* c = classes + cls_idx * 7u; - unsigned flags = c[0]; - unsigned m_begin = 0u, m_end = 0u; - if (map_idx != NONE) { - m_begin = map_ptr[map_idx]; - m_end = map_ptr[map_idx + 1u]; - } - for (unsigned ti = 0u; ti < 2u; ti++) { - for (unsigned to = 0u; to < 2u; to++) { - if ((flags & (1u << (1u + 2u * ti + to))) == 0u) { - continue; - } - for (unsigned an = 0u; an < 2u; an++) { - real s = low_slew[(u * 2u + ti) * 2u + an]; - if (s == absent_val(an)) { - continue; - } - real l = load[v * 4u + to * 2u + an]; - real d, q; - if (has_arc_values != 0u) { - const real* av=arc_values+(unsigned long long)k*16ULL; - unsigned j=(ti*2u+to)*2u+an; - d=av[j]; q=av[8u+j]; - } else if ((flags & WIRE) != 0u) { - if (has_wires != 0u) { - const real* wr = wires + (unsigned long long)k * 8ULL; - d = wr[to * 2u + an]; - real wk = wr[4u + to * 2u + an]; - q = (wk == (real)0) ? s : r_sqrt(s * s + wk * wk); - } else { - d = (real)0; - q = s; - } - } else { - d = lookup(luts, data, c[1u + to], s, l, l); - q = lookup(luts, data, c[3u + to], s, l, l); - if (q < (real)0) { - q = (real)0; - } - } - unsigned si = to * 2u + an; - if (better(an, q, ds[si])) { - ds[si] = q; - } - unsigned p = u | (ti << 31); - if (map_idx == NONE) { - for (unsigned t = 0u; t < ntags; t++) { - unsigned long long ui = - ((unsigned long long)(u * ntags + t) * 2ULL + ti) * 2ULL + an; - real x = low_arr[ui]; - if (x == absent_val(an)) { - continue; - } - real cand = x + d; - unsigned i = (t * 2u + to) * 2u + an; - if (better(an, cand, da[i])) { - da[i] = cand; - dp[i] = p; - } - } - continue; - } - for (unsigned mr = m_begin; mr < m_end; mr++) { - unsigned src_tag = map_tags[mr * 2u]; - unsigned dst_tag = map_tags[mr * 2u + 1u]; - const real* mv = map_vals + mr * 9u; - unsigned long long ui = - ((unsigned long long)(u * ntags + src_tag) * 2ULL + ti) * 2ULL + an; - real x = low_arr[ui]; - if (x == absent_val(an)) { - continue; - } - real m = x + mv[0]; - if (m < mv[1u + an]) { - continue; - } - real h = mv[7u + an]; - real close_an = mv[3u + an]; - if (close_an < r_inf()) { - real cs = mv[5u + an]; - real e = close_an - lookup(luts, data, c[5u + ti], cs, s, l); - if (e < h) { - h = e; - } - } - if (m > h) { - m = h; - } - real cand = m + d; - unsigned i = (dst_tag * 2u + to) * 2u + an; - if (better(an, cand, da[i])) { - da[i] = cand; - dp[i] = p; - } - } - } - } - } - } - real mx = (real)0; - for (unsigned i = 0u; i < w; i++) { - real a = canon(da[i]); - da[i] = a; - real ab = a < (real)0 ? -a : a; - if (ab < r_inf() && ab > mx) { - mx = ab; - } - } - for (unsigned i = 0u; i < 4u; i++) { - real s = canon(ds[i]); - ds[i] = s; - real ab = s < (real)0 ? -s : s; - if (ab < r_inf() && ab > mx) { - mx = ab; - } - } - return (unsigned long long)__double_as_longlong((double)mx); -} - -// One thread per node of one level (`base .. base + count`): full or `FromLevel` propagation. -extern "C" __global__ void propagate_level( - real* arr, - real* slew, - unsigned* pred, - const unsigned* in_ptr, - const unsigned* in_arcs, - const unsigned* map_ptr, - const unsigned* map_tags, - const real* map_vals, - const unsigned* classes, - const unsigned* luts, - const real* data, - const real* load, - const real* wires, - unsigned has_wires, - const real* arc_values, - unsigned has_arc_values, - const unsigned* start_row, - const real* start_arr, - const real* start_slew, - unsigned long long* max_bits, - unsigned base, - unsigned count, - unsigned ntags -) { - unsigned pos = blockIdx.x * blockDim.x + threadIdx.x; - if (pos >= count) { - return; - } - unsigned v = base + pos; - unsigned long long w = (unsigned long long)ntags * 4ULL; - unsigned long long mx = eval_node( - v, ntags, arr, slew, in_ptr, in_arcs, map_ptr, map_tags, map_vals, - classes, luts, data, load, wires, has_wires, arc_values, has_arc_values, start_row, start_arr, start_slew, - arr + (unsigned long long)v * w, - slew + (unsigned long long)v * 4ULL, - pred + (unsigned long long)v * w - ); - if (mx != 0ULL) { - atomicMax(max_bits, mx); - } -} - -// One thread per node of one level (`base .. base + count`): cone propagation with bitwise change -// detection (COMPUTE-REQ-0021, COMPUTE-REQ-0028). -extern "C" __global__ void propagate_cone( - real* arr, - real* slew, - unsigned* pred, - real* scratch_arr, - unsigned* scratch_pred, - const unsigned* in_ptr, - const unsigned* in_arcs, - const unsigned* map_ptr, - const unsigned* map_tags, - const real* map_vals, - const unsigned* classes, - const unsigned* luts, - const real* data, - const real* load, - const real* wires, - unsigned has_wires, - const real* arc_values, - unsigned has_arc_values, - const unsigned* start_row, - const real* start_arr, - const real* start_slew, - const unsigned* out_ptr, - const unsigned* out_dst, - unsigned char* dirty, - unsigned char* changed_flags, - unsigned long long* stats, - unsigned base, - unsigned count, - unsigned ntags -) { - unsigned pos = blockIdx.x * blockDim.x + threadIdx.x; - if (pos >= count) { - return; - } - unsigned v = base + pos; - if (!dirty[v]) { - return; - } - dirty[v] = 0; - unsigned w = ntags * 4u; - real* da = scratch_arr + (unsigned long long)pos * w; - unsigned* dp = scratch_pred + (unsigned long long)pos * w; - real ds[4]; - unsigned long long mx = eval_node( - v, ntags, arr, slew, in_ptr, in_arcs, map_ptr, map_tags, map_vals, - classes, luts, data, load, wires, has_wires, arc_values, has_arc_values, start_row, start_arr, start_slew, - da, ds, dp - ); - atomicAdd(&stats[0], 1ULL); - if (mx != 0ULL) { - atomicMax(&stats[2], mx); - } - real* oa = arr + (unsigned long long)v * w; - real* os = slew + (unsigned long long)v * 4ULL; - unsigned* op = pred + (unsigned long long)v * w; - bool diff = false; - for (unsigned i = 0u; i < 4u; i++) { - if (to_bits(ds[i]) != to_bits(os[i])) { - diff = true; - } - os[i] = ds[i]; - } - for (unsigned i = 0u; i < w; i++) { - if (to_bits(da[i]) != to_bits(oa[i]) || dp[i] != op[i]) { - diff = true; - } - oa[i] = da[i]; - op[i] = dp[i]; - } - if (diff) { - changed_flags[v] = 1; - atomicAdd(&stats[1], 1ULL); - for (unsigned k = out_ptr[v]; k < out_ptr[v + 1u]; k++) { - dirty[out_dst[k]] = 1; - } - } -} - -// Gathers the rows of `rows[0..row_count]` into compact arrays for `Readback::Rows` and -// `Readback::Changed` (COMPUTE-REQ-0022). -extern "C" __global__ void gather_rows( - const real* arr, - const real* slew, - const unsigned* pred, - const unsigned* rows, - real* out_arr, - real* out_slew, - unsigned* out_pred, - unsigned row_count, - unsigned w -) { - unsigned i = blockIdx.x * blockDim.x + threadIdx.x; - if (i >= row_count) { - return; - } - unsigned v = rows[i]; - const real* sa = arr + (unsigned long long)v * w; - const real* ss = slew + (unsigned long long)v * 4ULL; - const unsigned* sp = pred + (unsigned long long)v * w; - real* da = out_arr + (unsigned long long)i * w; - real* ds = out_slew + (unsigned long long)i * 4ULL; - unsigned* dp = out_pred + (unsigned long long)i * w; - for (unsigned j = 0u; j < w; j++) { - da[j] = sa[j]; - dp[j] = sp[j]; - } - for (unsigned j = 0u; j < 4u; j++) { - ds[j] = ss[j]; - } -} diff --git a/crates/core/rusteda-compute/src/timing/lut.rs b/crates/core/rusteda-compute/src/timing/lut.rs deleted file mode 100644 index 7aecc9c..0000000 --- a/crates/core/rusteda-compute/src/timing/lut.rs +++ /dev/null @@ -1,155 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Table evaluation in `f64` (COMPUTE-REQ-0014): linear interpolation on up to three axes and -//! linear extrapolation outside them, in a fixed order of operations that the CUDA kernels repeat -//! exactly. Rust never contracts a multiplication and an addition into a fused multiply-add, so -//! every operation rounds once. - -use super::abi::{LutDesc, NONE}; - -/// The interval `(i0, i1)` of axis points `p` around `x` and the weight of `x` in it -/// (COMPUTE-REQ-0014). -#[inline] -fn interval(p: &[f64], x: f64) -> (usize, usize, f64) { - let m = p.len(); - if m <= 1 { - return (0, 0, 0.0); - } - let mut k = 0; - while k + 2 < m && x > p[k + 1] { - k += 1; - } - (k, k + 1, (x - p[k]) / (p[k + 1] - p[k])) -} - -/// The value of table `t` of `luts` over `data` at the canonical arguments `x`; 0 for [`NONE`] -/// (COMPUTE-REQ-0014). The table must be valid (COMPUTE-REQ-0020). -#[inline] -pub fn lookup(luts: &[LutDesc], data: &[f64], t: u32, x: [f64; 3]) -> f64 { - if t == NONE { - return 0.0; - } - let l = &luts[t as usize]; - let v = &data[l.voff as usize..]; - let axis = |k: usize| { - let off = l.off[k] as usize; - interval(&data[off..off + l.n[k] as usize], x[l.arg[k] as usize]) - }; - if l.n[0] == 0 { - return v[0]; - } - let (i0, i1, w0) = axis(0); - if l.n[1] == 0 { - return v[i0] + (v[i1] - v[i0]) * w0; - } - let (j0, j1, w1) = axis(1); - let n1 = l.n[1] as usize; - let (c00, c01, c10, c11) = if l.n[2] == 0 { - let at = |i: usize, j: usize| v[i * n1 + j]; - (at(i0, j0), at(i0, j1), at(i1, j0), at(i1, j1)) - } else { - let (k0, k1, w2) = axis(2); - let n2 = l.n[2] as usize; - let c = |i: usize, j: usize| { - let b = (i * n1 + j) * n2; - v[b + k0] + (v[b + k1] - v[b + k0]) * w2 - }; - (c(i0, j0), c(i0, j1), c(i1, j0), c(i1, j1)) - }; - let lo = c00 + (c01 - c00) * w1; - let hi = c10 + (c11 - c10) * w1; - lo + (hi - lo) * w0 -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A table of `f(x0, x1, x2)` on the given axes (arguments 0, 1, 2 in order). - fn table(axes: &[&[f64]], f: impl Fn([f64; 3]) -> f64) -> (Vec, Vec) { - let mut data = Vec::new(); - let mut d = LutDesc::default(); - for (k, p) in axes.iter().enumerate() { - d.n[k] = p.len() as u32; - d.off[k] = data.len() as u32; - d.arg[k] = k as u32; - data.extend_from_slice(p); - } - d.voff = data.len() as u32; - let n = |k: usize| axes.get(k).map_or(1, |p| p.len()); - for i in 0..n(0) { - for j in 0..n(1) { - for k in 0..n(2) { - let pt = |a: usize, idx: usize| axes.get(a).map_or(0.0, |p| p[idx]); - data.push(f([pt(0, i), pt(1, j), pt(2, k)])); - } - } - } - (vec![d], data) - } - - // COMPUTE-REQ-0014: multilinear interpolation and extrapolation reproduce a function that is - // linear in each argument exactly at points where every operation is exact, on 0 to 3 axes. - #[test] - fn linear_functions_are_reproduced() { - let f = |x: [f64; 3]| 1.0 + 2.0 * x[0] + 4.0 * x[1] + 8.0 * x[2]; - let axes: [&[f64]; 3] = [&[0.0, 1.0, 2.0], &[0.0, 0.5, 1.0, 1.5], &[0.0, 2.0]]; - for dims in 0..=3 { - let (luts, data) = table(&axes[..dims], f); - for x in [ - [0.5, 0.25, 1.0], - [1.5, 1.25, 0.5], - [-1.0, 2.0, 4.0], - [4.0, -0.5, -2.0], - ] { - let mut want = [x[0], x[1], x[2]]; - for w in want.iter_mut().skip(dims) { - *w = 0.0; - } - assert_eq!(lookup(&luts, &data, 0, x), f(want), "{dims} axes at {x:?}"); - } - } - assert_eq!(lookup(&luts_none(), &[], NONE, [1.0; 3]), 0.0); - } - - fn luts_none() -> Vec { - Vec::new() - } - - // COMPUTE-REQ-0014: the interval is the first whose upper point is not below the argument, - // the last one beyond the points; a single point is constant along its axis; the canonical - // argument of each axis is its `arg`. - #[test] - fn intervals_arguments_and_single_points() { - let p = [1.0, 2.0, 4.0]; - assert_eq!(interval(&p, 0.0), (0, 1, -1.0)); - assert_eq!(interval(&p, 2.0), (0, 1, 1.0)); - assert_eq!(interval(&p, 3.0), (1, 2, 0.5)); - assert_eq!(interval(&p, 8.0), (1, 2, 3.0)); - assert_eq!(interval(&[5.0], 9.0), (0, 0, 0.0)); - // Axis 0 indexed by argument 2 and axis 1 by argument 0. - let data = vec![0.0, 1.0, 0.0, 2.0, 10.0, 11.0, 20.0, 22.0]; - let d = LutDesc { - n: [2, 2, 0], - off: [0, 2, 0], - arg: [2, 0, 0], - voff: 4, - }; - // value(i, j) = 10 + 10 i + j (1 + i): at x2 = 0.5, x0 = 1.0 (j weight 0.5). - let got = lookup(&[d], &data, 0, [1.0, 99.0, 0.5]); - let lo = 10.0 + (11.0 - 10.0) * 0.5; - let hi = 20.0 + (22.0 - 20.0) * 0.5; - assert_eq!(got, lo + (hi - lo) * 0.5); - // A one-point axis followed by a second axis. - let d1 = LutDesc { - n: [1, 2, 0], - off: [0, 2, 0], - arg: [0, 1, 0], - voff: 4, - }; - assert_eq!(lookup(&[d1], &data, 0, [7.0, 2.0, 0.0]), 11.0); - assert_eq!(lookup(&[d1], &data, 0, [7.0, 1.0, 0.0]), 10.5); - assert_eq!(lookup(&[LutDesc::scalar(6)], &data, 0, [1.0; 3]), 20.0); - } -} diff --git a/crates/core/rusteda-compute/src/timing/mod.rs b/crates/core/rusteda-compute/src/timing/mod.rs deleted file mode 100644 index 818cebd..0000000 --- a/crates/core/rusteda-compute/src/timing/mod.rs +++ /dev/null @@ -1,301 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The timing propagation task (`docs/specs/compute/timing.md`, COMPUTE-REQ-0010 to -//! COMPUTE-REQ-0029): arrival times per tag, transition times per pin and predecessors, -//! propagated level by level over a levelized timing graph, reduced with the minimum (early) and -//! the maximum (late). -//! -//! An engine lowers its graph, library tables, start values and tag maps into the interface of -//! [`abi`] ([`TimingProblem`]), selects a backend with [`timing_backend`] and either calls the -//! single-call form [`TimingPropagationBackend::propagate_levels`] or opens a -//! [`TimingSession`] for repeated and incremental propagation. The `cpu` backend is -//! authoritative; the `cuda` backend agrees with it bit for bit in `f64` (COMPUTE-REQ-0019). - -pub mod abi; -mod cpu; -#[cfg(feature = "cuda")] -mod cuda; -pub mod lut; -mod state; -#[cfg(test)] -mod tests; -mod validate; - -pub use abi::{ - ArcClass, ArcValues, CompiledLutBank, FLAG_MASK, InArc, LevelizedTimingCsr, LutDesc, - NEGATIVE_UNATE, NON_UNATE, NONE, POSITIVE_UNATE, StartRows, TagMapRec, TimingProblem, WIRE, - WireRec, out_adjacency, transition_bit, -}; -pub use state::{ - EARLY, FALL, LATE, NO_PRED, RISE, TimingStateSoA, TimingValues, absent, arr_index, pred_source, - slew_index, -}; -pub use validate::MAX_NODES; - -use rusteda_core::job::Ctx; - -use crate::{BackendKind, ComputeError, Precision}; - -/// The nodes a propagation computes (COMPUTE-REQ-0021). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Scope<'a> { - /// Every node. - All, - /// Every node from this level on (and from the lowest patched level, when lower), on the - /// session's resident values below it. - FromLevel(u32), - /// The forward cone of these nodes and of every node patched since the last propagation, - /// with bitwise change detection. - Cone(&'a [u32]), -} - -/// The values a propagation or [`TimingSession::read`] copies to the host view -/// (COMPUTE-REQ-0022). The CPU backend computes in place, so its host view is always complete. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Readback<'a> { - /// Every row. - All, - /// Nothing: the values stay resident on the device. - None, - /// The rows of these nodes. - Rows(&'a [u32]), - /// The rows the last propagation replaced. - Changed, -} - -/// One propagation (COMPUTE-REQ-0021, COMPUTE-REQ-0022, COMPUTE-REQ-0026). -#[derive(Debug, Clone, Copy)] -pub struct Request<'a> { - pub scope: Scope<'a>, - pub readback: Readback<'a>, - /// Only order-independent reductions. Every backend of this crate reduces deterministically - /// either way (COMPUTE-REQ-0024). - pub deterministic: bool, - /// Cancel token and progress observer. - pub ctx: Ctx<'a>, -} - -impl<'a> Request<'a> { - /// Every node, every row read back, deterministic, without a context. - pub fn all() -> Request<'a> { - Request { - scope: Scope::All, - readback: Readback::All, - deterministic: true, - ctx: Ctx::NONE, - } - } - - /// The cone of `seeds` with the changed rows read back. - pub fn cone(seeds: &'a [u32]) -> Request<'a> { - Request { - scope: Scope::Cone(seeds), - readback: Readback::Changed, - ..Request::all() - } - } - - /// The same request with another scope. - pub fn with_scope(self, scope: Scope<'a>) -> Request<'a> { - Request { scope, ..self } - } - - /// The same request with another readback. - pub fn with_readback(self, readback: Readback<'a>) -> Request<'a> { - Request { readback, ..self } - } - - /// The same request with a context. - pub fn with_ctx(self, ctx: Ctx<'a>) -> Request<'a> { - Request { ctx, ..self } - } -} - -/// What a propagation did and what it cost (COMPUTE-REQ-0024; wall-clock seconds). -#[derive(Debug, Clone, PartialEq)] -pub struct PropagationStats { - /// `Cpu` or `Cuda`: the backend that computed. - pub backend: BackendKind, - /// The device: the CPU thread count, or the GPU name and architecture. - pub device: String, - pub precision: Precision, - /// Whether every reduction was order-independent. - pub deterministic: bool, - /// Levels visited. - pub levels: u32, - /// Kernel launches (0 on the CPU). - pub launches: u32, - pub nodes_evaluated: u64, - /// Nodes whose values were replaced (every evaluated node for `All` and `FromLevel`). - pub nodes_changed: u64, - /// The largest magnitude of a present value among the evaluated rows. - pub max_abs_ns: f64, - pub upload_s: f64, - pub kernel_s: f64, - pub readback_s: f64, - pub total_s: f64, -} - -impl PropagationStats { - /// Empty statistics of a backend. - pub fn new(backend: BackendKind, device: String, precision: Precision) -> PropagationStats { - PropagationStats { - backend, - device, - precision, - deterministic: true, - levels: 0, - launches: 0, - nodes_evaluated: 0, - nodes_changed: 0, - max_abs_ns: 0.0, - upload_s: 0.0, - kernel_s: 0.0, - readback_s: 0.0, - total_s: 0.0, - } - } -} - -/// A backend of the timing task (COMPUTE-REQ-0010). -pub trait TimingPropagationBackend: Send + Sync + std::fmt::Debug { - /// `Cpu` or `Cuda`. - fn kind(&self) -> BackendKind; - - /// The precision of the backend's arithmetic. - fn precision(&self) -> Precision; - - /// Validates `problem` and makes it resident (COMPUTE-REQ-0020, COMPUTE-REQ-0021). - fn open<'p>( - &self, - problem: &TimingProblem<'p>, - ) -> Result, ComputeError>; - - /// The single-call form of the development plan (COMPUTE-REQ-0023): propagates every level - /// of one corner, with the loads and start rows that `state` carries and ideal wires, and - /// stores the arrivals, transitions and predecessors in `state`. - fn propagate_levels( - &self, - graph: &LevelizedTimingCsr, - lut_bank: &CompiledLutBank, - state: &mut TimingStateSoA, - deterministic: bool, - ) -> Result { - let (stats, values) = { - let p = TimingProblem { - graph, - luts: lut_bank, - ntags: state.ntags, - load: &state.load, - starts: &state.starts, - wires: &[], - arc_values: &[], - }; - let mut s = self.open(&p)?; - let req = Request { - deterministic, - ..Request::all() - }; - let stats = s.propagate(&req)?; - (stats, s.into_values()) - }; - state.set_values(values); - Ok(stats) - } -} - -/// A problem resident on a backend (COMPUTE-REQ-0021, COMPUTE-REQ-0022). -pub trait TimingSession: Send { - /// `Cpu` or `Cuda`. - fn kind(&self) -> BackendKind; - - /// The precision of the session's arithmetic. - fn precision(&self) -> Precision; - - /// Computes the values of `req.scope` and copies `req.readback` to the host view. On an - /// error the session has no resident values until the next complete propagation. - fn propagate(&mut self, req: &Request<'_>) -> Result; - - /// Copies resident values to the host view (a no-op on the CPU, which computes in place). - fn read(&mut self, readback: Readback<'_>) -> Result<(), ComputeError>; - - /// The host view: complete after a propagation or read of every row, otherwise current for - /// the rows read. - fn values(&self) -> &TimingValues; - - /// After a `Cone` propagation, the nodes whose values it replaced, increasing; `None` after - /// any other scope (every evaluated node). - fn changed(&self) -> Option<&[u32]>; - - /// Replaces loads: `(node, load[tr * 2 + an])`. - fn set_loads(&mut self, edits: &[(u32, [f64; 4])]) -> Result<(), ComputeError>; - - /// Replaces arc classes: `(index of the arc in CSR order, class)`. - fn set_arc_classes(&mut self, edits: &[(u32, u32)]) -> Result<(), ComputeError>; - - /// Replaces the start rows. - fn set_starts(&mut self, starts: &StartRows) -> Result<(), ComputeError>; - - /// Makes `values` the resident values, as if a complete propagation had computed them, for - /// example values carried over from an earlier analysis before a `Cone` propagation. - fn write_values(&mut self, values: &TimingValues) -> Result<(), ComputeError>; - - /// The host view, consuming the session. - fn into_values(self: Box) -> TimingValues; -} - -/// The CPU backend (COMPUTE-REQ-0027). -pub static CPU: cpu::CpuBackend = cpu::CpuBackend; - -/// The backend of `kind` in `precision` (COMPUTE-REQ-0010). `Gpu` resolves to the first compiled -/// GPU backend whose device passes the per-process self-test. An uncompiled or unusable backend -/// is an error, never another backend. -pub fn timing_backend( - kind: BackendKind, - precision: Precision, -) -> Result<&'static dyn TimingPropagationBackend, ComputeError> { - match kind { - BackendKind::Cpu => match precision { - Precision::F64 => Ok(&CPU), - Precision::F32 => Err(ComputeError::Unavailable( - "the cpu backend computes in f64 only".into(), - )), - }, - BackendKind::Cuda | BackendKind::Gpu => gpu_backend(kind, precision), - } -} - -#[cfg(feature = "cuda")] -fn gpu_backend( - kind: BackendKind, - precision: Precision, -) -> Result<&'static dyn TimingPropagationBackend, ComputeError> { - crate::prepare(crate::GpuRuntime::Cuda).map_err(|e| { - ComputeError::Unavailable(match kind { - BackendKind::Gpu => format!("no GPU backend passes its self-test (cuda: {e})"), - _ => e.0, - }) - })?; - Ok(cuda::backend(precision)) -} - -#[cfg(not(feature = "cuda"))] -fn gpu_backend( - kind: BackendKind, - _precision: Precision, -) -> Result<&'static dyn TimingPropagationBackend, ComputeError> { - Err(ComputeError::NotCompiled(kind.name())) -} - -/// Selects the backend and compiles its kernels for `precision` (COMPUTE-REQ-0029), so that an -/// engine can overlap the preparation with other work on a second thread (COMPUTE-REQ-0003). -pub fn prewarm(kind: BackendKind, precision: Precision) -> Result<(), ComputeError> { - let b = timing_backend(kind, precision)?; - #[cfg(feature = "cuda")] - if b.kind() == BackendKind::Cuda { - cuda::compile(precision)?; - } - let _ = b; - Ok(()) -} diff --git a/crates/core/rusteda-compute/src/timing/state.rs b/crates/core/rusteda-compute/src/timing/state.rs deleted file mode 100644 index 5f70fdf..0000000 --- a/crates/core/rusteda-compute/src/timing/state.rs +++ /dev/null @@ -1,210 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The values of a propagation and their layout (COMPUTE-REQ-0016), and the state of the -//! single-call form (COMPUTE-REQ-0015, COMPUTE-REQ-0023). - -use super::abi::StartRows; - -/// Analysis index of the early analysis (reduced with the minimum). -pub const EARLY: usize = 0; -/// Analysis index of the late analysis (reduced with the maximum). -pub const LATE: usize = 1; -/// Transition index of a rising transition. -pub const RISE: usize = 0; -/// Transition index of a falling transition. -pub const FALL: usize = 1; -/// The predecessor of a start value or an absent arrival. -pub const NO_PRED: u32 = u32::MAX; - -/// The absent value of analysis `an`: `+inf` early, `-inf` late. -#[inline] -pub fn absent(an: usize) -> f64 { - if an == EARLY { - f64::INFINITY - } else { - f64::NEG_INFINITY - } -} - -/// Index of the arrival (and predecessor) of node `v`, tag `t`, transition `tr` and analysis -/// `an` among `ntags` tags. -#[inline] -pub fn arr_index(v: usize, t: usize, tr: usize, an: usize, ntags: usize) -> usize { - ((v * ntags + t) * 2 + tr) * 2 + an -} - -/// Index of the transition of node `v`, transition `tr` and analysis `an`. -#[inline] -pub fn slew_index(v: usize, tr: usize, an: usize) -> usize { - (v * 2 + tr) * 2 + an -} - -/// The source node and source transition of a predecessor, or `None` for [`NO_PRED`]. -#[inline] -pub fn pred_source(p: u32) -> Option<(u32, usize)> { - (p != NO_PRED).then_some((p & 0x7FFF_FFFF, (p >> 31) as usize)) -} - -/// The canonical form of a value: zeros are `+0.0` (COMPUTE-REQ-0016). -#[inline] -pub(crate) fn canon(x: f64) -> f64 { - if x == 0.0 { 0.0 } else { x } -} - -/// Arrivals, transitions and predecessors of every node (COMPUTE-REQ-0016). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct TimingValues { - pub ntags: u32, - /// `arr[((v * T + t) * 2 + tr) * 2 + an]`. - pub arr: Vec, - /// `slew[(v * 2 + tr) * 2 + an]`. - pub slew: Vec, - /// Indexed like `arr`: `src | (ti << 31)` or [`NO_PRED`]. - pub pred: Vec, -} - -impl TimingValues { - /// Every value of `nodes` nodes absent. - pub fn absent(nodes: usize, ntags: u32) -> TimingValues { - let w = nodes * ntags as usize * 4; - TimingValues { - ntags, - arr: (0..w).map(|i| absent(i % 2)).collect(), - slew: (0..nodes * 4).map(|i| absent(i % 2)).collect(), - pred: vec![NO_PRED; w], - } - } - - /// The node count. - pub fn nodes(&self) -> usize { - self.slew.len() / 4 - } - - /// True when the arrays have the lengths of `nodes` nodes and `ntags` tags. - pub fn fits(&self, nodes: usize, ntags: u32) -> bool { - let w = nodes * ntags as usize * 4; - self.ntags == ntags - && self.arr.len() == w - && self.pred.len() == w - && self.slew.len() == nodes * 4 - } - - /// The arrival of node `v`, tag `t`, transition `tr` and analysis `an`. - pub fn arrival(&self, v: usize, t: usize, tr: usize, an: usize) -> f64 { - self.arr[arr_index(v, t, tr, an, self.ntags as usize)] - } - - /// The predecessor of that arrival. - pub fn predecessor(&self, v: usize, t: usize, tr: usize, an: usize) -> u32 { - self.pred[arr_index(v, t, tr, an, self.ntags as usize)] - } - - /// The transition of node `v`, transition `tr` and analysis `an`. - pub fn transition(&self, v: usize, tr: usize, an: usize) -> f64 { - self.slew[slew_index(v, tr, an)] - } - - /// The first value whose bits differ from `other`'s, as text, or `None` when every array is - /// bitwise equal. - pub fn first_difference(&self, other: &TimingValues) -> Option { - if self.ntags != other.ntags - || self.arr.len() != other.arr.len() - || self.slew.len() != other.slew.len() - || self.pred.len() != other.pred.len() - { - return Some(format!( - "shapes differ: {} tags, {}/{}/{} values against {} tags, {}/{}/{}", - self.ntags, - self.arr.len(), - self.slew.len(), - self.pred.len(), - other.ntags, - other.arr.len(), - other.slew.len(), - other.pred.len() - )); - } - let w = self.ntags as usize * 4; - let at = |i: usize| { - let (v, r) = (i / w.max(1), i % w.max(1)); - format!("node {v} tag {} tr {} an {}", r / 4, (r / 2) % 2, r % 2) - }; - if let Some(i) = - (0..self.arr.len()).find(|&i| self.arr[i].to_bits() != other.arr[i].to_bits()) - { - return Some(format!( - "arrival of {}: {:e} against {:e}", - at(i), - self.arr[i], - other.arr[i] - )); - } - if let Some(i) = - (0..self.slew.len()).find(|&i| self.slew[i].to_bits() != other.slew[i].to_bits()) - { - return Some(format!( - "transition of node {} tr {} an {}: {:e} against {:e}", - i / 4, - (i / 2) % 2, - i % 2, - self.slew[i], - other.slew[i] - )); - } - if let Some(i) = (0..self.pred.len()).find(|&i| self.pred[i] != other.pred[i]) { - return Some(format!( - "predecessor of {}: {:#x} against {:#x}", - at(i), - self.pred[i], - other.pred[i] - )); - } - None - } -} - -/// The inputs and values of one corner for the single-call form -/// [`propagate_levels`](super::TimingPropagationBackend::propagate_levels) (COMPUTE-REQ-0015, -/// COMPUTE-REQ-0023): the per-node loads and the start rows go in, the arrivals, transitions and -/// predecessors (COMPUTE-REQ-0016) come out. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct TimingStateSoA { - pub ntags: u32, - /// `load[v][tr * 2 + an]`. - pub load: Vec<[f64; 4]>, - pub starts: StartRows, - pub arr: Vec, - pub slew: Vec, - pub pred: Vec, -} - -impl TimingStateSoA { - /// A state with these inputs and no values yet. - pub fn new(ntags: u32, load: Vec<[f64; 4]>, starts: StartRows) -> TimingStateSoA { - TimingStateSoA { - ntags, - load, - starts, - ..TimingStateSoA::default() - } - } - - /// Moves values into the state. - pub fn set_values(&mut self, v: TimingValues) { - self.ntags = v.ntags; - self.arr = v.arr; - self.slew = v.slew; - self.pred = v.pred; - } - - /// A copy of the values. - pub fn values(&self) -> TimingValues { - TimingValues { - ntags: self.ntags, - arr: self.arr.clone(), - slew: self.slew.clone(), - pred: self.pred.clone(), - } - } -} diff --git a/crates/core/rusteda-compute/src/timing/tests/checks.rs b/crates/core/rusteda-compute/src/timing/tests/checks.rs deleted file mode 100644 index 267510d..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/checks.rs +++ /dev/null @@ -1,273 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Validation (COMPUTE-REQ-0020): every corruption of a valid problem is rejected with -//! `Invalid`, by `validate()` and by every backend's `open`. - -use super::random::{self, Owned, Shape}; -use crate::ComputeError; -use crate::timing::{ - CPU, NEGATIVE_UNATE, NONE, StartRows, TimingPropagationBackend, WIRE, WireRec, -}; - -/// A valid problem with every feature: maps, wires, three axes, several tags. -fn valid() -> Owned { - let mut shape = Shape::from_seed(3); - shape.levels = 6; - shape.width = 12; - shape.ntags = 3; - shape.wires = true; - let o = random::problem(3, shape); - o.problem() - .validate() - .unwrap_or_else(|e| panic!("valid problem rejected: {e}")); - assert!(!o.graph.maps.is_empty() && !o.wires.is_empty()); - o -} - -/// The first arc whose destination lies on level 2 or above, with its destination. -fn deep_arc(o: &Owned) -> (usize, usize) { - let lp = &o.graph.level_ptr; - let first = lp[2] as usize; - let k = o.graph.in_ptr[first] as usize; - let v = o.graph.in_ptr.partition_point(|&p| p as usize <= k) - 1; - (k, v) -} - -type Corruption = (&'static str, fn(&mut Owned)); - -const CORRUPTIONS: &[Corruption] = &[ - ("ntags is 0", |o| o.ntags = 0), - ("in_ptr is empty", |o| o.graph.in_ptr.to_mut().clear()), - ("level_ptr is empty", |o| o.graph.level_ptr.to_mut().clear()), - ("level_ptr does not end", |o| { - o.graph.level_ptr.to_mut().pop(); - }), - ("decreases", |o| { - let lp = o.graph.level_ptr.to_mut(); - lp[1] = lp[2] + 1; - }), - ("in_ptr does not end", |o| { - let p = o.graph.in_ptr.to_mut(); - let last = p.len() - 1; - p[last] -= 1; - }), - ("not on a lower level", |o| { - let (k, v) = deep_arc(o); - o.graph.in_arcs.to_mut()[k].src = v as u32; - }), - ("sources are not sorted", |o| { - let (k, v) = deep_arc(o); - let arcs = o.graph.in_arcs.to_mut(); - arcs[k].src = o.graph.level_ptr[1] - 1; - let end = o.graph.in_ptr[v + 1] as usize; - assert!(end > k); - arcs.insert(k + 1, arcs[k]); - arcs[k + 1].src = 0; - for p in o.graph.in_ptr.to_mut()[v + 1..].iter_mut() { - *p += 1; - } - let n = o.load.len(); - let (op, od) = crate::timing::out_adjacency(n, &o.graph.in_ptr, arcs); - *o.graph.out_ptr.to_mut() = op; - *o.graph.out_dst.to_mut() = od; - o.wires.insert(k + 1, WireRec::default()); - }), - ("class", |o| { - let n = o.luts.classes.len() as u32; - o.graph.in_arcs.to_mut()[0].class = n; - }), - ("tag map", |o| { - let n = o.graph.map_count() as u32; - o.graph.in_arcs.to_mut()[0].map = n; - }), - ("out_dst", |o| { - let d = o.graph.out_dst.to_mut(); - d[0] = d[0].wrapping_add(1); - }), - ("out_ptr has", |o| { - o.graph.out_ptr.to_mut().pop(); - }), - ("out_ptr of node", |o| { - let n = o.load.len(); - let p = o.graph.out_ptr.to_mut(); - let u = (0..n).find(|&u| p[u + 1] > p[u]).unwrap_or(0); - p[u + 1] -= 1; - o.graph.out_dst.to_mut().remove(p[u + 1] as usize); - for x in &mut p[u + 2..] { - *x -= 1; - } - }), - ("map_ptr", |o| { - o.graph.map_ptr.to_mut().pop(); - }), - ("without map_ptr", |o| o.graph.map_ptr.to_mut().clear()), - ("tags", |o| o.graph.maps.to_mut()[0].dst_tag = 3), - ("shift", |o| o.graph.maps.to_mut()[0].shift = f64::NAN), - ("shift", |o| o.graph.maps.to_mut()[0].shift = f64::INFINITY), - ("shift", |o| { - o.graph.maps.to_mut()[0].open[1] = f64::INFINITY - }), - ("shift", |o| { - o.graph.maps.to_mut()[0].close[0] = f64::NEG_INFINITY - }), - ("shift", |o| o.graph.maps.to_mut()[0].limit[1] = f64::NAN), - ("shift", |o| { - o.graph.maps.to_mut()[0].close_slew[0] = f64::INFINITY - }), - ("undefined flag bits", |o| { - o.luts.classes.to_mut()[1].flags |= 1 << 7 - }), - ("classes[", |o| { - let n = o.luts.luts.len() as u32; - o.luts.classes.to_mut()[1].delay[1] = n; - }), - ("a wire class has no tables", |o| { - o.luts.classes.to_mut()[0].slew[0] = 0; - }), - ("a wire class has no tables", |o| { - o.luts.classes.to_mut()[0].flags = WIRE | NEGATIVE_UNATE; - }), - ("is not finite", |o| o.luts.data.to_mut()[0] = f64::NAN), - ("is not finite", |o| { - o.luts.data.to_mut()[0] = f64::NEG_INFINITY - }), - ("axis 0 out of range", |o| { - let n = o.luts.data.len() as u32; - let t = first_axis_lut(o); - o.luts.luts.to_mut()[t].off[0] = n; - }), - ("not strictly increasing", |o| { - let t = first_axis_lut(o); - let (off, n) = { - let l = &o.luts.luts[t]; - (l.off[0] as usize, l.n[0] as usize) - }; - assert!(n >= 2); - let d = o.luts.data.to_mut(); - d[off + 1] = d[off]; - }), - ("argument", |o| { - let t = first_axis_lut(o); - o.luts.luts.to_mut()[t].arg[0] = 3; - }), - ("absent axis precedes", |o| { - let t = first_axis_lut(o); - let l = &mut o.luts.luts.to_mut()[t]; - l.n[2] = l.n[0]; - l.n[1] = 0; - }), - ("values out of range", |o| { - let n = o.luts.data.len() as u32; - o.luts.luts.to_mut()[0].voff = n; - }), - ("loads for", |o| { - o.load.pop(); - }), - ("load of node", |o| o.load[1][2] = f64::NAN), - ("load of node", |o| o.load[0][0] = f64::INFINITY), - ("does not increase", |o| { - let s = &mut o.starts; - s.node[1] = s.node[0]; - }), - ("start rows: node", |o| { - let n = o.load.len() as u32; - let s = &mut o.starts; - let last = s.node.len() - 1; - s.node[last] = n; - }), - ("start rows:", |o| { - o.starts.arr.pop(); - }), - ("without its transition", |o| { - let s = &mut o.starts; - // Arrival tag 0 rise early present, its transition absent. - s.arr[0] = 1.0; - s.slew[0] = f64::INFINITY; - }), - ("arrival", |o| o.starts.arr[0] = f64::NEG_INFINITY), - ("arrival", |o| o.starts.arr[1] = f64::NAN), - ("transition", |o| o.starts.slew[1] = f64::INFINITY), - ("wire records for", |o| { - o.wires.pop(); - }), - ("is not zero but its arc is no wire arc", |o| { - let k = o - .graph - .in_arcs - .iter() - .position(|a| a.class != 0) - .unwrap_or(0); - o.wires[k].delay[0] = 0.5; - }), - ("wires[", |o| { - let k = o - .graph - .in_arcs - .iter() - .position(|a| a.class == 0) - .unwrap_or(0); - o.wires[k].slew[3] = f64::INFINITY; - }), -]; - -/// A table with at least one axis of two or more points. -fn first_axis_lut(o: &Owned) -> usize { - o.luts - .luts - .iter() - .position(|l| l.n[0] >= 2 && l.n[1] == 0) - .unwrap_or(0) -} - -// COMPUTE-REQ-0020: each corruption is rejected by `validate()` and by `open`, naming the item. -#[test] -fn corruptions_are_rejected() { - let base = valid(); - for (i, (want, corrupt)) in CORRUPTIONS.iter().enumerate() { - let mut o = base.clone(); - corrupt(&mut o); - let p = o.problem(); - match p.validate() { - Err(ComputeError::Invalid(m)) => { - assert!(m.contains(want), "corruption {i}: '{m}' lacks '{want}'"); - } - other => panic!("corruption {i} ({want}) accepted: {other:?}"), - } - assert!( - matches!(CPU.open(&p).map(|_| ()), Err(ComputeError::Invalid(_))), - "corruption {i}" - ); - } -} - -// COMPUTE-REQ-0020: the boundary cases that are valid: no nodes, no maps (`map_ptr` empty or -// `[0]`), empty levels, nodes without arcs, no start rows, and no wire records. -#[test] -fn boundary_cases_are_valid() { - let mut o = valid(); - *o.graph.map_ptr.to_mut() = vec![0]; - o.graph.maps.to_mut().clear(); - for a in o.graph.in_arcs.to_mut() { - a.map = NONE; - } - o.problem().validate().unwrap_or_else(|e| panic!("{e}")); - o.graph.map_ptr.to_mut().clear(); - o.starts = StartRows::default(); - o.wires.clear(); - o.problem().validate().unwrap_or_else(|e| panic!("{e}")); - let lp = o.graph.level_ptr.to_mut(); - let last = *lp.last().unwrap_or(&0); - lp.push(last); - o.problem().validate().unwrap_or_else(|e| panic!("{e}")); - let mut empty = o.clone(); - *empty.graph.level_ptr.to_mut() = vec![0]; - *empty.graph.in_ptr.to_mut() = vec![0]; - empty.graph.in_arcs.to_mut().clear(); - *empty.graph.out_ptr.to_mut() = vec![0]; - empty.graph.out_dst.to_mut().clear(); - empty.load.clear(); - empty.problem().validate().unwrap_or_else(|e| panic!("{e}")); - let v = super::cpu_values(&empty); - assert_eq!((v.arr.len(), v.slew.len(), v.pred.len()), (0, 0, 0)); -} diff --git a/crates/core/rusteda-compute/src/timing/tests/cpu.rs b/crates/core/rusteda-compute/src/timing/tests/cpu.rs deleted file mode 100644 index b3784d0..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/cpu.rs +++ /dev/null @@ -1,463 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The CPU backend on random problems: the literal evaluator, thread counts, sessions, patches, -//! cones, readback, the single-call form, cancellation, progress and statistics. - -use std::borrow::Cow; -use std::sync::Mutex; - -use rusteda_core::job::{CancelToken, Ctx, Event, Observer}; - -use super::random::{self, Owned, Rng, Shape}; -use super::{assert_same, cpu_values, naive, pool}; -use crate::timing::{ - CPU, NONE, Readback, Request, Scope, StartRows, TimingPropagationBackend, TimingSession, - TimingStateSoA, TimingValues, absent, timing_backend, -}; -use crate::{BackendKind, ComputeError, Precision}; - -// COMPUTE-REQ-0014, COMPUTE-REQ-0016, COMPUTE-REQ-0017, COMPUTE-REQ-0018: the CPU backend equals -// a literal sequential reading of the rules bit for bit (arrivals, transitions, predecessors) on -// random problems with tag maps, three-axis tables, wire records and start rows. -#[test] -fn cpu_equals_the_literal_evaluator() { - for seed in 0..80 { - let o = random::problem(seed, Shape::from_seed(seed)); - let want = naive::evaluate(&o.problem()); - assert_same(&cpu_values(&o), &want, &format!("seed {seed}")); - } -} - -// COMPUTE-REQ-0019, COMPUTE-REQ-0027: 1, 3, 8 and 32 threads give the same bits. -#[test] -fn thread_counts_give_the_same_bits() { - let pools: Vec<(usize, rayon::ThreadPool)> = - [1, 3, 8, 32].into_iter().map(|t| (t, pool(t))).collect(); - for seed in 200..230 { - let mut shape = Shape::from_seed(seed); - shape.width = 120 + (seed as usize % 7) * 60; - let o = random::problem(seed, shape); - let base = pools[0].1.install(|| cpu_values(&o)); - for (t, p) in &pools[1..] { - let v = p.install(|| cpu_values(&o)); - assert_same(&v, &base, &format!("seed {seed}, {t} threads")); - } - } -} - -/// Changes a few loads, arc classes and start rows of `cur` and of the session `s`; returns the -/// patched nodes. -pub(super) fn patch(r: &mut Rng, cur: &mut Owned, s: &mut dyn TimingSession) -> Vec { - let n = cur.nodes(); - let mut nodes = Vec::new(); - let mut loads = Vec::new(); - for _ in 0..r.below(4) { - let v = r.below(n) as u32; - let c = r.range(0.001, 0.12); - let l = if r.chance(0.2) { - cur.load[v as usize] - } else { - [c, c * 1.1, c, c * 1.2] - }; - cur.load[v as usize] = l; - loads.push((v, l)); - nodes.push(v); - } - s.set_loads(&loads).unwrap_or_else(|e| panic!("{e}")); - let na = cur.graph.arcs(); - let nc = cur.luts.classes.len(); - let mut classes = Vec::new(); - if na > 0 { - for _ in 0..r.below(4) { - let k = r.below(na); - let keep_wire = cur.wires.get(k).is_some_and(|w| !w.is_zero()); - let c = if keep_wire { 0 } else { r.below(nc) as u32 }; - cur.graph.in_arcs.to_mut()[k].class = c; - classes.push((k as u32, c)); - let v = cur.graph.in_ptr.partition_point(|&p| p as usize <= k) - 1; - nodes.push(v as u32); - } - } - s.set_arc_classes(&classes) - .unwrap_or_else(|e| panic!("{e}")); - if r.chance(0.6) { - let nt = cur.ntags as usize; - let old = cur.starts.clone(); - let mut new = StartRows::default(); - let extra = r.below(n) as u32; - for k in 0..old.len() { - let v = old.node[k]; - if extra < v && new.node.last().is_none_or(|&l| l < extra) && old.find(extra).is_none() - { - new.push(extra, &vec![1.5; nt * 4], &[0.25; 4]); - } - if r.chance(0.1) { - continue; - } - let (a, sl) = old.row(k, cur.ntags); - let mut a = a.to_vec(); - if r.chance(0.2) { - let i = r.below(a.len()); - if a[i] != absent(i % 2) { - a[i] += 0.25; - } - } - new.push(v, &a, &[sl[0], sl[1], sl[2], sl[3]]); - } - let mut changed = Vec::new(); - crate::timing::cpu::diff_starts(&old, &new, cur.ntags, &mut changed); - nodes.extend(changed); - s.set_starts(&new).unwrap_or_else(|e| panic!("{e}")); - cur.starts = new; - } - nodes -} - -/// The nodes whose rows differ between two value sets. -pub(super) fn differing_rows(a: &TimingValues, b: &TimingValues) -> Vec { - let w = a.ntags as usize * 4; - (0..a.nodes()) - .filter(|&v| { - let r = v * w..(v + 1) * w; - a.arr[r.clone()] - .iter() - .zip(&b.arr[r.clone()]) - .any(|(x, y)| x.to_bits() != y.to_bits()) - || a.pred[r.clone()] != b.pred[r] - || a.slew[v * 4..v * 4 + 4] - .iter() - .zip(&b.slew[v * 4..v * 4 + 4]) - .any(|(x, y)| x.to_bits() != y.to_bits()) - }) - .map(|v| v as u32) - .collect() -} - -// COMPUTE-REQ-0021: after patches of loads, arc classes and start rows, a cone propagation from -// random seeds equals a fresh full propagation of the patched problem bit for bit, and lists -// exactly the rows that changed; FromLevel does the same. -#[test] -fn cone_and_from_level_equal_a_fresh_run() { - for seed in 300..360 { - let base = random::problem(seed, Shape::from_seed(seed)); - let mut cur = base.clone(); - let mut r = Rng::new(seed ^ 0xC0DE); - let p = base.problem(); - let mut s = CPU.open(&p).unwrap_or_else(|e| panic!("{e}")); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("{e}")); - for round in 0..6 { - let before = s.values().clone(); - patch(&mut r, &mut cur, s.as_mut()); - let seeds: Vec = (0..r.below(3)) - .map(|_| r.below(cur.nodes()) as u32) - .collect(); - let from_level = round % 3 == 2; - let st = if from_level { - let l = r.below(cur.graph.levels() + 1) as u32; - s.propagate(&Request::all().with_scope(Scope::FromLevel(l))) - } else { - s.propagate(&Request::cone(&seeds)) - } - .unwrap_or_else(|e| panic!("seed {seed} round {round}: {e}")); - let fresh = cpu_values(&cur); - assert_same(s.values(), &fresh, &format!("seed {seed} round {round}")); - if !from_level { - let changed = differing_rows(&before, &fresh); - assert_eq!(s.changed(), Some(&changed[..]), "seed {seed} round {round}"); - assert_eq!(st.nodes_changed, changed.len() as u64); - assert!(st.nodes_evaluated >= st.nodes_changed); - } else { - assert_eq!(s.changed(), None); - } - } - } -} - -// COMPUTE-REQ-0021: values written into a session on a patched problem, followed by a cone of the -// patched nodes, equal a fresh run; a cone with nothing patched changes nothing. -#[test] -fn written_values_restart_a_cone() { - for seed in 400..440 { - let base = random::problem(seed, Shape::from_seed(seed)); - let old = cpu_values(&base); - let mut cur = base.clone(); - let mut r = Rng::new(seed); - // Patch a scratch session to learn the patched nodes, then open one on the patched - // problem. - let p = base.problem(); - let mut scratch = CPU.open(&p).unwrap_or_else(|e| panic!("{e}")); - let mut nodes = patch(&mut r, &mut cur, scratch.as_mut()); - nodes.sort_unstable(); - nodes.dedup(); - let q = cur.problem(); - let mut s = CPU.open(&q).unwrap_or_else(|e| panic!("{e}")); - s.write_values(&old).unwrap_or_else(|e| panic!("{e}")); - s.propagate(&Request::cone(&nodes)) - .unwrap_or_else(|e| panic!("{e}")); - assert_same(s.values(), &cpu_values(&cur), &format!("seed {seed}")); - let st = s - .propagate(&Request::cone(&[])) - .unwrap_or_else(|e| panic!("{e}")); - assert_eq!((st.nodes_evaluated, st.nodes_changed), (0, 0)); - assert_eq!(s.changed(), Some(&[][..])); - } -} - -// COMPUTE-REQ-0022, COMPUTE-REQ-0023: the single-call form fills the state with the values of a -// session; the CPU host view is complete, reads of listed rows succeed and rows out of range are -// rejected. -#[test] -fn single_call_and_readback() { - for seed in 500..510 { - let o = random::problem(seed, Shape::from_seed(seed)); - let mut state = TimingStateSoA::new(o.ntags, o.load.clone(), o.starts.clone()); - let mut ideal = o.clone(); - ideal.wires.clear(); - let st = CPU - .propagate_levels(&o.graph, &o.luts, &mut state, true) - .unwrap_or_else(|e| panic!("{e}")); - assert_same( - &state.values(), - &cpu_values(&ideal), - &format!("seed {seed}"), - ); - assert_eq!(st.backend, BackendKind::Cpu); - assert_eq!(st.precision, Precision::F64); - let p = o.problem(); - let mut s = CPU.open(&p).unwrap_or_else(|e| panic!("{e}")); - assert!(matches!( - s.read(Readback::All), - Err(ComputeError::Invalid(_)) - )); - let rows = [0u32, (o.nodes() - 1) as u32]; - s.propagate(&Request::all().with_readback(Readback::Rows(&rows))) - .unwrap_or_else(|e| panic!("{e}")); - assert_same(s.values(), &cpu_values(&o), "rows"); - assert!(s.read(Readback::Rows(&rows)).is_ok()); - assert!(s.read(Readback::Changed).is_ok()); - let out = [o.nodes() as u32]; - assert!(matches!( - s.read(Readback::Rows(&out)), - Err(ComputeError::Invalid(_)) - )); - assert!(matches!( - s.propagate(&Request::cone(&out)), - Err(ComputeError::Invalid(_)) - )); - } -} - -// COMPUTE-REQ-0024: the statistics of the CPU backend. -#[test] -fn statistics() { - let o = random::problem(7, Shape::from_seed(7)); - let p = o.problem(); - let mut s = CPU.open(&p).unwrap_or_else(|e| panic!("{e}")); - let st = s - .propagate(&Request::all()) - .unwrap_or_else(|e| panic!("{e}")); - let v = s.values(); - let mx = v - .arr - .iter() - .chain(&v.slew) - .filter(|x| x.is_finite()) - .fold(0.0f64, |m, x| m.max(x.abs())); - assert_eq!(st.max_abs_ns, mx); - assert!(mx > 0.0); - assert_eq!(st.levels as usize, o.graph.levels()); - assert_eq!(st.nodes_evaluated as usize, o.nodes()); - assert_eq!(st.nodes_changed, st.nodes_evaluated); - assert_eq!(st.launches, 0); - assert!(st.deterministic); - assert!(st.total_s >= st.kernel_s && st.kernel_s >= 0.0); -} - -/// Records the progress events of a run. -#[derive(Default)] -struct Progress(Mutex>); - -impl Observer for Progress { - fn event(&self, e: &Event) { - if let Event::Progress { - phase: "propagate", - done, - total, - } = e - && let Ok(mut v) = self.0.lock() - { - v.push((*done, *total)); - } - } -} - -// COMPUTE-REQ-0026: a cancelled propagation stops with Cancelled and leaves no resident values, -// so that a cone is rejected until a full propagation; progress is reported per level. -#[test] -fn cancellation_and_progress() { - let mut shape = Shape::from_seed(11); - shape.levels = 12; - let o = random::problem(11, shape); - let p = o.problem(); - let mut s = CPU.open(&p).unwrap_or_else(|e| panic!("{e}")); - let obs = Progress::default(); - let ctx = Ctx::new(None, Some(&obs)); - s.propagate(&Request::all().with_ctx(ctx)) - .unwrap_or_else(|e| panic!("{e}")); - let seen = obs.0.lock().map(|v| v.clone()).unwrap_or_default(); - let levels = o.graph.levels() as u64; - assert_eq!(seen.last(), Some(&(levels, levels))); - let token = CancelToken::new(); - token.cancel(); - let ctx = Ctx::new(Some(&token), None); - assert_eq!( - s.propagate(&Request::all().with_ctx(ctx)).map(|_| ()), - Err(ComputeError::Cancelled) - ); - assert!(matches!( - s.propagate(&Request::cone(&[0])), - Err(ComputeError::Invalid(_)) - )); - assert!(matches!( - s.propagate(&Request::all().with_scope(Scope::FromLevel(1))), - Err(ComputeError::Invalid(_)) - )); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("{e}")); - assert_same(s.values(), &cpu_values(&o), "after the cancellation"); -} - -// COMPUTE-REQ-0020, COMPUTE-REQ-0021: patches are validated before they apply, and a rejected -// patch changes nothing. -#[test] -fn patches_are_validated() { - let mut shape = Shape::from_seed(13); - shape.wires = true; - let o = random::problem(13, shape); - let p = o.problem(); - let mut s = CPU.open(&p).unwrap_or_else(|e| panic!("{e}")); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("{e}")); - let n = o.nodes() as u32; - let inv = |r: Result<(), ComputeError>| matches!(r, Err(ComputeError::Invalid(_))); - assert!(inv(s.set_loads(&[(0, [0.1; 4]), (n, [0.1; 4])]))); - assert!(inv(s.set_loads(&[(0, [f64::NAN, 0.0, 0.0, 0.0])]))); - assert!(inv(s.set_loads(&[(0, [f64::INFINITY, 0.0, 0.0, 0.0])]))); - let na = o.graph.arcs() as u32; - let nc = o.luts.classes.len() as u32; - assert!(inv(s.set_arc_classes(&[(na, 0)]))); - assert!(inv(s.set_arc_classes(&[(0, nc)]))); - if let Some(k) = o.wires.iter().position(|w| !w.is_zero()) { - assert!(inv(s.set_arc_classes(&[(k as u32, 1)]))); - } - let mut bad = o.starts.clone(); - bad.node.reverse(); - if bad.len() > 1 { - assert!(inv(s.set_starts(&bad))); - } - assert!(inv(s.write_values(&TimingValues::absent(1, o.ntags)))); - let mut nan = cpu_values(&o); - nan.arr[0] = f64::NAN; - assert!(inv(s.write_values(&nan))); - // Nothing applied: a cone changes nothing. - s.propagate(&Request::cone(&[])) - .unwrap_or_else(|e| panic!("{e}")); - assert_eq!(s.changed(), Some(&[][..])); -} - -// COMPUTE-REQ-0010: the CPU backend is f64 only; GPU backends of a build without them are not -// compiled; selection never falls back. -#[test] -fn backend_selection() { - let b = timing_backend(BackendKind::Cpu, Precision::F64).unwrap_or_else(|e| panic!("{e}")); - assert_eq!( - (b.kind(), b.precision()), - (BackendKind::Cpu, Precision::F64) - ); - assert!(matches!( - timing_backend(BackendKind::Cpu, Precision::F32), - Err(ComputeError::Unavailable(_)) - )); - if !cfg!(feature = "cuda") { - for k in [BackendKind::Cuda, BackendKind::Gpu] { - for p in Precision::ALL { - assert_eq!( - timing_backend(k, p).map(|b| b.kind()), - Err(ComputeError::NotCompiled(k.name())) - ); - assert_eq!( - crate::timing::prewarm(k, p), - Err(ComputeError::NotCompiled(k.name())) - ); - } - } - } - assert_eq!( - crate::timing::prewarm(BackendKind::Cpu, Precision::F64), - Ok(()) - ); -} - -// COMPUTE-REQ-0011, COMPUTE-REQ-0021: sessions borrow the problem and copy only patched arrays, -// so that the problem a session was opened on is never changed. -#[test] -fn sessions_leave_the_problem_unchanged() { - let o = random::problem(17, Shape::from_seed(17)); - let copy = o.clone(); - { - let p = o.problem(); - let mut s = CPU.open(&p).unwrap_or_else(|e| panic!("{e}")); - let k = (0..o.graph.arcs()) - .find(|&k| o.graph.in_arcs[k].class != 0 && o.wires.get(k).is_none_or(|w| w.is_zero())); - if let Some(k) = k { - s.set_arc_classes(&[(k as u32, 1)]) - .unwrap_or_else(|e| panic!("{e}")); - } - s.set_loads(&[(0, [0.5; 4])]) - .unwrap_or_else(|e| panic!("{e}")); - s.set_starts(&StartRows::default()) - .unwrap_or_else(|e| panic!("{e}")); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("{e}")); - } - assert!(matches!(o.graph.in_arcs, Cow::Owned(_))); - assert_eq!(o.graph, copy.graph); - assert_eq!(o.load, copy.load); - assert_eq!(o.starts, copy.starts); - assert_eq!(NONE, u32::MAX); -} - -// COMPUTE-REQ-0017, COMPUTE-REQ-0018: the random problems of these tests exercise every rule: -// mapped arrivals dropped below the opening edge and limited by the setup time and by the limit, -// degraded wire transitions, clamped transitions, negative delays, three-axis tables, ties with -// start values and between arcs, and transitions set by untagged sources. -#[test] -fn random_problems_exercise_every_rule() { - let c = naive::Counts::default(); - for seed in 0..80 { - let o = random::problem(seed, Shape::from_seed(seed)); - naive::evaluate_counted(&o.problem(), &c); - } - let counts = [ - ("present arrivals", c.present_arrivals.get()), - ("mapped candidates", c.mapped_candidates.get()), - ("drops below the opening edge", c.open_drops.get()), - ("setup-time limits", c.setup_clamps.get()), - ("limits", c.limit_clamps.get()), - ("degraded transitions", c.degraded.get()), - ("clamped transitions", c.transition_clamps.get()), - ("negative delays", c.negative_delays.get()), - ("three-axis lookups", c.three_axis_lookups.get()), - ("ties with a start value", c.start_ties.get()), - ("ties between arcs", c.arc_ties.get()), - ( - "transitions of untagged sources", - c.untagged_transitions.get(), - ), - ]; - for (what, n) in counts { - assert!(n > 0, "no {what}: {counts:?}"); - } -} diff --git a/crates/core/rusteda-compute/src/timing/tests/gpu.rs b/crates/core/rusteda-compute/src/timing/tests/gpu.rs deleted file mode 100644 index 54f9892..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/gpu.rs +++ /dev/null @@ -1,269 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Tests of the CUDA timing backend (`--features cuda -- --include-ignored`). - -use super::cpu::{differing_rows, patch}; -use super::random::{self, Rng, Shape}; -use super::{assert_same, cpu_values}; -use crate::timing::cuda::{CUDA_F32, CUDA_F64}; -use crate::timing::{ - Readback, Request, Scope, TimingPropagationBackend, TimingStateSoA, TimingValues, absent, - prewarm, timing_backend, -}; -use crate::{BackendKind, ComputeError, Precision}; - -// COMPUTE-REQ-0019, COMPUTE-REQ-0028: CUDA f64 (--fmad=false) equals the CPU backend bit for bit -// (arrivals, transitions, predecessors) on >= 100 random problems with tag maps and three-axis -// tables, and repeated runs give identical bits. -#[test] -#[ignore = "needs a CUDA GPU"] -fn cuda_f64_equals_cpu_on_100_random_problems() { - for seed in 0..105 { - let mut shape = Shape::from_seed(seed); - shape.maps = true; - shape.axes3 = true; - let o = random::problem(seed, shape); - let want = cpu_values(&o); - let p = o.problem(); - let mut s = CUDA_F64 - .open(&p) - .unwrap_or_else(|e| panic!("seed {seed}: {e}")); - let st = s - .propagate(&Request::all()) - .unwrap_or_else(|e| panic!("seed {seed}: {e}")); - assert_same(s.values(), &want, &format!("seed {seed}")); - assert_eq!(st.backend, BackendKind::Cuda); - assert_eq!(st.precision, Precision::F64); - assert!(st.deterministic); - if seed < 15 { - let first = s.values().clone(); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("seed {seed} rerun: {e}")); - assert_same(s.values(), &first, &format!("seed {seed} rerun")); - } - } -} - -/// Checks that `got` (from `f32`) agrees with `want` (from `f64`) on absent entries and lies -/// within `max(1e-4, 2e-5 * |v|)` for every finite entry with `|v| <= 100.0`. -fn assert_f32_envelope(got: &TimingValues, want: &TimingValues, what: &str) { - assert_eq!(got.nodes(), want.nodes(), "{what}: node count"); - assert_eq!(got.ntags, want.ntags, "{what}: tag count"); - for (k, (&g, &w)) in got.arr.iter().zip(&want.arr).enumerate() { - let an = k % 2; - if w == absent(an) || g == absent(an) { - assert_eq!( - g.to_bits(), - w.to_bits(), - "{what}: arrival {k} absent mismatch ({g} vs {w})" - ); - continue; - } - assert!( - g.is_finite() && w.is_finite(), - "{what}: non-finite arrival {k}" - ); - if w.abs() <= 100.0 { - let tol = 1e-4_f64.max(2e-5 * w.abs()); - let diff = (g - w).abs(); - assert!( - diff <= tol, - "{what}: arrival {k}: got {g}, want {w}, diff {diff} > tol {tol}" - ); - } - } - for (k, (&g, &w)) in got.slew.iter().zip(&want.slew).enumerate() { - let an = k % 2; - if w == absent(an) || g == absent(an) { - assert_eq!( - g.to_bits(), - w.to_bits(), - "{what}: transition {k} absent mismatch ({g} vs {w})" - ); - continue; - } - assert!( - g.is_finite() && w.is_finite(), - "{what}: non-finite transition {k}" - ); - if w.abs() <= 100.0 { - let tol = 1e-4_f64.max(2e-5 * w.abs()); - let diff = (g - w).abs(); - assert!( - diff <= tol, - "{what}: transition {k}: got {g}, want {w}, diff {diff} > tol {tol}" - ); - } - } -} - -// COMPUTE-REQ-0019, COMPUTE-REQ-0028: CUDA f32 agrees with f64 on absent entries, stays within -// max(1e-4, 2e-5 * |v|) for |v| <= 100 ns on problems without window-drop near-ties, and gives -// bit-identical results across repeated runs. -#[test] -#[ignore = "needs a CUDA GPU"] -fn cuda_f32_within_envelope_and_deterministic() { - for seed in 0..40 { - let mut shape = Shape::from_seed(seed); - shape.continuous = true; - let o = random::problem(seed, shape); - let want = cpu_values(&o); - let p = o.problem(); - let mut s = CUDA_F32 - .open(&p) - .unwrap_or_else(|e| panic!("seed {seed}: {e}")); - let st = s - .propagate(&Request::all()) - .unwrap_or_else(|e| panic!("seed {seed}: {e}")); - assert_eq!(st.backend, BackendKind::Cuda); - assert_eq!(st.precision, Precision::F32); - assert!(st.deterministic); - let first = s.values().clone(); - assert_f32_envelope(&first, &want, &format!("seed {seed}")); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("seed {seed} rerun: {e}")); - assert_same(s.values(), &first, &format!("seed {seed} f32 rerun")); - } -} - -// COMPUTE-REQ-0021, COMPUTE-REQ-0028: on CUDA f64, after patches of loads, arc classes and start -// rows (and after write_values), Cone and FromLevel equal a fresh full propagation bit for bit, -// and Cone lists the exact rows that changed. -#[test] -#[ignore = "needs a CUDA GPU"] -fn cuda_cone_and_from_level_equal_fresh_run() { - for seed in 300..325 { - let base = random::problem(seed, Shape::from_seed(seed)); - let mut cur = base.clone(); - let mut r = Rng::new(seed ^ 0xC0DE); - let p = base.problem(); - let mut s = CUDA_F64.open(&p).unwrap_or_else(|e| panic!("{e}")); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("{e}")); - for round in 0..4 { - let before = s.values().clone(); - patch(&mut r, &mut cur, s.as_mut()); - let seeds: Vec = (0..r.below(3)) - .map(|_| r.below(cur.nodes()) as u32) - .collect(); - let from_level = round % 3 == 2; - let st = if from_level { - let l = r.below(cur.graph.levels() + 1) as u32; - s.propagate(&Request::all().with_scope(Scope::FromLevel(l))) - } else { - s.propagate(&Request::cone(&seeds)) - } - .unwrap_or_else(|e| panic!("seed {seed} round {round}: {e}")); - let fresh = cpu_values(&cur); - assert_same(s.values(), &fresh, &format!("seed {seed} round {round}")); - if !from_level { - let changed = differing_rows(&before, &fresh); - assert_eq!(s.changed(), Some(&changed[..]), "seed {seed} round {round}"); - assert_eq!(st.nodes_changed, changed.len() as u64); - assert!(st.nodes_evaluated >= st.nodes_changed); - } else { - assert_eq!(s.changed(), None); - } - } - } - for seed in 400..410 { - let base = random::problem(seed, Shape::from_seed(seed)); - let old = cpu_values(&base); - let mut cur = base.clone(); - let mut r = Rng::new(seed); - let p = base.problem(); - let mut scratch = CUDA_F64.open(&p).unwrap_or_else(|e| panic!("{e}")); - let mut nodes = patch(&mut r, &mut cur, scratch.as_mut()); - nodes.sort_unstable(); - nodes.dedup(); - let q = cur.problem(); - let mut s = CUDA_F64.open(&q).unwrap_or_else(|e| panic!("{e}")); - s.write_values(&old).unwrap_or_else(|e| panic!("{e}")); - s.propagate(&Request::cone(&nodes)) - .unwrap_or_else(|e| panic!("{e}")); - assert_same(s.values(), &cpu_values(&cur), &format!("seed {seed}")); - } -} - -// COMPUTE-REQ-0022, COMPUTE-REQ-0023, COMPUTE-REQ-0024, COMPUTE-REQ-0028, COMPUTE-REQ-0029: -// prewarm, backend selection, propagate_levels, Readback::None, Readback::Rows and -// Readback::Changed on CUDA, and PropagationStats fields. -#[test] -#[ignore = "needs a CUDA GPU"] -fn cuda_readback_and_single_call() { - prewarm(BackendKind::Cuda, Precision::F64).unwrap_or_else(|e| panic!("{e}")); - prewarm(BackendKind::Gpu, Precision::F32).unwrap_or_else(|e| panic!("{e}")); - let b = timing_backend(BackendKind::Gpu, Precision::F64).unwrap_or_else(|e| panic!("{e}")); - assert_eq!( - (b.kind(), b.precision()), - (BackendKind::Cuda, Precision::F64) - ); - - for seed in 500..508 { - let o = random::problem(seed, Shape::from_seed(seed)); - let mut state = TimingStateSoA::new(o.ntags, o.load.clone(), o.starts.clone()); - let mut ideal = o.clone(); - ideal.wires.clear(); - let st = CUDA_F64 - .propagate_levels(&o.graph, &o.luts, &mut state, true) - .unwrap_or_else(|e| panic!("{e}")); - assert_same( - &state.values(), - &cpu_values(&ideal), - &format!("seed {seed}"), - ); - assert_eq!(st.backend, BackendKind::Cuda); - assert_eq!(st.precision, Precision::F64); - assert!(st.device.contains("sm_"), "device: {}", st.device); - let non_empty_levels = o.graph.level_ptr.windows(2).filter(|w| w[1] > w[0]).count() as u32; - assert_eq!(st.launches, non_empty_levels); - assert!(st.total_s >= st.kernel_s && st.kernel_s >= 0.0); - - let want = cpu_values(&o); - let p = o.problem(); - let mut s = CUDA_F64.open(&p).unwrap_or_else(|e| panic!("{e}")); - assert!(matches!( - s.read(Readback::All), - Err(ComputeError::Invalid(_)) - )); - // Propagate with Readback::None, then read subsets of rows and finally All. - s.propagate(&Request::all().with_readback(Readback::None)) - .unwrap_or_else(|e| panic!("{e}")); - let n = o.nodes(); - let rows: Vec = (0..n).step_by(2).map(|v| v as u32).collect(); - s.read(Readback::Rows(&rows)) - .unwrap_or_else(|e| panic!("{e}")); - let w = o.ntags as usize * 4; - for &v in &rows { - let v = v as usize; - assert_eq!( - &s.values().arr[v * w..(v + 1) * w], - &want.arr[v * w..(v + 1) * w] - ); - assert_eq!( - &s.values().pred[v * w..(v + 1) * w], - &want.pred[v * w..(v + 1) * w] - ); - assert_eq!( - &s.values().slew[v * 4..v * 4 + 4], - &want.slew[v * 4..v * 4 + 4] - ); - } - s.read(Readback::All).unwrap_or_else(|e| panic!("{e}")); - assert_same(s.values(), &want, &format!("seed {seed} read all")); - - // Propagate with Readback::Changed on a cone after a load patch. - let mut cur = o.clone(); - let c = [0.09, 0.10, 0.09, 0.11]; - cur.load[0] = c; - s.set_loads(&[(0, c)]).unwrap_or_else(|e| panic!("{e}")); - s.propagate(&Request::cone(&[0]).with_readback(Readback::Changed)) - .unwrap_or_else(|e| panic!("{e}")); - assert_same( - s.values(), - &cpu_values(&cur), - &format!("seed {seed} read changed"), - ); - } -} diff --git a/crates/core/rusteda-compute/src/timing/tests/known.rs b/crates/core/rusteda-compute/src/timing/tests/known.rs deleted file mode 100644 index c2207cc..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/known.rs +++ /dev/null @@ -1,483 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Hand-derived values of small problems. Every table is linear in its arguments and every -//! input is a dyadic fraction, so that each expected value below is exact and derived from the -//! rules alone; the CPU backend and the literal evaluator must both give these bits. - -use std::borrow::Cow; - -use super::random::Owned; -use super::{assert_same, cpu_values, naive}; -use crate::timing::{ - ArcClass, CompiledLutBank, EARLY, FALL, InArc, LATE, LevelizedTimingCsr, LutDesc, - NEGATIVE_UNATE, NO_PRED, NONE, POSITIVE_UNATE, RISE, StartRows, TagMapRec, TimingValues, WIRE, - WireRec, absent, arr_index, out_adjacency, pred_source, slew_index, -}; - -/// A problem built level by level. -struct Build { - level_ptr: Vec, - in_ptr: Vec, - arcs: Vec, - map_ptr: Vec, - maps: Vec, - luts: Vec, - data: Vec, - classes: Vec, - ntags: u32, - load: Vec<[f64; 4]>, - starts: StartRows, - wires: Vec, -} - -impl Build { - fn new(ntags: u32) -> Build { - Build { - level_ptr: vec![0], - in_ptr: vec![0], - arcs: Vec::new(), - map_ptr: Vec::new(), - maps: Vec::new(), - luts: Vec::new(), - data: Vec::new(), - classes: vec![ArcClass::wire()], - ntags, - load: Vec::new(), - starts: StartRows::default(), - wires: Vec::new(), - } - } - - /// A table on `axes` (argument, points) with row-major `values`. - fn lut(&mut self, axes: &[(u32, &[f64])], values: &[f64]) -> u32 { - let mut d = LutDesc::default(); - for (k, (arg, pts)) in axes.iter().enumerate() { - d.n[k] = pts.len() as u32; - d.arg[k] = *arg; - d.off[k] = self.data.len() as u32; - self.data.extend_from_slice(pts); - } - d.voff = self.data.len() as u32; - assert_eq!(values.len(), d.values()); - self.data.extend_from_slice(values); - self.luts.push(d); - (self.luts.len() - 1) as u32 - } - - fn class(&mut self, c: ArcClass) -> u32 { - self.classes.push(c); - (self.classes.len() - 1) as u32 - } - - fn map(&mut self, recs: &[TagMapRec]) -> u32 { - if self.map_ptr.is_empty() { - self.map_ptr.push(0); - } - self.maps.extend_from_slice(recs); - self.map_ptr.push(self.maps.len() as u32); - (self.map_ptr.len() - 2) as u32 - } - - /// A level whose nodes have these incoming arcs and loads; returns the first node. - fn level(&mut self, nodes: &[(&[InArc], [f64; 4])]) -> u32 { - let first = self.load.len() as u32; - for (arcs, load) in nodes { - self.arcs.extend_from_slice(arcs); - self.in_ptr.push(self.arcs.len() as u32); - self.load.push(*load); - } - self.level_ptr.push(self.load.len() as u32); - first - } - - fn owned(self) -> Owned { - let n = self.load.len(); - let (out_ptr, out_dst) = out_adjacency(n, &self.in_ptr, &self.arcs); - Owned { - graph: LevelizedTimingCsr { - level_ptr: Cow::Owned(self.level_ptr), - in_ptr: Cow::Owned(self.in_ptr), - in_arcs: Cow::Owned(self.arcs), - out_ptr: Cow::Owned(out_ptr), - out_dst: Cow::Owned(out_dst), - map_ptr: Cow::Owned(self.map_ptr), - maps: Cow::Owned(self.maps), - }, - luts: CompiledLutBank { - luts: Cow::Owned(self.luts), - data: Cow::Owned(self.data), - classes: Cow::Owned(self.classes), - }, - ntags: self.ntags, - load: self.load, - starts: self.starts, - wires: self.wires, - } - } -} - -fn arc(src: u32, class: u32) -> InArc { - InArc { - src, - class, - map: NONE, - } -} - -const NO_ARCS: &[InArc] = &[]; -const ABSENT_ROW: [f64; 4] = [ - f64::INFINITY, - f64::NEG_INFINITY, - f64::INFINITY, - f64::NEG_INFINITY, -]; - -/// One tag's arrivals `[rise early, rise late, fall early, fall late]`. -fn row(x: [f64; 4]) -> Vec { - x.to_vec() -} - -/// Runs both evaluators and checks that they agree. -fn run(o: &Owned) -> TimingValues { - o.problem().validate().unwrap_or_else(|e| panic!("{e}")); - let v = cpu_values(o); - assert_same(&v, &naive::evaluate(&o.problem()), "literal evaluator"); - v -} - -fn arr(v: &TimingValues, node: usize, t: usize, tr: usize, an: usize) -> f64 { - v.arr[arr_index(node, t, tr, an, v.ntags as usize)] -} - -fn pred(v: &TimingValues, node: usize, t: usize, tr: usize, an: usize) -> u32 { - v.pred[arr_index(node, t, tr, an, v.ntags as usize)] -} - -fn slew(v: &TimingValues, node: usize, tr: usize, an: usize) -> f64 { - v.slew[slew_index(node, tr, an)] -} - -/// The delay table `0.125 + 0.5 s` and the transition table `0.0625 + 0.25 s + 0.5 L`. -fn tables(b: &mut Build) -> (u32, u32) { - let d = b.lut(&[(0, &[0.0, 1.0])], &[0.125, 0.625]); - let s = b.lut( - &[(0, &[0.0, 1.0]), (1, &[0.0, 2.0])], - &[0.0625, 1.0625, 0.3125, 1.3125], - ); - (d, s) -} - -// COMPUTE-REQ-0014, COMPUTE-REQ-0016, COMPUTE-REQ-0017: a wire, a positive-unate and a -// negative-unate arc in a chain; delays and transitions are evaluated at the source pin's -// transition and the node's load, per transition and analysis. -#[test] -fn chain_of_unate_arcs() { - let mut b = Build::new(1); - let (d, s) = tables(&mut b); - let pos = b.class(ArcClass::cell(POSITIVE_UNATE, [d, d], [s, s])); - let neg = b.class(ArcClass::cell(NEGATIVE_UNATE, [d, d], [s, s])); - b.level(&[(NO_ARCS, [0.0; 4])]); - b.starts - .push(0, &row([1.0, 2.0, 1.5, 2.5]), &[0.25, 0.5, 0.5, 0.75]); - b.level(&[(&[arc(0, 0)], [0.0; 4])]); - b.level(&[(&[arc(1, pos)], [0.5; 4])]); - b.level(&[(&[arc(2, neg)], [1.0; 4])]); - let v = run(&b.owned()); - // Node 1: the wire passes everything; predecessor node 0 with the same transition. - for (tr, an, a, s) in [ - (RISE, EARLY, 1.0, 0.25), - (RISE, LATE, 2.0, 0.5), - (FALL, EARLY, 1.5, 0.5), - (FALL, LATE, 2.5, 0.75), - ] { - assert_eq!(arr(&v, 1, 0, tr, an), a); - assert_eq!(slew(&v, 1, tr, an), s); - assert_eq!(pred_source(pred(&v, 1, 0, tr, an)), Some((0, tr))); - } - // Node 2: d = 0.125 + 0.5 s, q = 0.0625 + 0.25 s + 0.5 * 0.5. - for (tr, an, a, s) in [ - (RISE, EARLY, 1.25, 0.375), - (RISE, LATE, 2.375, 0.4375), - (FALL, EARLY, 1.875, 0.4375), - (FALL, LATE, 3.0, 0.5), - ] { - assert_eq!((arr(&v, 2, 0, tr, an), slew(&v, 2, tr, an)), (a, s)); - assert_eq!(pred_source(pred(&v, 2, 0, tr, an)), Some((1, tr))); - } - // Node 3: the crossed transitions, q = 0.5625 + 0.25 s. - for (tr, an, a, s) in [ - (RISE, EARLY, 2.21875, 0.671875), - (RISE, LATE, 3.375, 0.6875), - (FALL, EARLY, 1.5625, 0.65625), - (FALL, LATE, 2.71875, 0.671875), - ] { - assert_eq!((arr(&v, 3, 0, tr, an), slew(&v, 3, tr, an)), (a, s)); - assert_eq!(pred_source(pred(&v, 3, 0, tr, an)), Some((2, 1 - tr))); - } - assert_eq!(pred(&v, 0, 0, RISE, EARLY), NO_PRED); -} - -// COMPUTE-REQ-0017: the worst candidate wins per analysis; among equal candidates the start value -// and then the first arc in CSR order win. -#[test] -fn ties_and_start_values() { - let mut b = Build::new(1); - let (d, _) = tables(&mut b); - let id = b.lut(&[(0, &[0.0, 1.0])], &[0.0, 1.0]); - let one = b.lut(&[], &[1.0]); - let c = b.class(ArcClass::cell(POSITIVE_UNATE, [d, d], [id, id])); - let c2 = b.class(ArcClass::cell(POSITIVE_UNATE, [one, one], [id, id])); - b.level(&[(NO_ARCS, [0.0; 4]), (NO_ARCS, [0.0; 4])]); - for v in [0, 1] { - b.starts.push( - v, - &row([1.0, 2.0, f64::INFINITY, f64::NEG_INFINITY]), - &[0.25, 0.25, f64::INFINITY, f64::NEG_INFINITY], - ); - } - b.level(&[ - (&[arc(0, c), arc(1, c)], [0.0; 4]), - (&[arc(0, c)], [0.0; 4]), - (&[arc(0, c), arc(1, c2)], [0.0; 4]), - ]); - b.starts.push( - 3, - &row([1.25, 2.25, f64::INFINITY, f64::NEG_INFINITY]), - &[0.25, 0.25, f64::INFINITY, f64::NEG_INFINITY], - ); - let v = run(&b.owned()); - // Equal candidates from nodes 0 and 1: the first arc. - assert_eq!( - (arr(&v, 2, 0, RISE, EARLY), arr(&v, 2, 0, RISE, LATE)), - (1.25, 2.25) - ); - assert_eq!(pred(&v, 2, 0, RISE, EARLY), 0); - assert_eq!(pred(&v, 2, 0, RISE, LATE), 0); - // A candidate equal to the start value: the start value. - assert_eq!( - (arr(&v, 3, 0, RISE, EARLY), arr(&v, 3, 0, RISE, LATE)), - (1.25, 2.25) - ); - assert_eq!(pred(&v, 3, 0, RISE, EARLY), NO_PRED); - assert_eq!(pred(&v, 3, 0, RISE, LATE), NO_PRED); - // Different candidates: the smaller early, the larger late. - assert_eq!( - (arr(&v, 4, 0, RISE, EARLY), pred(&v, 4, 0, RISE, EARLY)), - (1.25, 0) - ); - assert_eq!( - (arr(&v, 4, 0, RISE, LATE), pred(&v, 4, 0, RISE, LATE)), - (3.0, 1) - ); - // No falling input: falling values stay absent. - assert_eq!(arr(&v, 4, 0, FALL, EARLY), absent(EARLY)); - assert_eq!(slew(&v, 4, FALL, LATE), absent(LATE)); - assert_eq!(pred(&v, 4, 0, FALL, LATE), NO_PRED); -} - -// COMPUTE-REQ-0012, COMPUTE-REQ-0014, COMPUTE-REQ-0018: tag maps shift arrivals into other tags, -// drop those below the opening edge, limit them to the closing edge minus the setup time of a -// three-axis table (closing-edge transition, source transition, load) and to the limit. -#[test] -fn tag_map_windows() { - let mut b = Build::new(2); - let p: &[f64] = &[0.0, 1.0]; - // setup = 0.125 + 0.25 x0 + 0.5 x1 + x2. - let vals: Vec = (0..8) - .map(|i| { - 0.125 - + 0.25 * f64::from((i >> 2) & 1) - + 0.5 * f64::from((i >> 1) & 1) - + f64::from(i & 1) - }) - .collect(); - let setup = b.lut(&[(0, p), (1, p), (2, p)], &vals); - let mut c = ArcClass::cell(POSITIVE_UNATE, [NONE, NONE], [NONE, NONE]); - c.setup = [setup, setup]; - let c = b.class(c); - let inf = f64::INFINITY; - let r0 = TagMapRec { - shift: -2.0, - open: [0.5, 0.5], - close: [inf, 2.5], - close_slew: [0.0, 0.5], - ..TagMapRec::through(0, 1) - }; - let r1 = TagMapRec { - limit: [inf, 4.0], - ..TagMapRec::through(0, 0) - }; - let r2 = TagMapRec { - close: [3.0, inf], - close_slew: [0.25, 0.0], - ..TagMapRec::through(1, 1) - }; - let m = b.map(&[r0, r1, r2]); - b.level(&[(NO_ARCS, [0.0; 4])]); - let mut a = row([1.0, 5.0, f64::INFINITY, f64::NEG_INFINITY]); - a.extend(row([0.5, 0.75, f64::INFINITY, f64::NEG_INFINITY])); - b.starts - .push(0, &a, &[0.25, 0.5, f64::INFINITY, f64::NEG_INFINITY]); - b.level(&[( - &[InArc { - src: 0, - class: c, - map: m, - }], - [0.5; 4], - )]); - let v = run(&b.owned()); - // Tag 0: only r1; the late arrival 5 is limited to 4. - assert_eq!( - (arr(&v, 1, 0, RISE, EARLY), arr(&v, 1, 0, RISE, LATE)), - (1.0, 4.0) - ); - // Tag 1 early: r0 drops 1 - 2 < 0.5; r2 keeps 0.5 (below 3 - setup(0.25, 0.25, 0.5) = - // 2.1875). Tag 1 late: r0 limits 5 - 2 = 3 to 2.5 - setup(0.5, 0.5, 0.5) = 1.5, which beats - // r2's 0.75. - assert_eq!( - (arr(&v, 1, 1, RISE, EARLY), arr(&v, 1, 1, RISE, LATE)), - (0.5, 1.5) - ); - for t in 0..2 { - for an in [EARLY, LATE] { - assert_eq!(pred_source(pred(&v, 1, t, RISE, an)), Some((0, RISE))); - assert_eq!(arr(&v, 1, t, FALL, an), absent(an)); - } - } - // Without tables the delay and transition are 0. - assert_eq!( - (slew(&v, 1, RISE, EARLY), slew(&v, 1, RISE, LATE)), - (0.0, 0.0) - ); -} - -// COMPUTE-REQ-0015, COMPUTE-REQ-0017: a wire record adds its delay and degrades the transition to -// sqrt(s^2 + k^2) (k = 0 keeps it); negative transitions clamp at 0, negative delays do not. -#[test] -fn wires_and_negative_values() { - let mut b = Build::new(1); - let neg_d = b.lut(&[], &[-0.5]); - let neg_s = b.lut(&[], &[-0.25]); - let c = b.class(ArcClass::cell( - POSITIVE_UNATE, - [neg_d, neg_d], - [neg_s, neg_s], - )); - b.level(&[(NO_ARCS, [0.0; 4])]); - b.starts.push( - 0, - &row([1.0, 2.0, f64::INFINITY, f64::NEG_INFINITY]), - &[0.5, 0.5, f64::INFINITY, f64::NEG_INFINITY], - ); - b.level(&[(&[arc(0, 0)], [0.0; 4])]); - b.level(&[(&[arc(1, c)], [0.0; 4])]); - b.wires = vec![ - WireRec { - delay: [0.125, 0.25, 0.0, 0.0], - slew: [0.375, 0.0, 0.0, 0.0], - }, - WireRec::default(), - ]; - let v = run(&b.owned()); - assert_eq!( - (arr(&v, 1, 0, RISE, EARLY), slew(&v, 1, RISE, EARLY)), - (1.125, 0.625) - ); - assert_eq!( - (arr(&v, 1, 0, RISE, LATE), slew(&v, 1, RISE, LATE)), - (2.25, 0.5) - ); - assert_eq!( - (arr(&v, 2, 0, RISE, EARLY), slew(&v, 2, RISE, EARLY)), - (0.625, 0.0) - ); - assert_eq!( - (arr(&v, 2, 0, RISE, LATE), slew(&v, 2, RISE, LATE)), - (1.75, 0.0) - ); - assert_eq!(WIRE, 1); -} - -// COMPUTE-REQ-0016, COMPUTE-REQ-0017: a source without arrivals (untagged) still sets the -// transition; arrival delays use the source pin's own transition; nodes without inputs stay -// absent. -#[test] -fn untagged_sources_set_transitions() { - let mut b = Build::new(1); - let (d, _) = tables(&mut b); - let id = b.lut(&[(0, &[0.0, 1.0])], &[0.0, 1.0]); - let c = b.class(ArcClass::cell(POSITIVE_UNATE, [d, d], [id, id])); - b.level(&[(NO_ARCS, [0.0; 4]), (NO_ARCS, [0.0; 4])]); - b.starts.push( - 0, - &ABSENT_ROW, - &[0.25, 0.75, f64::INFINITY, f64::NEG_INFINITY], - ); - b.starts.push( - 1, - &row([1.0, 1.0, f64::INFINITY, f64::NEG_INFINITY]), - &[0.5, 0.5, f64::INFINITY, f64::NEG_INFINITY], - ); - b.level(&[(&[arc(0, c), arc(1, c)], [0.0; 4]), (NO_ARCS, [0.0; 4])]); - let v = run(&b.owned()); - assert_eq!( - (slew(&v, 2, RISE, EARLY), slew(&v, 2, RISE, LATE)), - (0.25, 0.75) - ); - assert_eq!( - (arr(&v, 2, 0, RISE, EARLY), arr(&v, 2, 0, RISE, LATE)), - (1.375, 1.375) - ); - assert_eq!(pred(&v, 2, 0, RISE, LATE), 1); - for an in [EARLY, LATE] { - assert_eq!(slew(&v, 3, RISE, an), absent(an)); - assert_eq!(arr(&v, 3, 0, FALL, an), absent(an)); - assert_eq!(arr(&v, 0, 0, RISE, an), absent(an)); - } -} - -// COMPUTE-REQ-0016: zeros are stored as +0.0, from start values and from tables. -#[test] -fn zeros_are_canonical() { - let mut b = Build::new(1); - let nz = b.lut(&[], &[-0.0]); - let c = b.class(ArcClass::cell(POSITIVE_UNATE, [nz, nz], [nz, nz])); - b.level(&[(NO_ARCS, [0.0; 4])]); - b.starts.push( - 0, - &row([-0.0, -0.0, f64::INFINITY, f64::NEG_INFINITY]), - &[-0.0, -0.0, f64::INFINITY, f64::NEG_INFINITY], - ); - b.level(&[(&[arc(0, c)], [0.0; 4])]); - let v = run(&b.owned()); - for node in [0, 1] { - for an in [EARLY, LATE] { - assert_eq!(arr(&v, node, 0, RISE, an).to_bits(), 0.0f64.to_bits()); - assert_eq!(slew(&v, node, RISE, an).to_bits(), 0.0f64.to_bits()); - } - } -} - -// COMPUTE-REQ-0016: index helpers and predecessor encoding. -#[test] -fn layout_helpers() { - assert_eq!(arr_index(2, 1, 1, 0, 3), ((2 * 3 + 1) * 2 + 1) * 2); - assert_eq!(slew_index(5, 1, 1), 23); - assert_eq!(pred_source(7 | (1 << 31)), Some((7, 1))); - assert_eq!(pred_source(7), Some((7, 0))); - assert_eq!(pred_source(NO_PRED), None); - let v = TimingValues::absent(2, 3); - assert!(v.fits(2, 3) && !v.fits(3, 3) && !v.fits(2, 2)); - assert_eq!(v.arrival(1, 2, 1, 0), f64::INFINITY); - assert_eq!(v.transition(1, 1, 1), f64::NEG_INFINITY); - assert_eq!(v.predecessor(0, 0, 0, 0), NO_PRED); - assert_eq!(v.first_difference(&v.clone()), None); - let mut w = v.clone(); - w.pred[5] = 3; - assert!( - v.first_difference(&w) - .is_some_and(|d| d.contains("predecessor")) - ); -} diff --git a/crates/core/rusteda-compute/src/timing/tests/mod.rs b/crates/core/rusteda-compute/src/timing/tests/mod.rs deleted file mode 100644 index 066d209..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Tests of the timing task on random and hand-made problems; no engine is needed. The GPU tests -//! (module `gpu`) need a GPU and are ignored by default: run them with -//! `cargo test -p rusteda-compute --features cuda -- --include-ignored`. - -mod checks; -mod cpu; -#[cfg(feature = "cuda")] -mod gpu; -mod known; -pub(crate) mod naive; -mod prepared; -pub(crate) mod random; - -use crate::timing::{CPU, Request, TimingPropagationBackend, TimingValues}; -use random::Owned; - -/// The values of a full propagation on the CPU backend. -pub(crate) fn cpu_values(o: &Owned) -> TimingValues { - let p = o.problem(); - let mut s = CPU.open(&p).unwrap_or_else(|e| panic!("open: {e}")); - s.propagate(&Request::all()) - .unwrap_or_else(|e| panic!("propagate: {e}")); - s.into_values() -} - -/// Panics with the first difference between two value sets. -pub(crate) fn assert_same(a: &TimingValues, b: &TimingValues, what: &str) { - if let Some(d) = a.first_difference(b) { - panic!("{what}: {d}"); - } -} - -/// A rayon pool of `threads` threads. -pub(crate) fn pool(threads: usize) -> rayon::ThreadPool { - rayon::ThreadPoolBuilder::new() - .num_threads(threads) - .build() - .unwrap_or_else(|e| panic!("thread pool: {e}")) -} diff --git a/crates/core/rusteda-compute/src/timing/tests/naive.rs b/crates/core/rusteda-compute/src/timing/tests/naive.rs deleted file mode 100644 index e5fd184..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/naive.rs +++ /dev/null @@ -1,247 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! A literal, sequential reading of COMPUTE-REQ-0014 to COMPUTE-REQ-0018, structured differently -//! from the backends: every output value of a node gathers its candidates in the specified order -//! and keeps the first best one. It computes one node at a time in node order and evaluates -//! tables recursively, axis by axis. - -use std::cell::Cell; - -use crate::timing::{NO_PRED, NONE, TimingProblem, TimingValues}; - -/// How often each rule decided something, to show that random problems exercise every rule. -#[derive(Debug, Default)] -pub(crate) struct Counts { - pub present_arrivals: Cell, - pub mapped_candidates: Cell, - pub open_drops: Cell, - pub setup_clamps: Cell, - pub limit_clamps: Cell, - pub degraded: Cell, - pub transition_clamps: Cell, - pub negative_delays: Cell, - pub three_axis_lookups: Cell, - pub start_ties: Cell, - pub arc_ties: Cell, - pub untagged_transitions: Cell, -} - -fn bump(c: &Cell) { - c.set(c.get() + 1); -} - -/// The interval search of COMPUTE-REQ-0014, written as a search for the first upper point that -/// the argument does not exceed. -fn interval(p: &[f64], x: f64) -> (usize, usize, f64) { - if p.len() < 2 { - return (0, 0, 0.0); - } - let last = p.len() - 2; - let i0 = (0..=last) - .find(|&i| i == last || x <= p[i + 1]) - .unwrap_or(last); - (i0, i0 + 1, (x - p[i0]) / (p[i0 + 1] - p[i0])) -} - -/// Table `t` at canonical arguments `x`: interpolation along the first axis of the values -/// interpolated along the remaining axes. -fn table(p: &TimingProblem, t: u32, x: [f64; 3], c: &Counts) -> f64 { - if t == NONE { - return 0.0; - } - let d = &p.luts.luts[t as usize]; - let data = &p.luts.data; - let axes = d.axes(); - if axes == 3 { - bump(&c.three_axis_lookups); - } - let stride = |k: usize| -> usize { (k + 1..axes).map(|j| d.n[j] as usize).product() }; - fn rec( - data: &[f64], - d: &crate::timing::LutDesc, - axes: usize, - k: usize, - base: usize, - x: [f64; 3], - stride: &dyn Fn(usize) -> usize, - ) -> f64 { - if k == axes { - return data[d.voff as usize + base]; - } - let pts = &data[d.off[k] as usize..(d.off[k] + d.n[k]) as usize]; - let (i0, i1, w) = interval(pts, x[d.arg[k] as usize]); - let s = stride(k); - let f0 = rec(data, d, axes, k + 1, base + i0 * s, x, stride); - let f1 = rec(data, d, axes, k + 1, base + i1 * s, x, stride); - f0 + (f1 - f0) * w - } - rec(data, d, axes, 0, 0, x, &stride) -} - -fn better(an: usize, x: f64, y: f64) -> bool { - if an == 0 { x < y } else { x > y } -} - -fn missing(an: usize) -> f64 { - if an == 0 { - f64::INFINITY - } else { - f64::NEG_INFINITY - } -} - -/// The values of every node. -pub(crate) fn evaluate(p: &TimingProblem) -> TimingValues { - evaluate_counted(p, &Counts::default()) -} - -/// The values of every node, counting the decisions of the rules in `c`. -pub(crate) fn evaluate_counted(p: &TimingProblem, c: &Counts) -> TimingValues { - let n = p.nodes(); - let nt = p.ntags as usize; - let g = p.graph; - let mut arr = vec![0.0; n * nt * 4]; - let mut slew = vec![0.0; n * 4]; - let mut pred = vec![NO_PRED; n * nt * 4]; - let a_at = |v: usize, t: usize, tr: usize, an: usize| ((v * nt + t) * 2 + tr) * 2 + an; - for v in 0..n { - let start = p.starts.find(v as u32); - let arcs = g.in_ptr[v] as usize..g.in_ptr[v + 1] as usize; - // The delay and transition of an arc for (ti, to, an), or None when the arc does not - // carry ti to to or the source has no transition. - let eval = |k: usize, ti: usize, to: usize, an: usize, slew: &[f64]| { - let a = g.in_arcs[k]; - let cl = p.luts.classes[a.class as usize]; - if cl.flags & (1 << (1 + 2 * ti + to)) == 0 { - return None; - } - let s = slew[(a.src as usize * 2 + ti) * 2 + an]; - if s == missing(an) { - return None; - } - let l = p.load[v][to * 2 + an]; - if cl.flags & 1 == 1 { - let (d, k2) = match p.wires.get(k) { - Some(w) => (w.delay[to * 2 + an], w.slew[to * 2 + an]), - None => (0.0, 0.0), - }; - let q = if k2 == 0.0 { - s - } else { - bump(&c.degraded); - (s * s + k2 * k2).sqrt() - }; - return Some((d, q, s, l)); - } - let d = table(p, cl.delay[to], [s, l, l], c); - let q = table(p, cl.slew[to], [s, l, l], c); - if d < 0.0 { - bump(&c.negative_delays); - } - if q < 0.0 { - bump(&c.transition_clamps); - } - Some((d, q.max(0.0), s, l)) - }; - for to in 0..2 { - for an in 0..2 { - let mut best = start.map_or(missing(an), |k| p.starts.slew[k * 4 + to * 2 + an]); - for k in arcs.clone() { - for ti in 0..2 { - if let Some((_, q, _, _)) = eval(k, ti, to, an, &slew) - && better(an, q, best) - { - best = q; - let src = g.in_arcs[k].src as usize; - let tagged = (0..nt).any(|t| arr[a_at(src, t, ti, an)] != missing(an)); - if !tagged { - bump(&c.untagged_transitions); - } - } - } - } - slew[(v * 2 + to) * 2 + an] = if best == 0.0 { 0.0 } else { best }; - } - } - for t in 0..nt { - for to in 0..2 { - for an in 0..2 { - let mut best = start.map_or(missing(an), |k| { - p.starts.arr[(k * nt + t) * 4 + to * 2 + an] - }); - let mut from = NO_PRED; - for k in arcs.clone() { - let a = g.in_arcs[k]; - let cl = p.luts.classes[a.class as usize]; - for ti in 0..2 { - let Some((d, _, s, l)) = eval(k, ti, to, an, &slew) else { - continue; - }; - let mut cands = Vec::new(); - if a.map == NONE { - let x = arr[a_at(a.src as usize, t, ti, an)]; - if x != missing(an) { - cands.push(x + d); - } - } else { - for r in g.map(a.map).iter().filter(|r| r.dst_tag as usize == t) { - let x = arr[a_at(a.src as usize, r.src_tag as usize, ti, an)]; - if x == missing(an) { - continue; - } - bump(&c.mapped_candidates); - let m = x + r.shift; - if m < r.open[an] { - bump(&c.open_drops); - continue; - } - let mut h = r.limit[an]; - let mut by_setup = false; - if r.close[an] != f64::INFINITY { - let st = - table(p, cl.setup[ti], [r.close_slew[an], s, l], c); - by_setup = r.close[an] - st < h; - h = h.min(r.close[an] - st); - } - if m > h { - bump(if by_setup { - &c.setup_clamps - } else { - &c.limit_clamps - }); - } - cands.push(m.min(h) + d); - } - } - for cand in cands { - if better(an, cand, best) { - best = cand; - from = a.src | ((ti as u32) << 31); - } else if cand == best { - bump(if from == NO_PRED { - &c.start_ties - } else { - &c.arc_ties - }); - } - } - } - } - let i = a_at(v, t, to, an); - if best != missing(an) { - bump(&c.present_arrivals); - } - arr[i] = if best == 0.0 { 0.0 } else { best }; - pred[i] = from; - } - } - } - } - TimingValues { - ntags: p.ntags, - arr, - slew, - pred, - } -} diff --git a/crates/core/rusteda-compute/src/timing/tests/prepared.rs b/crates/core/rusteda-compute/src/timing/tests/prepared.rs deleted file mode 100644 index 5cb1cc5..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/prepared.rs +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! COMPUTE-REQ-0030: bounded three-node prepared-arc ABI and frozen-input checks. -//! Additional state <4 KiB/case, two test workers; no path enumeration. -use crate::timing::*; -use crate::{BackendKind, Precision}; -use std::borrow::Cow; - -fn run(kind: BackendKind) -> TimingValues { - let in_ptr = vec![0, 0, 1, 2]; - let arcs = vec![ - InArc { - src: 0, - class: 0, - map: NONE, - }, - InArc { - src: 1, - class: 1, - map: NONE, - }, - ]; - let (out_ptr, out_dst) = out_adjacency(3, &in_ptr, &arcs); - let graph = LevelizedTimingCsr { - level_ptr: Cow::Owned(vec![0, 1, 2, 3]), - in_ptr: Cow::Owned(in_ptr), - in_arcs: Cow::Owned(arcs), - out_ptr: Cow::Owned(out_ptr), - out_dst: Cow::Owned(out_dst), - map_ptr: Cow::Borrowed(&[0]), - maps: Cow::Borrowed(&[]), - }; - let bank = CompiledLutBank { - luts: Cow::Owned(vec![LutDesc::scalar(0), LutDesc::scalar(1)]), - data: Cow::Owned(vec![100.0, 100.0]), - classes: Cow::Owned(vec![ - ArcClass::cell(POSITIVE_UNATE, [0; 2], [1; 2]), - ArcClass::wire(), - ]), - }; - let mut starts = StartRows::default(); - starts.push(0, &[0.0; 4], &[0.1; 4]); - let values = [ - ArcValues { - delay: [0.2; 8], - slew: [0.15; 8], - }, - ArcValues { - delay: [0.03; 8], - slew: [0.17; 8], - }, - ]; - let loads = [[0.0; 4]; 3]; - let problem = TimingProblem { - graph: &graph, - luts: &bank, - ntags: 1, - load: &loads, - starts: &starts, - wires: &[], - arc_values: &values, - }; - problem.validate().unwrap(); - let backend = timing_backend(kind, Precision::F64).unwrap(); - let mut session = backend.open(&problem).unwrap(); - session.propagate(&Request::all()).unwrap(); - assert!(session.set_loads(&[(1, [1.0; 4])]).is_err()); - assert!(session.set_starts(&starts).is_err()); - assert!(session.set_arc_classes(&[(0, 0)]).is_err()); - let result = session.into_values(); - assert!((result.arrival(2, 0, 0, 1) - 0.23).abs() < 1e-15); - assert_eq!(result.transition(2, 0, 1), 0.17); - assert!( - TimingProblem { - arc_values: &values[..1], - ..problem - } - .validate() - .is_err() - ); - let mut invalid = values; - invalid[0].slew[0] = f64::NAN; - assert!( - TimingProblem { - arc_values: &invalid, - ..problem - } - .validate() - .is_err() - ); - result -} - -#[test] -fn prepared_values_and_frozen_inputs() { - run(BackendKind::Cpu); -} - -#[test] -#[cfg(feature = "cuda")] -#[ignore = "requires native CUDA, execute in a physical-memory cgroup"] -fn prepared_cpu_cuda_agree() { - super::assert_same( - &run(BackendKind::Cpu), - &run(BackendKind::Cuda), - "prepared RC arc f64", - ); -} diff --git a/crates/core/rusteda-compute/src/timing/tests/random.rs b/crates/core/rusteda-compute/src/timing/tests/random.rs deleted file mode 100644 index 4066eff..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/random.rs +++ /dev/null @@ -1,469 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Seeded random problems of the timing task: tables with 0 to 3 axes on every argument order, -//! wire, unate, non-unate, single-transition, disabled and latch classes, arcs that share a -//! source, tag maps with every kind of window bound, sparse start rows with absent entries and -//! untagged sources, wire records, and empty levels. - -use std::borrow::Cow; - -use crate::timing::{ - ArcClass, CompiledLutBank, InArc, LevelizedTimingCsr, LutDesc, NEGATIVE_UNATE, NON_UNATE, NONE, - POSITIVE_UNATE, StartRows, TagMapRec, TimingProblem, WireRec, absent, out_adjacency, - transition_bit, -}; - -/// SplitMix64. -pub(crate) struct Rng(u64); - -impl Rng { - pub(crate) fn new(seed: u64) -> Rng { - Rng(seed ^ 0x5DEE_CE66_D1CE_4E5B) - } - - pub(crate) fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - - /// Uniform in [0, 1). - pub(crate) fn unit(&mut self) -> f64 { - (self.next() >> 11) as f64 / (1u64 << 53) as f64 - } - - /// Uniform in [lo, hi). - pub(crate) fn range(&mut self, lo: f64, hi: f64) -> f64 { - lo + (hi - lo) * self.unit() - } - - /// Uniform in 0..n (0 for n = 0). - pub(crate) fn below(&mut self, n: usize) -> usize { - (self.next() % n.max(1) as u64) as usize - } - - /// True with probability `p`. - pub(crate) fn chance(&mut self, p: f64) -> bool { - self.unit() < p - } -} - -/// The shape of a random problem. -#[derive(Debug, Clone, Copy)] -pub(crate) struct Shape { - pub levels: usize, - /// Most nodes of a level. - pub width: usize, - pub ntags: u32, - pub max_fanin: usize, - /// Arcs with tag maps. - pub maps: bool, - /// Wire records. - pub wires: bool, - /// Tables with three axes. - pub axes3: bool, - /// Windows whose opening edge never drops an arrival (single-precision comparisons have no - /// near-ties of a drop decision then). - pub continuous: bool, -} - -impl Shape { - /// A shape chosen from the seed: small to medium, every feature on. - pub(crate) fn from_seed(seed: u64) -> Shape { - let mut r = Rng::new(seed.wrapping_mul(31).wrapping_add(7)); - Shape { - levels: 2 + r.below(24), - width: 1 + r.below(150), - ntags: 1 + r.below(5) as u32, - max_fanin: 1 + r.below(5), - maps: true, - wires: r.chance(0.5), - axes3: true, - continuous: false, - } - } -} - -/// An owned problem. -#[derive(Debug, Clone)] -pub(crate) struct Owned { - pub graph: LevelizedTimingCsr<'static>, - pub luts: CompiledLutBank<'static>, - pub ntags: u32, - pub load: Vec<[f64; 4]>, - pub starts: StartRows, - pub wires: Vec, -} - -impl Owned { - pub(crate) fn problem(&self) -> TimingProblem<'_> { - TimingProblem { - graph: &self.graph, - luts: &self.luts, - ntags: self.ntags, - load: &self.load, - starts: &self.starts, - wires: &self.wires, - arc_values: &[], - } - } - - pub(crate) fn nodes(&self) -> usize { - self.graph.nodes() - } -} - -/// Appends sorted axis points: `n` points from `lo` with random steps of about `step`. -fn axis(r: &mut Rng, data: &mut Vec, n: usize, lo: f64, step: f64) -> u32 { - let off = data.len() as u32; - let mut x = lo + r.range(0.0, step); - for _ in 0..n { - data.push(x); - x += step * r.range(0.2, 1.8); - } - off -} - -/// The value ranges of the canonical arguments: transitions (ns), loads (pF), loads. -const ARG_LO: [f64; 3] = [0.002, 0.0005, 0.0005]; -const ARG_STEP: [f64; 3] = [0.12, 0.02, 0.02]; - -/// A table of `n` axes over arguments `args`, with values `a + b x[0] + c x[1] + e x[2]` plus -/// noise (so that interpolation is no exact linear function). -fn table( - r: &mut Rng, - data: &mut Vec, - args: &[u32], - coef: [f64; 4], - noise: f64, - continuous: bool, -) -> LutDesc { - let mut d = LutDesc::default(); - for (k, &arg) in args.iter().enumerate() { - // One point now and then: constant along that axis. - let n = if r.chance(0.08) { 1 } else { 2 + r.below(6) }; - d.n[k] = n as u32; - d.arg[k] = arg; - d.off[k] = axis(r, data, n, ARG_LO[arg as usize], ARG_STEP[arg as usize]); - } - d.voff = data.len() as u32; - let count = d.values(); - let dims: Vec = (0..args.len()).map(|k| d.n[k] as usize).collect(); - for flat in 0..count { - let mut x = [0.0; 3]; - let mut rest = flat; - for k in (0..args.len()).rev() { - let i = rest % dims[k]; - rest /= dims[k]; - x[args[k] as usize] = data[d.off[k] as usize + i]; - } - let v = coef[0] + coef[1] * x[0] + coef[2] * x[1] + coef[3] * x[2]; - // Now and then a negative zero, which no stored value may keep. - let nz = r.chance(0.01); - let jitter = noise * r.range(-1.0, 1.0); - data.push(if nz && !continuous { -0.0 } else { v + jitter }); - } - d -} - -/// Argument orders of tables: every axis count, every order. -const ORDERS: [&[u32]; 13] = [ - &[], - &[0], - &[1], - &[2], - &[0, 1], - &[1, 0], - &[0, 2], - &[2, 0], - &[0, 1, 2], - &[1, 0, 2], - &[2, 1, 0], - &[0, 2, 1], - &[1, 2, 0], -]; - -/// A random problem of `shape` from `seed`. -pub(crate) fn problem(seed: u64, shape: Shape) -> Owned { - let mut r = Rng::new(seed); - let nt = shape.ntags as usize; - // Tables: delays, transitions (slope below 1 so that transitions stay bounded along paths, - // some with a negative intercept so that extrapolation goes below 0) and setup times. - let mut data = Vec::new(); - let mut luts = Vec::new(); - let orders: Vec<&[u32]> = ORDERS - .iter() - .copied() - .filter(|o| shape.axes3 || o.len() < 3) - .collect(); - let mut kinds = Vec::new(); - for i in 0..36 { - let order = orders[r.below(orders.len())]; - let kind = i % 3; - let coef = match kind { - // Delay. - 0 => [ - r.range(-0.02, 0.15), - r.range(0.0, 0.6), - r.range(0.5, 6.0), - r.range(0.0, 2.0), - ], - // Output transition. - 1 => [ - r.range(-0.03, 0.06), - r.range(0.0, 0.8), - r.range(0.5, 5.0), - r.range(0.0, 1.0), - ], - // Setup time: related transition, constrained transition, load. - _ => [ - r.range(-0.05, 0.1), - r.range(-0.2, 0.3), - r.range(0.0, 0.4), - r.range(0.0, 1.0), - ], - }; - let noise = if shape.continuous { 0.0005 } else { 0.004 }; - luts.push(table( - &mut r, - &mut data, - order, - coef, - noise, - shape.continuous, - )); - kinds.push(kind); - } - let pick = |r: &mut Rng, kind: usize| -> u32 { - if r.chance(0.08) { - return NONE; - } - loop { - let t = r.below(luts.len()); - if kinds[t] == kind { - return t as u32; - } - } - }; - let mut classes = vec![ArcClass::wire()]; - for k in 0..20 { - let bits = match k % 7 { - 0 | 1 => POSITIVE_UNATE, - 2 => NEGATIVE_UNATE, - 3 => NON_UNATE, - 4 => transition_bit(0, 0), - 5 => transition_bit(1, 0) | transition_bit(0, 0), - _ => { - if k == 6 { - 0 - } else { - transition_bit(1, 1) - } - } - }; - let mut c = ArcClass::cell( - bits, - [pick(&mut r, 0), pick(&mut r, 0)], - [pick(&mut r, 1), pick(&mut r, 1)], - ); - if k >= 14 { - c.setup = [pick(&mut r, 2), pick(&mut r, 2)]; - } - classes.push(c); - } - // Tag maps. - let mut map_ptr = vec![0u32]; - let mut maps = Vec::new(); - let nmaps = if shape.maps { 1 + r.below(12) } else { 0 }; - for _ in 0..nmaps { - for _ in 0..1 + r.below(4) { - let mut rec = TagMapRec::through(r.below(nt) as u32, r.below(nt) as u32); - rec.shift = if r.chance(0.2) { - 0.0 - } else { - r.range(-3.0, 3.0) - }; - for an in 0..2 { - if !shape.continuous && r.chance(0.6) { - rec.open[an] = r.range(-1.0, 6.0); - } - if r.chance(0.6) { - rec.close[an] = r.range(0.0, 9.0); - } - if r.chance(0.4) { - rec.limit[an] = r.range(1.0, 8.0); - } - rec.close_slew[an] = r.range(0.0, 0.5); - } - maps.push(rec); - } - map_ptr.push(maps.len() as u32); - } - // Levels and arcs. - let mut level_ptr = vec![0u32]; - let mut in_ptr = vec![0u32]; - let mut in_arcs = Vec::new(); - let mut n = 0usize; - for l in 0..shape.levels { - let count = if l == 0 { - 1 + r.below(shape.width) - } else if r.chance(0.04) { - 0 - } else { - 1 + r.below(shape.width) - }; - let lo = n; - for _ in 0..count { - if l > 0 && lo > 0 { - let fanin = if r.chance(0.03) { - 0 - } else { - 1 + r.below(shape.max_fanin) - }; - let mut arcs: Vec = Vec::new(); - for _ in 0..fanin { - // Mostly the previous level, so that paths grow long. - let prev = level_ptr[level_ptr.len().saturating_sub(2)] as usize; - let src = if r.chance(0.7) && prev < lo { - prev + r.below(lo - prev) - } else { - r.below(lo) - }; - let class = if r.chance(0.25) { - 0 - } else { - 1 + r.below(classes.len() - 1) as u32 - }; - let map = if nmaps > 0 && r.chance(0.2) { - r.below(nmaps) as u32 - } else { - NONE - }; - arcs.push(InArc { - src: src as u32, - class, - map, - }); - // Now and then a second arc from the same source. - if r.chance(0.08) { - arcs.push(InArc { - src: src as u32, - class: 1 + r.below(classes.len() - 1) as u32, - map: NONE, - }); - } - } - arcs.sort_by_key(|a| a.src); - in_arcs.extend(arcs); - } - n += 1; - in_ptr.push(in_arcs.len() as u32); - } - level_ptr.push(n as u32); - } - let (out_ptr, out_dst) = out_adjacency(n, &in_ptr, &in_arcs); - let load = (0..n) - .map(|_| { - let c = r.range(0.001, 0.12); - [ - c, - c * r.range(1.0, 1.3), - c * r.range(0.9, 1.1), - c * r.range(1.0, 1.4), - ] - }) - .collect(); - // Start rows: every source of level 0, and a few other nodes. - let mut starts = StartRows::default(); - let src_end = level_ptr[1] as usize; - for v in 0..n { - if v >= src_end && !r.chance(0.04) { - continue; - } - // Now and then the start row of a level-0 source that reaches this node through a bare - // wire: the start value ties with the arc's candidate. - let first = in_ptr[v] as usize; - if v >= src_end && !shape.wires && in_ptr[v + 1] as usize > first && r.chance(0.5) { - let a = in_arcs[first]; - if a.class == 0 && a.map == NONE && (a.src as usize) < src_end { - if let Some(k) = starts.find(a.src) { - let (arr, slew) = starts.row(k, shape.ntags); - let (arr, slew) = (arr.to_vec(), [slew[0], slew[1], slew[2], slew[3]]); - starts.push(v as u32, &arr, &slew); - continue; - } - } - } - let untagged = r.chance(0.1); - let mut slew = [0.0; 4]; - for (i, s) in slew.iter_mut().enumerate() { - *s = if r.chance(0.05) { - absent(i % 2) - } else { - r.range(0.005, 0.4) - }; - } - let mut arr = vec![0.0; nt * 4]; - for (i, a) in arr.iter_mut().enumerate() { - let an = i % 2; - // `i % 4` is the transition and analysis of the arrival. - let present = !untagged && slew[i % 4] != absent(an) && r.chance(0.7); - *a = if present { - // Zeros of both signs: the values store +0.0. - match r.below(40) { - 0 => 0.0, - 1 => -0.0, - _ => r.range(-0.5, 4.0), - } - } else { - absent(an) - }; - } - starts.push(v as u32, &arr, &slew); - } - let wires = if shape.wires { - in_arcs - .iter() - .map(|a| { - if a.class != 0 || r.chance(0.2) { - return WireRec::default(); - } - let mut w = WireRec::default(); - for i in 0..4 { - w.delay[i] = r.range(0.0, 0.05); - w.slew[i] = if r.chance(0.3) { - 0.0 - } else { - r.range(0.0, 0.08) - }; - } - w - }) - .collect() - } else { - Vec::new() - }; - Owned { - graph: LevelizedTimingCsr { - level_ptr: Cow::Owned(level_ptr), - in_ptr: Cow::Owned(in_ptr), - in_arcs: Cow::Owned(in_arcs), - out_ptr: Cow::Owned(out_ptr), - out_dst: Cow::Owned(out_dst), - map_ptr: Cow::Owned(map_ptr), - maps: Cow::Owned(maps), - }, - luts: CompiledLutBank { - luts: Cow::Owned(luts), - data: Cow::Owned(data), - classes: Cow::Owned(classes), - }, - ntags: shape.ntags, - load, - starts, - wires, - } -} diff --git a/crates/core/rusteda-compute/src/timing/validate.rs b/crates/core/rusteda-compute/src/timing/validate.rs deleted file mode 100644 index 6753d46..0000000 --- a/crates/core/rusteda-compute/src/timing/validate.rs +++ /dev/null @@ -1,301 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Validation of problems and patches (COMPUTE-REQ-0020): every statement of the interface that -//! the backends rely on, so that no kernel reads out of bounds and the level-parallel reductions -//! never read a value of their own level. - -use super::abi::{ - CompiledLutBank, FLAG_MASK, LevelizedTimingCsr, NEGATIVE_UNATE, NONE, StartRows, TimingProblem, - WireRec, -}; -use crate::ComputeError; - -/// The largest node count: predecessors keep the source transition in bit 31. -pub const MAX_NODES: usize = (1 << 31) - 1; - -fn bad(m: String) -> Result { - Err(ComputeError::Invalid(m)) -} - -/// `ptr` starts at 0, never decreases and ends at `end`. -fn pointers(what: &str, ptr: &[u32], end: usize) -> Result<(), ComputeError> { - if ptr.first() != Some(&0) { - return bad(format!("{what} does not start at 0")); - } - if let Some(i) = ptr.windows(2).position(|w| w[1] < w[0]) { - return bad(format!("{what}[{}] decreases", i + 1)); - } - if ptr.last().map(|&x| x as usize) != Some(end) { - return bad(format!("{what} does not end at {end}")); - } - Ok(()) -} - -/// An early value is finite or `+inf` (absent), a late one finite or `-inf`. -fn value_ok(x: f64, an: usize) -> bool { - x.is_finite() || (an == 0 && x == f64::INFINITY) || (an == 1 && x == f64::NEG_INFINITY) -} - -/// True when `x` is the absent value of analysis `an`. -fn absent(x: f64, an: usize) -> bool { - if an == 0 { - x == f64::INFINITY - } else { - x == f64::NEG_INFINITY - } -} - -impl TimingProblem<'_> { - /// Checks the problem against COMPUTE-REQ-0011 to COMPUTE-REQ-0015 (COMPUTE-REQ-0020). - pub fn validate(&self) -> Result<(), ComputeError> { - if self.ntags == 0 { - return bad("ntags is 0".into()); - } - let g = self.graph; - if g.in_ptr.is_empty() { - return bad("in_ptr is empty (it holds nodes + 1 entries)".into()); - } - let n = g.nodes(); - if n > MAX_NODES { - return bad(format!("{n} nodes (at most {MAX_NODES})")); - } - let rows = n.checked_mul(self.row_width()); - if rows.is_none_or(|x| x > isize::MAX as usize / 8) { - return bad(format!("{n} nodes with {} tags overflow", self.ntags)); - } - validate_graph(g, self.ntags, self.luts.classes.len())?; - validate_bank(self.luts)?; - if self.load.len() != n { - return bad(format!("{} loads for {n} nodes", self.load.len())); - } - for (v, l) in self.load.iter().enumerate() { - check_load(v, l)?; - } - validate_starts(self.starts, n, self.ntags)?; - if !self.arc_values.is_empty() { - if self.arc_values.len() != g.arcs() { - return bad("RC arc value count".into()); - } - for rec in self.arc_values { - if rec.delay.iter().chain(&rec.slew).any(|x| !x.is_finite()) - || rec.slew.iter().any(|x| *x < 0.0) - { - return bad("invalid RC arc values".into()); - } - } - } - - if !self.wires.is_empty() { - if self.wires.len() != g.arcs() { - return bad(format!( - "{} wire records for {} arcs", - self.wires.len(), - g.arcs() - )); - } - for (k, (w, a)) in self.wires.iter().zip(g.in_arcs.iter()).enumerate() { - check_wire(k, w, self.luts.classes[a.class as usize].is_wire())?; - } - } - Ok(()) - } -} - -/// The graph and its tag maps. -fn validate_graph(g: &LevelizedTimingCsr, ntags: u32, classes: usize) -> Result<(), ComputeError> { - let n = g.nodes(); - if g.level_ptr.is_empty() { - return bad("level_ptr is empty".into()); - } - pointers("level_ptr", &g.level_ptr, n)?; - pointers("in_ptr", &g.in_ptr, g.in_arcs.len())?; - let nmaps = if g.map_ptr.is_empty() { - if !g.maps.is_empty() { - return bad("tag map records without map_ptr".into()); - } - 0 - } else { - pointers("map_ptr", &g.map_ptr, g.maps.len())?; - g.map_count() - }; - for (l, w) in g.level_ptr.windows(2).enumerate() { - let lo = w[0]; - for v in w[0] as usize..w[1] as usize { - let mut prev = 0; - for (i, a) in g.incoming(v).iter().enumerate() { - let k = g.in_ptr[v] as usize + i; - if a.src >= lo { - return bad(format!( - "in_arcs[{k}] into node {v} (level {l}): source {} is not on a lower level", - a.src - )); - } - if a.src < prev { - return bad(format!( - "in_arcs[{k}] into node {v}: sources are not sorted" - )); - } - prev = a.src; - if a.class as usize >= classes { - return bad(format!("in_arcs[{k}]: class {} of {classes}", a.class)); - } - if a.map != NONE && a.map as usize >= nmaps { - return bad(format!("in_arcs[{k}]: tag map {} of {nmaps}", a.map)); - } - } - } - } - if g.out_ptr.len() != n + 1 { - return bad(format!( - "out_ptr has {} entries for {n} nodes", - g.out_ptr.len() - )); - } - pointers("out_ptr", &g.out_ptr, g.out_dst.len())?; - let (ptr, dst) = super::abi::out_adjacency(n, &g.in_ptr, &g.in_arcs); - if let Some(u) = (0..n).find(|&u| ptr[u + 1] != g.out_ptr[u + 1]) { - return bad(format!( - "out_ptr of node {u} does not match the incoming arcs" - )); - } - if let Some(i) = (0..dst.len()).find(|&i| dst[i] != g.out_dst[i]) { - return bad(format!( - "out_dst[{i}] is {}, the incoming arcs give {}", - g.out_dst[i], dst[i] - )); - } - for (i, r) in g.maps.iter().enumerate() { - if r.src_tag >= ntags || r.dst_tag >= ntags { - return bad(format!( - "maps[{i}]: tags {} -> {} of {ntags}", - r.src_tag, r.dst_tag - )); - } - let ok = r.shift.is_finite() - && r.close_slew.iter().all(|x| x.is_finite()) - && r.open.iter().all(|&x| !x.is_nan() && x < f64::INFINITY) - && r.close - .iter() - .all(|&x| !x.is_nan() && x > f64::NEG_INFINITY) - && r.limit - .iter() - .all(|&x| !x.is_nan() && x > f64::NEG_INFINITY); - if !ok { - return bad(format!( - "maps[{i}]: shift and closing-edge transitions must be finite, open below \ - +inf, close and limit above -inf" - )); - } - } - Ok(()) -} - -/// The tables and classes of a bank. -fn validate_bank(b: &CompiledLutBank) -> Result<(), ComputeError> { - let d = &b.data; - if let Some(i) = d.iter().position(|x| !x.is_finite()) { - return bad(format!("data[{i}] is not finite")); - } - for (t, l) in b.luts.iter().enumerate() { - let axes = l.axes(); - if l.n[axes..].iter().any(|&n| n != 0) { - return bad(format!("luts[{t}]: an absent axis precedes an axis in use")); - } - for k in 0..axes { - let (off, n) = (l.off[k] as usize, l.n[k] as usize); - if l.arg[k] > 2 { - return bad(format!("luts[{t}]: axis {k} argument {}", l.arg[k])); - } - let Some(p) = d.get(off..off + n) else { - return bad(format!("luts[{t}]: axis {k} out of range")); - }; - if p.windows(2).any(|w| w[1] <= w[0]) { - return bad(format!("luts[{t}]: axis {k} is not strictly increasing")); - } - } - if d.get(l.voff as usize..l.voff as usize + l.values()) - .is_none() - { - return bad(format!("luts[{t}]: values out of range")); - } - } - let nl = b.luts.len(); - for (i, c) in b.classes.iter().enumerate() { - if c.flags & !FLAG_MASK != 0 { - return bad(format!("classes[{i}]: undefined flag bits {:#x}", c.flags)); - } - let tables = c.delay.iter().chain(&c.slew).chain(&c.setup); - if let Some(t) = tables.clone().find(|&&t| t != NONE && t as usize >= nl) { - return bad(format!("classes[{i}]: table {t} of {nl}")); - } - if c.is_wire() && (tables.into_iter().any(|&t| t != NONE) || c.flags & NEGATIVE_UNATE != 0) - { - return bad(format!( - "classes[{i}]: a wire class has no tables and does not invert" - )); - } - } - Ok(()) -} - -/// One node's loads: finite. -pub(crate) fn check_load(v: usize, l: &[f64; 4]) -> Result<(), ComputeError> { - if l.iter().all(|x| x.is_finite()) { - Ok(()) - } else { - bad(format!("load of node {v} is not finite")) - } -} - -/// One wire record: finite, and zero unless its arc is a wire arc. -pub(crate) fn check_wire(k: usize, w: &WireRec, wire_arc: bool) -> Result<(), ComputeError> { - if !w.delay.iter().chain(&w.slew).all(|x| x.is_finite()) { - return bad(format!("wires[{k}] is not finite")); - } - if !wire_arc && !w.is_zero() { - return bad(format!("wires[{k}] is not zero but its arc is no wire arc")); - } - Ok(()) -} - -/// Start rows for `n` nodes and `ntags` tags (COMPUTE-REQ-0015). -pub(crate) fn validate_starts(s: &StartRows, n: usize, ntags: u32) -> Result<(), ComputeError> { - let w = ntags as usize * 4; - if s.arr.len() != s.node.len() * w || s.slew.len() != s.node.len() * 4 { - return bad(format!( - "start rows: {} nodes, {} arrivals and {} transitions for {ntags} tags", - s.node.len(), - s.arr.len(), - s.slew.len() - )); - } - if let Some(i) = s.node.windows(2).position(|p| p[1] <= p[0]) { - return bad(format!("start rows: node[{}] does not increase", i + 1)); - } - if let Some(&v) = s.node.last() - && v as usize >= n - { - return bad(format!("start rows: node {v} of {n}")); - } - for (k, &v) in s.node.iter().enumerate() { - let (arr, slew) = s.row(k, ntags); - for (i, &x) in slew.iter().enumerate() { - if !value_ok(x, i % 2) { - return bad(format!("start row of node {v}: transition {i} is {x}")); - } - } - for (i, &x) in arr.iter().enumerate() { - let (tr_an, an) = (i % 4, i % 2); - if !value_ok(x, an) { - return bad(format!("start row of node {v}: arrival {i} is {x}")); - } - if !absent(x, an) && absent(slew[tr_an], an) { - return bad(format!( - "start row of node {v}: arrival {i} is present without its transition" - )); - } - } - } - Ok(()) -} diff --git a/crates/core/rusteda-compute/tests/rc_transient.rs b/crates/core/rusteda-compute/tests/rc_transient.rs deleted file mode 100644 index f49a317..0000000 --- a/crates/core/rusteda-compute/tests/rc_transient.rs +++ /dev/null @@ -1,155 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! External nodal transient validation, not a second RC tree implementation. -//! Test peak estimate: six sequential four-node circuits, <32 MiB each; no paths. -use rusteda_compute::rc::{self, Pi, Thresholds}; -use std::{ - collections::BTreeMap, - fs, - path::PathBuf, - process::{Command, Stdio}, -}; - -fn measurements(text: &str) -> BTreeMap { - text.lines() - .filter_map(|l| { - let (key, value) = l.split_once('=')?; - Some(( - key.trim().to_string(), - value.split_whitespace().next()?.parse().ok()?, - )) - }) - .collect() -} - -// STA-REQ-0724: the external simulator independently solves capacitor current ODEs. -#[test] -#[ignore = "requires RUSTEDA_SPICE_SIMULATOR and RUSTEDA_RC_ORACLE_WORKSPACE, with OS caps"] -fn simulator_charge_and_thresholds() { - let tool = PathBuf::from( - std::env::var("RUSTEDA_SPICE_SIMULATOR").expect("external simulator required"), - ); - let root = PathBuf::from( - std::env::var("RUSTEDA_RC_ORACLE_WORKSPACE").expect("evidence workspace required"), - ); - fs::create_dir(&root).unwrap(); - for (i, (res, derate, low, high)) in [ - (0.01, 1.0, 0.2, 0.8), - (1.0, 1.0, 0.2, 0.8), - (20.0, 1.0, 0.2, 0.8), - (0.1, 0.5, 0.3, 0.7), - (5.0, 0.5, 0.3, 0.7), - ] - .into_iter() - .enumerate() - { - let pi = Pi { - near: 0.01, - far: 0.02, - resistance: res, - }; - let t = Thresholds { - lower: low, - upper: high, - input: 0.5, - output: 0.5, - derate, - }; - // A bounded synthetic NLDM law; the oracle receives the fitted circuit, - // then checks its actual charge and measured voltage crossing times. - let lookup = |c: f64| (0.05 + 2.0 * c, 0.3 + 3.0 * c); - let r = rc::gate_detail(pi, t, lookup).unwrap(); - let stop = 0.1 + r.duration + 30.0 * (r.resistance * pi.total() + pi.resistance * pi.far); - let step = stop / 100000.0; - let path = root.join(format!("case{i}.cir")); - let log = root.join(format!("case{i}.log")); - let circuit = format!( - "RC driver transient\nVsrc src 0 PWL(0 0 0.1n 0 {end}n 1)\nRd src root {rd}\nRpi root far {rp}\nCn root 0 {cn}p\nCf far 0 {cf}p\nRnom src nom {rd}\nCnom nom 0 {ce}p\n.control\nset numdgt=17\ntran {step}n {stop}n\nmeas tran tlo WHEN v(root)={low} RISE=1\nmeas tran tmid WHEN v(root)=0.5 RISE=1\nmeas tran thi WHEN v(root)={high} RISE=1\nmeas tran ntlo WHEN v(nom)={low} RISE=1\nmeas tran ntmid WHEN v(nom)=0.5 RISE=1\nmeas tran vr FIND v(root) AT={end}n\nmeas tran vf FIND v(far) AT={end}n\nmeas tran vn FIND v(nom) AT={end}n\nquit\n.endc\n.end\n", - end = 0.1 + r.duration, - rd = r.resistance * 1000.0, - rp = pi.resistance * 1000.0, - cn = pi.near, - cf = pi.far, - ce = r.capacitance - ); - fs::write(&path, circuit).unwrap(); - let file = fs::File::create(&log).unwrap(); - let status = Command::new("timeout") - .args(["--kill-after=2s", "35s"]) - .arg(&tool) - .args(["-b"]) - .arg(&path) - .stdin(Stdio::null()) - .stdout(Stdio::from(file.try_clone().unwrap())) - .stderr(Stdio::from(file)) - .status() - .unwrap(); - assert!(status.success(), "simulator failed: {}", log.display()); - assert!(fs::metadata(&log).unwrap().len() < 1 << 20); - let m = measurements(&fs::read_to_string(log).unwrap()); - let crossing = (m["tmid"] * 1e9 - 0.1 - r.root_crossing).abs(); - let slew = ((m["thi"] - m["tlo"]) * 1e9 / derate - r.slew).abs(); - let fit = ((m["ntmid"] - m["ntlo"]) * 1e9 - - lookup(r.capacitance).1 * derate * (0.5 - low) / (high - low)) - .abs(); - let charge = (pi.near * m["vr"] + pi.far * m["vf"] - r.capacitance * m["vn"]).abs(); - fs::write(root.join(format!("case{i}.metrics")),format!("crossing_error_ns {crossing:e}\nslew_error_ns {slew:e}\nfit_error_ns {fit:e}\ncharge_error_pf {charge:e}\n")).unwrap(); - assert!(crossing < 1e-6 && slew < 1e-6 && fit < 1e-6 && charge < 1e-8); - } -} - -// STA-REQ-0722: ideal-source single-pole step/ramp response, independent transient oracle. -#[test] -#[ignore = "requires RUSTEDA_SPICE_SIMULATOR and a fresh RUSTEDA_RC_WIRE_WORKSPACE, with OS caps"] -fn simulator_wire_thresholds() { - let tool = std::env::var("RUSTEDA_SPICE_SIMULATOR").expect("external simulator required"); - let root = PathBuf::from( - std::env::var("RUSTEDA_RC_WIRE_WORKSPACE").expect("evidence workspace required"), - ); - fs::create_dir(&root).unwrap(); - for (i, (tau, s, primary, derate, low, high)) in [ - (0.02, 0.0, true, 1.0, 0.2, 0.8), - (0.002, 0.3, false, 1.0, 0.2, 0.8), - (0.1, 0.02, false, 1.0, 0.2, 0.8), - (0.2, 0.3, false, 0.5, 0.3, 0.7), - ] - .into_iter() - .enumerate() - { - let t = Thresholds { - lower: low, - upper: high, - input: 0.5, - output: 0.5, - derate, - }; - let (d, q) = rc::wire(tau, s, t, primary).unwrap(); - let duration = s * derate / (high - low); - let end = 0.1 + duration.max(1e-8); - let stop = end + 30.0 * tau; - let path = root.join(format!("wire{i}.cir")); - let log = root.join(format!("wire{i}.log")); - fs::write(&path,format!("RC interconnect transient\nVsrc in 0 PWL(0 0 0.1n 0 {end}n 1)\nR1 in out {r}\nC1 out 0 1p\n.control\nset numdgt=17\ntran {step}n {stop}n\nmeas tran tlo WHEN v(out)={low} RISE=1\nmeas tran tmid WHEN v(out)=0.5 RISE=1\nmeas tran thi WHEN v(out)={high} RISE=1\nquit\n.endc\n.end\n",r=tau*1000.0,step=stop/100000.0)).unwrap(); - let file = fs::File::create(&log).unwrap(); - let status = Command::new("timeout") - .args(["--kill-after=2s", "35s", &tool, "-b"]) - .arg(path) - .stdin(Stdio::null()) - .stdout(Stdio::from(file.try_clone().unwrap())) - .stderr(Stdio::from(file)) - .status() - .unwrap(); - assert!(status.success()); - assert!(fs::metadata(&log).unwrap().len() < 1 << 20); - let m = measurements(&fs::read_to_string(log).unwrap()); - let de = (m["tmid"] * 1e9 - 0.1 - duration * 0.5 - d).abs(); - let se = ((m["thi"] - m["tlo"]) * 1e9 / derate - q).abs(); - fs::write( - root.join(format!("wire{i}.metrics")), - format!("delay_error_ns {de:e}\nslew_error_ns {se:e}\n"), - ) - .unwrap(); - assert!(de < 1e-6 && se < 1e-6); - } -} diff --git a/crates/core/rusteda-core/Cargo.toml b/crates/core/rusteda-core/Cargo.toml deleted file mode 100644 index 5b624ac..0000000 --- a/crates/core/rusteda-core/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "rusteda-core" -description = "Core of the RustEDA tools: coded messages, the context of engine runs and the Liberty library model" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -authors = ["RustEDA contributors"] -repository.workspace = true - -[dependencies] -bytemuck.workspace = true -rustc-hash.workspace = true - -[lints] -workspace = true diff --git a/crates/core/rusteda-core/README.md b/crates/core/rusteda-core/README.md deleted file mode 100644 index bfc1988..0000000 --- a/crates/core/rusteda-core/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# rusteda-core - -The core of the RustEDA tools, shared by every tool of the suite (`docs/specs/core/`). - -- `diag`: `Diag`, a coded message with severity, code, optional file location and help, rendered - and parsed in the one message format (JOB-REQ-0007). -- `job`: the contract between an engine run and its observers (JOB-REQ): `CancelToken` and `Ctx` - for cooperative cancellation at checkpoints between parallel sections, `Event` and `Observer` - for the phases, steps, progress, metrics and messages of a run. -- `liberty`: the Liberty library model: `Library` with its units, templates (in name order) and - cells, `Cell`, `Pin`, `TimingArc`, `InternalPower`, `Sequential`; `Table` with bilinear lookup - and linear extrapolation; `Func`, a Boolean function with evaluation and truth tables. The reader - that builds the model from Liberty text is `rusteda_formats::liberty` (`docs/specs/liberty/`). -- `symbol`: `SymbolInterner`, one per database, with dense `SymbolId`s in insertion order. -- `arena`: generational `Arena` with typed `Id`; a removed entry's id is stale and is - reported, never reused for another entry; freed slots are reused last-freed-first. -- `names`: printable-ASCII and hierarchical name rules (`check_verbatim`, `check_hierarchical`), - segment escaping and unescaping (`escape_segment`, `join`, `unescape`, `segments`), and simple - identifier checks (`is_simple_identifier`). -- `netlist`: `GateNetlist`, the editable netlist of one flat module (cell types, ports, nets, - instances, pins, assignments) with checked edits: connect, disconnect and move pins, remove - instances, nets and assignments, pin-compatible cell swaps, and buffer insertion and removal. - `freeze` gives the `FrozenNetlist` (`NETLIST v1` sections), `validate` checks untrusted tables - without panicking, and `thaw` rebuilds the editable netlist. - -It uses no other workspace crate. Engines take a `Ctx` and never depend on a command line. diff --git a/crates/core/rusteda-core/src/arena.rs b/crates/core/rusteda-core/src/arena.rs deleted file mode 100644 index 2210314..0000000 --- a/crates/core/rusteda-core/src/arena.rs +++ /dev/null @@ -1,417 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Generational arena: entries addressed by typed ids that detect use after removal -//! (CORE-REQ-0101 to 0103). -//! -//! An [`Id`] holds a slot index and the generation of the slot when the entry was inserted. -//! Removing an entry increments the slot's generation, so ids of removed entries are stale: -//! lookups report them (`None` or [`StaleId`]) instead of reaching another entry. An id carries -//! no mark of its arena: an id of another arena is reported only when it addresses no live entry -//! of this one, so ids must stay with the arena that made them. Freed slots -//! are reused last-freed-first, which makes the ids a function of the operation sequence. A slot -//! whose generation reaches `u32::MAX` is retired and never reused, so generations never wrap. -//! -//! ``` -//! use rusteda_core::arena::Arena; -//! -//! let mut a = Arena::new(); -//! let x = a.insert("x"); -//! assert_eq!(a.remove(x), Ok("x")); -//! let y = a.insert("y"); -//! assert_eq!(y.index(), x.index(), "the freed slot is reused"); -//! assert!(a.get(x).is_none(), "the old id is stale"); -//! assert_eq!(a[y], "y"); -//! ``` - -use std::cmp::Ordering; -use std::fmt; -use std::hash::{Hash, Hasher}; -use std::marker::PhantomData; - -/// The id of an entry of an [`Arena`]: a slot index and a generation. Lookups check both, -/// which detects removed entries; an id of another arena that addresses a live entry of this one -/// is not detected. -pub struct Id { - index: u32, - generation: u32, - _entry: PhantomData T>, -} - -impl Id { - /// An id from its parts, for example read back from a handle; lookups check it like any - /// other id. - pub fn new(index: u32, generation: u32) -> Id { - Id { - index, - generation, - _entry: PhantomData, - } - } - - pub fn index(self) -> u32 { - self.index - } - - pub fn generation(self) -> u32 { - self.generation - } -} - -// Manual impls: derives would require the same traits of `T`. -impl Clone for Id { - fn clone(&self) -> Id { - *self - } -} - -impl Copy for Id {} - -impl PartialEq for Id { - fn eq(&self, other: &Id) -> bool { - (self.index, self.generation) == (other.index, other.generation) - } -} - -impl Eq for Id {} - -impl PartialOrd for Id { - fn partial_cmp(&self, other: &Id) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for Id { - fn cmp(&self, other: &Id) -> Ordering { - (self.index, self.generation).cmp(&(other.index, other.generation)) - } -} - -impl Hash for Id { - fn hash(&self, state: &mut H) { - self.index.hash(state); - self.generation.hash(state); - } -} - -impl fmt::Debug for Id { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Id({}#{})", self.index, self.generation) - } -} - -/// An id that addresses no live entry: its entry was removed, its slot does not exist, or it is -/// an id of another arena that addresses no live entry here (see [`Id`]). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct StaleId { - pub index: u32, - pub generation: u32, -} - -impl fmt::Display for StaleId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "stale id (slot {}, generation {})", - self.index, self.generation - ) - } -} - -impl std::error::Error for StaleId {} - -#[derive(Debug, Clone)] -struct Slot { - generation: u32, - value: Option, -} - -/// Entries of type `T` in slots addressed by [`Id`]. -#[derive(Debug, Clone)] -pub struct Arena { - slots: Vec>, - /// Free slot indices; the last one is reused first. - free: Vec, - len: usize, -} - -impl Default for Arena { - fn default() -> Arena { - Arena { - slots: Vec::new(), - free: Vec::new(), - len: 0, - } - } -} - -impl Arena { - pub fn new() -> Arena { - Arena::default() - } - - /// Adds an entry: in the most recently freed slot, otherwise in a new slot. - /// - /// # Panics - /// - /// When the arena would need more than `u32::MAX` slots. - pub fn insert(&mut self, value: T) -> Id { - self.len += 1; - if let Some(index) = self.free.pop() { - let slot = &mut self.slots[index as usize]; - slot.value = Some(value); - return Id::new(index, slot.generation); - } - let index = u32::try_from(self.slots.len()).expect("more than u32::MAX arena slots"); - self.slots.push(Slot { - generation: 0, - value: Some(value), - }); - Id::new(index, 0) - } - - /// Removes an entry. Its id and all copies of it become stale. - pub fn remove(&mut self, id: Id) -> Result { - let Some(slot) = self - .slots - .get_mut(id.index as usize) - .filter(|s| s.generation == id.generation) - else { - return Err(stale(id)); - }; - let value = slot.value.take().ok_or_else(|| stale(id))?; - // Live slots never have generation u32::MAX, so this cannot overflow. - slot.generation += 1; - if slot.generation < u32::MAX { - self.free.push(id.index); - } - self.len -= 1; - Ok(value) - } - - pub fn get(&self, id: Id) -> Option<&T> { - self.slots - .get(id.index as usize) - .filter(|s| s.generation == id.generation) - .and_then(|s| s.value.as_ref()) - } - - pub fn get_mut(&mut self, id: Id) -> Option<&mut T> { - self.slots - .get_mut(id.index as usize) - .filter(|s| s.generation == id.generation) - .and_then(|s| s.value.as_mut()) - } - - /// [`Arena::get`] with the stale id as the error. - pub fn try_get(&self, id: Id) -> Result<&T, StaleId> { - self.get(id).ok_or_else(|| stale(id)) - } - - /// [`Arena::get_mut`] with the stale id as the error. - pub fn try_get_mut(&mut self, id: Id) -> Result<&mut T, StaleId> { - self.get_mut(id).ok_or_else(|| stale(id)) - } - - pub fn contains(&self, id: Id) -> bool { - self.get(id).is_some() - } - - /// Number of live entries. - pub fn len(&self) -> usize { - self.len - } - - pub fn is_empty(&self) -> bool { - self.len == 0 - } - - /// Number of slots (live, free and retired): every id index is below it, so it sizes side - /// tables indexed by [`Id::index`]. - pub fn slot_count(&self) -> usize { - self.slots.len() - } - - /// Live entries in slot index order. - pub fn iter(&self) -> impl Iterator, &T)> + '_ { - self.slots.iter().enumerate().filter_map(|(i, s)| { - s.value - .as_ref() - .map(|v| (Id::new(i as u32, s.generation), v)) - }) - } - - /// Live entries in slot index order, mutably. - pub fn iter_mut(&mut self) -> impl Iterator, &mut T)> + '_ { - self.slots.iter_mut().enumerate().filter_map(|(i, s)| { - let generation = s.generation; - s.value.as_mut().map(|v| (Id::new(i as u32, generation), v)) - }) - } - - /// Ids of the live entries in slot index order. - pub fn ids(&self) -> impl Iterator> + '_ { - self.iter().map(|(id, _)| id) - } -} - -fn stale(id: Id) -> StaleId { - StaleId { - index: id.index, - generation: id.generation, - } -} - -/// Indexing panics when the id is stale. -impl std::ops::Index> for Arena { - type Output = T; - - fn index(&self, id: Id) -> &T { - match self.get(id) { - Some(v) => v, - None => panic!("{}", stale(id)), - } - } -} - -/// Indexing panics when the id is stale. -impl std::ops::IndexMut> for Arena { - fn index_mut(&mut self, id: Id) -> &mut T { - match self.get_mut(id) { - Some(v) => v, - None => panic!("{}", stale(id)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // CORE-REQ-0101: lookups check slot and generation; removed entries leave stale ids. - #[test] - fn insert_get_remove() { - let mut a: Arena = Arena::new(); - assert!(a.is_empty()); - let x = a.insert("x".into()); - let y = a.insert("y".into()); - assert_eq!((x.index(), x.generation(), y.index()), (0, 0, 1)); - assert_eq!(a.len(), 2); - a.get_mut(y).unwrap().push('!'); - assert_eq!(a.try_get(y).map(String::as_str), Ok("y!")); - assert_eq!(a.remove(x).as_deref(), Ok("x")); - assert_eq!(a.len(), 1); - assert!(!a.contains(x)); - assert_eq!(a.get(x), None); - assert_eq!(a.get_mut(x), None); - let e = a.remove(x).unwrap_err(); - assert_eq!( - e, - StaleId { - index: 0, - generation: 0 - } - ); - assert_eq!(e.to_string(), "stale id (slot 0, generation 0)"); - assert_eq!(a.try_get_mut(x), Err(e)); - // Ids beyond the slots are stale too. - assert!(a.get(Id::new(7, 0)).is_none()); - assert_eq!(a.slot_count(), 2); - } - - // CORE-REQ-0102: freed slots are reused last-freed-first with the next generation; old ids - // stay stale. - #[test] - fn reuse_is_lifo_and_generational() { - let mut a = Arena::new(); - let ids: Vec> = (0..4).map(|v| a.insert(v)).collect(); - a.remove(ids[1]).unwrap(); - a.remove(ids[3]).unwrap(); - let p = a.insert(10); - let q = a.insert(11); - assert_eq!((p.index(), p.generation()), (3, 1)); - assert_eq!((q.index(), q.generation()), (1, 1)); - assert!(a.get(ids[3]).is_none() && a.get(ids[1]).is_none()); - assert_eq!(a[p], 10); - let r = a.insert(12); - assert_eq!((r.index(), r.generation()), (4, 0)); - // Iteration follows slot indices, not insertion order. - let order: Vec = a.iter().map(|(_, v)| *v).collect(); - assert_eq!(order, [0, 11, 2, 10, 12]); - assert_eq!( - a.ids().collect::>(), - a.iter().map(|(id, _)| id).collect::>() - ); - for (_, v) in a.iter_mut() { - *v += 1; - } - assert_eq!(a[q], 12); - // The same operation sequence gives the same ids. - let mut b = Arena::new(); - let ids2: Vec> = (0..4).map(|v| b.insert(v)).collect(); - b.remove(ids2[1]).unwrap(); - b.remove(ids2[3]).unwrap(); - assert_eq!((b.insert(0), b.insert(0), b.insert(0)), (p, q, r)); - } - - // CORE-REQ-0103: a slot whose generation reaches u32::MAX is never handed out again. - #[test] - fn exhausted_slots_retire() { - let mut a = Arena::new(); - let x = a.insert('x'); - a.slots[0].generation = u32::MAX - 2; - let x = Id::new(x.index(), u32::MAX - 2); - assert_eq!(a.remove(x), Ok('x')); - let y = a.insert('y'); - assert_eq!((y.index(), y.generation()), (0, u32::MAX - 1)); - assert_eq!(a.remove(y), Ok('y')); - assert!(a.free.is_empty(), "slot retired"); - let z = a.insert('z'); - assert_eq!((z.index(), z.generation()), (1, 0)); - assert!(a.get(Id::new(0, u32::MAX)).is_none()); - assert!(a.get(y).is_none()); - assert_eq!(a.iter().count(), 1); - assert_eq!(a.slot_count(), 2); - } - - #[test] - fn ids_are_plain_values() { - struct NotClone; - let mut a = Arena::new(); - let id = a.insert(NotClone); - let copy = id; - let mut set = std::collections::HashSet::new(); - set.insert(id); - assert!(set.contains(©)); - assert!(Id::::new(0, 1) > id && Id::::new(1, 0) > id); - assert_eq!(format!("{id:?}"), "Id(0#0)"); - } - - // CORE-REQ-0101: an id carries no mark of its arena: one of another arena reaches a live - // entry here, and is reported only when it addresses none. - #[test] - fn ids_of_another_arena() { - let mut a = Arena::new(); - let mut b = Arena::new(); - let x = a.insert('x'); - b.insert('y'); - assert_eq!(b.get(x), Some(&'y')); - let z = a.insert('z'); - assert_eq!( - b.try_get(z), - Err(StaleId { - index: 1, - generation: 0 - }) - ); - } - - #[test] - #[should_panic(expected = "stale id")] - fn indexing_with_a_stale_id_panics() { - let mut a = Arena::new(); - let id = a.insert(1); - a.remove(id).unwrap(); - let _ = a[id]; - } -} diff --git a/crates/core/rusteda-core/src/diag.rs b/crates/core/rusteda-core/src/diag.rs deleted file mode 100644 index d5e9e68..0000000 --- a/crates/core/rusteda-core/src/diag.rs +++ /dev/null @@ -1,271 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Coded messages (JOB-REQ-0007): `[]: ::: `, the file -//! location optional. - -/// Message severity, in increasing order of importance. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Severity { - Note, - Warning, - Error, -} - -impl Severity { - pub fn as_str(self) -> &'static str { - match self { - Severity::Note => "note", - Severity::Warning => "warning", - Severity::Error => "error", - } - } - - fn parse(s: &str) -> Option { - match s { - "note" => Some(Severity::Note), - "warning" => Some(Severity::Warning), - "error" => Some(Severity::Error), - _ => None, - } - } -} - -/// Where a message points to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Location { - pub file: String, - pub line: u32, - pub column: u32, -} - -/// A coded message. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Diag { - pub severity: Severity, - /// One letter and four digits, for example `V0100`. - pub code: String, - pub location: Option, - pub message: String, - pub help: Option, -} - -impl Diag { - pub fn new(severity: Severity, code: impl Into, message: impl Into) -> Diag { - Diag { - severity, - code: code.into(), - location: None, - message: message.into(), - help: None, - } - } - - pub fn error(code: impl Into, message: impl Into) -> Diag { - Diag::new(Severity::Error, code, message) - } - - pub fn warning(code: impl Into, message: impl Into) -> Diag { - Diag::new(Severity::Warning, code, message) - } - - pub fn note(code: impl Into, message: impl Into) -> Diag { - Diag::new(Severity::Note, code, message) - } - - pub fn at(mut self, file: impl Into, line: u32, column: u32) -> Diag { - self.location = Some(Location { - file: file.into(), - line, - column, - }); - self - } - - pub fn with_help(mut self, help: impl Into) -> Diag { - self.help = Some(help.into()); - self - } - - /// The message line, without the help line. - pub fn render_first_line(&self) -> String { - match &self.location { - Some(l) => format!( - "{}[{}]: {}:{}:{}: {}", - self.severity.as_str(), - self.code, - l.file, - l.line, - l.column, - self.message - ), - None => format!( - "{}[{}]: {}", - self.severity.as_str(), - self.code, - self.message - ), - } - } - - /// The message line and, when there is help, a second line ` help: …`. - pub fn render(&self) -> String { - match &self.help { - Some(h) => format!("{}\n help: {h}", self.render_first_line()), - None => self.render_first_line(), - } - } - - /// Reads one message line. The file location is the shortest prefix of the text that ends - /// with `: ` and is `::` (JOB-REQ-0007). - pub fn parse(text: &str) -> Option { - let text = text.strip_suffix('\n').unwrap_or(text); - if text.contains('\n') { - return None; - } - let open = text.find('[')?; - let severity = Severity::parse(&text[..open])?; - let close = open + text[open..].find("]: ")?; - let code = &text[open + 1..close]; - let valid = code.len() == 5 - && code.as_bytes()[0].is_ascii_uppercase() - && code[1..].bytes().all(|b| b.is_ascii_digit()); - if !valid { - return None; - } - let rest = &text[close + 3..]; - let mut d = Diag::new(severity, code, rest); - let mut from = 0; - while let Some(i) = rest[from..].find(": ") { - let head = &rest[..from + i]; - from += i + 2; - if let Some(l) = parse_location(head) { - d.location = Some(l); - d.message = rest[from..].to_string(); - break; - } - } - Some(d) - } -} - -/// `::` with a non-empty file part. -fn parse_location(head: &str) -> Option { - let mut parts = head.rsplitn(3, ':'); - let (Some(col), Some(line), Some(file)) = (parts.next(), parts.next(), parts.next()) else { - return None; - }; - let (Ok(line), Ok(column)) = (line.parse::(), col.parse::()) else { - return None; - }; - (!file.is_empty()).then(|| Location { - file: file.to_string(), - line, - column, - }) -} - -impl std::fmt::Display for Diag { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.render()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // JOB-REQ-0007 - #[test] - fn render_and_parse_round_trip() { - let cases = [ - Diag::error("V0002", "unknown module 'x'").at("top.sv", 3, 7), - Diag::warning("A0007", "--run-dir runs/u9 is not empty"), - Diag::note("L0003", "cell LAT: pin Q: arc from pin 'G' ignored").at("a:b.sv", 1, 2), - Diag::error("V0100", "expected ';': found 'x'").at("dir/f.sv", 10, 1), - ]; - for d in cases { - let line = d.render_first_line(); - assert_eq!(Diag::parse(&line), Some(d.clone()), "{line}"); - } - let h = Diag::error("A0008", "no runs root").with_help("give --runs-root DIR"); - assert_eq!( - h.render(), - "error[A0008]: no runs root\n help: give --runs-root DIR" - ); - // A message stays small enough to be the error type of results. - assert!(size_of::() <= 120, "{}", size_of::()); - } - - /// A deterministic generator (SplitMix64) for the property test below. - struct Gen(u64); - - impl Gen { - fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut z = self.0; - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - } - fn below(&mut self, n: usize) -> usize { - (self.next() % n as u64) as usize - } - fn pick<'a>(&mut self, xs: &[&'a str]) -> &'a str { - xs[self.below(xs.len())] - } - fn name(&mut self) -> String { - let n = 1 + self.below(8); - (0..n) - .map(|_| self.pick(&["a", "b", "z", "0", "9", "_", "-", ".", "µ", "Q"])) - .collect() - } - } - - // JOB-REQ-0007 (property): `Diag::parse(&d.render_first_line()) == Some(d)` for every diag - // without help, with or without file location. Message texts are words without `:` joined - // by blanks or by `: `, so that a text never starts with a location of its own. - #[test] - fn render_parse_property() { - let mut g = Gen(0x5EED); - for _ in 0..20_000 { - let sev = [Severity::Note, Severity::Warning, Severity::Error][g.below(3)]; - let letter = char::from(b'A' + g.below(26) as u8); - let code = format!("{letter}{:04}", g.below(10_000)); - let words = 1 + g.below(6); - let mut message = String::new(); - for i in 0..words { - if i > 0 { - message.push_str(g.pick(&[" ", ": ", " (", ") "])); - } - message.push_str(g.pick(&[ - "x", "no", "model", "'a'", "M1", "#7", "@x", "/p", "(", ")", "µ", - ])); - } - let mut d = Diag::new(sev, code, message); - if g.below(2) == 0 { - let file = format!("{}{}", g.pick(&["", "dir/", "a:b/", "c:\\x\\"]), g.name()); - d = d.at(file + ".sv", g.next() as u32, g.next() as u32); - } - let line = d.render_first_line(); - assert_eq!(Diag::parse(&line), Some(d), "{line}"); - } - } - - #[test] - fn rejects_non_messages() { - for t in [ - "", - "hello", - "error: plain", - "error[S01]: short code", - "fatal[S0100]: x", - "error[S0100]: a\nb", - ] { - assert_eq!(Diag::parse(t), None, "{t}"); - } - let d = Diag::parse("error[D0002]: SDC 3:5: bad").unwrap(); - assert_eq!(d.location, None); - assert_eq!(d.message, "SDC 3:5: bad"); - } -} diff --git a/crates/core/rusteda-core/src/job.rs b/crates/core/rusteda-core/src/job.rs deleted file mode 100644 index bedd0bc..0000000 --- a/crates/core/rusteda-core/src/job.rs +++ /dev/null @@ -1,261 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Progress events, cancellation and messages of engine runs (`docs/specs/core/`, JOB-REQ). - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -pub use crate::diag::{Diag, Location, Severity}; - -/// A shared cancellation flag (JOB-REQ-0001). -#[derive(Debug, Clone, Default)] -pub struct CancelToken(Arc); - -impl CancelToken { - pub fn new() -> CancelToken { - CancelToken::default() - } - - /// Asks every holder of the token to stop at its next checkpoint. - pub fn cancel(&self) { - self.0.store(true, Ordering::Relaxed); - } - - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::Relaxed) - } -} - -/// A run was cancelled at a checkpoint. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Cancelled; - -impl std::fmt::Display for Cancelled { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("cancelled") - } -} - -impl std::error::Error for Cancelled {} - -/// The failure of a job: cancelled, or failed with the engine's error (JOB-REQ-0006). -#[derive(Debug, Clone, PartialEq)] -pub enum JobError { - Cancelled, - Failed(E), -} - -impl From for JobError { - fn from(_: Cancelled) -> JobError { - JobError::Cancelled - } -} - -impl JobError { - pub fn map(self, f: impl FnOnce(E) -> F) -> JobError { - match self { - JobError::Cancelled => JobError::Cancelled, - JobError::Failed(e) => JobError::Failed(f(e)), - } - } -} - -/// What a run reports while it works (JOB-REQ-0003). -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub enum Event { - /// A phase begins. - PhaseStarted { phase: &'static str }, - /// A phase ended after `seconds`, the value the run records in its report. - PhaseFinished { phase: &'static str, seconds: f64 }, - /// Step `ordinal` (from 1) of `count` (0: unknown) of a phase begins. - Step { - phase: &'static str, - step: &'static str, - ordinal: u32, - count: u32, - }, - /// `done` of `total` units of the current step are finished. - Progress { - phase: &'static str, - done: u64, - total: u64, - }, - /// A named figure of the run (for example the node count of a graph). - Metric { name: &'static str, value: f64 }, - /// A message, in the order in which it arose. - Diagnostic(Diag), -} - -/// Receives the events of a run; called on the thread that runs the phases. -pub trait Observer: Sync { - fn event(&self, e: &Event); -} - -/// The context of a run: an optional cancel token and an optional observer (JOB-REQ-0002). -#[derive(Clone, Copy)] -pub struct Ctx<'a> { - cancel: Option<&'a CancelToken>, - obs: Option<&'a dyn Observer>, -} - -impl std::fmt::Debug for Ctx<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Ctx") - .field("cancel", &self.cancel) - .field("observed", &self.obs.is_some()) - .finish() - } -} - -impl<'a> Ctx<'a> { - /// No token and no observer: the plain library call. - pub const NONE: Ctx<'static> = Ctx { - cancel: None, - obs: None, - }; - - pub fn new(cancel: Option<&'a CancelToken>, obs: Option<&'a dyn Observer>) -> Ctx<'a> { - Ctx { cancel, obs } - } - - /// `Err(Cancelled)` when the token is set. - #[inline] - pub fn checkpoint(&self) -> Result<(), Cancelled> { - match self.cancel { - Some(t) if t.is_cancelled() => Err(Cancelled), - _ => Ok(()), - } - } - - #[inline] - pub fn observed(&self) -> bool { - self.obs.is_some() - } - - /// The same cancel token without the observer (JOB-REQ-0002). - #[inline] - pub fn unobserved(&self) -> Ctx<'a> { - Ctx { - cancel: self.cancel, - obs: None, - } - } - - /// Passes the event built by `f` to the observer; `f` runs only when there is one. - #[inline] - pub fn emit(&self, f: impl FnOnce() -> Event) { - if let Some(o) = self.obs { - o.event(&f()); - } - } - - /// Emits `Progress` when `done == total` or `done` is a multiple of - /// `max(1, ceil(total / 256))` (JOB-REQ-0004). - #[inline] - pub fn progress(&self, phase: &'static str, done: u64, total: u64) { - if let Some(o) = self.obs { - let stride = total.div_ceil(256).max(1); - if done == total || done.is_multiple_of(stride) { - o.event(&Event::Progress { phase, done, total }); - } - } - } - - /// A message as a `Diagnostic` event. - #[inline] - pub fn diag(&self, d: impl FnOnce() -> Diag) { - self.emit(|| Event::Diagnostic(d())); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - #[derive(Default)] - struct Record(Mutex>); - - impl Observer for Record { - fn event(&self, e: &Event) { - self.0.lock().unwrap().push(e.clone()); - } - } - - // JOB-REQ-0001, JOB-REQ-0002 - #[test] - fn checkpoint_follows_the_token() { - let t = CancelToken::new(); - let ctx = Ctx::new(Some(&t), None); - assert_eq!(ctx.checkpoint(), Ok(())); - t.clone().cancel(); - assert_eq!(ctx.checkpoint(), Err(Cancelled)); - assert_eq!(Ctx::NONE.checkpoint(), Ok(())); - } - - // JOB-REQ-0002: without an observer no event is built. - #[test] - fn unobserved_builds_nothing() { - let built = std::cell::Cell::new(0); - Ctx::NONE.emit(|| { - built.set(built.get() + 1); - Event::Metric { - name: "x", - value: 1.0, - } - }); - assert_eq!(built.get(), 0); - assert!(!Ctx::NONE.observed()); - } - - // JOB-REQ-0002: an unobserved copy keeps the cancel token and drops the observer. - #[test] - fn unobserved_copy_keeps_cancellation() { - let t = CancelToken::new(); - let r = Record::default(); - let ctx = Ctx::new(Some(&t), Some(&r)); - let quiet = ctx.unobserved(); - assert!(ctx.observed() && !quiet.observed()); - quiet.emit(|| Event::Metric { - name: "x", - value: 1.0, - }); - assert!(r.0.lock().unwrap().is_empty()); - t.cancel(); - assert_eq!(quiet.checkpoint(), Err(Cancelled)); - } - - // JOB-REQ-0004 - #[test] - fn progress_is_bounded_and_complete() { - for total in [0u64, 1, 7, 255, 256, 257, 1000, 10_000_000] { - let r = Record::default(); - let ctx = Ctx::new(None, Some(&r)); - for done in 1..=total.min(20_000_000) { - ctx.progress("p", done, total); - } - let ev = r.0.into_inner().unwrap(); - assert!(ev.len() <= 257, "{total}: {} events", ev.len()); - if total > 0 { - assert_eq!( - ev.last(), - Some(&Event::Progress { - phase: "p", - done: total, - total - }) - ); - } - } - } - - // JOB-REQ-0006 - #[test] - fn job_error_maps() { - let e: JobError = Cancelled.into(); - assert_eq!(e.map(|x| x + 1), JobError::Cancelled); - assert_eq!(JobError::Failed(1).map(|x| x + 1), JobError::Failed(2)); - } -} diff --git a/crates/core/rusteda-core/src/lib.rs b/crates/core/rusteda-core/src/lib.rs deleted file mode 100644 index cac0839..0000000 --- a/crates/core/rusteda-core/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The core of the RustEDA tools (`docs/specs/core/`): coded messages ([`diag`]), the context of -//! engine runs ([`job`]), the Liberty library model ([`liberty`]), per-database symbol interning -//! ([`symbol`]), generational arenas with typed ids ([`arena`]), netlist object names ([`names`]) -//! and the gate-level netlist with its dense, frozen view ([`netlist`]). - -pub mod arena; -pub mod diag; -pub mod job; -pub mod liberty; -pub mod names; -pub mod netlist; -pub mod symbol; diff --git a/crates/core/rusteda-core/src/liberty/func.rs b/crates/core/rusteda-core/src/liberty/func.rs deleted file mode 100644 index 7c30ce9..0000000 --- a/crates/core/rusteda-core/src/liberty/func.rs +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Liberty Boolean functions: names, evaluation and truth tables (LIBERTY-REQ-0022). The parser -//! of function text is `rusteda_formats::liberty::parse_function` (LIBERTY-REQ-0020). - -use std::fmt; - -/// A Boolean function. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum Func { - Const(bool), - Var(String), - Not(Box), - And(Box, Box), - Or(Box, Box), - Xor(Box, Box), -} - -impl Func { - /// Names referenced by the function, in order of first appearance. - pub fn vars(&self) -> Vec { - let mut out = Vec::new(); - self.collect_vars(&mut out); - out - } - - fn collect_vars(&self, out: &mut Vec) { - match self { - Func::Const(_) => {} - Func::Var(v) => { - if !out.contains(v) { - out.push(v.clone()); - } - } - Func::Not(a) => a.collect_vars(out), - Func::And(a, b) | Func::Or(a, b) | Func::Xor(a, b) => { - a.collect_vars(out); - b.collect_vars(out); - } - } - } - - /// Evaluates the function under an assignment given by a lookup closure. - pub fn eval(&self, value: &dyn Fn(&str) -> Option) -> Option { - Some(match self { - Func::Const(c) => *c, - Func::Var(v) => value(v)?, - Func::Not(a) => !a.eval(value)?, - Func::And(a, b) => a.eval(value)? & b.eval(value)?, - Func::Or(a, b) => a.eval(value)? | b.eval(value)?, - Func::Xor(a, b) => a.eval(value)? ^ b.eval(value)?, - }) - } - - /// Truth table over at most 6 ordered variables (LIBERTY-REQ-0022). Bit i of the result is - /// the value under the assignment where variable j equals bit j of i. Returns `None` if the - /// function refers to a name not in `vars` or if more than 6 variables are given. - pub fn truth_table(&self, vars: &[&str]) -> Option { - if vars.len() > 6 { - return None; - } - self.tt_rec(vars) - } - - fn tt_rec(&self, vars: &[&str]) -> Option { - let n = vars.len(); - let mask = if n == 6 { - u64::MAX - } else { - (1u64 << (1u32 << n)) - 1 - }; - Some(match self { - Func::Const(false) => 0, - Func::Const(true) => mask, - Func::Var(v) => { - let j = vars.iter().position(|x| x == v)?; - var_mask(j) & mask - } - Func::Not(a) => !a.tt_rec(vars)? & mask, - Func::And(a, b) => a.tt_rec(vars)? & b.tt_rec(vars)?, - Func::Or(a, b) => a.tt_rec(vars)? | b.tt_rec(vars)?, - Func::Xor(a, b) => a.tt_rec(vars)? ^ b.tt_rec(vars)?, - }) - } -} - -/// Truth-table mask of variable j (0..6) over 6 variables. -pub fn var_mask(j: usize) -> u64 { - const M: [u64; 6] = [ - 0xAAAA_AAAA_AAAA_AAAA, - 0xCCCC_CCCC_CCCC_CCCC, - 0xF0F0_F0F0_F0F0_F0F0, - 0xFF00_FF00_FF00_FF00, - 0xFFFF_0000_FFFF_0000, - 0xFFFF_FFFF_0000_0000, - ]; - M[j] -} - -impl fmt::Display for Func { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Func::Const(c) => write!(f, "{}", u8::from(*c)), - Func::Var(v) => write!(f, "{v}"), - Func::Not(a) => write!(f, "!({a})"), - Func::And(a, b) => write!(f, "({a} & {b})"), - Func::Or(a, b) => write!(f, "({a} | {b})"), - Func::Xor(a, b) => write!(f, "({a} ^ {b})"), - } - } -} diff --git a/crates/core/rusteda-core/src/liberty/kinds.rs b/crates/core/rusteda-core/src/liberty/kinds.rs deleted file mode 100644 index 687d2a6..0000000 --- a/crates/core/rusteda-core/src/liberty/kinds.rs +++ /dev/null @@ -1,306 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Enumerated attribute values of the library model: timing types and senses (LIBERTY-REQ-0023, -//! LIBERTY-REQ-0024), table variables (LIBERTY-REQ-0031), power and ground pin types -//! (LIBERTY-REQ-0042), the types of special cells (LIBERTY-REQ-0040), wire-load modes -//! (LIBERTY-REQ-0047) and interconnect tree types (LIBERTY-REQ-0048). Each enumeration maps the -//! Liberty names one to one and keeps any other name as `Other` with its text. - -use std::fmt; - -/// Defines an enumeration of Liberty names with an `Other` variant for unknown names, its -/// conversions from and to the Liberty name, and its `Display` (the Liberty name). -macro_rules! liberty_names { - ( - $(#[$meta:meta])* - pub enum $name:ident { - $($(#[$vmeta:meta])* $var:ident = $text:literal,)* - } - ) => { - $(#[$meta])* - #[derive(Debug, Clone, PartialEq, Eq, Hash)] - #[non_exhaustive] - pub enum $name { - $($(#[$vmeta])* $var,)* - /// A name that is not one of the above, as written in the library. - Other(String), - } - - impl $name { - /// The value with the Liberty name `name`; any other name gives `Other(name)`. - pub fn from_name(name: &str) -> $name { - match name { - $($text => $name::$var,)* - other => $name::Other(other.to_string()), - } - } - - /// The Liberty name of the value. - pub fn name(&self) -> &str { - match self { - $($name::$var => $text,)* - $name::Other(text) => text, - } - } - - /// False for `Other`. - pub fn is_known(&self) -> bool { - !matches!(self, $name::Other(_)) - } - } - - impl fmt::Display for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.name()) - } - } - }; -} - -liberty_names! { - /// The `timing_type` of a timing arc (LIBERTY-REQ-0023). - pub enum TimingType { - Combinational = "combinational", - CombinationalRise = "combinational_rise", - CombinationalFall = "combinational_fall", - ThreeStateEnable = "three_state_enable", - ThreeStateEnableRise = "three_state_enable_rise", - ThreeStateEnableFall = "three_state_enable_fall", - ThreeStateDisable = "three_state_disable", - ThreeStateDisableRise = "three_state_disable_rise", - ThreeStateDisableFall = "three_state_disable_fall", - RisingEdge = "rising_edge", - FallingEdge = "falling_edge", - Preset = "preset", - Clear = "clear", - SetupRising = "setup_rising", - SetupFalling = "setup_falling", - HoldRising = "hold_rising", - HoldFalling = "hold_falling", - RecoveryRising = "recovery_rising", - RecoveryFalling = "recovery_falling", - RemovalRising = "removal_rising", - RemovalFalling = "removal_falling", - SkewRising = "skew_rising", - SkewFalling = "skew_falling", - NonSeqSetupRising = "non_seq_setup_rising", - NonSeqSetupFalling = "non_seq_setup_falling", - NonSeqHoldRising = "non_seq_hold_rising", - NonSeqHoldFalling = "non_seq_hold_falling", - NochangeHighHigh = "nochange_high_high", - NochangeHighLow = "nochange_high_low", - NochangeLowHigh = "nochange_low_high", - NochangeLowLow = "nochange_low_low", - MinPulseWidth = "min_pulse_width", - MinimumPeriod = "minimum_period", - MaxClockTreePath = "max_clock_tree_path", - MinClockTreePath = "min_clock_tree_path", - } -} - -liberty_names! { - /// The `timing_sense` of a timing arc (LIBERTY-REQ-0024). - pub enum TimingSense { - PositiveUnate = "positive_unate", - NegativeUnate = "negative_unate", - NonUnate = "non_unate", - } -} - -liberty_names! { - /// A variable of a lookup-table template: what one axis of a table measures - /// (LIBERTY-REQ-0031). - pub enum TableVariable { - InputNetTransition = "input_net_transition", - TotalOutputNetCapacitance = "total_output_net_capacitance", - RelatedPinTransition = "related_pin_transition", - ConstrainedPinTransition = "constrained_pin_transition", - OutputNetLength = "output_net_length", - OutputNetWireCap = "output_net_wire_cap", - OutputNetPinCap = "output_net_pin_cap", - RelatedOutTotalOutputNetCapacitance = "related_out_total_output_net_capacitance", - RelatedOutOutputNetLength = "related_out_output_net_length", - RelatedOutOutputNetWireCap = "related_out_output_net_wire_cap", - RelatedOutOutputNetPinCap = "related_out_output_net_pin_cap", - EqualOrOppositeOutputNetCapacitance = "equal_or_opposite_output_net_capacitance", - InputTransitionTime = "input_transition_time", - NormalizedVoltage = "normalized_voltage", - Time = "time", - InputVoltage = "input_voltage", - OutputVoltage = "output_voltage", - InputNoiseHeight = "input_noise_height", - InputNoiseWidth = "input_noise_width", - } -} - -liberty_names! { - /// The `pg_type` of a power or ground pin (LIBERTY-REQ-0042). - pub enum PgType { - PrimaryPower = "primary_power", - PrimaryGround = "primary_ground", - BackupPower = "backup_power", - BackupGround = "backup_ground", - InternalPower = "internal_power", - InternalGround = "internal_ground", - Nwell = "nwell", - Pwell = "pwell", - DeepNwell = "deepnwell", - DeepPwell = "deeppwell", - } -} - -liberty_names! { - /// The `level_shifter_type` of a level-shifter cell (LIBERTY-REQ-0040): low to high, high - /// to low, or both. - pub enum LevelShifterType { - LowToHigh = "LH", - HighToLow = "HL", - Both = "HL_LH", - } -} - -liberty_names! { - /// The `switch_cell_type` of a power-switch cell (LIBERTY-REQ-0040). - pub enum SwitchCellType { - CoarseGrain = "coarse_grain", - FineGrain = "fine_grain", - } -} - -liberty_names! { - /// The `antenna_diode_type` of an antenna-diode cell (LIBERTY-REQ-0040). - pub enum AntennaDiodeType { - Power = "power", - Ground = "ground", - PowerAndGround = "power_and_ground", - } -} - -liberty_names! { - /// The `default_wire_load_mode` of a library (LIBERTY-REQ-0047). - pub enum WireLoadMode { - Top = "top", - Enclosed = "enclosed", - Segmented = "segmented", - } -} - -liberty_names! { - /// The `tree_type` of an `operating_conditions` group (LIBERTY-REQ-0048). - pub enum TreeType { - BestCaseTree = "best_case_tree", - BalancedTree = "balanced_tree", - WorstCaseTree = "worst_case_tree", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // LIBERTY-REQ-0023: every timing type round-trips through its Liberty name; any other name - // is kept as `Other`. - #[test] - fn timing_type_names() { - let names = [ - "combinational", - "combinational_rise", - "combinational_fall", - "three_state_enable", - "three_state_enable_rise", - "three_state_enable_fall", - "three_state_disable", - "three_state_disable_rise", - "three_state_disable_fall", - "rising_edge", - "falling_edge", - "preset", - "clear", - "setup_rising", - "setup_falling", - "hold_rising", - "hold_falling", - "recovery_rising", - "recovery_falling", - "removal_rising", - "removal_falling", - "skew_rising", - "skew_falling", - "non_seq_setup_rising", - "non_seq_setup_falling", - "non_seq_hold_rising", - "non_seq_hold_falling", - "nochange_high_high", - "nochange_high_low", - "nochange_low_high", - "nochange_low_low", - "min_pulse_width", - "minimum_period", - "max_clock_tree_path", - "min_clock_tree_path", - ]; - for n in names { - let t = TimingType::from_name(n); - assert!(t.is_known(), "{n}"); - assert_eq!(t.name(), n); - assert_eq!(t.to_string(), n); - } - assert_eq!( - TimingType::from_name("setup_rising"), - TimingType::SetupRising - ); - let other = TimingType::from_name("retaining_rise_edge"); - assert_eq!(other, TimingType::Other("retaining_rise_edge".into())); - assert!(!other.is_known()); - assert_eq!(other.name(), "retaining_rise_edge"); - } - - // LIBERTY-REQ-0024, LIBERTY-REQ-0031, LIBERTY-REQ-0042, LIBERTY-REQ-0040, LIBERTY-REQ-0047, - // LIBERTY-REQ-0048 - #[test] - fn other_enumerations() { - assert_eq!( - TimingSense::from_name("negative_unate"), - TimingSense::NegativeUnate - ); - assert!(!TimingSense::from_name("unate").is_known()); - assert_eq!( - TableVariable::from_name("related_out_output_net_pin_cap"), - TableVariable::RelatedOutOutputNetPinCap - ); - assert_eq!(TableVariable::Time.name(), "time"); - assert_eq!(PgType::from_name("deepnwell"), PgType::DeepNwell); - assert_eq!(PgType::from_name("backup_ground"), PgType::BackupGround); - assert_eq!(LevelShifterType::from_name("HL_LH"), LevelShifterType::Both); - assert_eq!( - SwitchCellType::from_name("fine_grain"), - SwitchCellType::FineGrain - ); - assert_eq!( - AntennaDiodeType::from_name("power_and_ground"), - AntennaDiodeType::PowerAndGround - ); - assert_eq!( - AntennaDiodeType::from_name("diode"), - AntennaDiodeType::Other("diode".into()) - ); - assert_eq!(WireLoadMode::from_name("top"), WireLoadMode::Top); - assert_eq!(WireLoadMode::from_name("enclosed"), WireLoadMode::Enclosed); - assert_eq!( - WireLoadMode::from_name("segmented"), - WireLoadMode::Segmented - ); - assert!(!WireLoadMode::from_name("custom").is_known()); - assert_eq!( - TreeType::from_name("best_case_tree"), - TreeType::BestCaseTree - ); - assert_eq!(TreeType::from_name("balanced_tree"), TreeType::BalancedTree); - assert_eq!( - TreeType::from_name("worst_case_tree"), - TreeType::WorstCaseTree - ); - assert!(!TreeType::from_name("binary_tree").is_known()); - } -} diff --git a/crates/core/rusteda-core/src/liberty/mod.rs b/crates/core/rusteda-core/src/liberty/mod.rs deleted file mode 100644 index a40ffa0..0000000 --- a/crates/core/rusteda-core/src/liberty/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The Liberty library model, specified in `docs/specs/core/` (section "Liberty library -//! model"): units, cells, pins, timing arcs, lookup tables, Boolean functions and the enumerated -//! attribute values. The reader that builds it from Liberty text is `rusteda_formats::liberty` -//! (`docs/specs/liberty/`). - -mod func; -mod kinds; -mod model; -mod table; -mod units; - -pub use func::{Func, var_mask}; -pub use kinds::{ - AntennaDiodeType, LevelShifterType, PgType, SwitchCellType, TableVariable, TimingSense, - TimingType, TreeType, WireLoadMode, -}; -pub use model::{ - Cell, Direction, InternalPower, Library, OperatingConditions, PgPin, Pin, SeqKind, Sequential, - Statetable, Template, TimingArc, WireLoad, WireLoadAreaRule, WireLoadSelection, WireLoadTable, -}; -pub use table::Table; -pub use units::{Unit, Units}; diff --git a/crates/core/rusteda-core/src/liberty/model.rs b/crates/core/rusteda-core/src/liberty/model.rs deleted file mode 100644 index e9f3f00..0000000 --- a/crates/core/rusteda-core/src/liberty/model.rs +++ /dev/null @@ -1,723 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Libraries, cells, pins, timing arcs, sequential groups, wire loads and operating conditions -//! (LIBERTY-REQ-0010 to LIBERTY-REQ-0019, LIBERTY-REQ-0021, LIBERTY-REQ-0023 to LIBERTY-REQ-0026, -//! LIBERTY-REQ-0031 and LIBERTY-REQ-0040 to LIBERTY-REQ-0048). - -use std::collections::BTreeMap; - -use super::func::Func; -use super::kinds::{ - AntennaDiodeType, LevelShifterType, PgType, SwitchCellType, TableVariable, TimingSense, - TimingType, TreeType, WireLoadMode, -}; -use super::table::Table; -use super::units::Units; - -/// Pin direction. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum Direction { - #[default] - Input, - Output, - Inout, - Internal, -} - -/// One timing arc from `related_pin` to the owning pin. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct TimingArc { - pub related_pin: String, - pub timing_sense: Option, - pub timing_type: String, - pub cell_rise: Option, - pub cell_fall: Option
, - pub rise_transition: Option
, - pub fall_transition: Option
, - /// Constraint tables of timing checks (setup, hold, recovery, removal). - pub rise_constraint: Option
, - pub fall_constraint: Option
, - /// Raw `when` condition text, when the timing group has one (LIBERTY-REQ-0026). - pub when_text: Option, - /// Parsed `when` condition, when `when_text` parses (LIBERTY-REQ-0026). - pub when: Option, - /// Raw `sdf_cond` text, when the timing group has one (LIBERTY-REQ-0026). - pub sdf_cond: Option, - /// 1-based line number of the `timing` group in the `.lib` file, or 0 when synthetic - /// (LIBERTY-REQ-0046). - pub line: u32, -} - -impl TimingArc { - /// The timing type of `timing_type` (LIBERTY-REQ-0023). - pub fn kind(&self) -> TimingType { - TimingType::from_name(self.timing_type.trim()) - } - - /// The timing sense of `timing_sense`, when the arc has one (LIBERTY-REQ-0024). - pub fn sense(&self) -> Option { - self.timing_sense - .as_deref() - .map(|s| TimingSense::from_name(s.trim())) - } -} - -/// One `internal_power` entry of a pin (LIBERTY-REQ-0016): energy per transition. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct InternalPower { - pub related_pin: Option, - pub rise_power: Option
, - pub fall_power: Option
, -} - -/// A cell pin. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Pin { - pub name: String, - pub direction: Direction, - /// Raw `function` text, unparsed. - pub function_text: Option, - /// Parsed function, when the text parses. - pub function: Option, - /// Raw `three_state` text: the condition under which the pin is in high impedance. - pub three_state: Option, - /// Parsed `three_state` function, when the text parses (LIBERTY-REQ-0025). - pub three_state_function: Option, - /// `capacitance`, in library units; 0 when the pin states none (see `capacitance_given`). - pub capacitance: f64, - /// True when the pin states a valid `capacitance` (LIBERTY-REQ-0018). - pub capacitance_given: bool, - pub rise_capacitance: Option, - pub fall_capacitance: Option, - /// `rise_capacitance_range` and `fall_capacitance_range`: (minimum, maximum). - pub rise_capacitance_range: Option<(f64, f64)>, - pub fall_capacitance_range: Option<(f64, f64)>, - /// `min_capacitance`: the smallest load the pin may drive, in library units. - pub min_capacitance: Option, - /// Pin design-rule limits, in library units. Missing limits inherit library defaults. - pub max_capacitance: Option, - pub max_transition: Option, - pub max_fanout: Option, - /// Dimensionless input load for the fanout design rule. - pub fanout_load: Option, - /// `min_pulse_width_high` and `min_pulse_width_low`: the unconditional minimum widths of the - /// high and the low pulse at the pin, in library units (LIBERTY-REQ-0049). - pub min_pulse_width_high: Option, - pub min_pulse_width_low: Option, - /// `min_period`: the unconditional minimum period of the signal at the pin, in library units - /// (LIBERTY-REQ-0049). - pub min_period: Option, - pub is_clock: bool, - pub arcs: Vec, - /// `internal_power` entries (LIBERTY-REQ-0016). - pub internal_power: Vec, - /// `isolation_cell_enable_pin` and `isolation_cell_data_pin` (LIBERTY-REQ-0041). - pub isolation_cell_enable_pin: bool, - pub isolation_cell_data_pin: bool, - /// `level_shifter_enable_pin` and `level_shifter_data_pin` (LIBERTY-REQ-0041). - pub level_shifter_enable_pin: bool, - pub level_shifter_data_pin: bool, - /// `always_on`: the pin stays powered when its cell's domain is off (LIBERTY-REQ-0041). - pub always_on: bool, - /// Names of the power and ground pins of the cell that the pin's signal refers to. - pub related_power_pin: Option, - pub related_ground_pin: Option, - /// Raw `power_down_function` text: the condition, over power and ground pins, under which - /// the pin is unpowered. - pub power_down_function: Option, - /// Raw `state_function` text of a clock-gating or statetable output pin (LIBERTY-REQ-0044). - pub state_function_text: Option, - /// Parsed `state_function`, when `state_function_text` parses (LIBERTY-REQ-0044). - pub state_function: Option, - /// `internal_node` name of an internal state pin (LIBERTY-REQ-0044). - pub internal_node: Option, - /// Clock-gating pin roles (LIBERTY-REQ-0043). - pub clock_gate_clock_pin: bool, - pub clock_gate_enable_pin: bool, - pub clock_gate_out_pin: bool, - pub clock_gate_test_pin: bool, - pub clock_gate_obs_pin: bool, - /// 1-based line number of the `pin` group in the `.lib` file, or 0 when synthetic - /// (LIBERTY-REQ-0046). - pub line: u32, -} - -/// Kind of a sequential group. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SeqKind { - FlipFlop, - Latch, -} - -/// A parsed `ff` or `latch` group. Expressions are kept both raw and parsed. -#[derive(Debug, Clone, PartialEq)] -pub struct Sequential { - pub kind: SeqKind, - pub state: String, - pub state_n: String, - /// `next_state` for flip-flops, `data_in` for latches. - pub data_text: String, - pub data: Func, - /// `clocked_on` for flip-flops, `enable` for latches. - pub clock_text: String, - pub clock: Func, - pub clocked_on_also: Option, - pub clear_text: Option, - pub clear: Option, - pub preset_text: Option, - pub preset: Option, - pub clear_preset_var1: Option, - pub clear_preset_var2: Option, -} - -/// A cell's `statetable` group (LIBERTY-REQ-0044): input node names, internal node names, -/// and the `table` text and rows. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct Statetable { - /// Input node names from the first group argument. - pub input_nodes: Vec, - /// Internal node names from the second group argument. - pub internal_nodes: Vec, - /// Raw `table` attribute text as written. - pub table: String, - /// Comma-separated rows of `table`, trimmed. - pub rows: Vec, -} - -/// A power or ground pin of a cell: one `pg_pin` group (LIBERTY-REQ-0042). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -#[non_exhaustive] -pub struct PgPin { - pub name: String, - /// `pg_type`, when stated. - pub pg_type: Option, - /// `voltage_name`: the name of the supply voltage the pin connects to, when stated. - pub voltage_name: Option, -} - -impl PgPin { - /// A pin with only its name. - pub fn new(name: impl Into) -> PgPin { - PgPin { - name: name.into(), - ..PgPin::default() - } - } -} - -/// A library cell. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Cell { - pub name: String, - pub area: f64, - pub dont_use: bool, - pub pins: Vec, - /// Names of the `pg_pin` groups, in source order; `pg_pin_groups` has their attributes. - pub pg_pins: Vec, - /// All `ff`/`latch` groups of the cell. - pub sequentials: Vec, - pub has_statetable: bool, - pub has_test_cell: bool, - pub is_clock_gating: bool, - pub has_bus: bool, - /// `cell_leakage_power` in library units (LIBERTY-REQ-0016). - pub leakage_power: Option, - /// Problems found while reading the cell (LIBERTY-REQ-0021); a non-empty list marks the cell - /// unusable. - pub problems: Vec, - /// The `pg_pin` groups with their types, in source order (LIBERTY-REQ-0042). - pub pg_pin_groups: Vec, - /// Classes of special cells (LIBERTY-REQ-0040). - pub is_isolation_cell: bool, - pub is_level_shifter: bool, - pub level_shifter_type: Option, - /// `always_on`: the cell stays powered when its domain is off. - pub always_on: bool, - /// `cell_footprint`: the name shared by cells that may replace each other in place. - pub cell_footprint: Option, - pub dont_touch: bool, - pub is_macro_cell: bool, - pub pad_cell: bool, - pub switch_cell_type: Option, - /// `retention_cell`: the retention type of a state-retention cell. - pub retention_cell: Option, - pub antenna_diode_type: Option, - /// `clock_gating_integrated_cell` attribute text, when present (LIBERTY-REQ-0043). - pub clock_gating_integrated_cell: Option, - /// Parsed `statetable` group, when present (LIBERTY-REQ-0044). - pub statetable: Option, - /// 1-based line number of the `cell` group in the `.lib` file, or 0 when synthetic - /// (LIBERTY-REQ-0046). - pub line: u32, -} - -impl Cell { - pub fn pin(&self, name: &str) -> Option<&Pin> { - self.pins.iter().find(|p| p.name == name) - } - - pub fn inputs(&self) -> impl Iterator { - self.pins.iter().filter(|p| p.direction == Direction::Input) - } - - pub fn outputs(&self) -> impl Iterator { - self.pins - .iter() - .filter(|p| p.direction == Direction::Output) - } -} - -/// Lookup-table template (LIBERTY-REQ-0031): the variables and default indices of up to three -/// axes. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Template { - pub variable_1: Option, - pub variable_2: Option, - pub index_1: Vec, - pub index_2: Vec, - pub variable_3: Option, - pub index_3: Vec, -} - -impl Template { - /// The variable of axis `axis` (1 to 3), when the template declares it. - pub fn variable(&self, axis: usize) -> Option { - let v = match axis { - 1 => &self.variable_1, - 2 => &self.variable_2, - 3 => &self.variable_3, - _ => return None, - }; - v.as_deref().map(|s| TableVariable::from_name(s.trim())) - } -} - -/// One `wire_load` group of a library (LIBERTY-REQ-0047). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct WireLoad { - pub name: String, - /// Resistance per unit wire length, in library units (0 when omitted). - pub resistance: f64, - /// Capacitance per unit wire length, in library units (0 when omitted). - pub capacitance: f64, - /// Area per unit wire length, in library units (0 when omitted). - pub area: f64, - /// Extrapolation slope (wire length per fanout step outside `fanout_length`, 0 when omitted). - pub slope: f64, - /// `(fanout, length)` pairs from `fanout_length`, sorted by ascending fanout. - pub fanout_length: Vec<(u32, f64)>, -} - -impl WireLoad { - /// Piecewise-linear wire length for `fanout` with linear `slope` extrapolation outside the - /// table and clamping at `0.0` below the minimum entry (LIBERTY-REQ-0047). Returns `0.0` when - /// `fanout_length` is empty. - pub fn length_for_fanout(&self, fanout: u32) -> f64 { - let Some(&(f_min, l_min)) = self.fanout_length.first() else { - return 0.0; - }; - if fanout <= f_min { - if fanout == f_min { - return l_min; - } - return (l_min - self.slope * f64::from(f_min - fanout)).max(0.0); - } - let &(f_max, l_max) = self.fanout_length.last().unwrap_or(&(f_min, l_min)); - if fanout >= f_max { - if fanout == f_max { - return l_max; - } - return l_max + self.slope * f64::from(fanout - f_max); - } - for pair in self.fanout_length.windows(2) { - let (f0, l0) = pair[0]; - let (f1, l1) = pair[1]; - if fanout >= f0 && fanout <= f1 { - if fanout == f0 || f0 == f1 { - return l0; - } - if fanout == f1 { - return l1; - } - return l0 + (l1 - l0) * f64::from(fanout - f0) / f64::from(f1 - f0); - } - } - l_max - } - - /// Alias for [`Self::length_for_fanout`] (LIBERTY-REQ-0047). - pub fn length(&self, fanout: u32) -> f64 { - self.length_for_fanout(fanout) - } - - /// Lumped wire capacitance `capacitance * length_for_fanout(fanout)` (LIBERTY-REQ-0047). - pub fn capacitance_for_fanout(&self, fanout: u32) -> f64 { - self.capacitance * self.length_for_fanout(fanout) - } - - /// Total wire resistance `resistance * length_for_fanout(fanout)` (LIBERTY-REQ-0047). - pub fn resistance_for_fanout(&self, fanout: u32) -> f64 { - self.resistance * self.length_for_fanout(fanout) - } -} - -/// One `wire_load_table` group of a library (LIBERTY-REQ-0047). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct WireLoadTable { - pub name: String, - pub fanout_length: Vec<(u32, f64)>, - pub fanout_capacitance: Vec<(u32, f64)>, - pub fanout_resistance: Vec<(u32, f64)>, - pub fanout_area: Vec<(u32, f64)>, -} - -/// One `wire_load_from_area(min_area, max_area, wire_load)` entry of a [`WireLoadSelection`] -/// group (LIBERTY-REQ-0047). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct WireLoadAreaRule { - pub min_area: f64, - pub max_area: f64, - pub wire_load: String, -} - -/// One `wire_load_selection` group of a library (LIBERTY-REQ-0047). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct WireLoadSelection { - pub name: String, - pub rules: Vec, -} - -impl WireLoadSelection { - /// Returns the `wire_load` name of the first `wire_load_from_area` rule whose - /// `[min_area, max_area]` interval contains `area` (LIBERTY-REQ-0047). - pub fn select(&self, area: f64) -> Option<&str> { - self.rules - .iter() - .find(|r| area >= r.min_area && area <= r.max_area) - .map(|r| r.wire_load.as_str()) - } -} - -/// One `operating_conditions` group of a library (LIBERTY-REQ-0048). -#[derive(Debug, Clone, Default, PartialEq)] -pub struct OperatingConditions { - pub name: String, - pub process: Option, - pub temperature: Option, - pub voltage: Option, - pub tree_type: Option, -} - -/// A Liberty library. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Library { - pub name: String, - /// Time unit expressed in nanoseconds; equal to `units.time.scale`. - pub time_unit_ns: f64, - /// Capacitive load unit expressed in picofarads; equal to `units.capacitance.scale`. - pub cap_unit_pf: f64, - pub default_max_capacitance: Option, - pub default_max_transition: Option, - pub default_max_fanout: Option, - pub default_fanout_load: f64, - /// Lookup-table templates by name. - pub templates: BTreeMap, - /// Cells in source order (LIBERTY-REQ-0019). - pub cells: Vec, - /// All units, each marked given or defaulted (LIBERTY-REQ-0017). - pub units: Units, - /// Capacitances of pins that state none, by direction (LIBERTY-REQ-0018). - pub default_input_pin_cap: Option, - pub default_output_pin_cap: Option, - pub default_inout_pin_cap: Option, - /// Delay and slew threshold percentages and slew derate; `None` when defaulted - /// (LIBERTY-REQ-0045). - pub input_threshold_pct_rise: Option, - pub input_threshold_pct_fall: Option, - pub output_threshold_pct_rise: Option, - pub output_threshold_pct_fall: Option, - pub slew_lower_threshold_pct_rise: Option, - pub slew_upper_threshold_pct_rise: Option, - pub slew_lower_threshold_pct_fall: Option, - pub slew_upper_threshold_pct_fall: Option, - pub slew_derate_from_library: Option, - /// `wire_load` groups by name (LIBERTY-REQ-0047). - pub wire_loads: BTreeMap, - /// `wire_load_table` groups by name (LIBERTY-REQ-0047). - pub wire_load_tables: BTreeMap, - /// `wire_load_selection` groups by name (LIBERTY-REQ-0047). - pub wire_load_selections: BTreeMap, - /// Library-level wire-load defaults (LIBERTY-REQ-0047). - pub default_wire_load: Option, - pub default_wire_load_mode: Option, - pub default_wire_load_selection: Option, - pub default_wire_load_area: Option, - pub default_wire_load_capacitance: Option, - pub default_wire_load_resistance: Option, - /// `operating_conditions` groups by name and `default_operating_conditions` - /// (LIBERTY-REQ-0048). - pub operating_conditions: BTreeMap, - pub default_operating_conditions: Option, - /// Nominal process, temperature and voltage (LIBERTY-REQ-0048). - pub nom_process: Option, - pub nom_temperature: Option, - pub nom_voltage: Option, -} - -impl Library { - /// The first cell with the given name (a linear search). - pub fn cell(&self, name: &str) -> Option<&Cell> { - self.cells.iter().find(|c| c.name == name) - } - - /// The `wire_load` group with the given name (LIBERTY-REQ-0047). - pub fn wire_load(&self, name: &str) -> Option<&WireLoad> { - self.wire_loads.get(name) - } - - /// The `wire_load_selection` group with the given name (LIBERTY-REQ-0047). - pub fn wire_load_selection(&self, name: &str) -> Option<&WireLoadSelection> { - self.wire_load_selections.get(name) - } - - /// The `operating_conditions` group with the given name (LIBERTY-REQ-0048). - pub fn operating_condition(&self, name: &str) -> Option<&OperatingConditions> { - self.operating_conditions.get(name) - } - - /// The capacitance of `pin` with the library default applied (LIBERTY-REQ-0018): its own - /// `capacitance` when it states one, else the default of its direction, else 0. - pub fn pin_capacitance(&self, pin: &Pin) -> f64 { - if pin.capacitance_given { - return pin.capacitance; - } - let default = match pin.direction { - Direction::Input => self.default_input_pin_cap, - Direction::Output => self.default_output_pin_cap, - Direction::Inout => self.default_inout_pin_cap, - Direction::Internal => None, - }; - default.unwrap_or(0.0) - } - - /// `max_transition` of `pin`, else the library default for every pin but internal ones - /// (LIBERTY-REQ-0018). - pub fn pin_max_transition(&self, pin: &Pin) -> Option { - pin.max_transition.or(match pin.direction { - Direction::Internal => None, - _ => self.default_max_transition, - }) - } - - /// `max_capacitance` of `pin`, else the library default for output and inout pins - /// (LIBERTY-REQ-0018). - pub fn pin_max_capacitance(&self, pin: &Pin) -> Option { - pin.max_capacitance.or(if drives(pin) { - self.default_max_capacitance - } else { - None - }) - } - - /// `max_fanout` of `pin`, else the library default for output and inout pins - /// (LIBERTY-REQ-0018). - pub fn pin_max_fanout(&self, pin: &Pin) -> Option { - pin.max_fanout.or(if drives(pin) { - self.default_max_fanout - } else { - None - }) - } - - /// `fanout_load` of `pin`, else the library default for input and inout pins, else 0 for - /// pins that load no net (LIBERTY-REQ-0018). - pub fn pin_fanout_load(&self, pin: &Pin) -> f64 { - pin.fanout_load.unwrap_or(match pin.direction { - Direction::Input | Direction::Inout => self.default_fanout_load, - Direction::Output | Direction::Internal => 0.0, - }) - } -} - -/// True for the directions that drive a net. -fn drives(pin: &Pin) -> bool { - matches!(pin.direction, Direction::Output | Direction::Inout) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pin(direction: Direction) -> Pin { - Pin { - name: "P".into(), - direction, - ..Pin::default() - } - } - - fn library() -> Library { - Library { - name: "l".into(), - time_unit_ns: 1.0, - cap_unit_pf: 1.0, - default_max_capacitance: Some(0.5), - default_max_transition: Some(1.5), - default_max_fanout: Some(8.0), - default_fanout_load: 2.0, - default_input_pin_cap: Some(0.01), - default_output_pin_cap: Some(0.02), - ..Library::default() - } - } - - // LIBERTY-REQ-0018: pin values win; library defaults apply by direction. - #[test] - fn library_defaults_by_direction() { - let lib = library(); - let input = pin(Direction::Input); - let output = pin(Direction::Output); - let inout = pin(Direction::Inout); - let internal = pin(Direction::Internal); - assert_eq!(lib.pin_capacitance(&input), 0.01); - assert_eq!(lib.pin_capacitance(&output), 0.02); - assert_eq!(lib.pin_capacitance(&inout), 0.0); - assert_eq!(lib.pin_capacitance(&internal), 0.0); - let stated = Pin { - capacitance: 0.003, - capacitance_given: true, - ..pin(Direction::Output) - }; - assert_eq!(lib.pin_capacitance(&stated), 0.003); - assert_eq!(lib.pin_max_transition(&input), Some(1.5)); - assert_eq!(lib.pin_max_transition(&output), Some(1.5)); - assert_eq!(lib.pin_max_transition(&internal), None); - assert_eq!(lib.pin_max_capacitance(&input), None); - assert_eq!(lib.pin_max_capacitance(&output), Some(0.5)); - assert_eq!(lib.pin_max_capacitance(&inout), Some(0.5)); - assert_eq!(lib.pin_max_fanout(&input), None); - assert_eq!(lib.pin_max_fanout(&output), Some(8.0)); - assert_eq!(lib.pin_fanout_load(&input), 2.0); - assert_eq!(lib.pin_fanout_load(&inout), 2.0); - assert_eq!(lib.pin_fanout_load(&output), 0.0); - let own = Pin { - max_transition: Some(0.7), - max_capacitance: Some(0.1), - max_fanout: Some(3.0), - fanout_load: Some(4.0), - ..pin(Direction::Inout) - }; - assert_eq!(lib.pin_max_transition(&own), Some(0.7)); - assert_eq!(lib.pin_max_capacitance(&own), Some(0.1)); - assert_eq!(lib.pin_max_fanout(&own), Some(3.0)); - assert_eq!(lib.pin_fanout_load(&own), 4.0); - } - - // LIBERTY-REQ-0023, LIBERTY-REQ-0024, LIBERTY-REQ-0026: typed views of the kept texts and - // conditional arc defaults. - #[test] - fn arc_kind_and_sense() { - let arc = TimingArc { - timing_type: "setup_rising".into(), - timing_sense: Some("non_unate".into()), - when_text: Some("EN".into()), - when: Some(Func::Var("EN".into())), - sdf_cond: Some("EN === 1'b1".into()), - line: 42, - ..TimingArc::default() - }; - assert_eq!(arc.kind(), TimingType::SetupRising); - assert_eq!(arc.sense(), Some(TimingSense::NonUnate)); - assert_eq!(arc.when_text.as_deref(), Some("EN")); - assert_eq!(arc.sdf_cond.as_deref(), Some("EN === 1'b1")); - assert_eq!(arc.line, 42); - let plain = TimingArc { - timing_type: "combinational".into(), - ..TimingArc::default() - }; - assert_eq!(plain.kind(), TimingType::Combinational); - assert_eq!(plain.sense(), None); - assert_eq!(plain.when, None); - assert_eq!(plain.line, 0); - } - - // LIBERTY-REQ-0031: typed template variables per axis. - #[test] - fn template_variables() { - let t = Template { - variable_1: Some("related_pin_transition".into()), - variable_2: Some("constrained_pin_transition".into()), - variable_3: Some("related_out_total_output_net_capacitance".into()), - ..Template::default() - }; - assert_eq!(t.variable(1), Some(TableVariable::RelatedPinTransition)); - assert_eq!(t.variable(2), Some(TableVariable::ConstrainedPinTransition)); - assert_eq!( - t.variable(3), - Some(TableVariable::RelatedOutTotalOutputNetCapacitance) - ); - assert_eq!(t.variable(0), None); - assert_eq!(t.variable(4), None); - assert_eq!(Template::default().variable(1), None); - } - - // LIBERTY-REQ-0042, LIBERTY-REQ-0043, LIBERTY-REQ-0044, LIBERTY-REQ-0045, LIBERTY-REQ-0046, - // LIBERTY-REQ-0047, LIBERTY-REQ-0048 - #[test] - fn pg_pin_and_extension_b_c_defaults() { - let p = PgPin::new("VPWR"); - assert_eq!(p.name, "VPWR"); - assert_eq!(p.pg_type, None); - assert_eq!(p.voltage_name, None); - let st = Statetable::default(); - assert!(st.input_nodes.is_empty() && st.internal_nodes.is_empty() && st.rows.is_empty()); - let cell = Cell::default(); - assert_eq!(cell.clock_gating_integrated_cell, None); - assert_eq!(cell.statetable, None); - assert_eq!(cell.line, 0); - let lib = Library::default(); - assert_eq!(lib.slew_derate_from_library, None); - assert_eq!(lib.slew_lower_threshold_pct_rise, None); - assert_eq!(lib.slew_upper_threshold_pct_fall, None); - assert!(lib.wire_loads.is_empty()); - assert!(lib.operating_conditions.is_empty()); - - let wl = WireLoad { - name: "wl_lin".into(), - resistance: 0.005, - capacitance: 0.001, - area: 0.1, - slope: 2.0, - fanout_length: vec![(1, 2.0), (3, 5.0)], - }; - assert_eq!(wl.length_for_fanout(0), 0.0); - assert_eq!(wl.length_for_fanout(1), 2.0); - assert_eq!(wl.length_for_fanout(2), 3.5); - assert_eq!(wl.length_for_fanout(3), 5.0); - assert_eq!(wl.length_for_fanout(5), 9.0); - assert_eq!(wl.length(5), 9.0); - assert!((wl.capacitance_for_fanout(2) - 0.0035).abs() < 1e-12); - assert!((wl.resistance_for_fanout(2) - 0.0175).abs() < 1e-12); - assert_eq!(WireLoad::default().length_for_fanout(3), 0.0); - - let sel = WireLoadSelection { - name: "sel".into(), - rules: vec![ - WireLoadAreaRule { - min_area: 0.0, - max_area: 500.0, - wire_load: "wl_small".into(), - }, - WireLoadAreaRule { - min_area: 500.0, - max_area: 2000.0, - wire_load: "wl_medium".into(), - }, - ], - }; - assert_eq!(sel.select(100.0), Some("wl_small")); - assert_eq!(sel.select(1200.0), Some("wl_medium")); - assert_eq!(sel.select(5000.0), None); - } -} diff --git a/crates/core/rusteda-core/src/liberty/table.rs b/crates/core/rusteda-core/src/liberty/table.rs deleted file mode 100644 index 7e92947..0000000 --- a/crates/core/rusteda-core/src/liberty/table.rs +++ /dev/null @@ -1,326 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Lookup tables (LIBERTY-REQ-0013, LIBERTY-REQ-0030, LIBERTY-REQ-0032 to LIBERTY-REQ-0034). - -use super::kinds::TableVariable; - -/// A lookup table with up to three axes. `values` is row-major over (index_1, index_2, -/// index_3): the value at points (i, j, k) is `values[(i * n2 + j) * n3 + k]`, where n2 and n3 -/// are the lengths of `index_2` and `index_3`, each at least 1 (LIBERTY-REQ-0032). -/// -/// `shape`, `is_consistent`, `lookup`, `lookup_slew_load` and `mid_slew` are the two-axis forms: -/// a table whose third axis has more than one point is not consistent for them. `shape3`, -/// `is_consistent3` and `lookup3` cover all three axes. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Table { - pub template: String, - pub variable_1: Option, - pub variable_2: Option, - pub index_1: Vec, - pub index_2: Vec, - pub values: Vec, - pub variable_3: Option, - pub index_3: Vec, -} - -/// Interpolation weights along one axis: (lower index, upper index, fraction). -fn axis(index: &[f64], x: f64) -> (usize, usize, f64) { - match index.len() { - 0 | 1 => (0, 0, 0.0), - n => { - // Segment containing x, clamped to the first/last segment for extrapolation. - let mut k = 0; - while k + 2 < n && x > index[k + 1] { - k += 1; - } - let (x0, x1) = (index[k], index[k + 1]); - let t = if x1 == x0 { 0.0 } else { (x - x0) / (x1 - x0) }; - (k, k + 1, t) - } - } -} - -/// The point at fraction `t` from `a` to `b`. -fn lerp(a: f64, b: f64, t: f64) -> f64 { - a + (b - a) * t -} - -impl Table { - /// Number of rows and columns. - pub fn shape(&self) -> (usize, usize) { - (self.index_1.len().max(1), self.index_2.len().max(1)) - } - - /// True if the value count matches the indices. - pub fn is_consistent(&self) -> bool { - let (r, c) = self.shape(); - !self.values.is_empty() && (self.values.len() == r * c || self.values.len() == 1) - } - - fn at(&self, i: usize, j: usize) -> f64 { - if self.values.len() == 1 { - return self.values[0]; - } - let (_, c) = self.shape(); - self.values.get(i * c + j).copied().unwrap_or(0.0) - } - - /// Bilinear interpolation or linear extrapolation at (x1, x2) (LIBERTY-REQ-0030). - pub fn lookup(&self, x1: f64, x2: f64) -> f64 { - if !self.is_consistent() { - return 0.0; - } - let (i0, i1, t) = axis(&self.index_1, x1); - let (j0, j1, u) = axis(&self.index_2, x2); - let v00 = self.at(i0, j0); - let v01 = self.at(i0, j1); - let v10 = self.at(i1, j0); - let v11 = self.at(i1, j1); - let a = v00 + (v01 - v00) * u; - let b = v10 + (v11 - v10) * u; - a + (b - a) * t - } - - /// Looks up by named variables, using the template variable names to decide which index - /// holds the input transition and which the output load. - pub fn lookup_slew_load(&self, slew: f64, load: f64) -> f64 { - let is_load = |v: &Option| v.as_deref().is_some_and(|s| s.contains("capacitance")); - if is_load(&self.variable_1) && !is_load(&self.variable_2) { - self.lookup(load, slew) - } else { - self.lookup(slew, load) - } - } - - /// Index value in the middle of the slew axis (LIBERTY-REQ-0034). - pub fn mid_slew(&self) -> f64 { - let is_load = |v: &Option| v.as_deref().is_some_and(|s| s.contains("capacitance")); - let idx = if is_load(&self.variable_1) && !is_load(&self.variable_2) { - &self.index_2 - } else { - &self.index_1 - }; - if idx.is_empty() { - 0.0 - } else { - idx[idx.len() / 2] - } - } - - /// The variable of axis `axis` (1 to 3), when the table has one (LIBERTY-REQ-0032). - pub fn variable(&self, axis: usize) -> Option { - let v = match axis { - 1 => &self.variable_1, - 2 => &self.variable_2, - 3 => &self.variable_3, - _ => return None, - }; - v.as_deref().map(|s| TableVariable::from_name(s.trim())) - } - - /// Number of points of the three axes, each at least 1 (LIBERTY-REQ-0032). - pub fn shape3(&self) -> (usize, usize, usize) { - ( - self.index_1.len().max(1), - self.index_2.len().max(1), - self.index_3.len().max(1), - ) - } - - /// True if the value count is the product of the three axis lengths, or 1 for a constant - /// table (LIBERTY-REQ-0032). - pub fn is_consistent3(&self) -> bool { - let (n1, n2, n3) = self.shape3(); - !self.values.is_empty() && (self.values.len() == n1 * n2 * n3 || self.values.len() == 1) - } - - fn at3(&self, i: usize, j: usize, k: usize) -> f64 { - if self.values.len() == 1 { - return self.values[0]; - } - let (_, n2, n3) = self.shape3(); - self.values - .get((i * n2 + j) * n3 + k) - .copied() - .unwrap_or(0.0) - } - - /// Interpolation between the enclosing index points and linear extrapolation beyond the - /// index range, along every axis with at least two points, at (x1, x2, x3); 0 for a table - /// that is not consistent (LIBERTY-REQ-0033). On a table of finite values without a third - /// axis it equals [`Table::lookup`] bit for bit. - pub fn lookup3(&self, x1: f64, x2: f64, x3: f64) -> f64 { - if !self.is_consistent3() { - return 0.0; - } - let (i0, i1, t) = axis(&self.index_1, x1); - let (j0, j1, u) = axis(&self.index_2, x2); - let (k0, k1, w) = axis(&self.index_3, x3); - let along3 = |i: usize, j: usize| lerp(self.at3(i, j, k0), self.at3(i, j, k1), w); - let a = lerp(along3(i0, j0), along3(i0, j1), u); - let b = lerp(along3(i1, j0), along3(i1, j1), u); - lerp(a, b, t) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // LIBERTY-REQ-0030 - #[test] - fn interpolation_and_extrapolation() { - let t = Table { - index_1: vec![1.0, 2.0], - index_2: vec![10.0, 20.0, 40.0], - values: vec![1.0, 2.0, 4.0, 3.0, 4.0, 6.0], - ..Table::default() - }; - assert!((t.lookup(1.0, 10.0) - 1.0).abs() < 1e-12); - assert!((t.lookup(2.0, 40.0) - 6.0).abs() < 1e-12); - assert!((t.lookup(1.5, 15.0) - 2.5).abs() < 1e-12); - // Extrapolation beyond both ends. - assert!((t.lookup(3.0, 10.0) - 5.0).abs() < 1e-12); - assert!((t.lookup(1.0, 0.0) - 0.0).abs() < 1e-12); - assert!((t.lookup(1.0, 60.0) - 6.0).abs() < 1e-12); - let s = Table { - values: vec![7.0], - ..Table::default() - }; - assert_eq!(s.lookup(5.0, 5.0), 7.0); - } - - // LIBERTY-REQ-0034: the middle index point of the input-transition axis, which is `index_2` - // when `variable_1` names a capacitance and `variable_2` does not, otherwise `index_1`; the - // upper of the two middle points of an even count; 0 without index points. - #[test] - fn middle_of_the_transition_axis() { - let slew = Some("input_net_transition"); - let load = Some("total_output_net_capacitance"); - let t = |v1: Option<&str>, v2: Option<&str>| Table { - variable_1: v1.map(String::from), - variable_2: v2.map(String::from), - index_1: vec![0.1, 0.2, 0.4], - index_2: vec![1.0, 2.0, 4.0, 8.0], - ..Table::default() - }; - assert_eq!(t(slew, load).mid_slew(), 0.2); - assert_eq!(t(None, None).mid_slew(), 0.2); - assert_eq!(t(load, load).mid_slew(), 0.2); - assert_eq!(t(load, slew).mid_slew(), 4.0); - assert_eq!(t(load, None).mid_slew(), 4.0); - let one = Table { - index_1: vec![0.3], - ..Table::default() - }; - assert_eq!(one.mid_slew(), 0.3); - assert_eq!(Table::default().mid_slew(), 0.0); - let no_slew_points = Table { - index_2: Vec::new(), - ..t(load, slew) - }; - assert_eq!(no_slew_points.mid_slew(), 0.0); - } - - /// A 2 x 3 x 2 table of v(x1, x2, x3) = 100 x1 + 10 x2 + x3 at its index points, which every - /// trilinear interpolation and extrapolation reproduces exactly. - fn affine() -> Table { - let (i1, i2, i3) = (vec![1.0, 2.0], vec![1.0, 2.0, 4.0], vec![0.5, 1.5]); - let mut values = Vec::new(); - for a in &i1 { - for b in &i2 { - for c in &i3 { - values.push(100.0 * a + 10.0 * b + c); - } - } - } - Table { - index_1: i1, - index_2: i2, - index_3: i3, - values, - ..Table::default() - } - } - - // LIBERTY-REQ-0032, LIBERTY-REQ-0033: row-major value layout over three axes, interpolation - // and extrapolation along each, constant tables, and the two-axis forms. - #[test] - fn three_axes() { - let t = affine(); - assert_eq!(t.shape3(), (2, 3, 2)); - assert!(t.is_consistent3()); - // Layout: (i, j, k) = (1, 2, 0) is value index (1 * 3 + 2) * 2 + 0 = 10. - assert_eq!(t.values[10], 200.0 + 40.0 + 0.5); - let f = |a: f64, b: f64, c: f64| 100.0 * a + 10.0 * b + c; - for (a, b, c) in [ - (1.0, 1.0, 0.5), - (2.0, 4.0, 1.5), - (1.25, 3.0, 1.0), - (0.0, 0.0, 0.0), - (3.0, 8.0, 2.5), - (1.5, 1.5, -1.0), - ] { - assert!( - (t.lookup3(a, b, c) - f(a, b, c)).abs() < 1e-9, - "{a} {b} {c}" - ); - } - // A third axis of more than one point fails the two-axis check. - assert!(!t.is_consistent()); - assert_eq!(t.lookup(1.0, 1.0), 0.0); - let bad = Table { - values: vec![1.0, 2.0, 3.0], - ..affine() - }; - assert!(!bad.is_consistent3()); - assert_eq!(bad.lookup3(1.0, 1.0, 1.0), 0.0); - let constant = Table { - values: vec![7.0], - ..affine() - }; - assert_eq!(constant.lookup3(9.0, 9.0, 9.0), 7.0); - assert!(!Table::default().is_consistent3()); - } - - // LIBERTY-REQ-0033: without a third axis, lookup3 equals lookup bit for bit. - #[test] - fn two_axis_tables_agree() { - let t = Table { - index_1: vec![0.01, 0.1, 0.7], - index_2: vec![0.001, 0.02, 0.3], - values: vec![0.1, 0.13, 0.9, 0.17, 0.25, 1.1, 0.33, 0.6, 1.9], - ..Table::default() - }; - for (a, b) in [ - (0.05, 0.01), - (0.0, 0.0), - (1.0, 0.5), - (0.7, 0.02), - (0.33, 0.123), - ] { - for c in [0.0, 5.0] { - assert_eq!(t.lookup3(a, b, c).to_bits(), t.lookup(a, b).to_bits()); - } - } - } - - // LIBERTY-REQ-0032: typed axis variables. - #[test] - fn axis_variables() { - let t = Table { - variable_1: Some("input_net_transition".into()), - variable_2: Some("total_output_net_capacitance".into()), - variable_3: Some("my_axis".into()), - ..Table::default() - }; - assert_eq!(t.variable(1), Some(TableVariable::InputNetTransition)); - assert_eq!( - t.variable(2), - Some(TableVariable::TotalOutputNetCapacitance) - ); - assert_eq!(t.variable(3), Some(TableVariable::Other("my_axis".into()))); - assert_eq!(t.variable(4), None); - } -} diff --git a/crates/core/rusteda-core/src/liberty/units.rs b/crates/core/rusteda-core/src/liberty/units.rs deleted file mode 100644 index d65b01c..0000000 --- a/crates/core/rusteda-core/src/liberty/units.rs +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The units of a library (LIBERTY-REQ-0017): each one expressed in a fixed unit of its -//! quantity and marked as given by the library or defaulted. - -/// One unit of a library: the size of one library unit of a quantity. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub struct Unit { - /// One library unit expressed in the fixed unit of the quantity (see [`Units`]). - pub scale: f64, - /// True when the library states the unit and the statement is valid; false when the default - /// applies, either because the attribute is absent or because its text is not a valid unit. - pub given: bool, - /// The text of the unit attribute as written, when the library has one; the two values of - /// `capacitive_load_unit` are joined by `,`. - pub text: Option, -} - -impl Unit { - /// The defaulted unit with the given scale. - pub fn defaulted(scale: f64) -> Unit { - Unit { - scale, - given: false, - text: None, - } - } - - /// A unit the library states with `text`. - pub fn given(scale: f64, text: impl Into) -> Unit { - Unit { - scale, - given: true, - text: Some(text.into()), - } - } -} - -impl Default for Unit { - /// One fixed unit, defaulted. - fn default() -> Unit { - Unit::defaulted(1.0) - } -} - -/// The units of a library. Each [`Unit::scale`] is expressed in a fixed unit per quantity: -/// time in ns (`time_unit`), capacitance in pF (`capacitive_load_unit`), resistance in kOhm -/// (`pulling_resistance_unit`), voltage in V (`voltage_unit`), current in mA (`current_unit`) and -/// leakage power in nW (`leakage_power_unit`). An absent unit is defaulted to one fixed unit. -#[derive(Debug, Clone, PartialEq, Default)] -#[non_exhaustive] -pub struct Units { - pub time: Unit, - pub capacitance: Unit, - pub resistance: Unit, - pub voltage: Unit, - pub current: Unit, - pub leakage_power: Unit, -} - -#[cfg(test)] -mod tests { - use super::*; - - // LIBERTY-REQ-0017: absent units default to one fixed unit and are marked defaulted. - #[test] - fn defaults_are_marked() { - let u = Units::default(); - for unit in [ - &u.time, - &u.capacitance, - &u.resistance, - &u.voltage, - &u.current, - &u.leakage_power, - ] { - assert_eq!(unit.scale, 1.0); - assert!(!unit.given); - assert_eq!(unit.text, None); - } - let ps = Unit::given(1e-3, "1ps"); - assert!(ps.given); - assert_eq!(ps.text.as_deref(), Some("1ps")); - } -} diff --git a/crates/core/rusteda-core/src/names.rs b/crates/core/rusteda-core/src/names.rs deleted file mode 100644 index 6a0b7ef..0000000 --- a/crates/core/rusteda-core/src/names.rs +++ /dev/null @@ -1,413 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Names of netlist objects (CORE-REQ-0110 to 0113): the characters every name may use, the -//! segments and escapes of port, net and instance names, and the conversions between a name and -//! its raw text. -//! -//! Every name, of a module, cell, cell pin, port, net or instance, is non-empty and consists of -//! printable ASCII characters (33 to 126), so that it has an exact form in a netlist file: a -//! Verilog escaped identifier allows exactly these characters. Module, cell and pin names are -//! kept verbatim ([`check_verbatim`]). Port, net and instance names are hierarchical -//! ([`check_hierarchical`]): one or more segments joined by unescaped `/`; inside a segment `\` -//! escapes the next character, which must be `/` or `\`, and no segment is empty. A segment's -//! raw text, for example an escaped identifier without its backslash, becomes a segment by -//! [`escape_segment`], and [`join`] builds a hierarchical name from raw segments, as flattening -//! does with an instance path. [`segments`] and [`unescape`] give the raw text back. -//! -//! ``` -//! use rusteda_core::names::{check_hierarchical, escape_segment, join, segments, unescape}; -//! -//! assert_eq!(escape_segment("a/b"), r"a\/b"); -//! let name = join(["u1", r"q\x/y"]); -//! assert_eq!(name, r"u1/q\\x\/y"); -//! assert_eq!(segments(&name), ["u1", r"q\x/y"]); -//! assert_eq!(unescape(&name), r"u1/q\x/y"); -//! assert!(check_hierarchical(&name).is_ok()); -//! assert!(check_hierarchical("u1//q").is_err()); -//! ``` - -use std::fmt; - -/// Why a name breaks the rules of its kind; byte offsets count from the start of the name. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum NameError { - /// The name is empty. - Empty, - /// A character outside printable ASCII (33 to 126). - NotPrintable { at: usize, ch: char }, - /// A backslash that is not followed by `/` or `\` (hierarchical names only). - BadEscape { at: usize }, - /// An unescaped `/` that begins or ends the name or follows another one, so that a segment - /// is empty (hierarchical names only). - EmptySegment { at: usize }, -} - -impl fmt::Display for NameError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - NameError::Empty => write!(f, "the name is empty"), - NameError::NotPrintable { at, ch } => write!( - f, - "character U+{:04X} at byte {at} is not printable ASCII (33 to 126)", - u32::from(*ch) - ), - NameError::BadEscape { at } => { - write!( - f, - "the backslash at byte {at} is not followed by '/' or '\\'" - ) - } - NameError::EmptySegment { at } => { - write!(f, "the '/' at byte {at} leaves an empty segment") - } - } - } -} - -impl std::error::Error for NameError {} - -/// True for printable ASCII: the characters 33 (`!`) to 126 (`~`). -fn is_printable(c: u8) -> bool { - (33..=126).contains(&c) -} - -/// Checks the rules of every name (CORE-REQ-0110): it is non-empty and consists of printable -/// ASCII characters. These are all the rules of module, cell and pin names. -pub fn check_verbatim(name: &str) -> Result<(), NameError> { - if name.is_empty() { - return Err(NameError::Empty); - } - match name.bytes().position(|c| !is_printable(c)) { - None => Ok(()), - // The bytes before `at` are ASCII, so a character starts at `at`. - Some(at) => Err(NameError::NotPrintable { - at, - ch: name[at..] - .chars() - .next() - .unwrap_or(char::REPLACEMENT_CHARACTER), - }), - } -} - -/// Checks the rules of port, net and instance names (CORE-REQ-0110, 0111): those of -/// [`check_verbatim`], every backslash followed by `/` or `\`, and no empty segment. -pub fn check_hierarchical(name: &str) -> Result<(), NameError> { - check_verbatim(name)?; - let b = name.as_bytes(); - // Whether the current segment has no character yet. - let mut empty = true; - let mut i = 0; - while i < b.len() { - match b[i] { - b'\\' => { - if !matches!(b.get(i + 1), Some(b'/' | b'\\')) { - return Err(NameError::BadEscape { at: i }); - } - empty = false; - i += 2; - } - b'/' if empty => return Err(NameError::EmptySegment { at: i }), - b'/' => { - empty = true; - i += 1; - } - _ => { - empty = false; - i += 1; - } - } - } - if empty { - // The name is not empty, so it ends with an unescaped '/'. - return Err(NameError::EmptySegment { at: b.len() - 1 }); - } - Ok(()) -} - -/// True for a simple identifier, `[A-Za-z_][A-Za-z0-9_$]*` (CORE-REQ-0113). This is the lexical -/// rule only: a format writer adds its own rules, for example the Verilog writer escapes keywords -/// too. -pub fn is_simple_identifier(s: &str) -> bool { - let b = s.as_bytes(); - b.first() - .is_some_and(|&c| c.is_ascii_alphabetic() || c == b'_') - && b.iter() - .all(|&c| c.is_ascii_alphanumeric() || c == b'_' || c == b'$') -} - -/// The segment of a raw text (CORE-REQ-0112): `\` becomes `\\` and `/` becomes `\/`, so the -/// segment has no unescaped `/` and [`unescape`] gives the text back. -pub fn escape_segment(raw: &str) -> String { - let mut s = String::with_capacity(raw.len()); - for c in raw.chars() { - if matches!(c, '/' | '\\') { - s.push('\\'); - } - s.push(c); - } - s -} - -/// The hierarchical name of raw segments, for example the instance path that flattening -/// follows (CORE-REQ-0112): each segment escaped by [`escape_segment`], joined by `/`. The name -/// is valid when there is a segment and every one is non-empty printable ASCII; no segments give -/// the empty string, which is no valid name. -pub fn join(raws: I) -> String -where - I: IntoIterator, - I::Item: AsRef, -{ - let mut s = String::new(); - for (k, raw) in raws.into_iter().enumerate() { - if k > 0 { - s.push('/'); - } - s.push_str(&escape_segment(raw.as_ref())); - } - s -} - -/// Calls `f` with each character of the raw text of `name`, and with `None` at each unescaped -/// `/`: `\/` gives `/`, `\\` gives `\`, and a backslash before anything else (only in an invalid -/// name) is kept. -fn walk(name: &str, mut f: impl FnMut(Option)) { - let mut chars = name.chars().peekable(); - while let Some(c) = chars.next() { - match c { - '\\' => match chars.next_if(|&e| matches!(e, '/' | '\\')) { - Some(e) => f(Some(e)), - None => f(Some('\\')), - }, - '/' => f(None), - c => f(Some(c)), - } - } -} - -/// The raw text of a name (CORE-REQ-0112): `\/` becomes `/`, `\\` becomes `\`, and an unescaped -/// `/` stays `/`. So `unescape(&escape_segment(raw)) == raw` for every text, and a flat name's -/// raw text is its segment's text; the separators of a hierarchical name become slashes that -/// [`escape_segment`] would escape. -pub fn unescape(name: &str) -> String { - let mut s = String::with_capacity(name.len()); - walk(name, |c| s.push(c.unwrap_or('/'))); - s -} - -/// The raw texts of the segments of a name, in order (CORE-REQ-0112): split at each unescaped -/// `/`, each unescaped. `segments(&join(raws)) == raws` for every non-empty list of texts; the -/// segments of a valid name are non-empty. -pub fn segments(name: &str) -> Vec { - let mut out = vec![String::new()]; - walk(name, |c| match c { - Some(c) => out.last_mut().into_iter().for_each(|s| s.push(c)), - None => out.push(String::new()), - }); - out -} - -/// `name` for messages: printable ASCII characters as they are and every other character as -/// `\u{...}`, so that a rejected name shows on one line. -pub fn for_display(name: &str) -> String { - let mut s = String::with_capacity(name.len()); - for c in name.chars() { - if u8::try_from(c).is_ok_and(is_printable) { - s.push(c); - } else { - s.extend(c.escape_unicode()); - } - } - s -} - -#[cfg(test)] -mod tests { - use super::*; - - /// xorshift64*. - struct Rng(u64); - - impl Rng { - fn below(&mut self, n: usize) -> usize { - let mut x = self.0; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - self.0 = x; - (x.wrapping_mul(0x2545_F491_4F6C_DD1D) % n as u64) as usize - } - - /// A text of up to `max` characters, mostly printable ASCII with many slashes and - /// backslashes; with `wild`, also blanks, control characters, DEL and non-ASCII ones. - fn text(&mut self, max: usize, wild: bool) -> String { - const ODD: [char; 8] = [' ', '\t', '\n', '\0', '\u{7f}', 'é', '\u{a0}', '中']; - (0..self.below(max + 1)) - .map(|_| match self.below(10) { - 0 | 1 => '/', - 2 | 3 => '\\', - 4 if wild => ODD[self.below(ODD.len())], - _ => char::from(33 + self.below(94) as u8), - }) - .collect() - } - } - - /// The rules of hierarchical names, stated as a scan over the characters: printable - /// ASCII, each backslash escaping '/' or '\', and segments that are not empty. - fn valid(name: &str) -> bool { - if name.is_empty() || !name.bytes().all(|c| (33..=126).contains(&c)) { - return false; - } - let mut lengths = vec![0usize]; - let mut chars = name.chars(); - while let Some(c) = chars.next() { - if c == '/' { - lengths.push(0); - continue; - } - if c == '\\' && !matches!(chars.next(), Some('/' | '\\')) { - return false; - } - if let Some(n) = lengths.last_mut() { - *n += 1; - } - } - lengths.iter().all(|&n| n > 0) - } - - // CORE-REQ-0110, 0111: the rules, with the offset and reason of each problem. - #[test] - fn rules_and_reasons() { - for name in [ - "a", "u1/n2", r"a\/b", r"a\\b", r"\/", r"\\", r"a\//b", "!~", "x[3]/y.z", - ] { - assert_eq!(check_hierarchical(name), Ok(()), "{name}"); - assert_eq!(check_verbatim(name), Ok(()), "{name}"); - } - let cases = [ - ("", NameError::Empty), - ("a b", NameError::NotPrintable { at: 1, ch: ' ' }), - ( - "ab\u{7f}", - NameError::NotPrintable { - at: 2, - ch: '\u{7f}', - }, - ), - ("xé", NameError::NotPrintable { at: 1, ch: 'é' }), - ("\tx", NameError::NotPrintable { at: 0, ch: '\t' }), - (r"a\b", NameError::BadEscape { at: 1 }), - (r"a\", NameError::BadEscape { at: 1 }), - (r"a\\\", NameError::BadEscape { at: 3 }), - ("/a", NameError::EmptySegment { at: 0 }), - ("a/", NameError::EmptySegment { at: 1 }), - ("a//b", NameError::EmptySegment { at: 2 }), - ("/", NameError::EmptySegment { at: 0 }), - (r"a\\/", NameError::EmptySegment { at: 3 }), - ]; - for (name, want) in &cases { - assert_eq!(check_hierarchical(name).as_ref(), Err(want), "{name:?}"); - } - // Verbatim names may hold any printable ASCII character, slashes and backslashes too. - for name in ["a//b", "/", r"a\b", r"\"] { - assert_eq!(check_verbatim(name), Ok(()), "{name}"); - } - assert_eq!(check_verbatim(""), Err(NameError::Empty)); - let messages = [ - NameError::Empty, - NameError::NotPrintable { at: 2, ch: 'é' }, - NameError::BadEscape { at: 1 }, - NameError::EmptySegment { at: 0 }, - ] - .map(|e| e.to_string()); - assert_eq!( - messages, - [ - "the name is empty", - "character U+00E9 at byte 2 is not printable ASCII (33 to 126)", - "the backslash at byte 1 is not followed by '/' or '\\'", - "the '/' at byte 0 leaves an empty segment", - ] - ); - assert_eq!(for_display("a b\u{e9}\\/c"), r"a\u{20}b\u{e9}\/c"); - } - - // CORE-REQ-0113: the lexical rule of simple identifiers; keywords are the writers' concern. - #[test] - fn simple_identifiers() { - for s in ["a", "_", "a$1", "A_b9", "module"] { - assert!(is_simple_identifier(s), "{s}"); - } - for s in ["", "1a", "$a", "a.b", "a/b", r"a\b", "a b", "é"] { - assert!(!is_simple_identifier(s), "{s}"); - } - } - - // CORE-REQ-0112: the conversions on fixed examples. - #[test] - fn conversions() { - assert_eq!(escape_segment(r"a/b\c"), r"a\/b\\c"); - assert_eq!(escape_segment("plain"), "plain"); - assert_eq!(unescape(r"u1/a\/b\\c"), r"u1/a/b\c"); - assert_eq!(segments(r"u1/a\/b\\c"), ["u1", r"a/b\c"]); - assert_eq!(join(["u1", "a/b"]), r"u1/a\/b"); - assert_eq!(join(Vec::::new()), ""); - assert_eq!(segments(""), [""]); - // Invalid names still convert: a stray backslash is kept, empty segments appear. - assert_eq!(unescape(r"a\b\"), r"a\b\"); - assert_eq!(segments("/a//"), ["", "a", "", ""]); - } - - // CORE-REQ-0110 to 0112: random raw texts escape, join and convert back exactly, and the - // check accepts a joined name exactly when every segment is non-empty printable ASCII. - #[test] - fn random_texts_round_trip() { - let mut rng = Rng(0x9E37_79B9_7F4A_7C15); - for _ in 0..5_000 { - let wild = rng.below(4) == 0; - let raw = rng.text(12, wild); - let seg = escape_segment(&raw); - assert_eq!(unescape(&seg), raw); - assert_eq!(segments(&seg), std::slice::from_ref(&raw)); - assert_eq!( - check_hierarchical(&seg).is_ok(), - check_verbatim(&raw).is_ok() - ); - let raws: Vec = (0..1 + rng.below(4)).map(|_| rng.text(5, wild)).collect(); - let name = join(&raws); - assert_eq!(segments(&name), raws); - assert_eq!(unescape(&name), raws.join("/")); - let ok = raws.iter().all(|r| check_verbatim(r).is_ok()); - assert_eq!(check_hierarchical(&name).is_ok(), ok, "{name:?}"); - } - } - - // CORE-REQ-0110, 0111: random texts, most of them invalid, are accepted exactly when the - // rules hold, and the segments of an accepted name escape and join back to it. - #[test] - fn random_texts_are_checked() { - let mut rng = Rng(0x5EED); - let mut accepted = 0; - for _ in 0..20_000 { - let wild = rng.below(3) == 0; - let name = rng.text(8, wild); - let r = check_hierarchical(&name); - assert_eq!(r.is_ok(), valid(&name), "{name:?}: {r:?}"); - match r { - Ok(()) => { - accepted += 1; - assert_eq!(join(segments(&name)), name); - } - Err(NameError::NotPrintable { at, .. } | NameError::BadEscape { at }) => { - assert!(at < name.len()); - } - Err(NameError::EmptySegment { at }) => assert_eq!(name.as_bytes()[at], b'/'), - Err(e) => assert_eq!(e, NameError::Empty), - } - } - assert!(accepted > 1_000 && accepted < 15_000, "{accepted} accepted"); - } -} diff --git a/crates/core/rusteda-core/src/netlist.rs b/crates/core/rusteda-core/src/netlist.rs deleted file mode 100644 index 1097dff..0000000 --- a/crates/core/rusteda-core/src/netlist.rs +++ /dev/null @@ -1,716 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Gate-level netlist of one flat module: cell types, ports, nets, cell instances with their -//! pins, and continuous assignments. -//! -//! [`GateNetlist`] is the editable form (CORE-REQ-0120). Its entities live in generational -//! arenas and are addressed by typed ids ([`InstId`], [`NetId`], [`PinId`], [`CellId`]); every -//! operation checks the ids and names it is given and either succeeds or returns a -//! [`NetlistError`] and leaves the netlist unchanged, without interning a name (CORE-REQ-0122). -//! Names are interned per netlist. [`GateNetlist::freeze`] produces the dense [`FrozenNetlist`] -//! (compressed-sparse-row tables in a canonical order) that file codecs and queries use; -//! [`FrozenNetlist::thaw`] turns it back into a `GateNetlist`. -//! -//! Every operation keeps these invariants (CORE-REQ-0121): -//! - names follow the rules of [`names`](crate::names) (CORE-REQ-0123): printable ASCII; port, -//! net and instance names are hierarchical, with valid escapes and no empty segment; module, -//! cell and cell pin names are verbatim; -//! - cell, port and instance names are unique; -//! - a net is anonymous, or has a name and an optional bit index; named nets have unique -//! (name, index) pairs, and a name belongs either to one scalar net or to indexed nets only; -//! - the nets named after a port are exactly its bits: index per bit for a bus, none for a -//! scalar port; a bus has at most [`MAX_RANGE_WIDTH`] bits; -//! - an instance has one pin per pin of its cell, in cell pin order, and lists its connected -//! pins once each in the order they were connected; -//! - a pin connected to a net is listed among that net's pins, and only then. -//! -//! Ids are checked against their slot and its generation only (see [`Id`]): an id that another -//! netlist made is detected when it addresses no live entry of this one, so ids must stay with -//! the netlist that made them. -//! -//! ``` -//! use rusteda_core::netlist::{Conn, GateNetlist, PinDir, PortDir}; -//! -//! let mut g = GateNetlist::new("top")?; -//! let inv = g.add_cell_type("inv", &[("A", PinDir::Input), ("Y", PinDir::Output)])?; -//! g.add_port("a", PortDir::Input, None)?; -//! g.add_port("y", PortDir::Output, Some((1, 0)))?; -//! let a = g.find_net("a", None).unwrap(); -//! let y0 = g.find_net("y", Some(0)).unwrap(); -//! let u1 = g.add_instance("u1", inv)?; -//! g.connect(u1, "A", Conn::Net(a))?; -//! g.connect(u1, "Y", Conn::Net(y0))?; -//! let y1 = g.find_net("y", Some(1)).unwrap(); -//! g.add_assign(y1, Conn::Const(true))?; -//! -//! let frozen = g.freeze(); -//! frozen.validate()?; -//! assert_eq!(frozen.thaw()?.freeze(), frozen); -//! # Ok::<(), Box>(()) -//! ``` - -mod edit; -mod error; -mod frozen; -mod validate; - -#[cfg(test)] -mod tests; -#[cfg(test)] -mod tests_frozen; -#[cfg(test)] -mod tests_random; - -use std::fmt; - -use rustc_hash::{FxHashMap, FxHashSet}; - -use crate::arena::{Arena, Id, StaleId}; -use crate::names::{self, NameError}; -use crate::symbol::{SymbolId, SymbolInterner}; - -pub use error::NetlistError; -pub use frozen::{ - ANONYMOUS, AssignRec, CONST0, CONST1, FORMAT_VERSION, FrozenError, FrozenNetlist, InstRec, - NetRec, PortRec, SCALAR, module_entry, section, -}; - -/// The widest port range: 2^24 bits. A wider range is refused before any of its nets is made, -/// so that a short declaration cannot exhaust memory. -pub const MAX_RANGE_WIDTH: u32 = 1 << 24; - -/// Id of an instance. -pub type InstId = Id; -/// Id of a net. -pub type NetId = Id; -/// Id of an instance pin. -pub type PinId = Id; -/// Id of a cell type. -pub type CellId = Id; - -/// Direction of a cell pin. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PinDir { - Input, - Output, - Inout, - /// Any other pin, for example an internal one. - Other, -} - -impl PinDir { - /// True for the directions that can drive a net (output, inout). - pub fn drives(self) -> bool { - matches!(self, PinDir::Output | PinDir::Inout) - } -} - -/// Direction of a module port. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum PortDir { - Input, - Output, - Inout, -} - -/// A pin of a cell type. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CellPin { - pub name: SymbolId, - pub dir: PinDir, -} - -/// A library cell as the netlist sees it: its name and its pins in library order. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CellType { - pub name: SymbolId, - pub pins: Vec, -} - -/// A module port. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Port { - pub name: SymbolId, - pub dir: PortDir, - /// `(msb, lsb)` of a bus; `None` for a scalar port. - pub range: Option<(i32, i32)>, - /// One net per bit in declaration order, from `msb` to `lsb`. - pub bits: Vec, -} - -/// A net: anonymous, or named with an optional bit index (a bit of a bus). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Net { - pub name: Option, - pub index: Option, - /// The pins connected to the net, in connection order. - pub pins: Vec, -} - -/// A cell instance. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Instance { - pub name: SymbolId, - pub cell: CellId, - /// One pin per cell pin, in cell pin order. - pub pins: Vec, - /// The connected pins in the order they were connected: the order of the instance's pins in - /// the frozen view and of its connections in a written netlist. - pub connected: Vec, -} - -/// A pin of an instance. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Pin { - pub inst: InstId, - /// Index of the pin in its cell's pin list. - pub cell_pin: u32, - pub conn: Option, -} - -/// What a pin or the right-hand side of an assignment connects to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Conn { - Net(NetId), - Const(bool), -} - -/// A continuous assignment `lhs = rhs`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Assign { - pub lhs: NetId, - pub rhs: Conn, -} - -/// A cell pin given by name or by index. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PinRef<'a> { - Name(&'a str), - Index(u32), -} - -impl<'a> From<&'a str> for PinRef<'a> { - fn from(name: &'a str) -> PinRef<'a> { - PinRef::Name(name) - } -} - -impl<'a> From for PinRef<'a> { - fn from(index: u32) -> PinRef<'a> { - PinRef::Index(index) - } -} - -impl fmt::Display for PinRef<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PinRef::Name(n) => write!(f, "'{n}'"), - PinRef::Index(i) => write!(f, "#{i}"), - } - } -} - -/// How a name is used by nets. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum NameUse { - Scalar, - Indexed, -} - -/// The editable netlist of one module (see the module documentation). -#[derive(Debug, Clone)] -pub struct GateNetlist { - symbols: SymbolInterner, - name: SymbolId, - cells: Arena, - ports: Vec, - nets: Arena, - insts: Arena, - pins: Arena, - assigns: Vec, - cell_by_name: FxHashMap, - port_by_name: FxHashMap, - net_by_key: FxHashMap<(SymbolId, Option), NetId>, - /// How each net name is used, and by how many nets. - net_names: FxHashMap, - inst_by_name: FxHashMap, -} - -fn stale(kind: &'static str) -> impl Fn(StaleId) -> NetlistError { - move |e| NetlistError::StaleId { - kind, - index: e.index, - generation: e.generation, - } -} - -/// The error of a name of `kind` that breaks the rules of [`names`] (CORE-REQ-0123). -fn name_error( - kind: &'static str, - name: &str, - r: Result<(), NameError>, -) -> Result<(), NetlistError> { - r.map_err(|problem| match problem { - NameError::Empty => NetlistError::EmptyName { kind }, - problem => NetlistError::InvalidName { - kind, - name: names::for_display(name), - problem, - }, - }) -} - -/// Checks a module, cell or cell pin name: printable ASCII, kept verbatim. -fn verbatim(kind: &'static str, name: &str) -> Result<(), NetlistError> { - name_error(kind, name, names::check_verbatim(name)) -} - -/// Checks a port, net or instance name: printable ASCII, valid escapes, no empty segment. -fn hierarchical(kind: &'static str, name: &str) -> Result<(), NetlistError> { - name_error(kind, name, names::check_hierarchical(name)) -} - -/// `name` or `name[index]`. -fn bit_name(name: &str, index: Option) -> String { - match index { - Some(i) => format!("{name}[{i}]"), - None => name.to_string(), - } -} - -/// Number of bits of the range `[msb:lsb]` (up to 2^32). -pub(crate) fn range_width(msb: i32, lsb: i32) -> u64 { - (i64::from(msb) - i64::from(lsb)).unsigned_abs() + 1 -} - -/// Bit indices of a range in declaration order, from `msb` to `lsb`; computed in 64 bits, so -/// any two bounds work. -pub(crate) fn range_indices(msb: i32, lsb: i32) -> impl DoubleEndedIterator { - let step = if msb >= lsb { -1 } else { 1 }; - (0..range_width(msb, lsb) as i64).map(move |k| (i64::from(msb) + step * k) as i32) -} - -impl GateNetlist { - /// An empty netlist of module `name`, a verbatim name (CORE-REQ-0123). - pub fn new(name: &str) -> Result { - verbatim("module", name)?; - let mut symbols = SymbolInterner::new(); - let name = symbols.intern(name); - Ok(GateNetlist { - symbols, - name, - cells: Arena::new(), - ports: Vec::new(), - nets: Arena::new(), - insts: Arena::new(), - pins: Arena::new(), - assigns: Vec::new(), - cell_by_name: FxHashMap::default(), - port_by_name: FxHashMap::default(), - net_by_key: FxHashMap::default(), - net_names: FxHashMap::default(), - inst_by_name: FxHashMap::default(), - }) - } - - /// The module name. - pub fn name(&self) -> &str { - self.symbols.resolve(self.name) - } - - /// The names of this netlist. - pub fn symbols(&self) -> &SymbolInterner { - &self.symbols - } - - /// The string of one of this netlist's symbols. - /// - /// # Panics - /// - /// When `s` comes from another netlist's interner. - pub fn resolve(&self, s: SymbolId) -> &str { - self.symbols.resolve(s) - } - - pub fn cell_count(&self) -> usize { - self.cells.len() - } - - pub fn port_count(&self) -> usize { - self.ports.len() - } - - pub fn net_count(&self) -> usize { - self.nets.len() - } - - pub fn instance_count(&self) -> usize { - self.insts.len() - } - - /// Number of instance pins, connected or not. - pub fn pin_count(&self) -> usize { - self.pins.len() - } - - pub fn assign_count(&self) -> usize { - self.assigns.len() - } - - pub fn cell(&self, id: CellId) -> Result<&CellType, NetlistError> { - self.cells.try_get(id).map_err(stale("cell")) - } - - pub fn net(&self, id: NetId) -> Result<&Net, NetlistError> { - self.nets.try_get(id).map_err(stale("net")) - } - - pub fn instance(&self, id: InstId) -> Result<&Instance, NetlistError> { - self.insts.try_get(id).map_err(stale("instance")) - } - - pub fn pin(&self, id: PinId) -> Result<&Pin, NetlistError> { - self.pins.try_get(id).map_err(stale("pin")) - } - - /// Ports in declaration order. - pub fn ports(&self) -> &[Port] { - &self.ports - } - - /// Assignments in the order they were added. - pub fn assigns(&self) -> &[Assign] { - &self.assigns - } - - /// Cell types in id index order. - pub fn cells(&self) -> impl Iterator + '_ { - self.cells.iter() - } - - /// Nets in id index order. - pub fn nets(&self) -> impl Iterator + '_ { - self.nets.iter() - } - - /// Instances in id index order. - pub fn instances(&self) -> impl Iterator + '_ { - self.insts.iter() - } - - /// Instance pins in id index order. - pub fn pins(&self) -> impl Iterator + '_ { - self.pins.iter() - } - - pub fn find_cell(&self, name: &str) -> Option { - let s = self.symbols.get(name)?; - self.cell_by_name.get(&s).copied() - } - - pub fn find_port(&self, name: &str) -> Option<&Port> { - let s = self.symbols.get(name)?; - self.port_by_name.get(&s).map(|&i| &self.ports[i]) - } - - /// The net `name` (`index == None`) or `name[index]`. - pub fn find_net(&self, name: &str, index: Option) -> Option { - let s = self.symbols.get(name)?; - self.net_by_key.get(&(s, index)).copied() - } - - pub fn find_instance(&self, name: &str) -> Option { - let s = self.symbols.get(name)?; - self.inst_by_name.get(&s).copied() - } - - /// The pin of `inst` for a cell pin given by name or index. - pub fn find_pin<'p>( - &self, - inst: InstId, - pin: impl Into>, - ) -> Result { - let i = self.instance(inst)?; - let k = self.cell_pin_index(i.cell, pin.into())?; - Ok(i.pins[k as usize]) - } - - /// Index of a cell pin in its cell's pin list. - fn cell_pin_index(&self, cell: CellId, pin: PinRef<'_>) -> Result { - let c = self.cell(cell)?; - let k = match pin { - PinRef::Index(k) => ((k as usize) < c.pins.len()).then_some(k), - PinRef::Name(n) => self - .symbols - .get(n) - .and_then(|s| c.pins.iter().position(|p| p.name == s)) - .map(|k| k as u32), - }; - k.ok_or_else(|| NetlistError::UnknownPin { - cell: self.resolve(c.name).to_string(), - pin: pin.to_string(), - }) - } - - /// Adds a cell type with its pins in library order; cell and pin names are verbatim - /// (CORE-REQ-0123). Adding a cell again with the same pins returns the existing id. - pub fn add_cell_type>( - &mut self, - name: &str, - pins: &[(S, PinDir)], - ) -> Result { - verbatim("cell", name)?; - // A set, so that thawing a cell with many pins takes time in proportion to them. - let mut seen = FxHashSet::default(); - for (p, _) in pins { - let p = p.as_ref(); - verbatim("cell pin", p)?; - if !seen.insert(p) { - return Err(NetlistError::DuplicateCellPin { - cell: name.to_string(), - pin: p.to_string(), - }); - } - } - if let Some(id) = self.find_cell(name) { - let c = &self.cells[id]; - let same = c.pins.len() == pins.len() - && c.pins - .iter() - .zip(pins) - .all(|(a, (n, d))| a.dir == *d && self.resolve(a.name) == n.as_ref()); - return if same { - Ok(id) - } else { - Err(NetlistError::CellConflict { - cell: name.to_string(), - }) - }; - } - let cell = CellType { - name: self.symbols.intern(name), - pins: pins - .iter() - .map(|(n, dir)| CellPin { - name: self.symbols.intern(n.as_ref()), - dir: *dir, - }) - .collect(), - }; - let name = cell.name; - let id = self.cells.insert(cell); - self.cell_by_name.insert(name, id); - Ok(id) - } - - /// Adds a port and its bit nets: a scalar port gets one net named after it, a bus port one - /// net per bit named after it with the bit index. The name is hierarchical (CORE-REQ-0123); - /// a bus has at most [`MAX_RANGE_WIDTH`] bits (CORE-REQ-0128). Returns the port's position. - pub fn add_port( - &mut self, - name: &str, - dir: PortDir, - range: Option<(i32, i32)>, - ) -> Result { - hierarchical("port", name)?; - if let Some((msb, lsb)) = range { - if msb == i32::MIN || lsb == i32::MIN { - return Err(NetlistError::InvalidIndex { - what: format!("port '{name}': range bound i32::MIN is reserved"), - }); - } - if range_width(msb, lsb) > u64::from(MAX_RANGE_WIDTH) { - return Err(NetlistError::InvalidIndex { - what: format!( - "port '{name}': range [{msb}:{lsb}] is wider than {MAX_RANGE_WIDTH} bits" - ), - }); - } - } - if let Some(s) = self.symbols.get(name) { - if self.port_by_name.contains_key(&s) { - return Err(NetlistError::DuplicateName { - kind: "port", - name: name.to_string(), - }); - } - if self.net_names.contains_key(&s) { - return Err(NetlistError::NetConflict { - net: name.to_string(), - reason: "a port cannot take the name of existing nets", - }); - } - } - let s = self.symbols.intern(name); - let bits: Vec = match range { - None => vec![self.insert_net(Some(s), None)], - Some((msb, lsb)) => range_indices(msb, lsb) - .map(|k| self.insert_net(Some(s), Some(k))) - .collect(), - }; - Ok(self.insert_port(s, dir, range, bits)) - } - - /// Adds a net: `name == None` makes an anonymous net (without index), `index` makes a bit - /// of a bus. The name is hierarchical (CORE-REQ-0123); names of ports are reserved for their - /// bits. - pub fn add_net( - &mut self, - name: Option<&str>, - index: Option, - ) -> Result { - let Some(name) = name else { - if index.is_some() { - return Err(NetlistError::InvalidIndex { - what: "an anonymous net has no bit index".into(), - }); - } - return Ok(self.insert_net(None, None)); - }; - self.check_net_name(name, index)?; - let s = self.symbols.intern(name); - Ok(self.insert_net(Some(s), index)) - } - - /// The name rules of a new named net. - fn check_net_name(&self, name: &str, index: Option) -> Result<(), NetlistError> { - hierarchical("net", name)?; - if index == Some(i32::MIN) { - return Err(NetlistError::InvalidIndex { - what: format!("net '{name}': bit index i32::MIN is reserved"), - }); - } - let Some(s) = self.symbols.get(name) else { - return Ok(()); - }; - let reason = if self.port_by_name.contains_key(&s) { - Some("the name belongs to a port") - } else if self.net_by_key.contains_key(&(s, index)) { - Some("the net exists already") - } else { - match (self.net_names.get(&s).map(|e| e.0), index) { - (Some(NameUse::Scalar), _) => Some("the name belongs to a scalar net"), - (Some(NameUse::Indexed), None) => Some("the name belongs to indexed nets"), - _ => None, - } - }; - match reason { - Some(reason) => Err(NetlistError::NetConflict { - net: bit_name(name, index), - reason, - }), - None => Ok(()), - } - } - - /// Adds an instance of `cell` with all pins unconnected; the name is hierarchical - /// (CORE-REQ-0123). - pub fn add_instance(&mut self, name: &str, cell: CellId) -> Result { - let n_pins = self.cell(cell)?.pins.len(); - self.check_instance_name(name)?; - let s = self.symbols.intern(name); - let inst = self.insts.insert(Instance { - name: s, - cell, - pins: Vec::with_capacity(n_pins), - connected: Vec::new(), - }); - for k in 0..n_pins { - let pin = self.pins.insert(Pin { - inst, - cell_pin: k as u32, - conn: None, - }); - self.insts[inst].pins.push(pin); - } - self.inst_by_name.insert(s, inst); - Ok(inst) - } - - fn check_instance_name(&self, name: &str) -> Result<(), NetlistError> { - hierarchical("instance", name)?; - if self.find_instance(name).is_some() { - return Err(NetlistError::DuplicateName { - kind: "instance", - name: name.to_string(), - }); - } - Ok(()) - } - - /// Appends the assignment `lhs = rhs`. - pub fn add_assign(&mut self, lhs: NetId, rhs: Conn) -> Result<(), NetlistError> { - self.net(lhs)?; - if let Conn::Net(n) = rhs { - self.net(n)?; - } - self.assigns.push(Assign { lhs, rhs }); - Ok(()) - } - - /// Inserts a net and indexes its name; the caller has checked the name rules. - fn insert_net(&mut self, name: Option, index: Option) -> NetId { - let id = self.nets.insert(Net { - name, - index, - pins: Vec::new(), - }); - if let Some(s) = name { - self.net_by_key.insert((s, index), id); - let usage = if index.is_some() { - NameUse::Indexed - } else { - NameUse::Scalar - }; - self.net_names.entry(s).or_insert((usage, 0)).1 += 1; - } - id - } - - /// Appends a port over existing bit nets; the caller has checked the port rules. - fn insert_port( - &mut self, - name: SymbolId, - dir: PortDir, - range: Option<(i32, i32)>, - bits: Vec, - ) -> usize { - let k = self.ports.len(); - self.ports.push(Port { - name, - dir, - range, - bits, - }); - self.port_by_name.insert(name, k); - k - } - - /// The display name of a net for messages: `name`, `name[index]`, or `# (anonymous)`. - pub fn net_label(&self, id: NetId) -> String { - match self.nets.get(id) { - Some(Net { - name: Some(s), - index, - .. - }) => bit_name(self.resolve(*s), *index), - Some(_) => format!("#{} (anonymous)", id.index()), - None => format!("#{} (stale)", id.index()), - } - } - - /// The display name of a pin for messages: `instance.pin`. - pub fn pin_label(&self, id: PinId) -> String { - let Some(pin) = self.pins.get(id) else { - return format!("#{} (stale)", id.index()); - }; - let inst = &self.insts[pin.inst]; - let cell = &self.cells[inst.cell]; - format!( - "{}.{}", - self.resolve(inst.name), - self.resolve(cell.pins[pin.cell_pin as usize].name) - ) - } -} diff --git a/crates/core/rusteda-core/src/netlist/edit.rs b/crates/core/rusteda-core/src/netlist/edit.rs deleted file mode 100644 index ad1a135..0000000 --- a/crates/core/rusteda-core/src/netlist/edit.rs +++ /dev/null @@ -1,377 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Connection edits, removal of instances, nets and assignments, cell swaps, and buffer -//! insertion and removal. Each operation checks all its arguments before it changes anything. - -use rustc_hash::FxHashSet; - -use super::{ - Assign, CellId, Conn, GateNetlist, InstId, NetId, NetlistError, PinDir, PinId, PinRef, stale, -}; - -fn dir_name(d: PinDir) -> &'static str { - match d { - PinDir::Input => "input", - PinDir::Output => "output", - PinDir::Inout => "inout", - PinDir::Other => "other", - } -} - -impl GateNetlist { - /// Connects an unconnected pin of `inst`, given by cell pin name or index, to a net or a - /// constant; it becomes the instance's last connected pin. Returns the pin. - pub fn connect<'p>( - &mut self, - inst: InstId, - pin: impl Into>, - conn: Conn, - ) -> Result { - let pin = self.find_pin(inst, pin)?; - self.connect_pin(pin, conn)?; - Ok(pin) - } - - /// Connects an unconnected pin to a net or a constant; it becomes its instance's last - /// connected pin. - pub fn connect_pin(&mut self, pin: PinId, conn: Conn) -> Result<(), NetlistError> { - let p = self.pin(pin)?; - if p.conn.is_some() { - return Err(NetlistError::AlreadyConnected { - pin: self.pin_label(pin), - }); - } - let inst = p.inst; - if let Conn::Net(n) = conn { - self.net(n)?; - self.nets[n].pins.push(pin); - } - self.pins[pin].conn = Some(conn); - self.insts[inst].connected.push(pin); - Ok(()) - } - - /// Disconnects a pin and returns what it was connected to (`None`: it was unconnected). - pub fn disconnect(&mut self, pin: PinId) -> Result, NetlistError> { - let p = self.pin(pin)?; - let (old, inst) = (p.conn, p.inst); - if let Some(Conn::Net(n)) = old { - let pins = &mut self.nets[n].pins; - if let Some(k) = pins.iter().position(|&p| p == pin) { - pins.remove(k); - } - } - if old.is_some() { - self.insts[inst].connected.retain(|&p| p != pin); - } - self.pins[pin].conn = None; - Ok(old) - } - - /// Moves a connected pin to a net or a constant. Unlike `disconnect` followed by - /// `connect_pin`, which makes it the last connected pin of its instance, the pin keeps its - /// place in its instance's connection order; it becomes the last pin of its new net. - /// Returns what it was connected to. - pub fn move_pin(&mut self, pin: PinId, to: Conn) -> Result { - let Some(old) = self.pin(pin)?.conn else { - return Err(NetlistError::NotConnected { - pin: self.pin_label(pin), - }); - }; - if let Conn::Net(n) = to { - self.net(n)?; - } - if old == to { - return Ok(old); - } - if let Conn::Net(n) = old { - self.nets[n].pins.retain(|&p| p != pin); - } - if let Conn::Net(n) = to { - self.nets[n].pins.push(pin); - } - self.pins[pin].conn = Some(to); - Ok(old) - } - - /// Why a live net cannot be removed, apart from its pins: it is a bit of a port, or an - /// assignment refers to it. - fn net_in_use(&self, net: NetId) -> Option<&'static str> { - let port_bit = self.nets[net] - .name - .is_some_and(|s| self.port_by_name.contains_key(&s)); - if port_bit { - Some("it is a bit of a port") - } else if self - .assigns - .iter() - .any(|a| a.lhs == net || a.rhs == Conn::Net(net)) - { - Some("an assignment refers to it") - } else { - None - } - } - - /// Removes a net that nothing uses: no pin is connected to it, it is not a bit of a port, - /// and no assignment refers to it. Its id becomes stale, and its name and index are free - /// again. - pub fn remove_net(&mut self, net: NetId) -> Result<(), NetlistError> { - let reason = if self.net(net)?.pins.is_empty() { - self.net_in_use(net) - } else { - Some("pins are connected to it") - }; - if let Some(reason) = reason { - return Err(NetlistError::NetInUse { - net: self.net_label(net), - reason, - }); - } - let removed = self.nets.remove(net).map_err(stale("net"))?; - if let Some(s) = removed.name { - self.net_by_key.remove(&(s, removed.index)); - if let Some(e) = self.net_names.get_mut(&s) { - e.1 -= 1; - if e.1 == 0 { - self.net_names.remove(&s); - } - } - } - Ok(()) - } - - /// Removes the assignment at position `k` of [`GateNetlist::assigns`] and returns it; the - /// later assignments move up. - pub fn remove_assign(&mut self, k: usize) -> Result { - if k >= self.assigns.len() { - return Err(NetlistError::NoAssign { - index: k, - count: self.assigns.len(), - }); - } - Ok(self.assigns.remove(k)) - } - - /// Removes an instance after disconnecting its pins. The ids of the instance and of its pins - /// become stale. - pub fn remove_instance(&mut self, inst: InstId) -> Result<(), NetlistError> { - let pins = self.instance(inst)?.pins.clone(); - for &p in &pins { - self.disconnect(p)?; - self.pins.remove(p).map_err(stale("pin"))?; - } - let removed = self.insts.remove(inst).map_err(stale("instance"))?; - self.inst_by_name.remove(&removed.name); - Ok(()) - } - - /// Gives an instance another cell that is pin-compatible: the same pin names with the same - /// directions, in any order. Each connection stays with its pin name, the pin ids stay valid, - /// and the connection order is kept. - pub fn swap_cell(&mut self, inst: InstId, cell: CellId) -> Result<(), NetlistError> { - let old_cell = self.instance(inst)?.cell; - let new = self.cell(cell)?; - let old = &self.cells[old_cell]; - let reject = |reason: String| NetlistError::IncompatibleCell { - inst: self.resolve(self.insts[inst].name).to_string(), - cell: self.resolve(new.name).to_string(), - reason, - }; - if new.pins.len() != old.pins.len() { - return Err(reject(format!( - "it has {} pins, '{}' has {}", - new.pins.len(), - self.resolve(old.name), - old.pins.len() - ))); - } - // Position in the old pin list of each new pin; a permutation, since pin names are - // unique within a cell and the counts agree. - let mut order = Vec::with_capacity(new.pins.len()); - for np in &new.pins { - let name = self.resolve(np.name); - match old.pins.iter().position(|op| op.name == np.name) { - None => { - return Err(reject(format!( - "pin '{name}' is not a pin of '{}'", - self.resolve(old.name) - ))); - } - Some(k) if old.pins[k].dir != np.dir => { - return Err(reject(format!( - "pin '{name}' has direction {}, not {} as in '{}'", - dir_name(np.dir), - dir_name(old.pins[k].dir), - self.resolve(old.name) - ))); - } - Some(k) => order.push(k), - } - } - let old_pins = self.insts[inst].pins.clone(); - let pins: Vec = order.iter().map(|&k| old_pins[k]).collect(); - for (j, &p) in pins.iter().enumerate() { - self.pins[p].cell_pin = j as u32; - } - let i = &mut self.insts[inst]; - i.cell = cell; - i.pins = pins; - Ok(()) - } - - /// Splits `net` with a buffer: a new instance `inst_name` of `cell` gets its `input` pin - /// connected to `net` and its `output` pin to a new scalar net `net_name` (`None`: - /// anonymous), and the `sinks` move from `net` to the new net, each keeping its place in its - /// instance's connection order. Every sink must be connected to `net` and must not drive it - /// (output and inout pins never move). Returns the buffer instance and the new net. - pub fn insert_buffer<'p>( - &mut self, - net: NetId, - sinks: &[PinId], - cell: CellId, - input: impl Into>, - output: impl Into>, - inst_name: &str, - net_name: Option<&str>, - ) -> Result<(InstId, NetId), NetlistError> { - self.net(net)?; - let k_in = self.cell_pin_index(cell, input.into())?; - let k_out = self.cell_pin_index(cell, output.into())?; - let c = &self.cells[cell]; - for (k, dir, expected) in [ - (k_in, PinDir::Input, "input"), - (k_out, PinDir::Output, "output"), - ] { - let p = &c.pins[k as usize]; - if p.dir != dir { - return Err(NetlistError::BufferPin { - cell: self.resolve(c.name).to_string(), - pin: format!("'{}'", self.resolve(p.name)), - expected, - }); - } - } - let mut moved = FxHashSet::default(); - for &p in sinks { - let pin = self.pin(p)?; - if !moved.insert(p) { - return Err(NetlistError::DuplicateSink { - pin: self.pin_label(p), - }); - } - if pin.conn != Some(Conn::Net(net)) { - return Err(NetlistError::NotOnNet { - pin: self.pin_label(p), - net: self.net_label(net), - }); - } - if self.pin_dir(p).drives() { - return Err(NetlistError::SinkIsDriver { - pin: self.pin_label(p), - }); - } - } - self.check_instance_name(inst_name)?; - if let Some(n) = net_name { - self.check_net_name(n, None)?; - } - // Everything is checked; the steps below cannot fail. - let out = self.add_net(net_name, None)?; - let buf = self.add_instance(inst_name, cell)?; - let (pin_in, pin_out) = { - let pins = &self.insts[buf].pins; - (pins[k_in as usize], pins[k_out as usize]) - }; - self.connect_pin(pin_in, Conn::Net(net))?; - self.connect_pin(pin_out, Conn::Net(out))?; - self.nets[net].pins.retain(|p| !moved.contains(p)); - for &p in sinks { - self.pins[p].conn = Some(Conn::Net(out)); - self.nets[out].pins.push(p); - } - Ok((buf, out)) - } - - /// Undoes [`GateNetlist::insert_buffer`]: `inst` must connect exactly one input pin, to a - /// net `a`, and one output pin, to another net `b`; `b` must not be a bit of a port or - /// appear in an assignment, and no other pin may drive it. The other pins of `b` move to - /// `a`, each keeping its place in its instance's connection order, and `inst` and `b` are - /// removed. So a buffer insertion followed by the removal of the buffer leaves the frozen - /// tables as they were. Returns `a`. - pub fn remove_buffer(&mut self, inst: InstId) -> Result { - let i = self.instance(inst)?; - let reject = |reason: String| NetlistError::NotABuffer { - inst: self.resolve(i.name).to_string(), - reason, - }; - let cell = &self.cells[i.cell]; - let (mut input, mut output) = (None, None); - for &p in &i.connected { - let pin = &self.pins[p]; - let problem = match (pin.conn, cell.pins[pin.cell_pin as usize].dir) { - (Some(Conn::Const(_)), _) => Some("is connected to a constant"), - (_, PinDir::Input) if input.is_some() => Some("is a second input"), - (_, PinDir::Output) if output.is_some() => Some("is a second output"), - (Some(Conn::Net(n)), PinDir::Input) => { - input = Some(n); - None - } - (Some(Conn::Net(n)), PinDir::Output) => { - output = Some((p, n)); - None - } - _ => Some("is neither an input nor an output"), - }; - if let Some(problem) = problem { - return Err(reject(format!("pin '{}' {problem}", self.pin_label(p)))); - } - } - let (Some(a), Some((pin_out, b))) = (input, output) else { - return Err(reject( - "it does not connect both an input and an output".into(), - )); - }; - if a == b { - let net = self.net_label(a); - return Err(reject(format!( - "its input and output are on the same net '{net}'" - ))); - } - if let Some(reason) = self.net_in_use(b) { - return Err(NetlistError::NetInUse { - net: self.net_label(b), - reason, - }); - } - let sinks: Vec = self.nets[b] - .pins - .iter() - .copied() - .filter(|&p| p != pin_out) - .collect(); - if let Some(&d) = sinks.iter().find(|&&p| self.pin_dir(p).drives()) { - return Err(reject(format!( - "pin '{}' drives its output net '{}' too", - self.pin_label(d), - self.net_label(b) - ))); - } - // Everything is checked; the steps below cannot fail. - self.nets[b].pins.retain(|&p| p == pin_out); - for &p in &sinks { - self.pins[p].conn = Some(Conn::Net(a)); - self.nets[a].pins.push(p); - } - self.remove_instance(inst)?; - self.remove_net(b)?; - Ok(a) - } - - /// The direction of a live pin's cell pin. - fn pin_dir(&self, pin: PinId) -> PinDir { - let p = &self.pins[pin]; - self.cells[self.insts[p.inst].cell].pins[p.cell_pin as usize].dir - } -} diff --git a/crates/core/rusteda-core/src/netlist/error.rs b/crates/core/rusteda-core/src/netlist/error.rs deleted file mode 100644 index 6ac1e73..0000000 --- a/crates/core/rusteda-core/src/netlist/error.rs +++ /dev/null @@ -1,145 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Errors of the netlist mutation API. Names in the messages are resolved when the error is -//! made, so an error can outlive its netlist. - -use std::fmt; - -use crate::names::NameError; - -/// A rejected netlist operation; the netlist is unchanged. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum NetlistError { - /// An id that addresses no live entry of this netlist: its entry was removed, or it comes - /// from another netlist. An id of another netlist that happens to address a live entry of - /// this one is not detected (see [`Id`](crate::arena::Id)). - StaleId { - kind: &'static str, - index: u32, - generation: u32, - }, - /// An empty name. - EmptyName { kind: &'static str }, - /// A name that breaks the rules of [`names`](crate::names) for its kind (CORE-REQ-0123), - /// shown with every character outside printable ASCII as `\u{...}`. - InvalidName { - kind: &'static str, - name: String, - problem: NameError, - }, - /// A cell of this name exists with other pins. - CellConflict { cell: String }, - /// A cell type lists a pin name twice. - DuplicateCellPin { cell: String, pin: String }, - /// A port or instance of this name exists. - DuplicateName { kind: &'static str, name: String }, - /// The net name and index are taken, or the name belongs to a port or to nets of the other - /// kind (scalar versus indexed). - NetConflict { net: String, reason: &'static str }, - /// A bit index or port range uses the reserved value `i32::MIN`, a port range is wider than - /// [`MAX_RANGE_WIDTH`](super::MAX_RANGE_WIDTH), or an anonymous net has an index. - InvalidIndex { what: String }, - /// The cell has no such pin. - UnknownPin { cell: String, pin: String }, - /// The pin is connected already. - AlreadyConnected { pin: String }, - /// `swap_cell` to a cell whose pins differ. - IncompatibleCell { - inst: String, - cell: String, - reason: String, - }, - /// A buffer sink is not connected to the buffered net. - NotOnNet { pin: String, net: String }, - /// A buffer sink drives its net (output or inout pin). - SinkIsDriver { pin: String }, - /// A buffer sink is listed twice. - DuplicateSink { pin: String }, - /// A buffer pin has the wrong direction. - BufferPin { - cell: String, - pin: String, - expected: &'static str, - }, - /// The pin is not connected. - NotConnected { pin: String }, - /// A net to remove has pins, is a bit of a port or appears in an assignment. - NetInUse { net: String, reason: &'static str }, - /// No assignment at this position. - NoAssign { index: usize, count: usize }, - /// `remove_buffer` of an instance that is not a buffer between two nets. - NotABuffer { inst: String, reason: String }, -} - -impl fmt::Display for NetlistError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - NetlistError::StaleId { - kind, - index, - generation, - } => write!( - f, - "stale {kind} id (slot {index}, generation {generation}): it addresses no live \ - {kind} of this netlist" - ), - NetlistError::EmptyName { kind } => write!(f, "empty {kind} name"), - NetlistError::InvalidName { - kind, - name, - problem, - } => write!(f, "invalid {kind} name '{name}': {problem}"), - NetlistError::CellConflict { cell } => { - write!(f, "cell '{cell}' exists with different pins") - } - NetlistError::DuplicateCellPin { cell, pin } => { - write!(f, "cell '{cell}' lists pin '{pin}' twice") - } - NetlistError::DuplicateName { kind, name } => { - write!(f, "{kind} '{name}' exists already") - } - NetlistError::NetConflict { net, reason } => write!(f, "net '{net}': {reason}"), - NetlistError::InvalidIndex { what } => write!(f, "{what}"), - NetlistError::UnknownPin { cell, pin } => write!(f, "cell '{cell}' has no pin {pin}"), - NetlistError::AlreadyConnected { pin } => write!(f, "pin '{pin}' is connected already"), - NetlistError::IncompatibleCell { inst, cell, reason } => { - write!(f, "instance '{inst}' cannot take cell '{cell}': {reason}") - } - NetlistError::NotOnNet { pin, net } => { - write!(f, "pin '{pin}' is not connected to net '{net}'") - } - NetlistError::SinkIsDriver { pin } => { - write!( - f, - "pin '{pin}' drives its net and cannot move behind a buffer" - ) - } - NetlistError::DuplicateSink { pin } => write!(f, "pin '{pin}' is listed twice"), - NetlistError::BufferPin { - cell, - pin, - expected, - } => write!( - f, - "pin {pin} of buffer cell '{cell}' is not an {expected} pin" - ), - NetlistError::NotConnected { pin } => write!(f, "pin '{pin}' is not connected"), - NetlistError::NetInUse { net, reason } => { - write!(f, "net '{net}' cannot be removed: {reason}") - } - NetlistError::NoAssign { index, count } => { - write!( - f, - "no assignment at position {index}: the netlist has {count}" - ) - } - NetlistError::NotABuffer { inst, reason } => { - write!(f, "instance '{inst}' is not a removable buffer: {reason}") - } - } - } -} - -impl std::error::Error for NetlistError {} diff --git a/crates/core/rusteda-core/src/netlist/frozen.rs b/crates/core/rusteda-core/src/netlist/frozen.rs deleted file mode 100644 index 8b119b6..0000000 --- a/crates/core/rusteda-core/src/netlist/frozen.rs +++ /dev/null @@ -1,713 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Frozen netlist: the dense tables of a [`GateNetlist`] in a canonical order, their section -//! bytes, and the conversions from and to the editable netlist. - -use std::borrow::Cow; -use std::fmt; -use std::ops::Range; - -use bytemuck::{Pod, Zeroable}; - -use super::{Conn, GateNetlist, NetId, NetlistError, PinDir, PinRef, PortDir}; -use crate::symbol::{SymbolId, SymbolInterner}; - -/// Version of the frozen layout. -pub const FORMAT_VERSION: u32 = 1; -/// `pin_net` and assignment right-hand-side value of the constant 0 (CORE-REQ-0132). -pub const CONST0: u32 = 0xFFFF_FFFE; -/// `pin_net` and assignment right-hand-side value of the constant 1 (CORE-REQ-0132). -pub const CONST1: u32 = 0xFFFF_FFFF; -/// Net name of an anonymous net (CORE-REQ-0132). -pub const ANONYMOUS: u32 = u32::MAX; -/// Bit index of a scalar net (CORE-REQ-0132). -pub const SCALAR: i32 = i32::MIN; - -/// Section ids of the netlist file, in the order of the [`FrozenNetlist`] fields. -pub mod section { - pub const STR_BYTES: u32 = 1; - pub const STR_OFFS: u32 = 2; - pub const MODULE: u32 = 3; - pub const CELLS: u32 = 4; - pub const CELL_PIN_START: u32 = 5; - pub const CELL_PIN_NAME: u32 = 6; - pub const CELL_PIN_DIR: u32 = 7; - pub const PORTS: u32 = 8; - pub const PORT_BIT_START: u32 = 9; - pub const PORT_BIT_NET: u32 = 10; - pub const NETS: u32 = 11; - pub const INSTANCES: u32 = 12; - pub const INST_PIN_START: u32 = 13; - pub const PIN_CELLPIN: u32 = 14; - pub const PIN_NET: u32 = 15; - pub const ASSIGNS: u32 = 16; - pub const NET_PIN_START: u32 = 17; - pub const NET_PINS: u32 = 18; - /// Number of sections. - pub const COUNT: usize = 18; - - /// The element size in bytes of a section id, `None` for an unknown id; a netlist file - /// declares these sizes in its section table. - pub const fn elem_size(id: u32) -> Option { - const SIZES: [u32; COUNT] = [1, 4, 4, 4, 4, 4, 1, 16, 4, 4, 8, 8, 4, 4, 4, 8, 4, 4]; - if id >= 1 && id as usize <= COUNT { - Some(SIZES[id as usize - 1]) - } else { - None - } - } - - /// The name of a section id. - pub fn name(id: u32) -> &'static str { - const NAMES: [&str; COUNT] = [ - "STR_BYTES", - "STR_OFFS", - "MODULE", - "CELLS", - "CELL_PIN_START", - "CELL_PIN_NAME", - "CELL_PIN_DIR", - "PORTS", - "PORT_BIT_START", - "PORT_BIT_NET", - "NETS", - "INSTANCES", - "INST_PIN_START", - "PIN_CELLPIN", - "PIN_NET", - "ASSIGNS", - "NET_PIN_START", - "NET_PINS", - ]; - (id as usize) - .checked_sub(1) - .and_then(|k| NAMES.get(k)) - .copied() - .unwrap_or("unknown") - } -} - -/// Positions of the entries of the `module` section, which has exactly [`module_entry::COUNT`] -/// entries: the module name and the numbers of entities. -pub mod module_entry { - /// String id of the module name. - pub const NAME: usize = 0; - pub const N_PORTS: usize = 1; - pub const N_NETS: usize = 2; - pub const N_INSTS: usize = 3; - /// Connected pins: the rows of `pin_net`. - pub const N_PINS: usize = 4; - pub const N_ASSIGNS: usize = 5; - /// Number of entries. - pub const COUNT: usize = 6; -} - -/// A port: name, direction (0 input, 1 output, 2 inout) and, for a bus (`is_bus == 1`), its -/// range; a scalar port has `msb == lsb == 0` (CORE-REQ-0131). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Pod, Zeroable)] -pub struct PortRec { - pub name: u32, - pub dir: u8, - pub is_bus: u8, - /// Always 0. - pub _pad: u16, - pub msb: i32, - pub lsb: i32, -} - -/// A net: name or [`ANONYMOUS`], bit index or [`SCALAR`]. -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Pod, Zeroable)] -pub struct NetRec { - pub name: u32, - pub index: i32, -} - -/// An instance: name and cell. -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Pod, Zeroable)] -pub struct InstRec { - pub name: u32, - pub cell: u32, -} - -/// An assignment: left-hand net and right-hand net or constant ([`CONST0`], [`CONST1`]). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Pod, Zeroable)] -pub struct AssignRec { - pub lhs: u32, - pub rhs: u32, -} - -/// The dense tables of a [`GateNetlist`] in a canonical order (NETLIST format version -/// [`FORMAT_VERSION`]). -/// -/// Each field is one section of a netlist file (CORE-REQ-0130): a slice of plain records in host -/// byte order, borrowed (for example from a mapped file) or owned. Entities are numbered densely: -/// the live entries in id index order (CORE-REQ-0133). Every string is stored once, and the -/// string table lists the strings in the order of their first use when visiting the module name, -/// each cell name followed by its pin names, the port names, the net names and the instance -/// names (CORE-REQ-0134). Every string is a name of printable ASCII characters, and a string that -/// names a port, net or instance is a hierarchical name, whatever else it names (CORE-REQ-0110, -/// 0111). A `*_start` section holds the start of each row of the section after it, plus its end -/// (rows + 1 entries, compressed sparse rows). -/// -/// | id | section | element | content | -/// |---|---|---|---| -/// | 1 | `str_bytes` | u8 | bytes of the strings (printable ASCII) | -/// | 2 | `str_offs` | u32 | start of each string, plus the end | -/// | 3 | `module` | u32 | module name and numbers of entities ([`module_entry`]) | -/// | 4 | `cells` | u32 | cell name | -/// | 5 | `cell_pin_start` | u32 | rows of cell pins, per cell | -/// | 6 | `cell_pin_name` | u32 | pin name | -/// | 7 | `cell_pin_dir` | u8 | 0 input, 1 output, 2 inout, 3 other | -/// | 8 | `ports` | [`PortRec`] | name, direction, bus range | -/// | 9 | `port_bit_start` | u32 | rows of bits, per port | -/// | 10 | `port_bit_net` | u32 | net of each bit, from msb to lsb | -/// | 11 | `nets` | [`NetRec`] | name ([`ANONYMOUS`]) and bit index ([`SCALAR`]) | -/// | 12 | `instances` | [`InstRec`] | name and cell | -/// | 13 | `inst_pin_start` | u32 | rows of connected pins, per instance | -/// | 14 | `pin_cellpin` | u32 | cell pin index, distinct within an instance | -/// | 15 | `pin_net` | u32 | net, or [`CONST0`] / [`CONST1`] | -/// | 16 | `assigns` | [`AssignRec`] | assignments in order | -/// | 17 | `net_pin_start` | u32 | rows of connected pins, per net | -/// | 18 | `net_pins` | u32 | pin (row of `pin_net`), increasing within a net | -/// -/// Only connected pins are stored, in the order they were connected; the other pins of an -/// instance are unconnected. [`section::elem_size`] gives the element size of each section. -/// Thawing gives every instance all the pins of its cell, so validation bounds the pins of all -/// instances together, connected or not: at most one per byte of the tables (or 2^24 for -/// smaller tables), never more than `u32::MAX`. A short file therefore cannot make `thaw` -/// exhaust memory (CORE-REQ-0137). [`FrozenNetlist::validate`] accepts exactly the tables that -/// [`GateNetlist::freeze`] can produce within this bound (CORE-REQ-0135), so `thaw` followed by -/// `freeze` gives back every valid frozen netlist unchanged (CORE-REQ-0136). -/// -/// Queries can use the tables without thawing (CORE-REQ-0138): [`FrozenNetlist::string`] reads -/// a string, and [`FrozenNetlist::cell_pins`], [`FrozenNetlist::instance_pins`], -/// [`FrozenNetlist::net_pins_of`], [`FrozenNetlist::port_bits`], -/// [`FrozenNetlist::pin_instance`] and [`FrozenNetlist::pin_dir`] give the rows and relations. -/// These assume validated tables: like slice indexing, they panic on an index out of range. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FrozenNetlist<'a> { - pub str_bytes: Cow<'a, [u8]>, - pub str_offs: Cow<'a, [u32]>, - pub module: Cow<'a, [u32]>, - pub cells: Cow<'a, [u32]>, - pub cell_pin_start: Cow<'a, [u32]>, - pub cell_pin_name: Cow<'a, [u32]>, - pub cell_pin_dir: Cow<'a, [u8]>, - pub ports: Cow<'a, [PortRec]>, - pub port_bit_start: Cow<'a, [u32]>, - pub port_bit_net: Cow<'a, [u32]>, - pub nets: Cow<'a, [NetRec]>, - pub instances: Cow<'a, [InstRec]>, - pub inst_pin_start: Cow<'a, [u32]>, - pub pin_cellpin: Cow<'a, [u32]>, - pub pin_net: Cow<'a, [u32]>, - pub assigns: Cow<'a, [AssignRec]>, - pub net_pin_start: Cow<'a, [u32]>, - pub net_pins: Cow<'a, [u32]>, -} - -/// A frozen netlist that breaks a rule of its format. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FrozenError { - /// The section id ([`section`]) where the problem was found. - pub section: u32, - pub message: String, -} - -impl FrozenError { - pub(crate) fn new(section: u32, message: impl Into) -> FrozenError { - FrozenError { - section, - message: message.into(), - } - } -} - -impl fmt::Display for FrozenError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "netlist section {} ({}): {}", - self.section, - section::name(self.section), - self.message - ) - } -} - -impl std::error::Error for FrozenError {} - -pub(crate) fn pin_dir_code(d: PinDir) -> u8 { - match d { - PinDir::Input => 0, - PinDir::Output => 1, - PinDir::Inout => 2, - PinDir::Other => 3, - } -} - -fn pin_dir_of(code: u8) -> PinDir { - match code { - 0 => PinDir::Input, - 1 => PinDir::Output, - 2 => PinDir::Inout, - _ => PinDir::Other, - } -} - -fn port_dir_code(d: PortDir) -> u8 { - match d { - PortDir::Input => 0, - PortDir::Output => 1, - PortDir::Inout => 2, - } -} - -fn port_dir_of(code: u8) -> PortDir { - match code { - 0 => PortDir::Input, - 1 => PortDir::Output, - _ => PortDir::Inout, - } -} - -/// Converts a table length or offset to its stored width. -/// -/// # Panics -/// -/// When the value does not fit 32 bits (a netlist with more than 4 GiB of names, for example). -fn u32_of(n: usize) -> u32 { - u32::try_from(n).expect("netlist table exceeds 32-bit offsets") -} - -/// The string table under construction: strings numbered by first use. -struct Strings<'s> { - symbols: &'s SymbolInterner, - ids: Vec, - bytes: Vec, - offs: Vec, -} - -impl Strings<'_> { - fn id(&mut self, s: SymbolId) -> u32 { - let slot = &mut self.ids[s.index() as usize]; - if *slot == u32::MAX { - *slot = u32_of(self.offs.len() - 1); - self.bytes - .extend_from_slice(self.symbols.resolve(s).as_bytes()); - self.offs.push(u32_of(self.bytes.len())); - } - *slot - } -} - -impl GateNetlist { - /// The dense, canonical tables of this netlist (see [`FrozenNetlist`]). - /// - /// # Panics - /// - /// When a table outgrows 32-bit offsets (more than 4 GiB of names). - pub fn freeze(&self) -> FrozenNetlist<'static> { - let mut st = Strings { - symbols: &self.symbols, - ids: vec![u32::MAX; self.symbols.len()], - bytes: Vec::new(), - offs: vec![0], - }; - let module_name = st.id(self.name); - let mut cell_ix = vec![u32::MAX; self.cells.slot_count()]; - let mut cells = Vec::with_capacity(self.cells.len()); - let mut cell_pin_start = vec![0]; - let mut cell_pin_name = Vec::new(); - let mut cell_pin_dir = Vec::new(); - for (k, (id, c)) in self.cells.iter().enumerate() { - cell_ix[id.index() as usize] = u32_of(k); - cells.push(st.id(c.name)); - for p in &c.pins { - cell_pin_name.push(st.id(p.name)); - cell_pin_dir.push(pin_dir_code(p.dir)); - } - cell_pin_start.push(u32_of(cell_pin_name.len())); - } - let mut net_ix = vec![u32::MAX; self.nets.slot_count()]; - for (k, id) in self.nets.ids().enumerate() { - net_ix[id.index() as usize] = u32_of(k); - } - let net = |id: NetId| net_ix[id.index() as usize]; - let encode = |c: Conn| match c { - Conn::Net(n) => net(n), - Conn::Const(false) => CONST0, - Conn::Const(true) => CONST1, - }; - let mut ports = Vec::with_capacity(self.ports.len()); - let mut port_bit_start = vec![0]; - let mut port_bit_net = Vec::new(); - for p in &self.ports { - let (msb, lsb) = p.range.unwrap_or((0, 0)); - ports.push(PortRec { - name: st.id(p.name), - dir: port_dir_code(p.dir), - is_bus: u8::from(p.range.is_some()), - _pad: 0, - msb, - lsb, - }); - port_bit_net.extend(p.bits.iter().map(|&b| net(b))); - port_bit_start.push(u32_of(port_bit_net.len())); - } - let nets: Vec = self - .nets - .iter() - .map(|(_, n)| NetRec { - name: n.name.map_or(ANONYMOUS, |s| st.id(s)), - index: n.index.unwrap_or(SCALAR), - }) - .collect(); - let mut instances = Vec::with_capacity(self.insts.len()); - let mut inst_pin_start = vec![0]; - let mut pin_cellpin = Vec::new(); - let mut pin_net = Vec::new(); - for (_, i) in self.insts.iter() { - instances.push(InstRec { - name: st.id(i.name), - cell: cell_ix[i.cell.index() as usize], - }); - for &p in &i.connected { - let pin = &self.pins[p]; - if let Some(c) = pin.conn { - pin_cellpin.push(pin.cell_pin); - pin_net.push(encode(c)); - } - } - inst_pin_start.push(u32_of(pin_net.len())); - } - let assigns: Vec = self - .assigns - .iter() - .map(|a| AssignRec { - lhs: net(a.lhs), - rhs: encode(a.rhs), - }) - .collect(); - // Pins per net by a counting sort, which keeps pin order within a net. - let n_nets = nets.len(); - let mut net_pin_start = vec![0u32; n_nets + 1]; - for &v in &pin_net { - if (v as usize) < n_nets { - net_pin_start[v as usize + 1] += 1; - } - } - for k in 0..n_nets { - net_pin_start[k + 1] += net_pin_start[k]; - } - let mut fill = net_pin_start.clone(); - let mut net_pins = vec![0u32; net_pin_start[n_nets] as usize]; - for (p, &v) in pin_net.iter().enumerate() { - if (v as usize) < n_nets { - net_pins[fill[v as usize] as usize] = u32_of(p); - fill[v as usize] += 1; - } - } - // In the order of `module_entry`. - let module = vec![ - module_name, - u32_of(ports.len()), - u32_of(n_nets), - u32_of(instances.len()), - u32_of(pin_net.len()), - u32_of(assigns.len()), - ]; - FrozenNetlist { - str_bytes: Cow::Owned(st.bytes), - str_offs: Cow::Owned(st.offs), - module: Cow::Owned(module), - cells: Cow::Owned(cells), - cell_pin_start: Cow::Owned(cell_pin_start), - cell_pin_name: Cow::Owned(cell_pin_name), - cell_pin_dir: Cow::Owned(cell_pin_dir), - ports: Cow::Owned(ports), - port_bit_start: Cow::Owned(port_bit_start), - port_bit_net: Cow::Owned(port_bit_net), - nets: Cow::Owned(nets), - instances: Cow::Owned(instances), - inst_pin_start: Cow::Owned(inst_pin_start), - pin_cellpin: Cow::Owned(pin_cellpin), - pin_net: Cow::Owned(pin_net), - assigns: Cow::Owned(assigns), - net_pin_start: Cow::Owned(net_pin_start), - net_pins: Cow::Owned(net_pins), - } - } -} - -/// Reads one section from its declared element size and its bytes: borrowed when aligned, -/// copied otherwise. -fn section_of<'a, T: Pod>( - id: u32, - (elem_size, bytes): (u32, &'a [u8]), -) -> Result, FrozenError> { - let size = size_of::(); - debug_assert_eq!(section::elem_size(id), Some(size as u32)); - if elem_size as usize != size { - return Err(FrozenError::new( - id, - format!("element size {elem_size}, expected {size}"), - )); - } - if !bytes.len().is_multiple_of(size) { - return Err(FrozenError::new( - id, - format!( - "{} bytes are not a whole number of {size}-byte elements", - bytes.len() - ), - )); - } - Ok(match bytemuck::try_cast_slice(bytes) { - Ok(s) => Cow::Borrowed(s), - Err(_) => { - let mut v = vec![T::zeroed(); bytes.len() / size]; - bytemuck::cast_slice_mut::(&mut v).copy_from_slice(bytes); - Cow::Owned(v) - } - }) -} - -/// Row `k` of a start section. Entry `k` is read first, so `k + 1` cannot overflow. -fn csr_row(start: &[u32], k: u32) -> Range { - let k = k as usize; - start[k] as usize..start[k + 1] as usize -} - -/// The bytes of string `k`: from entry `k` of the offsets to entry `k + 1`, or `None` when an -/// entry is missing. `k` comes from the tables, so `k + 1` is checked: on a 32-bit target -/// [`ANONYMOUS`] as an index is `usize::MAX`. -pub(super) fn string_bytes(offs: &[u32], k: usize) -> Option> { - let a = *offs.get(k)?; - let b = *offs.get(k.checked_add(1)?)?; - Some(a as usize..b as usize) -} - -impl<'a> FrozenNetlist<'a> { - /// The tables as owned data. - pub fn into_owned(self) -> FrozenNetlist<'static> { - FrozenNetlist { - str_bytes: Cow::Owned(self.str_bytes.into_owned()), - str_offs: Cow::Owned(self.str_offs.into_owned()), - module: Cow::Owned(self.module.into_owned()), - cells: Cow::Owned(self.cells.into_owned()), - cell_pin_start: Cow::Owned(self.cell_pin_start.into_owned()), - cell_pin_name: Cow::Owned(self.cell_pin_name.into_owned()), - cell_pin_dir: Cow::Owned(self.cell_pin_dir.into_owned()), - ports: Cow::Owned(self.ports.into_owned()), - port_bit_start: Cow::Owned(self.port_bit_start.into_owned()), - port_bit_net: Cow::Owned(self.port_bit_net.into_owned()), - nets: Cow::Owned(self.nets.into_owned()), - instances: Cow::Owned(self.instances.into_owned()), - inst_pin_start: Cow::Owned(self.inst_pin_start.into_owned()), - pin_cellpin: Cow::Owned(self.pin_cellpin.into_owned()), - pin_net: Cow::Owned(self.pin_net.into_owned()), - assigns: Cow::Owned(self.assigns.into_owned()), - net_pin_start: Cow::Owned(self.net_pin_start.into_owned()), - net_pins: Cow::Owned(self.net_pins.into_owned()), - } - } - - /// The sections as `(id, element size, bytes)` in id order, for writing a netlist file. - pub fn sections(&self) -> [(u32, u32, &[u8]); section::COUNT] { - fn s(id: u32, v: &[T]) -> (u32, u32, &[u8]) { - let size = size_of::() as u32; - debug_assert_eq!(section::elem_size(id), Some(size)); - (id, size, bytemuck::cast_slice(v)) - } - [ - s(section::STR_BYTES, &self.str_bytes), - s(section::STR_OFFS, &self.str_offs), - s(section::MODULE, &self.module), - s(section::CELLS, &self.cells), - s(section::CELL_PIN_START, &self.cell_pin_start), - s(section::CELL_PIN_NAME, &self.cell_pin_name), - s(section::CELL_PIN_DIR, &self.cell_pin_dir), - s(section::PORTS, &self.ports), - s(section::PORT_BIT_START, &self.port_bit_start), - s(section::PORT_BIT_NET, &self.port_bit_net), - s(section::NETS, &self.nets), - s(section::INSTANCES, &self.instances), - s(section::INST_PIN_START, &self.inst_pin_start), - s(section::PIN_CELLPIN, &self.pin_cellpin), - s(section::PIN_NET, &self.pin_net), - s(section::ASSIGNS, &self.assigns), - s(section::NET_PIN_START, &self.net_pin_start), - s(section::NET_PINS, &self.net_pins), - ] - } - - /// A frozen netlist over the sections of a netlist file, for example a mapped one: `section` - /// returns the declared element size and the bytes of a section id. Each declared size must - /// be the [`section::elem_size`] of its section and each length a whole number of elements. - /// Aligned sections are borrowed, others are copied. The content is not checked: call - /// [`FrozenNetlist::validate`] before using it. - pub fn from_sections( - section: impl Fn(u32) -> Option<(u32, &'a [u8])>, - ) -> Result, FrozenError> { - let get = |id: u32| section(id).ok_or_else(|| FrozenError::new(id, "section missing")); - Ok(FrozenNetlist { - str_bytes: section_of(section::STR_BYTES, get(section::STR_BYTES)?)?, - str_offs: section_of(section::STR_OFFS, get(section::STR_OFFS)?)?, - module: section_of(section::MODULE, get(section::MODULE)?)?, - cells: section_of(section::CELLS, get(section::CELLS)?)?, - cell_pin_start: section_of(section::CELL_PIN_START, get(section::CELL_PIN_START)?)?, - cell_pin_name: section_of(section::CELL_PIN_NAME, get(section::CELL_PIN_NAME)?)?, - cell_pin_dir: section_of(section::CELL_PIN_DIR, get(section::CELL_PIN_DIR)?)?, - ports: section_of(section::PORTS, get(section::PORTS)?)?, - port_bit_start: section_of(section::PORT_BIT_START, get(section::PORT_BIT_START)?)?, - port_bit_net: section_of(section::PORT_BIT_NET, get(section::PORT_BIT_NET)?)?, - nets: section_of(section::NETS, get(section::NETS)?)?, - instances: section_of(section::INSTANCES, get(section::INSTANCES)?)?, - inst_pin_start: section_of(section::INST_PIN_START, get(section::INST_PIN_START)?)?, - pin_cellpin: section_of(section::PIN_CELLPIN, get(section::PIN_CELLPIN)?)?, - pin_net: section_of(section::PIN_NET, get(section::PIN_NET)?)?, - assigns: section_of(section::ASSIGNS, get(section::ASSIGNS)?)?, - net_pin_start: section_of(section::NET_PIN_START, get(section::NET_PIN_START)?)?, - net_pins: section_of(section::NET_PINS, get(section::NET_PINS)?)?, - }) - } - - /// The string of a string id, checked for bounds and UTF-8: unlike the other accessors, it - /// never panics, also for unvalidated tables or [`ANONYMOUS`] (CORE-REQ-0138). - pub fn string(&self, k: u32) -> Result<&str, FrozenError> { - let Some(range) = usize::try_from(k) - .ok() - .and_then(|i| string_bytes(&self.str_offs, i)) - else { - return Err(FrozenError::new( - section::STR_OFFS, - format!("no string {k}"), - )); - }; - self.str_bytes - .get(range) - .and_then(|b| std::str::from_utf8(b).ok()) - .ok_or_else(|| FrozenError::new(section::STR_BYTES, format!("string {k} is invalid"))) - } - - /// The rows of cell `c`'s pins in `cell_pin_name` and `cell_pin_dir`, in library order. - pub fn cell_pins(&self, c: u32) -> Range { - csr_row(&self.cell_pin_start, c) - } - - /// The rows of instance `i`'s connected pins in `pin_cellpin` and `pin_net`, in connection - /// order. - pub fn instance_pins(&self, i: u32) -> Range { - csr_row(&self.inst_pin_start, i) - } - - /// The connected pins of net `n` (rows of `pin_net`), increasing. - pub fn net_pins_of(&self, n: u32) -> &[u32] { - &self.net_pins[csr_row(&self.net_pin_start, n)] - } - - /// The nets of port `p`'s bits, from msb to lsb. - pub fn port_bits(&self, p: u32) -> &[u32] { - &self.port_bit_net[csr_row(&self.port_bit_start, p)] - } - - /// The instance of pin `p` (a row of `pin_net`). - pub fn pin_instance(&self, p: u32) -> u32 { - assert!( - (p as usize) < self.pin_net.len(), - "pin {p} out of range ({} pins)", - self.pin_net.len() - ); - // The last row that starts at or before `p`; rows of unconnected instances are empty. - // Validated rows start at 0, so there is one; otherwise this panics rather than wraps. - let rows = self.inst_pin_start.partition_point(|&s| s <= p); - let k = rows - .checked_sub(1) - .unwrap_or_else(|| panic!("pin {p}: the instance pin rows do not start at 0")); - u32::try_from(k).unwrap_or_else(|_| panic!("pin {p}: instance {k} out of range")) - } - - /// The direction of pin `p`'s cell pin. - pub fn pin_dir(&self, p: u32) -> PinDir { - let cell = self.instances[self.pin_instance(p) as usize].cell; - // Indexing within the cell's row, never adding to its start, cannot overflow on 32-bit - // targets, and a cell pin outside the row panics instead of reading another cell's pin. - let dirs = &self.cell_pin_dir[self.cell_pins(cell)]; - pin_dir_of(dirs[self.pin_cellpin[p as usize] as usize]) - } - - /// Rebuilds the editable netlist after [`FrozenNetlist::validate`] (CORE-REQ-0136). Entities - /// keep their order, so `freeze` of the result equals `self`. - pub fn thaw(&self) -> Result { - self.validate()?; - let ctx = |section: u32| move |e: NetlistError| FrozenError::new(section, e.to_string()); - let name = self.string(self.module[module_entry::NAME])?; - let mut g = GateNetlist::new(name).map_err(ctx(section::MODULE))?; - // String k becomes symbol k: the table is canonical, so the module name is string 0. - let mut syms = Vec::with_capacity(self.str_offs.len() - 1); - for k in 0..self.str_offs.len() - 1 { - syms.push(g.symbols.intern(self.string(k as u32)?)); - } - let row = |start: &[u32], k: usize| start[k] as usize..start[k + 1] as usize; - let mut cells = Vec::with_capacity(self.cells.len()); - for (k, &name) in self.cells.iter().enumerate() { - let r = row(&self.cell_pin_start, k); - let mut pins = Vec::with_capacity(r.len()); - for p in r { - pins.push(( - self.string(self.cell_pin_name[p])?, - pin_dir_of(self.cell_pin_dir[p]), - )); - } - cells.push( - g.add_cell_type(self.string(name)?, &pins) - .map_err(ctx(section::CELLS))?, - ); - } - let nets: Vec = self - .nets - .iter() - .map(|n| { - let name = (n.name != ANONYMOUS).then(|| syms[n.name as usize]); - g.insert_net(name, (n.index != SCALAR).then_some(n.index)) - }) - .collect(); - for (k, p) in self.ports.iter().enumerate() { - let bits = self.port_bit_net[row(&self.port_bit_start, k)] - .iter() - .map(|&b| nets[b as usize]) - .collect(); - let range = (p.is_bus == 1).then_some((p.msb, p.lsb)); - g.insert_port(syms[p.name as usize], port_dir_of(p.dir), range, bits); - } - let decode = |v: u32| match v { - CONST0 => Conn::Const(false), - CONST1 => Conn::Const(true), - n => Conn::Net(nets[n as usize]), - }; - for (k, i) in self.instances.iter().enumerate() { - let inst = g - .add_instance(self.string(i.name)?, cells[i.cell as usize]) - .map_err(ctx(section::INSTANCES))?; - for p in row(&self.inst_pin_start, k) { - g.connect( - inst, - PinRef::Index(self.pin_cellpin[p]), - decode(self.pin_net[p]), - ) - .map_err(ctx(section::PIN_NET))?; - } - } - for a in self.assigns.iter() { - g.add_assign(nets[a.lhs as usize], decode(a.rhs)) - .map_err(ctx(section::ASSIGNS))?; - } - Ok(g) - } -} diff --git a/crates/core/rusteda-core/src/netlist/tests.rs b/crates/core/rusteda-core/src/netlist/tests.rs deleted file mode 100644 index c92354c..0000000 --- a/crates/core/rusteda-core/src/netlist/tests.rs +++ /dev/null @@ -1,844 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Mutation API: every operation, its error cases, and the invariants after each. - -use super::*; - -/// Checks every invariant of the module documentation on the internal tables (CORE-REQ-0121), -/// and that the frozen view validates and thaws back to itself (CORE-REQ-0135, 0136). -pub(super) fn assert_consistent(g: &GateNetlist) { - let hierarchical = |s: SymbolId| names::check_hierarchical(g.resolve(s)).is_ok(); - assert!(names::check_verbatim(g.name()).is_ok()); - assert_eq!(g.cell_by_name.len(), g.cells.len()); - for (id, c) in g.cells.iter() { - assert_eq!(g.cell_by_name[&c.name], id); - assert!(names::check_verbatim(g.resolve(c.name)).is_ok()); - for (k, p) in c.pins.iter().enumerate() { - assert!(names::check_verbatim(g.resolve(p.name)).is_ok()); - assert!(c.pins[..k].iter().all(|q| q.name != p.name)); - } - } - assert_eq!(g.port_by_name.len(), g.ports.len()); - for (k, p) in g.ports.iter().enumerate() { - assert_eq!(g.port_by_name[&p.name], k); - assert!(hierarchical(p.name)); - let want: Vec> = match p.range { - None => vec![None], - Some((m, l)) => range_indices(m, l).map(Some).collect(), - }; - let got: Vec> = p.bits.iter().map(|&b| g.nets[b].index).collect(); - assert_eq!(got, want); - assert!(p.bits.iter().all(|&b| g.nets[b].name == Some(p.name))); - let same_name = g - .nets - .iter() - .filter(|(_, n)| n.name == Some(p.name)) - .count(); - assert_eq!(same_name, p.bits.len(), "nets named after port"); - } - let mut named = 0; - let mut per_name: FxHashMap = FxHashMap::default(); - for (id, n) in g.nets.iter() { - match n.name { - None => assert_eq!(n.index, None), - Some(s) => { - named += 1; - assert!(hierarchical(s)); - *per_name.entry(s).or_default() += 1; - assert_eq!(g.net_by_key[&(s, n.index)], id); - let usage = if n.index.is_some() { - NameUse::Indexed - } else { - NameUse::Scalar - }; - assert_eq!(g.net_names[&s].0, usage); - } - } - for (k, &p) in n.pins.iter().enumerate() { - assert_eq!(g.pins[p].conn, Some(Conn::Net(id))); - assert!(!n.pins[..k].contains(&p), "pin listed twice"); - } - } - assert_eq!(g.net_by_key.len(), named); - assert_eq!(g.net_names.len(), per_name.len()); - for (s, count) in per_name { - assert_eq!(g.net_names[&s].1, count, "nets of a name"); - } - assert_eq!(g.inst_by_name.len(), g.insts.len()); - for (id, i) in g.insts.iter() { - assert_eq!(g.inst_by_name[&i.name], id); - assert!(hierarchical(i.name)); - assert_eq!(i.pins.len(), g.cells[i.cell].pins.len()); - for (k, &p) in i.pins.iter().enumerate() { - assert_eq!((g.pins[p].inst, g.pins[p].cell_pin), (id, k as u32)); - } - // The connection order lists exactly the connected pins, once each. - let connected = i.pins.iter().filter(|&&p| g.pins[p].conn.is_some()); - assert_eq!(connected.count(), i.connected.len()); - for (k, &p) in i.connected.iter().enumerate() { - assert!(i.pins.contains(&p) && g.pins[p].conn.is_some()); - assert!(!i.connected[..k].contains(&p), "pin listed twice"); - } - } - let listed: usize = g.insts.iter().map(|(_, i)| i.pins.len()).sum(); - assert_eq!(listed, g.pins.len(), "every pin belongs to an instance"); - for (id, p) in g.pins.iter() { - if let Some(Conn::Net(n)) = p.conn { - assert!(g.nets[n].pins.contains(&id)); - } - } - for a in &g.assigns { - assert!(g.nets.contains(a.lhs)); - if let Conn::Net(n) = a.rhs { - assert!(g.nets.contains(n)); - } - } - let f = g.freeze(); - f.validate().unwrap_or_else(|e| panic!("{e}")); - assert_eq!(f.thaw().unwrap().freeze(), f); -} - -/// A state to compare before and after a rejected operation: a refused call changes nothing -/// and interns nothing (CORE-REQ-0122). -pub(super) fn snapshot(g: &GateNetlist) -> (FrozenNetlist<'static>, usize, Vec>) { - let net_pins = g.nets.iter().map(|(_, n)| n.pins.clone()).collect(); - (g.freeze(), g.symbols.len(), net_pins) -} - -/// Cells inv, buf, nand2 and its pin-permuted variant nand2r, dff and tie. -pub(super) fn library(g: &mut GateNetlist) -> [CellId; 6] { - use PinDir::{Input as I, Output as O}; - [ - g.add_cell_type("inv", &[("A", I), ("Y", O)]).unwrap(), - g.add_cell_type("buf", &[("A", I), ("Y", O)]).unwrap(), - g.add_cell_type("nand2", &[("A", I), ("B", I), ("Y", O)]) - .unwrap(), - g.add_cell_type("nand2r", &[("Y", O), ("B", I), ("A", I)]) - .unwrap(), - g.add_cell_type("dff", &[("D", I), ("CK", I), ("Q", O), ("QN", O)]) - .unwrap(), - g.add_cell_type("tie", &[("H", O)]).unwrap(), - ] -} - -// CORE-REQ-0120, 0122: cell types with their pins in library order. -#[test] -fn cell_types() { - let mut g = GateNetlist::new("top").unwrap(); - let [inv, ..] = library(&mut g); - assert_eq!(g.name(), "top"); - // Adding the same cell again is idempotent; other pins are a conflict. - let pins = vec![ - ("A".to_string(), PinDir::Input), - ("Y".into(), PinDir::Output), - ]; - assert_eq!(g.add_cell_type("inv", &pins), Ok(inv)); - let before = snapshot(&g); - let errors = [ - g.add_cell_type("inv", &[("A", PinDir::Input)]).unwrap_err(), - g.add_cell_type("inv", &[("A", PinDir::Input), ("Y", PinDir::Inout)]) - .unwrap_err(), - g.add_cell_type("x", &[("A", PinDir::Input), ("A", PinDir::Output)]) - .unwrap_err(), - g.add_cell_type("", &[("A", PinDir::Input)]).unwrap_err(), - g.add_cell_type("x", &[("", PinDir::Input)]).unwrap_err(), - ]; - assert_eq!( - errors.map(|e| e.to_string()), - [ - "cell 'inv' exists with different pins", - "cell 'inv' exists with different pins", - "cell 'x' lists pin 'A' twice", - "empty cell name", - "empty cell pin name", - ] - ); - assert_eq!(snapshot(&g), before); - assert_eq!(g.cell_count(), 6); - let c = g.cell(inv).unwrap(); - let names: Vec<&str> = c.pins.iter().map(|p| g.resolve(p.name)).collect(); - assert_eq!(names, ["A", "Y"]); - assert_eq!( - g.find_cell("nand2r").map(|c| g.cell(c).unwrap().pins.len()), - Some(3) - ); - assert_eq!(g.find_cell("nand3"), None); - let order: Vec<&str> = g.cells().map(|(_, c)| g.resolve(c.name)).collect(); - assert_eq!(order, ["inv", "buf", "nand2", "nand2r", "dff", "tie"]); - assert!(GateNetlist::new("").is_err()); - assert_consistent(&g); -} - -// CORE-REQ-0120, 0122, 0128: ports with their bit nets, named, indexed and anonymous nets, -// and the reserved index and range width. -#[test] -fn ports_and_nets() { - let mut g = GateNetlist::new("top").unwrap(); - assert_eq!(g.add_port("clk", PortDir::Input, None), Ok(0)); - assert_eq!(g.add_port("d", PortDir::Input, Some((3, 0))), Ok(1)); - assert_eq!(g.add_port("q", PortDir::Output, Some((0, 2))), Ok(2)); - assert_eq!(g.add_port("io", PortDir::Inout, Some((-1, -1))), Ok(3)); - let bits = |g: &GateNetlist, k: usize| -> Vec { - g.ports()[k].bits.iter().map(|&b| g.net_label(b)).collect() - }; - assert_eq!(bits(&g, 0), ["clk"]); - assert_eq!(bits(&g, 1), ["d[3]", "d[2]", "d[1]", "d[0]"]); - assert_eq!(bits(&g, 2), ["q[0]", "q[1]", "q[2]"]); - assert_eq!(bits(&g, 3), ["io[-1]"]); - assert_eq!(g.find_port("q").map(|p| p.range), Some(Some((0, 2)))); - assert_eq!(g.find_port("x"), None); - let n1 = g.add_net(Some("n1"), None).unwrap(); - let w5 = g.add_net(Some("w"), Some(5)).unwrap(); - let w6 = g.add_net(Some("w"), Some(-6)).unwrap(); - let anon = g.add_net(None, None).unwrap(); - let anon2 = g.add_net(None, None).unwrap(); - assert_ne!(anon, anon2); - assert_eq!(g.find_net("n1", None), Some(n1)); - assert_eq!(g.find_net("w", Some(5)), Some(w5)); - assert_eq!(g.find_net("w", Some(-6)), Some(w6)); - assert_eq!(g.find_net("w", None), None); - assert_eq!(g.find_net("d", Some(2)), Some(g.ports()[1].bits[1])); - assert_eq!(g.net(anon).unwrap().name, None); - assert_eq!(g.net_label(anon), format!("#{} (anonymous)", anon.index())); - assert_eq!((g.port_count(), g.net_count()), (4, 9 + 5)); - let before = snapshot(&g); - let errors = [ - g.add_port("clk", PortDir::Input, None), - g.add_port("n1", PortDir::Input, None), - g.add_port("w", PortDir::Input, Some((1, 0))), - g.add_port("p", PortDir::Input, Some((i32::MIN, 0))), - g.add_port("", PortDir::Input, None), - g.add_port("p", PortDir::Input, Some((MAX_RANGE_WIDTH as i32, 0))), - g.add_port("p", PortDir::Input, Some((-1, i32::MAX))), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "port 'clk' exists already", - "net 'n1': a port cannot take the name of existing nets", - "net 'w': a port cannot take the name of existing nets", - "port 'p': range bound i32::MIN is reserved", - "empty port name", - "port 'p': range [16777216:0] is wider than 16777216 bits", - "port 'p': range [-1:2147483647] is wider than 16777216 bits", - ] - ); - let errors = [ - g.add_net(Some("n1"), None), - g.add_net(Some("n1"), Some(0)), - g.add_net(Some("w"), Some(5)), - g.add_net(Some("w"), None), - g.add_net(Some("d"), Some(7)), - g.add_net(Some("clk"), None), - g.add_net(None, Some(1)), - g.add_net(Some("z"), Some(i32::MIN)), - g.add_net(Some(""), None), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "net 'n1': the net exists already", - "net 'n1[0]': the name belongs to a scalar net", - "net 'w[5]': the net exists already", - "net 'w': the name belongs to indexed nets", - "net 'd[7]': the name belongs to a port", - "net 'clk': the name belongs to a port", - "an anonymous net has no bit index", - "net 'z': bit index i32::MIN is reserved", - "empty net name", - ] - ); - assert_eq!(snapshot(&g), before, "failed operations change nothing"); - // Nets iterate in id order: port bits first here, then the added nets. - let labels: Vec = g.nets().map(|(id, _)| g.net_label(id)).collect(); - assert_eq!(labels[..2], ["clk", "d[3]"]); - assert_eq!(labels[9..12], ["n1", "w[5]", "w[-6]"]); - assert_consistent(&g); -} - -// CORE-REQ-0124, 0122: instances, connections in connection order, and assignments. -#[test] -fn instances_and_connections() { - let mut g = GateNetlist::new("top").unwrap(); - let [inv, _, nand2, ..] = library(&mut g); - g.add_port("a", PortDir::Input, None).unwrap(); - let a = g.find_net("a", None).unwrap(); - let n = g.add_net(Some("n"), None).unwrap(); - let u1 = g.add_instance("u1", nand2).unwrap(); - let u2 = g.add_instance("u2", inv).unwrap(); - assert_eq!(g.find_instance("u2"), Some(u2)); - assert_eq!((g.instance_count(), g.pin_count()), (2, 5)); - let i1 = g.instance(u1).unwrap(); - assert_eq!(i1.cell, nand2); - assert!(i1.pins.iter().all(|&p| g.pin(p).unwrap().conn.is_none())); - let pa = g.connect(u1, "A", Conn::Net(a)).unwrap(); - let pb = g.connect(u1, 1, Conn::Const(true)).unwrap(); - let py = g.connect(u1, "Y", Conn::Net(n)).unwrap(); - let p2 = g.connect(u2, "A", Conn::Net(n)).unwrap(); - assert_eq!(g.find_pin(u1, "B"), Ok(pb)); - assert_eq!(g.find_pin(u1, 0), Ok(pa)); - assert_eq!(g.net(n).unwrap().pins, [py, p2]); - assert_eq!(g.pin(pb).unwrap().conn, Some(Conn::Const(true))); - assert_eq!(g.pin_label(py), "u1.Y"); - let before = snapshot(&g); - let stale_net = Id::new(n.index(), 7); - let errors = [ - g.add_instance("u1", inv).map(|_| ()), - g.add_instance("", inv).map(|_| ()), - g.add_instance("u3", Id::new(9, 0)).map(|_| ()), - g.connect(u1, "A", Conn::Net(n)).map(|_| ()), - g.connect(u1, "Z", Conn::Net(n)).map(|_| ()), - g.connect(u1, 3, Conn::Net(n)).map(|_| ()), - g.connect(u2, "Y", Conn::Net(stale_net)).map(|_| ()), - g.add_assign(stale_net, Conn::Const(false)), - g.add_assign(n, Conn::Net(stale_net)), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "instance 'u1' exists already", - "empty instance name", - "stale cell id (slot 9, generation 0): it addresses no live cell of this netlist", - "pin 'u1.A' is connected already", - "cell 'nand2' has no pin 'Z'", - "cell 'nand2' has no pin #3", - "stale net id (slot 1, generation 7): it addresses no live net of this netlist", - "stale net id (slot 1, generation 7): it addresses no live net of this netlist", - "stale net id (slot 1, generation 7): it addresses no live net of this netlist", - ] - ); - assert_eq!(snapshot(&g), before); - assert_eq!(g.instance(u1).unwrap().connected, [pa, pb, py]); - assert_eq!(g.disconnect(py), Ok(Some(Conn::Net(n)))); - assert_eq!(g.disconnect(py), Ok(None)); - assert_eq!(g.net(n).unwrap().pins, [p2]); - assert_eq!(g.disconnect(pb), Ok(Some(Conn::Const(true)))); - assert_eq!(g.instance(u1).unwrap().connected, [pa]); - // A reconnected pin comes last in the connection order. - g.connect_pin(py, Conn::Net(a)).unwrap(); - g.connect_pin(pb, Conn::Const(false)).unwrap(); - assert_eq!(g.instance(u1).unwrap().connected, [pa, py, pb]); - assert_eq!(g.net(a).unwrap().pins, [pa, py]); - g.add_assign(n, Conn::Net(a)).unwrap(); - g.add_assign(n, Conn::Const(false)).unwrap(); - assert_eq!( - g.assigns(), - [ - Assign { - lhs: n, - rhs: Conn::Net(a) - }, - Assign { - lhs: n, - rhs: Conn::Const(false) - } - ] - ); - assert_eq!(g.assign_count(), 2); - assert_consistent(&g); -} - -// CORE-REQ-0125, 0101, 0102: removed instances and pins leave stale ids; their slots are -// reused. -#[test] -fn removing_instances() { - let mut g = GateNetlist::new("top").unwrap(); - let [inv, ..] = library(&mut g); - let n = g.add_net(Some("n"), None).unwrap(); - let m = g.add_net(Some("m"), None).unwrap(); - let u1 = g.add_instance("u1", inv).unwrap(); - let u2 = g.add_instance("u2", inv).unwrap(); - let a1 = g.connect(u1, "A", Conn::Net(n)).unwrap(); - g.connect(u1, "Y", Conn::Net(m)).unwrap(); - let a2 = g.connect(u2, "A", Conn::Net(m)).unwrap(); - let y2 = g.connect(u2, "Y", Conn::Net(n)).unwrap(); - g.remove_instance(u1).unwrap(); - assert_eq!(g.net(n).unwrap().pins, [y2]); - assert_eq!(g.net(m).unwrap().pins, [a2]); - assert_eq!((g.instance_count(), g.pin_count()), (1, 2)); - assert_eq!(g.find_instance("u1"), None); - // Ids of the removed instance and its pins are stale everywhere. - let before = snapshot(&g); - assert!(matches!( - g.instance(u1), - Err(NetlistError::StaleId { - kind: "instance", - .. - }) - )); - assert!(matches!( - g.pin(a1), - Err(NetlistError::StaleId { kind: "pin", .. }) - )); - assert!(g.remove_instance(u1).is_err()); - assert!(g.connect(u1, "A", Conn::Const(false)).is_err()); - assert!(g.disconnect(a1).is_err()); - assert!(g.swap_cell(u1, inv).is_err()); - assert_eq!(snapshot(&g), before); - // The name is free again; freed slots are reused with new generations. - let u3 = g.add_instance("u1", inv).unwrap(); - assert_eq!((u3.index(), u3.generation()), (u1.index(), 1)); - assert!(g.pin(a1).is_err(), "old pin id stays stale"); - assert_consistent(&g); - // Dense frozen numbering follows slot order: u1 (new) comes before u2. - let f = g.freeze(); - let names: Vec<&str> = f - .instances - .iter() - .map(|i| { - let r = f.str_offs[i.name as usize] as usize..f.str_offs[i.name as usize + 1] as usize; - std::str::from_utf8(&f.str_bytes[r]).unwrap() - }) - .collect(); - assert_eq!(names, ["u1", "u2"]); -} - -// CORE-REQ-0126: pin-compatible cell swaps. -#[test] -fn swapping_cells() { - use PinDir::{Input as I, Output as O}; - let mut g = GateNetlist::new("top").unwrap(); - let [inv, buf, nand2, nand2r, dff, _] = library(&mut g); - let odd = g.add_cell_type("odd", &[("A", I), ("Z", O)]).unwrap(); - let rev = g.add_cell_type("rev", &[("A", O), ("Y", I)]).unwrap(); - let a = g.add_net(Some("a"), None).unwrap(); - let y = g.add_net(Some("y"), None).unwrap(); - let u = g.add_instance("u", nand2).unwrap(); - let pa = g.connect(u, "A", Conn::Net(a)).unwrap(); - let pb = g.connect(u, "B", Conn::Const(false)).unwrap(); - let py = g.connect(u, "Y", Conn::Net(y)).unwrap(); - let unswapped = g.freeze(); - // Same names and directions in another order: pins keep their ids, connections and - // connection order. - g.swap_cell(u, nand2r).unwrap(); - let i = g.instance(u).unwrap(); - assert_eq!(i.cell, nand2r); - assert_eq!(i.pins, [py, pb, pa]); - assert_eq!(i.connected, [pa, pb, py]); - assert_eq!(g.find_pin(u, "A"), Ok(pa)); - assert_eq!(g.pin(pa).unwrap().cell_pin, 2); - assert_eq!(g.pin(pa).unwrap().conn, Some(Conn::Net(a))); - assert_consistent(&g); - g.swap_cell(u, nand2).unwrap(); - assert_eq!(g.instance(u).unwrap().pins, [pa, pb, py]); - assert_eq!(g.freeze(), unswapped, "swapping back restores the tables"); - g.swap_cell(u, nand2).unwrap(); - let v = g.add_instance("v", inv).unwrap(); - let before = snapshot(&g); - let errors = [ - g.swap_cell(u, inv), - g.swap_cell(u, dff), - g.swap_cell(v, odd), - g.swap_cell(v, rev), - g.swap_cell(v, Id::new(40, 0)), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "instance 'u' cannot take cell 'inv': it has 2 pins, 'nand2' has 3", - "instance 'u' cannot take cell 'dff': it has 4 pins, 'nand2' has 3", - "instance 'v' cannot take cell 'odd': pin 'Z' is not a pin of 'inv'", - "instance 'v' cannot take cell 'rev': pin 'A' has direction output, not input as in \ - 'inv'", - "stale cell id (slot 40, generation 0): it addresses no live cell of this netlist", - ] - ); - assert_eq!(snapshot(&g), before); - g.swap_cell(v, buf).unwrap(); - assert_eq!(g.instance(v).unwrap().cell, buf); - assert_consistent(&g); -} - -// CORE-REQ-0127: buffer insertion splits a net; every check comes first. -#[test] -fn inserting_buffers() { - let mut g = GateNetlist::new("top").unwrap(); - let [inv, buf, nand2, _, dff, _] = library(&mut g); - let n = g.add_net(Some("n"), None).unwrap(); - let drv = g.add_instance("drv", inv).unwrap(); - let dy = g.connect(drv, "Y", Conn::Net(n)).unwrap(); - let mut sinks = Vec::new(); - for k in 0..3 { - let s = g.add_instance(&format!("s{k}"), nand2).unwrap(); - sinks.push(g.connect(s, "B", Conn::Net(n)).unwrap()); - } - let ff = g.add_instance("ff", dff).unwrap(); - let ck = g.connect(ff, "CK", Conn::Net(n)).unwrap(); - let q = g.connect(ff, "Q", Conn::Net(n)).unwrap(); - let other = g.add_net(Some("other"), None).unwrap(); - let s0 = g.find_instance("s0").unwrap(); - let off_net = g.connect(s0, "A", Conn::Net(other)).unwrap(); - let before = snapshot(&g); - let errors = [ - g.insert_buffer(n, &[sinks[0], off_net], buf, "A", "Y", "b", None), - g.insert_buffer(n, &[sinks[1], dy], buf, "A", "Y", "b", None), - g.insert_buffer(n, &[q], buf, "A", "Y", "b", None), - g.insert_buffer(n, &[sinks[1], sinks[1]], buf, "A", "Y", "b", None), - g.insert_buffer(n, &[sinks[1]], buf, "Y", "A", "b", None), - g.insert_buffer(n, &[sinks[1]], buf, "A", "Z", "b", None), - g.insert_buffer(n, &[sinks[1]], buf, "A", "Y", "drv", None), - g.insert_buffer(n, &[sinks[1]], buf, "A", "Y", "b", Some("n")), - g.insert_buffer(n, &[sinks[1]], buf, "A", "Y", "", None), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "pin 's0.A' is not connected to net 'n'", - "pin 'drv.Y' drives its net and cannot move behind a buffer", - "pin 'ff.Q' drives its net and cannot move behind a buffer", - "pin 's1.B' is listed twice", - "pin 'Y' of buffer cell 'buf' is not an input pin", - "cell 'buf' has no pin 'Z'", - "instance 'drv' exists already", - "net 'n': the net exists already", - "empty instance name", - ] - ); - assert_eq!(snapshot(&g), before); - let (b, out) = g - .insert_buffer(n, &[sinks[2], ck], buf, "A", 1, "b", Some("n_buf")) - .unwrap(); - let bin = g.find_pin(b, "A").unwrap(); - let bout = g.find_pin(b, "Y").unwrap(); - assert_eq!(g.net(n).unwrap().pins, [dy, sinks[0], sinks[1], q, bin]); - assert_eq!(g.net(out).unwrap().pins, [bout, sinks[2], ck]); - // Moved sinks keep their place in their instance's connection order. - assert_eq!(g.instance(b).unwrap().connected, [bin, bout]); - assert_eq!(g.instance(ff).unwrap().connected, [ck, q]); - assert_eq!(g.find_net("n_buf", None), Some(out)); - assert_eq!(g.pin(ck).unwrap().conn, Some(Conn::Net(out))); - // An anonymous output net and no sinks are allowed. - let (_, anon) = g.insert_buffer(out, &[], buf, 0, "Y", "b2", None).unwrap(); - assert_eq!(g.net(anon).unwrap().name, None); - assert_eq!(g.net(anon).unwrap().pins.len(), 1); - assert_consistent(&g); -} - -// CORE-REQ-0124, 0125: moved pins keep their place; only unused nets can be removed. -#[test] -fn moving_pins_and_removing_nets() { - let mut g = GateNetlist::new("top").unwrap(); - let [inv, _, nand2, ..] = library(&mut g); - g.add_port("a", PortDir::Input, None).unwrap(); - let a = g.find_net("a", None).unwrap(); - let n = g.add_net(Some("n"), None).unwrap(); - let m = g.add_net(Some("m"), Some(0)).unwrap(); - let u = g.add_instance("u", nand2).unwrap(); - let pa = g.connect(u, "A", Conn::Net(n)).unwrap(); - let pb = g.connect(u, "B", Conn::Net(a)).unwrap(); - let py = g.connect(u, "Y", Conn::Net(m)).unwrap(); - // A moved pin keeps its place in its instance's connection order. - assert_eq!(g.move_pin(pa, Conn::Net(m)), Ok(Conn::Net(n))); - assert_eq!(g.move_pin(pb, Conn::Const(true)), Ok(Conn::Net(a))); - assert_eq!(g.instance(u).unwrap().connected, [pa, pb, py]); - assert_eq!(g.net(m).unwrap().pins, [py, pa]); - assert!(g.net(n).unwrap().pins.is_empty() && g.net(a).unwrap().pins.is_empty()); - assert_eq!(g.move_pin(pa, Conn::Net(m)), Ok(Conn::Net(m))); - assert_eq!( - g.net(m).unwrap().pins, - [py, pa], - "a move to the same net is no change" - ); - assert_consistent(&g); - g.add_assign(n, Conn::Const(false)).unwrap(); - let v = g.add_instance("v", inv).unwrap(); - let free = g.find_pin(v, "A").unwrap(); - let before = snapshot(&g); - let errors = [ - g.move_pin(free, Conn::Net(n)).map(drop), - g.move_pin(pa, Conn::Net(Id::new(n.index(), 9))).map(drop), - g.remove_net(m), - g.remove_net(a), - g.remove_net(n), - g.remove_assign(1).map(drop), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "pin 'v.A' is not connected", - "stale net id (slot 1, generation 9): it addresses no live net of this netlist", - "net 'm[0]' cannot be removed: pins are connected to it", - "net 'a' cannot be removed: it is a bit of a port", - "net 'n' cannot be removed: an assignment refers to it", - "no assignment at position 1: the netlist has 1", - ] - ); - assert_eq!(snapshot(&g), before, "failed operations change nothing"); - assert_eq!( - g.remove_assign(0), - Ok(Assign { - lhs: n, - rhs: Conn::Const(false) - }) - ); - assert_eq!(g.assign_count(), 0); - g.remove_net(n).unwrap(); - assert!(g.net(n).is_err() && g.remove_net(n).is_err()); - assert_eq!(g.find_net("n", None), None); - // Once its last net is gone, a name is free for nets of the other kind. - g.move_pin(pa, Conn::Net(a)).unwrap(); - g.move_pin(py, Conn::Const(false)).unwrap(); - g.remove_net(m).unwrap(); - let m2 = g.add_net(Some("m"), None).unwrap(); - let n2 = g.add_net(Some("n"), Some(4)).unwrap(); - assert_eq!( - (m2.index(), m2.generation()), - (m.index(), 1), - "the freed slot is reused" - ); - assert_eq!(g.find_net("n", Some(4)), Some(n2)); - assert_eq!(g.instance(u).unwrap().connected, [pa, pb, py]); - assert_consistent(&g); -} - -// CORE-REQ-0127: removing a buffer undoes its insertion: the sinks go back to the buffered net -// in their places, and the frozen tables are those before the insertion. -#[test] -fn removing_buffers() { - use PinDir::{Inout, Input, Output}; - let mut g = GateNetlist::new("top").unwrap(); - let [inv, buf, nand2, _, dff, _] = library(&mut g); - let pad = g - .add_cell_type("pad", &[("A", Input), ("Y", Output), ("IO", Inout)]) - .unwrap(); - g.add_port("y", PortDir::Output, None).unwrap(); - let y = g.find_net("y", None).unwrap(); - let n = g.add_net(Some("n"), None).unwrap(); - let drv = g.add_instance("drv", inv).unwrap(); - g.connect(drv, "Y", Conn::Net(n)).unwrap(); - let mut sinks = Vec::new(); - for k in 0..3 { - let s = g.add_instance(&format!("s{k}"), nand2).unwrap(); - sinks.push(g.connect(s, "B", Conn::Net(n)).unwrap()); - g.connect(s, "A", Conn::Const(false)).unwrap(); - } - let ff = g.add_instance("ff", dff).unwrap(); - let ck = g.connect(ff, "CK", Conn::Net(n)).unwrap(); - let d = g.connect(ff, "D", Conn::Net(n)).unwrap(); - let original = g.freeze(); - let (b, out) = g - .insert_buffer( - n, - &[sinks[2], ck, sinks[0]], - buf, - "A", - "Y", - "b", - Some("n_b"), - ) - .unwrap(); - assert_ne!(g.freeze(), original); - assert_eq!(g.remove_buffer(b), Ok(n)); - assert_eq!(g.freeze(), original); - assert!(g.instance(b).is_err() && g.net(out).is_err()); - assert_eq!(g.instance(ff).unwrap().connected, [ck, d]); - assert_eq!(g.pin(ck).unwrap().conn, Some(Conn::Net(n))); - assert_consistent(&g); - // The same by hand: move the sinks back, then remove the buffer and its net. - let (b, out) = g - .insert_buffer(n, &[sinks[1], d], buf, "A", "Y", "b", None) - .unwrap(); - for p in [d, sinks[1]] { - assert_eq!(g.move_pin(p, Conn::Net(n)), Ok(Conn::Net(out))); - } - g.remove_instance(b).unwrap(); - g.remove_net(out).unwrap(); - assert_eq!(g.freeze(), original); - // Instances that are not buffers between two nets, and buffers whose output net is in use. - let (b, out) = g - .insert_buffer(n, &[sinks[1]], buf, "A", "Y", "b", Some("n_b")) - .unwrap(); - let to_port = g.add_instance("to_port", buf).unwrap(); - g.connect(to_port, "A", Conn::Net(n)).unwrap(); - g.connect(to_port, "Y", Conn::Net(y)).unwrap(); - let looped = g.add_instance("looped", buf).unwrap(); - g.connect(looped, "Y", Conn::Net(n)).unwrap(); - g.connect(looped, "A", Conn::Net(n)).unwrap(); - let io = g.add_instance("io", pad).unwrap(); - g.connect(io, "A", Conn::Net(n)).unwrap(); - g.connect(io, "IO", Conn::Net(y)).unwrap(); - let before = snapshot(&g); - let errors = [ - g.remove_buffer(drv), - g.remove_buffer(g.find_instance("s0").unwrap()), - g.remove_buffer(ff), - g.remove_buffer(looped), - g.remove_buffer(io), - g.remove_buffer(to_port), - g.remove_buffer(Id::new(77, 0)), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "instance 'drv' is not a removable buffer: it does not connect both an input and an \ - output", - "instance 's0' is not a removable buffer: pin 's0.A' is connected to a constant", - "instance 'ff' is not a removable buffer: pin 'ff.D' is a second input", - "instance 'looped' is not a removable buffer: its input and output are on the same \ - net 'n'", - "instance 'io' is not a removable buffer: pin 'io.IO' is neither an input nor an \ - output", - "net 'y' cannot be removed: it is a bit of a port", - "stale instance id (slot 77, generation 0): it addresses no live instance of this \ - netlist", - ] - ); - assert_eq!(snapshot(&g), before); - let other = g.add_net(Some("other"), None).unwrap(); - g.add_assign(other, Conn::Net(out)).unwrap(); - let e = g.remove_buffer(b).unwrap_err().to_string(); - assert_eq!(e, "net 'n_b' cannot be removed: an assignment refers to it"); - g.remove_assign(0).unwrap(); - let w = g.add_instance("w", inv).unwrap(); - g.connect(w, "Y", Conn::Net(out)).unwrap(); - let e = g.remove_buffer(b).unwrap_err().to_string(); - assert_eq!( - e, - "instance 'b' is not a removable buffer: pin 'w.Y' drives its output net 'n_b' too" - ); - g.remove_instance(w).unwrap(); - g.remove_buffer(b).unwrap(); - assert_consistent(&g); -} - -// CORE-REQ-0128: bit indices follow the range from msb to lsb for any bounds, without overflow. -#[test] -fn range_indices_of_any_bounds() { - let down: Vec = range_indices(2, -1).collect(); - assert_eq!(down, [2, 1, 0, -1]); - assert_eq!(range_indices(-3, -1).collect::>(), [-3, -2, -1]); - assert_eq!(range_indices(5, 5).collect::>(), [5]); - let mut wide = range_indices(i32::MAX, i32::MIN + 1); - assert_eq!( - (wide.next(), wide.next(), wide.next_back()), - (Some(i32::MAX), Some(i32::MAX - 1), Some(i32::MIN + 1)) - ); - let mut up = range_indices(i32::MIN + 1, i32::MAX); - assert_eq!( - (up.next(), up.next_back()), - (Some(i32::MIN + 1), Some(i32::MAX)) - ); - assert_eq!(range_width(i32::MAX, i32::MIN + 1), (1 << 32) - 1); - assert_eq!(range_width(i32::MIN, i32::MAX), 1 << 32); - assert_eq!(range_width(-1, 1), 3); -} - -// CORE-REQ-0101: ids carry no mark of their netlist: one of another netlist is refused only -// when it addresses no live entry here, so ids must stay with their netlist. -#[test] -fn ids_of_another_netlist() { - let mut g1 = GateNetlist::new("a").unwrap(); - let mut g2 = GateNetlist::new("b").unwrap(); - let n1 = g1.add_net(Some("x"), None).unwrap(); - g1.add_net(Some("y"), None).unwrap(); - let late = g1.add_net(Some("z"), None).unwrap(); - g2.add_net(Some("w"), None).unwrap(); - assert_eq!( - g2.net(n1).map(|n| n.name.map(|s| g2.resolve(s))), - Ok(Some("w")) - ); - assert!(matches!( - g2.net(late), - Err(NetlistError::StaleId { kind: "net", .. }) - )); -} - -// CORE-REQ-0123, 0122, 0110, 0111: every call that takes a name checks it against the rules of -// its kind and refuses a bad one with the kind, the name and the reason, changing nothing and -// interning nothing. Port, net and instance names may have segments and escapes; module, cell -// and pin names are verbatim, so slashes and backslashes are plain characters there. -#[test] -fn names_follow_the_rules_of_their_kind() { - use PinDir::{Input as I, Output as O}; - let mut g = GateNetlist::new("top").unwrap(); - let [inv, buf, ..] = library(&mut g); - g.add_port(r"io\/a", PortDir::Input, Some((1, 0))).unwrap(); - let n = g.add_net(Some("u1/n2"), None).unwrap(); - g.add_net(Some(r"u1\\x/q"), Some(3)).unwrap(); - let u = g.add_instance(r"u1/u\/2", inv).unwrap(); - g.connect(u, "A", Conn::Net(n)).unwrap(); - let odd = g - .add_cell_type("lib/c//x", &[(r"A\", I), ("/Y", O)]) - .unwrap(); - g.add_instance(&names::join(["u1", "a/b", r"c\d"]), odd) - .unwrap(); - assert_eq!(g.find_net("u1/n2", None), Some(n)); - assert_eq!( - g.find_instance(r"u1/a\/b/c\\d") - .map(|i| g.instance(i).unwrap().cell), - Some(odd) - ); - assert_eq!(GateNetlist::new(r"m/x//y\").unwrap().name(), r"m/x//y\"); - let before = snapshot(&g); - let interned = g.symbols().len(); - let errors = [ - GateNetlist::new("a b").map(drop), - GateNetlist::new("t\u{e9}").map(drop), - g.add_cell_type("in v", &[("A", I)]).map(drop), - g.add_cell_type("x", &[("A\tB", I)]).map(drop), - g.add_port("/p", PortDir::Input, None).map(drop), - g.add_port("p\u{7f}", PortDir::Input, Some((1, 0))) - .map(drop), - g.add_net(Some("a//b"), None).map(drop), - g.add_net(Some(r"a\b"), None).map(drop), - g.add_net(Some("n/"), Some(0)).map(drop), - g.add_instance("u 1", inv).map(drop), - g.add_instance(r"u\", inv).map(drop), - g.insert_buffer(n, &[], buf, "A", "Y", "b//1", None) - .map(drop), - g.insert_buffer(n, &[], buf, "A", "Y", "b1", Some("n\0")) - .map(drop), - ] - .map(|r| r.unwrap_err().to_string()); - assert_eq!( - errors, - [ - "invalid module name 'a\\u{20}b': character U+0020 at byte 1 is not printable ASCII \ - (33 to 126)", - "invalid module name 't\\u{e9}': character U+00E9 at byte 1 is not printable ASCII \ - (33 to 126)", - "invalid cell name 'in\\u{20}v': character U+0020 at byte 2 is not printable ASCII \ - (33 to 126)", - "invalid cell pin name 'A\\u{9}B': character U+0009 at byte 1 is not printable ASCII \ - (33 to 126)", - "invalid port name '/p': the '/' at byte 0 leaves an empty segment", - "invalid port name 'p\\u{7f}': character U+007F at byte 1 is not printable ASCII (33 \ - to 126)", - "invalid net name 'a//b': the '/' at byte 2 leaves an empty segment", - "invalid net name 'a\\b': the backslash at byte 1 is not followed by '/' or '\\'", - "invalid net name 'n/': the '/' at byte 1 leaves an empty segment", - "invalid instance name 'u\\u{20}1': character U+0020 at byte 1 is not printable ASCII \ - (33 to 126)", - "invalid instance name 'u\\': the backslash at byte 1 is not followed by '/' or '\\'", - "invalid instance name 'b//1': the '/' at byte 2 leaves an empty segment", - "invalid net name 'n\\u{0}': character U+0000 at byte 1 is not printable ASCII (33 to \ - 126)", - ] - ); - assert_eq!(snapshot(&g), before, "refused names change nothing"); - assert_eq!( - g.symbols().len(), - interned, - "refused names are not interned" - ); - assert_eq!( - g.add_net(Some("a//b"), None), - Err(NetlistError::InvalidName { - kind: "net", - name: "a//b".into(), - problem: NameError::EmptySegment { at: 2 } - }) - ); - // A name that is valid as a cell name can be invalid as a net name. - assert!(g.add_cell_type("c//d", &[("A", I)]).is_ok()); - assert!(g.add_net(Some("c//d"), None).is_err()); - assert_consistent(&g); -} diff --git a/crates/core/rusteda-core/src/netlist/tests_frozen.rs b/crates/core/rusteda-core/src/netlist/tests_frozen.rs deleted file mode 100644 index 49991a2..0000000 --- a/crates/core/rusteda-core/src/netlist/tests_frozen.rs +++ /dev/null @@ -1,783 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Frozen view: table layout, round trips, section bytes, and rejection of corrupted tables -//! without panics. - -use std::borrow::Cow; - -use super::tests::{assert_consistent, library}; -use super::tests_random::Rng; -use super::*; - -/// Every kind of entity: scalar and bus ports (descending and ascending), named, anonymous and -/// indexed nets, constants, an unconnected pin, assignments, an instance named like a net, and -/// the slot of a removed instance reused by a later one. -fn sample() -> GateNetlist { - let mut g = GateNetlist::new("top").unwrap(); - let [inv, _, nand2, _, dff, tie] = library(&mut g); - g.add_port("clk", PortDir::Input, None).unwrap(); - g.add_port("d", PortDir::Input, Some((1, 0))).unwrap(); - g.add_port("q", PortDir::Output, Some((0, 1))).unwrap(); - let net = |g: &GateNetlist, n: &str, i: Option| g.find_net(n, i).unwrap(); - let (clk, d1, d0) = ( - net(&g, "clk", None), - net(&g, "d", Some(1)), - net(&g, "d", Some(0)), - ); - let (q0, q1) = (net(&g, "q", Some(0)), net(&g, "q", Some(1))); - let n = g.add_net(Some("n"), None).unwrap(); - let w = g.add_net(None, None).unwrap(); - g.add_net(Some("v"), Some(1)).unwrap(); - g.add_net(Some("v"), Some(0)).unwrap(); - let gone = g.add_instance("gone", inv).unwrap(); - g.connect(gone, "A", Conn::Net(n)).unwrap(); - let u1 = g.add_instance("u1", nand2).unwrap(); - g.connect(u1, "Y", Conn::Net(n)).unwrap(); - g.connect(u1, "B", Conn::Const(true)).unwrap(); - g.connect(u1, "A", Conn::Net(d1)).unwrap(); - let r = g.add_instance("r", dff).unwrap(); - g.connect(r, "D", Conn::Net(n)).unwrap(); - g.connect(r, "CK", Conn::Net(clk)).unwrap(); - g.connect(r, "Q", Conn::Net(q0)).unwrap(); - let t = g.add_instance("v", tie).unwrap(); - g.connect(t, "H", Conn::Net(w)).unwrap(); - g.remove_instance(gone).unwrap(); - let u2 = g.add_instance("u2", inv).unwrap(); - g.connect(u2, "A", Conn::Net(d0)).unwrap(); - g.connect(u2, "Y", Conn::Net(w)).unwrap(); - g.add_assign(q1, Conn::Net(w)).unwrap(); - g.add_assign(q1, Conn::Const(false)).unwrap(); - g -} - -fn strings<'f>(f: &'f FrozenNetlist<'_>) -> Vec<&'f str> { - f.str_offs - .windows(2) - .map(|w| std::str::from_utf8(&f.str_bytes[w[0] as usize..w[1] as usize]).unwrap()) - .collect() -} - -/// Overwrites string `k` of `f` with a text of the same length. -fn overwrite_string(f: &mut FrozenNetlist<'_>, k: usize, text: &[u8]) { - let at = f.str_offs[k] as usize; - assert_eq!(f.str_offs[k + 1] as usize - at, text.len()); - f.str_bytes.to_mut()[at..at + text.len()].copy_from_slice(text); -} - -// CORE-REQ-0130 to 0134: the sections, records, special values, numbering and string order of -// a netlist with every kind of entity. -#[test] -fn table_layout() { - let f = sample().freeze(); - f.validate().unwrap(); - assert_eq!( - strings(&f), - [ - "top", "inv", "A", "Y", "buf", "nand2", "B", "nand2r", "dff", "D", "CK", "Q", "QN", - "tie", "H", "clk", "d", "q", "n", "v", "u2", "u1", "r" - ] - ); - // Module name, ports, nets, instances, connected pins, assignments. - assert_eq!(f.module[..], [0, 3, 9, 4, 9, 2]); - assert_eq!(f.module[module_entry::N_PINS], 9); - assert_eq!(f.cells[..], [1, 4, 5, 7, 8, 13]); - assert_eq!(f.cell_pin_start[..], [0, 2, 4, 7, 10, 14, 15]); - assert_eq!( - f.cell_pin_name[..], - [2, 3, 2, 3, 2, 6, 3, 3, 6, 2, 9, 10, 11, 12, 14] - ); - assert_eq!( - f.cell_pin_dir[..], - [0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 1] - ); - let port = |name, dir, is_bus, msb, lsb| PortRec { - name, - dir, - is_bus, - _pad: 0, - msb, - lsb, - }; - assert_eq!( - f.ports[..], - [ - port(15, 0, 0, 0, 0), - port(16, 0, 1, 1, 0), - port(17, 1, 1, 0, 1) - ] - ); - assert_eq!(f.port_bit_start[..], [0, 1, 3, 5]); - assert_eq!(f.port_bit_net[..], [0, 1, 2, 3, 4]); - let net = |name, index| NetRec { name, index }; - assert_eq!( - f.nets[..], - [ - net(15, SCALAR), - net(16, 1), - net(16, 0), - net(17, 0), - net(17, 1), - net(18, SCALAR), - net(ANONYMOUS, SCALAR), - net(19, 1), - net(19, 0) - ] - ); - // The reused slot puts u2 first; only connected pins are stored, in connection order (u1 - // connected Y, B, A). - let inst = |name, cell| InstRec { name, cell }; - assert_eq!( - f.instances[..], - [inst(20, 0), inst(21, 2), inst(22, 4), inst(19, 5)] - ); - assert_eq!(f.inst_pin_start[..], [0, 2, 5, 8, 9]); - assert_eq!(f.pin_cellpin[..], [0, 1, 2, 1, 0, 0, 1, 2, 0]); - assert_eq!(f.pin_net[..], [2, 6, 5, CONST1, 1, 5, 0, 3, 6]); - assert_eq!( - f.assigns[..], - [ - AssignRec { lhs: 4, rhs: 6 }, - AssignRec { - lhs: 4, - rhs: CONST0 - } - ] - ); - assert_eq!(f.net_pin_start[..], [0, 1, 2, 3, 4, 4, 6, 8, 8, 8]); - assert_eq!(f.net_pins[..], [6, 4, 0, 7, 2, 5, 1, 8]); - assert_eq!(size_of::(), 16); - assert_eq!(module_entry::COUNT, 6); - let sizes: Vec> = (0..=19).map(section::elem_size).collect(); - assert_eq!(sizes[0], None); - assert_eq!(sizes[19], None); - let declared: Vec = f.sections().iter().map(|s| s.1).collect(); - assert_eq!( - sizes[1..=18].iter().map(|s| s.unwrap()).collect::>(), - declared - ); - assert_eq!( - (section::STR_BYTES, section::NET_PINS, FORMAT_VERSION), - (1, 18, 1) - ); - assert_eq!(section::name(section::PIN_NET), "PIN_NET"); - assert_eq!(section::name(0), "unknown"); -} - -// CORE-REQ-0136: thawing and freezing give back the tables, borrowed or owned. -#[test] -fn round_trips() { - let g = sample(); - let f = g.freeze(); - let t = f.thaw().unwrap(); - assert_consistent(&t); - assert_eq!(t.freeze(), f, "freeze(thaw(f)) == f"); - assert_eq!(t.freeze().thaw().unwrap().freeze(), f, "idempotent"); - assert_eq!(t.name(), "top"); - let u2 = t.find_instance("u2").unwrap(); - assert_eq!(t.instance(u2).unwrap().cell, t.find_cell("inv").unwrap()); - let r = t.find_instance("r").unwrap(); - let qn = t.find_pin(r, "QN").unwrap(); - assert_eq!(t.pin(qn).unwrap().conn, None); - // The connection order survives: u1 connected Y, B, A. - let u1 = t.find_instance("u1").unwrap(); - let order: Vec = t - .instance(u1) - .unwrap() - .connected - .iter() - .map(|&p| t.pin_label(p)) - .collect(); - assert_eq!(order, ["u1.Y", "u1.B", "u1.A"]); - // A borrowed view is equal to its owned copy and round-trips alike. - let borrowed = FrozenNetlist { - str_bytes: Cow::Borrowed(&f.str_bytes[..]), - str_offs: Cow::Borrowed(&f.str_offs[..]), - ..f.clone() - }; - assert_eq!(borrowed, f); - assert_eq!(borrowed.clone().into_owned(), f); - assert_eq!(borrowed.thaw().unwrap().freeze(), f); - // The smallest netlist: a module without anything. - let empty = GateNetlist::new("m").unwrap().freeze(); - empty.validate().unwrap(); - assert_eq!(strings(&empty), ["m"]); - assert_eq!(empty.net_pin_start[..], [0]); - assert_eq!(empty.thaw().unwrap().freeze(), empty); -} - -// CORE-REQ-0130, 0138: the section bytes of a netlist file and the view over them. -#[test] -fn section_bytes() { - let f = sample().freeze(); - let secs = f.sections(); - let ids: Vec = secs.iter().map(|s| s.0).collect(); - assert_eq!(ids, (1..=18).collect::>()); - assert_eq!((secs[2].1, secs[2].2.len()), (4, 24)); - assert_eq!(secs[2].2, bytemuck::cast_slice::(&f.module)); - // Aligned copies are borrowed, misaligned ones copied; both give the same tables. - let aligned: Vec> = secs - .iter() - .map(|s| { - let mut v = vec![0u64; s.2.len().div_ceil(8)]; - bytemuck::cast_slice_mut::(&mut v)[..s.2.len()].copy_from_slice(s.2); - v - }) - .collect(); - let get = |id: u32| { - let k = id.checked_sub(1)? as usize; - let v = aligned.get(k)?; - Some(( - secs[k].1, - &bytemuck::cast_slice::(v)[..secs[k].2.len()], - )) - }; - let view = FrozenNetlist::from_sections(get).unwrap(); - assert_eq!(view, f); - assert!(matches!(view.pin_net, Cow::Borrowed(_))); - let shifted: Vec> = secs - .iter() - .map(|s| { - let mut v = vec![0u64; (s.2.len() + 1).div_ceil(8)]; - bytemuck::cast_slice_mut::(&mut v)[1..=s.2.len()].copy_from_slice(s.2); - v - }) - .collect(); - let get = |id: u32| { - let k = id.checked_sub(1)? as usize; - let v = shifted.get(k)?; - Some(( - secs[k].1, - &bytemuck::cast_slice::(v)[1..=secs[k].2.len()], - )) - }; - let copied = FrozenNetlist::from_sections(get).unwrap(); - assert_eq!(copied, f); - assert!(matches!(copied.pin_net, Cow::Owned(_))); - let empty = |id: u32| section::elem_size(id).map(|e| (e, &[][..])); - let missing = FrozenNetlist::from_sections(|id| empty(id).filter(|_| id != 7)); - assert_eq!( - missing.unwrap_err().to_string(), - "netlist section 7 (CELL_PIN_DIR): section missing" - ); - let odd = [0u8; 3]; - let e = - FrozenNetlist::from_sections(|id| Some((section::elem_size(id)?, &odd[..]))).unwrap_err(); - assert_eq!(e.section, section::STR_OFFS); - assert!(e.message.contains("3 bytes"), "{e}"); - // A declared element size must be the section's, even when the length would divide by it. - let wrong = |id: u32| empty(id).map(|(e, b)| (if id == 11 { 4 } else { e }, b)); - let e = FrozenNetlist::from_sections(wrong).unwrap_err(); - assert_eq!( - e.to_string(), - "netlist section 11 (NETS): element size 4, expected 8" - ); - // The module section is six 4-byte entries, not one 24-byte record. - let module = [0u8; 24]; - let record = |id: u32| match id { - 3 => Some((24, &module[..])), - _ => empty(id), - }; - let e = FrozenNetlist::from_sections(record).unwrap_err(); - assert_eq!( - e.to_string(), - "netlist section 3 (MODULE): element size 24, expected 4" - ); -} - -// CORE-REQ-0138: queries on the tables without thawing: strings, rows and relations. -#[test] -fn accessors() { - let f = sample().freeze(); - f.validate().unwrap(); - assert_eq!(f.string(0), Ok("top")); - assert_eq!(f.string(22), Ok("r")); - assert_eq!(f.string(23).unwrap_err().section, section::STR_OFFS); - let names = |rows: std::ops::Range| -> Vec<&str> { - rows.map(|k| f.string(f.cell_pin_name[k]).unwrap()) - .collect() - }; - assert_eq!(names(f.cell_pins(3)), ["Y", "B", "A"]); - assert_eq!(names(f.cell_pins(5)), ["H"]); - // u1 (instance 1) connected Y, B, A; its pins are rows 2 to 4. - assert_eq!(f.instance_pins(1), 2..5); - assert_eq!(f.instance_pins(3), 8..9); - assert_eq!(f.net_pins_of(5), [2, 5]); - assert_eq!(f.net_pins_of(4), [] as [u32; 0]); - assert_eq!(f.port_bits(1), [1, 2]); - assert_eq!(f.port_bits(2), [3, 4]); - let insts: Vec = (0..9).map(|p| f.pin_instance(p)).collect(); - assert_eq!(insts, [0, 0, 1, 1, 1, 2, 2, 2, 3]); - let dirs: Vec = (0..9).map(|p| f.pin_dir(p)).collect(); - use PinDir::{Input as I, Output as O}; - assert_eq!(dirs, [I, O, O, I, I, I, I, O, O]); - // An instance without connected pins has an empty row, which pin_instance skips. - let mut g = sample(); - let idle = g.add_instance("idle", g.find_cell("inv").unwrap()).unwrap(); - let a = g.find_net("d", Some(1)).unwrap(); - let late = g.add_instance("late", g.find_cell("inv").unwrap()).unwrap(); - g.connect(late, "A", Conn::Net(a)).unwrap(); - let f = g.freeze(); - assert_eq!(f.instance_pins(4), 9..9); - assert_eq!(f.pin_instance(9), 5); - assert!(g.instance(idle).unwrap().connected.is_empty()); -} - -#[test] -#[should_panic(expected = "pin 9 out of range")] -fn pin_instance_out_of_range() { - sample().freeze().pin_instance(9); -} - -// CORE-REQ-0133: any connection order of distinct cell pins is valid; a cell pin connected twice -// is not. -#[test] -fn connection_orders() { - let mut f = sample().freeze(); - let rows = f.instance_pins(2); - f.pin_cellpin.to_mut()[rows.clone()].reverse(); - f.validate().unwrap(); - let t = f.thaw().unwrap(); - assert_consistent(&t); - let r = t.find_instance("r").unwrap(); - let order: Vec = t - .instance(r) - .unwrap() - .connected - .iter() - .map(|&p| t.pin_label(p)) - .collect(); - assert_eq!(order, ["r.Q", "r.CK", "r.D"]); - assert_eq!(t.freeze(), f); - f.pin_cellpin.to_mut()[rows.start] = f.pin_cellpin[rows.start + 1]; - let e = f.validate().unwrap_err(); - assert_eq!(e.section, section::PIN_CELLPIN); - assert!(e.message.contains("connected twice"), "{e}"); -} - -type Corruption = fn(&mut FrozenNetlist<'static>); - -// CORE-REQ-0135: each corruption breaks one rule; validation reports it in the expected section -// and thawing refuses the tables. -#[test] -fn corrupted_tables_are_rejected() { - let f = sample().freeze(); - let cases: &[(&str, u32, Corruption)] = &[ - ("no string offsets", section::STR_OFFS, |f| { - f.str_offs.to_mut().clear() - }), - ("string end missing", section::STR_OFFS, |f| { - f.str_offs.to_mut().pop(); - }), - ("strings not from 0", section::STR_OFFS, |f| { - f.str_offs.to_mut()[0] = 1 - }), - ("string ends before it starts", section::STR_OFFS, |f| { - f.str_offs.to_mut()[2] = 0 - }), - ("empty string", section::STR_BYTES, |f| { - f.str_offs.to_mut()[2] = f.str_offs[1] - }), - ("invalid UTF-8", section::STR_BYTES, |f| { - f.str_bytes.to_mut()[0] = 0xFF - }), - ( - "module name with a control character", - section::STR_BYTES, - |f| overwrite_string(f, 0, b"t\x01p"), - ), - ("cell name with a blank", section::STR_BYTES, |f| { - overwrite_string(f, 1, b"i v") - }), - ("pin name DEL", section::STR_BYTES, |f| { - overwrite_string(f, 2, b"\x7f") - }), - ("port name with an empty segment", section::PORTS, |f| { - overwrite_string(f, 15, b"/lk") - }), - ("net name with a stray backslash", section::NETS, |f| { - overwrite_string(f, 18, b"\\") - }), - ("instance name ending in a slash", section::INSTANCES, |f| { - overwrite_string(f, 20, b"u/") - }), - ("name of nets and an instance", section::NETS, |f| { - overwrite_string(f, 19, b"/") - }), - ("repeated string", section::STR_BYTES, |f| { - let s = f.str_offs[4] as usize; - f.str_bytes.to_mut()[s..s + 3].copy_from_slice(b"inv") - }), - ("unused string", section::STR_OFFS, |f| { - f.str_bytes.to_mut().extend_from_slice(b"zz"); - let end = f.str_bytes.len() as u32; - f.str_offs.to_mut().push(end) - }), - ("strings not in first-use order", section::CELLS, |f| { - f.cells.to_mut().swap(0, 1) - }), - ("module name not the first string", section::MODULE, |f| { - f.module.to_mut()[module_entry::NAME] = 1 - }), - ("no module entries", section::MODULE, |f| { - f.module.to_mut().clear() - }), - ("module entry missing", section::MODULE, |f| { - f.module.to_mut().pop(); - }), - ("extra module entry", section::MODULE, |f| { - f.module.to_mut().push(0) - }), - ("net count", section::MODULE, |f| { - f.module.to_mut()[module_entry::N_NETS] += 1 - }), - ("pin count", section::MODULE, |f| { - f.pin_net.to_mut().pop(); - }), - ("cell name out of range", section::CELLS, |f| { - f.cells.to_mut()[5] = 999 - }), - ("cell pin row decreases", section::CELL_PIN_START, |f| { - f.cell_pin_start.to_mut()[1] = 5 - }), - ("cell pin rows end early", section::CELL_PIN_START, |f| { - *f.cell_pin_start.to_mut().last_mut().unwrap() -= 1 - }), - ("cell pin direction code", section::CELL_PIN_DIR, |f| { - f.cell_pin_dir.to_mut()[0] = 4 - }), - ("cell pin directions short", section::CELL_PIN_DIR, |f| { - f.cell_pin_dir.to_mut().pop(); - }), - ("pin name repeated in a cell", section::CELL_PIN_NAME, |f| { - f.cell_pin_name.to_mut()[9] = 6 - }), - ("port direction code", section::PORTS, |f| { - f.ports.to_mut()[0].dir = 3 - }), - ("port bus flag", section::PORTS, |f| { - f.ports.to_mut()[0].is_bus = 2 - }), - ("port padding", section::PORTS, |f| { - f.ports.to_mut()[1]._pad = 1 - }), - ("scalar port with a range", section::PORTS, |f| { - f.ports.to_mut()[0].msb = 1 - }), - ("reserved port bound", section::PORTS, |f| { - f.ports.to_mut()[1].lsb = SCALAR - }), - ("bus wider than the widest range", section::PORTS, |f| { - f.ports.to_mut()[1].msb = MAX_RANGE_WIDTH as i32 - }), - ("bus wider than its bits", section::PORT_BIT_NET, |f| { - f.ports.to_mut()[1].msb = 2 - }), - ("port bit out of range", section::PORT_BIT_NET, |f| { - f.port_bit_net.to_mut()[0] = 99 - }), - ("port bits out of order", section::PORT_BIT_NET, |f| { - f.port_bit_net.to_mut().swap(1, 2) - }), - ("port bit rows end late", section::PORT_BIT_START, |f| { - f.port_bit_start.to_mut()[3] = 6 - }), - ("anonymous net with an index", section::NETS, |f| { - f.nets.to_mut()[6].index = 0 - }), - ("repeated net", section::NETS, |f| { - f.nets.to_mut()[7].index = 0 - }), - ("scalar and indexed net of one name", section::NETS, |f| { - f.nets.to_mut()[8].index = SCALAR - }), - ("extra net of a port's name", section::NETS, |f| { - f.nets.to_mut()[6] = NetRec { name: 16, index: 5 } - }), - ("net named like a scalar port", section::NETS, |f| { - f.nets.to_mut()[6] = NetRec { - name: 15, - index: SCALAR, - } - }), - ("instance cell out of range", section::INSTANCES, |f| { - f.instances.to_mut()[0].cell = 6 - }), - ("repeated instance name", section::INSTANCES, |f| { - f.instances.to_mut()[3].name = 20 - }), - ("instance pin row decreases", section::INST_PIN_START, |f| { - f.inst_pin_start.to_mut()[1] = 6 - }), - ("cell pin out of range", section::PIN_CELLPIN, |f| { - f.pin_cellpin.to_mut()[1] = 2 - }), - ("cell pin connected twice", section::PIN_CELLPIN, |f| { - f.pin_cellpin.to_mut()[3] = 0 - }), - ("pin net out of range", section::PIN_NET, |f| { - f.pin_net.to_mut()[0] = 99 - }), - ("constant on the left", section::ASSIGNS, |f| { - f.assigns.to_mut()[0].lhs = CONST0 - }), - ("assignment net out of range", section::ASSIGNS, |f| { - f.assigns.to_mut()[1].rhs = 1000 - }), - ("net pin rows short", section::NET_PIN_START, |f| { - f.net_pin_start.to_mut().pop(); - }), - ("net pin missing", section::NET_PINS, |f| { - f.net_pins.to_mut().pop(); - *f.net_pin_start.to_mut().last_mut().unwrap() -= 1; - f.net_pin_start.to_mut()[7] -= 1; - f.net_pin_start.to_mut()[8] -= 1; - }), - ("net pins out of order", section::NET_PINS, |f| { - f.net_pins.to_mut().swap(4, 5) - }), - ("pin listed under another net", section::NET_PINS, |f| { - f.net_pins.to_mut()[0] = 2 - }), - ]; - for (what, id, corrupt) in cases { - let mut c = f.clone(); - corrupt(&mut c); - let e = c.validate().expect_err(what); - assert_eq!(e.section, *id, "{what}: {e}"); - assert_eq!(c.thaw().unwrap_err(), e, "{what}"); - } -} - -/// Appends instances without connections of cell 0 to `f` until it has `n`; their names are -/// new strings, which come last in the string table. -fn add_idle_instances(f: &mut FrozenNetlist<'static>, n: usize) { - for k in f.instances.len()..n { - let name = f.str_offs.len() as u32 - 1; - f.str_bytes - .to_mut() - .extend_from_slice(format!("u{k}").as_bytes()); - let end = f.str_bytes.len() as u32; - f.str_offs.to_mut().push(end); - f.instances.to_mut().push(InstRec { name, cell: 0 }); - let pins = *f.inst_pin_start.last().unwrap(); - f.inst_pin_start.to_mut().push(pins); - } - f.module.to_mut()[module_entry::N_INSTS] = n as u32; -} - -// CORE-REQ-0137: thawing creates every pin of every instance, connected or not, so their number -// is bounded by the size of the tables (one pin per byte, or 2^24 for smaller tables): a wide -// cell with many instances and few connections cannot make a short file exhaust memory. -#[test] -fn pins_of_all_instances_are_bounded() { - let mut g = GateNetlist::new("m").unwrap(); - let pins: Vec<(String, PinDir)> = (0..4096) - .map(|k| (format!("P{k}"), PinDir::Input)) - .collect(); - let wide = g.add_cell_type("wide", &pins).unwrap(); - g.add_instance("u0", wide).unwrap(); - let mut f = g.freeze(); - // 4096 instances of 4096 pins: 2^24 pins, the bound of tables of less than 2^24 bytes. - add_idle_instances(&mut f, 4096); - f.validate().unwrap(); - add_idle_instances(&mut f, 4097); - let bytes: usize = f.sections().iter().map(|s| s.2.len()).sum(); - let e = f.validate().unwrap_err(); - assert_eq!( - e.to_string(), - format!( - "netlist section 12 (INSTANCES): instances 0 to 4096 have 16781312 pins, connected \ - or not, more than the 16777216 that tables of {bytes} bytes may have (one per \ - byte, at least 16777216, at most 4294967295)" - ) - ); - assert_eq!(f.thaw().unwrap_err(), e); -} - -// CORE-REQ-0136: thawing takes time in proportion to the tables, also for a cell with very many -// pins. -#[test] -fn wide_cells_thaw() { - let mut g = GateNetlist::new("m").unwrap(); - let pins: Vec<(String, PinDir)> = (0..100_000) - .map(|k| (format!("P{k}"), PinDir::Input)) - .collect(); - let wide = g.add_cell_type("wide", &pins).unwrap(); - let u = g.add_instance("u", wide).unwrap(); - g.connect(u, 99_999, Conn::Const(true)).unwrap(); - let f = g.freeze(); - assert_eq!(f.thaw().unwrap().freeze(), f); -} - -/// Changes one section of `f` at random: a byte of an element, the length, or the order. -fn corrupt(v: &mut Cow<'_, [T]>, rng: &mut Rng) { - let v = v.to_mut(); - match rng.below(6) { - 0 | 1 if !v.is_empty() => { - let bytes = bytemuck::cast_slice_mut::(v); - let k = rng.below(bytes.len()); - bytes[k] = match rng.below(4) { - 0 => bytes[k].wrapping_add(1), - 1 => bytes[k].wrapping_sub(1), - 2 => rng.below(4) as u8, - _ => rng.next_u64() as u8, - }; - } - 2 => { - v.pop(); - } - 3 if !v.is_empty() => { - let e = v[rng.below(v.len())]; - v.push(e); - } - 4 if v.len() > 1 => { - let (a, b) = (rng.below(v.len()), rng.below(v.len())); - v.swap(a, b); - } - _ => { - let e = T::zeroed(); - v.insert(rng.below(v.len() + 1), e); - } - } -} - -// CORE-REQ-0135, 0136: random damage to random sections never panics; whatever still validates -// is canonical, so it thaws and freezes back unchanged. -#[test] -fn random_corruptions() { - let f = sample().freeze(); - let mut rng = Rng::new(11); - let mut accepted = 0; - for _ in 0..20_000 { - let mut c = f.clone(); - for _ in 0..1 + rng.below(2) { - match rng.below(18) { - 0 => corrupt(&mut c.str_bytes, &mut rng), - 1 => corrupt(&mut c.str_offs, &mut rng), - 2 => corrupt(&mut c.module, &mut rng), - 3 => corrupt(&mut c.cells, &mut rng), - 4 => corrupt(&mut c.cell_pin_start, &mut rng), - 5 => corrupt(&mut c.cell_pin_name, &mut rng), - 6 => corrupt(&mut c.cell_pin_dir, &mut rng), - 7 => corrupt(&mut c.ports, &mut rng), - 8 => corrupt(&mut c.port_bit_start, &mut rng), - 9 => corrupt(&mut c.port_bit_net, &mut rng), - 10 => corrupt(&mut c.nets, &mut rng), - 11 => corrupt(&mut c.instances, &mut rng), - 12 => corrupt(&mut c.inst_pin_start, &mut rng), - 13 => corrupt(&mut c.pin_cellpin, &mut rng), - 14 => corrupt(&mut c.pin_net, &mut rng), - 15 => corrupt(&mut c.assigns, &mut rng), - 16 => corrupt(&mut c.net_pin_start, &mut rng), - _ => corrupt(&mut c.net_pins, &mut rng), - } - } - match c.validate() { - Ok(()) => { - accepted += 1; - let g = c.thaw().unwrap(); - assert_consistent(&g); - assert_eq!(g.freeze(), c); - } - Err(e) => assert!(!e.to_string().is_empty()), - } - } - // Some damage keeps the tables valid (a renamed string, a changed bus index). - assert!(accepted > 0 && accepted < 5_000, "{accepted} accepted"); -} - -// CORE-REQ-0135, 0110, 0111: a string is checked against the rules of every name it is: as a -// cell or pin name any printable character will do, as a net or instance name the segments and -// escapes must be valid too. -#[test] -fn strings_follow_the_rules_of_every_use() { - use PinDir::{Input as I, Output as O}; - let mut g = GateNetlist::new("m").unwrap(); - let c = g.add_cell_type("w", &[("z", I), ("y", O)]).unwrap(); - let only_cell = g.freeze(); - // The net shares the cell's name, the instance the pin's. - g.add_net(Some("w"), None).unwrap(); - g.add_instance("z", c).unwrap(); - let shared = g.freeze(); - assert_eq!(strings(&shared), ["m", "w", "z", "y"]); - let mut f = only_cell.clone(); - overwrite_string(&mut f, 1, b"/"); - overwrite_string(&mut f, 2, b"\\"); - f.validate().unwrap(); - assert_eq!(f.thaw().unwrap().freeze(), f); - let mut f = shared.clone(); - overwrite_string(&mut f, 1, b"/"); - let e = f.validate().unwrap_err(); - assert_eq!( - e.to_string(), - "netlist section 11 (NETS): net 0: name '/' (string 1): the '/' at byte 0 leaves an \ - empty segment" - ); - assert_eq!(f.thaw().unwrap_err(), e); - let mut f = shared; - overwrite_string(&mut f, 2, b"\\"); - let e = f.validate().unwrap_err(); - assert_eq!( - e.to_string(), - "netlist section 12 (INSTANCES): instance 0: name '\\' (string 2): the backslash at \ - byte 0 is not followed by '/' or '\\'" - ); - // Names with valid segments and escapes are accepted. - let mut g = GateNetlist::new("m").unwrap(); - g.add_net(Some(r"u1/a\/b\\c"), Some(2)).unwrap(); - g.add_port(r"p\/q", PortDir::Output, None).unwrap(); - let f = g.freeze(); - f.validate().unwrap(); - assert_eq!(strings(&f), ["m", r"p\/q", r"u1/a\/b\\c"]); - assert_eq!(f.thaw().unwrap().freeze(), f); -} - -// CORE-REQ-0135, 0138: values read from the tables are bounded before any arithmetic, so no sum -// overflows on a 32-bit target, where the index u32::MAX is usize::MAX; the helpers take such -// values on any target. `string` never panics. -#[test] -fn table_values_never_overflow() { - let f = sample().freeze(); - for k in [u32::MAX, ANONYMOUS, CONST0, 23] { - assert_eq!(f.string(k).unwrap_err().section, section::STR_OFFS, "{k}"); - } - assert_eq!(frozen::string_bytes(&f.str_offs, usize::MAX), None); - assert_eq!(frozen::string_bytes(&[0, 3], 0), Some(0..3)); - assert_eq!(frozen::string_bytes(&[0, 3], 1), None); - let top = usize::MAX - 2..usize::MAX; - assert_eq!(validate::cell_pin_slot(&top, u32::MAX), None); - assert_eq!(validate::cell_pin_slot(&top, 2), None); - assert_eq!(validate::cell_pin_slot(&top, 1), Some(usize::MAX - 1)); - assert_eq!(validate::cell_pin_slot(&(4..4), 0), None); - let mut c = f.clone(); - c.pin_cellpin.to_mut()[0] = u32::MAX; - assert_eq!(c.validate().unwrap_err().section, section::PIN_CELLPIN); - for name in [ANONYMOUS, u32::MAX - 1] { - let mut c = f.clone(); - c.ports.to_mut()[0].name = name; - assert_eq!(c.validate().unwrap_err().section, section::PORTS); - let mut c = f.clone(); - c.instances.to_mut()[0].name = name; - assert_eq!(c.validate().unwrap_err().section, section::INSTANCES); - } -} - -// CORE-REQ-0138: the accessors assume validated tables and panic on others rather than wrap an -// index around or read another cell's pin. -#[test] -#[should_panic(expected = "the instance pin rows do not start at 0")] -fn pin_instance_of_unvalidated_rows() { - let mut f = sample().freeze(); - f.inst_pin_start.to_mut()[0] = 1; - f.pin_instance(0); -} - -#[test] -#[should_panic(expected = "index out of bounds")] -fn pin_dir_outside_its_cell() { - let mut f = sample().freeze(); - // Instance 0 is an inverter: cell pin 2 is the first pin of the next cell. - f.pin_cellpin.to_mut()[0] = 2; - f.pin_dir(0); -} diff --git a/crates/core/rusteda-core/src/netlist/tests_random.rs b/crates/core/rusteda-core/src/netlist/tests_random.rs deleted file mode 100644 index 756a190..0000000 --- a/crates/core/rusteda-core/src/netlist/tests_random.rs +++ /dev/null @@ -1,256 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Randomized edit sequences with fixed seeds (CORE-REQ-0121, 0122, 0123): after every accepted -//! step the invariants hold and the frozen view round-trips, a rejected step changes nothing, -//! and a seed always builds the same netlist (CORE-REQ-0102). Port, net and instance names have -//! segments and escapes, and some break the name rules. - -use super::tests::{assert_consistent, library, snapshot}; -use super::*; - -/// xorshift64* generator. -pub(super) struct Rng(u64); - -impl Rng { - pub(super) fn new(seed: u64) -> Rng { - Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1) - } - - pub(super) fn next_u64(&mut self) -> u64 { - let mut x = self.0; - x ^= x >> 12; - x ^= x << 25; - x ^= x >> 27; - self.0 = x; - x.wrapping_mul(0x2545_F491_4F6C_DD1D) - } - - /// A value in `0..n` (`n > 0`). - pub(super) fn below(&mut self, n: usize) -> usize { - (self.next_u64() % n as u64) as usize - } - - /// True with probability `1 / n`. - pub(super) fn one_in(&mut self, n: usize) -> bool { - self.below(n) == 0 - } - - pub(super) fn pick(&mut self, v: &[T]) -> Option { - (!v.is_empty()).then(|| v[self.below(v.len())]) - } -} - -const NAMES: [&str; 6] = ["a", "b", "n", "u", "y", "zz"]; -const PINS: [&str; 12] = [ - "A", "B", "Y", "D", "CK", "Q", "QN", "H", "IO", "EN", "X", "Z", -]; -/// Names that break a rule: empty segments, stray backslashes, characters outside printable -/// ASCII. -const BAD_NAMES: [&str; 8] = ["a//b", "/a", "a/", r"a\b", r"u\", "a b", "n\u{e9}", ""]; - -/// A port, net or instance name: one or two segments, some with a '/' or '\' that the name -/// escapes; one in sixteen breaks a rule. -fn name(rng: &mut Rng) -> String { - if rng.one_in(16) { - return BAD_NAMES[rng.below(BAD_NAMES.len())].to_string(); - } - let segment = |rng: &mut Rng| { - let mut s = format!("{}{}", NAMES[rng.below(NAMES.len())], rng.below(20)); - if rng.one_in(6) { - s.insert(rng.below(s.len() + 1), ['/', '\\'][rng.below(2)]); - } - s - }; - let first = segment(rng); - if rng.one_in(4) { - names::join([first, segment(rng)]) - } else { - names::escape_segment(&first) - } -} - -/// Runs `steps` random operations and returns the final frozen netlist. -fn run(seed: u64, steps: usize) -> FrozenNetlist<'static> { - use PinDir::{Inout, Input, Other}; - let mut g = GateNetlist::new("random").unwrap(); - let mut cells = library(&mut g).to_vec(); - cells.push( - g.add_cell_type("bidir", &[("IO", Inout), ("EN", Input), ("X", Other)]) - .unwrap(), - ); - let buf = cells[1]; - let mut rng = Rng::new(seed); - let mut dead_insts: Vec = Vec::new(); - let mut dead_pins: Vec = Vec::new(); - let (mut accepted, mut rejected, mut bad_names) = (0, 0, 0); - for step in 0..steps { - let before = snapshot(&g); - let nets: Vec = g.nets().map(|(id, _)| id).collect(); - let insts: Vec = g.instances().map(|(id, _)| id).collect(); - let pins: Vec = g.pins().map(|(id, _)| id).collect(); - let mut net = |rng: &mut Rng| match rng.pick(&nets) { - Some(n) if rng.one_in(12) => Id::new(n.index(), n.generation() + 1), - Some(n) => n, - None => Id::new(0, 0), - }; - let inst = |rng: &mut Rng| { - let pool = if rng.one_in(10) { &dead_insts } else { &insts }; - rng.pick(pool).unwrap_or(Id::new(99, 0)) - }; - let pin = |rng: &mut Rng| { - let pool = if rng.one_in(10) { &dead_pins } else { &pins }; - rng.pick(pool).unwrap_or(Id::new(99, 0)) - }; - let conn = |rng: &mut Rng, net: &mut dyn FnMut(&mut Rng) -> NetId| { - if rng.one_in(5) { - Conn::Const(rng.one_in(2)) - } else { - Conn::Net(net(rng)) - } - }; - let result = match rng.below(17) { - 0 | 1 => { - let n = (!rng.one_in(4)).then(|| name(&mut rng)); - let index = (rng.one_in(2)).then(|| rng.below(4) as i32 - 1); - g.add_net(n.as_deref(), index).map(drop) - } - 2 | 3 => { - let cell = rng.pick(&cells).unwrap(); - g.add_instance(&name(&mut rng), cell).map(drop) - } - 4 => { - let i = inst(&mut rng); - let pins = g.instance(i).map(|x| x.pins.clone()).unwrap_or_default(); - let r = g.remove_instance(i); - if r.is_ok() { - dead_insts.push(i); - dead_pins.extend(pins); - } - r - } - 5..=7 => { - let i = inst(&mut rng); - let c = conn(&mut rng, &mut net); - let own: Vec = g - .instance(i) - .and_then(|x| g.cell(x.cell)) - .map(|c| { - c.pins - .iter() - .map(|p| g.resolve(p.name).to_string()) - .collect() - }) - .unwrap_or_default(); - match rng.below(4) { - 0 => g.connect(i, rng.below(5) as u32, c).map(drop), - 1 => g.connect(i, PINS[rng.below(PINS.len())], c).map(drop), - _ => { - let pin = own.get(rng.below(own.len().max(1))).map_or("A", |s| s); - g.connect(i, pin, c).map(drop) - } - } - } - 8 => g.disconnect(pin(&mut rng)).map(drop), - 9 => g.swap_cell(inst(&mut rng), rng.pick(&cells).unwrap()), - 10 => { - let n = net(&mut rng); - let mut sinks: Vec = g - .net(n) - .map(|x| x.pins.iter().copied().filter(|_| rng.one_in(2)).collect()) - .unwrap_or_default(); - if rng.one_in(4) { - sinks.push(pin(&mut rng)); - } - let net_name = (!rng.one_in(3)).then(|| name(&mut rng)); - let inst_name = name(&mut rng); - match g.insert_buffer(n, &sinks, buf, "A", "Y", &inst_name, net_name.as_deref()) { - // Removing the buffer at once gives back the tables before the insertion. - Ok((b, _)) if rng.one_in(3) => { - assert_eq!(g.remove_buffer(b), Ok(n), "step {step}"); - assert_eq!(g.freeze(), before.0, "step {step}: buffer removal"); - Ok(()) - } - r => r.map(drop), - } - } - 11 => { - let lhs = net(&mut rng); - let rhs = conn(&mut rng, &mut net); - g.add_assign(lhs, rhs) - } - 12 => { - let dir = [PortDir::Input, PortDir::Output, PortDir::Inout][rng.below(3)]; - let range = rng - .one_in(2) - .then(|| (rng.below(4) as i32 - 1, rng.below(4) as i32 - 1)); - g.add_port(&name(&mut rng), dir, range).map(drop) - } - 13 => { - let p = pin(&mut rng); - let c = conn(&mut rng, &mut net); - g.move_pin(p, c).map(drop) - } - 14 => g.remove_net(net(&mut rng)), - 15 => g.remove_assign(rng.below(g.assign_count() + 2)).map(drop), - _ => { - let i = inst(&mut rng); - let pins = g.instance(i).map(|x| x.pins.clone()).unwrap_or_default(); - let r = g.remove_buffer(i).map(drop); - if r.is_ok() { - dead_insts.push(i); - dead_pins.extend(pins); - } - r - } - }; - match result { - Ok(()) => { - accepted += 1; - assert_consistent(&g); - } - Err(e) => { - rejected += 1; - if matches!( - e, - NetlistError::InvalidName { .. } | NetlistError::EmptyName { .. } - ) { - bad_names += 1; - } - assert!(!e.to_string().is_empty()); - assert_eq!( - snapshot(&g), - before, - "step {step}: rejected '{e}' changed state" - ); - } - } - } - assert!( - accepted > steps / 3 && rejected > steps / 10 && bad_names > 0, - "{accepted} accepted, {rejected} rejected, {bad_names} for their names" - ); - g.freeze() -} - -#[test] -fn random_edit_sequences() { - for seed in 1..=4 { - let f = run(seed, 1200); - assert!( - f.instances.len() > 10 && f.pin_net.len() > 20, - "seed {seed}" - ); - // Some names have two segments, some escape a '/' or a '\'. - let names: Vec<&str> = (1..f.str_offs.len() as u32 - 1) - .map(|k| f.string(k).unwrap()) - .collect(); - for part in ["/", r"\/", r"\\"] { - assert!( - names.iter().any(|n| n.contains(part)), - "seed {seed}: {part}" - ); - } - assert_eq!(run(seed, 1200), f, "seed {seed} builds the same netlist"); - } -} diff --git a/crates/core/rusteda-core/src/netlist/validate.rs b/crates/core/rusteda-core/src/netlist/validate.rs deleted file mode 100644 index d6bd381..0000000 --- a/crates/core/rusteda-core/src/netlist/validate.rs +++ /dev/null @@ -1,510 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Checks of a frozen netlist (CORE-REQ-0135): every length, row, index, name and encoding rule -//! of the format, the canonical order of [`GateNetlist::freeze`](super::GateNetlist::freeze) and -//! the bound on what thawing allocates (CORE-REQ-0137), without panicking on any input. Values -//! read from the tables are compared with a bound before any arithmetic, so no sum overflows on -//! 32-bit targets either. - -use std::cmp::Ordering; -use std::ops::Range; - -use rustc_hash::{FxHashMap, FxHashSet}; - -use super::frozen::{ - ANONYMOUS, CONST0, CONST1, FrozenError, FrozenNetlist, SCALAR, module_entry, section, -}; -use super::{MAX_RANGE_WIDTH, range_indices, range_width}; -use crate::names; - -type Check = Result<(), FrozenError>; - -/// The pins that the instances of any tables may have together, connected or not; larger -/// tables may have one per byte (see [`FrozenNetlist`]). -const MIN_PIN_BUDGET: u64 = 1 << 24; - -fn bad(section: u32, message: impl Into) -> FrozenError { - FrozenError::new(section, message) -} - -/// Checks the start section of `rows` rows over a section of `len` elements: `rows + 1` -/// entries, starting at 0, never decreasing, ending at `len`. -fn rows(id: u32, start: &[u32], rows: usize, len: usize) -> Check { - if start.len() != rows + 1 { - return Err(bad( - id, - format!("{} entries, expected {}", start.len(), rows + 1), - )); - } - if start[0] != 0 { - return Err(bad(id, format!("first entry is {}, expected 0", start[0]))); - } - if let Some(k) = start.windows(2).position(|w| w[1] < w[0]) { - return Err(bad(id, format!("row {k} ends before it starts"))); - } - if start[rows] as usize != len { - return Err(bad( - id, - format!("last entry is {}, expected {len}", start[rows]), - )); - } - Ok(()) -} - -/// Row `k` of a checked start section. -fn row(start: &[u32], k: usize) -> Range { - start[k] as usize..start[k + 1] as usize -} - -/// The row of cell pin `cp` among the rows `cell_pins` of its cell's pins, or `None` when the -/// cell has no pin `cp`. The pin is compared with the number of rows before it is added to -/// their start, so the sum stays within the rows on 32-bit targets too. -pub(super) fn cell_pin_slot(cell_pins: &Range, cp: u32) -> Option { - let cp = usize::try_from(cp).ok()?; - (cp < cell_pins.len()).then(|| cell_pins.start + cp) -} - -/// String references must introduce new strings in table order: the next new string is the -/// next table entry, so the table is the one `freeze` builds and has no unused strings. -struct FirstUse { - next: u32, - count: usize, -} - -impl FirstUse { - fn see(&mut self, id: u32, what: &str, s: u32) -> Check { - if s as usize >= self.count { - return Err(bad( - id, - format!("{what}: string {s} out of range ({} strings)", self.count), - )); - } - match s.cmp(&self.next) { - Ordering::Less => Ok(()), - Ordering::Equal => { - // `s < count`, and the distinct non-empty strings of a table of at most - // `u32::MAX` bytes number at most `u32::MAX`, so this cannot overflow. - self.next += 1; - Ok(()) - } - Ordering::Greater => Err(bad( - id, - format!( - "{what}: string {s} is used before string {}, so the string table is not in \ - first-use order", - self.next - ), - )), - } - } -} - -/// Names (string ids) must not repeat. -fn unique(id: u32, what: &str, names: impl Iterator) -> Check { - let mut seen = FxHashSet::default(); - for (k, s) in names.enumerate() { - if !seen.insert(s) { - return Err(bad(id, format!("{what} {k} repeats an earlier name"))); - } - } - Ok(()) -} - -/// A net index or a constant code. -fn conn_ok(v: u32, n_nets: usize) -> bool { - (v as usize) < n_nets || v == CONST0 || v == CONST1 -} - -impl FrozenNetlist<'_> { - /// Checks every rule of the format (CORE-REQ-0135): section lengths, rows, index bounds, - /// direction and flag codes, UTF-8 and uniqueness of strings and names, the name rules of - /// every use of a string (CORE-REQ-0110, 0111), bus widths and the naming of port bits, - /// distinct cell pins within an instance, the canonical order of strings and net pins, and - /// the bound on the pins of all instances (CORE-REQ-0137, see [`FrozenNetlist`]). Accepts - /// exactly what [`GateNetlist::freeze`](super::GateNetlist::freeze) produces within that - /// bound, and never panics, whatever the tables hold. - pub fn validate(&self) -> Result<(), FrozenError> { - self.check_strings()?; - let Ok(m) = <&[u32; module_entry::COUNT]>::try_from(&self.module[..]) else { - return Err(bad( - section::MODULE, - format!( - "{} entries, expected {}", - self.module.len(), - module_entry::COUNT - ), - )); - }; - for (what, k, len) in [ - ("ports", module_entry::N_PORTS, self.ports.len()), - ("nets", module_entry::N_NETS, self.nets.len()), - ("instances", module_entry::N_INSTS, self.instances.len()), - ("pins", module_entry::N_PINS, self.pin_net.len()), - ("assignments", module_entry::N_ASSIGNS, self.assigns.len()), - ] { - let count = m[k]; - if count as usize != len { - return Err(bad( - section::MODULE, - format!("{count} {what} recorded, the section has {len}"), - )); - } - } - if self.nets.len() >= CONST0 as usize { - return Err(bad(section::NETS, "too many nets for the constant codes")); - } - let n_cells = self.cells.len(); - rows( - section::CELL_PIN_START, - &self.cell_pin_start, - n_cells, - self.cell_pin_name.len(), - )?; - if self.cell_pin_dir.len() != self.cell_pin_name.len() { - return Err(bad( - section::CELL_PIN_DIR, - format!( - "{} entries for {} cell pins", - self.cell_pin_dir.len(), - self.cell_pin_name.len() - ), - )); - } - if let Some(k) = self.cell_pin_dir.iter().position(|&d| d > 3) { - return Err(bad( - section::CELL_PIN_DIR, - format!("cell pin {k} has direction code {}", self.cell_pin_dir[k]), - )); - } - rows( - section::PORT_BIT_START, - &self.port_bit_start, - self.ports.len(), - self.port_bit_net.len(), - )?; - rows( - section::INST_PIN_START, - &self.inst_pin_start, - self.instances.len(), - self.pin_cellpin.len(), - )?; - if self.pin_net.len() != self.pin_cellpin.len() { - return Err(bad( - section::PIN_NET, - format!( - "{} entries for {} pins", - self.pin_net.len(), - self.pin_cellpin.len() - ), - )); - } - rows( - section::NET_PIN_START, - &self.net_pin_start, - self.nets.len(), - self.net_pins.len(), - )?; - self.check_first_use(m[module_entry::NAME])?; - self.check_hierarchical_names()?; - unique(section::CELLS, "cell", self.cells.iter().copied())?; - for k in 0..n_cells { - let names = self.cell_pin_name[row(&self.cell_pin_start, k)].iter(); - unique(section::CELL_PIN_NAME, "pin of a cell", names.copied())?; - } - unique(section::PORTS, "port", self.ports.iter().map(|p| p.name))?; - unique( - section::INSTANCES, - "instance", - self.instances.iter().map(|i| i.name), - )?; - self.check_ports()?; - self.check_nets()?; - self.check_pins()?; - for (k, a) in self.assigns.iter().enumerate() { - if a.lhs as usize >= self.nets.len() || !conn_ok(a.rhs, self.nets.len()) { - return Err(bad( - section::ASSIGNS, - format!("assignment {k} refers to a missing net"), - )); - } - } - self.check_net_pins() - } - - /// Offsets, and distinct strings that follow the rules of every name (CORE-REQ-0110): - /// UTF-8, non-empty, printable ASCII. Every string of a valid table names something, since - /// the first-use order leaves no string unused. - fn check_strings(&self) -> Check { - let Some(n) = self.str_offs.len().checked_sub(1) else { - return Err(bad(section::STR_OFFS, "no entries")); - }; - rows(section::STR_OFFS, &self.str_offs, n, self.str_bytes.len())?; - let mut seen = FxHashSet::default(); - for k in 0..n { - let b = &self.str_bytes[row(&self.str_offs, k)]; - let Ok(s) = std::str::from_utf8(b) else { - return Err(bad(section::STR_BYTES, format!("string {k} is not UTF-8"))); - }; - if let Err(e) = names::check_verbatim(s) { - return Err(bad(section::STR_BYTES, format!("string {k}: {e}"))); - } - if !seen.insert(b) { - return Err(bad( - section::STR_BYTES, - format!("string {k} repeats an earlier string"), - )); - } - } - Ok(()) - } - - /// String ids in the traversal order of `freeze`. - fn check_first_use(&self, module: u32) -> Check { - let mut f = FirstUse { - next: 0, - count: self.str_offs.len() - 1, - }; - f.see(section::MODULE, "module name", module)?; - for (k, &c) in self.cells.iter().enumerate() { - f.see(section::CELLS, "cell name", c)?; - for &p in &self.cell_pin_name[row(&self.cell_pin_start, k)] { - f.see(section::CELL_PIN_NAME, "cell pin name", p)?; - } - } - for p in self.ports.iter() { - f.see(section::PORTS, "port name", p.name)?; - } - for n in self.nets.iter().filter(|n| n.name != ANONYMOUS) { - f.see(section::NETS, "net name", n.name)?; - } - for i in self.instances.iter() { - f.see(section::INSTANCES, "instance name", i.name)?; - } - if f.next as usize != f.count { - return Err(bad( - section::STR_OFFS, - format!("strings {} to {} are never used", f.next, f.count - 1), - )); - } - Ok(()) - } - - /// The names of ports, nets and instances are hierarchical (CORE-REQ-0111): valid escapes - /// and no empty segment. A string that also names a cell or a pin, or the module, must follow - /// both rule sets, so every such use is checked, each string once. The first-use order has - /// checked that the string ids are in range, and the strings are printable ASCII. - fn check_hierarchical_names(&self) -> Check { - let mut checked = vec![false; self.str_offs.len() - 1]; - let mut check = |id: u32, what: &str, k: usize, s: u32| -> Check { - match checked.get_mut(s as usize) { - Some(done) if !*done => *done = true, - _ => return Ok(()), - } - let name = self.string(s)?; - names::check_hierarchical(name) - .map_err(|e| bad(id, format!("{what} {k}: name '{name}' (string {s}): {e}"))) - }; - for (k, p) in self.ports.iter().enumerate() { - check(section::PORTS, "port", k, p.name)?; - } - for (k, n) in self.nets.iter().enumerate() { - if n.name != ANONYMOUS { - check(section::NETS, "net", k, n.name)?; - } - } - for (k, i) in self.instances.iter().enumerate() { - check(section::INSTANCES, "instance", k, i.name)?; - } - Ok(()) - } - - /// Codes and ranges of the ports, and their bits: nets named after the port with the bit - /// indices of the range, from msb to lsb. - fn check_ports(&self) -> Check { - for (k, p) in self.ports.iter().enumerate() { - let id = section::PORTS; - if p.dir > 2 || p.is_bus > 1 || p._pad != 0 { - return Err(bad(id, format!("port {k} has an invalid code"))); - } - let bits = &self.port_bit_net[row(&self.port_bit_start, k)]; - let wrong_bits = || { - bad( - section::PORT_BIT_NET, - format!("bits of port {k} are not the nets of its name and range"), - ) - }; - let net_is = |b: u32, index: i32| { - self.nets - .get(b as usize) - .is_some_and(|n| n.name == p.name && n.index == index) - }; - if p.is_bus == 0 { - if (p.msb, p.lsb) != (0, 0) { - return Err(bad(id, format!("scalar port {k} has a range"))); - } - if bits.len() != 1 || !net_is(bits[0], SCALAR) { - return Err(wrong_bits()); - } - continue; - } - if p.msb == SCALAR || p.lsb == SCALAR { - return Err(bad(id, format!("port {k} uses a reserved range bound"))); - } - let width = range_width(p.msb, p.lsb); - if width > u64::from(MAX_RANGE_WIDTH) { - return Err(bad( - id, - format!("port {k} is wider than {MAX_RANGE_WIDTH} bits"), - )); - } - if bits.len() as u64 != width { - return Err(wrong_bits()); - } - if !bits - .iter() - .zip(range_indices(p.msb, p.lsb)) - .all(|(&b, index)| net_is(b, index)) - { - return Err(wrong_bits()); - } - } - Ok(()) - } - - /// Net names: anonymous nets have no index, (name, index) pairs are unique, a name has one - /// scalar net or indexed nets only, and the nets of a port's name are exactly its bits. - fn check_nets(&self) -> Check { - let id = section::NETS; - let mut keys = FxHashSet::default(); - let mut names: FxHashMap = FxHashMap::default(); - for (k, n) in self.nets.iter().enumerate() { - if n.name == ANONYMOUS { - if n.index != SCALAR { - return Err(bad(id, format!("anonymous net {k} has a bit index"))); - } - continue; - } - if !keys.insert((n.name, n.index)) { - return Err(bad( - id, - format!("net {k} repeats an earlier name and index"), - )); - } - let scalar = n.index == SCALAR; - let e = names.entry(n.name).or_insert((scalar, 0)); - if e.0 != scalar { - return Err(bad( - id, - format!("net {k}: its name has both scalar and indexed nets"), - )); - } - e.1 += 1; - } - for (k, p) in self.ports.iter().enumerate() { - let count = names.get(&p.name).map_or(0, |e| e.1); - if count != row(&self.port_bit_start, k).len() { - return Err(bad( - id, - format!("nets named after port {k} that are not bits of the port"), - )); - } - } - Ok(()) - } - - /// Instance cells, and their connected pins: cell pin indices in range and distinct within - /// an instance (in any order, the order of connection), targets nets or constants. The - /// pins of all instances together, connected or not, are within the bound of - /// [`FrozenNetlist`]: one per byte of the tables, at least [`MIN_PIN_BUDGET`] and at most - /// `u32::MAX`. - fn check_pins(&self) -> Check { - let n_nets = self.nets.len(); - let bytes: u64 = self.sections().iter().map(|s| s.2.len() as u64).sum(); - let budget = bytes.max(MIN_PIN_BUDGET).min(u64::from(u32::MAX)); - let mut all_pins = 0u64; - // For each cell pin, the last instance (plus one) that connects it. - let mut user = vec![0u32; self.cell_pin_name.len()]; - for (k, i) in self.instances.iter().enumerate() { - let cell = i.cell as usize; - if cell >= self.cells.len() { - return Err(bad( - section::INSTANCES, - format!("instance {k} has cell {cell} of {}", self.cells.len()), - )); - } - let cell_pins = row(&self.cell_pin_start, cell); - all_pins += cell_pins.len() as u64; - if all_pins > budget { - return Err(bad( - section::INSTANCES, - format!( - "instances 0 to {k} have {all_pins} pins, connected or not, more than \ - the {budget} that tables of {bytes} bytes may have (one per byte, at \ - least {MIN_PIN_BUDGET}, at most {})", - u32::MAX - ), - )); - } - // The instance count is a u32 entry of the module section, so `k + 1` fits. - let me = k as u32 + 1; - for p in row(&self.inst_pin_start, k) { - let cp = self.pin_cellpin[p]; - let Some(slot) = cell_pin_slot(&cell_pins, cp).filter(|&s| user[s] != me) else { - return Err(bad( - section::PIN_CELLPIN, - format!( - "pin {p} of instance {k}: cell pin {cp} is out of range or connected \ - twice" - ), - )); - }; - user[slot] = me; - if !conn_ok(self.pin_net[p], n_nets) { - return Err(bad( - section::PIN_NET, - format!("pin {p} refers to net {} of {n_nets}", self.pin_net[p]), - )); - } - } - } - Ok(()) - } - - /// The pins of each net are exactly the pins whose `pin_net` is that net, increasing. - fn check_net_pins(&self) -> Check { - let id = section::NET_PINS; - let n_nets = self.nets.len(); - let connected = self - .pin_net - .iter() - .filter(|&&v| (v as usize) < n_nets) - .count(); - if self.net_pins.len() != connected { - return Err(bad( - id, - format!( - "{} entries for {connected} pins on nets", - self.net_pins.len() - ), - )); - } - for n in 0..n_nets { - let mut prev: Option = None; - for &p in &self.net_pins[row(&self.net_pin_start, n)] { - let on_net = self - .pin_net - .get(p as usize) - .is_some_and(|&v| v as usize == n); - if !on_net || prev.is_some_and(|q| p <= q) { - return Err(bad( - id, - format!("net {n} lists pin {p}, which is not its next pin"), - )); - } - prev = Some(p); - } - } - Ok(()) - } -} diff --git a/crates/core/rusteda-core/src/symbol.rs b/crates/core/rusteda-core/src/symbol.rs deleted file mode 100644 index 221de1f..0000000 --- a/crates/core/rusteda-core/src/symbol.rs +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Symbol interning (CORE-REQ-0100). Every database owns its interner; there is no global table, -//! so the symbols of one database are a function of the order in which it interned its names. -//! -//! ``` -//! use rusteda_core::symbol::SymbolInterner; -//! -//! let mut s = SymbolInterner::new(); -//! let a = s.intern("clk"); -//! let b = s.intern("rst_n"); -//! assert_eq!(s.intern("clk"), a); -//! assert_eq!((a.index(), b.index()), (0, 1)); -//! assert_eq!(s.resolve(b), "rst_n"); -//! assert_eq!(s.get("missing"), None); -//! ``` - -use rustc_hash::FxHashMap; - -/// An interned string of one [`SymbolInterner`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct SymbolId(u32); - -impl SymbolId { - /// Position of the symbol in its interner: ids are dense and follow insertion order. - pub fn index(self) -> u32 { - self.0 - } -} - -/// The strings of one database, numbered densely in insertion order. -#[derive(Debug, Clone, Default)] -pub struct SymbolInterner { - map: FxHashMap, SymbolId>, - strings: Vec>, -} - -impl SymbolInterner { - pub fn new() -> SymbolInterner { - SymbolInterner::default() - } - - /// The symbol of `s`; a new string gets the next id. - /// - /// # Panics - /// - /// When the interner already holds `u32::MAX` strings. - pub fn intern(&mut self, s: &str) -> SymbolId { - if let Some(&id) = self.map.get(s) { - return id; - } - let id = SymbolId(u32::try_from(self.strings.len()).expect("more than u32::MAX symbols")); - self.strings.push(s.into()); - self.map.insert(s.into(), id); - id - } - - /// The symbol of `s` if it was interned; never adds a string. - pub fn get(&self, s: &str) -> Option { - self.map.get(s).copied() - } - - /// The string of a symbol. - /// - /// # Panics - /// - /// When `id` was not made by this interner (see [`SymbolInterner::try_resolve`]). - pub fn resolve(&self, id: SymbolId) -> &str { - self.try_resolve(id).expect("symbol id of another interner") - } - - /// The string of a symbol, or `None` for an id this interner did not make. - pub fn try_resolve(&self, id: SymbolId) -> Option<&str> { - self.strings.get(id.0 as usize).map(|s| &**s) - } - - pub fn len(&self) -> usize { - self.strings.len() - } - - pub fn is_empty(&self) -> bool { - self.strings.is_empty() - } - - /// All symbols in id order. - pub fn iter(&self) -> impl ExactSizeIterator + '_ { - self.strings - .iter() - .enumerate() - .map(|(i, s)| (SymbolId(i as u32), &**s)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // CORE-REQ-0100: ids depend only on the insertion order, so two databases built alike agree. - #[test] - fn dense_ids_in_insertion_order() { - let names = ["b", "a", "b", "", "u1/q[3]", "a"]; - let build = || { - let mut s = SymbolInterner::new(); - let ids: Vec = names.iter().map(|n| s.intern(n).index()).collect(); - (s, ids) - }; - let (s, ids) = build(); - assert_eq!(ids, [0, 1, 0, 2, 3, 1]); - assert_eq!(build().1, ids); - assert_eq!(s.len(), 4); - assert!(!s.is_empty()); - let all: Vec<(u32, &str)> = s.iter().map(|(id, n)| (id.index(), n)).collect(); - assert_eq!(all, [(0, "b"), (1, "a"), (2, ""), (3, "u1/q[3]")]); - for (id, n) in s.iter() { - assert_eq!(s.get(n), Some(id)); - assert_eq!(s.resolve(id), n); - } - assert_eq!(s.get("c"), None); - assert_eq!(s.len(), 4, "get never interns"); - } - - fn foreign() -> (SymbolInterner, SymbolId) { - let mut big = SymbolInterner::new(); - big.intern("x"); - let y = big.intern("y"); - let mut small = SymbolInterner::new(); - assert!(small.is_empty()); - small.intern("x"); - (small, y) - } - - // CORE-REQ-0100: an id of another interner resolves to nothing, or panics. - #[test] - fn foreign_ids() { - let (small, y) = foreign(); - assert_eq!(small.try_resolve(y), None); - } - - #[test] - #[should_panic(expected = "another interner")] - fn resolving_a_foreign_id_panics() { - let (small, y) = foreign(); - small.resolve(y); - } -} diff --git a/crates/core/rusteda-workspace/Cargo.toml b/crates/core/rusteda-workspace/Cargo.toml deleted file mode 100644 index 9297708..0000000 --- a/crates/core/rusteda-workspace/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "rusteda-workspace" -description = "RustEDA disk workspace: revisions, tables, rbin containers, locks, history and the rusteda.v1 envelope" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -publish.workspace = true -# A shared crate names its own authors, matching its copyright line (contract §8, shared -# manifests), instead of inheriting those of the tool's workspace. -authors = ["RustEDA contributors"] -repository.workspace = true - -[dependencies] -bytemuck.workspace = true -memmap2.workspace = true -rusteda-core.workspace = true -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true -xxhash-rust.workspace = true - -[lints] -workspace = true diff --git a/crates/core/rusteda-workspace/README.md b/crates/core/rusteda-workspace/README.md deleted file mode 100644 index 7d96ec3..0000000 --- a/crates/core/rusteda-workspace/README.md +++ /dev/null @@ -1,286 +0,0 @@ -# rusteda-workspace - -The disk workspace of RustEDA, shared verbatim by every RustEDA tool. A workspace is a -directory of immutable revisions; every tool is a stateless process that opens it, maps the -tables it needs and, for a mutation, commits a new revision with optimistic concurrency. There -is no daemon and no hidden state: what is on disk is the truth. - -The crate is tool-neutral. It knows tables, hashes, revisions, locks, the history and the JSON -envelope of the command line, and leaves the content of tables to the tools. The NETLIST and -LIBERTY codecs that the contract (§1, §5) assigns to this crate, and with them the `rlib` -module that holds the normative LIBERTY v1 grammar, are deferred until `rusteda-core` is -available: this crate does not depend on any other RustEDA crate yet. - -## Layout - -```text -/manifest.json HEAD: {"schema","head","next_rev","corners"} -/workspace.lock shared lock for reads of the manifest, exclusive lock for changes -/history.jsonl one line per workspace-changing command, rejected ones included -/ui_state.json reserved: the selection of a future GUI (read by --target sel) -/cache/-.lib library, verbatim (h16 = xxh3-64 of its bytes) -/cache/-.rlib library, compiled -/revisions/rev/meta.json + tables -/revisions/.staging--/ a transaction in progress (ignored by readers) -/revisions/.orphan-* set-aside leftovers of an interrupted commit -``` - -A revision prints as `rev` (padded input such as `rev0003` is accepted) and lives in -`rev`. Queries take `HEAD` or `rev`; mutations take -`--base-rev rev` and never `HEAD`. The two resolvers return different types -(`Rev` and `BaseRev`), so a query revision cannot be passed where a base revision is required. -Object handles are revision-scoped `` (`inst12`, `path3`, `sel4`); every handle -matches `[A-Za-z0-9_-]+`, and the handles of `ui_state.json`, written by another program, are -checked when the file is read. - -## Tables and hashes - -`meta.json` lists the tables of a revision with their path, their hash and the hashes of the -tables they were computed from (`upstream`). Hashes are xxh3-64, written `xxh3:<16 hex>`: - -- a file table hashes its bytes; -- a directory table (such as an `rtl/` snapshot) hashes the sorted records - `relative path, NUL, file-hash hex, LF` of its files; -- the `libraries` pseudo-table hashes one record `name, NUL, corner, NUL, hash hex, LF` per - library, in list order; -- the `libraries.` pseudo-table hashes the records of the libraries of one corner. A - table computed from one corner (a netlist mapped against `default`) names it as its - upstream, so importing a library for another analysis corner does not drop it, while timing - over all corners names `libraries`. This pseudo-table extends the contract, which names only - `libraries`. - -A table path in `meta.json` is one entry of its revision directory; anything else (`..`, an -absolute path, a subdirectory) makes the revision `CORRUPT_TABLE`. Tables are opened only as -regular files (or directories), checked before and after the open, so a FIFO, a device or a -symbolic link in a revision, also inside a directory table, is `CORRUPT_TABLE` and never blocks -a reader. A directory table (an `rtl/` snapshot) is read with `Revision::table_files`, which -lists its files with their hashes after checking the directory hash against `meta.json`, and -`Revision::read_table_file`, which reads one of them without following symbolic links; -`Revision::read_table` reads file tables. - -Binary tables use the `rbin` container: a little-endian header (magic `RUSTEDA\0`, kind, -format version, flags, content hash, upstream entries, section table) followed by sections of -fixed-size elements at 8-byte aligned offsets. `RbinWriter` writes it deterministically. Every -open validates the header (magic, kind, version, `header_len`, bounds, alignment, element -sizes, order and overlap of sections) and hands out zero-copy `&[T]` views; -`Rbin::verify_hash` checks the content hash on request. Any failure is `CORRUPT_TABLE`, except -a format version newer than the reader's, which is `SCHEMA_TOO_NEW` (exit 1: upgrade the -tools). - -A map is sound only while nobody writes the file, so only files that the workspace never -writes again are mapped: the tables of committed revisions (`Revision::open_rbin`) and the -compiled libraries of the cache (`LibraryCache::open_rlib`, `Revision::open_library`). Any -other file is read into an 8-byte aligned buffer with `Rbin::read`, and bytes in memory are -opened with `Rbin::from_bytes`; a later write of the file does not change a table read so. - -A table enters a revision checked. Its files are written by the tool (a copy, a reflink copy -included, is a new file) or are hard links to files of the base revision; a staging file that -shares its inode with anything else, such as a user's source file, would let an edit of that -file rewrite the committed revision, so `add_file` and `add_dir` refuse it (`INTERNAL`). A -table that is an rbin file must pass the structural checks of every open and its content hash -(contract §5: "content hash on import"), whatever its kind; `add_bytes` and `add_file` refuse -a damaged one (`INTERNAL`) instead of committing it under its own file hash. - -The header of an rbin table records the table's upstream map: one entry per upstream table -with the hash `meta.json` records for it (for an rbin upstream, its file hash), under an -8-byte name. A name of up to 8 bytes is used as it is (`netlist`, `sta`); a longer one becomes -its first 3 bytes, `.` and 4 hex digits derived from its hash (`constraints`, `libraries`), -moved to the next free digits when another name of the same map has them, so the names of one -map never collide (two PDK corners whose 16-bit hashes collide included); see -`rbin::upstream_names`. The names depend on the map only, so the writer and every reader -derive the same ones. `Txn::add_rbin` writes these entries; `add_bytes` and `add_file` check -them in a table that is an rbin file, and `Revision::open_rbin` checks them again. - -A library entry of `meta.json` names its compiled form at its cache path, -`cache/-.rlib`, checked when a transaction adds it and when a revision is read. The -cache stores a compiled library only if it is a valid rbin file (structure and content hash), -compiles a cached `.rlib` that fails that check again, and never follows a symbolic link. - -## Summary and status - -`summary` and `status` of `meta.json` are kept per table, `{table: {key: value}}`, with keys -only for tables of the revision: contract §3, rule CA-STATUS. A value describes the table it is -set for (`Txn::set_summary(table, key, value)`, only for a table the transaction adds): a -carried table's values are copied from the base revision, and a dropped table drops them. A -status therefore never outlives the table it describes: when a new netlist drops `check`, the -revision no longer claims an equivalence, so an unchecked property is never reported as -passing. A property whose table is absent is unchecked; `rusteda show` and `diff` render a -missing `sta` status as timing `not_analyzed` and a missing `check` status as equivalence -`unchecked`. So a property is recorded under the table of the command that checks it -(equivalence under `check`), not as an `unchecked` value under another table, where a later -check could not replace it. A `meta.json` whose `summary` or `status` breaks the rule, such as -a flat `{"equivalence": "unchecked"}`, is `CORRUPT_TABLE` with a reason that names it. - -The `metric_deltas` of a history line are kept per table too, which extends the contract (it -leaves their inner shape open): for each table the command added, its numeric summary values -minus those of the table's previous version, found in the base or, when the base dropped the -table, in the nearest ancestor that has it (at most 64 revisions back). So the history line of -`ruststa run` after `rustsys opt-timing` shows the change of WNS since the last timing run. A -revert compares the tables that differ between the two revisions. - -## The commit protocol - -1. `Workspace::begin(base)` reads the manifest under the shared lock. If HEAD is not `base` it - fails with `REVISION_CONFLICT` before any work, and logs the conflict. Otherwise it creates - `revisions/.staging--/`. -2. The tool computes lock-free from the immutable base revision and adds its tables to the - transaction (`add_rbin`, `add_bytes`, `add_file`, `add_dir`), each with its upstream hashes; - the crate checks them as they enter (see above) and computes the table hashes. -3. `Txn::commit` takes the exclusive lock and re-checks HEAD; on a conflict it deletes the - staging directory and logs the command with `ok: false`, exit 4. Otherwise: - - the revision number is `N = max(next_rev, 1 + highest revision directory)`, so a revision - directory left by a crash between its rename and the manifest update is skipped (and set - aside as `.orphan-*`; readers never see it, since it is not below `next_rev`); - - carry-forward: the new revision starts from the base's tables; a carried table survives - only if each of its upstream hashes equals the hash of that table in the new revision, - otherwise it is dropped and listed in `dropped` with the reason; this repeats to a - fixpoint, so dropping a netlist also drops the timing computed from it; - - every upstream hash of a new table must hold in the new revision, or the commit fails; - - carried tables are hard-linked from the base (copied where links are impossible); - - `meta.json` is written, files and directories are fsynced, the staging directory is - renamed to `rev` and `revisions/` is fsynced; - - `manifest.json` is replaced through a temporary file, fsync, rename and directory fsync; - - the history line is appended and fsynced, and the lock is released. - -The rename of `manifest.json` is the commit point. A failure before it leaves HEAD where it was -and is an error. After it the commit has happened: a failed directory fsync or history append -does not make it fail; `Committed::warnings` carries it as a `W0102` or `W0101` warning, for a -result with `ok: true` and `new_rev` set, and no failure line is written. `init` and `revert` -follow the same rule (`Initialized::warnings`, `Reverted::warnings`). - -A command that commits with a negative or inconclusive verdict sets its exit code with -`Txn::set_exit(3)` or `Txn::set_exit(5)`; the history line records it with `ok: true`. - -`Workspace::revert(base, to)` takes the exclusive lock, checks HEAD, points HEAD at `to` and -logs the command; it creates no revision, and numbering continues after the highest revision. -Committed revisions are never written again. - -Every workspace-changing command gets one history line, rejected and conflicted ones -included. `init`, `fork`, `commit` and `revert` log their own outcome, success or failure (a -rejected `init` in the existing workspace, when its manifest can be read), and `begin` logs its -failure, the conflict included, so `ws.begin(base, &inv)?` loses no line. The caller logs only -what fails outside them: before `begin` (a command line, a `--base-rev` that does not resolve) -with `Workspace::log_rejected`, and between `begin` and `commit` with `Txn::reject`, which -writes the line before it removes the staging directory: a directory that cannot be removed -stays behind as a `.staging-*` entry, ignored by readers, without costing the command its line. - -## Forks - -Agents fork an experiment by copying the workspace (plan §1.1). `Workspace::fork(dst, inv)` -makes the copy under the shared lock, so it is one state of the workspace: HEAD and every -revision below `next_rev`, never a HEAD whose revision is still being committed. Revision files -and cached libraries are hard-linked, which is safe because no workspace writes them again -(copied where links are impossible); `history.jsonl` and `ui_state.json` are copied; staging -and orphan directories are left out; the fork gets a history line of its own. A plain copy of -the directory (`cp -r`, `cp --reflink`) is consistent only while it holds a shared lock on -`workspace.lock` (`flock -s ws/workspace.lock cp -r ws fork`), or if it copies `manifest.json` -before `revisions/`: a revision committed during the copy is then above the fork's `next_rev`, -an orphan the fork sets aside. - -## The envelope - -In JSON mode every command prints exactly one `rusteda.v1` envelope. `Envelope` writes the keys -in the contract's order: `schema`, `ok`, `command`, `workspace`, `base_rev`, `rev`, `new_rev`, -`scope`, `completeness`, `metrics`, the tool's own fields in insertion order, `diagnostics`, -`error`, `next_actions`. `scope`, `completeness` and `metrics` are JSON objects, set with -`set_scope`, `set_completeness` and `set_metrics`; no value of the envelope may hold an object -with two equal keys; and every diagnostic code is a `DiagCode`, a letter of the contract's -areas (`ABCDGLSTVW`) and four digits, so every diagnostic line parses. - -Nested values are kept as `RawJson` text, so key order survives without `serde_json`'s -`preserve_order` where the text is made in order: a struct keeps its field order -(`RawJson::new`), a `JsonObject` keeps its insertion order (for keys known only at run time, -such as one per corner), and `RawJson::parse` keeps the given text. `JsonObject` and `RawJson` -also serialize in that order as fields of a struct, so a per-corner object inside each entry of -a tool field keeps the tool's corner order. A map does not: a `BTreeMap`, `JsonMap`, -`serde_json::Value` or `json!` object serializes its keys sorted, and a `HashMap` in an -arbitrary order. A small reformatter prints the result pretty (two-space indents, the layout of -`serde_json::to_string_pretty`: every array element on its own line, numbers as serialized) or -compact; plan Example 1 is illustrative in that respect, binding in its keys, their order and -their values. - -`ErrorCode` maps every error code to its exit code (1, 2, 4 or 130). In text mode the error of -a failed command is printed like any other diagnostic: an error about the workspace with its -`W` code, `error[W0010]: revision conflict: --base-rev is rev3 but HEAD is rev4`, followed by -` help: ...`; `USAGE`, `INTERNAL`, `CANCELLED` and `BACKEND_UNAVAILABLE`, the application's -errors (contract §7: letter `A`), with the code the application passes, such as -`error[A0003]: unknown flag --x` (`Error::to_diagnostic`, `text::render_workspace_error`). JSON -keeps the envelope code. The `text` module has the text-mode lines and the POSIX quoting of -`next_actions`. - -## Example - -```rust -use rusteda_workspace::{Envelope, Invocation, JsonMap, JsonObject, JsonStyle, RbinWriter}; -use rusteda_workspace::{Upstream, Workspace}; - -# fn main() -> Result<(), rusteda_workspace::Error> { -# let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../target/tmp/readme-example"); -# let _ = std::fs::remove_dir_all(&root); -// `rusteda init`: rev0 without tables. -let init = Invocation::new("rusteda", "0.1.0", "rusteda init", ["rusteda", "init", "-w", "ws"]); -let ws = Workspace::init(&root, &init)?.workspace; - -// A mutation names its base revision; HEAD is refused here. -let base = ws.resolve_base("rev0")?; -let build = Invocation::new("demo", "0.1.0", "demo build", ["demo", "build", "--base-rev", "rev0"]); -let mut txn = ws.begin(base, &build)?; // REVISION_CONFLICT if HEAD moved -let cells: Vec = (0..1000).collect(); -let mut table = RbinWriter::new("DEMO", 1)?; -table.section(1, &cells)?; -txn.add_rbin("demo", "demo.rbin", &table, Upstream::new())?; -txn.set_summary("demo", "cells", 1000)?; -let committed = txn.commit(JsonMap::new())?; -assert_eq!(committed.rev.to_string(), "rev1"); -assert!(committed.warnings.is_empty()); - -// A query: HEAD is fine, and the table is mapped, not read. -let rev = ws.resolve_query("HEAD")?; -let demo = ws.revision(rev)?.open_rbin("demo", "DEMO", 1..=1)?; -let view: &[u32] = demo.section(1)?; -assert_eq!(view[999], 999); - -// The command's envelope, keys in contract order. -let mut envelope = Envelope::new("demo build"); -envelope.workspace = Some("ws".into()); -envelope.base_rev = Some(base.rev()); -envelope.new_rev = Some(committed.rev); -let mut metrics = JsonObject::new(); -metrics.insert("cells", &1000)?; -envelope.set_metrics(metrics)?; -envelope.diagnostics.extend(committed.warnings); -envelope.push_next_action(&["demo", "report", "-w", "ws", "--rev", "rev1"]); -let text = envelope.to_json(JsonStyle::Pretty); -assert!(text.starts_with("{\n \"schema\": \"rusteda.v1\",\n \"ok\": true,")); -# std::fs::remove_dir_all(&root).ok(); -# Ok(()) -# } -``` - -## Dependencies and safety - -Exact pins: `serde`, `serde_json` (`float_roundtrip` only), `thiserror`, `memmap2`, `bytemuck` -and `xxhash-rust` (`xxh3`). Locks are the standard library's `File::lock` family on a separate -open file description per lock; a wait interrupted by a signal starts again. The lock is -internal: every `Workspace` method takes the lock it needs and releases it before it returns, -so no caller can hold it across calls and wait for itself. The only `unsafe` code is the -read-only memory map, in one module with a `SAFETY` comment. It maps only files that the -workspace never writes again, the tables of committed revisions and the cached compiled -libraries, through `Revision::open_rbin` and `LibraryCache::open_rlib`; no public function maps -an arbitrary path, and a source test pins the callers. - -## Tests - -`cargo test` runs the unit tests and the integration tests (`tests/`): the rbin round trip, -every corruption class, the checks of a table on import (mutated tables included) and the -header upstream entries; reads that survive writes and truncation of the file; a chain of five -revisions with carried and dropped tables and their values; staging files linked to outside -files; conflicts found early and at commit between two threads; revert; forks, also during -commits; crash leftovers; failures after the commit point and of the cleanup of a rejected -command; hostile table paths and special files, inside directory tables too; the history schema -and exit codes; the reserved `ui_state.json`; corners; the library cache and library entries; -the envelope layout, its checks and determinism. -`cargo test --test bench -- --ignored --nocapture` runs the benchmark: open, validate and one -lookup on a 500k-row table set in a fresh process with a warm page cache. - -Requirement ids `WS-REQ-nnnn` in comments and tests refer to the workspace specification. diff --git a/crates/core/rusteda-workspace/src/cache.rs b/crates/core/rusteda-workspace/src/cache.rs deleted file mode 100644 index ffb006b..0000000 --- a/crates/core/rusteda-workspace/src/cache.rs +++ /dev/null @@ -1,341 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The library cache `cache/` of a workspace (WS-REQ-0080 to WS-REQ-0084). -//! -//! Every imported library is kept verbatim as `cache/-.lib` and compiled as -//! `cache/-.rlib`, where `h16` is the xxh3-64 of the library bytes in 16 hex digits. -//! Both files are written atomically (temporary file, fsync, rename), so concurrent imports of -//! the same library are harmless, and a library whose two files exist is not compiled again -//! while its `.rlib` is current: a valid rbin file (structure and content hash) of the kind and -//! a format version that the build reads. The caller names that kind and those versions, so a -//! compiled form left by an older release after a format version bump, a damaged one or one of -//! another kind is compiled again from the bytes (contract §5: "format_version bump => -//! recompile from cache/*.lib"); one of a newer release is `SCHEMA_TOO_NEW` and stays. -//! [`LibraryCache::recompile`] rebuilds the `.rlib` from the verbatim copy, and -//! [`LibraryCache::open_rlib_or_recompile`] and -//! [`Revision::open_library_or_recompile`](crate::Revision::open_library_or_recompile) open a -//! compiled library, compiling it again first when it is not current. -//! -//! A cache belongs to a workspace: it is reached through -//! [`Workspace::cache`](crate::Workspace::cache) and -//! [`Revision::open_library`](crate::Revision::open_library), never for an arbitrary directory, -//! because its compiled libraries are mapped (WS-REQ-0037). The cache reads regular files only -//! and never follows a symbolic link (WS-REQ-0025). - -use std::fs; -use std::ops::RangeInclusive; -use std::path::{Path, PathBuf}; - -use crate::error::{Corruption, Error, Result}; -use crate::fsutil; -use crate::hash::Hash64; -use crate::rbin::Rbin; -use crate::schema::LibraryEntry; -use crate::workspace::layout; - -/// A failure of [`LibraryCache::put_library`], [`LibraryCache::recompile`] or an open that -/// compiles again. -#[derive(Debug, thiserror::Error)] -pub enum PutError { - /// The compiler rejected the library. - #[error("compiling the library failed: {0}")] - Compile(E), - /// The cache could not be read or written; a cached file is damaged (`CORRUPT_TABLE`) or - /// of a newer release (`SCHEMA_TOO_NEW`); or the compiler returned something that is not a - /// valid rbin file of the kind and versions asked for (`INTERNAL`). - #[error(transparent)] - Workspace(#[from] Error), -} - -/// A library in the cache. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CachedLibrary { - /// The library name. - pub name: String, - /// The hash of the library bytes. - pub hash: Hash64, - /// The verbatim copy, relative to the workspace: `cache/-.lib`. - pub lib: String, - /// The compiled library, relative to the workspace: `cache/-.rlib`. - pub rlib: String, - /// The verbatim copy. - pub lib_path: PathBuf, - /// The compiled library. - pub rlib_path: PathBuf, - /// Whether this call ran the compiler. - pub compiled_now: bool, -} - -impl CachedLibrary { - /// The `meta.json` entry of this library in `corner`, imported from `source`. - pub fn entry(&self, corner: impl Into, source: impl Into) -> LibraryEntry { - LibraryEntry { - name: self.name.clone(), - corner: corner.into(), - hash: self.hash, - source: source.into(), - compiled: self.rlib.clone(), - } - } -} - -/// The library cache of a workspace, from [`Workspace::cache`](crate::Workspace::cache). -#[derive(Debug, Clone)] -pub struct LibraryCache { - root: PathBuf, -} - -impl LibraryCache { - /// The cache of the workspace at `workspace_root`. Crate-private (WS-REQ-0037): a cache - /// exists only for an open workspace (`Workspace::cache`), since its compiled libraries - /// are mapped, and a map is sound only for files that the workspace never writes again. - pub(crate) fn new(workspace_root: impl Into) -> LibraryCache { - LibraryCache { - root: workspace_root.into(), - } - } - - /// The cache directory. - pub fn dir(&self) -> PathBuf { - self.root.join(layout::CACHE) - } - - fn located(&self, name: &str, hash: Hash64) -> CachedLibrary { - let stem = format!("{name}-{}", hash.hex()); - let lib = format!("{}/{stem}.lib", layout::CACHE); - let rlib = rlib_path(name, hash); - CachedLibrary { - name: name.to_string(), - hash, - lib_path: self.root.join(&lib), - rlib_path: self.root.join(&rlib), - lib, - rlib, - compiled_now: false, - } - } - - /// Stores library `name` with its bytes and compiled form; `kind` and `versions` are the - /// rbin kind and the format versions of compiled libraries that this build reads, such as - /// `"LIBERTY", 1..=1`. The compiler runs only when the `.rlib` is not current: missing, not - /// a valid rbin file (structure and content hash), of another kind, or of a format version - /// below `versions`, as after a format version bump (WS-REQ-0080, WS-REQ-0083). Its output - /// must be a valid rbin file of `kind` and `versions` (otherwise `INTERNAL`). Nothing is - /// written when both files exist and the `.rlib` is current. An `.rlib` of a format - /// version above `versions`, written by a newer release, is `SCHEMA_TOO_NEW` and stays - /// (WS-REQ-0039). Library names are 1 to 128 characters of `[A-Za-z0-9._+-]`, not starting - /// with `.`. - pub fn put_library( - &self, - name: &str, - lib_bytes: &[u8], - kind: &str, - versions: RangeInclusive, - compile: impl FnOnce(&[u8]) -> std::result::Result, E>, - ) -> std::result::Result> { - check_library_name(name)?; - let mut library = self.located(name, Hash64::of(lib_bytes)); - let has_lib = is_regular_file(&library.lib_path); - let current = is_current_rlib(&library.rlib_path, kind, &versions)?; - if has_lib && current { - return Ok(library); - } - fsutil::create_dir_all(&self.dir())?; - if !has_lib { - fsutil::write_atomic(&library.lib_path, lib_bytes)?; - } - if !current { - let compiled = compile(lib_bytes).map_err(PutError::Compile)?; - let compiled = check_compiled(name, compiled, kind, &versions)?; - fsutil::write_atomic(&library.rlib_path, compiled.as_bytes())?; - library.compiled_now = true; - } - Ok(library) - } - - /// Compiles the cached verbatim library again and replaces its `.rlib` atomically, e.g. - /// after a format version change (WS-REQ-0083). The verbatim copy must still hash to - /// `hash` (otherwise `CORRUPT_TABLE`), and the compiled bytes must be a valid rbin file of - /// `kind` and `versions` (otherwise `INTERNAL`); on failure the old `.rlib` stays. - pub fn recompile( - &self, - name: &str, - hash: Hash64, - kind: &str, - versions: RangeInclusive, - compile: impl FnOnce(&[u8]) -> std::result::Result, E>, - ) -> std::result::Result> { - check_library_name(name)?; - let mut library = self.located(name, hash); - // WS-REQ-0025: a regular file only; a FIFO in its place would block the read. - let bytes = fsutil::read_regular(&library.lib_path)?; - let computed = Hash64::of(&bytes); - if computed != hash { - let what = Corruption::TableHash { - table: name.to_string(), - stored: hash, - computed, - }; - return Err(Error::corrupt(&library.lib_path, what).into()); - } - let compiled = compile(&bytes).map_err(PutError::Compile)?; - let compiled = check_compiled(name, compiled, kind, &versions)?; - fsutil::write_atomic(&library.rlib_path, compiled.as_bytes())?; - library.compiled_now = true; - Ok(library) - } - - /// The cached library `name` with `hash`, if both of its files exist as regular files - /// (WS-REQ-0081; a symbolic link is not followed). Whether its `.rlib` is current for a - /// build is checked when it is stored ([`LibraryCache::put_library`]) and opened. - pub fn lookup(&self, name: &str, hash: Hash64) -> Option { - check_library_name(name).ok()?; - let library = self.located(name, hash); - (is_regular_file(&library.lib_path) && is_regular_file(&library.rlib_path)) - .then_some(library) - } - - /// Every cached library with `hash`, whatever its name, sorted by name (WS-REQ-0081). - pub fn find(&self, hash: Hash64) -> Result> { - let dir = self.dir(); - let suffix = format!("-{}.lib", hash.hex()); - let entries = match fs::read_dir(&dir) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(Error::io(dir, e)), - }; - let mut found = Vec::new(); - for entry in entries { - let entry = entry.map_err(|e| Error::io(&dir, e))?; - let Ok(file) = entry.file_name().into_string() else { - continue; - }; - if let Some(name) = file.strip_suffix(&suffix) { - if let Some(library) = self.lookup(name, hash) { - found.push(library); - } - } - } - found.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(found) - } - - /// Maps the compiled library `cache/-.rlib` of this workspace read-only and - /// validates its header (`kind`, `versions`; WS-REQ-0032, WS-REQ-0082). The workspace - /// replaces the file only by a rename, never writes it in place, so it can be mapped - /// (WS-REQ-0037). It must be a regular file, checked without following a symbolic link - /// (WS-REQ-0025); otherwise `CORRUPT_TABLE`, as is a format version below `versions` - /// (see [`LibraryCache::open_rlib_or_recompile`]); a missing file is `IO_ERROR`. - pub fn open_rlib( - &self, - name: &str, - hash: Hash64, - kind: &str, - versions: RangeInclusive, - ) -> Result { - check_library_name(name)?; - Rbin::map_immutable(&self.located(name, hash).rlib_path, kind, versions) - } - - /// [`LibraryCache::open_rlib`], compiling the library again from its verbatim copy first - /// when its `.rlib` cannot be opened as `kind` and `versions` but the copy can replace it - /// (WS-REQ-0084): the `.rlib` is missing, damaged, of another kind, or of a format version - /// below `versions` (contract §5: "format_version bump => recompile from cache/*.lib"). The - /// copy must hash to `hash` ([`LibraryCache::recompile`]). An `.rlib` of a newer release is - /// `SCHEMA_TOO_NEW` and stays; the compiler runs at most once. - pub fn open_rlib_or_recompile( - &self, - name: &str, - hash: Hash64, - kind: &str, - versions: RangeInclusive, - compile: impl FnOnce(&[u8]) -> std::result::Result, E>, - ) -> std::result::Result> { - match self.open_rlib(name, hash, kind, versions.clone()) { - Err(e) if is_stale(&e) => { - self.recompile(name, hash, kind, versions.clone(), compile)?; - Ok(self.open_rlib(name, hash, kind, versions)?) - } - opened => Ok(opened?), - } - } -} - -/// The path of the compiled library `name` with `hash`, relative to the workspace: -/// `cache/-.rlib` (contract §2), as a library entry of `meta.json` records it. -pub(crate) fn rlib_path(name: &str, hash: Hash64) -> String { - format!("{}/{name}-{}.rlib", layout::CACHE, hash.hex()) -} - -/// Whether `path` itself, not following a symbolic link, is a regular file. -fn is_regular_file(path: &Path) -> bool { - fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_file()) -} - -/// Whether the cached `.rlib` at `path` is current for a build that reads `kind` and -/// `versions` (WS-REQ-0080, WS-REQ-0083): a regular file and a valid rbin file of that kind and -/// a format version in `versions`, with a matching content hash. `false` when it is missing or -/// fails a check, so it is compiled again; one of a newer format version is `SCHEMA_TOO_NEW`. -fn is_current_rlib(path: &Path, kind: &str, versions: &RangeInclusive) -> Result { - let opened = Rbin::read(path, kind, versions.clone()).and_then(|rbin| rbin.verify_hash()); - match opened { - Ok(()) => Ok(true), - Err(e) if is_stale(&e) => Ok(false), - Err(e) => Err(e), - } -} - -/// Whether an error of opening a cached `.rlib` means that compiling the library again from -/// its verbatim copy replaces it: the file is missing or fails validation, a format version -/// below the build's included. A newer format version (`SCHEMA_TOO_NEW`) or any other I/O -/// error does not. -fn is_stale(error: &Error) -> bool { - match error { - Error::Corrupt { .. } => true, - Error::Io { source, .. } => source.kind() == std::io::ErrorKind::NotFound, - _ => false, - } -} - -/// The compiled form of a library is a valid rbin file (structure and content hash) of `kind` -/// and a format version in `versions`; anything else is a bug of the compiler, `INTERNAL` -/// (WS-REQ-0080). Returns it, validated. -fn check_compiled( - name: &str, - compiled: Vec, - kind: &str, - versions: &RangeInclusive, -) -> Result { - Rbin::from_bytes(compiled, kind, versions.clone()) - .and_then(|rbin| rbin.verify_hash().map(|()| rbin)) - .map_err(|e| { - Error::internal(format!( - "the compiled form of library {name} is not a valid rbin file of kind {kind} and \ - format version {}..={}: {e}", - versions.start(), - versions.end() - )) - }) -} - -/// Whether `name` is a library name of the cache: 1 to 128 characters of `[A-Za-z0-9._+-]`, -/// not starting with `.`. -pub(crate) fn is_library_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 128 - && !name.starts_with('.') - && name - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-')) -} - -fn check_library_name(name: &str) -> Result<()> { - if !is_library_name(name) { - return Err(Error::input_rejected(format!( - "library name '{name}' must be 1 to 128 characters of [A-Za-z0-9._+-], not starting \ - with '.'" - ))); - } - Ok(()) -} diff --git a/crates/core/rusteda-workspace/src/edits.rs b/crates/core/rusteda-workspace/src/edits.rs deleted file mode 100644 index 849a806..0000000 --- a/crates/core/rusteda-workspace/src/edits.rs +++ /dev/null @@ -1,764 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Netlist edit files `rusteda.edits.v1`: the edits an optimizer proposes to a revision's netlist -//! and the queries it asks about them (WS-REQ-0140 to WS-REQ-0159, `docs/specs/workspace/edits.md`). -//! -//! An edit file holds `base` edits, accepted already, then `candidates`, each a list of edits that -//! is evaluated on its own after the base edits, and a `report` request: endpoints by name or -//! `ep` handle and ranked paths by `path` handle. Edits name netlist objects by name, never -//! by an id of one tool, so that any tool of the suite can write and read them. The five edit -//! kinds are `resize`, `insert_buffer`, `remove_buffer`, `swap_pins` and `clone`. -//! -//! [`parse`] reads and checks a file: its schema, every name against the naming rules of the -//! netlist (CORE-REQ-0123), the candidate ids against the handle rules, and the bounds of -//! [`Limits`]; [`EditFile::to_json`] writes the canonical form back. [`apply`] applies one edit to -//! a [`GateNetlist`](rusteda_core::netlist::GateNetlist) by a fixed sequence of netlist operations, -//! the same in every tool, so that the netlist a tool commits after accepting edits is the one -//! the edits were evaluated on. Whether an edit keeps the logic of the netlist (the function of a -//! cell) is a question for the tool that knows the cell libraries; this module checks structure -//! only. Every error has a `W03nn` code ([`codes`]) and the JSON path of what it is about. - -mod apply; -#[cfg(test)] -mod tests; - -use std::fmt; - -use rusteda_core::names; -use serde_json::{Map, Value}; - -use crate::envelope::{DiagCode, Diagnostic, Severity}; - -pub use apply::{Applied, apply, find_net, port_bit_net}; - -/// The schema id of an edit file (WS-REQ-0140). -pub const SCHEMA: &str = "rusteda.edits.v1"; - -/// The diagnostic codes of edit files and edits (WS-REQ-0144, WS-REQ-0151). -pub mod codes { - use crate::envelope::DiagCode; - - /// The file is not JSON, or a field is missing, of the wrong type or unknown. - pub const INVALID: DiagCode = DiagCode::of("W0300"); - /// `schema` is missing or is not [`super::SCHEMA`]. - pub const SCHEMA: DiagCode = DiagCode::of("W0301"); - /// A name breaks the naming rules of the netlist. - pub const NAME: DiagCode = DiagCode::of("W0302"); - /// A candidate id is not a handle-safe word, or two candidates share it. - pub const CANDIDATE_ID: DiagCode = DiagCode::of("W0303"); - /// A bound of [`super::Limits`] is exceeded. - pub const LIMIT: DiagCode = DiagCode::of("W0304"); - /// No instance has the name. - pub const UNKNOWN_INSTANCE: DiagCode = DiagCode::of("W0305"); - /// No net has the name, or the name is ambiguous. - pub const UNKNOWN_NET: DiagCode = DiagCode::of("W0306"); - /// No pin of the instance, or no port, has the name. - pub const UNKNOWN_PIN: DiagCode = DiagCode::of("W0307"); - /// The netlist has no cell of the name and none is supplied. - pub const UNKNOWN_CELL: DiagCode = DiagCode::of("W0308"); - /// A new instance or net would take a name in use. - pub const NAME_IN_USE: DiagCode = DiagCode::of("W0309"); - /// A sink is not on the net, drives it, is listed twice, or is a port that cannot move. - pub const SINK: DiagCode = DiagCode::of("W0310"); - /// The instance or cell does not have the shape of a buffer, or its nets cannot merge. - pub const BUFFER: DiagCode = DiagCode::of("W0311"); - /// The pins of a pin swap are not two distinct connected input pins. - pub const PIN_SWAP: DiagCode = DiagCode::of("W0312"); - /// The instance of a clone does not drive exactly one net from exactly one output pin. - pub const CLONE: DiagCode = DiagCode::of("W0313"); - /// The cell of a resize is not pin-compatible, or another netlist operation failed. - pub const NETLIST: DiagCode = DiagCode::of("W0314"); -} - -/// Bounds of an edit file (WS-REQ-0144): a file beyond them is rejected before any edit is -/// applied, so that reading it never allocates beyond them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Limits { - /// Bytes of the file text. - pub max_bytes: usize, - /// Candidates. - pub max_candidates: usize, - /// Edits of `base` and of each candidate. - pub max_edits: usize, - /// Sinks of one edit. - pub max_sinks: usize, - /// Entries of `report.endpoints` and of `report.paths`. - pub max_report: usize, -} - -impl Default for Limits { - fn default() -> Limits { - Limits { - max_bytes: 64 << 20, - max_candidates: 1024, - max_edits: 4096, - max_sinks: 65_536, - max_report: 4096, - } - } -} - -/// A sink of a net: a pin of an instance or a port (WS-REQ-0142). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Sink { - /// The pin `pin` of the instance `instance`. - Pin { - /// The instance name. - instance: String, - /// The cell pin name. - pin: String, - }, - /// The port `port`: a scalar port, or a bit `name[index]` of a bus port. - Port { - /// The port name. - port: String, - }, -} - -/// One netlist edit (WS-REQ-0142). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Edit { - /// Gives the instance another cell with the same pins and the same function. - Resize { - /// The instance. - instance: String, - /// The new cell. - cell: String, - }, - /// Splits the net with a new buffer that drives the sinks on a new net. - InsertBuffer { - /// The net to split. - net: String, - /// The cell of the buffer. - cell: String, - /// The name of the new buffer instance. - instance: String, - /// The name of the new net. - net_out: String, - /// The sinks of `net` that move behind the buffer. - sinks: Vec, - }, - /// Removes a buffer and merges its output net into its input net. - RemoveBuffer { - /// The buffer instance. - instance: String, - }, - /// Exchanges the connections of two input pins of an instance. - SwapPins { - /// The instance. - instance: String, - /// The first input pin. - pin_a: String, - /// The second input pin. - pin_b: String, - }, - /// Duplicates a driver with the same input connections and moves sinks to the copy. - Clone { - /// The instance to duplicate. - instance: String, - /// The name of the copy. - new_instance: String, - /// The name of the net the copy drives. - new_net: String, - /// The sinks of the instance's output net that move to the copy. - sinks: Vec, - }, -} - -impl Edit { - /// The `op` of the edit: `resize`, `insert_buffer`, `remove_buffer`, `swap_pins` or `clone`. - pub fn op(&self) -> &'static str { - match self { - Edit::Resize { .. } => "resize", - Edit::InsertBuffer { .. } => "insert_buffer", - Edit::RemoveBuffer { .. } => "remove_buffer", - Edit::SwapPins { .. } => "swap_pins", - Edit::Clone { .. } => "clone", - } - } - - /// The edit as a JSON object in canonical form (WS-REQ-0145). - pub fn to_value(&self) -> Value { - let mut o = Map::new(); - o.insert("op".into(), Value::from(self.op())); - let mut put = |k: &str, v: &str| { - o.insert(k.into(), Value::from(v)); - }; - match self { - Edit::Resize { instance, cell } => { - put("instance", instance); - put("cell", cell); - } - Edit::InsertBuffer { - net, - cell, - instance, - net_out, - sinks, - } => { - put("net", net); - put("cell", cell); - put("instance", instance); - put("net_out", net_out); - o.insert("sinks".into(), sinks_value(sinks)); - } - Edit::RemoveBuffer { instance } => put("instance", instance), - Edit::SwapPins { - instance, - pin_a, - pin_b, - } => { - put("instance", instance); - put("pin_a", pin_a); - put("pin_b", pin_b); - } - Edit::Clone { - instance, - new_instance, - new_net, - sinks, - } => { - put("instance", instance); - put("new_instance", new_instance); - put("new_net", new_net); - o.insert("sinks".into(), sinks_value(sinks)); - } - } - Value::Object(o) - } -} - -fn sinks_value(sinks: &[Sink]) -> Value { - Value::Array( - sinks - .iter() - .map(|s| { - let mut o = Map::new(); - match s { - Sink::Pin { instance, pin } => { - o.insert("instance".into(), Value::from(instance.as_str())); - o.insert("pin".into(), Value::from(pin.as_str())); - } - Sink::Port { port } => { - o.insert("port".into(), Value::from(port.as_str())); - } - } - Value::Object(o) - }) - .collect(), - ) -} - -/// A candidate: edits evaluated together, after the base edits (WS-REQ-0143). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Candidate { - /// Its id, a handle-safe word unique in the file. - pub id: String, - /// Its edits, in order. - pub edits: Vec, -} - -/// The values a reader of the file reports for the base and for each candidate (WS-REQ-0143). -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ReportRequest { - /// Endpoints: a pin `inst/pin`, a port, or an endpoint handle `ep`. - pub endpoints: Vec, - /// Ranked paths: path handles `path`. - pub paths: Vec, -} - -/// An edit file (WS-REQ-0140). -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct EditFile { - /// Edits applied first, in order. - pub base: Vec, - /// Candidates, each evaluated after `base` and independently of the others. - pub candidates: Vec, - /// What to report. - pub report: ReportRequest, -} - -impl EditFile { - /// The file as canonical JSON text: two-space indentation, the keys of each object in - /// byte order, a final newline (WS-REQ-0145). [`parse`] reads it back unchanged. - pub fn to_json(&self) -> String { - let mut o = Map::new(); - o.insert("schema".into(), Value::from(SCHEMA)); - o.insert( - "base".into(), - Value::Array(self.base.iter().map(Edit::to_value).collect()), - ); - o.insert( - "candidates".into(), - Value::Array( - self.candidates - .iter() - .map(|c| { - let mut co = Map::new(); - co.insert("id".into(), Value::from(c.id.as_str())); - co.insert( - "edits".into(), - Value::Array(c.edits.iter().map(Edit::to_value).collect()), - ); - Value::Object(co) - }) - .collect(), - ), - ); - let mut r = Map::new(); - r.insert( - "endpoints".into(), - Value::from(self.report.endpoints.clone()), - ); - r.insert("paths".into(), Value::from(self.report.paths.clone())); - o.insert("report".into(), Value::Object(r)); - let mut text = - serde_json::to_string_pretty(&canonical(Value::Object(o))).unwrap_or_default(); - text.push('\n'); - text - } -} - -/// `value` with the keys of every object inserted in byte order, so that the text is the same -/// whatever the map type of `serde_json`. -fn canonical(value: Value) -> Value { - match value { - Value::Object(o) => { - let mut entries: Vec<(String, Value)> = o.into_iter().collect(); - entries.sort_by(|a, b| a.0.cmp(&b.0)); - Value::Object( - entries - .into_iter() - .map(|(k, v)| (k, canonical(v))) - .collect(), - ) - } - Value::Array(a) => Value::Array(a.into_iter().map(canonical).collect()), - v => v, - } -} - -/// An error of an edit file or of an edit (WS-REQ-0144, WS-REQ-0151). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EditsError { - /// Its `W03nn` code. - pub code: DiagCode, - /// The JSON path of the value it is about, such as `candidates[2].edits[0].cell`; empty for - /// the whole file. - pub at: String, - /// What is wrong. - pub message: String, -} - -impl EditsError { - /// An error with `code` at `at`. - pub fn new(code: DiagCode, at: impl Into, message: impl Into) -> EditsError { - EditsError { - code, - at: at.into(), - message: message.into(), - } - } - - /// The error at `prefix` followed by its own path. - pub fn within(mut self, prefix: &str) -> EditsError { - self.at = if self.at.is_empty() { - prefix.to_string() - } else if prefix.is_empty() { - self.at - } else { - format!("{prefix}.{}", self.at) - }; - self - } - - /// The error as an error diagnostic of the envelope. - pub fn to_diagnostic(&self) -> Diagnostic { - Diagnostic::new(Severity::Error, self.code, self.to_string()) - } -} - -impl fmt::Display for EditsError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.at.is_empty() { - write!(f, "{}", self.message) - } else { - write!(f, "{}: {}", self.at, self.message) - } - } -} - -impl std::error::Error for EditsError {} - -/// Reads and checks an edit file (WS-REQ-0140 to WS-REQ-0144): the JSON structure, the schema, -/// every name, the candidate ids and the bounds of `limits`. Unknown keys are errors, so that a -/// misspelt field is never ignored. -pub fn parse(text: &str, limits: &Limits) -> Result { - if text.len() > limits.max_bytes { - return Err(EditsError::new( - codes::LIMIT, - "", - format!( - "the file has {} bytes, above the limit of {}", - text.len(), - limits.max_bytes - ), - )); - } - let value: Value = serde_json::from_str(text) - .map_err(|e| EditsError::new(codes::INVALID, "", format!("not valid JSON: {e}")))?; - let o = object(&value, "")?; - known_keys(o, "", &["schema", "base", "candidates", "report"])?; - match o.get("schema") { - Some(Value::String(s)) if s == SCHEMA => {} - Some(Value::String(s)) => { - return Err(EditsError::new( - codes::SCHEMA, - "schema", - format!("schema '{s}' is not '{SCHEMA}'"), - )); - } - _ => { - return Err(EditsError::new( - codes::SCHEMA, - "schema", - format!("the file has no schema '{SCHEMA}'"), - )); - } - } - let base = match o.get("base") { - None => Vec::new(), - Some(v) => edit_list(v, "base", limits)?, - }; - let mut candidates = Vec::new(); - if let Some(v) = o.get("candidates") { - let list = array(v, "candidates")?; - if list.len() > limits.max_candidates { - return Err(EditsError::new( - codes::LIMIT, - "candidates", - format!( - "{} candidates, above the limit of {}", - list.len(), - limits.max_candidates - ), - )); - } - for (k, c) in list.iter().enumerate() { - let at = format!("candidates[{k}]"); - let co = object(c, &at)?; - known_keys(co, &at, &["id", "edits"])?; - let id = string(co, &at, "id")?; - if !is_handle_word(&id) { - return Err(EditsError::new( - codes::CANDIDATE_ID, - format!("{at}.id"), - format!( - "candidate id '{}' is not 1 to 64 of [A-Za-z0-9_-]", - names::for_display(&id) - ), - )); - } - if candidates.iter().any(|c: &Candidate| c.id == id) { - return Err(EditsError::new( - codes::CANDIDATE_ID, - format!("{at}.id"), - format!("two candidates have the id '{id}'"), - )); - } - let edits = match co.get("edits") { - Some(v) => edit_list(v, &format!("{at}.edits"), limits)?, - None => { - return Err(EditsError::new( - codes::INVALID, - format!("{at}.edits"), - "a candidate needs its list of edits", - )); - } - }; - candidates.push(Candidate { id, edits }); - } - } - let mut report = ReportRequest::default(); - if let Some(v) = o.get("report") { - let ro = object(v, "report")?; - known_keys(ro, "report", &["endpoints", "paths"])?; - for (key, out) in [ - ("endpoints", &mut report.endpoints), - ("paths", &mut report.paths), - ] { - let Some(list) = ro.get(key) else { - continue; - }; - let at = format!("report.{key}"); - let list = array(list, &at)?; - if list.len() > limits.max_report { - return Err(EditsError::new( - codes::LIMIT, - at, - format!( - "{} entries, above the limit of {}", - list.len(), - limits.max_report - ), - )); - } - for (k, e) in list.iter().enumerate() { - let eat = format!("{at}[{k}]"); - let Value::String(s) = e else { - return Err(EditsError::new(codes::INVALID, eat, "expected a string")); - }; - if key == "paths" { - if !is_numbered_handle(s, "path") { - return Err(EditsError::new( - codes::NAME, - eat, - format!("'{}' is not a path handle path", names::for_display(s)), - )); - } - } else { - hierarchical(s, &eat)?; - } - out.push(s.clone()); - } - } - } - Ok(EditFile { - base, - candidates, - report, - }) -} - -/// Parses one edit from its JSON object (WS-REQ-0142). -pub fn parse_edit(value: &Value, limits: &Limits) -> Result { - let o = object(value, "")?; - let op = string(o, "", "op")?; - let edit = match op.as_str() { - "resize" => { - known_keys(o, "", &["op", "instance", "cell"])?; - Edit::Resize { - instance: name_field(o, "instance")?, - cell: verbatim_field(o, "cell")?, - } - } - "insert_buffer" => { - known_keys( - o, - "", - &["op", "net", "cell", "instance", "net_out", "sinks"], - )?; - Edit::InsertBuffer { - net: name_field(o, "net")?, - cell: verbatim_field(o, "cell")?, - instance: name_field(o, "instance")?, - net_out: name_field(o, "net_out")?, - sinks: sinks_field(o, limits)?, - } - } - "remove_buffer" => { - known_keys(o, "", &["op", "instance"])?; - Edit::RemoveBuffer { - instance: name_field(o, "instance")?, - } - } - "swap_pins" => { - known_keys(o, "", &["op", "instance", "pin_a", "pin_b"])?; - Edit::SwapPins { - instance: name_field(o, "instance")?, - pin_a: verbatim_field(o, "pin_a")?, - pin_b: verbatim_field(o, "pin_b")?, - } - } - "clone" => { - known_keys( - o, - "", - &["op", "instance", "new_instance", "new_net", "sinks"], - )?; - Edit::Clone { - instance: name_field(o, "instance")?, - new_instance: name_field(o, "new_instance")?, - new_net: name_field(o, "new_net")?, - sinks: sinks_field(o, limits)?, - } - } - other => { - return Err(EditsError::new( - codes::INVALID, - "op", - format!( - "unknown edit '{}': expected resize, insert_buffer, remove_buffer, \ - swap_pins or clone", - names::for_display(other) - ), - )); - } - }; - Ok(edit) -} - -fn edit_list(value: &Value, at: &str, limits: &Limits) -> Result, EditsError> { - let list = array(value, at)?; - if list.len() > limits.max_edits { - return Err(EditsError::new( - codes::LIMIT, - at, - format!( - "{} edits, above the limit of {}", - list.len(), - limits.max_edits - ), - )); - } - list.iter() - .enumerate() - .map(|(k, v)| parse_edit(v, limits).map_err(|e| e.within(&format!("{at}[{k}]")))) - .collect() -} - -fn sinks_field(o: &Map, limits: &Limits) -> Result, EditsError> { - let Some(v) = o.get("sinks") else { - return Err(EditsError::new( - codes::INVALID, - "sinks", - "the edit needs its sinks", - )); - }; - let list = array(v, "sinks")?; - if list.is_empty() { - return Err(EditsError::new( - codes::SINK, - "sinks", - "the edit moves no sink", - )); - } - if list.len() > limits.max_sinks { - return Err(EditsError::new( - codes::LIMIT, - "sinks", - format!( - "{} sinks, above the limit of {}", - list.len(), - limits.max_sinks - ), - )); - } - let mut out = Vec::with_capacity(list.len()); - for (k, s) in list.iter().enumerate() { - let at = format!("sinks[{k}]"); - let so = object(s, &at)?; - let sink = if so.contains_key("port") { - known_keys(so, &at, &["port"])?; - Sink::Port { - port: name_field(so, "port").map_err(|e| e.within(&at))?, - } - } else { - known_keys(so, &at, &["instance", "pin"])?; - Sink::Pin { - instance: name_field(so, "instance").map_err(|e| e.within(&at))?, - pin: verbatim_field(so, "pin").map_err(|e| e.within(&at))?, - } - }; - if out.contains(&sink) { - return Err(EditsError::new(codes::SINK, at, "the sink is listed twice")); - } - out.push(sink); - } - Ok(out) -} - -fn object<'v>(value: &'v Value, at: &str) -> Result<&'v Map, EditsError> { - value - .as_object() - .ok_or_else(|| EditsError::new(codes::INVALID, at, "expected an object")) -} - -fn array<'v>(value: &'v Value, at: &str) -> Result<&'v Vec, EditsError> { - value - .as_array() - .ok_or_else(|| EditsError::new(codes::INVALID, at, "expected an array")) -} - -fn join(at: &str, key: &str) -> String { - if at.is_empty() { - key.to_string() - } else { - format!("{at}.{key}") - } -} - -fn known_keys(o: &Map, at: &str, keys: &[&str]) -> Result<(), EditsError> { - match o.keys().find(|k| !keys.contains(&k.as_str())) { - Some(k) => Err(EditsError::new( - codes::INVALID, - join(at, k), - format!("unknown key '{}'", names::for_display(k)), - )), - None => Ok(()), - } -} - -fn string(o: &Map, at: &str, key: &str) -> Result { - match o.get(key) { - Some(Value::String(s)) => Ok(s.clone()), - Some(_) => Err(EditsError::new( - codes::INVALID, - join(at, key), - "expected a string", - )), - None => Err(EditsError::new( - codes::INVALID, - join(at, key), - format!("missing '{key}'"), - )), - } -} - -fn hierarchical(name: &str, at: &str) -> Result<(), EditsError> { - names::check_hierarchical(name).map_err(|e| { - EditsError::new( - codes::NAME, - at, - format!("'{}': {e}", names::for_display(name)), - ) - }) -} - -/// A port, net or instance name (CORE-REQ-0123). -fn name_field(o: &Map, key: &str) -> Result { - let s = string(o, "", key)?; - hierarchical(&s, key)?; - Ok(s) -} - -/// A cell or cell pin name (CORE-REQ-0123). -fn verbatim_field(o: &Map, key: &str) -> Result { - let s = string(o, "", key)?; - names::check_verbatim(&s).map_err(|e| { - EditsError::new( - codes::NAME, - key, - format!("'{}': {e}", names::for_display(&s)), - ) - })?; - Ok(s) -} - -/// A handle-safe word of 1 to 64 characters: `[A-Za-z0-9_-]+` (WS-REQ-0022). -fn is_handle_word(s: &str) -> bool { - (1..=64).contains(&s.len()) - && s.bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') -} - -/// True for `` with `N >= 1` written without leading zeros. -pub fn is_numbered_handle(s: &str, prefix: &str) -> bool { - s.strip_prefix(prefix).is_some_and(|n| { - !n.is_empty() - && n.len() <= 10 - && !n.starts_with('0') - && n.bytes().all(|b| b.is_ascii_digit()) - }) -} diff --git a/crates/core/rusteda-workspace/src/edits/apply.rs b/crates/core/rusteda-workspace/src/edits/apply.rs deleted file mode 100644 index e1bd2d2..0000000 --- a/crates/core/rusteda-workspace/src/edits/apply.rs +++ /dev/null @@ -1,735 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Applying an edit to a [`GateNetlist`] by a fixed sequence of netlist operations -//! (WS-REQ-0146 to WS-REQ-0151). -//! -//! Every edit checks its arguments before it changes the netlist, and an edit that fails leaves -//! the netlist unchanged. The sequence of operations is part of the definition of each edit: it -//! fixes the connection order of every pin and net, so that two tools that apply the same edits -//! to the same netlist get equal frozen netlists. - -use rusteda_core::names; -use rusteda_core::netlist::{ - CellId, Conn, GateNetlist, InstId, NetId, NetlistError, PinDir, PinId, PortDir, -}; - -use super::{Edit, EditsError, Sink, codes}; -use crate::envelope::DiagCode; - -/// What an applied edit changed (WS-REQ-0151): the instances whose cell or connections changed -/// and the nets that gained or lost a pin, the new ones included, each once, in the order the edit -/// touched them. Removed instances and nets are not listed. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct Applied { - /// Changed and new instances. - pub instances: Vec, - /// Changed and new nets. - pub nets: Vec, -} - -impl Applied { - fn inst(&mut self, i: InstId) { - if !self.instances.contains(&i) { - self.instances.push(i); - } - } - - fn net(&mut self, n: NetId) { - if !self.nets.contains(&n) { - self.nets.push(n); - } - } - - fn pin_owner(&mut self, nl: &GateNetlist, p: PinId) { - if let Ok(pin) = nl.pin(p) { - self.inst(pin.inst); - } - } -} - -/// Applies `edit` to `netlist` (WS-REQ-0146 to WS-REQ-0151). A cell that the netlist does not -/// have yet is added with the pins `cell_pins` gives for it, in library order; `cell_pins` -/// returns `None` for a cell it does not know. On an error the netlist is unchanged. -pub fn apply( - netlist: &mut GateNetlist, - edit: &Edit, - cell_pins: &dyn Fn(&str) -> Option>, -) -> Result { - match edit { - Edit::Resize { instance, cell } => resize(netlist, instance, cell, cell_pins), - Edit::InsertBuffer { - net, - cell, - instance, - net_out, - sinks, - } => insert_buffer(netlist, net, cell, instance, net_out, sinks, cell_pins), - Edit::RemoveBuffer { instance } => remove_buffer(netlist, instance), - Edit::SwapPins { - instance, - pin_a, - pin_b, - } => swap_pins(netlist, instance, pin_a, pin_b), - Edit::Clone { - instance, - new_instance, - new_net, - sinks, - } => clone(netlist, instance, new_instance, new_net, sinks), - } -} - -/// The net named `name` (WS-REQ-0141): the scalar net `name`, or else, when `name` is -/// `base[index]`, the bit `index` of the bus `base`. A name that names both is ambiguous. -pub fn find_net(netlist: &GateNetlist, name: &str) -> Result { - let scalar = netlist.find_net(name, None); - let bit = split_bit(name).and_then(|(base, k)| netlist.find_net(base, Some(k))); - match (scalar, bit) { - (Some(_), Some(_)) => Err(EditsError::new( - codes::UNKNOWN_NET, - "net", - format!( - "'{}' names both a scalar net and a bit of a bus", - names::for_display(name) - ), - )), - (Some(n), None) | (None, Some(n)) => Ok(n), - (None, None) => Err(EditsError::new( - codes::UNKNOWN_NET, - "net", - format!("no net '{}'", names::for_display(name)), - )), - } -} - -/// The net of the port bit `name` and the direction of its port (WS-REQ-0141): a scalar port, or -/// `base[index]` of a bus port. -pub fn port_bit_net(netlist: &GateNetlist, name: &str) -> Option<(NetId, PortDir)> { - if let Some(p) = netlist.find_port(name) - && p.range.is_none() - { - return p.bits.first().map(|&n| (n, p.dir)); - } - let (base, k) = split_bit(name)?; - let p = netlist.find_port(base)?; - p.range?; - let n = netlist.find_net(base, Some(k))?; - p.bits.contains(&n).then_some((n, p.dir)) -} - -/// `base[index]` split into its base and index. -fn split_bit(name: &str) -> Option<(&str, i32)> { - let inner = name.strip_suffix(']')?; - let open = inner.rfind('[')?; - let (base, k) = (&inner[..open], &inner[open + 1..]); - if base.is_empty() { - return None; - } - k.parse::().ok().map(|k| (base, k)) -} - -fn find_instance(nl: &GateNetlist, name: &str, at: &str) -> Result { - nl.find_instance(name).ok_or_else(|| { - EditsError::new( - codes::UNKNOWN_INSTANCE, - at, - format!("no instance '{}'", names::for_display(name)), - ) - }) -} - -fn free_instance_name(nl: &GateNetlist, name: &str, at: &str) -> Result<(), EditsError> { - if nl.find_instance(name).is_some() { - return Err(EditsError::new( - codes::NAME_IN_USE, - at, - format!("an instance '{}' exists", names::for_display(name)), - )); - } - Ok(()) -} - -fn find_pin(nl: &GateNetlist, inst: InstId, pin: &str, at: &str) -> Result { - nl.find_pin(inst, pin) - .map_err(|e| EditsError::new(codes::UNKNOWN_PIN, at, format!("{e}"))) -} - -/// The direction of the cell pin of `pin`. -fn pin_dir(nl: &GateNetlist, pin: PinId) -> PinDir { - nl.pin(pin) - .ok() - .and_then(|p| { - let inst = nl.instance(p.inst).ok()?; - let cell = nl.cell(inst.cell).ok()?; - cell.pins.get(p.cell_pin as usize).map(|cp| cp.dir) - }) - .unwrap_or(PinDir::Other) -} - -/// True when `net` is a bit of a port or an assignment refers to it. -fn net_in_use(nl: &GateNetlist, net: NetId) -> bool { - let port_bit = nl.ports().iter().any(|p| p.bits.contains(&net)); - port_bit - || nl - .assigns() - .iter() - .any(|a| a.lhs == net || a.rhs == Conn::Net(net)) -} - -fn netlist_error(code: DiagCode, at: &str, e: &NetlistError) -> EditsError { - EditsError::new(code, at, format!("{e}")) -} - -/// Checks that a new scalar net may take `name`: no port, scalar net or bus has it. -fn free_net_name(nl: &GateNetlist, name: &str, at: &str) -> Result<(), EditsError> { - let bus = nl.symbols().get(name).is_some_and(|s| { - nl.nets() - .any(|(_, n)| n.name == Some(s) && n.index.is_some()) - }); - if nl.find_port(name).is_some() || nl.find_net(name, None).is_some() || bus { - return Err(EditsError::new( - codes::NAME_IN_USE, - at, - format!("a port or net '{}' exists", names::for_display(name)), - )); - } - Ok(()) -} - -/// The id of cell `name`, added with the pins of `cell_pins` when the netlist does not have it; -/// `check` sees the pins first and may refuse them, before the cell is added. -fn cell_id( - nl: &mut GateNetlist, - name: &str, - cell_pins: &dyn Fn(&str) -> Option>, - check: &dyn Fn(&[(String, PinDir)]) -> Result<(), EditsError>, -) -> Result { - if let Some(c) = nl.find_cell(name) { - let pins: Vec<(String, PinDir)> = nl - .cell(c) - .map(|ct| { - ct.pins - .iter() - .map(|p| (nl.symbols().resolve(p.name).to_string(), p.dir)) - .collect() - }) - .unwrap_or_default(); - check(&pins)?; - return Ok(c); - } - let Some(pins) = cell_pins(name) else { - return Err(EditsError::new( - codes::UNKNOWN_CELL, - "cell", - format!("no cell '{}'", names::for_display(name)), - )); - }; - check(&pins)?; - nl.add_cell_type(name, &pins) - .map_err(|e| netlist_error(codes::UNKNOWN_CELL, "cell", &e)) -} - -/// The input and output cell pin indices of a buffer cell: exactly two pins, one input and one -/// output (WS-REQ-0147). -fn buffer_pins(pins: &[(String, PinDir)]) -> Option<(u32, u32)> { - if pins.len() != 2 { - return None; - } - let i = pins.iter().position(|p| p.1 == PinDir::Input)?; - let o = pins.iter().position(|p| p.1 == PinDir::Output)?; - Some((i as u32, o as u32)) -} - -/// Resolves the sinks of `net` (WS-REQ-0147, WS-REQ-0150): every pin sink is an input pin -/// connected to `net`; a port sink is an output port whose bit is `net` itself. Returns the pins -/// and whether a port is among the sinks. -fn resolve_sinks( - nl: &GateNetlist, - net: NetId, - sinks: &[Sink], -) -> Result<(Vec, bool), EditsError> { - let mut pins = Vec::with_capacity(sinks.len()); - let mut port = false; - for (k, s) in sinks.iter().enumerate() { - let at = format!("sinks[{k}]"); - match s { - Sink::Pin { instance, pin } => { - let inst = find_instance(nl, instance, &at)?; - let p = find_pin(nl, inst, pin, &at)?; - if nl.pin(p).ok().and_then(|x| x.conn) != Some(Conn::Net(net)) { - return Err(EditsError::new( - codes::SINK, - at, - format!( - "pin '{}/{}' is not on the net", - names::for_display(instance), - names::for_display(pin) - ), - )); - } - if pin_dir(nl, p) != PinDir::Input { - return Err(EditsError::new( - codes::SINK, - at, - format!( - "pin '{}/{}' is not an input pin", - names::for_display(instance), - names::for_display(pin) - ), - )); - } - if pins.contains(&p) { - return Err(EditsError::new(codes::SINK, at, "the sink is listed twice")); - } - pins.push(p); - } - Sink::Port { port: name } => { - let Some((pnet, dir)) = port_bit_net(nl, name) else { - return Err(EditsError::new( - codes::UNKNOWN_PIN, - at, - format!("no port bit '{}'", names::for_display(name)), - )); - }; - if pnet != net { - return Err(EditsError::new( - codes::SINK, - at, - format!("port '{}' is not on the net", names::for_display(name)), - )); - } - if dir != PortDir::Output { - return Err(EditsError::new( - codes::SINK, - at, - format!("port '{}' is not an output port", names::for_display(name)), - )); - } - if port { - return Err(EditsError::new(codes::SINK, at, "the sink is listed twice")); - } - port = true; - } - } - } - Ok((pins, port)) -} - -/// Adds the net `name`, or reports why its name cannot be taken. -fn add_named_net(nl: &mut GateNetlist, name: &str, at: &str) -> Result { - nl.add_net(Some(name), None) - .map_err(|e| netlist_error(codes::NAME_IN_USE, at, &e)) -} - -/// `resize` (WS-REQ-0146): `swap_cell` of the instance to the cell. -fn resize( - nl: &mut GateNetlist, - instance: &str, - cell: &str, - cell_pins: &dyn Fn(&str) -> Option>, -) -> Result { - let inst = find_instance(nl, instance, "instance")?; - let old: Vec<(String, PinDir)> = { - let i = nl - .instance(inst) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - let ct = nl - .cell(i.cell) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - ct.pins - .iter() - .map(|p| (nl.symbols().resolve(p.name).to_string(), p.dir)) - .collect() - }; - let compatible = |pins: &[(String, PinDir)]| { - let mut a = old.clone(); - let mut b = pins.to_vec(); - a.sort_by(|x, y| x.0.cmp(&y.0)); - b.sort_by(|x, y| x.0.cmp(&y.0)); - if a == b { - Ok(()) - } else { - Err(EditsError::new( - codes::NETLIST, - "cell", - format!( - "cell '{}' does not have the pins of instance '{}'", - names::for_display(cell), - names::for_display(instance) - ), - )) - } - }; - let c = cell_id(nl, cell, cell_pins, &compatible)?; - nl.swap_cell(inst, c) - .map_err(|e| netlist_error(codes::NETLIST, "cell", &e))?; - let mut applied = Applied::default(); - applied.inst(inst); - Ok(applied) -} - -/// `insert_buffer` (WS-REQ-0147). -#[allow(clippy::too_many_arguments)] -fn insert_buffer( - nl: &mut GateNetlist, - net: &str, - cell: &str, - instance: &str, - net_out: &str, - sinks: &[Sink], - cell_pins: &dyn Fn(&str) -> Option>, -) -> Result { - let n = find_net(nl, net)?; - free_instance_name(nl, instance, "instance")?; - free_net_name(nl, net_out, "net_out")?; - let (pins, port) = resolve_sinks(nl, n, sinks)?; - let is_buffer = |pins: &[(String, PinDir)]| match buffer_pins(pins) { - Some(_) => Ok(()), - None => Err(EditsError::new( - codes::BUFFER, - "cell", - format!( - "cell '{}' does not have exactly one input and one output pin", - names::for_display(cell) - ), - )), - }; - let c = cell_id(nl, cell, cell_pins, &is_buffer)?; - let pin_list: Vec<(String, PinDir)> = nl - .cell(c) - .map(|ct| { - ct.pins - .iter() - .map(|p| (nl.symbols().resolve(p.name).to_string(), p.dir)) - .collect() - }) - .unwrap_or_default(); - let Some((k_in, k_out)) = buffer_pins(&pin_list) else { - return Err(EditsError::new(codes::BUFFER, "cell", "not a buffer cell")); - }; - let mut applied = Applied::default(); - if !port { - let (buf, out) = nl - .insert_buffer(n, &pins, c, k_in, k_out, instance, Some(net_out)) - .map_err(|e| { - let code = match e { - NetlistError::NetConflict { .. } | NetlistError::DuplicateName { .. } => { - codes::NAME_IN_USE - } - NetlistError::InvalidName { .. } | NetlistError::EmptyName { .. } => { - codes::NAME - } - _ => codes::NETLIST, - }; - netlist_error(code, "net_out", &e) - })?; - applied.inst(buf); - for &p in &pins { - applied.pin_owner(nl, p); - } - applied.net(n); - applied.net(out); - return Ok(applied); - } - // A port bit cannot change nets: the buffer drives `net`, and the pins that stay with the - // driver move to the new net (WS-REQ-0147). - let m = add_named_net(nl, net_out, "net_out")?; - let buf = match nl.add_instance(instance, c) { - Ok(b) => b, - Err(e) => { - let _ = nl.remove_net(m); - return Err(netlist_error(codes::NAME_IN_USE, "instance", &e)); - } - }; - let stay: Vec = nl - .net(n) - .map(|x| { - x.pins - .iter() - .copied() - .filter(|p| !pins.contains(p)) - .collect() - }) - .unwrap_or_default(); - let (pin_in, pin_out) = { - let b = nl - .instance(buf) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - (b.pins[k_in as usize], b.pins[k_out as usize]) - }; - for &p in &stay { - nl.move_pin(p, Conn::Net(m)) - .map_err(|e| netlist_error(codes::NETLIST, "net", &e))?; - applied.pin_owner(nl, p); - } - nl.connect_pin(pin_in, Conn::Net(m)) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - nl.connect_pin(pin_out, Conn::Net(n)) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - applied.inst(buf); - applied.net(n); - applied.net(m); - Ok(applied) -} - -/// The connected input and output pins of a buffer instance and their nets. -struct BufferNets { - pin_in: PinId, - pin_out: PinId, - a: NetId, - b: NetId, -} - -fn buffer_nets(nl: &GateNetlist, inst: InstId, instance: &str) -> Result { - let reject = |why: &str| { - EditsError::new( - codes::BUFFER, - "instance", - format!("instance '{}' {why}", names::for_display(instance)), - ) - }; - let i = nl - .instance(inst) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - let ct = nl - .cell(i.cell) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - let pins: Vec<(String, PinDir)> = ct.pins.iter().map(|p| (String::new(), p.dir)).collect(); - let Some((k_in, k_out)) = buffer_pins(&pins) else { - return Err(reject( - "is not an instance of a cell with one input and one output pin", - )); - }; - let (pin_in, pin_out) = (i.pins[k_in as usize], i.pins[k_out as usize]); - let conn = |p: PinId| nl.pin(p).ok().and_then(|x| x.conn); - let (Some(Conn::Net(a)), Some(Conn::Net(b))) = (conn(pin_in), conn(pin_out)) else { - return Err(reject("does not connect its input and its output to nets")); - }; - if a == b { - return Err(reject("connects its input and its output to the same net")); - } - Ok(BufferNets { - pin_in, - pin_out, - a, - b, - }) -} - -/// `remove_buffer` (WS-REQ-0148). -fn remove_buffer(nl: &mut GateNetlist, instance: &str) -> Result { - let inst = find_instance(nl, instance, "instance")?; - let BufferNets { - pin_in, - pin_out, - a, - b, - } = buffer_nets(nl, inst, instance)?; - let mut applied = Applied::default(); - let others: Vec = nl - .net(b) - .map(|x| x.pins.iter().copied().filter(|&p| p != pin_out).collect()) - .unwrap_or_default(); - if others.iter().any(|&p| pin_dir(nl, p) != PinDir::Input) { - return Err(EditsError::new( - codes::BUFFER, - "instance", - format!( - "another pin than the output of '{}' drives its output net", - names::for_display(instance) - ), - )); - } - if !net_in_use(nl, b) { - let kept = nl - .remove_buffer(inst) - .map_err(|e| netlist_error(codes::BUFFER, "instance", &e))?; - for &p in &others { - applied.pin_owner(nl, p); - } - applied.net(kept); - return Ok(applied); - } - if net_in_use(nl, a) { - return Err(EditsError::new( - codes::BUFFER, - "instance", - format!( - "both nets of '{}' are bits of ports or appear in assignments", - names::for_display(instance) - ), - )); - } - // The output net is a port bit, which cannot be removed: the pins of the input net move to - // it, and the input net goes (WS-REQ-0148). - let moved: Vec = nl - .net(a) - .map(|x| x.pins.iter().copied().filter(|&p| p != pin_in).collect()) - .unwrap_or_default(); - for &p in &moved { - nl.move_pin(p, Conn::Net(b)) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - applied.pin_owner(nl, p); - } - nl.remove_instance(inst) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - nl.remove_net(a) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - applied.net(b); - Ok(applied) -} - -/// `swap_pins` (WS-REQ-0149). -fn swap_pins( - nl: &mut GateNetlist, - instance: &str, - pin_a: &str, - pin_b: &str, -) -> Result { - let inst = find_instance(nl, instance, "instance")?; - if pin_a == pin_b { - return Err(EditsError::new( - codes::PIN_SWAP, - "pin_b", - "the two pins of a pin swap are the same pin", - )); - } - let pa = find_pin(nl, inst, pin_a, "pin_a")?; - let pb = find_pin(nl, inst, pin_b, "pin_b")?; - for (p, name, at) in [(pa, pin_a, "pin_a"), (pb, pin_b, "pin_b")] { - if pin_dir(nl, p) != PinDir::Input { - return Err(EditsError::new( - codes::PIN_SWAP, - at, - format!("pin '{}' is not an input pin", names::for_display(name)), - )); - } - } - let conn = |p: PinId| nl.pin(p).ok().and_then(|x| x.conn); - let (Some(ca), Some(cb)) = (conn(pa), conn(pb)) else { - return Err(EditsError::new( - codes::PIN_SWAP, - "instance", - "both pins of a pin swap must be connected", - )); - }; - let mut applied = Applied::default(); - applied.inst(inst); - if ca != cb { - nl.move_pin(pa, cb) - .map_err(|e| netlist_error(codes::NETLIST, "pin_a", &e))?; - nl.move_pin(pb, ca) - .map_err(|e| netlist_error(codes::NETLIST, "pin_b", &e))?; - for c in [ca, cb] { - if let Conn::Net(n) = c { - applied.net(n); - } - } - } - Ok(applied) -} - -/// `clone` (WS-REQ-0150). -fn clone( - nl: &mut GateNetlist, - instance: &str, - new_instance: &str, - new_net: &str, - sinks: &[Sink], -) -> Result { - let inst = find_instance(nl, instance, "instance")?; - free_instance_name(nl, new_instance, "new_instance")?; - free_net_name(nl, new_net, "new_net")?; - let (cell, connected, out_pin) = { - let i = nl - .instance(inst) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - let ct = nl - .cell(i.cell) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - let outs: Vec = ct - .pins - .iter() - .enumerate() - .filter(|(_, p)| p.dir.drives()) - .map(|(k, _)| k) - .collect(); - if outs.len() != 1 || ct.pins[outs[0]].dir != PinDir::Output { - return Err(EditsError::new( - codes::CLONE, - "instance", - format!( - "instance '{}' does not have exactly one output pin and no inout pin", - names::for_display(instance) - ), - )); - } - (i.cell, i.connected.clone(), i.pins[outs[0]]) - }; - let Some(Conn::Net(n)) = nl.pin(out_pin).ok().and_then(|p| p.conn) else { - return Err(EditsError::new( - codes::CLONE, - "instance", - format!( - "the output pin of '{}' is not on a net", - names::for_display(instance) - ), - )); - }; - let (pins, port) = resolve_sinks(nl, n, sinks)?; - let m = add_named_net(nl, new_net, "new_net")?; - let k = match nl.add_instance(new_instance, cell) { - Ok(k) => k, - Err(e) => { - let _ = nl.remove_net(m); - return Err(netlist_error(codes::NAME_IN_USE, "new_instance", &e)); - } - }; - let stay: Vec = if port { - nl.net(n) - .map(|x| { - x.pins - .iter() - .copied() - .filter(|p| !pins.contains(p)) - .collect() - }) - .unwrap_or_default() - } else { - Vec::new() - }; - let mut applied = Applied::default(); - for &p in &connected { - let (cell_pin, conn) = match nl.pin(p) { - Ok(x) => (x.cell_pin, x.conn), - Err(e) => return Err(netlist_error(codes::NETLIST, "instance", &e)), - }; - let target = if p == out_pin { - Conn::Net(if port { n } else { m }) - } else { - match conn { - Some(c) => c, - None => continue, - } - }; - let q = nl - .find_pin(k, cell_pin) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - nl.connect_pin(q, target) - .map_err(|e| netlist_error(codes::NETLIST, "instance", &e))?; - if let Conn::Net(x) = target { - applied.net(x); - } - } - applied.inst(k); - let moved = if port { stay } else { pins }; - for &p in &moved { - nl.move_pin(p, Conn::Net(m)) - .map_err(|e| netlist_error(codes::NETLIST, "sinks", &e))?; - applied.pin_owner(nl, p); - } - applied.net(n); - applied.net(m); - Ok(applied) -} diff --git a/crates/core/rusteda-workspace/src/edits/tests.rs b/crates/core/rusteda-workspace/src/edits/tests.rs deleted file mode 100644 index cad87b2..0000000 --- a/crates/core/rusteda-workspace/src/edits/tests.rs +++ /dev/null @@ -1,602 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Tests of edit files and of the application of edits: each edit is compared with the netlist -//! that the operations of its specification give when a test performs them by hand. - -use rusteda_core::netlist::{Conn, GateNetlist, PinDir, PortDir}; - -use super::*; - -const BUF: [(&str, PinDir); 2] = [("A", PinDir::Input), ("Y", PinDir::Output)]; -const NAND: [(&str, PinDir); 3] = [ - ("A", PinDir::Input), - ("B", PinDir::Input), - ("Y", PinDir::Output), -]; - -/// Pins of the cells the tests know, in library order. -fn cells(name: &str) -> Option> { - let pins: &[(&str, PinDir)] = match name { - "buf1" | "buf2" | "inv1" => &BUF, - "nand2" | "nand2b" => &NAND, - _ => return None, - }; - Some(pins.iter().map(|(n, d)| ((*n).to_string(), *d)).collect()) -} - -/// `in0 -> u1 (nand2 A,B=in1) -> n1 -> {u2.A, u3.A, out0}`; `u2 -> n2 -> out1`; `u3 -> n3`. -fn netlist() -> GateNetlist { - let mut g = GateNetlist::new("top").unwrap(); - let nand = g.add_cell_type("nand2", &NAND).unwrap(); - let buf = g.add_cell_type("buf1", &BUF).unwrap(); - for (p, d) in [ - ("in0", PortDir::Input), - ("in1", PortDir::Input), - ("out0", PortDir::Output), - ("out1", PortDir::Output), - ] { - g.add_port(p, d, None).unwrap(); - } - let net = |g: &GateNetlist, n: &str| g.find_net(n, None).unwrap(); - let u1 = g.add_instance("u1", nand).unwrap(); - g.connect(u1, "A", Conn::Net(net(&g, "in0"))).unwrap(); - g.connect(u1, "B", Conn::Net(net(&g, "in1"))).unwrap(); - g.connect(u1, "Y", Conn::Net(net(&g, "out0"))).unwrap(); - let u2 = g.add_instance("u2", buf).unwrap(); - g.connect(u2, "A", Conn::Net(net(&g, "out0"))).unwrap(); - g.connect(u2, "Y", Conn::Net(net(&g, "out1"))).unwrap(); - let n3 = g.add_net(Some("n3"), None).unwrap(); - let u3 = g.add_instance("u3", buf).unwrap(); - g.connect(u3, "A", Conn::Net(net(&g, "out0"))).unwrap(); - g.connect(u3, "Y", Conn::Net(n3)).unwrap(); - g -} - -fn pin_sink(i: &str, p: &str) -> Sink { - Sink::Pin { - instance: i.into(), - pin: p.into(), - } -} - -fn run(g: &mut GateNetlist, e: &Edit) -> Result { - apply(g, e, &cells) -} - -const FILE: &str = r#"{ - "schema": "rusteda.edits.v1", - "base": [{"op": "resize", "instance": "u2", "cell": "buf2"}], - "candidates": [ - {"id": "c-1", "edits": [ - {"op": "insert_buffer", "net": "out0", "cell": "buf1", "instance": "b1", - "net_out": "nb1", "sinks": [{"instance": "u3", "pin": "A"}, {"port": "out0"}]}, - {"op": "swap_pins", "instance": "u1", "pin_a": "A", "pin_b": "B"}]}, - {"id": "c_2", "edits": [ - {"op": "clone", "instance": "u1", "new_instance": "u1c", "new_net": "n1c", - "sinks": [{"instance": "u2", "pin": "A"}]}, - {"op": "remove_buffer", "instance": "u3"}]} - ], - "report": {"endpoints": ["out1", "ep3"], "paths": ["path1"]} -}"#; - -// WS-REQ-0140, WS-REQ-0142, WS-REQ-0143, WS-REQ-0145: a file with every edit kind reads back -// from its canonical text unchanged, and the canonical text is a fixed point. -#[test] -fn edit_file_round_trips_through_canonical_json() { - let f = parse(FILE, &Limits::default()).unwrap(); - assert_eq!(f.base.len(), 1); - assert_eq!(f.candidates.len(), 2); - assert_eq!(f.candidates[0].id, "c-1"); - assert_eq!( - f.candidates[1] - .edits - .iter() - .map(Edit::op) - .collect::>(), - ["clone", "remove_buffer"] - ); - assert_eq!(f.report.endpoints, ["out1", "ep3"]); - assert_eq!(f.report.paths, ["path1"]); - let text = f.to_json(); - assert!(text.starts_with("{\n \"base\"")); - assert!(text.ends_with("}\n")); - let again = parse(&text, &Limits::default()).unwrap(); - assert_eq!(again, f); - assert_eq!(again.to_json(), text); - let empty = parse(r#"{"schema": "rusteda.edits.v1"}"#, &Limits::default()).unwrap(); - assert_eq!(empty, EditFile::default()); -} - -fn code_of(text: &str, limits: &Limits) -> (String, String) { - let e = parse(text, limits).unwrap_err(); - (e.code.as_str().to_string(), e.at) -} - -// WS-REQ-0141, WS-REQ-0144: every malformed file is rejected with its code and the JSON path of -// the value, before any edit is applied; nothing unknown is ignored. -#[test] -fn malformed_files_are_rejected_with_codes() { - let l = Limits::default(); - let wrap = |edits: &str| { - format!( - r#"{{"schema": "rusteda.edits.v1", "candidates": [{{"id": "c", "edits": [{edits}]}}]}}"# - ) - }; - let cases: Vec<(String, &str, &str)> = vec![ - ("not json".into(), "W0300", ""), - (r#"{"base": []}"#.into(), "W0301", "schema"), - (r#"{"schema": "rusteda.edits.v2"}"#.into(), "W0301", "schema"), - ( - r#"{"schema": "rusteda.edits.v1", "extra": 1}"#.into(), - "W0300", - "extra", - ), - ( - wrap(r#"{"op": "resize", "instance": "u1", "cell": "b", "x": 1}"#), - "W0300", - "candidates[0].edits[0].x", - ), - ( - wrap(r#"{"op": "grow", "instance": "u1"}"#), - "W0300", - "candidates[0].edits[0].op", - ), - ( - wrap(r#"{"op": "resize", "instance": "a//b", "cell": "b"}"#), - "W0302", - "candidates[0].edits[0].instance", - ), - ( - wrap(r#"{"op": "resize", "instance": "u1", "cell": "b c"}"#), - "W0302", - "candidates[0].edits[0].cell", - ), - ( - wrap( - r#"{"op": "clone", "instance": "u1", "new_instance": "k", "new_net": "m", "sinks": []}"#, - ), - "W0310", - "candidates[0].edits[0].sinks", - ), - ( - wrap( - r#"{"op": "clone", "instance": "u1", "new_instance": "k", "new_net": "m", "sinks": [{"port": "o"}, {"port": "o"}]}"#, - ), - "W0310", - "candidates[0].edits[0].sinks[1]", - ), - ( - r#"{"schema": "rusteda.edits.v1", "candidates": [{"id": "a b", "edits": []}]}"# - .into(), - "W0303", - "candidates[0].id", - ), - ( - r#"{"schema": "rusteda.edits.v1", "candidates": [{"id": "a", "edits": []}, {"id": "a", "edits": []}]}"# - .into(), - "W0303", - "candidates[1].id", - ), - ( - r#"{"schema": "rusteda.edits.v1", "report": {"paths": ["path0"]}}"#.into(), - "W0302", - "report.paths[0]", - ), - ( - r#"{"schema": "rusteda.edits.v1", "report": {"endpoints": [3]}}"#.into(), - "W0300", - "report.endpoints[0]", - ), - ]; - for (text, code, at) in &cases { - assert_eq!( - code_of(text, &l), - (code.to_string(), at.to_string()), - "{text}" - ); - } - let small = Limits { - max_candidates: 1, - max_edits: 1, - max_report: 1, - max_bytes: 4096, - max_sinks: 1, - }; - let two_candidates = r#"{"schema": "rusteda.edits.v1", "candidates": [{"id": "a", "edits": []}, {"id": "b", "edits": []}]}"#; - assert_eq!(code_of(two_candidates, &small).0, "W0304"); - let two_edits = r#"{"schema": "rusteda.edits.v1", "base": [{"op": "remove_buffer", "instance": "a"}, {"op": "remove_buffer", "instance": "b"}]}"#; - assert_eq!(code_of(two_edits, &small), ("W0304".into(), "base".into())); - let long = format!( - r#"{{"schema": "rusteda.edits.v1", "x": "{}"}}"#, - "y".repeat(5000) - ); - assert_eq!(code_of(&long, &small).0, "W0304"); - assert!(is_numbered_handle("path12", "path") && !is_numbered_handle("path012", "path")); -} - -// WS-REQ-0141, WS-REQ-0146: `resize` is `swap_cell` to a pin-compatible cell, which is added from -// the cell supplier when the netlist lacks it; an unknown or incompatible cell changes nothing. -#[test] -fn resize_is_a_cell_swap() { - let mut g = netlist(); - let applied = run( - &mut g, - &Edit::Resize { - instance: "u2".into(), - cell: "buf2".into(), - }, - ) - .unwrap(); - let mut want = netlist(); - let buf2 = want.add_cell_type("buf2", &BUF).unwrap(); - let u2 = want.find_instance("u2").unwrap(); - want.swap_cell(u2, buf2).unwrap(); - assert_eq!(g.freeze(), want.freeze()); - assert_eq!(applied.instances, [u2]); - let before = g.freeze(); - for (cell, code) in [("nand2", "W0314"), ("nope", "W0308")] { - let e = run( - &mut g, - &Edit::Resize { - instance: "u2".into(), - cell: cell.into(), - }, - ) - .unwrap_err(); - assert_eq!(e.code.as_str(), code); - assert_eq!(g.freeze(), before); - } - let e = run( - &mut g, - &Edit::Resize { - instance: "zz".into(), - cell: "buf1".into(), - }, - ) - .unwrap_err(); - assert_eq!(e.code.as_str(), "W0305"); -} - -// WS-REQ-0147: `insert_buffer` without a port sink is the netlist's buffer insertion; with a port -// sink the port's net keeps its name and its listed sinks, and the driver with the other pins -// moves to the new net, ahead of the buffer input. -#[test] -fn insert_buffer_with_pin_and_port_sinks() { - let mut g = netlist(); - run( - &mut g, - &Edit::InsertBuffer { - net: "out0".into(), - cell: "buf1".into(), - instance: "b1".into(), - net_out: "nb1".into(), - sinks: vec![pin_sink("u3", "A")], - }, - ) - .unwrap(); - let mut want = netlist(); - let buf = want.find_cell("buf1").unwrap(); - let n = want.find_net("out0", None).unwrap(); - let u3 = want.find_instance("u3").unwrap(); - let u3a = want.find_pin(u3, "A").unwrap(); - want.insert_buffer(n, &[u3a], buf, "A", "Y", "b1", Some("nb1")) - .unwrap(); - assert_eq!(g.freeze(), want.freeze()); - - let mut g = netlist(); - run( - &mut g, - &Edit::InsertBuffer { - net: "out0".into(), - cell: "buf1".into(), - instance: "b2".into(), - net_out: "nb2".into(), - sinks: vec![ - Sink::Port { - port: "out0".into(), - }, - pin_sink("u2", "A"), - ], - }, - ) - .unwrap(); - let mut want = netlist(); - let n = want.find_net("out0", None).unwrap(); - let m = want.add_net(Some("nb2"), None).unwrap(); - let b2 = want.add_instance("b2", buf).unwrap(); - let u2a = { - let u2 = want.find_instance("u2").unwrap(); - want.find_pin(u2, "A").unwrap() - }; - let stay: Vec<_> = want - .net(n) - .unwrap() - .pins - .iter() - .copied() - .filter(|&p| p != u2a) - .collect(); - for p in stay { - want.move_pin(p, Conn::Net(m)).unwrap(); - } - want.connect(b2, "A", Conn::Net(m)).unwrap(); - want.connect(b2, "Y", Conn::Net(n)).unwrap(); - assert_eq!(g.freeze(), want.freeze()); - - // A sink off the net, an input port, an inverter-shaped three-pin cell and a taken name are - // all rejected without a change. - let before = g.freeze(); - let bad = [ - ( - Edit::InsertBuffer { - net: "in0".into(), - cell: "buf1".into(), - instance: "b9".into(), - net_out: "nb9".into(), - sinks: vec![pin_sink("u2", "A")], - }, - "W0310", - ), - ( - Edit::InsertBuffer { - net: "in0".into(), - cell: "buf1".into(), - instance: "b9".into(), - net_out: "nb9".into(), - sinks: vec![Sink::Port { port: "in0".into() }], - }, - "W0310", - ), - ( - Edit::InsertBuffer { - net: "nb2".into(), - cell: "nand2".into(), - instance: "b9".into(), - net_out: "nb9".into(), - sinks: vec![pin_sink("b2", "A")], - }, - "W0311", - ), - ( - Edit::InsertBuffer { - net: "nb2".into(), - cell: "buf1".into(), - instance: "b9".into(), - net_out: "n3".into(), - sinks: vec![pin_sink("b2", "A")], - }, - "W0309", - ), - ( - Edit::InsertBuffer { - net: "nb2".into(), - cell: "buf1".into(), - instance: "u1".into(), - net_out: "nb9".into(), - sinks: vec![pin_sink("b2", "A")], - }, - "W0309", - ), - ( - Edit::InsertBuffer { - net: "nope".into(), - cell: "buf1".into(), - instance: "b9".into(), - net_out: "nb9".into(), - sinks: vec![pin_sink("b2", "A")], - }, - "W0306", - ), - ]; - for (e, code) in &bad { - assert_eq!(run(&mut g, e).unwrap_err().code.as_str(), *code, "{e:?}"); - assert_eq!(g.freeze(), before); - } -} - -// WS-REQ-0148: `remove_buffer` merges the output net into the input net, or, when the output net -// is a port bit, the input net into the output net. -#[test] -fn remove_buffer_in_both_directions() { - let mut g = netlist(); - run( - &mut g, - &Edit::RemoveBuffer { - instance: "u3".into(), - }, - ) - .unwrap(); - let mut want = netlist(); - let u3 = want.find_instance("u3").unwrap(); - want.remove_buffer(u3).unwrap(); - assert_eq!(g.freeze(), want.freeze()); - - // `u2` drives the port bit `out1`: the pins of `out0` move to `out1`, which `u1` then drives. - let mut g = netlist(); - let e = run( - &mut g, - &Edit::RemoveBuffer { - instance: "u2".into(), - }, - ) - .unwrap_err(); - // `out0` is a port bit too: neither net can go. - assert_eq!(e.code.as_str(), "W0311"); - let mut g = netlist(); - run( - &mut g, - &Edit::InsertBuffer { - net: "out0".into(), - cell: "buf1".into(), - instance: "b2".into(), - net_out: "nb2".into(), - sinks: vec![Sink::Port { - port: "out0".into(), - }], - }, - ) - .unwrap(); - run( - &mut g, - &Edit::RemoveBuffer { - instance: "b2".into(), - }, - ) - .unwrap(); - let mut want = netlist(); - let n = want.find_net("out0", None).unwrap(); - let a = want.add_net(Some("nb2"), None).unwrap(); - let buf = want.find_cell("buf1").unwrap(); - let b2 = want.add_instance("b2", buf).unwrap(); - let stay: Vec<_> = want.net(n).unwrap().pins.clone(); - for &p in &stay { - want.move_pin(p, Conn::Net(a)).unwrap(); - } - want.connect(b2, "A", Conn::Net(a)).unwrap(); - want.connect(b2, "Y", Conn::Net(n)).unwrap(); - let b2a = want.find_pin(b2, "A").unwrap(); - let moved: Vec<_> = want - .net(a) - .unwrap() - .pins - .iter() - .copied() - .filter(|&p| p != b2a) - .collect(); - for &p in &moved { - want.move_pin(p, Conn::Net(n)).unwrap(); - } - want.remove_instance(b2).unwrap(); - want.remove_net(a).unwrap(); - assert_eq!(g.freeze(), want.freeze()); - let e = run( - &mut g, - &Edit::RemoveBuffer { - instance: "u1".into(), - }, - ) - .unwrap_err(); - assert_eq!(e.code.as_str(), "W0311"); -} - -// WS-REQ-0149: `swap_pins` moves each input pin to the other's connection, `pin_a` first; the -// pins keep their places in the instance's connection order. -#[test] -fn swap_pins_exchanges_connections() { - let mut g = netlist(); - run( - &mut g, - &Edit::SwapPins { - instance: "u1".into(), - pin_a: "A".into(), - pin_b: "B".into(), - }, - ) - .unwrap(); - let mut want = netlist(); - let u1 = want.find_instance("u1").unwrap(); - let (a, b) = ( - want.find_pin(u1, "A").unwrap(), - want.find_pin(u1, "B").unwrap(), - ); - let (in0, in1) = ( - want.find_net("in0", None).unwrap(), - want.find_net("in1", None).unwrap(), - ); - want.move_pin(a, Conn::Net(in1)).unwrap(); - want.move_pin(b, Conn::Net(in0)).unwrap(); - assert_eq!(g.freeze(), want.freeze()); - for (pa, pb, code) in [ - ("A", "A", "W0312"), - ("A", "Y", "W0312"), - ("A", "Q", "W0307"), - ] { - let e = run( - &mut g, - &Edit::SwapPins { - instance: "u1".into(), - pin_a: pa.into(), - pin_b: pb.into(), - }, - ) - .unwrap_err(); - assert_eq!(e.code.as_str(), code); - } -} - -// WS-REQ-0150: `clone` copies the instance with the connections of its pins in their order, its -// output on the new net, and moves the listed sinks there; with a port sink the copy drives the -// port's net and the original with the other pins moves to the new net. -#[test] -fn clone_with_pin_and_port_sinks() { - let mut g = netlist(); - let applied = run( - &mut g, - &Edit::Clone { - instance: "u1".into(), - new_instance: "u1c".into(), - new_net: "n1c".into(), - sinks: vec![pin_sink("u3", "A")], - }, - ) - .unwrap(); - let mut want = netlist(); - let nand = want.find_cell("nand2").unwrap(); - let n = want.find_net("out0", None).unwrap(); - let m = want.add_net(Some("n1c"), None).unwrap(); - let k = want.add_instance("u1c", nand).unwrap(); - for (p, net) in [("A", "in0"), ("B", "in1")] { - let nid = want.find_net(net, None).unwrap(); - want.connect(k, p, Conn::Net(nid)).unwrap(); - } - want.connect(k, "Y", Conn::Net(m)).unwrap(); - let u3a = { - let u3 = want.find_instance("u3").unwrap(); - want.find_pin(u3, "A").unwrap() - }; - want.move_pin(u3a, Conn::Net(m)).unwrap(); - assert_eq!(g.freeze(), want.freeze()); - assert!(applied.instances.contains(&g.find_instance("u1c").unwrap())); - - let mut g = netlist(); - run( - &mut g, - &Edit::Clone { - instance: "u1".into(), - new_instance: "u1c".into(), - new_net: "n1c".into(), - sinks: vec![Sink::Port { - port: "out0".into(), - }], - }, - ) - .unwrap(); - let mut want = netlist(); - let m = want.add_net(Some("n1c"), None).unwrap(); - let k = want.add_instance("u1c", nand).unwrap(); - let stay: Vec<_> = want.net(n).unwrap().pins.clone(); - for (p, net) in [("A", "in0"), ("B", "in1")] { - let nid = want.find_net(net, None).unwrap(); - want.connect(k, p, Conn::Net(nid)).unwrap(); - } - want.connect(k, "Y", Conn::Net(n)).unwrap(); - for p in stay { - want.move_pin(p, Conn::Net(m)).unwrap(); - } - assert_eq!(g.freeze(), want.freeze()); - - let e = run( - &mut g, - &Edit::Clone { - instance: "u1".into(), - new_instance: "u1d".into(), - new_net: "n1d".into(), - sinks: vec![pin_sink("u1", "A")], - }, - ) - .unwrap_err(); - assert_eq!(e.code.as_str(), "W0310"); - assert!(find_net(&g, "n1d").is_err()); - assert!(port_bit_net(&g, "out1").is_some() && port_bit_net(&g, "n3").is_none()); -} diff --git a/crates/core/rusteda-workspace/src/envelope.rs b/crates/core/rusteda-workspace/src/envelope.rs deleted file mode 100644 index 724a722..0000000 --- a/crates/core/rusteda-workspace/src/envelope.rs +++ /dev/null @@ -1,752 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The `rusteda.v1` envelope: exactly one per invocation on stdout in JSON mode, including -//! usage errors (WS-REQ-0090). -//! -//! Keys, in this order: `schema`, `ok`, `command`, `workspace`, `base_rev`, `rev`, `new_rev`, -//! `scope`, `completeness`, `metrics`, then the tool-specific fields in insertion order, then -//! `diagnostics`, `error` and `next_actions`. The standard keys are always present (`null`, -//! `{}` or `[]` when they do not apply). -//! -//! Nested values are kept as [`RawJson`], JSON text spliced into the output, which -//! [`crate::json::reformat`] then lays out (WS-REQ-0091, WS-REQ-0092). `serde_json` is built -//! without `preserve_order`, so the order of object keys survives only where the text is made -//! in order: -//! -//! - a struct serializes its fields in declaration order ([`RawJson::new`]); -//! - a [`JsonObject`] keeps its keys in insertion order, also for keys known only at run time -//! (per corner, per clock); -//! - [`RawJson::parse`] keeps the text it is given; -//! - [`JsonObject`] and [`RawJson`] implement `Serialize` in that order, so they keep it as -//! fields of a struct too (a per-corner slack inside each `top_paths` entry); their numbers -//! are then written from their values (`1.50` becomes `1.5`); -//! - but a map (`BTreeMap`, [`crate::JsonMap`], `serde_json::Value`, `json!`) serializes its -//! keys sorted, and a `HashMap` in an arbitrary order that differs from run to run. -//! -//! The parts the contract defines are checked when they are set: `scope`, `completeness` and -//! `metrics` are JSON objects ([`Envelope::set_scope`] and its siblings), no object of a value -//! has two equal keys, and every diagnostic code is a [`DiagCode`] (WS-REQ-0090, WS-REQ-0094). -//! -//! The pretty layout is the one of `serde_json::to_string_pretty`: every array element and -//! object member on its own line, and numbers as they were serialized (an `f64` of -14.210 -//! prints as `-14.21`). The envelope of plan Example 1 is illustrative in this respect: the -//! keys, their order and the values are binding, the whitespace is not (WS-REQ-0092). - -use std::fmt; -use std::io::{self, Write}; - -use serde::ser::Error as _; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use crate::error::{Error, ErrorCode, Result}; -use crate::json::{self, JsonStyle}; -use crate::rev::Rev; -use crate::text::shell_join; - -/// The envelope schema. -pub const SCHEMA: &str = "rusteda.v1"; - -/// The standard keys, in output order; tool-specific fields go between `metrics` and -/// `diagnostics`. -pub const STANDARD_KEYS: [&str; 13] = [ - "schema", - "ok", - "command", - "workspace", - "base_rev", - "rev", - "new_rev", - "scope", - "completeness", - "metrics", - "diagnostics", - "error", - "next_actions", -]; - -/// A JSON value kept as compact text, so that the order of object keys survives (WS-REQ-0091). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct RawJson(String); - -impl RawJson { - /// Serializes `value` with `serde_json`. A struct keeps its field order; a map does not - /// (its keys come out sorted, or for a `HashMap` in an arbitrary order), so build an object - /// whose key order matters with [`JsonObject`] (WS-REQ-0091). - /// An object with two equal keys (from a hand-written `Serialize`) is `INTERNAL`. - pub fn new(value: &T) -> Result { - let text = serde_json::to_string(value) - .map_err(|e| Error::internal(format!("serializing an envelope value: {e}")))?; - json::validate(&text).map_err(|e| Error::internal(format!("envelope value: {e}")))?; - Ok(RawJson(text)) - } - - /// Takes JSON text as it is (order kept), validated and compacted. Malformed text, or an - /// object with two equal keys, is `INTERNAL` (WS-REQ-0091). - pub fn parse(text: &str) -> Result { - json::reformat(text, JsonStyle::Compact) - .map(RawJson) - .map_err(|e| Error::internal(format!("envelope value: {e}"))) - } - - /// `null`. - pub fn null() -> RawJson { - RawJson("null".to_string()) - } - - /// `{}`. - pub fn empty_object() -> RawJson { - RawJson("{}".to_string()) - } - - /// `[]`. - pub fn empty_array() -> RawJson { - RawJson("[]".to_string()) - } - - /// The compact JSON text. - pub fn as_str(&self) -> &str { - &self.0 - } - - /// Whether this is `null`. - pub fn is_null(&self) -> bool { - self.0 == "null" - } - - /// Whether this is a JSON object. - pub fn is_object(&self) -> bool { - // The text is compact JSON, so an object starts with its brace. - self.0.starts_with('{') - } -} - -/// Serializes the value with its object keys in text order (WS-REQ-0091), so a `RawJson` field -/// of a serialized struct keeps the order of its keys. -impl Serialize for RawJson { - fn serialize(&self, serializer: S) -> std::result::Result { - json::parse(&self.0) - .map_err(S::Error::custom)? - .serialize(serializer) - } -} - -impl fmt::Display for RawJson { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -/// A JSON object built key by key that keeps the insertion order of its keys (WS-REQ-0091), -/// for objects whose keys are known only at run time, such as metrics per corner. -/// -/// ``` -/// use rusteda_workspace::{JsonObject, RawJson}; -/// -/// let mut metrics = JsonObject::new(); -/// metrics.insert("wns_ns", &-0.382)?.insert("tns_ns", &-14.21)?; -/// metrics.insert_raw("clock_domains", RawJson::parse(r#"["core_clk"]"#)?); -/// assert_eq!( -/// RawJson::from(metrics).as_str(), -/// r#"{"wns_ns":-0.382,"tns_ns":-14.21,"clock_domains":["core_clk"]}"# -/// ); -/// # Ok::<(), rusteda_workspace::Error>(()) -/// ``` -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct JsonObject { - fields: Vec<(String, RawJson)>, -} - -impl JsonObject { - /// An empty object. - pub fn new() -> JsonObject { - JsonObject::default() - } - - /// Sets `key` to the JSON of `value`. A new key goes after the existing ones; an existing - /// key keeps its place and gets the new value. - pub fn insert(&mut self, key: &str, value: &T) -> Result<&mut Self> { - Ok(self.insert_raw(key, RawJson::new(value)?)) - } - - /// Sets `key` to JSON text, like [`JsonObject::insert`]. - pub fn insert_raw(&mut self, key: &str, value: RawJson) -> &mut Self { - match self.fields.iter_mut().find(|(k, _)| k == key) { - Some((_, slot)) => *slot = value, - None => self.fields.push((key.to_string(), value)), - } - self - } - - /// The value of `key`. - pub fn get(&self, key: &str) -> Option<&RawJson> { - self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v) - } - - /// The number of keys. - pub fn len(&self) -> usize { - self.fields.len() - } - - /// Whether the object has no keys. - pub fn is_empty(&self) -> bool { - self.fields.is_empty() - } - - /// The object as compact JSON text, keys in insertion order. - pub fn to_raw(&self) -> RawJson { - let mut o = ObjectText::new(); - for (key, value) in &self.fields { - o.field(key, value.as_str()); - } - RawJson(o.finish()) - } -} - -/// Serializes the object with its keys in insertion order (WS-REQ-0091). -impl Serialize for JsonObject { - fn serialize(&self, serializer: S) -> std::result::Result { - use serde::ser::SerializeMap; - let mut map = serializer.serialize_map(Some(self.fields.len()))?; - for (key, value) in &self.fields { - map.serialize_entry(key, value)?; - } - map.end() - } -} - -impl From for RawJson { - fn from(object: JsonObject) -> RawJson { - object.to_raw() - } -} - -impl From<&JsonObject> for RawJson { - fn from(object: &JsonObject) -> RawJson { - object.to_raw() - } -} - -/// The severity of a diagnostic. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - /// `note`. - Note, - /// `warning`. - Warning, - /// `error`. - Error, -} - -impl Severity { - /// `note`, `warning` or `error`. - pub const fn as_str(self) -> &'static str { - match self { - Severity::Note => "note", - Severity::Warning => "warning", - Severity::Error => "error", - } - } - - /// Reads [`Severity::as_str`]. - pub fn parse(text: &str) -> Option { - [Severity::Note, Severity::Warning, Severity::Error] - .into_iter() - .find(|s| s.as_str() == text) - } -} - -impl fmt::Display for Severity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -/// Where a diagnostic points. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DiagLocation { - /// The file, as given. - pub file: String, - /// 1-based line. - pub line: u32, - /// 1-based column. - pub col: u32, -} - -/// A diagnostic code: one letter of the contract's areas and four digits (WS-REQ-0094). The -/// letter names the area: A application, B blif, C check, D sdc, G aiger, L liberty, S synth, -/// T timing, V verilog, W workspace (WS-REQ-0190). A `DiagCode` holds a valid code only, so every [`Diagnostic`] has -/// one, and its text line is one the shared diagnostic reader parses. -/// -/// ``` -/// use rusteda_workspace::DiagCode; -/// -/// const UNCONSTRAINED: DiagCode = DiagCode::of("T0007"); // checked at compile time -/// assert_eq!(DiagCode::parse("T0007")?, UNCONSTRAINED); -/// assert!(DiagCode::parse("timing").is_err()); -/// assert!(DiagCode::parse("X0001").is_err()); -/// # Ok::<(), rusteda_workspace::Error>(()) -/// ``` -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct DiagCode([u8; 5]); - -impl DiagCode { - /// The area letters of the contract (§7). - pub const LETTERS: &'static str = "ABCDGLSTVW"; - - const fn check(text: &[u8]) -> Option { - if text.len() != 5 { - return None; - } - let letters = DiagCode::LETTERS.as_bytes(); - let mut known = false; - let mut i = 0; - while i < letters.len() { - known |= letters[i] == text[0]; - i += 1; - } - let mut digits = true; - let mut i = 1; - while i < 5 { - digits &= text[i].is_ascii_digit(); - i += 1; - } - if known && digits { - Some(DiagCode([text[0], text[1], text[2], text[3], text[4]])) - } else { - None - } - } - - /// A code written in the source, e.g. `DiagCode::of("T0007")`. In a `const` item an invalid - /// code is a compile error. - /// - /// # Panics - /// - /// If `text` is not a letter of [`DiagCode::LETTERS`] and four digits; use - /// [`DiagCode::parse`] for text known only at run time. - pub const fn of(text: &'static str) -> DiagCode { - match DiagCode::check(text.as_bytes()) { - Some(code) => code, - None => panic!("a diagnostic code is a letter of ABCDGLSTVW and four digits"), - } - } - - /// Reads a code; anything but a letter of [`DiagCode::LETTERS`] and four digits is - /// `INTERNAL` (codes are fixed in the tools' sources). - pub fn parse(text: &str) -> Result { - DiagCode::check(text.as_bytes()).ok_or_else(|| { - Error::internal(format!( - "'{text}' is not a diagnostic code: a letter of {} and four digits", - DiagCode::LETTERS - )) - }) - } - - /// The code, e.g. `W0101`. - pub fn as_str(&self) -> &str { - // Five ASCII bytes, checked when the code was made. - std::str::from_utf8(&self.0).unwrap_or_default() - } - - /// The area letter. - pub const fn letter(self) -> char { - self.0[0] as char - } -} - -impl fmt::Display for DiagCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl fmt::Debug for DiagCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "DiagCode({})", self.as_str()) - } -} - -impl Serialize for DiagCode { - fn serialize(&self, serializer: S) -> std::result::Result { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> Deserialize<'de> for DiagCode { - fn deserialize>(deserializer: D) -> std::result::Result { - let text = String::deserialize(deserializer)?; - DiagCode::parse(&text).map_err(serde::de::Error::custom) - } -} - -/// A diagnostic of the envelope: `{severity, code, location, message, help}` with `location` -/// `{file, line, col}` or `null` (WS-REQ-0094). The code is a [`DiagCode`]: one letter and -/// four digits, the letter naming the area. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Diagnostic { - /// The severity. - pub severity: Severity, - /// The code, e.g. `W0101`. - pub code: DiagCode, - /// The location, if any. - pub location: Option, - /// The message. - pub message: String, - /// A hint, if any. - pub help: Option, -} - -impl Diagnostic { - /// A diagnostic without location or help. - pub fn new(severity: Severity, code: DiagCode, message: impl Into) -> Diagnostic { - Diagnostic { - severity, - code, - location: None, - message: message.into(), - help: None, - } - } - - /// A diagnostic from the parts of a coded message: severity, code, optional file/line/column, - /// message and optional help. A code given as text is checked with [`DiagCode::parse`]. - pub fn from_parts( - severity: Severity, - code: DiagCode, - location: Option<(String, u32, u32)>, - message: impl Into, - help: Option, - ) -> Diagnostic { - Diagnostic { - severity, - code, - location: location.map(|(file, line, col)| DiagLocation { file, line, col }), - message: message.into(), - help, - } - } - - /// Sets the location. - pub fn at(mut self, file: impl Into, line: u32, col: u32) -> Diagnostic { - self.location = Some(DiagLocation { - file: file.into(), - line, - col, - }); - self - } - - /// Sets the help. - pub fn with_help(mut self, help: impl Into) -> Diagnostic { - self.help = Some(help.into()); - self - } - - /// The diagnostic as compact JSON. - pub fn to_json(&self) -> String { - let location = match &self.location { - Some(l) => { - let mut o = ObjectText::new(); - o.field("file", &json::quote(&l.file)); - o.field("line", &l.line.to_string()); - o.field("col", &l.col.to_string()); - o.finish() - } - None => "null".to_string(), - }; - let mut o = ObjectText::new(); - o.field("severity", &json::quote(self.severity.as_str())); - o.field("code", &json::quote(self.code.as_str())); - o.field("location", &location); - o.field("message", &json::quote(&self.message)); - o.field( - "help", - &self.help.as_deref().map_or("null".to_string(), json::quote), - ); - o.finish() - } -} - -/// The envelope's `error`: `{code, message, details}`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ErrorInfo { - /// The error code. - pub code: ErrorCode, - /// The message. - pub message: String, - /// Structured details, or `null`. - pub details: RawJson, -} - -impl ErrorInfo { - /// An error without details. - pub fn new(code: ErrorCode, message: impl Into) -> ErrorInfo { - ErrorInfo { - code, - message: message.into(), - details: RawJson::null(), - } - } - - /// Sets the details. - pub fn with_details(mut self, details: RawJson) -> ErrorInfo { - self.details = details; - self - } - - /// The process exit code. - pub fn exit_code(&self) -> i32 { - self.code.exit_code() - } - - /// The error as compact JSON. - pub fn to_json(&self) -> String { - let mut o = ObjectText::new(); - o.field("code", &json::quote(self.code.as_str())); - o.field("message", &json::quote(&self.message)); - o.field("details", self.details.as_str()); - o.finish() - } -} - -impl From<&Error> for ErrorInfo { - fn from(error: &Error) -> ErrorInfo { - let details = RawJson::new(&error.details()).unwrap_or_else(|_| RawJson::null()); - ErrorInfo { - code: error.code(), - message: error.to_string(), - details, - } - } -} - -impl From for ErrorInfo { - fn from(error: Error) -> ErrorInfo { - ErrorInfo::from(&error) - } -} - -/// A `rusteda.v1` envelope. `ok` is derived: true unless `error` is set (a command that -/// completed with a negative verdict is `ok`; its exit code is the caller's choice, 3 or 5). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Envelope { - /// The command, e.g. `ruststa run`. - pub command: String, - /// The workspace as given with `-w`, if known. - pub workspace: Option, - /// The base revision of a mutation. - pub base_rev: Option, - /// The revision a query read. - pub rev: Option, - /// The revision a mutation created. - pub new_rev: Option, - scope: RawJson, - completeness: RawJson, - metrics: RawJson, - fields: Vec<(String, RawJson)>, - /// Diagnostics; each has a valid [`DiagCode`]. - pub diagnostics: Vec, - /// The error of a failed command. - pub error: Option, - /// Canonical follow-up commands, POSIX-quoted. - pub next_actions: Vec, -} - -impl Envelope { - /// An envelope of a completed command, every other key empty. - pub fn new(command: impl Into) -> Envelope { - Envelope { - command: command.into(), - workspace: None, - base_rev: None, - rev: None, - new_rev: None, - scope: RawJson::empty_object(), - completeness: RawJson::empty_object(), - metrics: RawJson::empty_object(), - fields: Vec::new(), - diagnostics: Vec::new(), - error: None, - next_actions: Vec::new(), - } - } - - /// An envelope of a failed command. - pub fn failure(command: impl Into, error: impl Into) -> Envelope { - let mut envelope = Envelope::new(command); - envelope.error = Some(error.into()); - envelope - } - - /// Whether the command completed: no error. - pub fn ok(&self) -> bool { - self.error.is_none() - } - - /// Sets the analysis scope, a JSON object (WS-REQ-0090); anything else is `INTERNAL`. - pub fn set_scope(&mut self, scope: impl Into) -> Result<()> { - self.scope = object("scope", scope.into())?; - Ok(()) - } - - /// Sets the completeness, a JSON object (WS-REQ-0090); anything else is `INTERNAL`. - pub fn set_completeness(&mut self, completeness: impl Into) -> Result<()> { - self.completeness = object("completeness", completeness.into())?; - Ok(()) - } - - /// Sets the metrics, a JSON object with units in its key names (`_ns`, `_pf`, `_um2`) - /// (WS-REQ-0090); anything else is `INTERNAL`. - pub fn set_metrics(&mut self, metrics: impl Into) -> Result<()> { - self.metrics = object("metrics", metrics.into())?; - Ok(()) - } - - /// The analysis scope; `{}` unless set. - pub fn scope(&self) -> &RawJson { - &self.scope - } - - /// The completeness; `{}` unless set. - pub fn completeness(&self) -> &RawJson { - &self.completeness - } - - /// The metrics; `{}` unless set. - pub fn metrics(&self) -> &RawJson { - &self.metrics - } - - /// Sets a tool-specific field. Keys are `[a-z][a-z0-9_]*` and not standard keys; a new key - /// goes after the existing ones, an existing key keeps its place. - pub fn set_raw(&mut self, key: &str, value: RawJson) -> Result<()> { - let valid = key.bytes().next().is_some_and(|b| b.is_ascii_lowercase()) - && key - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') - && !STANDARD_KEYS.contains(&key); - if !valid { - return Err(Error::internal(format!( - "envelope field '{key}' must match [a-z][a-z0-9_]* and not be a standard key" - ))); - } - match self.fields.iter_mut().find(|(k, _)| k == key) { - Some((_, slot)) => *slot = value, - None => self.fields.push((key.to_string(), value)), - } - Ok(()) - } - - /// Sets a tool-specific field from any serializable value (see [`Envelope::set_raw`]). - pub fn set(&mut self, key: &str, value: &T) -> Result<()> { - self.set_raw(key, RawJson::new(value)?) - } - - /// The tool-specific field `key`. - pub fn field(&self, key: &str) -> Option<&RawJson> { - self.fields.iter().find(|(k, _)| k == key).map(|(_, v)| v) - } - - /// The tool-specific fields, in insertion order. - pub fn fields(&self) -> &[(String, RawJson)] { - &self.fields - } - - /// Appends a next action from its words, POSIX-quoted (WS-REQ-0096). - pub fn push_next_action>(&mut self, words: &[S]) { - self.next_actions.push(shell_join(words)); - } - - /// The envelope as JSON text, without a final newline. - pub fn to_json(&self, style: JsonStyle) -> String { - let compact = self.compact(); - match style { - JsonStyle::Compact => compact, - // Every piece is valid JSON, so reformatting cannot fail. - JsonStyle::Pretty => { - json::reformat(&compact, JsonStyle::Pretty).unwrap_or_else(|_| compact.clone()) - } - } - } - - /// Writes the envelope and a newline. - pub fn write_to(&self, mut out: W, style: JsonStyle) -> io::Result<()> { - let mut text = self.to_json(style); - text.push('\n'); - out.write_all(text.as_bytes()) - } - - fn compact(&self) -> String { - let string = |s: Option<&str>| s.map_or("null".to_string(), json::quote); - let rev = |r: Option| r.map_or("null".to_string(), |r| json::quote(&r.to_string())); - let mut o = ObjectText::new(); - o.field("schema", &json::quote(SCHEMA)); - o.field("ok", if self.ok() { "true" } else { "false" }); - o.field("command", &json::quote(&self.command)); - o.field("workspace", &string(self.workspace.as_deref())); - o.field("base_rev", &rev(self.base_rev)); - o.field("rev", &rev(self.rev)); - o.field("new_rev", &rev(self.new_rev)); - o.field("scope", self.scope.as_str()); - o.field("completeness", self.completeness.as_str()); - o.field("metrics", self.metrics.as_str()); - for (key, value) in &self.fields { - o.field(key, value.as_str()); - } - let diagnostics: Vec = self.diagnostics.iter().map(Diagnostic::to_json).collect(); - o.field("diagnostics", &format!("[{}]", diagnostics.join(","))); - o.field( - "error", - &self - .error - .as_ref() - .map_or("null".to_string(), ErrorInfo::to_json), - ); - let actions: Vec = self.next_actions.iter().map(|a| json::quote(a)).collect(); - o.field("next_actions", &format!("[{}]", actions.join(","))); - o.finish() - } -} - -/// `value` if it is a JSON object, else `INTERNAL`: the contract makes `key` an object. -fn object(key: &str, value: RawJson) -> Result { - if value.is_object() { - return Ok(value); - } - Err(Error::internal(format!( - "the envelope's {key} is a JSON object, not {value}" - ))) -} - -/// Compact JSON object text from keys and value texts, in order. -struct ObjectText { - text: String, -} - -impl ObjectText { - fn new() -> ObjectText { - ObjectText { - text: "{".to_string(), - } - } - - fn field(&mut self, key: &str, value: &str) { - if self.text.len() > 1 { - self.text.push(','); - } - self.text.push_str(&json::quote(key)); - self.text.push(':'); - self.text.push_str(value); - } - - fn finish(mut self) -> String { - self.text.push('}'); - self.text - } -} - -#[cfg(test)] -#[path = "envelope_tests.rs"] -mod tests; diff --git a/crates/core/rusteda-workspace/src/envelope_tests.rs b/crates/core/rusteda-workspace/src/envelope_tests.rs deleted file mode 100644 index d0b6dd3..0000000 --- a/crates/core/rusteda-workspace/src/envelope_tests.rs +++ /dev/null @@ -1,522 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -use super::*; - -#[derive(Serialize)] -struct Path { - handle: String, - slack_ns: f64, - startpoint: String, -} - -#[derive(Serialize)] -struct Scope { - corner: &'static str, - wire_model: &'static str, - parasitics: &'static str, -} - -// WS-REQ-0090, WS-REQ-0091, WS-REQ-0092: the expected output of a full envelope. -#[test] -fn key_order_and_layout() { - let mut e = Envelope::new("ruststa run"); - e.workspace = Some("./alu_ws".into()); - e.base_rev = Some(Rev(1)); - e.new_rev = Some(Rev(2)); - e.set_scope( - RawJson::new(&Scope { - corner: "tt", - wire_model: "wireload_estimated", - parasitics: "none", - }) - .unwrap(), - ) - .unwrap(); - e.set_metrics(RawJson::parse(r#"{"wns_ns": -0.382, "tns_ns": -14.21}"#).unwrap()) - .unwrap(); - let path = Path { - handle: "path1".into(), - slack_ns: -0.382, - startpoint: "u/q[3]".into(), - }; - e.set("top_paths", &[path]).unwrap(); - e.set("truncated_paths", &36).unwrap(); - e.set("abc", &true).unwrap(); - e.set("top_paths", &Vec::::new()).unwrap(); - e.diagnostics.push( - Diagnostic::new( - Severity::Warning, - DiagCode::of("T0007"), - "3 endpoints are unconstrained", - ) - .at("top.sdc", 4, 1) - .with_help("add set_output_delay"), - ); - e.push_next_action(&[ - "ruststa", "path", "-w", "./alu_ws", "--rev", "rev2", "--target", "path1", - ]); - let expected = r#"{ - "schema": "rusteda.v1", - "ok": true, - "command": "ruststa run", - "workspace": "./alu_ws", - "base_rev": "rev1", - "rev": null, - "new_rev": "rev2", - "scope": { - "corner": "tt", - "wire_model": "wireload_estimated", - "parasitics": "none" - }, - "completeness": {}, - "metrics": { - "wns_ns": -0.382, - "tns_ns": -14.21 - }, - "top_paths": [], - "truncated_paths": 36, - "abc": true, - "diagnostics": [ - { - "severity": "warning", - "code": "T0007", - "location": { - "file": "top.sdc", - "line": 4, - "col": 1 - }, - "message": "3 endpoints are unconstrained", - "help": "add set_output_delay" - } - ], - "error": null, - "next_actions": [ - "ruststa path -w ./alu_ws --rev rev2 --target path1" - ] -}"#; - assert_eq!(e.to_json(JsonStyle::Pretty), expected); - assert_eq!( - e.to_json(JsonStyle::Compact), - reformat_compact(expected), - "compact is the same document without whitespace" - ); - let mut out = Vec::new(); - e.write_to(&mut out, JsonStyle::Compact).unwrap(); - assert!(out.ends_with(b"}\n")); -} - -fn reformat_compact(text: &str) -> String { - json::reformat(text, JsonStyle::Compact).unwrap() -} - -// WS-REQ-0090: standard keys are present even when empty, also for usage errors. -#[test] -fn failure_envelope() { - let e = Envelope::failure( - "rusteda", - ErrorInfo::new(ErrorCode::Usage, "unknown flag --x"), - ); - assert!(!e.ok()); - assert_eq!( - e.to_json(JsonStyle::Compact), - r#"{"schema":"rusteda.v1","ok":false,"command":"rusteda","workspace":null,"base_rev":null,"rev":null,"new_rev":null,"scope":{},"completeness":{},"metrics":{},"diagnostics":[],"error":{"code":"USAGE","message":"unknown flag --x","details":null},"next_actions":[]}"# - ); - let conflict = Error::RevisionConflict { - base: Rev(3), - head: Rev(4), - }; - let e = Envelope::failure("rusteda revert", &conflict); - let error = e.error.as_ref().unwrap(); - assert_eq!(error.exit_code(), 4); - assert_eq!( - error.details.as_str(), - r#"{"base_rev":"rev3","head":"rev4"}"# - ); - let text = e.to_json(JsonStyle::Pretty); - let value: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!(value["error"]["code"], "REVISION_CONFLICT"); - // Every standard key, in order. - let mut last = 0; - for key in STANDARD_KEYS { - let at = text - .find(&format!("\n \"{key}\": ")) - .unwrap_or_else(|| panic!("{key}")); - assert!(at > last || key == "schema", "{key}"); - last = at; - } -} - -#[test] -fn field_keys() { - let mut e = Envelope::new("x"); - for bad in [ - "", - "ok", - "metrics", - "next_actions", - "Top", - "a-b", - "1a", - "a b", - ] { - assert!(e.set(bad, &1).is_err(), "{bad}"); - } - e.set("a1_b", &1).unwrap(); - assert_eq!(e.field("a1_b").unwrap().as_str(), "1"); - assert_eq!(e.fields().len(), 1); - assert!(RawJson::parse("{").is_err()); - assert!(RawJson::null().is_null()); -} - -// WS-REQ-0091 (review CW-10): a JsonObject keeps the caller's key order, also for keys -// known only at run time, where a map sorts them. -#[test] -fn objects_keep_insertion_order() { - let mut scope = JsonObject::new(); - scope - .insert("corner", "tt_025C_1v80") - .unwrap() - .insert("wire_model", "wireload_estimated") - .unwrap() - .insert("parasitics", "none") - .unwrap() - .insert("drc_status", "unchecked") - .unwrap(); - assert_eq!( - scope.to_raw().as_str(), - r#"{"corner":"tt_025C_1v80","wire_model":"wireload_estimated","parasitics":"none","drc_status":"unchecked"}"# - ); - let map: crate::JsonMap = [("wire_model", "y"), ("corner", "x")] - .into_iter() - .map(|(k, v)| (k.to_string(), serde_json::Value::from(v))) - .collect(); - assert_eq!( - RawJson::new(&map).unwrap().as_str(), - r#"{"corner":"x","wire_model":"y"}"#, - "a map sorts its keys" - ); - - let mut per_corner = JsonObject::new(); - for (corner, wns) in [("tt", -0.1), ("ss", -0.5), ("ff", 0.2)] { - per_corner.insert(corner, &wns).unwrap(); - } - per_corner.insert("tt", &-0.15).unwrap(); - assert_eq!((per_corner.len(), per_corner.is_empty()), (3, false)); - assert_eq!(per_corner.get("ss").unwrap().as_str(), "-0.5"); - let mut e = Envelope::new("ruststa run"); - e.set_metrics(&per_corner).unwrap(); - assert!( - e.to_json(JsonStyle::Compact) - .contains(r#""metrics":{"tt":-0.15,"ss":-0.5,"ff":0.2}"#) - ); - assert_eq!(JsonObject::new().to_raw().as_str(), "{}"); - assert!(JsonObject::new().is_empty()); -} - -// WS-REQ-0092 (review CW-11): plan Example 1 is illustrative for whitespace and the text of -// numbers. Made from its values, the envelope has its keys, in its order, and its values, -// plus the keys the contract adds; the layout is the documented one: every array element -// on its own line, and an f64 printed as serde_json prints it. -#[test] -fn plan_example_1() { - #[derive(Serialize)] - struct Completeness { - constrained_endpoints: u64, - unconstrained_endpoints: u64, - clock_domains: Vec<&'static str>, - } - #[derive(Serialize)] - struct TopPath { - handle: &'static str, - slack_ns: f64, - startpoint: &'static str, - endpoint: &'static str, - logic_levels: u32, - cell_delay_pct: f64, - wire_delay_pct: f64, - } - let plan = r#"{ - "schema": "rusteda.v1", - "ok": true, - "command": "ruststa run", - "workspace": "./alu_ws", - "base_rev": "rev1", - "new_rev": "rev2", - "scope": { - "corner": "tt_025C_1v80", - "wire_model": "wireload_estimated", - "parasitics": "none", - "drc_status": "unchecked" - }, - "completeness": { - "constrained_endpoints": 1024, - "unconstrained_endpoints": 0, - "clock_domains": ["core_clk"] - }, - "metrics": { - "wns_ns": -0.382, - "tns_ns": -14.210, - "violating_endpoints": 38 - }, - "top_paths": [ - { - "handle": "path1", - "slack_ns": -0.382, - "startpoint": "u_alu/reg_a_q_reg[31]/CLK", - "endpoint": "u_mul/acc_reg[63]/D", - "logic_levels": 19, - "cell_delay_pct": 74.2, - "wire_delay_pct": 25.8 - }, - { - "handle": "path2", - "slack_ns": -0.315, - "startpoint": "u_dec/op_reg[4]/CLK", - "endpoint": "u_alu/cout_reg/D", - "logic_levels": 14, - "cell_delay_pct": 81.0, - "wire_delay_pct": 19.0 - } - ], - "truncated_paths": 36, - "next_actions": [ - "ruststa path -w ./alu_ws --rev rev2 --target path1 --format json", - "rustsys opt-timing -w ./alu_ws --base-rev rev2 --target path1 --format json" - ] -}"#; - let mut e = Envelope::new("ruststa run"); - e.workspace = Some("./alu_ws".into()); - e.base_rev = Some(Rev(1)); - e.new_rev = Some(Rev(2)); - let mut scope = JsonObject::new(); - for (key, value) in [ - ("corner", "tt_025C_1v80"), - ("wire_model", "wireload_estimated"), - ("parasitics", "none"), - ("drc_status", "unchecked"), - ] { - scope.insert(key, value).unwrap(); - } - e.set_scope(scope).unwrap(); - e.set_completeness( - RawJson::new(&Completeness { - constrained_endpoints: 1024, - unconstrained_endpoints: 0, - clock_domains: vec!["core_clk"], - }) - .unwrap(), - ) - .unwrap(); - let mut metrics = JsonObject::new(); - metrics - .insert("wns_ns", &-0.382) - .unwrap() - .insert("tns_ns", &-14.210) - .unwrap() - .insert("violating_endpoints", &38) - .unwrap(); - e.set_metrics(metrics).unwrap(); - let paths = [ - TopPath { - handle: "path1", - slack_ns: -0.382, - startpoint: "u_alu/reg_a_q_reg[31]/CLK", - endpoint: "u_mul/acc_reg[63]/D", - logic_levels: 19, - cell_delay_pct: 74.2, - wire_delay_pct: 25.8, - }, - TopPath { - handle: "path2", - slack_ns: -0.315, - startpoint: "u_dec/op_reg[4]/CLK", - endpoint: "u_alu/cout_reg/D", - logic_levels: 14, - cell_delay_pct: 81.0, - wire_delay_pct: 19.0, - }, - ]; - e.set("top_paths", &paths).unwrap(); - e.set("truncated_paths", &36).unwrap(); - e.next_actions = vec![ - "ruststa path -w ./alu_ws --rev rev2 --target path1 --format json".into(), - "rustsys opt-timing -w ./alu_ws --base-rev rev2 --target path1 --format json".into(), - ]; - let ours = e.to_json(JsonStyle::Pretty); - - let mut expected: serde_json::Value = serde_json::from_str(plan).unwrap(); - for (key, value) in [ - ("rev", serde_json::Value::Null), - ("diagnostics", serde_json::json!([])), - ("error", serde_json::Value::Null), - ] { - expected[key] = value; - } - let value: serde_json::Value = serde_json::from_str(&ours).unwrap(); - assert_eq!(value, expected, "the values of Example 1"); - let order = [ - "schema", - "ok", - "command", - "workspace", - "base_rev", - "rev", - "new_rev", - "scope", - "completeness", - "metrics", - "top_paths", - "truncated_paths", - "diagnostics", - "error", - "next_actions", - ]; - let at: Vec = order - .iter() - .map(|key| ours.find(&format!("\n \"{key}\": ")).unwrap_or(0)) - .collect(); - assert!(at.windows(2).all(|w| w[0] < w[1]), "{ours}"); - for nested in [ - "\"corner\": \"tt_025C_1v80\",\n \"wire_model\": \"wireload_estimated\",\n \ - \"parasitics\": \"none\",\n \"drc_status\": \"unchecked\"\n", - "\"wns_ns\": -0.382,\n \"tns_ns\": -14.21,\n \"violating_endpoints\": 38\n", - ] { - assert!(ours.contains(nested), "{nested}"); - } - // The documented layout: arrays one element per line, f64 text as serialized. - assert!(ours.contains("\"clock_domains\": [\n \"core_clk\"\n ]")); - assert!(!ours.contains("-14.210")); -} - -// WS-REQ-0094 -#[test] -fn diagnostic_json() { - let d = Diagnostic::from_parts( - Severity::Error, - DiagCode::parse("V0100").unwrap(), - Some(("a.v".into(), 3, 7)), - "expected ';'", - None, - ); - assert_eq!(d.to_json(), serde_json::to_string(&d).unwrap()); - assert_eq!( - d.to_json(), - r#"{"severity":"error","code":"V0100","location":{"file":"a.v","line":3,"col":7},"message":"expected ';'","help":null}"# - ); - let d = Diagnostic::new(Severity::Note, DiagCode::of("W0101"), "copied \"x\"").with_help("h"); - assert_eq!(d.to_json(), serde_json::to_string(&d).unwrap()); - assert_eq!(Severity::parse("warning"), Some(Severity::Warning)); - assert_eq!(Severity::parse("fatal"), None); -} - -// WS-REQ-0094 (review CR2-7), WS-REQ-0190: a diagnostic code is a letter of the contract's -// areas (BLIF and AIGER readers included) and four digits; nothing else becomes a DiagCode, so -// no envelope or text line carries one. -#[test] -fn diagnostic_codes_are_checked() { - assert_eq!(DiagCode::LETTERS, "ABCDGLSTVW"); - for good in [ - "A0003", "B0001", "C0001", "D0002", "G0010", "L0003", "S0004", "T0007", "V0100", "W0101", - ] { - let code = DiagCode::parse(good).unwrap(); - assert_eq!(code.as_str(), good); - assert_eq!(code.to_string(), good); - assert_eq!(code.letter(), good.chars().next().unwrap()); - assert_eq!(serde_json::to_string(&code).unwrap(), format!("\"{good}\"")); - } - for bad in [ - "timing", "", "W001", "W00011", "X0001", "w0001", "W00a1", "W 001", "0W001", "Wé01", - ] { - let err = DiagCode::parse(bad).unwrap_err(); - assert_eq!(err.code(), ErrorCode::Internal, "{bad}"); - } - // A diagnostic read back from JSON is checked too. - let text = - r#"{"severity":"warning","code":"timing","location":null,"message":"m","help":null}"#; - assert!(serde_json::from_str::(text).is_err()); - let ok = text.replace("timing", "T0001"); - let d: Diagnostic = serde_json::from_str(&ok).unwrap(); - assert_eq!(d.code, DiagCode::of("T0001")); -} - -// WS-REQ-0090, WS-REQ-0091 (review CR2-7): scope, completeness and metrics are JSON objects, -// and no value of the envelope has an object with two equal keys. -#[test] -fn contract_parts_are_checked() { - let mut e = Envelope::new("x"); - for bad in ["[1,2]", "1", "\"tt\"", "null", "true"] { - let raw = RawJson::parse(bad).unwrap(); - assert_eq!( - e.set_scope(raw.clone()).unwrap_err().code(), - ErrorCode::Internal - ); - assert!(e.set_completeness(raw.clone()).is_err(), "{bad}"); - assert!(e.set_metrics(raw).is_err(), "{bad}"); - } - assert_eq!(e.scope().as_str(), "{}"); - let err = RawJson::parse(r#"{"corner":"a","corner":"b"}"#).unwrap_err(); - assert_eq!(err.code(), ErrorCode::Internal); - assert!(err.to_string().contains("duplicate key"), "{err}"); - e.set_scope(RawJson::parse(r#"{"corner":"a"}"#).unwrap()) - .unwrap(); - e.set_metrics(JsonObject::new()).unwrap(); - assert_eq!(e.scope().as_str(), r#"{"corner":"a"}"#); - assert_eq!(e.metrics().as_str(), "{}"); - assert_eq!(e.completeness().as_str(), "{}"); - - // A hand-written Serialize that repeats a key is refused as well. - struct Twice; - impl Serialize for Twice { - fn serialize(&self, s: S) -> std::result::Result { - use serde::ser::SerializeMap; - let mut map = s.serialize_map(Some(2))?; - map.serialize_entry("k", &1)?; - map.serialize_entry("k", &2)?; - map.end() - } - } - assert_eq!( - RawJson::new(&Twice).unwrap_err().code(), - ErrorCode::Internal - ); - assert!(e.set("twice", &Twice).is_err()); -} - -// WS-REQ-0091 (review CR2-9): a JsonObject or RawJson nested in a serialized struct keeps -// its key order, so a per-corner value inside each path of a tool field keeps the corners in -// the order the tool gave them. -#[test] -fn ordered_objects_nested_in_structs() { - #[derive(Serialize)] - struct TopPath { - handle: String, - slack_ns: JsonObject, - scope: RawJson, - } - let mut per_corner = JsonObject::new(); - for (corner, slack) in [("tt", -0.3), ("ss", -0.9), ("ff", 0.1)] { - per_corner.insert(corner, &slack).unwrap(); - } - let path = TopPath { - handle: "path1".into(), - slack_ns: per_corner.clone(), - scope: RawJson::parse(r#"{"z":[1,{"y":null,"b":true}],"a":"x"}"#).unwrap(), - }; - let mut e = Envelope::new("ruststa run"); - e.set("top_paths", &[path]).unwrap(); - assert_eq!( - e.field("top_paths").unwrap().as_str(), - r#"[{"handle":"path1","slack_ns":{"tt":-0.3,"ss":-0.9,"ff":0.1},"scope":{"z":[1,{"y":null,"b":true}],"a":"x"}}]"# - ); - assert_eq!( - serde_json::to_string(&per_corner).unwrap(), - per_corner.to_raw().as_str() - ); - // Numbers are written from their values. - let raw = RawJson::parse(r#"{"b":1.50,"a":-0.0,"n":-3}"#).unwrap(); - assert_eq!( - serde_json::to_string(&raw).unwrap(), - r#"{"b":1.5,"a":-0.0,"n":-3}"# - ); -} diff --git a/crates/core/rusteda-workspace/src/error.rs b/crates/core/rusteda-workspace/src/error.rs deleted file mode 100644 index 0784903..0000000 --- a/crates/core/rusteda-workspace/src/error.rs +++ /dev/null @@ -1,947 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Errors of this crate and the error codes of the `rusteda.v1` envelope. -//! -//! [`ErrorCode`] lists every code of the contract with its process exit code (WS-REQ-0093). -//! [`Error`] is the error type of the crate: [`Error::code`] gives its envelope code, -//! [`Error::details`] the structured `error.details`, [`Error::help`] a one-line hint for -//! text mode, and [`Error::to_diagnostic`] the diagnostic that text mode prints: a `W` code -//! for an error about the workspace, the application's own code for `USAGE`, `INTERNAL`, -//! `CANCELLED` and `BACKEND_UNAVAILABLE` (WS-REQ-0097). Every envelope code has a variant, so a -//! command logs the code of its envelope in `history.jsonl` (WS-REQ-0093). - -use std::fmt; -use std::io; -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use serde_json::{Value, json}; - -use crate::envelope::{DiagCode, Diagnostic, Severity}; -use crate::hash::Hash64; -use crate::rev::Rev; - -/// The `W` (workspace) diagnostic codes of this crate (WS-REQ-0097). -/// -/// - `W0001` to `W0099`: errors about the workspace, one per envelope error code -/// ([`ErrorCode::diag_code`]); `W0001`, `W0011` and `W0013` are not used: `USAGE`, `INTERNAL` -/// and `CANCELLED`, like `BACKEND_UNAVAILABLE` (`A0501`), are the application's errors and -/// print with its `A` code; -/// - `W0100` to `W0199`: warnings of commands that completed; -/// - `W0200` and above are left to the generic `rusteda` command line. -pub mod codes { - use crate::envelope::DiagCode; - - /// Warning: the command committed (or moved HEAD), but writing its history line failed - /// (WS-REQ-0076). - pub const HISTORY_NOT_WRITTEN: DiagCode = DiagCode::of("W0101"); - /// Warning: the command committed (or moved HEAD), but the directory fsync after the - /// `manifest.json` rename failed, so a crash may still bring back the previous HEAD - /// (WS-REQ-0076). - pub const NOT_DURABLE: DiagCode = DiagCode::of("W0102"); -} - -/// The result type of this crate. -pub type Result = std::result::Result; - -/// Process exit codes of the RustEDA command-line tools (WS-REQ-0093). -/// -/// Exit codes 3 and 5 belong to commands that completed: their envelope has `ok: true`. -pub mod exit { - /// The command completed; a verdict, if any, is positive. - pub const OK: i32 = 0; - /// The command was rejected: usage, input, workspace state or I/O. - pub const REJECTED: i32 = 1; - /// An internal error, or a table that failed validation. - pub const INTERNAL: i32 = 2; - /// The command completed with a negative verdict. - pub const NEGATIVE_VERDICT: i32 = 3; - /// `--base-rev` is not the workspace HEAD. - pub const REVISION_CONFLICT: i32 = 4; - /// The command completed without a verdict, for example because a budget ran out. - pub const INCONCLUSIVE: i32 = 5; - /// The command was cancelled. - pub const CANCELLED: i32 = 130; -} - -/// An error code of the envelope's `error.code` (WS-REQ-0093). -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ErrorCode { - /// `USAGE`: the command line is wrong. - Usage, - /// `INPUT_REJECTED`: an input file or value was rejected. - InputRejected, - /// `NOT_A_WORKSPACE`: the directory has no `manifest.json`. - NotAWorkspace, - /// `WORKSPACE_EXISTS`: `init` found an existing workspace. - WorkspaceExists, - /// `UNKNOWN_REVISION`: the revision does not exist or cannot be named here. - UnknownRevision, - /// `MISSING_TABLE`: the revision has no such table. - MissingTable, - /// `UNKNOWN_HANDLE`: an object handle does not resolve. - UnknownHandle, - /// `SCHEMA_TOO_NEW`: a file was written with a newer major schema. - SchemaTooNew, - /// `IO_ERROR`: reading or writing a file failed. - IoError, - /// `BACKEND_UNAVAILABLE`: the compute backend asked for is not compiled in or not usable; - /// never a silent fallback to another backend (contract §7). - BackendUnavailable, - /// `REVISION_CONFLICT`: `--base-rev` is not HEAD. - RevisionConflict, - /// `INTERNAL`: a bug. - Internal, - /// `CORRUPT_TABLE`: header, bounds or hash validation failed. - CorruptTable, - /// `CANCELLED`: the command was cancelled. - Cancelled, -} - -impl ErrorCode { - /// Every code, in the order of the contract. - pub const ALL: [ErrorCode; 14] = [ - ErrorCode::Usage, - ErrorCode::InputRejected, - ErrorCode::NotAWorkspace, - ErrorCode::WorkspaceExists, - ErrorCode::UnknownRevision, - ErrorCode::MissingTable, - ErrorCode::UnknownHandle, - ErrorCode::SchemaTooNew, - ErrorCode::IoError, - ErrorCode::BackendUnavailable, - ErrorCode::RevisionConflict, - ErrorCode::Internal, - ErrorCode::CorruptTable, - ErrorCode::Cancelled, - ]; - - /// The code as written in JSON, e.g. `REVISION_CONFLICT`. - pub const fn as_str(self) -> &'static str { - match self { - ErrorCode::Usage => "USAGE", - ErrorCode::InputRejected => "INPUT_REJECTED", - ErrorCode::NotAWorkspace => "NOT_A_WORKSPACE", - ErrorCode::WorkspaceExists => "WORKSPACE_EXISTS", - ErrorCode::UnknownRevision => "UNKNOWN_REVISION", - ErrorCode::MissingTable => "MISSING_TABLE", - ErrorCode::UnknownHandle => "UNKNOWN_HANDLE", - ErrorCode::SchemaTooNew => "SCHEMA_TOO_NEW", - ErrorCode::IoError => "IO_ERROR", - ErrorCode::BackendUnavailable => "BACKEND_UNAVAILABLE", - ErrorCode::RevisionConflict => "REVISION_CONFLICT", - ErrorCode::Internal => "INTERNAL", - ErrorCode::CorruptTable => "CORRUPT_TABLE", - ErrorCode::Cancelled => "CANCELLED", - } - } - - /// The process exit code of a command that fails with this code. - pub const fn exit_code(self) -> i32 { - match self { - ErrorCode::Usage - | ErrorCode::InputRejected - | ErrorCode::NotAWorkspace - | ErrorCode::WorkspaceExists - | ErrorCode::UnknownRevision - | ErrorCode::MissingTable - | ErrorCode::UnknownHandle - | ErrorCode::SchemaTooNew - | ErrorCode::IoError - | ErrorCode::BackendUnavailable => exit::REJECTED, - ErrorCode::RevisionConflict => exit::REVISION_CONFLICT, - ErrorCode::Internal | ErrorCode::CorruptTable => exit::INTERNAL, - ErrorCode::Cancelled => exit::CANCELLED, - } - } - - /// Reads a code as written by [`ErrorCode::as_str`]. - pub fn parse(text: &str) -> Option { - ErrorCode::ALL.into_iter().find(|c| c.as_str() == text) - } - - /// The `W` code under which text mode prints an error about the workspace with this - /// envelope code (WS-REQ-0097): `W0002` `INPUT_REJECTED` to `W0010` `REVISION_CONFLICT` and - /// `W0012` `CORRUPT_TABLE`, the positions of the codes in the list of contract draft 4. - /// `None` for `USAGE`, `INTERNAL`, `CANCELLED` and `BACKEND_UNAVAILABLE`: a command line, a - /// bug, a cancellation and a compute backend belong to the application (contract §7: letter - /// `A`, `A0501` for an unavailable backend), so they print with the application's code - /// ([`Error::to_diagnostic`]). JSON keeps the envelope code. - pub const fn diag_code(self) -> Option { - Some(match self { - ErrorCode::Usage - | ErrorCode::Internal - | ErrorCode::Cancelled - | ErrorCode::BackendUnavailable => return None, - ErrorCode::InputRejected => const { DiagCode::of("W0002") }, - ErrorCode::NotAWorkspace => const { DiagCode::of("W0003") }, - ErrorCode::WorkspaceExists => const { DiagCode::of("W0004") }, - ErrorCode::UnknownRevision => const { DiagCode::of("W0005") }, - ErrorCode::MissingTable => const { DiagCode::of("W0006") }, - ErrorCode::UnknownHandle => const { DiagCode::of("W0007") }, - ErrorCode::SchemaTooNew => const { DiagCode::of("W0008") }, - ErrorCode::IoError => const { DiagCode::of("W0009") }, - ErrorCode::RevisionConflict => const { DiagCode::of("W0010") }, - ErrorCode::CorruptTable => const { DiagCode::of("W0012") }, - }) - } -} - -impl fmt::Display for ErrorCode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl Serialize for ErrorCode { - fn serialize(&self, serializer: S) -> std::result::Result { - serializer.serialize_str(self.as_str()) - } -} - -impl<'de> Deserialize<'de> for ErrorCode { - fn deserialize>(deserializer: D) -> std::result::Result { - let text = String::deserialize(deserializer)?; - ErrorCode::parse(&text) - .ok_or_else(|| serde::de::Error::custom(format!("unknown error code '{text}'"))) - } -} - -/// Why a table or a workspace file failed validation; reported as `CORRUPT_TABLE`. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Corruption { - /// The file ends before the end of its header. - Truncated { - /// Bytes the header needs. - needed: u64, - /// Bytes in the file. - len: u64, - }, - /// The file does not start with `RUSTEDA\0`. - BadMagic, - /// The header names another kind of table. - WrongKind { - /// The kind the reader asked for. - expected: String, - /// The kind in the header. - found: String, - }, - /// The format version is older than the reader supports. (A newer one is - /// [`Error::FormatTooNew`], `SCHEMA_TOO_NEW`.) - UnsupportedVersion { - /// The version in the header. - found: u32, - /// The oldest supported version. - min: u32, - /// The newest supported version. - max: u32, - }, - /// The header flags are not zero. - Flags { - /// The flags in the header. - flags: u32, - }, - /// `header_len` does not match the section and upstream counts. - HeaderLen { - /// The `header_len` in the header. - declared: u64, - /// The length the counts give. - expected: u64, - }, - /// A kind or upstream name is not ASCII padded with NUL bytes. - BadName { - /// Which name. - field: &'static str, - }, - /// A section reaches past the end of the file. - SectionBounds { - /// The section id. - id: u32, - /// Its offset. - offset: u64, - /// Its length in bytes. - len: u64, - /// The file length. - file_len: u64, - }, - /// A section does not start on an 8-byte boundary. - Misaligned { - /// The section id. - id: u32, - /// Its offset. - offset: u64, - }, - /// A section's element size is zero or does not divide its length. - ElemSize { - /// The section id. - id: u32, - /// Its element size. - elem_size: u32, - /// Its length in bytes. - len: u64, - }, - /// Section ids are not strictly increasing: unsorted or duplicate. - SectionOrder { - /// The first id out of order. - id: u32, - }, - /// A section starts before the end of the header or of the previous section. - Overlap { - /// The section id. - id: u32, - }, - /// The content hash in the header does not match the content. - HashMismatch { - /// The hash in the header. - stored: Hash64, - /// The hash of the content. - computed: Hash64, - }, - /// A section the reader needs is absent. - MissingSection { - /// The section id. - id: u32, - }, - /// A section's element size is not the size of the requested element type. - TypeMismatch { - /// The section id. - id: u32, - /// The element size in the file. - elem_size: u32, - /// The size of the requested type. - type_size: usize, - }, - /// A table's file or directory hash differs from the hash in `meta.json`. - TableHash { - /// The table name. - table: String, - /// The hash in `meta.json`. - stored: Hash64, - /// The hash of the table on disk. - computed: Hash64, - }, - /// A workspace JSON file is malformed or inconsistent. - Json { - /// What is wrong. - reason: String, - }, - /// The table on disk is not a regular file (or directory): a FIFO, a device, a socket or a - /// symbolic link, which is never opened (WS-REQ-0025). - FileType { - /// What the table must be, e.g. `a regular file`. - expected: &'static str, - }, - /// The upstream entries of an rbin table's header are not the table's upstream map in - /// `meta.json` (WS-REQ-0038). - HeaderUpstream { - /// The table name. - table: String, - }, - /// An entry of a directory table has a name that is not UTF-8, which no directory table - /// the workspace commits holds (WS-REQ-0012). - NotUtf8, -} - -impl fmt::Display for Corruption { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Corruption::Truncated { needed, len } => { - write!( - f, - "truncated: the file has {len} bytes, its header needs {needed}" - ) - } - Corruption::BadMagic => f.write_str("not an rbin file (bad magic)"), - Corruption::WrongKind { expected, found } => { - write!(f, "the table kind is {found}, expected {expected}") - } - Corruption::UnsupportedVersion { found, min, max } => write!( - f, - "format version {found} is not supported (this build reads {min} to {max})" - ), - Corruption::Flags { flags } => write!(f, "header flags {flags:#x} are not zero"), - Corruption::HeaderLen { declared, expected } => write!( - f, - "header_len is {declared}, but the section and upstream counts give {expected}" - ), - Corruption::BadName { field } => { - write!(f, "the {field} is not ASCII padded with NUL bytes") - } - Corruption::SectionBounds { - id, - offset, - len, - file_len, - } => write!( - f, - "section {id} ({len} bytes at offset {offset}) ends past the end of the file \ - ({file_len} bytes)" - ), - Corruption::Misaligned { id, offset } => { - write!( - f, - "section {id} starts at offset {offset}, not a multiple of 8" - ) - } - Corruption::ElemSize { id, elem_size, len } => write!( - f, - "section {id}: length {len} is not a multiple of the element size {elem_size}" - ), - Corruption::SectionOrder { id } => { - write!(f, "section ids are not strictly increasing at section {id}") - } - Corruption::Overlap { id } => { - write!( - f, - "section {id} overlaps the header or the previous section" - ) - } - Corruption::HashMismatch { stored, computed } => write!( - f, - "content hash mismatch: the header records {stored}, the content hashes to \ - {computed}" - ), - Corruption::MissingSection { id } => write!(f, "section {id} is missing"), - Corruption::TypeMismatch { - id, - elem_size, - type_size, - } => write!( - f, - "section {id} has {elem_size}-byte elements, the reader expects {type_size} bytes" - ), - Corruption::TableHash { - table, - stored, - computed, - } => write!( - f, - "table {table} hashes to {computed}, but meta.json records {stored}" - ), - Corruption::Json { reason } => write!(f, "malformed: {reason}"), - Corruption::FileType { expected } => write!(f, "not {expected}"), - Corruption::HeaderUpstream { table } => write!( - f, - "the header's upstream entries are not the upstream of table {table} in meta.json" - ), - Corruption::NotUtf8 => f.write_str("the name is not UTF-8"), - } - } -} - -/// The error type of this crate; [`Error::code`] gives its envelope error code. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum Error { - /// The command line is wrong (`USAGE`). - #[error("{0}")] - Usage(String), - /// An input was rejected (`INPUT_REJECTED`). - #[error("{0}")] - InputRejected(String), - /// The directory is not a workspace (`NOT_A_WORKSPACE`). - #[error("not a workspace: {} has no manifest.json", .path.display())] - NotAWorkspace { - /// The directory. - path: PathBuf, - }, - /// `init` found an existing workspace (`WORKSPACE_EXISTS`). - #[error("a workspace already exists in {}", .path.display())] - WorkspaceExists { - /// The directory. - path: PathBuf, - }, - /// A revision does not exist or cannot be named here (`UNKNOWN_REVISION`). - #[error("unknown revision '{spec}': {reason}")] - UnknownRevision { - /// The revision as given. - spec: String, - /// Why it does not resolve. - reason: String, - }, - /// A revision lacks a table (`MISSING_TABLE`). - #[error("{rev} has no table '{table}'")] - MissingTable { - /// The revision. - rev: Rev, - /// The table name. - table: String, - }, - /// A table of the base revision does not carry into the revision being built - /// (`MISSING_TABLE`). - #[error("table '{table}' does not carry into the new revision: {reason}")] - DroppedTable { - /// The table name. - table: String, - /// Why it is dropped. - reason: String, - }, - /// An object handle does not resolve (`UNKNOWN_HANDLE`). - #[error("unknown handle '{handle}': {reason}")] - UnknownHandle { - /// The handle as given. - handle: String, - /// Why it does not resolve. - reason: String, - }, - /// A file has a newer major schema (`SCHEMA_TOO_NEW`). - #[error("{} has schema {found}; this build reads {supported}", .path.display())] - SchemaTooNew { - /// The file. - path: PathBuf, - /// The schema in the file. - found: String, - /// The schema this build reads. - supported: String, - }, - /// An rbin table has a newer format version than this build reads (`SCHEMA_TOO_NEW`, - /// WS-REQ-0039): it was written by a newer release. - #[error( - "{}: {kind} format version {found} is newer than this build reads ({min} to {max})", - .path.display() - )] - FormatTooNew { - /// The file. - path: PathBuf, - /// The table kind. - kind: String, - /// The format version in the header. - found: u32, - /// The oldest version this build reads. - min: u32, - /// The newest version this build reads. - max: u32, - }, - /// Reading or writing a file failed (`IO_ERROR`). - #[error("{}: {source}", .path.display())] - Io { - /// The file or directory. - path: PathBuf, - /// The operating-system error. - #[source] - source: io::Error, - }, - /// `--base-rev` is not HEAD (`REVISION_CONFLICT`). - #[error("revision conflict: --base-rev is {base} but HEAD is {head}")] - RevisionConflict { - /// The base revision of the command. - base: Rev, - /// The workspace HEAD. - head: Rev, - }, - /// A table or workspace file failed validation (`CORRUPT_TABLE`). - #[error("{}: {what}", .path.display())] - Corrupt { - /// The file. - path: PathBuf, - /// What failed. - what: Corruption, - }, - /// A bug: an API was used against its contract (`INTERNAL`). - #[error("internal error: {0}")] - Internal(String), - /// The compute backend asked for is not compiled in or not usable on this machine - /// (`BACKEND_UNAVAILABLE`, exit 1; contract §7): never a silent fallback to another backend. - /// The application's error, like `USAGE`: text mode prints it with the application's code - /// (`A0501`), and it reaches the history of a rejected command through - /// [`Workspace::log_rejected`](crate::Workspace::log_rejected) or - /// [`Txn::reject`](crate::Txn::reject) with its own code. - #[error("{0}")] - BackendUnavailable(String), - /// The command was cancelled (`CANCELLED`). - #[error("cancelled")] - Cancelled, -} - -impl Error { - /// A `USAGE` error. - pub fn usage(message: impl Into) -> Error { - Error::Usage(message.into()) - } - - /// An `INPUT_REJECTED` error. - pub fn input_rejected(message: impl Into) -> Error { - Error::InputRejected(message.into()) - } - - /// An `INTERNAL` error. - pub fn internal(message: impl Into) -> Error { - Error::Internal(message.into()) - } - - /// A `BACKEND_UNAVAILABLE` error, e.g. `cuda: no usable driver`. - pub fn backend_unavailable(message: impl Into) -> Error { - Error::BackendUnavailable(message.into()) - } - - /// An `IO_ERROR` for `path`. - pub fn io(path: impl Into, source: io::Error) -> Error { - Error::Io { - path: path.into(), - source, - } - } - - /// A `CORRUPT_TABLE` error for `path`. - pub fn corrupt(path: impl Into, what: Corruption) -> Error { - Error::Corrupt { - path: path.into(), - what, - } - } - - /// The envelope error code. - pub fn code(&self) -> ErrorCode { - match self { - Error::Usage(_) => ErrorCode::Usage, - Error::InputRejected(_) => ErrorCode::InputRejected, - Error::NotAWorkspace { .. } => ErrorCode::NotAWorkspace, - Error::WorkspaceExists { .. } => ErrorCode::WorkspaceExists, - Error::UnknownRevision { .. } => ErrorCode::UnknownRevision, - Error::MissingTable { .. } | Error::DroppedTable { .. } => ErrorCode::MissingTable, - Error::UnknownHandle { .. } => ErrorCode::UnknownHandle, - Error::SchemaTooNew { .. } | Error::FormatTooNew { .. } => ErrorCode::SchemaTooNew, - Error::Io { .. } => ErrorCode::IoError, - Error::RevisionConflict { .. } => ErrorCode::RevisionConflict, - Error::Corrupt { .. } => ErrorCode::CorruptTable, - Error::Internal(_) => ErrorCode::Internal, - Error::BackendUnavailable(_) => ErrorCode::BackendUnavailable, - Error::Cancelled => ErrorCode::Cancelled, - } - } - - /// The process exit code. - pub fn exit_code(&self) -> i32 { - self.code().exit_code() - } - - /// The `W` diagnostic code of this error for text mode, or `None` for `USAGE`, `INTERNAL`, - /// `CANCELLED` and `BACKEND_UNAVAILABLE`, which print with the application's code - /// (WS-REQ-0097). - pub fn diag_code(&self) -> Option { - self.code().diag_code() - } - - /// The error as a diagnostic: severity `error`, its `W` code ([`Error::diag_code`]), or - /// `app_code`, the application's code (letter `A`) for `USAGE`, `INTERNAL`, `CANCELLED` - /// and `BACKEND_UNAVAILABLE` (`A0501`); no location, the message and the help. Text mode prints it on stderr like - /// any other diagnostic, so the line reads as `error[W0010]: revision conflict: ...` or - /// `error[A0003]: unknown flag --x` (WS-REQ-0097). - pub fn to_diagnostic(&self, app_code: DiagCode) -> Diagnostic { - let code = self.diag_code().unwrap_or(app_code); - let mut diagnostic = Diagnostic::new(Severity::Error, code, self.to_string()); - diagnostic.help = self.help(); - diagnostic - } - - /// The `error.details` object of the envelope, or `null`. - pub fn details(&self) -> Value { - match self { - Error::NotAWorkspace { path } | Error::WorkspaceExists { path } => { - json!({ "path": display(path) }) - } - Error::UnknownRevision { spec, .. } => json!({ "revision": spec }), - Error::MissingTable { rev, table } => { - json!({ "rev": rev.to_string(), "table": table }) - } - Error::DroppedTable { table, reason } => json!({ "table": table, "reason": reason }), - Error::UnknownHandle { handle, .. } => json!({ "handle": handle }), - Error::SchemaTooNew { - path, - found, - supported, - } => { - json!({ "file": display(path), "schema": found, "supported": supported }) - } - Error::FormatTooNew { - path, - kind, - found, - min, - max, - } => json!({ - "file": display(path), - "kind": kind, - "format_version": found, - "min": min, - "max": max, - }), - Error::Io { path, source } => { - json!({ "path": display(path), "kind": format!("{:?}", source.kind()) }) - } - Error::RevisionConflict { base, head } => { - json!({ "base_rev": base.to_string(), "head": head.to_string() }) - } - Error::Corrupt { path, .. } => json!({ "file": display(path) }), - Error::Usage(_) - | Error::InputRejected(_) - | Error::Internal(_) - | Error::BackendUnavailable(_) - | Error::Cancelled => Value::Null, - } - } - - /// A one-line hint for text mode, if there is a useful one. - pub fn help(&self) -> Option { - let text = match self { - Error::NotAWorkspace { .. } => { - "pass the workspace directory with -w, or create one with `rusteda init`".into() - } - Error::WorkspaceExists { .. } => { - "use the existing workspace, or choose another directory".into() - } - Error::UnknownRevision { .. } => "list the revisions with `rusteda log`".into(), - Error::MissingTable { .. } => { - "run the command that produces this table on this revision first".into() - } - Error::DroppedTable { .. } => { - "compute the table again from the tables of the new revision".into() - } - Error::RevisionConflict { head, .. } => { - format!("HEAD moved; review {head} and rerun with --base-rev {head}") - } - Error::SchemaTooNew { .. } | Error::FormatTooNew { .. } => { - "the workspace was written by a newer RustEDA release; upgrade the tools".into() - } - Error::Corrupt { path, what } => corrupt_help(path, what).into(), - Error::BackendUnavailable(_) => { - "choose a backend that this build has and this machine runs; cpu always runs".into() - } - _ => return None, - }; - Some(text) - } -} - -fn display(path: &Path) -> String { - path.display().to_string() -} - -/// What to do about a file that failed validation; it depends on the file (CW-5): a revert -/// helps against a damaged table or revision, not against a damaged workspace file. -fn corrupt_help(path: &Path, what: &Corruption) -> &'static str { - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); - let in_cache = path - .parent() - .and_then(Path::file_name) - .is_some_and(|dir| dir == "cache"); - let old_format = matches!(what, Corruption::UnsupportedVersion { .. }); - match name { - "manifest.json" => { - "manifest.json is damaged, so no command can run; repair it by hand: \"head\" is the \ - newest good revision and \"next_rev\" one more than the highest revision directory" - } - "history.jsonl" => { - "history.jsonl is damaged; move it aside to continue (the revisions are not affected)" - } - "ui_state.json" => "ui_state.json is damaged; delete it and select again in the GUI", - "meta.json" => { - "the metadata of this revision is damaged; point HEAD at another revision with \ - `rusteda revert`" - } - _ if in_cache && old_format => { - "the compiled library has a format this build no longer reads; import the library \ - again, which compiles it again from its cached .lib" - } - _ if in_cache => "the cached library is damaged; delete it and import the library again", - _ if old_format => { - "the table has a format version this build no longer reads; compute the table again" - } - _ => "the table failed validation; revert to an earlier revision or compute it again", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // WS-REQ-0093 - #[test] - fn codes_and_exit_codes() { - let expected = [ - ("USAGE", 1), - ("INPUT_REJECTED", 1), - ("NOT_A_WORKSPACE", 1), - ("WORKSPACE_EXISTS", 1), - ("UNKNOWN_REVISION", 1), - ("MISSING_TABLE", 1), - ("UNKNOWN_HANDLE", 1), - ("SCHEMA_TOO_NEW", 1), - ("IO_ERROR", 1), - ("BACKEND_UNAVAILABLE", 1), - ("REVISION_CONFLICT", 4), - ("INTERNAL", 2), - ("CORRUPT_TABLE", 2), - ("CANCELLED", 130), - ]; - assert_eq!(ErrorCode::ALL.len(), expected.len()); - for (code, (text, exit)) in ErrorCode::ALL.into_iter().zip(expected) { - assert_eq!(code.as_str(), text); - assert_eq!(code.exit_code(), exit, "{text}"); - assert_eq!(ErrorCode::parse(text), Some(code)); - let json = serde_json::to_string(&code).unwrap(); - assert_eq!(json, format!("\"{text}\"")); - assert_eq!(serde_json::from_str::(&json).unwrap(), code); - } - assert_eq!(ErrorCode::parse("usage"), None); - assert!(serde_json::from_str::("\"NOPE\"").is_err()); - } - - #[test] - fn errors_map_to_codes_details_and_help() { - let e = Error::RevisionConflict { - base: Rev(3), - head: Rev(4), - }; - assert_eq!(e.code(), ErrorCode::RevisionConflict); - assert_eq!(e.exit_code(), 4); - assert_eq!( - e.to_string(), - "revision conflict: --base-rev is rev3 but HEAD is rev4" - ); - assert_eq!(e.details(), json!({ "base_rev": "rev3", "head": "rev4" })); - assert_eq!( - e.help().unwrap(), - "HEAD moved; review rev4 and rerun with --base-rev rev4" - ); - - let e = Error::corrupt("t.rbin", Corruption::MissingSection { id: 7 }); - assert_eq!(e.code(), ErrorCode::CorruptTable); - assert_eq!(e.exit_code(), 2); - assert_eq!(e.to_string(), "t.rbin: section 7 is missing"); - - let e = Error::io("x", io::Error::new(io::ErrorKind::NotFound, "gone")); - assert_eq!(e.code(), ErrorCode::IoError); - assert_eq!(e.details()["kind"], "NotFound"); - assert!(std::error::Error::source(&e).is_some()); - - assert_eq!(Error::Cancelled.exit_code(), 130); - assert_eq!(Error::usage("bad flag").details(), Value::Null); - assert_eq!(Error::internal("bug").help(), None); - } - - // WS-REQ-0097 (review CW-8, CR2-8): every envelope code about the workspace has its own W - // code, fixed once (the positions of contract draft 4), so the text-mode error line has the - // shape of a diagnostic line. USAGE, INTERNAL, CANCELLED and BACKEND_UNAVAILABLE have none: - // they print with the application's A code. - #[test] - fn diagnostic_codes() { - let expected = [ - (ErrorCode::InputRejected, "W0002"), - (ErrorCode::NotAWorkspace, "W0003"), - (ErrorCode::WorkspaceExists, "W0004"), - (ErrorCode::UnknownRevision, "W0005"), - (ErrorCode::MissingTable, "W0006"), - (ErrorCode::UnknownHandle, "W0007"), - (ErrorCode::SchemaTooNew, "W0008"), - (ErrorCode::IoError, "W0009"), - (ErrorCode::RevisionConflict, "W0010"), - (ErrorCode::CorruptTable, "W0012"), - ]; - let mut seen = std::collections::BTreeSet::new(); - for code in ErrorCode::ALL { - let Some(d) = code.diag_code() else { - assert!( - matches!( - code, - ErrorCode::Usage - | ErrorCode::Internal - | ErrorCode::Cancelled - | ErrorCode::BackendUnavailable - ), - "{code}" - ); - continue; - }; - let w = expected.iter().find(|(c, _)| *c == code).unwrap().1; - assert_eq!(d.as_str(), w, "{code}"); - assert_eq!(d.letter(), 'W'); - assert!(seen.insert(d), "{d} is used twice"); - } - assert_eq!(seen.len(), expected.len()); - assert_eq!( - ErrorCode::RevisionConflict.diag_code(), - Some(DiagCode::of("W0010")) - ); - assert!(!seen.contains(&codes::HISTORY_NOT_WRITTEN)); - assert!(!seen.contains(&codes::NOT_DURABLE)); - let app = DiagCode::of("A0003"); - let e = Error::RevisionConflict { - base: Rev(3), - head: Rev(4), - }; - let d = e.to_diagnostic(app); - assert_eq!((d.severity, d.code.as_str()), (Severity::Error, "W0010")); - assert_eq!(d.location, None); - assert_eq!(d.message, e.to_string()); - assert_eq!(d.help, e.help()); - assert_eq!( - e.code(), - ErrorCode::RevisionConflict, - "JSON keeps the envelope code" - ); - // The application's errors take its code (the timing tool's: A0003 command line, A0002 - // internal). - for (error, code) in [ - (Error::usage("unknown flag --x"), "A0003"), - (Error::internal("bug"), "A0002"), - (Error::Cancelled, "A0130"), - ] { - assert_eq!(error.diag_code(), None, "{error}"); - let d = error.to_diagnostic(DiagCode::parse(code).unwrap()); - assert_eq!(d.code.as_str(), code, "{error}"); - } - let corrupt = Error::corrupt("t.rbin", Corruption::BadMagic); - assert_eq!(corrupt.to_diagnostic(app).code.as_str(), "W0012"); - } - - // WS-REQ-0039 (review CW-5): a newer format version is SCHEMA_TOO_NEW, exit 1. - #[test] - fn newer_format_versions() { - let e = Error::FormatTooNew { - path: "t.rbin".into(), - kind: "NETLIST".into(), - found: 2, - min: 1, - max: 1, - }; - assert_eq!((e.code(), e.exit_code()), (ErrorCode::SchemaTooNew, 1)); - assert_eq!( - e.to_string(), - "t.rbin: NETLIST format version 2 is newer than this build reads (1 to 1)" - ); - assert!(e.help().unwrap().contains("upgrade the tools")); - assert_eq!(e.details()["format_version"], 2); - } - - // Review CW-5: the help for a file that failed validation depends on the file. - #[test] - fn corrupt_help_depends_on_the_file() { - let help = |path: &str, what: Corruption| Error::corrupt(path, what).help().unwrap(); - let json = || Corruption::Json { reason: "x".into() }; - let old = || Corruption::UnsupportedVersion { - found: 1, - min: 2, - max: 3, - }; - assert!(help("ws/manifest.json", json()).contains("repair it by hand")); - assert!(help("ws/history.jsonl", json()).contains("move it aside")); - assert!(help("ws/ui_state.json", json()).contains("delete it")); - assert!(help("ws/revisions/rev0003/meta.json", json()).contains("rusteda revert")); - let rlib = "ws/cache/a-0123456789abcdef.rlib"; - assert!(help(rlib, Corruption::BadMagic).contains("import the library again")); - assert!(help(rlib, old()).contains("cached .lib")); - let table = "ws/revisions/rev0003/sta.rbin"; - assert!(help(table, Corruption::BadMagic).contains("revert to an earlier revision")); - assert!(help(table, old()).contains("compute the table again")); - for file in ["ws/manifest.json", "ws/history.jsonl", "ws/ui_state.json"] { - assert!(!help(file, json()).contains("revert"), "{file}"); - } - } -} diff --git a/crates/core/rusteda-workspace/src/fsutil.rs b/crates/core/rusteda-workspace/src/fsutil.rs deleted file mode 100644 index 8d81f01..0000000 --- a/crates/core/rusteda-workspace/src/fsutil.rs +++ /dev/null @@ -1,547 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! File-system helpers: durable writes, atomic replacement, directory fsync, hard links -//! (WS-REQ-0066), and opening regular files only (WS-REQ-0025). - -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use crate::error::{Corruption, Error, Result}; - -/// Milliseconds since the Unix epoch. -pub(crate) fn now_unix_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) - .unwrap_or(0) -} - -/// `-`: the current time in nanoseconds, made unique within the process. -pub(crate) fn unique_suffix() -> String { - static LAST: AtomicU64 = AtomicU64::new(0); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX)) - .unwrap_or(0); - let mut last = LAST.load(Ordering::Relaxed); - let nanos = loop { - let next = now.max(last + 1); - match LAST.compare_exchange_weak(last, next, Ordering::Relaxed, Ordering::Relaxed) { - Ok(_) => break next, - Err(seen) => last = seen, - } - }; - format!("{}-{nanos}", std::process::id()) -} - -/// Creates `parent/-`, a directory that did not exist before. -pub(crate) fn create_unique_dir(parent: &Path, prefix: &str) -> Result { - loop { - let dir = parent.join(format!("{prefix}{}", unique_suffix())); - match fs::create_dir(&dir) { - Ok(()) => return Ok(dir), - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} - Err(e) => return Err(Error::io(dir, e)), - } - } -} - -pub(crate) fn create_dir_all(dir: &Path) -> Result<()> { - fs::create_dir_all(dir).map_err(|e| Error::io(dir, e)) -} - -/// Creates `dir` and its missing ancestors, like [`create_dir_all`], and makes the new entries -/// durable: the parent of every directory this call found missing is fsynced, from the top -/// down, so a crash after it returns loses none of them (WS-REQ-0066). An existing `dir` is -/// left as it is. -pub(crate) fn create_dir_all_durable(dir: &Path) -> Result<()> { - let mut missing = Vec::new(); - let mut at = dir; - loop { - match fs::symlink_metadata(at) { - Ok(_) => break, - Err(e) if e.kind() == io::ErrorKind::NotFound => missing.push(at), - Err(e) => return Err(Error::io(at, e)), - } - match at.parent() { - Some(parent) if !parent.as_os_str().is_empty() => at = parent, - _ => break, - } - } - if missing.is_empty() { - return Ok(()); - } - create_dir_all(dir)?; - for created in missing.iter().rev() { - fsync_dir(parent_dir(created))?; - } - Ok(()) -} - -pub(crate) fn rename(from: &Path, to: &Path) -> Result<()> { - fs::rename(from, to).map_err(|e| Error::io(from, e)) -} - -/// Creates a file that must not exist, writes `bytes` and fsyncs it (WS-REQ-0036). A partial -/// file is removed on failure. -pub(crate) fn write_new(path: &Path, bytes: &[u8]) -> Result<()> { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|e| Error::io(path, e))?; - let written = file.write_all(bytes).and_then(|()| file.sync_all()); - if let Err(e) = written { - drop(file); - let _ = fs::remove_file(path); - return Err(Error::io(path, e)); - } - Ok(()) -} - -/// What [`replace`] did after its rename succeeded. -#[derive(Debug)] -#[must_use] -pub(crate) enum Replaced { - /// The file is replaced and its directory entry is durable. - Durable, - /// The file is replaced, and readers see the new content, but the directory fsync failed: - /// a crash may still bring back the old file. - NotDurable(Error), -} - -/// Replaces `path` atomically: a temporary file in the same directory, fsync, rename over -/// `path`, fsync of the directory. An error means that nothing changed. Once the rename has -/// succeeded the replacement has happened, so a failed directory fsync is reported as -/// [`Replaced::NotDurable`] and not as an error (WS-REQ-0076). -pub(crate) fn replace(path: &Path, bytes: &[u8]) -> Result { - let dir = parent_dir(path); - let name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let tmp = dir.join(format!(".{name}.tmp-{}", unique_suffix())); - write_new(&tmp, bytes)?; - if let Err(e) = fs::rename(&tmp, path) { - let _ = fs::remove_file(&tmp); - return Err(Error::io(path, e)); - } - Ok(match sync_after_rename(dir) { - Ok(()) => Replaced::Durable, - Err(e) => Replaced::NotDurable(e), - }) -} - -/// [`replace`] for files that may simply be written again: a failed directory fsync is an -/// error too. -pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { - match replace(path, bytes)? { - Replaced::Durable => Ok(()), - Replaced::NotDurable(e) => Err(e), - } -} - -/// The directory fsync that follows the rename of [`replace`]. -fn sync_after_rename(dir: &Path) -> Result<()> { - #[cfg(test)] - if failpoint::SYNC_AFTER_RENAME.replace(false) { - return Err(Error::io( - dir, - io::Error::other("injected directory fsync failure"), - )); - } - fsync_dir(dir) -} - -/// Fault injection and observation for the unit tests. -#[cfg(test)] -#[allow(dead_code)] -pub(crate) mod failpoint { - use std::cell::{Cell, RefCell}; - use std::path::{Path, PathBuf}; - - thread_local! { - /// When set, the next directory fsync after a [`super::replace`] rename on this thread - /// fails. - pub(crate) static SYNC_AFTER_RENAME: Cell = const { Cell::new(false) }; - /// When set, the directories fsynced on this thread, in order. - static FSYNCED_DIRS: RefCell>> = const { RefCell::new(None) }; - /// When set, the next fork on this thread fails after its copy, before it publishes. - pub(crate) static FORK_AFTER_COPY: Cell = const { Cell::new(false) }; - } - - /// Makes the next directory fsync after a rename of `replace` on this thread fail. - pub(crate) fn fail_next_sync_after_rename() { - SYNC_AFTER_RENAME.set(true); - } - - /// Makes the next fork on this thread fail after it copied the workspace. - pub(crate) fn fail_next_fork_after_copy() { - FORK_AFTER_COPY.set(true); - } - - /// Starts recording the directories fsynced on this thread. - pub(crate) fn record_fsynced_dirs() { - FSYNCED_DIRS.set(Some(Vec::new())); - } - - /// The directories fsynced on this thread since [`record_fsynced_dirs`]; stops recording. - pub(crate) fn take_fsynced_dirs() -> Vec { - FSYNCED_DIRS.take().unwrap_or_default() - } - - pub(super) fn note_fsynced_dir(dir: &Path) { - FSYNCED_DIRS.with_borrow_mut(|dirs| { - if let Some(dirs) = dirs { - dirs.push(dir.to_path_buf()); - } - }); - } -} - -/// Opens a file for reading only if it is a regular file (WS-REQ-0025): the type is checked -/// before opening (a FIFO would block the open; symbolic links are not followed) and again on -/// the open handle. -pub(crate) fn open_regular(path: &Path) -> Result { - check_regular(path)?; - let file = File::open(path).map_err(|e| Error::io(path, e))?; - let meta = file.metadata().map_err(|e| Error::io(path, e))?; - if !meta.is_file() { - return Err(not_a_regular_file(path)); - } - Ok(file) -} - -/// Checks that `path` itself, not following a symbolic link, is a regular file -/// (WS-REQ-0025); a missing file is an `IO_ERROR` of kind `NotFound`. -pub(crate) fn check_regular(path: &Path) -> Result<()> { - let meta = fs::symlink_metadata(path).map_err(|e| Error::io(path, e))?; - if !meta.file_type().is_file() { - return Err(not_a_regular_file(path)); - } - Ok(()) -} - -fn not_a_regular_file(path: &Path) -> Error { - Error::corrupt( - path, - Corruption::FileType { - expected: "a regular file", - }, - ) -} - -/// Reads a whole regular file ([`open_regular`]). -pub(crate) fn read_regular(path: &Path) -> Result> { - let mut bytes = Vec::new(); - io::Read::read_to_end(&mut open_regular(path)?, &mut bytes).map_err(|e| Error::io(path, e))?; - Ok(bytes) -} - -/// [`read_regular`], or `None` when the file does not exist. -pub(crate) fn read_regular_if_exists(path: &Path) -> Result>> { - match read_regular(path) { - Ok(bytes) => Ok(Some(bytes)), - Err(Error::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), - } -} - -/// The directory that holds `path`; `.` for a bare file name. -pub(crate) fn parent_dir(path: &Path) -> &Path { - match path.parent() { - Some(p) if !p.as_os_str().is_empty() => p, - _ => Path::new("."), - } -} - -/// Makes the entries of a directory durable. Not needed (and not possible through `File`) on -/// Windows. -pub(crate) fn fsync_dir(dir: &Path) -> Result<()> { - #[cfg(test)] - failpoint::note_fsynced_dir(dir); - #[cfg(unix)] - { - File::open(dir) - .and_then(|d| d.sync_all()) - .map_err(|e| Error::io(dir, e)) - } - #[cfg(not(unix))] - { - let _ = dir; - Ok(()) - } -} - -/// Fsyncs a file, or every file and directory of a tree. -pub(crate) fn fsync_tree(path: &Path) -> Result<()> { - let meta = fs::symlink_metadata(path).map_err(|e| Error::io(path, e))?; - if meta.is_dir() { - for entry in fs::read_dir(path).map_err(|e| Error::io(path, e))? { - let entry = entry.map_err(|e| Error::io(path, e))?; - fsync_tree(&entry.path())?; - } - fsync_dir(path) - } else { - sync_file(path) - } -} - -/// Fsyncs every directory of a tree (not the files), deepest first. -pub(crate) fn fsync_dirs(dir: &Path) -> Result<()> { - for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? { - let entry = entry.map_err(|e| Error::io(dir, e))?; - if entry - .file_type() - .map_err(|e| Error::io(entry.path(), e))? - .is_dir() - { - fsync_dirs(&entry.path())?; - } - } - fsync_dir(dir) -} - -fn sync_file(path: &Path) -> Result<()> { - // Write access where possible: Windows flushes only handles opened for writing. A file the - // tool made read-only is synced through a read-only handle (enough on Unix). - OpenOptions::new() - .write(true) - .open(path) - .or_else(|_| File::open(path)) - .and_then(|f| f.sync_all()) - .map_err(|e| Error::io(path, e)) -} - -/// Hard-links `src` to `dst`, a file or a directory tree whose files are linked one by one; -/// a file that cannot be linked (another file system, no hard links) is copied and fsynced -/// (WS-REQ-0065). `dst` must not exist. -pub(crate) fn link_or_copy(src: &Path, dst: &Path) -> Result<()> { - let meta = fs::symlink_metadata(src).map_err(|e| Error::io(src, e))?; - if meta.is_dir() { - fs::create_dir(dst).map_err(|e| Error::io(dst, e))?; - for entry in fs::read_dir(src).map_err(|e| Error::io(src, e))? { - let entry = entry.map_err(|e| Error::io(src, e))?; - link_or_copy(&entry.path(), &dst.join(entry.file_name()))?; - } - return Ok(()); - } - if !meta.is_file() { - return Err(Error::io( - src, - io::Error::new( - io::ErrorKind::InvalidData, - "not a regular file or directory", - ), - )); - } - match fs::hard_link(src, dst) { - Ok(()) => Ok(()), - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(Error::io(dst, e)), - Err(_) => { - if dst.exists() { - return Err(Error::io( - dst, - io::Error::from(io::ErrorKind::AlreadyExists), - )); - } - fs::copy(src, dst).map_err(|e| Error::io(dst, e))?; - sync_file(dst) - } - } -} - -/// Removes a directory tree of the crate's own (a staging directory); a missing tree is fine. -/// A subdirectory without write permission (a copy of a read-only source tree) would stop -/// `remove_dir_all`, so on Unix such directories are made writable by their owner and the -/// removal is tried again. Symbolic links are never followed: only entries that are -/// directories themselves get new permissions. -pub(crate) fn remove_tree(dir: &Path) -> io::Result<()> { - match fs::remove_dir_all(dir) { - Ok(()) => return Ok(()), - Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(_) => {} - } - #[cfg(unix)] - make_dirs_writable(dir); - match fs::remove_dir_all(dir) { - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), - other => other, - } -} - -/// Adds owner read, write and search permission to `dir` and every directory below it. -#[cfg(unix)] -fn make_dirs_writable(dir: &Path) { - use std::os::unix::fs::PermissionsExt; - let Ok(meta) = fs::symlink_metadata(dir) else { - return; - }; - if !meta.is_dir() { - return; - } - let mode = meta.permissions().mode(); - if mode & 0o700 != 0o700 { - let _ = fs::set_permissions(dir, fs::Permissions::from_mode(mode | 0o700)); - } - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|t| t.is_dir()) { - make_dirs_writable(&entry.path()); - } - } - } -} - -/// Removes a directory tree of the crate's own ([`remove_tree`]), ignoring errors. -pub(crate) fn remove_dir_best_effort(dir: &Path) { - let _ = remove_tree(dir); -} - -/// The (device, inode) pairs of the regular files below `dir`, symbolic links not followed: -/// the files a staging file may share its inode with (WS-REQ-0077). -#[cfg(unix)] -pub(crate) fn file_ids_below(dir: &Path) -> Result> { - use std::os::unix::fs::MetadataExt; - fn walk(dir: &Path, ids: &mut std::collections::BTreeSet<(u64, u64)>) -> Result<()> { - for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? { - let entry = entry.map_err(|e| Error::io(dir, e))?; - let path = entry.path(); - let meta = fs::symlink_metadata(&path).map_err(|e| Error::io(&path, e))?; - if meta.is_dir() { - walk(&path, ids)?; - } else if meta.is_file() { - ids.insert((meta.dev(), meta.ino())); - } - } - Ok(()) - } - let mut ids = std::collections::BTreeSet::new(); - walk(dir, &mut ids)?; - Ok(ids) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::testutil::temp_dir; - - #[test] - fn unique_suffixes() { - let a = unique_suffix(); - let b = unique_suffix(); - assert_ne!(a, b); - assert!(a.starts_with(&format!("{}-", std::process::id()))); - } - - #[test] - fn atomic_and_new_writes() { - let dir = temp_dir("fsutil-write"); - let f = dir.join("m.json"); - write_atomic(&f, b"one").unwrap(); - write_atomic(&f, b"two").unwrap(); - assert_eq!(fs::read(&f).unwrap(), b"two"); - let names: Vec<_> = fs::read_dir(&dir) - .unwrap() - .map(|e| e.unwrap().file_name()) - .collect(); - assert_eq!(names.len(), 1, "no temporary file is left: {names:?}"); - let err = write_new(&f, b"three").unwrap_err(); - assert_eq!(err.code(), crate::ErrorCode::IoError); - assert_eq!(fs::read(&f).unwrap(), b"two"); - } - - // WS-REQ-0076 (review W1): once the rename succeeded, a failed directory fsync does not - // make `replace` fail: the file is replaced. - #[test] - fn replace_after_the_rename() { - let dir = temp_dir("fsutil-replace"); - let f = dir.join("m.json"); - assert!(matches!(replace(&f, b"one").unwrap(), Replaced::Durable)); - failpoint::fail_next_sync_after_rename(); - let Replaced::NotDurable(e) = replace(&f, b"two").unwrap() else { - panic!("the injected fsync failure is reported"); - }; - assert!(e.to_string().contains("injected"), "{e}"); - assert_eq!(fs::read(&f).unwrap(), b"two"); - // The strict form reports it as an error, also after the rename. - failpoint::fail_next_sync_after_rename(); - let err = write_atomic(&f, b"three").unwrap_err(); - assert_eq!(err.code(), crate::ErrorCode::IoError); - assert_eq!(fs::read(&f).unwrap(), b"three"); - assert!(matches!(replace(&f, b"four").unwrap(), Replaced::Durable)); - } - - // WS-REQ-0025 (review W2): only regular files are opened. - #[cfg(unix)] - #[test] - fn open_regular_files_only() { - let dir = temp_dir("fsutil-open-regular"); - fs::write(dir.join("f"), b"x").unwrap(); - open_regular(&dir.join("f")).unwrap(); - std::os::unix::fs::symlink("f", dir.join("link")).unwrap(); - for path in [dir.join("link"), dir.clone()] { - let err = open_regular(&path).unwrap_err(); - assert_eq!( - err.code(), - crate::ErrorCode::CorruptTable, - "{}", - path.display() - ); - assert!(err.to_string().ends_with("not a regular file"), "{err}"); - } - let err = open_regular(&dir.join("absent")).unwrap_err(); - assert_eq!(err.code(), crate::ErrorCode::IoError); - } - - // WS-REQ-0079 (review R2-C3): a staging tree with a read-only subdirectory is removed. - #[cfg(unix)] - #[test] - fn remove_read_only_trees() { - use std::os::unix::fs::PermissionsExt; - let dir = temp_dir("fsutil-remove-read-only"); - let tree = dir.join("staging"); - fs::create_dir_all(tree.join("rtl/vendor/deep")).unwrap(); - fs::write(tree.join("rtl/vendor/deep/ip.v"), b"module ip; endmodule\n").unwrap(); - fs::write(tree.join("rtl/vendor/ip.v"), b"module ip; endmodule\n").unwrap(); - // A symbolic link to a directory outside the tree keeps the outside's permissions. - let outside = dir.join("outside"); - fs::create_dir(&outside).unwrap(); - fs::set_permissions(&outside, fs::Permissions::from_mode(0o555)).unwrap(); - std::os::unix::fs::symlink(&outside, tree.join("rtl/link")).unwrap(); - for sub in ["rtl/vendor/deep", "rtl/vendor"] { - fs::set_permissions(tree.join(sub), fs::Permissions::from_mode(0o555)).unwrap(); - } - remove_tree(&tree).unwrap(); - assert!(!tree.exists()); - let mode = fs::metadata(&outside).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o555, "the link target is untouched"); - fs::set_permissions(&outside, fs::Permissions::from_mode(0o755)).unwrap(); - remove_tree(&tree).unwrap(); // gone already: fine - } - - #[test] - fn link_trees() { - let dir = temp_dir("fsutil-link"); - let src = dir.join("src"); - fs::create_dir_all(src.join("sub")).unwrap(); - fs::write(src.join("a"), b"a").unwrap(); - fs::write(src.join("sub/b"), b"b").unwrap(); - let dst = dir.join("dst"); - link_or_copy(&src, &dst).unwrap(); - assert_eq!(fs::read(dst.join("sub/b")).unwrap(), b"b"); - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let ino = |p: &Path| fs::metadata(p).unwrap().ino(); - assert_eq!(ino(&src.join("a")), ino(&dst.join("a"))); - } - assert!(link_or_copy(&src.join("a"), &dst.join("a")).is_err()); - fsync_tree(&dst).unwrap(); - fsync_dirs(&dst).unwrap(); - } -} diff --git a/crates/core/rusteda-workspace/src/hash.rs b/crates/core/rusteda-workspace/src/hash.rs deleted file mode 100644 index bd68e87..0000000 --- a/crates/core/rusteda-workspace/src/hash.rs +++ /dev/null @@ -1,396 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Content hashes: xxh3-64, written `xxh3:<16 lowercase hex digits>` (WS-REQ-0010). -//! -//! - File hash: xxh3-64 of the file bytes, computed streaming ([`hash_file`], WS-REQ-0011). -//! - Directory hash: xxh3-64 over one record per file below the directory, sorted by the -//! relative path with `/` separators: `path NUL file-hash-hex LF` ([`hash_dir`], -//! WS-REQ-0012). -//! - List hash: xxh3-64 over one record per element, in list order, fields joined by NUL and -//! ended by LF ([`hash_records`]); the `libraries` pseudo-table uses it (WS-REQ-0013). - -use std::fmt; -use std::fs::{self, File}; -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; -use std::str::FromStr; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use xxhash_rust::xxh3::{Xxh3Default, xxh3_64}; - -/// An xxh3-64 hash, written `xxh3:<16 lowercase hex digits>`. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] -pub struct Hash64(pub u64); - -impl Hash64 { - /// The prefix of the notation. - pub const PREFIX: &'static str = "xxh3:"; - - /// Hashes bytes. - pub fn of(bytes: &[u8]) -> Hash64 { - Hash64(xxh3_64(bytes)) - } - - /// The 16 lowercase hex digits without the prefix (`h16` in cache file names). - pub fn hex(self) -> String { - format!("{:016x}", self.0) - } - - /// Reads `xxh3:<16 lowercase hex digits>`; any other text gives `None`. - pub fn parse(text: &str) -> Option { - Hash64::parse_hex(text.strip_prefix(Hash64::PREFIX)?) - } - - /// Reads exactly 16 lowercase hex digits. - pub fn parse_hex(hex: &str) -> Option { - let valid = hex.len() == 16 && hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); - if !valid { - return None; - } - u64::from_str_radix(hex, 16).ok().map(Hash64) - } -} - -impl fmt::Display for Hash64 { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}{:016x}", Hash64::PREFIX, self.0) - } -} - -impl fmt::Debug for Hash64 { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Hash64({self})") - } -} - -/// The error of parsing a [`Hash64`]. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("'{0}' is not a hash: expected xxh3:<16 lowercase hex digits>")] -pub struct ParseHashError(pub String); - -impl FromStr for Hash64 { - type Err = ParseHashError; - - fn from_str(text: &str) -> Result { - Hash64::parse(text).ok_or_else(|| ParseHashError(text.to_string())) - } -} - -impl Serialize for Hash64 { - fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for Hash64 { - fn deserialize>(deserializer: D) -> Result { - let text = String::deserialize(deserializer)?; - text.parse().map_err(serde::de::Error::custom) - } -} - -/// A streaming xxh3-64 hasher; also an [`io::Write`] sink. -#[derive(Clone, Default)] -pub struct Hasher(Xxh3Default); - -impl Hasher { - /// A hasher of the empty input. - pub fn new() -> Hasher { - Hasher::default() - } - - /// Appends bytes. - pub fn update(&mut self, bytes: &[u8]) { - self.0.update(bytes); - } - - /// The hash of everything appended so far. - pub fn finish(&self) -> Hash64 { - Hash64(self.0.digest()) - } -} - -impl fmt::Debug for Hasher { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Hasher").finish_non_exhaustive() - } -} - -impl Write for Hasher { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.update(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -/// Hashes everything a reader yields. -pub fn hash_reader(mut reader: impl Read) -> io::Result { - let mut hasher = Hasher::new(); - let mut buf = vec![0u8; 256 * 1024]; - loop { - match reader.read(&mut buf) { - Ok(0) => return Ok(hasher.finish()), - Ok(n) => hasher.update(&buf[..n]), - Err(e) if e.kind() == io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } -} - -/// The file hash: xxh3-64 of the file bytes, read in chunks (WS-REQ-0011). -pub fn hash_file(path: &Path) -> io::Result { - hash_reader(File::open(path)?) -} - -/// The list hash: xxh3-64 over the records in order, each record's fields joined by NUL and -/// ended by LF (WS-REQ-0013). -pub fn hash_records(records: impl IntoIterator) -> Hash64 -where - R: IntoIterator, - F: AsRef<[u8]>, -{ - let mut hasher = Hasher::new(); - for record in records { - for (i, field) in record.into_iter().enumerate() { - if i > 0 { - hasher.update(b"\0"); - } - hasher.update(field.as_ref()); - } - hasher.update(b"\n"); - } - hasher.finish() -} - -/// The files below `dir` with their file hashes, sorted by relative path (`/` separators, -/// byte order). Symbolic links, special files and names that are not UTF-8 are errors -/// (`InvalidData`); empty directories contribute nothing. -pub fn dir_file_hashes(dir: &Path) -> io::Result> { - walk_files(dir) - .map_err(WalkError::into_io)? - .into_iter() - .map(|(rel, path)| Ok((rel, hash_file(&path)?))) - .collect() -} - -/// The directory hash: xxh3-64 over the records `relative path, NUL, file-hash hex, LF` of -/// [`dir_file_hashes`] (WS-REQ-0012). -pub fn hash_dir(dir: &Path) -> io::Result { - Ok(dir_hash(&dir_file_hashes(dir)?)) -} - -/// The directory hash of files with their hashes, sorted by relative path (WS-REQ-0012). -pub(crate) fn dir_hash(files: &[(String, Hash64)]) -> Hash64 { - hash_records( - files - .iter() - .map(|(rel, hash)| [rel.as_bytes().to_vec(), hash.hex().into_bytes()]), - ) -} - -/// Why a directory tree cannot be walked as a directory table. -#[derive(Debug)] -pub(crate) enum WalkError { - /// Reading a directory failed. - Io(PathBuf, io::Error), - /// An entry is not a regular file or a directory: a symbolic link, FIFO, device or socket. - Special { - /// The relative path, `/`-separated. - rel: String, - /// The full path. - path: PathBuf, - }, - /// An entry name is not UTF-8. - NotUtf8 { - /// The relative path, `/`-separated, with the name shown lossily. - rel: String, - /// The full path. - path: PathBuf, - }, -} - -impl WalkError { - /// The error of the public `io::Result` functions: `InvalidData` for a special file or a - /// name that is not UTF-8. - pub(crate) fn into_io(self) -> io::Error { - match self { - WalkError::Io(_, e) => e, - WalkError::Special { rel, .. } => io::Error::new( - io::ErrorKind::InvalidData, - format!("{rel}: not a regular file or directory"), - ), - WalkError::NotUtf8 { rel, .. } => io::Error::new( - io::ErrorKind::InvalidData, - format!("{rel}: name is not UTF-8"), - ), - } - } -} - -/// The regular files below `dir`, as (relative path with `/` separators, full path), sorted by -/// the bytes of the relative path. Symbolic links are not followed: they, special files and -/// names that are not UTF-8 are errors; empty directories contribute nothing. -pub(crate) fn walk_files(dir: &Path) -> Result, WalkError> { - let mut files = Vec::new(); - collect_files(dir, "", &mut files)?; - files.sort_unstable_by(|a, b| a.0.cmp(&b.0)); - Ok(files) -} - -fn collect_files( - dir: &Path, - prefix: &str, - out: &mut Vec<(String, PathBuf)>, -) -> Result<(), WalkError> { - let io_err = |e| WalkError::Io(dir.to_path_buf(), e); - for entry in fs::read_dir(dir).map_err(io_err)? { - let entry = entry.map_err(io_err)?; - let join = |name: &str| { - if prefix.is_empty() { - name.to_string() - } else { - format!("{prefix}/{name}") - } - }; - let name = match entry.file_name().into_string() { - Ok(name) => name, - Err(name) => { - return Err(WalkError::NotUtf8 { - rel: join(&Path::new(&name).display().to_string()), - path: entry.path(), - }); - } - }; - let rel = join(&name); - let kind = entry.file_type().map_err(io_err)?; - if kind.is_dir() { - collect_files(&entry.path(), &rel, out)?; - } else if kind.is_file() { - out.push((rel, entry.path())); - } else { - let path = entry.path(); - return Err(WalkError::Special { rel, path }); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::testutil::temp_dir; - - // WS-REQ-0010 - #[test] - fn notation() { - let h = Hash64(0x0123_4567_89ab_cdef); - assert_eq!(h.to_string(), "xxh3:0123456789abcdef"); - assert_eq!(h.hex(), "0123456789abcdef"); - assert_eq!(Hash64(1).to_string(), "xxh3:0000000000000001"); - assert_eq!(Hash64::parse("xxh3:0123456789abcdef"), Some(h)); - assert_eq!("xxh3:0123456789abcdef".parse::(), Ok(h)); - for bad in [ - "", - "0123456789abcdef", - "xxh3:0123456789ABCDEF", - "xxh3:0123456789abcde", - "xxh3:0123456789abcdef0", - "xxh3:+123456789abcdef", - "xxh64:0123456789abcdef", - "xxh3: 123456789abcdef", - ] { - assert_eq!(Hash64::parse(bad), None, "{bad}"); - } - assert_eq!( - serde_json::to_string(&h).unwrap(), - "\"xxh3:0123456789abcdef\"" - ); - assert_eq!( - serde_json::from_str::("\"xxh3:0123456789abcdef\"").unwrap(), - h - ); - assert!(serde_json::from_str::("\"xxh3:XYZ\"").is_err()); - assert_eq!(format!("{h:?}"), "Hash64(xxh3:0123456789abcdef)"); - } - - // WS-REQ-0011: streaming equals one-shot hashing, whatever the chunking. - #[test] - fn streaming_matches_one_shot() { - // The xxh3-64 of the empty input (default seed and secret). - assert_eq!(Hash64::of(b""), Hash64(0x2d06_8005_38d3_94c2)); - let data: Vec = (0..1_000_003u32) - .map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8) - .collect(); - let whole = Hash64::of(&data); - for chunk in [1, 7, 64, 240, 4096, 1 << 20] { - let mut h = Hasher::new(); - for part in data.chunks(chunk) { - h.update(part); - } - assert_eq!(h.finish(), whole, "chunk {chunk}"); - } - assert_eq!(hash_reader(&data[..]).unwrap(), whole); - let dir = temp_dir("hash-file"); - let path = dir.join("f.bin"); - fs::write(&path, &data).unwrap(); - assert_eq!(hash_file(&path).unwrap(), whole); - } - - // WS-REQ-0013 - #[test] - fn records() { - let h = hash_records([["a", "b"], ["c", "d"]]); - assert_eq!(h, Hash64::of(b"a\0b\nc\0d\n")); - assert_eq!(hash_records(Vec::>::new()), Hash64::of(b"")); - assert_ne!( - hash_records([["a", "b"], ["c", "d"]]), - hash_records([["c", "d"], ["a", "b"]]) - ); - } - - // WS-REQ-0012 - #[test] - fn directory_hash() { - let dir = temp_dir("hash-dir"); - fs::create_dir_all(dir.join("a/b")).unwrap(); - fs::create_dir_all(dir.join("empty")).unwrap(); - fs::write(dir.join("a.txt"), b"x").unwrap(); - fs::write(dir.join("a/b/c.v"), b"module c; endmodule\n").unwrap(); - fs::write(dir.join("a/z"), b"").unwrap(); - let files = dir_file_hashes(&dir).unwrap(); - let names: Vec<&str> = files.iter().map(|f| f.0.as_str()).collect(); - // Byte order of the '/'-separated paths: '.' (0x2e) sorts before '/' (0x2f). - assert_eq!(names, ["a.txt", "a/b/c.v", "a/z"]); - let mut text = Vec::new(); - for (rel, data) in [ - ("a.txt", &b"x"[..]), - ("a/b/c.v", b"module c; endmodule\n"), - ("a/z", b""), - ] { - text.extend_from_slice(rel.as_bytes()); - text.push(0); - text.extend_from_slice(Hash64::of(data).hex().as_bytes()); - text.push(b'\n'); - } - assert_eq!(hash_dir(&dir).unwrap(), Hash64::of(&text)); - // Content and names both count. - fs::write(dir.join("a/z"), b"1").unwrap(); - assert_ne!(hash_dir(&dir).unwrap(), Hash64::of(&text)); - } - - #[cfg(unix)] - #[test] - fn directory_hash_rejects_symlinks() { - let dir = temp_dir("hash-dir-symlink"); - fs::write(dir.join("f"), b"x").unwrap(); - std::os::unix::fs::symlink("f", dir.join("link")).unwrap(); - let err = hash_dir(&dir).unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - assert!(err.to_string().contains("link"), "{err}"); - } -} diff --git a/crates/core/rusteda-workspace/src/json.rs b/crates/core/rusteda-workspace/src/json.rs deleted file mode 100644 index 4a446f2..0000000 --- a/crates/core/rusteda-workspace/src/json.rs +++ /dev/null @@ -1,591 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! An order-preserving JSON reformatter (WS-REQ-0091, WS-REQ-0092). -//! -//! `serde_json` is built without `preserve_order`, so a `serde_json::Value` sorts the keys of -//! objects. The envelope therefore keeps nested values as JSON text, splices them, and -//! reformats the result here: [`reformat`] re-indents or compacts JSON text without reordering -//! anything and copies strings and numbers verbatim. The pretty style is the one of -//! `serde_json::to_string_pretty`: two-space indents, `"key": value`, and `{}` / `[]` for empty -//! containers. -//! -//! The text must be one JSON value in which no object has two equal keys (compared after -//! unescaping) and every `\u` escape is a whole character, so that every reader sees the same -//! value. The same parse gives the ordered value tree behind the `Serialize` implementations of -//! [`crate::RawJson`] and [`crate::JsonObject`]. - -use std::collections::BTreeSet; -use std::fmt::Write as _; - -use serde::ser::{Error as _, SerializeMap, SerializeSeq}; -use serde::{Serialize, Serializer}; - -/// How [`reformat`] lays out JSON. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum JsonStyle { - /// One value per line, two-space indents. - #[default] - Pretty, - /// No whitespace at all. - Compact, -} - -/// Malformed JSON text. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("invalid JSON at byte {offset}: {reason}")] -pub struct JsonSyntaxError { - /// Byte offset of the problem. - pub offset: usize, - /// What is wrong. - pub reason: &'static str, -} - -/// The deepest nesting [`reformat`] accepts. -pub const MAX_DEPTH: usize = 256; - -/// Re-lays out one JSON value, keeping the order of keys and the text of scalars. An object -/// with two equal keys is an error (WS-REQ-0091). -pub fn reformat(text: &str, style: JsonStyle) -> Result { - let node = parse(text)?; - let mut out = String::with_capacity(text.len() * 2); - print(&node, style, 0, &mut out); - Ok(out) -} - -/// Checks that `text` is one JSON value without duplicate keys. -pub fn validate(text: &str) -> Result<(), JsonSyntaxError> { - parse(text).map(drop) -} - -/// A JSON string literal, escaped like `serde_json` does: `"`, `\`, and control characters -/// (`\b \f \n \r \t`, otherwise `\u00xx`); everything else verbatim. -pub fn quote(text: &str) -> String { - let mut out = String::with_capacity(text.len() + 2); - out.push('"'); - for c in text.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - '\u{8}' => out.push_str("\\b"), - '\u{c}' => out.push_str("\\f"), - c if u32::from(c) < 0x20 => { - let _ = write!(out, "\\u{:04x}", u32::from(c)); - } - c => out.push(c), - } - } - out.push('"'); - out -} - -/// A parsed JSON value that keeps the text of its scalars and the order of its keys. -#[derive(Debug, Clone, PartialEq)] -pub(crate) enum Node<'a> { - /// `null`, `true`, `false` or a number, as written. - Literal(&'a str), - /// A string: its text with quotes and escapes, and its value. - Str { raw: &'a str, value: String }, - /// An array. - Array(Vec>), - /// An object: (key text with quotes, key value, value), in text order. - Object(Vec<(&'a str, String, Node<'a>)>), -} - -/// Parses one JSON value without duplicate keys. -pub(crate) fn parse(text: &str) -> Result, JsonSyntaxError> { - let mut parser = Parser { text, pos: 0 }; - let node = parser.value(0)?; - parser.skip_ws(); - if parser.pos != text.len() { - return Err(parser.error("trailing characters after the value")); - } - Ok(node) -} - -fn newline(style: JsonStyle, depth: usize, out: &mut String) { - if style == JsonStyle::Pretty { - out.push('\n'); - for _ in 0..depth { - out.push_str(" "); - } - } -} - -fn print(node: &Node<'_>, style: JsonStyle, depth: usize, out: &mut String) { - match node { - Node::Literal(text) => out.push_str(text), - Node::Str { raw, .. } => out.push_str(raw), - Node::Array(items) if items.is_empty() => out.push_str("[]"), - Node::Object(fields) if fields.is_empty() => out.push_str("{}"), - Node::Array(items) => { - out.push('['); - for (i, item) in items.iter().enumerate() { - if i > 0 { - out.push(','); - } - newline(style, depth + 1, out); - print(item, style, depth + 1, out); - } - newline(style, depth, out); - out.push(']'); - } - Node::Object(fields) => { - out.push('{'); - for (i, (key, _, value)) in fields.iter().enumerate() { - if i > 0 { - out.push(','); - } - newline(style, depth + 1, out); - out.push_str(key); - out.push_str(if style == JsonStyle::Pretty { - ": " - } else { - ":" - }); - print(value, style, depth + 1, out); - } - newline(style, depth, out); - out.push('}'); - } - } -} - -/// Serializes a JSON number from its text: an integer as `u64` or `i64` when it fits, anything -/// else as `f64` (the value is kept; the text may change, e.g. `1.50` becomes `1.5`). -fn serialize_number(text: &str, serializer: S) -> Result { - if !text.contains(['.', 'e', 'E']) { - if let Ok(v) = text.parse::() { - return serializer.serialize_u64(v); - } - if let Ok(v) = text.parse::() { - return serializer.serialize_i64(v); - } - } - match text.parse::() { - Ok(v) if v.is_finite() => serializer.serialize_f64(v), - _ => Err(S::Error::custom(format!( - "the number {text} is out of the range of f64" - ))), - } -} - -impl Serialize for Node<'_> { - fn serialize(&self, serializer: S) -> Result { - match self { - Node::Literal("null") => serializer.serialize_unit(), - Node::Literal("true") => serializer.serialize_bool(true), - Node::Literal("false") => serializer.serialize_bool(false), - Node::Literal(number) => serialize_number(number, serializer), - Node::Str { value, .. } => serializer.serialize_str(value), - Node::Array(items) => { - let mut seq = serializer.serialize_seq(Some(items.len()))?; - for item in items { - seq.serialize_element(item)?; - } - seq.end() - } - Node::Object(fields) => { - let mut map = serializer.serialize_map(Some(fields.len()))?; - for (_, key, value) in fields { - map.serialize_entry(key, value)?; - } - map.end() - } - } - } -} - -struct Parser<'a> { - text: &'a str, - pos: usize, -} - -impl<'a> Parser<'a> { - fn error(&self, reason: &'static str) -> JsonSyntaxError { - JsonSyntaxError { - offset: self.pos, - reason, - } - } - - fn peek(&self) -> Option { - self.text.as_bytes().get(self.pos).copied() - } - - fn skip_ws(&mut self) { - while let Some(b' ' | b'\t' | b'\n' | b'\r') = self.peek() { - self.pos += 1; - } - } - - /// `text[start..pos]`; both ends are at ASCII bytes, hence char boundaries. - fn since(&self, start: usize) -> &'a str { - &self.text[start..self.pos] - } - - fn value(&mut self, depth: usize) -> Result, JsonSyntaxError> { - if depth > MAX_DEPTH { - return Err(self.error("nesting too deep")); - } - self.skip_ws(); - match self.peek() { - Some(b'{') => self.object(depth), - Some(b'[') => self.array(depth), - Some(b'"') => { - let (raw, value) = self.string()?; - Ok(Node::Str { raw, value }) - } - Some(b'-' | b'0'..=b'9') => self.number(), - Some(b't') => self.literal("true"), - Some(b'f') => self.literal("false"), - Some(b'n') => self.literal("null"), - Some(_) => Err(self.error("expected a value")), - None => Err(self.error("unexpected end of input")), - } - } - - /// After `[` or `{`: true if the container is empty (and consumed). - fn open(&mut self, close: u8) -> bool { - self.pos += 1; - self.skip_ws(); - if self.peek() == Some(close) { - self.pos += 1; - return true; - } - false - } - - /// After a member: true at the end of the container (consumed), false after a `,`. - fn next_or_close(&mut self, close: u8, reason: &'static str) -> Result { - self.skip_ws(); - match self.peek() { - Some(b',') => { - self.pos += 1; - Ok(false) - } - Some(b) if b == close => { - self.pos += 1; - Ok(true) - } - _ => Err(self.error(reason)), - } - } - - fn array(&mut self, depth: usize) -> Result, JsonSyntaxError> { - let mut items = Vec::new(); - if self.open(b']') { - return Ok(Node::Array(items)); - } - loop { - items.push(self.value(depth + 1)?); - if self.next_or_close(b']', "expected ',' or ']'")? { - return Ok(Node::Array(items)); - } - } - } - - fn object(&mut self, depth: usize) -> Result, JsonSyntaxError> { - let mut fields = Vec::new(); - if self.open(b'}') { - return Ok(Node::Object(fields)); - } - let mut keys = BTreeSet::new(); - loop { - self.skip_ws(); - if self.peek() != Some(b'"') { - return Err(self.error("expected a key")); - } - let at = self.pos; - let (raw, key) = self.string()?; - if !keys.insert(key.clone()) { - return Err(JsonSyntaxError { - offset: at, - reason: "duplicate key in an object", - }); - } - self.skip_ws(); - if self.peek() != Some(b':') { - return Err(self.error("expected ':'")); - } - self.pos += 1; - let value = self.value(depth + 1)?; - fields.push((raw, key, value)); - if self.next_or_close(b'}', "expected ',' or '}'")? { - return Ok(Node::Object(fields)); - } - } - } - - /// A string literal: its text with quotes, and its value. - fn string(&mut self) -> Result<(&'a str, String), JsonSyntaxError> { - let start = self.pos; - self.pos += 1; - let mut value = String::new(); - loop { - let Some(c) = self.text[self.pos..].chars().next() else { - return Err(self.error("unterminated string")); - }; - match c { - '"' => { - self.pos += 1; - return Ok((self.since(start), value)); - } - '\\' => { - self.pos += 1; - value.push(self.escape()?); - } - c if u32::from(c) < 0x20 => { - return Err(self.error("control character in a string")); - } - c => { - self.pos += c.len_utf8(); - value.push(c); - } - } - } - } - - /// The character of an escape, after its backslash. - fn escape(&mut self) -> Result { - let c = match self.peek() { - Some(b'"') => '"', - Some(b'\\') => '\\', - Some(b'/') => '/', - Some(b'b') => '\u{8}', - Some(b'f') => '\u{c}', - Some(b'n') => '\n', - Some(b'r') => '\r', - Some(b't') => '\t', - Some(b'u') => { - self.pos += 1; - let unit = self.hex4()?; - return self.code_point(unit); - } - _ => return Err(self.error("bad escape")), - }; - self.pos += 1; - Ok(c) - } - - fn hex4(&mut self) -> Result { - let digits = self.text.get(self.pos..self.pos + 4).unwrap_or(""); - if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(self.error("bad \\u escape")); - } - self.pos += 4; - u32::from_str_radix(digits, 16).map_err(|_| self.error("bad \\u escape")) - } - - /// The character of `\u` code unit `unit`, reading the low half of a surrogate pair; a - /// lone surrogate is an error. - fn code_point(&mut self, unit: u32) -> Result { - let lone = "lone surrogate in a \\u escape"; - let code = match unit { - 0xD800..=0xDBFF => { - if !self.text[self.pos..].starts_with("\\u") { - return Err(self.error(lone)); - } - self.pos += 2; - let low = self.hex4()?; - if !(0xDC00..=0xDFFF).contains(&low) { - return Err(self.error(lone)); - } - 0x10000 + ((unit - 0xD800) << 10) + (low - 0xDC00) - } - 0xDC00..=0xDFFF => return Err(self.error(lone)), - _ => unit, - }; - char::from_u32(code).ok_or_else(|| self.error(lone)) - } - - fn number(&mut self) -> Result, JsonSyntaxError> { - let start = self.pos; - if self.peek() == Some(b'-') { - self.pos += 1; - } - match self.peek() { - Some(b'0') => self.pos += 1, - Some(b'1'..=b'9') => self.digits(), - _ => return Err(self.error("bad number")), - } - if self.peek() == Some(b'.') { - self.pos += 1; - self.required_digits()?; - } - if let Some(b'e' | b'E') = self.peek() { - self.pos += 1; - if let Some(b'+' | b'-') = self.peek() { - self.pos += 1; - } - self.required_digits()?; - } - Ok(Node::Literal(self.since(start))) - } - - fn digits(&mut self) { - while self.peek().is_some_and(|c| c.is_ascii_digit()) { - self.pos += 1; - } - } - - fn required_digits(&mut self) -> Result<(), JsonSyntaxError> { - let start = self.pos; - self.digits(); - if self.pos == start { - Err(self.error("expected digits")) - } else { - Ok(()) - } - } - - fn literal(&mut self, word: &'static str) -> Result, JsonSyntaxError> { - if !self.text.as_bytes()[self.pos..].starts_with(word.as_bytes()) { - return Err(self.error("bad literal")); - } - self.pos += word.len(); - Ok(Node::Literal(word)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::{Value, json}; - - fn samples() -> Vec { - vec![ - json!(null), - json!(true), - json!(-0.0), - json!(1.5e300), - json!("a \"q\" \\ \n \u{1} é ✓ 😀"), - json!([]), - json!({}), - json!([1, [2, []], {}]), - json!({"b": {"c": [1, 2, {"d": null}], "e": {}}, "a": [[]], "z": "x"}), - json!([{"k": [{"k": [{"k": []}]}]}]), - json!([u64::MAX, i64::MIN, 0.1, -2.5e-7]), - ] - } - - // WS-REQ-0092: the pretty style is the one of serde_json; compact is to_string. - #[test] - fn matches_serde_json_layout() { - for v in samples() { - let compact = serde_json::to_string(&v).unwrap(); - let pretty = serde_json::to_string_pretty(&v).unwrap(); - assert_eq!(reformat(&compact, JsonStyle::Pretty).unwrap(), pretty); - assert_eq!(reformat(&pretty, JsonStyle::Compact).unwrap(), compact); - assert_eq!(reformat(&pretty, JsonStyle::Pretty).unwrap(), pretty); - } - } - - // WS-REQ-0091: keys keep their order; scalars keep their text. - #[test] - fn keeps_order_and_text() { - let text = r#" { "z" : 1.50 , "a" : [ 1E3 , -0 , "\u00e9\/" ] , "m" : { } } "#; - assert_eq!( - reformat(text, JsonStyle::Compact).unwrap(), - r#"{"z":1.50,"a":[1E3,-0,"\u00e9\/"],"m":{}}"# - ); - assert_eq!( - reformat(text, JsonStyle::Pretty).unwrap(), - "{\n \"z\": 1.50,\n \"a\": [\n 1E3,\n -0,\n \"\\u00e9\\/\"\n ],\n \"m\": {}\n}" - ); - } - - #[test] - fn rejects_malformed_text() { - for bad in [ - "", - "{", - "[1,]", - "{\"a\"}", - "{\"a\":}", - "{a:1}", - "[1 2]", - "01", - "1.", - "1e", - "-", - "+1", - "tru", - "nul", - "\"a", - "\"\\x\"", - "\"\\u12g4\"", - "\"\u{1}\"", - "1 2", - "[]]", - ] { - assert!(validate(bad).is_err(), "{bad:?}"); - } - let deep = format!("{}{}", "[".repeat(MAX_DEPTH + 2), "]".repeat(MAX_DEPTH + 2)); - assert_eq!(validate(&deep).unwrap_err().reason, "nesting too deep"); - let ok = format!("{}{}", "[".repeat(MAX_DEPTH), "]".repeat(MAX_DEPTH)); - validate(&ok).unwrap(); - } - - // WS-REQ-0091 (review CR2-7): an object with two equal keys, also when one of them is - // written with escapes, is rejected at any depth; equal keys in different objects are fine. - #[test] - fn rejects_duplicate_keys() { - for bad in [ - r#"{"corner":"a","corner":"b"}"#, - r#"{"a":1,"\u0061":2}"#, - r#"{"x":[{"k":1,"k":1}]}"#, - r#"{"é":1,"\u00e9":2}"#, - ] { - let err = validate(bad).unwrap_err(); - assert_eq!(err.reason, "duplicate key in an object", "{bad}"); - } - validate(r#"{"k":{"k":1},"j":[{"k":1},{"k":2}]}"#).unwrap(); - // Lone surrogates do not decode to a character. - for bad in [r#""\ud800""#, r#""\udc00""#, r#""\ud800\u0041""#] { - assert!(validate(bad).is_err(), "{bad}"); - } - assert_eq!( - parse(r#""\ud83d\ude00""#).unwrap(), - Node::Str { - raw: r#""\ud83d\ude00""#, - value: "😀".into() - } - ); - } - - // WS-REQ-0091 (review CR2-9): the tree serializes through serde in text order, with the - // values of its scalars. - #[test] - fn tree_serializes_in_text_order() { - let text = r#"{"z":1.50,"a":[1E3,-0,"\u00e9\/",null,true,false],"m":{},"n":-7,"u":18446744073709551615}"#; - let node = parse(text).unwrap(); - assert_eq!( - serde_json::to_string(&node).unwrap(), - r#"{"z":1.5,"a":[1000.0,0,"é/",null,true,false],"m":{},"n":-7,"u":18446744073709551615}"# - ); - for v in samples() { - let compact = serde_json::to_string(&v).unwrap(); - assert_eq!( - serde_json::to_string(&parse(&compact).unwrap()).unwrap(), - compact - ); - } - let err = serde_json::to_string(&parse("1e400").unwrap()).unwrap_err(); - assert!(err.to_string().contains("out of the range"), "{err}"); - } - - #[test] - fn quoting_matches_serde_json() { - let mut all: String = (0u32..0x80).filter_map(char::from_u32).collect(); - all.push_str("é✓\u{2028}😀"); - assert_eq!(quote(&all), serde_json::to_string(&all).unwrap()); - for c in all.chars() { - let s = c.to_string(); - assert_eq!(quote(&s), serde_json::to_string(&s).unwrap(), "{c:?}"); - } - } -} diff --git a/crates/core/rusteda-workspace/src/lib.rs b/crates/core/rusteda-workspace/src/lib.rs deleted file mode 100644 index 8dd85b1..0000000 --- a/crates/core/rusteda-workspace/src/lib.rs +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The RustEDA disk workspace, shared verbatim by all RustEDA tools. -//! -//! A workspace is a directory of immutable revisions. Each revision is a directory of tables -//! (`rbin` containers, JSON files or directory snapshots) and a `meta.json` that records the -//! hash of every table and the hashes of the tables it was computed from. Tools are stateless -//! processes: a query maps the tables it needs; a mutation names its base revision, computes -//! its tables lock-free into a staging directory and commits them as a new revision under an -//! exclusive lock, failing with `REVISION_CONFLICT` when HEAD moved in the meantime. -//! -//! The crate is tool-neutral: it knows tables, hashes, revisions and envelopes, never what a -//! netlist or a timing graph is. The NETLIST and LIBERTY codecs that the contract (§1, §5) -//! assigns to this crate, and with them the `rlib` module that holds the normative LIBERTY v1 -//! grammar, are deferred until `rusteda-core` is available: this crate does not depend on any -//! other RustEDA crate yet. -//! -//! | Module | Content | -//! | --- | --- | -//! | [`hash`] | xxh3-64 hashes of bytes, files, directories and lists; `xxh3:<16 hex>` | -//! | [`rbin`] | the little-endian `rbin` container: deterministic writer, validating zero-copy reader | -//! | [`rev`] | revisions `rev`, `HEAD` for queries only, object handles `` | -//! | [`schema`] | `manifest.json`, `meta.json`, `history.jsonl` and `ui_state.json` | -//! | [`sta`] | the read-only view of the timing table `sta.rbin` (`STA` v1) and a builder of such tables | -//! | [`workspace`] | init, open, resolve, transactions and the commit protocol, revert, history, and the locks on `workspace.lock` they take | -//! | [`cache`] | the library cache `cache/-.lib` / `.rlib` | -//! | [`edits`] | netlist edit files `rusteda.edits.v1` and their application to a netlist | -//! | [`envelope`] | the `rusteda.v1` JSON envelope, diagnostics and ordered JSON objects | -//! | [`error`] | the error type, envelope error codes, exit codes and `W` diagnostic codes | -//! | [`json`] | the order-preserving JSON reformatter behind the envelope | -//! | [`text`] | text-mode output lines and POSIX shell quoting | -//! -//! Comments cite the requirements of the workspace specification as `WS-REQ-nnnn`. - -#![warn(missing_docs)] - -// WS-REQ-0035: rbin tables are little-endian and are mapped without conversion. -#[cfg(not(target_endian = "little"))] -compile_error!( - "rusteda-workspace supports little-endian targets only: rbin tables are little-endian" -); - -pub mod cache; -pub mod edits; -pub mod envelope; -pub mod error; -mod fsutil; -pub mod hash; -pub mod json; -mod lock; -mod mmap; -pub mod netlist; -pub mod rbin; -pub mod rev; -pub mod rlib; -pub mod schema; -pub mod sta; -pub mod text; -pub mod workspace; - -pub use cache::{CachedLibrary, LibraryCache, PutError}; -pub use envelope::{ - DiagCode, DiagLocation, Diagnostic, Envelope, ErrorInfo, JsonObject, RawJson, Severity, -}; -pub use error::{Corruption, Error, ErrorCode, Result, codes, exit}; -pub use hash::Hash64; -pub use json::JsonStyle; -pub use rbin::{Rbin, RbinWriter, SectionInfo, UpstreamInfo}; -pub use rev::{BadHandle, BaseRev, Handle, Rev, RevSpec}; -pub use schema::{ - DroppedEntry, HistoryEntry, HistoryError, JsonMap, LibraryEntry, Manifest, RevisionMeta, - SourceEntry, TableEntry, TableValues, UiState, Upstream, -}; -pub use workspace::{ - Committed, Forked, Initialized, Invocation, Reverted, Revision, Txn, Workspace, -}; - -#[cfg(test)] -pub(crate) mod testutil; - -/// The README's example, compiled and run as a doctest. -#[cfg(doctest)] -#[doc = include_str!("../README.md")] -#[derive(Debug)] -pub struct ReadmeDoctests; - -/// The lock on `workspace.lock` is internal (WS-REQ-0051): every `Workspace` method takes the -/// lock it needs and releases it before it returns, so a caller can never hold the lock while -/// calling the workspace, which would wait for itself. -/// -/// ```compile_fail -/// let _held = rusteda_workspace::WorkspaceLock::exclusive(std::path::Path::new("ws")); -/// ``` -/// -/// ```compile_fail -/// let _held = rusteda_workspace::lock::WorkspaceLock::exclusive(std::path::Path::new("ws")); -/// ``` -/// -/// ``` -/// let _open = rusteda_workspace::Workspace::open(std::path::Path::new("no-workspace-here")); -/// ``` -#[cfg(doctest)] -#[derive(Debug)] -pub struct LockIsInternal; - -/// Only files that the workspace never writes again are memory-mapped (WS-REQ-0037, review -/// R2-C1): there is no public way to map an arbitrary path, so safe code cannot hold a `&[T]` -/// that a write or a truncation of the file changes. A file outside a workspace is read. -/// -/// ```compile_fail -/// let _ = rusteda_workspace::Rbin::open("t.rbin", "STA", 1..=1); -/// ``` -/// -/// ```compile_fail -/// let _ = rusteda_workspace::Rbin::map_immutable(std::path::Path::new("t.rbin"), "STA", 1..=1); -/// ``` -/// -/// ``` -/// let _ = rusteda_workspace::Rbin::read("no-such-table.rbin", "STA", 1..=1); -/// ``` -#[cfg(doctest)] -#[derive(Debug)] -pub struct MapsAreInternal; diff --git a/crates/core/rusteda-workspace/src/lock.rs b/crates/core/rusteda-workspace/src/lock.rs deleted file mode 100644 index 7588c33..0000000 --- a/crates/core/rusteda-workspace/src/lock.rs +++ /dev/null @@ -1,228 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Locks on `workspace.lock` (WS-REQ-0050). -//! -//! Readers hold a shared lock while they read `manifest.json`; init, commit, revert and the -//! logging of rejected commands hold an exclusive lock. Every lock opens `workspace.lock` anew, -//! so each lock has its own open file description: two locks taken in one process, for example -//! by two threads, exclude each other exactly like locks of two processes. The locks are -//! advisory (`flock` through [`std::fs::File::lock`]); dropping a lock releases it, and so does -//! the end of the process. -//! -//! The lock is internal to the crate (WS-REQ-0051): every [`crate::Workspace`] method takes the -//! lock it needs and releases it before it returns, so no caller can hold a lock across calls -//! and deadlock against the next call of its own thread. -//! -//! A wait for a lock is not cancellable. A signal that interrupts it (`EINTR`, a handler -//! installed without `SA_RESTART`) does not end it: the standard library reports the -//! interruption instead of retrying, and the wait starts again (WS-REQ-0050). - -use std::fs::{File, OpenOptions}; -use std::io; -use std::path::Path; - -use crate::error::{Error, Result}; -use crate::workspace::layout; - -/// A shared or exclusive lock on `/workspace.lock`, released on drop. -#[derive(Debug)] -pub(crate) struct WorkspaceLock { - file: File, -} - -impl WorkspaceLock { - /// Waits for a shared lock. - pub(crate) fn shared(root: &Path) -> Result { - let file = open(root, false)?; - retry_interrupted(|| file.lock_shared()) - .map_err(|e| Error::io(root.join(layout::LOCK), e))?; - Ok(WorkspaceLock { file }) - } - - /// Waits for an exclusive lock. - pub(crate) fn exclusive(root: &Path) -> Result { - let file = open(root, true)?; - retry_interrupted(|| file.lock()).map_err(|e| Error::io(root.join(layout::LOCK), e))?; - Ok(WorkspaceLock { file }) - } - - /// A shared lock if no exclusive lock is held, else `None`. - #[cfg(test)] - pub(crate) fn try_shared(root: &Path) -> Result> { - let file = open(root, false)?; - match retry_would_block(|| file.try_lock_shared()) { - Ok(true) => Ok(Some(WorkspaceLock { file })), - Ok(false) => Ok(None), - Err(e) => Err(Error::io(root.join(layout::LOCK), e)), - } - } - - /// An exclusive lock if no other lock is held, else `None`. - #[cfg(test)] - pub(crate) fn try_exclusive(root: &Path) -> Result> { - let file = open(root, true)?; - match retry_would_block(|| file.try_lock()) { - Ok(true) => Ok(Some(WorkspaceLock { file })), - Ok(false) => Ok(None), - Err(e) => Err(Error::io(root.join(layout::LOCK), e)), - } - } -} - -impl Drop for WorkspaceLock { - fn drop(&mut self) { - // Closing the file releases the lock too; unlocking first makes the release explicit. - let _ = self.file.unlock(); - } -} - -/// Runs a blocking lock call until it is not interrupted by a signal: `File::lock` and -/// `File::lock_shared` return `ErrorKind::Interrupted` on `EINTR` (WS-REQ-0050). -fn retry_interrupted(mut call: impl FnMut() -> io::Result) -> io::Result { - loop { - match call() { - Err(e) if e.kind() == io::ErrorKind::Interrupted => {} - other => return other, - } - } -} - -/// Runs a non-blocking lock call until it is not interrupted: `Ok(true)` when the lock was -/// taken, `Ok(false)` when another lock holds it. -#[cfg(test)] -fn retry_would_block( - mut call: impl FnMut() -> std::result::Result<(), std::fs::TryLockError>, -) -> io::Result { - use std::fs::TryLockError; - loop { - match call() { - Ok(()) => return Ok(true), - Err(TryLockError::WouldBlock) => return Ok(false), - Err(TryLockError::Error(e)) if e.kind() == io::ErrorKind::Interrupted => {} - Err(TryLockError::Error(e)) => return Err(e), - } - } -} - -/// Opens the lock file: read-only for a shared lock, so that a read-only workspace can be -/// queried, and read-write for an exclusive lock (emulated `flock` on network file systems -/// needs write access for it). The file is never created here: a lock on a re-created file -/// would not exclude a lock on the old one. -fn open(root: &Path, write: bool) -> Result { - let path = root.join(layout::LOCK); - OpenOptions::new() - .read(true) - .write(write) - .open(&path) - .map_err(|e| { - if e.kind() == io::ErrorKind::NotFound && !root.join(layout::MANIFEST).exists() { - Error::NotAWorkspace { - path: root.to_path_buf(), - } - } else { - Error::io(path, e) - } - }) -} - -#[cfg(test)] -mod tests { - use std::fs::TryLockError; - - use super::*; - use crate::error::ErrorCode; - use crate::testutil::temp_dir; - - // WS-REQ-0050: separate open file descriptions of one process exclude each other. - #[test] - fn shared_and_exclusive() { - let root = temp_dir("lock"); - std::fs::write(root.join(layout::LOCK), b"").unwrap(); - let a = WorkspaceLock::shared(&root).unwrap(); - let b = WorkspaceLock::try_shared(&root) - .unwrap() - .expect("shared locks coexist"); - assert!(WorkspaceLock::try_exclusive(&root).unwrap().is_none()); - drop(a); - assert!(WorkspaceLock::try_exclusive(&root).unwrap().is_none()); - drop(b); - let x = WorkspaceLock::try_exclusive(&root) - .unwrap() - .expect("no lock is held"); - assert!(WorkspaceLock::try_shared(&root).unwrap().is_none()); - assert!(WorkspaceLock::try_exclusive(&root).unwrap().is_none()); - drop(x); - assert!(WorkspaceLock::try_shared(&root).unwrap().is_some()); - let x = WorkspaceLock::exclusive(&root).unwrap(); - assert!(WorkspaceLock::try_shared(&root).unwrap().is_none()); - drop(x); - } - - // WS-REQ-0050 (review W3): a lock wait interrupted by a signal starts again instead of - // failing; other errors end it. (The interruption itself cannot be produced here: that - // needs a signal handler installed without SA_RESTART, which takes unsafe code.) - #[test] - fn interrupted_lock_waits_are_retried() { - let mut calls = 0; - let result = retry_interrupted(|| { - calls += 1; - if calls < 4 { - Err(io::Error::from(io::ErrorKind::Interrupted)) - } else { - Ok(calls) - } - }); - assert_eq!(result.unwrap(), 4); - let mut calls = 0; - let result: io::Result<()> = retry_interrupted(|| { - calls += 1; - Err(io::Error::from(io::ErrorKind::PermissionDenied)) - }); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); - assert_eq!(calls, 1); - let mut calls = 0; - let taken = retry_would_block(|| { - calls += 1; - if calls < 3 { - Err(TryLockError::Error(io::ErrorKind::Interrupted.into())) - } else { - Err(TryLockError::WouldBlock) - } - }); - assert!(!taken.unwrap()); - assert_eq!(calls, 3); - } - - // WS-REQ-0050: a shared lock needs only read access, so a read-only workspace can be queried. - #[cfg(unix)] - #[test] - fn shared_lock_on_a_read_only_file() { - use std::os::unix::fs::PermissionsExt; - let root = temp_dir("lock-read-only"); - let path = root.join(layout::LOCK); - std::fs::write(&path, b"").unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap(); - let writable = OpenOptions::new().write(true).open(&path).is_ok(); - let shared = WorkspaceLock::shared(&root).unwrap(); - drop(shared); - if !writable { - // Not as root: the exclusive lock needs write access. - assert_eq!( - WorkspaceLock::exclusive(&root).unwrap_err().code(), - ErrorCode::IoError - ); - } - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); - } - - #[test] - fn missing_lock_file() { - let root = temp_dir("lock-missing"); - let err = WorkspaceLock::shared(&root).unwrap_err(); - assert_eq!(err.code(), ErrorCode::NotAWorkspace); - std::fs::write(root.join(layout::MANIFEST), b"{}").unwrap(); - let err = WorkspaceLock::exclusive(&root).unwrap_err(); - assert_eq!(err.code(), ErrorCode::IoError); - } -} diff --git a/crates/core/rusteda-workspace/src/mmap.rs b/crates/core/rusteda-workspace/src/mmap.rs deleted file mode 100644 index 9d5ef18..0000000 --- a/crates/core/rusteda-workspace/src/mmap.rs +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Read-only memory maps of immutable workspace files: the only `unsafe` code of the crate -//! (WS-REQ-0037). - -#![allow(unsafe_code)] - -use std::fs::File; -use std::io; - -use memmap2::Mmap; - -/// Maps a whole file read-only. Crate-private, called only by `Rbin::map_immutable` for a file -/// the workspace never writes again (see the SAFETY comment); a source test pins the callers. -pub(crate) fn map_readonly(file: &File) -> io::Result { - // SAFETY: `Mmap::map` is unsafe because the bytes behind the map, and every `&[T]` - // borrowed from it, would change if the file were written while it is mapped, and the - // process would fault (SIGBUS) on a page that a truncation removed. Neither happens to the - // files mapped here. The only caller is `Rbin::map_immutable`, which is crate-private and - // called from exactly two places, both public functions whose files the workspace protocol - // never writes or truncates: - // - // - `Revision::open_rbin` maps a table of a committed revision (a `Revision` exists only - // for a visible revision directory, WS-REQ-0023), at a path from its checked `meta.json` - // that names an entry of that directory (WS-REQ-0024). Such a file was created new in a - // staging directory, written once and fsynced, and published by renaming the directory - // (WS-REQ-0036, WS-REQ-0066). Nothing in the crate opens it for writing again - // (WS-REQ-0067): carry-forward and forks add hard links to it, never write through them. - // No name outside the workspace shares its inode either, since a staging file with a - // second link to anything but a file of the base revision is refused when it is added - // (WS-REQ-0077). - // - `LibraryCache::open_rlib` maps `cache/-.rlib`, which is written to a new - // temporary file, fsynced and renamed into place, and is replaced only by another - // rename (WS-REQ-0080); a rename leaves the inode that a map holds unchanged. - // - // Both check on the open handle that the file is a regular file (WS-REQ-0025), and the map - // is read-only, so this process cannot write through it. Every other file, in particular - // any file outside a workspace, is read into memory instead (`Rbin::read`). A program that - // bypasses the workspace and writes a committed file in place damages the workspace - // itself; the protocol excludes it, as it excludes deleting revisions. - unsafe { Mmap::map(file) } -} diff --git a/crates/core/rusteda-workspace/src/netlist.rs b/crates/core/rusteda-workspace/src/netlist.rs deleted file mode 100644 index cfc892e..0000000 --- a/crates/core/rusteda-workspace/src/netlist.rs +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The `NETLIST` v1 (`netlist.rbin`) zero-copy codec over [`FrozenNetlist`] and workspace -//! netlist table helpers (contract §5, `WS-REQ-0120` to `WS-REQ-0125`). -//! -//! ## Container layout (`WS-REQ-0120`, `WS-REQ-0121`) -//! -//! A workspace netlist table (`revisions/rev/netlist.rbin`, table name [`TABLE_NAME`]) is an -//! [`Rbin`] container of kind [`KIND`] (`"NETLIST"`) and format version [`FORMAT_VERSION`] (`1`, -//! range [`VERSIONS`]). It writes the 18 sections of [`FrozenNetlist`] (`section::STR_BYTES = 1` -//! through `section::NET_PINS = 18`) in ascending section ID order with the element sizes of -//! [`section::elem_size`], and records the table's upstream map in the `rbin` header per -//! `WS-REQ-0038`. -//! -//! ## Zero-copy decoding (`WS-REQ-0122`, `WS-REQ-0123`) -//! -//! [`decode`] verifies the container kind, format version, `xxh3-64` content hash, and the -//! presence and element sizes of all 18 sections, then constructs a [`FrozenNetlist`] whose 18 -//! fields are [`Cow::Borrowed`] slices pointing directly into the mapped or 8-byte aligned -//! [`Rbin`] buffer (zero-copy `bytemuck` casts). Finally, [`FrozenNetlist::validate`] runs on -//! the borrowed tables and maps any [`FrozenError`] to `CORRUPT_TABLE`. - -use std::borrow::Cow; -use std::ops::RangeInclusive; -use std::path::Path; - -pub use rusteda_core::netlist::{ - ANONYMOUS, AssignRec, CONST0, CONST1, FORMAT_VERSION, FrozenError, FrozenNetlist, GateNetlist, - InstRec, NetRec, PortRec, SCALAR, module_entry, section, -}; - -use crate::error::{Corruption, Error, Result}; -use crate::hash::Hash64; -use crate::rbin::{Rbin, RbinWriter}; -use crate::schema::Upstream; -use crate::workspace::{Revision, Txn, Workspace}; - -/// The `rbin` container kind for netlist tables (`WS-REQ-0120`). -pub const KIND: &str = "NETLIST"; - -/// The range of `NETLIST` format versions supported by this build (`WS-REQ-0120`). -pub const VERSIONS: RangeInclusive = FORMAT_VERSION..=FORMAT_VERSION; - -/// The canonical workspace table name for a revision's netlist (`WS-REQ-0120`). -pub const TABLE_NAME: &str = "netlist"; - -/// The canonical filename inside a revision directory for the netlist table (`WS-REQ-0120`). -pub const TABLE_PATH: &str = "netlist.rbin"; - -/// Builds an [`RbinWriter`] for `netlist` with kind [`KIND`], format version [`FORMAT_VERSION`], -/// and all 18 sections in ascending ID order (`WS-REQ-0120`, `WS-REQ-0121`). -pub fn writer<'a>(netlist: &'a FrozenNetlist<'_>) -> Result> { - let mut w = RbinWriter::new(KIND, FORMAT_VERSION)?; - for (id, elem_size, bytes) in netlist.sections() { - w.section_bytes(id, elem_size, bytes)?; - } - Ok(w) -} - -/// Encodes `netlist` into deterministic `NETLIST v1` `.rbin` bytes with no upstream header -/// entries (`WS-REQ-0120`, `WS-REQ-0121`). -pub fn encode(netlist: &FrozenNetlist<'_>) -> Result> { - Ok(writer(netlist)?.to_bytes()) -} - -/// Encodes `netlist` into deterministic `NETLIST v1` `.rbin` bytes recording `upstream` in the -/// `rbin` header per `WS-REQ-0038` (`WS-REQ-0120`, `WS-REQ-0121`). -pub fn encode_with_upstream(netlist: &FrozenNetlist<'_>, upstream: &Upstream) -> Result> { - let mut w = writer(netlist)?; - w.upstream_tables(upstream)?; - Ok(w.to_bytes()) -} - -/// Validates and opens an in-memory `.rbin` byte vector as a `NETLIST v1` [`Rbin`] container -/// (`WS-REQ-0122`). -pub fn open_bytes(bytes: Vec) -> Result { - let rbin = Rbin::from_bytes(bytes, KIND, VERSIONS)?; - rbin.verify_hash()?; - Ok(rbin) -} - -/// Decodes a [`FrozenNetlist`] zero-copy from `rbin`, borrowing all 18 sections directly from -/// `rbin`'s backing memory (`Cow::Borrowed`) and validating the netlist structure via -/// [`FrozenNetlist::validate`] (`WS-REQ-0122`, `WS-REQ-0123`). -/// -/// Any header mismatch, hash mismatch, missing/extra section, element-size mismatch, or -/// [`FrozenError`] is returned as `ErrorCode::CorruptTable` (`SCHEMA_TOO_NEW` for a newer -/// format version). -pub fn decode<'a>(rbin: &'a Rbin) -> Result> { - let path = rbin.path(); - if rbin.kind() != KIND { - return Err(Error::corrupt( - path, - Corruption::WrongKind { - expected: KIND.to_string(), - found: rbin.kind().to_string(), - }, - )); - } - let fv = rbin.format_version(); - if fv > *VERSIONS.end() { - return Err(Error::FormatTooNew { - path: path.to_path_buf(), - kind: KIND.to_string(), - found: fv, - min: *VERSIONS.start(), - max: *VERSIONS.end(), - }); - } - if !VERSIONS.contains(&fv) { - return Err(Error::corrupt( - path, - Corruption::UnsupportedVersion { - found: fv, - min: *VERSIONS.start(), - max: *VERSIONS.end(), - }, - )); - } - - rbin.verify_hash()?; - - let frozen = FrozenNetlist { - str_bytes: Cow::Borrowed(rbin.section::(section::STR_BYTES)?), - str_offs: Cow::Borrowed(rbin.section::(section::STR_OFFS)?), - module: Cow::Borrowed(rbin.section::(section::MODULE)?), - cells: Cow::Borrowed(rbin.section::(section::CELLS)?), - cell_pin_start: Cow::Borrowed(rbin.section::(section::CELL_PIN_START)?), - cell_pin_name: Cow::Borrowed(rbin.section::(section::CELL_PIN_NAME)?), - cell_pin_dir: Cow::Borrowed(rbin.section::(section::CELL_PIN_DIR)?), - ports: Cow::Borrowed(rbin.section::(section::PORTS)?), - port_bit_start: Cow::Borrowed(rbin.section::(section::PORT_BIT_START)?), - port_bit_net: Cow::Borrowed(rbin.section::(section::PORT_BIT_NET)?), - nets: Cow::Borrowed(rbin.section::(section::NETS)?), - instances: Cow::Borrowed(rbin.section::(section::INSTANCES)?), - inst_pin_start: Cow::Borrowed(rbin.section::(section::INST_PIN_START)?), - pin_cellpin: Cow::Borrowed(rbin.section::(section::PIN_CELLPIN)?), - pin_net: Cow::Borrowed(rbin.section::(section::PIN_NET)?), - assigns: Cow::Borrowed(rbin.section::(section::ASSIGNS)?), - net_pin_start: Cow::Borrowed(rbin.section::(section::NET_PIN_START)?), - net_pins: Cow::Borrowed(rbin.section::(section::NET_PINS)?), - }; - - if rbin.sections().len() != section::COUNT { - return Err(corrupt_netlist( - path, - format!( - "expected {} sections (1..={}), found {}", - section::COUNT, - section::COUNT, - rbin.sections().len() - ), - )); - } - - frozen - .validate() - .map_err(|e| corrupt_netlist(path, e.to_string()))?; - Ok(frozen) -} - -/// Validates `netlist` and stages it into `txn` as table [`TABLE_NAME`] (`netlist.rbin`) with -/// the given `upstream` map (`WS-REQ-0124`). -pub fn add_to_txn( - txn: &mut Txn, - netlist: &FrozenNetlist<'_>, - upstream: Upstream, -) -> Result { - netlist - .validate() - .map_err(|e| Error::internal(format!("staging invalid FrozenNetlist: {e}")))?; - let w = writer(netlist)?; - txn.add_rbin(TABLE_NAME, TABLE_PATH, &w, upstream) -} - -/// Maps the committed [`TABLE_NAME`] (`netlist.rbin`) table of `rev` via [`Revision::open_rbin`] -/// (`WS-REQ-0125`). -pub fn open_rev(rev: &Revision) -> Result { - rev.open_rbin(TABLE_NAME, KIND, VERSIONS) -} - -/// Maps the committed [`TABLE_NAME`] (`netlist.rbin`) table of the workspace's `HEAD` revision -/// (`WS-REQ-0125`). -pub fn open_head(ws: &Workspace) -> Result<(Revision, Rbin)> { - let rev = ws.revision(ws.head()?)?; - let rbin = open_rev(&rev)?; - Ok((rev, rbin)) -} - -/// Maps, validates, and thaws the committed [`TABLE_NAME`] table of `rev` into an editable -/// [`GateNetlist`] (`WS-REQ-0125`). -pub fn thaw_rev(rev: &Revision) -> Result { - let rbin = open_rev(rev)?; - let frozen = decode(&rbin)?; - frozen - .thaw() - .map_err(|e| corrupt_netlist(rbin.path(), e.to_string())) -} - -fn corrupt_netlist(path: &Path, reason: String) -> Error { - Error::corrupt(path, Corruption::Json { reason }) -} diff --git a/crates/core/rusteda-workspace/src/rbin.rs b/crates/core/rusteda-workspace/src/rbin.rs deleted file mode 100644 index 1c59d1b..0000000 --- a/crates/core/rusteda-workspace/src/rbin.rs +++ /dev/null @@ -1,1028 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The `rbin` table container. -//! -//! An `rbin` file is a little-endian header followed by sections of fixed-size elements -//! (WS-REQ-0030): -//! -//! | offset | size | field | -//! | ---: | ---: | --- | -//! | 0 | 8 | magic `RUSTEDA\0` | -//! | 8 | 8 | kind: ASCII padded with NUL bytes, e.g. `NETLIST`, `LIBERTY`, `CONSTR`, `STA` | -//! | 16 | 4 | `format_version` of the kind | -//! | 20 | 4 | `flags`, always 0 | -//! | 24 | 8 | `content_hash`: xxh3-64 of the bytes `[header_len, EOF)` | -//! | 32 | 4 | `section_count` | -//! | 36 | 4 | `upstream_count` | -//! | 40 | 8 | `header_len` = 48 + 16 `upstream_count` + 24 `section_count` | -//! | 48 | 16 each | upstream entries: `name` (8, ASCII, NUL-padded), `hash` u64 | -//! | | 24 each | section entries: `id` u32, `elem_size` u32, `offset` u64, `len` u64 (bytes) | -//! -//! The sections follow the header in strictly increasing id order, each at an 8-byte aligned -//! offset; the gaps and the end of the file are zero padding (WS-REQ-0031). [`RbinWriter`] -//! writes this layout deterministically: the bytes depend only on the kind, the version, the -//! upstream entries (sorted by name) and the sections (sorted by id). -//! -//! Opening an rbin always validates the header: magic, kind, version, flags, `header_len`, -//! bounds, alignment, `len % elem_size == 0`, strictly increasing ids and non-overlapping -//! sections (WS-REQ-0032). [`Rbin::section`] then returns zero-copy slices (WS-REQ-0033); -//! [`Rbin::verify_hash`] checks the content hash on request (WS-REQ-0034). Every validation -//! failure is an [`Error::Corrupt`] (`CORRUPT_TABLE`), except a format version newer than the -//! reader's, which is [`Error::FormatTooNew`] (`SCHEMA_TOO_NEW`, WS-REQ-0039). -//! -//! Only files that nobody writes while they are mapped are mapped (WS-REQ-0037): the tables of -//! committed revisions, through [`Revision::open_rbin`](crate::Revision::open_rbin), and the -//! compiled libraries of the cache, through -//! [`LibraryCache::open_rlib`](crate::LibraryCache::open_rlib) and -//! [`Revision::open_library`](crate::Revision::open_library). Any other file is read into an -//! 8-byte aligned buffer with [`Rbin::read`], and bytes in memory are opened with -//! [`Rbin::from_bytes`]; the views are zero-copy within that buffer. -//! -//! A table that enters a revision as an rbin file is checked as it enters: its structure (the -//! checks above except kind and version, which only the tool knows) and its content hash -//! (contract §5, "content hash on import"; WS-REQ-0078). -//! -//! # Upstream entries of workspace tables -//! -//! The header of an rbin table of a revision records the table's upstream map from `meta.json` -//! (WS-REQ-0038): one entry per upstream table (or libraries pseudo-table) holding the hash -//! that `meta.json` records for it, under the 8-byte name [`upstream_names`] gives. A name of at -//! most 8 bytes is used as it is (`netlist`, `rtl`, `sta`); a longer one is shortened to its -//! first 3 bytes, `.` and 4 hex digits derived from its hash (`constraints`, `libraries`), -//! chosen so that no two names of one map collide. The names depend only on the map, so the -//! writer and every reader derive the same ones. -//! [`Txn::add_rbin`](crate::Txn::add_rbin) writes these entries, the other `add_*` methods -//! check them in a table that is an rbin file, and -//! [`Revision::open_rbin`](crate::Revision::open_rbin) checks them again. - -use std::borrow::Cow; -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; -use std::fs::{self, OpenOptions}; -use std::io::{self, BufWriter, Read, Write}; -use std::ops::RangeInclusive; -use std::path::{Path, PathBuf}; - -use bytemuck::Pod; -use memmap2::Mmap; - -use crate::error::{Corruption, Error, Result}; -use crate::fsutil; -use crate::hash::{Hash64, Hasher}; -use crate::mmap; -use crate::schema::Upstream; - -/// The first 8 bytes of every rbin file. -pub const MAGIC: [u8; 8] = *b"RUSTEDA\0"; -/// The length of the fixed part of the header. -pub const FIXED_HEADER_LEN: u64 = 48; -/// The length of an upstream entry. -pub const UPSTREAM_ENTRY_LEN: u64 = 16; -/// The length of a section entry. -pub const SECTION_ENTRY_LEN: u64 = 24; -/// Section alignment. -pub const ALIGN: u64 = 8; - -/// The header length for the given counts. -pub fn header_len(upstream_count: u64, section_count: u64) -> u64 { - FIXED_HEADER_LEN + UPSTREAM_ENTRY_LEN * upstream_count + SECTION_ENTRY_LEN * section_count -} - -fn align_up(n: u64) -> u64 { - n.div_ceil(ALIGN) * ALIGN -} - -fn name_byte(b: u8) -> bool { - b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-') -} - -/// A kind or upstream name: 1 to 8 bytes of `[A-Za-z0-9_.-]`, padded with NUL bytes. -fn encode_name(name: &str, what: &str) -> Result<[u8; 8]> { - let bytes = name.as_bytes(); - if bytes.is_empty() || bytes.len() > 8 || !bytes.iter().copied().all(name_byte) { - return Err(Error::internal(format!( - "rbin {what} '{name}' must be 1 to 8 characters of [A-Za-z0-9_.-]" - ))); - } - let mut out = [0u8; 8]; - out[..bytes.len()].copy_from_slice(bytes); - Ok(out) -} - -fn decode_name(raw: &[u8]) -> Option { - let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); - let valid = - len > 0 && raw[..len].iter().copied().all(name_byte) && raw[len..].iter().all(|&b| b == 0); - valid.then(|| raw[..len].iter().map(|&b| char::from(b)).collect()) -} - -/// The 8-byte header names of the upstream tables of a workspace table, by table name -/// (WS-REQ-0038). A name of at most 8 bytes is its own header name. A longer one becomes its -/// first 3 bytes, `.` and 4 hex digits: the low 16 bits of its xxh3-64, plus one (modulo -/// 2^16) for as long as the result is taken by a name before it (the short names first, then -/// the long ones in byte order). The header names of one map are therefore distinct, and they -/// depend on the map only, so the writer and every reader of a table derive the same names -/// from its `meta.json` upstream map. -/// -/// ``` -/// use rusteda_workspace::{Hash64, Upstream}; -/// use rusteda_workspace::rbin::upstream_names; -/// -/// // Two corners whose 16-bit hashes collide still get distinct names. -/// let upstream = Upstream::from([ -/// ("netlist".to_string(), Hash64(1)), -/// ("libraries.ss_150C_1v60".to_string(), Hash64(2)), -/// ("libraries.sf_n40C_1v65".to_string(), Hash64(3)), -/// ]); -/// let names = upstream_names(&upstream)?; -/// assert_eq!(names["netlist"], "netlist"); -/// assert_eq!(names["libraries.sf_n40C_1v65"], "lib.95ae"); -/// assert_eq!(names["libraries.ss_150C_1v60"], "lib.95af"); -/// # Ok::<(), rusteda_workspace::Error>(()) -/// ``` -pub fn upstream_names(upstream: &Upstream) -> Result> { - let mut names = BTreeMap::new(); - let mut taken = BTreeSet::new(); - for table in upstream.keys() { - let bytes = table.as_bytes(); - if bytes.is_empty() || !bytes.iter().copied().all(name_byte) { - return Err(Error::internal(format!( - "'{table}' cannot name an rbin upstream entry: it needs [A-Za-z0-9_.-]" - ))); - } - if bytes.len() <= 8 { - taken.insert(table.clone()); - names.insert(table.clone(), table.clone()); - } - } - for table in upstream.keys().filter(|t| t.len() > 8) { - let mut suffix = Hash64::of(table.as_bytes()).0 & 0xffff; - let mut name = None; - for _ in 0..=0xffff { - // The bytes are ASCII, so 3 is a char boundary. - let candidate = format!("{}.{suffix:04x}", &table[..3]); - if taken.insert(candidate.clone()) { - name = Some(candidate); - break; - } - suffix = (suffix + 1) & 0xffff; - } - let Some(name) = name else { - return Err(Error::internal(format!( - "too many upstream tables share the prefix of {table}" - ))); - }; - names.insert(table.clone(), name); - } - Ok(names) -} - -/// The header upstream entries of a workspace table with upstream map `upstream`, keyed by -/// padded name (WS-REQ-0038). -pub(crate) fn header_upstream(upstream: &Upstream) -> Result> { - let names = upstream_names(upstream)?; - let mut entries = BTreeMap::new(); - for (table, hash) in upstream { - entries.insert(encode_name(&names[table], "upstream name")?, hash.0); - } - Ok(entries) -} - -/// Whether header upstream entries, in any order, are exactly `expected`. -fn same_upstream(entries: &[UpstreamInfo], expected: &BTreeMap<[u8; 8], u64>) -> bool { - let mut entries: Vec<([u8; 8], u64)> = entries - .iter() - .map(|u| { - let mut name = [0u8; 8]; - name[..u.name.len()].copy_from_slice(u.name.as_bytes()); - (name, u.hash.0) - }) - .collect(); - entries.sort_unstable(); - entries.len() == expected.len() - && entries - .iter() - .zip(expected) - .all(|(entry, (name, hash))| entry.0 == *name && entry.1 == *hash) -} - -/// What the import check of a table file found (WS-REQ-0078). -#[derive(Debug)] -pub(crate) enum Inspected { - /// Not an rbin file (it does not start with the magic): its file hash. - Plain(Hash64), - /// An rbin file with a valid structure and content hash. - Rbin { - /// The file hash, the table hash of `meta.json`. - file_hash: Hash64, - /// The header's upstream entries. - upstream: Vec, - }, - /// An rbin file that fails validation. - Corrupt(Corruption), -} - -impl Inspected { - /// Whether an rbin file's upstream entries are exactly `expected`; true for other files. - pub(crate) fn records_upstream(&self, expected: &BTreeMap<[u8; 8], u64>) -> bool { - match self { - Inspected::Rbin { upstream, .. } => same_upstream(upstream, expected), - _ => true, - } - } -} - -/// Reads a table file of `file_len` bytes once (WS-REQ-0078): its file hash, and for an rbin -/// file, one that starts with the magic, the checks of every open except kind and version -/// (WS-REQ-0032) and its content hash (WS-REQ-0034). -pub(crate) fn inspect(mut reader: impl Read, file_len: u64) -> io::Result { - let mut head = Vec::new(); - read_up_to(&mut reader, &mut head, FIXED_HEADER_LEN)?; - let mut file_hash = Hasher::new(); - if !head.starts_with(&MAGIC) { - file_hash.update(&head); - stream(&mut reader, |chunk| file_hash.update(chunk))?; - return Ok(Inspected::Plain(file_hash.finish())); - } - if head.len() as u64 == FIXED_HEADER_LEN { - // The whole header, when its length is consistent; otherwise the parse reports it. - let declared = read_u64(&head, 40); - let counts = header_len( - u64::from(read_u32(&head, 36)), - u64::from(read_u32(&head, 32)), - ); - if declared == counts && declared <= file_len { - read_up_to(&mut reader, &mut head, declared)?; - } - } - let header = match parse_header(&head, file_len, None) { - Ok(header) => header, - Err(what) => return Ok(Inspected::Corrupt(what)), - }; - file_hash.update(&head); - let mut content = Hasher::new(); - let rest = stream(&mut reader, |chunk| { - file_hash.update(chunk); - content.update(chunk); - })?; - if head.len() as u64 + rest != file_len { - return Err(io::Error::other("the file changed while it was read")); - } - let computed = content.finish(); - if computed != header.content_hash { - return Ok(Inspected::Corrupt(Corruption::HashMismatch { - stored: header.content_hash, - computed, - })); - } - Ok(Inspected::Rbin { - file_hash: file_hash.finish(), - upstream: header.upstream, - }) -} - -/// Reads from `reader` until `buf` holds `target` bytes or the input ends. -fn read_up_to(reader: &mut impl Read, buf: &mut Vec, target: u64) -> io::Result<()> { - let want = target.saturating_sub(buf.len() as u64); - reader.by_ref().take(want).read_to_end(buf)?; - Ok(()) -} - -/// Passes the rest of `reader` to `each` in chunks; returns the number of bytes. -fn stream(reader: &mut impl Read, mut each: impl FnMut(&[u8])) -> io::Result { - let mut buf = vec![0u8; 256 * 1024]; - let mut total = 0u64; - loop { - match reader.read(&mut buf) { - Ok(0) => return Ok(total), - Ok(n) => { - each(&buf[..n]); - total += n as u64; - } - Err(e) if e.kind() == io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } -} - -/// Reads all of `reader` into an 8-byte aligned buffer, so that sections can be viewed in -/// place; `hint` is the expected length. Returns the words and the length in bytes. -fn read_aligned(reader: &mut impl Read, hint: u64) -> io::Result<(Vec, usize)> { - // One word more than the hint, so that the end of the input is seen without growing. - let mut words = vec![0u64; usize::try_from(hint).unwrap_or(0).div_ceil(8) + 1]; - let mut len = 0; - loop { - if len == words.len() * 8 { - words.resize(words.len() * 2, 0); - } - let buf = bytemuck::cast_slice_mut::(&mut words); - match reader.read(&mut buf[len..]) { - Ok(0) => break, - Ok(n) => len += n, - Err(e) if e.kind() == io::ErrorKind::Interrupted => {} - Err(e) => return Err(e), - } - } - words.truncate(len.div_ceil(8)); - Ok((words, len)) -} - -/// The element size of `T` as stored in a section entry. -fn elem_size_of() -> Result { - let size = size_of::(); - if size == 0 || align_of::() > ALIGN as usize { - return Err(Error::internal(format!( - "rbin element type {} has size {size} and alignment {}; sections need a non-zero \ - size and an alignment of at most 8", - std::any::type_name::(), - align_of::() - ))); - } - u32::try_from(size).map_err(|_| Error::internal("rbin element size exceeds u32")) -} - -#[derive(Clone)] -struct PendingSection<'a> { - elem_size: u32, - bytes: Cow<'a, [u8]>, -} - -impl fmt::Debug for PendingSection<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PendingSection") - .field("elem_size", &self.elem_size) - .field("len", &self.bytes.len()) - .finish() - } -} - -/// Builds an rbin file from sections that borrow (or own) their data. -/// -/// ``` -/// use rusteda_workspace::{Hash64, Rbin, RbinWriter}; -/// -/// let names: Vec = vec![10, 20, 30]; -/// let mut w = RbinWriter::new("DEMO", 1)?; -/// w.upstream("NETLIST", Hash64(7))?; -/// w.section(1, &names)?; -/// let table = Rbin::from_bytes(w.to_bytes(), "DEMO", 1..=1)?; -/// assert_eq!(table.section::(1)?, &[10, 20, 30]); -/// # Ok::<(), rusteda_workspace::Error>(()) -/// ``` -#[derive(Debug, Clone)] -pub struct RbinWriter<'a> { - kind: [u8; 8], - format_version: u32, - upstream: BTreeMap<[u8; 8], u64>, - sections: BTreeMap>, -} - -impl<'a> RbinWriter<'a> { - /// A writer for a table kind (1 to 8 characters of `[A-Za-z0-9_.-]`) and format version. - pub fn new(kind: &str, format_version: u32) -> Result> { - Ok(RbinWriter { - kind: encode_name(kind, "kind")?, - format_version, - upstream: BTreeMap::new(), - sections: BTreeMap::new(), - }) - } - - /// Records the hash of something this file was computed from, under a raw header name of - /// 1 to 8 characters of `[A-Za-z0-9_.-]`; names are unique, and the header lists them - /// sorted. A table of a workspace revision records its upstream tables instead, with - /// [`RbinWriter::upstream_tables`] or through [`Txn::add_rbin`](crate::Txn::add_rbin). - pub fn upstream(&mut self, name: &str, hash: Hash64) -> Result<&mut Self> { - let key = encode_name(name, "upstream name")?; - if self.upstream.insert(key, hash.0).is_some() { - return Err(Error::internal(format!( - "rbin upstream '{name}' given twice" - ))); - } - Ok(self) - } - - /// Records the upstream map of a workspace table: one entry per upstream table, named by - /// [`upstream_names`], holding the hash `meta.json` records for it (WS-REQ-0038). - pub fn upstream_tables(&mut self, upstream: &Upstream) -> Result<&mut Self> { - for (name, hash) in header_upstream(upstream)? { - if self.upstream.insert(name, hash).is_some() { - let shown = decode_name(&name).unwrap_or_default(); - return Err(Error::internal(format!( - "rbin upstream '{shown}' given twice" - ))); - } - } - Ok(self) - } - - /// The upstream entries set so far. - pub(crate) fn upstream_entries(&self) -> &BTreeMap<[u8; 8], u64> { - &self.upstream - } - - /// Adds a section that borrows `data`; its element size is `size_of::()`. - pub fn section(&mut self, id: u32, data: &'a [T]) -> Result<&mut Self> { - let elem_size = elem_size_of::()?; - self.insert(id, elem_size, Cow::Borrowed(bytemuck::cast_slice(data))) - } - - /// Adds a section that owns a copy of `data`. - pub fn section_vec(&mut self, id: u32, data: Vec) -> Result<&mut Self> { - let elem_size = elem_size_of::()?; - let bytes = bytemuck::cast_slice::(&data).to_vec(); - self.insert(id, elem_size, Cow::Owned(bytes)) - } - - /// Adds a section of raw bytes holding elements of `elem_size` bytes. - pub fn section_bytes( - &mut self, - id: u32, - elem_size: u32, - bytes: impl Into>, - ) -> Result<&mut Self> { - let bytes = bytes.into(); - if elem_size == 0 || !(bytes.len() as u64).is_multiple_of(u64::from(elem_size)) { - return Err(Error::internal(format!( - "rbin section {id}: {} bytes are not a whole number of {elem_size}-byte elements", - bytes.len() - ))); - } - self.insert(id, elem_size, bytes) - } - - fn insert(&mut self, id: u32, elem_size: u32, bytes: Cow<'a, [u8]>) -> Result<&mut Self> { - if self.sections.contains_key(&id) { - return Err(Error::internal(format!("rbin section {id} given twice"))); - } - self.sections - .insert(id, PendingSection { elem_size, bytes }); - Ok(self) - } - - /// The header length. - pub fn header_len(&self) -> u64 { - header_len(self.upstream.len() as u64, self.sections.len() as u64) - } - - /// The length of the file this writer produces. - pub fn file_len(&self) -> u64 { - self.sections.values().fold(self.header_len(), |end, s| { - align_up(end + s.bytes.len() as u64) - }) - } - - /// The content hash the header will record: xxh3-64 of the bytes after the header. - pub fn content_hash(&self) -> Hash64 { - let mut hasher = Hasher::new(); - self.for_each_content_chunk(|chunk| hasher.update(chunk)); - hasher.finish() - } - - fn for_each_content_chunk(&self, mut f: impl FnMut(&[u8])) { - const ZEROS: [u8; ALIGN as usize] = [0; ALIGN as usize]; - for section in self.sections.values() { - let len = section.bytes.len() as u64; - f(§ion.bytes); - let pad = (align_up(len) - len) as usize; - if pad > 0 { - f(&ZEROS[..pad]); - } - } - } - - fn header(&self, content_hash: Hash64, upstream: &BTreeMap<[u8; 8], u64>) -> Vec { - let header_len = header_len(upstream.len() as u64, self.sections.len() as u64); - let mut h = Vec::with_capacity(header_len as usize); - h.extend_from_slice(&MAGIC); - h.extend_from_slice(&self.kind); - h.extend_from_slice(&self.format_version.to_le_bytes()); - h.extend_from_slice(&0u32.to_le_bytes()); - h.extend_from_slice(&content_hash.0.to_le_bytes()); - h.extend_from_slice(&(self.sections.len() as u32).to_le_bytes()); - h.extend_from_slice(&(upstream.len() as u32).to_le_bytes()); - h.extend_from_slice(&header_len.to_le_bytes()); - for (name, hash) in upstream { - h.extend_from_slice(name); - h.extend_from_slice(&hash.to_le_bytes()); - } - let mut offset = header_len; - for (id, section) in &self.sections { - let len = section.bytes.len() as u64; - h.extend_from_slice(&id.to_le_bytes()); - h.extend_from_slice(§ion.elem_size.to_le_bytes()); - h.extend_from_slice(&offset.to_le_bytes()); - h.extend_from_slice(&len.to_le_bytes()); - offset = align_up(offset + len); - } - h - } - - /// The whole file in memory. - pub fn to_bytes(&self) -> Vec { - let mut out = self.header(self.content_hash(), &self.upstream); - out.reserve(self.file_len() as usize - out.len()); - self.for_each_content_chunk(|chunk| out.extend_from_slice(chunk)); - out - } - - /// Writes the file to a new path (an existing file is never overwritten) and fsyncs it - /// (WS-REQ-0036). Returns the file hash, xxh3-64 of all its bytes, which is the table hash - /// recorded in `meta.json`. - pub fn write_to(&self, path: impl AsRef) -> Result { - self.write_with_upstream(path.as_ref(), &self.upstream) - } - - /// [`RbinWriter::write_to`] with the header upstream entries `upstream` in place of the - /// writer's own. - pub(crate) fn write_with_upstream( - &self, - path: &Path, - upstream: &BTreeMap<[u8; 8], u64>, - ) -> Result { - let header = self.header(self.content_hash(), upstream); - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(path) - .map_err(|e| Error::io(path, e))?; - let mut out = BufWriter::with_capacity(1 << 20, file); - let mut file_hash = Hasher::new(); - file_hash.update(&header); - let mut written = out.write_all(&header); - self.for_each_content_chunk(|chunk| { - if written.is_ok() { - file_hash.update(chunk); - written = out.write_all(chunk); - } - }); - let synced = written - .and_then(|()| out.into_inner().map_err(|e| e.into_error())) - .and_then(|file| file.sync_all()); - if let Err(e) = synced { - let _ = fs::remove_file(path); - return Err(Error::io(path, e)); - } - Ok(file_hash.finish()) - } -} - -/// An upstream entry of an rbin header. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UpstreamInfo { - /// The name, without NUL padding. - pub name: String, - /// The hash. - pub hash: Hash64, -} - -/// A section entry of an rbin header. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SectionInfo { - /// The section id. - pub id: u32, - /// The element size in bytes. - pub elem_size: u32, - /// The byte offset in the file. - pub offset: u64, - /// The length in bytes. - pub len: u64, -} - -impl SectionInfo { - /// The number of elements. - pub fn count(&self) -> u64 { - self.len / u64::from(self.elem_size) - } -} - -/// The bytes of an rbin: a read-only map, or an 8-byte aligned buffer. -enum Storage { - Mapped(Mmap), - Owned { words: Vec, len: usize }, -} - -impl Storage { - fn bytes(&self) -> &[u8] { - match self { - Storage::Mapped(map) => map, - Storage::Owned { words, len } => &bytemuck::cast_slice::(words)[..*len], - } - } -} - -impl fmt::Debug for Storage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Storage::Mapped(map) => write!(f, "Mapped({} bytes)", map.len()), - Storage::Owned { len, .. } => write!(f, "Owned({len} bytes)"), - } - } -} - -/// A validated rbin table: header fields and zero-copy access to its sections. -#[derive(Debug)] -pub struct Rbin { - path: PathBuf, - storage: Storage, - kind: String, - format_version: u32, - content_hash: Hash64, - header_len: u64, - upstream: Vec, - sections: Vec, -} - -/// The parsed header, before it is attached to its storage. -struct Header { - kind: String, - format_version: u32, - content_hash: Hash64, - header_len: u64, - upstream: Vec, - sections: Vec, -} - -fn read_u32(bytes: &[u8], at: usize) -> u32 { - let mut b = [0u8; 4]; - b.copy_from_slice(&bytes[at..at + 4]); - u32::from_le_bytes(b) -} - -fn read_u64(bytes: &[u8], at: usize) -> u64 { - let mut b = [0u8; 8]; - b.copy_from_slice(&bytes[at..at + 8]); - u64::from_le_bytes(b) -} - -impl Rbin { - /// Maps `path` read-only and validates its header (WS-REQ-0032); `kind` is the expected - /// kind and `versions` the format versions the caller reads. - /// - /// Crate-private, because a map is sound only while nobody writes or truncates the file - /// (WS-REQ-0037, see `mmap::map_readonly`): it is called for the tables of committed - /// revisions ([`Revision::open_rbin`](crate::Revision::open_rbin)) and the compiled - /// libraries of the cache ([`LibraryCache::open_rlib`](crate::LibraryCache::open_rlib)) - /// only, which the workspace never writes again. Only a regular file is mapped, checked - /// without following a symbolic link and again on the open handle (WS-REQ-0025). - pub(crate) fn map_immutable( - path: &Path, - kind: &str, - versions: RangeInclusive, - ) -> Result { - let file = fsutil::open_regular(path)?; - let map = mmap::map_readonly(&file).map_err(|e| Error::io(path, e))?; - Rbin::validate(path.to_path_buf(), Storage::Mapped(map), kind, versions) - } - - /// Reads the rbin file `path` into an 8-byte aligned buffer and validates its header - /// (WS-REQ-0032): for a file outside a workspace, which may change after it is read; the - /// table in memory does not. `kind` is the expected kind and `versions` the format - /// versions the caller reads. `path` must be a regular file; anything else (a FIFO, a - /// device, a directory, a symbolic link) is `CORRUPT_TABLE` and never blocks - /// (WS-REQ-0025). A format version above `versions` is `SCHEMA_TOO_NEW` (WS-REQ-0039). - /// - /// ``` - /// use rusteda_workspace::{Rbin, RbinWriter}; - /// - /// # let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../target/tmp/rbin-read-doc"); - /// # std::fs::create_dir_all(&dir).unwrap(); - /// let path = dir.join("cells.rbin"); - /// # let _ = std::fs::remove_file(&path); - /// let mut w = RbinWriter::new("DEMO", 1)?; - /// w.section_vec(1, vec![7u32, 8, 9])?; - /// w.write_to(&path)?; - /// let table = Rbin::read(&path, "DEMO", 1..=1)?; - /// std::fs::write(&path, b"rewritten")?; // the table in memory stays as it was read - /// assert_eq!(table.section::(1)?, &[7, 8, 9]); - /// # Ok::<(), Box>(()) - /// ``` - pub fn read(path: impl AsRef, kind: &str, versions: RangeInclusive) -> Result { - let path = path.as_ref(); - let mut file = fsutil::open_regular(path)?; - let io_err = |e| Error::io(path, e); - let hint = file.metadata().map_err(io_err)?.len(); - let (words, len) = read_aligned(&mut file, hint).map_err(io_err)?; - Rbin::validate( - path.to_path_buf(), - Storage::Owned { words, len }, - kind, - versions, - ) - } - - /// Validates an rbin held in memory. The bytes are copied once into an 8-byte aligned - /// buffer so that sections can be viewed in place. - pub fn from_bytes(bytes: Vec, kind: &str, versions: RangeInclusive) -> Result { - let len = bytes.len(); - let mut words = vec![0u64; len.div_ceil(8)]; - bytemuck::cast_slice_mut::(&mut words)[..len].copy_from_slice(&bytes); - Rbin::validate( - PathBuf::from(""), - Storage::Owned { words, len }, - kind, - versions, - ) - } - - fn validate( - path: PathBuf, - storage: Storage, - kind: &str, - versions: RangeInclusive, - ) -> Result { - let expect = Expect { - kind: encode_name(kind, "kind")?, - name: kind, - versions: &versions, - }; - let bytes = storage.bytes(); - match parse_header(bytes, bytes.len() as u64, Some(&expect)) { - Ok(h) => Ok(Rbin { - path, - storage, - kind: h.kind, - format_version: h.format_version, - content_hash: h.content_hash, - header_len: h.header_len, - upstream: h.upstream, - sections: h.sections, - }), - // WS-REQ-0039: written by a newer release, not damaged. - Err(Corruption::UnsupportedVersion { found, min, max }) if found > max => { - Err(Error::FormatTooNew { - path, - kind: kind.to_string(), - found, - min, - max, - }) - } - Err(what) => Err(Error::corrupt(path, what)), - } - } - - /// The file path (`` for [`Rbin::from_bytes`]). - pub fn path(&self) -> &Path { - &self.path - } - - /// The table kind. - pub fn kind(&self) -> &str { - &self.kind - } - - /// The format version. - pub fn format_version(&self) -> u32 { - self.format_version - } - - /// The content hash recorded in the header (not verified; see [`Rbin::verify_hash`]). - pub fn content_hash(&self) -> Hash64 { - self.content_hash - } - - /// The header length. - pub fn header_len(&self) -> u64 { - self.header_len - } - - /// The file length. - pub fn file_len(&self) -> u64 { - self.as_bytes().len() as u64 - } - - /// The upstream entries, in header order. - pub fn upstream(&self) -> &[UpstreamInfo] { - &self.upstream - } - - /// The hash of the upstream entry with header name `name`; for a workspace table, the - /// name of an upstream table is given by [`upstream_names`] of the table's upstream map. - pub fn upstream_hash(&self, name: &str) -> Option { - self.upstream - .iter() - .find(|u| u.name == name) - .map(|u| u.hash) - } - - /// Whether the header's upstream entries are exactly `expected`. - pub(crate) fn has_upstream(&self, expected: &BTreeMap<[u8; 8], u64>) -> bool { - same_upstream(&self.upstream, expected) - } - - /// The section entries, in increasing id order. - pub fn sections(&self) -> &[SectionInfo] { - &self.sections - } - - /// The entry of section `id`. - pub fn section_info(&self, id: u32) -> Option<&SectionInfo> { - self.sections - .binary_search_by_key(&id, |s| s.id) - .ok() - .map(|i| &self.sections[i]) - } - - /// Whether section `id` exists. - pub fn has_section(&self, id: u32) -> bool { - self.section_info(id).is_some() - } - - /// The bytes of section `id`; a missing section is `CORRUPT_TABLE`. - pub fn section_bytes(&self, id: u32) -> Result<&[u8]> { - let s = self - .section_info(id) - .ok_or_else(|| Error::corrupt(&self.path, Corruption::MissingSection { id }))?; - // Validation checked offset + len <= file length. - Ok(&self.as_bytes()[s.offset as usize..(s.offset + s.len) as usize]) - } - - /// Section `id` as a zero-copy slice of `T` (WS-REQ-0033). A missing section, or one whose - /// element size is not `size_of::()`, is `CORRUPT_TABLE`. - pub fn section(&self, id: u32) -> Result<&[T]> { - let elem_size = elem_size_of::()?; - let s = self - .section_info(id) - .ok_or_else(|| Error::corrupt(&self.path, Corruption::MissingSection { id }))?; - if s.elem_size != elem_size { - let what = Corruption::TypeMismatch { - id, - elem_size: s.elem_size, - type_size: size_of::(), - }; - return Err(Error::corrupt(&self.path, what)); - } - let bytes = self.section_bytes(id)?; - // Sections start at multiples of 8 in a page-aligned map or an 8-byte aligned buffer, - // and `elem_size_of` admits alignments up to 8, so the cast cannot fail. - bytemuck::try_cast_slice(bytes) - .map_err(|e| Error::internal(format!("section {id} cannot be viewed: {e}"))) - } - - /// Recomputes the content hash and compares it with the header (WS-REQ-0034). - pub fn verify_hash(&self) -> Result<()> { - let computed = Hash64::of(&self.as_bytes()[self.header_len as usize..]); - if computed != self.content_hash { - let what = Corruption::HashMismatch { - stored: self.content_hash, - computed, - }; - return Err(Error::corrupt(&self.path, what)); - } - Ok(()) - } - - /// The file hash: xxh3-64 of all bytes, the table hash that `meta.json` records. - pub fn file_hash(&self) -> Hash64 { - Hash64::of(self.as_bytes()) - } - - /// All bytes of the file. - pub fn as_bytes(&self) -> &[u8] { - self.storage.bytes() - } -} - -/// What a reader expects of a header besides its structure: the kind and format versions. -struct Expect<'a> { - kind: [u8; 8], - name: &'a str, - versions: &'a RangeInclusive, -} - -/// Parses and validates the header at the start of `bytes`, the first bytes of a file of -/// `file_len` bytes; `bytes` holds the whole header when the file does. With `expect` the kind -/// and the format version are checked too; without it only what every rbin satisfies, as when -/// a table enters a revision (WS-REQ-0078). -fn parse_header( - bytes: &[u8], - file_len: u64, - expect: Option<&Expect<'_>>, -) -> std::result::Result { - // The bytes at hand: the whole file, or for a streamed file its first bytes, fewer than - // `file_len` only when the file shrank while it was read. - let have = (bytes.len() as u64).min(file_len); - if have < FIXED_HEADER_LEN { - return Err(Corruption::Truncated { - needed: FIXED_HEADER_LEN, - len: have, - }); - } - if bytes[..8] != MAGIC { - return Err(Corruption::BadMagic); - } - let kind = decode_name(&bytes[8..16]).ok_or(Corruption::BadName { field: "kind" })?; - let format_version = read_u32(bytes, 16); - if let Some(expect) = expect { - if bytes[8..16] != expect.kind[..] { - return Err(Corruption::WrongKind { - expected: expect.name.to_string(), - found: kind, - }); - } - if !expect.versions.contains(&format_version) { - return Err(Corruption::UnsupportedVersion { - found: format_version, - min: *expect.versions.start(), - max: *expect.versions.end(), - }); - } - } - let flags = read_u32(bytes, 20); - if flags != 0 { - return Err(Corruption::Flags { flags }); - } - let content_hash = Hash64(read_u64(bytes, 24)); - let section_count = u64::from(read_u32(bytes, 32)); - let upstream_count = u64::from(read_u32(bytes, 36)); - let declared = read_u64(bytes, 40); - let expected_len = header_len(upstream_count, section_count); - if declared != expected_len { - return Err(Corruption::HeaderLen { - declared, - expected: expected_len, - }); - } - if declared > file_len { - return Err(Corruption::Truncated { - needed: declared, - len: file_len, - }); - } - if declared > have { - return Err(Corruption::Truncated { - needed: declared, - len: have, - }); - } - let mut upstream = Vec::with_capacity(upstream_count as usize); - for i in 0..upstream_count { - let at = (FIXED_HEADER_LEN + UPSTREAM_ENTRY_LEN * i) as usize; - let name = decode_name(&bytes[at..at + 8]).ok_or(Corruption::BadName { - field: "upstream name", - })?; - upstream.push(UpstreamInfo { - name, - hash: Hash64(read_u64(bytes, at + 8)), - }); - } - let table = FIXED_HEADER_LEN + UPSTREAM_ENTRY_LEN * upstream_count; - let mut sections = Vec::with_capacity(section_count as usize); - let mut previous_end = declared; - for i in 0..section_count { - let at = (table + SECTION_ENTRY_LEN * i) as usize; - let s = SectionInfo { - id: read_u32(bytes, at), - elem_size: read_u32(bytes, at + 4), - offset: read_u64(bytes, at + 8), - len: read_u64(bytes, at + 16), - }; - if sections.last().is_some_and(|p: &SectionInfo| s.id <= p.id) { - return Err(Corruption::SectionOrder { id: s.id }); - } - if !s.offset.is_multiple_of(ALIGN) { - return Err(Corruption::Misaligned { - id: s.id, - offset: s.offset, - }); - } - if s.elem_size == 0 || !s.len.is_multiple_of(u64::from(s.elem_size)) { - return Err(Corruption::ElemSize { - id: s.id, - elem_size: s.elem_size, - len: s.len, - }); - } - let end = match s.offset.checked_add(s.len) { - Some(end) if end <= file_len => end, - _ => { - let (id, offset, len) = (s.id, s.offset, s.len); - return Err(Corruption::SectionBounds { - id, - offset, - len, - file_len, - }); - } - }; - if s.offset < previous_end { - return Err(Corruption::Overlap { id: s.id }); - } - previous_end = end; - sections.push(s); - } - Ok(Header { - kind, - format_version, - content_hash, - header_len: declared, - upstream, - sections, - }) -} - -#[cfg(test)] -#[path = "rbin_tests.rs"] -mod tests; diff --git a/crates/core/rusteda-workspace/src/rbin_tests.rs b/crates/core/rusteda-workspace/src/rbin_tests.rs deleted file mode 100644 index 6d3068a..0000000 --- a/crates/core/rusteda-workspace/src/rbin_tests.rs +++ /dev/null @@ -1,324 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -use super::*; -use crate::error::ErrorCode; - -fn sample() -> RbinWriter<'static> { - let mut w = RbinWriter::new("TEST", 2).unwrap(); - w.upstream("netlist", Hash64(0x11)).unwrap(); - w.upstream("CONSTR", Hash64(0x22)).unwrap(); - w.section_vec(5, vec![1u8, 2, 3]).unwrap(); - w.section_vec(2, vec![10u32, 20, 30]).unwrap(); - w.section_vec(9, Vec::::new()).unwrap(); - w -} - -// WS-REQ-0030, WS-REQ-0031 -#[test] -fn layout() { - let w = sample(); - let b = w.to_bytes(); - assert_eq!(&b[..8], b"RUSTEDA\0"); - assert_eq!(&b[8..16], b"TEST\0\0\0\0"); - assert_eq!(read_u32(&b, 16), 2); - assert_eq!(read_u32(&b, 20), 0); - assert_eq!(read_u32(&b, 32), 3); - assert_eq!(read_u32(&b, 36), 2); - let hl = 48 + 2 * 16 + 3 * 24; - assert_eq!(read_u64(&b, 40), hl); - assert_eq!(read_u64(&b, 24), Hash64::of(&b[hl as usize..]).0); - // Upstream entries sorted by name bytes: "CONSTR" < "netlist". - assert_eq!(&b[48..56], b"CONSTR\0\0"); - assert_eq!(read_u64(&b, 56), 0x22); - assert_eq!(&b[64..72], b"netlist\0"); - // Sections by id: 2 (12 bytes, padded to 16), 5 (3 bytes, padded to 8), 9 (empty). - let sec = |i: u64| (80 + 24 * i) as usize; - assert_eq!((read_u32(&b, sec(0)), read_u32(&b, sec(0) + 4)), (2, 4)); - assert_eq!( - (read_u64(&b, sec(0) + 8), read_u64(&b, sec(0) + 16)), - (hl, 12) - ); - assert_eq!( - (read_u32(&b, sec(1)), read_u64(&b, sec(1) + 8)), - (5, hl + 16) - ); - assert_eq!( - (read_u32(&b, sec(2)), read_u64(&b, sec(2) + 8)), - (9, hl + 24) - ); - assert_eq!(b.len() as u64, hl + 24); - assert_eq!(w.file_len(), b.len() as u64); - assert_eq!(&b[hl as usize + 12..hl as usize + 16], &[0, 0, 0, 0]); - assert_eq!(&b[hl as usize + 16..], &[1, 2, 3, 0, 0, 0, 0, 0]); -} - -// WS-REQ-0031, WS-REQ-0100 -#[test] -fn deterministic_bytes() { - let a = sample().to_bytes(); - let mut w = RbinWriter::new("TEST", 2).unwrap(); - w.section_vec(9, Vec::::new()).unwrap(); - w.section_bytes( - 2, - 4, - [10u32, 20, 30] - .iter() - .flat_map(|v| v.to_le_bytes()) - .collect::>(), - ) - .unwrap(); - w.upstream("CONSTR", Hash64(0x22)).unwrap(); - w.section_bytes(5, 1, &[1u8, 2, 3][..]).unwrap(); - w.upstream("netlist", Hash64(0x11)).unwrap(); - assert_eq!(w.to_bytes(), a, "insertion order does not matter"); - assert_eq!(sample().to_bytes(), a); -} - -// WS-REQ-0033 -#[test] -fn round_trip_in_memory() { - let r = Rbin::from_bytes(sample().to_bytes(), "TEST", 1..=2).unwrap(); - assert_eq!(r.kind(), "TEST"); - assert_eq!(r.format_version(), 2); - assert_eq!(r.section::(2).unwrap(), &[10, 20, 30]); - assert_eq!(r.section::(5).unwrap(), &[1, 2, 3]); - assert!(r.section::(9).unwrap().is_empty()); - assert_eq!(r.upstream_hash("netlist"), Some(Hash64(0x11))); - assert_eq!(r.upstream_hash("x"), None); - assert_eq!( - r.sections().iter().map(|s| s.id).collect::>(), - [2, 5, 9] - ); - assert_eq!(r.section_info(2).unwrap().count(), 3); - r.verify_hash().unwrap(); - let err = r.section::(3).unwrap_err(); - assert!(matches!( - err, - Error::Corrupt { - what: Corruption::MissingSection { id: 3 }, - .. - } - )); - let err = r.section::(2).unwrap_err(); - assert!(matches!( - err, - Error::Corrupt { - what: Corruption::TypeMismatch { .. }, - .. - } - )); - assert_eq!(err.code(), ErrorCode::CorruptTable); -} - -#[test] -fn writer_misuse() { - assert!(RbinWriter::new("", 1).is_err()); - assert!(RbinWriter::new("TOOLONGKD", 1).is_err()); - assert!(RbinWriter::new("A B", 1).is_err()); - let mut w = RbinWriter::new("K", 1).unwrap(); - w.section_vec(1, vec![1u32]).unwrap(); - assert_eq!( - w.section_vec(1, vec![1u32]).unwrap_err().code(), - ErrorCode::Internal - ); - assert!(w.section_bytes(2, 4, vec![0u8; 3]).is_err()); - assert!(w.section_bytes(2, 0, vec![]).is_err()); - assert!(w.section_vec(3, vec![(); 2]).is_err()); - w.upstream("a", Hash64(1)).unwrap(); - assert!(w.upstream("a", Hash64(2)).is_err()); - assert!(w.upstream("ninechars", Hash64(2)).is_err()); -} - -/// The header name of `table` in the upstream map of `tables`. -fn name_in(tables: &[&str], table: &str) -> String { - let upstream: Upstream = tables.iter().map(|t| (t.to_string(), Hash64(0))).collect(); - upstream_names(&upstream).unwrap()[table].clone() -} - -// WS-REQ-0038 (review CW-3): every upstream table, the contract's long names included, has -// an 8-byte header name; short names stay as they are. -#[test] -fn upstream_names_of_tables() { - for short in ["rtl", "netlist", "synth", "check", "sta", "a-b_1234"] { - assert_eq!(name_in(&[short], short), short); - } - let longs = [ - "constraints", - "libraries", - "libraries.ss", - "libraries.tt", - "netlist_a", - ]; - let mut seen = BTreeSet::new(); - for long in longs { - let name = name_in(&[long], long); - assert_eq!(name.len(), 8, "{name}"); - assert_eq!((&name[..3], &name[3..4]), (&long[..3], "."), "{name}"); - assert_eq!(name, name_in(&longs, long), "no collision, the same name"); - encode_name(&name, "upstream name").unwrap(); - assert!(seen.insert(name), "{long}"); - } - for bad in ["", "a b"] { - let upstream = Upstream::from([(bad.to_string(), Hash64(0))]); - assert_eq!( - upstream_names(&upstream).unwrap_err().code(), - ErrorCode::Internal - ); - } - - let upstream = Upstream::from([ - ("netlist".to_string(), Hash64(1)), - ("constraints".to_string(), Hash64(2)), - ("libraries".to_string(), Hash64(3)), - ]); - let mut w = RbinWriter::new("STA", 1).unwrap(); - w.upstream_tables(&upstream).unwrap(); - assert!(w.upstream_tables(&upstream).is_err(), "entries given twice"); - let r = Rbin::from_bytes(w.to_bytes(), "STA", 1..=1).unwrap(); - let names = upstream_names(&upstream).unwrap(); - for (table, hash) in &upstream { - assert_eq!(r.upstream_hash(&names[table]), Some(*hash), "{table}"); - } - assert_eq!(r.upstream_hash("rtl"), None); - let expected = header_upstream(&upstream).unwrap(); - assert!(r.has_upstream(&expected)); - assert!(!r.has_upstream(&header_upstream(&Upstream::new()).unwrap())); -} - -// WS-REQ-0038 (review CR2-4): names whose 16-bit hashes collide get distinct header names in -// one map, deterministically, so a table computed from both can be written and read. -#[test] -fn colliding_upstream_names() { - let mut by_name = BTreeMap::new(); - let (a, b) = (0..100_000) - .find_map(|i| { - let table = format!("libraries.c{i}"); - let name = name_in(&[&table], &table); - by_name - .insert(name, table.clone()) - .map(|other| (other, table)) - }) - .expect("16-bit names collide within a few hundred names"); - let (first, second) = if a < b { (a, b) } else { (b, a) }; - let alone = name_in(&[&first], &first); - assert_eq!(alone, name_in(&[&second], &second), "they collide alone"); - let upstream = Upstream::from([(first.clone(), Hash64(1)), (second.clone(), Hash64(2))]); - let names = upstream_names(&upstream).unwrap(); - assert_eq!( - names[&first], alone, - "the first in byte order keeps its name" - ); - let bumped = u16::from_str_radix(&alone[4..], 16) - .unwrap() - .wrapping_add(1); - assert_eq!(names[&second], format!("{}.{bumped:04x}", &alone[..3])); - let entries = header_upstream(&upstream).unwrap(); - assert_eq!(entries.len(), 2); - let mut w = RbinWriter::new("STA", 1).unwrap(); - w.upstream_tables(&upstream).unwrap(); - let r = Rbin::from_bytes(w.to_bytes(), "STA", 1..=1).unwrap(); - assert!(r.has_upstream(&entries)); - assert_eq!(r.upstream_hash(&names[&second]), Some(Hash64(2))); - - // A short name that looks like a shortened one is taken first. - let short = alone.clone(); - let upstream = Upstream::from([(first.clone(), Hash64(1)), (short.clone(), Hash64(2))]); - let names = upstream_names(&upstream).unwrap(); - assert_eq!(names[&short], short); - assert_ne!(names[&first], short); - - // Many corners of a PDK: every map of all of them has distinct names. - let mut corners = Upstream::new(); - for p in ["tt", "ss", "ff", "sf", "fs"] { - for t in ["025C", "100C", "n40C", "125C", "150C", "085C", "000C"] { - for v in [ - "1v80", "1v60", "1v40", "1v28", "1v35", "1v56", "1v62", "1v65", - ] { - corners.insert(format!("libraries.{p}_{t}_{v}"), Hash64(0)); - } - } - } - let names = upstream_names(&corners).unwrap(); - let distinct: BTreeSet<&String> = names.values().collect(); - assert_eq!(distinct.len(), corners.len()); -} - -// WS-REQ-0078 (review R2-C4, CR2-3): the import check reads a table once and gives its file -// hash; an rbin file must have a valid structure and content hash, whatever its kind. -#[test] -fn import_inspection() { - let mut w = RbinWriter::new("ANY", 7).unwrap(); - w.upstream("x", Hash64(3)).unwrap(); - w.section_vec(1, vec![5u32; 8]).unwrap(); - let good = w.to_bytes(); - let inspect_bytes = |b: &[u8]| inspect(b, b.len() as u64).unwrap(); - match inspect_bytes(&good) { - Inspected::Rbin { - file_hash, - upstream, - } => { - assert_eq!(file_hash, Hash64::of(&good)); - assert_eq!(upstream.len(), 1); - } - other => panic!("{other:?}"), - } - for plain in [&b""[..], b"{}", b"RUSTEDA", b"module top; endmodule\n"] { - assert!( - matches!(inspect_bytes(plain), Inspected::Plain(h) if h == Hash64::of(plain)), - "{plain:?}" - ); - } - let header_len = read_u64(&good, 40) as usize; - let mut flipped = good.clone(); - flipped[header_len] ^= 0xff; - assert!(matches!( - inspect_bytes(&flipped), - Inspected::Corrupt(Corruption::HashMismatch { .. }) - )); - let mut oob = good.clone(); - oob[64 + 16..64 + 24].copy_from_slice(&4096u64.to_le_bytes()); - assert!(matches!( - inspect_bytes(&oob), - Inspected::Corrupt(Corruption::SectionBounds { len: 4096, .. }) - )); - for cut in [8, 20, 47, 48, 60, header_len - 1, good.len() - 8] { - assert!( - matches!(inspect_bytes(&good[..cut]), Inspected::Corrupt(_)), - "cut at {cut}" - ); - } - let mut count = good.clone(); - count[32..36].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(matches!( - inspect_bytes(&count), - Inspected::Corrupt(Corruption::HeaderLen { .. }) - )); - // A reader that delivers fewer bytes than the length it announced. - let err = inspect(&good[..], good.len() as u64 + 8).unwrap_err(); - assert!(err.to_string().contains("changed"), "{err}"); -} - -// WS-REQ-0039 (review CW-5): a newer format version is SCHEMA_TOO_NEW (exit 1), an older -// unsupported one CORRUPT_TABLE. -#[test] -fn newer_and_older_format_versions() { - let w = RbinWriter::new("K", 2).unwrap(); - let err = Rbin::from_bytes(w.to_bytes(), "K", 1..=1).unwrap_err(); - assert_eq!((err.code(), err.exit_code()), (ErrorCode::SchemaTooNew, 1)); - assert!( - matches!( - err, - Error::FormatTooNew { - found: 2, - min: 1, - max: 1, - .. - } - ), - "{err}" - ); - assert!(err.help().unwrap().contains("upgrade the tools")); - let err = Rbin::from_bytes(w.to_bytes(), "K", 3..=4).unwrap_err(); - assert_eq!((err.code(), err.exit_code()), (ErrorCode::CorruptTable, 2)); -} diff --git a/crates/core/rusteda-workspace/src/rev.rs b/crates/core/rusteda-workspace/src/rev.rs deleted file mode 100644 index cae9e4f..0000000 --- a/crates/core/rusteda-workspace/src/rev.rs +++ /dev/null @@ -1,452 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Revisions and object handles. -//! -//! - [`Rev`] is a revision `rev`: printed unpadded, read with or without zero padding, -//! stored in the directory `rev` (WS-REQ-0020). -//! - [`RevSpec`] is what a query names (`--rev`, `--from`, `--to`): `HEAD` or `rev`. -//! [`BaseRev`] is what a mutation names (`--base-rev`): only `rev`, never `HEAD` -//! (WS-REQ-0021). They are different types, so a revision resolved for a query cannot be -//! passed where a base revision is required. -//! - [`Handle`] is a revision-scoped object handle ``, such as `inst12`, `net3`, -//! `pin7`, `path1`, `sel4` or `mm2`. Every handle matches `[A-Za-z0-9_-]+`, and `N` starts at -//! 1: `inst` names row `N - 1` of its table, `path` the path of rank `N` (contract §2, -//! CA-HANDLES; WS-REQ-0022). - -use std::fmt; -use std::str::FromStr; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// A revision or handle text that does not have the required form. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("'{text}' {reason}")] -pub struct BadHandle { - /// The rejected text. - pub text: String, - /// Why it was rejected, phrased to follow the quoted text. - pub reason: String, -} - -impl BadHandle { - fn new(text: &str, reason: impl Into) -> BadHandle { - BadHandle { - text: text.to_string(), - reason: reason.into(), - } - } -} - -/// A revision number, printed `rev`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] -pub struct Rev(pub u32); - -impl Rev { - /// `rev0`, the empty revision that `init` creates. - pub const ZERO: Rev = Rev(0); - - /// Reads `rev`, with or without zero padding (`rev3`, `rev0003`). `HEAD` is rejected: - /// queries read it with [`RevSpec::parse`]. - pub fn parse(text: &str) -> Result { - if text == "HEAD" { - return Err(BadHandle::new( - text, - "is not accepted here: name an explicit revision such as rev3 \ - (HEAD is accepted only by --rev, --from and --to)", - )); - } - let Some(digits) = text.strip_prefix("rev") else { - return Err(BadHandle::new( - text, - "is not a revision: expected rev, e.g. rev3", - )); - }; - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return Err(BadHandle::new( - text, - "is not a revision: expected rev, e.g. rev3", - )); - } - parse_decimal(digits) - .map(Rev) - .ok_or_else(|| BadHandle::new(text, "is out of range")) - } - - /// The revision number. - pub const fn number(self) -> u32 { - self.0 - } - - /// The directory name: `rev` and the number zero-padded to at least 4 digits. - pub fn dir_name(self) -> String { - format!("rev{:04}", self.0) - } - - /// Reads a directory name; only the canonical form of [`Rev::dir_name`] is accepted. - pub fn from_dir_name(name: &str) -> Option { - let rev = Rev(parse_decimal(name.strip_prefix("rev")?)?); - (rev.dir_name() == name).then_some(rev) - } - - /// The next revision number, or `None` at `u32::MAX`. - pub fn next(self) -> Option { - self.0.checked_add(1).map(Rev) - } -} - -impl fmt::Display for Rev { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "rev{}", self.0) - } -} - -impl FromStr for Rev { - type Err = BadHandle; - - fn from_str(text: &str) -> Result { - Rev::parse(text) - } -} - -impl Serialize for Rev { - fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for Rev { - fn deserialize>(deserializer: D) -> Result { - let text = String::deserialize(deserializer)?; - Rev::parse(&text).map_err(serde::de::Error::custom) - } -} - -/// The revision a query names: `HEAD` or `rev` (`--rev`, `--from`, `--to`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum RevSpec { - /// The workspace HEAD at the time the query reads the manifest. - Head, - /// An explicit revision. - Rev(Rev), -} - -impl RevSpec { - /// Reads `HEAD` or `rev`. - pub fn parse(text: &str) -> Result { - if text == "HEAD" { - Ok(RevSpec::Head) - } else { - Rev::parse(text).map(RevSpec::Rev) - } - } -} - -impl fmt::Display for RevSpec { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - RevSpec::Head => f.write_str("HEAD"), - RevSpec::Rev(rev) => rev.fmt(f), - } - } -} - -impl FromStr for RevSpec { - type Err = BadHandle; - - fn from_str(text: &str) -> Result { - RevSpec::parse(text) - } -} - -/// The base revision of a mutation (`--base-rev`): always an explicit `rev`, never `HEAD`. -/// -/// [`Workspace::resolve_base`](crate::Workspace::resolve_base) also checks that it exists; -/// [`Workspace::begin`](crate::Workspace::begin) and -/// [`Workspace::revert`](crate::Workspace::revert) take only this type. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct BaseRev(Rev); - -impl BaseRev { - /// Reads `rev`; `HEAD` is rejected. - pub fn parse(text: &str) -> Result { - Rev::parse(text).map(BaseRev) - } - - /// The revision. - pub const fn rev(self) -> Rev { - self.0 - } -} - -impl fmt::Display for BaseRev { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -/// A revision-scoped object handle ``, such as `inst12` or `sel4`. -/// -/// The prefix starts with an ASCII letter, contains only `[A-Za-z0-9_-]` and does not end in a -/// digit; `N` is decimal, read with or without zero padding and printed unpadded. `N` starts at -/// 1 (contract §2, CA-HANDLES): `inst`, `net` and `port` are `1 + row` of the netlist, -/// `pin` is `1 + row` of its pin table, and `path` and `mm` are 1-based ranks, so -/// `N = 0` is never a handle; [`Handle::from_row`] and [`Handle::row`] convert (WS-REQ-0022). -/// Handles are always paired with their revision in JSON. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Handle { - prefix: String, - index: u32, -} - -impl Handle { - /// A handle from its parts; `index` is `N`, at least 1. - pub fn new(prefix: &str, index: u32) -> Result { - check_prefix(prefix)?; - if index == 0 { - return Err(BadHandle::new(&format!("{prefix}0"), ZERO)); - } - Ok(Handle { - prefix: prefix.to_string(), - index, - }) - } - - /// The handle of 0-based row (or rank minus one) `row`: `` - /// (CA-HANDLES). Row `u32::MAX` has no handle. - pub fn from_row(prefix: &str, row: u32) -> Result { - let index = row.checked_add(1).ok_or_else(|| { - BadHandle::new( - &format!("{prefix}{row}"), - "is out of range: row has no handle", - ) - })?; - Handle::new(prefix, index) - } - - /// Reads ``. - pub fn parse(text: &str) -> Result { - check_handle(text)?; - let split = text.trim_end_matches(|c: char| c.is_ascii_digit()).len(); - let (prefix, digits) = text.split_at(split); - if digits.is_empty() { - return Err(BadHandle::new( - text, - "has no number: expected , e.g. inst12", - )); - } - if prefix.is_empty() { - return Err(BadHandle::new( - text, - "has no prefix: expected , e.g. inst12", - )); - } - check_prefix(prefix).map_err(|e| BadHandle::new(text, e.reason))?; - let index = parse_decimal(digits).ok_or_else(|| BadHandle::new(text, "is out of range"))?; - if index == 0 { - return Err(BadHandle::new(text, ZERO)); - } - Ok(Handle { - prefix: prefix.to_string(), - index, - }) - } - - /// Reads `` for the given prefix and returns `N`. - pub fn parse_as(text: &str, prefix: &str) -> Result { - let handle = Handle::parse(text)?; - if handle.prefix != prefix { - return Err(BadHandle::new( - text, - format!("is not a {prefix} handle: expected {prefix}, e.g. {prefix}1"), - )); - } - Ok(handle.index) - } - - /// The prefix, e.g. `inst`. - pub fn prefix(&self) -> &str { - &self.prefix - } - - /// The number `N`, at least 1. - pub fn index(&self) -> u32 { - self.index - } - - /// The 0-based row (or rank minus one) the handle names: `N - 1` (CA-HANDLES). A handle - /// that does not resolve to a row of its table is `UNKNOWN_HANDLE` for the tool. - pub fn row(&self) -> u32 { - // `N >= 1` for every handle: `new` and `parse` refuse 0. - self.index - 1 - } -} - -/// Why `N = 0` is not a handle. -const ZERO: &str = "is not a handle: N starts at 1 (contract §2, CA-HANDLES), e.g. inst1"; - -impl fmt::Display for Handle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}{}", self.prefix, self.index) - } -} - -impl FromStr for Handle { - type Err = BadHandle; - - fn from_str(text: &str) -> Result { - Handle::parse(text) - } -} - -impl Serialize for Handle { - fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for Handle { - fn deserialize>(deserializer: D) -> Result { - let text = String::deserialize(deserializer)?; - Handle::parse(&text).map_err(serde::de::Error::custom) - } -} - -/// Whether `text` is shell-safe: non-empty and only `[A-Za-z0-9_-]`. -pub fn is_handle(text: &str) -> bool { - !text.is_empty() - && text - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') -} - -/// Checks [`is_handle`] and explains a failure. -pub fn check_handle(text: &str) -> Result<(), BadHandle> { - if text.is_empty() { - return Err(BadHandle::new(text, "is empty")); - } - if !is_handle(text) { - return Err(BadHandle::new( - text, - "is not a handle: handles match [A-Za-z0-9_-]+", - )); - } - Ok(()) -} - -fn check_prefix(prefix: &str) -> Result<(), BadHandle> { - let bytes = prefix.as_bytes(); - let valid = bytes.first().is_some_and(u8::is_ascii_alphabetic) - && is_handle(prefix) - && bytes.last().is_some_and(|b| !b.is_ascii_digit()); - if !valid { - return Err(BadHandle::new( - prefix, - "is not a handle prefix: it starts with a letter, uses [A-Za-z0-9_-] and does not \ - end in a digit", - )); - } - Ok(()) -} - -/// Decimal digits (zero padding allowed) to `u32`; no sign, no other characters. -fn parse_decimal(digits: &str) -> Option { - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - digits.parse().ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - // WS-REQ-0020 - #[test] - fn revisions() { - assert_eq!(Rev::parse("rev3"), Ok(Rev(3))); - assert_eq!(Rev::parse("rev0003"), Ok(Rev(3))); - assert_eq!(Rev::parse("rev0"), Ok(Rev(0))); - assert_eq!(Rev::parse("rev4294967295"), Ok(Rev(u32::MAX))); - for bad in [ - "", - "rev", - "3", - "Rev3", - "rev-1", - "rev+1", - "rev 1", - "rev1a", - "rev4294967296", - ] { - assert!(Rev::parse(bad).is_err(), "{bad}"); - } - assert_eq!(Rev(3).to_string(), "rev3"); - assert_eq!(Rev(3).dir_name(), "rev0003"); - assert_eq!(Rev(0).dir_name(), "rev0000"); - assert_eq!(Rev(12345).dir_name(), "rev12345"); - assert_eq!(Rev::from_dir_name("rev0003"), Some(Rev(3))); - assert_eq!(Rev::from_dir_name("rev12345"), Some(Rev(12345))); - assert_eq!(Rev::from_dir_name("rev3"), None); - assert_eq!(Rev::from_dir_name("rev01234"), None); - assert_eq!(Rev::from_dir_name(".staging-1-2"), None); - assert_eq!(serde_json::to_string(&Rev(7)).unwrap(), "\"rev7\""); - assert_eq!(serde_json::from_str::("\"rev0007\"").unwrap(), Rev(7)); - assert_eq!(Rev(u32::MAX).next(), None); - } - - // WS-REQ-0021 - #[test] - fn head_only_for_queries() { - assert_eq!(RevSpec::parse("HEAD"), Ok(RevSpec::Head)); - assert_eq!(RevSpec::parse("rev2"), Ok(RevSpec::Rev(Rev(2)))); - assert!(RevSpec::parse("head").is_err()); - let err = BaseRev::parse("HEAD").unwrap_err(); - assert!( - err.to_string().contains("HEAD is accepted only by"), - "{err}" - ); - assert_eq!(BaseRev::parse("rev0002").unwrap().rev(), Rev(2)); - assert_eq!(BaseRev::parse("rev2").unwrap().to_string(), "rev2"); - assert_eq!(RevSpec::Head.to_string(), "HEAD"); - } - - // WS-REQ-0022 - #[test] - fn handles() { - let h = Handle::parse("inst12").unwrap(); - assert_eq!((h.prefix(), h.index()), ("inst", 12)); - assert_eq!(h.to_string(), "inst12"); - assert_eq!(Handle::parse("inst_007").unwrap().to_string(), "inst_7"); - assert_eq!(Handle::parse("my-path3").unwrap().prefix(), "my-path"); - assert_eq!(Handle::parse_as("sel4", "sel"), Ok(4)); - assert!( - Handle::parse_as("path4", "sel") - .unwrap_err() - .to_string() - .contains("sel handle") - ); - for bad in [ - "", - "12", - "inst", - "inst#1", - "inst 1", - "$x1", - "_x1", - "inst99999999999", - "é1", - ] { - assert!(Handle::parse(bad).is_err(), "{bad}"); - } - assert_eq!(Handle::new("path", 3).unwrap().to_string(), "path3"); - assert!(Handle::new("p2", 3).is_err()); - assert!(Handle::new("", 3).is_err()); - assert!(is_handle("rev3") && is_handle("a-b_C9") && !is_handle("a.b") && !is_handle("")); - assert_eq!(serde_json::to_string(&h).unwrap(), "\"inst12\""); - assert_eq!( - serde_json::from_str::("\"mm2\"").unwrap(), - Handle::new("mm", 2).unwrap() - ); - } -} diff --git a/crates/core/rusteda-workspace/src/rlib.rs b/crates/core/rusteda-workspace/src/rlib.rs deleted file mode 100644 index 40918e1..0000000 --- a/crates/core/rusteda-workspace/src/rlib.rs +++ /dev/null @@ -1,836 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! The `LIBERTY` v4 (`.rlib`) compiled library codec and workspace cache loader -//! (contract §5, `WS-REQ-0110` to `WS-REQ-0116`, `WS-REQ-0117`). -//! -//! ## Container layout (`WS-REQ-0110`) -//! -//! A compiled Liberty library (`cache/-.rlib`) is an [`Rbin`] container of kind -//! [`KIND`] (`"LIBERTY"`) and format version [`FORMAT_VERSION`] (`4`, range [`VERSIONS`]; an -//! older `.rlib` is compiled again from its cached `.lib`, `WS-REQ-0084`). -//! When compiled from a source `.lib` file ([`encode_with_source`], [`put`]), the container -//! header records upstream `"lib"` mapped to the `xxh3-64` hash of the `.lib` bytes. -//! -//! Three sections are written in ascending section ID order: -//! - [`SEC_STR`] (`1`, `elem_size = 1`): concatenated UTF-8 bytes of all strings in the library, -//! deduplicated in first-appearance order. -//! - [`SEC_MODEL`] (`2`, `elem_size = 1`): deterministic little-endian byte stream of the -//! [`Library`] model. -//! - [`SEC_F64`] (`3`, `elem_size = 8`): pool of little-endian IEEE-754 `f64` bit patterns -//! (`u64` in little-endian byte order) referenced by `(start, len)` slices from [`Template`] -//! and [`Table`] records in `MODEL`. -//! -//! ## Normative `MODEL` byte-stream grammar (`WS-REQ-0111`, `WS-REQ-0112`) -//! -//! Primitives in `MODEL`: -//! - `u8`: 1 byte. -//! - `bool`: 1 byte (`0` = `false`, `1` = `true`). -//! - `u32`: 4 bytes, little-endian. -//! - `f64`: 8 bytes, little-endian IEEE-754 bits (`f64::to_bits().to_le_bytes()`). -//! - `str_ref`: `(offset: u32, len: u32)` indexing `&STR[offset..offset + len]`. -//! - `opt`: `u8` tag (`0` = `None`, `1` = `Some(T)`). -//! - `f64_slice`: `(start: u32, len: u32)` indexing `len` elements starting at `start` in `F64`. -//! - `vec`: `count: u32` followed by `count` encoded `T` records. -//! -//! Records in `MODEL`: -//! - `Library`: `name: str_ref`, `time_unit_ns: f64`, `cap_unit_pf: f64`, -//! `default_max_capacitance: opt`, `default_max_transition: opt`, -//! `default_max_fanout: opt`, `default_fanout_load: f64`, -//! `default_input_pin_cap: opt`, `default_output_pin_cap: opt`, -//! `default_inout_pin_cap: opt`, `units: Units`, -//! `templates: vec<(str_ref, Template)>` (strictly ascending key order), -//! `cells: vec` (source order), `input_threshold_pct_rise: opt`, -//! `input_threshold_pct_fall: opt`, `output_threshold_pct_rise: opt`, -//! `output_threshold_pct_fall: opt`, `slew_lower_threshold_pct_rise: opt`, -//! `slew_upper_threshold_pct_rise: opt`, `slew_lower_threshold_pct_fall: opt`, -//! `slew_upper_threshold_pct_fall: opt`, `slew_derate_from_library: opt`, -//! `wire_loads: vec<(str_ref, WireLoad)>` (strictly ascending key order), -//! `wire_load_tables: vec<(str_ref, WireLoadTable)>` (strictly ascending key order), -//! `wire_load_selections: vec<(str_ref, WireLoadSelection)>` (strictly ascending key order), -//! `default_wire_load: opt`, `default_wire_load_mode: opt`, -//! `default_wire_load_selection: opt`, `default_wire_load_area: opt`, -//! `default_wire_load_capacitance: opt`, `default_wire_load_resistance: opt`, -//! `operating_conditions: vec<(str_ref, OperatingConditions)>` (strictly ascending key order), -//! `default_operating_conditions: opt`, `nom_process: opt`, -//! `nom_temperature: opt`, `nom_voltage: opt`. -//! - `Units`: six `Unit` records: `time`, `capacitance`, `resistance`, `voltage`, `current`, -//! `leakage_power`. -//! - `Unit`: `scale: f64`, `given: bool`, `text: opt`. -//! - `Template`: `variable_1: opt`, `variable_2: opt`, -//! `variable_3: opt`, `index_1: f64_slice`, `index_2: f64_slice`, -//! `index_3: f64_slice`. -//! - `Table`: `template: str_ref`, `variable_1: opt`, `variable_2: opt`, -//! `variable_3: opt`, `index_1: f64_slice`, `index_2: f64_slice`, -//! `index_3: f64_slice`, `values: f64_slice`. -//! - `Func`: `u8` discriminant (`0` = `Const(false)`, `1` = `Const(true)`, -//! `2` = `Var(str_ref)`, `3` = `Not(Func)`, `4` = `And(Func, Func)`, `5` = `Or(Func, Func)`, -//! `6` = `Xor(Func, Func)`). -//! - `Direction`: `u8` (`0` = `Input`, `1` = `Output`, `2` = `Inout`, `3` = `Internal`). -//! - `SeqKind`: `u8` (`0` = `FlipFlop`, `1` = `Latch`). -//! - `PgType`, `LevelShifterType`, `SwitchCellType`, `AntennaDiodeType`, `WireLoadMode`, -//! `TreeType`: `u8` tag (`0` = `from_name(str_ref)`, `1` = explicit `Other(str_ref)`). -//! - `WireLoad`: `name: str_ref`, `resistance: f64`, `capacitance: f64`, `area: f64`, -//! `slope: f64`, `fanout_length: vec<(u32, f64)>`. -//! - `WireLoadTable`: `name: str_ref`, `fanout_length: vec<(u32, f64)>`, -//! `fanout_capacitance: vec<(u32, f64)>`, `fanout_resistance: vec<(u32, f64)>`, -//! `fanout_area: vec<(u32, f64)>`. -//! - `WireLoadAreaRule`: `min_area: f64`, `max_area: f64`, `wire_load: str_ref`. -//! - `WireLoadSelection`: `name: str_ref`, `rules: vec`. -//! - `OperatingConditions`: `name: str_ref`, `process: opt`, `temperature: opt`, -//! `voltage: opt`, `tree_type: opt`. -//! - `TimingArc`: `related_pin: str_ref`, `timing_sense: opt`, `timing_type: str_ref`, -//! `cell_rise: opt
`, `cell_fall: opt
`, `rise_transition: opt
`, -//! `fall_transition: opt
`, `rise_constraint: opt
`, `fall_constraint: opt
`, -//! `when_text: opt`, `when: opt`, `sdf_cond: opt`, `line: u32`. -//! - `InternalPower`: `related_pin: opt`, `rise_power: opt
`, -//! `fall_power: opt
`. -//! - `Pin`: `name: str_ref`, `direction: Direction`, `function_text: opt`, -//! `function: opt`, `three_state: opt`, `three_state_function: opt`, -//! `capacitance: f64`, `capacitance_given: bool`, `rise_capacitance: opt`, -//! `fall_capacitance: opt`, `rise_capacitance_range: opt<(f64, f64)>`, -//! `fall_capacitance_range: opt<(f64, f64)>`, `min_capacitance: opt`, -//! `max_capacitance: opt`, `max_transition: opt`, `max_fanout: opt`, -//! `fanout_load: opt`, `is_clock: bool`, `arcs: vec`, -//! `internal_power: vec`, `isolation_cell_enable_pin: bool`, -//! `isolation_cell_data_pin: bool`, `level_shifter_enable_pin: bool`, -//! `level_shifter_data_pin: bool`, `always_on: bool`, `related_power_pin: opt`, -//! `related_ground_pin: opt`, `power_down_function: opt`, -//! `state_function_text: opt`, `state_function: opt`, -//! `internal_node: opt`, `clock_gate_clock_pin: bool`, -//! `clock_gate_enable_pin: bool`, `clock_gate_out_pin: bool`, `clock_gate_test_pin: bool`, -//! `clock_gate_obs_pin: bool`, `line: u32`, `min_pulse_width_high: opt`, -//! `min_pulse_width_low: opt`, `min_period: opt` (the last three since version 4, -//! `WS-REQ-0117`). -//! - `Sequential`: `kind: SeqKind`, `state: str_ref`, `state_n: str_ref`, -//! `data_text: str_ref`, `data: Func`, `clock_text: str_ref`, `clock: Func`, -//! `clocked_on_also: opt`, `clear_text: opt`, `clear: opt`, -//! `preset_text: opt`, `preset: opt`, `clear_preset_var1: opt`, -//! `clear_preset_var2: opt`. -//! - `Statetable`: `input_nodes: vec`, `internal_nodes: vec`, -//! `table: str_ref`, `rows: vec`. -//! - `PgPin`: `name: str_ref`, `pg_type: opt`, `voltage_name: opt`. -//! - `Cell`: `name: str_ref`, `area: f64`, `dont_use: bool`, `pins: vec`, -//! `pg_pins: vec`, `sequentials: vec`, `has_statetable: bool`, -//! `has_test_cell: bool`, `is_clock_gating: bool`, `has_bus: bool`, -//! `leakage_power: opt`, `problems: vec`, `pg_pin_groups: vec`, -//! `is_isolation_cell: bool`, `is_level_shifter: bool`, -//! `level_shifter_type: opt`, `always_on: bool`, -//! `cell_footprint: opt`, `dont_touch: bool`, `is_macro_cell: bool`, -//! `pad_cell: bool`, `switch_cell_type: opt`, `retention_cell: opt`, -//! `antenna_diode_type: opt`, -//! `clock_gating_integrated_cell: opt`, `statetable: opt`, `line: u32`. - -mod decode; - -use std::collections::BTreeMap; -use std::ops::RangeInclusive; -use std::path::Path; - -use rusteda_core::liberty::{ - Cell, Direction, Func, InternalPower, Library, OperatingConditions, PgPin, Pin, SeqKind, - Sequential, Statetable, Table, Template, TimingArc, Unit, Units, WireLoad, WireLoadAreaRule, - WireLoadSelection, WireLoadTable, -}; - -use crate::cache::{CachedLibrary, PutError}; -use crate::error::{Corruption, Error, Result}; -use crate::hash::Hash64; -use crate::rbin::{Rbin, RbinWriter}; -use crate::schema::LibraryEntry; -use crate::workspace::{Revision, Workspace}; - -/// The `rbin` container kind for compiled Liberty libraries (`WS-REQ-0110`). -pub const KIND: &str = "LIBERTY"; - -/// The current `LIBERTY` format version (`WS-REQ-0110`, `WS-REQ-0117`). -pub const FORMAT_VERSION: u32 = 4; - -/// The range of `LIBERTY` format versions supported by this build (`WS-REQ-0110`). -pub const VERSIONS: RangeInclusive = FORMAT_VERSION..=FORMAT_VERSION; - -/// Section ID of the UTF-8 string pool (`elem_size = 1`, `WS-REQ-0110`). -pub const SEC_STR: u32 = 1; - -/// Section ID of the model byte stream (`elem_size = 1`, `WS-REQ-0110`). -pub const SEC_MODEL: u32 = 2; - -/// Section ID of the `f64` table value pool (`elem_size = 8`, `WS-REQ-0110`). -pub const SEC_F64: u32 = 3; - -/// Maximum recursion depth for encoded [`Func`] expressions (`WS-REQ-0114`). -pub(super) const MAX_FUNC_DEPTH: usize = 256; - -/// Encodes `lib` into a deterministic `LIBERTY` `.rlib` byte vector without an upstream -/// header entry (`WS-REQ-0110`, `WS-REQ-0112`, `WS-REQ-0113`). -pub fn encode(lib: &Library) -> Result> { - encode_inner(lib, None) -} - -/// Encodes `lib` into a deterministic `LIBERTY` `.rlib` byte vector recording `source_hash` -/// under upstream `"lib"` in the `rbin` header (`WS-REQ-0110`, `WS-REQ-0112`, `WS-REQ-0113`). -pub fn encode_with_source(lib: &Library, source_hash: Hash64) -> Result> { - encode_inner(lib, Some(source_hash)) -} - -fn encode_inner(lib: &Library, source_hash: Option) -> Result> { - let mut enc = Encoder::default(); - enc.write_library(lib)?; - - let mut writer = RbinWriter::new(KIND, FORMAT_VERSION)?; - if let Some(hash) = source_hash { - writer.upstream("lib", hash)?; - } - writer.section_bytes(SEC_STR, 1, enc.str_bytes)?; - writer.section_bytes(SEC_MODEL, 1, enc.model)?; - writer.section(SEC_F64, &enc.f64_bits)?; - Ok(writer.to_bytes()) -} - -/// Validates and decodes a `LIBERTY` `.rlib` byte slice into a [`Library`] (`WS-REQ-0113`, -/// `WS-REQ-0114`). -pub fn decode(bytes: &[u8]) -> Result { - let rbin = Rbin::from_bytes(bytes.to_vec(), KIND, VERSIONS)?; - rbin.verify_hash()?; - decode_rbin(&rbin) -} - -/// Decodes a [`Library`] from an opened [`Rbin`] container (`WS-REQ-0113`, `WS-REQ-0114`). -pub fn decode_rbin(rbin: &Rbin) -> Result { - let path = rbin.path(); - if rbin.kind() != KIND { - return Err(Error::corrupt( - path, - Corruption::WrongKind { - expected: KIND.to_string(), - found: rbin.kind().to_string(), - }, - )); - } - let fv = rbin.format_version(); - if fv > *VERSIONS.end() { - return Err(Error::SchemaTooNew { - path: path.to_path_buf(), - found: format!("{KIND} v{fv}"), - supported: format!("{KIND} v{}", VERSIONS.end()), - }); - } - if !VERSIONS.contains(&fv) { - return Err(Error::corrupt( - path, - Corruption::UnsupportedVersion { - found: fv, - min: *VERSIONS.start(), - max: *VERSIONS.end(), - }, - )); - } - - let str_bytes: &[u8] = rbin.section(SEC_STR)?; - let model_bytes: &[u8] = rbin.section(SEC_MODEL)?; - let f64_bits: &[u64] = rbin.section(SEC_F64)?; - - let str_pool = std::str::from_utf8(str_bytes) - .map_err(|e| corrupt_rlib(path, format!("STR section is not valid UTF-8: {e}")))?; - - decode::decode_model(path, str_pool, model_bytes, f64_bits) -} - -/// Compiles and stores `lib` and its verbatim `lib_bytes` in `ws`'s library cache -/// (`cache/-.lib` and `cache/-.rlib`, `WS-REQ-0115`). -pub fn put(ws: &Workspace, lib: &Library, lib_bytes: &[u8]) -> Result { - ws.cache() - .put_library(&lib.name, lib_bytes, KIND, VERSIONS, |raw| { - encode_with_source(lib, Hash64::of(raw)) - }) - .map_err(|e| match e { - PutError::Compile(err) | PutError::Workspace(err) => err, - }) -} - -/// Returns the [`LibraryEntry`] items of `rev` belonging to `corner`, in revision order -/// (`WS-REQ-0116`). -pub fn libraries_of_corner<'a>(rev: &'a Revision, corner: &str) -> Vec<&'a LibraryEntry> { - rev.meta() - .libraries - .iter() - .filter(|l| l.corner == corner) - .collect() -} - -/// Finds the [`LibraryEntry`] of `rev` with the given `name` and `corner` (`WS-REQ-0116`). -pub fn find_library<'a>(rev: &'a Revision, name: &str, corner: &str) -> Result<&'a LibraryEntry> { - rev.meta() - .libraries - .iter() - .find(|l| l.name == name && l.corner == corner) - .ok_or_else(|| Error::MissingTable { - rev: rev.rev(), - table: format!("library {name} ({corner})"), - }) -} - -/// Maps and decodes `entry` from the workspace cache of `rev` (`WS-REQ-0116`). -pub fn load_entry(rev: &Revision, entry: &LibraryEntry) -> Result { - let rbin = rev.open_library(entry, KIND, VERSIONS)?; - decode_rbin(&rbin) -} - -/// Maps and decodes the library `(name, corner)` of `rev` (`WS-REQ-0116`). -pub fn load(_ws: &Workspace, rev: &Revision, name: &str, corner: &str) -> Result { - let entry = find_library(rev, name, corner)?; - load_entry(rev, entry) -} - -/// Maps and decodes the library `(name, corner)` of `rev`, transparently recompiling its -/// `.rlib` from the cached `.lib` via `parse` when the `.rlib` is missing, stale, or has an -/// older `format_version` (`WS-REQ-0084`, `WS-REQ-0116`). Returns `(library, recompiled)`. -pub fn load_or_recompile( - ws: &Workspace, - rev: &Revision, - name: &str, - corner: &str, - parse: impl FnOnce(&[u8]) -> std::result::Result, -) -> std::result::Result<(Library, bool), PutError> { - let entry = find_library(rev, name, corner).map_err(PutError::Workspace)?; - let cache = ws.cache(); - let mut recompiled = false; - let rbin = cache.open_rlib_or_recompile(&entry.name, entry.hash, KIND, VERSIONS, |raw| { - recompiled = true; - let parsed = parse(raw).map_err(PutError::Compile)?; - encode_with_source(&parsed, entry.hash).map_err(PutError::Workspace) - }); - let rbin = match rbin { - Ok(r) => r, - Err(PutError::Compile(inner)) => return Err(inner), - Err(PutError::Workspace(err)) => return Err(PutError::Workspace(err)), - }; - let lib = decode_rbin(&rbin).map_err(PutError::Workspace)?; - Ok((lib, recompiled)) -} - -pub(super) fn corrupt_rlib(path: &Path, reason: impl Into) -> Error { - Error::corrupt( - path, - Corruption::Json { - reason: reason.into(), - }, - ) -} - -#[derive(Default)] -struct Encoder { - str_bytes: Vec, - str_index: BTreeMap, - model: Vec, - f64_bits: Vec, -} - -impl Encoder { - fn intern_str(&mut self, s: &str) -> Result<(u32, u32)> { - if let Some(&span) = self.str_index.get(s) { - return Ok(span); - } - let offset = u32::try_from(self.str_bytes.len()) - .map_err(|_| Error::internal("rlib STR section exceeds u32::MAX bytes"))?; - let len = u32::try_from(s.len()) - .map_err(|_| Error::internal("rlib string exceeds u32::MAX bytes"))?; - self.str_bytes.extend_from_slice(s.as_bytes()); - let span = (offset, len); - self.str_index.insert(s.to_string(), span); - Ok(span) - } - - fn write_u8(&mut self, v: u8) { - self.model.push(v); - } - - fn write_bool(&mut self, v: bool) { - self.write_u8(u8::from(v)); - } - - fn write_u32(&mut self, v: u32) { - self.model.extend_from_slice(&v.to_le_bytes()); - } - - fn write_usize_u32(&mut self, v: usize) -> Result<()> { - let n = u32::try_from(v).map_err(|_| Error::internal("rlib count exceeds u32::MAX"))?; - self.write_u32(n); - Ok(()) - } - - fn write_f64(&mut self, v: f64) { - self.model.extend_from_slice(&v.to_bits().to_le_bytes()); - } - - fn write_opt_f64(&mut self, v: Option) { - match v { - None => self.write_u8(0), - Some(x) => { - self.write_u8(1); - self.write_f64(x); - } - } - } - - fn write_opt_f64_pair(&mut self, v: Option<(f64, f64)>) { - match v { - None => self.write_u8(0), - Some((a, b)) => { - self.write_u8(1); - self.write_f64(a); - self.write_f64(b); - } - } - } - - fn write_str(&mut self, s: &str) -> Result<()> { - let (off, len) = self.intern_str(s)?; - self.write_u32(off); - self.write_u32(len); - Ok(()) - } - - fn write_opt_str(&mut self, s: Option<&str>) -> Result<()> { - match s { - None => self.write_u8(0), - Some(text) => { - self.write_u8(1); - self.write_str(text)?; - } - } - Ok(()) - } - - fn write_f64_slice(&mut self, slice: &[f64]) -> Result<()> { - let start = u32::try_from(self.f64_bits.len()) - .map_err(|_| Error::internal("rlib F64 section exceeds u32::MAX elements"))?; - let len = u32::try_from(slice.len()) - .map_err(|_| Error::internal("rlib f64 slice exceeds u32::MAX elements"))?; - self.f64_bits - .extend(slice.iter().map(|v| v.to_bits().to_le())); - self.write_u32(start); - self.write_u32(len); - Ok(()) - } - - fn write_unit(&mut self, u: &Unit) -> Result<()> { - self.write_f64(u.scale); - self.write_bool(u.given); - self.write_opt_str(u.text.as_deref()) - } - - fn write_units(&mut self, u: &Units) -> Result<()> { - self.write_unit(&u.time)?; - self.write_unit(&u.capacitance)?; - self.write_unit(&u.resistance)?; - self.write_unit(&u.voltage)?; - self.write_unit(&u.current)?; - self.write_unit(&u.leakage_power) - } - - fn write_template(&mut self, t: &Template) -> Result<()> { - self.write_opt_str(t.variable_1.as_deref())?; - self.write_opt_str(t.variable_2.as_deref())?; - self.write_opt_str(t.variable_3.as_deref())?; - self.write_f64_slice(&t.index_1)?; - self.write_f64_slice(&t.index_2)?; - self.write_f64_slice(&t.index_3) - } - - fn write_table(&mut self, t: &Table) -> Result<()> { - self.write_str(&t.template)?; - self.write_opt_str(t.variable_1.as_deref())?; - self.write_opt_str(t.variable_2.as_deref())?; - self.write_opt_str(t.variable_3.as_deref())?; - self.write_f64_slice(&t.index_1)?; - self.write_f64_slice(&t.index_2)?; - self.write_f64_slice(&t.index_3)?; - self.write_f64_slice(&t.values) - } - - fn write_opt_table(&mut self, t: Option<&Table>) -> Result<()> { - match t { - None => self.write_u8(0), - Some(tbl) => { - self.write_u8(1); - self.write_table(tbl)?; - } - } - Ok(()) - } - - fn write_func(&mut self, f: &Func, depth: usize) -> Result<()> { - if depth > MAX_FUNC_DEPTH { - return Err(Error::internal("rlib Func expression exceeds max depth")); - } - match f { - Func::Const(false) => self.write_u8(0), - Func::Const(true) => self.write_u8(1), - Func::Var(v) => { - self.write_u8(2); - self.write_str(v)?; - } - Func::Not(a) => { - self.write_u8(3); - self.write_func(a, depth + 1)?; - } - Func::And(a, b) => { - self.write_u8(4); - self.write_func(a, depth + 1)?; - self.write_func(b, depth + 1)?; - } - Func::Or(a, b) => { - self.write_u8(5); - self.write_func(a, depth + 1)?; - self.write_func(b, depth + 1)?; - } - Func::Xor(a, b) => { - self.write_u8(6); - self.write_func(a, depth + 1)?; - self.write_func(b, depth + 1)?; - } - } - Ok(()) - } - - fn write_opt_func(&mut self, f: Option<&Func>) -> Result<()> { - match f { - None => self.write_u8(0), - Some(func) => { - self.write_u8(1); - self.write_func(func, 0)?; - } - } - Ok(()) - } - - fn write_named_enum(&mut self, is_known: bool, name: &str) -> Result<()> { - self.write_u8(if is_known { 0 } else { 1 }); - self.write_str(name) - } - - fn write_arc(&mut self, a: &TimingArc) -> Result<()> { - self.write_str(&a.related_pin)?; - self.write_opt_str(a.timing_sense.as_deref())?; - self.write_str(&a.timing_type)?; - self.write_opt_table(a.cell_rise.as_ref())?; - self.write_opt_table(a.cell_fall.as_ref())?; - self.write_opt_table(a.rise_transition.as_ref())?; - self.write_opt_table(a.fall_transition.as_ref())?; - self.write_opt_table(a.rise_constraint.as_ref())?; - self.write_opt_table(a.fall_constraint.as_ref())?; - self.write_opt_str(a.when_text.as_deref())?; - self.write_opt_func(a.when.as_ref())?; - self.write_opt_str(a.sdf_cond.as_deref())?; - self.write_u32(a.line); - Ok(()) - } - - fn write_internal_power(&mut self, ip: &InternalPower) -> Result<()> { - self.write_opt_str(ip.related_pin.as_deref())?; - self.write_opt_table(ip.rise_power.as_ref())?; - self.write_opt_table(ip.fall_power.as_ref()) - } - - fn write_pin(&mut self, p: &Pin) -> Result<()> { - self.write_str(&p.name)?; - let dir_tag = match p.direction { - Direction::Input => 0, - Direction::Output => 1, - Direction::Inout => 2, - Direction::Internal => 3, - }; - self.write_u8(dir_tag); - self.write_opt_str(p.function_text.as_deref())?; - self.write_opt_func(p.function.as_ref())?; - self.write_opt_str(p.three_state.as_deref())?; - self.write_opt_func(p.three_state_function.as_ref())?; - self.write_f64(p.capacitance); - self.write_bool(p.capacitance_given); - self.write_opt_f64(p.rise_capacitance); - self.write_opt_f64(p.fall_capacitance); - self.write_opt_f64_pair(p.rise_capacitance_range); - self.write_opt_f64_pair(p.fall_capacitance_range); - self.write_opt_f64(p.min_capacitance); - self.write_opt_f64(p.max_capacitance); - self.write_opt_f64(p.max_transition); - self.write_opt_f64(p.max_fanout); - self.write_opt_f64(p.fanout_load); - self.write_bool(p.is_clock); - self.write_usize_u32(p.arcs.len())?; - for arc in &p.arcs { - self.write_arc(arc)?; - } - self.write_usize_u32(p.internal_power.len())?; - for ip in &p.internal_power { - self.write_internal_power(ip)?; - } - self.write_bool(p.isolation_cell_enable_pin); - self.write_bool(p.isolation_cell_data_pin); - self.write_bool(p.level_shifter_enable_pin); - self.write_bool(p.level_shifter_data_pin); - self.write_bool(p.always_on); - self.write_opt_str(p.related_power_pin.as_deref())?; - self.write_opt_str(p.related_ground_pin.as_deref())?; - self.write_opt_str(p.power_down_function.as_deref())?; - self.write_opt_str(p.state_function_text.as_deref())?; - self.write_opt_func(p.state_function.as_ref())?; - self.write_opt_str(p.internal_node.as_deref())?; - self.write_bool(p.clock_gate_clock_pin); - self.write_bool(p.clock_gate_enable_pin); - self.write_bool(p.clock_gate_out_pin); - self.write_bool(p.clock_gate_test_pin); - self.write_bool(p.clock_gate_obs_pin); - self.write_u32(p.line); - self.write_opt_f64(p.min_pulse_width_high); - self.write_opt_f64(p.min_pulse_width_low); - self.write_opt_f64(p.min_period); - Ok(()) - } - - fn write_sequential(&mut self, s: &Sequential) -> Result<()> { - let kind_tag = match s.kind { - SeqKind::FlipFlop => 0, - SeqKind::Latch => 1, - }; - self.write_u8(kind_tag); - self.write_str(&s.state)?; - self.write_str(&s.state_n)?; - self.write_str(&s.data_text)?; - self.write_func(&s.data, 0)?; - self.write_str(&s.clock_text)?; - self.write_func(&s.clock, 0)?; - self.write_opt_str(s.clocked_on_also.as_deref())?; - self.write_opt_str(s.clear_text.as_deref())?; - self.write_opt_func(s.clear.as_ref())?; - self.write_opt_str(s.preset_text.as_deref())?; - self.write_opt_func(s.preset.as_ref())?; - self.write_opt_str(s.clear_preset_var1.as_deref())?; - self.write_opt_str(s.clear_preset_var2.as_deref()) - } - - fn write_statetable(&mut self, st: &Statetable) -> Result<()> { - self.write_usize_u32(st.input_nodes.len())?; - for n in &st.input_nodes { - self.write_str(n)?; - } - self.write_usize_u32(st.internal_nodes.len())?; - for n in &st.internal_nodes { - self.write_str(n)?; - } - self.write_str(&st.table)?; - self.write_usize_u32(st.rows.len())?; - for r in &st.rows { - self.write_str(r)?; - } - Ok(()) - } - - fn write_pg_pin(&mut self, pg: &PgPin) -> Result<()> { - self.write_str(&pg.name)?; - match &pg.pg_type { - None => self.write_u8(0), - Some(t) => { - self.write_u8(1); - self.write_named_enum(t.is_known(), t.name())?; - } - } - self.write_opt_str(pg.voltage_name.as_deref()) - } - - fn write_cell(&mut self, c: &Cell) -> Result<()> { - self.write_str(&c.name)?; - self.write_f64(c.area); - self.write_bool(c.dont_use); - self.write_usize_u32(c.pins.len())?; - for pin in &c.pins { - self.write_pin(pin)?; - } - self.write_usize_u32(c.pg_pins.len())?; - for pg in &c.pg_pins { - self.write_str(pg)?; - } - self.write_usize_u32(c.sequentials.len())?; - for seq in &c.sequentials { - self.write_sequential(seq)?; - } - self.write_bool(c.has_statetable); - self.write_bool(c.has_test_cell); - self.write_bool(c.is_clock_gating); - self.write_bool(c.has_bus); - self.write_opt_f64(c.leakage_power); - self.write_usize_u32(c.problems.len())?; - for prob in &c.problems { - self.write_str(prob)?; - } - self.write_usize_u32(c.pg_pin_groups.len())?; - for pg in &c.pg_pin_groups { - self.write_pg_pin(pg)?; - } - self.write_bool(c.is_isolation_cell); - self.write_bool(c.is_level_shifter); - match &c.level_shifter_type { - None => self.write_u8(0), - Some(t) => { - self.write_u8(1); - self.write_named_enum(t.is_known(), t.name())?; - } - } - self.write_bool(c.always_on); - self.write_opt_str(c.cell_footprint.as_deref())?; - self.write_bool(c.dont_touch); - self.write_bool(c.is_macro_cell); - self.write_bool(c.pad_cell); - match &c.switch_cell_type { - None => self.write_u8(0), - Some(t) => { - self.write_u8(1); - self.write_named_enum(t.is_known(), t.name())?; - } - } - self.write_opt_str(c.retention_cell.as_deref())?; - match &c.antenna_diode_type { - None => self.write_u8(0), - Some(t) => { - self.write_u8(1); - self.write_named_enum(t.is_known(), t.name())?; - } - } - self.write_opt_str(c.clock_gating_integrated_cell.as_deref())?; - match &c.statetable { - None => self.write_u8(0), - Some(st) => { - self.write_u8(1); - self.write_statetable(st)?; - } - } - self.write_u32(c.line); - Ok(()) - } - - fn write_fanout_pairs(&mut self, pairs: &[(u32, f64)]) -> Result<()> { - self.write_usize_u32(pairs.len())?; - for &(fanout, val) in pairs { - self.write_u32(fanout); - self.write_f64(val); - } - Ok(()) - } - - fn write_wire_load(&mut self, wl: &WireLoad) -> Result<()> { - self.write_str(&wl.name)?; - self.write_f64(wl.resistance); - self.write_f64(wl.capacitance); - self.write_f64(wl.area); - self.write_f64(wl.slope); - self.write_fanout_pairs(&wl.fanout_length) - } - - fn write_wire_load_table(&mut self, wlt: &WireLoadTable) -> Result<()> { - self.write_str(&wlt.name)?; - self.write_fanout_pairs(&wlt.fanout_length)?; - self.write_fanout_pairs(&wlt.fanout_capacitance)?; - self.write_fanout_pairs(&wlt.fanout_resistance)?; - self.write_fanout_pairs(&wlt.fanout_area) - } - - fn write_wire_load_rule(&mut self, r: &WireLoadAreaRule) -> Result<()> { - self.write_f64(r.min_area); - self.write_f64(r.max_area); - self.write_str(&r.wire_load) - } - - fn write_wire_load_selection(&mut self, sel: &WireLoadSelection) -> Result<()> { - self.write_str(&sel.name)?; - self.write_usize_u32(sel.rules.len())?; - for rule in &sel.rules { - self.write_wire_load_rule(rule)?; - } - Ok(()) - } - - fn write_operating_conditions(&mut self, oc: &OperatingConditions) -> Result<()> { - self.write_str(&oc.name)?; - self.write_opt_f64(oc.process); - self.write_opt_f64(oc.temperature); - self.write_opt_f64(oc.voltage); - match &oc.tree_type { - None => self.write_u8(0), - Some(t) => { - self.write_u8(1); - self.write_named_enum(t.is_known(), t.name())?; - } - } - Ok(()) - } - - fn write_library(&mut self, lib: &Library) -> Result<()> { - self.write_str(&lib.name)?; - self.write_f64(lib.time_unit_ns); - self.write_f64(lib.cap_unit_pf); - self.write_opt_f64(lib.default_max_capacitance); - self.write_opt_f64(lib.default_max_transition); - self.write_opt_f64(lib.default_max_fanout); - self.write_f64(lib.default_fanout_load); - self.write_opt_f64(lib.default_input_pin_cap); - self.write_opt_f64(lib.default_output_pin_cap); - self.write_opt_f64(lib.default_inout_pin_cap); - self.write_units(&lib.units)?; - self.write_usize_u32(lib.templates.len())?; - for (name, tmpl) in &lib.templates { - self.write_str(name)?; - self.write_template(tmpl)?; - } - self.write_usize_u32(lib.cells.len())?; - for cell in &lib.cells { - self.write_cell(cell)?; - } - self.write_opt_f64(lib.input_threshold_pct_rise); - self.write_opt_f64(lib.input_threshold_pct_fall); - self.write_opt_f64(lib.output_threshold_pct_rise); - self.write_opt_f64(lib.output_threshold_pct_fall); - self.write_opt_f64(lib.slew_lower_threshold_pct_rise); - self.write_opt_f64(lib.slew_upper_threshold_pct_rise); - self.write_opt_f64(lib.slew_lower_threshold_pct_fall); - self.write_opt_f64(lib.slew_upper_threshold_pct_fall); - self.write_opt_f64(lib.slew_derate_from_library); - self.write_usize_u32(lib.wire_loads.len())?; - for (name, wl) in &lib.wire_loads { - self.write_str(name)?; - self.write_wire_load(wl)?; - } - self.write_usize_u32(lib.wire_load_tables.len())?; - for (name, wlt) in &lib.wire_load_tables { - self.write_str(name)?; - self.write_wire_load_table(wlt)?; - } - self.write_usize_u32(lib.wire_load_selections.len())?; - for (name, sel) in &lib.wire_load_selections { - self.write_str(name)?; - self.write_wire_load_selection(sel)?; - } - self.write_opt_str(lib.default_wire_load.as_deref())?; - match &lib.default_wire_load_mode { - None => self.write_u8(0), - Some(m) => { - self.write_u8(1); - self.write_named_enum(m.is_known(), m.name())?; - } - } - self.write_opt_str(lib.default_wire_load_selection.as_deref())?; - self.write_opt_f64(lib.default_wire_load_area); - self.write_opt_f64(lib.default_wire_load_capacitance); - self.write_opt_f64(lib.default_wire_load_resistance); - self.write_usize_u32(lib.operating_conditions.len())?; - for (name, oc) in &lib.operating_conditions { - self.write_str(name)?; - self.write_operating_conditions(oc)?; - } - self.write_opt_str(lib.default_operating_conditions.as_deref())?; - self.write_opt_f64(lib.nom_process); - self.write_opt_f64(lib.nom_temperature); - self.write_opt_f64(lib.nom_voltage); - Ok(()) - } -} diff --git a/crates/core/rusteda-workspace/src/rlib/decode.rs b/crates/core/rusteda-workspace/src/rlib/decode.rs deleted file mode 100644 index 66db251..0000000 --- a/crates/core/rusteda-workspace/src/rlib/decode.rs +++ /dev/null @@ -1,987 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 RustEDA contributors - -//! Decoder for the `LIBERTY` v3 (`.rlib`) binary cache (`WS-REQ-0111` to `WS-REQ-0114`). - -use std::collections::BTreeMap; -use std::path::Path; - -use rusteda_core::liberty::{ - AntennaDiodeType, Cell, Direction, Func, InternalPower, LevelShifterType, Library, - OperatingConditions, PgPin, PgType, Pin, SeqKind, Sequential, Statetable, SwitchCellType, - Table, Template, TimingArc, TreeType, Unit, Units, WireLoad, WireLoadAreaRule, WireLoadMode, - WireLoadSelection, WireLoadTable, -}; - -use super::{MAX_FUNC_DEPTH, corrupt_rlib}; -use crate::error::Result; - -pub(super) fn decode_model( - path: &Path, - str_pool: &str, - model: &[u8], - f64_bits: &[u64], -) -> Result { - let mut dec = Decoder { - path, - str_pool, - model, - pos: 0, - f64_bits, - }; - let lib = dec.read_library()?; - if dec.pos != dec.model.len() { - return Err(corrupt_rlib( - path, - format!( - "MODEL section has {} trailing byte(s)", - dec.model.len() - dec.pos - ), - )); - } - Ok(lib) -} - -struct Decoder<'a> { - path: &'a Path, - str_pool: &'a str, - model: &'a [u8], - pos: usize, - f64_bits: &'a [u64], -} - -impl<'a> Decoder<'a> { - fn remaining(&self) -> usize { - self.model.len().saturating_sub(self.pos) - } - - fn read_bytes(&mut self, n: usize) -> Result<&'a [u8]> { - let end = self - .pos - .checked_add(n) - .ok_or_else(|| corrupt_rlib(self.path, "MODEL byte offset overflow"))?; - let slice = self.model.get(self.pos..end).ok_or_else(|| { - corrupt_rlib( - self.path, - format!("truncated MODEL stream at byte {}", self.pos), - ) - })?; - self.pos = end; - Ok(slice) - } - - fn read_u8(&mut self) -> Result { - Ok(self.read_bytes(1)?[0]) - } - - fn read_bool(&mut self) -> Result { - match self.read_u8()? { - 0 => Ok(false), - 1 => Ok(true), - other => Err(corrupt_rlib( - self.path, - format!("invalid bool tag {other} in MODEL stream"), - )), - } - } - - fn read_u32(&mut self) -> Result { - let b = self.read_bytes(4)?; - Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) - } - - fn read_count(&mut self, min_elem_bytes: usize) -> Result { - let n = self.read_u32()? as usize; - let required = n - .checked_mul(min_elem_bytes) - .ok_or_else(|| corrupt_rlib(self.path, "MODEL element count overflow"))?; - if required > self.remaining() { - return Err(corrupt_rlib( - self.path, - format!( - "MODEL count {n} requires at least {required} bytes, only {} remaining", - self.remaining() - ), - )); - } - Ok(n) - } - - fn read_f64(&mut self) -> Result { - let b = self.read_bytes(8)?; - Ok(f64::from_bits(u64::from_le_bytes([ - b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], - ]))) - } - - fn read_opt_f64(&mut self) -> Result> { - match self.read_u8()? { - 0 => Ok(None), - 1 => Ok(Some(self.read_f64()?)), - other => Err(corrupt_rlib( - self.path, - format!("invalid opt tag {other}"), - )), - } - } - - fn read_opt_f64_pair(&mut self) -> Result> { - match self.read_u8()? { - 0 => Ok(None), - 1 => Ok(Some((self.read_f64()?, self.read_f64()?))), - other => Err(corrupt_rlib( - self.path, - format!("invalid opt<(f64, f64)> tag {other}"), - )), - } - } - - fn read_str(&mut self) -> Result { - let off = self.read_u32()? as usize; - let len = self.read_u32()? as usize; - let end = off - .checked_add(len) - .ok_or_else(|| corrupt_rlib(self.path, "STR slice overflow"))?; - let s = self.str_pool.get(off..end).ok_or_else(|| { - corrupt_rlib( - self.path, - format!("STR slice ({off}, {len}) out of bounds or not on UTF-8 boundary"), - ) - })?; - Ok(s.to_string()) - } - - fn read_opt_str(&mut self) -> Result> { - match self.read_u8()? { - 0 => Ok(None), - 1 => Ok(Some(self.read_str()?)), - other => Err(corrupt_rlib( - self.path, - format!("invalid opt tag {other}"), - )), - } - } - - fn read_f64_slice(&mut self) -> Result> { - let start = self.read_u32()? as usize; - let len = self.read_u32()? as usize; - let end = start - .checked_add(len) - .ok_or_else(|| corrupt_rlib(self.path, "F64 slice overflow"))?; - let slice = self.f64_bits.get(start..end).ok_or_else(|| { - corrupt_rlib( - self.path, - format!( - "F64 slice ({start}, {len}) exceeds pool length {}", - self.f64_bits.len() - ), - ) - })?; - Ok(slice - .iter() - .map(|&bits| f64::from_bits(u64::from_le(bits))) - .collect()) - } - - fn read_unit(&mut self) -> Result { - let scale = self.read_f64()?; - let given = self.read_bool()?; - let text = self.read_opt_str()?; - let mut u = Unit::default(); - u.scale = scale; - u.given = given; - u.text = text; - Ok(u) - } - - fn read_units(&mut self) -> Result { - let mut u = Units::default(); - u.time = self.read_unit()?; - u.capacitance = self.read_unit()?; - u.resistance = self.read_unit()?; - u.voltage = self.read_unit()?; - u.current = self.read_unit()?; - u.leakage_power = self.read_unit()?; - Ok(u) - } - - fn read_template(&mut self) -> Result