Skip to content

Refactor the crate's internal structures to make invalid states unrepresentable - #43

Open
marlls1989 wants to merge 62 commits into
masterfrom
refactor/kind-as-variant
Open

Refactor the crate's internal structures to make invalid states unrepresentable#43
marlls1989 wants to merge 62 commits into
masterfrom
refactor/kind-as-variant

Conversation

@marlls1989

@marlls1989 marlls1989 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

What this is for. Making the crate more maintainable and reducing its technical debt — by
sharpening GUIDELINES.md, the standing rules every contribution is held to, and bringing the code
into compliance with the sharpened version. Six rules are involved: two that shaped the work from the
start, two added after a review found the first pass had drifted from them, and two more added after
an audit of how the crate uses its own logic library. Two behaviours change, both named below.

A classification is a variant

Picking the variant is the classification, and the variant carries exactly the fields that kind has;
a field that only sometimes applies is an Option inside it.

  • src/emit/block.rs holds Block: eleven variants over six payload structs, one for each Liberate
    block form, with a single Display writing the whole block and the -type literal owned by the arm
    that emits it. It is owned and hashable, so it is also the dedup key — Blocks is
    IndexMap<Block, Vec<Minterm<Symbol>>>, keyed on the block value. DefineArc, LeakageBlock,
    BlockKind and MaskedArc are gone, and Conflation { block, states } makes the conflation
    report's subject the emitted block itself.
  • A hazard's cause carries its own cardinality: Toggle { pin: PinEdge },
    Race { pins: [PinEdge; 2] }, Pulse { pin: PinEdge }. Empty and three-or-more are
    unrepresentable, and the rule that a lone toggle yields no constraint is structural rather than
    prose.
  • The analysis returns Result<Derivations, machine::ExplorationLimit>, so the invariant its doc used
    to state — stopped means every derived field is empty — holds because a stopped exploration hands
    back no Derivations to hold fields.
  • edge.rs speaks Edge through its decision core instead of an is_rise: bool, so a pair whose two
    halves disagree cannot be built, and EdgeArcs states its read-gate factorisation once.
  • state_holding is derived from the cached regions; the CLI's spec source and output destination are
    types rather than a sentinel string and a loose bool-plus-path.

Structure is held until the edge

Display is for output; a function that returns rendered text has already lost.

  • A condition is a BoolExpr end to end. logic::product builds one from literals and the logic
    layer's rendering toolkit is deleted, along with Hazard's five *_str methods.
  • A -vector, -ic and -pinlist are three projections of one column walk, typed as VectorValue
    and IcColumn in src/emit/tcl.rs, so they cannot fall out of alignment.
  • Diagnostics render through borrowing Display adapters in src/report.rs.
  • The deck reaches the sink through one writer: cell_arcs returns blocks, main writes them. The
    Liberty library writes through an indenting fmt::Write adapter rather than rendering a string and
    re-parsing it to re-indent, and each artifact renders its own alphabet through its own Display.

The library's types are the ones to use

An audit of every espresso-logic call site, and of the crate's own vocabulary against the library's,
found the same shape rebuilt by hand in a dozen places. GUIDELINES.md gains two sections — one
naming which type owns which job, one stating that the library's operations come verified, so a
hand-rolled equivalent is a second implementation nobody tests and is a defect whether or not it
works. The audit's own lesson is written down with them: most of these were not written in ignorance
of the library but after failing to find one method that did the whole job, when the answer was a
composition of two.

A name-to-Boolean mapping is a Minterm<Symbol>. A variable a minterm does not define is by
definition a don't-care, so a partial mapping needs no separate type and no absent-key convention.
Gone, each replaced by the row it always was: logic::assignment, which took a Minterm and returned
a map of it; confluence::node_levels_at; ExposedLevel; VictimNode; and the by-name adapters that
existed only to restore lookup over vectors of them.

ArcLevels and RestLevels stay, because neither was ever the name-to-Boolean map — their fields
were. ArcLevels groups the three rows one arc samples, and on master held them as
Vec<(Symbol, bool)> and Vec<ExposedLevel>; it now holds outputs, exposed_start and
exposed_end, a Minterm<Symbol> each. A minterm gives one value per variable, so two mappings over
one node set cannot be one row. Constraint.nodes and ConstraintColumns.probed are minterms for the
same reason, and Hazard::node_levels is a method deriving its row from the state the hazard probed
rather than a field storing it again.

A set of tri-state rows over a shared header is a Cover. StateRegions held each of its three
regions twice — once as an espresso Cover, once as Vec<StateCube>, a positional
Vec<Option<bool>> aligned by hand to a separate name vector — with a projection keeping them in
step, and its own comment admitting the two were the same cover. The hand-rolled half is gone, and
with it region_cubes, verilog.rs's level_at (which re-implemented Minterm::value_of as a
linear name scan), and statetable.rs's two index maps and the loop over them that re-implemented
Minterm::project_to.

Operations come from the library. Three hand-written copies of BddBuilder::build_cover's
per-cube fold are gone. A pin's function is Cover::to_expr_by_index, rendered by BoolExpr's own
Display at the sink; both hand-written sum-of-products assemblers went with them, including one
that decided a function was a tautology by string-comparing its rendered text. Comparing two machine
states is a Kleene XOR of the two rows rather than a per-variable loop, and the one-reference-against-
many case in width.rs is that XOR folded with OR. merged_victims is Minterm::or for the variable
set composed with project_to_labels for the levels.

One datum, one representation. A state-table column is held once under both its names
(StateNode { signal, node }) rather than as a vector and a map that a test helper had to invert,
relying on an injectivity nothing enforced. A hazard derives its victim levels from the state it
probed instead of storing them again, and the one-probed-state invariant is stated where the
remaining views are declared and asserted where they are copied.

Ordered containers that bought nothing are unordered. The exploration's candidate pool is a
HashSet — its order was never read and it reaches four million entries on the candidate path — and a
warning's outcomes are filed in a HashMap. Which observation supplies a general block is now
schedule-dependent, recorded in KNOWN-ISSUES.md beside the free choice it belongs to.

Errors

The crate has one error style, taken from espresso-logic: hand-written Display and
std::error::Error with a real source() chain, From for composition, From<_> for io::Error at the
boundary, #[non_exhaustive], and small leaf errors wrapped by composites that add the context they
have. thiserror is dropped from the dependency list and Box<dyn Error> from run, which returns
io::Result<()>. ExplorationLimit is a leaf naming which budget stopped an exploration and at what
ceiling; ModelError::Exploration wraps it and names the cell, the way its seventeen siblings do. The
CLI's io boundaries name the path they were given, so a missing spec or an unwritable directory says
which one.

Two behaviour changes

A run that passes an exploration budget fails at that cell and exits, rather than analysing the rest
and listing every offending cell at the end. ExplorationLimit propagates with ? instead of being
stashed on the cell and scanned later, so AnalysedCell.unexplored and the scan are gone.

A run that fails partway through writing its artifacts leaves none of them: each is written to a
sibling temporary path and renamed into place at the end, so a downstream Liberate run cannot read a
half-written output directory.

What changes in the output

Conditions and functions spell as A & !B where they spelled A*!B — both notations are accepted by
Liberty and Verilog alike, and the hand-rolled joins were the defect. A Liberty function may also
come out factored, A & (B | C) rather than A & B | A & C, since the library's own lowering is now
what produces it; nothing ever required a flat sum of products. Hazards are reported on stderr and
nowhere else: the emitted Tcl and .lib state the timing that removes a hazard and carry no comment
about the hazard itself. Nothing else moves.

A value with more than one component takes a name

A tuple is an ordinary Rust value inside a function — a map's key and value, enumerate, zip, a
buffer a foreign signature dictates — but it may not outlive the scope that makes it, and neither may
a struct or enum variant that tells its components apart by position. GUIDELINES.md states the rule;
the crate is brought into line with it. Named where they were positional: Capture,
Sample/Firing/Move, Delta, DerivedArcs, RegionAction, SignalDef, OutcomeField,
System, FiringIdentity/HiddenFiringIdentity, StateNode, TransitionEvent, SeparationRole,
and Projected's columns. src/emit/block.rs owns the emitted-form vocabulary, so a type naming what
the analysis decided is named for that instead: TransitionEvent is the transition an arc records,
SeparationRole the kind of separation a hazard calls for and which end it constrains.

Tests assert properties

A test states a property of the thing under test and asserts it semantically, and it holds for every
output the tool is free to produce. Running the code twice and asserting the two results agree is not
a test of behaviour: it passes whenever the two paths are self-consistent, including when both are
wrong. That holds whatever the currency — text, values, counts or multisets. A test resting on
equality often asserts no property at all but immutability, which is a fact about the previous run
rather than about the thing under test. The property a test asserts is one the code or its
documentation states; where neither does, the claim belongs there first.

Where a correspondence between the parts of one output is the claim, that correspondence is itself a
property: Liberate reads a block's -pinlist, -vector and -ic as positional columns of one
argument, so those three agreeing on the pin order a run produced is the contract with that reader.
Emitted columns are now read by name off the emitter's own column list, so no minterm's stored header
order reaches the output, and a test states that correspondence without fixing which order a run
picks.

An order kept in the code needs a motivation stated where the order is, and two kinds qualify: a
reader outside the crate that requires the position, or an algorithm made cheaper than its unordered
form — sorting to dedup is a reason by itself. An order bought only for its own stability is not;
"held sorted so the report is stable" answers to nothing. Where determinism is not required the
cheaper variant wins, so a sort whose stability cannot be observed is sort_unstable.

Above all of it, GUIDELINES.md now states that efficiency and correctness come first and that every
rule about how the code is shaped serves those two — code that compromises efficiency to keep an order
is wrong, and a test that checks the output matches a reference is not establishing correctness.

Twenty comparison-shaped tests across arcs_tcl.rs, liberty.rs, statetable.rs, verilog.rs,
model.rs, main.rs and edge.rs now assert properties instead, and the three comparator helpers
arc_shape, shaped_blocks and sorted_blocks are deleted with their last consumers. tests/cli.rs
is dissolved into the modules it tests: no test spawns the cellsmith binary, and none asserts an exit
code.

Dead determinism scaffolding removed

explore's candidate ranking — the settlement and depth quantities, the depth relaxation, the ranked
pool and its sort — is deleted. It ordered the whole pool and pruned nothing, so the seed set is
unchanged and only the order seeds enter the queue, which nothing may depend on. The walk was already
schedule-shaped past the seed segment: each BFS level collects into a HashMap whose iteration order
feeds both ex.order and the next frontier, which explore's own comments call free choices. The
crate's only #[allow] went with a write-only field in edge.rs's forcing scan.

How that is established

The green bar, and nothing else: cargo fmt --all --check,
cargo clippy --all-targets --locked -- -D warnings, cargo build --all-targets --locked,
cargo test --locked, cargo doc. 408 lib and 27 bin tests pass; no #[allow] remains in src/.

Correctness here is the same set of arcs, hazards and constraints, never identical bytes — emission is
not reproducible run to run, and it is not meant to be. Measured on one binary twice over
examples/cells.toml --when: cells_arcs.tcl differs by 6016 lines while cells.v, cells.lib and
cells_cells.tcl are byte-stable. The tests are what catch a regression, at the site where it happens.

Findings declined, and shapes left alone

Two findings from review were declined on their merits. One asked for
*std::slice::from_ref(&node) to be replaced; clippy::cloned_ref_to_slice_refs recommends exactly
that form, so the existing code was already right. The other assumed an intra-doc link would be
dropped; instead TemplateSpec was widened to pub so the link resolves.

Deliberately untouched, each because it is a shape the rule permits: model.rs's signal_regions()
and its call sites, which is std's collectible (K, V) protocol; the product-literal buffers in
emit/arcs_tcl.rs, iterated on the spot; the argument buffers whose shape Minterm::with_labels and
compose_map dictate; and statetable.rs's positional row types, which are a projection made at the
point of writing, since the Liberty statetable and the Verilog UDP are columnar formats.

Record correction. Commit 5017397's message names a type ReportField; the type delivered is
SubblockField in src/main.rs. The message is not rewritten.

Deferred, and recorded in KNOWN-ISSUES.md. Seed settling still runs sequentially, a shape that
only survived as the ordering's justification; parallelising it is a critical-path change and its own
pass. And the (discovered, ordinal) key that picks among equally dominant observations stays, with
the sites a removal would have to answer for written down.

Benchmarks

The thread count a target is measured at is asked for at the point of running it:
CELLSMITH_BENCH_THREADS names the widths as a comma-separated list, max standing for the width the
global pool was built with, and unset measures once with nothing pinned. A width is a point on one axis
rather than a mode, so the single-threaded measurement is the n=1 point of a sweep. Every target
reads the same list.

…pe its columns

Edge and Racer gain a Display impl (arrow()/rf() go), four hand-rolled
edge-from-level correspondences collapse onto Edge::settled_level/from_settled_level,
and the -vector/-ic/-pinlist columns of arcs_tcl.rs, define_cell.rs and liberty.rs
move from hand-joined String lists to typed VectorValue/IcColumn/Symbol columns
rendered through the new Words/Braced wrappers, replacing five separate
join-names-with-spaces implementations with one.
The module holds the vocabulary the Liberate emitters write a command in:
the value of one -vector or -ic column, and the wrappers Tcl's syntax puts
around a list of them. Nothing outside src/emit reaches it, so it is
pub(crate), and src/emit's own documentation names it without linking into
a private item.

The indenting writer arrives with library_liberty, which is what writes
through it.
…regenerate the example decks

The logic layer stops rendering conditions and states: Minterm::condition and logic::product build
a BoolExpr instead of a hand-joined string, and Hazard exposes condition()/path()/pre_state() over
the same values rather than pre-rendered text. Every -when line and the stderr when: field render
that BoolExpr through Display, respelling the condition from A*!B to A & !B.

The diagnostic report gets its own borrowing Display adapters (report::State/Path/Commas), and
hazard_warning writes fields straight into the locked stderr handle instead of building strings.
The # oscillation: Tcl comment and the /* oscillation: */ Liberty comment are deleted along with
the code that built and spliced them; a hazard reaches the user through the stderr report alone.
Deleting the splice merges an annotated block with its otherwise-identical unannotated twin where
one exists, which the masked-arc conflation warning now reports.

README, the hazard-detection doc and the Unreleased changelog entries are updated to match, and
the eight examples/* artifacts are regenerated with the tool.
…ate block with a single Display impl, replacing DefineArc/LeakageBlock/BlockKind/MaskedArc

Every define_arc -type and define_leakage form is now a Block variant carrying exactly its own fields (PinEdge, RacingPins, Column/LevelColumn), deriving PartialEq/Eq/Hash so the value itself is the dedup key (an IndexMap<Block, Vec<Minterm<Symbol>>>) instead of a rendered-text HashMap key. Conflation replaces MaskedArc, naming its subject through the same Block value a Description adapter renders. The parallel per-cell analysis now produces structure (Vec<Block>/Vec<Conflation>) rather than text; a single Display walk writes the deck through the caller's writer at the sink, and banner/write_file take &impl Display instead of &str bodies.
… own cardinality

`Cause::Race` held a `Vec<Racer>` that detection filled with one pin or two, so every use site
re-derived which it had: constraint generation matched the slice to decide whether a separation
could be stated at all, and the stderr report matched it to choose between `toggling A↑` and
`too little separation between A↑ and B↑`. The cause states it now. `Cause::Toggle { pin: Racer }`
is one input toggled alone, `Cause::Race { pins: [Racer; 2] }` a pair probed together, and an empty
or three-pin cause cannot be built.

Confluence files a Toggle from its single-toggle capture and a Race from each pair probe, holding
the pair in the order the probe named the pins; the sorted view a situation key needs stays in
`SituationKind::of`. `remedy` answers `None` for a toggle in an arm of its own, and the report's
`orders` field belongs to the pair, which is where it already went: a lone toggle is only ever filed
as an oscillation, so no occasion of one carried an order. `Hazard::ordinal` ranks a toggle with a
race, both being inputs that fail to converge, so the `(discovered, ordinal)` tie-break picks the
same representatives.

`orders_str` writes the pair's two arrival orders directly, retiring the general permutation helper,
and `trigger_str` reads the cause. The emitted artifacts and the warnings are unchanged.
…torisation on EdgeArcs, and name the bench thread-sweep profiles

The candidate edge arc — the currency the whole classification is decided in — becomes a named
ActiveEdge { clock: Symbol, edge: Edge } in place of the (Symbol, bool) alias that also shadowed
super::arcs::Arc. The direction reaches generates, born and types_edge as an Edge derived once at
the toggle, and a node's per-clock direction list is Vec<Edge>, so a pair whose two halves disagree
about which way the clock moved cannot be built. retain_active_edges compares a state's clock level
against edge.settled_level().

Reordering follows: Edge::Rise sorts before Edge::Fall where false (Fall) sorted before true (Rise),
so every BTreeSet and BTreeMap keyed on the arc iterates the other way within a clock. Nothing reads
that order. The emitted grouping — clocks in cell input-pin order, Rise before Fall inside a clock —
is built explicitly from the input list, and the remaining consumers ask only for membership or
emptiness. retain_active_edges searches its set for something to remove, but its keep-predicate is
monotone in the set (the set appears only under the "is this co-resident toggle itself an active
edge" test, which guards against removal), so the loop computes the greatest fixed point of a
monotone operator over the subsets of the initial set and lands on the same set whichever candidate
it takes first.

EdgeArcs gains factored: the outputs the read-gate factorisation took apart, stated by the pass that
decides it. cell_verilog asks that set whether an output emits a continuous assign instead of a UDP,
rather than rebuilding the reader map to test membership.

regions_from documents why hysteretic comes out true: everything it assembles is a capture or an
off-edge of an edge register, and a node that carries its value across the phase between two active
edges is a state variable.

The benchmark thread sweep was a (parallel, heavy) bool pair with only three meanings; it is now a
Profile enum with a variant per meaning, reached through Profile::parallel/serial and asked for its
points. The HEAVY cell list stays as it is — that one is data.
…te_holding from the cached regions, and type the CLI's spec source and output destination

MachineAnalysis becomes an enum: Derived carries the arcs, hidden arcs,
constraints, hazards, leakage states, edge classification and the
exploration handed on to a second view, and Stopped carries the budget
counter alone. The derivations are fields of Derived, so a stopped pass has
no empty collections to hold. AnalysedCell.unexplored and exposed_view keep
their Option types and their docs now state that each records what its one
write site decided.

AnalysedCell::state_holding reads the cached regions instead of a bool field
set beside them: a region is hysteretic exactly when the signal is a state
variable, so any(hysteretic) is the same verdict the deleted holds_state
computed.

The CLI's positional spec is a SpecSource behind a value parser, so "-" is
the Stdin variant rather than a token compared at each use, and the output
destination resolves once into a Destination matched at the emission site.
… artifact's alphabet through its own Display

cell_liberty hands back the Vec<Group> it builds. library_liberty states the
whole run as a Library value the sink displays: the library group, then every
cell's groups one level in, nested by tcl's Indented, a fmt::Write that puts
the prefix on a line at its first character and passes an empty line through
bare. Indented arrives here with the emitter that writes through it.

The statetable alphabets are Display newtypes local to the artifact whose
syntax they belong to. Liberty writes a cube value as Level (H/L/-), a cube as
Levels, a clock column as Token (R/F/~R/~F/-), a row's next-state actions as
NextPattern (H/L/N/-), an edge row's input field as EdgeInputs and the joint
body as Table. Verilog writes a UDP column as its own Level (1/0/?) and a cube
as Pattern. The two alphabets stay apart: they are different value sets in
different external syntaxes.
…dently

A target's sweep is set by two independent flags: whether the cell is one of
HEAVY, and whether the stage being measured parallelises internally. All four
combinations are configurations a target can be in, a light cell on a serial
stage among them.

The n=1 baseline and the default width are informative for every target, one
being the reference the other is read against. The intermediate widths show
scaling only where the stage parallelises and the cell is wide enough for it:
on a serial stage they re-measure the same single-threaded work, and on a cheap
cell they measure noise. Light targets carry the baseline their documentation
names.
CELLSMITH_BENCH_THREADS names the widths as a comma-separated list, so
CELLSMITH_BENCH_THREADS=1 measures the single-threaded point and 1,2,4,8 sweeps
those four. A width is a point on one axis rather than a mode. Unset is one
measurement with nothing pinned, running on the global pool as the tool does.

The list reaches the benchmarks through the environment because criterion_main!
owns argv and rejects arguments it does not recognise.

Every target is measured over the same list. The cell names that stood for a
harder workload, and the per-target annotation of whether a stage parallelises
internally, decided thread counts from the source and are gone; what a target
runs on is now asked for at the point of running it.
CELLSMITH_BENCH_THREADS=1,2,4,max reaches the top of the host without naming a
number that holds on only one machine. The token resolves as the list is read
and the measurement is reported as the count it resolved to, that being what
ran.

Resolving it can name a width the list already holds, so a width reached twice
is measured once: criterion takes one registration per benchmark id.
@marlls1989 marlls1989 changed the title Hold each classification as a variant and each value as a structure, rendering only at the sink Refactor the emission system's internal structures to make invalid states unrepresentable Aug 18, 2026
@marlls1989 marlls1989 changed the title Refactor the emission system's internal structures to make invalid states unrepresentable Refactor the crate's internal structures to make invalid states unrepresentable Aug 18, 2026
analyse_machine returns Result<Derivations, machine::ExplorationLimit>,
the form Machine::build and Exploration::Reused already use for this
outcome, and Cell::analyse matches its Ok and Err arms into the
AnalysedCell fields.
…hrough Display adapters and buffer it onto stderr

PinEdge lives beside Edge in logic::arcs and carries the derives Racer had, so a detected hazard's racing pin and an emitted block's switching pin are one type. emit::block and emit::arcs_tcl import it from there, keeping the dependency running from emit to logic.

main.rs's header, orders and trigger renderers are borrowing Display adapters writing into the warning's own writer. Trigger's variant is which transition the cause names, and the caller emits the field only where the cause names one.

The hazard report goes out through a BufWriter over the locked stderr handle, flushed before the artifacts are written, so the report's many small writes reach the unbuffered stream in batches.
…gs, and update the hazard docs and comments to the three-cause model

when_str/hidden_when_str/pinlist_str return BoolExpr/Vec<Symbol> options and structures, not strings; renamed to when/hidden_when/pinlist to match their siblings. Fixed the dangling constraint_vector_str reference in model.rs to name constraint_columns, the function that actually reads the prevector's held levels.

docs/hazard-detection.md, and the comments in constraint.rs and arcs_tcl.rs that state the tie-break, now describe three causes (Toggle, Race, Pulse) crossed with two outcomes, with a lone toggle its own cause rather than a degenerate race, and the tie-break's six (cause, outcome) pairs collapsing into four ranks by Hazard::ordinal.
…ne adapter

benches/stages.rs: cell_liberty now renders through the Liberty wrapper,
matching aggregate.rs, so the target measures Liberty rendering again
instead of just group construction.

Words, Commas, Projected, Levels, NextPattern, EdgeInputs and Pattern each
wrote their own "separator between consecutive items" loop. They now share
one adapter, Joined, that carries the separator and a per-item projection;
each keeps its own name and call shape as a thin wrapper over it.
EdgeInputs' per-column choice between a Level and a Token becomes a small
EdgeInputColumn variant instead of writing either inline, so its projection
still returns one Display type.
- Rewrite MUT cell comment in examples/cells.toml to accurately describe that oscillations are detected and reported on stderr, and constraint blocks are generated only when constraint_arcs is explicitly requested
- Record the leakage-block conflation asymmetry in KNOWN-ISSUES.md: leakage blocks conflate when no expose list is present, but combinational and hidden blocks do not, despite facing the same difficulty; the question is whether the constraint-block handling code arms are unreachable in practice
`PinEdge` lives in `logic::arcs` beside the `Edge` it carries, and both the
hazard report and the emitted block name it from there. `emit::block` keeps its
`use` private so that path stays the only one, and both modules say so where a
reader would otherwise have to work out whether a second path was intended.
…run at an over-budget cell

ModelError and ExplorationLimit are written out by hand: per-variant docs, a
Display arm each, an Error impl whose source() chains into the wrapped error,
and From impls for composition and for the io boundary.

ExplorationLimit is a leaf again, carrying only the ceiling it passed, and
ModelError::Exploration adds the cell and the flag that raises it. An
exploration stopped by a budget now fails the analysis at that cell rather
than leaving an unexplored cell for the CLI to scan for, so
AnalysedCell::unexplored and the per-cell scan are gone and run returns
io::Result. thiserror is removed from Cargo.toml/Cargo.lock.
Drop the claim that an over-budget run reports the cell that failed first: the
cells are analysed in parallel and rayon does not say which error a collect
returns, so the README and main's comment now say the run fails at an
over-budget cell without promising which.

analyse_machine's doc no longer describes a view mirroring a ceiling, a path
that went with Exploration::Reused's Result payload, and the KNOWN-ISSUES entry
cites the constraint arms and their covering test where they actually are.
thiserror leaves the README's dependency list, where it outlived its removal
from Cargo.toml.

State's and Path's Display render through Joined, so the adapter's claim to be
the one separator loop holds. ModelError::source() matches every variant
explicitly, so a variant added later has to answer whether it wraps an error
rather than compiling against a wildcard.

The stage benchmarks take a cell that fails to analyse as a broken fixture and
stop: the cells come from the checked-in examples/cells.toml and every one of
them analyses under the default budget, so a silently shorter run would hide
the breakage.
… the sink

Delete render(), the last helper forcing an emitter to return a String and
concatenate a Vec<String>. cell_verilog now returns Vec<Item> (Primitive,
EdgeRegister, Constant, Wrapper) and cell_define_cell returns Vec<DefineCell>;
both render through a borrowing Display at the sink in main.rs, matching the
Tcl deck and Liberty group emitters already converted.
The manifest declares `liberty-parser = "0.3"`, and every module path,
doc comment and README mention spells the crate `liberty_parser` — the
name Cargo derives from the published package.
…'s brace groups through Braced, hold Verilog UDP rows as ordered values compared by row set, and document the fail-fast changelog entry and the ModelError/ExplorationLimit derives
…arcs deck's Deck adapter beside cell_arcs, fold Arc and HiddenArc's pin/edge fields into one PinEdge each, and correct the conflation KNOWN-ISSUES entry
…t pin/edge fields into PinEdge

Closes the last instance of the PR's own principle: a pin together with the
edge it makes is one PinEdge value, not two separately-stored fields.
…ve the pulse-constraint test key a named struct over PinEdge

Closes the split pin/edge sweep for PR #43: the last two stored Symbol+Edge
carriers in the edge-register classifier become one PinEdge each, and the
width.rs test helper's positional (String, Edge, String) key becomes a named
ConstraintKey holding a PinEdge and Vec<Symbol>.
`OutputLevels` carries an output with its levels at both ends of one
toggle, the pair that decides an arc's direction. `Literal` carries a
variable and whether it appears positive, which is what `logic::product`
folds. `ReportField` carries a diagnostic's label and the value written
beside it. The minimiser's duplicate groups and demotions, the seed
ranking's candidate and settlement, the statetable's level signals and
cover passes, and edge.rs's read-gate pass levels and per-clock active
directions each carry their components by name. One shared type states
the region-and-action pairing that the Verilog and statetable emitters
each spelled in their own order.
…them positional

Replaces bare tuples used as test fixtures/keys across the model, logic and emit
layers with named structs (AnalysedPair, PulseKey, ReleaseKey, LabelSite,
OpenPhase, AwkwardVoltage, RenderedStatetable, ConditionedCounts, ContextKey,
PinSignature, ExposureFixture, SignalDef/ParsedSystem, AbValues, TransitionKey),
and shares one analyse_both test helper across the four emit modules that each
carried an identical copy. All changes are confined to #[cfg(test)] scope and
tests/cli.rs; no test expectation changes.
…, and the rendered-row token alphabet to an enum

width.rs: PulseKey.nodes is now Vec<Symbol> (was a comma-joined String), keys() clones the
hazard's group directly, and on_nodes takes the node list as a name slice compared against
Symbol::as_str. fn keys() gets back its own doc comment, transposed onto the PulseKey struct
since 299688d.

statetable.rs: RenderedStatetable's input_names/state_names are Vec<Symbol>. RenderedRow's
three token fields move off Vec<String> onto a new Token enum with a parsing function that
panics on any unrecognised text; row_matches, predict_node and settle_rendered match on Token
variants instead of token strings.
…two test-only pairings

arcs_tcl.rs: fn groups's Columns doc drops the sentence duplicating what the
per-field docs already say, and its masked-toggle test fixture pairs a
conflation report with the pin it names through a MaskedToggle struct instead
of a positional tuple.

liberty.rs: the multi-name test's PinSignature carries an Option<Direction>
over Liberty's closed direction keywords instead of a Debug-rendered,
default-collapsed String, so a pin missing its direction attribute stays
distinct from one that has it.
Step 3: Restore dedup_pass doc above function, trim DuplicateGroup doc to 2 lines, fix SignalDef and system! macro docs
Step 4: Fix Literal doc ordering claim
Step 7: Restore ExposedLevel and TemplateSpec intra-doc links, widen TemplateSpec to pub
Step 9: Revert slice::from_ref assertion
Step 10: Delete swap-test justifications from SubblockField and AbValues docs
…forced

Test-only LabelSite drops its &'a str output field for the Symbol
l.output already holds, dropping the lifetime parameter along with it.

Forcing.forced is deleted: it had three writers and no reader, and the
#[allow(dead_code)] that shielded it goes with it. The two agreement
checks the classifier relies on — pinned_value's shape-match and the
posts.len() == 1 conjunct — are untouched; only the consumption of
each match's now-unused value is dropped.
The fn doc and arcs.rs's ExposedLevel cite were converted; the module
doc's cite was left in bare code font.
Split run() into apply_overrides, artifacts, diagnostics and
emit_stdout/emit_files, each callable and assertable in process, and move
every test tests/cli.rs held into the modules they exercise: ten bin tests
on the split functions' Result and written output, eleven value-level arcs
tests on Block in arcs_tcl, and one edge/level Liberty-form test in
liberty.rs using analyse_both instead of a CLI-flag process run. Delete
tests/cli.rs and its CARGO_BIN_EXE_cellsmith harness; no test spawns the
cellsmith binary or asserts an exit code.
A test states a property and asserts it semantically, against an
expectation derived from the input, and holds for every output the tool
is free to produce. Running the code twice and asserting the two results
agree passes whenever the two paths are self-consistent, including when
both are wrong. Two relations survive: a correspondence the consuming
format imposes, and an equivalence or non-interference claim that is
itself the documented behaviour.
The pin order a run produced is the same order in its -pinlist, its
-vector's characters and its -ic's columns. That claim fixes no order
and needs no second run, so it holds for every output the tool is free
to produce and is asserted like any other property.
A statetable's rows carry their values in the order that table's own
header declares its pin columns — the same correspondence the pinlist,
vector and ic columns hold, and the one a test can assert on any valid
output.
The property a test asserts is one the code or its documentation states.
Where neither states it, the claim belongs there first, otherwise the
test pins something nothing promised. The correspondences named are
examples rather than the list.
A justification for keeping an order names what outside this crate
requires it, as part of a contract with that reader. Justifying an order
by our own use of it answers to nothing; where no external reader
requires it, the order is free.
Liberate reads a block's -pinlist, -vector and -ic as positional columns
of one argument, so those three agreeing on the pin order a run produced
is the contract with that reader.
Seed settling has no reason left to run sequentially once the candidate
ranking is gone, and the key that picks among equally dominant
observations could go — with the sites a removal would have to answer
for.
Each `--when` test now derives its expectation from the analysed cell it
emits — the distinct transition identities its arcs carry and the distinct
hidden events its toggles carry — rather than re-emitting a sibling
analysis and comparing the two outputs. Where a test needs several
selections it assigns `when` on the one analysed cell between emissions,
which `cell_arcs` is deterministic over.

The helpers those comparisons went through (`arc_shape`, `shaped_blocks`,
`sorted_blocks`) and the block multiset counter go with them, and the key
one general block per transition is counted under is hoisted beside
`transition_of` for the two tests that read it. `ConstraintRank`'s doc
states what the tie-break settles without appealing to a fold landing on
one answer.

`leakage_section_follows_hidden_arcs` is dropped: nothing outside the crate
requires `define_leakage` to follow the arc blocks.
`when_output_contains_every_default_arc_block` is dropped for its claim
being stated directly in `bare_when_emits_arc_when_lines` — every
conditioned block's transition, or hidden event, is one the general layer
states too.
The ranking ordered the whole candidate pool and pruned nothing, so the
seed set is unchanged; only the order seeds enter the BFS queue, which
nothing reads. With it go the per-candidate settlement maps, the state
variables' depth relaxation and its tie-break, and the `Candidate` pair
that carried a seed alongside its settlement map — a seed is now a bare
`Minterm<Symbol>`.

The module's budget doc, `Coordinates`, `ExplorationBudget::default` and
`explore` drop the sentences that described the ranking and the order it
fixed.
…tion

Liberty, statetable and Verilog emitters each carried a test that ran
analysis twice (default vs forced no-edge-collapse, or direct vs
CLI-flag opt-out) and asserted the two renderings matched byte-for-byte
or as an equal Debug string. That agreement is self-consistent even
when both runs are wrong, so it is dropped in favour of the per-side
assertions the tests already carry alongside it. verilog.rs's
table_row_set helper, whose only callers were these asserts, goes with
them.

model.rs's determinism test ran the same analysis eight times and
asserted the repeats agreed; it becomes a direct assertion that the
first-declared offending output is the one reported, which is the
property the code actually promises.

main.rs's hazard-landing test compared a race's two alternatives as a
single order-joined string; race alternatives are a set, so the test
now splits on the group/landings boundary and compares the landings as
a HashSet.

hazard.rs and edge.rs comments that justified an internal tie-break
key by appeal to "deterministic"/"byte-identical" report output are
restated as description of what the key does, since nothing outside
the crate reads which of several equally dominant observations is
picked.
…is.rs

The edge-classification invariance test now compares its two runs by
value field-by-field, comparing AnalysedOutput and StateRegions (which
carry no PartialEq) on their identifying fields rather than Debug text,
and canonicalises hazard_shapes' settled set before comparing it instead
of relying on its container's incidental order. The exploration-untouched
test's comment no longer claims an order its assertions never check.
The classify opt-out leaves the annotation at its plain Default, and
RDFF's reset arcs take the same classification SYNCR takes. Neither
claim is about bytes.
…tity/HiddenFiringIdentity, and Projected's named fields
… its cost

An order needs a motivation stated where it is, checkable and checked:
an outside reader of the position, or an algorithm made cheaper than
its unordered form — sorting to dedup is a reason by itself. What fails
is an order bought only for its own stability, and where determinism is
not required the cheaper variant wins.

In tests no economy is a reason, and equality often asserts immutability
rather than a property: checking the output matches a reference does not
establish correctness, the test has to reason about why it is right.
…found

State that a name is a Symbol and a name-to-Boolean mapping is a Minterm as one
rule, under proper use of support types: a variable a Minterm does not define is
by definition a don't-care, so a partial assignment needs no separate type and no
absent-key convention. The Symbol bullet moves out of Language and style into
that section, so the rule is stated once.

Add "Structure is held until the edge" as its own section: a value stays
structured until the sink renders it.

Scope "efficiency and correctness come first" to the rules about how the code is
shaped and tested, leaving British spelling and the Git conventions outside the
trade-off. Require that a reason outranking a rule's letter amend the rule in the
same change. Drop the demand that an order's motivation be checked, keeping
checkable. Restate the test-economy rule as never a reason to assert less. The
collection bullet defers to the order rule instead of restating it narrower.
Deletes assignment(), whose BTreeMap<Symbol, bool> result dropped a
minterm's don't-cares; its callers in the arcs emitter now read the
minterm directly through value_of, and the four -when literal builders
walk vars() against iter(). ConstraintColumns.probed, Hazard.node_levels
(and its node_levels_at/victims plumbing), and forcing_pins' forcing-pin
row (dissolving the single-field Forcing struct) all become
Minterm<Symbol>, keeping the fully-initialised-state panics that read a
missing group node as a defect rather than a silent don't-care.
Add "Use the library's operations, not your own": espresso-logic's operations
come verified, so a hand-rolled equivalent is a second implementation nobody
tests and is a defect whether or not it works. The rule names the step where
this goes wrong — concluding the library lacks an operation after failing to
find one method that does the whole job, when the answer is a composition of
two — and lists the calls this crate has needed, so the loop is not written
again. It also says what is not the defect: projecting a row onto its columns as
Liberty's statetable or the Verilog UDP is written, which those formats demand.

Under proper use of support types, add that a set of tri-state rows over a
shared header is a Cover, since splitting the header off the rows is what forces
every reader downstream to re-pair them by position.
Convert every StateRegions reader (emit.rs, verilog.rs, statetable.rs,
liberty.rs, edge.rs) to read the on/off/hold Espresso Cover directly by
name via Minterm::value_of/project_to_labels, render functions with
Cover::to_expr_by_index instead of hand-joined SOP strings, and fold
sampled points into a BDD with one Cover::from_cubes + build_cover call
instead of a manual OR/AND loop. Delete the now-redundant StateCube type
alias, region_cubes projection and the duplicated on/off/hold vector
fields, leaving each region held once as its cover.
Liberty and Verilog both read `&`, `|` and `!`, and this design uses no buses, so
bitwise and logical negation are the same token. The Liberty and Verilog sinks
therefore write the `BoolExpr` a region lowers to, and the expression layer's own
rendering is the one both formats get.

The tests that named a function by its operator spelling name the same functions
in that rendering. A new test asserts the shape instead of the text: a lowered
region is a sum of products, over two fixtures whose primes respectively cannot
and can share a literal, so factoring is accepted without being required and a
change in how the expression layer renders surfaces here rather than in a deck.

examples/cells.lib and examples/sequentials.lib are regenerated.
…e state comparisons with espresso's Minterm API

The arc/rest level families (HeldLevel, ExposedLevel, ArcLevels, RestLevels) and the
constraint victim family (VictimNode) each held a mapping from a node name to a
Boolean as a struct or a Vec of them, with adapters restoring by-name lookup for
consumers. These become Minterm<Symbol> rows, read back with value_of, built once
from accumulated (Symbol, Option<bool>) pairs. merged_victims's union goes through
Minterm::or, its levels through project_to_labels. The per-variable divergence
closures in confluence::detect and width::detect are replaced by the Kleene XOR/OR
operators. A stray doc comment claiming byte-compatible emitted cubes is restated as
a claim about shared computation, not output bytes.
A constraint block's victim columns are read off the emitter's own column list
by name, so no minterm's stored header order reaches the output and a node the
row does not define renders as the don't-care it is. A new test states the
correspondence the block must keep between its parts, without fixing which order
a run produces.

The exploration's candidate pool is a HashSet: its order was never read, and the
pool reaches four million entries on the candidate path. Which observation
supplies a general block is now schedule-dependent, recorded in KNOWN-ISSUES.md
beside the free choice it belongs to.

A state-table column is held once under both its names, so the table node and the
signal it came from cannot desynchronise. A hazard derives its victim levels from
the state it probed rather than storing them again, and the one-probed-state
invariant is stated where the remaining views are declared and asserted where
they are copied.

The CLI names the path in its io errors and renames its artifacts into place
together, so a failed run leaves no half-written output directory. A warning's
outcomes are filed in a HashMap, since nothing reads their order.

Test and documentation corrections the review found: each minimisation fixture is
pinned to the answer it forces and builder-independence is documented as the
property being checked; the two-latch opt-out test asserts the model each switch
states; the -ic gate test compares decks as multisets of blocks; the block key's
converse direction is argued rather than asserted; the engine document drops the
candidate ranking that no longer exists; comments name items instead of line
numbers; sorts that cannot observe stability use sort_unstable; two type names
this branch made ambiguous are TransitionEvent and SeparationRole.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant