Skip to content

Rework the FSM solver, scope the preprocessing functions, and clean up the examples - #649

Merged
julianspeith merged 31 commits into
masterfrom
feature/examples_cleanup
Sep 10, 2026
Merged

julianspeith merged 31 commits into
masterfrom
feature/examples_cleanup

Conversation

@julianspeith

@julianspeith julianspeith commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The branch started as a cleanup of the example projects and grew into the fixes the examples needed to run. Twenty-eight commits on top of master; the Boolean function work that was merged separately in #636 is already in master and does not reappear here.

FSM solver (solve_fsm)

  • Reworked the API into a single function set up through a Configuration object, which also selects between the SMT and the brute-force approach.
  • Reports the value of each configured FSM output per state and annotates the DOT graph with it.
  • No longer writes a file or opens the DOT viewer on its own; the state transition graph gained generate_dot_graph, to_string, and write_txt (with a legend mapping state bits, outputs, and net variables back to the netlist).
  • Fixed a user-provided initial state being interpreted with the wrong bit order.
  • One header per struct, mirroring the dataflow plugin; removed the stdout debug output; added tests, of which the plugin had none.

Preprocessing (netlist_preprocessing, xilinx_toolbox)

  • Optional gate scope on the preprocessing functions, restricting which gates may be modified or deleted; defaults to the whole netlist.
  • split_shift_registers and unify_ff_outputs assign new gates to the module of the gate they replace instead of the top module.
  • Fixed simplify_lut_inits crashing on a LUT with an unconnected output, split_luts crashing on a LUT6_2 using only one output, and remove_unconnected_gates looping forever when a gate cannot be deleted.
  • Context menu entries for remove_buffers, unify_ff_outputs, split_luts, and split_shift_registers, on the selection or the whole netlist, with GUI layout updates held back while they run.
  • Tests for xilinx_toolbox, which had none.

GUI and DOT viewer

  • Deleting a selected net removes the net itself from the selection, not an unrelated gate.
  • Module model no longer emits row signals while being reset; uniform row heights in the large tree views; module elements tree no longer rebuilt twice per selection change.
  • DOT viewer renders multi-line node labels instead of showing the escapes, and no longer draws a red debug rectangle around labels that do not fit.

Core and bindings

  • Fixed a nullptr crash and the pin lookup semantics in Net and Gate.
  • Added the missing Python bindings of the plugin manager.

Examples

  • Reworked the FSM example project, fixed the examples ignore list, corrected the SMT result comments in the Simple ALU script, shipped the HAWKEYE S-box database and fixed the crypto_trojan example, removed an orphaned screenshot.

Merge state

Master is merged in (merge commit resolves CHANGELOG.md, the preprocessing Python bindings, and the preprocessing tests). The changelog entries are folded into master's grouped layout, with new sub-headings for the FSM solver and the Xilinx toolbox. The merge also brought master's binding lifetime check, which flagged the FSM solver bindings; those now carry hal::borrowed().

Test plan

  • Merge master into the branch and resolve the three conflicts
  • CI green on Ubuntu 22.04/24.04/26.04 and macOS
  • Open the reworked FSM and Simple ALU example projects in the GUI

🤖 Generated with Claude Code

julianspeith and others added 30 commits August 11, 2026 19:34
Net::remove_source(Gate*, const GatePin*) and its destination counterpart
dereferenced the pin without checking it, so passing a nullptr crashed. The
string overloads never reach that state because they reject unknown pin names
first, but the pin overloads are called directly by netlist_preprocessing and
netlist_utils, and they are exposed to Python, where net.remove_source(g, None)
terminated the interpreter. Their is_a_source and is_a_destination counterparts
already guarded both arguments, so add the same guards for consistency.

While looking up an endpoint, Net and Gate compared the pin by value rather than
by pointer identity, so a distinct but equal GatePin of another gate type would
match. All pins of a gate come from its gate type, so identity is what these
lookups mean. Net::operator== keeps comparing by value, since it compares
endpoints of two different nets that do not share pin objects.

Add direct tests for the pin overloads of remove_source and remove_destination
covering removal, the wrong pin, a nullptr pin, a nullptr gate, a gate that is
not connected, and removing twice. They previously had no direct coverage at
all, which the removed TODOs asked for. The remaining six TODOs were stale: the
string overloads, the endpoint overloads, and both pin overloads of is_a_source
and is_a_destination are already covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five of the seven Python binding TODOs in plugin_manager.h are addressed:

* get_cli_plugin_flags() and get_ui_plugin_flags() are bound and return a dict.
* add_model_changed_callback() and remove_model_changed_callback() are bound, so
  a Python callable can be notified about loaded and unloaded plugins.
* get_plugin_instance() gained the initialize and silent parameters. The other
  half of that TODO, bindings for different plugin types, needs no work:
  pybind11 already returns the plugin's own type once the Python module of that
  plugin has been imported, and the base interface otherwise. The docstring now
  says so.

The remaining two TODOs, add_existing_options_description() and
get_cli_plugin_options(), both take or return a ProgramOptions, which has no
Python bindings at all. They keep a TODO that names that prerequisite instead of
just saying "TODO Python binding".

Verified from Python: the callback fires once per plugin on load and stops after
removal, both flag getters return a dict, get_plugin_instance(name, False) skips
initialization, and get_plugin_instance(name, True, True) returns None for an
unknown plugin without logging an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the Python bindings of plugin_manager by binding the types it depends
on:

* ProgramOptions and ProgramArguments, needed by add_existing_options_description()
  and get_cli_plugin_options().
* FacExtensionInterface including its Feature enum, and plugin_manager::PluginFeature,
  needed by get_plugin_features().

With those in place, add_existing_options_description(), get_cli_plugin_options(),
get_plugin_path(), has_valid_file_extension(), and get_plugin_features() are bound,
and plugin_manager.h no longer carries any TODO.

Two points worth noting:

ProgramOptions::add() takes its flags as a std::initializer_list, which cannot be
built from a container at runtime and is therefore not bindable. Changing the
parameter to a std::vector is not an option either, because a call such as
add({"-h", "--help"}, "...") then becomes ambiguous: std::string has an
iterator-pair constructor, so the braced list is a viable argument for the
single-flag overload as well. The vector-based implementation is therefore
exposed under the new name add_flags(), to which both existing overloads
delegate, so that all 87 call sites keep working unchanged. Python binds
add_flags() as add().

ProgramArguments::get_original_arguments() is deliberately not bound.
ProgramArguments stores the argv pointer without owning the strings, which no
longer exist once parse() has returned to Python, so exposing it would hand out
a dangling pointer.

Verified from Python: options can be added with one or several flags, parsed
from an argument list, queried, removed, and merged into another set; plugin
features report the parser of the Verilog plugin with its .v extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selecting a module made the GUI hang for minutes on large designs. Selecting
the top module of the OpenTitan benchmark, which holds all of its 424866 gates
directly, froze HAL indefinitely.

ModuleModel::populateTree() correctly brackets the population with
beginResetModel() and endResetModel(), but createChildItem() emitted
beginInsertRows()/endInsertRows() for every single item in between. Besides
being undefined per the QAbstractItemModel contract, it made the attached
SelectionTreeProxyModel re-map its source rows once per inserted row, which is
quadratic in the number of items.

Measured with a QSortFilterProxyModel over a plain list model, populating with
a row signal per item takes 0.84 s for 50000 items, 3.29 s for 100000 and
14.71 s for 200000, while populating within a plain model reset is too fast to
measure. Extrapolated to 424866 items that is more than a minute of pure proxy
bookkeeping for a bare model, and considerably more for real tree items.

createChildItem() and removeChildItem() now skip their row signals while a
reset is in progress, since the reset already tells the views to re-read
everything.

Both functions also reset mIsModifying to false unconditionally, clobbering the
true that populateTree() had set, so the guard in ModuleWidget was only in
effect for the very first item. They now restore the previous value.
mIsModifying was never initialized in the constructor either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unfolding the top module of the OpenTitan benchmark, which holds all of its
424866 gates directly, kept the GUI busy for seconds.

A sample of the GUI taken while unfolding shows 5094 of 8032 main thread
samples below QTreeViewPrivate::itemHeight, reached from updateScrollBars()
when the expand is applied. Without uniform row heights that function measures
rows individually through QTreeView::indexRowSizeHint(), which asks the item
delegate for a size hint, which lays out and shapes the item text. 2345 samples
end up in QTextEngine::shapeText() and 821 in CTFontGetGlyphsForCharacters(),
so the view spends most of the unfold shaping gate names just to learn how tall
their rows are.

All rows of these views are plain single line text in one font, so their height
is constant and QTreeViewPrivate::itemHeight() can return it directly.

Applies to the four views that can hold one item per gate or net of a netlist:
the selection details tree, the module elements tree, the module widget tree
and the module pins tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the S-box database from the HAWKEYE artifacts to the plugin and copy
it into the build directory at build time, analogous to how the gate
library definitions are handled. The plugin's .gitignore is an allowlist,
so the database needs an explicit exception to be tracked.

Turn the placeholder py/hawkeye.py of the crypto_trojan example into a
working script that detects state register candidates, isolates the round
function, and identifies its S-box. On this netlist it reports AES.

Both crypto_trojan and toy_cipher shipped outdated copies of their gate
library that predate the c_* gate type properties: 101 of 163 cells in
lsi_10k and 44 of 273 in XILINX_UNISIM were missing them. As a result,
any analysis selecting gates by property found nothing in those projects.
In crypto_trojan this made netlist_preprocessing::unify_ff_outputs fail
with "gate library does not contain an inverter gate", leaving the FD1
Q/QN dual outputs in place and causing errors during S-box identification.
Both libraries are refreshed from the shipped definitions.

Also correct the documentation of identify_sbox: it returns an empty
string when no S-box of the database matches, and only reports an error
if the candidate could not be analyzed. Both the C++ and the Python
documentation previously described a no-match as an error.
Selecting the top module of the OpenTitan benchmark took over a second.

A sample of the GUI shows ModuleModel::populateTree() running three times for a
single selection, 321, 313 and 307 samples of 1358 in that branch. Once for the
selection details tree and twice for the module elements tree, because
SelectionDetailsWidget::handleSelectionUpdate() drives the details widget
directly and also populates the selection tree, whose current index change
drives the very same details widget through triggerSelection().

Each of those runs builds one tree item per gate and per net of the module,
about 825000 items for the OpenTitan top module. Two thirds of the time goes
into the net items in moduleAssignNets(), the rest into the gate items in
addRecursively() and into destroying the previous tree.

ModuleElementsTree already tracks the module it displays, so it can skip the
rebuild if it is asked for the same one again. The model keeps itself up to
date through the netlist relay, so the rebuild is not what refreshes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sample of a HAWKEYE S-box identification run spent 96% of the main thread in
BooleanFunction::compute_truth_table(), which evaluates the function once per
truth table row.

Three fixes, measured on a 50 node function over 8 variables, 256 rows per
truth table, 5.17 ms per table before:

BooleanFunction::operator< compared two functions of equal node count by
building their reverse polish notation strings and comparing those. The
symbolic state keeps its variables in a std::map<BooleanFunction,
BooleanFunction>, so every variable lookup during evaluation formatted several
strings, and since all keys are single variable nodes the node count never
discriminated. Comparing the nodes directly is equivalent, Node::operator< is
already a field wise comparison consistent with Node::operator==. Neither of
the two maps keyed by BooleanFunction is ever iterated, so the changed order is
not observable. 5.17 ms -> 4.56 ms.

SymbolicExecution::constant_propagation() copied the value vector of every
operand twice, once into a local and once into the vector of values. Some of
the cases modify the values in place so one copy is still needed. 4.56 ms ->
3.94 ms.

BooleanFunction::evaluate() validated the input sizes by comparing every input
name against every node, which is a string comparison per pair on every call.
Walking the nodes once and looking up each variable is equivalent. 3.94 ms ->
3.81 ms.

That is 26% overall. The remaining cost is dominated by allocation, every node
evaluation builds a std::vector<BooleanFunction> of operands and a
BooleanFunction result, each owning a vector of nodes that own a string and a
vector of values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three corrections to constant folding of Boolean functions, all found while
adding evaluation tests for the operators that had none.

Addition and subtraction masked their result to 32 bit before truncating it to
the width of the operands, so every addition or subtraction of 33 to 64 bit
operands silently lost its upper bits. A 33 bit 2^31 + 2^31 returned 0 instead
of 2^32, and a 33 bit 0 - 1 returned 0xffffffff instead of 0x1ffffffff. The
mask is not only too narrow but redundant, since Const() already takes exactly
as many bits as the operands are wide.

Eq returned a definite "not equal" when an undefined bit was involved, even
though the two values would have been equal had that bit turned out the other
way. It now returns X in that case, unless another bit already tells the values
apart, which is how the other comparisons already behave.

Sdiv, Udiv, Srem and Urem were not implemented and made evaluation of any
function containing them fail. They are translated to bvsdiv, bvudiv, bvsrem
and bvurem when handed to an SMT solver, so the folding follows those
definitions, including a division by zero yielding all ones as the quotient and
the dividend as the remainder. Restoring long division is used rather than 64
bit integer arithmetic, so that the operations work at any width like Mul does.

The tests evaluate the four new operations against reference implementations of
the SMT-LIB definitions for every pair of 4 bit operands, and cover the
operators that had no evaluation coverage at all before: shifts, rotates,
slice, concat, zero and sign extension, the comparisons, Ite, and the
propagation of undefined values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the four factory functions themselves rather than their evaluation: that
they accept operands and a result of equal size, produce a node of the right
type and size over the right variables, and reject operands that differ in size
from each other or from the result.

The Python bindings for Sdiv, Udiv, Srem and Urem, including the node type
constants, already exist and work, verified by hand against the new constant
folding. They are not covered by tests/python_binding/test_boolean_function.py
since that file is not referenced by the build or by any workflow and still
calls get_variables(), get_truth_table(), is_constant_zero(), to_dnf() and
optimize(), none of which are bound any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests/python_binding/ is not referenced by the build or by any workflow, so
nothing has run these files in a long time. They would not run either: they
still call boolean_function(), value.ONE, get_variables(), get_truth_table(),
is_constant_zero(), to_dnf() and optimize() on the netlist and Boolean function
classes, none of which are bound any more.

Rather than leave tests that cannot pass and are not executed, drop them. The
C++ suite under tests/netlist covers the same ground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Evaluating a Boolean function walks its nodes and hands each one to
SymbolicExecution::simplify(), which builds a std::vector<BooleanFunction> of
operands and returns a BooleanFunction result. Each of those owns a vector of
nodes whose nodes own a string and a vector of values, so computing a single
AND of two bits costs several heap allocations. A sample of a HAWKEYE S-box
identification run spent 63% of the evaluation in malloc and free.

Whenever every variable of a function is bound to a constant, which is the case
for anything computing a truth table, no sub-expression can survive and the
whole detour through Boolean functions is unnecessary. SymbolicExecution
therefore folds such a function on a stack of plain values and only falls back
to the general path when a variable turns out not to be constant.

To avoid a second implementation of the operator semantics, the switch that
applies a node to constant operands moved into ConstantPropagation::fold(),
shared by the new path and by constant_propagation(), which wraps its result
back into a Boolean function as before. The 18 helpers of that namespace now
return the value vector they were computing all along instead of wrapping it.
They are local to the translation unit, no public signature changes.

Resolving the variables is now done through SymbolicState::get_bindings() as
well. Going through get() per variable built a Boolean function to use as the
key and looked it up in a std::map keyed by whole Boolean functions, comparing
them node by node and therefore string by string.

Measured on a 50 node function over 8 variables, 5.17 ms per truth table before
and 1.94 ms after, 2.7x. Together with the changes already on the misc branch
it comes to 1.64 ms, 3.15x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the changes of this branch that had no entry yet: the two GUI hangs on
large designs, the duplicate rebuild of the module elements tree, the faster
Boolean function comparison, the shipped HAWKEYE S-box database, and the
removal of the stale Python binding tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference implementations of the SMT-LIB division semantics let the
compiler deduce their return type. On macOS that works out, u64 and the
unsigned long long of the ~0ull and 1ull literals are the same type there. On
Linux u64 is unsigned long, so the branches of the signed variants deduced
unsigned long from one helper and unsigned long long from another, which GCC
rejects:

  error: inconsistent types 'long unsigned int' and 'long long unsigned int'
  deduced for lambda return type

Spelling out u64 as the return type fixes it. The loop over the bit widths of
the wide arithmetic test no longer relies on a narrowing conversion either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BooleanFunction::simplify() runs the rule set of SymbolicExecution::simplify()
and then hands the result to ABC. ABC works on single bits, so it collapses
things like A ^ (A ^ B) even though the rule set has no rule for it, but it
cannot reach the word level operations. Those are exactly the ones the rule set
covers least: Zext and Sext had no rules at all, Slice had two and each
comparison had one.

None of the following was simplified before:

  ZEXT(A, |A|)                 =>  A
  SEXT(A, |A|)                 =>  A
  ZEXT(ZEXT(A, n), m)          =>  ZEXT(A, m)
  SEXT(SEXT(A, n), m)          =>  SEXT(A, m)
  SLICE(SLICE(A, i, j), k, l)  =>  SLICE(A, i+k, i+l)
  SLICE(CONCAT(A, B), i, j)    =>  SLICE(B, i, j) or SLICE(A, i-|B|, j-|B|)
  0 <=u A                      =>  1
  A <=u 111...1                =>  1
  111...1 <u A                 =>  0
  A == ~A                      =>  0
  A == 1, A == 0               =>  A, ~A     for a single bit
  ITE(A, 1, 0), ITE(A, 0, 1)   =>  A, ~A     for a single bit

Only extensions of the same kind collapse, ZEXT(SEXT(A, n), m) is not
ZEXT(A, m). A slice of a concatenation is only reduced when it falls entirely
into one of the two halves.

The tests check that simplification does not change the value of a function for
any assignment of its variables, rather than that it produces one particular
shape, and that something was simplified at all.

Also fixes the rule comments of Urem, which were copied from Srem and claimed to
describe the signed operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An extension leaves the bits of the original value untouched and pads above
them, so a slice that does not cross the boundary only depends on one of the
two parts:

  SLICE(ZEXT(X, n), i, j)  =>  0             for i >= |X|, the range is padding
  SLICE(ZEXT(X, n), i, j)  =>  SLICE(X, i, j) for j < |X|
  SLICE(SEXT(X, n), i, j)  =>  SLICE(X, i, j) for j < |X|

A slice that crosses the boundary is left alone. For a zero extension it would
become a zero extension of a slice, and for a sign extension the padding is the
replicated sign bit rather than a constant, so neither case gets shorter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preprocessing functions of netlist_preprocessing and xilinx_toolbox
always worked on the entire netlist, so there was no way to clean up a
single module or a hand-picked set of gates.

They now take an optional vector of gates that restricts which gates may
be modified or deleted. An empty vector keeps the previous behaviour and
considers the whole netlist, and the function's own type filter is still
applied on top, so passing every gate of a module is always valid.

Where a rewrite involves more than one gate, the scope means:

  - remove_consecutive_inverters requires both inverters of a pair to be
    in scope, even if only the second one ends up being deleted
  - remove_redundant_gates only deletes gates that are in scope, the
    equivalent gate kept in their stead may lie outside of it
  - remove_unconnected_gates and propagate_constants keep iterating to a
    fixpoint, but never cascade into gates outside of the scope

Left out for now are the two redundancy passes, whose gate set doubles
as the boundary for building Boolean functions, manual_mux_optimizations,
which replaces subgraphs through the resynthesis plugin, and the parsers
and reconstruction passes, which have no gates to scope by.

Scoping also exposed that newly created gates always ended up in the top
module, which would silently move logic out of the module being worked
on. split_shift_registers and unify_ff_outputs now assign their
replacements to the module of the gate they replace, as split_luts
already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Running a preprocessing function required writing a script, even for the
routine cases where the user has just selected the modules or gates they
want cleaned up.

Both plugins now contribute a GUI extension, so remove_buffers,
unify_ff_outputs, split_luts, and split_shift_registers show up in the
context menus of the graph view, the module tree, and the selection
details. Which variant is offered depends on the selection: while
modules or gates are selected the entries operate on that selection,
otherwise they operate on the entire netlist. Only one of the two is
ever shown, so the menu always reflects what a click would act on.

A selected module contributes all of its gates including those of its
submodules, and a gate selected both directly and through a parent
module is only passed once. The gate list is handed to the preprocessing
function as its scope, so the function's own type filter still decides
what is actually touched and passing a whole module is always valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
solve_fsm had grown two entry points and a widening parameter list, wrote a
DOT file as a side effect of solving, and pushed the resulting graph into the
dot viewer without being asked. It also printed every transition to stdout on
every run.

The plugin now exposes a single solve_fsm(Configuration) that returns a
StateTransitionGraph and touches nothing else. Rendering is a separate step on
the result: generate_dot_graph for the picture, to_string for the full text,
write_txt for that text in a file. Displaying the graph is left to the caller,
which is what dot_viewer::load_dot_file is for.

The configuration also carries a brute_force flag that replaces the separate
solve_fsm_brute_force function. Brute forcing enumerates every state, so it
now discards the states that are unreachable from the initial state and both
approaches return the same graph for the same configuration.

Beyond the restructuring, the solver can report what the FSM outputs. Given a
list of named outputs, each a vector of nets with the first providing the least
significant bit, it evaluates every output in every state. An output that only
depends on the state (Moore) reduces to a constant, one that also depends on
the inputs (Mealy) keeps the input variables in its function, so both kinds are
described without having to split transitions. Outputs are taken as nets rather
than gates because an output does not need a gate of its own, as in the FSM
example project where a port is driven straight off a flip-flop.

The text representation prints the full condition of every transition without
truncating anything, preceded by a legend that maps each state bit to its
flip-flop, each output to its nets, and each net variable of a Boolean function
to the net it stands for. The last one matters because those variables are
built from net IDs and match no net name.

Also fixes the initial state being assembled with the opposite bit order to the
state vector handed to the solver, which made a non-zero initial state start
the exploration from a different state than the one requested. The first
flip-flop of the state register now provides the least significant bit
everywhere.

The plugin had no tests. Eight are added, covering Moore and Mealy outputs,
multi-bit outputs and their bit order, the initial state, the equivalence of
the two approaches, invalid configurations, and both renderings. They skip
rather than fail when no local SMT solver is available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SelectionRelay::handleNetRemoved took the removed net out of the gate set of
the pending selection action instead of the net set. Deleting a selected net
therefore left it selected, and additionally dropped whichever gate happened
to carry the same numeric ID from the selection.

handleModuleRemoved and handleGateRemoved were already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems in QGVNode::paint, both visible as soon as a node label is longer
than the node is wide.

The DOT format encodes a line break within a label as the escape sequence \n,
which is kept verbatim in the attribute. Qt only breaks on an actual newline
character, and the overflow branch additionally passed Qt::TextSingleLine, so
the escape was drawn as two literal characters and the whole label ended up on
one line. displayLabel() now resolves \n, \l and \r before drawing, the width
check measures the widest line rather than the whole string, and the single
line flag is gone.

The overflow branch also stroked a red rectangle around the label. That is
leftover debug drawing with no way to switch it off, so it is removed.

The two interact: graphviz sizes a node for the label with its line breaks
applied, Qt then drew it as one long line, which overflowed and triggered the
red rectangle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A preprocessing function invoked from a context menu deletes or replaces one
gate at a time, and the GUI re-laid out its graph views after every one of
them, which dominated the runtime of the operation itself.

Both plugins now hold a GuiLayoutLocker for the duration of the call, which
defers the affected views and updates each of them once at the end. The class
mirrors the one the dataflow analysis and module identification plugins
already use: it asks the plugin manager for the UI plugin and does nothing if
none is running, so it is safe in headless runs as well.

The lock is taken explicitly by the plugin rather than wrapped around every
context menu action by the GUI, because not every action needs it and holding
one has a cost of its own.

Note this only covers the graph view. Widgets such as the selection details
react to the individual netlist events and still update once per gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The FSM project is the entry point of the example projects, so it is now flat
and anonymous like a netlist recovered from real hardware: one module holding
all 21 gates, named U1 to U21, with no hierarchy to give the answer away. The
structure is what the guide asks the reader to recover by hand.

Its script follows from that. gate_info.py is gone, it printed the Boolean
function of every LUT and taught nothing that the selection details do not
show. fsm.py now identifies the modules the reader built by ID rather than by
name, takes the nets carrying the outputs of the machine, and hands all of it
to the reworked solve_fsm API. It prints the solver's own text representation
instead of formatting the transitions itself, and renders the DOT graph as a
separate step.

The outputs are given as nets rather than being read off the output logic
module, because OUTPUT_0 leaves its flip-flop and goes straight to the port
without passing through any gate, so that module cannot describe it.

The ignore list kept a README.md that does not exist and did not list
crypto_trojan.zip, which is tracked. Both corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The FSM wiki page no longer embeds it, and nothing else in the wiki or the
repository references it. It also showed the old graph, whose states carried
no output annotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The script closed with "if output is SAT, then verified adder for given
opcode", which states the opposite of what it checks. The constraint it builds
asserts that the ALU and the adder differ, so UnSat is the result that proves
them equal and Sat is the counterexample.

Both comments now say what the constraint means and what each result implies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eanup

# Conflicts:
#	CHANGELOG.md
#	plugins/netlist_preprocessing/python/python_bindings.cpp
#	plugins/netlist_preprocessing/test/netlist_preprocessing.cpp
The merge from master brought the binding lifetime check, and it flags
the four FSM solver bindings that hand out gates without keeping their
netlist alive: the state register, the transition logic, the initial
state, and the state register of the state transition graph. The outputs
of the configuration and the output nets of the graph hand out nets the
same way behind a tuple, which the check does not see through, so they
get the policy as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@julianspeith
julianspeith merged commit c8ba6b1 into master Sep 10, 2026
4 of 5 checks passed
@julianspeith
julianspeith deleted the feature/examples_cleanup branch September 10, 2026 13:03
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