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/.gitignore b/.gitignore index 6e17576..bdf7a45 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ __pycache__/ *.swo /.cex /cecmiter.aig + +HANDOFF.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 272e479..fa3e5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,30 @@ 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), 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`; + - `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 +111,21 @@ 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. +- 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`. ### Fixed - The README, the user guide, the synthesis specification (SYNTH-REQ-0516), the roadmap and ADRs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1daaff5..41423f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ RustSys is one of the tools of RustEDA, a suite of electronic design automation share their core crates and a disk workspace. This file holds the rules that every change follows: the eight engineering rules of RustEDA, the rules of this repository, ownership and hand-over between work items, and the checks to run before sending a change. The phases of the suite and -their work items are in the [roadmap](docs/roadmap.md). +their work items are in the [scope](https://lab-emi.github.io/RustEDA/verification/). Instruction files for coding agents, such as `AGENTS.md`, stay local and are never committed; this file is the tracked statement of the rules. @@ -193,7 +193,7 @@ the lab checks them: ## Ownership and hand-over -Work is divided into work items (see the [roadmap](docs/roadmap.md)), each with its specification, +Work is divided into work items (see the [scope](https://lab-emi.github.io/RustEDA/verification/)), each with its specification, its acceptance criteria and the paths it owns. - Every path has one owner at a time. A work item edits only the paths it owns, plus minimal @@ -235,3 +235,20 @@ 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, the public first-party foundation. Workspace dependencies and Cargo.lock +pin one exact revision over HTTPS; no GitHub credentials are 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. + +Cross-repository validation uses the pinned foundation source. The historical public main +branch retains its existing build policy until a reviewed migration is merged. diff --git a/Cargo.lock b/Cargo.lock index 367a2f4..5341d0b 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,25 @@ version = "0.1.0" dependencies = [ "clap", "clap_complete", + "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+https://github.com/lab-emi/RustEDA-Common.git?rev=c96ef1520ab08b40e48ed7643002b5c06ad13e07#c96ef1520ab08b40e48ed7643002b5c06ad13e07" +dependencies = [ + "clap", + "clap_complete", + "libc", "rayon", "rusteda-core", "rusteda-formats", @@ -390,6 +444,7 @@ dependencies = [ [[package]] name = "rusteda-compute" version = "0.1.0" +source = "git+https://github.com/lab-emi/RustEDA-Common.git?rev=c96ef1520ab08b40e48ed7643002b5c06ad13e07#c96ef1520ab08b40e48ed7643002b5c06ad13e07" dependencies = [ "bytemuck", "cudarc", @@ -400,6 +455,7 @@ dependencies = [ [[package]] name = "rusteda-core" version = "0.1.0" +source = "git+https://github.com/lab-emi/RustEDA-Common.git?rev=c96ef1520ab08b40e48ed7643002b5c06ad13e07#c96ef1520ab08b40e48ed7643002b5c06ad13e07" dependencies = [ "bytemuck", "rustc-hash", @@ -408,10 +464,13 @@ dependencies = [ [[package]] name = "rusteda-formats" version = "0.1.0" +source = "git+https://github.com/lab-emi/RustEDA-Common.git?rev=c96ef1520ab08b40e48ed7643002b5c06ad13e07#c96ef1520ab08b40e48ed7643002b5c06ad13e07" dependencies = [ + "flate2", "rayon", "rustc-hash", "rusteda-core", + "serde", "smallvec", "thiserror", ] @@ -419,6 +478,7 @@ dependencies = [ [[package]] name = "rusteda-workspace" version = "0.1.0" +source = "git+https://github.com/lab-emi/RustEDA-Common.git?rev=c96ef1520ab08b40e48ed7643002b5c06ad13e07#c96ef1520ab08b40e48ed7643002b5c06ad13e07" dependencies = [ "bytemuck", "memmap2", @@ -551,6 +611,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..2748f01 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 = "https://github.com/lab-emi/RustEDA-Common.git", rev = "c96ef1520ab08b40e48ed7643002b5c06ad13e07" } +rusteda-workspace = { git = "https://github.com/lab-emi/RustEDA-Common.git", rev = "c96ef1520ab08b40e48ed7643002b5c06ad13e07" } +rusteda-compute = { git = "https://github.com/lab-emi/RustEDA-Common.git", rev = "c96ef1520ab08b40e48ed7643002b5c06ad13e07" } +rusteda-formats = { git = "https://github.com/lab-emi/RustEDA-Common.git", rev = "c96ef1520ab08b40e48ed7643002b5c06ad13e07" } 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 = "https://github.com/lab-emi/RustEDA-Common.git", rev = "c96ef1520ab08b40e48ed7643002b5c06ad13e07" } 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 @@ -29,6 +30,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/README.md b/README.md index 73a0472..06b3fe9 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,39 @@

- RustSys — a RustEDA tool + RustSys

-# RustSys +

Synthesis for the next design iteration.

-**Ultrafast Verilog/SystemVerilog synthesis. Built for terminals and agents.** - -[![CI](https://github.com/lab-emi/RustSys/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/lab-emi/RustSys/actions/workflows/ci.yml) -[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![Rust 1.97.1](https://img.shields.io/badge/rust-1.97.1-orange.svg?logo=rust)](rust-toolchain.toml) -[![Documentation](https://img.shields.io/badge/docs-online-57D6BE)](https://lab-emi.github.io/RustSys/) - -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. +

+ Documentation · + RustEDA · + Benchmarks · + Apache-2.0 +

- Verilog source flows through RustSys synthesis into connected standard-cell gates. + RustSys turns RTL into mapped netlists with bounded synthesis, technology mapping and independently checked equivalence -[Static banner](docs/assets/rustsys-banner.svg) · [Get started](#quick-start) · -[Agent guide](docs/guide/agents.md) · [Documentation](https://lab-emi.github.io/RustSys/) · [Benchmarks](#benchmarks) +RustSys turns RTL into mapped netlists with bounded synthesis, technology mapping and independently checked equivalence. + +Part of **[RustEDA](https://github.com/lab-emi/RustEDA)**, infrastructure for chip-design recursive self-improvement. Native tools shorten the work between inference steps; verified results guide the next iteration. + +## Development status · 2026-10-28 - +The **RustEDA integration branch** combines bounded synthesis and mapping, +BLIF/AIGER/mapped-netlist inputs, revision-aware optimization and the shared +RustEDA foundation. The five-design, three-library benchmark retains arbitrary +initial state: all 30 tool/design/library netlists pass RTL equivalence. -- **High performance:** 1.50×–42.46× faster than pinned Yosys 0.67 on the measured five-design, three-PDK suite, with 16 physical host cores per tool. -- **Easy to script:** result-only stdout, coded diagnostics on stderr, JSON output, fixed exit statuses. -- **Easy to resume:** recorded commands, input hashes, artifacts, and event logs; query reports as JSON or TSV. -- **Safe to coordinate:** daemonless disk workspaces, immutable revisions, and explicit `--base-rev` conflict checks. -- **Built to check:** an independent synthesis checker, bounded proof work, and external open-source LEC evidence. +Real-SRAM macro preservation is being developed for IHP Ibex in a separate +experimental checkout. It is not included in this integration snapshot and does +not establish physical closure. The standalone `main` branch can lag this +integration; use `codex/unified-brand` or RustEDA's pinned recursive checkout. -> **Development preview:** version 0.1.0 is unreleased and is not on crates.io. Build from this -> repository. Linux is tested in CI; other platforms are not tested. +[Development status and evidence](docs/development-status.md) · +[RustEDA engine progress](https://lab-emi.github.io/RustEDA/engine-status/) ## Benchmarks @@ -71,7 +72,7 @@ Aggregate cgroup memory peaks remain in the complete receipts. † **Setup-Fmax is an OpenSTA estimate** at the TT corner with ideal clocks and no wire parasitics. SERV/EZ130 and OpenC910 loads exceed Liberty characterization limits; -these raw estimates do not establish achievable frequency. [Timing scope](https://lab-emi.github.io/RustSys/guide/performance/#interpret-the-timing-estimate). +these raw estimates do not establish achievable frequency. [Timing scope](https://lab-emi.github.io/RustEDA/modules/rustsys/guide/performance/#interpret-the-timing-estimate). Speedup = **Yosys time ÷ RustSys time**. All **30 distinct netlists pass arbitrary-state RTL LEC**, with no reset or input assumption added. @@ -79,7 +80,7 @@ arbitrary-state RTL LEC**, with no reset or input assumption added. [Current runtime audit, three-PDK refresh and LEC](verification/benchmarks/open-hardware/results/2026-10-23-gf180-runtime-audit/README.md) · [Historical Sky130/GF180: full 1/16-core results and LEC certificates](verification/benchmarks/open-hardware/results/2026-10-21-default-multicore/README.md) · [IHP/EZ130: 16-core results and LEC certificates](verification/benchmarks/open-hardware/results/2026-10-23-ez130/README.md) · -[Benchmark method](https://lab-emi.github.io/RustSys/guide/benchmarks/). +[Benchmark method](https://lab-emi.github.io/RustEDA/modules/rustsys/guide/benchmarks/). SERV/EZ130 uses **0.77% less cell area than Yosys**, with **3.55×** faster synthesis. The current runtime repair remeasures RustSys across all three PDKs and retains @@ -87,7 +88,7 @@ the pinned Yosys runs. All fifteen RustSys netlists are unchanged from the area fix; exact-byte LEC and timing reuse is explicit. EZ130 functional LEC follows the vendor’s nominal tie-cell Verilog models; the Liberty driver-type conflict and checked supplement are -[documented explicitly](https://lab-emi.github.io/RustSys/verification/pdk/#additional-ihp-sg13g2-benchmark-library). +[documented explicitly](https://lab-emi.github.io/RustEDA/modules/rustsys/verification/pdk/#additional-ihp-sg13g2-benchmark-library). @@ -168,7 +169,7 @@ Install [Rust with rustup](https://rustup.rs) and Git. The repository pins Rust The CPU flow needs no GPU or CUDA toolkit. Run these commands in one POSIX shell: ```sh -git clone https://github.com/lab-emi/RustSys.git +git clone --branch codex/unified-brand https://github.com/lab-emi/RustSys.git cd RustSys cargo install --locked --path crates/apps/rustsys-cli @@ -241,7 +242,7 @@ of an existing workspace names the expected revision; a stale base fails with | Bounded work | Proof/search budgets report exhaustion as undecided; `unverified` evidence stays unverified. | [Agent integration recipes](docs/guide/agents.md) · [CLI reference](docs/reference/cli.md) · -[JSON and report contracts](docs/reference/reports.md) · [Agent documentation index](https://lab-emi.github.io/RustSys/llms.txt). +[JSON and report contracts](docs/reference/reports.md) · [Agent documentation index](https://lab-emi.github.io/RustEDA/modules/rustsys/llms.txt). ## Synthesis and verification @@ -271,12 +272,12 @@ modports, tri-state logic, `inout`, and dynamic types are among the current limi ## Documentation -The [documentation site](https://lab-emi.github.io/RustSys/) follows a task-based path from a first netlist to repeatable automation +The [documentation site](https://lab-emi.github.io/RustEDA/modules/rustsys/) follows a task-based path from a first netlist to repeatable automation and inspectable proof evidence. All pages also live in this repository. | Start here | Go deeper | |---|---| -| [Documentation home](https://lab-emi.github.io/RustSys/) | [Architecture and crate boundaries](docs/architecture/index.md) | +| [Documentation home](https://lab-emi.github.io/RustEDA/modules/rustsys/) | [Architecture and crate boundaries](docs/architecture/index.md) | | [Install and run your first design](docs/guide/quickstart.md) | [Synthesis specifications](docs/specs/synth/README.md) | | [Automate with scripts and agents](docs/guide/agents.md) | [Workspace and JSON protocol](docs/specs/workspace/README.md) | | [Check equivalence](docs/guide/checking.md) | [Verification evidence and limitations](docs/verification/index.md) | diff --git a/crates/apps/rusteda-cli/Cargo.toml b/crates/apps/rusteda-cli/Cargo.toml index 51e9a25..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 @@ -29,5 +30,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/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 9e0e0ad..0000000 --- a/crates/apps/rusteda-cli/src/import.rs +++ /dev/null @@ -1,157 +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::{Diagnostic, Envelope, Error, Invocation, JsonMap, JsonObject, 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, - pub sdc_files: Vec, - pub sdc_env: Vec, -} - -/// 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() - { - let err = Error::usage("import requires at least one of --lib, --rtl, --netlist, or --sdc"); - 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, &mut metrics, diags)?; - } - if !cmd.sdc_files.is_empty() { - import_sdc( - ws, - &mut txn, - &cmd.sdc_files, - &cmd.sdc_env, - &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); - - 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 055caf3..0000000 --- a/crates/apps/rusteda-cli/src/import_netlist.rs +++ /dev/null @@ -1,440 +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`). -pub(crate) fn import_netlist( - ws: &Workspace, - txn: &mut Txn, - file: &Path, - 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) { - #[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()) - }) - { - debug_assert_eq!(fast, g.freeze(), "fast path diverged from read_structural"); - } - drop(text); - 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)); - 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/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 e0ed54d..f8177cb 100644 --- a/crates/apps/rusteda-cli/src/lib.rs +++ b/crates/apps/rusteda-cli/src/lib.rs @@ -1,870 +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 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 { - /// 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, - /// 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, -} - -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); - } + command_with_version(env!("CARGO_PKG_VERSION")) } -/// 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.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(), - sdc_files: imp.sdc.clone(), - sdc_env: imp.sdc_env.clone(), - }; - 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 b26a282..0000000 --- a/crates/apps/rusteda-cli/src/query.rs +++ /dev/null @@ -1,455 +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)?; - } - } - 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/import_netlist.rs b/crates/apps/rusteda-cli/tests/import_netlist.rs index a7d1578..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,6 +315,231 @@ endmodule let _ = fs::remove_dir_all(&root); } +/// 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_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]); + 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); +} + +/// 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] 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/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/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/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 87fd8db..0000000 --- a/crates/core/rusteda-compute/src/lib.rs +++ /dev/null @@ -1,468 +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 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/timing/abi.rs b/crates/core/rusteda-compute/src/timing/abi.rs deleted file mode 100644 index 142ddc1..0000000 --- a/crates/core/rusteda-compute/src/timing/abi.rs +++ /dev/null @@ -1,356 +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) - } -} - -/// 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], -} - -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 0760d5a..0000000 --- a/crates/core/rusteda-compute/src/timing/cpu.rs +++ /dev/null @@ -1,666 +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, 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], - 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 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], - 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, - 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, - 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> { - 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> { - 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> { - 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 441bfad..0000000 --- a/crates/core/rusteda-compute/src/timing/cuda.rs +++ /dev/null @@ -1,895 +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_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.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 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_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_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_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> { - 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> { - 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> { - 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 a428c7d..0000000 --- a/crates/core/rusteda-compute/src/timing/kernels/timing.cu +++ /dev/null @@ -1,426 +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 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 ((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 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, 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 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, 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 0f8b886..0000000 --- a/crates/core/rusteda-compute/src/timing/mod.rs +++ /dev/null @@ -1,300 +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, 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: &[], - }; - 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 3370ed6..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/mod.rs +++ /dev/null @@ -1,41 +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; -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/random.rs b/crates/core/rusteda-compute/src/timing/tests/random.rs deleted file mode 100644 index 91c1f94..0000000 --- a/crates/core/rusteda-compute/src/timing/tests/random.rs +++ /dev/null @@ -1,468 +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, - } - } - - 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 b8ed3d8..0000000 --- a/crates/core/rusteda-compute/src/timing/validate.rs +++ /dev/null @@ -1,288 +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.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-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 23d20bb..0000000 --- a/crates/core/rusteda-core/src/liberty/model.rs +++ /dev/null @@ -1,716 +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, - 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/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 628758b..0000000 --- a/crates/core/rusteda-workspace/src/error.rs +++ /dev/null @@ -1,946 +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 (RustSTA: 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 8becac1..0000000 --- a/crates/core/rusteda-workspace/src/lib.rs +++ /dev/null @@ -1,122 +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` | -//! | [`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 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 5cc6b2b..0000000 --- a/crates/core/rusteda-workspace/src/rlib.rs +++ /dev/null @@ -1,830 +0,0 @@ -// 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`). -//! -//! ## 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`]). -//! 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`. -//! - `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`). -pub const FORMAT_VERSION: u32 = 3; - -/// 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); - 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 54dbd52..0000000 --- a/crates/core/rusteda-workspace/src/rlib/decode.rs +++ /dev/null @@ -1,981 +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